From 014d46fa04a2246d2ae6f5ebf6c78134147dacf0 Mon Sep 17 00:00:00 2001 From: OliverGamborg Date: Tue, 19 May 2026 09:53:10 +0200 Subject: [PATCH] Add archive search and project collapse Adds client-side filtering for archived threads in Settings > Archive, including project-level matches and thread-level matches across title, branch, and worktree path. Adds local per-project collapse controls and focused unit/browser coverage while leaving archive data loading and server contracts unchanged. --- .../settings/SettingsPanels.browser.tsx | 181 ++++++++++++++++- .../settings/SettingsPanels.logic.test.ts | 91 +++++++++ .../settings/SettingsPanels.logic.ts | 68 +++++++ .../components/settings/SettingsPanels.tsx | 192 +++++++++++++----- .../components/settings/settingsLayout.tsx | 10 +- 5 files changed, 484 insertions(+), 58 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 34f24006edc..a5f670faf21 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -9,13 +9,17 @@ import { type DesktopBridge, type DesktopUpdateChannel, type DesktopUpdateState, + type EnvironmentApi, type LocalApi, + type OrchestrationShellSnapshot, + ProjectId, ProviderDriverKind, ProviderInstanceId, type ServerConfig, type ServerProcessResourceHistoryResult, type ServerProvider, type SourceControlDiscoveryResult, + ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; @@ -30,14 +34,24 @@ import { createRoute, createRouter, } from "@tanstack/react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { __resetLocalApiForTests } from "../../localApi"; +import { + __resetEnvironmentApiOverridesForTests, + __setEnvironmentApiOverrideForTests, +} from "../../environmentApi"; import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; +import { useStore } from "../../store"; import { useUiStateStore } from "../../uiStateStore"; import { ConnectionsSettings } from "./ConnectionsSettings"; import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings"; -import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels"; +import { + ArchivedThreadsPanel, + GeneralSettingsPanel, + ProviderSettingsPanel, +} from "./SettingsPanels"; import { SourceControlSettingsPanel } from "./SourceControlSettings"; function renderWithTestRouter(children: ReactNode) { @@ -283,6 +297,71 @@ function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistory }; } +const archivedPanelEnvironmentId = EnvironmentId.make("environment-archive-test"); +const archivedPanelProjectId = ProjectId.make("project-docs"); +const archivedPanelModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", +}; + +function createArchivedPanelSnapshot( + threads: OrchestrationShellSnapshot["threads"], +): OrchestrationShellSnapshot { + return { + snapshotSequence: 1, + projects: [ + { + id: archivedPanelProjectId, + title: "Docs Portal", + workspaceRoot: "/work/clients/docs", + defaultModelSelection: archivedPanelModelSelection, + scripts: [], + createdAt: "2036-04-07T00:00:00.000Z", + updatedAt: "2036-04-07T00:00:00.000Z", + }, + ], + threads, + updatedAt: "2036-04-07T00:03:00.000Z", + }; +} + +function createArchivedPanelThread(input: { + readonly id: string; + readonly title: string; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly createdAt: string; + readonly archivedAt: string; +}): OrchestrationShellSnapshot["threads"][number] { + return { + id: ThreadId.make(input.id), + projectId: archivedPanelProjectId, + title: input.title, + modelSelection: archivedPanelModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: input.branch, + worktreePath: input.worktreePath, + latestTurn: null, + createdAt: input.createdAt, + updatedAt: input.archivedAt, + archivedAt: input.archivedAt, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +function createArchivedPanelEnvironmentApi(snapshot: OrchestrationShellSnapshot): EnvironmentApi { + return { + orchestration: { + getArchivedShellSnapshot: vi.fn().mockResolvedValue(snapshot), + }, + } as unknown as EnvironmentApi; +} + function makePairingLink(input: { readonly id: string; readonly credential: string; @@ -1219,6 +1298,106 @@ describe("GeneralSettingsPanel observability", () => { }); }); +describe("ArchivedThreadsPanel", () => { + let mounted: + | (Awaited> & { + cleanup?: () => Promise; + unmount?: () => Promise; + }) + | null = null; + + beforeEach(() => { + resetAppAtomRegistryForTests(); + __resetEnvironmentApiOverridesForTests(); + document.body.innerHTML = ""; + useStore.setState({ + activeEnvironmentId: null, + environmentStateById: {}, + }); + }); + + afterEach(async () => { + if (mounted) { + const teardown = mounted.cleanup ?? mounted.unmount; + await teardown?.call(mounted).catch(() => {}); + } + mounted = null; + document.body.innerHTML = ""; + __resetEnvironmentApiOverridesForTests(); + resetAppAtomRegistryForTests(); + useStore.setState({ + activeEnvironmentId: null, + environmentStateById: {}, + }); + }); + + it("searches archived threads and collapses archived projects", async () => { + useStore + .getState() + .syncServerShellSnapshot(createArchivedPanelSnapshot([]), archivedPanelEnvironmentId); + + __setEnvironmentApiOverrideForTests( + archivedPanelEnvironmentId, + createArchivedPanelEnvironmentApi( + createArchivedPanelSnapshot([ + createArchivedPanelThread({ + id: "thread-docs-bug", + title: "Fix publishing bug", + branch: "docs-fix", + worktreePath: "/work/clients/docs/.worktrees/docs-fix", + createdAt: "2036-04-07T00:01:00.000Z", + archivedAt: "2036-04-07T00:04:00.000Z", + }), + createArchivedPanelThread({ + id: "thread-docs-homepage", + title: "Rewrite homepage copy", + branch: null, + worktreePath: null, + createdAt: "2036-04-07T00:02:00.000Z", + archivedAt: "2036-04-07T00:03:00.000Z", + }), + ]), + ), + ); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + mounted = await renderWithTestRouter( + + + + + , + ); + + await expect.element(page.getByText("Fix publishing bug")).toBeInTheDocument(); + await expect.element(page.getByText("Rewrite homepage copy")).toBeInTheDocument(); + + await page.getByLabelText("Search archived threads").fill("homepage"); + + await expect.element(page.getByText("Rewrite homepage copy")).toBeInTheDocument(); + await expect.element(page.getByText("Fix publishing bug")).not.toBeInTheDocument(); + + await page.getByLabelText("Search archived threads").fill(""); + await expect.element(page.getByText("Fix publishing bug")).toBeInTheDocument(); + + await page.getByRole("button", { name: "Collapse Docs Portal", exact: true }).click(); + + await expect.element(page.getByText("Fix publishing bug")).not.toBeInTheDocument(); + await expect.element(page.getByText("Rewrite homepage copy")).not.toBeInTheDocument(); + + await page.getByRole("button", { name: "Expand Docs Portal", exact: true }).click(); + + await expect.element(page.getByText("Fix publishing bug")).toBeInTheDocument(); + await expect.element(page.getByText("Rewrite homepage copy")).toBeInTheDocument(); + }); +}); + describe("SourceControlSettingsPanel discovery states", () => { let mounted: | (Awaited> & { diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 7a7c68a6b9c..f0499028bef 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -7,9 +7,100 @@ import { import { describe, expect, it } from "vitest"; import { buildProviderInstanceUpdatePatch, + filterArchivedThreadGroups, formatDiagnosticsDescription, } from "./SettingsPanels.logic"; +const archivedGroups = [ + { + project: { + id: "project-docs", + name: "Docs Portal", + cwd: "/work/clients/docs", + }, + threads: [ + { + id: "thread-docs-bug", + title: "Fix publishing bug", + branch: "docs-fix", + worktreePath: "/work/clients/docs/.worktrees/docs-fix", + }, + { + id: "thread-docs-copy", + title: "Rewrite homepage copy", + branch: null, + worktreePath: null, + }, + ], + }, + { + project: { + id: "project-api", + name: "API Service", + cwd: "/work/services/api", + }, + threads: [ + { + id: "thread-api-cache", + title: "Tune cache invalidation", + branch: "cache-tuning", + worktreePath: "/work/services/api/.worktrees/cache-tuning", + }, + ], + }, +]; + +describe("archive thread search helpers", () => { + it("returns all groups for an empty query", () => { + expect(filterArchivedThreadGroups(archivedGroups, " ")).toEqual(archivedGroups); + }); + + it("keeps all project threads when the project name matches", () => { + const filtered = filterArchivedThreadGroups(archivedGroups, "docs portal"); + + expect(filtered).toHaveLength(1); + expect(filtered[0]?.project.id).toBe("project-docs"); + expect(filtered[0]?.threads.map((thread) => thread.id)).toEqual([ + "thread-docs-bug", + "thread-docs-copy", + ]); + }); + + it("keeps all project threads when the project cwd matches", () => { + const filtered = filterArchivedThreadGroups(archivedGroups, "services api"); + + expect(filtered).toHaveLength(1); + expect(filtered[0]?.project.id).toBe("project-api"); + expect(filtered[0]?.threads.map((thread) => thread.id)).toEqual(["thread-api-cache"]); + }); + + it("keeps only matching threads when the thread title matches", () => { + const filtered = filterArchivedThreadGroups(archivedGroups, " HOMEpage "); + + expect(filtered).toHaveLength(1); + expect(filtered[0]?.project.id).toBe("project-docs"); + expect(filtered[0]?.threads.map((thread) => thread.id)).toEqual(["thread-docs-copy"]); + }); + + it("matches thread branch and worktree path", () => { + expect( + filterArchivedThreadGroups(archivedGroups, "cache-tuning")[0]?.threads.map( + (thread) => thread.id, + ), + ).toEqual(["thread-api-cache"]); + + expect( + filterArchivedThreadGroups(archivedGroups, "worktrees docs-fix")[0]?.threads.map( + (thread) => thread.id, + ), + ).toEqual(["thread-docs-bug"]); + }); + + it("drops groups with no matches", () => { + expect(filterArchivedThreadGroups(archivedGroups, "not-here")).toEqual([]); + }); +}); + describe("formatDiagnosticsDescription", () => { it("collapses trace and metric URLs that share the same OTEL base path", () => { expect( diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 99d7052965a..307a828ddfb 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -7,6 +7,74 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +type ArchivedSearchProject = { + readonly name: string; + readonly cwd: string; +}; + +type ArchivedSearchThread = { + readonly title: string; + readonly branch: string | null; + readonly worktreePath: string | null; +}; + +type ArchivedThreadSearchGroup< + TProject extends ArchivedSearchProject = ArchivedSearchProject, + TThread extends ArchivedSearchThread = ArchivedSearchThread, +> = { + readonly project: TProject; + readonly threads: ReadonlyArray; +}; + +function normalizeArchivedSearchQuery(query: string): string[] { + return query.trim().toLowerCase().split(/\s+/).filter(Boolean); +} + +function searchableTextMatchesAllTokens( + fields: ReadonlyArray, + tokens: ReadonlyArray, +): boolean { + if (tokens.length === 0) { + return true; + } + + const searchableText = fields + .filter((field): field is string => typeof field === "string" && field.length > 0) + .join(" ") + .toLowerCase(); + + return tokens.every((token) => searchableText.includes(token)); +} + +export function filterArchivedThreadGroups< + TProject extends ArchivedSearchProject, + TThread extends ArchivedSearchThread, +>( + groups: ReadonlyArray>, + query: string, +): Array> { + const tokens = normalizeArchivedSearchQuery(query); + if (tokens.length === 0) { + return [...groups]; + } + + return groups.flatMap((group) => { + const projectFields = [group.project.name, group.project.cwd]; + if (searchableTextMatchesAllTokens(projectFields, tokens)) { + return [group]; + } + + const threads = group.threads.filter((thread) => + searchableTextMatchesAllTokens( + [...projectFields, thread.title, thread.branch, thread.worktreePath], + tokens, + ), + ); + + return threads.length > 0 ? [{ ...group, threads }] : []; + }); +} + function collapseOtelSignalsUrl(input: { readonly tracesUrl: string; readonly metricsUrl: string; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e7f21da4809..39b6cb270e9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,4 +1,12 @@ -import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { + ArchiveIcon, + ArchiveX, + ChevronDownIcon, + LoaderIcon, + PlusIcon, + RefreshCwIcon, + SearchIcon, +} from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -66,6 +74,7 @@ import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, + filterArchivedThreadGroups, formatDiagnosticsDescription, } from "./SettingsPanels.logic"; import { @@ -1334,6 +1343,10 @@ export function ProviderSettingsPanel() { export function ArchivedThreadsPanel() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const [archiveSearchQuery, setArchiveSearchQuery] = useState(""); + const [collapsedArchivedProjectKeys, setCollapsedArchivedProjectKeys] = useState< + ReadonlySet + >(() => new Set()); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], [projects], @@ -1386,6 +1399,22 @@ export function ArchivedThreadsPanel() { .filter((group) => group.threads.length > 0); }, [archivedSnapshots]); + const visibleArchivedGroups = useMemo( + () => filterArchivedThreadGroups(archivedGroups, archiveSearchQuery), + [archiveSearchQuery, archivedGroups], + ); + const toggleArchivedProjectCollapsed = useCallback((projectKey: string) => { + setCollapsedArchivedProjectKeys((current) => { + const next = new Set(current); + if (next.has(projectKey)) { + next.delete(projectKey); + } else { + next.add(projectKey); + } + return next; + }); + }, []); + const handleArchivedThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { const api = readLocalApi(); @@ -1449,62 +1478,117 @@ export function ArchivedThreadsPanel() { /> ) : ( - archivedGroups.map(({ project, threads: projectThreads }) => ( - } - > - {projectThreads.map((thread) => ( + <> +
+ + setArchiveSearchQuery(event.currentTarget.value)} + placeholder="Search archived threads" + aria-label="Search archived threads" + className="h-8 w-full rounded-lg border border-input bg-background pr-3 pl-8 text-sm text-foreground outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24" + /> +
+ + {visibleArchivedGroups.length === 0 ? ( + { - event.preventDefault(); - void handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - { - x: event.clientX, - y: event.clientY, - }, - ); - }} - title={thread.title} - description={ - <> - Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} - {" \u00b7 Created "} - {formatRelativeTimeLabel(thread.createdAt)} - - } - control={ - + title={ + + + No matching archived threads + } + description="Try a different search." /> - ))} - - )) +
+ ) : ( + visibleArchivedGroups.map(({ project, threads: projectThreads }) => { + const projectKey = `${project.environmentId}:${project.id}`; + const isCollapsed = collapsedArchivedProjectKeys.has(projectKey); + return ( + } + contentVisible={!isCollapsed} + headerAction={ +
+ + {projectThreads.length} {projectThreads.length === 1 ? "thread" : "threads"} + + +
+ } + > + {projectThreads.map((thread) => ( + { + event.preventDefault(); + void handleArchivedThreadContextMenu( + scopeThreadRef(thread.environmentId, thread.id), + { + x: event.clientX, + y: event.clientY, + }, + ); + }} + title={thread.title} + description={ + <> + Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} + {" \u00b7 Created "} + {formatRelativeTimeLabel(thread.createdAt)} + + } + control={ + + } + /> + ))} +
+ ); + }) + )} + )} ); diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 391898b1110..4aec3891657 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -19,6 +19,7 @@ export function SettingsSection({ title, icon, headerAction, + contentVisible = true, children, className, ...sectionProps @@ -26,6 +27,7 @@ export function SettingsSection({ title: string; icon?: ReactNode; headerAction?: ReactNode; + contentVisible?: boolean; children: ReactNode; }) { return ( @@ -38,9 +40,11 @@ export function SettingsSection({
{headerAction}
-
- {children} -
+ {contentVisible ? ( +
+ {children} +
+ ) : null} ); }