| author | |
| committer | |
| log | 052314b65fcb2dd441c18bffe530dda690634a6b |
| tree | 4c82072a9fb36be20714321072caa4ace9bfca46 |
| parent | 1dca7012b5e34e8cbc7bfd8cc01d83fbaa4dff83 |
- 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 { | ... | @@ -4,7 +4,6 @@ import { |
| 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 5 | type ProviderSendTurnAttachmentInput, | 5 | type ProviderSendTurnAttachmentInput, |
| 6 | type ProviderApprovalDecision, | 6 | type ProviderApprovalDecision, |
| 7 | type ProviderEvent, | ||
| 8 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 9 | import { | 8 | import { |
| 10 | type ClipboardEvent, | 9 | type ClipboardEvent, |
| ... | @@ -30,6 +29,7 @@ import { | ... | @@ -30,6 +29,7 @@ import { |
| 30 | resolveModelSlug, | 29 | resolveModelSlug, |
| 31 | } from "../model-logic"; | 30 | } from "../model-logic"; |
| 32 | import { | 31 | import { |
| 32 | derivePendingApprovals, | ||
| 33 | derivePhase, | 33 | derivePhase, |
| 34 | deriveTimelineEntries, | 34 | deriveTimelineEntries, |
| 35 | deriveWorkLogEntries, | 35 | deriveWorkLogEntries, |
| ... | @@ -74,13 +74,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | ... | @@ -74,13 +74,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 74 | return "text-muted-foreground/40"; | 74 | return "text-muted-foreground/40"; |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | interface PendingApprovalCard { | ||
| 78 | requestId: string; | ||
| 79 | requestKind: "command" | "file-change"; | ||
| 80 | createdAt: string; | ||
| 81 | detail?: string; | ||
| 82 | } | ||
| 83 | |||
| 84 | type SessionContinuityState = "resumed" | "new" | "fallback_new"; | 77 | type SessionContinuityState = "resumed" | "new" | "fallback_new"; |
| 85 | 78 | ||
| 86 | interface EnsuredSessionInfo { | 79 | interface EnsuredSessionInfo { |
| ... | @@ -99,57 +92,6 @@ interface ExpandedImagePreview { | ... | @@ -99,57 +92,6 @@ interface ExpandedImagePreview { |
| 99 | name: string; | 92 | name: string; |
| 100 | } | 93 | } |
| 101 | 94 | ||
| 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 | |||
| 153 | function readFileAsDataUrl(file: File): Promise<string> { | 95 | function readFileAsDataUrl(file: File): Promise<string> { |
| 154 | return new Promise((resolve, reject) => { | 96 | return new Promise((resolve, reject) => { |
| 155 | const reader = new FileReader(); | 97 | const reader = new FileReader(); |
| ... | @@ -305,9 +247,6 @@ export default function ChatView() { | ... | @@ -305,9 +247,6 @@ export default function ChatView() { |
| 305 | (activeThread.messages.length > 0 || | 247 | (activeThread.messages.length > 0 || |
| 306 | (activeThread.session !== null && activeThread.session.status !== "closed")), | 248 | (activeThread.session !== null && activeThread.session.status !== "closed")), |
| 307 | ); | 249 | ); |
| 308 | const terminalShortcutHint = navigator.platform.includes("Mac") | ||
| 309 | ? "\u2318J" | ||
| 310 | : "Ctrl+J"; | ||
| 311 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { | 250 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { |
| 312 | for (const image of images) { | 251 | for (const image of images) { |
| 313 | if (!image.previewUrl) continue; | 252 | if (!image.previewUrl) continue; |
apps/web/src/components/Sidebar.tsx+78-11| ... | @@ -1,10 +1,11 @@ | ... | @@ -1,10 +1,11 @@ |
| 1 | import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; | 1 | import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useState } from "react"; | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import { isElectron } from "../env"; | 3 | import { isElectron } from "../env"; |
| 4 | import { useTheme } from "../hooks/useTheme"; | 4 | import { useTheme } from "../hooks/useTheme"; |
| 5 | import { DEFAULT_MODEL } from "../model-logic"; | 5 | import { DEFAULT_MODEL } from "../model-logic"; |
| 6 | import { derivePendingApprovals } from "../session-logic"; | ||
| 6 | import { useStore } from "../store"; | 7 | 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"; |
| 8 | import { useNativeApi } from "../hooks/useNativeApi"; | 9 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 9 | 10 | ||
| 10 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; | 11 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; |
| ... | @@ -25,11 +26,61 @@ function inferProjectName(cwd: string): string { | ... | @@ -25,11 +26,61 @@ function inferProjectName(cwd: string): string { |
| 25 | return last && last.length > 0 ? last : "project"; | 26 | return last && last.length > 0 ? last : "project"; |
| 26 | } | 27 | } |
| 27 | 28 | ||
| 28 | function threadStatusLabel( | 29 | interface ThreadStatusPill { |
| 29 | status: "connecting" | "ready" | "running" | "error" | "closed" | undefined, | 30 | label: "Working" | "Connecting" | "Completed" | "Awaiting response"; |
| 30 | ): "Working" | "Connecting" | null { | 31 | colorClass: string; |
| 31 | if (status === "running") return "Working"; | 32 | dotClass: string; |
| 32 | if (status === "connecting") return "Connecting"; | 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 | |||
| 33 | return null; | 84 | return null; |
| 34 | } | 85 | } |
| 35 | 86 | ||
| ... | @@ -41,6 +92,13 @@ export default function Sidebar() { | ... | @@ -41,6 +92,13 @@ export default function Sidebar() { |
| 41 | const [newCwd, setNewCwd] = useState(""); | 92 | const [newCwd, setNewCwd] = useState(""); |
| 42 | const [isPickingFolder, setIsPickingFolder] = useState(false); | 93 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 43 | const [isAddingProject, setIsAddingProject] = useState(false); | 94 | 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]); | ||
| 44 | 102 | ||
| 45 | const handleNewThread = useCallback( | 103 | const handleNewThread = useCallback( |
| 46 | (projectId: string) => { | 104 | (projectId: string) => { |
| ... | @@ -312,7 +370,10 @@ export default function Sidebar() { | ... | @@ -312,7 +370,10 @@ export default function Sidebar() { |
| 312 | <div className="ml-2 border-l border-border/80 pl-2"> | 370 | <div className="ml-2 border-l border-border/80 pl-2"> |
| 313 | {threads.map((thread) => { | 371 | {threads.map((thread) => { |
| 314 | const isActive = state.activeThreadId === thread.id; | 372 | 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 | ); | ||
| 316 | return ( | 377 | return ( |
| 317 | <button | 378 | <button |
| 318 | key={thread.id} | 379 | key={thread.id} |
| ... | @@ -338,9 +399,15 @@ export default function Sidebar() { | ... | @@ -338,9 +399,15 @@ export default function Sidebar() { |
| 338 | > | 399 | > |
| 339 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | 400 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 340 | {threadStatus && ( | 401 | {threadStatus && ( |
| 341 | <span className="inline-flex items-center gap-1 text-[10px] text-sky-600 dark:text-sky-300/80"> | 402 | <span |
| 342 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-500 dark:bg-sky-300/80" /> | 403 | className={`inline-flex items-center gap-1 text-[10px] ${threadStatus.colorClass}`} |
| 343 | <span className="hidden xl:inline">{threadStatus}</span> | 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> | ||
| 344 | </span> | 411 | </span> |
| 345 | )} | 412 | )} |
| 346 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> | 413 | <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", () => { | ... | @@ -141,6 +141,7 @@ describe("hydratePersistedState", () => { |
| 141 | terminalHeight: 360, | 141 | terminalHeight: 360, |
| 142 | messages: [], | 142 | messages: [], |
| 143 | createdAt: "2026-02-08T10:00:00.000Z", | 143 | createdAt: "2026-02-08T10:00:00.000Z", |
| 144 | lastVisitedAt: "2026-02-08T10:01:00.000Z", | ||
| 144 | }, | 145 | }, |
| 145 | ], | 146 | ], |
| 146 | activeThreadId: "t-1", | 147 | activeThreadId: "t-1", |
| ... | @@ -149,6 +150,7 @@ describe("hydratePersistedState", () => { | ... | @@ -149,6 +150,7 @@ describe("hydratePersistedState", () => { |
| 149 | const hydrated = hydratePersistedState(payload, false); | 150 | const hydrated = hydratePersistedState(payload, false); |
| 150 | expect(hydrated?.threads[0]?.terminalOpen).toBe(true); | 151 | expect(hydrated?.threads[0]?.terminalOpen).toBe(true); |
| 151 | expect(hydrated?.threads[0]?.terminalHeight).toBe(360); | 152 | expect(hydrated?.threads[0]?.terminalHeight).toBe(360); |
| 153 | expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z"); | ||
| 152 | }); | 154 | }); |
| 153 | 155 | ||
| 154 | it("defaults terminalHeight when hydrating v5 payloads", () => { | 156 | it("defaults terminalHeight when hydrating v5 payloads", () => { |
| ... | @@ -217,6 +219,7 @@ describe("toPersistedState", () => { | ... | @@ -217,6 +219,7 @@ describe("toPersistedState", () => { |
| 217 | events: [], | 219 | events: [], |
| 218 | error: "boom", | 220 | error: "boom", |
| 219 | createdAt: "2026-02-08T10:00:00.000Z", | 221 | createdAt: "2026-02-08T10:00:00.000Z", |
| 222 | lastVisitedAt: "2026-02-08T10:02:00.000Z", | ||
| 220 | branch: null, | 223 | branch: null, |
| 221 | worktreePath: null, | 224 | worktreePath: null, |
| 222 | }; | 225 | }; |
| ... | @@ -265,6 +268,7 @@ describe("toPersistedState", () => { | ... | @@ -265,6 +268,7 @@ describe("toPersistedState", () => { |
| 265 | }, | 268 | }, |
| 266 | ], | 269 | ], |
| 267 | createdAt: thread.createdAt, | 270 | createdAt: thread.createdAt, |
| 271 | lastVisitedAt: thread.lastVisitedAt, | ||
| 268 | branch: null, | 272 | branch: null, |
| 269 | worktreePath: null, | 273 | worktreePath: null, |
| 270 | }); | 274 | }); |
apps/web/src/persistenceSchema.ts+3| ... | @@ -50,6 +50,7 @@ const persistedThreadSchema = z.object({ | ... | @@ -50,6 +50,7 @@ const persistedThreadSchema = z.object({ |
| 50 | ), | 50 | ), |
| 51 | messages: z.array(persistedMessageSchema), | 51 | messages: z.array(persistedMessageSchema), |
| 52 | createdAt: z.string().min(1), | 52 | createdAt: z.string().min(1), |
| 53 | lastVisitedAt: z.string().min(1).optional(), | ||
| 53 | branch: z.string().min(1).nullable().optional(), | 54 | branch: z.string().min(1).nullable().optional(), |
| 54 | worktreePath: z.string().min(1).nullable().optional(), | 55 | worktreePath: z.string().min(1).nullable().optional(), |
| 55 | }); | 56 | }); |
| ... | @@ -153,6 +154,7 @@ function hydrateThread( | ... | @@ -153,6 +154,7 @@ function hydrateThread( |
| 153 | events: [], | 154 | events: [], |
| 154 | error: null, | 155 | error: null, |
| 155 | createdAt: thread.createdAt, | 156 | createdAt: thread.createdAt, |
| 157 | lastVisitedAt: thread.lastVisitedAt, | ||
| 156 | branch: thread.branch ?? null, | 158 | branch: thread.branch ?? null, |
| 157 | worktreePath: thread.worktreePath ?? null, | 159 | worktreePath: thread.worktreePath ?? null, |
| 158 | }; | 160 | }; |
| ... | @@ -228,6 +230,7 @@ export function toPersistedState( | ... | @@ -228,6 +230,7 @@ export function toPersistedState( |
| 228 | streaming: message.streaming, | 230 | streaming: message.streaming, |
| 229 | })), | 231 | })), |
| 230 | createdAt: thread.createdAt, | 232 | createdAt: thread.createdAt, |
| 233 | ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}), | ||
| 231 | branch: thread.branch, | 234 | branch: thread.branch, |
| 232 | worktreePath: thread.worktreePath, | 235 | worktreePath: thread.worktreePath, |
| 233 | })), | 236 | })), |
apps/web/src/session-logic.test.ts+68| ... | @@ -4,6 +4,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | ... | @@ -4,6 +4,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; |
| 4 | import { | 4 | import { |
| 5 | type WorkLogEntry, | 5 | type WorkLogEntry, |
| 6 | applyEventToMessages, | 6 | applyEventToMessages, |
| 7 | derivePendingApprovals, | ||
| 7 | deriveTimelineEntries, | 8 | deriveTimelineEntries, |
| 8 | deriveWorkLogEntries, | 9 | deriveWorkLogEntries, |
| 9 | evolveSession, | 10 | evolveSession, |
| ... | @@ -392,6 +393,73 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -392,6 +393,73 @@ describe("deriveWorkLogEntries", () => { |
| 392 | }); | 393 | }); |
| 393 | }); | 394 | }); |
| 394 | 395 | ||
| 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 | |||
| 395 | describe("evolveSession", () => { | 463 | describe("evolveSession", () => { |
| 396 | it("updates thread id when thread starts", () => { | 464 | it("updates thread id when thread starts", () => { |
| 397 | const previous = makeSession(); | 465 | const previous = makeSession(); |
apps/web/src/session-logic.ts+53| ... | @@ -78,6 +78,13 @@ export interface WorkLogEntry { | ... | @@ -78,6 +78,13 @@ export interface WorkLogEntry { |
| 78 | tone: "thinking" | "tool" | "info" | "error"; | 78 | tone: "thinking" | "tool" | "info" | "error"; |
| 79 | } | 79 | } |
| 80 | 80 | ||
| 81 | export interface PendingApproval { | ||
| 82 | requestId: string; | ||
| 83 | requestKind: "command" | "file-change"; | ||
| 84 | createdAt: string; | ||
| 85 | detail?: string; | ||
| 86 | } | ||
| 87 | |||
| 81 | export type TimelineEntry = | 88 | export type TimelineEntry = |
| 82 | | { | 89 | | { |
| 83 | id: string; | 90 | id: string; |
| ... | @@ -98,6 +105,52 @@ function normalizeDetail(value: string | undefined): string | undefined { | ... | @@ -98,6 +105,52 @@ function normalizeDetail(value: string | undefined): string | undefined { |
| 98 | return trimmed.length > 0 ? trimmed : undefined; | 105 | return trimmed.length > 0 ? trimmed : undefined; |
| 99 | } | 106 | } |
| 100 | 107 | ||
| 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 | |||
| 101 | function normalizeItemType(raw: string | undefined): string { | 154 | function normalizeItemType(raw: string | undefined): string { |
| 102 | if (!raw) return "item"; | 155 | if (!raw) return "item"; |
| 103 | return raw | 156 | return raw |
apps/web/src/store.test.ts+44| ... | @@ -219,4 +219,48 @@ describe("store reducer thread continuity", () => { | ... | @@ -219,4 +219,48 @@ describe("store reducer thread continuity", () => { |
| 219 | expect(next.threads[0]?.projectId).toBe("project-new-a"); | 219 | expect(next.threads[0]?.projectId).toBe("project-new-a"); |
| 220 | expect(next.activeThreadId).toBe("thread-a"); | 220 | expect(next.activeThreadId).toBe("thread-a"); |
| 221 | }); | 221 | }); |
| 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 | }); | ||
| 222 | }); | 266 | }); |
apps/web/src/store.ts+15-2| ... | @@ -293,13 +293,23 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -293,13 +293,23 @@ export function reducer(state: AppState, action: Action): AppState { |
| 293 | { | 293 | { |
| 294 | ...action.thread, | 294 | ...action.thread, |
| 295 | model: resolveModelSlug(action.thread.model), | 295 | model: resolveModelSlug(action.thread.model), |
| 296 | lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, | ||
| 296 | }, | 297 | }, |
| 297 | ], | 298 | ], |
| 298 | activeThreadId: action.thread.id, | 299 | activeThreadId: action.thread.id, |
| 299 | }; | 300 | }; |
| 300 | 301 | ||
| 301 | case "SET_ACTIVE_THREAD": | 302 | case "SET_ACTIVE_THREAD": { |
| 302 | return { ...state, activeThreadId: action.threadId }; | 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 | } | ||
| 303 | 313 | ||
| 304 | case "TOGGLE_THREAD_TERMINAL": | 314 | case "TOGGLE_THREAD_TERMINAL": |
| 305 | return { | 315 | return { |
| ... | @@ -356,6 +366,9 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -356,6 +366,9 @@ export function reducer(state: AppState, action: Action): AppState { |
| 356 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), | 366 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), |
| 357 | events: [event, ...t.events], | 367 | events: [event, ...t.events], |
| 358 | ...updateTurnFields(t, event), | 368 | ...updateTurnFields(t, event), |
| 369 | ...(event.method === "turn/completed" && t.id === state.activeThreadId | ||
| 370 | ? { lastVisitedAt: event.createdAt } | ||
| 371 | : {}), | ||
| 359 | })), | 372 | })), |
| 360 | }; | 373 | }; |
| 361 | } | 374 | } |
apps/web/src/types.ts+1| ... | @@ -50,6 +50,7 @@ export interface Thread { | ... | @@ -50,6 +50,7 @@ export interface Thread { |
| 50 | latestTurnStartedAt?: string | undefined; | 50 | latestTurnStartedAt?: string | undefined; |
| 51 | latestTurnCompletedAt?: string | undefined; | 51 | latestTurnCompletedAt?: string | undefined; |
| 52 | latestTurnDurationMs?: number | undefined; | 52 | latestTurnDurationMs?: number | undefined; |
| 53 | lastVisitedAt?: string | undefined; | ||
| 53 | branch: string | null; | 54 | branch: string | null; |
| 54 | worktreePath: string | null; | 55 | worktreePath: string | null; |
| 55 | } | 56 | } |