a transcript with nothing in it can say nothing

"Send a message to start" is right on the chat screen and wrong under a setup
card that already says what to do — it contradicts the Start button above it and
takes 200px of height doing so, which is what squeezed the new-commander pane
into a scroller a third of the panel tall.

`emptyState` is optional and three-valued on purpose: undefined keeps the
default, a node replaces it, null shows nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 00:16:28 +00:00
co-authored by Claude Opus 5
parent a2ddf258b6
commit 1155449e4b
4 changed files with 24 additions and 5 deletions
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import type { UseChatType } from '../../../hooks/useChat';
import { useEmbeddableChat } from './useEmbeddableChat';
import { MessageList } from '../components/MessageList';
@@ -5,6 +6,14 @@ import { InputArea } from '../components/InputArea';
type EmbeddableChatProps = {
sessionId?: string;
/**
* What the transcript shows before there is anything in it. `null` shows nothing.
*
* The default, "Send a message to start", is true on the chat screen and wrong wherever something else
* already explains what to do — a new commander has a setup card and a Start button above it, so the
* message contradicts them and takes 200px of height while doing it.
*/
emptyState?: ReactNode;
/** Which panel this is — see `queue-store`. Absent on the screen, where there is only one chat. */
panelId?: string;
initialModel?: string | null;
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import type { KeyboardEvent } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useUserVisibleModels } from 'state/useModels';
@@ -51,6 +52,7 @@ type UseEmbeddableChatParams = {
contextId?: string;
/** This session is a commander, per `GET /chat/sessions/:id`. See the note where it is surfaced. */
isCommander?: boolean;
emptyState?: ReactNode;
};
/**
@@ -622,6 +624,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
would lose the brand-new composer, which has no session yet.
*/
isCommander: params.context === 'command-hq' || params.isCommander === true,
emptyState: params.emptyState,
/*
Claude's transcript id, which is what a commander is keyed on everywhere — the Command HQ row, the
record of what it spawned, the delete cascade. `sessionId` above is OFFICER's handle for the same
@@ -107,11 +107,17 @@ export const MessageList = ({ manager }: MessageListProps) => {
</div>
)}
<div ref={scrollViewportRef} className="h-full overflow-y-auto">
{rows.length === 0 && !isGenerating && (
<div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground text-sm">
Send a message to start
</div>
)}
{rows.length === 0 &&
!isGenerating &&
// `undefined` means nobody said, so keep the default. `null` means "show nothing", which is what
// a screen that already explains itself above the transcript passes.
(manager.emptyState !== undefined ? (
manager.emptyState
) : (
<div className="flex items-center justify-center h-full min-h-[200px] text-muted-foreground text-sm">
Send a message to start
</div>
))}
{rows.length > 0 && (
<ToolOpenProvider>
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
@@ -365,6 +365,7 @@ function NewCommander({
!started && !ready ? { reason: !name.trim() ? 'Name it first…' : 'Choose a working directory first…' } : false
}
className={!started ? 'shrink-0' : 'min-h-0 flex-1'}
{...(!started ? { emptyState: null } : {})}
/>
<DirPickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelect={setCwd} />
</div>