a commander's own files, beside its transcript
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/<email>/commanders/<dir>, 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 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||
|
||||
@@ -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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFiles}
|
||||
aria-pressed={filesOpen}
|
||||
title="This commander's own files"
|
||||
className={`shrink-0 cursor-pointer rounded-md p-1.5 transition-colors ${
|
||||
filesOpen ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:bg-foreground/10'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
{!onToggleFiles && onPlanModeChange && currentHarness !== 'opencode' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPlanModeChange(!planMode)}
|
||||
|
||||
@@ -11,6 +11,16 @@ import type { CommandHqCategoryFilter } from './types';
|
||||
* there is no separate detail-only page to lose the grid to.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether the commander's own file browser is open beside the transcript — `?files=1`.
|
||||
*
|
||||
* A query param rather than a route because it is something laid ON TOP of the open commander rather than
|
||||
* a different place to be: the transcript stays mounted and stays the page. It lives here rather than in
|
||||
* the hook that reads it because the composer's button WRITES it, and a magic string spelled in two files
|
||||
* is a magic string that will eventually be spelled two ways.
|
||||
*/
|
||||
export const COMMANDER_FILES_PARAM = 'files';
|
||||
|
||||
const categoryQuery = (category: CommandHqCategoryFilter): string =>
|
||||
category === undefined ? '' : `?category=${category}`;
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ export type CommandHqCommander = {
|
||||
export type CommandHqCommanderDetail = {
|
||||
sessionId: string;
|
||||
name: string | null;
|
||||
/** This commander's own directory under `user-data` — what the files panel is rooted at. */
|
||||
dirName: string | null;
|
||||
referencePaths: string[];
|
||||
categoryId: number | null;
|
||||
tagIds: number[];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './useFilesAPI';
|
||||
export * from './useFileViewerPanels';
|
||||
export * from './useCommanderFilesPanel';
|
||||
export * from './useChat';
|
||||
export * from './useChatNotifications';
|
||||
export * from './useDock';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useCommanderFilesPanel } from './useCommanderFilesPanel';
|
||||
export type { UseCommanderFilesPanelType } from './useCommanderFilesPanel';
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { EphemeralPanels, LayoutNode } from '../../components/Workspace';
|
||||
import { FileBrowserApp } from '../../apps/FileBrowser';
|
||||
import type { CommandHqCommanderDetail } from '../../apps/CommandHQ/types';
|
||||
import { COMMANDER_FILES_PARAM } from '../../apps/CommandHQ/command-hq-routes';
|
||||
|
||||
/**
|
||||
* The open commander's own directory, as a file browser beside the transcript.
|
||||
*
|
||||
* ── Why a file browser cannot otherwise reach it ──
|
||||
*
|
||||
* A commander's directory is `$DATA_PATH/<email>/commanders/<dirName>`. The file browser serves exactly
|
||||
* two roots, `home` and `user-data` (`file-browser/router.ts` → `getRootDir`), and `/files` is rooted at
|
||||
* `home` — so there is no path from the Files screen to a commander at all. This panel is the only way to
|
||||
* see what one has written, and it needs the OTHER root.
|
||||
*
|
||||
* ── Why ephemeral rather than a panel in the layout ──
|
||||
*
|
||||
* `/command-hq` is a `locked` workspace with a three-item allow-list. A persisted panel would mean
|
||||
* unlocking a screen whose structure is deliberately code's rather than the user's, and widening a list
|
||||
* that exists to keep a stranded panel from collapsing onto the fallback. An ephemeral layout is rendered
|
||||
* BESIDE `workspace.value` and never enters it, so neither has to change — the same trade `/files` makes
|
||||
* for the file viewer, for the same reason.
|
||||
*
|
||||
* ── Why the breadcrumbs stop at the commander ──
|
||||
*
|
||||
* `basePath` is the browser's floor: `Breadcrumb` renders relative to it and its home crumb IS the base,
|
||||
* so nothing above the commander is offered. Note 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.
|
||||
*/
|
||||
const layout: LayoutNode = { type: 'panel', id: 'command-hq-files', appType: null };
|
||||
|
||||
export const useCommanderFilesPanel = (): EphemeralPanels | null => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const client = useClient();
|
||||
const open = searchParams.get(COMMANDER_FILES_PARAM) === '1';
|
||||
|
||||
// The composer knows it is a commander but not which directory that is, so the panel resolves it from
|
||||
// the open session rather than the button carrying it. Only fetched while the panel is open.
|
||||
const { data } = useQuery<CommandHqCommanderDetail>({
|
||||
queryKey: ['command-hq', 'commander', sessionId ?? ''],
|
||||
queryFn: () => client.get<CommandHqCommanderDetail>(`/command-hq/commanders/${sessionId}`),
|
||||
enabled: open && !!sessionId,
|
||||
});
|
||||
|
||||
const onClose = useCallback(
|
||||
() =>
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete(COMMANDER_FILES_PARAM);
|
||||
return next;
|
||||
}),
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const dirName = data?.dirName ?? null;
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
'command-hq-files': {
|
||||
component: () => (
|
||||
<FileBrowserApp basePath={`/commanders/${dirName}`} rootOverride="user-data" panelId="command-hq-files" />
|
||||
),
|
||||
onClose,
|
||||
},
|
||||
}),
|
||||
[dirName, onClose],
|
||||
);
|
||||
|
||||
// Nothing until the directory name is known. Mounting on `/commanders/null` would list the whole
|
||||
// commanders folder for a beat — every OTHER commander included — before correcting itself.
|
||||
if (!open || !dirName) return null;
|
||||
return { layout, components, defaultBaseSize: 45, onClose };
|
||||
};
|
||||
|
||||
export type UseCommanderFilesPanelType = ReturnType<typeof useCommanderFilesPanel>;
|
||||
Reference in New Issue
Block a user