resolve /chat/<id> by scanning project groups and make session rows real links

deep-linking or refreshing /chat/<id> only has the id, so add loadClaudeSessionById
to scan every project group for the transcript and return its real cwd. the session
list page resolves on load from that, setting the cwd picker and resuming. session
rows are now <Link to=/chat/<id>> so clicking updates the url and flows through the
same resolve path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 03:35:47 +00:00
co-authored by Claude Opus 4.8
parent 32749eb665
commit 3682269936
4 changed files with 70 additions and 32 deletions
@@ -1,9 +1,11 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import type { LayoutNode, SelectedSession } from 'officerdev'; import type { LayoutNode, SelectedSession } from 'officerdev';
import { WorkspaceView } from 'officerdev'; import { WorkspaceView } from 'officerdev';
import { useIsMobile } from 'hooks/useIsMobile'; import { useIsMobile } from 'hooks/useIsMobile';
import { useClient } from 'hooks/useClient';
import { useDashboardState } from 'state/useDashboardState'; import { useDashboardState } from 'state/useDashboardState';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { defaultLayout } from './defaultLayout'; import { defaultLayout } from './defaultLayout';
@@ -32,7 +34,11 @@ type SessionListPageProps = {
export const SessionListPage = ({ isNew }: SessionListPageProps) => { export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { sessionId } = useParams<{ sessionId: string }>(); const { sessionId } = useParams<{ sessionId: string }>();
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null); const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [, setActiveCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
const client = useClient();
const selectedRef = useRef(selected);
selectedRef.current = selected;
const rawWorkspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout); const rawWorkspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -52,13 +58,36 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
} }
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
// Fresh /chat/<id> (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(() => { useEffect(() => {
if (isNew) { if (isNew) {
setSelected({ id: `new:${Date.now()}` }); setSelected({ id: `new:${Date.now()}` });
return; return;
} }
if (!sessionId) 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<ClaudeSessionDetail>(`/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<SelectedSession>['initialMessages'],
});
} catch {
if (!cancelled) setSelected({ id: sessionId });
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, isNew]); }, [sessionId, isNew]);
return ( return (
+6 -1
View File
@@ -6,6 +6,7 @@ import {
listClaudePwds, listClaudePwds,
listClaudeSessions, listClaudeSessions,
loadClaudeSession, loadClaudeSession,
loadClaudeSessionById,
deleteClaudeSession, deleteClaudeSession,
renameClaudeSession, renameClaudeSession,
} from './claude-sessions'; } from './claude-sessions';
@@ -50,7 +51,11 @@ chatRouter.get('/sessions/:id', async (ctx) => {
const email = ctx.get('user').email; const email = ctx.get('user').email;
const id = ctx.req.param('id'); const id = ctx.req.param('id');
const cwd = cwdOf(ctx, email); 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/<id> 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); if (!detail) return ctx.text('Not found', 404);
return ctx.json(detail); return ctx.json(detail);
}); });
+25 -3
View File
@@ -163,14 +163,13 @@ function blockText(content: unknown): string {
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] }; 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. */ /** 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 { function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd = ''): ClaudeSessionDetail | null {
const filePath = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
if (!existsSync(filePath)) return null; if (!existsSync(filePath)) return null;
const messages: ClaudeChatMessage[] = []; const messages: ClaudeChatMessage[] = [];
const toolById = new Map<string, Extract<ClaudeChatMessage, { role: 'tool' }>>(); const toolById = new Map<string, Extract<ClaudeChatMessage, { role: 'tool' }>>();
let model = ''; let model = '';
let sessionCwd = cwd; let sessionCwd = fallbackCwd;
for (const line of readFileSync(filePath, 'utf-8').split('\n')) { for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
if (!line.trim()) continue; if (!line.trim()) continue;
@@ -227,6 +226,29 @@ export function loadClaudeSession(email: string, cwd: string, sessionId: string)
return { id: sessionId, model, cwd: sessionCwd, messages }; 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/<id>, 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. */ /** 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 { export function deleteClaudeSession(email: string, cwd: string, sessionId: string): boolean {
const filePath = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`); const filePath = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
@@ -1,10 +1,9 @@
import { useRef, useState, useCallback } from 'react'; import { useRef, useState, useCallback } from 'react';
import { useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { Plus, MessageSquare, RefreshCw, Loader2, Trash2, Pencil, Check, X } from 'lucide-react'; import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { useClaudeSessions } from 'state/useClaudeSessions'; import { useClaudeSessions } from 'state/useClaudeSessions';
import type { SelectedSession } from './ChatDetailPanel'; import type { SelectedSession } from './ChatDetailPanel';
import type { ChatMessage } from '../Chat/types';
import { PwdSelector } from './PwdSelector'; import { PwdSelector } from './PwdSelector';
// Reads the /chat conversation list from Claude's own transcript store (source of truth). // Reads the /chat conversation list from Claude's own transcript store (source of truth).
@@ -13,9 +12,8 @@ export const SessionList = () => {
const navigate = useNavigate(); const navigate = useNavigate();
// The working directory the list operates on (null = the default general_chat_sessions dir). // The working directory the list operates on (null = the default general_chat_sessions dir).
const [activeCwd, setActiveCwd] = usePanelChannel<string | null>('chat:active-cwd', null); const [activeCwd, setActiveCwd] = usePanelChannel<string | null>('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<SelectedSession>('chat:selected-session', null); const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [openingId, setOpeningId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
const [confirmingId, setConfirmingId] = useState<string | null>(null); const [confirmingId, setConfirmingId] = useState<string | null>(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) => { const startRename = (id: string, current: string) => {
setConfirmingId(null); setConfirmingId(null);
setEditingId(id); setEditingId(id);
@@ -144,16 +131,11 @@ export const SessionList = () => {
</div> </div>
) : ( ) : (
<> <>
<button <Link
onClick={() => handleSelect(session.id)} to={`/chat/${session.id}`}
disabled={!!openingId}
className="flex flex-1 items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer" className="flex flex-1 items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
> >
{openingId === session.id ? ( <MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<Loader2 className="h-4 w-4 shrink-0 text-duck-teal/60 animate-spin" />
) : (
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate"> <div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
{session.title} {session.title}
@@ -179,7 +161,7 @@ export const SessionList = () => {
)} )}
</div> </div>
</div> </div>
</button> </Link>
{isConfirming ? ( {isConfirming ? (
<div className="flex shrink-0 items-center gap-1 mr-2"> <div className="flex shrink-0 items-center gap-1 mr-2">