From 00baa83a2cdeec29bc0047300a8e63047b98d694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 24 Aug 2026 14:22:58 +0000 Subject: [PATCH] a commander's own files, beside its transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a commander writes into its own directory — procedures, reports, the loop it armed — and there was no way to look at any of it. /files cannot reach it and never could: the file browser serves two roots, `home` and `user-data`, and a commander lives at $DATA_PATH//commanders/, which is under the one /files does not use. so the Plan button becomes a files button on a commander. instead, not beside: plan mode is research-and-propose-until-a-human-approves, and a commander is the case where nobody is watching to approve. the control promised something that cannot happen there, while the thing genuinely missing was any view of what it had written. an EPHEMERAL panel, the same trade /files makes for the file viewer. /command-hq is a locked workspace with a three-item allow-list whose comments are explicit that its structure is code's rather than the user's; a persisted panel would mean unlocking it and widening that list. an ephemeral layout renders beside workspace.value and never enters it, so neither has to change. ?files=1 rather than a route, and a panel rather than a link: the transcript stays mounted and stays the page, and a link on this surface opens a second copy of the whole app in a new tab and loses the conversation you were reading. breadcrumbs stop at the commander for free — Breadcrumb renders relative to basePath and its home crumb IS the base. worth knowing that this is the UI's floor and not the server's: the root really is all of user-data, which is the owner's own data either way, so it bounds the navigation rather than the account. the button reads its state from props rather than useSearchParams. ModelSelector is rendered bare in its own tests and reaching for the router inside it failed all twelve — a control that has to be mounted in a route to render is not a control any more. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .../Dashboard/CommandHQ/CommandHqScreen.tsx | 7 +- src/servers/api/command-hq/router.ts | 4 + .../src/apps/Chat/components/InputArea.tsx | 26 +++++- .../apps/Chat/components/ModelSelector.tsx | 42 +++++++++- .../src/apps/CommandHQ/command-hq-routes.ts | 10 +++ .../officerdev/src/apps/CommandHQ/types.ts | 2 + src/workspaces/officerdev/src/hooks/index.ts | 1 + .../src/hooks/useCommanderFilesPanel/index.ts | 2 + .../useCommanderFilesPanel.tsx | 81 +++++++++++++++++++ 9 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 src/workspaces/officerdev/src/hooks/useCommanderFilesPanel/index.ts create mode 100644 src/workspaces/officerdev/src/hooks/useCommanderFilesPanel/useCommanderFilesPanel.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx b/src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx index 29e81bfd..a84ff4da 100644 --- a/src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import type { LayoutNode } from 'officerdev'; -import { WorkspaceView } from 'officerdev'; +import { WorkspaceView, useCommanderFilesPanel } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout, isLegacyThreeColumn } from './defaultLayout'; @@ -10,6 +10,7 @@ import { defaultLayout, isLegacyThreeColumn } from './defaultLayout'; export const CommandHqScreen = () => { const workspace = useDashboardState('screens/command-hq', defaultLayout); + const ephemeral = useCommanderFilesPanel(); /* Move a saved three-column layout onto the two-column one. @@ -26,6 +27,9 @@ export const CommandHqScreen = () => { return (
+ {/* The commander's own file browser is a layout of its own, rendered beside this one — it never + enters `workspace.value`, so this screen stays locked and the allow-list does not have to know + about it. Same trade `/files` makes for the file viewer. */} { allowed: ['command-hq-categories', 'command-hq-grid', 'command-hq-detail'], fallback: 'command-hq-detail', }} + ephemeral={ephemeral} />
); diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts index 41441c90..eed24f8c 100644 --- a/src/servers/api/command-hq/router.ts +++ b/src/servers/api/command-hq/router.ts @@ -447,6 +447,10 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => { return ctx.json({ sessionId: row.sessionId, name: row.name, + // The commander's OWN directory name, so a client can open a file browser on it. Its directory sits + // under `user-data` (`$DATA_PATH//commanders/`), which the `home` root the file + // browser normally serves cannot reach at all — see `file-browser/router.ts` → `getRootDir`. + dirName: row.dirName, referencePaths: row.referencePaths, categoryId: row.categoryId, tagIds: tagsByRow.get(row.id) ?? [], diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index 264257c5..b6afb972 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -1,9 +1,11 @@ import type { DragEvent } from 'react'; -import { useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router'; import { Loader2, Send, Square, ListPlus, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat'; import { ModelSelector } from './ModelSelector'; +import { COMMANDER_FILES_PARAM } from '../../CommandHQ/command-hq-routes'; import { BackgroundJobDialog } from './BackgroundJobDialog'; import { ClaudeLoginDialog } from './ClaudeLoginDialog'; import { useCommanderRecordPaths } from '../useCommanderRecordPaths'; @@ -27,6 +29,26 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => { // 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. const recordPaths = useCommanderRecordPaths(manager.isCommander ? manager.claudeSessionId : null); + + /* + A commander's own files, opened beside the transcript — `useCommanderFilesPanel` renders the panel off + this same param. + + A param rather than a route, and a panel rather than a link: a commander's directory is the one place + `/files` cannot reach (a different root entirely), and a link would open a second copy of the whole app + in a new tab and lose the conversation being read. + */ + const [searchParams, setSearchParams] = useSearchParams(); + const toggleCommanderFiles = useCallback( + () => + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (next.get(COMMANDER_FILES_PARAM) === '1') next.delete(COMMANDER_FILES_PARAM); + else next.set(COMMANDER_FILES_PARAM, '1'); + return next; + }), + [setSearchParams], + ); const { input, setInput, @@ -201,6 +223,8 @@ export const InputArea = ({ manager, disabled }: InputAreaProps) => { onBackgroundJob={() => setJobDialogOpen(true)} planMode={planMode} onPlanModeChange={setPlanMode} + filesOpen={manager.isCommander && searchParams.get(COMMANDER_FILES_PARAM) === '1'} + onToggleFiles={manager.isCommander ? toggleCommanderFiles : undefined} /> diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx index c79ac8b1..e14af62f 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { CirclePlay, ClipboardList } from 'lucide-react'; +import { CirclePlay, ClipboardList, FolderOpen } from 'lucide-react'; import { toneText } from '@/components/Data'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { ModelOption } from 'state/useModels'; @@ -52,6 +52,16 @@ type ModelSelectorProps = { * about what this turn will cost. */ planMode?: boolean; + /** + * Open this commander's own files, and whether they are open — absent on an ordinary chat, where the + * Plan control takes this place instead. + * + * Given as props rather than read from the URL here on purpose: this component is rendered bare in its + * own tests, and reaching for `useSearchParams` inside it made every one of them fail on a missing + * Router. A control that has to be mounted in a route to render is not a control any more. + */ + filesOpen?: boolean; + onToggleFiles?: () => void; onPlanModeChange?: (enabled: boolean) => void; }; @@ -66,6 +76,8 @@ export function ModelSelector({ hasStarted, onBackgroundJob, planMode = false, + filesOpen = false, + onToggleFiles, onPlanModeChange, }: ModelSelectorProps) { const { t } = useTranslation(); @@ -240,7 +252,33 @@ export function ModelSelector({ Sonnet, quietly, and a user who picked it expecting Opus would never find that out. Officer does NOT auto-enter plan mode for it, because the CLI does not either; it says so instead. */} - {onPlanModeChange && currentHarness !== 'opencode' && ( + {/* + A commander's own directory, instead of Plan. + + Instead, not beside: a commander is an agent that acts while nobody watches, and plan mode is the + opposite posture — research, propose, change nothing until a human approves. There is no human in + the loop to approve, so the control promises something that cannot happen. What is genuinely + missing there is a way to SEE what the commander has been writing, and its directory is + unreachable from `/files` (a different root entirely), so this is the only door to it. + + `?files=1` rather than a route: it opens an ephemeral panel beside the transcript in this same + workspace — `useCommanderFilesPanel`. A link would open a second copy of the whole app in a new + tab and lose the conversation you were reading. + */} + {onToggleFiles && ( + + )} + {!onToggleFiles && onPlanModeChange && currentHarness !== 'opencode' && (