Skip to content
172 changes: 172 additions & 0 deletions apps/web/src/archiveProjectFiltering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type {
EnvironmentProject,
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
buildArchivedProjectModel,
filterArchivedProjectGroups,
validateArchivedThreadsSearch,
} from "./archiveProjectFiltering";

const primaryEnvironmentId = EnvironmentId.make("env-primary");
const remoteEnvironmentId = EnvironmentId.make("env-remote");
const groupingSettings = {
sidebarProjectGroupingMode: "repository" as const,
sidebarProjectGroupingOverrides: {},
};
const repositoryIdentity = {
canonicalKey: "github.com/example/shared-repo",
locator: {
source: "git-remote" as const,
remoteName: "origin",
remoteUrl: "https://github.com/example/shared-repo.git",
},
};

function makeProject(overrides: Partial<EnvironmentProject> = {}): EnvironmentProject {
return {
id: ProjectId.make("project-1"),
environmentId: primaryEnvironmentId,
title: "Project one",
workspaceRoot: "/tmp/project-one",
repositoryIdentity: null,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}

function makeThread(
project: EnvironmentProject,
overrides: Partial<EnvironmentThreadShell> = {},
): EnvironmentThreadShell {
return {
id: ThreadId.make(`thread-${project.environmentId}-${project.id}`),
projectId: project.id,
environmentId: project.environmentId,
title: `Archived thread for ${project.title}`,
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
latestTurn: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
archivedAt: "2026-01-02T00:00:00.000Z",
settledOverride: null,
settledAt: null,
session: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
...overrides,
};
}

function buildModel(
projects: ReadonlyArray<EnvironmentProject>,
threads: ReadonlyArray<EnvironmentThreadShell>,
) {
return buildArchivedProjectModel({
projects,
threads,
settings: groupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: (environmentId) =>
environmentId === primaryEnvironmentId ? "Local" : "Remote",
});
}

describe("archive project filtering", () => {
it("shows every archived project for All and narrows to one selected project", () => {
const alpha = makeProject({ title: "Alpha", workspaceRoot: "/tmp/alpha" });
const beta = makeProject({
id: ProjectId.make("project-beta"),
title: "Beta",
workspaceRoot: "/tmp/beta",
});
const model = buildModel([beta, alpha], [makeThread(beta), makeThread(alpha)]);

expect(model.projectGroups.map((group) => group.displayName)).toEqual(["Alpha", "Beta"]);
expect(filterArchivedProjectGroups(model.archivedGroups, null, true)).toHaveLength(2);

const alphaKey = model.projectGroups.find((group) => group.displayName === "Alpha")?.projectKey;
expect(alphaKey).toBeDefined();
expect(filterArchivedProjectGroups(model.archivedGroups, alphaKey ?? null, true)).toEqual([
expect.objectContaining({ project: expect.objectContaining({ title: "Alpha" }) }),
]);
});

it("selects every physical member of one logical project", () => {
const local = makeProject({ repositoryIdentity, title: "Shared" });
const remote = makeProject({
id: ProjectId.make("project-remote"),
environmentId: remoteEnvironmentId,
workspaceRoot: "/srv/shared",
repositoryIdentity,
title: "Shared remote",
});
const model = buildModel([local, remote], [makeThread(local), makeThread(remote)]);

expect(model.projectGroups).toHaveLength(1);
expect(
filterArchivedProjectGroups(model.archivedGroups, model.projectGroups[0]!.projectKey, true),
).toHaveLength(2);
});

it("keeps duplicate project ids scoped to their environments", () => {
const local = makeProject({ title: "Local", workspaceRoot: "/tmp/local" });
const remote = makeProject({
environmentId: remoteEnvironmentId,
title: "Remote",
workspaceRoot: "/srv/remote",
});
const model = buildModel([local, remote], [makeThread(local), makeThread(remote)]);

expect(model.archivedGroups).toHaveLength(2);
expect(model.archivedGroups.map((group) => group.threads[0]?.environmentId)).toEqual([
primaryEnvironmentId,
remoteEnvironmentId,
]);
});

it("keeps an archived-only project as an individual picker item", () => {
const archivedOnly = makeProject({ title: "Removed project" });
const model = buildModel([archivedOnly], [makeThread(archivedOnly)]);

expect(model.projectGroups.map((group) => group.displayName)).toEqual(["Removed project"]);
});

it("does not widen an unavailable project filter to All", () => {
const project = makeProject();
const model = buildModel([project], [makeThread(project)]);

expect(filterArchivedProjectGroups(model.archivedGroups, "pending-project", true)).toEqual([]);
});

it("keeps a known project scope empty until project identity is stable", () => {
const project = makeProject();
const model = buildModel([project], [makeThread(project)]);
const projectKey = model.projectGroups[0]!.projectKey;

expect(filterArchivedProjectGroups(model.archivedGroups, projectKey, false)).toEqual([]);
expect(filterArchivedProjectGroups(model.archivedGroups, projectKey, true)).toHaveLength(1);
});

it("preserves a project key longer than 500 characters during route validation", () => {
const projectKey = `environment:${"nested-worktree/".repeat(40)}`;

expect(projectKey.length).toBeGreaterThan(500);
expect(validateArchivedThreadsSearch({ project: projectKey })).toEqual({ project: projectKey });
});
});
101 changes: 101 additions & 0 deletions apps/web/src/archiveProjectFiltering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type {
EnvironmentProject,
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentId } from "@t3tools/contracts";

import { derivePhysicalProjectKey, type ProjectGroupingSettings } from "./logicalProject";
import {
buildPhysicalToLogicalProjectKeyMap,
buildSidebarProjectSnapshots,
type SidebarProjectSnapshot,
} from "./sidebarProjectGrouping";

export interface ArchivedProjectGroup {
readonly logicalProjectKey: string;
readonly project: EnvironmentProject;
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
}

export interface ArchivedProjectModel {
readonly archivedGroups: ReadonlyArray<ArchivedProjectGroup>;
readonly projectGroups: ReadonlyArray<SidebarProjectSnapshot>;
}

export interface ArchivedThreadsSearch {
readonly project?: string;
}

export function validateArchivedThreadsSearch(raw: Record<string, unknown>): ArchivedThreadsSearch {
return typeof raw.project === "string" && raw.project ? { project: raw.project } : {};
}

function scopedProjectId(project: Pick<EnvironmentProject, "environmentId" | "id">): string {
return `${project.environmentId}:${project.id}`;
}

export function buildArchivedProjectModel(input: {
readonly primaryEnvironmentId: EnvironmentId | null;
readonly projects: ReadonlyArray<EnvironmentProject>;
readonly resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null;
readonly settings: ProjectGroupingSettings;
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
}): ArchivedProjectModel {
const threadsByProject = new Map<string, EnvironmentThreadShell[]>();
for (const thread of input.threads) {
const key = `${thread.environmentId}:${thread.projectId}`;
const existing = threadsByProject.get(key);
if (existing) {
existing.push(thread);
} else {
threadsByProject.set(key, [thread]);
}
}

const physicalGroups = input.projects.flatMap((project) => {
const projectThreads = threadsByProject.get(scopedProjectId(project));
if (!projectThreads?.length) return [];
return [
{
project,
threads: projectThreads.toSorted((left, right) => {
const leftKey = left.archivedAt ?? left.createdAt;
const rightKey = right.archivedAt ?? right.createdAt;
return rightKey.localeCompare(leftKey) || right.id.localeCompare(left.id);
}),
},
];
});
const archivedProjects = physicalGroups.map((group) => group.project);
const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({
projects: archivedProjects,
settings: input.settings,
primaryEnvironmentId: input.primaryEnvironmentId,
});
const projectGroups = buildSidebarProjectSnapshots({
projects: archivedProjects,
settings: input.settings,
primaryEnvironmentId: input.primaryEnvironmentId,
resolveEnvironmentLabel: input.resolveEnvironmentLabel,
}).sort((left, right) => left.displayName.localeCompare(right.displayName));

return {
projectGroups,
archivedGroups: physicalGroups.map((group) => ({
...group,
logicalProjectKey:
logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(group.project)) ??
derivePhysicalProjectKey(group.project),
})),
};
}

