authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 16:49:49-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 18:37:41-08:00
log052314b65fcb2dd441c18bffe530dda690634a6b
tree4c82072a9fb36be20714321072caa4ace9bfca46
parent1dca7012b5e34e8cbc7bfd8cc01d83fbaa4dff83

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>

9 files changed, 267 insertions(+), 75 deletions(-)

apps/web/src/components/ChatView.tsx+1-62
......@@ -4,7 +4,6 @@ import {
44 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
55 type ProviderSendTurnAttachmentInput,
66 type ProviderApprovalDecision,
7 type ProviderEvent,
87} from "@t3tools/contracts";
98import {
109 type ClipboardEvent,
......@@ -30,6 +29,7 @@ import {
3029 resolveModelSlug,
3130} from "../model-logic";
3231import {
32 derivePendingApprovals,
3333 derivePhase,
3434 deriveTimelineEntries,
3535 deriveWorkLogEntries,
......@@ -74,13 +74,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
7474 return "text-muted-foreground/40";
7575}
7676
77interface PendingApprovalCard {
78 requestId: string;
79 requestKind: "command" | "file-change";
80 createdAt: string;
81 detail?: string;
82}
83
8477type SessionContinuityState = "resumed" | "new" | "fallback_new";
8578
8679interface EnsuredSessionInfo {
......@@ -99,57 +92,6 @@ interface ExpandedImagePreview {
9992 name: string;
10093}
10194
102function asRecord(value: unknown): Record<string, unknown> | undefined {
103 if (!value || typeof value !== "object") return undefined;
104 return value as Record<string, unknown>;
105}
106
107function asString(value: unknown): string | undefined {
108 return typeof value === "string" ? value : undefined;
109}
110
111function 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
118function 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
15395function readFileAsDataUrl(file: File): Promise<string> {
15496 return new Promise((resolve, reject) => {
15597 const reader = new FileReader();
......@@ -305,9 +247,6 @@ export default function ChatView() {
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 @@
11import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
2import { useCallback, useEffect, useState } from "react";
2import { useCallback, useEffect, useMemo, useState } from "react";
33import { isElectron } from "../env";
44import { useTheme } from "../hooks/useTheme";
55import { DEFAULT_MODEL } from "../model-logic";
6import { derivePendingApprovals } from "../session-logic";
67import { useStore } from "../store";
7import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types";
8import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project, type Thread } from "../types";
89import { useNativeApi } from "../hooks/useNativeApi";
910
1011const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
......@@ -25,11 +26,61 @@ function inferProjectName(cwd: string): string {
2526 return last && last.length > 0 ? last : "project";
2627}
2728
28function 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";
29interface ThreadStatusPill {
30 label: "Working" | "Connecting" | "Completed" | "Awaiting response";
31 colorClass: string;
32 dotClass: string;
33 pulse: boolean;
34}
35
36function 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
47function 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() {
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() {
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() {
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
......@@ -141,6 +141,7 @@ describe("hydratePersistedState", () => {
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", () => {
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", () => {
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", () => {
265268 },
266269 ],
267270 createdAt: thread.createdAt,
271 lastVisitedAt: thread.lastVisitedAt,
268272 branch: null,
269273 worktreePath: null,
270274 });
apps/web/src/persistenceSchema.ts+3
......@@ -50,6 +50,7 @@ const persistedThreadSchema = z.object({
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(
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(
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
......@@ -4,6 +4,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
44import {
55 type WorkLogEntry,
66 applyEventToMessages,
7 derivePendingApprovals,
78 deriveTimelineEntries,
89 deriveWorkLogEntries,
910 evolveSession,
......@@ -392,6 +393,73 @@ describe("deriveWorkLogEntries", () => {
392393 });
393394});
394395
396describe("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
395463describe("evolveSession", () => {
396464 it("updates thread id when thread starts", () => {
397465 const previous = makeSession();
apps/web/src/session-logic.ts+53
......@@ -78,6 +78,13 @@ export interface WorkLogEntry {
7878 tone: "thinking" | "tool" | "info" | "error";
7979}
8080
81export interface PendingApproval {
82 requestId: string;
83 requestKind: "command" | "file-change";
84 createdAt: string;
85 detail?: string;
86}
87
8188export type TimelineEntry =
8289 | {
8390 id: string;
......@@ -98,6 +105,52 @@ function normalizeDetail(value: string | undefined): string | undefined {
98105 return trimmed.length > 0 ? trimmed : undefined;
99106}
100107
108function 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
115export 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
101154function normalizeItemType(raw: string | undefined): string {
102155 if (!raw) return "item";
103156 return raw
apps/web/src/store.test.ts+44
......@@ -219,4 +219,48 @@ describe("store reducer thread continuity", () => {
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 {
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 {
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
......@@ -50,6 +50,7 @@ export interface Thread {
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}