authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-01 16:56:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-01 23:56:40+00:00
logae6f9715c6472670c0fae3b9cd0f44484f8e31d4
tree6cbdf97c28deaac5fc59cc0be370d85eef3f2548
parentb92c78f55215daf2a62d4cc6d06547029dc9df8f
signature Signed by PGP key B5690EEEBB952194

Normalize sidebar thread state for faster updates (#1668)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

9 files changed, 837 insertions(+), 491 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+25-53
...@@ -46,10 +46,12 @@ describe("hasUnseenCompletion", () => {...@@ -46,10 +46,12 @@ describe("hasUnseenCompletion", () => {
46 it("returns true when a thread completed after its last visit", () => {46 it("returns true when a thread completed after its last visit", () => {
47 expect(47 expect(
48 hasUnseenCompletion({48 hasUnseenCompletion({
49 hasActionableProposedPlan: false,
50 hasPendingApprovals: false,
51 hasPendingUserInput: false,
49 interactionMode: "default",52 interactionMode: "default",
50 latestTurn: makeLatestTurn(),53 latestTurn: makeLatestTurn(),
51 lastVisitedAt: "2026-03-09T10:04:00.000Z",54 lastVisitedAt: "2026-03-09T10:04:00.000Z",
52 proposedPlans: [],
53 session: null,55 session: null,
54 }),56 }),
55 ).toBe(true);57 ).toBe(true);
...@@ -322,17 +324,14 @@ describe("getVisibleSidebarThreadIds", () => {...@@ -322,17 +324,14 @@ describe("getVisibleSidebarThreadIds", () => {
322 expect(324 expect(
323 getVisibleSidebarThreadIds([325 getVisibleSidebarThreadIds([
324 {326 {
325 renderedThreads: [327 renderedThreadIds: [
326 { id: ThreadId.makeUnsafe("thread-12") },328 ThreadId.makeUnsafe("thread-12"),
327 { id: ThreadId.makeUnsafe("thread-11") },329 ThreadId.makeUnsafe("thread-11"),
328 { id: ThreadId.makeUnsafe("thread-10") },330 ThreadId.makeUnsafe("thread-10"),
329 ],331 ],
330 },332 },
331 {333 {
332 renderedThreads: [334 renderedThreadIds: [ThreadId.makeUnsafe("thread-8"), ThreadId.makeUnsafe("thread-6")],
333 { id: ThreadId.makeUnsafe("thread-8") },
334 { id: ThreadId.makeUnsafe("thread-6") },
335 ],
336 },335 },
337 ]),336 ]),
338 ).toEqual([337 ).toEqual([
...@@ -349,17 +348,14 @@ describe("getVisibleSidebarThreadIds", () => {...@@ -349,17 +348,14 @@ describe("getVisibleSidebarThreadIds", () => {
349 getVisibleSidebarThreadIds([348 getVisibleSidebarThreadIds([
350 {349 {
351 shouldShowThreadPanel: false,350 shouldShowThreadPanel: false,
352 renderedThreads: [351 renderedThreadIds: [
353 { id: ThreadId.makeUnsafe("thread-hidden-2") },352 ThreadId.makeUnsafe("thread-hidden-2"),
354 { id: ThreadId.makeUnsafe("thread-hidden-1") },353 ThreadId.makeUnsafe("thread-hidden-1"),
355 ],354 ],
356 },355 },
357 {356 {
358 shouldShowThreadPanel: true,357 shouldShowThreadPanel: true,
359 renderedThreads: [358 renderedThreadIds: [ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")],
360 { id: ThreadId.makeUnsafe("thread-12") },
361 { id: ThreadId.makeUnsafe("thread-11") },
362 ],
363 },359 },
364 ]),360 ]),
365 ).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]);361 ).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]);
...@@ -400,10 +396,12 @@ describe("isContextMenuPointerDown", () => {...@@ -400,10 +396,12 @@ describe("isContextMenuPointerDown", () => {
400396
401describe("resolveThreadStatusPill", () => {397describe("resolveThreadStatusPill", () => {
402 const baseThread = {398 const baseThread = {
399 hasActionableProposedPlan: false,
400 hasPendingApprovals: false,
401 hasPendingUserInput: false,
403 interactionMode: "plan" as const,402 interactionMode: "plan" as const,
404 latestTurn: null,403 latestTurn: null,
405 lastVisitedAt: undefined,404 lastVisitedAt: undefined,
406 proposedPlans: [],
407 session: {405 session: {
408 provider: "codex" as const,406 provider: "codex" as const,
409 status: "running" as const,407 status: "running" as const,
...@@ -416,9 +414,11 @@ describe("resolveThreadStatusPill", () => {...@@ -416,9 +414,11 @@ describe("resolveThreadStatusPill", () => {
416 it("shows pending approval before all other statuses", () => {414 it("shows pending approval before all other statuses", () => {
417 expect(415 expect(
418 resolveThreadStatusPill({416 resolveThreadStatusPill({
419 thread: baseThread,417 thread: {
420 hasPendingApprovals: true,418 ...baseThread,
421 hasPendingUserInput: true,419 hasPendingApprovals: true,
420 hasPendingUserInput: true,
421 },
422 }),422 }),
423 ).toMatchObject({ label: "Pending Approval", pulse: false });423 ).toMatchObject({ label: "Pending Approval", pulse: false });
424 });424 });
...@@ -426,9 +426,10 @@ describe("resolveThreadStatusPill", () => {...@@ -426,9 +426,10 @@ describe("resolveThreadStatusPill", () => {
426 it("shows awaiting input when plan mode is blocked on user answers", () => {426 it("shows awaiting input when plan mode is blocked on user answers", () => {
427 expect(427 expect(
428 resolveThreadStatusPill({428 resolveThreadStatusPill({
429 thread: baseThread,429 thread: {
430 hasPendingApprovals: false,430 ...baseThread,
431 hasPendingUserInput: true,431 hasPendingUserInput: true,
432 },
432 }),433 }),
433 ).toMatchObject({ label: "Awaiting Input", pulse: false });434 ).toMatchObject({ label: "Awaiting Input", pulse: false });
434 });435 });
...@@ -437,8 +438,6 @@ describe("resolveThreadStatusPill", () => {...@@ -437,8 +438,6 @@ describe("resolveThreadStatusPill", () => {
437 expect(438 expect(
438 resolveThreadStatusPill({439 resolveThreadStatusPill({
439 thread: baseThread,440 thread: baseThread,
440 hasPendingApprovals: false,
441 hasPendingUserInput: false,
442 }),441 }),
443 ).toMatchObject({ label: "Working", pulse: true });442 ).toMatchObject({ label: "Working", pulse: true });
444 });443 });
...@@ -448,26 +447,14 @@ describe("resolveThreadStatusPill", () => {...@@ -448,26 +447,14 @@ describe("resolveThreadStatusPill", () => {
448 resolveThreadStatusPill({447 resolveThreadStatusPill({
449 thread: {448 thread: {
450 ...baseThread,449 ...baseThread,
450 hasActionableProposedPlan: true,
451 latestTurn: makeLatestTurn(),451 latestTurn: makeLatestTurn(),
452 proposedPlans: [
453 {
454 id: "plan-1" as never,
455 turnId: "turn-1" as never,
456 createdAt: "2026-03-09T10:00:00.000Z",
457 updatedAt: "2026-03-09T10:05:00.000Z",
458 planMarkdown: "# Plan",
459 implementedAt: null,
460 implementationThreadId: null,
461 },
462 ],
463 session: {452 session: {
464 ...baseThread.session,453 ...baseThread.session,
465 status: "ready",454 status: "ready",
466 orchestrationStatus: "ready",455 orchestrationStatus: "ready",
467 },456 },
468 },457 },
469 hasPendingApprovals: false,
470 hasPendingUserInput: false,
471 }),458 }),
472 ).toMatchObject({ label: "Plan Ready", pulse: false });459 ).toMatchObject({ label: "Plan Ready", pulse: false });
473 });460 });
...@@ -478,25 +465,12 @@ describe("resolveThreadStatusPill", () => {...@@ -478,25 +465,12 @@ describe("resolveThreadStatusPill", () => {
478 thread: {465 thread: {
479 ...baseThread,466 ...baseThread,
480 latestTurn: makeLatestTurn(),467 latestTurn: makeLatestTurn(),
481 proposedPlans: [
482 {
483 id: "plan-1" as never,
484 turnId: "turn-1" as never,
485 createdAt: "2026-03-09T10:00:00.000Z",
486 updatedAt: "2026-03-09T10:05:00.000Z",
487 planMarkdown: "# Plan",
488 implementedAt: "2026-03-09T10:06:00.000Z",
489 implementationThreadId: "thread-implement" as never,
490 },
491 ],
492 session: {468 session: {
493 ...baseThread.session,469 ...baseThread.session,
494 status: "ready",470 status: "ready",
495 orchestrationStatus: "ready",471 orchestrationStatus: "ready",
496 },472 },
497 },473 },
498 hasPendingApprovals: false,
499 hasPendingUserInput: false,
500 }),474 }),
501 ).toMatchObject({ label: "Completed", pulse: false });475 ).toMatchObject({ label: "Completed", pulse: false });
502 });476 });
...@@ -515,8 +489,6 @@ describe("resolveThreadStatusPill", () => {...@@ -515,8 +489,6 @@ describe("resolveThreadStatusPill", () => {
515 orchestrationStatus: "ready",489 orchestrationStatus: "ready",
516 },490 },
517 },491 },
518 hasPendingApprovals: false,
519 hasPendingUserInput: false,
520 }),492 }),
521 ).toMatchObject({ label: "Completed", pulse: false });493 ).toMatchObject({ label: "Completed", pulse: false });
522 });494 });
apps/web/src/components/Sidebar.logic.ts+16-23
...@@ -1,12 +1,8 @@...@@ -1,12 +1,8 @@
1import * as React from "react";1import * as React from "react";
2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import type { Thread } from "../types";3import type { SidebarThreadSummary, Thread } from "../types";
4import { cn } from "../lib/utils";4import { cn } from "../lib/utils";
5import {5import { isLatestTurnSettled } from "../session-logic";
6 findLatestProposedPlan,
7 hasActionableProposedPlan,
8 isLatestTurnSettled,
9} from "../session-logic";
106
11export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";7export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
12export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;8export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
...@@ -47,8 +43,13 @@ const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {...@@ -47,8 +43,13 @@ const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
47};43};
4844
49type ThreadStatusInput = Pick<45type ThreadStatusInput = Pick<
50 Thread,46 SidebarThreadSummary,
51 "interactionMode" | "latestTurn" | "proposedPlans" | "session"47 | "hasActionableProposedPlan"
48 | "hasPendingApprovals"
49 | "hasPendingUserInput"
50 | "interactionMode"
51 | "latestTurn"
52 | "session"
52> & {53> & {
53 lastVisitedAt?: string | undefined;54 lastVisitedAt?: string | undefined;
54};55};
...@@ -232,15 +233,11 @@ export function orderItemsByPreferredIds<TItem, TId>(input: {...@@ -232,15 +233,11 @@ export function orderItemsByPreferredIds<TItem, TId>(input: {
232export function getVisibleSidebarThreadIds<TThreadId>(233export function getVisibleSidebarThreadIds<TThreadId>(
233 renderedProjects: readonly {234 renderedProjects: readonly {
234 shouldShowThreadPanel?: boolean;235 shouldShowThreadPanel?: boolean;
235 renderedThreads: readonly {236 renderedThreadIds: readonly TThreadId[];
236 id: TThreadId;
237 }[];
238 }[],237 }[],
239): TThreadId[] {238): TThreadId[] {
240 return renderedProjects.flatMap((renderedProject) =>239 return renderedProjects.flatMap((renderedProject) =>
241 renderedProject.shouldShowThreadPanel === false240 renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds,
242 ? []
243 : renderedProject.renderedThreads.map((thread) => thread.id),
244 );241 );
245}242}
246243
...@@ -313,12 +310,10 @@ export function resolveThreadRowClassName(input: {...@@ -313,12 +310,10 @@ export function resolveThreadRowClassName(input: {
313310
314export function resolveThreadStatusPill(input: {311export function resolveThreadStatusPill(input: {
315 thread: ThreadStatusInput;312 thread: ThreadStatusInput;
316 hasPendingApprovals: boolean;
317 hasPendingUserInput: boolean;
318}): ThreadStatusPill | null {313}): ThreadStatusPill | null {
319 const { hasPendingApprovals, hasPendingUserInput, thread } = input;314 const { thread } = input;
320315
321 if (hasPendingApprovals) {316 if (thread.hasPendingApprovals) {
322 return {317 return {
323 label: "Pending Approval",318 label: "Pending Approval",
324 colorClass: "text-amber-600 dark:text-amber-300/90",319 colorClass: "text-amber-600 dark:text-amber-300/90",
...@@ -327,7 +322,7 @@ export function resolveThreadStatusPill(input: {...@@ -327,7 +322,7 @@ export function resolveThreadStatusPill(input: {
327 };322 };
328 }323 }
329324
330 if (hasPendingUserInput) {325 if (thread.hasPendingUserInput) {
331 return {326 return {
332 label: "Awaiting Input",327 label: "Awaiting Input",
333 colorClass: "text-indigo-600 dark:text-indigo-300/90",328 colorClass: "text-indigo-600 dark:text-indigo-300/90",
...@@ -355,12 +350,10 @@ export function resolveThreadStatusPill(input: {...@@ -355,12 +350,10 @@ export function resolveThreadStatusPill(input: {
355 }350 }
356351
357 const hasPlanReadyPrompt =352 const hasPlanReadyPrompt =
358 !hasPendingUserInput &&353 !thread.hasPendingUserInput &&
359 thread.interactionMode === "plan" &&354 thread.interactionMode === "plan" &&
360 isLatestTurnSettled(thread.latestTurn, thread.session) &&355 isLatestTurnSettled(thread.latestTurn, thread.session) &&
361 hasActionableProposedPlan(356 thread.hasActionableProposedPlan;
362 findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null),
363 );
364 if (hasPlanReadyPrompt) {357 if (hasPlanReadyPrompt) {
365 return {358 return {
366 label: "Plan Ready",359 label: "Plan Ready",
apps/web/src/components/Sidebar.tsx+380-374
...@@ -18,8 +18,13 @@ import {...@@ -18,8 +18,13 @@ import {
18 useMemo,18 useMemo,
19 useRef,19 useRef,
20 useState,20 useState,
21 type Dispatch,
22 type KeyboardEvent,
21 type MouseEvent,23 type MouseEvent,
24 type MutableRefObject,
22 type PointerEvent,25 type PointerEvent,
26 type ReactNode,
27 type SetStateAction,
23} from "react";28} from "react";
24import { useShallow } from "zustand/react/shallow";29import { useShallow } from "zustand/react/shallow";
25import {30import {
...@@ -55,6 +60,7 @@ import { APP_STAGE_LABEL, APP_VERSION } from "../branding";...@@ -55,6 +60,7 @@ import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
55import { isTerminalFocused } from "../lib/terminalFocus";60import { isTerminalFocused } from "../lib/terminalFocus";
56import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";61import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
57import { useStore } from "../store";62import { useStore } from "../store";
63import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
58import { useUiStateStore } from "../uiStateStore";64import { useUiStateStore } from "../uiStateStore";
59import {65import {
60 resolveShortcutCommand,66 resolveShortcutCommand,
...@@ -64,14 +70,12 @@ import {...@@ -64,14 +70,12 @@ import {
64 threadJumpIndexFromCommand,70 threadJumpIndexFromCommand,
65 threadTraversalDirectionFromCommand,71 threadTraversalDirectionFromCommand,
66} from "../keybindings";72} from "../keybindings";
67import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
68import { gitStatusQueryOptions } from "../lib/gitReactQuery";73import { gitStatusQueryOptions } from "../lib/gitReactQuery";
69import { readNativeApi } from "../nativeApi";74import { readNativeApi } from "../nativeApi";
70import { useComposerDraftStore } from "../composerDraftStore";75import { useComposerDraftStore } from "../composerDraftStore";
71import { useHandleNewThread } from "../hooks/useHandleNewThread";76import { useHandleNewThread } from "../hooks/useHandleNewThread";
7277
73import { useThreadActions } from "../hooks/useThreadActions";78import { useThreadActions } from "../hooks/useThreadActions";
74import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
75import { toastManager } from "./ui/toast";79import { toastManager } from "./ui/toast";
76import { formatRelativeTimeLabel } from "../timestampFormat";80import { formatRelativeTimeLabel } from "../timestampFormat";
77import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";81import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
...@@ -125,7 +129,8 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";...@@ -125,7 +129,8 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
125import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";129import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
126import { useSettings, useUpdateSettings } from "~/hooks/useSettings";130import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
127import { useServerKeybindings } from "../rpc/serverState";131import { useServerKeybindings } from "../rpc/serverState";
128import type { Project, Thread } from "../types";132import { useSidebarThreadSummaryById } from "../storeSelectors";
133import type { Project } from "../types";
129const THREAD_PREVIEW_LIMIT = 6;134const THREAD_PREVIEW_LIMIT = 6;
130const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {135const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
131 updated_at: "Last user message",136 updated_at: "Last user message",
...@@ -141,79 +146,9 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {...@@ -141,79 +146,9 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
141 easing: "ease-out",146 easing: "ease-out",
142} as const;147} as const;
143148
144type SidebarThreadSnapshot = Pick<
145 Thread,
146 | "activities"
147 | "archivedAt"
148 | "branch"
149 | "createdAt"
150 | "id"
151 | "interactionMode"
152 | "latestTurn"
153 | "projectId"
154 | "proposedPlans"
155 | "session"
156 | "title"
157 | "updatedAt"
158 | "worktreePath"
159> & {
160 lastVisitedAt?: string | undefined;
161 latestUserMessageAt: string | null;
162};
163
164type SidebarProjectSnapshot = Project & {149type SidebarProjectSnapshot = Project & {
165 expanded: boolean;150 expanded: boolean;
166};151};
167
168const sidebarThreadSnapshotCache = new WeakMap<
169 Thread,
170 { lastVisitedAt?: string | undefined; snapshot: SidebarThreadSnapshot }
171>();
172
173function getLatestUserMessageAt(thread: Thread): string | null {
174 let latestUserMessageAt: string | null = null;
175
176 for (const message of thread.messages) {
177 if (message.role !== "user") {
178 continue;
179 }
180 if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) {
181 latestUserMessageAt = message.createdAt;
182 }
183 }
184
185 return latestUserMessageAt;
186}
187
188function toSidebarThreadSnapshot(
189 thread: Thread,
190 lastVisitedAt: string | undefined,
191): SidebarThreadSnapshot {
192 const cached = sidebarThreadSnapshotCache.get(thread);
193 if (cached && cached.lastVisitedAt === lastVisitedAt) {
194 return cached.snapshot;
195 }
196
197 const snapshot: SidebarThreadSnapshot = {
198 id: thread.id,
199 projectId: thread.projectId,
200 title: thread.title,
201 interactionMode: thread.interactionMode,
202 session: thread.session,
203 createdAt: thread.createdAt,
204 updatedAt: thread.updatedAt,
205 archivedAt: thread.archivedAt,
206 latestTurn: thread.latestTurn,
207 lastVisitedAt,
208 branch: thread.branch,
209 worktreePath: thread.worktreePath,
210 activities: thread.activities,
211 proposedPlans: thread.proposedPlans,
212 latestUserMessageAt: getLatestUserMessageAt(thread),
213 };
214 sidebarThreadSnapshotCache.set(thread, { lastVisitedAt, snapshot });
215 return snapshot;
216}
217interface TerminalStatusIndicator {152interface TerminalStatusIndicator {
218 label: "Terminal process running";153 label: "Terminal process running";
219 colorClass: string;154 colorClass: string;
...@@ -310,6 +245,301 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {...@@ -310,6 +245,301 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
310 return null;245 return null;
311}246}
312247
248interface SidebarThreadRowProps {
249 threadId: ThreadId;
250 orderedProjectThreadIds: readonly ThreadId[];
251 routeThreadId: ThreadId | null;
252 selectedThreadIds: ReadonlySet<ThreadId>;
253 showThreadJumpHints: boolean;
254 jumpLabel: string | null;
255 appSettingsConfirmThreadArchive: boolean;
256 renamingThreadId: ThreadId | null;
257 renamingTitle: string;
258 setRenamingTitle: (title: string) => void;
259 renamingInputRef: MutableRefObject<HTMLInputElement | null>;
260 renamingCommittedRef: MutableRefObject<boolean>;
261 confirmingArchiveThreadId: ThreadId | null;
262 setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>;
263 confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>;
264 handleThreadClick: (
265 event: MouseEvent,
266 threadId: ThreadId,
267 orderedProjectThreadIds: readonly ThreadId[],
268 ) => void;
269 navigateToThread: (threadId: ThreadId) => void;
270 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
271 handleThreadContextMenu: (
272 threadId: ThreadId,
273 position: { x: number; y: number },
274 ) => Promise<void>;
275 clearSelection: () => void;
276 commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise<void>;
277 cancelRename: () => void;
278 attemptArchiveThread: (threadId: ThreadId) => Promise<void>;
279 openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void;
280 pr: ThreadPr | null;
281}
282
283function SidebarThreadRow(props: SidebarThreadRowProps) {
284 const thread = useSidebarThreadSummaryById(props.threadId);
285 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]);
286 const runningTerminalIds = useTerminalStateStore(
287 (state) =>
288 selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds,
289 );
290
291 if (!thread) {
292 return null;
293 }
294
295 const isActive = props.routeThreadId === thread.id;
296 const isSelected = props.selectedThreadIds.has(thread.id);
297 const isHighlighted = isActive || isSelected;
298 const isThreadRunning =
299 thread.session?.status === "running" && thread.session.activeTurnId != null;
300 const threadStatus = resolveThreadStatusPill({
301 thread: {
302 ...thread,
303 lastVisitedAt,
304 },
305 });
306 const prStatus = prStatusIndicator(props.pr);
307 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
308 const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning;
309 const threadMetaClassName = isConfirmingArchive
310 ? "pointer-events-none opacity-0"
311 : !isThreadRunning
312 ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"
313 : "pointer-events-none";
314
315 return (
316 <SidebarMenuSubItem
317 className="w-full"
318 data-thread-item
319 onMouseLeave={() => {
320 props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
321 }}
322 onBlurCapture={(event) => {
323 const currentTarget = event.currentTarget;
324 requestAnimationFrame(() => {
325 if (currentTarget.contains(document.activeElement)) {
326 return;
327 }
328 props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
329 });
330 }}
331 >
332 <SidebarMenuSubButton
333 render={<div role="button" tabIndex={0} />}
334 size="sm"
335 isActive={isActive}
336 data-testid={`thread-row-${thread.id}`}
337 className={`${resolveThreadRowClassName({
338 isActive,
339 isSelected,
340 })} relative isolate`}
341 onClick={(event) => {
342 props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds);
343 }}
344 onKeyDown={(event) => {
345 if (event.key !== "Enter" && event.key !== " ") return;
346 event.preventDefault();
347 props.navigateToThread(thread.id);
348 }}
349 onContextMenu={(event) => {
350 event.preventDefault();
351 if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) {
352 void props.handleMultiSelectContextMenu({
353 x: event.clientX,
354 y: event.clientY,
355 });
356 } else {
357 if (props.selectedThreadIds.size > 0) {
358 props.clearSelection();
359 }
360 void props.handleThreadContextMenu(thread.id, {
361 x: event.clientX,
362 y: event.clientY,
363 });
364 }
365 }}
366 >
367 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
368 {prStatus && (
369 <Tooltip>
370 <TooltipTrigger
371 render={
372 <button
373 type="button"
374 aria-label={prStatus.tooltip}
375 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
376 onClick={(event) => {
377 props.openPrLink(event, prStatus.url);
378 }}
379 >
380 <GitPullRequestIcon className="size-3" />
381 </button>
382 }
383 />
384 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
385 </Tooltip>
386 )}
387 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
388 {props.renamingThreadId === thread.id ? (
389 <input
390 ref={(element) => {
391 if (element && props.renamingInputRef.current !== element) {
392 props.renamingInputRef.current = element;
393 element.focus();
394 element.select();
395 }
396 }}
397 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
398 value={props.renamingTitle}
399 onChange={(event) => props.setRenamingTitle(event.target.value)}
400 onKeyDown={(event) => {
401 event.stopPropagation();
402 if (event.key === "Enter") {
403 event.preventDefault();
404 props.renamingCommittedRef.current = true;
405 void props.commitRename(thread.id, props.renamingTitle, thread.title);
406 } else if (event.key === "Escape") {
407 event.preventDefault();
408 props.renamingCommittedRef.current = true;
409 props.cancelRename();
410 }
411 }}
412 onBlur={() => {
413 if (!props.renamingCommittedRef.current) {
414 void props.commitRename(thread.id, props.renamingTitle, thread.title);
415 }
416 }}
417 onClick={(event) => event.stopPropagation()}
418 />
419 ) : (
420 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
421 )}
422 </div>
423 <div className="ml-auto flex shrink-0 items-center gap-1.5">
424 {terminalStatus && (
425 <span
426 role="img"
427 aria-label={terminalStatus.label}
428 title={terminalStatus.label}
429 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
430 >
431 <TerminalIcon className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`} />
432 </span>
433 )}
434 <div className="flex min-w-12 justify-end">
435 {isConfirmingArchive ? (
436 <button
437 ref={(element) => {
438 if (element) {
439 props.confirmArchiveButtonRefs.current.set(thread.id, element);
440 } else {
441 props.confirmArchiveButtonRefs.current.delete(thread.id);
442 }
443 }}
444 type="button"
445 data-thread-selection-safe
446 data-testid={`thread-archive-confirm-${thread.id}`}
447 aria-label={`Confirm archive ${thread.title}`}
448 className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40"
449 onPointerDown={(event) => {
450 event.stopPropagation();
451 }}
452 onClick={(event) => {
453 event.preventDefault();
454 event.stopPropagation();
455 props.setConfirmingArchiveThreadId((current) =>
456 current === thread.id ? null : current,
457 );
458 void props.attemptArchiveThread(thread.id);
459 }}
460 >
461 Confirm
462 </button>
463 ) : !isThreadRunning ? (
464 props.appSettingsConfirmThreadArchive ? (
465 <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
466 <button
467 type="button"
468 data-thread-selection-safe
469 data-testid={`thread-archive-${thread.id}`}
470 aria-label={`Archive ${thread.title}`}
471 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
472 onPointerDown={(event) => {
473 event.stopPropagation();
474 }}
475 onClick={(event) => {
476 event.preventDefault();
477 event.stopPropagation();
478 props.setConfirmingArchiveThreadId(thread.id);
479 requestAnimationFrame(() => {
480 props.confirmArchiveButtonRefs.current.get(thread.id)?.focus();
481 });
482 }}
483 >
484 <ArchiveIcon className="size-3.5" />
485 </button>
486 </div>
487 ) : (
488 <Tooltip>
489 <TooltipTrigger
490 render={
491 <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
492 <button
493 type="button"
494 data-thread-selection-safe
495 data-testid={`thread-archive-${thread.id}`}
496 aria-label={`Archive ${thread.title}`}
497 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
498 onPointerDown={(event) => {
499 event.stopPropagation();
500 }}
501 onClick={(event) => {
502 event.preventDefault();
503 event.stopPropagation();
504 void props.attemptArchiveThread(thread.id);
505 }}
506 >
507 <ArchiveIcon className="size-3.5" />
508 </button>
509 </div>
510 }
511 />
512 <TooltipPopup side="top">Archive</TooltipPopup>
513 </Tooltip>
514 )
515 ) : null}
516 <span className={threadMetaClassName}>
517 {props.showThreadJumpHints && props.jumpLabel ? (
518 <span
519 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
520 title={props.jumpLabel}
521 >
522 {props.jumpLabel}
523 </span>
524 ) : (
525 <span
526 className={`text-[10px] ${
527 isHighlighted
528 ? "text-foreground/72 dark:text-foreground/82"
529 : "text-muted-foreground/40"
530 }`}
531 >
532 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
533 </span>
534 )}
535 </span>
536 </div>
537 </div>
538 </SidebarMenuSubButton>
539 </SidebarMenuSubItem>
540 );
541}
542
313function T3Wordmark() {543function T3Wordmark() {
314 return (544 return (
315 <svg545 <svg
...@@ -405,7 +635,7 @@ function SortableProjectItem({...@@ -405,7 +635,7 @@ function SortableProjectItem({
405}: {635}: {
406 projectId: ProjectId;636 projectId: ProjectId;
407 disabled?: boolean;637 disabled?: boolean;
408 children: (handleProps: SortableProjectHandleProps) => React.ReactNode;638 children: (handleProps: SortableProjectHandleProps) => ReactNode;
409}) {639}) {
410 const {640 const {
411 attributes,641 attributes,
...@@ -437,7 +667,8 @@ function SortableProjectItem({...@@ -437,7 +667,8 @@ function SortableProjectItem({
437667
438export default function Sidebar() {668export default function Sidebar() {
439 const projects = useStore((store) => store.projects);669 const projects = useStore((store) => store.projects);
440 const serverThreads = useStore((store) => store.threads);670 const sidebarThreadsById = useStore((store) => store.sidebarThreadsById);
671 const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId);
441 const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore(672 const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore(
442 useShallow((store) => ({673 useShallow((store) => ({
443 projectExpandedById: store.projectExpandedById,674 projectExpandedById: store.projectExpandedById,
...@@ -513,13 +744,7 @@ export default function Sidebar() {...@@ -513,13 +744,7 @@ export default function Sidebar() {
513 })),744 })),
514 [orderedProjects, projectExpandedById],745 [orderedProjects, projectExpandedById],
515 );746 );
516 const threads = useMemo(747 const sidebarThreads = useMemo(() => Object.values(sidebarThreadsById), [sidebarThreadsById]);
517 () =>
518 serverThreads.map((thread) =>
519 toSidebarThreadSnapshot(thread, threadLastVisitedAtById[thread.id]),
520 ),
521 [serverThreads, threadLastVisitedAtById],
522 );
523 const projectCwdById = useMemo(748 const projectCwdById = useMemo(
524 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),749 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
525 [projects],750 [projects],
...@@ -539,12 +764,12 @@ export default function Sidebar() {...@@ -539,12 +764,12 @@ export default function Sidebar() {
539 );764 );
540 const threadGitTargets = useMemo(765 const threadGitTargets = useMemo(
541 () =>766 () =>
542 threads.map((thread) => ({767 sidebarThreads.map((thread) => ({
543 threadId: thread.id,768 threadId: thread.id,
544 branch: thread.branch,769 branch: thread.branch,
545 cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null,770 cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null,
546 })),771 })),
547 [projectCwdById, threads],772 [projectCwdById, sidebarThreads],
548 );773 );
549 const threadGitStatusCwds = useMemo(774 const threadGitStatusCwds = useMemo(
550 () => [775 () => [
...@@ -585,7 +810,7 @@ export default function Sidebar() {...@@ -585,7 +810,7 @@ export default function Sidebar() {
585 return map;810 return map;
586 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);811 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
587812
588 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {813 const openPrLink = useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => {
589 event.preventDefault();814 event.preventDefault();
590 event.stopPropagation();815 event.stopPropagation();
591816
...@@ -625,7 +850,10 @@ export default function Sidebar() {...@@ -625,7 +850,10 @@ export default function Sidebar() {
625 const focusMostRecentThreadForProject = useCallback(850 const focusMostRecentThreadForProject = useCallback(
626 (projectId: ProjectId) => {851 (projectId: ProjectId) => {
627 const latestThread = sortThreadsForSidebar(852 const latestThread = sortThreadsForSidebar(
628 threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null),853 (threadIdsByProjectId[projectId] ?? [])
854 .map((threadId) => sidebarThreadsById[threadId])
855 .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined)
856 .filter((thread) => thread.archivedAt === null),
629 appSettings.sidebarThreadSortOrder,857 appSettings.sidebarThreadSortOrder,
630 )[0];858 )[0];
631 if (!latestThread) return;859 if (!latestThread) return;
...@@ -635,7 +863,7 @@ export default function Sidebar() {...@@ -635,7 +863,7 @@ export default function Sidebar() {
635 params: { threadId: latestThread.id },863 params: { threadId: latestThread.id },
636 });864 });
637 },865 },
638 [appSettings.sidebarThreadSortOrder, navigate, threads],866 [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId],
639 );867 );
640868
641 const addProjectFromPath = useCallback(869 const addProjectFromPath = useCallback(
...@@ -831,7 +1059,7 @@ export default function Sidebar() {...@@ -831,7 +1059,7 @@ export default function Sidebar() {
831 async (threadId: ThreadId, position: { x: number; y: number }) => {1059 async (threadId: ThreadId, position: { x: number; y: number }) => {
832 const api = readNativeApi();1060 const api = readNativeApi();
833 if (!api) return;1061 if (!api) return;
834 const thread = threads.find((t) => t.id === threadId);1062 const thread = sidebarThreadsById[threadId];
835 if (!thread) return;1063 if (!thread) return;
836 const threadWorkspacePath =1064 const threadWorkspacePath =
837 thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null;1065 thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null;
...@@ -894,7 +1122,7 @@ export default function Sidebar() {...@@ -894,7 +1122,7 @@ export default function Sidebar() {
894 deleteThread,1122 deleteThread,
895 markThreadUnread,1123 markThreadUnread,
896 projectCwdById,1124 projectCwdById,
897 threads,1125 sidebarThreadsById,
898 ],1126 ],
899 );1127 );
9001128
...@@ -916,7 +1144,7 @@ export default function Sidebar() {...@@ -916,7 +1144,7 @@ export default function Sidebar() {
9161144
917 if (clicked === "mark-unread") {1145 if (clicked === "mark-unread") {
918 for (const id of ids) {1146 for (const id of ids) {
919 const thread = threads.find((candidate) => candidate.id === id);1147 const thread = sidebarThreadsById[id];
920 markThreadUnread(id, thread?.latestTurn?.completedAt);1148 markThreadUnread(id, thread?.latestTurn?.completedAt);
921 }1149 }
922 clearSelection();1150 clearSelection();
...@@ -948,7 +1176,7 @@ export default function Sidebar() {...@@ -948,7 +1176,7 @@ export default function Sidebar() {
948 markThreadUnread,1176 markThreadUnread,
949 removeFromSelection,1177 removeFromSelection,
950 selectedThreadIds,1178 selectedThreadIds,
951 threads,1179 sidebarThreadsById,
952 ],1180 ],
953 );1181 );
9541182
...@@ -1024,8 +1252,8 @@ export default function Sidebar() {...@@ -1024,8 +1252,8 @@ export default function Sidebar() {
1024 }1252 }
1025 if (clicked !== "delete") return;1253 if (clicked !== "delete") return;
10261254
1027 const projectThreads = threads.filter((thread) => thread.projectId === projectId);1255 const projectThreadIds = threadIdsByProjectId[projectId] ?? [];
1028 if (projectThreads.length > 0) {1256 if (projectThreadIds.length > 0) {
1029 toastManager.add({1257 toastManager.add({
1030 type: "warning",1258 type: "warning",
1031 title: "Project is not empty",1259 title: "Project is not empty",
...@@ -1064,7 +1292,7 @@ export default function Sidebar() {...@@ -1064,7 +1292,7 @@ export default function Sidebar() {
1064 copyPathToClipboard,1292 copyPathToClipboard,
1065 getDraftThreadByProjectId,1293 getDraftThreadByProjectId,
1066 projects,1294 projects,
1067 threads,1295 threadIdsByProjectId,
1068 ],1296 ],
1069 );1297 );
10701298
...@@ -1152,8 +1380,8 @@ export default function Sidebar() {...@@ -1152,8 +1380,8 @@ export default function Sidebar() {
1152 );1380 );
11531381
1154 const visibleThreads = useMemo(1382 const visibleThreads = useMemo(
1155 () => threads.filter((thread) => thread.archivedAt === null),1383 () => sidebarThreads.filter((thread) => thread.archivedAt === null),
1156 [threads],1384 [sidebarThreads],
1157 );1385 );
1158 const sortedProjects = useMemo(1386 const sortedProjects = useMemo(
1159 () =>1387 () =>
...@@ -1164,22 +1392,22 @@ export default function Sidebar() {...@@ -1164,22 +1392,22 @@ export default function Sidebar() {
1164 const renderedProjects = useMemo(1392 const renderedProjects = useMemo(
1165 () =>1393 () =>
1166 sortedProjects.map((project) => {1394 sortedProjects.map((project) => {
1395 const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) =>
1396 resolveThreadStatusPill({
1397 thread: {
1398 ...thread,
1399 lastVisitedAt: threadLastVisitedAtById[thread.id],
1400 },
1401 });
1167 const projectThreads = sortThreadsForSidebar(1402 const projectThreads = sortThreadsForSidebar(
1168 visibleThreads.filter((thread) => thread.projectId === project.id),1403 (threadIdsByProjectId[project.id] ?? [])
1404 .map((threadId) => sidebarThreadsById[threadId])
1405 .filter((thread): thread is NonNullable<typeof thread> => thread !== undefined)
1406 .filter((thread) => thread.archivedAt === null),
1169 appSettings.sidebarThreadSortOrder,1407 appSettings.sidebarThreadSortOrder,
1170 );1408 );
1171 const threadStatuses = new Map(
1172 projectThreads.map((thread) => [
1173 thread.id,
1174 resolveThreadStatusPill({
1175 thread,
1176 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
1177 hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
1178 }),
1179 ]),
1180 );
1181 const projectStatus = resolveProjectStatusIndicator(1409 const projectStatus = resolveProjectStatusIndicator(
1182 projectThreads.map((thread) => threadStatuses.get(thread.id) ?? null),1410 projectThreads.map((thread) => resolveProjectThreadStatus(thread)),
1183 );1411 );
1184 const activeThreadId = routeThreadId ?? undefined;1412 const activeThreadId = routeThreadId ?? undefined;
1185 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);1413 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
...@@ -1199,12 +1427,12 @@ export default function Sidebar() {...@@ -1199,12 +1427,12 @@ export default function Sidebar() {
1199 previewLimit: THREAD_PREVIEW_LIMIT,1427 previewLimit: THREAD_PREVIEW_LIMIT,
1200 });1428 });
1201 const hiddenThreadStatus = resolveProjectStatusIndicator(1429 const hiddenThreadStatus = resolveProjectStatusIndicator(
1202 hiddenThreads.map((thread) => threadStatuses.get(thread.id) ?? null),1430 hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)),
1203 );1431 );
1204 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);1432 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1205 const renderedThreads = pinnedCollapsedThread1433 const renderedThreadIds = pinnedCollapsedThread
1206 ? [pinnedCollapsedThread]1434 ? [pinnedCollapsedThread.id]
1207 : visibleProjectThreads;1435 : visibleProjectThreads.map((thread) => thread.id);
1208 const showEmptyThreadState = project.expanded && projectThreads.length === 0;1436 const showEmptyThreadState = project.expanded && projectThreads.length === 0;
12091437
1210 return {1438 return {
...@@ -1213,9 +1441,7 @@ export default function Sidebar() {...@@ -1213,9 +1441,7 @@ export default function Sidebar() {
1213 orderedProjectThreadIds,1441 orderedProjectThreadIds,
1214 project,1442 project,
1215 projectStatus,1443 projectStatus,
1216 projectThreads,1444 renderedThreadIds,
1217 threadStatuses,
1218 renderedThreads,
1219 showEmptyThreadState,1445 showEmptyThreadState,
1220 shouldShowThreadPanel,1446 shouldShowThreadPanel,
1221 isThreadListExpanded,1447 isThreadListExpanded,
...@@ -1226,7 +1452,9 @@ export default function Sidebar() {...@@ -1226,7 +1452,9 @@ export default function Sidebar() {
1226 expandedThreadListsByProject,1452 expandedThreadListsByProject,
1227 routeThreadId,1453 routeThreadId,
1228 sortedProjects,1454 sortedProjects,
1229 visibleThreads,1455 sidebarThreadsById,
1456 threadIdsByProjectId,
1457 threadLastVisitedAtById,
1230 ],1458 ],
1231 );1459 );
1232 const visibleSidebarThreadIds = useMemo(1460 const visibleSidebarThreadIds = useMemo(
...@@ -1267,7 +1495,7 @@ export default function Sidebar() {...@@ -1267,7 +1495,7 @@ export default function Sidebar() {
1267 terminalOpen: routeTerminalOpen,1495 terminalOpen: routeTerminalOpen,
1268 });1496 });
12691497
1270 const onWindowKeyDown = (event: KeyboardEvent) => {1498 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
1271 updateThreadJumpHintsVisibility(1499 updateThreadJumpHintsVisibility(
1272 shouldShowThreadJumpHints(event, keybindings, {1500 shouldShowThreadJumpHints(event, keybindings, {
1273 platform,1501 platform,
...@@ -1315,7 +1543,7 @@ export default function Sidebar() {...@@ -1315,7 +1543,7 @@ export default function Sidebar() {
1315 navigateToThread(targetThreadId);1543 navigateToThread(targetThreadId);
1316 };1544 };
13171545
1318 const onWindowKeyUp = (event: KeyboardEvent) => {1546 const onWindowKeyUp = (event: globalThis.KeyboardEvent) => {
1319 updateThreadJumpHintsVisibility(1547 updateThreadJumpHintsVisibility(
1320 shouldShowThreadJumpHints(event, keybindings, {1548 shouldShowThreadJumpHints(event, keybindings, {
1321 platform,1549 platform,
...@@ -1358,263 +1586,11 @@ export default function Sidebar() {...@@ -1358,263 +1586,11 @@ export default function Sidebar() {
1358 orderedProjectThreadIds,1586 orderedProjectThreadIds,
1359 project,1587 project,
1360 projectStatus,1588 projectStatus,
1361 projectThreads,1589 renderedThreadIds,
1362 threadStatuses,
1363 renderedThreads,
1364 showEmptyThreadState,1590 showEmptyThreadState,
1365 shouldShowThreadPanel,1591 shouldShowThreadPanel,
1366 isThreadListExpanded,1592 isThreadListExpanded,
1367 } = renderedProject;1593 } = renderedProject;
1368 const renderThreadRow = (thread: (typeof projectThreads)[number]) => {
1369 const isActive = routeThreadId === thread.id;
1370 const isSelected = selectedThreadIds.has(thread.id);
1371 const isHighlighted = isActive || isSelected;
1372 const jumpLabel = threadJumpLabelById.get(thread.id) ?? null;
1373 const isThreadRunning =
1374 thread.session?.status === "running" && thread.session.activeTurnId != null;
1375 const threadStatus = threadStatuses.get(thread.id) ?? null;
1376 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
1377 const terminalStatus = terminalStatusFromRunningIds(
1378 selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds,
1379 );
1380 const isConfirmingArchive = confirmingArchiveThreadId === thread.id && !isThreadRunning;
1381 const threadMetaClassName = isConfirmingArchive
1382 ? "pointer-events-none opacity-0"
1383 : !isThreadRunning
1384 ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"
1385 : "pointer-events-none";
1386
1387 return (
1388 <SidebarMenuSubItem
1389 key={thread.id}
1390 className="w-full"
1391 data-thread-item
1392 onMouseLeave={() => {
1393 setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
1394 }}
1395 onBlurCapture={(event) => {
1396 const currentTarget = event.currentTarget;
1397 requestAnimationFrame(() => {
1398 if (currentTarget.contains(document.activeElement)) {
1399 return;
1400 }
1401 setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current));
1402 });
1403 }}
1404 >
1405 <SidebarMenuSubButton
1406 render={<div role="button" tabIndex={0} />}
1407 size="sm"
1408 isActive={isActive}
1409 data-testid={`thread-row-${thread.id}`}
1410 className={`${resolveThreadRowClassName({
1411 isActive,
1412 isSelected,
1413 })} relative isolate`}
1414 onClick={(event) => {
1415 handleThreadClick(event, thread.id, orderedProjectThreadIds);
1416 }}
1417 onKeyDown={(event) => {
1418 if (event.key !== "Enter" && event.key !== " ") return;
1419 event.preventDefault();
1420 navigateToThread(thread.id);
1421 }}
1422 onContextMenu={(event) => {
1423 event.preventDefault();
1424 if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) {
1425 void handleMultiSelectContextMenu({
1426 x: event.clientX,
1427 y: event.clientY,
1428 });
1429 } else {
1430 if (selectedThreadIds.size > 0) {
1431 clearSelection();
1432 }
1433 void handleThreadContextMenu(thread.id, {
1434 x: event.clientX,
1435 y: event.clientY,
1436 });
1437 }
1438 }}
1439 >
1440 <div className="flex min-w-0 flex-1 items-center gap-1.5 text-left">
1441 {prStatus && (
1442 <Tooltip>
1443 <TooltipTrigger
1444 render={
1445 <button
1446 type="button"
1447 aria-label={prStatus.tooltip}
1448 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
1449 onClick={(event) => {
1450 openPrLink(event, prStatus.url);
1451 }}
1452 >
1453 <GitPullRequestIcon className="size-3" />
1454 </button>
1455 }
1456 />
1457 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
1458 </Tooltip>
1459 )}
1460 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
1461 {renamingThreadId === thread.id ? (
1462 <input
1463 ref={(el) => {
1464 if (el && renamingInputRef.current !== el) {
1465 renamingInputRef.current = el;
1466 el.focus();
1467 el.select();
1468 }
1469 }}
1470 className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
1471 value={renamingTitle}
1472 onChange={(e) => setRenamingTitle(e.target.value)}
1473 onKeyDown={(e) => {
1474 e.stopPropagation();
1475 if (e.key === "Enter") {
1476 e.preventDefault();
1477 renamingCommittedRef.current = true;
1478 void commitRename(thread.id, renamingTitle, thread.title);
1479 } else if (e.key === "Escape") {
1480 e.preventDefault();
1481 renamingCommittedRef.current = true;
1482 cancelRename();
1483 }
1484 }}
1485 onBlur={() => {
1486 if (!renamingCommittedRef.current) {
1487 void commitRename(thread.id, renamingTitle, thread.title);
1488 }
1489 }}
1490 onClick={(e) => e.stopPropagation()}
1491 />
1492 ) : (
1493 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
1494 )}
1495 </div>
1496 <div className="ml-auto flex shrink-0 items-center gap-1.5">
1497 {terminalStatus && (
1498 <span
1499 role="img"
1500 aria-label={terminalStatus.label}
1501 title={terminalStatus.label}
1502 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1503 >
1504 <TerminalIcon
1505 className={`size-3 ${terminalStatus.pulse ? "animate-pulse" : ""}`}
1506 />
1507 </span>
1508 )}
1509 <div className="flex min-w-12 justify-end">
1510 {isConfirmingArchive ? (
1511 <button
1512 ref={(element) => {
1513 if (element) {
1514 confirmArchiveButtonRefs.current.set(thread.id, element);
1515 } else {
1516 confirmArchiveButtonRefs.current.delete(thread.id);
1517 }
1518 }}
1519 type="button"
1520 data-thread-selection-safe
1521 data-testid={`thread-archive-confirm-${thread.id}`}
1522 aria-label={`Confirm archive ${thread.title}`}
1523 className="absolute top-1/2 right-1 inline-flex h-5 -translate-y-1/2 cursor-pointer items-center rounded-full bg-destructive/12 px-2 text-[10px] font-medium text-destructive transition-colors hover:bg-destructive/18 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-destructive/40"
1524 onPointerDown={(event) => {
1525 event.stopPropagation();
1526 }}
1527 onClick={(event) => {
1528 event.preventDefault();
1529 event.stopPropagation();
1530 setConfirmingArchiveThreadId((current) =>
1531 current === thread.id ? null : current,
1532 );
1533 void attemptArchiveThread(thread.id);
1534 }}
1535 >
1536 Confirm
1537 </button>
1538 ) : !isThreadRunning ? (
1539 appSettings.confirmThreadArchive ? (
1540 <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
1541 <button
1542 type="button"
1543 data-thread-selection-safe
1544 data-testid={`thread-archive-${thread.id}`}
1545 aria-label={`Archive ${thread.title}`}
1546 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
1547 onPointerDown={(event) => {
1548 event.stopPropagation();
1549 }}
1550 onClick={(event) => {
1551 event.preventDefault();
1552 event.stopPropagation();
1553 setConfirmingArchiveThreadId(thread.id);
1554 requestAnimationFrame(() => {
1555 confirmArchiveButtonRefs.current.get(thread.id)?.focus();
1556 });
1557 }}
1558 >
1559 <ArchiveIcon className="size-3.5" />
1560 </button>
1561 </div>
1562 ) : (
1563 <Tooltip>
1564 <TooltipTrigger
1565 render={
1566 <div className="pointer-events-none absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity duration-150 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
1567 <button
1568 type="button"
1569 data-thread-selection-safe
1570 data-testid={`thread-archive-${thread.id}`}
1571 aria-label={`Archive ${thread.title}`}
1572 className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
1573 onPointerDown={(event) => {
1574 event.stopPropagation();
1575 }}
1576 onClick={(event) => {
1577 event.preventDefault();
1578 event.stopPropagation();
1579 void attemptArchiveThread(thread.id);
1580 }}
1581 >
1582 <ArchiveIcon className="size-3.5" />
1583 </button>
1584 </div>
1585 }
1586 />
1587 <TooltipPopup side="top">Archive</TooltipPopup>
1588 </Tooltip>
1589 )
1590 ) : null}
1591 <span className={threadMetaClassName}>
1592 {showThreadJumpHints && jumpLabel ? (
1593 <span
1594 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
1595 title={jumpLabel}
1596 >
1597 {jumpLabel}
1598 </span>
1599 ) : (
1600 <span
1601 className={`text-[10px] ${
1602 isHighlighted
1603 ? "text-foreground/72 dark:text-foreground/82"
1604 : "text-muted-foreground/40"
1605 }`}
1606 >
1607 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
1608 </span>
1609 )}
1610 </span>
1611 </div>
1612 </div>
1613 </SidebarMenuSubButton>
1614 </SidebarMenuSubItem>
1615 );
1616 };
1617
1618 return (1594 return (
1619 <>1595 <>
1620 <div className="group/project-header relative">1596 <div className="group/project-header relative">
...@@ -1737,7 +1713,37 @@ export default function Sidebar() {...@@ -1737,7 +1713,37 @@ export default function Sidebar() {
1737 </div>1713 </div>
1738 </SidebarMenuSubItem>1714 </SidebarMenuSubItem>
1739 ) : null}1715 ) : null}
1740 {shouldShowThreadPanel && renderedThreads.map((thread) => renderThreadRow(thread))}1716 {shouldShowThreadPanel &&
1717 renderedThreadIds.map((threadId) => (
1718 <SidebarThreadRow
1719 key={threadId}
1720 threadId={threadId}
1721 orderedProjectThreadIds={orderedProjectThreadIds}
1722 routeThreadId={routeThreadId}
1723 selectedThreadIds={selectedThreadIds}
1724 showThreadJumpHints={showThreadJumpHints}
1725 jumpLabel={threadJumpLabelById.get(threadId) ?? null}
1726 appSettingsConfirmThreadArchive={appSettings.confirmThreadArchive}
1727 renamingThreadId={renamingThreadId}
1728 renamingTitle={renamingTitle}
1729 setRenamingTitle={setRenamingTitle}
1730 renamingInputRef={renamingInputRef}
1731 renamingCommittedRef={renamingCommittedRef}
1732 confirmingArchiveThreadId={confirmingArchiveThreadId}
1733 setConfirmingArchiveThreadId={setConfirmingArchiveThreadId}
1734 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
1735 handleThreadClick={handleThreadClick}
1736 navigateToThread={navigateToThread}
1737 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
1738 handleThreadContextMenu={handleThreadContextMenu}
1739 clearSelection={clearSelection}
1740 commitRename={commitRename}
1741 cancelRename={cancelRename}
1742 attemptArchiveThread={attemptArchiveThread}
1743 openPrLink={openPrLink}
1744 pr={prByThreadId.get(threadId) ?? null}
1745 />
1746 ))}
17411747
1742 {project.expanded && hasHiddenThreads && !isThreadListExpanded && (1748 {project.expanded && hasHiddenThreads && !isThreadListExpanded && (
1743 <SidebarMenuSubItem className="w-full">1749 <SidebarMenuSubItem className="w-full">
...@@ -1778,7 +1784,7 @@ export default function Sidebar() {...@@ -1778,7 +1784,7 @@ export default function Sidebar() {
1778 }1784 }
17791785
1780 const handleProjectTitleClick = useCallback(1786 const handleProjectTitleClick = useCallback(
1781 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {1787 (event: MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
1782 if (suppressProjectClickForContextMenuRef.current) {1788 if (suppressProjectClickForContextMenuRef.current) {
1783 suppressProjectClickForContextMenuRef.current = false;1789 suppressProjectClickForContextMenuRef.current = false;
1784 event.preventDefault();1790 event.preventDefault();
...@@ -1806,7 +1812,7 @@ export default function Sidebar() {...@@ -1806,7 +1812,7 @@ export default function Sidebar() {
1806 );1812 );
18071813
1808 const handleProjectTitleKeyDown = useCallback(1814 const handleProjectTitleKeyDown = useCallback(
1809 (event: React.KeyboardEvent<HTMLButtonElement>, projectId: ProjectId) => {1815 (event: KeyboardEvent<HTMLButtonElement>, projectId: ProjectId) => {
1810 if (event.key !== "Enter" && event.key !== " ") return;1816 if (event.key !== "Enter" && event.key !== " ") return;
1811 event.preventDefault();1817 event.preventDefault();
1812 if (dragInProgressRef.current) {1818 if (dragInProgressRef.current) {
apps/web/src/routes/__root.tsx+63-2
...@@ -153,6 +153,43 @@ function errorDetails(error: unknown): string {...@@ -153,6 +153,43 @@ function errorDetails(error: unknown): string {
153 }153 }
154}154}
155155
156function coalesceOrchestrationUiEvents(
157 events: ReadonlyArray<OrchestrationEvent>,
158): OrchestrationEvent[] {
159 if (events.length < 2) {
160 return [...events];
161 }
162
163 const coalesced: OrchestrationEvent[] = [];
164 for (const event of events) {
165 const previous = coalesced.at(-1);
166 if (
167 previous?.type === "thread.message-sent" &&
168 event.type === "thread.message-sent" &&
169 previous.payload.threadId === event.payload.threadId &&
170 previous.payload.messageId === event.payload.messageId
171 ) {
172 coalesced[coalesced.length - 1] = {
173 ...event,
174 payload: {
175 ...event.payload,
176 attachments: event.payload.attachments ?? previous.payload.attachments,
177 createdAt: previous.payload.createdAt,
178 text:
179 !event.payload.streaming && event.payload.text.length > 0
180 ? event.payload.text
181 : previous.payload.text + event.payload.text,
182 },
183 };
184 continue;
185 }
186
187 coalesced.push(event);
188 }
189
190 return coalesced;
191}
192
156function EventRouter() {193function EventRouter() {
157 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);194 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);
158 const syncServerReadModel = useStore((store) => store.syncServerReadModel);195 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
...@@ -269,6 +306,8 @@ function EventRouter() {...@@ -269,6 +306,8 @@ function EventRouter() {
269 disposedRef.current = false;306 disposedRef.current = false;
270 const recovery = createOrchestrationRecoveryCoordinator();307 const recovery = createOrchestrationRecoveryCoordinator();
271 let needsProviderInvalidation = false;308 let needsProviderInvalidation = false;
309 const pendingDomainEvents: OrchestrationEvent[] = [];
310 let flushPendingDomainEventsScheduled = false;
272311
273 const reconcileSnapshotDerivedState = () => {312 const reconcileSnapshotDerivedState = () => {
274 const threads = useStore.getState().threads;313 const threads = useStore.getState().threads;
...@@ -316,6 +355,7 @@ function EventRouter() {...@@ -316,6 +355,7 @@ function EventRouter() {
316 }355 }
317356
318 const batchEffects = deriveOrchestrationBatchEffects(nextEvents);357 const batchEffects = deriveOrchestrationBatchEffects(nextEvents);
358 const uiEvents = coalesceOrchestrationUiEvents(nextEvents);
319 const needsProjectUiSync = nextEvents.some(359 const needsProjectUiSync = nextEvents.some(
320 (event) =>360 (event) =>
321 event.type === "project.created" ||361 event.type === "project.created" ||
...@@ -328,7 +368,7 @@ function EventRouter() {...@@ -328,7 +368,7 @@ function EventRouter() {
328 void queryInvalidationThrottler.maybeExecute();368 void queryInvalidationThrottler.maybeExecute();
329 }369 }
330370
331 applyOrchestrationEvents(nextEvents);371 applyOrchestrationEvents(uiEvents);
332 if (needsProjectUiSync) {372 if (needsProjectUiSync) {
333 const projects = useStore.getState().projects;373 const projects = useStore.getState().projects;
334 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));374 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
...@@ -357,6 +397,23 @@ function EventRouter() {...@@ -357,6 +397,23 @@ function EventRouter() {
357 removeTerminalState(threadId);397 removeTerminalState(threadId);
358 }398 }
359 };399 };
400 const flushPendingDomainEvents = () => {
401 flushPendingDomainEventsScheduled = false;
402 if (disposed || pendingDomainEvents.length === 0) {
403 return;
404 }
405
406 const events = pendingDomainEvents.splice(0, pendingDomainEvents.length);
407 applyEventBatch(events);
408 };
409 const schedulePendingDomainEventFlush = () => {
410 if (flushPendingDomainEventsScheduled) {
411 return;
412 }
413
414 flushPendingDomainEventsScheduled = true;
415 queueMicrotask(flushPendingDomainEvents);
416 };
360417
361 const recoverFromSequenceGap = async (): Promise<void> => {418 const recoverFromSequenceGap = async (): Promise<void> => {
362 if (!recovery.beginReplayRecovery("sequence-gap")) {419 if (!recovery.beginReplayRecovery("sequence-gap")) {
...@@ -410,10 +467,12 @@ function EventRouter() {...@@ -410,10 +467,12 @@ function EventRouter() {
410 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {467 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {
411 const action = recovery.classifyDomainEvent(event.sequence);468 const action = recovery.classifyDomainEvent(event.sequence);
412 if (action === "apply") {469 if (action === "apply") {
413 applyEventBatch([event]);470 pendingDomainEvents.push(event);
471 schedulePendingDomainEventFlush();
414 return;472 return;
415 }473 }
416 if (action === "recover") {474 if (action === "recover") {
475 flushPendingDomainEvents();
417 void recoverFromSequenceGap();476 void recoverFromSequenceGap();
418 }477 }
419 });478 });
...@@ -434,6 +493,8 @@ function EventRouter() {...@@ -434,6 +493,8 @@ function EventRouter() {
434 disposed = true;493 disposed = true;
435 disposedRef.current = true;494 disposedRef.current = true;
436 needsProviderInvalidation = false;495 needsProviderInvalidation = false;
496 flushPendingDomainEventsScheduled = false;
497 pendingDomainEvents.length = 0;
437 queryInvalidationThrottler.cancel();498 queryInvalidationThrottler.cancel();
438 unsubDomainEvent();499 unsubDomainEvent();
439 unsubTerminalEvent();500 unsubTerminalEvent();
apps/web/src/routes/_chat.$threadId.tsx+2-2
...@@ -222,7 +222,7 @@ function ChatThreadRouteView() {...@@ -222,7 +222,7 @@ function ChatThreadRouteView() {
222 return (222 return (
223 <>223 <>
224 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">224 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
225 <ChatView key={threadId} threadId={threadId} />225 <ChatView threadId={threadId} />
226 </SidebarInset>226 </SidebarInset>
227 <DiffPanelInlineSidebar227 <DiffPanelInlineSidebar
228 diffOpen={diffOpen}228 diffOpen={diffOpen}
...@@ -237,7 +237,7 @@ function ChatThreadRouteView() {...@@ -237,7 +237,7 @@ function ChatThreadRouteView() {
237 return (237 return (
238 <>238 <>
239 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">239 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
240 <ChatView key={threadId} threadId={threadId} />240 <ChatView threadId={threadId} />
241 </SidebarInset>241 </SidebarInset>
242 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>242 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
243 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}243 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}
apps/web/src/store.test.ts+73
...@@ -47,6 +47,9 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -47,6 +47,9 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
47}47}
4848
49function makeState(thread: Thread): AppState {49function makeState(thread: Thread): AppState {
50 const threadIdsByProjectId: AppState["threadIdsByProjectId"] = {
51 [thread.projectId]: [thread.id],
52 };
50 return {53 return {
51 projects: [54 projects: [
52 {55 {
...@@ -61,6 +64,8 @@ function makeState(thread: Thread): AppState {...@@ -61,6 +64,8 @@ function makeState(thread: Thread): AppState {
61 },64 },
62 ],65 ],
63 threads: [thread],66 threads: [thread],
67 sidebarThreadsById: {},
68 threadIdsByProjectId,
64 bootstrapComplete: true,69 bootstrapComplete: true,
65 };70 };
66}71}
...@@ -271,6 +276,8 @@ describe("store read model sync", () => {...@@ -271,6 +276,8 @@ describe("store read model sync", () => {
271 },276 },
272 ],277 ],
273 threads: [],278 threads: [],
279 sidebarThreadsById: {},
280 threadIdsByProjectId: {},
274 bootstrapComplete: true,281 bootstrapComplete: true,
275 };282 };
276 const readModel: OrchestrationReadModel = {283 const readModel: OrchestrationReadModel = {
...@@ -361,6 +368,8 @@ describe("incremental orchestration updates", () => {...@@ -361,6 +368,8 @@ describe("incremental orchestration updates", () => {
361 },368 },
362 ],369 ],
363 threads: [],370 threads: [],
371 sidebarThreadsById: {},
372 threadIdsByProjectId: {},
364 bootstrapComplete: true,373 bootstrapComplete: true,
365 };374 };
366375
...@@ -386,6 +395,70 @@ describe("incremental orchestration updates", () => {...@@ -386,6 +395,70 @@ describe("incremental orchestration updates", () => {
386 expect(next.projects[0]?.name).toBe("Project Recreated");395 expect(next.projects[0]?.name).toBe("Project Recreated");
387 });396 });
388397
398 it("removes stale project index entries when thread.created recreates a thread under a new project", () => {
399 const originalProjectId = ProjectId.makeUnsafe("project-1");
400 const recreatedProjectId = ProjectId.makeUnsafe("project-2");
401 const threadId = ThreadId.makeUnsafe("thread-1");
402 const thread = makeThread({
403 id: threadId,
404 projectId: originalProjectId,
405 });
406 const state: AppState = {
407 projects: [
408 {
409 id: originalProjectId,
410 name: "Project 1",
411 cwd: "/tmp/project-1",
412 defaultModelSelection: {
413 provider: "codex",
414 model: DEFAULT_MODEL_BY_PROVIDER.codex,
415 },
416 scripts: [],
417 },
418 {
419 id: recreatedProjectId,
420 name: "Project 2",
421 cwd: "/tmp/project-2",
422 defaultModelSelection: {
423 provider: "codex",
424 model: DEFAULT_MODEL_BY_PROVIDER.codex,
425 },
426 scripts: [],
427 },
428 ],
429 threads: [thread],
430 sidebarThreadsById: {},
431 threadIdsByProjectId: {
432 [originalProjectId]: [threadId],
433 },
434 bootstrapComplete: true,
435 };
436
437 const next = applyOrchestrationEvent(
438 state,
439 makeEvent("thread.created", {
440 threadId,
441 projectId: recreatedProjectId,
442 title: "Recovered thread",
443 modelSelection: {
444 provider: "codex",
445 model: DEFAULT_MODEL_BY_PROVIDER.codex,
446 },
447 runtimeMode: DEFAULT_RUNTIME_MODE,
448 interactionMode: DEFAULT_INTERACTION_MODE,
449 branch: null,
450 worktreePath: null,
451 createdAt: "2026-02-27T00:00:01.000Z",
452 updatedAt: "2026-02-27T00:00:01.000Z",
453 }),
454 );
455
456 expect(next.threads).toHaveLength(1);
457 expect(next.threads[0]?.projectId).toBe(recreatedProjectId);
458 expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined();
459 expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]);
460 });
461
389 it("updates only the affected thread for message events", () => {462 it("updates only the affected thread for message events", () => {
390 const thread1 = makeThread({463 const thread1 = makeThread({
391 id: ThreadId.makeUnsafe("thread-1"),464 id: ThreadId.makeUnsafe("thread-1"),
apps/web/src/store.ts+246-35
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 type OrchestrationEvent,2 type OrchestrationEvent,
3 type OrchestrationMessage,3 type OrchestrationMessage,
4 type OrchestrationProposedPlan,4 type OrchestrationProposedPlan,
5 type ProjectId,
5 type ProviderKind,6 type ProviderKind,
6 ThreadId,7 ThreadId,
7 type OrchestrationReadModel,8 type OrchestrationReadModel,
...@@ -12,25 +13,36 @@ import {...@@ -12,25 +13,36 @@ import {
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
13import { resolveModelSlugForProvider } from "@t3tools/shared/model";14import { resolveModelSlugForProvider } from "@t3tools/shared/model";
14import { create } from "zustand";15import { create } from "zustand";
15import { type ChatMessage, type Project, type Thread } from "./types";16import {
17 findLatestProposedPlan,
18 hasActionableProposedPlan,
19 derivePendingApprovals,
20 derivePendingUserInputs,
21} from "./session-logic";
22import { type ChatMessage, type Project, type SidebarThreadSummary, type Thread } from "./types";
1623
17// ── State ────────────────────────────────────────────────────────────24// ── State ────────────────────────────────────────────────────────────
1825
19export interface AppState {26export interface AppState {
20 projects: Project[];27 projects: Project[];
21 threads: Thread[];28 threads: Thread[];
29 sidebarThreadsById: Record<string, SidebarThreadSummary>;
30 threadIdsByProjectId: Record<string, ThreadId[]>;
22 bootstrapComplete: boolean;31 bootstrapComplete: boolean;
23}32}
2433
25const initialState: AppState = {34const initialState: AppState = {
26 projects: [],35 projects: [],
27 threads: [],36 threads: [],
37 sidebarThreadsById: {},
38 threadIdsByProjectId: {},
28 bootstrapComplete: false,39 bootstrapComplete: false,
29};40};
30const MAX_THREAD_MESSAGES = 2_000;41const MAX_THREAD_MESSAGES = 2_000;
31const MAX_THREAD_CHECKPOINTS = 500;42const MAX_THREAD_CHECKPOINTS = 500;
32const MAX_THREAD_PROPOSED_PLANS = 200;43const MAX_THREAD_PROPOSED_PLANS = 200;
33const MAX_THREAD_ACTIVITIES = 500;44const MAX_THREAD_ACTIVITIES = 500;
45const EMPTY_THREAD_IDS: ThreadId[] = [];
3446
35// ── Pure helpers ──────────────────────────────────────────────────────47// ── Pure helpers ──────────────────────────────────────────────────────
3648
...@@ -180,6 +192,127 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec...@@ -180,6 +192,127 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec
180 };192 };
181}193}
182194
195function getLatestUserMessageAt(
196 messages: ReadonlyArray<Thread["messages"][number]>,
197): string | null {
198 let latestUserMessageAt: string | null = null;
199
200 for (const message of messages) {
201 if (message.role !== "user") {
202 continue;
203 }
204 if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) {
205 latestUserMessageAt = message.createdAt;
206 }
207 }
208
209 return latestUserMessageAt;
210}
211
212function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary {
213 return {
214 id: thread.id,
215 projectId: thread.projectId,
216 title: thread.title,
217 interactionMode: thread.interactionMode,
218 session: thread.session,
219 createdAt: thread.createdAt,
220 archivedAt: thread.archivedAt,
221 updatedAt: thread.updatedAt,
222 latestTurn: thread.latestTurn,
223 branch: thread.branch,
224 worktreePath: thread.worktreePath,
225 latestUserMessageAt: getLatestUserMessageAt(thread.messages),
226 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,
227 hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,
228 hasActionableProposedPlan: hasActionableProposedPlan(
229 findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null),
230 ),
231 };
232}
233
234function sidebarThreadSummariesEqual(
235 left: SidebarThreadSummary | undefined,
236 right: SidebarThreadSummary,
237): boolean {
238 return (
239 left !== undefined &&
240 left.id === right.id &&
241 left.projectId === right.projectId &&
242 left.title === right.title &&
243 left.interactionMode === right.interactionMode &&
244 left.session === right.session &&
245 left.createdAt === right.createdAt &&
246 left.archivedAt === right.archivedAt &&
247 left.updatedAt === right.updatedAt &&
248 left.latestTurn === right.latestTurn &&
249 left.branch === right.branch &&
250 left.worktreePath === right.worktreePath &&
251 left.latestUserMessageAt === right.latestUserMessageAt &&
252 left.hasPendingApprovals === right.hasPendingApprovals &&
253 left.hasPendingUserInput === right.hasPendingUserInput &&
254 left.hasActionableProposedPlan === right.hasActionableProposedPlan
255 );
256}
257
258function appendThreadIdByProjectId(
259 threadIdsByProjectId: Record<string, ThreadId[]>,
260 projectId: ProjectId,
261 threadId: ThreadId,
262): Record<string, ThreadId[]> {
263 const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS;
264 if (existingThreadIds.includes(threadId)) {
265 return threadIdsByProjectId;
266 }
267 return {
268 ...threadIdsByProjectId,
269 [projectId]: [...existingThreadIds, threadId],
270 };
271}
272
273function removeThreadIdByProjectId(
274 threadIdsByProjectId: Record<string, ThreadId[]>,
275 projectId: ProjectId,
276 threadId: ThreadId,
277): Record<string, ThreadId[]> {
278 const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS;
279 if (!existingThreadIds.includes(threadId)) {
280 return threadIdsByProjectId;
281 }
282 const nextThreadIds = existingThreadIds.filter(
283 (existingThreadId) => existingThreadId !== threadId,
284 );
285 if (nextThreadIds.length === existingThreadIds.length) {
286 return threadIdsByProjectId;
287 }
288 if (nextThreadIds.length === 0) {
289 const nextThreadIdsByProjectId = { ...threadIdsByProjectId };
290 delete nextThreadIdsByProjectId[projectId];
291 return nextThreadIdsByProjectId;
292 }
293 return {
294 ...threadIdsByProjectId,
295 [projectId]: nextThreadIds,
296 };
297}
298
299function buildThreadIdsByProjectId(threads: ReadonlyArray<Thread>): Record<string, ThreadId[]> {
300 const threadIdsByProjectId: Record<string, ThreadId[]> = {};
301 for (const thread of threads) {
302 const existingThreadIds = threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS;
303 threadIdsByProjectId[thread.projectId] = [...existingThreadIds, thread.id];
304 }
305 return threadIdsByProjectId;
306}
307
308function buildSidebarThreadsById(
309 threads: ReadonlyArray<Thread>,
310): Record<string, SidebarThreadSummary> {
311 return Object.fromEntries(
312 threads.map((thread) => [thread.id, buildSidebarThreadSummary(thread)]),
313 );
314}
315
183function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") {316function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") {
184 if (status === "error") {317 if (status === "error") {
185 return "error" as const;318 return "error" as const;
...@@ -398,6 +531,46 @@ function attachmentPreviewRoutePath(attachmentId: string): string {...@@ -398,6 +531,46 @@ function attachmentPreviewRoutePath(attachmentId: string): string {
398 return `/attachments/${encodeURIComponent(attachmentId)}`;531 return `/attachments/${encodeURIComponent(attachmentId)}`;
399}532}
400533
534function updateThreadState(
535 state: AppState,
536 threadId: ThreadId,
537 updater: (thread: Thread) => Thread,
538): AppState {
539 let updatedThread: Thread | null = null;
540 const threads = updateThread(state.threads, threadId, (thread) => {
541 const nextThread = updater(thread);
542 if (nextThread !== thread) {
543 updatedThread = nextThread;
544 }
545 return nextThread;
546 });
547 if (threads === state.threads || updatedThread === null) {
548 return state;
549 }
550
551 const nextSummary = buildSidebarThreadSummary(updatedThread);
552 const previousSummary = state.sidebarThreadsById[threadId];
553 const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary)
554 ? state.sidebarThreadsById
555 : {
556 ...state.sidebarThreadsById,
557 [threadId]: nextSummary,
558 };
559
560 if (sidebarThreadsById === state.sidebarThreadsById) {
561 return {
562 ...state,
563 threads,
564 };
565 }
566
567 return {
568 ...state,
569 threads,
570 sidebarThreadsById,
571 };
572}
573
401// ── Pure state transition functions ────────────────────────────────────574// ── Pure state transition functions ────────────────────────────────────
402575
403export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState {576export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState {
...@@ -405,10 +578,14 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea...@@ -405,10 +578,14 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
405 .filter((project) => project.deletedAt === null)578 .filter((project) => project.deletedAt === null)
406 .map(mapProject);579 .map(mapProject);
407 const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread);580 const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread);
581 const sidebarThreadsById = buildSidebarThreadsById(threads);
582 const threadIdsByProjectId = buildThreadIdsByProjectId(threads);
408 return {583 return {
409 ...state,584 ...state,
410 projects,585 projects,
411 threads,586 threads,
587 sidebarThreadsById,
588 threadIdsByProjectId,
412 bootstrapComplete: true,589 bootstrapComplete: true,
413 };590 };
414}591}
...@@ -489,34 +666,72 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -489,34 +666,72 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
489 const threads = existing666 const threads = existing
490 ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread))667 ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread))
491 : [...state.threads, nextThread];668 : [...state.threads, nextThread];
492 return { ...state, threads };669 const nextSummary = buildSidebarThreadSummary(nextThread);
670 const previousSummary = state.sidebarThreadsById[nextThread.id];
671 const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary)
672 ? state.sidebarThreadsById
673 : {
674 ...state.sidebarThreadsById,
675 [nextThread.id]: nextSummary,
676 };
677 const nextThreadIdsByProjectId =
678 existing !== undefined && existing.projectId !== nextThread.projectId
679 ? removeThreadIdByProjectId(state.threadIdsByProjectId, existing.projectId, existing.id)
680 : state.threadIdsByProjectId;
681 const threadIdsByProjectId = appendThreadIdByProjectId(
682 nextThreadIdsByProjectId,
683 nextThread.projectId,
684 nextThread.id,
685 );
686 return {
687 ...state,
688 threads,
689 sidebarThreadsById,
690 threadIdsByProjectId,
691 };
493 }692 }
494693
495 case "thread.deleted": {694 case "thread.deleted": {
496 const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId);695 const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId);
497 return threads.length === state.threads.length ? state : { ...state, threads };696 if (threads.length === state.threads.length) {
697 return state;
698 }
699 const deletedThread = state.threads.find((thread) => thread.id === event.payload.threadId);
700 const sidebarThreadsById = { ...state.sidebarThreadsById };
701 delete sidebarThreadsById[event.payload.threadId];
702 const threadIdsByProjectId = deletedThread
703 ? removeThreadIdByProjectId(
704 state.threadIdsByProjectId,
705 deletedThread.projectId,
706 deletedThread.id,
707 )
708 : state.threadIdsByProjectId;
709 return {
710 ...state,
711 threads,
712 sidebarThreadsById,
713 threadIdsByProjectId,
714 };
498 }715 }
499716
500 case "thread.archived": {717 case "thread.archived": {
501 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({718 return updateThreadState(state, event.payload.threadId, (thread) => ({
502 ...thread,719 ...thread,
503 archivedAt: event.payload.archivedAt,720 archivedAt: event.payload.archivedAt,
504 updatedAt: event.payload.updatedAt,721 updatedAt: event.payload.updatedAt,
505 }));722 }));
506 return threads === state.threads ? state : { ...state, threads };
507 }723 }
508724
509 case "thread.unarchived": {725 case "thread.unarchived": {
510 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({726 return updateThreadState(state, event.payload.threadId, (thread) => ({
511 ...thread,727 ...thread,
512 archivedAt: null,728 archivedAt: null,
513 updatedAt: event.payload.updatedAt,729 updatedAt: event.payload.updatedAt,
514 }));730 }));
515 return threads === state.threads ? state : { ...state, threads };
516 }731 }
517732
518 case "thread.meta-updated": {733 case "thread.meta-updated": {
519 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({734 return updateThreadState(state, event.payload.threadId, (thread) => ({
520 ...thread,735 ...thread,
521 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),736 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
522 ...(event.payload.modelSelection !== undefined737 ...(event.payload.modelSelection !== undefined
...@@ -528,29 +743,26 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -528,29 +743,26 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
528 : {}),743 : {}),
529 updatedAt: event.payload.updatedAt,744 updatedAt: event.payload.updatedAt,
530 }));745 }));
531 return threads === state.threads ? state : { ...state, threads };
532 }746 }
533747
534 case "thread.runtime-mode-set": {748 case "thread.runtime-mode-set": {
535 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({749 return updateThreadState(state, event.payload.threadId, (thread) => ({
536 ...thread,750 ...thread,
537 runtimeMode: event.payload.runtimeMode,751 runtimeMode: event.payload.runtimeMode,
538 updatedAt: event.payload.updatedAt,752 updatedAt: event.payload.updatedAt,
539 }));753 }));
540 return threads === state.threads ? state : { ...state, threads };
541 }754 }
542755
543 case "thread.interaction-mode-set": {756 case "thread.interaction-mode-set": {
544 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({757 return updateThreadState(state, event.payload.threadId, (thread) => ({
545 ...thread,758 ...thread,
546 interactionMode: event.payload.interactionMode,759 interactionMode: event.payload.interactionMode,
547 updatedAt: event.payload.updatedAt,760 updatedAt: event.payload.updatedAt,
548 }));761 }));
549 return threads === state.threads ? state : { ...state, threads };
550 }762 }
551763
552 case "thread.turn-start-requested": {764 case "thread.turn-start-requested": {
553 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({765 return updateThreadState(state, event.payload.threadId, (thread) => ({
554 ...thread,766 ...thread,
555 ...(event.payload.modelSelection !== undefined767 ...(event.payload.modelSelection !== undefined
556 ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) }768 ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) }
...@@ -560,14 +772,13 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -560,14 +772,13 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
560 pendingSourceProposedPlan: event.payload.sourceProposedPlan,772 pendingSourceProposedPlan: event.payload.sourceProposedPlan,
561 updatedAt: event.occurredAt,773 updatedAt: event.occurredAt,
562 }));774 }));
563 return threads === state.threads ? state : { ...state, threads };
564 }775 }
565776
566 case "thread.turn-interrupt-requested": {777 case "thread.turn-interrupt-requested": {
567 if (event.payload.turnId === undefined) {778 if (event.payload.turnId === undefined) {
568 return state;779 return state;
569 }780 }
570 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {781 return updateThreadState(state, event.payload.threadId, (thread) => {
571 const latestTurn = thread.latestTurn;782 const latestTurn = thread.latestTurn;
572 if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) {783 if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) {
573 return thread;784 return thread;
...@@ -586,11 +797,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -586,11 +797,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
586 updatedAt: event.occurredAt,797 updatedAt: event.occurredAt,
587 };798 };
588 });799 });
589 return threads === state.threads ? state : { ...state, threads };
590 }800 }
591801
592 case "thread.message-sent": {802 case "thread.message-sent": {
593 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {803 return updateThreadState(state, event.payload.threadId, (thread) => {
594 const message = mapMessage({804 const message = mapMessage({
595 id: event.payload.messageId,805 id: event.payload.messageId,
596 role: event.payload.role,806 role: event.payload.role,
...@@ -678,11 +888,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -678,11 +888,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
678 updatedAt: event.occurredAt,888 updatedAt: event.occurredAt,
679 };889 };
680 });890 });
681 return threads === state.threads ? state : { ...state, threads };
682 }891 }
683892
684 case "thread.session-set": {893 case "thread.session-set": {
685 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({894 return updateThreadState(state, event.payload.threadId, (thread) => ({
686 ...thread,895 ...thread,
687 session: mapSession(event.payload.session),896 session: mapSession(event.payload.session),
688 error: event.payload.session.lastError ?? null,897 error: event.payload.session.lastError ?? null,
...@@ -710,11 +919,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -710,11 +919,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
710 : thread.latestTurn,919 : thread.latestTurn,
711 updatedAt: event.occurredAt,920 updatedAt: event.occurredAt,
712 }));921 }));
713 return threads === state.threads ? state : { ...state, threads };
714 }922 }
715923
716 case "thread.session-stop-requested": {924 case "thread.session-stop-requested": {
717 const threads = updateThread(state.threads, event.payload.threadId, (thread) =>925 return updateThreadState(state, event.payload.threadId, (thread) =>
718 thread.session === null926 thread.session === null
719 ? thread927 ? thread
720 : {928 : {
...@@ -729,11 +937,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -729,11 +937,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
729 updatedAt: event.occurredAt,937 updatedAt: event.occurredAt,
730 },938 },
731 );939 );
732 return threads === state.threads ? state : { ...state, threads };
733 }940 }
734941
735 case "thread.proposed-plan-upserted": {942 case "thread.proposed-plan-upserted": {
736 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {943 return updateThreadState(state, event.payload.threadId, (thread) => {
737 const proposedPlan = mapProposedPlan(event.payload.proposedPlan);944 const proposedPlan = mapProposedPlan(event.payload.proposedPlan);
738 const proposedPlans = [945 const proposedPlans = [
739 ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id),946 ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id),
...@@ -750,11 +957,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -750,11 +957,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
750 updatedAt: event.occurredAt,957 updatedAt: event.occurredAt,
751 };958 };
752 });959 });
753 return threads === state.threads ? state : { ...state, threads };
754 }960 }
755961
756 case "thread.turn-diff-completed": {962 case "thread.turn-diff-completed": {
757 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {963 return updateThreadState(state, event.payload.threadId, (thread) => {
758 const checkpoint = mapTurnDiffSummary({964 const checkpoint = mapTurnDiffSummary({
759 turnId: event.payload.turnId,965 turnId: event.payload.turnId,
760 checkpointTurnCount: event.payload.checkpointTurnCount,966 checkpointTurnCount: event.payload.checkpointTurnCount,
...@@ -800,11 +1006,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -800,11 +1006,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
800 updatedAt: event.occurredAt,1006 updatedAt: event.occurredAt,
801 };1007 };
802 });1008 });
803 return threads === state.threads ? state : { ...state, threads };
804 }1009 }
8051010
806 case "thread.reverted": {1011 case "thread.reverted": {
807 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {1012 return updateThreadState(state, event.payload.threadId, (thread) => {
808 const turnDiffSummaries = thread.turnDiffSummaries1013 const turnDiffSummaries = thread.turnDiffSummaries
809 .filter(1014 .filter(
810 (entry) =>1015 (entry) =>
...@@ -853,11 +1058,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -853,11 +1058,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
853 updatedAt: event.occurredAt,1058 updatedAt: event.occurredAt,
854 };1059 };
855 });1060 });
856 return threads === state.threads ? state : { ...state, threads };
857 }1061 }
8581062
859 case "thread.activity-appended": {1063 case "thread.activity-appended": {
860 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {1064 return updateThreadState(state, event.payload.threadId, (thread) => {
861 const activities = [1065 const activities = [
862 ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id),1066 ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id),
863 { ...event.payload.activity },1067 { ...event.payload.activity },
...@@ -870,7 +1074,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve...@@ -870,7 +1074,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
870 updatedAt: event.occurredAt,1074 updatedAt: event.occurredAt,
871 };1075 };
872 });1076 });
873 return threads === state.threads ? state : { ...state, threads };
874 }1077 }
8751078
876 case "thread.approval-response-requested":1079 case "thread.approval-response-requested":
...@@ -901,12 +1104,21 @@ export const selectThreadById =...@@ -901,12 +1104,21 @@ export const selectThreadById =
901 (state: AppState): Thread | undefined =>1104 (state: AppState): Thread | undefined =>
902 threadId ? state.threads.find((thread) => thread.id === threadId) : undefined;1105 threadId ? state.threads.find((thread) => thread.id === threadId) : undefined;
9031106
1107export const selectSidebarThreadSummaryById =
1108 (threadId: ThreadId | null | undefined) =>
1109 (state: AppState): SidebarThreadSummary | undefined =>
1110 threadId ? state.sidebarThreadsById[threadId] : undefined;
1111
1112export const selectThreadIdsByProjectId =
1113 (projectId: ProjectId | null | undefined) =>
1114 (state: AppState): ThreadId[] =>
1115 projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS;
1116
904export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {1117export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
905 const threads = updateThread(state.threads, threadId, (t) => {1118 return updateThreadState(state, threadId, (t) => {
906 if (t.error === error) return t;1119 if (t.error === error) return t;
907 return { ...t, error };1120 return { ...t, error };
908 });1121 });
909 return threads === state.threads ? state : { ...state, threads };
910}1122}
9111123
912export function setThreadBranch(1124export function setThreadBranch(
...@@ -915,7 +1127,7 @@ export function setThreadBranch(...@@ -915,7 +1127,7 @@ export function setThreadBranch(
915 branch: string | null,1127 branch: string | null,
916 worktreePath: string | null,1128 worktreePath: string | null,
917): AppState {1129): AppState {
918 const threads = updateThread(state.threads, threadId, (t) => {1130 return updateThreadState(state, threadId, (t) => {
919 if (t.branch === branch && t.worktreePath === worktreePath) return t;1131 if (t.branch === branch && t.worktreePath === worktreePath) return t;
920 const cwdChanged = t.worktreePath !== worktreePath;1132 const cwdChanged = t.worktreePath !== worktreePath;
921 return {1133 return {
...@@ -925,7 +1137,6 @@ export function setThreadBranch(...@@ -925,7 +1137,6 @@ export function setThreadBranch(
925 ...(cwdChanged ? { session: null } : {}),1137 ...(cwdChanged ? { session: null } : {}),
926 };1138 };
927 });1139 });
928 return threads === state.threads ? state : { ...state, threads };
929}1140}
9301141
931// ── Zustand store ────────────────────────────────────────────────────1142// ── Zustand store ────────────────────────────────────────────────────
apps/web/src/storeSelectors.ts+14-2
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import { type ThreadId } from "@t3tools/contracts";1import { type ThreadId } from "@t3tools/contracts";
2import { useMemo } from "react";2import { useMemo } from "react";
3import { selectProjectById, selectThreadById, useStore } from "./store";3import {
4import { type Project, type Thread } from "./types";4 selectProjectById,
5 selectSidebarThreadSummaryById,
6 selectThreadById,
7 useStore,
8} from "./store";
9import { type Project, type SidebarThreadSummary, type Thread } from "./types";
510
6export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined {11export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined {
7 const selector = useMemo(() => selectProjectById(projectId), [projectId]);12 const selector = useMemo(() => selectProjectById(projectId), [projectId]);
...@@ -12,3 +17,10 @@ export function useThreadById(threadId: ThreadId | null | undefined): Thread | u...@@ -12,3 +17,10 @@ export function useThreadById(threadId: ThreadId | null | undefined): Thread | u
12 const selector = useMemo(() => selectThreadById(threadId), [threadId]);17 const selector = useMemo(() => selectThreadById(threadId), [threadId]);
13 return useStore(selector);18 return useStore(selector);
14}19}
20
21export function useSidebarThreadSummaryById(
22 threadId: ThreadId | null | undefined,
23): SidebarThreadSummary | undefined {
24 const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]);
25 return useStore(selector);
26}
apps/web/src/types.ts+18
...@@ -111,6 +111,24 @@ export interface Thread {...@@ -111,6 +111,24 @@ export interface Thread {
111 activities: OrchestrationThreadActivity[];111 activities: OrchestrationThreadActivity[];
112}112}
113113
114export interface SidebarThreadSummary {
115 id: ThreadId;
116 projectId: ProjectId;
117 title: string;
118 interactionMode: ProviderInteractionMode;
119 session: ThreadSession | null;
120 createdAt: string;
121 archivedAt: string | null;
122 updatedAt?: string | undefined;
123 latestTurn: OrchestrationLatestTurn | null;
124 branch: string | null;
125 worktreePath: string | null;
126 latestUserMessageAt: string | null;
127 hasPendingApprovals: boolean;
128 hasPendingUserInput: boolean;
129 hasActionableProposedPlan: boolean;
130}
131
114export interface ThreadSession {132export interface ThreadSession {
115 provider: ProviderKind;133 provider: ProviderKind;
116 status: SessionPhase | "error" | "closed";134 status: SessionPhase | "error" | "closed";