diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9a3eb10a18d0..a86f88737eca 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,11 @@ import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { buildDraftThreadRouteParams } from "../threadRoutes"; +import { + buildDraftThreadRouteParams, + resolveThreadRouteTarget, + shouldKeepActiveThreadVisitOnUnmount, +} from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -1149,10 +1153,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 @@ -1164,6 +1165,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( @@ -1751,23 +1753,37 @@ 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( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, - ); - }, [ - activeThreadLastVisitedAt, - markThreadVisited, - serverThread?.environmentId, - serverThread?.id, - serverThread?.updatedAt, - ]); + if (routeKind !== "server") { + markActiveThreadVisited(routeThreadKey, null); + return; + } + 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 ( + shouldKeepActiveThreadVisitOnUnmount({ + currentRouteTarget, + routeThreadKey, + draftId, + }) + ) { + return; + } + if (useUiStateStore.getState().activeThreadVisit?.threadId === routeThreadKey) { + markActiveThreadVisited(null, null); + } + }, + [draftId, markActiveThreadVisited, routeThreadKey, router], + ); const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 65424b830a2c..01c13325a058 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + classifySidebarThreadFilterStatus, createThreadJumpHintVisibilityController, + filterSidebarThreadsForActiveRoute, + getFlatSidebarRenderedThreads, + getGroupedSidebarRenderedThreads, + getSidebarRangeSelectionThreadKeys, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -10,35 +15,48 @@ import { getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, + hasActiveSidebarThreadFilters, + hasNarrowingSidebarThreadFilters, isContextMenuPointerDown, isTrailingDoubleClick, + matchesSidebarThreadFilters, orderItemsByPreferredIds, resolveProjectStatusIndicator, + resolvePinnedCollapsedSidebarThread, + removeProjectKeysFromSidebarThreadFilters, + resolveSidebarArchiveEnvironmentIds, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, + resolveSidebarThreadFilterStatuses, resolveThreadRowClassName, resolveSidebarV2Status, + resolveSidebarV2TopStatus, resolveThreadStatusPill, resolveWorkingStartedAt, formatWorkingDurationLabel, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, + shouldShowAllSidebarThreads, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, + sidebarProviderInstanceKey, + SIDEBAR_RECENT_FILTER_REFRESH_MS, + startSidebarRecentFilterClock, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; 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, @@ -98,6 +116,667 @@ describe("shouldNavigateAfterProjectRemoval", () => { }); }); +const filterableThread = { + archivedAt: null, + createdAt: "2026-03-09T09:00:00.000Z", + environmentId: localEnvironmentId, + projectId: ProjectId.make("project-1"), + 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", () => { + 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, + isExplicitlyUnread: true, + }), + ).toBe("unread"); + expect(classifySidebarThreadFilterStatus(filterableThread)).toBe("done"); + }); + + it("keeps needs-attention status when a thread is also explicitly unread", () => { + expect( + classifySidebarThreadFilterStatus({ + ...filterableThread, + hasPendingUserInput: true, + isExplicitlyUnread: true, + }), + ).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, + 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], + projectKeys: ["environment-local:project-1"], + sources: [ProviderDriverKind.make("codex")], + recentOnly: false, + attentionOnly: false, + includeArchived: true, + groupByProject: true, + }, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: filterableThread, + providerDriverKind: ProviderDriverKind.make("claudeAgent"), + filters: { + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + sources: [ProviderDriverKind.make("codex")], + }, + }), + ).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("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, + attentionOnly: true, + }; + expect( + matchesSidebarThreadFilters({ + thread: { ...filterableThread, hasPendingUserInput: true }, + providerDriverKind: ProviderDriverKind.make("codex"), + 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: { + ...filterableThread, + latestTurn: makeLatestTurn(), + }, + lastVisitedAt: "2026-03-09T10:04:00.000Z", + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: filterableThread, + isExplicitlyUnread: true, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + }), + ).toBe(true); + expect( + matchesSidebarThreadFilters({ + thread: filterableThread, + providerDriverKind: ProviderDriverKind.make("codex"), + 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, + 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", + createdAt: "2026-03-03T09:00:00.000Z", + }, + providerDriverKind: ProviderDriverKind.make("codex"), + filters, + nowMs, + }), + ).toBe(false); + expect( + matchesSidebarThreadFilters({ + thread: { + ...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("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); + expect( + hasActiveSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + statuses: ["needs_attention", "unread", "working"], + }), + ).toBe(true); + expect( + hasActiveSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + includeArchived: true, + }), + ).toBe(true); + expect( + hasNarrowingSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + includeArchived: true, + }), + ).toBe(false); + expect( + hasActiveSidebarThreadFilters({ + ...DEFAULT_SIDEBAR_THREAD_FILTERS, + attentionOnly: true, + }), + ).toBe(true); + expect( + hasActiveSidebarThreadFilters({ + ...DEFAULT_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); + }); +}); + +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"); + + it("scopes provider instance ids by environment", () => { + const instanceId = ProviderInstanceId.make("codex_work"); + + expect(sidebarProviderInstanceKey(localEnvironmentId, instanceId)).not.toBe( + sidebarProviderInstanceKey(remoteEnvironmentId, instanceId), + ); + }); + + it("loads archived snapshots only for selected environments", () => { + expect( + resolveSidebarArchiveEnvironmentIds({ + availableEnvironmentIds: [localEnvironmentId, remoteEnvironmentId], + selectedEnvironmentIds: [remoteEnvironmentId], + includeArchived: true, + }), + ).toEqual([remoteEnvironmentId]); + expect( + resolveSidebarArchiveEnvironmentIds({ + availableEnvironmentIds: [localEnvironmentId, remoteEnvironmentId], + selectedEnvironmentIds: [], + includeArchived: true, + }), + ).toEqual([localEnvironmentId, remoteEnvironmentId]); + expect( + resolveSidebarArchiveEnvironmentIds({ + availableEnvironmentIds: [localEnvironmentId, remoteEnvironmentId], + selectedEnvironmentIds: [remoteEnvironmentId], + includeArchived: false, + }), + ).toEqual([]); + expect( + resolveSidebarArchiveEnvironmentIds({ + availableEnvironmentIds: [localEnvironmentId, remoteEnvironmentId], + selectedEnvironmentIds: [], + includeArchived: false, + requiredEnvironmentIds: [remoteEnvironmentId], + }), + ).toEqual([remoteEnvironmentId]); + }); + + it("excludes archived rows from shift-range selection order", () => { + expect( + getSidebarRangeSelectionThreadKeys([ + { threadKey: "active-1", archivedAt: null }, + { threadKey: "archived", archivedAt: "2026-03-09T11:00:00.000Z" }, + { threadKey: "active-2", archivedAt: null }, + ]), + ).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", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; const success = { _tag: "Success" } as const; @@ -762,6 +1441,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, @@ -943,6 +1654,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({ @@ -950,11 +1678,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({ @@ -1099,6 +1839,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({ @@ -1109,7 +1898,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, }); @@ -1136,7 +1926,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, }); @@ -1149,6 +1940,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 { @@ -1200,6 +2017,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({ @@ -1405,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 e7082ca8adad..8c91119b4528 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,17 @@ import * as React from "react"; -import type { ContextMenuItem } from "@t3tools/contracts"; -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { + ContextMenuItem, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { + SIDEBAR_THREAD_FILTER_STATUSES, + type SidebarProjectSortOrder, + type SidebarThreadFilters, + type SidebarThreadFilterStatus, + type SidebarThreadSortOrder, +} from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, sortThreads, @@ -99,6 +110,7 @@ export interface ThreadStatusPill { label: | "Working" | "Connecting" + | "Unread" | "Completed" | "Pending Approval" | "Awaiting Input" @@ -114,6 +126,7 @@ const THREAD_STATUS_PRIORITY: Record = { Working: 3, Connecting: 3, "Plan Ready": 2, + Unread: 1, Completed: 1, }; @@ -127,6 +140,7 @@ type ThreadStatusInput = Pick< | "session" > & { lastVisitedAt?: string | undefined; + isExplicitlyUnread?: boolean | undefined; }; export interface ThreadJumpHintVisibilityController { @@ -232,6 +246,319 @@ export function hasUnseenCompletion(thread: ThreadStatusInput): boolean { return completedAt > lastVisitedAt; } +type SidebarThreadFilterInput = Pick< + SidebarThreadSummary, + | "archivedAt" + | "createdAt" + | "environmentId" + | "projectId" + | "hasActionableProposedPlan" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "interactionMode" + | "latestUserMessageAt" + | "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; +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; + 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 { + 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 (thread.isExplicitlyUnread) { + return "unread"; + } + + if (hasUnseenCompletion(thread)) { + return "unread"; + } + + return "done"; +} + +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; +}): 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.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)) + ) { + return false; + } + + const classifiedThread = { + ...thread, + ...(input.lastVisitedAt ? { lastVisitedAt: input.lastVisitedAt } : {}), + ...(input.isExplicitlyUnread ? { isExplicitlyUnread: true } : {}), + }; + const status = classifySidebarThreadFilterStatus(classifiedThread); + const isUnread = + classifiedThread.isExplicitlyUnread === true || hasUnseenCompletion(classifiedThread); + if (filters.attentionOnly && status !== "needs_attention" && !isUnread) { + return false; + } + if (filters.recentOnly) { + const recentTimestampMs = latestValidTimestampMs( + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt ?? null, + thread.latestTurn?.startedAt ?? null, + thread.latestTurn?.completedAt ?? null, + thread.updatedAt, + thread.createdAt, + ); + if ( + recentTimestampMs === null || + recentTimestampMs < (input.nowMs ?? Date.now()) - SIDEBAR_RECENT_WINDOW_MS + ) { + return false; + } + } + const selectedStatuses = resolveSidebarThreadFilterStatuses(filters.statuses); + return selectedStatuses.includes(status) || (isUnread && selectedStatuses.includes("unread")); +} + +export function hasNarrowingSidebarThreadFilters(filters: SidebarThreadFilters): boolean { + const selectedStatuses = new Set(resolveSidebarThreadFilterStatuses(filters.statuses)); + const includesEveryStatus = SIDEBAR_THREAD_FILTER_STATUSES.every((status) => + selectedStatuses.has(status), + ); + return ( + filters.attentionOnly || + filters.recentOnly || + filters.environmentIds.length > 0 || + filters.projectKeys.length > 0 || + filters.sources.length > 0 || + selectedStatuses.size !== SIDEBAR_THREAD_FILTER_STATUSES.length || + !includesEveryStatus + ); +} + +export function hasActiveSidebarThreadFilters(filters: SidebarThreadFilters): boolean { + 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, +): string { + return `${environmentId}\u0000${instanceId}`; +} + +export function resolveSidebarArchiveEnvironmentIds(input: { + readonly availableEnvironmentIds: readonly EnvironmentId[]; + readonly selectedEnvironmentIds: readonly EnvironmentId[]; + readonly includeArchived: boolean; + readonly requiredEnvironmentIds?: readonly EnvironmentId[]; +}): EnvironmentId[] { + const selectedEnvironmentIds = new Set(input.selectedEnvironmentIds); + const requiredEnvironmentIds = new Set(input.requiredEnvironmentIds); + return input.availableEnvironmentIds.filter( + (environmentId) => + requiredEnvironmentIds.has(environmentId) || + (input.includeArchived && + (selectedEnvironmentIds.size === 0 || selectedEnvironmentIds.has(environmentId))), + ); +} + +export function getSidebarRangeSelectionThreadKeys( + threads: readonly { + readonly threadKey: string; + readonly archivedAt: string | null; + }[], +): string[] { + 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 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); @@ -481,6 +808,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 { @@ -650,6 +993,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", @@ -680,9 +1032,17 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } -export function getVisibleThreadsForProject>(input: { +export function shouldShowAllSidebarThreads(input: { + flatMode: boolean; + isThreadListExpanded: boolean; +}): boolean { + return input.flatMode || input.isThreadListExpanded; +} + +export function getVisibleThreadsForProject(input: { threads: readonly T[]; - activeThreadId: T["id"] | undefined; + activeThreadKey: string | null; + getThreadKey: (thread: T) => string; isThreadListExpanded: boolean; previewLimit: number; }): { @@ -690,7 +1050,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) { @@ -702,7 +1062,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), @@ -710,7 +1073,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, @@ -719,12 +1082,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))), }; } @@ -793,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[], @@ -801,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 014e629fda7d..c68e21d761f8 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, @@ -172,18 +196,32 @@ import { openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + filterSidebarThreadsForActiveRoute, + getFlatSidebarRenderedThreads, + getGroupedSidebarRenderedThreads, + getSidebarRangeSelectionThreadKeys, getSidebarThreadIdsToPrewarm, + hasActiveSidebarThreadFilters, + hasNarrowingSidebarThreadFilters, + matchesSidebarThreadFilters, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, + resolvePinnedCollapsedSidebarThread, resolveProjectStatusIndicator, + removeProjectKeysFromSidebarThreadFilters, + resolveSidebarArchiveEnvironmentIds, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarThreadFilterStatuses, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + sidebarProviderInstanceKey, shouldClearThreadSelectionOnMouseDown, + shouldShowAllSidebarThreads, sortProjectsForSidebar, + startSidebarRecentFilterClock, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; @@ -194,6 +232,7 @@ import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { primaryServerKeybindingsAtom, primaryServerSettingsAtom } from "../state/server"; +import { deriveProviderInstanceEntries } from "../providerInstances"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -202,6 +241,7 @@ import { } from "../logicalProject"; import type { SidebarThreadSummary } from "../types"; import { + buildFlatSidebarProjectSnapshot, buildPhysicalToLogicalProjectKeyMap, buildSidebarProjectSnapshots, type SidebarProjectGroupMember, @@ -216,6 +256,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 +384,7 @@ interface SidebarThreadRowProps { ) => Promise; cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + attemptUnarchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; } @@ -364,12 +411,17 @@ 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, + ); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: thread.environmentId, @@ -420,6 +472,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(); @@ -441,7 +498,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; @@ -449,6 +506,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr thread: { ...thread, lastVisitedAt, + isExplicitlyUnread, }, }); const pr = resolveThreadPr({ @@ -484,14 +542,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. @@ -504,15 +567,17 @@ 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.target !== event.currentTarget) return; 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) => { @@ -657,7 +722,18 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }, [attemptArchiveThread, threadRef], ); - const rowButtonRender = useMemo(() =>
, []); + const handleUnarchiveClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptUnarchiveThread(threadRef); + }, + [attemptUnarchiveThread, threadRef], + ); + const rowButtonRender = useMemo( + () => (isArchived ?
:
), + [isArchived], + ); return (
+ {isArchived ? : null} {prStatus && (
- {discoveredPorts.length > 0 && ( + {!isArchived && discoveredPorts.length > 0 && ( - {isConfirmingArchive ? ( + {isArchived ? ( + + + +
+ } + /> + Unarchive +
+ ) : isConfirmingArchive ? ( +
} - > - {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; @@ -2670,42 +2903,332 @@ function ProjectSortMenu({ ))} - -
- Visible threads -
-
- - - - { - event.stopPropagation(); + {showThreadPreviewCount ? ( + +
+ Visible threads +
+
+ + + + { + event.stopPropagation(); + }} + /> + + + +
+
+ ) : null} + + + ); +} + +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); +} + +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, + onFiltersChange, +}: { + filters: SidebarThreadFilters; + environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>; + projects: ReadonlyArray; + providerSources: ReadonlyArray<{ + readonly driverKind: ProviderDriverKind; + readonly label: string; + }>; + archiveLoading: boolean; + archiveError: string | null; + onFiltersChange: (filters: SidebarThreadFilters) => void; +}) { + const filtersActive = hasActiveSidebarThreadFilters(filters); + const selectedStatuses = resolveSidebarThreadFilterStatuses(filters.statuses); + const statusFilterActive = + selectedStatuses.length !== SIDEBAR_THREAD_FILTER_STATUSES.length || + !SIDEBAR_THREAD_FILTER_STATUSES.every((status) => selectedStatuses.includes(status)); + + return ( + + + + } + > + + {filtersActive ? ( + + ) : null} + + + {filtersActive ? "Sidebar filters are active" : "Filter sidebar threads"} + + + + { + onFiltersChange({ ...filters, groupByProject: checked }); + }} + > + Group by project + + +
+ 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(selectedStatuses, status, checked), + }); + }} + > + {SIDEBAR_THREAD_FILTER_STATUS_LABELS[status]} + + ))} + + + + + 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 + {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} + + ); @@ -2757,6 +3280,18 @@ interface SidebarProjectsContentProps { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; threadPreviewCount: SidebarThreadPreviewCount; + sidebarThreadFilters: SidebarThreadFilters; + sidebarFilterNowMs: number; + environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>; + providerSources: ReadonlyArray<{ + readonly driverKind: ProviderDriverKind; + readonly label: string; + }>; + providerDriverKindByScopedInstanceKey: ReadonlyMap; + archivedThreads: readonly SidebarThreadSummary[]; + archiveLoading: boolean; + archiveError: string | null; + filtersActive: boolean; updateSettings: ReturnType; openAddProject: () => void; isManualProjectSorting: boolean; @@ -2767,7 +3302,9 @@ interface SidebarProjectsContentProps { handleProjectDragCancel: (event: DragCancelEvent) => void; handleNewThread: ReturnType; archiveThread: ReturnType["archiveThread"]; + unarchiveThread: ReturnType["unarchiveThread"]; deleteThread: ReturnType["deleteThread"]; + availableProjects: readonly SidebarProjectSnapshot[]; sortedProjects: readonly SidebarProjectSnapshot[]; expandedThreadListsByProject: ReadonlySet; activeRouteProjectKey: string | null; @@ -2797,6 +3334,15 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( projectSortOrder, threadSortOrder, threadPreviewCount, + sidebarThreadFilters, + sidebarFilterNowMs, + environments, + providerSources, + providerDriverKindByScopedInstanceKey, + archivedThreads, + archiveLoading, + archiveError, + filtersActive, updateSettings, openAddProject, isManualProjectSorting, @@ -2807,7 +3353,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleProjectDragCancel, handleNewThread, archiveThread, + unarchiveThread, deleteThread, + availableProjects, sortedProjects, expandedThreadListsByProject, activeRouteProjectKey, @@ -2824,6 +3372,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( attachProjectListAutoAnimateRef, projectsLength, } = props; + const clearThreadSelection = useThreadSelectionStore((state) => state.clearSelection); const handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { @@ -2831,6 +3380,13 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleSidebarThreadFiltersChange = useCallback( + (filters: SidebarThreadFilters) => { + clearThreadSelection(); + updateSettings({ sidebarThreadFilters: filters }); + }, + [clearThreadSelection, updateSettings], + ); const handleThreadSortOrderChange = useCallback( (sortOrder: SidebarThreadSortOrder) => { updateSettings({ sidebarThreadSortOrder: sortOrder }); @@ -2843,6 +3399,23 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const flatProject = useMemo( + () => 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 ( @@ -2895,12 +3468,44 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
- Projects + + {sidebarThreadFilters.groupByProject ? "Projects" : "Threads"} +
+ {!sidebarThreadFilters.groupByProject && flatProject ? ( + + + } + > + + + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + + ) : null} +
- {isManualProjectSorting ? ( + {!sidebarThreadFilters.groupByProject ? ( + + {flatProject ? ( + + ) : null} + + ) : isManualProjectSorting ? ( ( )} - {projectsLength === 0 && ( + {sortedProjects.length === 0 && (
- No projects yet + {projectsLength === 0 + ? "No projects yet" + : filtersActive + ? "No threads match these filters" + : "No projects to show"}
)} @@ -3020,9 +3670,19 @@ 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 [sidebarFilterNowMs, setSidebarFilterNowMs] = useState(() => Date.now()); + useEffect( + () => + startSidebarRecentFilterClock({ + enabled: sidebarThreadFilters.recentOnly, + onTick: setSidebarFilterNowMs, + }), + [sidebarThreadFilters.recentOnly], + ); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); + const { archiveThread, unarchiveThread, deleteThread } = useThreadActions(); const { isMobile, setOpenMobile } = useSidebar(); const routeTarget = useParams({ strict: false, @@ -3059,6 +3719,64 @@ export default function Sidebar() { const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); const { environments } = useEnvironments(); + const archiveEnvironmentIds = useMemo( + () => + resolveSidebarArchiveEnvironmentIds({ + availableEnvironmentIds: environments.map((environment) => environment.environmentId), + selectedEnvironmentIds: sidebarThreadFilters.environmentIds, + includeArchived: sidebarThreadFilters.includeArchived, + }), + [environments, sidebarThreadFilters.environmentIds, 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( + () => + environments.flatMap((environment) => + deriveProviderInstanceEntries(environment.serverConfig?.providers ?? []).map((entry) => ({ + ...entry, + environmentId: environment.environmentId, + })), + ), + [environments], + ); + const providerDriverKindByScopedInstanceKey = useMemo( + () => + new Map( + providerEntries.map( + (entry) => + [ + sidebarProviderInstanceKey(entry.environmentId, 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( () => @@ -3139,6 +3857,58 @@ 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 threadExplicitlyUnreadById = useUiStateStore((state) => state.threadExplicitlyUnreadById); + const filtersActive = hasActiveSidebarThreadFilters(sidebarThreadFilters); + const narrowingFiltersActive = hasNarrowingSidebarThreadFilters(sidebarThreadFilters); + const filteredSidebarThreads = useMemo( + () => + 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, + threadLastVisitedAtById, + ], + ); // Resolve the active route's project key to a logical key so it matches the // sidebar's grouped project entries. const activeRouteProjectKey = useMemo(() => { @@ -3158,7 +3928,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)), @@ -3172,7 +3942,7 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + }, [filteredSidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -3280,16 +4050,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) => + !narrowingFiltersActive || (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)), @@ -3309,60 +4080,73 @@ export default function Sidebar() { }); }, [ sidebarProjectSortOrder, + filteredSidebarThreads, + narrowingFiltersActive, 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, - ), - sidebarThreadSortOrder, - ); - const projectExpanded = resolveProjectExpanded( - projectExpandedById, - projectExpansionPreferenceKeys(project), - ); - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === - activeThreadKey, - ) ?? null) - : null; - 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 renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - }), - [ - sidebarThreadSortOrder, - sidebarThreadPreviewCount, - expandedThreadListsByProject, - projectExpandedById, - routeThreadKey, - sortedProjects, - threadsByProjectKey, - ], - ); + const visibleSidebarThreadKeys = useMemo(() => { + if (!sidebarThreadFilters.groupByProject) { + 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) => { + 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 preview = getGroupedSidebarRenderedThreads({ + threads: projectThreads, + activeThreadKey: routeThreadKey, + getThreadKey, + previewLimit: sidebarThreadPreviewCount, + showAllThreads: isThreadListExpanded, + }); + const renderedThreads = pinnedCollapsedThread + ? [pinnedCollapsedThread] + : preview.renderedThreads; + 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()) { @@ -3628,6 +4412,15 @@ export default function Sidebar() { projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} threadPreviewCount={sidebarThreadPreviewCount} + sidebarThreadFilters={sidebarThreadFilters} + sidebarFilterNowMs={sidebarFilterNowMs} + environments={environments} + providerSources={providerSources} + providerDriverKindByScopedInstanceKey={providerDriverKindByScopedInstanceKey} + archivedThreads={archivedThreads} + archiveLoading={archiveLoading} + archiveError={archiveError} + filtersActive={filtersActive} updateSettings={updateSettings} openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} @@ -3638,7 +4431,9 @@ export default function Sidebar() { handleProjectDragCancel={handleProjectDragCancel} handleNewThread={handleNewThread} archiveThread={archiveThread} + unarchiveThread={unarchiveThread} deleteThread={deleteThread} + availableProjects={sidebarProjects} sortedProjects={sortedProjects} expandedThreadListsByProject={expandedThreadListsByProject} activeRouteProjectKey={activeRouteProjectKey} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 361c3460bfd9..55de3ee3f612 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, @@ -67,6 +73,7 @@ import { } from "../logicalProject"; import { buildSidebarProjectSnapshots, + getSidebarProjectRemovalRefs, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; @@ -91,13 +98,20 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { + filterSidebarThreadsForActiveRoute, + firstValidTimestampMs, formatWorkingDurationLabel, hasUnseenCompletion, + hasActiveSidebarThreadFilters, isTrailingDoubleClick, + matchesSidebarThreadFilters, orderItemsByPreferredIds, + removeProjectKeysFromSidebarThreadFilters, resolveAdjacentThreadId, resolveSettledTimestamp, + resolveSidebarArchiveEnvironmentIds, resolveSidebarV2Status, + resolveSidebarV2TopStatus, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, @@ -109,8 +123,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"; @@ -125,12 +138,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. @@ -279,7 +302,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; @@ -289,7 +312,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; @@ -301,6 +324,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 { @@ -315,6 +339,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onThreadActivate, onThreadClick, onUnsettle, + onUnarchive, renamingTitle, thread, variant, @@ -325,14 +350,23 @@ 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, + ); 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); + 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. @@ -346,38 +380,44 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. const topStatus = - status === "working" + 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( @@ -408,7 +448,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, @@ -435,9 +478,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) => { @@ -451,20 +498,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(() => { @@ -506,6 +561,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); @@ -518,8 +581,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 @@ -605,8 +670,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { - {!props.settlementSupported ? null : variantAction === "unsettle" ? ( + {variantAction === "unarchive" ? ( + + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + + ) : null; return ( <> @@ -1807,50 +2073,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 && @@ -1917,6 +2206,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 @@ -1981,20 +2300,7 @@ export default function SidebarV2() { row, ]; })} - {hiddenSettledCount > 0 ? ( -
  • - -
  • - ) : null} + {filteredArchivedThreads.length === 0 ? showMoreSettledRow : null} {orderedThreads.length === 0 ? ( @@ -2011,8 +2317,8 @@ export default function SidebarV2() { Add project - ) : scopedProjectGroup ? ( - `No threads in ${scopedProjectGroup.displayName} yet` + ) : filtersActive ? ( + "No threads match these filters" ) : ( "No threads yet" )} @@ -2034,6 +2340,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}
    @@ -2146,6 +2461,7 @@ export default function SidebarV2() {