Skip to content

Commit 014d46f

Browse files
committed
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.
1 parent d1e85c4 commit 014d46f

5 files changed

Lines changed: 484 additions & 58 deletions

File tree

apps/web/src/components/settings/SettingsPanels.browser.tsx

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ import {
99
type DesktopBridge,
1010
type DesktopUpdateChannel,
1111
type DesktopUpdateState,
12+
type EnvironmentApi,
1213
type LocalApi,
14+
type OrchestrationShellSnapshot,
15+
ProjectId,
1316
ProviderDriverKind,
1417
ProviderInstanceId,
1518
type ServerConfig,
1619
type ServerProcessResourceHistoryResult,
1720
type ServerProvider,
1821
type SourceControlDiscoveryResult,
22+
ThreadId,
1923
} from "@t3tools/contracts";
2024
import * as DateTime from "effect/DateTime";
2125
import * as Option from "effect/Option";
@@ -30,14 +34,24 @@ import {
3034
createRoute,
3135
createRouter,
3236
} from "@tanstack/react-router";
37+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3338

3439
import { __resetLocalApiForTests } from "../../localApi";
40+
import {
41+
__resetEnvironmentApiOverridesForTests,
42+
__setEnvironmentApiOverrideForTests,
43+
} from "../../environmentApi";
3544
import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry";
3645
import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
46+
import { useStore } from "../../store";
3747
import { useUiStateStore } from "../../uiStateStore";
3848
import { ConnectionsSettings } from "./ConnectionsSettings";
3949
import { DiagnosticsSettingsPanel } from "./DiagnosticsSettings";
40-
import { GeneralSettingsPanel, ProviderSettingsPanel } from "./SettingsPanels";
50+
import {
51+
ArchivedThreadsPanel,
52+
GeneralSettingsPanel,
53+
ProviderSettingsPanel,
54+
} from "./SettingsPanels";
4155
import { SourceControlSettingsPanel } from "./SourceControlSettings";
4256

4357
function renderWithTestRouter(children: ReactNode) {
@@ -283,6 +297,71 @@ function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistory
283297
};
284298
}
285299

300+
const archivedPanelEnvironmentId = EnvironmentId.make("environment-archive-test");
301+
const archivedPanelProjectId = ProjectId.make("project-docs");
302+
const archivedPanelModelSelection = {
303+
instanceId: ProviderInstanceId.make("codex"),
304+
model: "gpt-5",
305+
};
306+
307+
function createArchivedPanelSnapshot(
308+
threads: OrchestrationShellSnapshot["threads"],
309+
): OrchestrationShellSnapshot {
310+
return {
311+
snapshotSequence: 1,
312+
projects: [
313+
{
314+
id: archivedPanelProjectId,
315+
title: "Docs Portal",
316+
workspaceRoot: "/work/clients/docs",
317+
defaultModelSelection: archivedPanelModelSelection,
318+
scripts: [],
319+
createdAt: "2036-04-07T00:00:00.000Z",
320+
updatedAt: "2036-04-07T00:00:00.000Z",
321+
},
322+
],
323+
threads,
324+
updatedAt: "2036-04-07T00:03:00.000Z",
325+
};
326+
}
327+
328+
function createArchivedPanelThread(input: {
329+
readonly id: string;
330+
readonly title: string;
331+
readonly branch: string | null;
332+
readonly worktreePath: string | null;
333+
readonly createdAt: string;
334+
readonly archivedAt: string;
335+
}): OrchestrationShellSnapshot["threads"][number] {
336+
return {
337+
id: ThreadId.make(input.id),
338+
projectId: archivedPanelProjectId,
339+
title: input.title,
340+
modelSelection: archivedPanelModelSelection,
341+
runtimeMode: "full-access",
342+
interactionMode: "default",
343+
branch: input.branch,
344+
worktreePath: input.worktreePath,
345+
latestTurn: null,
346+
createdAt: input.createdAt,
347+
updatedAt: input.archivedAt,
348+
archivedAt: input.archivedAt,
349+
session: null,
350+
latestUserMessageAt: null,
351+
hasPendingApprovals: false,
352+
hasPendingUserInput: false,
353+
hasActionableProposedPlan: false,
354+
};
355+
}
356+
357+
function createArchivedPanelEnvironmentApi(snapshot: OrchestrationShellSnapshot): EnvironmentApi {
358+
return {
359+
orchestration: {
360+
getArchivedShellSnapshot: vi.fn().mockResolvedValue(snapshot),
361+
},
362+
} as unknown as EnvironmentApi;
363+
}
364+
286365
function makePairingLink(input: {
287366
readonly id: string;
288367
readonly credential: string;
@@ -1219,6 +1298,106 @@ describe("GeneralSettingsPanel observability", () => {
12191298
});
12201299
});
12211300

