From fa8237e79a121ca6f2688ee36c6a68fb7814d731 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 16 Aug 2026 18:52:17 -0700 Subject: [PATCH 01/10] feat(web): filter archived threads by project --- apps/web/src/archiveProjectFiltering.test.ts | 159 ++++++++++++++++++ apps/web/src/archiveProjectFiltering.ts | 102 +++++++++++ .../src/components/ProjectScopeBreadcrumb.tsx | 77 +++++++++ .../settings/ProjectSettingsPanel.tsx | 78 ++------- .../settings/SettingsBreadcrumb.tsx | 40 +++++ .../components/settings/SettingsPanels.tsx | 98 ++++------- apps/web/src/lib/archivedThreadsState.ts | 50 ++++++ apps/web/src/routes/settings.archived.tsx | 13 +- 8 files changed, 488 insertions(+), 129 deletions(-) create mode 100644 apps/web/src/archiveProjectFiltering.test.ts create mode 100644 apps/web/src/archiveProjectFiltering.ts create mode 100644 apps/web/src/components/ProjectScopeBreadcrumb.tsx diff --git a/apps/web/src/archiveProjectFiltering.test.ts b/apps/web/src/archiveProjectFiltering.test.ts new file mode 100644 index 000000000000..ba909b7a82e9 --- /dev/null +++ b/apps/web/src/archiveProjectFiltering.test.ts @@ -0,0 +1,159 @@ +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, + resolveArchivedProjectKey, +} 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 { + 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 { + 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, + threads: ReadonlyArray, +) { + 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)).toHaveLength(2); + + const alphaKey = model.projectGroups.find((group) => group.displayName === "Alpha")?.projectKey; + expect(alphaKey).toBeDefined(); + expect(filterArchivedProjectGroups(model.archivedGroups, alphaKey ?? null)).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), + ).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("falls back to All for a stale project key", () => { + const project = makeProject(); + const model = buildModel([project], [makeThread(project)]); + + expect(resolveArchivedProjectKey(model.projectGroups, "missing-project")).toBeNull(); + expect(resolveArchivedProjectKey(model.projectGroups, model.projectGroups[0]!.projectKey)).toBe( + model.projectGroups[0]!.projectKey, + ); + }); +}); diff --git a/apps/web/src/archiveProjectFiltering.ts b/apps/web/src/archiveProjectFiltering.ts new file mode 100644 index 000000000000..c010c0023873 --- /dev/null +++ b/apps/web/src/archiveProjectFiltering.ts @@ -0,0 +1,102 @@ +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; +} + +export interface ArchivedProjectModel { + readonly archivedGroups: ReadonlyArray; + readonly projectGroups: ReadonlyArray; +} + +function scopedProjectId(project: Pick): string { + return `${project.environmentId}:${project.id}`; +} + +export function buildArchivedProjectModel(input: { + readonly primaryEnvironmentId: EnvironmentId | null; + readonly projects: ReadonlyArray; + readonly resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; + readonly settings: ProjectGroupingSettings; + readonly threads: ReadonlyArray; +}): ArchivedProjectModel { + const threadsByProject = new Map(); + 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 resolveArchivedProjectKey( + projectGroups: ReadonlyArray, + requestedProjectKey: string | null, +): string | null { + if (requestedProjectKey === null) return null; + return projectGroups.some((group) => group.projectKey === requestedProjectKey) + ? requestedProjectKey + : null; +} + +export function filterArchivedProjectGroups( + archivedGroups: ReadonlyArray, + projectKey: string | null, +): ReadonlyArray { + return projectKey === null + ? archivedGroups + : archivedGroups.filter((group) => group.logicalProjectKey === projectKey); +} diff --git a/apps/web/src/components/ProjectScopeBreadcrumb.tsx b/apps/web/src/components/ProjectScopeBreadcrumb.tsx new file mode 100644 index 000000000000..dc883f36c24d --- /dev/null +++ b/apps/web/src/components/ProjectScopeBreadcrumb.tsx @@ -0,0 +1,77 @@ +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_projects__"; + +export interface ProjectScopeBreadcrumbItem { + readonly id: string; + readonly label: string; +} + +export function ProjectScopeBreadcrumb(props: { + readonly allLabel?: string | undefined; + readonly ariaLabel: string; + readonly items: ReadonlyArray; + 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 openProjectMenu = (event: ReactMouseEvent) => { + const api = readLocalApi(); + if (!api) return; + + const rect = event.currentTarget.getBoundingClientRect(); + const items: ContextMenuItem[] = [ + ...(props.allLabel + ? [{ id: ALL_PROJECTS_MENU_ID, label: props.allLabel } satisfies ContextMenuItem] + : []), + ...props.items, + ]; + void settlePromise(() => + api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), + ).then((clicked) => { + if (clicked._tag === "Failure" || clicked.value === null) return; + props.onSelect(clicked.value === ALL_PROJECTS_MENU_ID ? null : clicked.value); + }); + }; + + return ( + + {props.rootLabel} + + + {selectedLabel ? ( + + ) : ( + {props.unavailableLabel} + )} + + + ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 0fb0415a34bb..120abc68f3ee 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -13,7 +13,6 @@ import { selectProjectGroupingSettings, } from "../../logicalProject"; import type { - ContextMenuItem, ModelSelection, ProviderDriverKind, SidebarProjectGroupingMode, @@ -26,14 +25,7 @@ import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; import { isElectron } from "../../env"; @@ -73,6 +65,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { ProjectFavicon } from "../ProjectFavicon"; +import { ProjectScopeBreadcrumb } from "../ProjectScopeBreadcrumb"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, @@ -96,11 +89,6 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { SidebarInset } from "../ui/sidebar"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { SettingResetButton, @@ -186,53 +174,23 @@ export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); - const selected = groups.find((group) => group.projectKey === projectKey) ?? null; - const openProjectMenu = (event: ReactMouseEvent) => { - const api = readLocalApi(); - if (!api) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const items: ContextMenuItem[] = groups.map((group) => ({ - id: group.projectKey, - label: group.displayName, - })); - void settlePromise(() => - api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), - ).then((clicked) => { - if (clicked._tag === "Failure" || clicked.value === null) return; - void navigate({ - to: "/projects/$projectKey", - params: { projectKey: clicked.value }, - replace: true, - hashScrollIntoView: false, - }); - }); - }; - return ( - - Projects - - - {selected ? ( - - ) : ( - Unavailable project - )} - - + ({ id: group.projectKey, label: group.displayName }))} + onSelect={(selectedProjectKey) => { + if (selectedProjectKey === null) return; + void navigate({ + to: "/projects/$projectKey", + params: { projectKey: selectedProjectKey }, + replace: true, + hashScrollIntoView: false, + }); + }} + rootLabel="Projects" + selectedKey={projectKey} + unavailableLabel="Unavailable project" + /> ); } diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.tsx index bb631187cb1e..c7f320449fde 100644 --- a/apps/web/src/components/settings/SettingsBreadcrumb.tsx +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -1,3 +1,9 @@ +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { resolveArchivedProjectKey } from "../../archiveProjectFiltering"; +import { useArchivedProjectModel } from "../../lib/archivedThreadsState"; +import { ProjectScopeBreadcrumb } from "../ProjectScopeBreadcrumb"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, @@ -16,6 +22,10 @@ function settingsBreadcrumbLabel(pathname: string): string | null { } export function SettingsBreadcrumb({ pathname }: { pathname: string }) { + const normalizedPathname = pathname.replace(/\/+$/, "") || "/"; + if (normalizedPathname === "/settings/archived") { + return ; + } const sectionLabel = settingsBreadcrumbLabel(pathname); return ( @@ -32,3 +42,33 @@ export function SettingsBreadcrumb({ pathname }: { pathname: string }) { ); } + +function ArchivedThreadsBreadcrumb() { + const search = useSearch({ from: "/settings/archived" }); + const navigate = useNavigate({ from: "/settings/archived" }); + const { isLoading, projectGroups } = useArchivedProjectModel(); + const selectedProjectKey = resolveArchivedProjectKey(projectGroups, search.project ?? null); + + useEffect(() => { + if (search.project === undefined || isLoading || selectedProjectKey !== null) return; + void navigate({ search: {}, replace: true, hashScrollIntoView: false }); + }, [isLoading, navigate, search.project, selectedProjectKey]); + + return ( + ({ id: group.projectKey, label: group.displayName }))} + onSelect={(projectKey) => { + void navigate({ + search: projectKey === null ? {} : { project: projectKey }, + replace: true, + hashScrollIntoView: false, + }); + }} + rootLabel="Archive" + selectedKey={selectedProjectKey} + unavailableLabel="All" + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9539f95914cb..52cef379cd52 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -78,8 +78,11 @@ import { import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; -import { useProjects } from "../../state/entities"; -import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; +import { useArchivedProjectModel } from "../../lib/archivedThreadsState"; +import { + filterArchivedProjectGroups, + resolveArchivedProjectKey, +} from "../../archiveProjectFiltering"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; @@ -2404,70 +2407,24 @@ export function GeneralSettingsPanel() { ); } -export function ArchivedThreadsPanel() { - const projects = useProjects(); +export function ArchivedThreadsPanel({ projectKey }: { projectKey: string | null }) { const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); - const environmentIds = useMemo( - () => [...new Set(projects.map((project) => project.environmentId))], - [projects], - ); const { - snapshots: archivedSnapshots, + archivedGroups, error: archiveError, isLoading: isLoadingArchive, + projectGroups, refresh: refreshArchivedThreads, - } = useArchivedThreadSnapshots(environmentIds); - - const archivedGroups = useMemo(() => { - const projectsByEnvironmentAndId = new Map( - archivedSnapshots.flatMap(({ environmentId, snapshot }) => - snapshot.projects.map( - (project) => - [ - `${environmentId}:${project.id}`, - { - id: project.id, - environmentId, - name: project.title, - cwd: project.workspaceRoot, - faviconPath: project.faviconPath, - }, - ] as const, - ), - ), - ); - const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => - snapshot.threads.map((thread) => ({ - ...thread, - environmentId, - })), - ); - - const archivedProjects = Array.from(projectsByEnvironmentAndId.values()); - const groups: Array<{ - readonly project: (typeof archivedProjects)[number]; - readonly threads: Array<(typeof threads)[number]>; - }> = []; - for (const project of archivedProjects) { - const projectThreads: Array<(typeof threads)[number]> = []; - for (const thread of threads) { - if (thread.projectId === project.id && thread.environmentId === project.environmentId) { - projectThreads.push(thread); - } - } - if (projectThreads.length > 0) { - groups.push({ - 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); - }), - }); - } - } - return groups; - }, [archivedSnapshots]); + } = useArchivedProjectModel(); + const selectedProjectKey = resolveArchivedProjectKey(projectGroups, projectKey); + const selectedProject = + selectedProjectKey === null + ? null + : (projectGroups.find((group) => group.projectKey === selectedProjectKey) ?? null); + const visibleArchivedGroups = useMemo( + () => filterArchivedProjectGroups(archivedGroups, selectedProjectKey), + [archivedGroups, selectedProjectKey], + ); const handleArchivedThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { @@ -2519,7 +2476,7 @@ export function ArchivedThreadsPanel() { return ( - {archivedGroups.length === 0 ? ( + {visibleArchivedGroups.length === 0 ? ( } description={ isLoadingArchive ? "Checking connected environments." - : (archiveError ?? "Archived threads will appear here.") + : (archiveError ?? + (selectedProject + ? "Choose another project or All." + : "Archived threads will appear here.")) } /> ) : ( - archivedGroups.map(({ project, threads: projectThreads }, index) => ( + visibleArchivedGroups.map(({ project, threads: projectThreads }, index) => ( } diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index 2d52383c02c9..74764f84a8d4 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -7,8 +7,13 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useMemo } from "react"; +import { buildArchivedProjectModel } from "../archiveProjectFiltering"; +import { selectProjectGroupingSettings } from "../logicalProject"; +import { useClientSettings } from "../hooks/useSettings"; import { orchestrationEnvironment } from "../state/orchestration"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useProjects } from "../state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; function archivedSnapshotAtom(environmentId: EnvironmentId) { return orchestrationEnvironment.archivedShellSnapshot({ @@ -48,3 +53,48 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray + [ + ...new Set([ + ...environments.map((environment) => environment.environmentId), + ...liveProjects.map((project) => project.environmentId), + ]), + ].sort(), + [environments, liveProjects], + ); + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const archiveState = useArchivedThreadSnapshots(environmentIds); + const model = useMemo(() => { + const projects = archiveState.snapshots.flatMap(({ environmentId, snapshot }) => + snapshot.projects.map((project) => ({ ...project, environmentId })), + ); + const threads = archiveState.snapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ ...thread, environmentId })), + ); + return buildArchivedProjectModel({ + projects, + threads, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + }); + }, [archiveState.snapshots, environmentLabelById, primaryEnvironmentId, projectGroupingSettings]); + + return { + ...archiveState, + ...model, + }; +} diff --git a/apps/web/src/routes/settings.archived.tsx b/apps/web/src/routes/settings.archived.tsx index 3ad690afc027..eadb5e500519 100644 --- a/apps/web/src/routes/settings.archived.tsx +++ b/apps/web/src/routes/settings.archived.tsx @@ -2,6 +2,17 @@ import { createFileRoute } from "@tanstack/react-router"; import { ArchivedThreadsPanel } from "../components/settings/SettingsPanels"; +export interface ArchivedThreadsSearch { + readonly project?: string; +} + export const Route = createFileRoute("/settings/archived")({ - component: ArchivedThreadsPanel, + validateSearch: (raw: Record): ArchivedThreadsSearch => + typeof raw.project === "string" && raw.project ? { project: raw.project.slice(0, 500) } : {}, + component: ArchivedThreadsRouteView, }); + +function ArchivedThreadsRouteView() { + const search = Route.useSearch(); + return ; +} From fb1422b081f8eabfb4780209754428bc1569f306 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 16 Aug 2026 19:28:29 -0700 Subject: [PATCH 02/10] fix(web): preserve long archive project keys --- apps/web/src/archiveProjectFiltering.test.ts | 8 ++++++++ apps/web/src/archiveProjectFiltering.ts | 8 ++++++++ apps/web/src/routes/settings.archived.tsx | 8 ++------ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/web/src/archiveProjectFiltering.test.ts b/apps/web/src/archiveProjectFiltering.test.ts index ba909b7a82e9..b876017d0d89 100644 --- a/apps/web/src/archiveProjectFiltering.test.ts +++ b/apps/web/src/archiveProjectFiltering.test.ts @@ -9,6 +9,7 @@ import { buildArchivedProjectModel, filterArchivedProjectGroups, resolveArchivedProjectKey, + validateArchivedThreadsSearch, } from "./archiveProjectFiltering"; const primaryEnvironmentId = EnvironmentId.make("env-primary"); @@ -156,4 +157,11 @@ describe("archive project filtering", () => { model.projectGroups[0]!.projectKey, ); }); + + 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 }); + }); }); diff --git a/apps/web/src/archiveProjectFiltering.ts b/apps/web/src/archiveProjectFiltering.ts index c010c0023873..f839bbe29636 100644 --- a/apps/web/src/archiveProjectFiltering.ts +++ b/apps/web/src/archiveProjectFiltering.ts @@ -22,6 +22,14 @@ export interface ArchivedProjectModel { readonly projectGroups: ReadonlyArray; } +export interface ArchivedThreadsSearch { + readonly project?: string; +} + +export function validateArchivedThreadsSearch(raw: Record): ArchivedThreadsSearch { + return typeof raw.project === "string" && raw.project ? { project: raw.project } : {}; +} + function scopedProjectId(project: Pick): string { return `${project.environmentId}:${project.id}`; } diff --git a/apps/web/src/routes/settings.archived.tsx b/apps/web/src/routes/settings.archived.tsx index eadb5e500519..3212b251adab 100644 --- a/apps/web/src/routes/settings.archived.tsx +++ b/apps/web/src/routes/settings.archived.tsx @@ -1,14 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; +import { validateArchivedThreadsSearch } from "../archiveProjectFiltering"; import { ArchivedThreadsPanel } from "../components/settings/SettingsPanels"; -export interface ArchivedThreadsSearch { - readonly project?: string; -} - export const Route = createFileRoute("/settings/archived")({ - validateSearch: (raw: Record): ArchivedThreadsSearch => - typeof raw.project === "string" && raw.project ? { project: raw.project.slice(0, 500) } : {}, + validateSearch: validateArchivedThreadsSearch, component: ArchivedThreadsRouteView, }); From 759a9102a94c9a0c88c2a94a46ccd68cd3577f1a Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 16 Aug 2026 21:09:52 -0700 Subject: [PATCH 03/10] fix(web): preserve archive project selection while loading --- apps/web/src/archiveProjectFiltering.test.ts | 60 +++++++++++++++++-- apps/web/src/archiveProjectFiltering.ts | 49 ++++++++++++--- .../settings/SettingsBreadcrumb.tsx | 18 +++--- .../components/settings/SettingsPanels.tsx | 17 ++++-- apps/web/src/lib/archivedThreadsState.ts | 27 ++++++++- .../src/state/archivedThreads.test.ts | 19 ++++++ .../src/state/archivedThreads.ts | 5 +- 7 files changed, 164 insertions(+), 31 deletions(-) diff --git a/apps/web/src/archiveProjectFiltering.test.ts b/apps/web/src/archiveProjectFiltering.test.ts index b876017d0d89..3dd704caa459 100644 --- a/apps/web/src/archiveProjectFiltering.test.ts +++ b/apps/web/src/archiveProjectFiltering.test.ts @@ -7,8 +7,9 @@ import { describe, expect, it } from "vite-plus/test"; import { buildArchivedProjectModel, + canValidateArchivedProjectKey, filterArchivedProjectGroups, - resolveArchivedProjectKey, + resolveArchivedProjectSelection, validateArchivedThreadsSearch, } from "./archiveProjectFiltering"; @@ -148,14 +149,61 @@ describe("archive project filtering", () => { expect(model.projectGroups.map((group) => group.displayName)).toEqual(["Removed project"]); }); - it("falls back to All for a stale project key", () => { + it("only falls back to All for a stale project key after sources are ready", () => { const project = makeProject(); const model = buildModel([project], [makeThread(project)]); + const projectKey = model.projectGroups[0]!.projectKey; - expect(resolveArchivedProjectKey(model.projectGroups, "missing-project")).toBeNull(); - expect(resolveArchivedProjectKey(model.projectGroups, model.projectGroups[0]!.projectKey)).toBe( - model.projectGroups[0]!.projectKey, - ); + expect( + resolveArchivedProjectSelection({ + canValidateProjectKey: false, + projectGroups: model.projectGroups, + requestedProjectKey: "missing-project", + }), + ).toEqual({ + selectedProjectKey: "missing-project", + shouldClearRequestedProjectKey: false, + }); + expect( + resolveArchivedProjectSelection({ + canValidateProjectKey: true, + projectGroups: model.projectGroups, + requestedProjectKey: "missing-project", + }), + ).toEqual({ selectedProjectKey: null, shouldClearRequestedProjectKey: true }); + expect( + resolveArchivedProjectSelection({ + canValidateProjectKey: true, + projectGroups: model.projectGroups, + requestedProjectKey: projectKey, + }), + ).toEqual({ selectedProjectKey: projectKey, shouldClearRequestedProjectKey: false }); + }); + + it("waits for every archive project-key source before validating", () => { + const ready = { + archiveError: null, + archivesReady: true, + environmentsReady: true, + primaryEnvironmentReady: true, + isLoadingArchive: false, + settingsHydrated: true, + }; + + expect(canValidateArchivedProjectKey(ready)).toBe(true); + expect(canValidateArchivedProjectKey({ ...ready, archivesReady: false })).toBe(false); + expect(canValidateArchivedProjectKey({ ...ready, environmentsReady: false })).toBe(false); + expect(canValidateArchivedProjectKey({ ...ready, primaryEnvironmentReady: false })).toBe(false); + expect(canValidateArchivedProjectKey({ ...ready, settingsHydrated: false })).toBe(false); + expect(canValidateArchivedProjectKey({ ...ready, isLoadingArchive: true })).toBe(false); + expect(canValidateArchivedProjectKey({ ...ready, archiveError: "Failed" })).toBe(false); + }); + + it("does not widen an unresolved project filter to All", () => { + const project = makeProject(); + const model = buildModel([project], [makeThread(project)]); + + expect(filterArchivedProjectGroups(model.archivedGroups, "pending-project")).toEqual([]); }); it("preserves a project key longer than 500 characters during route validation", () => { diff --git a/apps/web/src/archiveProjectFiltering.ts b/apps/web/src/archiveProjectFiltering.ts index f839bbe29636..2e3c93b1d1f9 100644 --- a/apps/web/src/archiveProjectFiltering.ts +++ b/apps/web/src/archiveProjectFiltering.ts @@ -26,6 +26,11 @@ export interface ArchivedThreadsSearch { readonly project?: string; } +export interface ArchivedProjectSelection { + readonly selectedProjectKey: string | null; + readonly shouldClearRequestedProjectKey: boolean; +} + export function validateArchivedThreadsSearch(raw: Record): ArchivedThreadsSearch { return typeof raw.project === "string" && raw.project ? { project: raw.project } : {}; } @@ -90,14 +95,42 @@ export function buildArchivedProjectModel(input: { }; } -export function resolveArchivedProjectKey( - projectGroups: ReadonlyArray, - requestedProjectKey: string | null, -): string | null { - if (requestedProjectKey === null) return null; - return projectGroups.some((group) => group.projectKey === requestedProjectKey) - ? requestedProjectKey - : null; +export function canValidateArchivedProjectKey(input: { + readonly archiveError: string | null; + readonly archivesReady: boolean; + readonly environmentsReady: boolean; + readonly primaryEnvironmentReady: boolean; + readonly isLoadingArchive: boolean; + readonly settingsHydrated: boolean; +}): boolean { + return ( + input.environmentsReady && + input.primaryEnvironmentReady && + input.settingsHydrated && + input.archivesReady && + !input.isLoadingArchive && + input.archiveError === null + ); +} + +export function resolveArchivedProjectSelection(input: { + readonly canValidateProjectKey: boolean; + readonly projectGroups: ReadonlyArray; + readonly requestedProjectKey: string | null; +}): ArchivedProjectSelection { + if (input.requestedProjectKey === null) { + return { selectedProjectKey: null, shouldClearRequestedProjectKey: false }; + } + if ( + input.projectGroups.some((group) => group.projectKey === input.requestedProjectKey) || + !input.canValidateProjectKey + ) { + return { + selectedProjectKey: input.requestedProjectKey, + shouldClearRequestedProjectKey: false, + }; + } + return { selectedProjectKey: null, shouldClearRequestedProjectKey: true }; } export function filterArchivedProjectGroups( diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.tsx index c7f320449fde..ca1fe86488aa 100644 --- a/apps/web/src/components/settings/SettingsBreadcrumb.tsx +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -1,7 +1,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; import { useEffect } from "react"; -import { resolveArchivedProjectKey } from "../../archiveProjectFiltering"; +import { resolveArchivedProjectSelection } from "../../archiveProjectFiltering"; import { useArchivedProjectModel } from "../../lib/archivedThreadsState"; import { ProjectScopeBreadcrumb } from "../ProjectScopeBreadcrumb"; import { @@ -46,13 +46,17 @@ export function SettingsBreadcrumb({ pathname }: { pathname: string }) { function ArchivedThreadsBreadcrumb() { const search = useSearch({ from: "/settings/archived" }); const navigate = useNavigate({ from: "/settings/archived" }); - const { isLoading, projectGroups } = useArchivedProjectModel(); - const selectedProjectKey = resolveArchivedProjectKey(projectGroups, search.project ?? null); + const { canValidateProjectKey, isLoading, projectGroups } = useArchivedProjectModel(); + const selection = resolveArchivedProjectSelection({ + canValidateProjectKey, + projectGroups, + requestedProjectKey: search.project ?? null, + }); useEffect(() => { - if (search.project === undefined || isLoading || selectedProjectKey !== null) return; + if (!selection.shouldClearRequestedProjectKey) return; void navigate({ search: {}, replace: true, hashScrollIntoView: false }); - }, [isLoading, navigate, search.project, selectedProjectKey]); + }, [navigate, selection.shouldClearRequestedProjectKey]); return ( ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 52cef379cd52..508f71604088 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -81,7 +81,7 @@ import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../. import { useArchivedProjectModel } from "../../lib/archivedThreadsState"; import { filterArchivedProjectGroups, - resolveArchivedProjectKey, + resolveArchivedProjectSelection, } from "../../archiveProjectFiltering"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -2411,19 +2411,24 @@ export function ArchivedThreadsPanel({ projectKey }: { projectKey: string | null const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); const { archivedGroups, + canValidateProjectKey, error: archiveError, isLoading: isLoadingArchive, projectGroups, refresh: refreshArchivedThreads, } = useArchivedProjectModel(); - const selectedProjectKey = resolveArchivedProjectKey(projectGroups, projectKey); + const selection = resolveArchivedProjectSelection({ + canValidateProjectKey, + projectGroups, + requestedProjectKey: projectKey, + }); const selectedProject = - selectedProjectKey === null + selection.selectedProjectKey === null ? null - : (projectGroups.find((group) => group.projectKey === selectedProjectKey) ?? null); + : (projectGroups.find((group) => group.projectKey === selection.selectedProjectKey) ?? null); const visibleArchivedGroups = useMemo( - () => filterArchivedProjectGroups(archivedGroups, selectedProjectKey), - [archivedGroups, selectedProjectKey], + () => filterArchivedProjectGroups(archivedGroups, selection.selectedProjectKey), + [archivedGroups, selection.selectedProjectKey], ); const handleArchivedThreadContextMenu = useCallback( diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index 74764f84a8d4..ad88ade1caaa 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -7,9 +7,12 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useMemo } from "react"; -import { buildArchivedProjectModel } from "../archiveProjectFiltering"; +import { + buildArchivedProjectModel, + canValidateArchivedProjectKey, +} from "../archiveProjectFiltering"; import { selectProjectGroupingSettings } from "../logicalProject"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useClientSettingsHydrated } from "../hooks/useSettings"; import { orchestrationEnvironment } from "../state/orchestration"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useProjects } from "../state/entities"; @@ -35,6 +38,7 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray; readonly error: string | null; readonly isLoading: boolean; + readonly isReady: boolean; readonly refresh: () => void; } { const environmentKey = useMemo( @@ -57,8 +61,9 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray [ @@ -92,9 +97,25 @@ export function useArchivedProjectModel() { resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }); }, [archiveState.snapshots, environmentLabelById, primaryEnvironmentId, projectGroupingSettings]); + const isLoading = + archiveState.isLoading || + !archiveState.isReady || + !environmentsReady || + primaryEnvironmentId === null || + !settingsHydrated; + const canValidateProjectKey = canValidateArchivedProjectKey({ + archiveError: archiveState.error, + archivesReady: archiveState.isReady, + environmentsReady, + primaryEnvironmentReady: primaryEnvironmentId !== null, + isLoadingArchive: archiveState.isLoading, + settingsHydrated, + }); return { ...archiveState, ...model, + canValidateProjectKey, + isLoading, }; } diff --git a/packages/client-runtime/src/state/archivedThreads.test.ts b/packages/client-runtime/src/state/archivedThreads.test.ts index aa16b9cadcd7..a9460c7133e4 100644 --- a/packages/client-runtime/src/state/archivedThreads.test.ts +++ b/packages/client-runtime/src/state/archivedThreads.test.ts @@ -34,6 +34,25 @@ it("does not expose an archived snapshot failure message", () => { snapshots: [], error: "Failed to load archived threads.", isLoading: false, + isReady: true, + }); + + registry.dispose(); +}); + +it("does not mark an initial archived snapshot as ready", () => { + const environmentId = EnvironmentId.make("env-initial"); + const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ + getSnapshotAtom: () => Atom.make(AsyncResult.initial(false)), + labelPrefix: "test:archived-thread-snapshots", + }); + const registry = AtomRegistry.make(); + + expect(registry.get(snapshotsAtom(makeArchivedThreadsEnvironmentKey([environmentId])))).toEqual({ + snapshots: [], + error: null, + isLoading: false, + isReady: false, }); registry.dispose(); diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts index 8c64f1ae506d..c9b471a12934 100644 --- a/packages/client-runtime/src/state/archivedThreads.ts +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -14,6 +14,7 @@ export interface ArchivedThreadSnapshotsState { readonly snapshots: ReadonlyArray; readonly error: string | null; readonly isLoading: boolean; + readonly isReady: boolean; } const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; @@ -48,10 +49,12 @@ export function createArchivedThreadSnapshotsAtomFamily(options: { const snapshots: ArchivedSnapshotEntry[] = []; let error: string | null = null; let isLoading = false; + let isReady = true; for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) { const result = get(options.getSnapshotAtom(environmentId)); isLoading ||= result.waiting; + isReady &&= result._tag !== "Initial"; const snapshot = Option.getOrNull(AsyncResult.value(result)); if (snapshot !== null) { @@ -63,7 +66,7 @@ export function createArchivedThreadSnapshotsAtomFamily(options: { } } - return { snapshots, error, isLoading }; + return { snapshots, error, isLoading, isReady }; }).pipe(Atom.withLabel(`${options.labelPrefix}:${environmentKey}`)), ); } From 59d9ffbc75478a6a5e9c5b0255639d7c89c39946 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 16 Aug 2026 21:17:33 -0700 Subject: [PATCH 04/10] fix(web): keep archive filter recovery available --- apps/web/src/archiveProjectFiltering.test.ts | 4 ++-- apps/web/src/archiveProjectFiltering.ts | 4 ++-- apps/web/src/components/ProjectScopeBreadcrumb.tsx | 5 +++-- apps/web/src/lib/archivedThreadsState.ts | 8 ++------ 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/apps/web/src/archiveProjectFiltering.test.ts b/apps/web/src/archiveProjectFiltering.test.ts index 3dd704caa459..68e04ae827b9 100644 --- a/apps/web/src/archiveProjectFiltering.test.ts +++ b/apps/web/src/archiveProjectFiltering.test.ts @@ -185,7 +185,7 @@ describe("archive project filtering", () => { archiveError: null, archivesReady: true, environmentsReady: true, - primaryEnvironmentReady: true, + hasProjectGroups: true, isLoadingArchive: false, settingsHydrated: true, }; @@ -193,7 +193,7 @@ describe("archive project filtering", () => { expect(canValidateArchivedProjectKey(ready)).toBe(true); expect(canValidateArchivedProjectKey({ ...ready, archivesReady: false })).toBe(false); expect(canValidateArchivedProjectKey({ ...ready, environmentsReady: false })).toBe(false); - expect(canValidateArchivedProjectKey({ ...ready, primaryEnvironmentReady: false })).toBe(false); + expect(canValidateArchivedProjectKey({ ...ready, hasProjectGroups: false })).toBe(false); expect(canValidateArchivedProjectKey({ ...ready, settingsHydrated: false })).toBe(false); expect(canValidateArchivedProjectKey({ ...ready, isLoadingArchive: true })).toBe(false); expect(canValidateArchivedProjectKey({ ...ready, archiveError: "Failed" })).toBe(false); diff --git a/apps/web/src/archiveProjectFiltering.ts b/apps/web/src/archiveProjectFiltering.ts index 2e3c93b1d1f9..f2ab6fe26818 100644 --- a/apps/web/src/archiveProjectFiltering.ts +++ b/apps/web/src/archiveProjectFiltering.ts @@ -99,13 +99,13 @@ export function canValidateArchivedProjectKey(input: { readonly archiveError: string | null; readonly archivesReady: boolean; readonly environmentsReady: boolean; - readonly primaryEnvironmentReady: boolean; + readonly hasProjectGroups: boolean; readonly isLoadingArchive: boolean; readonly settingsHydrated: boolean; }): boolean { return ( input.environmentsReady && - input.primaryEnvironmentReady && + input.hasProjectGroups && input.settingsHydrated && input.archivesReady && !input.isLoadingArchive && diff --git a/apps/web/src/components/ProjectScopeBreadcrumb.tsx b/apps/web/src/components/ProjectScopeBreadcrumb.tsx index dc883f36c24d..0b4046453a82 100644 --- a/apps/web/src/components/ProjectScopeBreadcrumb.tsx +++ b/apps/web/src/components/ProjectScopeBreadcrumb.tsx @@ -30,6 +30,7 @@ export function ProjectScopeBreadcrumb(props: { 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) => { const api = readLocalApi(); if (!api) return; @@ -54,7 +55,7 @@ export function ProjectScopeBreadcrumb(props: { {props.rootLabel} - {selectedLabel ? ( + {selectedLabel || selectionAvailable ? (