export function filterArchivedProjectGroups(
archivedGroups: ReadonlyArray<ArchivedProjectGroup>,
projectKey: string | null,
scopeReady: boolean,
): ReadonlyArray<ArchivedProjectGroup> {
if (projectKey === null) return archivedGroups;
if (!scopeReady) return [];
return archivedGroups.filter((group) => group.logicalProjectKey === projectKey);
}
96 changes: 96 additions & 0 deletions apps/web/src/components/ProjectScopeBreadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { settlePromise } from "@t3tools/client-runtime/state/runtime";
import type { ContextMenuItem } from "@t3tools/contracts";
import { ChevronDownIcon } from "lucide-react";
import type { MouseEvent as ReactMouseEvent } from "react";

import { readLocalApi } from "../localApi";
import {
WorkspaceBreadcrumb,
WorkspaceBreadcrumbItem,
WorkspaceBreadcrumbSeparator,
} from "./WorkspaceBreadcrumb";

const ALL_PROJECTS_MENU_ID = "all";

export interface ProjectScopeBreadcrumbItem {
readonly id: string;
readonly label: string;
}

export function ProjectScopeBreadcrumb(props: {
readonly allLabel?: string | undefined;
readonly ariaLabel: string;
readonly items: ReadonlyArray<ProjectScopeBreadcrumbItem>;
readonly onSelect: (projectKey: string | null) => void;
readonly rootLabel: string;
readonly selectedKey: string | null;
readonly unavailableLabel: string;
}) {
const selectedLabel =
props.selectedKey === null
? (props.allLabel ?? null)
: (props.items.find((item) => item.id === props.selectedKey)?.label ?? null);
const selectionAvailable = props.allLabel !== undefined || props.items.length > 0;
const openProjectMenu = (event: ReactMouseEvent<HTMLButtonElement>) => {
const api = readLocalApi();
if (!api) return;

const rect = event.currentTarget.getBoundingClientRect();
const projectKeyByMenuId = new Map<string, string>(
props.items.map((item, index) => [`project:${index}`, item.id] as const),
);
const items: ContextMenuItem<string>[] = [
...(props.allLabel
? [{ id: ALL_PROJECTS_MENU_ID, label: props.allLabel } satisfies ContextMenuItem<string>]
: []),
...props.items.map((item, index) => ({ id: `project:${index}`, label: item.label })),
];
void settlePromise(() =>
api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }),
).then((clicked) => {
if (clicked._tag === "Failure" || clicked.value === null) return;
if (clicked.value === ALL_PROJECTS_MENU_ID) {
props.onSelect(null);
return;
}
const projectKey = projectKeyByMenuId.get(clicked.value);
if (projectKey !== undefined) {
props.onSelect(projectKey);
}
});
};

return (
<WorkspaceBreadcrumb ariaLabel={props.ariaLabel}>
<WorkspaceBreadcrumbItem>{props.rootLabel}</WorkspaceBreadcrumbItem>
<WorkspaceBreadcrumbSeparator />
<WorkspaceBreadcrumbItem current>
{selectedLabel || selectionAvailable ? (
<button
type="button"
aria-haspopup="menu"
aria-label="Switch project"
onClick={openProjectMenu}
className="group/project-title inline-flex min-w-0 max-w-64 cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={
selectedLabel === null
? "min-w-0 truncate text-muted-foreground"
: "min-w-0 truncate"
}
>
{selectedLabel ?? props.unavailableLabel}
</span>
<ChevronDownIcon
aria-hidden
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/project-title:opacity-100 group-focus-visible/project-title:opacity-100"
/>
</button>
) : (
<span className="truncate text-muted-foreground">{props.unavailableLabel}</span>
)}
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>
);
}
Loading
Loading