import { useEffect, useMemo, useRef } from 'react'; import { useParams, useNavigate } from 'react-router'; import type { LayoutNode, SelectedSession } from 'officerdev'; import { WorkspaceView } from 'officerdev'; import { useIsMobile } from 'hooks/useIsMobile'; import { useClient } from 'hooks/useClient'; import { useDashboardState } from 'state/useDashboardState'; import type { ClaudeSessionDetail } from 'state/useClaudeSessions'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { defaultLayout } from './defaultLayout'; // Allowed panel app types for the /chat screen const ALLOWED_APP_TYPES = new Set(['chat-session-list', 'chat-detail', null]); // How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in. const CHAT_TAIL = 20; /** Recursively fix any panel that uses a wrong app type (e.g. officerdev/chat) */ function normalizeLayout(node: LayoutNode): LayoutNode { if (node.type === 'panel') { if (!ALLOWED_APP_TYPES.has(node.appType)) { return { ...node, appType: 'chat-detail' }; } return node; } const children = node.children.map((c) => { const fixed = normalizeLayout(c.node); return fixed === c.node ? c : { ...c, node: fixed }; }); const changed = children.some((c, i) => c !== node.children[i]); return changed ? { ...node, children } : node; } type SessionListPageProps = { isNew?: boolean; }; export const SessionListPage = ({ isNew }: SessionListPageProps) => { const { sessionId } = useParams<{ sessionId: string }>(); const [selected, setSelected] = usePanelChannel('chat:selected-session', null); const [, setActiveCwd] = usePanelChannel('chat:active-cwd', null); const client = useClient(); const selectedRef = useRef(selected); selectedRef.current = selected; const rawWorkspace = useDashboardState('screens/chat', defaultLayout); const isMobile = useIsMobile(); const navigate = useNavigate(); const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined; // Normalize synchronously so the wrong panel never renders const workspace = useMemo(() => { const fixed = normalizeLayout(rawWorkspace.value); if (fixed === rawWorkspace.value) return rawWorkspace; return { ...rawWorkspace, value: fixed }; }, [rawWorkspace]); // Persist the fix to the backend useEffect(() => { if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { rawWorkspace.setValue(workspace.value); } }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); // Fresh /chat/ (deep-link or refresh): all we have is the id. Resolve the session by id — the // backend scans project groups — so the cwd picker lands on its real dir AND the chat resumes, exactly // as clicking it from the list would. Guarded so it never clobbers an already-loaded selection. // Transcripts get extremely long, so we load only the tail window (?limit) and anchor to the bottom; // scrolling up lazy-loads older messages. `total`/`offset` tell the chat where the window sits. useEffect(() => { if (isNew) { setSelected({ id: `new:${Date.now()}` }); return; } if (!sessionId) return; if (selectedRef.current?.id === sessionId && selectedRef.current.resumeSessionId) return; let cancelled = false; (async () => { try { const detail = await client.get(`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`); if (cancelled) return; setActiveCwd(detail.cwd || null); setSelected({ id: sessionId, model: detail.model, resumeSessionId: sessionId, initialMessages: detail.messages as unknown as NonNullable['initialMessages'], total: detail.total, initialOffset: detail.offset, }); } catch { if (!cancelled) setSelected({ id: sessionId }); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, isNew]); return (
{ if (!id) navigate('/chat', { replace: true }); }} />
); };