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' && (