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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user