Skip to content

Refactor web orchestration sync to incremental events and isolated recovery#1560

Open
juliusmarminge wants to merge 19 commits intomainfrom
t3code/ui-perf-regression
Open

Refactor web orchestration sync to incremental events and isolated recovery#1560
juliusmarminge wants to merge 19 commits intomainfrom
t3code/ui-perf-regression

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Mar 29, 2026

Summary

  • Replace steady-state orchestration snapshot refreshes with incremental event application, replay-based gap recovery, and snapshot-only bootstrap/fallback recovery.
  • Split UI-only project/thread metadata into a dedicated persisted UI store and narrow chat/sidebar subscriptions to reduce rerenders and simplify recovery merges.
  • Move draft promotion, terminal cleanup, and implementation-thread navigation onto event-driven flows so ChatView no longer depends on redundant getSnapshot() calls.
  • Simplify ChatView dispatch state so busy UI clears from server acknowledgement, and harden recovery/cache edge cases with targeted regression tests and review fixes.

Testing

  • bun fmt
  • bun lint
  • bun typecheck
  • bun run test --filter=@t3tools/web
  • cd apps/web && bun run test:browser -- src/components/ChatView.browser.tsx

Note

High Risk
Touches core state management and websocket event processing, including recovery logic and persisted UI state, so regressions could affect routing, thread/project lists, and live chat updates. Added complexity around sequencing/replay increases the chance of subtle edge-case bugs.

Overview
Orchestration syncing is refactored from “snapshot refresh on events” to incremental domain-event application with explicit recovery. EventRouter now applies batches via applyOrchestrationEvents, detects sequence gaps, replays missing events, and falls back to snapshot bootstrap/recovery using a new createOrchestrationRecoveryCoordinator.

UI-only metadata is moved out of the main app store into a new persisted useUiStateStore. Project expansion/order and thread last-visited/unread state are no longer stored/persisted in store types; components (Sidebar, ChatView, route guards) are updated to read/write UI state from the new store and use narrower selectors (storeSelectors).

Chat/Sidebar behaviors are adjusted to be event-driven and less snapshot-dependent. Draft-thread promotion/cleanup is driven by thread.created effects (clearPromotedDraftThread(s) + deriveOrchestrationBatchEffects), thread creation navigation waits for observed server start (waitForStartedServerThread), send-busy state is tracked via LocalDispatchSnapshot, and terminal state cleanup can remove per-thread state on deletes.

Tests are expanded/updated to cover recovery coordination, batch side effects, incremental reducers, UI state persistence rules, and browser tests now simulate live domainEvent pushes rather than manual snapshot syncs.

Written by Cursor Bugbot for commit ea6a5ae. This will update automatically on new commits. Configure here.

Note

Refactor web orchestration sync to apply domain events incrementally with isolated recovery

  • Replaces full snapshot syncs on every domain event with an incremental applyOrchestrationEvent/applyOrchestrationEvents reducer in store.ts, mapping orchestration events directly to app state.
  • Adds orchestrationRecovery.ts to coordinate snapshot bootstrap, detect sequence gaps, defer events, and drive replay cycles with progress detection.
  • Moves UI-only state (project expand/collapse, project ordering, thread last-visited timestamps) out of the main store into a new Zustand-backed uiStateStore.ts with debounced localStorage persistence.
  • Introduces deriveOrchestrationBatchEffects in orchestrationEventEffects.ts to derive targeted per-batch side effects (draft cleanup, terminal state removal, provider invalidation) instead of triggering them broadly.
  • Updates ChatView to use waitForStartedServerThread and LocalDispatchSnapshot-based acknowledgment instead of forcing snapshot syncs after thread creation.
  • Renames threadsHydrated to bootstrapComplete across state, routes, and tests.
  • Risk: localStorage persistence for project/thread UI state is migrated from the main store to uiStateStore; existing persisted keys are read as legacy and may be cleared on first load.

Macroscope summarized ea6a5ae.

