diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx index bdcda6d6..970827db 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx @@ -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. * diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index 7e46354a..5bbebba1 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -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 diff --git a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx index 59e86208..ac2dbf66 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx @@ -243,6 +243,28 @@ const TaskStrip = ({ label, children, trailing, divided }: TaskStripProps) => ( ); +/** + * 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 }) => ( + + + + {label} + + +); + // ── Chip ── type TaskChipProps = { diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index b6afb972..b0cc86dd 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -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) => {
{commandFeedback}
)} + {manager.watcher && } + diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx index 90c2843c..8ebd019f 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx @@ -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" />