From c9735580fc0d5cef24b8560c165d95dad187fffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 11:14:55 +0000 Subject: [PATCH] declare panel channels once, and fix the bump that could lose a refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Screens/Dashboard/ChatHistory/index.tsx | 5 +- .../ServerSettings/run-command-channel.ts | 6 -- .../Dashboard/Settings/SystemSettings.tsx | 91 ++----------------- src/workspaces/hooks/src/usePanelChannel.ts | 17 ++++ .../src/apps/Chat/ChatPanelWrapper.tsx | 12 +-- .../src/apps/ChatHistory/ChatDetailPanel.tsx | 6 +- .../src/apps/ChatHistory/SessionList.tsx | 4 +- .../FileBrowserApp/useFileBrowserApp.ts | 4 +- .../apps/FileViewer/FileViewerProvider.tsx | 12 +-- src/workspaces/officerdev/src/channels.ts | 66 ++++++++++++++ .../src/components/Workspace/PanelSlot.tsx | 6 +- .../src/components/Workspace/types.ts | 13 ++- .../hooks/useFileViewerPanels/Providers.tsx | 8 +- src/workspaces/officerdev/src/index.ts | 1 + 14 files changed, 131 insertions(+), 120 deletions(-) delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts create mode 100644 src/workspaces/officerdev/src/channels.ts 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 (