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", () => {
4646 it("returns true when a thread completed after its last visit", () => {
4747 expect(
4848 hasUnseenCompletion({
49 hasActionableProposedPlan: false,
50 hasPendingApprovals: false,
51 hasPendingUserInput: false,
4952 interactionMode: "default",
5053 latestTurn: makeLatestTurn(),
5154 lastVisitedAt: "2026-03-09T10:04:00.000Z",
52 proposedPlans: [],
5355 session: null,
5456 }),
5557 ).toBe(true);
......@@ -322,17 +324,14 @@ describe("getVisibleSidebarThreadIds", () => {
322324 expect(
323325 getVisibleSidebarThreadIds([
324326 {
325 renderedThreads: [
326 { id: ThreadId.makeUnsafe("thread-12") },
327 { id: ThreadId.makeUnsafe("thread-11") },
328 { id: ThreadId.makeUnsafe("thread-10") },
327 renderedThreadIds: [
328 ThreadId.makeUnsafe("thread-12"),
329 ThreadId.makeUnsafe("thread-11"),
330 ThreadId.makeUnsafe("thread-10"),
329331 ],
330332 },
331333 {
332 renderedThreads: [
333 { id: ThreadId.makeUnsafe("thread-8") },
334 { id: ThreadId.makeUnsafe("thread-6") },
335 ],
334 renderedThreadIds: [ThreadId.makeUnsafe("thread-8"), ThreadId.makeUnsafe("thread-6")],
336335 },
337336 ]),
338337 ).toEqual([
......@@ -349,17 +348,14 @@ describe("getVisibleSidebarThreadIds", () => {
349348 getVisibleSidebarThreadIds([
350349 {
351350 shouldShowThreadPanel: false,
352 renderedThreads: [
353 { id: ThreadId.makeUnsafe("thread-hidden-2") },
354 { id: ThreadId.makeUnsafe("thread-hidden-1") },
351 renderedThreadIds: [
352 ThreadId.makeUnsafe("thread-hidden-2"),
353 ThreadId.makeUnsafe("thread-hidden-1"),
355354 ],
356355 },
357356 {
358357 shouldShowThreadPanel: true,
359 renderedThreads: [
360 { id: ThreadId.makeUnsafe("thread-12") },
361 { id: ThreadId.makeUnsafe("thread-11") },
362 ],
358 renderedThreadIds: [ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")],
363359 },
364360 ]),
365361 ).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]);
......@@ -400,10 +396,12 @@ describe("isContextMenuPointerDown", () => {
400396
401397describe("resolveThreadStatusPill", () => {
402398 const baseThread = {
399 hasActionableProposedPlan: false,
400 hasPendingApprovals: false,
401 hasPendingUserInput: false,
403402 interactionMode: "plan" as const,
404403 latestTurn: null,
405404 lastVisitedAt: undefined,
406 proposedPlans: [],
407405 session: {
408406 provider: "codex" as const,
409407 status: "running" as const,
......@@ -416,9 +414,11 @@ describe("resolveThreadStatusPill", () => {
416414 it("shows pending approval before all other statuses", () => {
417415 expect(
418416 resolveThreadStatusPill({
419 thread: baseThread,
420 hasPendingApprovals: true,
421 hasPendingUserInput: true,
417 thread: {
418 ...baseThread,
419 hasPendingApprovals: true,
420 hasPendingUserInput: true,
421 },
422422 }),
423423 ).toMatchObject({ label: "Pending Approval", pulse: false });
424424 });
......@@ -426,9 +426,10 @@ describe("resolveThreadStatusPill", () => {
426426 it("shows awaiting input when plan mode is blocked on user answers", () => {
427427 expect(
428428 resolveThreadStatusPill({
429 thread: baseThread,
430 hasPendingApprovals: false,
431 hasPendingUserInput: true,
429 thread: {
430 ...baseThread,
431 hasPendingUserInput: true,
432 },
432433 }),
433434 ).toMatchObject({ label: "Awaiting Input", pulse: false });
434435 });
......@@ -437,8 +438,6 @@ describe("resolveThreadStatusPill", () => {
437438 expect(
438439 resolveThreadStatusPill({
439440 thread: baseThread,
440 hasPendingApprovals: false,
441 hasPendingUserInput: false,
442441 }),
443442 ).toMatchObject({ label: "Working", pulse: true });
444443 });
......@@ -448,26 +447,14 @@ describe("resolveThreadStatusPill", () => {
448447 resolveThreadStatusPill({
449448 thread: {
450449 ...baseThread,
450 hasActionableProposedPlan: true,
451451 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 ],
463452 session: {
464453 ...baseThread.session,
465454 status: "ready",
466455 orchestrationStatus: "ready",
467456 },
468457 },
469 hasPendingApprovals: false,
470 hasPendingUserInput: false,
471458 }),
472459 ).toMatchObject({ label: "Plan Ready", pulse: false });
473460 });
......@@ -478,25 +465,12 @@ describe("resolveThreadStatusPill", () => {
478465 thread: {
479466 ...baseThread,
480467 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 ],
492468 session: {
493469 ...baseThread.session,
494470 status: "ready",
495471 orchestrationStatus: "ready",
496472 },
497473 },
498 hasPendingApprovals: false,
499 hasPendingUserInput: false,
500474 }),
501475 ).toMatchObject({ label: "Completed", pulse: false });
502476 });
......@@ -515,8 +489,6 @@ describe("resolveThreadStatusPill", () => {
515489 orchestrationStatus: "ready",
516490 },
517491 },
518 hasPendingApprovals: false,
519 hasPendingUserInput: false,
520492 }),
521493 ).toMatchObject({ label: "Completed", pulse: false });
522494 });
apps/web/src/components/Sidebar.logic.ts+16-23
......@@ -1,12 +1,8 @@
11import * as React from "react";
22import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import type { Thread } from "../types";
3import type { SidebarThreadSummary, Thread } from "../types";
44import { cn } from "../lib/utils";
5import {
6 findLatestProposedPlan,
7 hasActionableProposedPlan,
8 isLatestTurnSettled,
9} from "../session-logic";
5import { isLatestTurnSettled } from "../session-logic";
106
117export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
128export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
......@@ -47,8 +43,13 @@ const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
4743};
4844
4945type ThreadStatusInput = Pick<
50 Thread,
51 "interactionMode" | "latestTurn" | "proposedPlans" | "session"
46 SidebarThreadSummary,
47 | "hasActionableProposedPlan"
48 | "hasPendingApprovals"
49 | "hasPendingUserInput"
50 | "interactionMode"
51 | "latestTurn"
52 | "session"
5253> & {
5354 lastVisitedAt?: string | undefined;
5455};
......@@ -232,15 +233,11 @@ export function orderItemsByPreferredIds<TItem, TId>(input: {
232233export function getVisibleSidebarThreadIds<TThreadId>(
233234 renderedProjects: readonly {
234235 shouldShowThreadPanel?: boolean;
235 renderedThreads: readonly {
236 id: TThreadId;
237 }[];
236 renderedThreadIds: readonly TThreadId[];
238237 }[],
239238): TThreadId[] {
240239 return renderedProjects.flatMap((renderedProject) =>
241 renderedProject.shouldShowThreadPanel === false
242 ? []
243 : renderedProject.renderedThreads.map((thread) => thread.id),
240 renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds,
244241 );
245242}
246243
......@@ -313,12 +310,10 @@ export function resolveThreadRowClassName(input: {
313310
314311export function resolveThreadStatusPill(input: {
315312 thread: ThreadStatusInput;
316 hasPendingApprovals: boolean;
317 hasPendingUserInput: boolean;
318313}): ThreadStatusPill | null {
319 const { hasPendingApprovals, hasPendingUserInput, thread } = input;
314 const { thread } = input;
320315
321 if (hasPendingApprovals) {
316 if (thread.hasPendingApprovals) {
322317 return {
323318 label: "Pending Approval",
324319 colorClass: "text-amber-600 dark:text-amber-300/90",
......@@ -327,7 +322,7 @@ export function resolveThreadStatusPill(input: {
327322 };
328323 }
329324
330 if (hasPendingUserInput) {
325 if (thread.hasPendingUserInput) {
331326 return {
332327 label: "Awaiting Input",
333328 colorClass: "text-indigo-600 dark:text-indigo-300/90",
......@@ -355,12 +350,10 @@ export function resolveThreadStatusPill(input: {
355350 }
356351
357352 const hasPlanReadyPrompt =
358 !hasPendingUserInput &&
353 !thread.hasPendingUserInput &&
359354 thread.interactionMode === "plan" &&
360355 isLatestTurnSettled(thread.latestTurn, thread.session) &&
361 hasActionableProposedPlan(
362 findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null),
363 );
356 thread.hasActionableProposedPlan;
364357 if (hasPlanReadyPrompt) {
365358 return {
366359 label: "Plan Ready",
apps/web/src/components/Sidebar.tsx+380-374
......@@ -18,8 +18,13 @@ import {
1818 useMemo,
1919 useRef,
2020 useState,
21 type Dispatch,
22 type KeyboardEvent,
2123 type MouseEvent,
24 type MutableRefObject,
2225 type PointerEvent,
26 type ReactNode,
27 type SetStateAction,
2328} from "react";
2429import { useShallow } from "zustand/react/shallow";
2530import {
......@@ -55,6 +60,7 @@ import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
5560import { isTerminalFocused } from "../lib/terminalFocus";
5661import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
5762import { useStore } from "../store";
63import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
5864import { useUiStateStore } from "../uiStateStore";
5965import {
6066 resolveShortcutCommand,
......@@ -64,14 +70,12 @@ import {
6470 threadJumpIndexFromCommand,
6571 threadTraversalDirectionFromCommand,
6672} from "../keybindings";
67import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
6873import { gitStatusQueryOptions } from "../lib/gitReactQuery";
6974import { readNativeApi } from "../nativeApi";
7075import { useComposerDraftStore } from "../composerDraftStore";
7176import { useHandleNewThread } from "../hooks/useHandleNewThread";
7277
7378import { useThreadActions } from "../hooks/useThreadActions";
74import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
7579import { toastManager } from "./ui/toast";
7680import { formatRelativeTimeLabel } from "../timestampFormat";
7781import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
......@@ -125,7 +129,8 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
125129import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
126130import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
127131import { useServerKeybindings } from "../rpc/serverState";
128import type { Project, Thread } from "../types";
132import { useSidebarThreadSummaryById } from "../storeSelectors";
133import type { Project } from "../types";
129134const THREAD_PREVIEW_LIMIT = 6;
130135const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
131136 updated_at: "Last user message",
......@@ -141,79 +146,9 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
141146 easing: "ease-out",
142147} 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
164149type SidebarProjectSnapshot = Project & {
165150 expanded: boolean;
166151};
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}
217152interface TerminalStatusIndicator {
218153 label: "Terminal process running";
219154 colorClass: string;
......@@ -310,6 +245,301 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
310245 return null;
311246}
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
313543function T3Wordmark() {
314544 return (
315545 <svg
......@@ -405,7 +635,7 @@ function SortableProjectItem({
405635}: {
406636 projectId: ProjectId;
407637 disabled?: boolean;
408 children: (handleProps: SortableProjectHandleProps) => React.ReactNode;
638 children: (handleProps: SortableProjectHandleProps) => ReactNode;
409639}) {
410640 const {
411641 attributes,
......@@ -437,7 +667,8 @@ function SortableProjectItem({
437667
438668export default function Sidebar() {
439669 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);
441672 const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore(
442673 useShallow((store) => ({
443674 projectExpandedById: store.projectExpandedById,
......@@ -513,13 +744,7 @@ export default function Sidebar() {
513744 })),
514745 [orderedProjects, projectExpandedById],
515746 );
516 const threads = useMemo(
517 () =>
518 serverThreads.map((thread) =>
519 toSidebarThreadSnapshot(thread, threadLastVisitedAtById[thread.id]),
520 ),
521 [serverThreads, threadLastVisitedAtById],
522 );
747 const sidebarThreads = useMemo(() => Object.values(sidebarThreadsById), [sidebarThreadsById]);
523748 const projectCwdById = useMemo(
524749 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
525750 [projects],
......@@ -539,12 +764,12 @@ export default function Sidebar() {
539764 );
540765 const threadGitTargets = useMemo(
541766 () =>
542 threads.map((thread) => ({
767 sidebarThreads.map((thread) => ({
543768 threadId: thread.id,
544769 branch: thread.branch,
545770 cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null,
546771 })),
547 [projectCwdById, threads],
772 [projectCwdById, sidebarThreads],
548773 );
549774 const threadGitStatusCwds = useMemo(
550775 () => [
......@@ -585,7 +810,7 @@ export default function Sidebar() {
585810 return map;
586811 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
587812
588 const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => {
813 const openPrLink = useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => {
589814 event.preventDefault();
590815 event.stopPropagation();
591816
......@@ -625,7 +850,10 @@ export default function Sidebar() {
625850 const focusMostRecentThreadForProject = useCallback(
626851 (projectId: ProjectId) => {
627852 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),
629857 appSettings.sidebarThreadSortOrder,
630858 )[0];
631859 if (!latestThread) return;
......@@ -635,7 +863,7 @@ export default function Sidebar() {
635863 params: { threadId: latestThread.id },
636864 });
637865 },
638 [appSettings.sidebarThreadSortOrder, navigate, threads],
866 [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId],
639867 );
640868
641869 const addProjectFromPath = useCallback(
......@@ -831,7 +1059,7 @@ export default function Sidebar() {
8311059 async (threadId: ThreadId, position: { x: number; y: number }) => {
8321060 const api = readNativeApi();
8331061 if (!api) return;
834 const thread = threads.find((t) => t.id === threadId);
1062 const thread = sidebarThreadsById[threadId];
8351063 if (!thread) return;
8361064 const threadWorkspacePath =
8371065 thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null;
......@@ -894,7 +1122,7 @@ export default function Sidebar() {
8941122 deleteThread,
8951123 markThreadUnread,
8961124 projectCwdById,
897 threads,
1125 sidebarThreadsById,
8981126 ],
8991127 );
9001128
......@@ -916,7 +1144,7 @@ export default function Sidebar() {
9161144
9171145 if (clicked === "mark-unread") {
9181146 for (const id of ids) {
919 const thread = threads.find((candidate) => candidate.id === id);
1147 const thread = sidebarThreadsById[id];
9201148 markThreadUnread(id, thread?.latestTurn?.completedAt);
9211149 }
9221150 clearSelection();
......@@ -948,7 +1176,7 @@ export default function Sidebar() {
9481176 markThreadUnread,
9491177 removeFromSelection,
9501178 selectedThreadIds,
951 threads,
1179 sidebarThreadsById,
9521180 ],
9531181 );
9541182
......@@ -1024,8 +1252,8 @@ export default function Sidebar() {
10241252 }
10251253 if (clicked !== "delete") return;
10261254
1027 const projectThreads = threads.filter((thread) => thread.projectId === projectId);
1028 if (projectThreads.length > 0) {
1255 const projectThreadIds = threadIdsByProjectId[projectId] ?? [];
1256 if (projectThreadIds.length > 0) {
10291257 toastManager.add({
10301258 type: "warning",
10311259 title: "Project is not empty",
......@@ -1064,7 +1292,7 @@ export default function Sidebar() {
10641292 copyPathToClipboard,
10651293 getDraftThreadByProjectId,
10661294 projects,
1067 threads,
1295 threadIdsByProjectId,
10681296 ],
10691297 );
10701298
......@@ -1152,8 +1380,8 @@ export default function Sidebar() {
11521380 );
11531381
11541382 const visibleThreads = useMemo(
1155 () => threads.filter((thread) => thread.archivedAt === null),
1156 [threads],
1383 () => sidebarThreads.filter((thread) => thread.archivedAt === null),
1384 [sidebarThreads],
11571385 );
11581386 const sortedProjects = useMemo(
11591387 () =>
......@@ -1164,22 +1392,22 @@ export default function Sidebar() {
11641392 const renderedProjects = useMemo(
11651393 () =>
11661394 sortedProjects.map((project) => {
1395 const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) =>
1396 resolveThreadStatusPill({
1397 thread: {
1398 ...thread,
1399 lastVisitedAt: threadLastVisitedAtById[thread.id],
1400 },
1401 });
11671402 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),
11691407 appSettings.sidebarThreadSortOrder,
11701408 );
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 );
11811409 const projectStatus = resolveProjectStatusIndicator(
1182 projectThreads.map((thread) => threadStatuses.get(thread.id) ?? null),
1410 projectThreads.map((thread) => resolveProjectThreadStatus(thread)),
11831411 );
11841412 const activeThreadId = routeThreadId ?? undefined;
11851413 const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
......@@ -1199,12 +1427,12 @@ export default function Sidebar() {
11991427 previewLimit: THREAD_PREVIEW_LIMIT,
12001428 });
12011429 const hiddenThreadStatus = resolveProjectStatusIndicator(
1202 hiddenThreads.map((thread) => threadStatuses.get(thread.id) ?? null),
1430 hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)),
12031431 );
12041432 const orderedProjectThreadIds = projectThreads.map((thread) => thread.id);
1205 const renderedThreads = pinnedCollapsedThread
1206 ? [pinnedCollapsedThread]
1207 : visibleProjectThreads;
1433 const renderedThreadIds = pinnedCollapsedThread
1434 ? [pinnedCollapsedThread.id]
1435 : visibleProjectThreads.map((thread) => thread.id);
12081436 const showEmptyThreadState = project.expanded && projectThreads.length === 0;
12091437
12101438 return {
......@@ -1213,9 +1441,7 @@ export default function Sidebar() {
12131441 orderedProjectThreadIds,
12141442 project,
12151443 projectStatus,
1216 projectThreads,
1217 threadStatuses,
1218 renderedThreads,
1444 renderedThreadIds,
12191445 showEmptyThreadState,
12201446 shouldShowThreadPanel,
12211447 isThreadListExpanded,
......@@ -1226,7 +1452,9 @@ export default function Sidebar() {
12261452 expandedThreadListsByProject,
12271453 routeThreadId,
12281454 sortedProjects,
1229 visibleThreads,
1455 sidebarThreadsById,
1456 threadIdsByProjectId,
1457 threadLastVisitedAtById,
12301458 ],
12311459 );
12321460 const visibleSidebarThreadIds = useMemo(
......@@ -1267,7 +1495,7 @@ export default function Sidebar() {
12671495 terminalOpen: routeTerminalOpen,
12681496 });
12691497
1270 const onWindowKeyDown = (event: KeyboardEvent) => {
1498 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
12711499 updateThreadJumpHintsVisibility(
12721500 shouldShowThreadJumpHints(event, keybindings, {
12731501 platform,
......@@ -1315,7 +1543,7 @@ export default function Sidebar() {
13151543 navigateToThread(targetThreadId);
13161544 };
13171545
1318 const onWindowKeyUp = (event: KeyboardEvent) => {
1546 const onWindowKeyUp = (event: globalThis.KeyboardEvent) => {
13191547 updateThreadJumpHintsVisibility(
13201548 shouldShowThreadJumpHints(event, keybindings, {
13211549 platform,
......@@ -1358,263 +1586,11 @@ export default function Sidebar() {
13581586 orderedProjectThreadIds,
13591587 project,
13601588 projectStatus,
1361 projectThreads,
1362 threadStatuses,
1363 renderedThreads,
1589 renderedThreadIds,
13641590 showEmptyThreadState,
13651591 shouldShowThreadPanel,
13661592 isThreadListExpanded,
13671593 } = 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
16181594 return (
16191595 <>
16201596 <div className="group/project-header relative">
......@@ -1737,7 +1713,37 @@ export default function Sidebar() {
17371713 </div>
17381714 </SidebarMenuSubItem>
17391715 ) : 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
17421748 {project.expanded && hasHiddenThreads && !isThreadListExpanded && (
17431749 <SidebarMenuSubItem className="w-full">
......@@ -1778,7 +1784,7 @@ export default function Sidebar() {
17781784 }
17791785
17801786 const handleProjectTitleClick = useCallback(
1781 (event: React.MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
1787 (event: MouseEvent<HTMLButtonElement>, projectId: ProjectId) => {
17821788 if (suppressProjectClickForContextMenuRef.current) {
17831789 suppressProjectClickForContextMenuRef.current = false;
17841790 event.preventDefault();
......@@ -1806,7 +1812,7 @@ export default function Sidebar() {
18061812 );
18071813
18081814 const handleProjectTitleKeyDown = useCallback(
1809 (event: React.KeyboardEvent<HTMLButtonElement>, projectId: ProjectId) => {
1815 (event: KeyboardEvent<HTMLButtonElement>, projectId: ProjectId) => {
18101816 if (event.key !== "Enter" && event.key !== " ") return;
18111817 event.preventDefault();
18121818 if (dragInProgressRef.current) {
apps/web/src/routes/__root.tsx+63-2
......@@ -153,6 +153,43 @@ function errorDetails(error: unknown): string {
153153 }
154154}
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
156193function EventRouter() {
157194 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);
158195 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
......@@ -269,6 +306,8 @@ function EventRouter() {
269306 disposedRef.current = false;
270307 const recovery = createOrchestrationRecoveryCoordinator();
271308 let needsProviderInvalidation = false;
309 const pendingDomainEvents: OrchestrationEvent[] = [];
310 let flushPendingDomainEventsScheduled = false;
272311
273312 const reconcileSnapshotDerivedState = () => {
274313 const threads = useStore.getState().threads;
......@@ -316,6 +355,7 @@ function EventRouter() {
316355 }
317356
318357 const batchEffects = deriveOrchestrationBatchEffects(nextEvents);
358 const uiEvents = coalesceOrchestrationUiEvents(nextEvents);
319359 const needsProjectUiSync = nextEvents.some(
320360 (event) =>
321361 event.type === "project.created" ||
......@@ -328,7 +368,7 @@ function EventRouter() {
328368 void queryInvalidationThrottler.maybeExecute();
329369 }
330370
331 applyOrchestrationEvents(nextEvents);
371 applyOrchestrationEvents(uiEvents);
332372 if (needsProjectUiSync) {
333373 const projects = useStore.getState().projects;
334374 syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd })));
......@@ -357,6 +397,23 @@ function EventRouter() {
357397 removeTerminalState(threadId);
358398 }
359399 };
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
361418 const recoverFromSequenceGap = async (): Promise<void> => {
362419 if (!recovery.beginReplayRecovery("sequence-gap")) {
......@@ -410,10 +467,12 @@ function EventRouter() {
410467 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {
411468 const action = recovery.classifyDomainEvent(event.sequence);
412469 if (action === "apply") {
413 applyEventBatch([event]);
470 pendingDomainEvents.push(event);
471 schedulePendingDomainEventFlush();
414472 return;
415473 }
416474 if (action === "recover") {
475 flushPendingDomainEvents();
417476 void recoverFromSequenceGap();
418477 }
419478 });
......@@ -434,6 +493,8 @@ function EventRouter() {
434493 disposed = true;
435494 disposedRef.current = true;
436495 needsProviderInvalidation = false;
496 flushPendingDomainEventsScheduled = false;
497 pendingDomainEvents.length = 0;
437498 queryInvalidationThrottler.cancel();
438499 unsubDomainEvent();
439500 unsubTerminalEvent();
apps/web/src/routes/_chat.$threadId.tsx+2-2
......@@ -222,7 +222,7 @@ function ChatThreadRouteView() {
222222 return (
223223 <>
224224 <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} />
226226 </SidebarInset>
227227 <DiffPanelInlineSidebar
228228 diffOpen={diffOpen}
......@@ -237,7 +237,7 @@ function ChatThreadRouteView() {
237237 return (
238238 <>
239239 <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} />
241241 </SidebarInset>
242242 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
243243 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}
apps/web/src/store.test.ts+73
......@@ -47,6 +47,9 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
4747}
4848
4949function makeState(thread: Thread): AppState {
50 const threadIdsByProjectId: AppState["threadIdsByProjectId"] = {
51 [thread.projectId]: [thread.id],
52 };
5053 return {
5154 projects: [
5255 {
......@@ -61,6 +64,8 @@ function makeState(thread: Thread): AppState {
6164 },
6265 ],
6366 threads: [thread],
67 sidebarThreadsById: {},
68 threadIdsByProjectId,
6469 bootstrapComplete: true,
6570 };
6671}
......@@ -271,6 +276,8 @@ describe("store read model sync", () => {
271276 },
272277 ],
273278 threads: [],
279 sidebarThreadsById: {},
280 threadIdsByProjectId: {},
274281 bootstrapComplete: true,
275282 };
276283 const readModel: OrchestrationReadModel = {
......@@ -361,6 +368,8 @@ describe("incremental orchestration updates", () => {
361368 },
362369 ],
363370 threads: [],
371 sidebarThreadsById: {},
372 threadIdsByProjectId: {},
364373 bootstrapComplete: true,
365374 };
366375
......@@ -386,6 +395,70 @@ describe("incremental orchestration updates", () => {
386395 expect(next.projects[0]?.name).toBe("Project Recreated");
387396 });
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
389462 it("updates only the affected thread for message events", () => {
390463 const thread1 = makeThread({
391464 id: ThreadId.makeUnsafe("thread-1"),
apps/web/src/store.ts+246-35
......@@ -2,6 +2,7 @@ import {
22 type OrchestrationEvent,
33 type OrchestrationMessage,
44 type OrchestrationProposedPlan,
5 type ProjectId,
56 type ProviderKind,
67 ThreadId,
78 type OrchestrationReadModel,
......@@ -12,25 +13,36 @@ import {
1213} from "@t3tools/contracts";
1314import { resolveModelSlugForProvider } from "@t3tools/shared/model";
1415import { 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
1724// ── State ────────────────────────────────────────────────────────────
1825
1926export interface AppState {
2027 projects: Project[];
2128 threads: Thread[];
29 sidebarThreadsById: Record<string, SidebarThreadSummary>;
30 threadIdsByProjectId: Record<string, ThreadId[]>;
2231 bootstrapComplete: boolean;
2332}
2433
2534const initialState: AppState = {
2635 projects: [],
2736 threads: [],
37 sidebarThreadsById: {},
38 threadIdsByProjectId: {},
2839 bootstrapComplete: false,
2940};
3041const MAX_THREAD_MESSAGES = 2_000;
3142const MAX_THREAD_CHECKPOINTS = 500;
3243const MAX_THREAD_PROPOSED_PLANS = 200;
3344const MAX_THREAD_ACTIVITIES = 500;
45const EMPTY_THREAD_IDS: ThreadId[] = [];
3446
3547// ── Pure helpers ──────────────────────────────────────────────────────
3648
......@@ -180,6 +192,127 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec
180192 };
181193}
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
183316function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") {
184317 if (status === "error") {
185318 return "error" as const;
......@@ -398,6 +531,46 @@ function attachmentPreviewRoutePath(attachmentId: string): string {
398531 return `/attachments/${encodeURIComponent(attachmentId)}`;
399532}
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
401574// ── Pure state transition functions ────────────────────────────────────
402575
403576export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState {
......@@ -405,10 +578,14 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
405578 .filter((project) => project.deletedAt === null)
406579 .map(mapProject);
407580 const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread);
581 const sidebarThreadsById = buildSidebarThreadsById(threads);
582 const threadIdsByProjectId = buildThreadIdsByProjectId(threads);
408583 return {
409584 ...state,
410585 projects,
411586 threads,
587 sidebarThreadsById,
588 threadIdsByProjectId,
412589 bootstrapComplete: true,
413590 };
414591}
......@@ -489,34 +666,72 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
489666 const threads = existing
490667 ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread))
491668 : [...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 };
493692 }
494693
495694 case "thread.deleted": {
496695 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 };
498715 }
499716
500717 case "thread.archived": {
501 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
718 return updateThreadState(state, event.payload.threadId, (thread) => ({
502719 ...thread,
503720 archivedAt: event.payload.archivedAt,
504721 updatedAt: event.payload.updatedAt,
505722 }));
506 return threads === state.threads ? state : { ...state, threads };
507723 }
508724
509725 case "thread.unarchived": {
510 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
726 return updateThreadState(state, event.payload.threadId, (thread) => ({
511727 ...thread,
512728 archivedAt: null,
513729 updatedAt: event.payload.updatedAt,
514730 }));
515 return threads === state.threads ? state : { ...state, threads };
516731 }
517732
518733 case "thread.meta-updated": {
519 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
734 return updateThreadState(state, event.payload.threadId, (thread) => ({
520735 ...thread,
521736 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
522737 ...(event.payload.modelSelection !== undefined
......@@ -528,29 +743,26 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
528743 : {}),
529744 updatedAt: event.payload.updatedAt,
530745 }));
531 return threads === state.threads ? state : { ...state, threads };
532746 }
533747
534748 case "thread.runtime-mode-set": {
535 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
749 return updateThreadState(state, event.payload.threadId, (thread) => ({
536750 ...thread,
537751 runtimeMode: event.payload.runtimeMode,
538752 updatedAt: event.payload.updatedAt,
539753 }));
540 return threads === state.threads ? state : { ...state, threads };
541754 }
542755
543756 case "thread.interaction-mode-set": {
544 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
757 return updateThreadState(state, event.payload.threadId, (thread) => ({
545758 ...thread,
546759 interactionMode: event.payload.interactionMode,
547760 updatedAt: event.payload.updatedAt,
548761 }));
549 return threads === state.threads ? state : { ...state, threads };
550762 }
551763
552764 case "thread.turn-start-requested": {
553 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
765 return updateThreadState(state, event.payload.threadId, (thread) => ({
554766 ...thread,
555767 ...(event.payload.modelSelection !== undefined
556768 ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) }
......@@ -560,14 +772,13 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
560772 pendingSourceProposedPlan: event.payload.sourceProposedPlan,
561773 updatedAt: event.occurredAt,
562774 }));
563 return threads === state.threads ? state : { ...state, threads };
564775 }
565776
566777 case "thread.turn-interrupt-requested": {
567778 if (event.payload.turnId === undefined) {
568779 return state;
569780 }
570 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
781 return updateThreadState(state, event.payload.threadId, (thread) => {
571782 const latestTurn = thread.latestTurn;
572783 if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) {
573784 return thread;
......@@ -586,11 +797,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
586797 updatedAt: event.occurredAt,
587798 };
588799 });
589 return threads === state.threads ? state : { ...state, threads };
590800 }
591801
592802 case "thread.message-sent": {
593 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
803 return updateThreadState(state, event.payload.threadId, (thread) => {
594804 const message = mapMessage({
595805 id: event.payload.messageId,
596806 role: event.payload.role,
......@@ -678,11 +888,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
678888 updatedAt: event.occurredAt,
679889 };
680890 });
681 return threads === state.threads ? state : { ...state, threads };
682891 }
683892
684893 case "thread.session-set": {
685 const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({
894 return updateThreadState(state, event.payload.threadId, (thread) => ({
686895 ...thread,
687896 session: mapSession(event.payload.session),
688897 error: event.payload.session.lastError ?? null,
......@@ -710,11 +919,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
710919 : thread.latestTurn,
711920 updatedAt: event.occurredAt,
712921 }));
713 return threads === state.threads ? state : { ...state, threads };
714922 }
715923
716924 case "thread.session-stop-requested": {
717 const threads = updateThread(state.threads, event.payload.threadId, (thread) =>
925 return updateThreadState(state, event.payload.threadId, (thread) =>
718926 thread.session === null
719927 ? thread
720928 : {
......@@ -729,11 +937,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
729937 updatedAt: event.occurredAt,
730938 },
731939 );
732 return threads === state.threads ? state : { ...state, threads };
733940 }
734941
735942 case "thread.proposed-plan-upserted": {
736 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
943 return updateThreadState(state, event.payload.threadId, (thread) => {
737944 const proposedPlan = mapProposedPlan(event.payload.proposedPlan);
738945 const proposedPlans = [
739946 ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id),
......@@ -750,11 +957,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
750957 updatedAt: event.occurredAt,
751958 };
752959 });
753 return threads === state.threads ? state : { ...state, threads };
754960 }
755961
756962 case "thread.turn-diff-completed": {
757 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
963 return updateThreadState(state, event.payload.threadId, (thread) => {
758964 const checkpoint = mapTurnDiffSummary({
759965 turnId: event.payload.turnId,
760966 checkpointTurnCount: event.payload.checkpointTurnCount,
......@@ -800,11 +1006,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
8001006 updatedAt: event.occurredAt,
8011007 };
8021008 });
803 return threads === state.threads ? state : { ...state, threads };
8041009 }
8051010
8061011 case "thread.reverted": {
807 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
1012 return updateThreadState(state, event.payload.threadId, (thread) => {
8081013 const turnDiffSummaries = thread.turnDiffSummaries
8091014 .filter(
8101015 (entry) =>
......@@ -853,11 +1058,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
8531058 updatedAt: event.occurredAt,
8541059 };
8551060 });
856 return threads === state.threads ? state : { ...state, threads };
8571061 }
8581062
8591063 case "thread.activity-appended": {
860 const threads = updateThread(state.threads, event.payload.threadId, (thread) => {
1064 return updateThreadState(state, event.payload.threadId, (thread) => {
8611065 const activities = [
8621066 ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id),
8631067 { ...event.payload.activity },
......@@ -870,7 +1074,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve
8701074 updatedAt: event.occurredAt,
8711075 };
8721076 });
873 return threads === state.threads ? state : { ...state, threads };
8741077 }
8751078
8761079 case "thread.approval-response-requested":
......@@ -901,12 +1104,21 @@ export const selectThreadById =
9011104 (state: AppState): Thread | undefined =>
9021105 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
9041117export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
905 const threads = updateThread(state.threads, threadId, (t) => {
1118 return updateThreadState(state, threadId, (t) => {
9061119 if (t.error === error) return t;
9071120 return { ...t, error };
9081121 });
909 return threads === state.threads ? state : { ...state, threads };
9101122}
9111123
9121124export function setThreadBranch(
......@@ -915,7 +1127,7 @@ export function setThreadBranch(
9151127 branch: string | null,
9161128 worktreePath: string | null,
9171129): AppState {
918 const threads = updateThread(state.threads, threadId, (t) => {
1130 return updateThreadState(state, threadId, (t) => {
9191131 if (t.branch === branch && t.worktreePath === worktreePath) return t;
9201132 const cwdChanged = t.worktreePath !== worktreePath;
9211133 return {
......@@ -925,7 +1137,6 @@ export function setThreadBranch(
9251137 ...(cwdChanged ? { session: null } : {}),
9261138 };
9271139 });
928 return threads === state.threads ? state : { ...state, threads };
9291140}
9301141
9311142// ── Zustand store ────────────────────────────────────────────────────
apps/web/src/storeSelectors.ts+14-2
......@@ -1,7 +1,12 @@
11import { type ThreadId } from "@t3tools/contracts";
22import { useMemo } from "react";
3import { selectProjectById, selectThreadById, useStore } from "./store";
4import { type Project, type Thread } from "./types";
3import {
4 selectProjectById,
5 selectSidebarThreadSummaryById,
6 selectThreadById,
7 useStore,
8} from "./store";
9import { type Project, type SidebarThreadSummary, type Thread } from "./types";
510
611export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined {
712 const selector = useMemo(() => selectProjectById(projectId), [projectId]);
......@@ -12,3 +17,10 @@ export function useThreadById(threadId: ThreadId | null | undefined): Thread | u
1217 const selector = useMemo(() => selectThreadById(threadId), [threadId]);
1318 return useStore(selector);
1419}
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 {
111111 activities: OrchestrationThreadActivity[];
112112}
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
114132export interface ThreadSession {
115133 provider: ProviderKind;
116134 status: SessionPhase | "error" | "closed";