diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index e55cf7b2..ea7897c6 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -1,9 +1,11 @@ -import { useEffect, useMemo } from 'react'; +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'; @@ -32,7 +34,11 @@ type SessionListPageProps = { export const SessionListPage = ({ isNew }: SessionListPageProps) => { const { sessionId } = useParams<{ sessionId: string }>(); - const [, setSelected] = usePanelChannel('chat:selected-session', null); + 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(); @@ -52,13 +58,36 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { } }, [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. useEffect(() => { if (isNew) { setSelected({ id: `new:${Date.now()}` }); return; } if (!sessionId) return; - setSelected({ id: sessionId }); + if (selectedRef.current?.id === sessionId && selectedRef.current.resumeSessionId) return; + let cancelled = false; + (async () => { + try { + const detail = await client.get(`/chat/sessions/${sessionId}`); + if (cancelled) return; + setActiveCwd(detail.cwd || null); + setSelected({ + id: sessionId, + model: detail.model, + resumeSessionId: sessionId, + initialMessages: detail.messages as unknown as NonNullable['initialMessages'], + }); + } catch { + if (!cancelled) setSelected({ id: sessionId }); + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, isNew]); return ( diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index a04d5575..75f2877a 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -6,6 +6,7 @@ import { listClaudePwds, listClaudeSessions, loadClaudeSession, + loadClaudeSessionById, deleteClaudeSession, renameClaudeSession, } from './claude-sessions'; @@ -50,7 +51,11 @@ chatRouter.get('/sessions/:id', async (ctx) => { const email = ctx.get('user').email; const id = ctx.req.param('id'); const cwd = cwdOf(ctx, email); - const detail = isOpenCodeSessionId(id) ? await loadOpenCodeSession(id) : loadClaudeSession(email, cwd, id); + // Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/ deep-link/refresh + // doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI. + const detail = isOpenCodeSessionId(id) + ? await loadOpenCodeSession(id) + : (loadClaudeSession(email, cwd, id) ?? loadClaudeSessionById(email, id)); if (!detail) return ctx.text('Not found', 404); return ctx.json(detail); }); diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 8b9a9747..940a9e35 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -163,14 +163,13 @@ function blockText(content: unknown): string { export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] }; /** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */ -export function loadClaudeSession(email: string, cwd: string, sessionId: string): ClaudeSessionDetail | null { - const filePath = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`); +function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd = ''): ClaudeSessionDetail | null { if (!existsSync(filePath)) return null; const messages: ClaudeChatMessage[] = []; const toolById = new Map>(); let model = ''; - let sessionCwd = cwd; + let sessionCwd = fallbackCwd; for (const line of readFileSync(filePath, 'utf-8').split('\n')) { if (!line.trim()) continue; @@ -227,6 +226,29 @@ export function loadClaudeSession(email: string, cwd: string, sessionId: string) return { id: sessionId, model, cwd: sessionCwd, messages }; } +/** Load a session when its cwd (project group) is known. */ +export function loadClaudeSession(email: string, cwd: string, sessionId: string): ClaudeSessionDetail | null { + return parseClaudeTranscript(join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`), sessionId, cwd); +} + +/** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link / + * refresh to /chat/, when the cwd isn't known yet; the transcript records the real cwd, which the + * caller uses to scope the list + cwd picker. */ +export function loadClaudeSessionById(email: string, sessionId: string): ClaudeSessionDetail | null { + const projectsDir = claudeProjectsDir(email); + let slugs: string[]; + try { + slugs = readdirSync(projectsDir); + } catch { + return null; + } + for (const slug of slugs) { + const filePath = join(projectsDir, slug, `${sessionId}.jsonl`); + if (existsSync(filePath)) return parseClaudeTranscript(filePath, sessionId); + } + return null; +} + /** Delete a session by removing its transcript file. Returns false if it didn't exist. */ export function deleteClaudeSession(email: string, cwd: string, sessionId: string): boolean { const filePath = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index bb9074f8..3c44cbe9 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -1,10 +1,9 @@ import { useRef, useState, useCallback } from 'react'; -import { useNavigate } from 'react-router'; -import { Plus, MessageSquare, RefreshCw, Loader2, Trash2, Pencil, Check, X } from 'lucide-react'; +import { Link, useNavigate } from 'react-router'; +import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X } from 'lucide-react'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useClaudeSessions } from 'state/useClaudeSessions'; import type { SelectedSession } from './ChatDetailPanel'; -import type { ChatMessage } from '../Chat/types'; import { PwdSelector } from './PwdSelector'; // Reads the /chat conversation list from Claude's own transcript store (source of truth). @@ -13,9 +12,8 @@ export const SessionList = () => { const navigate = useNavigate(); // The working directory the list operates on (null = the default general_chat_sessions dir). const [activeCwd, setActiveCwd] = usePanelChannel('chat:active-cwd', null); - const { sessions, isLoading, refetch, loadSession, deleteSession, renameSession } = useClaudeSessions(activeCwd); + const { sessions, isLoading, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd); const [selected, setSelected] = usePanelChannel('chat:selected-session', null); - const [openingId, setOpeningId] = useState(null); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(''); const [confirmingId, setConfirmingId] = useState(null); @@ -28,17 +26,6 @@ export const SessionList = () => { } }, []); - const handleSelect = async (id: string) => { - if (openingId || editingId) return; - setOpeningId(id); - try { - const detail = await loadSession(id); - setSelected({ id, model: detail.model, resumeSessionId: id, initialMessages: detail.messages as ChatMessage[] }); - } finally { - setOpeningId(null); - } - }; - const startRename = (id: string, current: string) => { setConfirmingId(null); setEditingId(id); @@ -144,16 +131,11 @@ export const SessionList = () => { ) : ( <> - + {isConfirming ? (