declare panel channels once, and fix the bump that could lose a refresh

Four channels were bare string literals repeated across files, with the payload type supplied by each
caller. Neither hole errors: a typo yields a different, empty channel — publisher publishing into nowhere,
subscriber waiting forever — and a publisher and subscriber can simply disagree about the payload with
nothing to check them. defineChannel(name, initial) returns the hook, officerdev/src/channels.ts declares
the four, and every usePanelChannel call site in the repo now passes a shared constant.

files:refresh-signal was bumped two different ways: Date.now() at the Chat sites, setSignal((n) => n + 1)
at the FileViewer ones. The increment is wrong — useGlobal's functional setter applies against the value
captured at render, so two bumps in one render window both compute snapshot + 1 and the second writes the
same number as the first. Nobody re-reads and the file that was just written stays stale. Date.now() has
the same flaw at millisecond scale, and the four FileViewer sites (save, delete, extract, transcribe) sit
close enough to hit it. useFilesRefresh's bump is a module counter that never reads React state, so it is
right however many times it is called between renders, and it is identity-stable through a ref because
useGlobal's setter is a fresh closure every render and this goes into dependency lists.

system-settings:run-command is deleted. It had a writer once — 7c0b11c wired the AI harness installer to
it — and when that install moved server-side to POST /server-settings/chat-providers/install the write
went with it, leaving a channel whose only remaining writes were clears, a terminal pane nothing could
open, and a second layout nothing could select.

PanelComponentEntry's component, header and provider are typed with { panelId: string }, which is what
PanelSlot has always rendered them with. A no-prop component is still assignable, so no screen changed.

chat:active-session and preview:refresh are declared but still have no subscriber. preview:refresh has no
plausible one — the PreviewProvider that read it is gone from the repo — but both are published by the
chat panel, and that is not this branch's to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 11:14:55 +00:00
co-authored by Claude Opus 5
parent 30fcab2bd3
commit c9735580fc
14 changed files with 131 additions and 120 deletions
@@ -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<SelectedSession>('chat:selected-session', null);
const [selected, setSelected] = useSelectedChatSession();
const client = useClient();
const selectedRef = useRef(selected);
selectedRef.current = selected;
@@ -1,6 +0,0 @@
export type RunCommandState = {
command: string;
refetchKeys: string[];
} | null;
export const RUN_COMMAND_CHANNEL = 'system-settings:run-command';
@@ -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<RunCommandState>(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 (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">Run Command</span>
<button
onClick={close}
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<TerminalView className="flex-1" command={session.command} sessionId={session.id} onCommandDone={onCommandDone} />
</div>
);
};
export const SystemSettings = () => {
const [runCommand] = usePanelChannel<RunCommandState>(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 (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
<WorkspaceLayout layout={baseLayout} onLayoutChange={() => {}} components={panelComponents} />
</div>
);
};
@@ -3,3 +3,20 @@ import { useGlobal } from './useGlobal';
export const usePanelChannel = <T>(channel: string, initialData: T) => {
return useGlobal<T>(['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 =
<T>(channel: string, initialData: T) =>
() =>
usePanelChannel<T>(channel, initialData);
@@ -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<string | null>('chat:active-session', null);
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
const [, setFilesRefresh] = usePanelChannel<number>('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
@@ -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<SelectedSession>(CHANNEL, null);
const [selected] = useSelectedChatSession();
if (!selected) {
return (
@@ -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<SelectedSession>('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/<id> shows
// that session among its neighbours instead of snapping the list back to the default group. Null =
// the default general_chat_sessions dir.
@@ -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<number>('files:refresh-signal', 0);
const [refreshSignal] = useFilesRefresh();
useEffect(() => {
if (basePath !== '/' && !currentPath.startsWith(basePath)) {
@@ -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<number>('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');
+66
View File
@@ -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<number>('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<SelectedSession>('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<string | null>('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<number>('preview:refresh', 0);
@@ -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;
@@ -69,14 +69,19 @@ export type AppRegistryEntry = {
export type AppRegistryMap = Record<string, AppRegistryEntry>;
// 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<string, ComponentType | PanelComponentEntry>;
export type PanelComponents = Record<string, ComponentType<{ panelId: string }> | PanelComponentEntry>;
export type EphemeralPanels = {
layout: LayoutNode;
@@ -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<number>('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 (
<EmbeddableChat
+1
View File
@@ -1,4 +1,5 @@
export * from './hooks';
export * from './channels';
export * from './AppRegistry';
export * from './WidgetRegistry';
export * from './MusicPlayer';