Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
cacf444
Refine archived settings panel UX
Quicksaver Jun 24, 2026
57a1e76
Move archived project actions into context menu
Quicksaver Jun 25, 2026
67a0477
Add archive thread search and ranked filtering
Quicksaver Jun 25, 2026
da19562
Scope archive bulk actions to visible threads
Quicksaver Jun 25, 2026
5d9ac5a
Fix archive project bulk actions during search
Quicksaver Jun 25, 2026
d8cb38b
Scope archive bulk actions to visible rows
Quicksaver Jun 25, 2026
1b02962
Merge remote-tracking branch 'upstream/main' into split/archive-setti…
Quicksaver Jun 25, 2026
74fcff2
Respect archive delete confirmation setting
Quicksaver Jun 25, 2026
6167a3d
Clarify archived bulk failure toasts
Quicksaver Jun 25, 2026
9653b5b
Merge remote-tracking branch 'upstream/main' into split/archive-setti…
Quicksaver Jun 25, 2026
5455c7e
Port archive settings review fix
Quicksaver Jun 25, 2026
b6ad57b
Merge remote-tracking branch 'upstream/main' into split/archive-setti…
Quicksaver Jun 25, 2026
e493576
Address archive settings review follow-ups
Quicksaver Jun 26, 2026
8405c06
Merge remote-tracking branch 'upstream/main' into split/archive-setti…
Quicksaver Jun 26, 2026
7d9e19f
Refactor archived settings logic
Quicksaver Jun 26, 2026
8fb00f4
Fix archived project bulk action errors
Quicksaver Jun 26, 2026
a5b1b97
Fix archived settings review issues
Quicksaver Jun 26, 2026
0c08df9
Fix archived confirmation fallback
Quicksaver Jun 26, 2026
a579f7e
Fix archived project grouping keys
Quicksaver Jun 26, 2026
5ff0c25
Fix archived project UI keys
Quicksaver Jun 26, 2026
5d8b2f3
Merge remote-tracking branch 'upstream/main' into split/archive-setti…
Quicksaver Jun 27, 2026
b07ae41
Merge remote-tracking branch 'upstream/main' into split/archive-setti…
Quicksaver Jun 27, 2026
6ae2079
Move archived settings into a dedicated panel
Quicksaver Jun 27, 2026
39e2452
Fix archived project action access
Quicksaver Jun 27, 2026
19e1ffa
Align archived thread rows with headers
Quicksaver Jun 27, 2026
9ccfdce
Fix archived settings review issues
Quicksaver Jun 27, 2026
3c509f3
Fix archive action confirmation fallback
Quicksaver Jun 27, 2026
0d07f3a
Handle archive bulk action exceptions
Quicksaver Jun 27, 2026
10632e1
Fetch archive settings across all environments
Quicksaver Jun 27, 2026
26eea31
Clarify filtered archive bulk menu actions
Quicksaver Jun 27, 2026
5c7e395
Merge remote-tracking branch 'upstream/main' into split/archive-setti…
Quicksaver Jun 29, 2026
c8e2721
Merge remote-tracking branch 'upstream/main' into split/archive-setti…
Quicksaver Jul 1, 2026
c7dacda
Merge remote-tracking branch 'upstream/main' into split/archive-setti…
Quicksaver Jul 3, 2026
69785d4
Merge remote-tracking branch 'upstream/main' into split/archive-setti…
Quicksaver Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
362 changes: 362 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,377 @@
import {
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
ThreadId,
type OrchestrationProjectShell,
type OrchestrationThreadShell,
type ProviderInstanceConfig,
} from "@t3tools/contracts";
import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads";
import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
import { normalizeSearchQuery } from "@t3tools/shared/searchRanking";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { describe, expect, it } from "vite-plus/test";
import {
archivedThreadSearchScore,
buildArchivedThreadGroups,
buildProviderInstanceUpdatePatch,
formatDiagnosticsDescription,
nextArchivedThreadSortState,
parseArchivedThreadSearchInput,
runArchivedProjectThreadActions,
} from "./SettingsPanels.logic";

const environmentId = EnvironmentId.make("environment-1");

function scoreArchivedTitle(title: string, query: string): number | null {
const normalizedQuery = normalizeSearchQuery(query);
return archivedThreadSearchScore({
normalizedTitle: normalizeSearchQuery(title),
normalizedQuery,
tokens: normalizedQuery.split(/\s+/u).filter((token) => token.length > 0),
});
}

function makeProject(
input: Partial<OrchestrationProjectShell> & Pick<OrchestrationProjectShell, "id" | "title">,
): OrchestrationProjectShell {
return {
workspaceRoot: `/workspaces/${input.id}`,
repositoryIdentity: null,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
...input,
};
}

function makeThread(
input: Partial<OrchestrationThreadShell> &
Pick<OrchestrationThreadShell, "id" | "projectId" | "title">,
): OrchestrationThreadShell {
return {
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
latestTurn: null,
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
archivedAt: "2026-06-02T00:00:00.000Z",
session: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
...input,
};
}

