a commander is named and given a directory before it can be spoken to
The third of the three, and the one the owner will actually see. The new-commander card now collects both
decisions above the guide, and the composer refuses input until they are made — with the reason in the
placeholder rather than a validation message after the fact.
Both are chosen first because neither is cheap afterwards. The directory decides where every relative path in
every turn resolves and which project group holds the transcripts, so changing it later strands a
conversation away from its own history. The name has to reach the transcript the moment it exists, because
Claude Code names a new session from its first prompt and the platform's rename only wins by getting there
first.
The directory is a BUTTON, not a text field: it is an absolute server path, and typing one is how a commander
ends up running somewhere that does not exist. `DirPickerModal` already existed for /chat and browses the
account's own tree.
CAUGHT BEFORE SHIPPING, and only by checking: I first passed `cwd` and `commanderName` as `useChat` options
and `disabled` as an EmbeddableChat prop. None of the three existed. It typechecked anyway, because object
SPREADS bypass excess-property checking — `...(cwd ? { cwd } : {})` is invisible to it. That is the fourth
time today a silently-ignored prop has cost something, so the props are real now:
- `commanderName` is a `useChat` option on the send payload, and rides EVERY turn rather than only the first.
The server applies it once; sending it again costs bytes. Sending it once would lose it if that turn failed
and was retried — and a commander named after its second prompt is exactly what this prevents.
- `disabled` is a real EmbeddableChat prop carrying a reason, threaded to the textarea's NATIVE disabled —
which also removes it from the tab order and blocks a paste, neither of which a guard on send would do.
- the directory goes to EmbeddableChat's existing `cwd` prop, because that is what feeds `sendPrompt`'s
`cwdParam`. Two values chosen in one place travelling by two paths, noted where somebody would otherwise
have to discover it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,14 @@ type EmbeddableChatProps = {
|
||||
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
/**
|
||||
* Refuse input entirely, with a reason the composer shows.
|
||||
*
|
||||
* For a surface where something must be chosen before a conversation can start — a new commander needs a
|
||||
* name and a working directory, both unchangeable afterwards. Disabling the composer is the only honest
|
||||
* place to enforce that: anywhere later and the person has already written what they meant to say.
|
||||
*/
|
||||
disabled?: false | { reason: string };
|
||||
onMessageComplete?: () => void;
|
||||
};
|
||||
|
||||
@@ -43,7 +51,7 @@ export const EmbeddableChat = ({ className, onMessageComplete, ...params }: Embe
|
||||
return (
|
||||
<div className={`flex flex-col ${className ?? ''}`} onKeyDown={manager.handleEscape}>
|
||||
<MessageList manager={manager} />
|
||||
<InputArea manager={manager} />
|
||||
<InputArea manager={manager} disabled={params.disabled} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,9 +16,11 @@ import { BackgroundTaskTray } from './BackgroundTaskTray';
|
||||
|
||||
type InputAreaProps = {
|
||||
manager: UseEmbeddableChatType;
|
||||
/** When set, input is refused and the reason is shown in place of the composer's own hints. */
|
||||
disabled?: false | { reason: string };
|
||||
};
|
||||
|
||||
export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
export const InputArea = ({ manager, disabled }: InputAreaProps) => {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
// Only for a commander, and only once it has an id. Everything a commander's loop needs in order to be
|
||||
// stoppable and deletable hangs off these two paths — see api/command-hq/spawned.ts.
|
||||
@@ -110,6 +112,10 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
// Native `disabled`, not a click guard: it also takes the field out of the tab order and stops a
|
||||
// paste, which a guard on send would not.
|
||||
disabled={!!disabled}
|
||||
placeholder={disabled ? disabled.reason : undefined}
|
||||
onChange={(ev) => setInput(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
// Enter is how anybody actually sends, so the interception has to be here as well as on the
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Check, Radar, Trash2, Unplug, X } from 'lucide-react';
|
||||
import { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { EditableTitle } from '@/components/EditableTitle';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
||||
import { useChat, EmbeddableChat } from '../Chat';
|
||||
import { DirPickerModal } from '../ChatHistory/DirPickerModal';
|
||||
import type { ChatMessage } from '../Chat/types';
|
||||
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
|
||||
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
|
||||
@@ -64,6 +65,69 @@ const NewCommanderGuide = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* The two decisions a commander cannot be started without.
|
||||
*
|
||||
* Rendered above the guide rather than in a dialog: a first-time reader learns what a commander is and makes
|
||||
* these choices without leaving the page, and somebody who already knows can fill both in without opening
|
||||
* anything.
|
||||
*
|
||||
* The directory is a button rather than a text field. It is an absolute path on the server and typing one is
|
||||
* how you get a commander running in a directory that does not exist — `DirPickerModal` browses the account's
|
||||
* own tree, which is the same containment the server applies to whatever it is handed.
|
||||
*/
|
||||
const CommanderSetup = ({
|
||||
name,
|
||||
onName,
|
||||
cwd,
|
||||
onPickDirectory,
|
||||
}: {
|
||||
name: string;
|
||||
onName: (next: string) => void;
|
||||
cwd: string | null;
|
||||
onPickDirectory: () => void;
|
||||
}) => (
|
||||
<div className="shrink-0 border-b border-border bg-muted/20 px-5 py-4">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="commander-name" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="commander-name"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(ev) => onName(ev.target.value)}
|
||||
placeholder="Platform deploys"
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
{/* Said here because it is not obvious and it is not reversible cheaply: the harness would otherwise
|
||||
name this after whatever the first message happens to say. */}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Applied to the conversation as it starts, instead of the harness naming it after your first message.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Working directory</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickDirectory}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
cwd ? 'border-border bg-background' : 'border-dashed border-border text-muted-foreground hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">{cwd ?? 'Choose a directory…'}</span>
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Where every command it runs resolves from, and where its history is kept. It cannot be moved later.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
|
||||
*
|
||||
@@ -91,7 +155,31 @@ function NewCommander({
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const chat = useChat(undefined, undefined, { replaceUrl: false, context: 'command-hq' });
|
||||
/*
|
||||
A name and a working directory, both required BEFORE the first message.
|
||||
|
||||
Neither can be set afterwards without cost. The directory decides where every relative path in every turn
|
||||
resolves and which project group holds the transcripts, so changing it later would strand a conversation
|
||||
away from its own history. The name has to reach the transcript the moment it exists — `commanderName` on
|
||||
the turn — because Claude Code names a new session from its first prompt, and the platform's rename only
|
||||
wins by getting there first.
|
||||
|
||||
So the composer is disabled until both are chosen. That is why this lives on the card rather than in a
|
||||
dialog: you read what a commander is and make the two decisions in the same place.
|
||||
*/
|
||||
const [name, setName] = useState('');
|
||||
const [cwd, setCwd] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const ready = name.trim().length > 0 && !!cwd;
|
||||
|
||||
const chat = useChat(undefined, undefined, {
|
||||
replaceUrl: false,
|
||||
context: 'command-hq',
|
||||
// The name rides the turn; the DIRECTORY is a prop on EmbeddableChat below, because that is what feeds
|
||||
// `sendPrompt`'s `cwdParam`. Two different paths for two values chosen in the same place, which is worth
|
||||
// a note rather than a discovery.
|
||||
...(name.trim() ? { commanderName: name.trim() } : {}),
|
||||
});
|
||||
// Also the transcript id: `command_hq_sessions` is keyed on it, so an action addressed by officer's key
|
||||
// updates nothing and says nothing. Same root cause as the navigate below.
|
||||
const { setCategory } = useCommandHqCommanderActions(chat.claudeSessionId ?? '');
|
||||
@@ -137,8 +225,27 @@ function NewCommander({
|
||||
and not to the component left that radio hidden inside a commander — the one place it exists for. */}
|
||||
{/* Before the first message, the pane explains itself. `EmbeddableChat`'s own empty state is "Send a
|
||||
message to start", which is true and tells a first-time user nothing about what to send. */}
|
||||
{chat.messages.length === 0 && <NewCommanderGuide />}
|
||||
<EmbeddableChat chat={chat} sessionId={undefined} context="command-hq" className="min-h-0 flex-1" />
|
||||
{chat.messages.length === 0 && (
|
||||
<>
|
||||
<CommanderSetup name={name} onName={setName} cwd={cwd} onPickDirectory={() => setPickerOpen(true)} />
|
||||
<NewCommanderGuide />
|
||||
</>
|
||||
)}
|
||||
{/* Disabled until both are chosen. The composer is where somebody would otherwise type first and
|
||||
discover the requirement afterwards, having already written what they meant to say. */}
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
sessionId={undefined}
|
||||
context="command-hq"
|
||||
{...(cwd ? { cwd: { path: cwd } } : {})}
|
||||
disabled={
|
||||
chat.messages.length === 0 && !ready
|
||||
? { reason: !name.trim() ? 'Name it first…' : 'Choose a working directory first…' }
|
||||
: false
|
||||
}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
<DirPickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelect={setCwd} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ type UsePiChatOptions = {
|
||||
projectScoped?: boolean;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
/**
|
||||
* The name chosen for a COMMANDER before its first message.
|
||||
*
|
||||
* Rides every turn rather than just the first: the server applies it once, in `fileAsCommander`, and
|
||||
* sending it again costs a few bytes. Sending it only on the first turn would mean losing it if that turn
|
||||
* failed and was retried, which is the case that matters — a commander named after its second prompt is
|
||||
* the bug this exists to prevent.
|
||||
*/
|
||||
commanderName?: string;
|
||||
resumeSummary?: string;
|
||||
resumeSessionId?: string;
|
||||
initialMessages?: ChatMessage[];
|
||||
@@ -73,6 +82,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
projectScoped,
|
||||
context,
|
||||
contextId,
|
||||
commanderName,
|
||||
resumeSummary: initialResumeSummary,
|
||||
resumeSessionId,
|
||||
initialMessages: preloadedMessages,
|
||||
@@ -834,6 +844,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
...(thinking ? { thinking } : {}),
|
||||
...(context ? { context } : {}),
|
||||
...(contextId ? { contextId } : {}),
|
||||
...(commanderName ? { commanderName } : {}),
|
||||
...(pendingResumeSummary ? { resumeSummary: pendingResumeSummary } : {}),
|
||||
...(resumeSessionId ? { resumeSessionId } : {}),
|
||||
...(armWatcher ? { armWatcher } : {}),
|
||||
|
||||
Reference in New Issue
Block a user