Skip to content

Commit 7a5d299

Browse files
committed
perf: prevent thread status churn and memoize shell UI
1 parent ffeb6e8 commit 7a5d299

File tree

5 files changed

+160
-43
lines changed

5 files changed

+160
-43
lines changed

memory/decisions.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,10 @@ Type: decision
295295
Event: Settings reported missing Tailscale CLI even when installed because GUI runtime PATH did not include shell-resolved aliases/paths.
296296
Action: Added Tailscale binary candidate resolution (`PATH` first, then standard install paths including macOS app bundle path) before status checks.
297297
Rule: Desktop CLI integrations must not rely on shell aliases or login-shell PATH alone; include deterministic install-path fallbacks.
298+
299+
## 2026-02-07 21:24
300+
Context: Composer typing lag while non-active threads stream updates
301+
Type: decision
302+
Event: Thread status actions (`markProcessing`, `markUnread`, `markReviewing`) were creating new reducer state even when values were unchanged, and composer/sidebar surfaces lacked memo boundaries against unrelated parent re-renders.
303+
Action: Added no-op guards in `useThreadsReducer` for unchanged thread-status transitions and wrapped `Composer`/`Sidebar` in `React.memo` to prevent unnecessary rerenders on unrelated app-state updates.
304+
Rule: Streaming/event reducers must return previous state for no-op status transitions, and high-churn UI shells should be memoized to isolate typing/input responsiveness.

src/features/app/components/Sidebar.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
WorkspaceInfo,
77
} from "../../../types";
88
import { createPortal } from "react-dom";
9-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
1010
import type { RefObject } from "react";
1111
import { FolderOpen } from "lucide-react";
1212
import Copy from "lucide-react/dist/esm/icons/copy";
@@ -103,7 +103,7 @@ type SidebarProps = {
103103
onWorkspaceDrop: (event: React.DragEvent<HTMLElement>) => void;
104104
};
105105

