| 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 | 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 5 | 5 | type ProviderSendTurnAttachmentInput, |
| 6 | 6 | type ProviderApprovalDecision, |
| 7 | type ProviderEvent, | |
| 8 | 7 | } from "@t3tools/contracts"; |
| 9 | 8 | import { |
| 10 | 9 | type ClipboardEvent, |
| ... | ... | @@ -30,6 +29,7 @@ import { |
| 30 | 29 | resolveModelSlug, |
| 31 | 30 | } from "../model-logic"; |
| 32 | 31 | import { |
| 32 | derivePendingApprovals, | |
| 33 | 33 | derivePhase, |
| 34 | 34 | deriveTimelineEntries, |
| 35 | 35 | deriveWorkLogEntries, |
| ... | ... | @@ -74,13 +74,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 74 | 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 | 77 | type SessionContinuityState = "resumed" | "new" | "fallback_new"; |
| 85 | 78 | |
| 86 | 79 | interface EnsuredSessionInfo { |
| ... | ... | @@ -99,57 +92,6 @@ interface ExpandedImagePreview { |
| 99 | 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 | 95 | function readFileAsDataUrl(file: File): Promise<string> { |
| 154 | 96 | return new Promise((resolve, reject) => { |
| 155 | 97 | const reader = new FileReader(); |
| ... | ... | @@ -305,9 +247,6 @@ export default function ChatView() { |
| 305 | 247 | (activeThread.messages.length > 0 || |
| 306 | 248 | (activeThread.session !== null && activeThread.session.status !== "closed")), |
| 307 | 249 | ); |
| 308 | const terminalShortcutHint = navigator.platform.includes("Mac") | |
| 309 | ? "\u2318J" | |
| 310 | : "Ctrl+J"; | |
| 311 | 250 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { |
| 312 | 251 | for (const image of images) { |
| 313 | 252 | if (!image.previewUrl) continue; |
apps/web/src/components/Sidebar.tsx+78-11| ... | ... | @@ -1,10 +1,11 @@ |
| 1 | 1 | import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useState } from "react"; | |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 3 | 3 | import { isElectron } from "../env"; |
| 4 | 4 | import { useTheme } from "../hooks/useTheme"; |
| 5 | 5 | import { DEFAULT_MODEL } from "../model-logic"; |
| 6 | import { derivePendingApprovals } from "../session-logic"; | |
| 6 | 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 | 9 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 9 | 10 | |
| 10 | 11 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; |
| ... | ... | @@ -25,11 +26,61 @@ function inferProjectName(cwd: string): string { |
| 25 | 26 | return last && last.length > 0 ? last : "project"; |
| 26 | 27 | } |
| 27 | 28 | |
| 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 | ||
| 33 | 84 | return null; |
| 34 | 85 | } |
| 35 | 86 | |
| ... | ... | @@ -41,6 +92,13 @@ export default function Sidebar() { |
| 41 | 92 | const [newCwd, setNewCwd] = useState(""); |
| 42 | 93 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 43 | 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 | 103 | const handleNewThread = useCallback( |
| 46 | 104 | (projectId: string) => { |
| ... | ... | @@ -312,7 +370,10 @@ export default function Sidebar() { |
| 312 | 370 | <div className="ml-2 border-l border-border/80 pl-2"> |
| 313 | 371 | {threads.map((thread) => { |
| 314 | 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 | 377 | return ( |
| 317 | 378 | <button |
| 318 | 379 | key={thread.id} |
| ... | ... | @@ -338,9 +399,15 @@ export default function Sidebar() { |
| 338 | 399 | > |
| 339 | 400 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> |
| 340 | 401 | {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> | |
| 344 | 411 | </span> |
| 345 | 412 | )} |
| 346 | 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 | 141 | terminalHeight: 360, |
| 142 | 142 | messages: [], |
| 143 | 143 | createdAt: "2026-02-08T10:00:00.000Z", |
| 144 | lastVisitedAt: "2026-02-08T10:01:00.000Z", | |
| 144 | 145 | }, |
| 145 | 146 | ], |
| 146 | 147 | activeThreadId: "t-1", |
| ... | ... | @@ -149,6 +150,7 @@ describe("hydratePersistedState", () => { |
| 149 | 150 | const hydrated = hydratePersistedState(payload, false); |
| 150 | 151 | expect(hydrated?.threads[0]?.terminalOpen).toBe(true); |
| 151 | 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 | 156 | it("defaults terminalHeight when hydrating v5 payloads", () => { |
| ... | ... | @@ -217,6 +219,7 @@ describe("toPersistedState", () => { |
| 217 | 219 | events: [], |
| 218 | 220 | error: "boom", |
| 219 | 221 | createdAt: "2026-02-08T10:00:00.000Z", |
| 222 | lastVisitedAt: "2026-02-08T10:02:00.000Z", | |
| 220 | 223 | branch: null, |
| 221 | 224 | worktreePath: null, |
| 222 | 225 | }; |
| ... | ... | @@ -265,6 +268,7 @@ describe("toPersistedState", () => { |
| 265 | 268 | }, |
| 266 | 269 | ], |
| 267 | 270 | createdAt: thread.createdAt, |
| 271 | lastVisitedAt: thread.lastVisitedAt, | |
| 268 | 272 | branch: null, |
| 269 | 273 | worktreePath: null, |
| 270 | 274 | }); |
apps/web/src/persistenceSchema.ts+3| ... | ... | @@ -50,6 +50,7 @@ const persistedThreadSchema = z.object({ |
| 50 | 50 | ), |
| 51 | 51 | messages: z.array(persistedMessageSchema), |
| 52 | 52 | createdAt: z.string().min(1), |
| 53 | lastVisitedAt: z.string().min(1).optional(), | |
| 53 | 54 | branch: z.string().min(1).nullable().optional(), |
| 54 | 55 | worktreePath: z.string().min(1).nullable().optional(), |
| 55 | 56 | }); |
| ... | ... | @@ -153,6 +154,7 @@ function hydrateThread( |
| 153 | 154 | events: [], |
| 154 | 155 | error: null, |
| 155 | 156 | createdAt: thread.createdAt, |
| 157 | lastVisitedAt: thread.lastVisitedAt, | |
| 156 | 158 | branch: thread.branch ?? null, |
| 157 | 159 | worktreePath: thread.worktreePath ?? null, |
| 158 | 160 | }; |
| ... | ... | @@ -228,6 +230,7 @@ export function toPersistedState( |
| 228 | 230 | streaming: message.streaming, |
| 229 | 231 | })), |
| 230 | 232 | createdAt: thread.createdAt, |
| 233 | ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}), | |
| 231 | 234 | branch: thread.branch, |
| 232 | 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 | 4 | import { |
| 5 | 5 | type WorkLogEntry, |
| 6 | 6 | applyEventToMessages, |
| 7 | derivePendingApprovals, | |
| 7 | 8 | deriveTimelineEntries, |
| 8 | 9 | deriveWorkLogEntries, |
| 9 | 10 | evolveSession, |
| ... | ... | @@ -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 | 463 | describe("evolveSession", () => { |
| 396 | 464 | it("updates thread id when thread starts", () => { |
| 397 | 465 | const previous = makeSession(); |
apps/web/src/session-logic.ts+53| ... | ... | @@ -78,6 +78,13 @@ export interface WorkLogEntry { |
| 78 | 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 | 88 | export type TimelineEntry = |
| 82 | 89 | | { |
| 83 | 90 | id: string; |
| ... | ... | @@ -98,6 +105,52 @@ function normalizeDetail(value: string | undefined): string | undefined { |
| 98 | 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 | 154 | function normalizeItemType(raw: string | undefined): string { |
| 102 | 155 | if (!raw) return "item"; |
| 103 | 156 | return raw |
apps/web/src/store.test.ts+44| ... | ... | @@ -219,4 +219,48 @@ describe("store reducer thread continuity", () => { |
| 219 | 219 | expect(next.threads[0]?.projectId).toBe("project-new-a"); |
| 220 | 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 | 293 | { |
| 294 | 294 | ...action.thread, |
| 295 | 295 | model: resolveModelSlug(action.thread.model), |
| 296 | lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, | |
| 296 | 297 | }, |
| 297 | 298 | ], |
| 298 | 299 | activeThreadId: action.thread.id, |
| 299 | 300 | }; |
| 300 | 301 | |
| 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 | } | |
| 303 | 313 | |
| 304 | 314 | case "TOGGLE_THREAD_TERMINAL": |
| 305 | 315 | return { |
| ... | ... | @@ -356,6 +366,9 @@ export function reducer(state: AppState, action: Action): AppState { |
| 356 | 366 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), |
| 357 | 367 | events: [event, ...t.events], |
| 358 | 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 | 50 | latestTurnStartedAt?: string | undefined; |
| 51 | 51 | latestTurnCompletedAt?: string | undefined; |
| 52 | 52 | latestTurnDurationMs?: number | undefined; |
| 53 | lastVisitedAt?: string | undefined; | |
| 53 | 54 | branch: string | null; |
| 54 | 55 | worktreePath: string | null; |
| 55 | 56 | } |