import { useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router'; import type { LayoutNode, SelectedSession } 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 { defaultLayout, hasAppType } from './defaultLayout'; import { useTranslation } from '@/lib/i18n'; // How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in. const CHAT_TAIL = 20; type SessionListPageProps = { isNew?: boolean; }; export const SessionListPage = ({ isNew }: SessionListPageProps) => { const { t } = useTranslation(); // 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] = useSelectedChatSession(); const client = useClient(); const selectedRef = useRef(selected); selectedRef.current = selected; // A layout persisted before the chat panels were renamed still names `officerdev/chat`, which no // longer resolves; `appTypes` lands anything unknown on the detail panel. const workspace = useDashboardState('screens/chat', defaultLayout); const isMobile = useIsMobile(); const navigate = useNavigate(); const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined; /* Adopt a structural change to this screen's layout. `useDashboardState` seeds its default ONLY when the key is absent, so anyone who has ever opened /chat keeps the shape it had then — for good. `appTypes`/`normalizeLayout` does not help: it repairs which app a panel RUNS, never the tree. ── The test is INVERTED, and getting that wrong is destructive ── This used to read `if (hasAppType(value, 'chat-live')) return;` — adopt the layout that HAS the Live panel. Now that the two sidebar panels are merged, `chat-live` is the appType that no longer exists, so the same line would refuse to migrate exactly the layouts that need it, and `normalizeLayout` would collapse the stranded panel onto `appTypes.fallback` and WRITE THAT BACK. That is not hypothetical: it is what made /offscale render three copies of its server picker on 2026-08-17 (`ed3806f9`), and a locked screen has no app picker to recover with. So: replace any layout still naming `chat-live`. Terminates because the replacement does not contain it. Replacing outright is safe *here* specifically because the screen is `locked` — its structure is dictated by code, and the only thing a user can have contributed is the column sizes, which is a cheap thing to lose once. */ useEffect(() => { if (!workspace.isLoaded) return; if (!hasAppType(workspace.value, 'chat-live')) return; workspace.setValue(defaultLayout); }, [workspace.isLoaded, workspace.value, workspace.setValue]); // Retire a legacy `?cwd=`. Nothing reads it any more and nothing writes it, but a refresh re-requests // the address bar verbatim — so one left over from before the path-based groups sits there forever, // looking like it means something. On a bare /chat it still says which group you wanted, so upgrade // it to the path form rather than dropping it; anywhere else the session decides its own directory // and the parameter is simply removed. Other params are left alone. useEffect(() => { const params = new URLSearchParams(window.location.search); const legacyCwd = params.get('cwd'); if (legacyCwd === null) return; params.delete('cwd'); const rest = params.toString(); const canBeUpgraded = !sessionId && !isNew && !groupCwd && legacyCwd.startsWith('/'); const path = canBeUpgraded ? chatListPath(legacyCwd) : window.location.pathname; navigate(`${path}${rest ? `?${rest}` : ''}`, { replace: true }); }, [sessionId, isNew, groupCwd, navigate]); // 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) { // A new chat has no transcript to read a cwd from, so the group in the URL is the authority — // and it has to be on the selection, because that is what the composer runs in. setSelected({ id: `new:${Date.now()}`, cwd: groupCwd }); 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; // The session's own cwd rides on the selection rather than being written back into the URL. // It used to do both, and the URL copy was the one the composer read — so a deep link ran its // first turn in the default dir during the window before this resolve landed. setSelected({ id: sessionId, // `officerModel`, not `detail.model`. The latter is what the API served (`claude-opus-5`), which // is a different id space from what the picker offers (`claude-code/opus[1m]`) — so it matched no // catalogue entry, the selector fell back to whichever model was listed first, and a resumed // conversation came back claiming the wrong one. Invisible for as long as the first entry was // `opus`; obvious the day `fable` went above it. The server resolves this from the session's // stored row, falling back to a family guess. Null means "unknown", and the picker must then // leave the selection alone rather than guess — see ModelSelector. model: detail.officerModel ?? undefined, planMode: detail.planMode, resumeSessionId: sessionId, initialMessages: detail.messages as unknown as NonNullable['initialMessages'], total: detail.total, initialOffset: detail.offset, cwd: detail.cwd, title: detail.title, partCount: detail.partCount, }); } catch (err) { if (cancelled) return; // Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and // no hint that the transcript could not be read, which is indistinguishable from a new session. // Most often the id is stale — the transcript was deleted or pruned out from under the link. toast.error(t('chatHistory.loadFailed', { reason: errorText(err, 'not found') })); setSelected({ id: sessionId }); } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, isNew, groupCwd]); return (
{ // Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL; // on /chat/ it isn't (deliberately — see chat-routes.ts), so fall back to the open // session's own directory, which the resolve above put on the selection. if (!id) navigate(chatListPath(groupCwd ?? selectedRef.current?.cwd ?? null), { replace: true }); }} />
); };