- Apply orchestration events incrementally instead of resyncing full snapshots
- Reduce store subscription churn with cached thread snapshots and selectors
- Update store tests for incremental event handling
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 29, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8294b099-302b-4360-850a-6b1a94f50b2b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/ui-perf-regression

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 29, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Filter identity check always fails, causing unnecessary updates
    • Replaced the always-false filter() === original identity checks with length comparisons to correctly detect when no element was actually removed.

Create PR

Or push these changes by commenting:

@cursor push a1f6b8993a
Preview (a1f6b8993a)
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -568,7 +568,9 @@
 
     case "project.deleted": {
       const projects = state.projects.filter((project) => project.id !== event.payload.projectId);
-      return projects === state.projects ? state : { ...state, projects, threadsHydrated: true };
+      return projects.length === state.projects.length
+        ? state
+        : { ...state, projects, threadsHydrated: true };
     }
 
     case "thread.created": {
@@ -604,7 +606,9 @@
 
     case "thread.deleted": {
       const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId);
-      return threads === state.threads ? state : { ...state, threads, threadsHydrated: true };
+      return threads.length === state.threads.length
+        ? state
+        : { ...state, threads, threadsHydrated: true };
     }
 
     case "thread.archived": {

Co-authored-by: codex <codex@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Turn-diff-completed can regress latestTurn to older turn
    • Added a guard so the turn-diff-completed handler only updates latestTurn when the event's turnId matches the current latestTurn or latestTurn is null, preventing delayed checkpoint events for older turns from regressing latestTurn.

Create PR

Or push these changes by commenting:

@cursor push 0927687131
Preview (0927687131)
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -876,24 +876,22 @@
               (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER),
           )
           .slice(-MAX_THREAD_CHECKPOINTS);
+        const latestTurn =
+          thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId
+            ? buildLatestTurn({
+                previous: thread.latestTurn,
+                turnId: event.payload.turnId,
+                state: checkpointStatusToLatestTurnState(event.payload.status),
+                requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt,
+                startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt,
+                completedAt: event.payload.completedAt,
+                assistantMessageId: event.payload.assistantMessageId,
+              })
+            : thread.latestTurn;
         return {
           ...thread,
           turnDiffSummaries,
-          latestTurn: buildLatestTurn({
-            previous: thread.latestTurn,
-            turnId: event.payload.turnId,
-            state: checkpointStatusToLatestTurnState(event.payload.status),
-            requestedAt:
-              thread.latestTurn?.turnId === event.payload.turnId
-                ? thread.latestTurn.requestedAt
-                : event.payload.completedAt,
-            startedAt:
-              thread.latestTurn?.turnId === event.payload.turnId
-                ? (thread.latestTurn.startedAt ?? event.payload.completedAt)
-                : event.payload.completedAt,
-            completedAt: event.payload.completedAt,
-            assistantMessageId: event.payload.assistantMessageId,
-          }),
+          latestTurn,
           updatedAt: event.occurredAt,
         };
       });

- Keep store identity unchanged for missing project/thread deletes
- Avoid regressing latestTurn when an older turn diff completes late
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Module-level Map cache never cleaned up, unlike WeakMap
    • Added a useEffect that prunes entries from threadPlanCatalogCache for thread IDs no longer present in the store's thread list.
  • ✅ Fixed: Removed snapshot sync causes race with navigation
    • Restored the getSnapshot + syncServerReadModel calls before navigation and in the error cleanup path, ensuring the new thread exists in the store before ChatView renders.

Create PR

Or push these changes by commenting:

@cursor push 342dacc8c1
Preview (342dacc8c1)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -494,6 +494,14 @@
   const threadPlanCatalog = useStore(
     useShallow((store) => store.threads.map(toThreadPlanCatalogEntry)),
   );