106-
export function Sidebar({
106+
export const Sidebar = memo(function Sidebar({
107107
workspaces,
108108
groupedWorkspaces,
109109
hasWorkspaceGroups,
@@ -699,4 +699,6 @@ export function Sidebar({
699699
/>
700700
</aside>
701701
);
702-
}
702+
});
703+
704+
Sidebar.displayName = "Sidebar";

src/features/composer/components/Composer.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
memo,
23
useCallback,
34
useEffect,
45
useLayoutEffect,
@@ -132,7 +133,7 @@ const DEFAULT_EDITOR_SETTINGS: ComposerEditorSettings = {
132133
};
133134
const CARET_ANCHOR_GAP = 8;
134135

135-
export function Composer({
136+
export const Composer = memo(function Composer({
136137
onSend,
137138
onQueue,
138139
onStop,
@@ -725,4 +726,6 @@ export function Composer({
725726
/>
726727
</footer>
727728
);
728-
}
729+
});
730+
731+
Composer.displayName = "Composer";

src/features/threads/hooks/useThreadsReducer.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,67 @@ describe("threadReducer", () => {
167167
expect(stopped.threadStatusById["thread-1"]?.lastDurationMs).toBe(600);
168168
});
169169

170+
it("does not churn state for repeated processing=true updates", () => {
171+
const processingState = threadReducer(
172+
{
173+
...initialState,
174+
threadStatusById: {
175+
"thread-1": {
176+
isProcessing: true,
177+
hasUnread: false,
178+
isReviewing: false,
179+
processingStartedAt: 1000,
180+
lastDurationMs: null,
181+
},
182+
},
183+
},
184+
{
185+
type: "markProcessing",
186+
threadId: "thread-1",
187+
isProcessing: true,
188+
timestamp: 1200,
189+
},
190+
);
191+
192+
expect(processingState).toBe(
193+
threadReducer(processingState, {
194+
type: "markProcessing",
195+
threadId: "thread-1",
196+
isProcessing: true,
197+
timestamp: 1400,
198+
}),
199+
);
200+
});
201+
202+
it("does not churn state for unchanged unread/review flags", () => {
203+
const base = {
204+
...initialState,
205+
threadStatusById: {
206+
"thread-1": {
207+
isProcessing: false,
208+
hasUnread: true,
209+
isReviewing: true,
210+
processingStartedAt: null,
211+
lastDurationMs: 300,
212+
},
213+
},
214+
};
215+
216+
const unread = threadReducer(base, {
217+
type: "markUnread",
218+
threadId: "thread-1",
219+
hasUnread: true,
220+
});
221+
expect(unread).toBe(base);
222+
223+
const reviewing = threadReducer(base, {
224+
type: "markReviewing",
225+
threadId: "thread-1",
226+
isReviewing: true,
227+
});
228+
expect(reviewing).toBe(base);
229+
});
230+
170231
it("tracks request user input queue", () => {
171232
const request = {
172233
workspace_id: "ws-1",

src/features/threads/hooks/useThreadsReducer.ts

Lines changed: 82 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -528,37 +528,62 @@ export function threadReducer(state: ThreadState, action: ThreadAction): ThreadS
528528
const wasProcessing = previous?.isProcessing ?? false;
529529
const startedAt = previous?.processingStartedAt ?? null;
530530
const lastDurationMs = previous?.lastDurationMs ?? null;
531+
const hasUnread = previous?.hasUnread ?? false;
532+
const isReviewing = previous?.isReviewing ?? false;
531533
if (action.isProcessing) {
534+
const nextStartedAt =
535+
wasProcessing && startedAt ? startedAt : action.timestamp;
536+
const nextStatus: ThreadActivityStatus = {
537+
isProcessing: true,
538+
hasUnread,
539+
isReviewing,
540+
processingStartedAt: nextStartedAt,
541+
lastDurationMs,
542+
};
543+
if (
544+
previous &&
545+
previous.isProcessing === nextStatus.isProcessing &&
546+
previous.hasUnread === nextStatus.hasUnread &&
547+
previous.isReviewing === nextStatus.isReviewing &&
548+
previous.processingStartedAt === nextStatus.processingStartedAt &&
549+
previous.lastDurationMs === nextStatus.lastDurationMs
550+
) {
551+
return state;
552+
}
532553
return {
533554
...state,
534555
threadStatusById: {
535556
...state.threadStatusById,
536-
[action.threadId]: {
537-
isProcessing: true,
538-
hasUnread: previous?.hasUnread ?? false,
539-
isReviewing: previous?.isReviewing ?? false,
540-
processingStartedAt:
541-
wasProcessing && startedAt ? startedAt : action.timestamp,
542-
lastDurationMs,
543-
},
557+
[action.threadId]: nextStatus,
544558
},
545559
};
546560
}
547561
const nextDuration =
548562
wasProcessing && startedAt
549563
? Math.max(0, action.timestamp - startedAt)
550564
: lastDurationMs ?? null;
565+
const nextStatus: ThreadActivityStatus = {
566+
isProcessing: false,
567+
hasUnread,
568+
isReviewing,
569+
processingStartedAt: null,
570+
lastDurationMs: nextDuration,
571+
};
572+
if (
573+
previous &&
574+
previous.isProcessing === nextStatus.isProcessing &&
575+
previous.hasUnread === nextStatus.hasUnread &&
576+
previous.isReviewing === nextStatus.isReviewing &&
577+
previous.processingStartedAt === nextStatus.processingStartedAt &&
578+
previous.lastDurationMs === nextStatus.lastDurationMs
579+
) {
580+
return state;
581+
}
551582
return {
552583
...state,
553584
threadStatusById: {
554585
...state.threadStatusById,
555-
[action.threadId]: {
556-
isProcessing: false,
557-
hasUnread: previous?.hasUnread ?? false,
558-
isReviewing: previous?.isReviewing ?? false,
559-
processingStartedAt: null,
560-
lastDurationMs: nextDuration,
561-
},
586+
[action.threadId]: nextStatus,
562587
},
563588
};
564589
}
@@ -570,41 +595,60 @@ export function threadReducer(state: ThreadState, action: ThreadAction): ThreadS
570595
[action.threadId]: action.turnId,
571596
},
572597
};
573-
case "markReviewing":
598+
case "markReviewing": {
599+
const previous = state.threadStatusById[action.threadId];
600+
const nextStatus: ThreadActivityStatus = {
601+
isProcessing: previous?.isProcessing ?? false,
602+
hasUnread: previous?.hasUnread ?? false,
603+
isReviewing: action.isReviewing,
604+
processingStartedAt: previous?.processingStartedAt ?? null,
605+
lastDurationMs: previous?.lastDurationMs ?? null,
606+
};
607+
if (
608+
previous &&
609+
previous.isProcessing === nextStatus.isProcessing &&
610+
previous.hasUnread === nextStatus.hasUnread &&
611+
previous.isReviewing === nextStatus.isReviewing &&
612+
previous.processingStartedAt === nextStatus.processingStartedAt &&
613+
previous.lastDurationMs === nextStatus.lastDurationMs
614+
) {
615+
return state;
616+
}
574617
return {
575618
...state,
576619
threadStatusById: {
577620
...state.threadStatusById,
578-
[action.threadId]: {
579-
isProcessing:
580-
state.threadStatusById[action.threadId]?.isProcessing ?? false,
581-
hasUnread: state.threadStatusById[action.threadId]?.hasUnread ?? false,
582-
isReviewing: action.isReviewing,
583-
processingStartedAt:
584-
state.threadStatusById[action.threadId]?.processingStartedAt ?? null,
585-
lastDurationMs:
586-
state.threadStatusById[action.threadId]?.lastDurationMs ?? null,
587-
},
621+
[action.threadId]: nextStatus,
588622
},
589623
};
590-
case "markUnread":
624+
}
625+
case "markUnread": {
626+
const previous = state.threadStatusById[action.threadId];
627+
const nextStatus: ThreadActivityStatus = {
628+
isProcessing: previous?.isProcessing ?? false,
629+
hasUnread: action.hasUnread,
630+
isReviewing: previous?.isReviewing ?? false,
631+
processingStartedAt: previous?.processingStartedAt ?? null,
632+
lastDurationMs: previous?.lastDurationMs ?? null,
633+
};
634+
if (
635+
previous &&
636+
previous.isProcessing === nextStatus.isProcessing &&
637+
previous.hasUnread === nextStatus.hasUnread &&
638+
previous.isReviewing === nextStatus.isReviewing &&
639+
previous.processingStartedAt === nextStatus.processingStartedAt &&
640+
previous.lastDurationMs === nextStatus.lastDurationMs
641+
) {
642+
return state;
643+
}
591644
return {
592645
...state,
593646
threadStatusById: {
594647
...state.threadStatusById,
595-
[action.threadId]: {
596-
isProcessing:
597-
state.threadStatusById[action.threadId]?.isProcessing ?? false,
598-
hasUnread: action.hasUnread,
599-
isReviewing:
600-
state.threadStatusById[action.threadId]?.isReviewing ?? false,
601-
processingStartedAt:
602-
state.threadStatusById[action.threadId]?.processingStartedAt ?? null,
603-
lastDurationMs:
604-
state.threadStatusById[action.threadId]?.lastDurationMs ?? null,
605-
},
648+
[action.threadId]: nextStatus,
606649
},
607650
};
651+
}
608652
case "addAssistantMessage": {
609653
const list = state.itemsByThread[action.threadId] ?? [];
610654
const message: ConversationItem = {

0 commit comments

Comments
 (0)