the watcher shows above the composer, named for what it watches

"watching" was a word in the metadata bar at the top, next to the token
count. the thing the owner actually reads is the strip above the input,
where background tasks live — and a watcher had nothing there at all, so a
commander quietly doing its job looked like a commander doing nothing.

WatcherStrip, in the same component and the same place as the task chips,
because to the person reading it is the same KIND of thing: something
running on their behalf that they want to see without scrolling.

it is NOT a background task and cannot be derived like one — a watcher is a
detached loop that outlives the turn that armed it, so it has no task id
and never appears in `messages`. the platform knows it from a pid file, and
only the screen holding the commander's metadata knows what it watches. so
it is passed in.

named for the subject rather than the fact: "Voice Memos", not "watcher
armed".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-25 06:27:36 +00:00
co-authored by Claude Opus 5
parent f4e983693e
commit 1b2a2d0a78
5 changed files with 47 additions and 1 deletions
@@ -44,6 +44,14 @@ type EmbeddableChatProps = {
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
*/
isCommander?: boolean;
/**
* A detached loop this conversation armed, shown above the composer beside the background tasks.
*
* Passed in rather than derived, because a watcher is not a background task: it has no task id and is
* not in `messages` at all — the platform knows about it from a pid file, and only the screen holding
* the commander's metadata can say what it watches.
*/
watcher?: { label: string; running: boolean };
/**
* Refuse input entirely, with a reason the composer shows.
*
@@ -52,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;
watcher?: { label: string; running: boolean };
emptyState?: ReactNode;
};
@@ -624,6 +625,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,
watcher: params.watcher,
emptyState: params.emptyState,
/*
Claude's transcript id, which is what a commander is keyed on everywhere — the Command HQ row, the
@@ -243,6 +243,28 @@ const TaskStrip = ({ label, children, trailing, divided }: TaskStripProps) => (
</div>
);
/**
* The commander's own watcher, docked in the same strip as the background tasks.
*
* A watcher is not a background TASK — it is a detached loop that outlives the turn that armed it, so it
* has no task id and never appears in `messages`. It is the same KIND of thing to the person reading:
* something running on their behalf that they want to see without scrolling. So it reads the same, sits
* in the same place, and says what it is watching rather than that something is watching.
*/
export const WatcherStrip = ({ label, running }: { label: string; running: boolean }) => (
<TaskStrip label={running ? 'watching' : 'not watching'}>
<span
className={`inline-flex min-w-0 shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs ${
running ? 'border-success/40 text-foreground' : 'border-border text-muted-foreground'
}`}
title={label}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${running ? 'bg-success' : 'bg-muted-foreground/40'}`} />
<span className="truncate">{label}</span>
</span>
</TaskStrip>
);
// ── Chip ──
type TaskChipProps = {
@@ -14,7 +14,7 @@ import { useWorkspace } from '../../../components/Workspace';
import { AttachmentList } from './AttachmentList';
import { AttachButton } from './AttachButton';
import { WebpageDialog } from './WebpageDialog';
import { BackgroundTaskTray } from './BackgroundTaskTray';
import { BackgroundTaskTray, WatcherStrip } from './BackgroundTaskTray';
import { useTranslation } from '../../../locales';
type InputAreaProps = {
@@ -119,6 +119,8 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => {
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
)}
{manager.watcher && <WatcherStrip label={manager.watcher.label} running={manager.watcher.running} />}
<BackgroundTaskTray messages={messages} sessionId={sessionId} />
<QueuedList queued={queued} onRemove={unqueue} onSendNow={sendQueuedNow} />
@@ -654,6 +654,15 @@ type ResumeState = {
* Same shape here.
*/
function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: ResumeState }) {
// The commander's own metadata, for the watcher strip above the composer. Same query the MetaBar above
// already holds, so this is a cache read rather than a second request.
const { commander } = useCommandHqCommander(sessionId);
const reference = commander?.referencePaths?.[0];
const watcher =
commander?.watcherArmed && reference
? { label: reference.split('/').filter(Boolean).pop() ?? reference, running: commander.watcherRunning === true }
: undefined;
const { t, tUi } = useTranslation();
const chat = useChat(undefined, resume.model ?? undefined, {
replaceUrl: false,
@@ -678,6 +687,9 @@ function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: Re
sessionId={undefined}
context="command-hq"
panelId={`command-hq:${sessionId}`}
// Named for what it watches, not for the fact that it does. "Voice Memos" above the composer says
// more than "watcher armed" ever did, and it is the only place the reference path is known.
watcher={watcher}
className="min-h-0 flex-1"
/>
</div>