1301+
describe("ArchivedThreadsPanel", () => {
1302+
let mounted:
1303+
| (Awaited<ReturnType<typeof render>> & {
1304+
cleanup?: () => Promise<void>;
1305+
unmount?: () => Promise<void>;
1306+
})
1307+
| null = null;
1308+
1309+
beforeEach(() => {
1310+
resetAppAtomRegistryForTests();
1311+
__resetEnvironmentApiOverridesForTests();
1312+
document.body.innerHTML = "";
1313+
useStore.setState({
1314+
activeEnvironmentId: null,
1315+
environmentStateById: {},
1316+
});
1317+
});
1318+
1319+
afterEach(async () => {
1320+
if (mounted) {
1321+
const teardown = mounted.cleanup ?? mounted.unmount;
1322+
await teardown?.call(mounted).catch(() => {});
1323+
}
1324+
mounted = null;
1325+
document.body.innerHTML = "";
1326+
__resetEnvironmentApiOverridesForTests();
1327+
resetAppAtomRegistryForTests();
1328+
useStore.setState({
1329+
activeEnvironmentId: null,
1330+
environmentStateById: {},
1331+
});
1332+
});
1333+
1334+
it("searches archived threads and collapses archived projects", async () => {
1335+
useStore
1336+
.getState()
1337+
.syncServerShellSnapshot(createArchivedPanelSnapshot([]), archivedPanelEnvironmentId);
1338+
1339+
__setEnvironmentApiOverrideForTests(
1340+
archivedPanelEnvironmentId,
1341+
createArchivedPanelEnvironmentApi(
1342+
createArchivedPanelSnapshot([
1343+
createArchivedPanelThread({
1344+
id: "thread-docs-bug",
1345+
title: "Fix publishing bug",
1346+
branch: "docs-fix",
1347+
worktreePath: "/work/clients/docs/.worktrees/docs-fix",
1348+
createdAt: "2036-04-07T00:01:00.000Z",
1349+
archivedAt: "2036-04-07T00:04:00.000Z",
1350+
}),
1351+
createArchivedPanelThread({
1352+
id: "thread-docs-homepage",
1353+
title: "Rewrite homepage copy",
1354+
branch: null,
1355+
worktreePath: null,
1356+
createdAt: "2036-04-07T00:02:00.000Z",
1357+
archivedAt: "2036-04-07T00:03:00.000Z",
1358+
}),
1359+
]),
1360+
),
1361+
);
1362+
1363+
const queryClient = new QueryClient({
1364+
defaultOptions: {
1365+
queries: { retry: false },
1366+
mutations: { retry: false },
1367+
},
1368+
});
1369+
1370+
mounted = await renderWithTestRouter(
1371+
<QueryClientProvider client={queryClient}>
1372+
<AppAtomRegistryProvider>
1373+
<ArchivedThreadsPanel />
1374+
</AppAtomRegistryProvider>
1375+
</QueryClientProvider>,
1376+
);
1377+
1378+
await expect.element(page.getByText("Fix publishing bug")).toBeInTheDocument();
1379+
await expect.element(page.getByText("Rewrite homepage copy")).toBeInTheDocument();
1380+
1381+
await page.getByLabelText("Search archived threads").fill("homepage");
1382+
1383+
await expect.element(page.getByText("Rewrite homepage copy")).toBeInTheDocument();
1384+
await expect.element(page.getByText("Fix publishing bug")).not.toBeInTheDocument();
1385+
1386+
await page.getByLabelText("Search archived threads").fill("");
1387+
await expect.element(page.getByText("Fix publishing bug")).toBeInTheDocument();
1388+
1389+
await page.getByRole("button", { name: "Collapse Docs Portal", exact: true }).click();
1390+
1391+
await expect.element(page.getByText("Fix publishing bug")).not.toBeInTheDocument();
1392+
await expect.element(page.getByText("Rewrite homepage copy")).not.toBeInTheDocument();
1393+
1394+
await page.getByRole("button", { name: "Expand Docs Portal", exact: true }).click();
1395+
1396+
await expect.element(page.getByText("Fix publishing bug")).toBeInTheDocument();
1397+
await expect.element(page.getByText("Rewrite homepage copy")).toBeInTheDocument();
1398+
});
1399+
});
1400+
12221401
describe("SourceControlSettingsPanel discovery states", () => {
12231402
let mounted:
12241403
| (Awaited<ReturnType<typeof render>> & {

apps/web/src/components/settings/SettingsPanels.logic.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,100 @@ import {
77
import { describe, expect, it } from "vitest";
88
import {
99
buildProviderInstanceUpdatePatch,
10+
filterArchivedThreadGroups,
1011
formatDiagnosticsDescription,
1112
} from "./SettingsPanels.logic";
1213

14+
const archivedGroups = [
15+
{
16+
project: {
17+
id: "project-docs",
18+
name: "Docs Portal",
19+
cwd: "/work/clients/docs",
20+
},
21+
threads: [
22+
{
23+
id: "thread-docs-bug",
24+
title: "Fix publishing bug",
25+
branch: "docs-fix",
26+
worktreePath: "/work/clients/docs/.worktrees/docs-fix",
27+
},
28+
{
29+
id: "thread-docs-copy",
30+
title: "Rewrite homepage copy",
31+
branch: null,
32+
worktreePath: null,
33+
},
34+
],
35+
},
36+
{
37+
project: {
38+
id: "project-api",
39+
name: "API Service",
40+
cwd: "/work/services/api",
41+
},
42+
threads: [
43+
{
44+
id: "thread-api-cache",
45+
title: "Tune cache invalidation",
46+
branch: "cache-tuning",
47+
worktreePath: "/work/services/api/.worktrees/cache-tuning",
48+
},
49+
],
50+
},
51+
];
52+
53+
describe("archive thread search helpers", () => {
54+
it("returns all groups for an empty query", () => {
55+
expect(filterArchivedThreadGroups(archivedGroups, " ")).toEqual(archivedGroups);
56+
});
57+
58+
it("keeps all project threads when the project name matches", () => {
59+
const filtered = filterArchivedThreadGroups(archivedGroups, "docs portal");
60+
61+
expect(filtered).toHaveLength(1);
62+
expect(filtered[0]?.project.id).toBe("project-docs");
63+
expect(filtered[0]?.threads.map((thread) => thread.id)).toEqual([
64+
"thread-docs-bug",
65+
"thread-docs-copy",
66+
]);
67+
});
68+
69+
it("keeps all project threads when the project cwd matches", () => {
70+
const filtered = filterArchivedThreadGroups(archivedGroups, "services api");
71+
72+
expect(filtered).toHaveLength(1);
73+
expect(filtered[0]?.project.id).toBe("project-api");
74+
expect(filtered[0]?.threads.map((thread) => thread.id)).toEqual(["thread-api-cache"]);
75+
});
76+
77+
it("keeps only matching threads when the thread title matches", () => {
78+
const filtered = filterArchivedThreadGroups(archivedGroups, " HOMEpage ");
79+
80+
expect(filtered).toHaveLength(1);
81+
expect(filtered[0]?.project.id).toBe("project-docs");
82+
expect(filtered[0]?.threads.map((thread) => thread.id)).toEqual(["thread-docs-copy"]);
83+
});
84+
85+
it("matches thread branch and worktree path", () => {
86+
expect(
87+
filterArchivedThreadGroups(archivedGroups, "cache-tuning")[0]?.threads.map(
88+
(thread) => thread.id,
89+
),
90+
).toEqual(["thread-api-cache"]);
91+
92+
expect(
93+
filterArchivedThreadGroups(archivedGroups, "worktrees docs-fix")[0]?.threads.map(
94+
(thread) => thread.id,
95+
),
96+
).toEqual(["thread-docs-bug"]);
97+
});
98+
99+
it("drops groups with no matches", () => {
100+
expect(filterArchivedThreadGroups(archivedGroups, "not-here")).toEqual([]);
101+
});
102+
});
103+
13104
describe("formatDiagnosticsDescription", () => {
14105
it("collapses trace and metric URLs that share the same OTEL base path", () => {
15106
expect(

apps/web/src/components/settings/SettingsPanels.logic.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,74 @@ import type {
77
} from "@t3tools/contracts";
88
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
99

10+
type ArchivedSearchProject = {
11+
readonly name: string;
12+
readonly cwd: string;
13+
};
14+
15+
type ArchivedSearchThread = {
16+
readonly title: string;
17+
readonly branch: string | null;
18+
readonly worktreePath: string | null;
19+
};
20+
21+
type ArchivedThreadSearchGroup<
22+
TProject extends ArchivedSearchProject = ArchivedSearchProject,
23+
TThread extends ArchivedSearchThread = ArchivedSearchThread,
24+
> = {
25+
readonly project: TProject;
26+
readonly threads: ReadonlyArray<TThread>;
27+
};
28+
29+
function normalizeArchivedSearchQuery(query: string): string[] {
30+
return query.trim().toLowerCase().split(/\s+/).filter(Boolean);
31+
}
32+
33+
function searchableTextMatchesAllTokens(
34+
fields: ReadonlyArray<string | null | undefined>,
35+
tokens: ReadonlyArray<string>,
36+
): boolean {
37+
if (tokens.length === 0) {
38+
return true;
39+
}
40+
41+
const searchableText = fields
42+
.filter((field): field is string => typeof field === "string" && field.length > 0)
43+
.join(" ")
44+
.toLowerCase();
45+
46+
return tokens.every((token) => searchableText.includes(token));
47+
}
48+
49+
export function filterArchivedThreadGroups<
50+
TProject extends ArchivedSearchProject,
51+
TThread extends ArchivedSearchThread,
52+
>(
53+
groups: ReadonlyArray<ArchivedThreadSearchGroup<TProject, TThread>>,
54+
query: string,
55+
): Array<ArchivedThreadSearchGroup<TProject, TThread>> {
56+
const tokens = normalizeArchivedSearchQuery(query);
57+
if (tokens.length === 0) {
58+
return [...groups];
59+
}
60+
61+
return groups.flatMap((group) => {
62+
const projectFields = [group.project.name, group.project.cwd];
63+
if (searchableTextMatchesAllTokens(projectFields, tokens)) {
64+
return [group];
65+
}
66+
67+
const threads = group.threads.filter((thread) =>
68+
searchableTextMatchesAllTokens(
69+
[...projectFields, thread.title, thread.branch, thread.worktreePath],
70+
tokens,
71+
),
72+
);
73+
74+
return threads.length > 0 ? [{ ...group, threads }] : [];
75+
});
76+
}
77+
1078
function collapseOtelSignalsUrl(input: {
1179
readonly tracesUrl: string;
1280
readonly metricsUrl: string;

0 commit comments

Comments
 (0)