diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index c29f1271087..7524ef859d8 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -114,7 +114,9 @@ export function buildThreadActionItems(input: { searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""], title: thread.title, description: descriptionParts.join(" · "), - timestamp: formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt), + timestamp: formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + ), icon: input.icon, run: async () => { await input.runThread(thread); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2495429efd8..787a027b867 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -745,7 +745,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP : "text-muted-foreground/40" }`} > - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} + {formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + )} )} @@ -1101,6 +1103,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ), [allSidebarThreads], ); + // Keep a ref so callbacks can read the latest map without appearing in + // dependency arrays (avoids invalidating every thread-row memo on each + // thread-list change). + const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); + sidebarThreadByKeyRef.current = sidebarThreadByKey; // All threads from the representative + other member environments are // already fetched into allSidebarThreads, so we can use them directly. const projectThreads = allSidebarThreads; @@ -1444,7 +1451,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (clicked === "mark-unread") { for (const threadKey of threadKeys) { - const thread = sidebarThreadByKey.get(threadKey); + const thread = sidebarThreadByKeyRef.current.get(threadKey); markThreadUnread(threadKey, thread?.latestTurn?.completedAt); } clearSelection(); @@ -1465,7 +1472,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const deletedThreadKeys = new Set(threadKeys); for (const threadKey of threadKeys) { - const thread = sidebarThreadByKey.get(threadKey); + const thread = sidebarThreadByKeyRef.current.get(threadKey); if (!thread) continue; await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { deletedThreadKeys, @@ -1479,7 +1486,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec deleteThread, markThreadUnread, removeFromSelection, - sidebarThreadByKey, ], ); @@ -1608,12 +1614,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const api = readLocalApi(); if (!api) return; const threadKey = scopedThreadKey(threadRef); - const thread = - projectThreads.find( - (projectThread) => - projectThread.environmentId === threadRef.environmentId && - projectThread.id === threadRef.threadId, - ) ?? null; + const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null; if (!thread) return; const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null; const clicked = await api.contextMenu.show( @@ -1675,7 +1676,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec deleteThread, markThreadUnread, project.cwd, - projectThreads, ], ); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 4835edd4d19..60b1be85a70 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -2,7 +2,7 @@ import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/ import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "@tanstack/react-router"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; @@ -33,6 +33,12 @@ export function useThreadActions() { const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); const router = useRouter(); const { handleNewThread } = useNewThreadHandler(); + // Keep a ref so archiveThread can call handleNewThread without appearing in + // its dependency array — handleNewThread is inherently unstable (depends on + // the projects list) and would otherwise cascade new references into every + // sidebar row via archiveThread → attemptArchiveThread. + const handleNewThreadRef = useRef(handleNewThread); + handleNewThreadRef.current = handleNewThread; const queryClient = useQueryClient(); const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { @@ -73,10 +79,10 @@ export function useThreadActions() { currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId ) { - await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId)); + await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); } }, - [getCurrentRouteThreadRef, handleNewThread, resolveThreadTarget], + [getCurrentRouteThreadRef, resolveThreadTarget], ); const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 0673f48c2f0..9bb01ba0be3 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -5,12 +5,10 @@ import { EnvironmentId, EventId, MessageId, - type OrchestrationShellSnapshot, ProjectId, ThreadId, TurnId, type OrchestrationEvent, - type OrchestrationReadModel, } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; @@ -23,8 +21,6 @@ import { selectThreadExistsByRef, setThreadBranch, selectThreadsAcrossEnvironments, - syncServerReadModel, - syncServerShellSnapshot, type AppState, type EnvironmentState, } from "./store"; @@ -371,75 +367,6 @@ describe("thread selection memoization", () => { }); }); -function makeReadModelThread(overrides: Partial) { - return { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - }, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - archivedAt: null, - deletedAt: null, - messages: [], - activities: [], - proposedPlans: [], - checkpoints: [], - session: null, - ...overrides, - } satisfies OrchestrationReadModel["threads"][number]; -} - -function makeReadModel(thread: OrchestrationReadModel["threads"][number]): OrchestrationReadModel { - return { - snapshotSequence: 1, - updatedAt: "2026-02-27T00:00:00.000Z", - projects: [ - { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/tmp/project", - defaultModelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - deletedAt: null, - scripts: [], - }, - ], - threads: [thread], - }; -} - -function makeReadModelProject( - overrides: Partial, -): OrchestrationReadModel["projects"][number] { - return { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/tmp/project", - defaultModelSelection: { - provider: "codex", - model: "gpt-5.3-codex", - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - deletedAt: null, - scripts: [], - ...overrides, - }; -} - describe("setThreadBranch", () => { it("updates only the scoped thread environment", () => { const sharedThreadId = ThreadId.make("thread-shared"); @@ -480,236 +407,6 @@ describe("setThreadBranch", () => { }); }); -describe("store read model sync", () => { - it("marks bootstrap complete after snapshot sync", () => { - const initialState = withActiveEnvironmentState( - localEnvironmentStateOf(makeState(makeThread())), - { - bootstrapComplete: false, - }, - ); - - const next = syncServerReadModel( - initialState, - makeReadModel(makeReadModelThread({})), - localEnvironmentId, - ); - - expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true); - }); - - it("updates shell state without discarding hydrated thread detail", () => { - const initialState = makeState( - makeThread({ - title: "Initial thread", - messages: [ - { - id: MessageId.make("message-1"), - role: "assistant", - text: "hydrated body", - createdAt: "2026-02-13T00:00:01.000Z", - completedAt: "2026-02-13T00:00:01.000Z", - streaming: false, - }, - ], - }), - ); - const shellSnapshot: OrchestrationShellSnapshot = { - snapshotSequence: 2, - projects: [ - { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/tmp/project", - repositoryIdentity: null, - defaultModelSelection: { - provider: "codex", - model: "gpt-5-codex", - }, - scripts: [], - createdAt: "2026-02-13T00:00:00.000Z", - updatedAt: "2026-02-13T00:00:00.000Z", - }, - ], - threads: [ - { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Renamed thread", - modelSelection: { - provider: "codex", - model: "gpt-5-codex", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: "feature/renamed", - worktreePath: null, - latestTurn: null, - createdAt: "2026-02-13T00:00:00.000Z", - updatedAt: "2026-02-13T00:00:02.000Z", - archivedAt: null, - session: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }, - ], - updatedAt: "2026-02-13T00:00:02.000Z", - }; - - const next = syncServerShellSnapshot(initialState, shellSnapshot, localEnvironmentId); - const thread = selectThreadByRef( - next, - scopeThreadRef(localEnvironmentId, ThreadId.make("thread-1")), - ); - - expect(thread?.title).toBe("Renamed thread"); - expect(thread?.branch).toBe("feature/renamed"); - expect(thread?.messages).toEqual([ - expect.objectContaining({ - id: MessageId.make("message-1"), - text: "hydrated body", - }), - ]); - expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true); - }); - - it("preserves claude model slugs without an active session", () => { - const initialState = makeState(makeThread()); - const readModel = makeReadModel( - makeReadModelThread({ - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - }, - }), - ); - - const next = syncServerReadModel(initialState, readModel, localEnvironmentId); - - expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); - }); - - it("resolves claude aliases when session provider is claudeAgent", () => { - const initialState = makeState(makeThread()); - const readModel = makeReadModel( - makeReadModelThread({ - modelSelection: { - provider: "claudeAgent", - model: "sonnet", - }, - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "claudeAgent", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: "2026-02-27T00:00:00.000Z", - }, - }), - ); - - const next = syncServerReadModel(initialState, readModel, localEnvironmentId); - - expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); - }); - - it("preserves project and thread updatedAt timestamps from the read model", () => { - const initialState = makeState(makeThread()); - const readModel = makeReadModel( - makeReadModelThread({ - updatedAt: "2026-02-27T00:05:00.000Z", - }), - ); - - const next = syncServerReadModel(initialState, readModel, localEnvironmentId); - - expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); - expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); - }); - - it("maps archivedAt from the read model", () => { - const initialState = makeState(makeThread()); - const archivedAt = "2026-02-28T00:00:00.000Z"; - const next = syncServerReadModel( - initialState, - makeReadModel( - makeReadModelThread({ - archivedAt, - }), - ), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); - }); - - it("replaces projects using snapshot order during recovery", () => { - const project1 = ProjectId.make("project-1"); - const project2 = ProjectId.make("project-2"); - const project3 = ProjectId.make("project-3"); - const initialState: AppState = makeEmptyState({ - projectIds: [project2, project1], - projectById: { - [project2]: { - id: project2, - environmentId: localEnvironmentId, - name: "Project 2", - cwd: "/tmp/project-2", - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - [project1]: { - id: project1, - environmentId: localEnvironmentId, - name: "Project 1", - cwd: "/tmp/project-1", - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - }, - }); - const readModel: OrchestrationReadModel = { - snapshotSequence: 2, - updatedAt: "2026-02-27T00:00:00.000Z", - projects: [ - makeReadModelProject({ - id: project1, - title: "Project 1", - workspaceRoot: "/tmp/project-1", - }), - makeReadModelProject({ - id: project2, - title: "Project 2", - workspaceRoot: "/tmp/project-2", - }), - makeReadModelProject({ - id: project3, - title: "Project 3", - workspaceRoot: "/tmp/project-3", - }), - ], - threads: [], - }; - - const next = syncServerReadModel(initialState, readModel, localEnvironmentId); - - expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); - }); -}); - describe("incremental orchestration updates", () => { it("does not mark bootstrap complete for incremental events", () => { const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 092593b06d2..ec326024807 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -3,6 +3,7 @@ import type { MessageId, OrchestrationCheckpointSummary, OrchestrationEvent, + OrchestrationLatestTurn, OrchestrationMessage, OrchestrationProposedPlan, OrchestrationReadModel, @@ -22,12 +23,6 @@ import type { } from "@t3tools/contracts"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; -import { - derivePendingApprovals, - derivePendingUserInputs, - findLatestProposedPlan, - hasActionableProposedPlan, -} from "./session-logic"; import { type ChatMessage, type Project, @@ -46,11 +41,33 @@ import { getThreadFromEnvironmentState } from "./threadDerivation"; export interface EnvironmentState { projectIds: ProjectId[]; projectById: Record; + + // --------------------------------------------------------------------------- + // Thread bookkeeping — written by BOTH shell stream and detail stream. + // Both streams ensure the thread is registered here; the bookkeeping is + // additive (append-only IDs) so concurrent writes are safe. + // --------------------------------------------------------------------------- threadIds: ThreadId[]; threadIdsByProjectId: Record; + + // --------------------------------------------------------------------------- + // Thread shell / session / turn — written by BOTH shell stream and detail + // stream. The shell stream is the *authoritative* source (server pre- + // computes these from the projection pipeline), but the detail stream also + // writes them so the active thread has up-to-date state even if the shell + // event hasn't arrived yet. Structural equality checks in both write + // functions prevent unnecessary React re-renders when both streams deliver + // equivalent data. + // --------------------------------------------------------------------------- threadShellById: Record; threadSessionById: Record; threadTurnStateById: Record; + + // --------------------------------------------------------------------------- + // Thread detail content — written ONLY by the detail stream + // (writeThreadState / syncServerThreadDetail). The shell stream never + // touches these. + // --------------------------------------------------------------------------- messageIdsByThreadId: Record; messageByThreadId: Record>; activityIdsByThreadId: Record; @@ -59,7 +76,16 @@ export interface EnvironmentState { proposedPlanByThreadId: Record>; turnDiffIdsByThreadId: Record; turnDiffSummaryByThreadId: Record>; + + // --------------------------------------------------------------------------- + // Sidebar summary — written ONLY by the shell stream + // (writeThreadShellState / mapThreadShell). Pre-computed server-side with + // fields like latestUserMessageAt, hasPendingApprovals, etc. The detail + // stream must NOT write here; the shell stream is the single source of + // truth for sidebar data. + // --------------------------------------------------------------------------- sidebarThreadSummaryById: Record; + bootstrapComplete: boolean; } @@ -308,40 +334,47 @@ function toThreadTurnState(thread: Thread): ThreadTurnState { }; } -function getLatestUserMessageAt(messages: ReadonlyArray): string | null { - let latestUserMessageAt: string | null = null; - for (const message of messages) { - if (message.role !== "user") { - continue; - } - if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) { - latestUserMessageAt = message.createdAt; - } - } - return latestUserMessageAt; +function sourceProposedPlansEqual( + left: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, + right: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, +): boolean { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + return left.threadId === right.threadId && left.planId === right.planId; } -function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { - return { - id: thread.id, - environmentId: thread.environmentId, - projectId: thread.projectId, - title: thread.title, - interactionMode: thread.interactionMode, - session: thread.session, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - branch: thread.branch, - worktreePath: thread.worktreePath, - latestUserMessageAt: getLatestUserMessageAt(thread.messages), - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - hasActionableProposedPlan: hasActionableProposedPlan( - findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null), - ), - }; +function latestTurnsEqual( + left: OrchestrationLatestTurn | null | undefined, + right: OrchestrationLatestTurn | null | undefined, +): boolean { + if (left === right) return true; + if (left == null || right == null) return false; + return ( + left.turnId === right.turnId && + left.state === right.state && + left.requestedAt === right.requestedAt && + left.startedAt === right.startedAt && + left.completedAt === right.completedAt && + left.assistantMessageId === right.assistantMessageId && + sourceProposedPlansEqual(left.sourceProposedPlan, right.sourceProposedPlan) + ); +} + +function threadSessionsEqual( + left: ThreadSession | null | undefined, + right: ThreadSession | null | undefined, +): boolean { + if (left === right) return true; + if (left == null || right == null) return false; + return ( + left.provider === right.provider && + left.status === right.status && + left.orchestrationStatus === right.orchestrationStatus && + left.activeTurnId === right.activeTurnId && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + left.lastError === right.lastError + ); } function sidebarThreadSummariesEqual( @@ -354,11 +387,11 @@ function sidebarThreadSummariesEqual( left.projectId === right.projectId && left.title === right.title && left.interactionMode === right.interactionMode && - left.session === right.session && + threadSessionsEqual(left.session, right.session) && left.createdAt === right.createdAt && left.archivedAt === right.archivedAt && left.updatedAt === right.updatedAt && - left.latestTurn === right.latestTurn && + latestTurnsEqual(left.latestTurn, right.latestTurn) && left.branch === right.branch && left.worktreePath === right.worktreePath && left.latestUserMessageAt === right.latestUserMessageAt && @@ -391,8 +424,8 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean { return ( left !== undefined && - left.latestTurn === right.latestTurn && - left.pendingSourceProposedPlan === right.pendingSourceProposedPlan + latestTurnsEqual(left.latestTurn, right.latestTurn) && + sourceProposedPlansEqual(left.pendingSourceProposedPlan, right.pendingSourceProposedPlan) ); } @@ -466,34 +499,32 @@ function getThreads(state: EnvironmentState): Thread[] { }); } -function writeThreadState( +/** + * Ensure a thread is registered in the bookkeeping indices (threadIds, + * threadIdsByProjectId). Shared by both the shell stream and detail stream + * write paths — the bookkeeping is additive (append-only IDs) so concurrent + * writes from both streams are safe. + */ +function ensureThreadRegistered( state: EnvironmentState, - nextThread: Thread, - previousThread?: Thread, + threadId: ThreadId, + nextProjectId: ProjectId, + previousProjectId: ProjectId | undefined, ): EnvironmentState { - const nextShell = toThreadShell(nextThread); - const nextTurnState = toThreadTurnState(nextThread); - const previousShell = state.threadShellById[nextThread.id]; - const previousTurnState = state.threadTurnStateById[nextThread.id]; - const previousSummary = state.sidebarThreadSummaryById[nextThread.id]; - const nextSummary = buildSidebarThreadSummary(nextThread); - let nextState = state; - if (!state.threadIds.includes(nextThread.id)) { + if (!state.threadIds.includes(threadId)) { nextState = { ...nextState, - threadIds: [...nextState.threadIds, nextThread.id], + threadIds: [...nextState.threadIds, threadId], }; } - const previousProjectId = previousThread?.projectId; - const nextProjectId = nextThread.projectId; if (previousProjectId !== nextProjectId) { let threadIdsByProjectId = nextState.threadIdsByProjectId; if (previousProjectId) { const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; - const nextIds = removeId(previousIds, nextThread.id); + const nextIds = removeId(previousIds, threadId); if (nextIds.length === 0) { const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; threadIdsByProjectId = rest as Record; @@ -505,7 +536,7 @@ function writeThreadState( } } const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; - const nextProjectThreadIds = appendId(projectThreadIds, nextThread.id); + const nextProjectThreadIds = appendId(projectThreadIds, threadId); if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { threadIdsByProjectId = { ...threadIdsByProjectId, @@ -520,6 +551,36 @@ function writeThreadState( } } + return nextState; +} + +/** + * Write thread state from the **detail stream** (per-thread subscription). + * + * Owns: messages, activities, proposed plans, turn diff summaries. + * Also writes threadShellById / threadSessionById / threadTurnStateById so + * the active thread has up-to-date state even if the shell stream event + * hasn't arrived yet (both streams use structural equality checks to avoid + * unnecessary re-renders when delivering equivalent data). + * Does NOT write sidebarThreadSummaryById — that is shell-stream-only. + */ +function writeThreadState( + state: EnvironmentState, + nextThread: Thread, + previousThread?: Thread, +): EnvironmentState { + const nextShell = toThreadShell(nextThread); + const nextTurnState = toThreadTurnState(nextThread); + const previousShell = state.threadShellById[nextThread.id]; + const previousTurnState = state.threadTurnStateById[nextThread.id]; + + let nextState = ensureThreadRegistered( + state, + nextThread.id, + nextThread.projectId, + previousThread?.projectId, + ); + if (!threadShellsEqual(previousShell, nextShell)) { nextState = { ...nextState, @@ -530,7 +591,7 @@ function writeThreadState( }; } - if ((previousThread?.session ?? null) !== nextThread.session) { + if (!threadSessionsEqual(previousThread?.session ?? null, nextThread.session)) { nextState = { ...nextState, threadSessionById: { @@ -610,19 +671,20 @@ function writeThreadState( }; } - if (!sidebarThreadSummariesEqual(previousSummary, nextSummary)) { - nextState = { - ...nextState, - sidebarThreadSummaryById: { - ...nextState.sidebarThreadSummaryById, - [nextThread.id]: nextSummary, - }, - }; - } - return nextState; } +/** + * Write thread state from the **shell stream** (all-threads subscription). + * + * Owns: sidebarThreadSummaryById (pre-computed server-side sidebar data). + * Also writes threadShellById / threadSessionById / threadTurnStateById as + * the authoritative source for these fields. The detail stream may also + * write them for the focused thread (see writeThreadState); structural + * equality checks prevent unnecessary re-renders. + * Does NOT write message/activity/proposedPlan/turnDiff content — that is + * detail-stream-only. + */ function writeThreadShellState( state: EnvironmentState, nextThread: { @@ -632,49 +694,14 @@ function writeThreadShellState( summary: SidebarThreadSummary; }, ): EnvironmentState { - let nextState = state; const previousShell = state.threadShellById[nextThread.shell.id]; - const previousProjectId = previousShell?.projectId; - const nextProjectId = nextThread.shell.projectId; - if (!state.threadIds.includes(nextThread.shell.id)) { - nextState = { - ...nextState, - threadIds: [...nextState.threadIds, nextThread.shell.id], - }; - } - - if (previousProjectId !== nextProjectId) { - let threadIdsByProjectId = nextState.threadIdsByProjectId; - if (previousProjectId) { - const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; - const nextIds = removeId(previousIds, nextThread.shell.id); - if (nextIds.length === 0) { - const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; - threadIdsByProjectId = rest as Record; - } else if (!arraysEqual(previousIds, nextIds)) { - threadIdsByProjectId = { - ...threadIdsByProjectId, - [previousProjectId]: nextIds, - }; - } - } - - const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; - const nextProjectThreadIds = appendId(projectThreadIds, nextThread.shell.id); - if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { - threadIdsByProjectId = { - ...threadIdsByProjectId, - [nextProjectId]: nextProjectThreadIds, - }; - } - if (threadIdsByProjectId !== nextState.threadIdsByProjectId) { - nextState = { - ...nextState, - threadIdsByProjectId, - }; - } - } + let nextState = ensureThreadRegistered( + state, + nextThread.shell.id, + nextThread.shell.projectId, + previousShell?.projectId, + ); if (!threadShellsEqual(previousShell, nextThread.shell)) { nextState = { @@ -686,7 +713,9 @@ function writeThreadShellState( }; } - if ((state.threadSessionById[nextThread.shell.id] ?? null) !== nextThread.session) { + if ( + !threadSessionsEqual(state.threadSessionById[nextThread.shell.id] ?? null, nextThread.session) + ) { nextState = { ...nextState, threadSessionById: { @@ -1009,82 +1038,6 @@ function buildProjectState( }; } -function buildThreadState( - threads: ReadonlyArray, -): Pick< - EnvironmentState, - | "threadIds" - | "threadIdsByProjectId" - | "threadShellById" - | "threadSessionById" - | "threadTurnStateById" - | "messageIdsByThreadId" - | "messageByThreadId" - | "activityIdsByThreadId" - | "activityByThreadId" - | "proposedPlanIdsByThreadId" - | "proposedPlanByThreadId" - | "turnDiffIdsByThreadId" - | "turnDiffSummaryByThreadId" - | "sidebarThreadSummaryById" -> { - const threadIds: ThreadId[] = []; - const threadIdsByProjectId: Record = {}; - const threadShellById: Record = {}; - const threadSessionById: Record = {}; - const threadTurnStateById: Record = {}; - const messageIdsByThreadId: Record = {}; - const messageByThreadId: Record> = {}; - const activityIdsByThreadId: Record = {}; - const activityByThreadId: Record> = {}; - const proposedPlanIdsByThreadId: Record = {}; - const proposedPlanByThreadId: Record> = {}; - const turnDiffIdsByThreadId: Record = {}; - const turnDiffSummaryByThreadId: Record> = {}; - const sidebarThreadSummaryById: Record = {}; - - for (const thread of threads) { - threadIds.push(thread.id); - threadIdsByProjectId[thread.projectId] = [ - ...(threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS), - thread.id, - ]; - threadShellById[thread.id] = toThreadShell(thread); - threadSessionById[thread.id] = thread.session; - threadTurnStateById[thread.id] = toThreadTurnState(thread); - const messageSlice = buildMessageSlice(thread); - messageIdsByThreadId[thread.id] = messageSlice.ids; - messageByThreadId[thread.id] = messageSlice.byId; - const activitySlice = buildActivitySlice(thread); - activityIdsByThreadId[thread.id] = activitySlice.ids; - activityByThreadId[thread.id] = activitySlice.byId; - const proposedPlanSlice = buildProposedPlanSlice(thread); - proposedPlanIdsByThreadId[thread.id] = proposedPlanSlice.ids; - proposedPlanByThreadId[thread.id] = proposedPlanSlice.byId; - const turnDiffSlice = buildTurnDiffSlice(thread); - turnDiffIdsByThreadId[thread.id] = turnDiffSlice.ids; - turnDiffSummaryByThreadId[thread.id] = turnDiffSlice.byId; - sidebarThreadSummaryById[thread.id] = buildSidebarThreadSummary(thread); - } - - return { - threadIds, - threadIdsByProjectId, - threadShellById, - threadSessionById, - threadTurnStateById, - messageIdsByThreadId, - messageByThreadId, - activityIdsByThreadId, - activityByThreadId, - proposedPlanIdsByThreadId, - proposedPlanByThreadId, - turnDiffIdsByThreadId, - turnDiffSummaryByThreadId, - sidebarThreadSummaryById, - }; -} - function getStoredEnvironmentState( state: AppState, environmentId: EnvironmentId, @@ -1116,25 +1069,6 @@ function commitEnvironmentState( }; } -function syncEnvironmentReadModel( - state: EnvironmentState, - readModel: OrchestrationReadModel, - environmentId: EnvironmentId, -): EnvironmentState { - const projects = readModel.projects - .filter((project) => project.deletedAt === null) - .map((project) => mapProject(project, environmentId)); - const threads = readModel.threads - .filter((thread) => thread.deletedAt === null) - .map((thread) => mapThread(thread, environmentId)); - return { - ...state, - ...buildProjectState(projects), - ...buildThreadState(threads), - bootstrapComplete: true, - }; -} - function syncEnvironmentShellSnapshot( state: EnvironmentState, snapshot: OrchestrationShellSnapshot, @@ -1175,22 +1109,6 @@ function syncEnvironmentShellSnapshot( return nextState; } -export function syncServerReadModel( - state: AppState, - readModel: OrchestrationReadModel, - environmentId: EnvironmentId, -): AppState { - return commitEnvironmentState( - state, - environmentId, - syncEnvironmentReadModel( - getStoredEnvironmentState(state, environmentId), - readModel, - environmentId, - ), - ); -} - export function syncServerShellSnapshot( state: AppState, snapshot: OrchestrationShellSnapshot, @@ -2007,7 +1925,6 @@ export function setThreadBranch( interface AppStore extends AppState { setActiveEnvironmentId: (environmentId: EnvironmentId) => void; - syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; syncServerShellSnapshot: ( snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId, @@ -2031,8 +1948,6 @@ export const useStore = create((set) => ({ ...initialState, setActiveEnvironmentId: (environmentId) => set((state) => setActiveEnvironmentId(state, environmentId)), - syncServerReadModel: (readModel, environmentId) => - set((state) => syncServerReadModel(state, readModel, environmentId)), syncServerShellSnapshot: (snapshot, environmentId) => set((state) => syncServerShellSnapshot(state, snapshot, environmentId)), syncServerThreadDetail: (thread, environmentId) => diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index 02b88f31b18..95ed6ff1f41 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,6 +1,6 @@ import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; -import { type Project, type SidebarThreadSummary, type Thread } from "./types"; +import { type Project, type Thread } from "./types"; import { getThreadFromEnvironmentState } from "./threadDerivation"; export function createProjectSelectorByRef( @@ -10,15 +10,6 @@ export function createProjectSelectorByRef( ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined; } -export function createSidebarThreadSummarySelectorByRef( - ref: ScopedThreadRef | null | undefined, -): (state: AppState) => SidebarThreadSummary | undefined { - return (state) => - ref - ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] - : undefined; -} - function createScopedThreadSelector( resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, ): (state: AppState) => Thread | undefined { diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index c39e4a6c7d3..4cf47a2c398 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -57,8 +57,7 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st const diffMs = Date.now() - new Date(isoDate).getTime(); if (diffMs < 0) return { value: "just now", suffix: null }; const seconds = Math.floor(diffMs / 1000); - if (seconds < 5) return { value: "just now", suffix: null }; - if (seconds < 60) return { value: `${seconds}s`, suffix: "ago" }; + if (seconds < 60) return { value: "just now", suffix: null }; const minutes = Math.floor(seconds / 60); if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" }; const hours = Math.floor(minutes / 60);