function makeSnapshot(
projects: ReadonlyArray<OrchestrationProjectShell>,
threads: ReadonlyArray<OrchestrationThreadShell>,
targetEnvironmentId = environmentId,
): ArchivedSnapshotEntry {
return {
environmentId: targetEnvironmentId,
snapshot: {
snapshotSequence: 1,
projects,
threads,
updatedAt: "2026-06-04T00:00:00.000Z",
},
};
}

function successResult(value: unknown = null): AtomCommandResult<unknown, unknown> {
return AsyncResult.success(value);
}

function failureResult(cause: unknown): AtomCommandResult<unknown, unknown> {
return AsyncResult.failure(Cause.fail(cause));
}

function waitForMacrotask(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}

describe("archivedThreadSearchScore", () => {
it("ranks phrase matches ahead of all-token and partial-token matches", () => {
const phraseMatch = scoreArchivedTitle("Alpha Beta cleanup", "alpha beta");
const allTokenMatch = scoreArchivedTitle("Alpha cleanup Beta", "alpha beta");
const partialTokenMatch = scoreArchivedTitle("Alpha cleanup", "alpha beta");

expect(phraseMatch).not.toBeNull();
expect(allTokenMatch).not.toBeNull();
expect(partialTokenMatch).not.toBeNull();
expect(phraseMatch!).toBeLessThan(allTokenMatch!);
expect(allTokenMatch!).toBeLessThan(partialTokenMatch!);
});

it("matches titles case-insensitively and rejects unrelated titles", () => {
expect(scoreArchivedTitle("Release Candidate Notes", "candidate")).not.toBeNull();
expect(scoreArchivedTitle("Release Candidate Notes", "missing")).toBeNull();
});
});

describe("buildArchivedThreadGroups", () => {
it("keeps project order when not searching and sorts threads by archive timestamp", () => {
const firstProject = makeProject({ id: ProjectId.make("project-1"), title: "First" });
const secondProject = makeProject({ id: ProjectId.make("project-2"), title: "Second" });
const older = makeThread({
id: ThreadId.make("thread-older"),
projectId: firstProject.id,
title: "Older",
});
const newer = makeThread({
archivedAt: "2026-06-03T00:00:00.000Z",
id: ThreadId.make("thread-newer"),
projectId: firstProject.id,
title: "Newer",
});
const search = parseArchivedThreadSearchInput("");

const result = buildArchivedThreadGroups({
snapshots: [makeSnapshot([firstProject, secondProject], [older, newer])],
normalizedSearchQuery: search.normalizedQuery,
searchTokens: search.tokens,
isSearching: search.isSearching,
sort: { field: "archivedAt", direction: "desc" },
});

expect(result.map((group) => group.project.id)).toEqual(["project-1"]);
expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-newer", "thread-older"]);
});

it("filters ranked title matches and sorts matching projects by best score", () => {
const partialProject = makeProject({ id: ProjectId.make("project-partial"), title: "Partial" });
const phraseProject = makeProject({ id: ProjectId.make("project-phrase"), title: "Phrase" });
const partialThread = makeThread({
id: ThreadId.make("thread-partial"),
projectId: partialProject.id,
title: "Alpha cleanup",
});
const phraseThread = makeThread({
id: ThreadId.make("thread-phrase"),
projectId: phraseProject.id,
title: "Alpha Beta cleanup",
});
const missingThread = makeThread({
id: ThreadId.make("thread-missing"),
projectId: partialProject.id,
title: "Gamma cleanup",
});
const search = parseArchivedThreadSearchInput("alpha beta");

const result = buildArchivedThreadGroups({
snapshots: [
makeSnapshot([partialProject, phraseProject], [partialThread, phraseThread, missingThread]),
],
normalizedSearchQuery: search.normalizedQuery,
searchTokens: search.tokens,
isSearching: search.isSearching,
sort: { field: "archivedAt", direction: "desc" },
});

expect(result.map((group) => group.project.id)).toEqual(["project-phrase", "project-partial"]);
expect(result.flatMap((group) => group.threads.map((thread) => thread.id))).toEqual([
"thread-phrase",
"thread-partial",
]);
});

it("uses the latest duplicate project metadata and ignores threads without projects", () => {
const sharedProjectId = ProjectId.make("project-shared");
const remoteEnvironmentId = EnvironmentId.make("environment-2");
const olderProject = makeProject({ id: sharedProjectId, title: "Older Local Project" });
const latestProject = makeProject({
id: sharedProjectId,
title: "Latest Local Project",
workspaceRoot: "/workspaces/latest-local",
});
const remoteProject = makeProject({
id: sharedProjectId,
title: "Remote Project",
workspaceRoot: "/workspaces/remote",
});
const localThread = makeThread({
id: ThreadId.make("thread-local"),
projectId: sharedProjectId,
title: "Local thread",
});
const remoteThread = makeThread({
id: ThreadId.make("thread-remote"),
projectId: sharedProjectId,
title: "Remote thread",
});
const orphanThread = makeThread({
id: ThreadId.make("thread-orphan"),
projectId: ProjectId.make("project-missing"),
title: "Missing project thread",
});
const search = parseArchivedThreadSearchInput("");

const result = buildArchivedThreadGroups({
snapshots: [
makeSnapshot([olderProject], [orphanThread]),
makeSnapshot([latestProject], [localThread]),
makeSnapshot([remoteProject], [remoteThread], remoteEnvironmentId),
],
normalizedSearchQuery: search.normalizedQuery,
searchTokens: search.tokens,
isSearching: search.isSearching,
sort: { field: "archivedAt", direction: "desc" },
});

expect(result).toHaveLength(2);
expect(result.map((group) => `${group.project.environmentId}:${group.project.name}`)).toEqual([
"environment-1:Latest Local Project",
"environment-2:Remote Project",
]);
expect(result.map((group) => group.project.cwd)).toEqual([
"/workspaces/latest-local",
"/workspaces/remote",
]);
expect(result.flatMap((group) => group.threads.map((thread) => thread.id))).toEqual([
"thread-local",
"thread-remote",
]);
});

it("keeps projects separate when environment and project ids contain colons", () => {
const firstEnvironmentId = EnvironmentId.make("environment:one");
const secondEnvironmentId = EnvironmentId.make("environment");
const firstProject = makeProject({
id: ProjectId.make("project"),
title: "First Project",
});
const secondProject = makeProject({
id: ProjectId.make("one:project"),
title: "Second Project",
});
const firstThread = makeThread({
id: ThreadId.make("thread-first"),
projectId: firstProject.id,
title: "First thread",
});
const secondThread = makeThread({
id: ThreadId.make("thread-second"),
projectId: secondProject.id,
title: "Second thread",
});
const search = parseArchivedThreadSearchInput("");

const result = buildArchivedThreadGroups({
snapshots: [
makeSnapshot([firstProject], [firstThread], firstEnvironmentId),
makeSnapshot([secondProject], [secondThread], secondEnvironmentId),
],
normalizedSearchQuery: search.normalizedQuery,
searchTokens: search.tokens,
isSearching: search.isSearching,
sort: { field: "archivedAt", direction: "desc" },
});

expect(
result.map((group) => ({
environmentId: group.project.environmentId,
projectId: group.project.id,
threadIds: group.threads.map((thread) => thread.id),
})),
).toEqual([
{
environmentId: "environment:one",
projectId: "project",
threadIds: ["thread-first"],
},
{
environmentId: "environment",
projectId: "one:project",
threadIds: ["thread-second"],
},
]);
});
});

