History

Move approval derivation and thread visit tracking into core session log

- Moved pending-approval extraction to `session-logic` and reused it in `Sidebar` status rendering - Added `lastVisitedAt` threading persistence through schema, types, hydrate/serialize paths, and tests - Updated sidebar thread pills for completed/approval states and pulsing working status - Marked active thread visits and completion visibility timestamps on selection and `turn/completed` - Co-authored-by: codex <codex@users.noreply.github.com>

Julius Marminge committed 9 months ago

Commit 1b7abe51 parent 1dca7019 files · +267 −75Browse files

Unified · Split

9 files
  1. apps/web/src/components/ChatView.tsx +1 −62
  2. apps/web/src/components/Sidebar.tsx +78 −11
  3. apps/web/src/persistenceSchema.test.ts +4 −0
  4. apps/web/src/persistenceSchema.ts +3 −0
  5. apps/web/src/session-logic.test.ts +68 −0
  6. apps/web/src/session-logic.ts +53 −0
  7. apps/web/src/store.test.ts +44 −0
  8. apps/web/src/store.ts +15 −2
  9. apps/web/src/types.ts +1 −0
apps/web/src/components/ChatView.tsx+1−62
@@ -4,7 +4,6 @@ import { · 3 unmodified lines
44 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
55 type ProviderSendTurnAttachmentInput,
66 type ProviderApprovalDecision,
7− type ProviderEvent,
87 } from "@t3tools/contracts";
98 import {
109 type ClipboardEvent,
@@ -30,6 +29,7 @@ import { · 19 unmodified lines
3029 resolveModelSlug,
3130 } from "../model-logic";
3231 import {
32+ derivePendingApprovals,
3333 derivePhase,
3434 deriveTimelineEntries,
3535 deriveWorkLogEntries,
@@ -74,13 +74,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { · 38 unmodified lines
7474 return "text-muted-foreground/40";
7575 }
7676
77−interface PendingApprovalCard {
78− requestId: string;
79− requestKind: "command" | "file-change";
80− createdAt: string;
81− detail?: string;
82−}
83−
8477 type SessionContinuityState = "resumed" | "new" | "fallback_new";
8578
8679 interface EnsuredSessionInfo {
@@ -99,57 +92,6 @@ interface ExpandedImagePreview { · 12 unmodified lines
9992 name: string;
10093 }
10194
102−function asRecord(value: unknown): Record<string, unknown> | undefined {
103− if (!value || typeof value !== "object") return undefined;
104− return value as Record<string, unknown>;
105−}
106−
107−function asString(value: unknown): string | undefined {
108− return typeof value === "string" ? value : undefined;
109−}
110−
111−function approvalDetail(event: ProviderEvent): string | undefined {
112− const payload = asRecord(event.payload);
113− const command = asString(payload?.command);
114− if (command) return command;
115− return asString(payload?.reason);
116−}
117−
118−function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[] {
119− const pending = new Map<string, PendingApprovalCard>();
120− const ordered = [...events].toReversed();
121−
122− for (const event of ordered) {
123− if (event.method === "session/closed" || event.method === "session/exited") {
124− pending.clear();
125− continue;
126− }
127−
128− const requestId = event.requestId ?? asString(asRecord(event.payload)?.requestId);
129− if (!requestId) continue;
130−
131− if (
132− event.kind === "request" &&
133− (event.requestKind === "command" || event.requestKind === "file-change")
134− ) {
135− const detail = approvalDetail(event);
136− pending.set(requestId, {
137− requestId,
138− requestKind: event.requestKind,
139− createdAt: event.createdAt,
140− ...(detail ? { detail } : {}),
141− });
142− continue;
143− }
144−
145− if (event.method === "item/requestApproval/decision") {
146− pending.delete(requestId);
147− }
148− }
149−
150− return Array.from(pending.values());
151−}
152−
15395 function readFileAsDataUrl(file: File): Promise<string> {
15496 return new Promise((resolve, reject) => {
15597 const reader = new FileReader();
@@ -305,9 +247,6 @@ export default function ChatView() { · 149 unmodified lines
305247 (activeThread.messages.length > 0 ||
306248 (activeThread.session !== null && activeThread.session.status !== "closed")),
307249 );
308− const terminalShortcutHint = navigator.platform.includes("Mac")
309− ? "\u2318J"
310− : "Ctrl+J";
311250 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
312251 for (const image of images) {
313252 if (!image.previewUrl) continue;
apps/web/src/components/Sidebar.tsx+78−11
@@ -1,10 +1,11 @@
11 import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
2−import { useCallback, useEffect, useState } from "react";
2+import { useCallback, useEffect, useMemo, useState } from "react";
33 import { isElectron } from "../env";
44 import { useTheme } from "../hooks/useTheme";
55 import { DEFAULT_MODEL } from "../model-logic";
6+import { derivePendingApprovals } from "../session-logic";
67 import { useStore } from "../store";
7−import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types";
8+import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project, type Thread } from "../types";
89 import { useNativeApi } from "../hooks/useNativeApi";
910
1011 const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
@@ -25,11 +26,61 @@ function inferProjectName(cwd: string): string { · 14 unmodified lines
2526 return last && last.length > 0 ? last : "project";
2627 }
2728
28−function threadStatusLabel(
29− status: "connecting" | "ready" | "running" | "error" | "closed" | undefined,
30−): "Working" | "Connecting" | null {
31− if (status === "running") return "Working";
32− if (status === "connecting") return "Connecting";
29+interface ThreadStatusPill {
30+ label: "Working" | "Connecting" | "Completed" | "Awaiting response";
31+ colorClass: string;
32+ dotClass: string;
33+ pulse: boolean;
34+}
35+
36+function hasUnseenCompletion(thread: Thread): boolean {
37+ if (!thread.latestTurnCompletedAt) return false;
38+ const completedAt = Date.parse(thread.latestTurnCompletedAt);
39+ if (Number.isNaN(completedAt)) return false;
40+ if (!thread.lastVisitedAt) return true;
41+
42+ const lastVisitedAt = Date.parse(thread.lastVisitedAt);
43+ if (Number.isNaN(lastVisitedAt)) return true;
44+ return completedAt > lastVisitedAt;
45+}
46+
47+function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null {
48+ if (hasPendingApprovals) {
49+ return {
50+ label: "Awaiting response",
51+ colorClass: "text-amber-600 dark:text-amber-300/90",
52+ dotClass: "bg-amber-500 dark:bg-amber-300/90",
53+ pulse: false,
54+ };
55+ }
56+
57+ if (thread.session?.status === "running") {
58+ return {
59+ label: "Working",
60+ colorClass: "text-sky-600 dark:text-sky-300/80",
61+ dotClass: "bg-sky-500 dark:bg-sky-300/80",
62+ pulse: true,
63+ };
64+ }
65+
66+ if (thread.session?.status === "connecting") {
67+ return {
68+ label: "Connecting",
69+ colorClass: "text-sky-600 dark:text-sky-300/80",
70+ dotClass: "bg-sky-500 dark:bg-sky-300/80",
71+ pulse: true,
72+ };
73+ }
74+
75+ if (hasUnseenCompletion(thread)) {
76+ return {
77+ label: "Completed",
78+ colorClass: "text-emerald-600 dark:text-emerald-300/90",
79+ dotClass: "bg-emerald-500 dark:bg-emerald-300/90",
80+ pulse: false,
81+ };
82+ }
83+
3384 return null;
3485 }
3586
@@ -41,6 +92,13 @@ export default function Sidebar() { · 5 unmodified lines
4192 const [newCwd, setNewCwd] = useState("");
4293 const [isPickingFolder, setIsPickingFolder] = useState(false);
4394 const [isAddingProject, setIsAddingProject] = useState(false);
95+ const pendingApprovalByThreadId = useMemo(() => {
96+ const map = new Map<string, boolean>();
97+ for (const thread of state.threads) {
98+ map.set(thread.id, derivePendingApprovals(thread.events).length > 0);
99+ }
100+ return map;
101+ }, [state.threads]);
44102
45103 const handleNewThread = useCallback(
46104 (projectId: string) => {
@@ -312,7 +370,10 @@ export default function Sidebar() { · 265 unmodified lines
312370 <div className="ml-2 border-l border-border/80 pl-2">
313371 {threads.map((thread) => {
314372 const isActive = state.activeThreadId === thread.id;
315− const threadStatus = threadStatusLabel(thread.session?.status);
373+ const threadStatus = threadStatusPill(
374+ thread,
375+ pendingApprovalByThreadId.get(thread.id) === true,
376+ );
316377 return (
317378 <button
318379 key={thread.id}
@@ -338,9 +399,15 @@ export default function Sidebar() { · 19 unmodified lines
338399 >
339400 <div className="flex min-w-0 flex-1 items-center gap-1.5">
340401 {threadStatus && (
341− <span className="inline-flex items-center gap-1 text-[10px] text-sky-600 dark:text-sky-300/80">
342− <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-500 dark:bg-sky-300/80" />
343− <span className="hidden xl:inline">{threadStatus}</span>
402+ <span
403+ className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`}
404+ >
405+ <span
406+ className={`h-1.5 w-1.5 rounded-full ${threadStatus.dotClass} ${
407+ threadStatus.pulse ? "animate-pulse" : ""
408+ }`}
409+ />
410+ <span className="hidden xl:inline">{threadStatus.label}</span>
344411 </span>
345412 )}
346413 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
apps/web/src/persistenceSchema.test.ts+4−0
@@ -141,6 +141,7 @@ describe("hydratePersistedState", () => { · 140 unmodified lines
141141 terminalHeight: 360,
142142 messages: [],
143143 createdAt: "2026-02-08T10:00:00.000Z",
144+ lastVisitedAt: "2026-02-08T10:01:00.000Z",
144145 },
145146 ],
146147 activeThreadId: "t-1",
@@ -149,6 +150,7 @@ describe("hydratePersistedState", () => { · 2 unmodified lines
149150 const hydrated = hydratePersistedState(payload, false);
150151 expect(hydrated?.threads[0]?.terminalOpen).toBe(true);
151152 expect(hydrated?.threads[0]?.terminalHeight).toBe(360);
153+ expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z");
152154 });
153155
154156 it("defaults terminalHeight when hydrating v5 payloads", () => {
@@ -217,6 +219,7 @@ describe("toPersistedState", () => { · 62 unmodified lines
217219 events: [],
218220 error: "boom",
219221 createdAt: "2026-02-08T10:00:00.000Z",
222+ lastVisitedAt: "2026-02-08T10:02:00.000Z",
220223 branch: null,
221224 worktreePath: null,
222225 };
@@ -265,6 +268,7 @@ describe("toPersistedState", () => { · 42 unmodified lines
265268 },
266269 ],
267270 createdAt: thread.createdAt,
271+ lastVisitedAt: thread.lastVisitedAt,
268272 branch: null,
269273 worktreePath: null,
270274 });
apps/web/src/persistenceSchema.ts+3−0
@@ -50,6 +50,7 @@ const persistedThreadSchema = z.object({ · 49 unmodified lines
5050 ),
5151 messages: z.array(persistedMessageSchema),
5252 createdAt: z.string().min(1),
53+ lastVisitedAt: z.string().min(1).optional(),
5354 branch: z.string().min(1).nullable().optional(),
5455 worktreePath: z.string().min(1).nullable().optional(),
5556 });
@@ -153,6 +154,7 @@ function hydrateThread( · 97 unmodified lines
153154 events: [],
154155 error: null,
155156 createdAt: thread.createdAt,
157+ lastVisitedAt: thread.lastVisitedAt,
156158 branch: thread.branch ?? null,
157159 worktreePath: thread.worktreePath ?? null,
158160 };
@@ -228,6 +230,7 @@ export function toPersistedState( · 69 unmodified lines
228230 streaming: message.streaming,
229231 })),
230232 createdAt: thread.createdAt,
233+ ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}),
231234 branch: thread.branch,
232235 worktreePath: thread.worktreePath,
233236 })),
apps/web/src/session-logic.test.ts+68−0
@@ -4,6 +4,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; · 3 unmodified lines
44 import {
55 type WorkLogEntry,
66 applyEventToMessages,
7+ derivePendingApprovals,
78 deriveTimelineEntries,
89 deriveWorkLogEntries,
910 evolveSession,
@@ -392,6 +393,73 @@ describe("deriveWorkLogEntries", () => { · 382 unmodified lines
392393 });
393394 });
394395
396+describe("derivePendingApprovals", () => {
397+ it("returns pending command/file-change approvals", () => {
398+ const approvals = derivePendingApprovals([
399+ makeEvent({
400+ id: "evt-request",
401+ kind: "request",
402+ method: "item/commandExecution/requestApproval",
403+ requestId: "req-1",
404+ requestKind: "command",
405+ createdAt: "2026-02-08T10:00:00.000Z",
406+ payload: { command: "git commit -m 'msg'" },
407+ }),
408+ ]);
409+
410+ expect(approvals).toEqual([
411+ {
412+ requestId: "req-1",
413+ requestKind: "command",
414+ createdAt: "2026-02-08T10:00:00.000Z",
415+ detail: "git commit -m 'msg'",
416+ },
417+ ]);
418+ });
419+
420+ it("removes approvals after a decision event", () => {
421+ const approvals = derivePendingApprovals([
422+ makeEvent({
423+ id: "evt-decision",
424+ method: "item/requestApproval/decision",
425+ requestId: "req-1",
426+ createdAt: "2026-02-08T10:00:01.000Z",
427+ }),
428+ makeEvent({
429+ id: "evt-request",
430+ kind: "request",
431+ method: "item/fileChange/requestApproval",
432+ requestId: "req-1",
433+ requestKind: "file-change",
434+ createdAt: "2026-02-08T10:00:00.000Z",
435+ }),
436+ ]);
437+
438+ expect(approvals).toHaveLength(0);
439+ });
440+
441+ it("clears pending approvals after turn completion", () => {
442+ const approvals = derivePendingApprovals([
443+ makeEvent({
444+ id: "evt-turn-complete",
445+ method: "turn/completed",
446+ createdAt: "2026-02-08T10:00:01.000Z",
447+ payload: { turn: { id: "turn-1", status: "completed" } },
448+ }),
449+ makeEvent({
450+ id: "evt-request",
451+ kind: "request",
452+ method: "item/commandExecution/requestApproval",
453+ requestId: "req-1",
454+ requestKind: "command",
455+ createdAt: "2026-02-08T10:00:00.000Z",
456+ }),
457+ ]);
458+
459+ expect(approvals).toHaveLength(0);
460+ });
461+});
462+
395463 describe("evolveSession", () => {
396464 it("updates thread id when thread starts", () => {
397465 const previous = makeSession();
apps/web/src/session-logic.ts+53−0
@@ -78,6 +78,13 @@ export interface WorkLogEntry { · 77 unmodified lines
7878 tone: "thinking" | "tool" | "info" | "error";
7979 }
8080
81+export interface PendingApproval {
82+ requestId: string;
83+ requestKind: "command" | "file-change";
84+ createdAt: string;
85+ detail?: string;
86+}
87+
8188 export type TimelineEntry =
8289 | {
8390 id: string;
@@ -98,6 +105,52 @@ function normalizeDetail(value: string | undefined): string | undefined { · 14 unmodified lines
98105 return trimmed.length > 0 ? trimmed : undefined;
99106 }
100107
108+function approvalDetail(event: ProviderEvent): string | undefined {
109+ const payload = asObject(event.payload);
110+ const command = asString(payload?.command);
111+ if (command) return command;
112+ return asString(payload?.reason);
113+}
114+
115+export function derivePendingApprovals(events: ProviderEvent[]): PendingApproval[] {
116+ const pending = new Map<string, PendingApproval>();
117+ const ordered = [...events].toReversed();
118+
119+ for (const event of ordered) {
120+ if (
121+ event.method === "session/closed" ||
122+ event.method === "session/exited" ||
123+ event.method === "turn/completed"
124+ ) {
125+ pending.clear();
126+ continue;
127+ }
128+
129+ const requestId = event.requestId ?? asString(asObject(event.payload)?.requestId);
130+ if (!requestId) continue;
131+
132+ if (
133+ event.kind === "request" &&
134+ (event.requestKind === "command" || event.requestKind === "file-change")
135+ ) {
136+ const detail = approvalDetail(event);
137+ pending.set(requestId, {
138+ requestId,
139+ requestKind: event.requestKind,
140+ createdAt: event.createdAt,
141+ ...(detail ? { detail } : {}),
142+ });
143+ continue;
144+ }
145+
146+ if (event.method === "item/requestApproval/decision") {
147+ pending.delete(requestId);
148+ }
149+ }
150+
151+ return Array.from(pending.values());
152+}
153+
101154 function normalizeItemType(raw: string | undefined): string {
102155 if (!raw) return "item";
103156 return raw
apps/web/src/store.test.ts+44−0
@@ -219,4 +219,48 @@ describe("store reducer thread continuity", () => { · 218 unmodified lines
219219 expect(next.threads[0]?.projectId).toBe("project-new-a");
220220 expect(next.activeThreadId).toBe("thread-a");
221221 });
222+
223+ it("marks the active thread as visited when selected", () => {
224+ const state = makeState(
225+ makeThread({
226+ lastVisitedAt: "2000-01-01T00:00:00.000Z",
227+ }),
228+ );
229+
230+ const next = reducer(state, {
231+ type: "SET_ACTIVE_THREAD",
232+ threadId: "thread-local-1",
233+ });
234+
235+ expect(next.activeThreadId).toBe("thread-local-1");
236+ expect(next.threads[0]?.lastVisitedAt).toBeDefined();
237+ expect(next.threads[0]?.lastVisitedAt).not.toBe("2000-01-01T00:00:00.000Z");
238+ });
239+
240+ it("marks completion as seen immediately for the active thread", () => {
241+ const state = makeState(
242+ makeThread({
243+ session: makeSession({
244+ status: "running",
245+ activeTurnId: "turn-1",
246+ }),
247+ lastVisitedAt: "2026-02-08T10:00:00.000Z",
248+ }),
249+ );
250+
251+ const completedAt = "2026-02-08T10:00:10.000Z";
252+ const next = reducer(state, {
253+ type: "APPLY_EVENT",
254+ event: makeEvent({
255+ method: "turn/completed",
256+ turnId: "turn-1",
257+ createdAt: completedAt,
258+ payload: { turn: { id: "turn-1", status: "completed" } },
259+ }),
260+ activeAssistantItemRef: { current: null },
261+ });
262+
263+ expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt);
264+ expect(next.threads[0]?.lastVisitedAt).toBe(completedAt);
265+ });
222266 });
apps/web/src/store.ts+15−2
@@ -293,13 +293,23 @@ export function reducer(state: AppState, action: Action): AppState { · 292 unmodified lines
293293 {
294294 ...action.thread,
295295 model: resolveModelSlug(action.thread.model),
296+ lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt,
296297 },
297298 ],
298299 activeThreadId: action.thread.id,
299300 };
300301
301− case "SET_ACTIVE_THREAD":
302− return { ...state, activeThreadId: action.threadId };
302+ case "SET_ACTIVE_THREAD": {
303+ const visitedAt = new Date().toISOString();
304+ return {
305+ ...state,
306+ activeThreadId: action.threadId,
307+ threads: updateThread(state.threads, action.threadId, (t) => ({
308+ ...t,
309+ lastVisitedAt: visitedAt,
310+ })),
311+ };
312+ }
303313
304314 case "TOGGLE_THREAD_TERMINAL":
305315 return {
@@ -356,6 +366,9 @@ export function reducer(state: AppState, action: Action): AppState { · 50 unmodified lines
356366 messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),
357367 events: [event, ...t.events],
358368 ...updateTurnFields(t, event),
369+ ...(event.method === "turn/completed" && t.id === state.activeThreadId
370+ ? { lastVisitedAt: event.createdAt }
371+ : {}),
359372 })),
360373 };
361374 }
apps/web/src/types.ts+1−0
@@ -50,6 +50,7 @@ export interface Thread { · 49 unmodified lines
5050 latestTurnStartedAt?: string | undefined;
5151 latestTurnCompletedAt?: string | undefined;
5252 latestTurnDurationMs?: number | undefined;
53+ lastVisitedAt?: string | undefined;
5354 branch: string | null;
5455 worktreePath: string | null;
5556 }

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t