authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-13 13:20:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-13 13:20:10-07:00
log6f699346e4a4f43059c6d2e459532abb80df1338
tree4ad8ae58b795244a4477b0f3eeb37dbe0b13278f
parentf5ecca4480b5b399833cdeade4c4abce1b74cf4d
signature Signed by PGP key B5690EEEBB952194

Use latest user message time for thread timestamps (#1996)


7 files changed, 165 insertions(+), 555 deletions(-)

apps/web/src/components/CommandPalette.logic.ts+3-1
......@@ -114,7 +114,9 @@ export function buildThreadActionItems(input: {
114114 searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""],
115115 title: thread.title,
116116 description: descriptionParts.join(" · "),
117 timestamp: formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt),
117 timestamp: formatRelativeTimeLabel(
118 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
119 ),
118120 icon: input.icon,
119121 run: async () => {
120122 await input.runThread(thread);
apps/web/src/components/Sidebar.tsx+11-11
......@@ -759,7 +759,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
759759 : "text-muted-foreground/40"
760760 }`}
761761 >
762 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
762 {formatRelativeTimeLabel(
763 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
764 )}
763765 </span>
764766 )}
765767 </span>
......@@ -1115,6 +1117,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
11151117 ),
11161118 [allSidebarThreads],
11171119 );
1120 // Keep a ref so callbacks can read the latest map without appearing in
1121 // dependency arrays (avoids invalidating every thread-row memo on each
1122 // thread-list change).
1123 const sidebarThreadByKeyRef = useRef(sidebarThreadByKey);
1124 sidebarThreadByKeyRef.current = sidebarThreadByKey;
11181125 // All threads from the representative + other member environments are
11191126 // already fetched into allSidebarThreads, so we can use them directly.
11201127 const projectThreads = allSidebarThreads;
......@@ -1458,7 +1465,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14581465
14591466 if (clicked === "mark-unread") {
14601467 for (const threadKey of threadKeys) {
1461 const thread = sidebarThreadByKey.get(threadKey);
1468 const thread = sidebarThreadByKeyRef.current.get(threadKey);
14621469 markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
14631470 }
14641471 clearSelection();
......@@ -1479,7 +1486,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14791486
14801487 const deletedThreadKeys = new Set(threadKeys);
14811488 for (const threadKey of threadKeys) {
1482 const thread = sidebarThreadByKey.get(threadKey);
1489 const thread = sidebarThreadByKeyRef.current.get(threadKey);
14831490 if (!thread) continue;
14841491 await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
14851492 deletedThreadKeys,
......@@ -1493,7 +1500,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14931500 deleteThread,
14941501 markThreadUnread,
14951502 removeFromSelection,
1496 sidebarThreadByKey,
14971503 ],
14981504 );
14991505
......@@ -1622,12 +1628,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
16221628 const api = readLocalApi();
16231629 if (!api) return;
16241630 const threadKey = scopedThreadKey(threadRef);
1625 const thread =
1626 projectThreads.find(
1627 (projectThread) =>
1628 projectThread.environmentId === threadRef.environmentId &&
1629 projectThread.id === threadRef.threadId,
1630 ) ?? null;
1631 const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null;
16311632 if (!thread) return;
16321633 const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null;
16331634 const clicked = await api.contextMenu.show(
......@@ -1689,7 +1690,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
16891690 deleteThread,
16901691 markThreadUnread,
16911692 project.cwd,
1692 projectThreads,
16931693 ],
16941694 );
16951695
apps/web/src/hooks/useThreadActions.ts+9-3
......@@ -2,7 +2,7 @@ import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/
22import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
33import { useQueryClient } from "@tanstack/react-query";
44import { useRouter } from "@tanstack/react-router";
5import { useCallback } from "react";
5import { useCallback, useRef } from "react";
66
77import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
88import { useComposerDraftStore } from "../composerDraftStore";
......@@ -33,6 +33,12 @@ export function useThreadActions() {
3333 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
3434 const router = useRouter();
3535 const { handleNewThread } = useNewThreadHandler();
36 // Keep a ref so archiveThread can call handleNewThread without appearing in
37 // its dependency array — handleNewThread is inherently unstable (depends on
38 // the projects list) and would otherwise cascade new references into every
39 // sidebar row via archiveThread → attemptArchiveThread.
40 const handleNewThreadRef = useRef(handleNewThread);
41 handleNewThreadRef.current = handleNewThread;
3642 const queryClient = useQueryClient();
3743
3844 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {
......@@ -73,10 +79,10 @@ export function useThreadActions() {
7379 currentRouteThreadRef?.threadId === threadRef.threadId &&
7480 currentRouteThreadRef.environmentId === threadRef.environmentId
7581 ) {
76 await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId));
82 await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId));
7783 }
7884 },
79 [getCurrentRouteThreadRef, handleNewThread, resolveThreadTarget],
85 [getCurrentRouteThreadRef, resolveThreadTarget],
8086 );
8187
8288 const unarchiveThread = useCallback(async (target: ScopedThreadRef) => {
apps/web/src/store.test.ts-303
......@@ -5,12 +5,10 @@ import {
55 EnvironmentId,
66 EventId,
77 MessageId,
8 type OrchestrationShellSnapshot,
98 ProjectId,
109 ThreadId,
1110 TurnId,
1211 type OrchestrationEvent,
13 type OrchestrationReadModel,
1412} from "@t3tools/contracts";
1513import { describe, expect, it } from "vitest";
1614
......@@ -23,8 +21,6 @@ import {
2321 selectThreadExistsByRef,
2422 setThreadBranch,
2523 selectThreadsAcrossEnvironments,
26 syncServerReadModel,
27 syncServerShellSnapshot,
2824 type AppState,
2925 type EnvironmentState,
3026} from "./store";
......@@ -371,75 +367,6 @@ describe("thread selection memoization", () => {
371367 });
372368});
373369
374function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"][number]>) {
375 return {
376 id: ThreadId.make("thread-1"),
377 projectId: ProjectId.make("project-1"),
378 title: "Thread",
379 modelSelection: {
380 provider: "codex",
381 model: "gpt-5.3-codex",
382 },
383 runtimeMode: DEFAULT_RUNTIME_MODE,
384 interactionMode: DEFAULT_INTERACTION_MODE,
385 branch: null,
386 worktreePath: null,
387 latestTurn: null,
388 createdAt: "2026-02-27T00:00:00.000Z",
389 updatedAt: "2026-02-27T00:00:00.000Z",
390 archivedAt: null,
391 deletedAt: null,
392 messages: [],
393 activities: [],
394 proposedPlans: [],
395 checkpoints: [],
396 session: null,
397 ...overrides,
398 } satisfies OrchestrationReadModel["threads"][number];
399}
400
401function makeReadModel(thread: OrchestrationReadModel["threads"][number]): OrchestrationReadModel {
402 return {
403 snapshotSequence: 1,
404 updatedAt: "2026-02-27T00:00:00.000Z",
405 projects: [
406 {
407 id: ProjectId.make("project-1"),
408 title: "Project",
409 workspaceRoot: "/tmp/project",
410 defaultModelSelection: {
411 provider: "codex",
412 model: "gpt-5.3-codex",
413 },
414 createdAt: "2026-02-27T00:00:00.000Z",
415 updatedAt: "2026-02-27T00:00:00.000Z",
416 deletedAt: null,
417 scripts: [],
418 },
419 ],
420 threads: [thread],
421 };
422}
423
424function makeReadModelProject(
425 overrides: Partial<OrchestrationReadModel["projects"][number]>,
426): OrchestrationReadModel["projects"][number] {
427 return {
428 id: ProjectId.make("project-1"),
429 title: "Project",
430 workspaceRoot: "/tmp/project",
431 defaultModelSelection: {
432 provider: "codex",
433 model: "gpt-5.3-codex",
434 },
435 createdAt: "2026-02-27T00:00:00.000Z",
436 updatedAt: "2026-02-27T00:00:00.000Z",
437 deletedAt: null,
438 scripts: [],
439 ...overrides,
440 };
441}
442
443370describe("setThreadBranch", () => {
444371 it("updates only the scoped thread environment", () => {
445372 const sharedThreadId = ThreadId.make("thread-shared");
......@@ -480,236 +407,6 @@ describe("setThreadBranch", () => {
480407 });
481408});
482409
483describe("store read model sync", () => {
484 it("marks bootstrap complete after snapshot sync", () => {
485 const initialState = withActiveEnvironmentState(
486 localEnvironmentStateOf(makeState(makeThread())),
487 {
488 bootstrapComplete: false,
489 },
490 );
491
492 const next = syncServerReadModel(
493 initialState,
494 makeReadModel(makeReadModelThread({})),
495 localEnvironmentId,
496 );
497
498 expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true);
499 });
500
501 it("updates shell state without discarding hydrated thread detail", () => {
502 const initialState = makeState(
503 makeThread({
504 title: "Initial thread",
505 messages: [
506 {
507 id: MessageId.make("message-1"),
508 role: "assistant",
509 text: "hydrated body",
510 createdAt: "2026-02-13T00:00:01.000Z",
511 completedAt: "2026-02-13T00:00:01.000Z",
512 streaming: false,
513 },
514 ],
515 }),
516 );
517 const shellSnapshot: OrchestrationShellSnapshot = {
518 snapshotSequence: 2,
519 projects: [
520 {
521 id: ProjectId.make("project-1"),
522 title: "Project",
523 workspaceRoot: "/tmp/project",
524 repositoryIdentity: null,
525 defaultModelSelection: {
526 provider: "codex",
527 model: "gpt-5-codex",
528 },
529 scripts: [],
530 createdAt: "2026-02-13T00:00:00.000Z",
531 updatedAt: "2026-02-13T00:00:00.000Z",
532 },
533 ],
534 threads: [
535 {
536 id: ThreadId.make("thread-1"),
537 projectId: ProjectId.make("project-1"),
538 title: "Renamed thread",
539 modelSelection: {
540 provider: "codex",
541 model: "gpt-5-codex",
542 },
543 runtimeMode: "full-access",
544 interactionMode: "default",
545 branch: "feature/renamed",
546 worktreePath: null,
547 latestTurn: null,
548 createdAt: "2026-02-13T00:00:00.000Z",
549 updatedAt: "2026-02-13T00:00:02.000Z",
550 archivedAt: null,
551 session: null,
552 latestUserMessageAt: null,
553 hasPendingApprovals: false,
554 hasPendingUserInput: false,
555 hasActionableProposedPlan: false,
556 },
557 ],
558 updatedAt: "2026-02-13T00:00:02.000Z",
559 };
560
561 const next = syncServerShellSnapshot(initialState, shellSnapshot, localEnvironmentId);
562 const thread = selectThreadByRef(
563 next,
564 scopeThreadRef(localEnvironmentId, ThreadId.make("thread-1")),
565 );
566
567 expect(thread?.title).toBe("Renamed thread");
568 expect(thread?.branch).toBe("feature/renamed");
569 expect(thread?.messages).toEqual([
570 expect.objectContaining({
571 id: MessageId.make("message-1"),
572 text: "hydrated body",
573 }),
574 ]);
575 expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true);
576 });
577
578 it("preserves claude model slugs without an active session", () => {
579 const initialState = makeState(makeThread());
580 const readModel = makeReadModel(
581 makeReadModelThread({
582 modelSelection: {
583 provider: "claudeAgent",
584 model: "claude-opus-4-6",
585 },
586 }),
587 );
588
589 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
590
591 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6");
592 });
593
594 it("resolves claude aliases when session provider is claudeAgent", () => {
595 const initialState = makeState(makeThread());
596 const readModel = makeReadModel(
597 makeReadModelThread({
598 modelSelection: {
599 provider: "claudeAgent",
600 model: "sonnet",
601 },
602 session: {
603 threadId: ThreadId.make("thread-1"),
604 status: "ready",
605 providerName: "claudeAgent",
606 runtimeMode: "approval-required",
607 activeTurnId: null,
608 lastError: null,
609 updatedAt: "2026-02-27T00:00:00.000Z",
610 },
611 }),
612 );
613
614 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
615
616 expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6");
617 });
618
619 it("preserves project and thread updatedAt timestamps from the read model", () => {
620 const initialState = makeState(makeThread());
621 const readModel = makeReadModel(
622 makeReadModelThread({
623 updatedAt: "2026-02-27T00:05:00.000Z",
624 }),
625 );
626
627 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
628
629 expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z");
630 expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z");
631 });
632
633 it("maps archivedAt from the read model", () => {
634 const initialState = makeState(makeThread());
635 const archivedAt = "2026-02-28T00:00:00.000Z";
636 const next = syncServerReadModel(
637 initialState,
638 makeReadModel(
639 makeReadModelThread({
640 archivedAt,
641 }),
642 ),
643 localEnvironmentId,
644 );
645
646 expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt);
647 });
648
649 it("replaces projects using snapshot order during recovery", () => {
650 const project1 = ProjectId.make("project-1");
651 const project2 = ProjectId.make("project-2");
652 const project3 = ProjectId.make("project-3");
653 const initialState: AppState = makeEmptyState({
654 projectIds: [project2, project1],
655 projectById: {
656 [project2]: {
657 id: project2,
658 environmentId: localEnvironmentId,
659 name: "Project 2",
660 cwd: "/tmp/project-2",
661 defaultModelSelection: {
662 provider: "codex",
663 model: DEFAULT_MODEL_BY_PROVIDER.codex,
664 },
665 createdAt: "2026-02-27T00:00:00.000Z",
666 updatedAt: "2026-02-27T00:00:00.000Z",
667 scripts: [],
668 },
669 [project1]: {
670 id: project1,
671 environmentId: localEnvironmentId,
672 name: "Project 1",
673 cwd: "/tmp/project-1",
674 defaultModelSelection: {
675 provider: "codex",
676 model: DEFAULT_MODEL_BY_PROVIDER.codex,
677 },
678 createdAt: "2026-02-27T00:00:00.000Z",
679 updatedAt: "2026-02-27T00:00:00.000Z",
680 scripts: [],
681 },
682 },
683 });
684 const readModel: OrchestrationReadModel = {
685 snapshotSequence: 2,
686 updatedAt: "2026-02-27T00:00:00.000Z",
687 projects: [
688 makeReadModelProject({
689 id: project1,
690 title: "Project 1",
691 workspaceRoot: "/tmp/project-1",
692 }),
693 makeReadModelProject({
694 id: project2,
695 title: "Project 2",
696 workspaceRoot: "/tmp/project-2",
697 }),
698 makeReadModelProject({
699 id: project3,
700 title: "Project 3",
701 workspaceRoot: "/tmp/project-3",
702 }),
703 ],
704 threads: [],
705 };
706
707 const next = syncServerReadModel(initialState, readModel, localEnvironmentId);
708
709 expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]);
710 });
711});
712
713410describe("incremental orchestration updates", () => {
714411 it("does not mark bootstrap complete for incremental events", () => {
715412 const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), {
apps/web/src/store.ts+140-225
......@@ -3,6 +3,7 @@ import type {
33 MessageId,
44 OrchestrationCheckpointSummary,
55 OrchestrationEvent,
6 OrchestrationLatestTurn,
67 OrchestrationMessage,
78 OrchestrationProposedPlan,
89 OrchestrationReadModel,
......@@ -22,12 +23,6 @@ import type {
2223} from "@t3tools/contracts";
2324import { resolveModelSlugForProvider } from "@t3tools/shared/model";
2425import { create } from "zustand";
25import {
26 derivePendingApprovals,
27 derivePendingUserInputs,
28 findLatestProposedPlan,
29 hasActionableProposedPlan,
30} from "./session-logic";
3126import {
3227 type ChatMessage,
3328 type Project,
......@@ -46,11 +41,33 @@ import { getThreadFromEnvironmentState } from "./threadDerivation";
4641export interface EnvironmentState {
4742 projectIds: ProjectId[];
4843 projectById: Record<ProjectId, Project>;
44
45 // ---------------------------------------------------------------------------
46 // Thread bookkeeping — written by BOTH shell stream and detail stream.
47 // Both streams ensure the thread is registered here; the bookkeeping is
48 // additive (append-only IDs) so concurrent writes are safe.
49 // ---------------------------------------------------------------------------
4950 threadIds: ThreadId[];
5051 threadIdsByProjectId: Record<ProjectId, ThreadId[]>;
52
53 // ---------------------------------------------------------------------------
54 // Thread shell / session / turn — written by BOTH shell stream and detail
55 // stream. The shell stream is the *authoritative* source (server pre-
56 // computes these from the projection pipeline), but the detail stream also
57 // writes them so the active thread has up-to-date state even if the shell
58 // event hasn't arrived yet. Structural equality checks in both write
59 // functions prevent unnecessary React re-renders when both streams deliver
60 // equivalent data.
61 // ---------------------------------------------------------------------------
5162 threadShellById: Record<ThreadId, ThreadShell>;
5263 threadSessionById: Record<ThreadId, ThreadSession | null>;
5364 threadTurnStateById: Record<ThreadId, ThreadTurnState>;
65
66 // ---------------------------------------------------------------------------
67 // Thread detail content — written ONLY by the detail stream
68 // (writeThreadState / syncServerThreadDetail). The shell stream never
69 // touches these.
70 // ---------------------------------------------------------------------------
5471 messageIdsByThreadId: Record<ThreadId, MessageId[]>;
5572 messageByThreadId: Record<ThreadId, Record<MessageId, ChatMessage>>;
5673 activityIdsByThreadId: Record<ThreadId, string[]>;
......@@ -59,7 +76,16 @@ export interface EnvironmentState {
5976 proposedPlanByThreadId: Record<ThreadId, Record<string, ProposedPlan>>;
6077 turnDiffIdsByThreadId: Record<ThreadId, TurnId[]>;
6178 turnDiffSummaryByThreadId: Record<ThreadId, Record<TurnId, TurnDiffSummary>>;
79
80 // ---------------------------------------------------------------------------
81 // Sidebar summary — written ONLY by the shell stream
82 // (writeThreadShellState / mapThreadShell). Pre-computed server-side with
83 // fields like latestUserMessageAt, hasPendingApprovals, etc. The detail
84 // stream must NOT write here; the shell stream is the single source of
85 // truth for sidebar data.
86 // ---------------------------------------------------------------------------
6287 sidebarThreadSummaryById: Record<ThreadId, SidebarThreadSummary>;
88
6389 bootstrapComplete: boolean;
6490}
6591
......@@ -308,40 +334,47 @@ function toThreadTurnState(thread: Thread): ThreadTurnState {
308334 };
309335}
310336
311function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string | null {
312 let latestUserMessageAt: string | null = null;
313 for (const message of messages) {
314 if (message.role !== "user") {
315 continue;
316 }
317 if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) {
318 latestUserMessageAt = message.createdAt;
319 }
320 }
321 return latestUserMessageAt;
337function sourceProposedPlansEqual(
338 left: OrchestrationLatestTurn["sourceProposedPlan"] | undefined,
339 right: OrchestrationLatestTurn["sourceProposedPlan"] | undefined,
340): boolean {
341 if (left === right) return true;
342 if (left === undefined || right === undefined) return false;
343 return left.threadId === right.threadId && left.planId === right.planId;
322344}
323345
324function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary {
325 return {
326 id: thread.id,
327 environmentId: thread.environmentId,
328 projectId: thread.projectId,
329 title: thread.title,
330 interactionMode: thread.interactionMode,
331 session: thread.session,
332 createdAt: thread.createdAt,
333 archivedAt: thread.archivedAt,
334 updatedAt: thread.updatedAt,
335 latestTurn: thread.latestTurn,
336 branch: thread.branch,
337 worktreePath: thread.worktreePath,
338 latestUserMessageAt: getLatestUserMessageAt(thread.messages),
339 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
340 hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
341 hasActionableProposedPlan: hasActionableProposedPlan(
342 findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null),
343 ),
344 };
346function latestTurnsEqual(
347 left: OrchestrationLatestTurn | null | undefined,
348 right: OrchestrationLatestTurn | null | undefined,
349): boolean {
350 if (left === right) return true;
351 if (left == null || right == null) return false;
352 return (
353 left.turnId === right.turnId &&
354 left.state === right.state &&
355 left.requestedAt === right.requestedAt &&
356 left.startedAt === right.startedAt &&
357 left.completedAt === right.completedAt &&
358 left.assistantMessageId === right.assistantMessageId &&
359 sourceProposedPlansEqual(left.sourceProposedPlan, right.sourceProposedPlan)
360 );
361}
362
363function threadSessionsEqual(
364 left: ThreadSession | null | undefined,
365 right: ThreadSession | null | undefined,
366): boolean {
367 if (left === right) return true;
368 if (left == null || right == null) return false;
369 return (
370 left.provider === right.provider &&
371 left.status === right.status &&
372 left.orchestrationStatus === right.orchestrationStatus &&
373 left.activeTurnId === right.activeTurnId &&
374 left.createdAt === right.createdAt &&
375 left.updatedAt === right.updatedAt &&
376 left.lastError === right.lastError
377 );
345378}
346379
347380function sidebarThreadSummariesEqual(
......@@ -354,11 +387,11 @@ function sidebarThreadSummariesEqual(
354387 left.projectId === right.projectId &&
355388 left.title === right.title &&
356389 left.interactionMode === right.interactionMode &&
357 left.session === right.session &&
390 threadSessionsEqual(left.session, right.session) &&
358391 left.createdAt === right.createdAt &&
359392 left.archivedAt === right.archivedAt &&
360393 left.updatedAt === right.updatedAt &&
361 left.latestTurn === right.latestTurn &&
394 latestTurnsEqual(left.latestTurn, right.latestTurn) &&
362395 left.branch === right.branch &&
363396 left.worktreePath === right.worktreePath &&
364397 left.latestUserMessageAt === right.latestUserMessageAt &&
......@@ -391,8 +424,8 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b
391424function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean {
392425 return (
393426 left !== undefined &&
394 left.latestTurn === right.latestTurn &&
395 left.pendingSourceProposedPlan === right.pendingSourceProposedPlan
427 latestTurnsEqual(left.latestTurn, right.latestTurn) &&
428 sourceProposedPlansEqual(left.pendingSourceProposedPlan, right.pendingSourceProposedPlan)
396429 );
397430}
398431
......@@ -466,34 +499,32 @@ function getThreads(state: EnvironmentState): Thread[] {
466499 });
467500}
468501
469function writeThreadState(
502/**
503 * Ensure a thread is registered in the bookkeeping indices (threadIds,
504 * threadIdsByProjectId). Shared by both the shell stream and detail stream
505 * write paths — the bookkeeping is additive (append-only IDs) so concurrent
506 * writes from both streams are safe.
507 */
508function ensureThreadRegistered(
470509 state: EnvironmentState,
471 nextThread: Thread,
472 previousThread?: Thread,
510 threadId: ThreadId,
511 nextProjectId: ProjectId,
512 previousProjectId: ProjectId | undefined,
473513): EnvironmentState {
474 const nextShell = toThreadShell(nextThread);
475 const nextTurnState = toThreadTurnState(nextThread);
476 const previousShell = state.threadShellById[nextThread.id];
477 const previousTurnState = state.threadTurnStateById[nextThread.id];
478 const previousSummary = state.sidebarThreadSummaryById[nextThread.id];
479 const nextSummary = buildSidebarThreadSummary(nextThread);
480
481514 let nextState = state;
482515
483 if (!state.threadIds.includes(nextThread.id)) {
516 if (!state.threadIds.includes(threadId)) {
484517 nextState = {
485518 ...nextState,
486 threadIds: [...nextState.threadIds, nextThread.id],
519 threadIds: [...nextState.threadIds, threadId],
487520 };
488521 }
489522
490 const previousProjectId = previousThread?.projectId;
491 const nextProjectId = nextThread.projectId;
492523 if (previousProjectId !== nextProjectId) {
493524 let threadIdsByProjectId = nextState.threadIdsByProjectId;
494525 if (previousProjectId) {
495526 const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS;
496 const nextIds = removeId(previousIds, nextThread.id);
527 const nextIds = removeId(previousIds, threadId);
497528 if (nextIds.length === 0) {
498529 const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId;
499530 threadIdsByProjectId = rest as Record<ProjectId, ThreadId[]>;
......@@ -505,7 +536,7 @@ function writeThreadState(
505536 }
506537 }
507538 const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS;
508 const nextProjectThreadIds = appendId(projectThreadIds, nextThread.id);
539 const nextProjectThreadIds = appendId(projectThreadIds, threadId);
509540 if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) {
510541 threadIdsByProjectId = {
511542 ...threadIdsByProjectId,
......@@ -520,6 +551,36 @@ function writeThreadState(
520551 }
521552 }
522553
554 return nextState;
555}
556
557/**
558 * Write thread state from the **detail stream** (per-thread subscription).
559 *
560 * Owns: messages, activities, proposed plans, turn diff summaries.
561 * Also writes threadShellById / threadSessionById / threadTurnStateById so
562 * the active thread has up-to-date state even if the shell stream event
563 * hasn't arrived yet (both streams use structural equality checks to avoid
564 * unnecessary re-renders when delivering equivalent data).
565 * Does NOT write sidebarThreadSummaryById — that is shell-stream-only.
566 */
567function writeThreadState(
568 state: EnvironmentState,
569 nextThread: Thread,
570 previousThread?: Thread,
571): EnvironmentState {
572 const nextShell = toThreadShell(nextThread);
573 const nextTurnState = toThreadTurnState(nextThread);
574 const previousShell = state.threadShellById[nextThread.id];
575 const previousTurnState = state.threadTurnStateById[nextThread.id];
576
577 let nextState = ensureThreadRegistered(
578 state,
579 nextThread.id,
580 nextThread.projectId,
581 previousThread?.projectId,
582 );
583
523584 if (!threadShellsEqual(previousShell, nextShell)) {
524585 nextState = {
525586 ...nextState,
......@@ -530,7 +591,7 @@ function writeThreadState(
530591 };
531592 }
532593
533 if ((previousThread?.session ?? null) !== nextThread.session) {
594 if (!threadSessionsEqual(previousThread?.session ?? null, nextThread.session)) {
534595 nextState = {
535596 ...nextState,
536597 threadSessionById: {
......@@ -610,19 +671,20 @@ function writeThreadState(
610671 };
611672 }
612673
613 if (!sidebarThreadSummariesEqual(previousSummary, nextSummary)) {
614 nextState = {
615 ...nextState,
616 sidebarThreadSummaryById: {
617 ...nextState.sidebarThreadSummaryById,
618 [nextThread.id]: nextSummary,
619 },
620 };
621 }
622
623674 return nextState;
624675}
625676
677/**
678 * Write thread state from the **shell stream** (all-threads subscription).
679 *
680 * Owns: sidebarThreadSummaryById (pre-computed server-side sidebar data).
681 * Also writes threadShellById / threadSessionById / threadTurnStateById as
682 * the authoritative source for these fields. The detail stream may also
683 * write them for the focused thread (see writeThreadState); structural
684 * equality checks prevent unnecessary re-renders.
685 * Does NOT write message/activity/proposedPlan/turnDiff content — that is
686 * detail-stream-only.
687 */
626688function writeThreadShellState(
627689 state: EnvironmentState,
628690 nextThread: {
......@@ -632,49 +694,14 @@ function writeThreadShellState(
632694 summary: SidebarThreadSummary;
633695 },
634696): EnvironmentState {
635 let nextState = state;
636697 const previousShell = state.threadShellById[nextThread.shell.id];
637 const previousProjectId = previousShell?.projectId;
638 const nextProjectId = nextThread.shell.projectId;
639698
640 if (!state.threadIds.includes(nextThread.shell.id)) {
641 nextState = {
642 ...nextState,
643 threadIds: [...nextState.threadIds, nextThread.shell.id],
644 };
645 }
646
647 if (previousProjectId !== nextProjectId) {
648 let threadIdsByProjectId = nextState.threadIdsByProjectId;
649 if (previousProjectId) {
650 const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS;
651 const nextIds = removeId(previousIds, nextThread.shell.id);
652 if (nextIds.length === 0) {
653 const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId;
654 threadIdsByProjectId = rest as Record<ProjectId, ThreadId[]>;
655 } else if (!arraysEqual(previousIds, nextIds)) {
656 threadIdsByProjectId = {
657 ...threadIdsByProjectId,
658 [previousProjectId]: nextIds,
659 };
660 }
661 }
662
663 const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS;
664 const nextProjectThreadIds = appendId(projectThreadIds, nextThread.shell.id);
665 if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) {
666 threadIdsByProjectId = {
667 ...threadIdsByProjectId,
668 [nextProjectId]: nextProjectThreadIds,
669 };
670 }
671 if (threadIdsByProjectId !== nextState.threadIdsByProjectId) {
672 nextState = {
673 ...nextState,
674 threadIdsByProjectId,
675 };
676 }
677 }
699 let nextState = ensureThreadRegistered(
700 state,
701 nextThread.shell.id,
702 nextThread.shell.projectId,
703 previousShell?.projectId,
704 );
678705
679706 if (!threadShellsEqual(previousShell, nextThread.shell)) {
680707 nextState = {
......@@ -686,7 +713,9 @@ function writeThreadShellState(
686713 };
687714 }
688715
689 if ((state.threadSessionById[nextThread.shell.id] ?? null) !== nextThread.session) {
716 if (
717 !threadSessionsEqual(state.threadSessionById[nextThread.shell.id] ?? null, nextThread.session)
718 ) {
690719 nextState = {
691720 ...nextState,
692721 threadSessionById: {
......@@ -1009,82 +1038,6 @@ function buildProjectState(
10091038 };
10101039}
10111040
1012function buildThreadState(
1013 threads: ReadonlyArray<Thread>,
1014): Pick<
1015 EnvironmentState,
1016 | "threadIds"
1017 | "threadIdsByProjectId"
1018 | "threadShellById"
1019 | "threadSessionById"
1020 | "threadTurnStateById"
1021 | "messageIdsByThreadId"
1022 | "messageByThreadId"
1023 | "activityIdsByThreadId"
1024 | "activityByThreadId"
1025 | "proposedPlanIdsByThreadId"
1026 | "proposedPlanByThreadId"
1027 | "turnDiffIdsByThreadId"
1028 | "turnDiffSummaryByThreadId"
1029 | "sidebarThreadSummaryById"
1030> {
1031 const threadIds: ThreadId[] = [];
1032 const threadIdsByProjectId: Record<ProjectId, ThreadId[]> = {};
1033 const threadShellById: Record<ThreadId, ThreadShell> = {};
1034 const threadSessionById: Record<ThreadId, ThreadSession | null> = {};
1035 const threadTurnStateById: Record<ThreadId, ThreadTurnState> = {};
1036 const messageIdsByThreadId: Record<ThreadId, MessageId[]> = {};
1037 const messageByThreadId: Record<ThreadId, Record<MessageId, ChatMessage>> = {};
1038 const activityIdsByThreadId: Record<ThreadId, string[]> = {};
1039 const activityByThreadId: Record<ThreadId, Record<string, OrchestrationThreadActivity>> = {};
1040 const proposedPlanIdsByThreadId: Record<ThreadId, string[]> = {};
1041 const proposedPlanByThreadId: Record<ThreadId, Record<string, ProposedPlan>> = {};
1042 const turnDiffIdsByThreadId: Record<ThreadId, TurnId[]> = {};
1043 const turnDiffSummaryByThreadId: Record<ThreadId, Record<TurnId, TurnDiffSummary>> = {};
1044 const sidebarThreadSummaryById: Record<ThreadId, SidebarThreadSummary> = {};
1045
1046 for (const thread of threads) {
1047 threadIds.push(thread.id);
1048 threadIdsByProjectId[thread.projectId] = [
1049 ...(threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS),
1050 thread.id,
1051 ];
1052 threadShellById[thread.id] = toThreadShell(thread);
1053 threadSessionById[thread.id] = thread.session;
1054 threadTurnStateById[thread.id] = toThreadTurnState(thread);
1055 const messageSlice = buildMessageSlice(thread);
1056 messageIdsByThreadId[thread.id] = messageSlice.ids;
1057 messageByThreadId[thread.id] = messageSlice.byId;
1058 const activitySlice = buildActivitySlice(thread);
1059 activityIdsByThreadId[thread.id] = activitySlice.ids;
1060 activityByThreadId[thread.id] = activitySlice.byId;
1061 const proposedPlanSlice = buildProposedPlanSlice(thread);
1062 proposedPlanIdsByThreadId[thread.id] = proposedPlanSlice.ids;
1063 proposedPlanByThreadId[thread.id] = proposedPlanSlice.byId;
1064 const turnDiffSlice = buildTurnDiffSlice(thread);
1065 turnDiffIdsByThreadId[thread.id] = turnDiffSlice.ids;
1066 turnDiffSummaryByThreadId[thread.id] = turnDiffSlice.byId;
1067 sidebarThreadSummaryById[thread.id] = buildSidebarThreadSummary(thread);
1068 }
1069
1070 return {
1071 threadIds,
1072 threadIdsByProjectId,
1073 threadShellById,
1074 threadSessionById,
1075 threadTurnStateById,
1076 messageIdsByThreadId,
1077 messageByThreadId,
1078 activityIdsByThreadId,
1079 activityByThreadId,
1080 proposedPlanIdsByThreadId,
1081 proposedPlanByThreadId,
1082 turnDiffIdsByThreadId,
1083 turnDiffSummaryByThreadId,
1084 sidebarThreadSummaryById,
1085 };
1086}
1087
10881041function getStoredEnvironmentState(
10891042 state: AppState,
10901043 environmentId: EnvironmentId,
......@@ -1116,25 +1069,6 @@ function commitEnvironmentState(
11161069 };
11171070}
11181071
1119function syncEnvironmentReadModel(
1120 state: EnvironmentState,
1121 readModel: OrchestrationReadModel,
1122 environmentId: EnvironmentId,
1123): EnvironmentState {
1124 const projects = readModel.projects
1125 .filter((project) => project.deletedAt === null)
1126 .map((project) => mapProject(project, environmentId));
1127 const threads = readModel.threads
1128 .filter((thread) => thread.deletedAt === null)
1129 .map((thread) => mapThread(thread, environmentId));
1130 return {
1131 ...state,
1132 ...buildProjectState(projects),
1133 ...buildThreadState(threads),
1134 bootstrapComplete: true,
1135 };
1136}
1137
11381072function syncEnvironmentShellSnapshot(
11391073 state: EnvironmentState,
11401074 snapshot: OrchestrationShellSnapshot,
......@@ -1175,22 +1109,6 @@ function syncEnvironmentShellSnapshot(
11751109 return nextState;
11761110}
11771111
1178export function syncServerReadModel(
1179 state: AppState,
1180 readModel: OrchestrationReadModel,
1181 environmentId: EnvironmentId,
1182): AppState {
1183 return commitEnvironmentState(
1184 state,
1185 environmentId,
1186 syncEnvironmentReadModel(
1187 getStoredEnvironmentState(state, environmentId),
1188 readModel,
1189 environmentId,
1190 ),
1191 );
1192}
1193
11941112export function syncServerShellSnapshot(
11951113 state: AppState,
11961114 snapshot: OrchestrationShellSnapshot,
......@@ -2007,7 +1925,6 @@ export function setThreadBranch(
20071925
20081926interface AppStore extends AppState {
20091927 setActiveEnvironmentId: (environmentId: EnvironmentId) => void;
2010 syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void;
20111928 syncServerShellSnapshot: (
20121929 snapshot: OrchestrationShellSnapshot,
20131930 environmentId: EnvironmentId,
......@@ -2031,8 +1948,6 @@ export const useStore = create<AppStore>((set) => ({
20311948 ...initialState,
20321949 setActiveEnvironmentId: (environmentId) =>
20331950 set((state) => setActiveEnvironmentId(state, environmentId)),
2034 syncServerReadModel: (readModel, environmentId) =>
2035 set((state) => syncServerReadModel(state, readModel, environmentId)),
20361951 syncServerShellSnapshot: (snapshot, environmentId) =>
20371952 set((state) => syncServerShellSnapshot(state, snapshot, environmentId)),
20381953 syncServerThreadDetail: (thread, environmentId) =>
apps/web/src/storeSelectors.ts+1-10
......@@ -1,6 +1,6 @@
11import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
22import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store";
3import { type Project, type SidebarThreadSummary, type Thread } from "./types";
3import { type Project, type Thread } from "./types";
44import { getThreadFromEnvironmentState } from "./threadDerivation";
55
66export function createProjectSelectorByRef(
......@@ -10,15 +10,6 @@ export function createProjectSelectorByRef(
1010 ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined;
1111}
1212
13export function createSidebarThreadSummarySelectorByRef(
14 ref: ScopedThreadRef | null | undefined,
15): (state: AppState) => SidebarThreadSummary | undefined {
16 return (state) =>
17 ref
18 ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId]
19 : undefined;
20}
21
2213function createScopedThreadSelector(
2314 resolveRef: (state: AppState) => ScopedThreadRef | null | undefined,
2415): (state: AppState) => Thread | undefined {
apps/web/src/timestampFormat.ts+1-2
......@@ -57,8 +57,7 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st
5757 const diffMs = Date.now() - new Date(isoDate).getTime();
5858 if (diffMs < 0) return { value: "just now", suffix: null };
5959 const seconds = Math.floor(diffMs / 1000);
60 if (seconds < 5) return { value: "just now", suffix: null };
61 if (seconds < 60) return { value: `${seconds}s`, suffix: "ago" };
60 if (seconds < 60) return { value: "just now", suffix: null };
6261 const minutes = Math.floor(seconds / 60);
6362 if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" };
6463 const hours = Math.floor(minutes / 60);