describe("nextArchivedThreadSortState", () => {
it("toggles the active sort field and defaults new fields to descending", () => {
expect(
nextArchivedThreadSortState({ field: "archivedAt", direction: "desc" }, "archivedAt"),
).toEqual({ field: "archivedAt", direction: "asc" });
expect(
nextArchivedThreadSortState({ field: "archivedAt", direction: "asc" }, "createdAt"),
).toEqual({ field: "createdAt", direction: "desc" });
});
});

describe("runArchivedProjectThreadActions", () => {
it("runs all archived project thread actions and returns failures", async () => {
const threads = Array.from({ length: 6 }, (_, index) => ({
id: ThreadId.make(`thread-${index}`),
environmentId,
}));
let activeCount = 0;
let maxActiveCount = 0;
const attemptedThreadIds: string[] = [];

const failures = await runArchivedProjectThreadActions(threads, async (thread) => {
attemptedThreadIds.push(thread.id);
activeCount += 1;
maxActiveCount = Math.max(maxActiveCount, activeCount);
await waitForMacrotask();
activeCount -= 1;
return thread.id === "thread-2" ? failureResult(new Error("failed")) : successResult();
});

expect(failures).toHaveLength(1);
expect(attemptedThreadIds).toHaveLength(threads.length);
expect(new Set(attemptedThreadIds)).toEqual(new Set(threads.map((thread) => thread.id)));
expect(maxActiveCount).toBe(4);
});

it("waits for active archived project thread actions before rethrowing aggregate errors", async () => {
const threads = Array.from({ length: 6 }, (_, index) => ({
id: ThreadId.make(`thread-${index}`),
environmentId,
}));
let activeCount = 0;
const attemptedThreadIds: string[] = [];
let caughtError: unknown;

try {
await runArchivedProjectThreadActions(threads, async (thread) => {
attemptedThreadIds.push(thread.id);
activeCount += 1;
try {
await waitForMacrotask();
if (thread.id === "thread-0" || thread.id === "thread-1") {
throw new Error("failed");
}
return successResult();
} finally {
activeCount -= 1;
}
});
} catch (error) {
caughtError = error;
}

expect(activeCount).toBe(0);
expect(caughtError).toBeInstanceOf(AggregateError);
expect((caughtError as AggregateError).errors).toHaveLength(2);
expect(attemptedThreadIds).toHaveLength(4);
expect(new Set(attemptedThreadIds)).toEqual(
new Set(["thread-0", "thread-1", "thread-2", "thread-3"]),
);
});
});

describe("formatDiagnosticsDescription", () => {
it("collapses trace and metric URLs that share the same OTEL base path", () => {
expect(
Expand Down
Loading
Loading