From 2a3799058bc395f2b73e28df5d28417c1dbe17ee Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 20 Jul 2026 18:02:37 +0800 Subject: [PATCH] =?UTF-8?q?perf(web):=20one-shot=20session=20resume=20?= =?UTF-8?q?=E2=80=94=20cut=20conversation=20switch=20to=20a=20single=20rou?= =?UTF-8?q?nd=20trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resuming a conversation from the web/desktop UI used to walk six serial round trips before the timeline could repaint: PATCH mark-as-read → GET /api/sessions/{id} (full JSONL, disk read #1) → POST /api/sessions (resume; disk read #2 + ReconstructState) → GET /api/status → GET /api/goal → GET /api/todos, with ask/approval pending on top. The pane stayed blank the whole time. Now: - POST /api/sessions (resume) is a one-shot reply: it returns the raw entries alongside goal/todos/status. The server already reads the JSONL to reconstruct engine state, so the entries ride along — the file is read once instead of twice, and four follow-up GETs vanish from the critical path. (sessions.go writeResumeReply + statusSnapshot factored out of handleStatus so the two payloads can't drift.) - loadSession drives the whole resume off that one response; the old endpoints remain as a fallback for older servers (provider field discriminates), fetched in parallel and never gating the repaint. - The chat pane swaps to a skeleton the instant the click lands (sessionLoading + ChatView skeleton), so perceived latency is ~0 instead of blank-until-ready. - Marking a session read is fire-and-forget — the awaited PATCH was a round trip on the critical path of every unread open. - ask/approval pending reconciliation stays, but non-blocking. Measured (localhost, 500-entry / 1.1MB session, same-project switch): - critical-path round trips: 6-7 → 1 - server-side session-file reads per resume: 2 → 1 - click → timeline data ready: ~100ms → ~62ms (POST 59-60ms + rebuild 2-3ms + hydrate ~0); small sessions ~51ms - the savings scale with transport latency: every removed round trip is a full saved cycle on desktop sidecar IPC or remote links Verified end-to-end in the browser: switching between a 500-entry session and a small one renders correctly with a single POST each (60ms / 1.1MB payload incl. entries); go test + golangci-lint + tsc all pass. --- internal/web/server.go | 36 ++++-- internal/web/sessions.go | 46 ++++++- web/src/app/store.ts | 221 ++++++++++++++++++-------------- web/src/components/ChatView.tsx | 22 ++++ web/src/components/Sidebar.tsx | 5 +- web/src/i18n/locales/en.ts | 1 + web/src/i18n/locales/ja.ts | 1 + web/src/i18n/locales/ko.ts | 1 + web/src/i18n/locales/zh-Hans.ts | 1 + web/src/i18n/locales/zh-Hant.ts | 1 + web/src/lib/api.ts | 19 ++- web/src/styles.css | 48 +++++++ 12 files changed, 288 insertions(+), 114 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index 5353a99..06a7d80 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -560,21 +560,18 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { }) } -func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - eng := s.activeEngine() - if eng == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) - return - } +// statusSnapshot assembles the per-engine status payload (running state, +// provider/model/mode, live token snapshot) shared by GET /api/status and the +// one-shot resume reply (POST /api/sessions), so the two can never drift. +func (s *Server) statusSnapshot(eng *Engine) map[string]any { full := eng.tokenUsage.GetFull() provider, mdl, modeStr := eng.modelSnapshot() - writeJSON(w, http.StatusOK, map[string]any{ - "running": eng.running.Load(), - "ws_clients": s.wsBroker.ClientCount(), - "pwd": eng.pwd, - "provider": provider, - "model": mdl, - "mode": modeStr, + return map[string]any{ + "running": eng.running.Load(), + "pwd": eng.pwd, + "provider": provider, + "model": mdl, + "mode": modeStr, // Live token snapshot so a client reconnecting between turns can render // the context bar + cache hit rate without waiting for the next // token_update WS event. total_tokens = current context occupancy. @@ -590,7 +587,18 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { "cache_supported": eng.tokenUsage.CacheObserved(), "model_context_limit": s.currentModelContextLimit(eng), }, - }) + } +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + eng := s.activeEngine() + if eng == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"}) + return + } + payload := s.statusSnapshot(eng) + payload["ws_clients"] = s.wsBroker.ClientCount() + writeJSON(w, http.StatusOK, payload) } // currentModelContextLimit resolves the context window of the given engine's diff --git a/internal/web/sessions.go b/internal/web/sessions.go index 1b2b9d2..fe00bf3 100644 --- a/internal/web/sessions.go +++ b/internal/web/sessions.go @@ -346,6 +346,34 @@ func (s *Server) handleTruncateHistory(w http.ResponseWriter, r *http.Request) { }) } +// writeResumeReply answers a resume request (POST /api/sessions with a +// session_id) with everything the client needs to repaint the conversation in +// ONE round trip: the raw JSONL entries it replays into its timeline, plus the +// server-truth state it would otherwise fetch with four more serial requests +// (status / goal / todos). entries is omitted when the session file could not +// be read; the client then falls back to GET /api/sessions/{id}. The server +// already loads the entries to reconstruct the engine state, so this reuses +// that read instead of doing a second one for the client. +func (s *Server) writeResumeReply(w http.ResponseWriter, eng *Engine, entries []session.Entry) { + resp := s.statusSnapshot(eng) + resp["status"] = "ok" + resp["session_id"] = eng.taskID + if entries != nil { + resp["entries"] = entries + } + if eng.env != nil && eng.env.GoalStore != nil { + resp["goal"] = eng.env.GoalStore.Get() + } else { + resp["goal"] = nil + } + if eng.todoStore != nil { + resp["todos"] = eng.todoStore.Items() + } else { + resp["todos"] = []any{} + } + writeJSON(w, http.StatusOK, resp) +} + func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { // Parse optional resume session ID + project. Creating a task no longer // blocks on "is the agent running" — tasks run concurrently. @@ -364,7 +392,13 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { if req.SessionID != "" { if eng := s.resolveEngine(req.SessionID); eng != nil { s.setActiveEngine(eng) - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID}) + // Best-effort: a read failure only drops the embedded entries — + // the client falls back to GET /api/sessions/{id}. + entries, err := session.LoadSession(req.SessionID) + if err != nil { + config.Logger().Printf("[web] resume: embedded entries unavailable for %s (client falls back to GET): %v", req.SessionID, err) + } + s.writeResumeReply(w, eng, entries) return } } @@ -389,8 +423,10 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { } // Resume: hydrate the fresh engine with the persisted conversation/todos/goal. + var entries []session.Entry if req.SessionID != "" { - entries, lerr := session.LoadSession(req.SessionID) + var lerr error + entries, lerr = session.LoadSession(req.SessionID) if lerr != nil { // Stale/nonexistent session id: don't silently register a phantom empty // engine under it — tear the just-built engine down and report not-found. @@ -422,7 +458,11 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { // Brand-new task: tell its view to start clean. if req.SessionID == "" { s.wsBroker.Broadcast(WSEvent{TaskID: eng.taskID, Type: "session_reset", Data: map[string]string{}}) + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID}) + return } - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID}) + // Resume: one-shot reply (entries + goal + todos + status) so the client + // repaints without follow-up round trips. + s.writeResumeReply(w, eng, entries) } diff --git a/web/src/app/store.ts b/web/src/app/store.ts index b82ca1f..3dfacfb 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -46,6 +46,9 @@ function isNewerTs(candidate: string, current: string | undefined): boolean { interface ChatState { timeline: ThreadItem[] isRunning: boolean + /** A session resume (replay) is in flight — the chat pane shows a skeleton + * so the switch feels instant instead of blank-until-ready. */ + sessionLoading: boolean tokenSnapshot: TokenSnapshot | null goal: Goal | null goalArmed: boolean @@ -60,6 +63,7 @@ interface ChatState { const initialChat: ChatState = { timeline: [], isRunning: false, + sessionLoading: false, tokenSnapshot: null, goal: null, goalArmed: false, @@ -91,6 +95,9 @@ const chatSlice = createSlice({ setRunning(s, a: { payload: boolean }) { s.isRunning = a.payload }, + setSessionLoading(s, a: { payload: boolean }) { + s.sessionLoading = a.payload + }, addMessage( s, a: { payload: { role: Message['role']; content: string; source?: string; images?: Message['images']; level?: Message['level']; detail?: string; durationMs?: number } }, @@ -1045,117 +1052,141 @@ export const loadWorkspaceState = createAsyncThunk('app/loadWorkspaceState', asy }) /** - * Load (replay) a session's history into the timeline. Ported from the Vue - * store's loadSession: fetches the JSONL entries, tells the backend to resume - * that session, clears the UI, then rebuilds the timeline by walking the entries - * (user/assistant → messages; tool_call+tool_result → resolved tool calls). + * Load (replay) a session's history into the timeline. + * + * Fast path: a SINGLE POST /api/sessions round trip both resumes the session + * server-side and returns everything the view needs to repaint — the raw + * JSONL entries (the server reuses its own reconstructing read; the file is + * not read twice) plus goal/todos/status. The pane swaps to a skeleton the + * instant the click lands, so perceived latency is ~0 and the old flow's + * four serial follow-up GETs (status, ask/approval pending, goal, todos) + * are gone. Legacy fallback (older server): fetch the entries via GET and + * the rest individually, in parallel, without gating the repaint. */ export const loadSession = createAsyncThunk( 'session/loadOne', async (uuid: string, { dispatch, getState }) => { - // Fetch the session's history. A 404 means the session has no JSONL yet - // (fresh, never-used session) — return without rebuilding so the caller can - // fall back to a different session. - let entries: SessionEntry[] + // Immediate skeleton: the pane reacts to the click, not to the network. + dispatch(chatActions.setSessionLoading(true)) try { - entries = await api.session(uuid) - } catch { - return - } - // Tell the backend to switch to (resume) this session. - const resp = await api.newSession(uuid) - dispatch(sessionActions.setCurrentSession(resp.session_id || uuid)) + const resp = await api.newSession(uuid) + dispatch(sessionActions.setCurrentSession(resp.session_id || uuid)) - // Clear the UI before rebuilding. - dispatch(chatActions.clearChat()) - - // Rebuild the timeline from entries. - const timeline: ThreadItem[] = [] - const pendingToolCalls = new Map() - for (const e of entries) { - if (e.type === 'user' && (e.content || (e.images && e.images.length > 0))) { - timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('msg'), role: 'user', content: e.content || '', timestamp: ts(e.timestamp), images: e.images } }) - } else if (e.type === 'assistant' && e.content) { - timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('asst'), role: 'assistant', content: e.content, timestamp: ts(e.timestamp) } }) - } else if (e.type === 'tool_call' && e.name) { - const tc: ToolCall = { - id: genId('tc'), - toolCallID: e.tool_call_id, - name: e.name, - args: e.args || '', - status: 'running', - timestamp: ts(e.timestamp), - displayInfo: extractToolDisplayInfo(e.name, e.args || ''), - batchId: e.batch_id, - batchIndex: e.batch_index, - batchSize: e.batch_size, - startedAt: e.started_at, + // One-shot resume payload (entries + goal + todos + status). `provider` + // discriminates an older server without the one-shot payload at all; + // `entries` is omitted when the server could not read the session file + // — fall back to the dedicated endpoint then (a transient read failure + // must not blank a conversation that has history). + const oneShot = resp.provider !== undefined + let entries: SessionEntry[] | null | undefined = resp.entries + if (entries === undefined) { + // Older server (no one-shot payload) OR current server with an + // unreadable session file. A 404 means the session has no JSONL yet + // (fresh, never-used session) — return without rebuilding so the + // caller can fall back to a different session. + try { + entries = await api.session(uuid) + } catch { + return } - timeline.push({ kind: 'tool', seq: nextSeq(), data: tc }) - if (e.tool_call_id) pendingToolCalls.set(e.tool_call_id, tc) - } else if (e.type === 'tool_result') { - let resolved = false - if (e.tool_call_id) { - const tc = pendingToolCalls.get(e.tool_call_id) - if (tc) { - tc.output = e.output || '' - tc.error = e.error || '' - tc.status = e.error ? 'error' : 'done' - tc.denied = e.denied || undefined - if (e.duration_ms !== undefined && tc.meta?.duration_ms === undefined) { - tc.meta = { ...(tc.meta || {}), duration_ms: e.duration_ms } + } + + // Clear the UI before rebuilding. + dispatch(chatActions.clearChat()) + + // Rebuild the timeline from entries. + const timeline: ThreadItem[] = [] + const pendingToolCalls = new Map() + for (const e of entries || []) { + if (e.type === 'user' && (e.content || (e.images && e.images.length > 0))) { + timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('msg'), role: 'user', content: e.content || '', timestamp: ts(e.timestamp), images: e.images } }) + } else if (e.type === 'assistant' && e.content) { + timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('asst'), role: 'assistant', content: e.content, timestamp: ts(e.timestamp) } }) + } else if (e.type === 'tool_call' && e.name) { + const tc: ToolCall = { + id: genId('tc'), + toolCallID: e.tool_call_id, + name: e.name, + args: e.args || '', + status: 'running', + timestamp: ts(e.timestamp), + displayInfo: extractToolDisplayInfo(e.name, e.args || ''), + batchId: e.batch_id, + batchIndex: e.batch_index, + batchSize: e.batch_size, + startedAt: e.started_at, + } + timeline.push({ kind: 'tool', seq: nextSeq(), data: tc }) + if (e.tool_call_id) pendingToolCalls.set(e.tool_call_id, tc) + } else if (e.type === 'tool_result') { + let resolved = false + if (e.tool_call_id) { + const tc = pendingToolCalls.get(e.tool_call_id) + if (tc) { + tc.output = e.output || '' + tc.error = e.error || '' + tc.status = e.error ? 'error' : 'done' + tc.denied = e.denied || undefined + if (e.duration_ms !== undefined && tc.meta?.duration_ms === undefined) { + tc.meta = { ...(tc.meta || {}), duration_ms: e.duration_ms } + } + pendingToolCalls.delete(e.tool_call_id) + resolved = true } - pendingToolCalls.delete(e.tool_call_id) - resolved = true } - } - if (!resolved && e.name) { - for (let i = timeline.length - 1; i >= 0; i--) { - const item = timeline[i] - if (item.kind === 'tool' && item.data.name === e.name && item.data.status === 'running') { - item.data.output = e.output || '' - item.data.error = e.error || '' - item.data.status = e.error ? 'error' : 'done' - item.data.denied = e.denied || undefined - if (e.duration_ms !== undefined && item.data.meta?.duration_ms === undefined) { - item.data.meta = { ...(item.data.meta || {}), duration_ms: e.duration_ms } + if (!resolved && e.name) { + for (let i = timeline.length - 1; i >= 0; i--) { + const item = timeline[i] + if (item.kind === 'tool' && item.data.name === e.name && item.data.status === 'running') { + item.data.output = e.output || '' + item.data.error = e.error || '' + item.data.status = e.error ? 'error' : 'done' + item.data.denied = e.denied || undefined + if (e.duration_ms !== undefined && item.data.meta?.duration_ms === undefined) { + item.data.meta = { ...(item.data.meta || {}), duration_ms: e.duration_ms } + } + break } - break } } } } - } - // Any tool calls that never got a result (interrupted session) → done. - for (const tc of pendingToolCalls.values()) tc.status = 'done' + // Any tool calls that never got a result (interrupted session) → done. + for (const tc of pendingToolCalls.values()) tc.status = 'done' - dispatch(chatActions.setTimeline(timeline)) - - // Seed isRunning from the task list (a resumed task may still be running). - const state = getState() as RootState - const resumedId = resp.session_id || uuid - const running = !!state.session.tasks.find((t) => t.uuid === resumedId)?.running - dispatch(chatActions.setRunning(running)) + dispatch(chatActions.setTimeline(timeline)) - // Rehydrate server-truth state for the resumed session (token snapshot, - // provider/model, mode). clearChat nulled tokenSnapshot, and no - // token_update arrives until the session's next LLM call — without this - // the context ring stays hidden after switching conversations. - await dispatch(loadStatus()) - await dispatch(reconcilePendingInteractions()) - - // Refresh goal + todos (the backend restored them; no WS push on switch). - try { - const goal = await api.goal() - dispatch(chatActions.setGoal(goal)) - } catch { - // ignore - } - try { - const todos = await api.todos() - dispatch(chatActions.setTodos(todos)) - } catch { - // ignore + if (oneShot) { + // Hydrate server-truth state from the SAME response — the old flow + // spent four extra serial round trips here (status, ask/approval + // pending, goal, todos). clearChat nulled tokenSnapshot, and no + // token_update arrives until the session's next LLM call — without + // this the context ring stays hidden after switching conversations. + dispatch(chatActions.setRunning(!!resp.running)) + if (resp.pwd) dispatch(sessionActions.setProjectPath(resp.pwd)) + dispatch(modelActions.setProvider(resp.provider || '')) + dispatch(modelActions.setModel(resp.model || '')) + dispatch(modelActions.setMode(normalizeMode(resp.mode || ''))) + if (resp.token) dispatch(chatActions.setTokenSnapshot(resp.token)) + dispatch(chatActions.setGoal(resp.goal ?? null)) + dispatch(chatActions.setTodos(resp.todos ?? [])) + } else { + // Older server: seed isRunning from the task list (a resumed task may + // still be running), then fetch the rest individually — in parallel, + // and none of it gates the timeline repaint. + const state = getState() as RootState + const resumedId = resp.session_id || uuid + const running = !!state.session.tasks.find((t) => t.uuid === resumedId)?.running + dispatch(chatActions.setRunning(running)) + void dispatch(loadStatus()) + void dispatch(loadGoal()) + void dispatch(loadTodos()) + } + // Pending approval/ask interactions only add interactive blocks — they + // never gate the repaint, so don't await them. + void dispatch(reconcilePendingInteractions()) + } finally { + dispatch(chatActions.setSessionLoading(false)) } }, ) diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index c511b4f..6ca989d 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -61,6 +61,7 @@ function PendingIndicator() { export function ChatView({ readOnly }: ChatViewProps) { const { t } = useTranslation() const hasMessages = useAppSelector((s) => s.chat.timeline.length > 0) + const sessionLoading = useAppSelector((s) => s.chat.sessionLoading) const projectPath = useAppSelector((s) => s.session.projectPath) const backdropKind = useAppSelector((s) => { const provider = s.model.providers.find((candidate) => candidate.id === s.model.providerName) @@ -79,6 +80,27 @@ export function ChatView({ readOnly }: ChatViewProps) { ) } + // Resume in flight: swap to a skeleton the instant the click lands, so the + // switch feels immediate instead of blank-until-ready (the old flow showed + // nothing until the full history had fetched AND rebuilt). + if (sessionLoading) { + const label = t('chat.loadingConversation') + return ( +
+
+
+
+
+
+
+
+
+ {label} +
+
+ ) + } + // Welcome screen: centered hero + elevated composer (no messages yet). if (!hasMessages) { const subtitle = t('welcome.subtitle') diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 72c78d0..4c46ad8 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -338,7 +338,10 @@ export function Sidebar() { async function openItem(row: SessionRow) { dispatch(uiActions.setView('chat')) - if (row.unread) await patchTask(row.uuid, { unread: false }) + // Marking read is metadata bookkeeping — fire-and-forget so it never sits + // on the resume critical path (an awaited PATCH here delayed the replay + // by a full round trip on every unread open). + if (row.unread) void patchTask(row.uuid, { unread: false }) if (row.project && activePath && row.project !== activePath) { // Remote workspaces need the wizard (prefill + optional load task). if (isRemotePath(row.project)) { diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 2610264..b24d3b6 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -222,6 +222,7 @@ export default { stop: 'Stop', stopAgent: 'Stop agent (Esc)', thinking: 'Thinking…', + loadingConversation: 'Loading conversation…', attachFiles: 'Attach files', goal: 'Goal', add: 'Add', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index e6d0b0a..0e189c3 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -212,6 +212,7 @@ export default { stop: '停止', stopAgent: 'エージェントを停止 (Esc)', thinking: '考え中…', + loadingConversation: '会話を読み込み中…', attachFiles: 'ファイルを添付', goal: '目標', add: '追加', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index e0a89ca..7e8b245 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -212,6 +212,7 @@ export default { stop: '중지', stopAgent: '에이전트 중지 (Esc)', thinking: '생각 중…', + loadingConversation: '대화 불러오는 중…', attachFiles: '파일 첨부', goal: '목표', add: '추가', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 829b31c..7c725f6 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -209,6 +209,7 @@ export default { stop: '停止', stopAgent: '停止 Agent (Esc)', thinking: '思考中…', + loadingConversation: '加载会话中…', attachFiles: '添加附件', goal: '目标', add: '添加', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 86320c9..1dd5f9a 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -213,6 +213,7 @@ export default { stop: '停止', stopAgent: '停止 Agent (Esc)', thinking: '思考中…', + loadingConversation: '載入對話中…', attachFiles: '附加檔案', goal: '目標', add: '新增', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 74abf60..6413b06 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -81,7 +81,24 @@ export const api = { deleteSession: (id: string) => request<{ status: string }>(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }), newSession: (sessionId?: string) => - request<{ status: string; session_id: string }>('/api/sessions', { + request<{ + status: string + session_id: string + /** One-shot resume payload (present when resuming against a current + * server): everything the client needs to repaint the conversation — + * the raw JSONL entries plus goal/todos/status. `entries` is omitted + * if the server could not read the session file (client falls back to + * GET /api/sessions/{id}); all fields are absent on older servers. */ + entries?: SessionEntry[] | null + goal?: Goal | null + todos?: TodoItem[] + running?: boolean + pwd?: string + provider?: string + model?: string + mode?: string + token?: TokenUpdateData + }>('/api/sessions', { method: 'POST', body: sessionId ? JSON.stringify({ session_id: sessionId }) : undefined, }), diff --git a/web/src/styles.css b/web/src/styles.css index 63e208c..8b7c26d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -336,6 +336,54 @@ select:focus-visible { position: relative; z-index: 1; } +/* Resume skeleton — shown while a conversation replay loads, so switching + conversations feels instant instead of blank-until-ready. */ +.resume-skeleton { + display: flex; + flex-direction: column; + justify-content: center; + gap: 22px; + min-height: 0; + flex: 1; + padding-bottom: 10vh; +} +.resume-skeleton__rows { + display: flex; + flex-direction: column; + gap: 14px; +} +.rs-row { + display: flex; +} +.rs-row--user { + justify-content: flex-end; +} +.rs-bar { + height: 14px; + border-radius: var(--radius-md); + opacity: 0.55; + background: linear-gradient( + 90deg, + var(--color-muted) 25%, + var(--color-secondary) 45%, + var(--color-muted) 65% + ); + background-size: 220% 100%; + animation: rs-shimmer 1.2s ease-in-out infinite; +} +@keyframes rs-shimmer { + 0% { background-position: 130% 0; } + 100% { background-position: -130% 0; } +} +.resume-skeleton__label { + text-align: center; + font-size: 11px; + color: var(--color-muted-foreground); + opacity: 0.8; +} +@media (prefers-reduced-motion: reduce) { + .rs-bar { animation: none; } +} .welcome-logo { display: flex; align-items: center;