+  useEffect(() => {
+    const activeIds = new Set(threadPlanCatalog.map((t) => t.id));
+    for (const id of threadPlanCatalogCache.keys()) {
+      if (!activeIds.has(id)) {
+        threadPlanCatalogCache.delete(id);
+      }
+    }
+  }, [threadPlanCatalog]);
   const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
   const localDraftThread = useMemo(
     () =>
@@ -3158,7 +3166,9 @@
           createdAt,
         });
       })
-      .then(() => {
+      .then(() => api.orchestration.getSnapshot())
+      .then((snapshot) => {
+        useStore.getState().syncServerReadModel(snapshot);
         // Signal that the plan sidebar should open on the new thread.
         planSidebarOpenOnNextThreadRef.current = true;
         return navigate({
@@ -3174,6 +3184,12 @@
             threadId: nextThreadId,
           })
           .catch(() => undefined);
+        await api.orchestration
+          .getSnapshot()
+          .then((snapshot) => {
+            useStore.getState().syncServerReadModel(snapshot);
+          })
+          .catch(() => undefined);
         toastManager.add({
           type: "error",
           title: "Could not start implementation thread",

- Derive batch effects for draft and terminal-state cleanup
- Remove terminal state entries on thread delete
- Add tests for lifecycle effect handling
- Split orchestration effects for promoted vs deleted threads
- Add single-thread draft cleanup helper and update route handling
- Cover promotion cleanup behavior with store and effect tests
- Rename store hydration flag to bootstrapComplete
- Only clear missing-thread redirects after snapshot sync
- Split project and thread UI state from server data
- Preserve sidebar ordering and unread tracking
- Co-authored-by: codex <codex@users.noreply.github.com>
- Read threads and projects from store state on demand
- Expose a default project id from the new-thread hook
- Skip extra work in global chat shortcut handling
- Simulate thread.created pushes in browser tests
- Verify promoted drafts clear via live batch effects
- Replace transient send phase state with local dispatch snapshots
- Clear the busy state only after the server reflects the turn/session update
- Cover the acknowledgment rules with logic tests
- Move snapshot/replay sequencing state into a shared coordinator
- Add tests for deferred replay, gap recovery, and replay fallback
- Replace unbounded Map cache with LRU limits
- Estimate per-thread plan entry size before caching
- Add thread-start detection helper and wait logic
- Cover immediate, subscription-driven, and timeout cases
@juliusmarminge juliusmarminge changed the title Optimize orchestration sync and sidebar thread snapshots Refactor web orchestration sync to incremental events and isolated recovery Mar 30, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Duplicated project ordering logic across two files
    • Extracted a shared orderByPriority utility in lib/utils.ts and replaced both independent implementations in useHandleNewThread.ts and Sidebar.tsx with calls to it.
  • ✅ Fixed: Selector factories cause unnecessary per-render function allocations
    • Added a simple Map-based cache to both selectProjectById and selectThreadById so the same argument always returns the identical selector reference, enabling Zustand's fast-path identity check.

Create PR

Or push these changes by commenting:

@cursor push c83a7972fc
Preview (c83a7972fc)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -54,7 +54,13 @@
 import { isElectron } from "../env";
 import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
 import { isTerminalFocused } from "../lib/terminalFocus";
-import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
+import {
+  isLinuxPlatform,
+  isMacPlatform,
+  newCommandId,
+  newProjectId,
+  orderByPriority,
+} from "../lib/utils";
 import { useStore } from "../store";
 import { useUiStateStore } from "../uiStateStore";
 import {
@@ -500,18 +506,10 @@
   const platform = navigator.platform;
   const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
   const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
-  const orderedProjects = useMemo(() => {
-    if (projectOrder.length === 0) {
-      return projects;
-    }
-    const projectsById = new Map(projects.map((project) => [project.id, project] as const));
-    const ordered = projectOrder.flatMap((projectId) => {
-      const project = projectsById.get(projectId);
-      return project ? [project] : [];
-    });
-    const remaining = projects.filter((project) => !projectOrder.includes(project.id));
-    return [...ordered, ...remaining];
-  }, [projectOrder, projects]);
+  const orderedProjects = useMemo(
+    () => orderByPriority(projects, projectOrder, (p) => p.id),
+    [projectOrder, projects],
+  );
   const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(
     () =>
       orderedProjects.map((project) => ({

diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts
--- a/apps/web/src/hooks/useHandleNewThread.ts
+++ b/apps/web/src/hooks/useHandleNewThread.ts
@@ -7,7 +7,7 @@
   type DraftThreadState,
   useComposerDraftStore,
 } from "../composerDraftStore";
-import { newThreadId } from "../lib/utils";
+import { newThreadId, orderByPriority } from "../lib/utils";
 import { selectThreadById, useStore } from "../store";
 import { useUiStateStore } from "../uiStateStore";
 
@@ -23,15 +23,10 @@
   const activeDraftThread = useComposerDraftStore((store) =>
     routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,
   );
-  const orderedProjects = useMemo(() => {
-    if (projectOrder.length === 0) {
-      return projectIds;
-    }
-    const projectIdsSet = new Set(projectIds);
-    const ordered = projectOrder.filter((projectId) => projectIdsSet.has(projectId));
-    const remaining = projectIds.filter((projectId) => !projectOrder.includes(projectId));
-    return [...ordered, ...remaining];
-  }, [projectIds, projectOrder]);
+  const orderedProjects = useMemo(
+    () => orderByPriority(projectIds, projectOrder, (id) => id),
+    [projectIds, projectOrder],
+  );
 
   const handleNewThread = useCallback(
     (

diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -34,3 +34,23 @@
 export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());
 
 export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());
+
+/**
+ * Reorder `items` so that those whose key appears in `orderedKeys` come first
+ * (in `orderedKeys` order), followed by the remaining items in their original order.
+ */
+export function orderByPriority<T>(
+  items: readonly T[],
+  orderedKeys: readonly string[],
+  getKey: (item: T) => string,
+): T[] {
+  if (orderedKeys.length === 0) return items.slice();
+  const itemsByKey = new Map(items.map((item) => [getKey(item), item] as const));
+  const ordered = orderedKeys.flatMap((key) => {
+    const item = itemsByKey.get(key);
+    return item ? [item] : [];
+  });
+  const orderedKeySet = new Set(orderedKeys);
+  const remaining = items.filter((item) => !orderedKeySet.has(getKey(item)));
+  return [...ordered, ...remaining];
+}

diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -852,15 +852,39 @@
   return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state);
 }
 
-export const selectProjectById =
-  (projectId: Project["id"] | null | undefined) =>
-  (state: AppState): Project | undefined =>
-    projectId ? state.projects.find((project) => project.id === projectId) : undefined;
+const _projectSelectorCache = new Map<
+  string | null | undefined,
+  (state: AppState) => Project | undefined
+>();
+export function selectProjectById(
+  projectId: Project["id"] | null | undefined,
+): (state: AppState) => Project | undefined {
+  const key = projectId ?? null;
+  let selector = _projectSelectorCache.get(key);
+  if (!selector) {
+    selector = (state: AppState) =>
+      projectId ? state.projects.find((project) => project.id === projectId) : undefined;
+    _projectSelectorCache.set(key, selector);
+  }
+  return selector;
+}
 
-export const selectThreadById =
-  (threadId: ThreadId | null | undefined) =>
-  (state: AppState): Thread | undefined =>
-    threadId ? state.threads.find((thread) => thread.id === threadId) : undefined;
+const _threadSelectorCache = new Map<
+  string | null | undefined,
+  (state: AppState) => Thread | undefined
+>();
+export function selectThreadById(
+  threadId: ThreadId | null | undefined,
+): (state: AppState) => Thread | undefined {
+  const key = threadId ?? null;
+  let selector = _threadSelectorCache.get(key);
+  if (!selector) {
+    selector = (state: AppState) =>
+      threadId ? state.threads.find((thread) => thread.id === threadId) : undefined;
+    _threadSelectorCache.set(key, selector);
+  }
+  return selector;
+}
 
 export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
   const threads = updateThread(state.threads, threadId, (t) => {

- Share preferred-id ordering across sidebar and new-thread flows
- Add resilient thread startup wait logic and selector hooks
- Cover the new ordering helper and race condition in tests
- Skip duplicate preferred IDs when ordering sidebar items
- Add regression test for repeated preferred IDs
- Replace inferred local types with explicit shared contract exports
- Keep router, chat, and sidebar tests aligned with contract shapes
- Reorder ChatView helper declarations for readability
- No functional change
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Empty replay response causes infinite recovery loop
    • Added a no-progress check: if latestSequence doesn't advance after a replay batch, the recovery loop now falls back to snapshot recovery instead of looping infinitely.
  • ✅ Fixed: Thread plan catalog recomputes on every store change
    • Replaced the useShallow+map selector with a direct store.threads subscription and useMemo, so the O(n) mapping only runs when the threads array reference changes rather than on every store update.

Create PR

Or push these changes by commenting:

@cursor push dde2fc9315
Preview (dde2fc9315)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -28,7 +28,6 @@
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { useDebouncedValue } from "@tanstack/react-pacer";
 import { useNavigate, useSearch } from "@tanstack/react-router";
-import { useShallow } from "zustand/react/shallow";
 import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
 import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
 import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
@@ -591,9 +590,8 @@
   );
 
   const fallbackDraftProject = useProjectById(draftThread?.projectId);
-  const threadPlanCatalog = useStore(
-    useShallow((store) => store.threads.map(toThreadPlanCatalogEntry)),
-  );
+  const threads = useStore((store) => store.threads);
+  const threadPlanCatalog = useMemo(() => threads.map(toThreadPlanCatalogEntry), [threads]);
   const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
   const localDraftThread = useMemo(
     () =>

diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -250,8 +250,10 @@
         return;
       }
 
+      const sequenceBefore = recovery.getState().latestSequence;
+
       try {
-        const events = await api.orchestration.replayEvents(recovery.getState().latestSequence);
+        const events = await api.orchestration.replayEvents(sequenceBefore);
         if (!disposed) {
           applyEventBatch(events);
         }
@@ -261,7 +263,17 @@
         return;
       }
 
-      if (!disposed && recovery.completeReplayRecovery()) {
+      if (disposed) {
+        return;
+      }
+
+      if (recovery.getState().latestSequence === sequenceBefore) {
+        recovery.failReplayRecovery();
+        void fallbackToSnapshotRecovery();
+        return;
+      }
+
+      if (recovery.completeReplayRecovery()) {
         void recoverFromSequenceGap();
       }
     };

- Scope thread plan catalog selection to active/source threads
- Avoid immediate replay retries when recovery makes no progress
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Missing latestTurn regression guard in message-sent handler
    • Added the same thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId guard used by thread.turn-diff-completed to the thread.message-sent handler, preventing late-arriving older-turn messages from regressing latestTurn.

Create PR

Or push these changes by commenting:

@cursor push 9a9ecd8f0d
Preview (9a9ecd8f0d)
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -608,7 +608,9 @@
           : [...thread.messages, message];
         const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES);
         const latestTurn: Thread["latestTurn"] =
-          event.payload.role === "assistant" && event.payload.turnId !== null
+          event.payload.role === "assistant" &&
+          event.payload.turnId !== null &&
+          (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
             ? buildLatestTurn({
                 previous: thread.latestTurn,
                 turnId: event.payload.turnId,

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 9a9ecd8

Add the same defensive guard that thread.turn-diff-completed uses to
prevent a late-arriving assistant message from an older turn from
overwriting latestTurn, which could regress the current turn's progress.

Applied via @cursor push command
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Incremental event path drops sourceProposedPlan from latestTurn
    • Added a pendingSourceProposedPlan field to Thread, stored sourceProposedPlan from the turn-start-requested event, and passed it as a fallback to buildLatestTurn so new turns on the incremental path preserve the plan reference.

Create PR

Or push these changes by commenting:

@cursor push 921270aa86
Preview (921270aa86)
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -158,6 +158,7 @@
     archivedAt: thread.archivedAt,
     updatedAt: thread.updatedAt,
     latestTurn: thread.latestTurn,
+    pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan,
     branch: thread.branch,
     worktreePath: thread.worktreePath,
     turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary),
@@ -214,7 +215,12 @@
   startedAt: string | null;
   completedAt: string | null;
   assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"];
+  sourceProposedPlan?: Thread["pendingSourceProposedPlan"];
 }): NonNullable<Thread["latestTurn"]> {
+  const resolvedPlan =
+    params.previous?.turnId === params.turnId
+      ? params.previous.sourceProposedPlan
+      : params.sourceProposedPlan;
   return {
     turnId: params.turnId,
     state: params.state,
@@ -222,9 +228,7 @@
     startedAt: params.startedAt,
     completedAt: params.completedAt,
     assistantMessageId: params.assistantMessageId,
-    ...(params.previous?.turnId === params.turnId && params.previous.sourceProposedPlan
-      ? { sourceProposedPlan: params.previous.sourceProposedPlan }
-      : {}),
+    ...(resolvedPlan ? { sourceProposedPlan: resolvedPlan } : {}),
   };
 }
 
@@ -534,6 +538,7 @@
           : {}),
         runtimeMode: event.payload.runtimeMode,
         interactionMode: event.payload.interactionMode,
+        pendingSourceProposedPlan: event.payload.sourceProposedPlan,
         updatedAt: event.occurredAt,
       }));
       return threads === state.threads ? state : { ...state, threads };
@@ -629,6 +634,7 @@
                   thread.latestTurn?.turnId === event.payload.turnId
                     ? (thread.latestTurn.startedAt ?? event.payload.createdAt)
                     : event.payload.createdAt,
+                sourceProposedPlan: thread.pendingSourceProposedPlan,
                 completedAt: event.payload.streaming
                   ? thread.latestTurn?.turnId === event.payload.turnId
                     ? (thread.latestTurn.completedAt ?? null)
@@ -671,6 +677,7 @@
                   thread.latestTurn?.turnId === event.payload.session.activeTurnId
                     ? thread.latestTurn.assistantMessageId
                     : null,
+                sourceProposedPlan: thread.pendingSourceProposedPlan,
               })
             : thread.latestTurn,
         updatedAt: event.occurredAt,
@@ -755,6 +762,7 @@
                 startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt,
                 completedAt: event.payload.completedAt,
                 assistantMessageId: event.payload.assistantMessageId,
+                sourceProposedPlan: thread.pendingSourceProposedPlan,
               })
             : thread.latestTurn;
         return {

diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -104,6 +104,7 @@
   archivedAt: string | null;
   updatedAt?: string | undefined;
   latestTurn: OrchestrationLatestTurn | null;
+  pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"];
   branch: string | null;
   worktreePath: string | null;
   turnDiffSummaries: TurnDiffSummary[];

interactionMode: event.payload.interactionMode,
updatedAt: event.occurredAt,
}));
return threads === state.threads ? state : { ...state, threads };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental event path drops sourceProposedPlan from latestTurn

High Severity

The thread.turn-start-requested handler updates modelSelection, runtimeMode, and interactionMode but never stores event.payload.sourceProposedPlan onto latestTurn. The contracts schema (ThreadTurnStartRequestedPayload) includes an optional sourceProposedPlan field, and findSidebarProposedPlan plus useThreadPlanCatalog in ChatView depend on latestTurn.sourceProposedPlan to look up the originating plan and display the plan sidebar for implementation threads. The snapshot sync path preserves this field (via mapThread copying thread.latestTurn directly), but the incremental event path silently drops it, breaking plan sidebar navigation for any turn started after bootstrap.

Additional Locations (1)
Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants