Skip to content

Normalize sidebar thread state for faster updates#1668

Merged
juliusmarminge merged 8 commits intomainfrom
t3code/typing-lag-trace-analysis
Apr 1, 2026
Merged

Normalize sidebar thread state for faster updates#1668
juliusmarminge merged 8 commits intomainfrom
t3code/typing-lag-trace-analysis

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 1, 2026

Summary

  • Normalize sidebar thread data in the web store so rows can update from stable IDs and summaries instead of rebuilding full thread snapshots.
  • Move sidebar status derivation onto precomputed thread summary fields, including pending approvals, pending input, and actionable plan state.
  • Split sidebar row rendering into a dedicated component and tighten selector usage to reduce unnecessary rerenders.
  • Update visible thread ordering, jump hints, and archive/delete actions to use the normalized thread index.
  • Refresh related tests to match the new sidebar data shape and status resolution logic.

Testing

  • Not run (PR description only).

Note

Medium Risk
Refactors core client state and sidebar rendering to rely on new normalized indices/summaries and changes orchestration event application timing/coalescing, which could impact thread list correctness and real-time UI updates.

Overview
Normalizes sidebar thread data by adding SidebarThreadSummary plus sidebarThreadsById and threadIdsByProjectId to the web store, and keeping these in sync on read-model sync and all thread mutations (including correct removal when a thread is recreated under a different project).

Refactors the sidebar to render by stable IDs: thread rows now select their own summary via useSidebarThreadSummaryById, getVisibleSidebarThreadIds consumes renderedThreadIds, and status pills use precomputed flags (hasPendingApprovals, hasPendingUserInput, hasActionableProposedPlan) instead of deriving from activities/proposed plans on each render.

Reduces redundant UI updates from orchestration events by batching domain events into microtasks and coalescing consecutive thread.message-sent events for the same message (streaming) before applying them to the UI.

Removes key={threadId} from ChatView, preventing forced remounts on thread navigation, and updates/extends tests to match the new sidebar/store shapes and status-resolution inputs.

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

Note

Normalize sidebar thread state into a dedicated store for faster updates

  • Introduces SidebarThreadSummary as a normalized type in types.ts capturing per-thread flags (hasPendingApprovals, hasPendingUserInput, hasActionableProposedPlan) and metadata needed by the sidebar.
  • Adds sidebarThreadsById and threadIdsByProjectId to AppState in store.ts, populated on sync and kept current via a new updateThreadState helper that recalculates summaries on every thread mutation.
  • Refactors Sidebar.tsx to read from the normalized store selectors instead of deriving state inline; extracts a new SidebarThreadRow component that fetches its own data via useSidebarThreadSummaryById.
  • Coalesces consecutive thread.message-sent events for the same message in __root.tsx and applies domain events in microtask-sized batches to reduce redundant UI updates.
  • Behavioral Change: ChatView in _chat.$threadId.tsx is no longer force-remounted when switching threads because key={threadId} has been removed.

Macroscope summarized a56c655.

- derive sidebar rows from thread summaries and indexed ids
- move status pill logic off full thread activity scans
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 1, 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: 34292e28-c1b3-4eac-a778-6e9f0e9c512e

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/typing-lag-trace-analysis

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

@github-actions github-actions bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Apr 1, 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: Coalescing concatenates text ignoring streaming replacement semantics
    • Added a check so that when the later event has streaming=false and non-empty text, its text is used as-is (replacement) instead of being concatenated with the previous event's text.
  • ✅ Fixed: Exported hook useThreadIdsByProjectId is never used
    • Removed the unused useThreadIdsByProjectId hook and its selectThreadIdsByProjectId import from storeSelectors.ts since there are no call sites in the codebase.

Create PR

Or push these changes by commenting:

@cursor push 4abafd3efa
Preview (4abafd3efa)
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
@@ -175,7 +175,10 @@
           ...event.payload,
           attachments: event.payload.attachments ?? previous.payload.attachments,
           createdAt: previous.payload.createdAt,
-          text: previous.payload.text + event.payload.text,
+          text:
+            !event.payload.streaming && event.payload.text.length > 0
+              ? event.payload.text
+              : previous.payload.text + event.payload.text,
         },
       };
       continue;

diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts
--- a/apps/web/src/storeSelectors.ts
+++ b/apps/web/src/storeSelectors.ts
@@ -1,10 +1,9 @@
-import { type ProjectId, type ThreadId } from "@t3tools/contracts";
+import { type ThreadId } from "@t3tools/contracts";
 import { useMemo } from "react";
 import {
   selectProjectById,
   selectSidebarThreadSummaryById,
   selectThreadById,
-  selectThreadIdsByProjectId,
   useStore,
 } from "./store";
 import { type Project, type SidebarThreadSummary, type Thread } from "./types";
@@ -25,8 +24,3 @@
   const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]);
   return useStore(selector);
 }
-
-export function useThreadIdsByProjectId(projectId: ProjectId | null | undefined): ThreadId[] {
-  const selector = useMemo(() => selectThreadIdsByProjectId(projectId), [projectId]);
-  return useStore(selector);
-}

You can send follow-ups to this agent here.

@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 1, 2026

Approvability

Verdict: Needs human review

This PR refactors sidebar state management for performance by introducing normalized indexes and extracting the thread row component. While the changes are internal optimizations, an unresolved review comment identifies a potential bug where project deletion doesn't clean up the new thread index maps, leaving stale entries.

You can customize Macroscope's approvability policy. Learn more.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 4abafd3

…sed useThreadIdsByProjectId hook

- In coalesceOrchestrationUiEvents, when the later event has streaming=false
  and non-empty text, use the later event's text as a replacement instead of
  concatenating, matching the store handler's replacement semantics.
- Remove unused useThreadIdsByProjectId hook and its selectThreadIdsByProjectId
  import from storeSelectors.ts.

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.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

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

  • ✅ Fixed: Removing key from ChatView leaks state across threads
    • Restored key={threadId} on both ChatView render sites so the component remounts on thread navigation, clearing all ~25 useState hooks that have no manual reset logic.

Create PR

Or push these changes by commenting:

@cursor push 2c692e6a62
Preview (2c692e6a62)
diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx
--- a/apps/web/src/routes/_chat.$threadId.tsx
+++ b/apps/web/src/routes/_chat.$threadId.tsx
@@ -222,7 +222,7 @@
     return (
       <>
         <SidebarInset className="h-dvh  min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
-          <ChatView threadId={threadId} />
+          <ChatView key={threadId} threadId={threadId} />
         </SidebarInset>
         <DiffPanelInlineSidebar
           diffOpen={diffOpen}
@@ -237,7 +237,7 @@
   return (
     <>
       <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
-        <ChatView threadId={threadId} />
+        <ChatView key={threadId} threadId={threadId} />
       </SidebarInset>
       <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
         {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}

You can send follow-ups to this agent here.

- Derive checkpoint diffs from orchestration snapshots
- Refresh checked-out branch upstream refs asynchronously
- Update git, orchestration, and UI tests for the new flow
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: Project deletion doesn't clean up thread index maps
    • Added cleanup of threadIdsByProjectId and sidebarThreadsById entries for the deleted project's threads in the project.deleted handler.

Create PR

Or push these changes by commenting:

@cursor push c8e1571f41
Preview (c8e1571f41)
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
@@ -637,8 +637,22 @@
     }
 
     case "project.deleted": {
-      const projects = state.projects.filter((project) => project.id !== event.payload.projectId);
-      return projects.length === state.projects.length ? state : { ...state, projects };
+      const deletedProjectId = event.payload.projectId;
+      const projects = state.projects.filter((project) => project.id !== deletedProjectId);
+      if (projects.length === state.projects.length) {
+        return state;
+      }
+      const orphanedThreadIds = state.threadIdsByProjectId[deletedProjectId];
+      if (!orphanedThreadIds || orphanedThreadIds.length === 0) {
+        const { [deletedProjectId]: _, ...threadIdsByProjectId } = state.threadIdsByProjectId;
+        return _ ? { ...state, projects, threadIdsByProjectId } : { ...state, projects };
+      }
+      const { [deletedProjectId]: _, ...threadIdsByProjectId } = state.threadIdsByProjectId;
+      const sidebarThreadsById = { ...state.sidebarThreadsById };
+      for (const threadId of orphanedThreadIds) {
+        delete sidebarThreadsById[threadId];
+      }
+      return { ...state, projects, threadIdsByProjectId, sidebarThreadsById };
     }
 
     case "thread.created": {

You can send follow-ups to this agent here.

? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread))
: [...state.threads, nextThread];
return { ...state, threads };
const nextSummary = buildSidebarThreadSummary(nextThread);
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.

Project deletion doesn't clean up thread index maps

Low Severity

The project.deleted handler removes the project from state.projects but doesn't clean up the newly introduced threadIdsByProjectId or sidebarThreadsById entries. If a project is deleted without prior individual thread.deleted events for its threads, stale entries remain in both indexes.

Fix in Cursor Fix in Web

@pingdotgg pingdotgg deleted a comment from cursor bot Apr 1, 2026
- Remove the accidental double period in the test command note
- Remove stray period from the test command note
@juliusmarminge juliusmarminge enabled auto-merge (squash) April 1, 2026 23:55
@juliusmarminge juliusmarminge merged commit ae6f971 into main Apr 1, 2026
11 of 12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/typing-lag-trace-analysis branch April 1, 2026 23:56
gigq pushed a commit to gigq/t3code that referenced this pull request Apr 6, 2026
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Chrono-byte pushed a commit to Chrono-byte/t3code that referenced this pull request Apr 7, 2026
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
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