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: {...@@ -114,7 +114,9 @@ export function buildThreadActionItems(input: {
114 searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""],114 searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""],
115 title: thread.title,115 title: thread.title,
116 description: descriptionParts.join(" · "),116 description: descriptionParts.join(" · "),
117 timestamp: formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt),117 timestamp: formatRelativeTimeLabel(
118 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
119 ),
118 icon: input.icon,120 icon: input.icon,
119 run: async () => {121 run: async () => {
120 await input.runThread(thread);122 await input.runThread(thread);
apps/web/src/components/Sidebar.tsx+11-11
...@@ -759,7 +759,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -759,7 +759,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
759 : "text-muted-foreground/40"759 : "text-muted-foreground/40"
760 }`}760 }`}
761 >761 >
762 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}762 {formatRelativeTimeLabel(
763 thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
764 )}
763 </span>765 </span>
764 )}766 )}
765 </span>767 </span>
...@@ -1115,6 +1117,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1115,6 +1117,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1115 ),1117 ),
1116 [allSidebarThreads],1118 [allSidebarThreads],
1117 );1119 );
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;
1118 // All threads from the representative + other member environments are1125 // All threads from the representative + other member environments are
1119 // already fetched into allSidebarThreads, so we can use them directly.1126 // already fetched into allSidebarThreads, so we can use them directly.
1120 const projectThreads = allSidebarThreads;1127 const projectThreads = allSidebarThreads;
...@@ -1458,7 +1465,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1458,7 +1465,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14581465
1459 if (clicked === "mark-unread") {1466 if (clicked === "mark-unread") {
1460 for (const threadKey of threadKeys) {1467 for (const threadKey of threadKeys) {
1461 const thread = sidebarThreadByKey.get(threadKey);1468 const thread = sidebarThreadByKeyRef.current.get(threadKey);
1462 markThreadUnread(threadKey, thread?.latestTurn?.completedAt);1469 markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
1463 }1470 }
1464 clearSelection();1471 clearSelection();
...@@ -1479,7 +1486,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1479,7 +1486,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14791486
1480 const deletedThreadKeys = new Set(threadKeys);1487 const deletedThreadKeys = new Set(threadKeys);
1481 for (const threadKey of threadKeys) {1488 for (const threadKey of threadKeys) {
1482 const thread = sidebarThreadByKey.get(threadKey);1489 const thread = sidebarThreadByKeyRef.current.get(threadKey);
1483 if (!thread) continue;1490 if (!thread) continue;
1484 await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {1491 await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
1485 deletedThreadKeys,1492 deletedThreadKeys,
...@@ -1493,7 +1500,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1493,7 +1500,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1493 deleteThread,1500 deleteThread,
1494 markThreadUnread,1501 markThreadUnread,
1495 removeFromSelection,1502 removeFromSelection,
1496 sidebarThreadByKey,
1497 ],1503 ],
1498 );1504 );
14991505
...@@ -1622,12 +1628,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1622,12 +1628,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1622 const api = readLocalApi();1628 const api = readLocalApi();
1623 if (!api) return;1629 if (!api) return;
1624 const threadKey = scopedThreadKey(threadRef);1630 const threadKey = scopedThreadKey(threadRef);
1625 const thread =1631 const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null;
1626 projectThreads.find(
1627 (projectThread) =>
1628 projectThread.environmentId === threadRef.environmentId &&
1629 projectThread.id === threadRef.threadId,
1630 ) ?? null;
1631 if (!thread) return;1632 if (!thread) return;
1632 const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null;1633 const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null;
1633 const clicked = await api.contextMenu.show(1634 const clicked = await api.contextMenu.show(
...@@ -1689,7 +1690,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1689,7 +1690,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1689 deleteThread,1690 deleteThread,
1690 markThreadUnread,1691 markThreadUnread,
1691 project.cwd,1692 project.cwd,
1692 projectThreads,
1693 ],1693 ],
1694 );1694 );
16951695
apps/web/src/hooks/useThreadActions.ts+9-3
...@@ -2,7 +2,7 @@ import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/...@@ -2,7 +2,7 @@ import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/
2import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";2import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3import { useQueryClient } from "@tanstack/react-query";3import { useQueryClient } from "@tanstack/react-query";
4import { useRouter } from "@tanstack/react-router";4import { useRouter } from "@tanstack/react-router";
5import { useCallback } from "react";5import { useCallback, useRef } from "react";
66
7import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";7import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic";
8import { useComposerDraftStore } from "../composerDraftStore";8import { useComposerDraftStore } from "../composerDraftStore";
...@@ -33,6 +33,12 @@ export function useThreadActions() {...@@ -33,6 +33,12 @@ export function useThreadActions() {
33 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);33 const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState);
34 const router = useRouter();34 const router = useRouter();
35 const { handleNewThread } = useNewThreadHandler();35 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;
36 const queryClient = useQueryClient();42 const queryClient = useQueryClient();
3743
38 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {44 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {
...@@ -73,10 +79,10 @@ export function useThreadActions() {...@@ -73,10 +79,10 @@ export function useThreadActions() {
73 currentRouteThreadRef?.threadId === threadRef.threadId &&79 currentRouteThreadRef?.threadId === threadRef.threadId &&
74 currentRouteThreadRef.environmentId === threadRef.environmentId80 currentRouteThreadRef.environmentId === threadRef.environmentId
75 ) {81 ) {
76 await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId));82 await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId));
77 }83 }
78 },84 },
79 [getCurrentRouteThreadRef, handleNewThread, resolveThreadTarget],85 [getCurrentRouteThreadRef, resolveThreadTarget],
80 );86 );
8187
82 const unarchiveThread = useCallback(async (target: ScopedThreadRef) => {88 const unarchiveThread = useCallback(async (target: ScopedThreadRef) => {
apps/web/src/store.test.ts-303
...@@ -5,12 +5,10 @@ import {...@@ -5,12 +5,10 @@ import {
5 EnvironmentId,5 EnvironmentId,
6 EventId,6 EventId,
7 MessageId,7 MessageId,
8 type OrchestrationShellSnapshot,
9 ProjectId,8 ProjectId,
10 ThreadId,9 ThreadId,
11 TurnId,10 TurnId,
12 type OrchestrationEvent,11 type OrchestrationEvent,
13 type OrchestrationReadModel,
14} from "@t3tools/contracts";12} from "@t3tools/contracts";
15import { describe, expect, it } from "vitest";13import { describe, expect, it } from "vitest";
1614
...@@ -23,8 +21,6 @@ import {...@@ -23,8 +21,6 @@ import {
23 selectThreadExistsByRef,21 selectThreadExistsByRef,
24 setThreadBranch,22 setThreadBranch,
25 selectThreadsAcrossEnvironments,23 selectThreadsAcrossEnvironments,
26 syncServerReadModel,
27 syncServerShellSnapshot,
28 type AppState,24 type AppState,
29 type EnvironmentState,25 type EnvironmentState,
30} from "./store";26} from "./store";
...@@ -371,75 +367,6 @@ describe("thread selection memoization", () => {...@@ -371,75 +367,6 @@ describe("thread selection memoization", () => {
371 });367 });
372});368});
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
443describe("setThreadBranch", () => {370describe("setThreadBranch", () => {
444 it("updates only the scoped thread environment", () => {371 it("updates only the scoped thread environment", () => {
445 const sharedThreadId = ThreadId.make("thread-shared");372 const sharedThreadId = ThreadId.make("thread-shared");
...@@ -480,236 +407,6 @@ describe("setThreadBranch", () => {...@@ -480,236 +407,6 @@ describe("setThreadBranch", () => {
480 });407 });
481});408});
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
713describe("incremental orchestration updates", () => {410describe("incremental orchestration updates", () => {
714 it("does not mark bootstrap complete for incremental events", () => {411 it("does not mark bootstrap complete for incremental events", () => {
715 const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), {412 const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), {
apps/web/src/store.ts+140-225
...@@ -3,6 +3,7 @@ import type {...@@ -3,6 +3,7 @@ import type {
3 MessageId,3 MessageId,
4 OrchestrationCheckpointSummary,4 OrchestrationCheckpointSummary,
5 OrchestrationEvent,5 OrchestrationEvent,
6 OrchestrationLatestTurn,
6 OrchestrationMessage,7 OrchestrationMessage,
7 OrchestrationProposedPlan,8 OrchestrationProposedPlan,
8 OrchestrationReadModel,9 OrchestrationReadModel,
...@@ -22,12 +23,6 @@ import type {...@@ -22,12 +23,6 @@ import type {
22} from "@t3tools/contracts";23} from "@t3tools/contracts";
23import { resolveModelSlugForProvider } from "@t3tools/shared/model";24import { resolveModelSlugForProvider } from "@t3tools/shared/model";
24import { create } from "zustand";25import { create } from "zustand";
25import {
26 derivePendingApprovals,
27 derivePendingUserInputs,
28 findLatestProposedPlan,
29 hasActionableProposedPlan,
30} from "./session-logic";
31import {26import {
32 type ChatMessage,27 type ChatMessage,
33 type Project,28 type Project,
...@@ -46,11 +41,33 @@ import { getThreadFromEnvironmentState } from "./threadDerivation";...@@ -46,11 +41,33 @@ import { getThreadFromEnvironmentState } from "./threadDerivation";
46export interface EnvironmentState {41export interface EnvironmentState {
47 projectIds: ProjectId[];42 projectIds: ProjectId[];
48 projectById: Record<ProjectId, Project>;43 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 // ---------------------------------------------------------------------------
49 threadIds: ThreadId[];50 threadIds: ThreadId[];
50 threadIdsByProjectId: Record<ProjectId, ThreadId[]>;51 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 // ---------------------------------------------------------------------------
51 threadShellById: Record<ThreadId, ThreadShell>;62 threadShellById: Record<ThreadId, ThreadShell>;
52 threadSessionById: Record<ThreadId, ThreadSession | null>;63 threadSessionById: Record<ThreadId, ThreadSession | null>;
53 threadTurnStateById: Record<ThreadId, ThreadTurnState>;64 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 // ---------------------------------------------------------------------------
54 messageIdsByThreadId: Record<ThreadId, MessageId[]>;71 messageIdsByThreadId: Record<ThreadId, MessageId[]>;
55 messageByThreadId: Record<ThreadId, Record<MessageId, ChatMessage>>;72 messageByThreadId: Record<ThreadId, Record<MessageId, ChatMessage>>;
56 activityIdsByThreadId: Record<ThreadId, string[]>;73 activityIdsByThreadId: Record<ThreadId, string[]>;
...@@ -59,7 +76,16 @@ export interface EnvironmentState {...@@ -59,7 +76,16 @@ export interface EnvironmentState {
59 proposedPlanByThreadId: Record<ThreadId, Record<string, ProposedPlan>>;76 proposedPlanByThreadId: Record<ThreadId, Record<string, ProposedPlan>>;
60 turnDiffIdsByThreadId: Record<ThreadId, TurnId[]>;77 turnDiffIdsByThreadId: Record<ThreadId, TurnId[]>;
61 turnDiffSummaryByThreadId: Record<ThreadId, Record<TurnId, TurnDiffSummary>>;78 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 // ---------------------------------------------------------------------------
62 sidebarThreadSummaryById: Record<ThreadId, SidebarThreadSummary>;87 sidebarThreadSummaryById: Record<ThreadId, SidebarThreadSummary>;
88
63 bootstrapComplete: boolean;89 bootstrapComplete: boolean;
64}90}
6591
...@@ -308,40 +334,47 @@ function toThreadTurnState(thread: Thread): ThreadTurnState {...@@ -308,40 +334,47 @@ function toThreadTurnState(thread: Thread): ThreadTurnState {
308 };334 };
309}335}
310336
311function getLatestUserMessageAt(messages: ReadonlyArray<ChatMessage>): string | null {337function sourceProposedPlansEqual(
312 let latestUserMessageAt: string | null = null;338 left: OrchestrationLatestTurn["sourceProposedPlan"] | undefined,
313 for (const message of messages) {339 right: OrchestrationLatestTurn["sourceProposedPlan"] | undefined,
314 if (message.role !== "user") {340): boolean {
315 continue;341 if (left === right) return true;
316 }342 if (left === undefined || right === undefined) return false;
317 if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) {343 return left.threadId === right.threadId && left.planId === right.planId;
318 latestUserMessageAt = message.createdAt;
319 }
320 }
321 return latestUserMessageAt;
322}344}
323345
324function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary {346function latestTurnsEqual(
325 return {347 left: OrchestrationLatestTurn | null | undefined,
326 id: thread.id,348 right: OrchestrationLatestTurn | null | undefined,
327 environmentId: thread.environmentId,349): boolean {
328 projectId: thread.projectId,350 if (left === right) return true;
329 title: thread.title,351 if (left == null || right == null) return false;
330 interactionMode: thread.interactionMode,352 return (
331 session: thread.session,353 left.turnId === right.turnId &&
332 createdAt: thread.createdAt,354 left.state === right.state &&
333 archivedAt: thread.archivedAt,355 left.requestedAt === right.requestedAt &&
334 updatedAt: thread.updatedAt,356 left.startedAt === right.startedAt &&
335 latestTurn: thread.latestTurn,357 left.completedAt === right.completedAt &&
336 branch: thread.branch,358 left.assistantMessageId === right.assistantMessageId &&
337 worktreePath: thread.worktreePath,359 sourceProposedPlansEqual(left.sourceProposedPlan, right.sourceProposedPlan)
338 latestUserMessageAt: getLatestUserMessageAt(thread.messages),360 );
339 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,361}
340 hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,362
341 hasActionableProposedPlan: hasActionableProposedPlan(363function threadSessionsEqual(
342 findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null),364 left: ThreadSession | null | undefined,
343 ),365 right: ThreadSession | null | undefined,
344 };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 );
345}378}
346379
347function sidebarThreadSummariesEqual(380function sidebarThreadSummariesEqual(
...@@ -354,11 +387,11 @@ function sidebarThreadSummariesEqual(...@@ -354,11 +387,11 @@ function sidebarThreadSummariesEqual(
354 left.projectId === right.projectId &&387 left.projectId === right.projectId &&
355 left.title === right.title &&388 left.title === right.title &&
356 left.interactionMode === right.interactionMode &&389 left.interactionMode === right.interactionMode &&
357 left.session === right.session &&390 threadSessionsEqual(left.session, right.session) &&
358 left.createdAt === right.createdAt &&391 left.createdAt === right.createdAt &&
359 left.archivedAt === right.archivedAt &&392 left.archivedAt === right.archivedAt &&
360 left.updatedAt === right.updatedAt &&393 left.updatedAt === right.updatedAt &&
361 left.latestTurn === right.latestTurn &&394 latestTurnsEqual(left.latestTurn, right.latestTurn) &&
362 left.branch === right.branch &&395 left.branch === right.branch &&
363 left.worktreePath === right.worktreePath &&396 left.worktreePath === right.worktreePath &&
364 left.latestUserMessageAt === right.latestUserMessageAt &&397 left.latestUserMessageAt === right.latestUserMessageAt &&
...@@ -391,8 +424,8 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b...@@ -391,8 +424,8 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b
391function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean {424function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean {
392 return (425 return (
393 left !== undefined &&426 left !== undefined &&
394 left.latestTurn === right.latestTurn &&427 latestTurnsEqual(left.latestTurn, right.latestTurn) &&
395 left.pendingSourceProposedPlan === right.pendingSourceProposedPlan428 sourceProposedPlansEqual(left.pendingSourceProposedPlan, right.pendingSourceProposedPlan)
396 );429 );
397}430}
398431
...@@ -466,34 +499,32 @@ function getThreads(state: EnvironmentState): Thread[] {...@@ -466,34 +499,32 @@ function getThreads(state: EnvironmentState): Thread[] {
466 });499 });
467}500}
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(
470 state: EnvironmentState,509 state: EnvironmentState,
471 nextThread: Thread,510 threadId: ThreadId,
472 previousThread?: Thread,511 nextProjectId: ProjectId,
512 previousProjectId: ProjectId | undefined,
473): EnvironmentState {513): 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
481 let nextState = state;514 let nextState = state;
482515
483 if (!state.threadIds.includes(nextThread.id)) {516 if (!state.threadIds.includes(threadId)) {
484 nextState = {517 nextState = {
485 ...nextState,518 ...nextState,
486 threadIds: [...nextState.threadIds, nextThread.id],519 threadIds: [...nextState.threadIds, threadId],
487 };520 };
488 }521 }
489522
490 const previousProjectId = previousThread?.projectId;
491 const nextProjectId = nextThread.projectId;
492 if (previousProjectId !== nextProjectId) {523 if (previousProjectId !== nextProjectId) {
493 let threadIdsByProjectId = nextState.threadIdsByProjectId;524 let threadIdsByProjectId = nextState.threadIdsByProjectId;
494 if (previousProjectId) {525 if (previousProjectId) {
495 const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS;526 const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS;
496 const nextIds = removeId(previousIds, nextThread.id);527 const nextIds = removeId(previousIds, threadId);
497 if (nextIds.length === 0) {528 if (nextIds.length === 0) {
498 const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId;529 const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId;
499 threadIdsByProjectId = rest as Record<ProjectId, ThreadId[]>;530 threadIdsByProjectId = rest as Record<ProjectId, ThreadId[]>;
...@@ -505,7 +536,7 @@ function writeThreadState(...@@ -505,7 +536,7 @@ function writeThreadState(
505 }536 }
506 }537 }
507 const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS;538 const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS;
508 const nextProjectThreadIds = appendId(projectThreadIds, nextThread.id);539 const nextProjectThreadIds = appendId(projectThreadIds, threadId);
509 if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) {540 if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) {
510 threadIdsByProjectId = {541 threadIdsByProjectId = {
511 ...threadIdsByProjectId,542 ...threadIdsByProjectId,
...@@ -520,6 +551,36 @@ function writeThreadState(...@@ -520,6 +551,36 @@ function writeThreadState(
520 }551 }
521 }552 }
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
523 if (!threadShellsEqual(previousShell, nextShell)) {584 if (!threadShellsEqual(previousShell, nextShell)) {
524 nextState = {585 nextState = {
525 ...nextState,586 ...nextState,
...@@ -530,7 +591,7 @@ function writeThreadState(...@@ -530,7 +591,7 @@ function writeThreadState(
530 };591 };
531 }592 }
532593
533 if ((previousThread?.session ?? null) !== nextThread.session) {594 if (!threadSessionsEqual(previousThread?.session ?? null, nextThread.session)) {
534 nextState = {595 nextState = {
535 ...nextState,596 ...nextState,
536 threadSessionById: {597 threadSessionById: {
...@@ -610,19 +671,20 @@ function writeThreadState(...@@ -610,19 +671,20 @@ function writeThreadState(
610 };671 };
611 }672 }
612673
613 if (!sidebarThreadSummariesEqual(previousSummary, nextSummary)) {
614 nextState = {
615 ...nextState,
616 sidebarThreadSummaryById: {
617 ...nextState.sidebarThreadSummaryById,
618 [nextThread.id]: nextSummary,
619 },
620 };
621 }
622
623 return nextState;674 return nextState;
624}675}
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 */
626function writeThreadShellState(688function writeThreadShellState(
627 state: EnvironmentState,689 state: EnvironmentState,
628 nextThread: {690 nextThread: {
...@@ -632,49 +694,14 @@ function writeThreadShellState(...@@ -632,49 +694,14 @@ function writeThreadShellState(
632 summary: SidebarThreadSummary;694 summary: SidebarThreadSummary;
633 },695 },
634): EnvironmentState {696): EnvironmentState {
635 let nextState = state;
636 const previousShell = state.threadShellById[nextThread.shell.id];697 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)) {699 let nextState = ensureThreadRegistered(
641 nextState = {700 state,
642 ...nextState,701 nextThread.shell.id,
643 threadIds: [...nextState.threadIds, nextThread.shell.id],702 nextThread.shell.projectId,
644 };703 previousShell?.projectId,
645 }704 );
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 }
678705
679 if (!threadShellsEqual(previousShell, nextThread.shell)) {706 if (!threadShellsEqual(previousShell, nextThread.shell)) {
680 nextState = {707 nextState = {
...@@ -686,7 +713,9 @@ function writeThreadShellState(...@@ -686,7 +713,9 @@ function writeThreadShellState(
686 };713 };
687 }714 }
688715
689 if ((state.threadSessionById[nextThread.shell.id] ?? null) !== nextThread.session) {716 if (
717 !threadSessionsEqual(state.threadSessionById[nextThread.shell.id] ?? null, nextThread.session)
718 ) {
690 nextState = {719 nextState = {
691 ...nextState,720 ...nextState,
692 threadSessionById: {721 threadSessionById: {
...@@ -1009,82 +1038,6 @@ function buildProjectState(...@@ -1009,82 +1038,6 @@ function buildProjectState(
1009 };1038 };
1010}1039}
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
1088function getStoredEnvironmentState(1041function getStoredEnvironmentState(
1089 state: AppState,1042 state: AppState,
1090 environmentId: EnvironmentId,1043 environmentId: EnvironmentId,
...@@ -1116,25 +1069,6 @@ function commitEnvironmentState(...@@ -1116,25 +1069,6 @@ function commitEnvironmentState(
1116 };1069 };
1117}1070}
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
1138function syncEnvironmentShellSnapshot(1072function syncEnvironmentShellSnapshot(
1139 state: EnvironmentState,1073 state: EnvironmentState,
1140 snapshot: OrchestrationShellSnapshot,1074 snapshot: OrchestrationShellSnapshot,
...@@ -1175,22 +1109,6 @@ function syncEnvironmentShellSnapshot(...@@ -1175,22 +1109,6 @@ function syncEnvironmentShellSnapshot(
1175 return nextState;1109 return nextState;
1176}1110}
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
1194export function syncServerShellSnapshot(1112export function syncServerShellSnapshot(
1195 state: AppState,1113 state: AppState,
1196 snapshot: OrchestrationShellSnapshot,1114 snapshot: OrchestrationShellSnapshot,
...@@ -2007,7 +1925,6 @@ export function setThreadBranch(...@@ -2007,7 +1925,6 @@ export function setThreadBranch(
20071925
2008interface AppStore extends AppState {1926interface AppStore extends AppState {
2009 setActiveEnvironmentId: (environmentId: EnvironmentId) => void;1927 setActiveEnvironmentId: (environmentId: EnvironmentId) => void;
2010 syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void;
2011 syncServerShellSnapshot: (1928 syncServerShellSnapshot: (
2012 snapshot: OrchestrationShellSnapshot,1929 snapshot: OrchestrationShellSnapshot,
2013 environmentId: EnvironmentId,1930 environmentId: EnvironmentId,
...@@ -2031,8 +1948,6 @@ export const useStore = create<AppStore>((set) => ({...@@ -2031,8 +1948,6 @@ export const useStore = create<AppStore>((set) => ({
2031 ...initialState,1948 ...initialState,
2032 setActiveEnvironmentId: (environmentId) =>1949 setActiveEnvironmentId: (environmentId) =>
2033 set((state) => setActiveEnvironmentId(state, environmentId)),1950 set((state) => setActiveEnvironmentId(state, environmentId)),
2034 syncServerReadModel: (readModel, environmentId) =>
2035 set((state) => syncServerReadModel(state, readModel, environmentId)),
2036 syncServerShellSnapshot: (snapshot, environmentId) =>1951 syncServerShellSnapshot: (snapshot, environmentId) =>
2037 set((state) => syncServerShellSnapshot(state, snapshot, environmentId)),1952 set((state) => syncServerShellSnapshot(state, snapshot, environmentId)),
2038 syncServerThreadDetail: (thread, environmentId) =>1953 syncServerThreadDetail: (thread, environmentId) =>
apps/web/src/storeSelectors.ts+1-10
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";1import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
2import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store";2import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store";
3import { type Project, type SidebarThreadSummary, type Thread } from "./types";3import { type Project, type Thread } from "./types";
4import { getThreadFromEnvironmentState } from "./threadDerivation";4import { getThreadFromEnvironmentState } from "./threadDerivation";
55
6export function createProjectSelectorByRef(6export function createProjectSelectorByRef(
...@@ -10,15 +10,6 @@ export function createProjectSelectorByRef(...@@ -10,15 +10,6 @@ export function createProjectSelectorByRef(
10 ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined;10 ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined;
11}11}
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
22function createScopedThreadSelector(13function createScopedThreadSelector(
23 resolveRef: (state: AppState) => ScopedThreadRef | null | undefined,14 resolveRef: (state: AppState) => ScopedThreadRef | null | undefined,
24): (state: AppState) => Thread | undefined {15): (state: AppState) => Thread | undefined {
apps/web/src/timestampFormat.ts+1-2
...@@ -57,8 +57,7 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st...@@ -57,8 +57,7 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st
57 const diffMs = Date.now() - new Date(isoDate).getTime();57 const diffMs = Date.now() - new Date(isoDate).getTime();
58 if (diffMs < 0) return { value: "just now", suffix: null };58 if (diffMs < 0) return { value: "just now", suffix: null };
59 const seconds = Math.floor(diffMs / 1000);59 const seconds = Math.floor(diffMs / 1000);
60 if (seconds < 5) return { value: "just now", suffix: null };60 if (seconds < 60) return { value: "just now", suffix: null };
61 if (seconds < 60) return { value: `${seconds}s`, suffix: "ago" };
62 const minutes = Math.floor(seconds / 60);61 const minutes = Math.floor(seconds / 60);
63 if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" };62 if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" };
64 const hours = Math.floor(minutes / 60);63 const hours = Math.floor(minutes / 60);