From 1d2db1387e1818ae83608daa013cd4b9cae2a343 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:03:53 +0000 Subject: [PATCH 01/30] fix: mark first-seen completed threads unread Co-authored-by: Codex (cherry picked from commit c87567aa1f16fcb43652d224c38d37a167312875) --- .../useMarkFirstSeenCompletedThreadsUnread.ts | 57 +++++++++++++++++++ apps/web/src/routes/__root.tsx | 3 + 2 files changed, 60 insertions(+) create mode 100644 apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts new file mode 100644 index 0000000000000..3cc7e2a5fafc4 --- /dev/null +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts @@ -0,0 +1,57 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { useThreadShells } from "../state/entities"; +import { environmentShell } from "../state/shell"; +import { useUiStateStore } from "../uiStateStore"; + +const environmentSnapshotIdsAtom = Atom.make((get): ReadonlyArray => { + const environmentIds: EnvironmentId[] = []; + for (const environmentId of get(environmentCatalog.catalogValueAtom).entries.keys()) { + if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { + environmentIds.push(environmentId); + } + } + return environmentIds; +}).pipe(Atom.withLabel("completed-thread-unread:snapshot-environments")); + +export function useMarkFirstSeenCompletedThreadsUnread(): void { + const threads = useThreadShells(); + const environmentSnapshotIds = useAtomValue(environmentSnapshotIdsAtom); + const seenThreadKeysByEnvironmentRef = useRef>>(new Map()); + + useEffect(() => { + const snapshotEnvironmentIds = new Set(environmentSnapshotIds); + const nextThreadKeysByEnvironment = new Map>(); + for (const environmentId of snapshotEnvironmentIds) { + nextThreadKeysByEnvironment.set(environmentId, new Set()); + } + + for (const thread of threads) { + if (!snapshotEnvironmentIds.has(thread.environmentId)) { + continue; + } + + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const nextThreadKeys = nextThreadKeysByEnvironment.get(thread.environmentId); + nextThreadKeys?.add(threadKey); + + const previousThreadKeys = seenThreadKeysByEnvironmentRef.current.get(thread.environmentId); + if ( + previousThreadKeys !== undefined && + !previousThreadKeys.has(threadKey) && + thread.latestTurn?.state === "completed" + ) { + useUiStateStore.getState().markThreadUnread(threadKey, thread.latestTurn.completedAt); + } + } + + seenThreadKeysByEnvironmentRef.current = nextThreadKeysByEnvironment; + }, [environmentSnapshotIds, threads]); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114de1..cb7530c0345bd 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,6 +27,7 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { useMarkFirstSeenCompletedThreadsUnread } from "../hooks/useMarkFirstSeenCompletedThreadsUnread"; import { useClientSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, @@ -277,6 +278,8 @@ function AuthenticatedTracingBootstrap() { } function EventRouter() { + useMarkFirstSeenCompletedThreadsUnread(); + const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); From 69d7c258bbbdf5c2134a44ff772f2e3bc47b4f15 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 00:23:47 -0400 Subject: [PATCH 02/30] test(web): cover first-seen unread detection --- ...arkFirstSeenCompletedThreadsUnread.test.ts | 72 +++++++++++++++ .../useMarkFirstSeenCompletedThreadsUnread.ts | 92 +++++++++++++------ 2 files changed, 138 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts new file mode 100644 index 0000000000000..34e78f56c8474 --- /dev/null +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts @@ -0,0 +1,72 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveFirstSeenCompletedThreads } from "./useMarkFirstSeenCompletedThreadsUnread"; + +const localEnvironmentId = EnvironmentId.make("environment-local"); +const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + +function thread( + id: string, + state: "completed" | "running" = "completed", + environmentId = localEnvironmentId, +) { + return { + environmentId, + id: ThreadId.make(id), + latestTurn: { + state, + completedAt: "2026-06-18T09:00:00.000Z", + }, + } as const; +} + +describe("resolveFirstSeenCompletedThreads", () => { + it("seeds initial snapshot history without marking it unread", () => { + const result = resolveFirstSeenCompletedThreads({ + threads: [thread("historical")], + environmentSnapshotIds: [localEnvironmentId], + previouslySeenThreadKeysByEnvironment: new Map(), + }); + + expect(result.newlyUnreadThreads).toEqual([]); + expect(result.nextSeenThreadKeysByEnvironment.get(localEnvironmentId)).toEqual( + new Set([scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("historical")))]), + ); + }); + + it("marks a completed thread that first appears after bootstrap unread", () => { + const historicalKey = scopedThreadKey( + scopeThreadRef(localEnvironmentId, ThreadId.make("historical")), + ); + const completedKey = scopedThreadKey( + scopeThreadRef(localEnvironmentId, ThreadId.make("completed")), + ); + const result = resolveFirstSeenCompletedThreads({ + threads: [thread("historical"), thread("completed")], + environmentSnapshotIds: [localEnvironmentId], + previouslySeenThreadKeysByEnvironment: new Map([ + [localEnvironmentId, new Set([historicalKey])], + ]), + }); + + expect(result.newlyUnreadThreads).toEqual([ + { + threadKey: completedKey, + completedAt: "2026-06-18T09:00:00.000Z", + }, + ]); + }); + + it("does not mark a new unfinished thread or a thread outside a snapshot environment", () => { + const result = resolveFirstSeenCompletedThreads({ + threads: [thread("running", "running"), thread("remote", "completed", remoteEnvironmentId)], + environmentSnapshotIds: [localEnvironmentId], + previouslySeenThreadKeysByEnvironment: new Map([[localEnvironmentId, new Set()]]), + }); + + expect(result.newlyUnreadThreads).toEqual([]); + expect(result.nextSeenThreadKeysByEnvironment.has(remoteEnvironmentId)).toBe(false); + }); +}); diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts index 3cc7e2a5fafc4..241c998ae533d 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; @@ -20,38 +20,78 @@ const environmentSnapshotIdsAtom = Atom.make((get): ReadonlyArray return environmentIds; }).pipe(Atom.withLabel("completed-thread-unread:snapshot-environments")); +interface FirstSeenThreadInput { + readonly environmentId: EnvironmentId; + readonly id: ThreadId; + readonly latestTurn: { + readonly state: string; + readonly completedAt: string | null; + } | null; +} + +export function resolveFirstSeenCompletedThreads(input: { + readonly threads: ReadonlyArray; + readonly environmentSnapshotIds: ReadonlyArray; + readonly previouslySeenThreadKeysByEnvironment: ReadonlyMap>; +}): { + readonly nextSeenThreadKeysByEnvironment: Map>; + readonly newlyUnreadThreads: ReadonlyArray<{ + readonly threadKey: string; + readonly completedAt: string | null; + }>; +} { + const snapshotEnvironmentIds = new Set(input.environmentSnapshotIds); + const nextSeenThreadKeysByEnvironment = new Map>(); + const newlyUnreadThreads: Array<{ + readonly threadKey: string; + readonly completedAt: string | null; + }> = []; + for (const environmentId of snapshotEnvironmentIds) { + nextSeenThreadKeysByEnvironment.set(environmentId, new Set()); + } + + for (const thread of input.threads) { + if (!snapshotEnvironmentIds.has(thread.environmentId)) { + continue; + } + + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + nextSeenThreadKeysByEnvironment.get(thread.environmentId)?.add(threadKey); + + const previousThreadKeys = input.previouslySeenThreadKeysByEnvironment.get( + thread.environmentId, + ); + if ( + previousThreadKeys !== undefined && + !previousThreadKeys.has(threadKey) && + thread.latestTurn?.state === "completed" + ) { + newlyUnreadThreads.push({ + threadKey, + completedAt: thread.latestTurn.completedAt, + }); + } + } + + return { nextSeenThreadKeysByEnvironment, newlyUnreadThreads }; +} + export function useMarkFirstSeenCompletedThreadsUnread(): void { const threads = useThreadShells(); const environmentSnapshotIds = useAtomValue(environmentSnapshotIdsAtom); const seenThreadKeysByEnvironmentRef = useRef>>(new Map()); useEffect(() => { - const snapshotEnvironmentIds = new Set(environmentSnapshotIds); - const nextThreadKeysByEnvironment = new Map>(); - for (const environmentId of snapshotEnvironmentIds) { - nextThreadKeysByEnvironment.set(environmentId, new Set()); - } - - for (const thread of threads) { - if (!snapshotEnvironmentIds.has(thread.environmentId)) { - continue; - } - - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const threadKey = scopedThreadKey(threadRef); - const nextThreadKeys = nextThreadKeysByEnvironment.get(thread.environmentId); - nextThreadKeys?.add(threadKey); - - const previousThreadKeys = seenThreadKeysByEnvironmentRef.current.get(thread.environmentId); - if ( - previousThreadKeys !== undefined && - !previousThreadKeys.has(threadKey) && - thread.latestTurn?.state === "completed" - ) { - useUiStateStore.getState().markThreadUnread(threadKey, thread.latestTurn.completedAt); - } + const { nextSeenThreadKeysByEnvironment, newlyUnreadThreads } = + resolveFirstSeenCompletedThreads({ + threads, + environmentSnapshotIds, + previouslySeenThreadKeysByEnvironment: seenThreadKeysByEnvironmentRef.current, + }); + for (const thread of newlyUnreadThreads) { + useUiStateStore.getState().markThreadUnread(thread.threadKey, thread.completedAt); } - seenThreadKeysByEnvironmentRef.current = nextThreadKeysByEnvironment; + seenThreadKeysByEnvironmentRef.current = nextSeenThreadKeysByEnvironment; }, [environmentSnapshotIds, threads]); } From b55f82c5058ac14f97d86e7182b5f8a9c442085e Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 01:36:05 -0400 Subject: [PATCH 03/30] fix(web): preserve manual unread state --- apps/web/src/components/ChatView.tsx | 25 ++++---- apps/web/src/components/Sidebar.logic.test.ts | 17 +++++ apps/web/src/components/Sidebar.logic.ts | 12 ++++ apps/web/src/components/Sidebar.tsx | 41 +++++++++--- apps/web/src/components/SidebarV2.tsx | 14 ++++- .../src/components/ThreadStatusIndicators.tsx | 7 ++- apps/web/src/uiStateStore.test.ts | 47 +++++++++++++- apps/web/src/uiStateStore.ts | 63 +++++++++++++++---- 8 files changed, 186 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9a3eb10a18d07..32df9109f8500 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1149,10 +1149,7 @@ function ChatViewContent(props: ChatViewProps) { const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useThread(routeThreadRef); - const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[routeThreadKey], - ); + const markActiveThreadVisited = useUiStateStore((store) => store.markActiveThreadVisited); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the // settings UI never writes to remote environments), so read them from the @@ -1751,19 +1748,19 @@ function ChatViewContent(props: ChatViewProps) { ); useEffect(() => { - if (!serverThread?.id) return; - const threadUpdatedAt = Date.parse(serverThread.updatedAt); - if (Number.isNaN(threadUpdatedAt)) return; - const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; - - markThreadVisited( + if (!serverThread?.id) { + markActiveThreadVisited(null, null); + return; + } + const visitedAt = Number.isNaN(Date.parse(serverThread.updatedAt)) + ? null + : serverThread.updatedAt; + markActiveThreadVisited( scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, + visitedAt, ); }, [ - activeThreadLastVisitedAt, - markThreadVisited, + markActiveThreadVisited, serverThread?.environmentId, serverThread?.id, serverThread?.updatedAt, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 65424b830a2c6..3212f40a85e10 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -943,6 +943,23 @@ describe("resolveThreadStatusPill", () => { }, }; + it("shows explicit unread even without a completed turn", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + isExplicitlyUnread: true, + latestTurn: null, + session: { + ...baseThread.session, + status: "ready", + activeTurnId: null, + }, + }, + }), + ).toMatchObject({ label: "Unread", pulse: false }); + }); + it("shows pending approval before all other statuses", () => { expect( resolveThreadStatusPill({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index e7082ca8adad8..a5a64ddae638c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -99,6 +99,7 @@ export interface ThreadStatusPill { label: | "Working" | "Connecting" + | "Unread" | "Completed" | "Pending Approval" | "Awaiting Input" @@ -114,6 +115,7 @@ const THREAD_STATUS_PRIORITY: Record = { Working: 3, Connecting: 3, "Plan Ready": 2, + Unread: 1, Completed: 1, }; @@ -127,6 +129,7 @@ type ThreadStatusInput = Pick< | "session" > & { lastVisitedAt?: string | undefined; + isExplicitlyUnread?: boolean | undefined; }; export interface ThreadJumpHintVisibilityController { @@ -600,6 +603,15 @@ export function resolveThreadStatusPill(input: { }): ThreadStatusPill | null { const { thread } = input; + if (thread.isExplicitlyUnread) { + return { + label: "Unread", + colorClass: "text-blue-600 dark:text-blue-300/90", + dotClass: "bg-blue-500 dark:bg-blue-300/90", + pulse: false, + }; + } + if (thread.hasPendingApprovals) { return { label: "Pending Approval", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 014e629fda7d3..e46430072f14d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -370,6 +370,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isExplicitlyUnread = useUiStateStore( + (state) => state.threadExplicitlyUnreadById[threadKey] === true, + ); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, @@ -449,6 +452,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr thread: { ...thread, lastVisitedAt, + isExplicitlyUnread, }, }); const pr = resolveThreadPr({ @@ -1198,6 +1202,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ), ), ); + const threadExplicitlyUnreadStates = useUiStateStore( + useShallow((state) => + projectThreads.map( + (thread) => + state.threadExplicitlyUnreadById[ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) + ] === true, + ), + ), + ); const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); @@ -1246,14 +1260,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec threadLastVisitedAts[index] ?? null, ]), ); - const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { - const lastVisitedAt = lastVisitedAtByThreadKey.get( + const explicitlyUnreadByThreadKey = new Map( + projectThreads.map((thread, index) => [ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + threadExplicitlyUnreadStates[index] ?? false, + ]), + ); + const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const lastVisitedAt = lastVisitedAtByThreadKey.get(threadKey); return resolveThreadStatusPill({ thread: { ...thread, ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), + ...(explicitlyUnreadByThreadKey.get(threadKey) ? { isExplicitlyUnread: true } : {}), }, }); }; @@ -1271,7 +1291,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectStatus, visibleProjectThreads, }; - }, [projectThreads, threadLastVisitedAts, threadSortOrder]); + }, [projectThreads, threadExplicitlyUnreadStates, threadLastVisitedAts, threadSortOrder]); const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -1298,14 +1318,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec threadLastVisitedAts[index] ?? null, ]), ); - const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { - const lastVisitedAt = lastVisitedAtByThreadKey.get( + const explicitlyUnreadByThreadKey = new Map( + projectThreads.map((thread, index) => [ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + threadExplicitlyUnreadStates[index] ?? false, + ]), + ); + const resolveProjectThreadStatus = (thread: SidebarThreadSummary) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const lastVisitedAt = lastVisitedAtByThreadKey.get(threadKey); return resolveThreadStatusPill({ thread: { ...thread, ...(lastVisitedAt !== null && lastVisitedAt !== undefined ? { lastVisitedAt } : {}), + ...(explicitlyUnreadByThreadKey.get(threadKey) ? { isExplicitlyUnread: true } : {}), }, }); }; @@ -1343,6 +1369,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec projectExpanded, projectThreads, sidebarThreadPreviewCount, + threadExplicitlyUnreadStates, threadLastVisitedAts, visibleProjectThreads, ]); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 361c3460bfd9d..cccee078061c0 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -326,12 +326,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ); const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isExplicitlyUnread = useUiStateStore( + (state) => state.threadExplicitlyUnreadById[threadKey] === true, + ); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const openPrLink = useOpenPrLink(); // Same semantics as v1 (never-visited counts as read): flipping the beta // flag must not light up every historical thread as unread. - const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); + const isUnread = isExplicitlyUnread || hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for @@ -345,8 +348,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. - const topStatus = - status === "working" + const topStatus = isExplicitlyUnread + ? { + label: "Unread", + icon: null, + className: "text-blue-600 dark:text-blue-300", + } + : status === "working" ? { label: "Working", icon: "working" as const, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 3c58d6ea98983..f385193682bed 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -225,8 +225,10 @@ export function ThreadStatusLabel({ */ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const lastVisitedAt = useUiStateStore( - (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isExplicitlyUnread = useUiStateStore( + (state) => state.threadExplicitlyUnreadById[threadKey] === true, ); const threadProject = useProject( useMemo( @@ -254,6 +256,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar thread: { ...thread, lastVisitedAt, + isExplicitlyUnread, }, }); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 0fbbd79ec273f..b1e63fa22e0a1 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { legacyProjectCwdPreferenceKey, + markActiveThreadVisited, markThreadUnread, markThreadVisited, parsePersistedState, @@ -22,7 +23,9 @@ function makeUiState(overrides: Partial = {}): UiState { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, + threadExplicitlyUnreadById: {}, threadChangedFilesExpandedById: {}, + activeThreadVisit: null, defaultAdvertisedEndpointKey: null, ...overrides, }; @@ -39,7 +42,7 @@ describe("uiStateStore pure functions", () => { expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited); }); - it("marks a completed thread unread using the server completion timestamp", () => { + it("marks any thread explicitly unread without requiring a completion timestamp", () => { const threadId = ThreadId.make("thread-1"); const initialState = makeUiState({ threadLastVisitedAtById: { @@ -49,10 +52,36 @@ describe("uiStateStore pure functions", () => { const next = markThreadUnread(initialState, threadId, "2026-02-25T12:30:00.000Z"); - expect(next.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z"); + expect(next.threadExplicitlyUnreadById[threadId]).toBe(true); + expect(next.threadLastVisitedAtById).toBe(initialState.threadLastVisitedAtById); expect(markThreadUnread(next, threadId, null)).toBe(next); }); + it("keeps an active thread unread until its route or update changes", () => { + const threadId = ThreadId.make("thread-1"); + const otherThreadId = ThreadId.make("thread-2"); + const updatedAt = "2026-02-25T12:30:00.000Z"; + const active = markActiveThreadVisited(makeUiState(), threadId, updatedAt); + const unread = markThreadUnread(active, threadId, null); + + expect(markActiveThreadVisited(unread, threadId, updatedAt)).toBe(unread); + expect(unread.threadExplicitlyUnreadById[threadId]).toBe(true); + + const away = markActiveThreadVisited(unread, otherThreadId, null); + const returned = markActiveThreadVisited(away, threadId, updatedAt); + expect(returned.threadExplicitlyUnreadById[threadId]).toBeUndefined(); + }); + + it("clears explicit unread when the active thread updates", () => { + const threadId = ThreadId.make("thread-1"); + const active = markActiveThreadVisited(makeUiState(), threadId, "2026-02-25T12:30:00.000Z"); + const unread = markThreadUnread(active, threadId, null); + const updated = markActiveThreadVisited(unread, threadId, "2026-02-25T12:35:00.000Z"); + + expect(updated.threadExplicitlyUnreadById[threadId]).toBeUndefined(); + expect(updated.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:35:00.000Z"); + }); + it("resolves project expansion from logical, physical, and legacy preference keys", () => { const physicalKey = "environment:/repo/project"; const legacyKey = legacyProjectCwdPreferenceKey("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/repo/project"); @@ -154,6 +183,10 @@ describe("parsePersistedState", () => { "environment:thread-1": "2026-02-25T12:35:00.000Z", invalid: "not-a-date", }, + threadExplicitlyUnreadById: { + "environment:thread-1": true, + read: false, + }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { "environment:thread-1": { @@ -171,6 +204,10 @@ describe("parsePersistedState", () => { threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + threadExplicitlyUnreadById: { + "environment:thread-1": true, + }, + activeThreadVisit: null, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { "environment:thread-1": { @@ -255,6 +292,9 @@ describe("uiStateStore persistence", () => { threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + threadExplicitlyUnreadById: { + "environment:thread-1": true, + }, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, @@ -277,6 +317,9 @@ describe("uiStateStore persistence", () => { threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + threadExplicitlyUnreadById: { + "environment:thread-1": true, + }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { "environment:thread-1": { diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4c9..4796186fda3ea 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -20,6 +20,7 @@ export interface PersistedUiState { projectExpandedById?: Record; projectOrder?: string[]; threadLastVisitedAtById?: Record; + threadExplicitlyUnreadById?: Record; collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; projectOrderCwds?: string[]; @@ -34,7 +35,12 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; + threadExplicitlyUnreadById: Record; threadChangedFilesExpandedById: Record>; + activeThreadVisit: { + readonly threadId: string; + readonly visitedAt: string | null; + } | null; } export interface UiEndpointState { @@ -47,7 +53,9 @@ const initialState: UiState = { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, + threadExplicitlyUnreadById: {}, threadChangedFilesExpandedById: {}, + activeThreadVisit: null, defaultAdvertisedEndpointKey: null, }; @@ -124,9 +132,15 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { projectExpandedById, projectOrder, threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), + threadExplicitlyUnreadById: Object.fromEntries( + Object.entries(sanitizeBooleanRecord(parsed.threadExplicitlyUnreadById)).filter( + ([, unread]) => unread, + ), + ), threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, ), + activeThreadVisit: null, defaultAdvertisedEndpointKey: typeof parsed.defaultAdvertisedEndpointKey === "string" && parsed.defaultAdvertisedEndpointKey.length > 0 @@ -209,6 +223,7 @@ export function persistState(state: UiState): void { projectExpandedById, projectOrder: state.projectOrder, threadLastVisitedAtById: state.threadLastVisitedAtById, + threadExplicitlyUnreadById: state.threadExplicitlyUnreadById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, } satisfies PersistedUiState), @@ -252,25 +267,46 @@ export function markThreadVisited(state: UiState, threadId: string, visitedAt: s export function markThreadUnread( state: UiState, threadId: string, - latestTurnCompletedAt: string | null | undefined, + _latestTurnCompletedAt?: string | null | undefined, ): UiState { - if (!latestTurnCompletedAt) { + if (state.threadExplicitlyUnreadById[threadId]) { return state; } - const latestTurnCompletedAtMs = Date.parse(latestTurnCompletedAt); - if (Number.isNaN(latestTurnCompletedAtMs)) { - return state; + return { + ...state, + threadExplicitlyUnreadById: { + ...state.threadExplicitlyUnreadById, + [threadId]: true, + }, + }; +} + +export function markActiveThreadVisited( + state: UiState, + threadId: string | null, + visitedAt: string | null, +): UiState { + if (threadId === null) { + return state.activeThreadVisit === null ? state : { ...state, activeThreadVisit: null }; } - const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); - if (state.threadLastVisitedAtById[threadId] === unreadVisitedAt) { + + if ( + state.activeThreadVisit?.threadId === threadId && + state.activeThreadVisit.visitedAt === visitedAt + ) { return state; } + + const visitedState = visitedAt === null ? state : markThreadVisited(state, threadId, visitedAt); + const nextExplicitlyUnreadById = { + ...visitedState.threadExplicitlyUnreadById, + }; + delete nextExplicitlyUnreadById[threadId]; + return { - ...state, - threadLastVisitedAtById: { - ...state.threadLastVisitedAtById, - [threadId]: unreadVisitedAt, - }, + ...visitedState, + threadExplicitlyUnreadById: nextExplicitlyUnreadById, + activeThreadVisit: { threadId, visitedAt }, }; } @@ -414,6 +450,7 @@ export function reorderProjects( interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; + markActiveThreadVisited: (threadId: string | null, visitedAt: string | null) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; @@ -430,6 +467,8 @@ export const useUiStateStore = create((set) => ({ set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), + markActiveThreadVisited: (threadId, visitedAt) => + set((state) => markActiveThreadVisited(state, threadId, visitedAt)), setThreadChangedFilesExpanded: (threadId, turnId, expanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => From 5c3c1687f0810c2eab100fc8932793ef4ea7a0ff Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 17:23:22 -0400 Subject: [PATCH 04/30] fix(web): harden unread thread tracking --- apps/web/src/components/ChatView.tsx | 9 ++ apps/web/src/components/Sidebar.logic.test.ts | 45 +++++++++ apps/web/src/components/Sidebar.logic.ts | 34 +++++-- apps/web/src/components/SidebarV2.tsx | 37 ++++--- ...arkFirstSeenCompletedThreadsUnread.test.ts | 96 +++++++++++++++++-- .../useMarkFirstSeenCompletedThreadsUnread.ts | 57 ++++++++--- apps/web/src/routes/__root.tsx | 8 +- 7 files changed, 238 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 32df9109f8500..49c2d38349ff8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1766,6 +1766,15 @@ function ChatViewContent(props: ChatViewProps) { serverThread?.updatedAt, ]); + useEffect( + () => () => { + if (useUiStateStore.getState().activeThreadVisit?.threadId === routeThreadKey) { + markActiveThreadVisited(null, null); + } + }, + [markActiveThreadVisited, routeThreadKey], + ); + const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3212f40a85e10..55742287e03ec 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -19,6 +19,7 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveSidebarV2Status, + resolveSidebarV2TopStatus, resolveThreadStatusPill, resolveWorkingStartedAt, formatWorkingDurationLabel, @@ -762,6 +763,38 @@ describe("resolveSidebarV2Status", () => { }); }); +describe("resolveSidebarV2TopStatus", () => { + it.each(["approval", "input", "working", "failed"] as const)( + "keeps %s above an explicit unread marker", + (status) => { + expect( + resolveSidebarV2TopStatus({ + status, + isExplicitlyUnread: true, + isUnread: true, + }), + ).toBe(status); + }, + ); + + it("uses unread only for a ready thread", () => { + expect( + resolveSidebarV2TopStatus({ + status: "ready", + isExplicitlyUnread: true, + isUnread: true, + }), + ).toBe("unread"); + expect( + resolveSidebarV2TopStatus({ + status: "ready", + isExplicitlyUnread: false, + isUnread: true, + }), + ).toBe("done"); + }); +}); + describe("sortThreadsForSidebarV2", () => { const sortable = (input: { id: string; createdAt: string }) => ({ id: input.id, @@ -967,11 +1000,23 @@ describe("resolveThreadStatusPill", () => { ...baseThread, hasPendingApprovals: true, hasPendingUserInput: true, + isExplicitlyUnread: true, }, }), ).toMatchObject({ label: "Pending Approval", pulse: false }); }); + it("shows active work before an explicit unread marker", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + isExplicitlyUnread: true, + }, + }), + ).toMatchObject({ label: "Working", pulse: true }); + }); + it("shows awaiting input when plan mode is blocked on user answers", () => { expect( resolveThreadStatusPill({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a5a64ddae638c..5f7ba0c60853c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -484,6 +484,22 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S return "ready"; } +export type SidebarV2TopStatus = Exclude | "unread" | "done" | null; + +export function resolveSidebarV2TopStatus(input: { + readonly status: SidebarV2Status; + readonly isExplicitlyUnread: boolean; + readonly isUnread: boolean; +}): SidebarV2TopStatus { + if (input.status !== "ready") { + return input.status; + } + if (input.isExplicitlyUnread) { + return "unread"; + } + return input.isUnread ? "done" : null; +} + /** NaN-safe Date.parse for sort comparators: a malformed timestamp must not poison the whole ordering, so it sinks to the epoch instead. */ export function parseTimestampMs(isoDate: string): number { @@ -603,15 +619,6 @@ export function resolveThreadStatusPill(input: { }): ThreadStatusPill | null { const { thread } = input; - if (thread.isExplicitlyUnread) { - return { - label: "Unread", - colorClass: "text-blue-600 dark:text-blue-300/90", - dotClass: "bg-blue-500 dark:bg-blue-300/90", - pulse: false, - }; - } - if (thread.hasPendingApprovals) { return { label: "Pending Approval", @@ -662,6 +669,15 @@ export function resolveThreadStatusPill(input: { }; } + if (thread.isExplicitlyUnread) { + return { + label: "Unread", + colorClass: "text-blue-600 dark:text-blue-300/90", + dotClass: "bg-blue-500 dark:bg-blue-300/90", + pulse: false, + }; + } + if (hasUnseenCompletion(thread)) { return { label: "Completed", diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index cccee078061c0..1a5006d975286 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -98,6 +98,7 @@ import { resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, + resolveSidebarV2TopStatus, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, @@ -336,6 +337,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // flag must not light up every historical thread as unread. const isUnread = isExplicitlyUnread || hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); + const topStatusKind = resolveSidebarV2TopStatus({ + status, + isExplicitlyUnread, + isUnread, + }); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, and failed. @@ -348,44 +354,45 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. - const topStatus = isExplicitlyUnread - ? { - label: "Unread", - icon: null, - className: "text-blue-600 dark:text-blue-300", - } - : status === "working" + const topStatus = + topStatusKind === "working" ? { label: "Working", icon: "working" as const, className: "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400", } - : status === "approval" + : topStatusKind === "approval" ? { label: "Approval", icon: null, className: "text-amber-700 dark:text-amber-300", } - : status === "input" + : topStatusKind === "input" ? { label: "Input", icon: null, className: "text-indigo-600 dark:text-indigo-300", } - : status === "failed" + : topStatusKind === "failed" ? { label: "Failed", icon: null, className: "text-red-700 dark:text-red-300", } - : isUnread + : topStatusKind === "unread" ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", + label: "Unread", + icon: null, + className: "text-blue-600 dark:text-blue-300", } - : null; + : topStatusKind === "done" + ? { + label: "Done", + icon: "done" as const, + className: "text-emerald-700 dark:text-emerald-300", + } + : null; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts index 34e78f56c8474..1afb19078c916 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts @@ -11,11 +11,13 @@ function thread( id: string, state: "completed" | "running" = "completed", environmentId = localEnvironmentId, + turnId = `turn-${id}`, ) { return { environmentId, id: ThreadId.make(id), latestTurn: { + turnId, state, completedAt: "2026-06-18T09:00:00.000Z", }, @@ -27,12 +29,17 @@ describe("resolveFirstSeenCompletedThreads", () => { const result = resolveFirstSeenCompletedThreads({ threads: [thread("historical")], environmentSnapshotIds: [localEnvironmentId], - previouslySeenThreadKeysByEnvironment: new Map(), + previouslyObservedThreadsByEnvironment: new Map(), }); expect(result.newlyUnreadThreads).toEqual([]); - expect(result.nextSeenThreadKeysByEnvironment.get(localEnvironmentId)).toEqual( - new Set([scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("historical")))]), + expect(result.nextObservedThreadsByEnvironment.get(localEnvironmentId)).toEqual( + new Map([ + [ + scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("historical"))), + { turnId: "turn-historical", state: "completed" }, + ], + ]), ); }); @@ -46,8 +53,11 @@ describe("resolveFirstSeenCompletedThreads", () => { const result = resolveFirstSeenCompletedThreads({ threads: [thread("historical"), thread("completed")], environmentSnapshotIds: [localEnvironmentId], - previouslySeenThreadKeysByEnvironment: new Map([ - [localEnvironmentId, new Set([historicalKey])], + previouslyObservedThreadsByEnvironment: new Map([ + [ + localEnvironmentId, + new Map([[historicalKey, { turnId: "turn-historical", state: "completed" }]]), + ], ]), }); @@ -63,10 +73,82 @@ describe("resolveFirstSeenCompletedThreads", () => { const result = resolveFirstSeenCompletedThreads({ threads: [thread("running", "running"), thread("remote", "completed", remoteEnvironmentId)], environmentSnapshotIds: [localEnvironmentId], - previouslySeenThreadKeysByEnvironment: new Map([[localEnvironmentId, new Set()]]), + previouslyObservedThreadsByEnvironment: new Map([[localEnvironmentId, new Map()]]), + }); + + expect(result.newlyUnreadThreads).toEqual([]); + expect(result.nextObservedThreadsByEnvironment.has(remoteEnvironmentId)).toBe(false); + }); + + it("marks a previously observed running thread unread when its turn completes", () => { + const runningKey = scopedThreadKey( + scopeThreadRef(localEnvironmentId, ThreadId.make("running")), + ); + const result = resolveFirstSeenCompletedThreads({ + threads: [thread("running", "completed")], + environmentSnapshotIds: [localEnvironmentId], + previouslyObservedThreadsByEnvironment: new Map([ + [localEnvironmentId, new Map([[runningKey, { turnId: "turn-running", state: "running" }]])], + ]), + }); + + expect(result.newlyUnreadThreads).toEqual([ + { + threadKey: runningKey, + completedAt: "2026-06-18T09:00:00.000Z", + }, + ]); + }); + + it("marks a later completed turn unread even when the prior turn was completed", () => { + const threadKey = scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("reused"))); + const result = resolveFirstSeenCompletedThreads({ + threads: [thread("reused", "completed", localEnvironmentId, "turn-new")], + environmentSnapshotIds: [localEnvironmentId], + previouslyObservedThreadsByEnvironment: new Map([ + [ + localEnvironmentId, + new Map([[threadKey, { turnId: "turn-previous", state: "completed" }]]), + ], + ]), + }); + + expect(result.newlyUnreadThreads).toHaveLength(1); + }); + + it("retains observations while a thread is archived and does not flag it on re-entry", () => { + const threadKey = scopedThreadKey( + scopeThreadRef(localEnvironmentId, ThreadId.make("archived")), + ); + const previous = new Map([ + [localEnvironmentId, new Map([[threadKey, { turnId: "turn-archived", state: "completed" }]])], + ]); + const whileArchived = resolveFirstSeenCompletedThreads({ + threads: [], + environmentSnapshotIds: [localEnvironmentId], + previouslyObservedThreadsByEnvironment: previous, + }); + const afterUnarchive = resolveFirstSeenCompletedThreads({ + threads: [thread("archived")], + environmentSnapshotIds: [localEnvironmentId], + previouslyObservedThreadsByEnvironment: whileArchived.nextObservedThreadsByEnvironment, + }); + + expect( + whileArchived.nextObservedThreadsByEnvironment.get(localEnvironmentId)?.has(threadKey), + ).toBe(true); + expect(afterUnarchive.newlyUnreadThreads).toEqual([]); + }); + + it("does not mark the currently active thread unread", () => { + const activeKey = scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("active"))); + const result = resolveFirstSeenCompletedThreads({ + threads: [thread("active")], + environmentSnapshotIds: [localEnvironmentId], + previouslyObservedThreadsByEnvironment: new Map([[localEnvironmentId, new Map()]]), + activeThreadKey: activeKey, }); expect(result.newlyUnreadThreads).toEqual([]); - expect(result.nextSeenThreadKeysByEnvironment.has(remoteEnvironmentId)).toBe(false); }); }); diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts index 241c998ae533d..fb2c8aef48d09 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts @@ -24,30 +24,47 @@ interface FirstSeenThreadInput { readonly environmentId: EnvironmentId; readonly id: ThreadId; readonly latestTurn: { + readonly turnId: string; readonly state: string; readonly completedAt: string | null; } | null; } +export interface ObservedThreadTurn { + readonly turnId: string | null; + readonly state: string | null; +} + export function resolveFirstSeenCompletedThreads(input: { readonly threads: ReadonlyArray; readonly environmentSnapshotIds: ReadonlyArray; - readonly previouslySeenThreadKeysByEnvironment: ReadonlyMap>; + readonly previouslyObservedThreadsByEnvironment: ReadonlyMap< + EnvironmentId, + ReadonlyMap + >; + readonly activeThreadKey?: string | null; }): { - readonly nextSeenThreadKeysByEnvironment: Map>; + readonly nextObservedThreadsByEnvironment: Map>; readonly newlyUnreadThreads: ReadonlyArray<{ readonly threadKey: string; readonly completedAt: string | null; }>; } { const snapshotEnvironmentIds = new Set(input.environmentSnapshotIds); - const nextSeenThreadKeysByEnvironment = new Map>(); + const nextObservedThreadsByEnvironment = new Map( + [...input.previouslyObservedThreadsByEnvironment].map(([environmentId, threads]) => [ + environmentId, + new Map(threads), + ]), + ); const newlyUnreadThreads: Array<{ readonly threadKey: string; readonly completedAt: string | null; }> = []; for (const environmentId of snapshotEnvironmentIds) { - nextSeenThreadKeysByEnvironment.set(environmentId, new Set()); + if (!nextObservedThreadsByEnvironment.has(environmentId)) { + nextObservedThreadsByEnvironment.set(environmentId, new Map()); + } } for (const thread of input.threads) { @@ -56,42 +73,52 @@ export function resolveFirstSeenCompletedThreads(input: { } const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - nextSeenThreadKeysByEnvironment.get(thread.environmentId)?.add(threadKey); - - const previousThreadKeys = input.previouslySeenThreadKeysByEnvironment.get( + const observedThread: ObservedThreadTurn = { + turnId: thread.latestTurn?.turnId ?? null, + state: thread.latestTurn?.state ?? null, + }; + const previousEnvironmentThreads = input.previouslyObservedThreadsByEnvironment.get( thread.environmentId, ); + const previousThread = previousEnvironmentThreads?.get(threadKey); if ( - previousThreadKeys !== undefined && - !previousThreadKeys.has(threadKey) && - thread.latestTurn?.state === "completed" + previousEnvironmentThreads !== undefined && + thread.latestTurn?.state === "completed" && + threadKey !== input.activeThreadKey && + (previousThread === undefined || + previousThread.turnId !== observedThread.turnId || + previousThread.state !== "completed") ) { newlyUnreadThreads.push({ threadKey, completedAt: thread.latestTurn.completedAt, }); } + nextObservedThreadsByEnvironment.get(thread.environmentId)?.set(threadKey, observedThread); } - return { nextSeenThreadKeysByEnvironment, newlyUnreadThreads }; + return { nextObservedThreadsByEnvironment, newlyUnreadThreads }; } export function useMarkFirstSeenCompletedThreadsUnread(): void { const threads = useThreadShells(); const environmentSnapshotIds = useAtomValue(environmentSnapshotIdsAtom); - const seenThreadKeysByEnvironmentRef = useRef>>(new Map()); + const observedThreadsByEnvironmentRef = useRef< + Map> + >(new Map()); useEffect(() => { - const { nextSeenThreadKeysByEnvironment, newlyUnreadThreads } = + const { nextObservedThreadsByEnvironment, newlyUnreadThreads } = resolveFirstSeenCompletedThreads({ threads, environmentSnapshotIds, - previouslySeenThreadKeysByEnvironment: seenThreadKeysByEnvironmentRef.current, + previouslyObservedThreadsByEnvironment: observedThreadsByEnvironmentRef.current, + activeThreadKey: useUiStateStore.getState().activeThreadVisit?.threadId ?? null, }); for (const thread of newlyUnreadThreads) { useUiStateStore.getState().markThreadUnread(thread.threadKey, thread.completedAt); } - seenThreadKeysByEnvironmentRef.current = nextSeenThreadKeysByEnvironment; + observedThreadsByEnvironmentRef.current = nextObservedThreadsByEnvironment; }, [environmentSnapshotIds, threads]); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index cb7530c0345bd..aeaf4759646e0 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -135,6 +135,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} @@ -198,6 +199,11 @@ function HostedStaticEnvironmentBootstrap() { return null; } +function CompletedThreadUnreadTracker() { + useMarkFirstSeenCompletedThreadsUnread(); + return null; +} + function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); @@ -278,8 +284,6 @@ function AuthenticatedTracingBootstrap() { } function EventRouter() { - useMarkFirstSeenCompletedThreadsUnread(); - const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); From 47274fd98c5e0b813a0f64035bc0769b2bd26a0c Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 18:12:27 -0400 Subject: [PATCH 05/30] fix(web): avoid unread bootstrap races --- ...arkFirstSeenCompletedThreadsUnread.test.ts | 26 +++++++++++++++++++ .../useMarkFirstSeenCompletedThreadsUnread.ts | 9 +++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts index 1afb19078c916..4e71987238dfb 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts @@ -43,6 +43,32 @@ describe("resolveFirstSeenCompletedThreads", () => { ); }); + it("waits for lagging snapshot threads before seeding history", () => { + const beforeThreadsHydrate = resolveFirstSeenCompletedThreads({ + threads: [], + environmentSnapshotIds: [localEnvironmentId], + previouslyObservedThreadsByEnvironment: new Map(), + }); + const afterThreadsHydrate = resolveFirstSeenCompletedThreads({ + threads: [thread("historical")], + environmentSnapshotIds: [localEnvironmentId], + previouslyObservedThreadsByEnvironment: beforeThreadsHydrate.nextObservedThreadsByEnvironment, + }); + + expect(beforeThreadsHydrate.nextObservedThreadsByEnvironment.has(localEnvironmentId)).toBe( + false, + ); + expect(afterThreadsHydrate.newlyUnreadThreads).toEqual([]); + expect(afterThreadsHydrate.nextObservedThreadsByEnvironment.get(localEnvironmentId)).toEqual( + new Map([ + [ + scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("historical"))), + { turnId: "turn-historical", state: "completed" }, + ], + ]), + ); + }); + it("marks a completed thread that first appears after bootstrap unread", () => { const historicalKey = scopedThreadKey( scopeThreadRef(localEnvironmentId, ThreadId.make("historical")), diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts index fb2c8aef48d09..db1d4d0080fda 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts @@ -61,17 +61,14 @@ export function resolveFirstSeenCompletedThreads(input: { readonly threadKey: string; readonly completedAt: string | null; }> = []; - for (const environmentId of snapshotEnvironmentIds) { - if (!nextObservedThreadsByEnvironment.has(environmentId)) { - nextObservedThreadsByEnvironment.set(environmentId, new Map()); - } - } - for (const thread of input.threads) { if (!snapshotEnvironmentIds.has(thread.environmentId)) { continue; } + if (!nextObservedThreadsByEnvironment.has(thread.environmentId)) { + nextObservedThreadsByEnvironment.set(thread.environmentId, new Map()); + } const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const observedThread: ObservedThreadTurn = { turnId: thread.latestTurn?.turnId ?? null, From 9a130c0f1720b63136458b03ede10fb2c16d9b19 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 18:34:33 -0400 Subject: [PATCH 06/30] fix(web): observe unread bootstrap atomically --- ...arkFirstSeenCompletedThreadsUnread.test.ts | 29 +++++++++---------- .../useMarkFirstSeenCompletedThreadsUnread.ts | 27 +++++++++++------ 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts index 4e71987238dfb..5c3eabc1327eb 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts @@ -43,30 +43,27 @@ describe("resolveFirstSeenCompletedThreads", () => { ); }); - it("waits for lagging snapshot threads before seeding history", () => { - const beforeThreadsHydrate = resolveFirstSeenCompletedThreads({ + it("seeds a genuinely empty snapshot before observing later completions", () => { + const emptySnapshot = resolveFirstSeenCompletedThreads({ threads: [], environmentSnapshotIds: [localEnvironmentId], previouslyObservedThreadsByEnvironment: new Map(), }); - const afterThreadsHydrate = resolveFirstSeenCompletedThreads({ - threads: [thread("historical")], + const laterCompletion = resolveFirstSeenCompletedThreads({ + threads: [thread("completed")], environmentSnapshotIds: [localEnvironmentId], - previouslyObservedThreadsByEnvironment: beforeThreadsHydrate.nextObservedThreadsByEnvironment, + previouslyObservedThreadsByEnvironment: emptySnapshot.nextObservedThreadsByEnvironment, }); - expect(beforeThreadsHydrate.nextObservedThreadsByEnvironment.has(localEnvironmentId)).toBe( - false, - ); - expect(afterThreadsHydrate.newlyUnreadThreads).toEqual([]); - expect(afterThreadsHydrate.nextObservedThreadsByEnvironment.get(localEnvironmentId)).toEqual( - new Map([ - [ - scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("historical"))), - { turnId: "turn-historical", state: "completed" }, - ], - ]), + expect(emptySnapshot.nextObservedThreadsByEnvironment.get(localEnvironmentId)).toEqual( + new Map(), ); + expect(laterCompletion.newlyUnreadThreads).toEqual([ + { + threadKey: scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("completed"))), + completedAt: "2026-06-18T09:00:00.000Z", + }, + ]); }); it("marks a completed thread that first appears after bootstrap unread", () => { diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts index db1d4d0080fda..942b5f155b01d 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts @@ -6,19 +6,26 @@ import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; import { environmentCatalog } from "../connection/catalog"; -import { useThreadShells } from "../state/entities"; import { environmentShell } from "../state/shell"; +import { environmentThreadShells } from "../state/threads"; import { useUiStateStore } from "../uiStateStore"; -const environmentSnapshotIdsAtom = Atom.make((get): ReadonlyArray => { +// Read snapshot readiness and the shells derived from those snapshots in one +// Atom transaction. Separate React subscriptions can briefly observe a new +// snapshot with the prior shell list and misclassify its history as newly +// completed on the following render. +const completedThreadObservationSnapshotAtom = Atom.make((get) => { const environmentIds: EnvironmentId[] = []; for (const environmentId of get(environmentCatalog.catalogValueAtom).entries.keys()) { if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { environmentIds.push(environmentId); } } - return environmentIds; -}).pipe(Atom.withLabel("completed-thread-unread:snapshot-environments")); + return { + environmentSnapshotIds: environmentIds, + threads: get(environmentThreadShells.threadShellsAtom), + }; +}).pipe(Atom.withLabel("completed-thread-unread:observation-snapshot")); interface FirstSeenThreadInput { readonly environmentId: EnvironmentId; @@ -61,14 +68,17 @@ export function resolveFirstSeenCompletedThreads(input: { readonly threadKey: string; readonly completedAt: string | null; }> = []; + for (const environmentId of snapshotEnvironmentIds) { + if (!nextObservedThreadsByEnvironment.has(environmentId)) { + nextObservedThreadsByEnvironment.set(environmentId, new Map()); + } + } + for (const thread of input.threads) { if (!snapshotEnvironmentIds.has(thread.environmentId)) { continue; } - if (!nextObservedThreadsByEnvironment.has(thread.environmentId)) { - nextObservedThreadsByEnvironment.set(thread.environmentId, new Map()); - } const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const observedThread: ObservedThreadTurn = { turnId: thread.latestTurn?.turnId ?? null, @@ -98,8 +108,7 @@ export function resolveFirstSeenCompletedThreads(input: { } export function useMarkFirstSeenCompletedThreadsUnread(): void { - const threads = useThreadShells(); - const environmentSnapshotIds = useAtomValue(environmentSnapshotIdsAtom); + const { environmentSnapshotIds, threads } = useAtomValue(completedThreadObservationSnapshotAtom); const observedThreadsByEnvironmentRef = useRef< Map> >(new Map()); From b6691c427d02de225bffa5d0afafd69c4095f5df Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 19:10:44 -0400 Subject: [PATCH 07/30] fix(web): preserve unread through thread hydration --- apps/web/src/components/ChatView.tsx | 35 +++++++++++----------- apps/web/src/uiStateStore.test.ts | 44 ++++++++++++++++++++++++++++ apps/web/src/uiStateStore.ts | 34 ++++++++++++++++----- 3 files changed, 88 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 49c2d38349ff8..4f97ecc719c3e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -54,7 +54,7 @@ import { useState, } from "react"; import { flushSync } from "react-dom"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useRouter } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { isAtomCommandInterrupted, @@ -161,7 +161,7 @@ import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { buildDraftThreadRouteParams } from "../threadRoutes"; +import { buildDraftThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -1161,6 +1161,7 @@ function ChatViewContent(props: ChatViewProps) { const timestampFormat = settings.timestampFormat; const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); + const router = useRouter(); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( @@ -1748,31 +1749,29 @@ function ChatViewContent(props: ChatViewProps) { ); useEffect(() => { - if (!serverThread?.id) { - markActiveThreadVisited(null, null); + if (routeKind !== "server") { return; } - const visitedAt = Number.isNaN(Date.parse(serverThread.updatedAt)) - ? null - : serverThread.updatedAt; - markActiveThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - visitedAt, - ); - }, [ - markActiveThreadVisited, - serverThread?.environmentId, - serverThread?.id, - serverThread?.updatedAt, - ]); + const visitedAt = + !serverThread || Number.isNaN(Date.parse(serverThread.updatedAt)) + ? null + : serverThread.updatedAt; + markActiveThreadVisited(routeThreadKey, visitedAt); + }, [markActiveThreadVisited, routeKind, routeThreadKey, serverThread?.updatedAt]); useEffect( () => () => { + const currentRouteParams = + router.state.matches[router.state.matches.length - 1]?.params ?? {}; + const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); + if (currentRouteTarget?.kind === "server") { + return; + } if (useUiStateStore.getState().activeThreadVisit?.threadId === routeThreadKey) { markActiveThreadVisited(null, null); } }, - [markActiveThreadVisited, routeThreadKey], + [markActiveThreadVisited, routeThreadKey, router], ); const selectedProviderByThreadId = composerActiveProvider ?? null; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index b1e63fa22e0a1..f1f3938720791 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -25,6 +25,7 @@ function makeUiState(overrides: Partial = {}): UiState { threadLastVisitedAtById: {}, threadExplicitlyUnreadById: {}, threadChangedFilesExpandedById: {}, + hasTrackedActiveThreadRoute: false, activeThreadVisit: null, defaultAdvertisedEndpointKey: null, ...overrides, @@ -72,6 +73,48 @@ describe("uiStateStore pure functions", () => { expect(returned.threadExplicitlyUnreadById[threadId]).toBeUndefined(); }); + it("preserves persisted unread state through initial route hydration", () => { + const threadId = ThreadId.make("thread-1"); + const updatedAt = "2026-02-25T12:30:00.000Z"; + const restarted = parsePersistedState({ + threadLastVisitedAtById: { [threadId]: updatedAt }, + threadExplicitlyUnreadById: { [threadId]: true }, + }); + + const routed = markActiveThreadVisited(restarted, threadId, null); + const hydrated = markActiveThreadVisited(routed, threadId, updatedAt); + + expect(routed.threadExplicitlyUnreadById[threadId]).toBe(true); + expect(hydrated.threadExplicitlyUnreadById[threadId]).toBe(true); + expect(hydrated.activeThreadVisit).toEqual({ threadId, visitedAt: updatedAt }); + }); + + it("preserves active unread state through a transient detail gap", () => { + const threadId = ThreadId.make("thread-1"); + const updatedAt = "2026-02-25T12:30:00.000Z"; + const active = markActiveThreadVisited(makeUiState(), threadId, updatedAt); + const unread = markThreadUnread(active, threadId, null); + + const missing = markActiveThreadVisited(unread, threadId, null); + const restored = markActiveThreadVisited(missing, threadId, updatedAt); + + expect(missing).toBe(unread); + expect(restored).toBe(unread); + expect(restored.threadExplicitlyUnreadById[threadId]).toBe(true); + }); + + it("clears explicit unread after leaving and returning to the same route", () => { + const threadId = ThreadId.make("thread-1"); + const updatedAt = "2026-02-25T12:30:00.000Z"; + const active = markActiveThreadVisited(makeUiState(), threadId, updatedAt); + const unread = markThreadUnread(active, threadId, null); + + const away = markActiveThreadVisited(unread, null, null); + const returned = markActiveThreadVisited(away, threadId, updatedAt); + + expect(returned.threadExplicitlyUnreadById[threadId]).toBeUndefined(); + }); + it("clears explicit unread when the active thread updates", () => { const threadId = ThreadId.make("thread-1"); const active = markActiveThreadVisited(makeUiState(), threadId, "2026-02-25T12:30:00.000Z"); @@ -207,6 +250,7 @@ describe("parsePersistedState", () => { threadExplicitlyUnreadById: { "environment:thread-1": true, }, + hasTrackedActiveThreadRoute: false, activeThreadVisit: null, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4796186fda3ea..c02267ff6eff9 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -37,6 +37,7 @@ export interface UiThreadState { threadLastVisitedAtById: Record; threadExplicitlyUnreadById: Record; threadChangedFilesExpandedById: Record>; + hasTrackedActiveThreadRoute: boolean; activeThreadVisit: { readonly threadId: string; readonly visitedAt: string | null; @@ -55,6 +56,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadExplicitlyUnreadById: {}, threadChangedFilesExpandedById: {}, + hasTrackedActiveThreadRoute: false, activeThreadVisit: null, defaultAdvertisedEndpointKey: null, }; @@ -140,6 +142,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, ), + hasTrackedActiveThreadRoute: false, activeThreadVisit: null, defaultAdvertisedEndpointKey: typeof parsed.defaultAdvertisedEndpointKey === "string" && @@ -287,25 +290,42 @@ export function markActiveThreadVisited( visitedAt: string | null, ): UiState { if (threadId === null) { - return state.activeThreadVisit === null ? state : { ...state, activeThreadVisit: null }; + return state.activeThreadVisit === null && state.hasTrackedActiveThreadRoute + ? state + : { + ...state, + hasTrackedActiveThreadRoute: true, + activeThreadVisit: null, + }; } - if ( - state.activeThreadVisit?.threadId === threadId && - state.activeThreadVisit.visitedAt === visitedAt - ) { - return state; + if (state.activeThreadVisit?.threadId === threadId) { + if (visitedAt === null || state.activeThreadVisit.visitedAt === visitedAt) { + return state; + } + + if (state.activeThreadVisit.visitedAt === null) { + const visitedState = markThreadVisited(state, threadId, visitedAt); + return { + ...visitedState, + hasTrackedActiveThreadRoute: true, + activeThreadVisit: { threadId, visitedAt }, + }; + } } const visitedState = visitedAt === null ? state : markThreadVisited(state, threadId, visitedAt); const nextExplicitlyUnreadById = { ...visitedState.threadExplicitlyUnreadById, }; - delete nextExplicitlyUnreadById[threadId]; + if (state.hasTrackedActiveThreadRoute) { + delete nextExplicitlyUnreadById[threadId]; + } return { ...visitedState, threadExplicitlyUnreadById: nextExplicitlyUnreadById, + hasTrackedActiveThreadRoute: true, activeThreadVisit: { threadId, visitedAt }, }; } From 641f27ac66b757bb4fe94067ea761e239f08858a Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 19:43:08 -0400 Subject: [PATCH 08/30] fix(web): track draft route before unread visits --- apps/web/src/components/ChatView.tsx | 1 + ...arkFirstSeenCompletedThreadsUnread.test.ts | 42 ++++++++++++++----- apps/web/src/uiStateStore.test.ts | 18 ++++++++ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4f97ecc719c3e..05d0ddde5f688 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1750,6 +1750,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (routeKind !== "server") { + markActiveThreadVisited(null, null); return; } const visitedAt = diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts index 5c3eabc1327eb..7bffbbaecc811 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts @@ -19,7 +19,7 @@ function thread( latestTurn: { turnId, state, - completedAt: "2026-06-18T09:00:00.000Z", + completedAt: state === "completed" ? "2026-06-18T09:00:00.000Z" : null, }, } as const; } @@ -103,19 +103,41 @@ describe("resolveFirstSeenCompletedThreads", () => { expect(result.nextObservedThreadsByEnvironment.has(remoteEnvironmentId)).toBe(false); }); - it("marks a previously observed running thread unread when its turn completes", () => { + it("marks a remote thread unread when a turn first seen running after bootstrap completes", () => { + const historicalKey = scopedThreadKey( + scopeThreadRef(remoteEnvironmentId, ThreadId.make("historical")), + ); const runningKey = scopedThreadKey( - scopeThreadRef(localEnvironmentId, ThreadId.make("running")), + scopeThreadRef(remoteEnvironmentId, ThreadId.make("running")), ); - const result = resolveFirstSeenCompletedThreads({ - threads: [thread("running", "completed")], - environmentSnapshotIds: [localEnvironmentId], - previouslyObservedThreadsByEnvironment: new Map([ - [localEnvironmentId, new Map([[runningKey, { turnId: "turn-running", state: "running" }]])], - ]), + const bootstrap = resolveFirstSeenCompletedThreads({ + threads: [thread("historical", "completed", remoteEnvironmentId)], + environmentSnapshotIds: [remoteEnvironmentId], + previouslyObservedThreadsByEnvironment: new Map(), + }); + const runningObservation = resolveFirstSeenCompletedThreads({ + threads: [ + thread("historical", "completed", remoteEnvironmentId), + thread("running", "running", remoteEnvironmentId), + ], + environmentSnapshotIds: [remoteEnvironmentId], + previouslyObservedThreadsByEnvironment: bootstrap.nextObservedThreadsByEnvironment, + }); + const completion = resolveFirstSeenCompletedThreads({ + threads: [ + thread("historical", "completed", remoteEnvironmentId), + thread("running", "completed", remoteEnvironmentId), + ], + environmentSnapshotIds: [remoteEnvironmentId], + previouslyObservedThreadsByEnvironment: runningObservation.nextObservedThreadsByEnvironment, }); - expect(result.newlyUnreadThreads).toEqual([ + expect(bootstrap.newlyUnreadThreads).toEqual([]); + expect( + bootstrap.nextObservedThreadsByEnvironment.get(remoteEnvironmentId)?.get(historicalKey), + ).toEqual({ turnId: "turn-historical", state: "completed" }); + expect(runningObservation.newlyUnreadThreads).toEqual([]); + expect(completion.newlyUnreadThreads).toEqual([ { threadKey: runningKey, completedAt: "2026-06-18T09:00:00.000Z", diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index f1f3938720791..087d28c8d55ab 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -89,6 +89,24 @@ describe("uiStateStore pure functions", () => { expect(hydrated.activeThreadVisit).toEqual({ threadId, visitedAt: updatedAt }); }); + it("clears persisted unread after tracking a draft route before opening the thread", () => { + const threadId = ThreadId.make("thread-1"); + const updatedAt = "2026-02-25T12:30:00.000Z"; + const restarted = parsePersistedState({ + threadLastVisitedAtById: { [threadId]: updatedAt }, + threadExplicitlyUnreadById: { [threadId]: true }, + }); + + const draft = markActiveThreadVisited(restarted, null, null); + const routed = markActiveThreadVisited(draft, threadId, null); + const hydrated = markActiveThreadVisited(routed, threadId, updatedAt); + + expect(draft.hasTrackedActiveThreadRoute).toBe(true); + expect(routed.threadExplicitlyUnreadById[threadId]).toBeUndefined(); + expect(hydrated.threadExplicitlyUnreadById[threadId]).toBeUndefined(); + expect(hydrated.activeThreadVisit).toEqual({ threadId, visitedAt: updatedAt }); + }); + it("preserves active unread state through a transient detail gap", () => { const threadId = ThreadId.make("thread-1"); const updatedAt = "2026-02-25T12:30:00.000Z"; From 614845668f60eccd808c49a6dac2515d4742786f Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 20:01:14 -0400 Subject: [PATCH 09/30] fix(web): preserve unread across draft promotion --- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/routes/__root.tsx | 21 +++++++++++++++++++++ apps/web/src/uiStateStore.test.ts | 23 ++++++++++++++++++++--- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 05d0ddde5f688..472d021da2286 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1750,7 +1750,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (routeKind !== "server") { - markActiveThreadVisited(null, null); + markActiveThreadVisited(routeThreadKey, null); return; } const visitedAt = diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index aeaf4759646e0..1db8e128cdbdb 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { type ErrorComponentProps, useLocation, useNavigate, + useRouterState, } from "@tanstack/react-router"; import { useEffect, useEffectEvent, useRef, useState } from "react"; @@ -49,6 +50,7 @@ import { primaryServerWelcomeAtom, } from "../state/server"; import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; +import { resolveThreadRouteTarget } from "../threadRoutes"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -135,6 +137,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} @@ -204,6 +207,24 @@ function CompletedThreadUnreadTracker() { return null; } +function ActiveThreadRouteTracker() { + const routeKind = useRouterState({ + select: (state) => { + const params = state.matches[state.matches.length - 1]?.params ?? {}; + return resolveThreadRouteTarget(params)?.kind ?? null; + }, + }); + const markActiveThreadVisited = useUiStateStore((state) => state.markActiveThreadVisited); + + useEffect(() => { + if (routeKind === null) { + markActiveThreadVisited(null, null); + } + }, [markActiveThreadVisited, routeKind]); + + return null; +} + function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 087d28c8d55ab..08fc74953d39b 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -89,7 +89,7 @@ describe("uiStateStore pure functions", () => { expect(hydrated.activeThreadVisit).toEqual({ threadId, visitedAt: updatedAt }); }); - it("clears persisted unread after tracking a draft route before opening the thread", () => { + it("preserves persisted unread when a draft promotes to the same thread", () => { const threadId = ThreadId.make("thread-1"); const updatedAt = "2026-02-25T12:30:00.000Z"; const restarted = parsePersistedState({ @@ -97,14 +97,31 @@ describe("uiStateStore pure functions", () => { threadExplicitlyUnreadById: { [threadId]: true }, }); - const draft = markActiveThreadVisited(restarted, null, null); + const draft = markActiveThreadVisited(restarted, threadId, null); const routed = markActiveThreadVisited(draft, threadId, null); const hydrated = markActiveThreadVisited(routed, threadId, updatedAt); expect(draft.hasTrackedActiveThreadRoute).toBe(true); + expect(routed.threadExplicitlyUnreadById[threadId]).toBe(true); + expect(hydrated.threadExplicitlyUnreadById[threadId]).toBe(true); + expect(hydrated.activeThreadVisit).toEqual({ threadId, visitedAt: updatedAt }); + }); + + it("clears persisted unread after tracking a non-chat route", () => { + const threadId = ThreadId.make("thread-1"); + const updatedAt = "2026-02-25T12:30:00.000Z"; + const restarted = parsePersistedState({ + threadLastVisitedAtById: { [threadId]: updatedAt }, + threadExplicitlyUnreadById: { [threadId]: true }, + }); + + const nonChatRoute = markActiveThreadVisited(restarted, null, null); + const routed = markActiveThreadVisited(nonChatRoute, threadId, null); + const hydrated = markActiveThreadVisited(routed, threadId, updatedAt); + + expect(nonChatRoute.hasTrackedActiveThreadRoute).toBe(true); expect(routed.threadExplicitlyUnreadById[threadId]).toBeUndefined(); expect(hydrated.threadExplicitlyUnreadById[threadId]).toBeUndefined(); - expect(hydrated.activeThreadVisit).toEqual({ threadId, visitedAt: updatedAt }); }); it("preserves active unread state through a transient detail gap", () => { From 8d14c1f5d22defe6e5a92cce7765d9072d492b61 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 20:10:59 -0400 Subject: [PATCH 10/30] fix(web): clear stale active visits across routes --- apps/web/src/components/ChatView.tsx | 16 +++++++++-- apps/web/src/threadRoutes.test.ts | 43 ++++++++++++++++++++++++++++ apps/web/src/threadRoutes.ts | 17 ++++++++++- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 472d021da2286..a86f88737eca4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -161,7 +161,11 @@ import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { buildDraftThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { + buildDraftThreadRouteParams, + resolveThreadRouteTarget, + shouldKeepActiveThreadVisitOnUnmount, +} from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -1765,14 +1769,20 @@ function ChatViewContent(props: ChatViewProps) { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); - if (currentRouteTarget?.kind === "server") { + if ( + shouldKeepActiveThreadVisitOnUnmount({ + currentRouteTarget, + routeThreadKey, + draftId, + }) + ) { return; } if (useUiStateStore.getState().activeThreadVisit?.threadId === routeThreadKey) { markActiveThreadVisited(null, null); } }, - [markActiveThreadVisited, routeThreadKey, router], + [draftId, markActiveThreadVisited, routeThreadKey, router], ); const selectedProviderByThreadId = composerActiveProvider ?? null; diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 3edb2f38dc85c..012f62d1af5b5 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -10,6 +10,7 @@ import { resolveThreadRouteRenderState, resolveThreadRouteRef, resolveThreadRouteTarget, + shouldKeepActiveThreadVisitOnUnmount, } from "./threadRoutes"; describe("threadRoutes", () => { @@ -65,6 +66,48 @@ describe("threadRoutes", () => { kind: "draft", draftId: "draft-1", }); + + expect(resolveThreadRouteTarget({ environmentId: "env-1" })).toBeNull(); + expect(resolveThreadRouteTarget({ threadId: "thread-1" })).toBeNull(); + }); + + it("keeps an active visit only while the same chat route remains mounted", () => { + const routeThreadKey = "env-1:thread-1"; + + expect( + shouldKeepActiveThreadVisitOnUnmount({ + currentRouteTarget: resolveThreadRouteTarget({ + environmentId: "env-1", + threadId: "thread-1", + }), + routeThreadKey, + draftId: null, + }), + ).toBe(true); + expect( + shouldKeepActiveThreadVisitOnUnmount({ + currentRouteTarget: resolveThreadRouteTarget({ + environmentId: "env-1", + threadId: "thread-2", + }), + routeThreadKey, + draftId: null, + }), + ).toBe(false); + expect( + shouldKeepActiveThreadVisitOnUnmount({ + currentRouteTarget: resolveThreadRouteTarget({ draftId: "draft-1" }), + routeThreadKey, + draftId: DraftId.make("draft-1"), + }), + ).toBe(true); + expect( + shouldKeepActiveThreadVisitOnUnmount({ + currentRouteTarget: resolveThreadRouteTarget({ draftId: "draft-2" }), + routeThreadKey, + draftId: DraftId.make("draft-1"), + }), + ).toBe(false); }); it("resolves the backing thread while a draft route is being promoted", () => { diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index fd5bc39d836ae..ce92478182207 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { DraftId } from "./composerDraftStore"; @@ -85,6 +85,21 @@ export function resolveThreadRouteTarget( }; } +export function shouldKeepActiveThreadVisitOnUnmount(input: { + readonly currentRouteTarget: ThreadRouteTarget | null; + readonly routeThreadKey: string; + readonly draftId: DraftId | null; +}): boolean { + if (input.currentRouteTarget?.kind === "server") { + return scopedThreadKey(input.currentRouteTarget.threadRef) === input.routeThreadKey; + } + return ( + input.currentRouteTarget?.kind === "draft" && + input.draftId !== null && + input.currentRouteTarget.draftId === input.draftId + ); +} + /** * Resolves the thread represented by either a canonical thread route or a * draft route whose promotion to a server thread has been recorded. From a31fdbe94446381adf2b57edb99a8c150d74c3f3 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 20:15:39 -0400 Subject: [PATCH 11/30] fix(web): ignore stale active visit timestamps --- apps/web/src/uiStateStore.test.ts | 15 +++++++++++++++ apps/web/src/uiStateStore.ts | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 08fc74953d39b..d1944beee8919 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -160,6 +160,21 @@ describe("uiStateStore pure functions", () => { expect(updated.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:35:00.000Z"); }); + it("preserves explicit unread when an active visit timestamp regresses", () => { + const threadId = ThreadId.make("thread-1"); + const active = markActiveThreadVisited(makeUiState(), threadId, "2026-02-25T12:35:00.000Z"); + const unread = markThreadUnread(active, threadId, null); + + const stale = markActiveThreadVisited(unread, threadId, "2026-02-25T12:30:00.000Z"); + + expect(stale).toBe(unread); + expect(stale.threadExplicitlyUnreadById[threadId]).toBe(true); + expect(stale.activeThreadVisit).toEqual({ + threadId, + visitedAt: "2026-02-25T12:35:00.000Z", + }); + }); + it("resolves project expansion from logical, physical, and legacy preference keys", () => { const physicalKey = "environment:/repo/project"; const legacyKey = legacyProjectCwdPreferenceKey("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/repo/project"); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index c02267ff6eff9..f568936742d55 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -312,6 +312,15 @@ export function markActiveThreadVisited( activeThreadVisit: { threadId, visitedAt }, }; } + + const previousVisitedAtMs = Date.parse(state.activeThreadVisit.visitedAt); + const nextVisitedAtMs = Date.parse(visitedAt); + if ( + !Number.isFinite(nextVisitedAtMs) || + (Number.isFinite(previousVisitedAtMs) && nextVisitedAtMs <= previousVisitedAtMs) + ) { + return state; + } } const visitedState = visitedAt === null ? state : markThreadVisited(state, threadId, visitedAt); From ad827b5cd5b9486f96a4eb8771e01f5d405a0071 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Wed, 22 Jul 2026 22:53:16 -0400 Subject: [PATCH 12/30] feat(web): add persistent sidebar thread filters --- apps/web/src/components/Sidebar.logic.test.ts | 99 +++- apps/web/src/components/Sidebar.logic.ts | 87 ++- apps/web/src/components/Sidebar.tsx | 556 ++++++++++++++++-- packages/contracts/src/settings.test.ts | 49 ++ packages/contracts/src/settings.ts | 38 +- 5 files changed, 785 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 55742287e03ec..e104787aa5047 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + classifySidebarThreadFilterStatus, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, @@ -10,8 +11,10 @@ import { getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, + hasActiveSidebarThreadFilters, isContextMenuPointerDown, isTrailingDoubleClick, + matchesSidebarThreadFilters, orderItemsByPreferredIds, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, @@ -36,10 +39,11 @@ import { EnvironmentId, OrchestrationLatestTurn, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; - +import { DEFAULT_SIDEBAR_THREAD_FILTERS } from "@t3tools/contracts/settings"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -99,6 +103,99 @@ describe("shouldNavigateAfterProjectRemoval", () => { }); }); +const filterableThread = { + archivedAt: null, + environmentId: localEnvironmentId, + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default", + latestTurn: null, + session: null, +} as const; + +describe("sidebar thread filters", () => { + it("classifies attention, working, unread, and done as exclusive statuses", () => { + expect( + classifySidebarThreadFilterStatus({ ...filterableThread, hasPendingApprovals: true }), + ).toBe("needs_attention"); + expect( + classifySidebarThreadFilterStatus({ + ...filterableThread, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }, + }), + ).toBe("working"); + expect( + classifySidebarThreadFilterStatus({ + ...filterableThread, + latestTurn: makeLatestTurn(), + lastVisitedAt: "2026-03-09T10:04:00.000Z", + }), + ).toBe("unread"); + expect(classifySidebarThreadFilterStatus(filterableThread)).toBe("done"); + }); + + it("matches status, environment, source, and archived controls", () => { + const archivedThread = { + ...filterableThread, + archivedAt: "2026-03-09T11:00:00.000Z", + }; + expect( + matchesSidebarThreadFilters({ + thread: archivedThread, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: DEFAULT_SIDEBAR_THREAD_FILTERS, + }), + ).toBe(false); + expect( + matchesSidebarThreadFilters({ + thread: archivedThread, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { + statuses: ["done"], + environmentIds: [localEnvironmentId], + sources: [ProviderDriverKind.make("codex")], + includeArchived: true, + }, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: filterableThread, + providerDriverKind: ProviderDriverKind.make("claudeAgent"), + filters: { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + sources: [ProviderDriverKind.make("codex")], + }, + }), + ).toBe(false); + }); + + it("recognizes the default filter state and meaningful deviations", () => { + expect(hasActiveSidebarThreadFilters(DEFAULT_SIDEBAR_THREAD_FILTERS)).toBe(false); + expect( + hasActiveSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + statuses: ["needs_attention", "unread", "working"], + }), + ).toBe(true); + expect( + hasActiveSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + includeArchived: true, + }), + ).toBe(true); + }); +}); + describe("archiveSelectedThreadEntries", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; const success = { _tag: "Success" } as const; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5f7ba0c60853c..7bbd15a20f0bf 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,13 @@ import * as React from "react"; import type { ContextMenuItem } from "@t3tools/contracts"; -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import { + SIDEBAR_THREAD_FILTER_STATUSES, + type SidebarProjectSortOrder, + type SidebarThreadFilters, + type SidebarThreadFilterStatus, + type SidebarThreadSortOrder, +} from "@t3tools/contracts/settings"; +import type { ProviderDriverKind } from "@t3tools/contracts"; import { getThreadSortTimestamp, sortThreads, @@ -235,6 +242,84 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { return completedAt > lastVisitedAt; } +type SidebarThreadFilterInput = Pick< + SidebarThreadSummary, + | "archivedAt" + | "environmentId" + | "hasActionableProposedPlan" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "interactionMode" + | "latestTurn" + | "session" +>; + +export function classifySidebarThreadFilterStatus( + thread: SidebarThreadFilterInput & { readonly lastVisitedAt?: string | undefined }, +): SidebarThreadFilterStatus { + const needsAttention = + thread.hasPendingApprovals || + thread.hasPendingUserInput || + thread.session?.status === "error" || + (thread.interactionMode === "plan" && + isLatestTurnSettled(thread.latestTurn, thread.session) && + thread.hasActionableProposedPlan); + if (needsAttention) { + return "needs_attention"; + } + + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + + if (hasUnseenCompletion(thread)) { + return "unread"; + } + + return "done"; +} + +export function matchesSidebarThreadFilters(input: { + readonly thread: SidebarThreadFilterInput; + readonly lastVisitedAt?: string | null | undefined; + readonly providerDriverKind: ProviderDriverKind | null; + readonly filters: SidebarThreadFilters; +}): boolean { + const { filters, thread } = input; + if (thread.archivedAt !== null && !filters.includeArchived) { + return false; + } + if (filters.environmentIds.length > 0 && !filters.environmentIds.includes(thread.environmentId)) { + return false; + } + if ( + filters.sources.length > 0 && + (input.providerDriverKind === null || !filters.sources.includes(input.providerDriverKind)) + ) { + return false; + } + + const status = classifySidebarThreadFilterStatus({ + ...thread, + ...(input.lastVisitedAt ? { lastVisitedAt: input.lastVisitedAt } : {}), + }); + return filters.statuses.includes(status); +} + +export function hasActiveSidebarThreadFilters(filters: SidebarThreadFilters): boolean { + const selectedStatuses = new Set(filters.statuses); + const includesEveryStatus = SIDEBAR_THREAD_FILTER_STATUSES.every((status) => + selectedStatuses.has(status), + ); + return ( + filters.includeArchived || + filters.environmentIds.length > 0 || + filters.sources.length > 0 || + selectedStatuses.size !== SIDEBAR_THREAD_FILTER_STATUSES.length || + !includesEveryStatus + ); +} + export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { if (target === null) return true; return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e46430072f14d..298e67c954ae9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { ArchiveIcon, + ArchiveRestoreIcon, ArrowUpDownIcon, ChevronRightIcon, CloudIcon, @@ -7,6 +8,7 @@ import { FolderPlusIcon, Globe2Icon, LoaderIcon, + ListFilterIcon, SearchIcon, SquarePenIcon, TerminalIcon, @@ -43,7 +45,10 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { type ContextMenuItem, + type EnvironmentId, ProjectId, + PROVIDER_DISPLAY_NAMES, + type ProviderDriverKind, type ScopedThreadRef, type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, @@ -64,12 +69,17 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { + DEFAULT_SIDEBAR_THREAD_FILTERS, MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, + SIDEBAR_THREAD_FILTER_STATUSES, type SidebarProjectSortOrder, + type SidebarThreadFilters, + type SidebarThreadFilterStatus, type SidebarThreadPreviewCount, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; +import { scopeThreadShell } from "@t3tools/client-runtime/state/models"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; @@ -110,6 +120,7 @@ import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions"; +import { useArchivedThreadSnapshots } from "../lib/archivedThreadsState"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; @@ -145,7 +156,20 @@ import { DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; -import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { + Menu, + MenuCheckboxItem, + MenuGroup, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "./ui/menu"; import { NumberField, NumberFieldDecrement, @@ -173,6 +197,8 @@ import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, + hasActiveSidebarThreadFilters, + matchesSidebarThreadFilters, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, @@ -193,7 +219,12 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom, primaryServerSettingsAtom } from "../state/server"; +import { + primaryServerKeybindingsAtom, + primaryServerProvidersAtom, + primaryServerSettingsAtom, +} from "../state/server"; +import { deriveProviderInstanceEntries } from "../providerInstances"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -216,6 +247,12 @@ const SIDEBAR_THREAD_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", }; +const SIDEBAR_THREAD_FILTER_STATUS_LABELS: Record = { + needs_attention: "Needs attention", + unread: "Unread", + working: "Working", + done: "Done", +}; const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", @@ -338,6 +375,7 @@ interface SidebarThreadRowProps { ) => Promise; cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + attemptUnarchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; } @@ -364,11 +402,13 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr commitRename, cancelRename, attemptArchiveThread, + attemptUnarchiveThread, openPrLink, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); + const isArchived = thread.archivedAt !== null; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isExplicitlyUnread = useUiStateStore( (state) => state.threadExplicitlyUnreadById[threadKey] === true, @@ -488,14 +528,19 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); const handleRowClick = useCallback( (event: React.MouseEvent) => { + if (isArchived) { + event.preventDefault(); + return; + } handleThreadClick(event, threadRef, orderedProjectThreadKeys); }, - [handleThreadClick, orderedProjectThreadKeys, threadRef], + [handleThreadClick, isArchived, orderedProjectThreadKeys, threadRef], ); const handleRowDoubleClick = useCallback( (event: React.MouseEvent) => { // Already renaming this row: a double-click on the row chrome (outside the // input) must not restart and discard the in-progress edit. + if (isArchived) return; if (renamingThreadKey === threadKey) return; // On mobile the first tap navigates and closes the sidebar sheet, so the // inline rename can't be shown. Renaming there stays on the context menu. @@ -508,15 +553,16 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr event.preventDefault(); startThreadRename(threadKey, thread.title); }, - [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], + [isArchived, isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], ); const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); + if (isArchived) return; navigateToThread(threadRef); }, - [navigateToThread, threadRef], + [isArchived, navigateToThread, threadRef], ); const handleRowContextMenu = useCallback( (event: React.MouseEvent) => { @@ -661,6 +707,14 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, [attemptArchiveThread, threadRef], ); + const handleUnarchiveClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptUnarchiveThread(threadRef); + }, + [attemptUnarchiveThread, threadRef], + ); const rowButtonRender = useMemo(() =>
, []); return ( @@ -678,13 +732,14 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr className={`${resolveThreadRowClassName({ isActive, isSelected, - })} relative isolate`} + })} relative isolate ${isArchived ? "text-muted-foreground/65" : ""}`} onClick={handleRowClick} onDoubleClick={handleRowDoubleClick} onKeyDown={handleRowKeyDown} onContextMenu={handleRowContextMenu} >
+ {isArchived ? : null} {prStatus && ( - {isConfirmingArchive ? ( + {isArchived ? ( + + + +
+ } + /> + Unarchive + + ) : isConfirmingArchive ? ( +
+ + + Status + {statusFilterActive ? ( + + ) : null} + + + {SIDEBAR_THREAD_FILTER_STATUSES.map((status) => ( + { + onFiltersChange({ + ...filters, + statuses: toggleSidebarFilterValue(filters.statuses, status, checked), + }); + }} + > + {SIDEBAR_THREAD_FILTER_STATUS_LABELS[status]} + + ))} + + + + + Environment + {filters.environmentIds.length > 0 ? ( + + ) : null} + + + {environments.map((environment) => ( + { + onFiltersChange({ + ...filters, + environmentIds: toggleSidebarFilterValue( + filters.environmentIds, + environment.environmentId, + checked, + ), + }); + }} + > + {environment.label} + + ))} + {environments.length === 0 ? ( + + No environments + + ) : null} + + + + + Source + {filters.sources.length > 0 ? ( + + ) : null} + + + {providerSources.map((source) => ( + { + onFiltersChange({ + ...filters, + sources: toggleSidebarFilterValue(filters.sources, source.driverKind, checked), + }); + }} + > + {source.label} + + ))} + {providerSources.length === 0 ? ( + + No providers + + ) : null} + + + + { + onFiltersChange({ ...filters, includeArchived: checked }); + }} + > + + Archived + {archiveLoading ? : null} + {archiveError ? : null} + + + + + ); +} + function SortableProjectItem({ projectId, disabled = false, @@ -2784,6 +3123,17 @@ interface SidebarProjectsContentProps { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; threadPreviewCount: SidebarThreadPreviewCount; + sidebarThreadFilters: SidebarThreadFilters; + environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>; + providerSources: ReadonlyArray<{ + readonly driverKind: ProviderDriverKind; + readonly label: string; + }>; + providerDriverKindByInstanceId: ReadonlyMap; + archivedThreads: readonly SidebarThreadSummary[]; + archiveLoading: boolean; + archiveError: string | null; + filtersActive: boolean; updateSettings: ReturnType; openAddProject: () => void; isManualProjectSorting: boolean; @@ -2794,6 +3144,7 @@ interface SidebarProjectsContentProps { handleProjectDragCancel: (event: DragCancelEvent) => void; handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; + unarchiveThread: ReturnType["unarchiveThread"]; deleteThread: ReturnType["deleteThread"]; sortedProjects: readonly SidebarProjectSnapshot[]; expandedThreadListsByProject: ReadonlySet; @@ -2824,6 +3175,14 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( projectSortOrder, threadSortOrder, threadPreviewCount, + sidebarThreadFilters, + environments, + providerSources, + providerDriverKindByInstanceId, + archivedThreads, + archiveLoading, + archiveError, + filtersActive, updateSettings, openAddProject, isManualProjectSorting, @@ -2834,6 +3193,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleProjectDragCancel, handleNewThread, archiveThread, + unarchiveThread, deleteThread, sortedProjects, expandedThreadListsByProject, @@ -2858,6 +3218,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleSidebarThreadFiltersChange = useCallback( + (filters: SidebarThreadFilters) => { + updateSettings({ sidebarThreadFilters: filters }); + }, + [updateSettings], + ); const handleThreadSortOrderChange = useCallback( (sortOrder: SidebarThreadSortOrder) => { updateSettings({ sidebarThreadSortOrder: sortOrder }); @@ -2924,6 +3290,14 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
Projects
+ ( )} - {projectsLength === 0 && ( + {sortedProjects.length === 0 && (
- No projects yet + {projectsLength === 0 + ? "No projects yet" + : filtersActive + ? "No threads match these filters" + : "No projects to show"}
)} @@ -3047,9 +3433,10 @@ export default function Sidebar() { const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const sidebarThreadFilters = useClientSettings((s) => s.sidebarThreadFilters); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); + const { archiveThread, unarchiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); const routeTarget = useParams({ strict: false, @@ -3069,6 +3456,7 @@ export default function Sidebar() { : false, ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const serverProviders = useAtomValue(primaryServerProvidersAtom); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), [], @@ -3086,6 +3474,47 @@ export default function Sidebar() { const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); const { environments } = useEnvironments(); + const archiveEnvironmentIds = useMemo( + () => + sidebarThreadFilters.includeArchived + ? environments.map((environment) => environment.environmentId) + : [], + [environments, sidebarThreadFilters.includeArchived], + ); + const { + snapshots: archivedSnapshots, + isLoading: archiveLoading, + error: archiveError, + } = useArchivedThreadSnapshots(archiveEnvironmentIds); + const archivedThreads = useMemo( + () => + archivedSnapshots.flatMap((entry) => + entry.snapshot.threads + .filter((thread) => thread.archivedAt !== null) + .map((thread) => scopeThreadShell(entry.environmentId, thread)), + ), + [archivedSnapshots], + ); + const providerEntries = useMemo( + () => deriveProviderInstanceEntries(serverProviders), + [serverProviders], + ); + const providerDriverKindByInstanceId = useMemo( + () => new Map(providerEntries.map((entry) => [entry.instanceId, entry.driverKind] as const)), + [providerEntries], + ); + const providerSources = useMemo(() => { + const labelsByDriverKind = new Map(); + for (const entry of providerEntries) { + if (!labelsByDriverKind.has(entry.driverKind)) { + labelsByDriverKind.set( + entry.driverKind, + PROVIDER_DISPLAY_NAMES[entry.driverKind] ?? entry.displayName, + ); + } + } + return [...labelsByDriverKind].map(([driverKind, label]) => ({ driverKind, label })); + }, [providerEntries]); const primaryEnvironmentId = usePrimaryEnvironmentId(); const environmentLabelById = useMemo( () => @@ -3166,6 +3595,43 @@ export default function Sidebar() { ), [sidebarThreads], ); + const sidebarThreadsWithArchived = useMemo(() => { + const threadsByKey = new Map( + sidebarThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ); + for (const thread of archivedThreads) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if (!threadsByKey.has(threadKey)) { + threadsByKey.set(threadKey, thread); + } + } + return [...threadsByKey.values()]; + }, [archivedThreads, sidebarThreads]); + const threadLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); + const filtersActive = hasActiveSidebarThreadFilters(sidebarThreadFilters); + const filteredSidebarThreads = useMemo( + () => + sidebarThreadsWithArchived.filter((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const providerInstanceId = + thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + return matchesSidebarThreadFilters({ + thread, + lastVisitedAt: threadLastVisitedAtById[threadKey], + providerDriverKind: providerDriverKindByInstanceId.get(providerInstanceId) ?? null, + filters: sidebarThreadFilters, + }); + }), + [ + providerDriverKindByInstanceId, + sidebarThreadFilters, + sidebarThreadsWithArchived, + threadLastVisitedAtById, + ], + ); // Resolve the active route's project key to a logical key so it matches the // sidebar's grouped project entries. const activeRouteProjectKey = useMemo(() => { @@ -3185,7 +3651,7 @@ export default function Sidebar() { // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map(); - for (const thread of sidebarThreads) { + for (const thread of filteredSidebarThreads) { const physicalKey = projectPhysicalKeyByScopedRef.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), @@ -3199,7 +3665,7 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + }, [filteredSidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -3307,16 +3773,17 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], - ); const sortedProjects = useMemo(() => { - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - const sortableThreads = visibleThreads.map((thread) => { + const sortableProjects = sidebarProjects + .filter( + (project) => + !filtersActive || (threadsByProjectKey.get(project.projectKey)?.length ?? 0) > 0, + ) + .map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = filteredSidebarThreads.map((thread) => { const physicalKey = projectPhysicalKeyByScopedRef.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), @@ -3336,20 +3803,20 @@ export default function Sidebar() { }); }, [ sidebarProjectSortOrder, + filteredSidebarThreads, + filtersActive, physicalToLogicalKey, projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarProjects, - visibleThreads, + threadsByProjectKey, ]); const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { const projectThreads = sortThreads( - (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, - ), + threadsByProjectKey.get(project.projectKey) ?? [], sidebarThreadSortOrder, ); const projectExpanded = resolveProjectExpanded( @@ -3376,9 +3843,9 @@ export default function Sidebar() { ? projectThreads : projectThreads.slice(0, sidebarThreadPreviewCount); const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + return renderedThreads + .filter((thread) => thread.archivedAt === null) + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); }), [ sidebarThreadSortOrder, @@ -3655,6 +4122,14 @@ export default function Sidebar() { projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} threadPreviewCount={sidebarThreadPreviewCount} + sidebarThreadFilters={sidebarThreadFilters} + environments={environments} + providerSources={providerSources} + providerDriverKindByInstanceId={providerDriverKindByInstanceId} + archivedThreads={archivedThreads} + archiveLoading={archiveLoading} + archiveError={archiveError} + filtersActive={filtersActive} updateSettings={updateSettings} openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} @@ -3665,6 +4140,7 @@ export default function Sidebar() { handleProjectDragCancel={handleProjectDragCancel} handleNewThread={handleNewThread} archiveThread={archiveThread} + unarchiveThread={unarchiveThread} deleteThread={deleteThread} sortedProjects={sortedProjects} expandedThreadListsByProject={expandedThreadListsByProject} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e8eaf723e174d..4d50dcbbe31aa 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, ClientSettingsPatch, + DEFAULT_SIDEBAR_THREAD_FILTERS, DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch, @@ -68,6 +69,54 @@ describe("ClientSettings sidebar v2", () => { }); }); +describe("ClientSettings sidebar thread filters", () => { + it("defaults to showing every active thread", () => { + expect(decodeClientSettings({}).sidebarThreadFilters).toEqual(DEFAULT_SIDEBAR_THREAD_FILTERS); + expect(DEFAULT_SIDEBAR_THREAD_FILTERS).toEqual({ + statuses: ["needs_attention", "unread", "working", "done"], + environmentIds: [], + sources: [], + includeArchived: false, + }); + }); + + it("hydrates missing nested fields and accepts a complete filter patch", () => { + expect( + decodeClientSettings({ sidebarThreadFilters: { includeArchived: true } }) + .sidebarThreadFilters, + ).toEqual({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + includeArchived: true, + }); + + expect( + decodeClientSettingsPatch({ + sidebarThreadFilters: { + statuses: ["unread"], + environmentIds: ["environment-local"], + sources: ["codex"], + includeArchived: true, + }, + }).sidebarThreadFilters, + ).toEqual({ + statuses: ["unread"], + environmentIds: ["environment-local"], + sources: ["codex"], + includeArchived: true, + }); + }); + + it("rejects unknown status values", () => { + expect(() => + decodeClientSettingsPatch({ + sidebarThreadFilters: { + statuses: ["paused"], + }, + }), + ).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67ab..c6da538078223 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,10 +2,14 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { EnvironmentId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; -import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; +import { + ProviderDriverKind, + ProviderInstanceConfig, + ProviderInstanceId, +} from "./providerInstance.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -21,6 +25,32 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const SidebarThreadFilterStatus = Schema.Literals([ + "needs_attention", + "unread", + "working", + "done", +]); +export type SidebarThreadFilterStatus = typeof SidebarThreadFilterStatus.Type; +export const SIDEBAR_THREAD_FILTER_STATUSES: readonly SidebarThreadFilterStatus[] = [ + "needs_attention", + "unread", + "working", + "done", +]; +export const SidebarThreadFilters = Schema.Struct({ + statuses: Schema.Array(SidebarThreadFilterStatus).pipe( + Schema.withDecodingDefault(Effect.succeed([...SIDEBAR_THREAD_FILTER_STATUSES])), + ), + environmentIds: Schema.Array(EnvironmentId).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + sources: Schema.Array(ProviderDriverKind).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + includeArchived: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), +}); +export type SidebarThreadFilters = typeof SidebarThreadFilters.Type; +export const DEFAULT_SIDEBAR_THREAD_FILTERS: SidebarThreadFilters = Schema.decodeSync( + SidebarThreadFilters, +)({}); + export const SidebarProjectGroupingMode = Schema.Literals([ "repository", "repository_path", @@ -111,6 +141,9 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), + sidebarThreadFilters: SidebarThreadFilters.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_FILTERS)), + ), sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), @@ -602,6 +635,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), + sidebarThreadFilters: Schema.optionalKey(SidebarThreadFilters), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), From d9584e4ee05ccde763f12badfcfef9feb1267adb Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Wed, 22 Jul 2026 23:20:36 -0400 Subject: [PATCH 13/30] fix(web): clarify unchecked sidebar filters --- apps/web/src/components/ui/menu.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index c2b130e51ac9a..16deb307bded3 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -103,7 +103,9 @@ function MenuCheckboxItem({ checked={checked} className={cn( "grid min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-default items-center gap-2 rounded-sm py-1 ps-2 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", - variant === "switch" ? "grid-cols-[1fr_auto] gap-4 pe-1.5" : "grid-cols-[1rem_1fr] pe-4", + variant === "switch" + ? "grid-cols-[1fr_auto] gap-4 pe-1.5" + : "grid-cols-[minmax(0,1fr)_1rem] ps-3 pe-2", className, )} data-slot="menu-checkbox-item" @@ -121,7 +123,8 @@ function MenuCheckboxItem({ ) : ( <> - + {children} + - {children} )} From aaaaf32a39d1ea0f34552577a8786f55a3a9de02 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 00:21:32 -0400 Subject: [PATCH 14/30] feat(web): add recent and attention sidebar filters --- apps/web/src/components/Sidebar.logic.test.ts | 89 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 22 +++++ apps/web/src/components/Sidebar.tsx | 29 +++++- packages/contracts/src/settings.test.ts | 6 ++ packages/contracts/src/settings.ts | 2 + 5 files changed, 147 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e104787aa5047..598a4c37c8228 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -105,13 +105,16 @@ describe("shouldNavigateAfterProjectRemoval", () => { const filterableThread = { archivedAt: null, + createdAt: "2026-03-09T09:00:00.000Z", environmentId: localEnvironmentId, hasActionableProposedPlan: false, hasPendingApprovals: false, hasPendingUserInput: false, interactionMode: "default", + latestUserMessageAt: "2026-03-09T10:00:00.000Z", latestTurn: null, session: null, + updatedAt: "2026-03-09T10:00:00.000Z", } as const; describe("sidebar thread filters", () => { @@ -163,6 +166,8 @@ describe("sidebar thread filters", () => { statuses: ["done"], environmentIds: [localEnvironmentId], sources: [ProviderDriverKind.make("codex")], + recentOnly: false, + attentionOnly: false, includeArchived: true, }, }), @@ -179,6 +184,78 @@ describe("sidebar thread filters", () => { ).toBe(false); }); + it("matches the attention quick filter as unread OR needs attention", () => { + const filters = { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + attentionOnly: true, + }; + expect( + matchesSidebarThreadFilters({ + thread: { ...filterableThread, hasPendingUserInput: true }, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: { + ...filterableThread, + latestTurn: makeLatestTurn(), + }, + lastVisitedAt: "2026-03-09T10:04:00.000Z", + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: filterableThread, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + }), + ).toBe(false); + }); + + it("matches recent activity within the last seven days", () => { + const filters = { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + recentOnly: true, + }; + const nowMs = Date.parse("2026-03-10T10:00:00.000Z"); + expect( + matchesSidebarThreadFilters({ + thread: filterableThread, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + nowMs, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: { + ...filterableThread, + latestUserMessageAt: null, + updatedAt: "2026-03-03T09:59:59.999Z", + }, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + nowMs, + }), + ).toBe(false); + expect( + matchesSidebarThreadFilters({ + thread: { + ...filterableThread, + latestUserMessageAt: null, + updatedAt: "not-a-date", + }, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + nowMs, + }), + ).toBe(false); + }); + it("recognizes the default filter state and meaningful deviations", () => { expect(hasActiveSidebarThreadFilters(DEFAULT_SIDEBAR_THREAD_FILTERS)).toBe(false); expect( @@ -193,6 +270,18 @@ describe("sidebar thread filters", () => { includeArchived: true, }), ).toBe(true); + expect( + hasActiveSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + attentionOnly: true, + }), + ).toBe(true); + expect( + hasActiveSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + recentOnly: true, + }), + ).toBe(true); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7bbd15a20f0bf..ef927584bdbc7 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -245,15 +245,21 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { type SidebarThreadFilterInput = Pick< SidebarThreadSummary, | "archivedAt" + | "createdAt" | "environmentId" | "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" | "interactionMode" + | "latestUserMessageAt" | "latestTurn" | "session" + | "updatedAt" >; +export const SIDEBAR_RECENT_WINDOW_DAYS = 7; +const SIDEBAR_RECENT_WINDOW_MS = SIDEBAR_RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1_000; + export function classifySidebarThreadFilterStatus( thread: SidebarThreadFilterInput & { readonly lastVisitedAt?: string | undefined }, ): SidebarThreadFilterStatus { @@ -284,6 +290,7 @@ export function matchesSidebarThreadFilters(input: { readonly lastVisitedAt?: string | null | undefined; readonly providerDriverKind: ProviderDriverKind | null; readonly filters: SidebarThreadFilters; + readonly nowMs?: number | undefined; }): boolean { const { filters, thread } = input; if (thread.archivedAt !== null && !filters.includeArchived) { @@ -303,6 +310,19 @@ export function matchesSidebarThreadFilters(input: { ...thread, ...(input.lastVisitedAt ? { lastVisitedAt: input.lastVisitedAt } : {}), }); + if (filters.attentionOnly && status !== "needs_attention" && status !== "unread") { + return false; + } + if (filters.recentOnly) { + const recentTimestamp = thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt; + const recentTimestampMs = Date.parse(recentTimestamp); + if ( + Number.isNaN(recentTimestampMs) || + recentTimestampMs < (input.nowMs ?? Date.now()) - SIDEBAR_RECENT_WINDOW_MS + ) { + return false; + } + } return filters.statuses.includes(status); } @@ -312,6 +332,8 @@ export function hasActiveSidebarThreadFilters(filters: SidebarThreadFilters): bo selectedStatuses.has(status), ); return ( + filters.attentionOnly || + filters.recentOnly || filters.includeArchived || filters.environmentIds.length > 0 || filters.sources.length > 0 || diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 298e67c954ae9..2a7ebcd045eff 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2951,7 +2951,7 @@ function SidebarFilterMenu({ {filtersActive ? "Sidebar filters are active" : "Filter sidebar threads"} - +
Filters
+ { + onFiltersChange({ ...filters, recentOnly: checked }); + }} + > + + Recent + Last 7 days + + + { + onFiltersChange({ ...filters, attentionOnly: checked }); + }} + > + + Attention + Unread + needs attention + + + Status diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 4d50dcbbe31aa..c4b6fe612c552 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -76,6 +76,8 @@ describe("ClientSettings sidebar thread filters", () => { statuses: ["needs_attention", "unread", "working", "done"], environmentIds: [], sources: [], + recentOnly: false, + attentionOnly: false, includeArchived: false, }); }); @@ -95,6 +97,8 @@ describe("ClientSettings sidebar thread filters", () => { statuses: ["unread"], environmentIds: ["environment-local"], sources: ["codex"], + recentOnly: true, + attentionOnly: true, includeArchived: true, }, }).sidebarThreadFilters, @@ -102,6 +106,8 @@ describe("ClientSettings sidebar thread filters", () => { statuses: ["unread"], environmentIds: ["environment-local"], sources: ["codex"], + recentOnly: true, + attentionOnly: true, includeArchived: true, }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c6da538078223..03db84f5e1c8a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -44,6 +44,8 @@ export const SidebarThreadFilters = Schema.Struct({ ), environmentIds: Schema.Array(EnvironmentId).pipe(Schema.withDecodingDefault(Effect.succeed([]))), sources: Schema.Array(ProviderDriverKind).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + recentOnly: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + attentionOnly: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), includeArchived: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), }); export type SidebarThreadFilters = typeof SidebarThreadFilters.Type; From 9d1e838f39eb217a9f83b07b6d83f179164f45ed Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 01:38:48 -0400 Subject: [PATCH 15/30] fix(web): tighten sidebar filter menu --- apps/web/src/components/Sidebar.logic.test.ts | 14 ++++++++++ apps/web/src/components/Sidebar.logic.ts | 10 ++++++- apps/web/src/components/Sidebar.tsx | 26 +++++++++++-------- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 598a4c37c8228..c3ccf3843b12c 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -143,6 +143,12 @@ describe("sidebar thread filters", () => { lastVisitedAt: "2026-03-09T10:04:00.000Z", }), ).toBe("unread"); + expect( + classifySidebarThreadFilterStatus({ + ...filterableThread, + isExplicitlyUnread: true, + }), + ).toBe("unread"); expect(classifySidebarThreadFilterStatus(filterableThread)).toBe("done"); }); @@ -207,6 +213,14 @@ describe("sidebar thread filters", () => { filters, }), ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: filterableThread, + isExplicitlyUnread: true, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + }), + ).toBe(true); expect( matchesSidebarThreadFilters({ thread: filterableThread, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ef927584bdbc7..8481e88f35001 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -255,7 +255,9 @@ type SidebarThreadFilterInput = Pick< | "latestTurn" | "session" | "updatedAt" ->; +> & { + readonly isExplicitlyUnread?: boolean | undefined; +}; export const SIDEBAR_RECENT_WINDOW_DAYS = 7; const SIDEBAR_RECENT_WINDOW_MS = SIDEBAR_RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1_000; @@ -263,6 +265,10 @@ const SIDEBAR_RECENT_WINDOW_MS = SIDEBAR_RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1_0 export function classifySidebarThreadFilterStatus( thread: SidebarThreadFilterInput & { readonly lastVisitedAt?: string | undefined }, ): SidebarThreadFilterStatus { + if (thread.isExplicitlyUnread) { + return "unread"; + } + const needsAttention = thread.hasPendingApprovals || thread.hasPendingUserInput || @@ -288,6 +294,7 @@ export function classifySidebarThreadFilterStatus( export function matchesSidebarThreadFilters(input: { readonly thread: SidebarThreadFilterInput; readonly lastVisitedAt?: string | null | undefined; + readonly isExplicitlyUnread?: boolean | undefined; readonly providerDriverKind: ProviderDriverKind | null; readonly filters: SidebarThreadFilters; readonly nowMs?: number | undefined; @@ -309,6 +316,7 @@ export function matchesSidebarThreadFilters(input: { const status = classifySidebarThreadFilterStatus({ ...thread, ...(input.lastVisitedAt ? { lastVisitedAt: input.lastVisitedAt } : {}), + ...(input.isExplicitlyUnread ? { isExplicitlyUnread: true } : {}), }); if (filters.attentionOnly && status !== "needs_attention" && status !== "unread") { return false; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2a7ebcd045eff..e7d94faa2841c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1396,6 +1396,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return matchesSidebarThreadFilters({ thread, lastVisitedAt: lastVisitedAtByThreadKey.get(threadKey), + isExplicitlyUnread: explicitlyUnreadByThreadKey.get(threadKey), providerDriverKind: providerDriverKindByInstanceId.get(providerInstanceId) ?? null, filters: sidebarThreadFilters, }); @@ -2951,8 +2952,8 @@ function SidebarFilterMenu({ {filtersActive ? "Sidebar filters are active" : "Filter sidebar threads"} - -
+ +
Filters +
Archived {archiveLoading ? : null} - {archiveError ? : null} + {archiveError ? ( + + + + ) : null}
@@ -3157,7 +3171,7 @@ interface SidebarProjectsContentProps { readonly driverKind: ProviderDriverKind; readonly label: string; }>; - providerDriverKindByInstanceId: ReadonlyMap; + providerDriverKindByScopedInstanceKey: ReadonlyMap; archivedThreads: readonly SidebarThreadSummary[]; archiveLoading: boolean; archiveError: string | null; @@ -3206,7 +3220,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( sidebarThreadFilters, environments, providerSources, - providerDriverKindByInstanceId, + providerDriverKindByScopedInstanceKey, archivedThreads, archiveLoading, archiveError, @@ -3239,6 +3253,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( attachProjectListAutoAnimateRef, projectsLength, } = props; + const clearThreadSelection = useThreadSelectionStore((state) => state.clearSelection); const handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { @@ -3248,9 +3263,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( ); const handleSidebarThreadFiltersChange = useCallback( (filters: SidebarThreadFilters) => { + clearThreadSelection(); updateSettings({ sidebarThreadFilters: filters }); }, - [updateSettings], + [clearThreadSelection, updateSettings], ); const handleThreadSortOrderChange = useCallback( (sortOrder: SidebarThreadSortOrder) => { @@ -3374,7 +3390,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( project={project} archivedThreads={archivedThreads} sidebarThreadFilters={sidebarThreadFilters} - providerDriverKindByInstanceId={providerDriverKindByInstanceId} + providerDriverKindByScopedInstanceKey={ + providerDriverKindByScopedInstanceKey + } isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)} activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null @@ -3410,7 +3428,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( project={project} archivedThreads={archivedThreads} sidebarThreadFilters={sidebarThreadFilters} - providerDriverKindByInstanceId={providerDriverKindByInstanceId} + providerDriverKindByScopedInstanceKey={providerDriverKindByScopedInstanceKey} isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)} activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null @@ -3484,7 +3502,6 @@ export default function Sidebar() { : false, ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const serverProviders = useAtomValue(primaryServerProvidersAtom); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), [], @@ -3504,10 +3521,12 @@ export default function Sidebar() { const { environments } = useEnvironments(); const archiveEnvironmentIds = useMemo( () => - sidebarThreadFilters.includeArchived - ? environments.map((environment) => environment.environmentId) - : [], - [environments, sidebarThreadFilters.includeArchived], + resolveSidebarArchiveEnvironmentIds({ + availableEnvironmentIds: environments.map((environment) => environment.environmentId), + selectedEnvironmentIds: sidebarThreadFilters.environmentIds, + includeArchived: sidebarThreadFilters.includeArchived, + }), + [environments, sidebarThreadFilters.environmentIds, sidebarThreadFilters.includeArchived], ); const { snapshots: archivedSnapshots, @@ -3524,11 +3543,26 @@ export default function Sidebar() { [archivedSnapshots], ); const providerEntries = useMemo( - () => deriveProviderInstanceEntries(serverProviders), - [serverProviders], + () => + environments.flatMap((environment) => + deriveProviderInstanceEntries(environment.serverConfig?.providers ?? []).map((entry) => ({ + ...entry, + environmentId: environment.environmentId, + })), + ), + [environments], ); - const providerDriverKindByInstanceId = useMemo( - () => new Map(providerEntries.map((entry) => [entry.instanceId, entry.driverKind] as const)), + const providerDriverKindByScopedInstanceKey = useMemo( + () => + new Map( + providerEntries.map( + (entry) => + [ + sidebarProviderInstanceKey(entry.environmentId, entry.instanceId), + entry.driverKind, + ] as const, + ), + ), [providerEntries], ); const providerSources = useMemo(() => { @@ -3651,12 +3685,15 @@ export default function Sidebar() { thread, lastVisitedAt: threadLastVisitedAtById[threadKey], isExplicitlyUnread: threadExplicitlyUnreadById[threadKey], - providerDriverKind: providerDriverKindByInstanceId.get(providerInstanceId) ?? null, + providerDriverKind: + providerDriverKindByScopedInstanceKey.get( + sidebarProviderInstanceKey(thread.environmentId, providerInstanceId), + ) ?? null, filters: sidebarThreadFilters, }); }), [ - providerDriverKindByInstanceId, + providerDriverKindByScopedInstanceKey, sidebarThreadFilters, sidebarThreadsWithArchived, threadExplicitlyUnreadById, @@ -4156,7 +4193,7 @@ export default function Sidebar() { sidebarThreadFilters={sidebarThreadFilters} environments={environments} providerSources={providerSources} - providerDriverKindByInstanceId={providerDriverKindByInstanceId} + providerDriverKindByScopedInstanceKey={providerDriverKindByScopedInstanceKey} archivedThreads={archivedThreads} archiveLoading={archiveLoading} archiveError={archiveError} From e3f7fb89d86cb152f775972da02e9a542172a79a Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 17:30:53 -0400 Subject: [PATCH 17/30] fix(web): preserve active thread filter status --- apps/web/src/components/Sidebar.logic.test.ts | 37 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 17 +++++---- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b8d69cddab744..bef6465b07789 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -165,6 +165,24 @@ describe("sidebar thread filters", () => { ).toBe("needs_attention"); }); + it("keeps working status when a live thread is also explicitly unread", () => { + expect( + classifySidebarThreadFilterStatus({ + ...filterableThread, + isExplicitlyUnread: true, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }, + }), + ).toBe("working"); + }); + it("matches status, environment, source, and archived controls", () => { const archivedThread = { ...filterableThread, @@ -215,6 +233,25 @@ describe("sidebar thread filters", () => { filters, }), ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: { + ...filterableThread, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }, + }, + isExplicitlyUnread: true, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + }), + ).toBe(true); expect( matchesSidebarThreadFilters({ thread: { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4dad371dd58e6..c10aa64e91e01 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -280,14 +280,14 @@ export function classifySidebarThreadFilterStatus( return "needs_attention"; } - if (thread.isExplicitlyUnread) { - return "unread"; - } - if (thread.session?.status === "running" || thread.session?.status === "starting") { return "working"; } + if (thread.isExplicitlyUnread) { + return "unread"; + } + if (hasUnseenCompletion(thread)) { return "unread"; } @@ -317,12 +317,15 @@ export function matchesSidebarThreadFilters(input: { return false; } - const status = classifySidebarThreadFilterStatus({ + const classifiedThread = { ...thread, ...(input.lastVisitedAt ? { lastVisitedAt: input.lastVisitedAt } : {}), ...(input.isExplicitlyUnread ? { isExplicitlyUnread: true } : {}), - }); - if (filters.attentionOnly && status !== "needs_attention" && status !== "unread") { + }; + const status = classifySidebarThreadFilterStatus(classifiedThread); + const isUnread = + classifiedThread.isExplicitlyUnread === true || hasUnseenCompletion(classifiedThread); + if (filters.attentionOnly && status !== "needs_attention" && !isUnread) { return false; } if (filters.recentOnly) { From e5df6b17f18c13b0f99071ee6ecdf04de3855031 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 17:30:54 -0400 Subject: [PATCH 18/30] fix(web): remove dead focus from archived rows --- apps/web/src/components/Sidebar.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index be3d651b025c4..783603a21dad2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -715,7 +715,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, [attemptUnarchiveThread, threadRef], ); - const rowButtonRender = useMemo(() =>
, []); + const rowButtonRender = useMemo( + () => (isArchived ?
:
), + [isArchived], + ); return ( Date: Thu, 23 Jul 2026 18:15:36 -0400 Subject: [PATCH 19/30] fix(web): harden sidebar filter semantics --- apps/web/src/components/Sidebar.logic.test.ts | 34 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 32 ++++++++++++++--- apps/web/src/components/Sidebar.tsx | 6 ++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bef6465b07789..af5b41d0e1859 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -13,6 +13,7 @@ import { getProjectSortTimestamp, hasUnseenCompletion, hasActiveSidebarThreadFilters, + hasNarrowingSidebarThreadFilters, isContextMenuPointerDown, isTrailingDoubleClick, matchesSidebarThreadFilters, @@ -300,6 +301,7 @@ describe("sidebar thread filters", () => { ...filterableThread, latestUserMessageAt: null, updatedAt: "2026-03-03T09:59:59.999Z", + createdAt: "2026-03-03T09:00:00.000Z", }, providerDriverKind: ProviderDriverKind.make("codex"), filters, @@ -312,16 +314,42 @@ describe("sidebar thread filters", () => { ...filterableThread, latestUserMessageAt: null, updatedAt: "not-a-date", + createdAt: "2026-03-03T09:00:00.000Z", }, providerDriverKind: ProviderDriverKind.make("codex"), filters, nowMs, }), ).toBe(false); + expect( + matchesSidebarThreadFilters({ + thread: { + ...filterableThread, + latestUserMessageAt: "2026-03-01T10:00:00.000Z", + updatedAt: "2026-03-10T09:00:00.000Z", + }, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + nowMs, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: { + ...filterableThread, + latestUserMessageAt: "not-a-date", + updatedAt: "2026-03-10T09:00:00.000Z", + }, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + nowMs, + }), + ).toBe(true); }); it("recognizes the default filter state and meaningful deviations", () => { expect(hasActiveSidebarThreadFilters(DEFAULT_SIDEBAR_THREAD_FILTERS)).toBe(false); + expect(hasNarrowingSidebarThreadFilters(DEFAULT_SIDEBAR_THREAD_FILTERS)).toBe(false); expect( hasActiveSidebarThreadFilters({ ...DEFAULT_SIDEBAR_THREAD_FILTERS, @@ -334,6 +362,12 @@ describe("sidebar thread filters", () => { includeArchived: true, }), ).toBe(true); + expect( + hasNarrowingSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + includeArchived: true, + }), + ).toBe(false); expect( hasActiveSidebarThreadFilters({ ...DEFAULT_SIDEBAR_THREAD_FILTERS, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index c10aa64e91e01..193cf21fe8c83 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -266,6 +266,22 @@ type SidebarThreadFilterInput = Pick< export const SIDEBAR_RECENT_WINDOW_DAYS = 7; const SIDEBAR_RECENT_WINDOW_MS = SIDEBAR_RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1_000; +function latestValidTimestampMs(...timestamps: ReadonlyArray): number | null { + let latestTimestampMs: number | null = null; + for (const timestamp of timestamps) { + if (timestamp === null) { + continue; + } + const timestampMs = Date.parse(timestamp); + if (Number.isNaN(timestampMs)) { + continue; + } + latestTimestampMs = + latestTimestampMs === null ? timestampMs : Math.max(latestTimestampMs, timestampMs); + } + return latestTimestampMs; +} + export function classifySidebarThreadFilterStatus( thread: SidebarThreadFilterInput & { readonly lastVisitedAt?: string | undefined }, ): SidebarThreadFilterStatus { @@ -329,10 +345,13 @@ export function matchesSidebarThreadFilters(input: { return false; } if (filters.recentOnly) { - const recentTimestamp = thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt; - const recentTimestampMs = Date.parse(recentTimestamp); + const recentTimestampMs = latestValidTimestampMs( + thread.latestUserMessageAt, + thread.updatedAt, + thread.createdAt, + ); if ( - Number.isNaN(recentTimestampMs) || + recentTimestampMs === null || recentTimestampMs < (input.nowMs ?? Date.now()) - SIDEBAR_RECENT_WINDOW_MS ) { return false; @@ -341,7 +360,7 @@ export function matchesSidebarThreadFilters(input: { return filters.statuses.includes(status); } -export function hasActiveSidebarThreadFilters(filters: SidebarThreadFilters): boolean { +export function hasNarrowingSidebarThreadFilters(filters: SidebarThreadFilters): boolean { const selectedStatuses = new Set(filters.statuses); const includesEveryStatus = SIDEBAR_THREAD_FILTER_STATUSES.every((status) => selectedStatuses.has(status), @@ -349,7 +368,6 @@ export function hasActiveSidebarThreadFilters(filters: SidebarThreadFilters): bo return ( filters.attentionOnly || filters.recentOnly || - filters.includeArchived || filters.environmentIds.length > 0 || filters.sources.length > 0 || selectedStatuses.size !== SIDEBAR_THREAD_FILTER_STATUSES.length || @@ -357,6 +375,10 @@ export function hasActiveSidebarThreadFilters(filters: SidebarThreadFilters): bo ); } +export function hasActiveSidebarThreadFilters(filters: SidebarThreadFilters): boolean { + return filters.includeArchived || hasNarrowingSidebarThreadFilters(filters); +} + export function sidebarProviderInstanceKey( environmentId: EnvironmentId, instanceId: ProviderInstanceId, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 783603a21dad2..9b0e260954a77 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -199,6 +199,7 @@ import { getSidebarRangeSelectionThreadKeys, getSidebarThreadIdsToPrewarm, hasActiveSidebarThreadFilters, + hasNarrowingSidebarThreadFilters, matchesSidebarThreadFilters, resolveAdjacentThreadId, isContextMenuPointerDown, @@ -3682,6 +3683,7 @@ export default function Sidebar() { const threadLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); const threadExplicitlyUnreadById = useUiStateStore((state) => state.threadExplicitlyUnreadById); const filtersActive = hasActiveSidebarThreadFilters(sidebarThreadFilters); + const narrowingFiltersActive = hasNarrowingSidebarThreadFilters(sidebarThreadFilters); const filteredSidebarThreads = useMemo( () => sidebarThreadsWithArchived.filter((thread) => { @@ -3852,7 +3854,7 @@ export default function Sidebar() { const sortableProjects = sidebarProjects .filter( (project) => - !filtersActive || (threadsByProjectKey.get(project.projectKey)?.length ?? 0) > 0, + !narrowingFiltersActive || (threadsByProjectKey.get(project.projectKey)?.length ?? 0) > 0, ) .map((project) => ({ ...project, @@ -3879,7 +3881,7 @@ export default function Sidebar() { }, [ sidebarProjectSortOrder, filteredSidebarThreads, - filtersActive, + narrowingFiltersActive, physicalToLogicalKey, projectPhysicalKeyByScopedRef, sidebarProjectByKey, From f6f8951b6f5b405d235d67b2f21fca1f158e46fa Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 18:28:41 -0400 Subject: [PATCH 20/30] fix(web): keep archived rows non-navigable --- apps/web/src/components/Sidebar.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9b0e260954a77..dcb5bf2e1160f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -463,6 +463,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const isHighlighted = isActive || isSelected; const handleOpenDiscoveredPort = useCallback( (event: React.MouseEvent) => { + if (isArchived) { + event.preventDefault(); + event.stopPropagation(); + return; + } const port = discoveredPorts[0]; if (!port) return; event.preventDefault(); @@ -484,7 +489,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); })(); }, - [discoveredPorts, navigateToThread, openPreview, threadRef], + [discoveredPorts, isArchived, navigateToThread, openPreview, threadRef], ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -798,7 +803,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )}
- {discoveredPorts.length > 0 && ( + {!isArchived && discoveredPorts.length > 0 && ( Date: Thu, 23 Jul 2026 18:46:26 -0400 Subject: [PATCH 21/30] fix(web): include turn activity in recent filter --- apps/web/src/components/Sidebar.logic.test.ts | 63 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 3 + 2 files changed, 66 insertions(+) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index af5b41d0e1859..fcdb134d9e8b6 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -347,6 +347,69 @@ describe("sidebar thread filters", () => { ).toBe(true); }); + it("counts activity recorded only on the latest turn as recent", () => { + const filters = { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + recentOnly: true, + }; + const nowMs = Date.parse("2026-03-10T10:00:00.000Z"); + const recentTimestamp = "2026-03-10T09:00:00.000Z"; + const olderTurn = { + ...makeLatestTurn({ + completedAt: "2026-03-01T10:05:00.000Z", + startedAt: "2026-03-01T10:00:00.000Z", + }), + requestedAt: "2026-03-01T09:59:00.000Z", + }; + const olderThread = { + ...filterableThread, + latestUserMessageAt: null, + updatedAt: "2026-03-01T10:00:00.000Z", + createdAt: "2026-03-01T09:00:00.000Z", + }; + + for (const latestTurn of [ + { ...olderTurn, requestedAt: recentTimestamp }, + { ...olderTurn, startedAt: recentTimestamp }, + { ...olderTurn, completedAt: recentTimestamp }, + ]) { + expect( + matchesSidebarThreadFilters({ + thread: { ...olderThread, latestTurn }, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + nowMs, + }), + ).toBe(true); + } + }); + + it("ignores malformed and older latest-turn timestamps for recent filtering", () => { + expect( + matchesSidebarThreadFilters({ + thread: { + ...filterableThread, + latestUserMessageAt: null, + latestTurn: { + ...makeLatestTurn({ + completedAt: "not-a-date", + startedAt: "2026-03-01T10:00:00.000Z", + }), + requestedAt: "not-a-date", + }, + updatedAt: "2026-03-01T10:00:00.000Z", + createdAt: "2026-03-01T09:00:00.000Z", + }, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + recentOnly: true, + }, + nowMs: Date.parse("2026-03-10T10:00:00.000Z"), + }), + ).toBe(false); + }); + it("recognizes the default filter state and meaningful deviations", () => { expect(hasActiveSidebarThreadFilters(DEFAULT_SIDEBAR_THREAD_FILTERS)).toBe(false); expect(hasNarrowingSidebarThreadFilters(DEFAULT_SIDEBAR_THREAD_FILTERS)).toBe(false); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 193cf21fe8c83..1a5a602395313 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -347,6 +347,9 @@ export function matchesSidebarThreadFilters(input: { if (filters.recentOnly) { const recentTimestampMs = latestValidTimestampMs( thread.latestUserMessageAt, + thread.latestTurn?.requestedAt ?? null, + thread.latestTurn?.startedAt ?? null, + thread.latestTurn?.completedAt ?? null, thread.updatedAt, thread.createdAt, ); From 1fca098f9f1060b63c93d23cc39ec17c0c107617 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 19:09:33 -0400 Subject: [PATCH 22/30] fix(web): match unread as secondary filter status --- apps/web/src/components/Sidebar.logic.test.ts | 100 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 2 +- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fcdb134d9e8b6..1d23f3643b24e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -281,6 +281,106 @@ describe("sidebar thread filters", () => { ).toBe(false); }); + it("matches unread live threads through either their live or unread status facet", () => { + const workingThread = { + ...filterableThread, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }, + } as const; + + expect( + matchesSidebarThreadFilters({ + thread: workingThread, + isExplicitlyUnread: true, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { ...DEFAULT_SIDEBAR_THREAD_FILTERS, statuses: ["unread"] }, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: workingThread, + isExplicitlyUnread: true, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { ...DEFAULT_SIDEBAR_THREAD_FILTERS, statuses: ["working"] }, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: workingThread, + isExplicitlyUnread: true, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { ...DEFAULT_SIDEBAR_THREAD_FILTERS, statuses: ["done"] }, + }), + ).toBe(false); + expect( + matchesSidebarThreadFilters({ + thread: { ...workingThread, latestTurn: makeLatestTurn() }, + lastVisitedAt: "2026-03-09T10:04:00.000Z", + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { ...DEFAULT_SIDEBAR_THREAD_FILTERS, statuses: ["unread"] }, + }), + ).toBe(true); + }); + + it("composes the attention quick filter with live and unread status facets", () => { + const workingThread = { + ...filterableThread, + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }, + } as const; + + for (const statuses of [["working"], ["unread"]] as const) { + expect( + matchesSidebarThreadFilters({ + thread: workingThread, + isExplicitlyUnread: true, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + attentionOnly: true, + statuses: [...statuses], + }, + }), + ).toBe(true); + } + expect( + matchesSidebarThreadFilters({ + thread: workingThread, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + attentionOnly: true, + statuses: ["working"], + }, + }), + ).toBe(false); + expect( + matchesSidebarThreadFilters({ + thread: { ...workingThread, hasPendingUserInput: true }, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + attentionOnly: true, + statuses: ["needs_attention"], + }, + }), + ).toBe(true); + }); + it("matches recent activity within the last seven days", () => { const filters = { ...DEFAULT_SIDEBAR_THREAD_FILTERS, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1a5a602395313..5c0491414d325 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -360,7 +360,7 @@ export function matchesSidebarThreadFilters(input: { return false; } } - return filters.statuses.includes(status); + return filters.statuses.includes(status) || (isUnread && filters.statuses.includes("unread")); } export function hasNarrowingSidebarThreadFilters(filters: SidebarThreadFilters): boolean { From cfb698cfe0cd684789ac68e7a40534320f6fb5c1 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 19:52:08 -0400 Subject: [PATCH 23/30] fix(web): keep sidebar filters current and navigable --- apps/web/src/components/Sidebar.logic.test.ts | 120 +++++++++++- apps/web/src/components/Sidebar.logic.ts | 79 +++++++- apps/web/src/components/Sidebar.tsx | 183 ++++++++++-------- ...arkFirstSeenCompletedThreadsUnread.test.ts | 50 +++++ .../useMarkFirstSeenCompletedThreadsUnread.ts | 26 ++- apps/web/src/lib/archivedThreadsState.ts | 16 +- .../firstSeenCompletedThreadObservations.ts | 52 +++++ 7 files changed, 429 insertions(+), 97 deletions(-) create mode 100644 apps/web/src/lib/firstSeenCompletedThreadObservations.ts diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 1d23f3643b24e..b17cf8e8c4f43 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -4,6 +4,7 @@ import { buildMultiSelectThreadContextMenuItems, classifySidebarThreadFilterStatus, createThreadJumpHintVisibilityController, + filterSidebarThreadsForActiveRoute, getSidebarRangeSelectionThreadKeys, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, @@ -19,10 +20,12 @@ import { matchesSidebarThreadFilters, orderItemsByPreferredIds, resolveProjectStatusIndicator, + resolvePinnedCollapsedSidebarThread, resolveSidebarArchiveEnvironmentIds, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, + resolveSidebarThreadFilterStatuses, resolveThreadRowClassName, resolveSidebarV2Status, resolveSidebarV2TopStatus, @@ -37,6 +40,8 @@ import { sortProjectsForSidebar, sortScopedProjectsForSidebar, sidebarProviderInstanceKey, + SIDEBAR_RECENT_FILTER_REFRESH_MS, + startSidebarRecentFilterClock, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; import { @@ -222,6 +227,26 @@ describe("sidebar thread filters", () => { ).toBe(false); }); + it("treats an empty status selection as an unrestricted status filter", () => { + const filters = { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + statuses: [], + }; + + expect( + matchesSidebarThreadFilters({ + thread: filterableThread, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + }), + ).toBe(true); + expect(resolveSidebarThreadFilterStatuses(filters.statuses)).toEqual( + DEFAULT_SIDEBAR_THREAD_FILTERS.statuses, + ); + expect(hasNarrowingSidebarThreadFilters(filters)).toBe(false); + expect(hasActiveSidebarThreadFilters(filters)).toBe(false); + }); + it("matches the attention quick filter as unread OR needs attention", () => { const filters = { ...DEFAULT_SIDEBAR_THREAD_FILTERS, @@ -546,6 +571,46 @@ describe("sidebar thread filters", () => { }); }); +describe("sidebar recent filter clock", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("ticks only while the Recent filter is enabled and stops on cleanup", () => { + let nowMs = 100; + const ticks: number[] = []; + const disabledCleanup = startSidebarRecentFilterClock({ + enabled: false, + now: () => nowMs, + onTick: (nextNowMs) => ticks.push(nextNowMs), + }); + + vi.advanceTimersByTime(SIDEBAR_RECENT_FILTER_REFRESH_MS); + expect(ticks).toEqual([]); + disabledCleanup(); + + const cleanup = startSidebarRecentFilterClock({ + enabled: true, + now: () => nowMs, + onTick: (nextNowMs) => ticks.push(nextNowMs), + }); + expect(ticks).toEqual([100]); + + nowMs = 200; + vi.advanceTimersByTime(SIDEBAR_RECENT_FILTER_REFRESH_MS); + expect(ticks).toEqual([100, 200]); + + cleanup(); + nowMs = 300; + vi.advanceTimersByTime(SIDEBAR_RECENT_FILTER_REFRESH_MS); + expect(ticks).toEqual([100, 200]); + }); +}); + describe("sidebar filter scoping", () => { const remoteEnvironmentId = EnvironmentId.make("environment-remote"); @@ -1654,6 +1719,55 @@ describe("resolveProjectStatusIndicator", () => { }); describe("getVisibleThreadsForProject", () => { + it("keeps only the active route when every thread fails the filters", () => { + const threads = [ + { threadKey: "environment-local:thread-active", matches: false }, + { threadKey: "environment-local:thread-hidden", matches: false }, + ]; + + const filteredThreads = filterSidebarThreadsForActiveRoute({ + threads, + activeThreadKey: "environment-local:thread-active", + getThreadKey: (thread) => thread.threadKey, + matchesFilters: (thread) => thread.matches, + }); + + expect(filteredThreads).toEqual([threads[0]]); + expect( + resolvePinnedCollapsedSidebarThread({ + threads: filteredThreads, + activeThreadKey: "environment-local:thread-active", + projectExpanded: false, + getThreadKey: (thread) => thread.threadKey, + }), + ).toBe(threads[0]); + expect( + resolvePinnedCollapsedSidebarThread({ + threads: filteredThreads, + activeThreadKey: "environment-local:thread-active", + projectExpanded: true, + getThreadKey: (thread) => thread.threadKey, + }), + ).toBeNull(); + }); + + it("continues applying filters to non-active rows", () => { + const threads = [ + { threadKey: "active", matches: false }, + { threadKey: "matching", matches: true }, + { threadKey: "hidden", matches: false }, + ]; + + expect( + filterSidebarThreadsForActiveRoute({ + threads, + activeThreadKey: "active", + getThreadKey: (thread) => thread.threadKey, + matchesFilters: (thread) => thread.matches, + }).map((thread) => thread.threadKey), + ).toEqual(["active", "matching"]); + }); + it("includes the active thread even when it falls below the folded preview", () => { const threads = Array.from({ length: 8 }, (_, index) => makeThread({ @@ -1664,7 +1778,8 @@ describe("getVisibleThreadsForProject", () => { const result = getVisibleThreadsForProject({ threads, - activeThreadId: ThreadId.make("thread-8"), + activeThreadKey: "environment-local:thread-8", + getThreadKey: (thread) => `environment-local:${thread.id}`, isThreadListExpanded: false, previewLimit: 6, }); @@ -1691,7 +1806,8 @@ describe("getVisibleThreadsForProject", () => { const result = getVisibleThreadsForProject({ threads, - activeThreadId: ThreadId.make("thread-8"), + activeThreadKey: "environment-local:thread-8", + getThreadKey: (thread) => `environment-local:${thread.id}`, isThreadListExpanded: true, previewLimit: 6, }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5c0491414d325..fb9a28b1b7019 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -265,6 +265,32 @@ type SidebarThreadFilterInput = Pick< export const SIDEBAR_RECENT_WINDOW_DAYS = 7; const SIDEBAR_RECENT_WINDOW_MS = SIDEBAR_RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1_000; +export const SIDEBAR_RECENT_FILTER_REFRESH_MS = 60_000; + +export function startSidebarRecentFilterClock(input: { + readonly enabled: boolean; + readonly onTick: (nowMs: number) => void; + readonly now?: () => number; +}): () => void { + if (!input.enabled) { + return () => {}; + } + + const now = input.now ?? Date.now; + input.onTick(now()); + const interval = globalThis.setInterval(() => { + input.onTick(now()); + }, SIDEBAR_RECENT_FILTER_REFRESH_MS); + return () => { + globalThis.clearInterval(interval); + }; +} + +export function resolveSidebarThreadFilterStatuses( + statuses: ReadonlyArray, +): ReadonlyArray { + return statuses.length === 0 ? SIDEBAR_THREAD_FILTER_STATUSES : statuses; +} function latestValidTimestampMs(...timestamps: ReadonlyArray): number | null { let latestTimestampMs: number | null = null; @@ -360,11 +386,12 @@ export function matchesSidebarThreadFilters(input: { return false; } } - return filters.statuses.includes(status) || (isUnread && filters.statuses.includes("unread")); + const selectedStatuses = resolveSidebarThreadFilterStatuses(filters.statuses); + return selectedStatuses.includes(status) || (isUnread && selectedStatuses.includes("unread")); } export function hasNarrowingSidebarThreadFilters(filters: SidebarThreadFilters): boolean { - const selectedStatuses = new Set(filters.statuses); + const selectedStatuses = new Set(resolveSidebarThreadFilterStatuses(filters.statuses)); const includesEveryStatus = SIDEBAR_THREAD_FILTER_STATUSES.every((status) => selectedStatuses.has(status), ); @@ -382,6 +409,32 @@ export function hasActiveSidebarThreadFilters(filters: SidebarThreadFilters): bo return filters.includeArchived || hasNarrowingSidebarThreadFilters(filters); } +export function filterSidebarThreadsForActiveRoute(input: { + readonly threads: ReadonlyArray; + readonly activeThreadKey: string | null; + readonly getThreadKey: (thread: T) => string; + readonly matchesFilters: (thread: T) => boolean; +}): T[] { + return input.threads.filter( + (thread) => + input.getThreadKey(thread) === input.activeThreadKey || input.matchesFilters(thread), + ); +} + +export function resolvePinnedCollapsedSidebarThread(input: { + readonly threads: ReadonlyArray; + readonly activeThreadKey: string | null; + readonly projectExpanded: boolean; + readonly getThreadKey: (thread: T) => string; +}): T | null { + if (input.projectExpanded || input.activeThreadKey === null) { + return null; + } + return ( + input.threads.find((thread) => input.getThreadKey(thread) === input.activeThreadKey) ?? null + ); +} + export function sidebarProviderInstanceKey( environmentId: EnvironmentId, instanceId: ProviderInstanceId, @@ -889,9 +942,10 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } -export function getVisibleThreadsForProject>(input: { +export function getVisibleThreadsForProject(input: { threads: readonly T[]; - activeThreadId: T["id"] | undefined; + activeThreadKey: string | null; + getThreadKey: (thread: T) => string; isThreadListExpanded: boolean; previewLimit: number; }): { @@ -899,7 +953,7 @@ export function getVisibleThreadsForProject>(input: visibleThreads: T[]; hiddenThreads: T[]; } { - const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; + const { activeThreadKey, getThreadKey, isThreadListExpanded, previewLimit, threads } = input; const hasHiddenThreads = threads.length > previewLimit; if (!hasHiddenThreads || isThreadListExpanded) { @@ -911,7 +965,10 @@ export function getVisibleThreadsForProject>(input: } const previewThreads = threads.slice(0, previewLimit); - if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) { + if ( + activeThreadKey === null || + previewThreads.some((thread) => getThreadKey(thread) === activeThreadKey) + ) { return { hasHiddenThreads: true, hiddenThreads: threads.slice(previewLimit), @@ -919,7 +976,7 @@ export function getVisibleThreadsForProject>(input: }; } - const activeThread = threads.find((thread) => thread.id === activeThreadId); + const activeThread = threads.find((thread) => getThreadKey(thread) === activeThreadKey); if (!activeThread) { return { hasHiddenThreads: true, @@ -928,12 +985,14 @@ export function getVisibleThreadsForProject>(input: }; } - const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id)); + const visibleThreadKeys = new Set( + [...previewThreads, activeThread].map((thread) => getThreadKey(thread)), + ); return { hasHiddenThreads: true, - hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(thread.id)), - visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)), + hiddenThreads: threads.filter((thread) => !visibleThreadKeys.has(getThreadKey(thread))), + visibleThreads: threads.filter((thread) => visibleThreadKeys.has(getThreadKey(thread))), }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dcb5bf2e1160f..969ec3c2b3dd0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -196,24 +196,29 @@ import { openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + filterSidebarThreadsForActiveRoute, getSidebarRangeSelectionThreadKeys, getSidebarThreadIdsToPrewarm, + getVisibleThreadsForProject, hasActiveSidebarThreadFilters, hasNarrowingSidebarThreadFilters, matchesSidebarThreadFilters, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, + resolvePinnedCollapsedSidebarThread, resolveProjectStatusIndicator, resolveSidebarArchiveEnvironmentIds, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarThreadFilterStatuses, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, sidebarProviderInstanceKey, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, + startSidebarRecentFilterClock, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; @@ -1155,6 +1160,7 @@ interface SidebarProjectItemProps { project: SidebarProjectSnapshot; archivedThreads: readonly SidebarThreadSummary[]; sidebarThreadFilters: SidebarThreadFilters; + sidebarFilterNowMs: number; providerDriverKindByScopedInstanceKey: ReadonlyMap; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; @@ -1179,6 +1185,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec project, archivedThreads, sidebarThreadFilters, + sidebarFilterNowMs, providerDriverKindByScopedInstanceKey, isThreadListExpanded, activeRouteThreadKey, @@ -1402,20 +1409,26 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); }; const visibleProjectThreads = sortThreads( - projectThreads.filter((thread) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const providerInstanceId = - thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; - return matchesSidebarThreadFilters({ - thread, - lastVisitedAt: lastVisitedAtByThreadKey.get(threadKey), - isExplicitlyUnread: explicitlyUnreadByThreadKey.get(threadKey), - providerDriverKind: - providerDriverKindByScopedInstanceKey.get( - sidebarProviderInstanceKey(thread.environmentId, providerInstanceId), - ) ?? null, - filters: sidebarThreadFilters, - }); + filterSidebarThreadsForActiveRoute({ + threads: projectThreads, + activeThreadKey: activeRouteThreadKey, + getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + matchesFilters: (thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const providerInstanceId = + thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + return matchesSidebarThreadFilters({ + thread, + lastVisitedAt: lastVisitedAtByThreadKey.get(threadKey), + isExplicitlyUnread: explicitlyUnreadByThreadKey.get(threadKey), + providerDriverKind: + providerDriverKindByScopedInstanceKey.get( + sidebarProviderInstanceKey(thread.environmentId, providerInstanceId), + ) ?? null, + filters: sidebarThreadFilters, + nowMs: sidebarFilterNowMs, + }); + }, }), threadSortOrder, ); @@ -1435,22 +1448,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, [ projectThreads, providerDriverKindByScopedInstanceKey, + activeRouteThreadKey, + sidebarFilterNowMs, sidebarThreadFilters, threadExplicitlyUnreadStates, threadLastVisitedAts, threadSortOrder, ]); const pinnedCollapsedThread = useMemo(() => { - const activeThreadKey = activeRouteThreadKey ?? undefined; - if (!activeThreadKey || projectExpanded) { - return null; - } - return ( - visibleProjectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, - ) ?? null - ); + return resolvePinnedCollapsedSidebarThread({ + threads: visibleProjectThreads, + activeThreadKey: activeRouteThreadKey, + projectExpanded, + getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); }, [activeRouteThreadKey, projectExpanded, visibleProjectThreads]); const { @@ -1483,25 +1494,18 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? visibleProjectThreads - : visibleProjectThreads.slice(0, sidebarThreadPreviewCount); - const visibleThreadKeys = new Set( - [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ); - const renderedThreads = pinnedCollapsedThread - ? [pinnedCollapsedThread] - : visibleProjectThreads.filter((thread) => - visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const hiddenThreads = visibleProjectThreads.filter( - (thread) => - !visibleThreadKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); + const { + hasHiddenThreads: hasOverflowingThreads, + hiddenThreads, + visibleThreads: previewThreads, + } = getVisibleThreadsForProject({ + threads: visibleProjectThreads, + activeThreadKey: activeRouteThreadKey, + getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + isThreadListExpanded, + previewLimit: sidebarThreadPreviewCount, + }); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; return { hasOverflowingThreads, hiddenThreadStatus: resolveProjectStatusIndicator( @@ -1513,6 +1517,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; }, [ isThreadListExpanded, + activeRouteThreadKey, pinnedCollapsedThread, projectExpanded, projectThreads, @@ -2944,9 +2949,10 @@ function SidebarFilterMenu({ onFiltersChange: (filters: SidebarThreadFilters) => void; }) { const filtersActive = hasActiveSidebarThreadFilters(filters); + const selectedStatuses = resolveSidebarThreadFilterStatuses(filters.statuses); const statusFilterActive = - filters.statuses.length !== SIDEBAR_THREAD_FILTER_STATUSES.length || - !SIDEBAR_THREAD_FILTER_STATUSES.every((status) => filters.statuses.includes(status)); + selectedStatuses.length !== SIDEBAR_THREAD_FILTER_STATUSES.length || + !SIDEBAR_THREAD_FILTER_STATUSES.every((status) => selectedStatuses.includes(status)); return ( @@ -3023,13 +3029,13 @@ function SidebarFilterMenu({ {SIDEBAR_THREAD_FILTER_STATUSES.map((status) => ( { onFiltersChange({ ...filters, - statuses: toggleSidebarFilterValue(filters.statuses, status, checked), + statuses: toggleSidebarFilterValue(selectedStatuses, status, checked), }); }} > @@ -3179,6 +3185,7 @@ interface SidebarProjectsContentProps { threadSortOrder: SidebarThreadSortOrder; threadPreviewCount: SidebarThreadPreviewCount; sidebarThreadFilters: SidebarThreadFilters; + sidebarFilterNowMs: number; environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>; providerSources: ReadonlyArray<{ readonly driverKind: ProviderDriverKind; @@ -3231,6 +3238,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( threadSortOrder, threadPreviewCount, sidebarThreadFilters, + sidebarFilterNowMs, environments, providerSources, providerDriverKindByScopedInstanceKey, @@ -3403,6 +3411,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( project={project} archivedThreads={archivedThreads} sidebarThreadFilters={sidebarThreadFilters} + sidebarFilterNowMs={sidebarFilterNowMs} providerDriverKindByScopedInstanceKey={ providerDriverKindByScopedInstanceKey } @@ -3441,6 +3450,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( project={project} archivedThreads={archivedThreads} sidebarThreadFilters={sidebarThreadFilters} + sidebarFilterNowMs={sidebarFilterNowMs} providerDriverKindByScopedInstanceKey={providerDriverKindByScopedInstanceKey} isThreadListExpanded={expandedThreadListsByProject.has(project.projectKey)} activeRouteThreadKey={ @@ -3493,6 +3503,15 @@ export default function Sidebar() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); const sidebarThreadFilters = useClientSettings((s) => s.sidebarThreadFilters); + const [sidebarFilterNowMs, setSidebarFilterNowMs] = useState(() => Date.now()); + useEffect( + () => + startSidebarRecentFilterClock({ + enabled: sidebarThreadFilters.recentOnly, + onTick: setSidebarFilterNowMs, + }), + [sidebarThreadFilters.recentOnly], + ); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); const { archiveThread, unarchiveThread, deleteThread } = useThreadActions(); @@ -3691,23 +3710,31 @@ export default function Sidebar() { const narrowingFiltersActive = hasNarrowingSidebarThreadFilters(sidebarThreadFilters); const filteredSidebarThreads = useMemo( () => - sidebarThreadsWithArchived.filter((thread) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const providerInstanceId = - thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; - return matchesSidebarThreadFilters({ - thread, - lastVisitedAt: threadLastVisitedAtById[threadKey], - isExplicitlyUnread: threadExplicitlyUnreadById[threadKey], - providerDriverKind: - providerDriverKindByScopedInstanceKey.get( - sidebarProviderInstanceKey(thread.environmentId, providerInstanceId), - ) ?? null, - filters: sidebarThreadFilters, - }); + filterSidebarThreadsForActiveRoute({ + threads: sidebarThreadsWithArchived, + activeThreadKey: routeThreadKey, + getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + matchesFilters: (thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const providerInstanceId = + thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + return matchesSidebarThreadFilters({ + thread, + lastVisitedAt: threadLastVisitedAtById[threadKey], + isExplicitlyUnread: threadExplicitlyUnreadById[threadKey], + providerDriverKind: + providerDriverKindByScopedInstanceKey.get( + sidebarProviderInstanceKey(thread.environmentId, providerInstanceId), + ) ?? null, + filters: sidebarThreadFilters, + nowMs: sidebarFilterNowMs, + }); + }, }), [ providerDriverKindByScopedInstanceKey, + routeThreadKey, + sidebarFilterNowMs, sidebarThreadFilters, sidebarThreadsWithArchived, threadExplicitlyUnreadById, @@ -3905,29 +3932,28 @@ export default function Sidebar() { projectExpandedById, projectExpansionPreferenceKeys(project), ); - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; + const getThreadKey = (thread: SidebarThreadSummary) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const pinnedCollapsedThread = resolvePinnedCollapsedSidebarThread({ + threads: projectThreads, + activeThreadKey: routeThreadKey, + projectExpanded, + getThreadKey, + }); const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; if (!shouldShowThreadPanel) { return []; } const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, sidebarThreadPreviewCount); + const { visibleThreads: previewThreads } = getVisibleThreadsForProject({ + threads: projectThreads, + activeThreadKey: routeThreadKey, + getThreadKey, + isThreadListExpanded, + previewLimit: sidebarThreadPreviewCount, + }); const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads - .filter((thread) => thread.archivedAt === null) - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); + return renderedThreads.filter((thread) => thread.archivedAt === null).map(getThreadKey); }), [ sidebarThreadSortOrder, @@ -4205,6 +4231,7 @@ export default function Sidebar() { threadSortOrder={sidebarThreadSortOrder} threadPreviewCount={sidebarThreadPreviewCount} sidebarThreadFilters={sidebarThreadFilters} + sidebarFilterNowMs={sidebarFilterNowMs} environments={environments} providerSources={providerSources} providerDriverKindByScopedInstanceKey={providerDriverKindByScopedInstanceKey} diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts index 7bffbbaecc811..bb6243257cc34 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts @@ -2,6 +2,7 @@ import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import { createFirstSeenCompletedThreadObservationSeedStore } from "../lib/firstSeenCompletedThreadObservations"; import { resolveFirstSeenCompletedThreads } from "./useMarkFirstSeenCompletedThreadsUnread"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -185,6 +186,55 @@ describe("resolveFirstSeenCompletedThreads", () => { expect(afterUnarchive.newlyUnreadThreads).toEqual([]); }); + it("seeds loaded archived history before the thread is unarchived", () => { + const archivedKey = scopedThreadKey( + scopeThreadRef(localEnvironmentId, ThreadId.make("archived")), + ); + const seedStore = createFirstSeenCompletedThreadObservationSeedStore(); + seedStore.seed([thread("archived")]); + + const afterUnarchive = resolveFirstSeenCompletedThreads({ + threads: [thread("archived")], + environmentSnapshotIds: [localEnvironmentId], + previouslyObservedThreadsByEnvironment: new Map([[localEnvironmentId, new Map()]]), + seededObservedThreadsByEnvironment: seedStore.snapshot(), + }); + expect(afterUnarchive.newlyUnreadThreads).toEqual([]); + expect( + afterUnarchive.nextObservedThreadsByEnvironment.get(localEnvironmentId)?.get(archivedKey), + ).toEqual({ + turnId: "turn-archived", + state: "completed", + }); + + const laterCompletion = resolveFirstSeenCompletedThreads({ + threads: [thread("archived", "completed", localEnvironmentId, "turn-new")], + environmentSnapshotIds: [localEnvironmentId], + previouslyObservedThreadsByEnvironment: afterUnarchive.nextObservedThreadsByEnvironment, + seededObservedThreadsByEnvironment: seedStore.snapshot(), + }); + expect(laterCompletion.newlyUnreadThreads).toEqual([ + { + threadKey: archivedKey, + completedAt: "2026-06-18T09:00:00.000Z", + }, + ]); + }); + + it("does not let archived seeds turn initial active history into unread history", () => { + const seedStore = createFirstSeenCompletedThreadObservationSeedStore(); + seedStore.seed([thread("archived")]); + + const initialSnapshot = resolveFirstSeenCompletedThreads({ + threads: [thread("active-history")], + environmentSnapshotIds: [localEnvironmentId], + previouslyObservedThreadsByEnvironment: new Map(), + seededObservedThreadsByEnvironment: seedStore.snapshot(), + }); + + expect(initialSnapshot.newlyUnreadThreads).toEqual([]); + }); + it("does not mark the currently active thread unread", () => { const activeKey = scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("active"))); const result = resolveFirstSeenCompletedThreads({ diff --git a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts index 942b5f155b01d..7ac6bffcc6a25 100644 --- a/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts +++ b/apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts @@ -6,6 +6,10 @@ import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; import { environmentCatalog } from "../connection/catalog"; +import { + archivedThreadObservationSeeds, + type ObservedThreadTurn, +} from "../lib/firstSeenCompletedThreadObservations"; import { environmentShell } from "../state/shell"; import { environmentThreadShells } from "../state/threads"; import { useUiStateStore } from "../uiStateStore"; @@ -37,11 +41,6 @@ interface FirstSeenThreadInput { } | null; } -export interface ObservedThreadTurn { - readonly turnId: string | null; - readonly state: string | null; -} - export function resolveFirstSeenCompletedThreads(input: { readonly threads: ReadonlyArray; readonly environmentSnapshotIds: ReadonlyArray; @@ -49,6 +48,10 @@ export function resolveFirstSeenCompletedThreads(input: { EnvironmentId, ReadonlyMap >; + readonly seededObservedThreadsByEnvironment?: ReadonlyMap< + EnvironmentId, + ReadonlyMap + >; readonly activeThreadKey?: string | null; }): { readonly nextObservedThreadsByEnvironment: Map>; @@ -72,6 +75,14 @@ export function resolveFirstSeenCompletedThreads(input: { if (!nextObservedThreadsByEnvironment.has(environmentId)) { nextObservedThreadsByEnvironment.set(environmentId, new Map()); } + const nextEnvironmentThreads = nextObservedThreadsByEnvironment.get(environmentId); + for (const [threadKey, observedThread] of input.seededObservedThreadsByEnvironment?.get( + environmentId, + ) ?? []) { + if (!nextEnvironmentThreads?.has(threadKey)) { + nextEnvironmentThreads?.set(threadKey, observedThread); + } + } } for (const thread of input.threads) { @@ -87,7 +98,9 @@ export function resolveFirstSeenCompletedThreads(input: { const previousEnvironmentThreads = input.previouslyObservedThreadsByEnvironment.get( thread.environmentId, ); - const previousThread = previousEnvironmentThreads?.get(threadKey); + const previousThread = + previousEnvironmentThreads?.get(threadKey) ?? + input.seededObservedThreadsByEnvironment?.get(thread.environmentId)?.get(threadKey); if ( previousEnvironmentThreads !== undefined && thread.latestTurn?.state === "completed" && @@ -119,6 +132,7 @@ export function useMarkFirstSeenCompletedThreadsUnread(): void { threads, environmentSnapshotIds, previouslyObservedThreadsByEnvironment: observedThreadsByEnvironmentRef.current, + seededObservedThreadsByEnvironment: archivedThreadObservationSeeds.snapshot(), activeThreadKey: useUiStateStore.getState().activeThreadVisit?.threadId ?? null, }); for (const thread of newlyUnreadThreads) { diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index 2d52383c02c92..b7573f3c8d866 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -5,10 +5,11 @@ import { makeArchivedThreadsEnvironmentKey, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { orchestrationEnvironment } from "../state/orchestration"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { archivedThreadObservationSeeds } from "./firstSeenCompletedThreadObservations"; function archivedSnapshotAtom(environmentId: EnvironmentId) { return orchestrationEnvironment.archivedShellSnapshot({ @@ -37,6 +38,19 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray { + archivedThreadObservationSeeds.seed( + result.snapshots.flatMap((entry) => + entry.snapshot.threads + .filter((thread) => thread.archivedAt !== null) + .map((thread) => ({ + environmentId: entry.environmentId, + id: thread.id, + latestTurn: thread.latestTurn, + })), + ), + ); + }, [result.snapshots]); const refresh = useCallback(() => { for (const environmentId of environmentIds) { appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); diff --git a/apps/web/src/lib/firstSeenCompletedThreadObservations.ts b/apps/web/src/lib/firstSeenCompletedThreadObservations.ts new file mode 100644 index 0000000000000..45f96500e5f29 --- /dev/null +++ b/apps/web/src/lib/firstSeenCompletedThreadObservations.ts @@ -0,0 +1,52 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +export interface ObservedThreadTurn { + readonly turnId: string | null; + readonly state: string | null; +} + +interface ThreadObservationSeed { + readonly environmentId: EnvironmentId; + readonly id: ThreadId; + readonly latestTurn: { + readonly turnId: string; + readonly state: string; + } | null; +} + +export interface FirstSeenCompletedThreadObservationSeedStore { + readonly seed: (threads: ReadonlyArray) => void; + readonly snapshot: () => ReadonlyMap>; +} + +export function createFirstSeenCompletedThreadObservationSeedStore(): FirstSeenCompletedThreadObservationSeedStore { + const observationsByEnvironment = new Map>(); + + return { + seed(threads) { + for (const thread of threads) { + const environmentObservations = + observationsByEnvironment.get(thread.environmentId) ?? new Map(); + environmentObservations.set( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + { + turnId: thread.latestTurn?.turnId ?? null, + state: thread.latestTurn?.state ?? null, + }, + ); + observationsByEnvironment.set(thread.environmentId, environmentObservations); + } + }, + snapshot() { + return new Map( + [...observationsByEnvironment].map(([environmentId, observations]) => [ + environmentId, + new Map(observations), + ]), + ); + }, + }; +} + +export const archivedThreadObservationSeeds = createFirstSeenCompletedThreadObservationSeedStore(); From 613cf3d8cbd8ee129dd2e387c0518d54fab94a10 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 20:56:54 -0400 Subject: [PATCH 24/30] fix(web): register active thread routes eagerly --- apps/web/src/routes/__root.tsx | 12 ++++++------ apps/web/src/threadRoutes.test.ts | 16 ++++++++++++++++ apps/web/src/threadRoutes.ts | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1db8e128cdbdb..b5e39e0fee3a4 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -50,7 +50,7 @@ import { primaryServerWelcomeAtom, } from "../state/server"; import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; -import { resolveThreadRouteTarget } from "../threadRoutes"; +import { resolveEagerActiveThreadRouteKey, resolveThreadRouteTarget } from "../threadRoutes"; import { createKeybindingsUpdateToastController, type KeybindingsUpdateToastController, @@ -208,19 +208,19 @@ function CompletedThreadUnreadTracker() { } function ActiveThreadRouteTracker() { - const routeKind = useRouterState({ + const routeThreadKey = useRouterState({ select: (state) => { const params = state.matches[state.matches.length - 1]?.params ?? {}; - return resolveThreadRouteTarget(params)?.kind ?? null; + return resolveEagerActiveThreadRouteKey(resolveThreadRouteTarget(params)); }, }); const markActiveThreadVisited = useUiStateStore((state) => state.markActiveThreadVisited); useEffect(() => { - if (routeKind === null) { - markActiveThreadVisited(null, null); + if (routeThreadKey !== undefined) { + markActiveThreadVisited(routeThreadKey, null); } - }, [markActiveThreadVisited, routeKind]); + }, [markActiveThreadVisited, routeThreadKey]); return null; } diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 012f62d1af5b5..da32d14659437 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -7,6 +7,7 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams, resolveActiveThreadRouteRef, + resolveEagerActiveThreadRouteKey, resolveThreadRouteRenderState, resolveThreadRouteRef, resolveThreadRouteTarget, @@ -71,6 +72,21 @@ describe("threadRoutes", () => { expect(resolveThreadRouteTarget({ threadId: "thread-1" })).toBeNull(); }); + it("eagerly tracks server routes before their thread detail mounts", () => { + expect( + resolveEagerActiveThreadRouteKey( + resolveThreadRouteTarget({ + environmentId: "env-1", + threadId: "thread-1", + }), + ), + ).toBe("env-1:thread-1"); + expect( + resolveEagerActiveThreadRouteKey(resolveThreadRouteTarget({ draftId: "draft-1" })), + ).toBeUndefined(); + expect(resolveEagerActiveThreadRouteKey(null)).toBeNull(); + }); + it("keeps an active visit only while the same chat route remains mounted", () => { const routeThreadKey = "env-1:thread-1"; diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index ce92478182207..7a761cd48eafe 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -85,6 +85,20 @@ export function resolveThreadRouteTarget( }; } +/** + * Canonical routes can register their active thread before thread detail has + * loaded. Draft routes defer to ChatView because the draft id alone does not + * identify its scoped thread ref. + */ +export function resolveEagerActiveThreadRouteKey( + target: ThreadRouteTarget | null, +): string | null | undefined { + if (target === null) { + return null; + } + return target.kind === "server" ? scopedThreadKey(target.threadRef) : undefined; +} + export function shouldKeepActiveThreadVisitOnUnmount(input: { readonly currentRouteTarget: ThreadRouteTarget | null; readonly routeThreadKey: string; From 95eba6068010c41e648901f10e2363a85ea500da Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 02:35:03 -0400 Subject: [PATCH 25/30] feat(web): add flat sidebar thread grouping --- apps/web/src/components/Sidebar.logic.test.ts | 44 ++ apps/web/src/components/Sidebar.logic.ts | 8 + apps/web/src/components/Sidebar.tsx | 476 +++++++++++------- apps/web/src/environmentGrouping.test.ts | 39 ++ apps/web/src/sidebarProjectGrouping.ts | 38 +- packages/contracts/src/settings.test.ts | 6 + packages/contracts/src/settings.ts | 4 + 7 files changed, 442 insertions(+), 173 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b17cf8e8c4f43..86da3f1efcd12 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -116,6 +116,7 @@ const filterableThread = { archivedAt: null, createdAt: "2026-03-09T09:00:00.000Z", environmentId: localEnvironmentId, + projectId: ProjectId.make("project-1"), hasActionableProposedPlan: false, hasPendingApprovals: false, hasPendingUserInput: false, @@ -208,10 +209,12 @@ describe("sidebar thread filters", () => { filters: { statuses: ["done"], environmentIds: [localEnvironmentId], + projectKeys: ["environment-local:project-1"], sources: [ProviderDriverKind.make("codex")], recentOnly: false, attentionOnly: false, includeArchived: true, + groupByProject: true, }, }), ).toBe(true); @@ -247,6 +250,29 @@ describe("sidebar thread filters", () => { expect(hasActiveSidebarThreadFilters(filters)).toBe(false); }); + it("filters by scoped project key", () => { + expect( + matchesSidebarThreadFilters({ + thread: filterableThread, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + projectKeys: ["environment-local:project-1"], + }, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: filterableThread, + providerDriverKind: ProviderDriverKind.make("codex"), + filters: { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + projectKeys: ["environment-local:project-2"], + }, + }), + ).toBe(false); + }); + it("matches the attention quick filter as unread OR needs attention", () => { const filters = { ...DEFAULT_SIDEBAR_THREAD_FILTERS, @@ -568,6 +594,24 @@ describe("sidebar thread filters", () => { recentOnly: true, }), ).toBe(true); + expect( + hasActiveSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + projectKeys: ["environment-local:project-1"], + }), + ).toBe(true); + expect( + hasNarrowingSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + projectKeys: ["environment-local:project-1"], + }), + ).toBe(true); + expect( + hasActiveSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + groupByProject: false, + }), + ).toBe(false); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index fb9a28b1b7019..3e5b039cce3e5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -251,6 +251,7 @@ type SidebarThreadFilterInput = Pick< | "archivedAt" | "createdAt" | "environmentId" + | "projectId" | "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" @@ -352,6 +353,12 @@ export function matchesSidebarThreadFilters(input: { if (filters.environmentIds.length > 0 && !filters.environmentIds.includes(thread.environmentId)) { return false; } + if ( + filters.projectKeys.length > 0 && + !filters.projectKeys.includes(`${thread.environmentId}:${thread.projectId}`) + ) { + return false; + } if ( filters.sources.length > 0 && (input.providerDriverKind === null || !filters.sources.includes(input.providerDriverKind)) @@ -399,6 +406,7 @@ export function hasNarrowingSidebarThreadFilters(filters: SidebarThreadFilters): filters.attentionOnly || filters.recentOnly || filters.environmentIds.length > 0 || + filters.projectKeys.length > 0 || filters.sources.length > 0 || selectedStatuses.size !== SIDEBAR_THREAD_FILTER_STATUSES.length || !includesEveryStatus diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 969ec3c2b3dd0..de0057f3e08cc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -238,6 +238,7 @@ import { } from "../logicalProject"; import type { SidebarThreadSummary } from "../types"; import { + buildFlatSidebarProjectSnapshot, buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, type SidebarProjectGroupMember, @@ -1178,6 +1179,7 @@ interface SidebarProjectItemProps { suppressProjectClickForContextMenuRef: React.RefObject; isManualProjectSorting: boolean; dragHandleProps: SortableProjectHandleProps | null; + flatMode?: boolean; } const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjectItemProps) { @@ -1203,6 +1205,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec suppressProjectClickForContextMenuRef, isManualProjectSorting, dragHandleProps, + flatMode = false, } = props; const threadSortOrder = useClientSettings( (settings) => settings.sidebarThreadSortOrder, @@ -1320,9 +1323,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]); - const projectExpanded = useUiStateStore((state) => + const storedProjectExpanded = useUiStateStore((state) => resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), ); + const projectExpanded = flatMode || storedProjectExpanded; const threadLastVisitedAts = useUiStateStore( useShallow((state) => projectThreads.map( @@ -2449,111 +2453,113 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <> -
- - {!projectExpanded && projectStatus ? ( + {!flatMode ? ( +
+ + {!projectExpanded && projectStatus ? ( + + + } + > + + + + + + {projectStatus.label} + + ) : ( + + )} + + + + {project.displayName} + + {project.groupedProjectCount > 1 ? ( + + {project.groupedProjectCount} projects + + ) : null} + + + {/* Environment badge – visible by default, crossfades with the + "new thread" button on hover using the same pointer-events + + opacity pattern as the thread row archive/timestamp swap. */} + {project.environmentPresence === "remote-only" && ( } > - - - - + {project.allRemoteMembersAreDesktopLocal ? ( + + ) : ( + + )} - {projectStatus.label} + + {project.allRemoteMembersAreDesktopLocal + ? `Local sandbox: ${project.remoteEnvironmentLabels.join(", ")}` + : `Remote environment: ${project.remoteEnvironmentLabels.join(", ")}`} + - ) : ( - )} - - - - {project.displayName} - - {project.groupedProjectCount > 1 ? ( - - {project.groupedProjectCount} projects - - ) : null} - - - {/* Environment badge – visible by default, crossfades with the - "new thread" button on hover using the same pointer-events + - opacity pattern as the thread row archive/timestamp swap. */} - {project.environmentPresence === "remote-only" && ( +
+ +
} - > - {project.allRemoteMembersAreDesktopLocal ? ( - - ) : ( - - )} -
+ /> - {project.allRemoteMembersAreDesktopLocal - ? `Local sandbox: ${project.remoteEnvironmentLabels.join(", ")}` - : `Remote environment: ${project.remoteEnvironmentLabels.join(", ")}`} + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"}
- )} - - - -
- } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - -
+
+ ) : null} void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; @@ -2882,42 +2890,44 @@ function ProjectSortMenu({ ))} - -
- Visible threads -
-
- - - - { - event.stopPropagation(); - }} - /> - - - -
-
+ {showThreadPreviewCount ? ( + +
+ Visible threads +
+
+ + + + { + event.stopPropagation(); + }} + /> + + + +
+
+ ) : null} ); @@ -2930,9 +2940,26 @@ function toggleSidebarFilterValue(values: readonly T[], value: T, checked: bo return values.filter((candidate) => candidate !== value); } +function toggleSidebarFilterValues( + values: readonly T[], + toggledValues: readonly T[], + checked: boolean, +): T[] { + const next = new Set(values); + for (const value of toggledValues) { + if (checked) { + next.add(value); + } else { + next.delete(value); + } + } + return [...next]; +} + function SidebarFilterMenu({ filters, environments, + projects, providerSources, archiveLoading, archiveError, @@ -2940,6 +2967,7 @@ function SidebarFilterMenu({ }: { filters: SidebarThreadFilters; environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>; + projects: ReadonlyArray; providerSources: ReadonlyArray<{ readonly driverKind: ProviderDriverKind; readonly label: string; @@ -2978,6 +3006,17 @@ function SidebarFilterMenu({ + { + onFiltersChange({ ...filters, groupByProject: checked }); + }} + > + Group by project + +
Filters { - onFiltersChange(DEFAULT_SIDEBAR_THREAD_FILTERS); + onFiltersChange({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + groupByProject: filters.groupByProject, + }); }} > Reset @@ -3044,6 +3086,47 @@ function SidebarFilterMenu({ ))} + + + Project + {filters.projectKeys.length > 0 ? ( + + ) : null} + + + {projects.map((project) => { + const projectKeys = project.memberProjectRefs.map(scopedProjectKey); + const checked = + filters.projectKeys.length > 0 && + projectKeys.every((projectKey) => filters.projectKeys.includes(projectKey)); + return ( + { + onFiltersChange({ + ...filters, + projectKeys: toggleSidebarFilterValues( + filters.projectKeys, + projectKeys, + nextChecked, + ), + }); + }} + > + {project.displayName} + + ); + })} + {projects.length === 0 ? ( + + No projects + + ) : null} + + Environment @@ -3208,6 +3291,7 @@ interface SidebarProjectsContentProps { archiveThread: ReturnType["archiveThread"]; unarchiveThread: ReturnType["unarchiveThread"]; deleteThread: ReturnType["deleteThread"]; + availableProjects: readonly SidebarProjectSnapshot[]; sortedProjects: readonly SidebarProjectSnapshot[]; expandedThreadListsByProject: ReadonlySet; activeRouteProjectKey: string | null; @@ -3258,6 +3342,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( archiveThread, unarchiveThread, deleteThread, + availableProjects, sortedProjects, expandedThreadListsByProject, activeRouteProjectKey, @@ -3301,6 +3386,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const flatProject = useMemo( + () => buildFlatSidebarProjectSnapshot(sortedProjects), + [sortedProjects], + ); return ( @@ -3353,11 +3442,14 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
- Projects + + {sidebarThreadFilters.groupByProject ? "Projects" : "Threads"} +
- {isManualProjectSorting ? ( + {!sidebarThreadFilters.groupByProject ? ( + + {flatProject ? ( + + ) : null} + + ) : isManualProjectSorting ? ( - sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( - threadsByProjectKey.get(project.projectKey) ?? [], - sidebarThreadSortOrder, - ); - const projectExpanded = resolveProjectExpanded( - projectExpandedById, - projectExpansionPreferenceKeys(project), - ); - const getThreadKey = (thread: SidebarThreadSummary) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const pinnedCollapsedThread = resolvePinnedCollapsedSidebarThread({ - threads: projectThreads, - activeThreadKey: routeThreadKey, - projectExpanded, - getThreadKey, - }); - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const { visibleThreads: previewThreads } = getVisibleThreadsForProject({ - threads: projectThreads, - activeThreadKey: routeThreadKey, - getThreadKey, - isThreadListExpanded, - previewLimit: sidebarThreadPreviewCount, - }); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.filter((thread) => thread.archivedAt === null).map(getThreadKey); - }), - [ - sidebarThreadSortOrder, - sidebarThreadPreviewCount, - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - sortedProjects, - threadsByProjectKey, - ], - ); + const visibleSidebarThreadKeys = useMemo(() => { + if (!sidebarThreadFilters.groupByProject) { + const flatProject = buildFlatSidebarProjectSnapshot(sortedProjects); + const sortedThreads = sortThreads(filteredSidebarThreads, sidebarThreadSortOrder); + const showAll = + flatProject !== null && expandedThreadListsByProject.has(flatProject.projectKey); + return (showAll ? sortedThreads : sortedThreads.slice(0, sidebarThreadPreviewCount)) + .filter((thread) => thread.archivedAt === null) + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); + } + + return sortedProjects.flatMap((project) => { + const projectThreads = sortThreads( + threadsByProjectKey.get(project.projectKey) ?? [], + sidebarThreadSortOrder, + ); + const projectExpanded = resolveProjectExpanded( + projectExpandedById, + projectExpansionPreferenceKeys(project), + ); + const getThreadKey = (thread: SidebarThreadSummary) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const pinnedCollapsedThread = resolvePinnedCollapsedSidebarThread({ + threads: projectThreads, + activeThreadKey: routeThreadKey, + projectExpanded, + getThreadKey, + }); + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); + const { visibleThreads: previewThreads } = getVisibleThreadsForProject({ + threads: projectThreads, + activeThreadKey: routeThreadKey, + getThreadKey, + isThreadListExpanded, + previewLimit: sidebarThreadPreviewCount, + }); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.filter((thread) => thread.archivedAt === null).map(getThreadKey); + }); + }, [ + filteredSidebarThreads, + sidebarThreadSortOrder, + sidebarThreadPreviewCount, + sidebarThreadFilters.groupByProject, + expandedThreadListsByProject, + projectExpandedById, + routeThreadKey, + sortedProjects, + threadsByProjectKey, + ]); const threadJumpCommandByKey = useMemo(() => { const mapping = new Map>>(); for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { @@ -4251,6 +4382,7 @@ export default function Sidebar() { archiveThread={archiveThread} unarchiveThread={unarchiveThread} deleteThread={deleteThread} + availableProjects={sidebarProjects} sortedProjects={sortedProjects} expandedThreadListsByProject={expandedThreadListsByProject} activeRouteProjectKey={activeRouteProjectKey} diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca091281..04e4cf613d2f6 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -9,6 +9,7 @@ import { resolveProjectGroupingMode, } from "./logicalProject"; import { + buildFlatSidebarProjectSnapshot, buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, @@ -349,4 +350,42 @@ describe("environment grouping", () => { expect(groups.map((group) => group.displayName)).toEqual(["separate", "shared-repo"]); }); + + it("combines project groups into one flat thread-list snapshot", () => { + const stale = makeProject({ + id: ProjectId.make("project-stale"), + repositoryIdentity, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("project-canonical"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + const second = makeProject({ + id: ProjectId.make("project-second"), + title: "another-repo", + workspaceRoot: "/tmp/another-repo", + repositoryIdentity: { + ...repositoryIdentity, + canonicalKey: "github.com/example/another-repo", + }, + }); + const snapshots = buildSidebarProjectSnapshots({ + projects: [stale, canonical, second], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => "primary", + }); + + const flat = buildFlatSidebarProjectSnapshot(snapshots); + + expect(flat?.displayName).toBe("Threads"); + expect(flat?.memberProjectRefs.map((projectRef) => projectRef.projectId)).toEqual([ + stale.id, + canonical.id, + second.id, + ]); + expect(flat?.memberProjects.map((project) => project.id)).toEqual([canonical.id, second.id]); + }); }); diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 32299b565f4b4..aff67bc1ea8fb 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; import { deriveLogicalProjectKeyFromSettings, @@ -37,6 +37,42 @@ export interface SidebarProjectPickerEntry { isPreferred: boolean; } +const FLAT_THREAD_LIST_PROJECT_KEY = "__all_threads__"; + +export function buildFlatSidebarProjectSnapshot( + projects: ReadonlyArray, +): SidebarProjectSnapshot | null { + const representative = projects[0]; + if (!representative) { + return null; + } + + const membersByKey = new Map(); + const projectRefsByKey = new Map(); + for (const project of projects) { + for (const member of project.memberProjects) { + membersByKey.set(scopedProjectKey(scopeProjectRef(member.environmentId, member.id)), member); + } + for (const projectRef of project.memberProjectRefs) { + projectRefsByKey.set(scopedProjectKey(projectRef), projectRef); + } + } + const memberProjects = [...membersByKey.values()]; + const memberProjectRefs = [...projectRefsByKey.values()]; + + return { + ...representative, + projectKey: FLAT_THREAD_LIST_PROJECT_KEY, + displayName: "Threads", + groupedProjectCount: memberProjects.length, + memberProjects, + memberProjectRefs, + remoteEnvironmentLabels: projects + .flatMap((project) => project.remoteEnvironmentLabels) + .filter((label, index, labels) => labels.indexOf(label) === index), + }; +} + interface SidebarProjectGroupCandidate { readonly logicalKey: string; readonly project: Project; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index c4b6fe612c552..508655419d650 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -75,10 +75,12 @@ describe("ClientSettings sidebar thread filters", () => { expect(DEFAULT_SIDEBAR_THREAD_FILTERS).toEqual({ statuses: ["needs_attention", "unread", "working", "done"], environmentIds: [], + projectKeys: [], sources: [], recentOnly: false, attentionOnly: false, includeArchived: false, + groupByProject: true, }); }); @@ -96,19 +98,23 @@ describe("ClientSettings sidebar thread filters", () => { sidebarThreadFilters: { statuses: ["unread"], environmentIds: ["environment-local"], + projectKeys: ["environment-local:project-1"], sources: ["codex"], recentOnly: true, attentionOnly: true, includeArchived: true, + groupByProject: false, }, }).sidebarThreadFilters, ).toEqual({ statuses: ["unread"], environmentIds: ["environment-local"], + projectKeys: ["environment-local:project-1"], sources: ["codex"], recentOnly: true, attentionOnly: true, includeArchived: true, + groupByProject: false, }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 03db84f5e1c8a..b3b5306a046e1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -43,10 +43,14 @@ export const SidebarThreadFilters = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([...SIDEBAR_THREAD_FILTER_STATUSES])), ), environmentIds: Schema.Array(EnvironmentId).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + projectKeys: Schema.Array(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), sources: Schema.Array(ProviderDriverKind).pipe(Schema.withDecodingDefault(Effect.succeed([]))), recentOnly: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), attentionOnly: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), includeArchived: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + groupByProject: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type SidebarThreadFilters = typeof SidebarThreadFilters.Type; export const DEFAULT_SIDEBAR_THREAD_FILTERS: SidebarThreadFilters = Schema.decodeSync( From c1567c2e9420ddbcca2a86c2203aa1542d4e0913 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 03:13:20 -0400 Subject: [PATCH 26/30] fix(web): show all threads in flat sidebar mode --- apps/web/src/components/Sidebar.logic.test.ts | 27 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 7 +++++ apps/web/src/components/Sidebar.tsx | 16 ++++++----- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 86da3f1efcd12..2c5028c09b688 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -34,6 +34,7 @@ import { formatWorkingDurationLabel, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, + shouldShowAllSidebarThreads, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, @@ -1864,6 +1865,32 @@ describe("getVisibleThreadsForProject", () => { }); }); +describe("shouldShowAllSidebarThreads", () => { + it("always shows the complete list in flat mode", () => { + expect( + shouldShowAllSidebarThreads({ + flatMode: true, + isThreadListExpanded: false, + }), + ).toBe(true); + }); + + it("preserves preview folding for grouped projects", () => { + expect( + shouldShowAllSidebarThreads({ + flatMode: false, + isThreadListExpanded: false, + }), + ).toBe(false); + expect( + shouldShowAllSidebarThreads({ + flatMode: false, + isThreadListExpanded: true, + }), + ).toBe(true); + }); +}); + function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3e5b039cce3e5..36892b01cd01f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -950,6 +950,13 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } +export function shouldShowAllSidebarThreads(input: { + flatMode: boolean; + isThreadListExpanded: boolean; +}): boolean { + return input.flatMode || input.isThreadListExpanded; +} + export function getVisibleThreadsForProject(input: { threads: readonly T[]; activeThreadKey: string | null; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index de0057f3e08cc..eb93cc79a4395 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -217,6 +217,7 @@ import { orderItemsByPreferredIds, sidebarProviderInstanceKey, shouldClearThreadSelectionOnMouseDown, + shouldShowAllSidebarThreads, sortProjectsForSidebar, startSidebarRecentFilterClock, useThreadJumpHintVisibility, @@ -1498,17 +1499,22 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; + const showAllThreads = shouldShowAllSidebarThreads({ + flatMode, + isThreadListExpanded, + }); const { - hasHiddenThreads: hasOverflowingThreads, + hasHiddenThreads, hiddenThreads, visibleThreads: previewThreads, } = getVisibleThreadsForProject({ threads: visibleProjectThreads, activeThreadKey: activeRouteThreadKey, getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - isThreadListExpanded, + isThreadListExpanded: showAllThreads, previewLimit: sidebarThreadPreviewCount, }); + const hasOverflowingThreads = !flatMode && hasHiddenThreads; const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; return { hasOverflowingThreads, @@ -1520,6 +1526,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null, }; }, [ + flatMode, isThreadListExpanded, activeRouteThreadKey, pinnedCollapsedThread, @@ -4044,11 +4051,8 @@ export default function Sidebar() { const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo(() => { if (!sidebarThreadFilters.groupByProject) { - const flatProject = buildFlatSidebarProjectSnapshot(sortedProjects); const sortedThreads = sortThreads(filteredSidebarThreads, sidebarThreadSortOrder); - const showAll = - flatProject !== null && expandedThreadListsByProject.has(flatProject.projectKey); - return (showAll ? sortedThreads : sortedThreads.slice(0, sidebarThreadPreviewCount)) + return sortedThreads .filter((thread) => thread.archivedAt === null) .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); } From baba9a681b6195e8571fd3b522cfa6069019ab4d Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 17:30:39 -0400 Subject: [PATCH 27/30] feat(web): support sidebar filters in v2 --- apps/web/src/components/SidebarV2.tsx | 518 +++++++++++++----- .../components/sidebar/SidebarFilterMenu.tsx | 339 ++++++++++++ .../sidebar/sidebarProviderFilters.test.ts | 70 +++ .../sidebar/sidebarProviderFilters.ts | 46 ++ 4 files changed, 836 insertions(+), 137 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarFilterMenu.tsx create mode 100644 apps/web/src/components/sidebar/sidebarProviderFilters.test.ts create mode 100644 apps/web/src/components/sidebar/sidebarProviderFilters.ts diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 1a5006d975286..f34576ebe6d64 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,14 +1,20 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + scopeThreadShell, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, scopeThreadRef, + scopedProjectKey, scopedThreadKey, } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { SidebarThreadFilters } from "@t3tools/contracts/settings"; import { + ArchiveRestoreIcon, CheckIcon, ChevronDownIcon, CircleAlertIcon, @@ -91,9 +97,12 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { + firstValidTimestampMs, formatWorkingDurationLabel, hasUnseenCompletion, + hasActiveSidebarThreadFilters, isTrailingDoubleClick, + matchesSidebarThreadFilters, orderItemsByPreferredIds, resolveAdjacentThreadId, resolveSettledTimestamp, @@ -110,8 +119,7 @@ import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; -import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; -import { primaryServerProvidersAtom } from "../state/server"; +import type { ProviderInstanceEntry } from "../providerInstances"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { CommandDialogTrigger } from "./ui/command"; import { Button } from "./ui/button"; @@ -126,12 +134,22 @@ import { } from "./ui/dialog"; import { Input } from "./ui/input"; import { Kbd } from "./ui/kbd"; -import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { Menu, MenuCheckboxItem, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "./ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { useComposerDraftStore } from "../composerDraftStore"; +import { useArchivedThreadSnapshots } from "../lib/archivedThreadsState"; +import { + SidebarFilterMenu, + toggleSidebarFilterValues, + type SidebarFilterProjectOption, +} from "./sidebar/SidebarFilterMenu"; +import { + buildSidebarProviderFilterState, + sidebarProviderInstanceKey, +} from "./sidebar/sidebarProviderFilters"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. @@ -280,7 +298,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { variant: "card" | "slim"; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). - variantAction: "settle" | "unsettle"; + variantAction: "settle" | "unsettle" | "unarchive"; // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; @@ -290,7 +308,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { environmentLabel: string | null; projectCwd: string | null; projectTitle: string | null; - providerEntryByInstanceId: ReadonlyMap; + providerEntryByScopedInstanceKey: ReadonlyMap; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; @@ -302,6 +320,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; + onUnarchive: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { @@ -316,6 +335,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onThreadActivate, onThreadClick, onUnsettle, + onUnarchive, renamingTitle, thread, variant, @@ -326,6 +346,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { [thread.environmentId, thread.id], ); const threadKey = scopedThreadKey(threadRef); + const isArchived = thread.archivedAt !== null; const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isExplicitlyUnread = useUiStateStore( (state) => state.threadExplicitlyUnreadById[threadKey] === true, @@ -423,7 +444,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onChangeRequestState, prState, threadKey]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; + const providerEntry = + props.providerEntryByScopedInstanceKey.get( + sidebarProviderInstanceKey(thread.environmentId, modelInstanceId), + ) ?? null; const driverKind = providerEntry?.driverKind ?? null; const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, @@ -450,9 +474,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const handleClick = useCallback( (event: ReactMouseEvent) => { + if (isArchived) { + event.preventDefault(); + return; + } onThreadClick(event, threadRef); }, - [onThreadClick, threadRef], + [isArchived, onThreadClick, threadRef], ); const handleContextMenu = useCallback( (event: ReactMouseEvent) => { @@ -466,20 +494,28 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { if (event.target !== event.currentTarget) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); + if (isArchived) return; onThreadActivate(threadRef); }, - [onThreadActivate, threadRef], + [isArchived, onThreadActivate, threadRef], ); const handleDoubleClick = useCallback( (event: ReactMouseEvent) => { - if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + if ( + isArchived || + isRenaming || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { return; } if ((event.target as HTMLElement).closest("button, a, input")) return; event.preventDefault(); onStartRename(threadRef, thread.title); }, - [isRenaming, onStartRename, thread.title, threadRef], + [isArchived, isRenaming, onStartRename, thread.title, threadRef], ); const renameCommittedRef = useRef(false); useEffect(() => { @@ -521,6 +557,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onUnsettle, threadRef], ); + const handleUnarchiveClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnarchive(threadRef); + }, + [onUnarchive, threadRef], + ); const handlePrClick = useCallback( (event: ReactMouseEvent) => { if (pr?.url) openPrLink(event, pr.url); @@ -533,8 +577,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // a useful hierarchy nor a reliable hover cue. Status now lives in the row // content; surface is reserved for interaction (hover, multi-select, route). const rowSurfaceClassName = cn( - "group/v2-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", + "group/v2-row relative w-full overflow-hidden rounded-md text-left outline-none select-none", + isArchived ? "cursor-default" : "cursor-pointer", variant === "card" && "backdrop-blur-[16px]", + isArchived && "opacity-65", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected @@ -620,8 +666,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { - {!props.settlementSupported ? null : variantAction === "unsettle" ? ( + {variantAction === "unarchive" ? ( + + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + + ) : null; return ( <> @@ -1822,50 +2026,61 @@ export default function SidebarV2() { aria-label="Filter threads by project" className="flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 text-left text-sm font-medium text-sidebar-muted-foreground outline-none hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar" > - {scopedProjectGroup ? ( + {singleSelectedProjectGroup ? ( ) : ( )} - - {scopedProjectGroup?.displayName ?? "All projects"} - + {projectFilterLabel} - - setProjectScopeKey(value === "all" ? null : (value as string)) - } + selectOnlyProjectGroup(null)} > - - - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; - return ( - + + All projects + + + {projectGroups.map((project) => { + const projectKeys = project.memberProjectRefs.map(scopedProjectKey); + const checked = + projectKeys.length > 0 && + projectKeys.every((projectKey) => + sidebarThreadFilters.projectKeys.includes(projectKey), + ); + return ( + { + handleSidebarThreadFiltersChange({ + ...sidebarThreadFilters, + projectKeys: toggleSidebarFilterValues( + sidebarThreadFilters.projectKeys, + projectKeys, + nextChecked, + ), + }); + }} + > + - {project.displayName} + + {project.displayName} + - - ); - })} - + + + ); + })} + {orderedThreads.flatMap((thread, threadIndex) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const isSettledRow = settledThreadKeys.has(threadKey); + const isArchivedRow = archivedThreadKeys.has(threadKey); + const isSettledRow = !isArchivedRow && settledThreadKeys.has(threadKey); // Settled is the ONLY thing that collapses a row: every // not-settled thread is a full card. Density comes from users // (or the auto rules) actually settling work, not from the // sidebar second-guessing what still matters. - const isCard = !isSettledRow; + const isCard = !isSettledRow && !isArchivedRow; const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; const previousWasCard = previousThread != null && @@ -1932,6 +2159,14 @@ export default function SidebarV2() { ), ); const showSettledGap = !isCard && previousWasCard; + const showArchivedGap = + isArchivedRow && + (previousThread == null || + !archivedThreadKeys.has( + scopedThreadKey( + scopeThreadRef(previousThread.environmentId, previousThread.id), + ), + )); const row = ( ); - if (!showSettledGap) return [row]; + if (showArchivedGap) { + return [ + ...(showMoreSettledRow ? [showMoreSettledRow] : []), +
  • +
    + + Archived + + +
    +
  • , + row, + ]; + } + if (!showSettledGap || isArchivedRow) return [row]; // The divider is its own keyed list item (not part of the first // settled row): it keeps one stable DOM node at the boundary, // so settling a thread slides it instead of teleporting it @@ -1996,20 +2253,7 @@ export default function SidebarV2() { row, ]; })} - {hiddenSettledCount > 0 ? ( -
  • - -
  • - ) : null} + {filteredArchivedThreads.length === 0 ? showMoreSettledRow : null} {orderedThreads.length === 0 ? ( @@ -2026,8 +2270,8 @@ export default function SidebarV2() { Add project - ) : scopedProjectGroup ? ( - `No threads in ${scopedProjectGroup.displayName} yet` + ) : filtersActive ? ( + "No threads match these filters" ) : ( "No threads yet" )} diff --git a/apps/web/src/components/sidebar/SidebarFilterMenu.tsx b/apps/web/src/components/sidebar/SidebarFilterMenu.tsx new file mode 100644 index 0000000000000..ead025e52a405 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarFilterMenu.tsx @@ -0,0 +1,339 @@ +import { + DEFAULT_SIDEBAR_THREAD_FILTERS, + SIDEBAR_THREAD_FILTER_STATUSES, + type SidebarThreadFilters, + type SidebarThreadFilterStatus, +} from "@t3tools/contracts/settings"; +import type { EnvironmentId, ProviderDriverKind } from "@t3tools/contracts"; +import { ListFilterIcon, LoaderIcon, TriangleAlertIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { hasActiveSidebarThreadFilters } from "../Sidebar.logic"; +import { + Menu, + MenuCheckboxItem, + MenuItem, + MenuPopup, + MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +const SIDEBAR_THREAD_FILTER_STATUS_LABELS: Record = { + needs_attention: "Needs attention", + unread: "Unread", + working: "Working", + done: "Done", +}; + +export interface SidebarFilterProjectOption { + readonly id: string; + readonly label: string; + readonly projectKeys: readonly string[]; +} + +export interface SidebarFilterEnvironmentOption { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export interface SidebarFilterProviderOption { + readonly driverKind: ProviderDriverKind; + readonly label: string; +} + +export function toggleSidebarFilterValue(values: readonly T[], value: T, checked: boolean): T[] { + if (checked) { + return values.includes(value) ? [...values] : [...values, value]; + } + return values.filter((candidate) => candidate !== value); +} + +export function toggleSidebarFilterValues( + values: readonly T[], + toggledValues: readonly T[], + checked: boolean, +): T[] { + const next = new Set(values); + for (const value of toggledValues) { + if (checked) { + next.add(value); + } else { + next.delete(value); + } + } + return [...next]; +} + +export function SidebarFilterMenu({ + filters, + environments, + projects, + providerSources, + archiveLoading, + archiveError, + onFiltersChange, + showGroupByProject = true, + triggerClassName, +}: { + filters: SidebarThreadFilters; + environments: ReadonlyArray; + projects: ReadonlyArray; + providerSources: ReadonlyArray; + archiveLoading: boolean; + archiveError: string | null; + onFiltersChange: (filters: SidebarThreadFilters) => void; + showGroupByProject?: boolean; + triggerClassName?: string; +}) { + const filtersActive = hasActiveSidebarThreadFilters(filters); + const statusFilterActive = + filters.statuses.length !== SIDEBAR_THREAD_FILTER_STATUSES.length || + !SIDEBAR_THREAD_FILTER_STATUSES.every((status) => filters.statuses.includes(status)); + + return ( + + + + } + > + + {filtersActive ? ( + + ) : null} + + + {filtersActive ? "Sidebar filters are active" : "Filter sidebar threads"} + + + + {showGroupByProject ? ( + <> + { + onFiltersChange({ ...filters, groupByProject: checked }); + }} + > + Group by project + + + + ) : null} +
    + Filters + { + onFiltersChange({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + groupByProject: filters.groupByProject, + }); + }} + > + Reset + +
    + { + onFiltersChange({ ...filters, recentOnly: checked }); + }} + > + + Recent + Last 7 days + + + { + onFiltersChange({ ...filters, attentionOnly: checked }); + }} + > + + Inbox + Unread + needs attention + + + + + + Status + {statusFilterActive ? ( + + ) : null} + + + {SIDEBAR_THREAD_FILTER_STATUSES.map((status) => ( + { + onFiltersChange({ + ...filters, + statuses: toggleSidebarFilterValue(filters.statuses, status, checked), + }); + }} + > + {SIDEBAR_THREAD_FILTER_STATUS_LABELS[status]} + + ))} + + + + + Project + {filters.projectKeys.length > 0 ? ( + + ) : null} + + + {projects.map((project) => { + const checked = + filters.projectKeys.length > 0 && + project.projectKeys.every((projectKey) => filters.projectKeys.includes(projectKey)); + return ( + { + onFiltersChange({ + ...filters, + projectKeys: toggleSidebarFilterValues( + filters.projectKeys, + project.projectKeys, + nextChecked, + ), + }); + }} + > + {project.label} + + ); + })} + {projects.length === 0 ? ( + + No projects + + ) : null} + + + + + Environment + {filters.environmentIds.length > 0 ? ( + + ) : null} + + + {environments.map((environment) => ( + { + onFiltersChange({ + ...filters, + environmentIds: toggleSidebarFilterValue( + filters.environmentIds, + environment.environmentId, + checked, + ), + }); + }} + > + {environment.label} + + ))} + {environments.length === 0 ? ( + + No environments + + ) : null} + + + + + Source + {filters.sources.length > 0 ? ( + + ) : null} + + + {providerSources.map((source) => ( + { + onFiltersChange({ + ...filters, + sources: toggleSidebarFilterValue(filters.sources, source.driverKind, checked), + }); + }} + > + {source.label} + + ))} + {providerSources.length === 0 ? ( + + No providers + + ) : null} + + + + { + onFiltersChange({ ...filters, includeArchived: checked }); + }} + > + + Archived + {archiveLoading ? : null} + {archiveError ? ( + + + + ) : null} + + +
    +
    + ); +} diff --git a/apps/web/src/components/sidebar/sidebarProviderFilters.test.ts b/apps/web/src/components/sidebar/sidebarProviderFilters.test.ts new file mode 100644 index 0000000000000..571c2671bc338 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarProviderFilters.test.ts @@ -0,0 +1,70 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildSidebarProviderFilterState, + sidebarProviderInstanceKey, +} from "./sidebarProviderFilters"; + +function provider(instanceId: string, driverKind: string): ServerProvider { + return { + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make(driverKind), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-07-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; +} + +function config(providers: readonly ServerProvider[]): ServerConfig { + return { providers } as ServerConfig; +} + +describe("buildSidebarProviderFilterState", () => { + it("keeps provider instance lookups scoped to their environment", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const state = buildSidebarProviderFilterState( + new Map([ + [localEnvironmentId, config([provider("shared", "codex")])], + [remoteEnvironmentId, config([provider("shared", "claudeAgent")])], + ]), + ); + + expect( + state.driverKindByScopedInstanceKey.get( + sidebarProviderInstanceKey(localEnvironmentId, ProviderInstanceId.make("shared")), + ), + ).toBe("codex"); + expect( + state.driverKindByScopedInstanceKey.get( + sidebarProviderInstanceKey(remoteEnvironmentId, ProviderInstanceId.make("shared")), + ), + ).toBe("claudeAgent"); + }); + + it("includes provider sources that exist only on remote environments", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const state = buildSidebarProviderFilterState( + new Map([ + [localEnvironmentId, config([provider("codex", "codex")])], + [remoteEnvironmentId, config([provider("grok-remote", "grok")])], + ]), + ); + + expect(state.sources.map((source) => source.driverKind)).toEqual(["codex", "grok"]); + }); +}); diff --git a/apps/web/src/components/sidebar/sidebarProviderFilters.ts b/apps/web/src/components/sidebar/sidebarProviderFilters.ts new file mode 100644 index 0000000000000..6cc877ac61c16 --- /dev/null +++ b/apps/web/src/components/sidebar/sidebarProviderFilters.ts @@ -0,0 +1,46 @@ +import { + PROVIDER_DISPLAY_NAMES, + type EnvironmentId, + type ProviderDriverKind, + type ServerConfig, +} from "@t3tools/contracts"; + +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../../providerInstances"; +import { sidebarProviderInstanceKey } from "../Sidebar.logic"; +import type { SidebarFilterProviderOption } from "./SidebarFilterMenu"; + +export { sidebarProviderInstanceKey } from "../Sidebar.logic"; + +export interface SidebarProviderFilterState { + readonly entryByScopedInstanceKey: ReadonlyMap; + readonly driverKindByScopedInstanceKey: ReadonlyMap; + readonly sources: ReadonlyArray; +} + +export function buildSidebarProviderFilterState( + serverConfigs: ReadonlyMap, +): SidebarProviderFilterState { + const entryByScopedInstanceKey = new Map(); + const driverKindByScopedInstanceKey = new Map(); + const labelsByDriverKind = new Map(); + + for (const [environmentId, config] of serverConfigs) { + for (const entry of deriveProviderInstanceEntries(config.providers)) { + const key = sidebarProviderInstanceKey(environmentId, entry.instanceId); + entryByScopedInstanceKey.set(key, entry); + driverKindByScopedInstanceKey.set(key, entry.driverKind); + if (!labelsByDriverKind.has(entry.driverKind)) { + labelsByDriverKind.set( + entry.driverKind, + PROVIDER_DISPLAY_NAMES[entry.driverKind] ?? entry.displayName, + ); + } + } + } + + return { + entryByScopedInstanceKey, + driverKindByScopedInstanceKey, + sources: [...labelsByDriverKind].map(([driverKind, label]) => ({ driverKind, label })), + }; +} From 16e472004847956af15625008e73666302a23d69 Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 19:17:11 -0400 Subject: [PATCH 28/30] fix(web): align flat thread jump targets --- apps/web/src/components/Sidebar.logic.test.ts | 50 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 30 +++++++++++ apps/web/src/components/Sidebar.tsx | 14 ++++-- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 2c5028c09b688..3d9d5d8c74285 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -5,6 +5,7 @@ import { classifySidebarThreadFilterStatus, createThreadJumpHintVisibilityController, filterSidebarThreadsForActiveRoute, + getFlatSidebarRenderedThreads, getSidebarRangeSelectionThreadKeys, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, @@ -1942,6 +1943,55 @@ function makeThread(overrides: Partial = {}): Thread { }; } +describe("getFlatSidebarRenderedThreads", () => { + it("uses only project refs represented by the flat row", () => { + const projectId = ProjectId.make("project-visible"); + const threads = getFlatSidebarRenderedThreads({ + threads: [ + makeThread({ + id: ThreadId.make("thread-orphan"), + projectId: ProjectId.make("project-missing"), + updatedAt: "2026-03-09T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-visible"), + projectId, + updatedAt: "2026-03-09T11:00:00.000Z", + }), + ], + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId }], + sortOrder: "updated_at", + }); + + expect(threads.map((thread) => thread.id)).toEqual([ThreadId.make("thread-visible")]); + }); + + it("keeps normal flat rows in the configured thread order", () => { + const projectId = ProjectId.make("project-visible"); + const threads = getFlatSidebarRenderedThreads({ + threads: [ + makeThread({ + id: ThreadId.make("thread-old"), + projectId, + updatedAt: "2026-03-09T10:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-new"), + projectId, + updatedAt: "2026-03-09T11:00:00.000Z", + }), + ], + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId }], + sortOrder: "updated_at", + }); + + expect(threads.map((thread) => thread.id)).toEqual([ + ThreadId.make("thread-new"), + ThreadId.make("thread-old"), + ]); + }); +}); + describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 36892b01cd01f..611bb9211e08e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -477,6 +477,36 @@ export function getSidebarRangeSelectionThreadKeys( return threads.filter((thread) => thread.archivedAt === null).map((thread) => thread.threadKey); } +export function getFlatSidebarRenderedThreads< + TThread extends { + readonly id: string; + readonly environmentId: string; + readonly projectId: string; + readonly archivedAt: string | null; + } & ThreadSortInput, +>(input: { + readonly threads: readonly TThread[]; + readonly memberProjectRefs: readonly { + readonly environmentId: string; + readonly projectId: string; + }[]; + readonly sortOrder: SidebarThreadSortOrder; +}): TThread[] { + const memberProjectKeys = new Set( + input.memberProjectRefs.map( + (projectRef) => `${projectRef.environmentId}\u0000${projectRef.projectId}`, + ), + ); + return sortThreads( + input.threads.filter( + (thread) => + thread.archivedAt === null && + memberProjectKeys.has(`${thread.environmentId}\u0000${thread.projectId}`), + ), + input.sortOrder, + ); +} + export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { if (target === null) return true; return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index eb93cc79a4395..c41a14101f834 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -197,6 +197,7 @@ import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, filterSidebarThreadsForActiveRoute, + getFlatSidebarRenderedThreads, getSidebarRangeSelectionThreadKeys, getSidebarThreadIdsToPrewarm, getVisibleThreadsForProject, @@ -4051,10 +4052,15 @@ export default function Sidebar() { const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo(() => { if (!sidebarThreadFilters.groupByProject) { - const sortedThreads = sortThreads(filteredSidebarThreads, sidebarThreadSortOrder); - return sortedThreads - .filter((thread) => thread.archivedAt === null) - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); + const flatProject = buildFlatSidebarProjectSnapshot(sortedProjects); + if (!flatProject) { + return []; + } + return getFlatSidebarRenderedThreads({ + threads: filteredSidebarThreads, + memberProjectRefs: flatProject.memberProjectRefs, + sortOrder: sidebarThreadSortOrder, + }).map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); } return sortedProjects.flatMap((project) => { From 5ac338eb6c9c35fbd9327e9ebea3be4ede8c1e2a Mon Sep 17 00:00:00 2001 From: "Daniel L. Iser" Date: Thu, 23 Jul 2026 19:57:02 -0400 Subject: [PATCH 29/30] fix(web): address sidebar grouping review feedback --- apps/web/src/components/Sidebar.logic.test.ts | 76 ++++++++- apps/web/src/components/Sidebar.logic.ts | 76 +++++++-- apps/web/src/components/Sidebar.tsx | 73 +++++++-- apps/web/src/components/SidebarV2.tsx | 149 ++++++++++++------ apps/web/src/environmentGrouping.test.ts | 10 +- apps/web/src/sidebarProjectGrouping.ts | 43 ++++- 6 files changed, 345 insertions(+), 82 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 3d9d5d8c74285..01c13325a058a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -6,6 +6,7 @@ import { createThreadJumpHintVisibilityController, filterSidebarThreadsForActiveRoute, getFlatSidebarRenderedThreads, + getGroupedSidebarRenderedThreads, getSidebarRangeSelectionThreadKeys, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, @@ -22,6 +23,7 @@ import { orderItemsByPreferredIds, resolveProjectStatusIndicator, resolvePinnedCollapsedSidebarThread, + removeProjectKeysFromSidebarThreadFilters, resolveSidebarArchiveEnvironmentIds, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, @@ -690,6 +692,14 @@ describe("sidebar filter scoping", () => { includeArchived: false, }), ).toEqual([]); + expect( + resolveSidebarArchiveEnvironmentIds({ + availableEnvironmentIds: [localEnvironmentId, remoteEnvironmentId], + selectedEnvironmentIds: [], + includeArchived: false, + requiredEnvironmentIds: [remoteEnvironmentId], + }), + ).toEqual([remoteEnvironmentId]); }); it("excludes archived rows from shift-range selection order", () => { @@ -701,6 +711,70 @@ describe("sidebar filter scoping", () => { ]), ).toEqual(["active-1", "active-2"]); }); + + it("budgets grouped previews from active rows before appending archived rows", () => { + const activeOne = { id: "active-1", archivedAt: null }; + const activeTwo = { id: "active-2", archivedAt: null }; + const activeThree = { id: "active-3", archivedAt: null }; + const archivedOne = { id: "archived-1", archivedAt: "2026-03-09T11:00:00.000Z" }; + const archivedTwo = { id: "archived-2", archivedAt: "2026-03-09T10:00:00.000Z" }; + + const result = getGroupedSidebarRenderedThreads({ + threads: [archivedOne, activeOne, archivedTwo, activeTwo, activeThree], + activeThreadKey: null, + getThreadKey: (thread) => thread.id, + previewLimit: 2, + showAllThreads: false, + }); + + expect(result.hasOverflowingThreads).toBe(true); + expect(result.renderedThreads.map((thread) => thread.id)).toEqual([ + "active-1", + "active-2", + "archived-1", + "archived-2", + ]); + expect(result.hiddenThreads.map((thread) => thread.id)).toEqual(["active-3"]); + }); + + it("pins an active row below the grouped preview without charging archived rows", () => { + const threads = [ + { id: "active-1", archivedAt: null }, + { id: "archived-1", archivedAt: "2026-03-09T11:00:00.000Z" }, + { id: "active-2", archivedAt: null }, + { id: "active-3", archivedAt: null }, + { id: "active-4", archivedAt: null }, + ]; + + const result = getGroupedSidebarRenderedThreads({ + threads, + activeThreadKey: "active-4", + getThreadKey: (thread) => thread.id, + previewLimit: 2, + showAllThreads: false, + }); + + expect(result.hasOverflowingThreads).toBe(true); + expect(result.renderedThreads.map((thread) => thread.id)).toEqual([ + "active-1", + "active-2", + "active-4", + "archived-1", + ]); + expect(result.hiddenThreads.map((thread) => thread.id)).toEqual(["active-3"]); + }); + + it("removes only deleted project keys from persisted sidebar filters", () => { + expect( + removeProjectKeysFromSidebarThreadFilters( + { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + projectKeys: ["environment-local:project-1", "environment-remote:project-2"], + }, + new Set(["environment-local:project-1"]), + ).projectKeys, + ).toEqual(["environment-remote:project-2"]); + }); }); describe("archiveSelectedThreadEntries", () => { @@ -2197,7 +2271,7 @@ describe("sortProjectsForSidebar", () => { updatedAt: "2026-03-09T10:10:00.000Z", archivedAt: "2026-03-09T10:11:00.000Z", }), - ].filter((thread) => thread.archivedAt === null), + ], "updated_at", ); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 611bb9211e08e..8c91119b45282 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -454,17 +454,15 @@ export function resolveSidebarArchiveEnvironmentIds(input: { readonly availableEnvironmentIds: readonly EnvironmentId[]; readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly includeArchived: boolean; + readonly requiredEnvironmentIds?: readonly EnvironmentId[]; }): EnvironmentId[] { - if (!input.includeArchived) { - return []; - } - if (input.selectedEnvironmentIds.length === 0) { - return [...input.availableEnvironmentIds]; - } - const selectedEnvironmentIds = new Set(input.selectedEnvironmentIds); - return input.availableEnvironmentIds.filter((environmentId) => - selectedEnvironmentIds.has(environmentId), + const requiredEnvironmentIds = new Set(input.requiredEnvironmentIds); + return input.availableEnvironmentIds.filter( + (environmentId) => + requiredEnvironmentIds.has(environmentId) || + (input.includeArchived && + (selectedEnvironmentIds.size === 0 || selectedEnvironmentIds.has(environmentId))), ); } @@ -507,6 +505,60 @@ export function getFlatSidebarRenderedThreads< ); } +export function getGroupedSidebarRenderedThreads< + TThread extends { + readonly archivedAt: string | null; + }, +>(input: { + readonly threads: readonly TThread[]; + readonly activeThreadKey: string | null; + readonly getThreadKey: (thread: TThread) => string; + readonly previewLimit: number | null; + readonly showAllThreads: boolean; +}): { + readonly hasOverflowingThreads: boolean; + readonly hiddenThreads: TThread[]; + readonly renderedThreads: TThread[]; +} { + const activeThreads = input.threads.filter((thread) => thread.archivedAt === null); + const archivedThreads = input.threads.filter((thread) => thread.archivedAt !== null); + const activePreview = + input.previewLimit === null + ? { + hasHiddenThreads: false, + hiddenThreads: [], + visibleThreads: activeThreads, + } + : getVisibleThreadsForProject({ + threads: activeThreads, + activeThreadKey: input.activeThreadKey, + getThreadKey: input.getThreadKey, + isThreadListExpanded: input.showAllThreads, + previewLimit: input.previewLimit, + }); + + return { + hasOverflowingThreads: activePreview.hasHiddenThreads, + hiddenThreads: activePreview.hiddenThreads, + renderedThreads: [...activePreview.visibleThreads, ...archivedThreads], + }; +} + +export function removeProjectKeysFromSidebarThreadFilters( + filters: SidebarThreadFilters, + removedProjectKeys: ReadonlySet, +): SidebarThreadFilters { + const projectKeys = filters.projectKeys.filter( + (projectKey) => !removedProjectKeys.has(projectKey), + ); + return projectKeys.length === filters.projectKeys.length + ? filters + : { + ...filters, + projectKeys, + }; +} + export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { if (target === null) return true; return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); @@ -1106,7 +1158,10 @@ function sortProjectsByActivity( export function sortProjectsForSidebar< TProject extends SidebarProject, - TThread extends Pick & ThreadSortInput, + TThread extends Pick & + ThreadSortInput & { + readonly archivedAt?: string | null; + }, >( projects: readonly TProject[], threads: readonly TThread[], @@ -1114,6 +1169,7 @@ export function sortProjectsForSidebar< ): TProject[] { const threadsByProjectId = new Map(); for (const thread of threads) { + if (thread.archivedAt != null) continue; const existing = threadsByProjectId.get(thread.projectId) ?? []; existing.push(thread); threadsByProjectId.set(thread.projectId, existing); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c41a14101f834..c68e21d761f81 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -198,9 +198,9 @@ import { buildMultiSelectThreadContextMenuItems, filterSidebarThreadsForActiveRoute, getFlatSidebarRenderedThreads, + getGroupedSidebarRenderedThreads, getSidebarRangeSelectionThreadKeys, getSidebarThreadIdsToPrewarm, - getVisibleThreadsForProject, hasActiveSidebarThreadFilters, hasNarrowingSidebarThreadFilters, matchesSidebarThreadFilters, @@ -209,6 +209,7 @@ import { isTrailingDoubleClick, resolvePinnedCollapsedSidebarThread, resolveProjectStatusIndicator, + removeProjectKeysFromSidebarThreadFilters, resolveSidebarArchiveEnvironmentIds, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, @@ -1504,23 +1505,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec flatMode, isThreadListExpanded, }); - const { - hasHiddenThreads, - hiddenThreads, - visibleThreads: previewThreads, - } = getVisibleThreadsForProject({ + const preview = getGroupedSidebarRenderedThreads({ threads: visibleProjectThreads, activeThreadKey: activeRouteThreadKey, getThreadKey: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - isThreadListExpanded: showAllThreads, - previewLimit: sidebarThreadPreviewCount, + previewLimit: flatMode ? null : sidebarThreadPreviewCount, + showAllThreads, }); - const hasOverflowingThreads = !flatMode && hasHiddenThreads; - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + const renderedThreads = pinnedCollapsedThread + ? [pinnedCollapsedThread] + : preview.renderedThreads; return { - hasOverflowingThreads, + hasOverflowingThreads: preview.hasOverflowingThreads, hiddenThreadStatus: resolveProjectStatusIndicator( - hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), + preview.hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ), renderedThreads, showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0, @@ -1639,9 +1637,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec draftStore.clearDraftThread(projectDraftThread.draftId); } draftStore.clearProjectDraftThreadId(memberProjectRef); + const nextSidebarThreadFilters = removeProjectKeysFromSidebarThreadFilters( + sidebarThreadFilters, + new Set([scopedProjectKey(memberProjectRef)]), + ); + if (nextSidebarThreadFilters !== sidebarThreadFilters) { + updateSettings({ sidebarThreadFilters: nextSidebarThreadFilters }); + } return result; }, - [deleteProject], + [deleteProject, sidebarThreadFilters, updateSettings], ); const handleRemoveProject = useCallback( @@ -3398,6 +3403,19 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( () => buildFlatSidebarProjectSnapshot(sortedProjects), [sortedProjects], ); + const { isMobile, setOpenMobile } = useSidebar(); + const handleFlatNewThread = useCallback(() => { + if (!flatProject) return; + if (flatProject.memberProjects.length !== 1) { + openCommandPalette({ open: "new-thread-in" }); + return; + } + const member = flatProject.memberProjects[0]!; + if (isMobile) { + setOpenMobile(false); + } + void handleNewThread(scopeProjectRef(member.environmentId, member.id)); + }, [flatProject, handleNewThread, isMobile, setOpenMobile]); return ( @@ -3454,6 +3472,26 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( {sidebarThreadFilters.groupByProject ? "Projects" : "Threads"}
    + {!sidebarThreadFilters.groupByProject && flatProject ? ( + + + } + > + + + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + + ) : null} thread.archivedAt === null).map(getThreadKey); }); }, [ diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f34576ebe6d64..37e468908e199 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -73,6 +73,7 @@ import { } from "../logicalProject"; import { buildSidebarProjectSnapshots, + getSidebarProjectRemovalRefs, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; @@ -104,8 +105,10 @@ import { isTrailingDoubleClick, matchesSidebarThreadFilters, orderItemsByPreferredIds, + removeProjectKeysFromSidebarThreadFilters, resolveAdjacentThreadId, resolveSettledTimestamp, + resolveSidebarArchiveEnvironmentIds, resolveSidebarV2Status, resolveSidebarV2TopStatus, resolveWorkingStartedAt, @@ -1014,18 +1017,31 @@ export default function SidebarV2() { ), [projectGroups, sidebarThreadFilters.projectKeys], ); - const archiveEnvironmentIds = useMemo( + const projectActionEnvironmentIds = useMemo( () => - sidebarThreadFilters.includeArchived - ? environments - .map((environment) => environment.environmentId) - .filter( - (environmentId) => - sidebarThreadFilters.environmentIds.length === 0 || - sidebarThreadFilters.environmentIds.includes(environmentId), - ) + projectActionsTarget + ? [ + ...new Set( + projectActionsTarget.memberProjectRefs.map((projectRef) => projectRef.environmentId), + ), + ] : [], - [environments, sidebarThreadFilters.environmentIds, sidebarThreadFilters.includeArchived], + [projectActionsTarget], + ); + const archiveEnvironmentIds = useMemo( + () => + resolveSidebarArchiveEnvironmentIds({ + availableEnvironmentIds: environments.map((environment) => environment.environmentId), + selectedEnvironmentIds: sidebarThreadFilters.environmentIds, + includeArchived: sidebarThreadFilters.includeArchived, + requiredEnvironmentIds: projectActionEnvironmentIds, + }), + [ + environments, + projectActionEnvironmentIds, + sidebarThreadFilters.environmentIds, + sidebarThreadFilters.includeArchived, + ], ); const { snapshots: archivedSnapshots, @@ -1041,6 +1057,34 @@ export default function SidebarV2() { ), [archivedSnapshots], ); + const projectByRefKey = useMemo( + () => + new Map( + projects.map( + (project) => + [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + project, + ] as const, + ), + ), + [projects], + ); + const threadsWithArchived = useMemo(() => { + const byKey = new Map( + threads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ); + for (const thread of archivedThreads) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if (!byKey.has(threadKey)) { + byKey.set(threadKey, thread); + } + } + return [...byKey.values()]; + }, [archivedThreads, threads]); const projectCwdByKey = useMemo( () => new Map( @@ -1133,11 +1177,16 @@ export default function SidebarV2() { const handleRemoveProjectMembers = useCallback( async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => { const api = readLocalApi(); - if (!api) return; + if (!api || archiveLoading || archiveError !== null) return; - const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`)); - const projectThreads = threads.filter((thread) => - memberKeys.has(`${thread.environmentId}:${thread.projectId}`), + const projectRefs = getSidebarProjectRemovalRefs({ + projectGroup, + members, + projects, + }); + const memberKeys = new Set(projectRefs.map(scopedProjectKey)); + const projectThreads = threadsWithArchived.filter((thread) => + memberKeys.has(scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId))), ); const isWholeGroup = members.length === projectGroup.memberProjects.length; const singleMember = members.length === 1 ? members[0]! : null; @@ -1154,7 +1203,7 @@ export default function SidebarV2() { ? [`Environment: ${singleMember.environmentLabel}`] : []), ] - : [`This removes ${members.length} grouped project entries.`]), + : [`This removes ${projectRefs.length} grouped project entries.`]), "This permanently clears conversation history for those threads.", isWholeGroup ? "This removes only the project entries, not the files on disk." @@ -1170,7 +1219,7 @@ export default function SidebarV2() { ? [`Environment: ${singleMember.environmentLabel}`] : []), ] - : [`This removes ${members.length} grouped project entries.`]), + : [`This removes ${projectRefs.length} grouped project entries.`]), isWholeGroup ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", @@ -1181,12 +1230,12 @@ export default function SidebarV2() { const draftStore = useComposerDraftStore.getState(); let shouldNavigate = false; - for (const project of members) { + for (const projectRef of projectRefs) { const memberThreads = projectThreads.filter( (thread) => - thread.environmentId === project.environmentId && thread.projectId === project.id, + thread.environmentId === projectRef.environmentId && + thread.projectId === projectRef.projectId, ); - const projectRef = scopeProjectRef(project.environmentId, project.id); const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({ routeTarget: routeTargetRef.current, @@ -1195,9 +1244,9 @@ export default function SidebarV2() { }); const result = await deleteProject({ - environmentId: project.environmentId, + environmentId: projectRef.environmentId, input: { - projectId: project.id, + projectId: projectRef.projectId, ...(memberThreads.length > 0 ? { force: true } : {}), }, }); @@ -1207,7 +1256,9 @@ export default function SidebarV2() { toastManager.add( stackedThreadToast({ type: "error", - title: `Failed to remove "${project.title}"`, + title: `Failed to remove "${ + projectByRefKey.get(scopedProjectKey(projectRef))?.title ?? targetLabel + }"`, description: error instanceof Error ? error.message : "An error occurred.", }), ); @@ -1225,25 +1276,29 @@ export default function SidebarV2() { draftStore.clearProjectDraftThreadId(projectRef); } - const removedProjectKeys = new Set( - members.map((member) => scopedProjectKey(scopeProjectRef(member.environmentId, member.id))), + const nextSidebarThreadFilters = removeProjectKeysFromSidebarThreadFilters( + sidebarThreadFilters, + new Set(projectRefs.map(scopedProjectKey)), ); - if ( - sidebarThreadFilters.projectKeys.some((projectKey) => removedProjectKeys.has(projectKey)) - ) { - handleSidebarThreadFiltersChange({ - ...sidebarThreadFilters, - projectKeys: sidebarThreadFilters.projectKeys.filter( - (projectKey) => !removedProjectKeys.has(projectKey), - ), - }); + if (nextSidebarThreadFilters !== sidebarThreadFilters) { + handleSidebarThreadFiltersChange(nextSidebarThreadFilters); } if (shouldNavigate) { void router.navigate({ to: "/" }); } }, - [deleteProject, handleSidebarThreadFiltersChange, router, sidebarThreadFilters, threads], + [ + deleteProject, + archiveError, + archiveLoading, + handleSidebarThreadFiltersChange, + projectByRefKey, + projects, + router, + sidebarThreadFilters, + threadsWithArchived, + ], ); const renameProjectMember = useCallback( @@ -1296,21 +1351,6 @@ export default function SidebarV2() { [], ); - const threadsWithArchived = useMemo(() => { - const byKey = new Map( - threads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ); - for (const thread of archivedThreads) { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - if (!byKey.has(threadKey)) { - byKey.set(threadKey, thread); - } - } - return [...byKey.values()]; - }, [archivedThreads, threads]); const threadLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); const threadExplicitlyUnreadById = useUiStateStore((state) => state.threadExplicitlyUnreadById); const filtersActive = hasActiveSidebarThreadFilters(sidebarThreadFilters); @@ -2293,6 +2333,15 @@ export default function SidebarV2() { ? `${projectActionsTarget.displayName} has an entry in each environment. Changes apply only to the entry you choose.` : `Manage ${projectActionsTarget?.displayName ?? "this project"} in this environment.`} + {archiveLoading ? ( +

    + Checking archived conversations before project removal… +

    + ) : archiveError ? ( +

    + Archived conversations could not be checked, so project removal is unavailable. +

    + ) : null}
    @@ -2405,6 +2454,7 @@ export default function SidebarV2() {