diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index eae863fa..f6776693 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -1,14 +1,13 @@ import { useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router'; import type { LayoutNode, SelectedSession } from 'officerdev'; -import { WorkspaceView, chatListPath, cwdFromSplat } from 'officerdev'; +import { WorkspaceView, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev'; import { toast } from '@/components/ui/sonner'; import { useIsMobile } from 'hooks/useIsMobile'; import { useClient } from 'hooks/useClient'; import { errorText } from 'helpers/error-text'; import { useDashboardState } from 'state/useDashboardState'; import type { ClaudeSessionDetail } from 'state/useClaudeSessions'; -import { usePanelChannel } from 'hooks/usePanelChannel'; import { defaultLayout } from './defaultLayout'; // How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in. @@ -22,7 +21,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { // The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts. const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>(); const groupCwd = cwdFromSplat(splat); - const [selected, setSelected] = usePanelChannel('chat:selected-session', null); + const [selected, setSelected] = useSelectedChatSession(); const client = useClient(); const selectedRef = useRef(selected); selectedRef.current = selected; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts deleted file mode 100644 index b3174a66..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type RunCommandState = { - command: string; - refetchKeys: string[]; -} | null; - -export const RUN_COMMAND_CHANNEL = 'system-settings:run-command'; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index 290315d4..91fe4a01 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -1,13 +1,9 @@ -import { useState, useEffect, useMemo } from 'react'; -import { toast } from 'sonner'; -import { Bot, Settings, Mail, Volume2, Mic, ScanText, X } from 'lucide-react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { Bot, Settings, Mail, Volume2, Mic, ScanText } from 'lucide-react'; import type { LayoutNode, PanelComponents } from 'officerdev'; -import { WorkspaceLayout, TerminalView } from 'officerdev'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { WorkspaceLayout } from 'officerdev'; import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel'; -import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel'; import { SMTPSection } from './ServerSettings/SMTPSection'; import { TTSSection } from './ServerSettings/TTSSection'; import { STTSection } from './ServerSettings/STTSection'; @@ -51,6 +47,12 @@ const { Sidebar, Content } = createSettingsPanelComponents({ groups, }); +// There used to be a second layout here that split a terminal in below the settings content, driven by a +// `system-settings:run-command` channel: a section asked for a sudo command, this screen grew a pane, ran +// it, and invalidated the queries the section named. Its only writer was the AI harness installer, and +// that moved server-side to `POST /server-settings/chat-providers/install` — leaving a channel whose two +// remaining writes were both `setState(null)`, a pane nothing could ever open, and a layout nothing could +// ever select. Removed rather than rewired; the install path it existed for no longer wants a terminal. const baseLayout: LayoutNode = { type: 'group', id: 'system-root', @@ -61,91 +63,18 @@ const baseLayout: LayoutNode = { ], }; -const splitLayout: LayoutNode = { - type: 'group', - id: 'system-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'system-left', appType: null }, size: 20 }, - { - node: { - type: 'group', - id: 'system-right-group', - direction: 'vertical', - children: [ - { node: { type: 'panel', id: 'system-right', appType: null }, size: 50 }, - { node: { type: 'panel', id: 'system-terminal', appType: null }, size: 50 }, - ], - }, - size: 80, - }, - ], -}; - -const SystemTerminalPanel = () => { - const queryClient = useQueryClient(); - const [state, setState] = usePanelChannel(RUN_COMMAND_CHANNEL, null); - const [session, setSession] = useState<{ id: string; command: string } | null>(null); - - useEffect(() => { - if (state && (!session || session.command !== state.command)) { - setSession({ id: `run-cmd-${Date.now()}`, command: state.command }); - } else if (!state) { - setSession(null); - } - }, [state]); - - const close = () => setState(null); - - const onCommandDone = (exitCode: number, output: string) => { - if (state) { - for (const key of state.refetchKeys) { - queryClient.invalidateQueries({ queryKey: [key] }); - } - } - if (exitCode === 0) { - toast.success('Command completed successfully'); - } else { - toast.error(output || `Command failed with exit code ${exitCode}`, { duration: 8000 }); - } - setState(null); - }; - - if (!state || !session) return null; - - return ( -
-
- Run Command - -
- -
- ); -}; - export const SystemSettings = () => { - const [runCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null); - - const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]); - const panelComponents: PanelComponents = useMemo( () => ({ 'system-left': Sidebar, 'system-right': Content, - 'system-terminal': SystemTerminalPanel, }), [], ); return (
- {}} components={panelComponents} /> + {}} components={panelComponents} />
); }; diff --git a/src/workspaces/hooks/src/usePanelChannel.ts b/src/workspaces/hooks/src/usePanelChannel.ts index bf5920f6..002bf6cd 100644 --- a/src/workspaces/hooks/src/usePanelChannel.ts +++ b/src/workspaces/hooks/src/usePanelChannel.ts @@ -3,3 +3,20 @@ import { useGlobal } from './useGlobal'; export const usePanelChannel = (channel: string, initialData: T) => { return useGlobal(['PANEL_CHANNEL', channel], initialData); }; + +/** + * Declare a channel once — its name, its payload type and its initial value — and get the hook that reads + * and writes it. + * + * Calling `usePanelChannel('files:refresh-signal', 0)` at each site instead has two holes, both silent. + * A typo in the name does not error: it yields a *different*, empty channel pinned to its initial value, + * so the publisher publishes into nowhere and the subscriber waits forever. And `T` comes from each + * caller, so a publisher and a subscriber can simply disagree about the payload and nothing checks — the + * four `files:refresh-signal` sites were `number` by convention only. + * + * One definition fixes both: there is one spelling and one type, and every consumer gets them by import. + */ +export const defineChannel = + (channel: string, initialData: T) => + () => + usePanelChannel(channel, initialData); diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index b2e941d3..a734befb 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect } from 'react'; import { useWorkspace } from '../../components/Workspace'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels'; import { useChat } from '../../hooks/useChat'; import { EmbeddableChat } from './EmbeddableChat'; import { AgentIdentityBar } from './AgentIdentityBar'; @@ -74,18 +74,18 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp const agentPanel = useAgentPanel(panelId); - const [, setActiveSession] = usePanelChannel('chat:active-session', null); - const [, setPreviewRefresh] = usePanelChannel('preview:refresh', 0); - const [, setFilesRefresh] = usePanelChannel('files:refresh-signal', 0); + const [, setActiveSession] = useActiveChatSession(); + const [, setPreviewRefresh] = usePreviewRefresh(); + const [, bumpFilesRefresh] = useFilesRefresh(); const onTurnComplete = useCallback( (hadToolCalls: boolean) => { if (hadToolCalls) { setPreviewRefresh(Date.now()); - setFilesRefresh(Date.now()); + bumpFilesRefresh(); } }, - [setPreviewRefresh, setFilesRefresh], + [setPreviewRefresh, bumpFilesRefresh], ); // A named agent's own directory wins over the dashboard's. It has to: `deliverToAgentPanel` runs an diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 1ca3d7b0..1eddfb5e 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useLocation } from 'react-router'; import { Unplug } from 'lucide-react'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useSelectedChatSession } from '../../channels'; import { useAuth } from 'hooks/useAuth'; import { useClaudeSessions } from 'state/useClaudeSessions'; import { useChat, EmbeddableChat } from '../Chat'; @@ -28,8 +28,6 @@ export type SelectedSession = { partCount?: number; } | null; -const CHANNEL = 'chat:selected-session'; - type ChatLocationState = { initialMessage?: string; prefillInput?: string; @@ -162,7 +160,7 @@ function NewChat(props: NewChatProps) { } export const ChatDetailPanel = () => { - const [selected] = usePanelChannel(CHANNEL, null); + const [selected] = useSelectedChatSession(); if (!selected) { return ( diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index da1375e7..4c836465 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router'; import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react'; import { toast } from '@/components/ui/sonner'; import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useSelectedChatSession } from '../../channels'; import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; import type { SelectedSession } from './ChatDetailPanel'; @@ -18,7 +18,7 @@ export const SessionList = () => { // them there rather than from the selection channel means the highlight and the group are correct on // a deep link and on back/forward, before any panel has published. const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>(); - const [selected, setSelected] = usePanelChannel('chat:selected-session', null); + const [selected, setSelected] = useSelectedChatSession(); // A group path when we're on one; otherwise the open session's own directory, so /chat/ shows // that session among its neighbours instead of snapping the list back to the default group. Null = // the default general_chat_sessions dir. diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index a9fcc0f2..717cdb75 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -6,7 +6,7 @@ import { useTasks, type TaskSummary } from '../useTasks'; import { useAgents, type AgentSummary } from '../useAgents'; import { useUserState } from 'state/useUserState'; import { useAuth } from 'hooks/useAuth'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useFilesRefresh } from '../../../channels'; export const useFileBrowserApp = (basePath: string, rootOverride?: string) => { const { user } = useAuth(); @@ -84,7 +84,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string) => { } }; - const [refreshSignal] = usePanelChannel('files:refresh-signal', 0); + const [refreshSignal] = useFilesRefresh(); useEffect(() => { if (basePath !== '/' && !currentPath.startsWith(basePath)) { diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx index 71a35045..3560dc2d 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import type { ReactNode } from 'react'; import { useFilesAPI } from '../../hooks/useFilesAPI'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useFilesRefresh } from '../../channels'; import { toast } from 'sonner'; import { getFileType } from './file-types'; import { FileViewerContext } from './FileViewerContext'; @@ -38,7 +38,7 @@ export const FileViewerProvider = ({ const [saveResultLoading, setSaveResultLoading] = useState(false); const [editing, setEditing] = useState(false); const files = useFilesAPI(root); - const [, setRefreshSignal] = usePanelChannel('files:refresh-signal', 0); + const [, bumpFilesRefresh] = useFilesRefresh(); const fileType = getFileType(fileName); const editable = fileType === 'markdown' || fileType === 'code' || fileType === 'text'; @@ -74,7 +74,7 @@ export const FileViewerProvider = ({ setTtsLoading(true); try { const { audioPath, audioRoot } = await files.tts(filePath, { saveNextTo: true }); - setRefreshSignal((n) => n + 1); + bumpFilesRefresh(); onReplaceView?.(filePath, root, audioPath, audioRoot); } catch { toast.error('Failed to generate speech audio'); @@ -87,7 +87,7 @@ export const FileViewerProvider = ({ setOcrLoading(true); try { const { ocrPath, ocrRoot } = await files.ocr(filePath, { saveNextTo: true }); - setRefreshSignal((n) => n + 1); + bumpFilesRefresh(); onReplaceView?.(filePath, root, ocrPath, ocrRoot); } catch { toast.error('Failed to extract text from image'); @@ -100,7 +100,7 @@ export const FileViewerProvider = ({ setTranscribeLoading(true); try { const { transcriptionPath, transcriptionRoot } = await files.transcribe(filePath, { saveNextTo: true }); - setRefreshSignal((n) => n + 1); + bumpFilesRefresh(); onReplaceView?.(filePath, root, transcriptionPath, transcriptionRoot); } catch { toast.error('Failed to transcribe audio'); @@ -129,7 +129,7 @@ export const FileViewerProvider = ({ try { const { savedPath } = await files.saveResult(filePath); const savedName = savedPath.split('/').pop() ?? savedPath; - setRefreshSignal((n) => n + 1); + bumpFilesRefresh(); toast.success(`Saved as "${savedName}"`); } catch { toast.error('Failed to save file'); diff --git a/src/workspaces/officerdev/src/channels.ts b/src/workspaces/officerdev/src/channels.ts new file mode 100644 index 00000000..4fe2c8d4 --- /dev/null +++ b/src/workspaces/officerdev/src/channels.ts @@ -0,0 +1,66 @@ +import type { SelectedSession } from './apps/ChatHistory'; +import { useCallback, useRef } from 'react'; +import { defineChannel } from 'hooks/usePanelChannel'; + +/** + * Panel channels, declared once each. + * + * A channel is a *signal*, not a selection — "something changed, re-read it", "this pane is now showing + * that". Anything addressable belongs in the URL; see `docs/navigation-audit.md`. + */ + +const useFilesRefreshChannel = defineChannel('files:refresh-signal', 0); + +// Bumping this used to be done two different ways: `Date.now()` at the Chat sites and +// `setSignal((n) => n + 1)` at the FileViewer ones. The increment is the wrong one — `useGlobal`'s +// functional setter applies against the value captured at render, so two bumps inside one render window +// both compute `snapshot + 1` and the second overwrites the first with the same number. Nobody re-reads, +// and the file that was just written stays stale on screen. +// +// A timestamp has the same flaw in miniature: two bumps in the same millisecond are the same number. So +// the nonce is a plain counter that never reads React state at all, and is therefore never wrong however +// many times it is called between renders. +let nonce = 0; + +/** + * "The files on disk changed — anyone showing them should re-read." Subscribers name the signal as an + * effect dependency; publishers call `bump` after a write, a delete, an upload or an agent turn that + * touched the filesystem. + */ +export const useFilesRefresh = () => { + const [signal, setSignal] = useFilesRefreshChannel(); + + // `useGlobal`'s setter is a fresh closure on every render, so a `bump` built directly on it would be + // too — and it goes into dependency lists. Reading through a ref makes the identity stable without + // freezing the setter from the first render. + const setRef = useRef(setSignal); + setRef.current = setSignal; + const bump = useCallback(() => setRef.current(++nonce), []); + + return [signal, bump] as const; +}; + +/** + * Which conversation the Chat History detail pane is showing. This one is a genuine exception to + * "selection lives in the URL": the route (`/chat/:sessionId`) is already the source of truth for *which* + * session, and this carries the transcript the list has already loaded so the detail pane does not fetch + * it a second time. §5.8 covers finishing the job. + */ +export const useSelectedChatSession = defineChannel('chat:selected-session', null); + +/** + * Which session the chat panel is currently on. + * + * **No subscriber today.** `ChatPanelWrapper` publishes it and nothing reads it. It is declared here + * rather than deleted because removing the publisher changes the chat panel's own prop plumbing, and the + * chat is not this branch's to change — see `COMMS/chat-agent-handoff-2026-08-07.md`. + */ +export const useActiveChatSession = defineChannel('chat:active-session', null); + +/** + * "An agent turn touched something a preview is showing." + * + * **No subscriber today either**, and unlike the one above it has no plausible one: the `PreviewProvider` + * that read it no longer exists anywhere in the repo. Same reason for declaring rather than deleting. + */ +export const usePreviewRefresh = defineChannel('preview:refresh', 0); diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx index 443af0c1..2e4c321c 100644 --- a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx @@ -41,7 +41,7 @@ type PanelContextMenuProps = { children: React.ReactNode; }; -const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelComponentEntry => +const isPanelEntry = (v: PanelComponents[string]): v is PanelComponentEntry => typeof v === 'object' && v !== null && 'component' in v; const PanelContextMenu = ({ @@ -349,7 +349,9 @@ export const PanelSlot = ({ const rawPanelComponent = components?.[panel.id]; const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null; - const PanelComponent = panelEntry ? panelEntry.component : (rawPanelComponent as ComponentType | undefined); + const PanelComponent = panelEntry + ? panelEntry.component + : (rawPanelComponent as ComponentType<{ panelId: string }> | undefined); const entry = panel.appType ? registry[panel.appType] : null; const AppComponent = PanelComponent ?? entry?.component; diff --git a/src/workspaces/officerdev/src/components/Workspace/types.ts b/src/workspaces/officerdev/src/components/Workspace/types.ts index 8f00be55..c4038c6b 100644 --- a/src/workspaces/officerdev/src/components/Workspace/types.ts +++ b/src/workspaces/officerdev/src/components/Workspace/types.ts @@ -69,14 +69,19 @@ export type AppRegistryEntry = { export type AppRegistryMap = Record; +// A panel supplied by a screen rather than by the registry. `PanelSlot` renders all three of these with +// `panelId`, exactly as it does the registry's — they were typed with no props at all, so a +// `components`-supplied panel was handed an id it could not see, and a screen wanting one had to reach +// for `useParams` or a channel instead. A component that takes no props is still assignable here, so the +// existing screens are unaffected. export type PanelComponentEntry = { - component: ComponentType; - header?: ComponentType; - provider?: ComponentType<{ children: ReactNode }>; + component: ComponentType<{ panelId: string }>; + header?: ComponentType<{ panelId: string }>; + provider?: ComponentType<{ panelId: string; children: ReactNode }>; onClose?: () => void; }; -export type PanelComponents = Record; +export type PanelComponents = Record | PanelComponentEntry>; export type EphemeralPanels = { layout: LayoutNode; diff --git a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx index 59f75276..3b652093 100644 --- a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx +++ b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { useCallback } from 'react'; import { useSearchParams } from 'react-router'; -import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useFilesRefresh } from '../../channels'; import { FileViewerProvider } from '../../apps/FileViewer'; import { EmbeddableChat } from '../../apps/Chat/EmbeddableChat'; @@ -81,7 +81,7 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) { export const ChatEphemeralBody = () => { const [searchParams] = useSearchParams(); - const [, setRefreshSignal] = usePanelChannel('files:refresh-signal', 0); + const [, bumpFilesRefresh] = useFilesRefresh(); const chatContext = searchParams.get('chatContext') ?? ''; const chatType = searchParams.get('chatType') as 'file' | 'folder' | null; @@ -96,8 +96,8 @@ export const ChatEphemeralBody = () => { : `[${tag}: ${path || '/'}] consider, for this session, this directory as your current working directory`; const handleMessageComplete = useCallback(() => { - setRefreshSignal((n) => n + 1); - }, [setRefreshSignal]); + bumpFilesRefresh(); + }, [bumpFilesRefresh]); return (