diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 7c83d36536..a4f4468279 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -46,10 +46,12 @@ describe("hasUnseenCompletion", () => { it("returns true when a thread completed after its last visit", () => { expect( hasUnseenCompletion({ + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, interactionMode: "default", latestTurn: makeLatestTurn(), lastVisitedAt: "2026-03-09T10:04:00.000Z", - proposedPlans: [], session: null, }), ).toBe(true); @@ -322,17 +324,14 @@ describe("getVisibleSidebarThreadIds", () => { expect( getVisibleSidebarThreadIds([ { - renderedThreads: [ - { id: ThreadId.makeUnsafe("thread-12") }, - { id: ThreadId.makeUnsafe("thread-11") }, - { id: ThreadId.makeUnsafe("thread-10") }, + renderedThreadIds: [ + ThreadId.makeUnsafe("thread-12"), + ThreadId.makeUnsafe("thread-11"), + ThreadId.makeUnsafe("thread-10"), ], }, { - renderedThreads: [ - { id: ThreadId.makeUnsafe("thread-8") }, - { id: ThreadId.makeUnsafe("thread-6") }, - ], + renderedThreadIds: [ThreadId.makeUnsafe("thread-8"), ThreadId.makeUnsafe("thread-6")], }, ]), ).toEqual([ @@ -349,17 +348,14 @@ describe("getVisibleSidebarThreadIds", () => { getVisibleSidebarThreadIds([ { shouldShowThreadPanel: false, - renderedThreads: [ - { id: ThreadId.makeUnsafe("thread-hidden-2") }, - { id: ThreadId.makeUnsafe("thread-hidden-1") }, + renderedThreadIds: [ + ThreadId.makeUnsafe("thread-hidden-2"), + ThreadId.makeUnsafe("thread-hidden-1"), ], }, { shouldShowThreadPanel: true, - renderedThreads: [ - { id: ThreadId.makeUnsafe("thread-12") }, - { id: ThreadId.makeUnsafe("thread-11") }, - ], + renderedThreadIds: [ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")], }, ]), ).toEqual([ThreadId.makeUnsafe("thread-12"), ThreadId.makeUnsafe("thread-11")]); @@ -400,10 +396,12 @@ describe("isContextMenuPointerDown", () => { describe("resolveThreadStatusPill", () => { const baseThread = { + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, interactionMode: "plan" as const, latestTurn: null, lastVisitedAt: undefined, - proposedPlans: [], session: { provider: "codex" as const, status: "running" as const, @@ -416,9 +414,11 @@ describe("resolveThreadStatusPill", () => { it("shows pending approval before all other statuses", () => { expect( resolveThreadStatusPill({ - thread: baseThread, - hasPendingApprovals: true, - hasPendingUserInput: true, + thread: { + ...baseThread, + hasPendingApprovals: true, + hasPendingUserInput: true, + }, }), ).toMatchObject({ label: "Pending Approval", pulse: false }); }); @@ -426,9 +426,10 @@ describe("resolveThreadStatusPill", () => { it("shows awaiting input when plan mode is blocked on user answers", () => { expect( resolveThreadStatusPill({ - thread: baseThread, - hasPendingApprovals: false, - hasPendingUserInput: true, + thread: { + ...baseThread, + hasPendingUserInput: true, + }, }), ).toMatchObject({ label: "Awaiting Input", pulse: false }); }); @@ -437,8 +438,6 @@ describe("resolveThreadStatusPill", () => { expect( resolveThreadStatusPill({ thread: baseThread, - hasPendingApprovals: false, - hasPendingUserInput: false, }), ).toMatchObject({ label: "Working", pulse: true }); }); @@ -448,26 +447,14 @@ describe("resolveThreadStatusPill", () => { resolveThreadStatusPill({ thread: { ...baseThread, + hasActionableProposedPlan: true, latestTurn: makeLatestTurn(), - proposedPlans: [ - { - id: "plan-1" as never, - turnId: "turn-1" as never, - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - planMarkdown: "# Plan", - implementedAt: null, - implementationThreadId: null, - }, - ], session: { ...baseThread.session, status: "ready", orchestrationStatus: "ready", }, }, - hasPendingApprovals: false, - hasPendingUserInput: false, }), ).toMatchObject({ label: "Plan Ready", pulse: false }); }); @@ -478,25 +465,12 @@ describe("resolveThreadStatusPill", () => { thread: { ...baseThread, latestTurn: makeLatestTurn(), - proposedPlans: [ - { - id: "plan-1" as never, - turnId: "turn-1" as never, - createdAt: "2026-03-09T10:00:00.000Z", - updatedAt: "2026-03-09T10:05:00.000Z", - planMarkdown: "# Plan", - implementedAt: "2026-03-09T10:06:00.000Z", - implementationThreadId: "thread-implement" as never, - }, - ], session: { ...baseThread.session, status: "ready", orchestrationStatus: "ready", }, }, - hasPendingApprovals: false, - hasPendingUserInput: false, }), ).toMatchObject({ label: "Completed", pulse: false }); }); @@ -515,8 +489,6 @@ describe("resolveThreadStatusPill", () => { orchestrationStatus: "ready", }, }, - hasPendingApprovals: false, - hasPendingUserInput: false, }), ).toMatchObject({ label: "Completed", pulse: false }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index c606853392..ed151c6da8 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,12 +1,8 @@ import * as React from "react"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import type { Thread } from "../types"; +import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; -import { - findLatestProposedPlan, - hasActionableProposedPlan, - isLatestTurnSettled, -} from "../session-logic"; +import { isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -47,8 +43,13 @@ const THREAD_STATUS_PRIORITY: Record = { }; type ThreadStatusInput = Pick< - Thread, - "interactionMode" | "latestTurn" | "proposedPlans" | "session" + SidebarThreadSummary, + | "hasActionableProposedPlan" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "interactionMode" + | "latestTurn" + | "session" > & { lastVisitedAt?: string | undefined; }; @@ -232,15 +233,11 @@ export function orderItemsByPreferredIds(input: { export function getVisibleSidebarThreadIds( renderedProjects: readonly { shouldShowThreadPanel?: boolean; - renderedThreads: readonly { - id: TThreadId; - }[]; + renderedThreadIds: readonly TThreadId[]; }[], ): TThreadId[] { return renderedProjects.flatMap((renderedProject) => - renderedProject.shouldShowThreadPanel === false - ? [] - : renderedProject.renderedThreads.map((thread) => thread.id), + renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, ); } @@ -313,12 +310,10 @@ export function resolveThreadRowClassName(input: { export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; - hasPendingApprovals: boolean; - hasPendingUserInput: boolean; }): ThreadStatusPill | null { - const { hasPendingApprovals, hasPendingUserInput, thread } = input; + const { thread } = input; - if (hasPendingApprovals) { + if (thread.hasPendingApprovals) { return { label: "Pending Approval", colorClass: "text-amber-600 dark:text-amber-300/90", @@ -327,7 +322,7 @@ export function resolveThreadStatusPill(input: { }; } - if (hasPendingUserInput) { + if (thread.hasPendingUserInput) { return { label: "Awaiting Input", colorClass: "text-indigo-600 dark:text-indigo-300/90", @@ -355,12 +350,10 @@ export function resolveThreadStatusPill(input: { } const hasPlanReadyPrompt = - !hasPendingUserInput && + !thread.hasPendingUserInput && thread.interactionMode === "plan" && isLatestTurnSettled(thread.latestTurn, thread.session) && - hasActionableProposedPlan( - findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null), - ); + thread.hasActionableProposedPlan; if (hasPlanReadyPrompt) { return { label: "Plan Ready", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1e3340cb24..fbe4e0528a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -18,8 +18,13 @@ import { useMemo, useRef, useState, + type Dispatch, + type KeyboardEvent, type MouseEvent, + type MutableRefObject, type PointerEvent, + type ReactNode, + type SetStateAction, } from "react"; import { useShallow } from "zustand/react/shallow"; import { @@ -55,6 +60,7 @@ import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, @@ -64,14 +70,12 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitStatusQueryOptions } from "../lib/gitReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -125,7 +129,8 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import type { Project, Thread } from "../types"; +import { useSidebarThreadSummaryById } from "../storeSelectors"; +import type { Project } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -141,79 +146,9 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; -type SidebarThreadSnapshot = Pick< - Thread, - | "activities" - | "archivedAt" - | "branch" - | "createdAt" - | "id" - | "interactionMode" - | "latestTurn" - | "projectId" - | "proposedPlans" - | "session" - | "title" - | "updatedAt" - | "worktreePath" -> & { - lastVisitedAt?: string | undefined; - latestUserMessageAt: string | null; -}; - type SidebarProjectSnapshot = Project & { expanded: boolean; }; - -const sidebarThreadSnapshotCache = new WeakMap< - Thread, - { lastVisitedAt?: string | undefined; snapshot: SidebarThreadSnapshot } ->(); - -function getLatestUserMessageAt(thread: Thread): string | null { - let latestUserMessageAt: string | null = null; - - for (const message of thread.messages) { - if (message.role !== "user") { - continue; - } - if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) { - latestUserMessageAt = message.createdAt; - } - } - - return latestUserMessageAt; -} - -function toSidebarThreadSnapshot( - thread: Thread, - lastVisitedAt: string | undefined, -): SidebarThreadSnapshot { - const cached = sidebarThreadSnapshotCache.get(thread); - if (cached && cached.lastVisitedAt === lastVisitedAt) { - return cached.snapshot; - } - - const snapshot: SidebarThreadSnapshot = { - id: thread.id, - projectId: thread.projectId, - title: thread.title, - interactionMode: thread.interactionMode, - session: thread.session, - createdAt: thread.createdAt, - updatedAt: thread.updatedAt, - archivedAt: thread.archivedAt, - latestTurn: thread.latestTurn, - lastVisitedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - activities: thread.activities, - proposedPlans: thread.proposedPlans, - latestUserMessageAt: getLatestUserMessageAt(thread), - }; - sidebarThreadSnapshotCache.set(thread, { lastVisitedAt, snapshot }); - return snapshot; -} interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -310,6 +245,301 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { return null; } +interface SidebarThreadRowProps { + threadId: ThreadId; + orderedProjectThreadIds: readonly ThreadId[]; + routeThreadId: ThreadId | null; + selectedThreadIds: ReadonlySet; + showThreadJumpHints: boolean; + jumpLabel: string | null; + appSettingsConfirmThreadArchive: boolean; + renamingThreadId: ThreadId | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + renamingInputRef: MutableRefObject; + renamingCommittedRef: MutableRefObject; + confirmingArchiveThreadId: ThreadId | null; + setConfirmingArchiveThreadId: Dispatch>; + confirmArchiveButtonRefs: MutableRefObject>; + handleThreadClick: ( + event: MouseEvent, + threadId: ThreadId, + orderedProjectThreadIds: readonly ThreadId[], + ) => void; + navigateToThread: (threadId: ThreadId) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadId: ThreadId, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadId: ThreadId) => Promise; + openPrLink: (event: MouseEvent, prUrl: string) => void; + pr: ThreadPr | null; +} + +function SidebarThreadRow(props: SidebarThreadRowProps) { + const thread = useSidebarThreadSummaryById(props.threadId); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, + ); + + if (!thread) { + return null; + } + + const isActive = props.routeThreadId === thread.id; + const isSelected = props.selectedThreadIds.has(thread.id); + const isHighlighted = isActive || isSelected; + const isThreadRunning = + thread.session?.status === "running" && thread.session.activeTurnId != null; + const threadStatus = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt, + }, + }); + const prStatus = prStatusIndicator(props.pr); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; + const threadMetaClassName = isConfirmingArchive + ? "pointer-events-none opacity-0" + : !isThreadRunning + ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" + : "pointer-events-none"; + + return ( + { + props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + }} + onBlurCapture={(event) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); + }); + }} + > + } + size="sm" + isActive={isActive} + data-testid={`thread-row-${thread.id}`} + className={`${resolveThreadRowClassName({ + isActive, + isSelected, + })} relative isolate`} + onClick={(event) => { + props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + props.navigateToThread(thread.id); + }} + onContextMenu={(event) => { + event.preventDefault(); + if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) { + void props.handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + } else { + if (props.selectedThreadIds.size > 0) { + props.clearSelection(); + } + void props.handleThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + } + }} + > +
+ {prStatus && ( + + { + props.openPrLink(event, prStatus.url); + }} + > + + + } + /> + {prStatus.tooltip} + + )} + {threadStatus && } + {props.renamingThreadId === thread.id ? ( + { + if (element && props.renamingInputRef.current !== element) { + props.renamingInputRef.current = element; + element.focus(); + element.select(); + } + }} + className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" + value={props.renamingTitle} + onChange={(event) => props.setRenamingTitle(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + props.renamingCommittedRef.current = true; + void props.commitRename(thread.id, props.renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + props.renamingCommittedRef.current = true; + props.cancelRename(); + } + }} + onBlur={() => { + if (!props.renamingCommittedRef.current) { + void props.commitRename(thread.id, props.renamingTitle, thread.title); + } + }} + onClick={(event) => event.stopPropagation()} + /> + ) : ( + {thread.title} + )} +
+
+ {terminalStatus && ( + + + + )} +
+ {isConfirmingArchive ? ( + + ) : !isThreadRunning ? ( + props.appSettingsConfirmThreadArchive ? ( +
+ +
+ ) : ( + + + +
+ } + /> + Archive + + ) + ) : null} + + {props.showThreadJumpHints && props.jumpLabel ? ( + + {props.jumpLabel} + + ) : ( + + {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} + + )} + +
+ +
+
+ ); +} + function T3Wordmark() { return ( React.ReactNode; + children: (handleProps: SortableProjectHandleProps) => ReactNode; }) { const { attributes, @@ -437,7 +667,8 @@ function SortableProjectItem({ export default function Sidebar() { const projects = useStore((store) => store.projects); - const serverThreads = useStore((store) => store.threads); + const sidebarThreadsById = useStore((store) => store.sidebarThreadsById); + const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ projectExpandedById: store.projectExpandedById, @@ -513,13 +744,7 @@ export default function Sidebar() { })), [orderedProjects, projectExpandedById], ); - const threads = useMemo( - () => - serverThreads.map((thread) => - toSidebarThreadSnapshot(thread, threadLastVisitedAtById[thread.id]), - ), - [serverThreads, threadLastVisitedAtById], - ); + const sidebarThreads = useMemo(() => Object.values(sidebarThreadsById), [sidebarThreadsById]); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -539,12 +764,12 @@ export default function Sidebar() { ); const threadGitTargets = useMemo( () => - threads.map((thread) => ({ + sidebarThreads.map((thread) => ({ threadId: thread.id, branch: thread.branch, cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, })), - [projectCwdById, threads], + [projectCwdById, sidebarThreads], ); const threadGitStatusCwds = useMemo( () => [ @@ -585,7 +810,7 @@ export default function Sidebar() { return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); - const openPrLink = useCallback((event: React.MouseEvent, prUrl: string) => { + const openPrLink = useCallback((event: MouseEvent, prUrl: string) => { event.preventDefault(); event.stopPropagation(); @@ -625,7 +850,10 @@ export default function Sidebar() { const focusMostRecentThreadForProject = useCallback( (projectId: ProjectId) => { const latestThread = sortThreadsForSidebar( - threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), + (threadIdsByProjectId[projectId] ?? []) + .map((threadId) => sidebarThreadsById[threadId]) + .filter((thread): thread is NonNullable => thread !== undefined) + .filter((thread) => thread.archivedAt === null), appSettings.sidebarThreadSortOrder, )[0]; if (!latestThread) return; @@ -635,7 +863,7 @@ export default function Sidebar() { params: { threadId: latestThread.id }, }); }, - [appSettings.sidebarThreadSortOrder, navigate, threads], + [appSettings.sidebarThreadSortOrder, navigate, sidebarThreadsById, threadIdsByProjectId], ); const addProjectFromPath = useCallback( @@ -831,7 +1059,7 @@ export default function Sidebar() { async (threadId: ThreadId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; - const thread = threads.find((t) => t.id === threadId); + const thread = sidebarThreadsById[threadId]; if (!thread) return; const threadWorkspacePath = thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null; @@ -894,7 +1122,7 @@ export default function Sidebar() { deleteThread, markThreadUnread, projectCwdById, - threads, + sidebarThreadsById, ], ); @@ -916,7 +1144,7 @@ export default function Sidebar() { if (clicked === "mark-unread") { for (const id of ids) { - const thread = threads.find((candidate) => candidate.id === id); + const thread = sidebarThreadsById[id]; markThreadUnread(id, thread?.latestTurn?.completedAt); } clearSelection(); @@ -948,7 +1176,7 @@ export default function Sidebar() { markThreadUnread, removeFromSelection, selectedThreadIds, - threads, + sidebarThreadsById, ], ); @@ -1024,8 +1252,8 @@ export default function Sidebar() { } if (clicked !== "delete") return; - const projectThreads = threads.filter((thread) => thread.projectId === projectId); - if (projectThreads.length > 0) { + const projectThreadIds = threadIdsByProjectId[projectId] ?? []; + if (projectThreadIds.length > 0) { toastManager.add({ type: "warning", title: "Project is not empty", @@ -1064,7 +1292,7 @@ export default function Sidebar() { copyPathToClipboard, getDraftThreadByProjectId, projects, - threads, + threadIdsByProjectId, ], ); @@ -1152,8 +1380,8 @@ export default function Sidebar() { ); const visibleThreads = useMemo( - () => threads.filter((thread) => thread.archivedAt === null), - [threads], + () => sidebarThreads.filter((thread) => thread.archivedAt === null), + [sidebarThreads], ); const sortedProjects = useMemo( () => @@ -1164,22 +1392,22 @@ export default function Sidebar() { const renderedProjects = useMemo( () => sortedProjects.map((project) => { + const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) => + resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt: threadLastVisitedAtById[thread.id], + }, + }); const projectThreads = sortThreadsForSidebar( - visibleThreads.filter((thread) => thread.projectId === project.id), + (threadIdsByProjectId[project.id] ?? []) + .map((threadId) => sidebarThreadsById[threadId]) + .filter((thread): thread is NonNullable => thread !== undefined) + .filter((thread) => thread.archivedAt === null), appSettings.sidebarThreadSortOrder, ); - const threadStatuses = new Map( - projectThreads.map((thread) => [ - thread.id, - resolveThreadStatusPill({ - thread, - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - }), - ]), - ); const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => threadStatuses.get(thread.id) ?? null), + projectThreads.map((thread) => resolveProjectThreadStatus(thread)), ); const activeThreadId = routeThreadId ?? undefined; const isThreadListExpanded = expandedThreadListsByProject.has(project.id); @@ -1199,12 +1427,12 @@ export default function Sidebar() { previewLimit: THREAD_PREVIEW_LIMIT, }); const hiddenThreadStatus = resolveProjectStatusIndicator( - hiddenThreads.map((thread) => threadStatuses.get(thread.id) ?? null), + hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ); const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreads = pinnedCollapsedThread - ? [pinnedCollapsedThread] - : visibleProjectThreads; + const renderedThreadIds = pinnedCollapsedThread + ? [pinnedCollapsedThread.id] + : visibleProjectThreads.map((thread) => thread.id); const showEmptyThreadState = project.expanded && projectThreads.length === 0; return { @@ -1213,9 +1441,7 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - projectThreads, - threadStatuses, - renderedThreads, + renderedThreadIds, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -1226,7 +1452,9 @@ export default function Sidebar() { expandedThreadListsByProject, routeThreadId, sortedProjects, - visibleThreads, + sidebarThreadsById, + threadIdsByProjectId, + threadLastVisitedAtById, ], ); const visibleSidebarThreadIds = useMemo( @@ -1267,7 +1495,7 @@ export default function Sidebar() { terminalOpen: routeTerminalOpen, }); - const onWindowKeyDown = (event: KeyboardEvent) => { + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { updateThreadJumpHintsVisibility( shouldShowThreadJumpHints(event, keybindings, { platform, @@ -1315,7 +1543,7 @@ export default function Sidebar() { navigateToThread(targetThreadId); }; - const onWindowKeyUp = (event: KeyboardEvent) => { + const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { updateThreadJumpHintsVisibility( shouldShowThreadJumpHints(event, keybindings, { platform, @@ -1358,263 +1586,11 @@ export default function Sidebar() { orderedProjectThreadIds, project, projectStatus, - projectThreads, - threadStatuses, - renderedThreads, + renderedThreadIds, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, } = renderedProject; - const renderThreadRow = (thread: (typeof projectThreads)[number]) => { - const isActive = routeThreadId === thread.id; - const isSelected = selectedThreadIds.has(thread.id); - const isHighlighted = isActive || isSelected; - const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; - const isThreadRunning = - thread.session?.status === "running" && thread.session.activeTurnId != null; - const threadStatus = threadStatuses.get(thread.id) ?? null; - const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); - const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id).runningTerminalIds, - ); - const isConfirmingArchive = confirmingArchiveThreadId === thread.id && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; - - return ( - { - setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }} - onBlurCapture={(event) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }); - }} - > - } - size="sm" - isActive={isActive} - data-testid={`thread-row-${thread.id}`} - className={`${resolveThreadRowClassName({ - isActive, - isSelected, - })} relative isolate`} - onClick={(event) => { - handleThreadClick(event, thread.id, orderedProjectThreadIds); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(thread.id); - }} - onContextMenu={(event) => { - event.preventDefault(); - if (selectedThreadIds.size > 0 && selectedThreadIds.has(thread.id)) { - void handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (selectedThreadIds.size > 0) { - clearSelection(); - } - void handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - } - }} - > -
- {prStatus && ( - - { - openPrLink(event, prStatus.url); - }} - > - - - } - /> - {prStatus.tooltip} - - )} - {threadStatus && } - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(thread.id, renamingTitle, thread.title); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename(thread.id, renamingTitle, thread.title); - } - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {thread.title} - )} -
-
- {terminalStatus && ( - - - - )} -
- {isConfirmingArchive ? ( - - ) : !isThreadRunning ? ( - appSettings.confirmThreadArchive ? ( -
- -
- ) : ( - - - -
- } - /> - Archive - - ) - ) : null} - - {showThreadJumpHints && jumpLabel ? ( - - {jumpLabel} - - ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - - )} - -
- - - - ); - }; - return ( <>
@@ -1737,7 +1713,37 @@ export default function Sidebar() {
) : null} - {shouldShowThreadPanel && renderedThreads.map((thread) => renderThreadRow(thread))} + {shouldShowThreadPanel && + renderedThreadIds.map((threadId) => ( + + ))} {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( @@ -1778,7 +1784,7 @@ export default function Sidebar() { } const handleProjectTitleClick = useCallback( - (event: React.MouseEvent, projectId: ProjectId) => { + (event: MouseEvent, projectId: ProjectId) => { if (suppressProjectClickForContextMenuRef.current) { suppressProjectClickForContextMenuRef.current = false; event.preventDefault(); @@ -1806,7 +1812,7 @@ export default function Sidebar() { ); const handleProjectTitleKeyDown = useCallback( - (event: React.KeyboardEvent, projectId: ProjectId) => { + (event: KeyboardEvent, projectId: ProjectId) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); if (dragInProgressRef.current) { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 368c595a4b..d186a7c9c1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -153,6 +153,43 @@ function errorDetails(error: unknown): string { } } +function coalesceOrchestrationUiEvents( + events: ReadonlyArray, +): OrchestrationEvent[] { + if (events.length < 2) { + return [...events]; + } + + const coalesced: OrchestrationEvent[] = []; + for (const event of events) { + const previous = coalesced.at(-1); + if ( + previous?.type === "thread.message-sent" && + event.type === "thread.message-sent" && + previous.payload.threadId === event.payload.threadId && + previous.payload.messageId === event.payload.messageId + ) { + coalesced[coalesced.length - 1] = { + ...event, + payload: { + ...event.payload, + attachments: event.payload.attachments ?? previous.payload.attachments, + createdAt: previous.payload.createdAt, + text: + !event.payload.streaming && event.payload.text.length > 0 + ? event.payload.text + : previous.payload.text + event.payload.text, + }, + }; + continue; + } + + coalesced.push(event); + } + + return coalesced; +} + function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); const syncServerReadModel = useStore((store) => store.syncServerReadModel); @@ -269,6 +306,8 @@ function EventRouter() { disposedRef.current = false; const recovery = createOrchestrationRecoveryCoordinator(); let needsProviderInvalidation = false; + const pendingDomainEvents: OrchestrationEvent[] = []; + let flushPendingDomainEventsScheduled = false; const reconcileSnapshotDerivedState = () => { const threads = useStore.getState().threads; @@ -316,6 +355,7 @@ function EventRouter() { } const batchEffects = deriveOrchestrationBatchEffects(nextEvents); + const uiEvents = coalesceOrchestrationUiEvents(nextEvents); const needsProjectUiSync = nextEvents.some( (event) => event.type === "project.created" || @@ -328,7 +368,7 @@ function EventRouter() { void queryInvalidationThrottler.maybeExecute(); } - applyOrchestrationEvents(nextEvents); + applyOrchestrationEvents(uiEvents); if (needsProjectUiSync) { const projects = useStore.getState().projects; syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); @@ -357,6 +397,23 @@ function EventRouter() { removeTerminalState(threadId); } }; + const flushPendingDomainEvents = () => { + flushPendingDomainEventsScheduled = false; + if (disposed || pendingDomainEvents.length === 0) { + return; + } + + const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); + applyEventBatch(events); + }; + const schedulePendingDomainEventFlush = () => { + if (flushPendingDomainEventsScheduled) { + return; + } + + flushPendingDomainEventsScheduled = true; + queueMicrotask(flushPendingDomainEvents); + }; const recoverFromSequenceGap = async (): Promise => { if (!recovery.beginReplayRecovery("sequence-gap")) { @@ -410,10 +467,12 @@ function EventRouter() { const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { const action = recovery.classifyDomainEvent(event.sequence); if (action === "apply") { - applyEventBatch([event]); + pendingDomainEvents.push(event); + schedulePendingDomainEventFlush(); return; } if (action === "recover") { + flushPendingDomainEvents(); void recoverFromSequenceGap(); } }); @@ -434,6 +493,8 @@ function EventRouter() { disposed = true; disposedRef.current = true; needsProviderInvalidation = false; + flushPendingDomainEventsScheduled = false; + pendingDomainEvents.length = 0; queryInvalidationThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index b95d1ef7b0..31920cf40f 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -222,7 +222,7 @@ function ChatThreadRouteView() { return ( <> - + - + {shouldRenderDiffContent ? : null} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 6e909b38f0..da1498d494 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -47,6 +47,9 @@ function makeThread(overrides: Partial = {}): Thread { } function makeState(thread: Thread): AppState { + const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { + [thread.projectId]: [thread.id], + }; return { projects: [ { @@ -61,6 +64,8 @@ function makeState(thread: Thread): AppState { }, ], threads: [thread], + sidebarThreadsById: {}, + threadIdsByProjectId, bootstrapComplete: true, }; } @@ -271,6 +276,8 @@ describe("store read model sync", () => { }, ], threads: [], + sidebarThreadsById: {}, + threadIdsByProjectId: {}, bootstrapComplete: true, }; const readModel: OrchestrationReadModel = { @@ -361,6 +368,8 @@ describe("incremental orchestration updates", () => { }, ], threads: [], + sidebarThreadsById: {}, + threadIdsByProjectId: {}, bootstrapComplete: true, }; @@ -386,6 +395,70 @@ describe("incremental orchestration updates", () => { expect(next.projects[0]?.name).toBe("Project Recreated"); }); + it("removes stale project index entries when thread.created recreates a thread under a new project", () => { + const originalProjectId = ProjectId.makeUnsafe("project-1"); + const recreatedProjectId = ProjectId.makeUnsafe("project-2"); + const threadId = ThreadId.makeUnsafe("thread-1"); + const thread = makeThread({ + id: threadId, + projectId: originalProjectId, + }); + const state: AppState = { + projects: [ + { + id: originalProjectId, + name: "Project 1", + cwd: "/tmp/project-1", + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + scripts: [], + }, + { + id: recreatedProjectId, + name: "Project 2", + cwd: "/tmp/project-2", + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + scripts: [], + }, + ], + threads: [thread], + sidebarThreadsById: {}, + threadIdsByProjectId: { + [originalProjectId]: [threadId], + }, + bootstrapComplete: true, + }; + + const next = applyOrchestrationEvent( + state, + makeEvent("thread.created", { + threadId, + projectId: recreatedProjectId, + title: "Recovered thread", + modelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: "2026-02-27T00:00:01.000Z", + updatedAt: "2026-02-27T00:00:01.000Z", + }), + ); + + expect(next.threads).toHaveLength(1); + expect(next.threads[0]?.projectId).toBe(recreatedProjectId); + expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); + expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); + }); + it("updates only the affected thread for message events", () => { const thread1 = makeThread({ id: ThreadId.makeUnsafe("thread-1"), diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index eff6a6fd07..12c709b796 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -2,6 +2,7 @@ import { type OrchestrationEvent, type OrchestrationMessage, type OrchestrationProposedPlan, + type ProjectId, type ProviderKind, ThreadId, type OrchestrationReadModel, @@ -12,25 +13,36 @@ import { } from "@t3tools/contracts"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; -import { type ChatMessage, type Project, type Thread } from "./types"; +import { + findLatestProposedPlan, + hasActionableProposedPlan, + derivePendingApprovals, + derivePendingUserInputs, +} from "./session-logic"; +import { type ChatMessage, type Project, type SidebarThreadSummary, type Thread } from "./types"; // ── State ──────────────────────────────────────────────────────────── export interface AppState { projects: Project[]; threads: Thread[]; + sidebarThreadsById: Record; + threadIdsByProjectId: Record; bootstrapComplete: boolean; } const initialState: AppState = { projects: [], threads: [], + sidebarThreadsById: {}, + threadIdsByProjectId: {}, bootstrapComplete: false, }; const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; const MAX_THREAD_ACTIVITIES = 500; +const EMPTY_THREAD_IDS: ThreadId[] = []; // ── Pure helpers ────────────────────────────────────────────────────── @@ -180,6 +192,127 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec }; } +function getLatestUserMessageAt( + messages: ReadonlyArray, +): string | null { + let latestUserMessageAt: string | null = null; + + for (const message of messages) { + if (message.role !== "user") { + continue; + } + if (latestUserMessageAt === null || message.createdAt > latestUserMessageAt) { + latestUserMessageAt = message.createdAt; + } + } + + return latestUserMessageAt; +} + +function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { + return { + id: thread.id, + projectId: thread.projectId, + title: thread.title, + interactionMode: thread.interactionMode, + session: thread.session, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + latestTurn: thread.latestTurn, + branch: thread.branch, + worktreePath: thread.worktreePath, + latestUserMessageAt: getLatestUserMessageAt(thread.messages), + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + hasActionableProposedPlan: hasActionableProposedPlan( + findLatestProposedPlan(thread.proposedPlans, thread.latestTurn?.turnId ?? null), + ), + }; +} + +function sidebarThreadSummariesEqual( + left: SidebarThreadSummary | undefined, + right: SidebarThreadSummary, +): boolean { + return ( + left !== undefined && + left.id === right.id && + left.projectId === right.projectId && + left.title === right.title && + left.interactionMode === right.interactionMode && + left.session === right.session && + left.createdAt === right.createdAt && + left.archivedAt === right.archivedAt && + left.updatedAt === right.updatedAt && + left.latestTurn === right.latestTurn && + left.branch === right.branch && + left.worktreePath === right.worktreePath && + left.latestUserMessageAt === right.latestUserMessageAt && + left.hasPendingApprovals === right.hasPendingApprovals && + left.hasPendingUserInput === right.hasPendingUserInput && + left.hasActionableProposedPlan === right.hasActionableProposedPlan + ); +} + +function appendThreadIdByProjectId( + threadIdsByProjectId: Record, + projectId: ProjectId, + threadId: ThreadId, +): Record { + const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; + if (existingThreadIds.includes(threadId)) { + return threadIdsByProjectId; + } + return { + ...threadIdsByProjectId, + [projectId]: [...existingThreadIds, threadId], + }; +} + +function removeThreadIdByProjectId( + threadIdsByProjectId: Record, + projectId: ProjectId, + threadId: ThreadId, +): Record { + const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; + if (!existingThreadIds.includes(threadId)) { + return threadIdsByProjectId; + } + const nextThreadIds = existingThreadIds.filter( + (existingThreadId) => existingThreadId !== threadId, + ); + if (nextThreadIds.length === existingThreadIds.length) { + return threadIdsByProjectId; + } + if (nextThreadIds.length === 0) { + const nextThreadIdsByProjectId = { ...threadIdsByProjectId }; + delete nextThreadIdsByProjectId[projectId]; + return nextThreadIdsByProjectId; + } + return { + ...threadIdsByProjectId, + [projectId]: nextThreadIds, + }; +} + +function buildThreadIdsByProjectId(threads: ReadonlyArray): Record { + const threadIdsByProjectId: Record = {}; + for (const thread of threads) { + const existingThreadIds = threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS; + threadIdsByProjectId[thread.projectId] = [...existingThreadIds, thread.id]; + } + return threadIdsByProjectId; +} + +function buildSidebarThreadsById( + threads: ReadonlyArray, +): Record { + return Object.fromEntries( + threads.map((thread) => [thread.id, buildSidebarThreadSummary(thread)]), + ); +} + function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { if (status === "error") { return "error" as const; @@ -398,6 +531,46 @@ function attachmentPreviewRoutePath(attachmentId: string): string { return `/attachments/${encodeURIComponent(attachmentId)}`; } +function updateThreadState( + state: AppState, + threadId: ThreadId, + updater: (thread: Thread) => Thread, +): AppState { + let updatedThread: Thread | null = null; + const threads = updateThread(state.threads, threadId, (thread) => { + const nextThread = updater(thread); + if (nextThread !== thread) { + updatedThread = nextThread; + } + return nextThread; + }); + if (threads === state.threads || updatedThread === null) { + return state; + } + + const nextSummary = buildSidebarThreadSummary(updatedThread); + const previousSummary = state.sidebarThreadsById[threadId]; + const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) + ? state.sidebarThreadsById + : { + ...state.sidebarThreadsById, + [threadId]: nextSummary, + }; + + if (sidebarThreadsById === state.sidebarThreadsById) { + return { + ...state, + threads, + }; + } + + return { + ...state, + threads, + sidebarThreadsById, + }; +} + // ── Pure state transition functions ──────────────────────────────────── export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { @@ -405,10 +578,14 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea .filter((project) => project.deletedAt === null) .map(mapProject); const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); + const sidebarThreadsById = buildSidebarThreadsById(threads); + const threadIdsByProjectId = buildThreadIdsByProjectId(threads); return { ...state, projects, threads, + sidebarThreadsById, + threadIdsByProjectId, bootstrapComplete: true, }; } @@ -489,34 +666,72 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve const threads = existing ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) : [...state.threads, nextThread]; - return { ...state, threads }; + const nextSummary = buildSidebarThreadSummary(nextThread); + const previousSummary = state.sidebarThreadsById[nextThread.id]; + const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) + ? state.sidebarThreadsById + : { + ...state.sidebarThreadsById, + [nextThread.id]: nextSummary, + }; + const nextThreadIdsByProjectId = + existing !== undefined && existing.projectId !== nextThread.projectId + ? removeThreadIdByProjectId(state.threadIdsByProjectId, existing.projectId, existing.id) + : state.threadIdsByProjectId; + const threadIdsByProjectId = appendThreadIdByProjectId( + nextThreadIdsByProjectId, + nextThread.projectId, + nextThread.id, + ); + return { + ...state, + threads, + sidebarThreadsById, + threadIdsByProjectId, + }; } case "thread.deleted": { const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); - return threads.length === state.threads.length ? state : { ...state, threads }; + if (threads.length === state.threads.length) { + return state; + } + const deletedThread = state.threads.find((thread) => thread.id === event.payload.threadId); + const sidebarThreadsById = { ...state.sidebarThreadsById }; + delete sidebarThreadsById[event.payload.threadId]; + const threadIdsByProjectId = deletedThread + ? removeThreadIdByProjectId( + state.threadIdsByProjectId, + deletedThread.projectId, + deletedThread.id, + ) + : state.threadIdsByProjectId; + return { + ...state, + threads, + sidebarThreadsById, + threadIdsByProjectId, + }; } case "thread.archived": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, archivedAt: event.payload.archivedAt, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.unarchived": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, archivedAt: null, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.meta-updated": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), ...(event.payload.modelSelection !== undefined @@ -528,29 +743,26 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : {}), updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.runtime-mode-set": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, runtimeMode: event.payload.runtimeMode, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.interaction-mode-set": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, interactionMode: event.payload.interactionMode, updatedAt: event.payload.updatedAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.turn-start-requested": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.modelSelection !== undefined ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } @@ -560,14 +772,13 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve pendingSourceProposedPlan: event.payload.sourceProposedPlan, updatedAt: event.occurredAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.turn-interrupt-requested": { if (event.payload.turnId === undefined) { return state; } - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const latestTurn = thread.latestTurn; if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { return thread; @@ -586,11 +797,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.message-sent": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const message = mapMessage({ id: event.payload.messageId, role: event.payload.role, @@ -678,11 +888,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.session-set": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => ({ + return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, session: mapSession(event.payload.session), error: event.payload.session.lastError ?? null, @@ -710,11 +919,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : thread.latestTurn, updatedAt: event.occurredAt, })); - return threads === state.threads ? state : { ...state, threads }; } case "thread.session-stop-requested": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => + return updateThreadState(state, event.payload.threadId, (thread) => thread.session === null ? thread : { @@ -729,11 +937,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }, ); - return threads === state.threads ? state : { ...state, threads }; } case "thread.proposed-plan-upserted": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const proposedPlan = mapProposedPlan(event.payload.proposedPlan); const proposedPlans = [ ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id), @@ -750,11 +957,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.turn-diff-completed": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const checkpoint = mapTurnDiffSummary({ turnId: event.payload.turnId, checkpointTurnCount: event.payload.checkpointTurnCount, @@ -800,11 +1006,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.reverted": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const turnDiffSummaries = thread.turnDiffSummaries .filter( (entry) => @@ -853,11 +1058,10 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.activity-appended": { - const threads = updateThread(state.threads, event.payload.threadId, (thread) => { + return updateThreadState(state, event.payload.threadId, (thread) => { const activities = [ ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), { ...event.payload.activity }, @@ -870,7 +1074,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - return threads === state.threads ? state : { ...state, threads }; } case "thread.approval-response-requested": @@ -901,12 +1104,21 @@ export const selectThreadById = (state: AppState): Thread | undefined => threadId ? state.threads.find((thread) => thread.id === threadId) : undefined; +export const selectSidebarThreadSummaryById = + (threadId: ThreadId | null | undefined) => + (state: AppState): SidebarThreadSummary | undefined => + threadId ? state.sidebarThreadsById[threadId] : undefined; + +export const selectThreadIdsByProjectId = + (projectId: ProjectId | null | undefined) => + (state: AppState): ThreadId[] => + projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; + export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - const threads = updateThread(state.threads, threadId, (t) => { + return updateThreadState(state, threadId, (t) => { if (t.error === error) return t; return { ...t, error }; }); - return threads === state.threads ? state : { ...state, threads }; } export function setThreadBranch( @@ -915,7 +1127,7 @@ export function setThreadBranch( branch: string | null, worktreePath: string | null, ): AppState { - const threads = updateThread(state.threads, threadId, (t) => { + return updateThreadState(state, threadId, (t) => { if (t.branch === branch && t.worktreePath === worktreePath) return t; const cwdChanged = t.worktreePath !== worktreePath; return { @@ -925,7 +1137,6 @@ export function setThreadBranch( ...(cwdChanged ? { session: null } : {}), }; }); - return threads === state.threads ? state : { ...state, threads }; } // ── Zustand store ──────────────────────────────────────────────────── diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index 271fbb256b..65f8e6caaa 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,7 +1,12 @@ import { type ThreadId } from "@t3tools/contracts"; import { useMemo } from "react"; -import { selectProjectById, selectThreadById, useStore } from "./store"; -import { type Project, type Thread } from "./types"; +import { + selectProjectById, + selectSidebarThreadSummaryById, + selectThreadById, + useStore, +} from "./store"; +import { type Project, type SidebarThreadSummary, type Thread } from "./types"; export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined { const selector = useMemo(() => selectProjectById(projectId), [projectId]); @@ -12,3 +17,10 @@ export function useThreadById(threadId: ThreadId | null | undefined): Thread | u const selector = useMemo(() => selectThreadById(threadId), [threadId]); return useStore(selector); } + +export function useSidebarThreadSummaryById( + threadId: ThreadId | null | undefined, +): SidebarThreadSummary | undefined { + const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]); + return useStore(selector); +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0ebf150310..0599b9c989 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -111,6 +111,24 @@ export interface Thread { activities: OrchestrationThreadActivity[]; } +export interface SidebarThreadSummary { + id: ThreadId; + projectId: ProjectId; + title: string; + interactionMode: ProviderInteractionMode; + session: ThreadSession | null; + createdAt: string; + archivedAt: string | null; + updatedAt?: string | undefined; + latestTurn: OrchestrationLatestTurn | null; + branch: string | null; + worktreePath: string | null; + latestUserMessageAt: string | null; + hasPendingApprovals: boolean; + hasPendingUserInput: boolean; + hasActionableProposedPlan: boolean; +} + export interface ThreadSession { provider: ProviderKind; status: SessionPhase | "error" | "closed";