diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c547ca06ab59..f1c365e87cf3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1120,6 +1120,16 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { "None", ); assert.deepEqual(yield* snapshotQuery.getDeletedWorktreeThreads(), []); + const archivedOnlyShell = yield* snapshotQuery.getShellSnapshot(); + assert.deepEqual(archivedOnlyShell.threads, []); + assert.deepEqual( + archivedOnlyShell.projects.map((project) => project.id), + [ProjectId.make("project-archive-test")], + ); + assert.deepEqual( + (yield* snapshotQuery.getArchivedShellSnapshot()).projects.map((project) => project.id), + [ProjectId.make("project-archive-test")], + ); yield* sql` UPDATE projection_threads SET branch = 'retained-branch', worktree_path = '/tmp/archived-worktree', diff --git a/apps/web/src/archiveProjectFiltering.test.ts b/apps/web/src/archiveProjectFiltering.test.ts deleted file mode 100644 index 09fc573fb925..000000000000 --- a/apps/web/src/archiveProjectFiltering.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -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, - connectedArchiveEnvironmentIds, - 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 { - 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, - pullRequests: [], - 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("requests archives only from connected environments", () => { - expect( - connectedArchiveEnvironmentIds([ - { - environmentId: remoteEnvironmentId, - connection: { phase: "offline" }, - }, - { - environmentId: primaryEnvironmentId, - connection: { phase: "connected" }, - }, - ]), - ).toEqual([primaryEnvironmentId]); - }); - - 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("does not widen an unavailable 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", () => { - 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 deleted file mode 100644 index 2798cad17253..000000000000 --- a/apps/web/src/archiveProjectFiltering.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -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; -} - -export interface ArchivedThreadsSearch { - readonly project?: string; -} - -export function connectedArchiveEnvironmentIds( - environments: ReadonlyArray<{ - readonly environmentId: EnvironmentId; - readonly connection: { readonly phase: EnvironmentConnectionPhase }; - }>, -): ReadonlyArray { - return environments - .filter((environment) => environment.connection.phase === "connected") - .map((environment) => environment.environmentId) - .toSorted(); -} - -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}`; -} - -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 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 deleted file mode 100644 index 090b54f91782..000000000000 --- a/apps/web/src/components/ProjectScopeBreadcrumb.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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; - 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) => { - const api = readLocalApi(); - if (!api) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const projectKeyByMenuId = new Map( - props.items.map((item, index) => [`project:${index}`, item.id] as const), - ); - const items: ContextMenuItem[] = [ - ...(props.allLabel - ? [{ id: ALL_PROJECTS_MENU_ID, label: props.allLabel } satisfies ContextMenuItem] - : []), - ...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 ( - - {props.rootLabel} - - - {selectedLabel || selectionAvailable ? ( - - ) : ( - {props.unavailableLabel} - )} - - - ); -} diff --git a/apps/web/src/components/settings/ArchivedThreadsPanel.test.tsx b/apps/web/src/components/settings/ArchivedThreadsPanel.test.tsx new file mode 100644 index 000000000000..08c2876c476e --- /dev/null +++ b/apps/web/src/components/settings/ArchivedThreadsPanel.test.tsx @@ -0,0 +1,631 @@ +import type { ReactNode } from "react"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { ProjectId, type EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + scope: null as unknown as { + kind: "all" | "environment" | "project" | "checkout" | "unavailable"; + message?: string; + reason?: string; + environmentIds: ReadonlyArray; + members: ReadonlyArray<{ + id: string; + environmentId: EnvironmentId; + physicalProjectKey?: string; + }>; + }, + scopeReady: true, + projectSnapshotsReady: true, + disabledEnvironmentIds: [] as EnvironmentId[], + configsReady: true, + connectedEnvironmentIds: [] as EnvironmentId[], + archive: { + snapshots: [] as Array<{ + environmentId: EnvironmentId; + snapshot: { + projects: Array<{ id: string; title: string; workspaceRoot: string }>; + threads: Array<{ + id: string; + projectId: string; + title: string; + createdAt: string; + archivedAt: string; + }>; + }; + }>, + error: null as string | null, + isLoading: false, + refresh: vi.fn(), + }, + requestedEnvironmentIds: [] as EnvironmentId[], + contextMenu: vi.fn(), + unarchiveThread: vi.fn(), + confirmAndDeleteThread: vi.fn(), +})); + +vi.mock("./SettingsScopeContext", () => ({ + useSettingsScope: () => ({ + scope: { + ...state.scope, + members: state.scope.members.map((member) => ({ + ...member, + physicalProjectKey: + member.physicalProjectKey ?? `${member.environmentId}:/repos/${member.id}`, + })), + }, + isReady: state.scopeReady, + projectSnapshotsReady: state.projectSnapshotsReady, + environments: state.scope.environmentIds.map((environmentId) => ({ + environmentId, + connection: { + phase: state.connectedEnvironmentIds.includes(environmentId) ? "connected" : "offline", + }, + serverConfig: state.configsReady ? {} : null, + })), + connectedEnvironments: state.configsReady + ? state.connectedEnvironmentIds.map((environmentId) => ({ environmentId })) + : [], + }), +})); + +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useLocation: ({ select }: { select: (location: { hash: string }) => unknown }) => + select({ hash: "" }), +})); + +vi.mock("../../state/environments", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironments: () => ({ + environments: [envA, envB].map((environmentId) => ({ + environmentId, + entry: { enabled: !state.disabledEnvironmentIds.includes(environmentId) }, + connection: { + phase: state.connectedEnvironmentIds.includes(environmentId) ? "connected" : "offline", + }, + })), + }), +})); + +vi.mock("../../lib/archivedThreadsState", () => ({ + useArchivedThreadSnapshots: (environmentIds: EnvironmentId[]) => { + state.requestedEnvironmentIds = environmentIds; + return { + ...state.archive, + snapshots: state.archive.snapshots.filter((entry) => + environmentIds.includes(entry.environmentId), + ), + }; + }, +})); + +vi.mock("../../hooks/useThreadActions", () => ({ + useThreadActions: () => ({ + unarchiveThread: state.unarchiveThread, + confirmAndDeleteThread: state.confirmAndDeleteThread, + }), +})); + +vi.mock("../../localApi", () => ({ + ensureLocalApi: vi.fn(), + readLocalApi: () => ({ contextMenu: { show: state.contextMenu } }), +})); + +vi.mock("../ProjectFavicon", () => ({ + ProjectFavicon: () => null, +})); + +vi.mock("./settingsLayout", () => ({ + PolicyTooltip: ({ children }: { children: ReactNode }) => children, + SETTINGS_PICKER_TRIGGER_CLASSNAME: "", + SettingResetButton: () => null, + SettingsPageContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + SettingsSection: ({ title, children }: { title: ReactNode; children: ReactNode }) => ( +
{children}
+ ), + SettingsRow: ({ title, description, control, onContextMenu }: Record) => ( +
void}> +
{title as ReactNode}
+
{description as ReactNode}
+ {control as ReactNode} +
+ ), + useSettingsSearchTarget: () => vi.fn(), + useSettingsSearchTargetId: () => null, +})); + +import { ArchivedThreadsPanel } from "./SettingsPanels"; +import { SettingsScopeBoundary } from "../../routes/settings"; +import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; +import { resolveSettingsScope } from "./settingsScope"; + +const envA = "environment-a" as EnvironmentId; +const envB = "environment-b" as EnvironmentId; +const renderers: ReactTestRenderer[] = []; + +function snapshot( + environmentId: EnvironmentId, + projects: Array<{ id: string; title: string; workspaceRoot?: string }>, + threads: Array<{ id: string; projectId: string; title: string }>, +) { + return { + environmentId, + snapshot: { + projects: projects.map((project) => ({ + ...project, + workspaceRoot: project.workspaceRoot ?? `/repos/${project.id}`, + })), + threads: threads.map((thread, index) => ({ + ...thread, + createdAt: `2026-09-0${index + 1}T00:00:00.000Z`, + archivedAt: `2026-09-1${index + 1}T00:00:00.000Z`, + })), + }, + }; +} + +function ScopedArchive() { + return ( + + + + ); +} + +function renderPanel(): ReactTestRenderer { + let renderer!: ReactTestRenderer; + act(() => { + renderer = create(); + }); + renderers.push(renderer); + return renderer; +} + +function text(renderer: ReactTestRenderer): string { + const collect = (node: string | { children: Array }): string => + typeof node === "string" + ? node + : node.children + .filter( + (child): child is string | { children: Array } => + typeof child === "string" || + (typeof child === "object" && child !== null && "children" in child), + ) + .map(collect) + .join(" "); + return renderer.root + .findAll((node) => node.props["data-testid"] === "row-title") + .map(collect) + .join(" "); +} + +describe("ArchivedThreadsPanel", () => { + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.scope = { kind: "all", environmentIds: [envA, envB], members: [] }; + state.scopeReady = true; + state.projectSnapshotsReady = true; + state.disabledEnvironmentIds = []; + state.configsReady = true; + state.connectedEnvironmentIds = [envA, envB]; + state.archive.snapshots = [ + snapshot( + envA, + [ + { id: "project-a", title: "Alpha" }, + { id: "project-hidden", title: "Hidden checkout" }, + ], + [ + { id: "thread-a", projectId: "project-a", title: "Alpha thread" }, + { id: "thread-hidden", projectId: "project-hidden", title: "Hidden thread" }, + ], + ), + snapshot( + envB, + [{ id: "project-b", title: "Beta" }], + [{ id: "thread-b", projectId: "project-b", title: "Beta thread" }], + ), + ]; + state.archive.error = null; + state.archive.isLoading = false; + state.archive.refresh.mockReset(); + state.contextMenu.mockReset(); + state.unarchiveThread.mockReset(); + state.confirmAndDeleteThread.mockReset(); + state.requestedEnvironmentIds = []; + }); + + afterEach(() => { + act(() => { + for (const renderer of renderers.splice(0)) renderer.unmount(); + }); + vi.unstubAllGlobals(); + }); + + it.each([ + { + name: "all environments", + scope: { kind: "all" as const, environmentIds: [envA, envB], members: [] }, + shown: ["Alpha thread", "Hidden thread", "Beta thread"], + hidden: [], + }, + { + name: "one environment", + scope: { kind: "environment" as const, environmentIds: [envA], members: [] }, + shown: ["Alpha thread", "Hidden thread"], + hidden: ["Beta thread"], + }, + { + name: "a project across environments", + scope: { + kind: "project" as const, + environmentIds: [envA, envB], + members: [ + { id: "project-a", environmentId: envA }, + { id: "project-b", environmentId: envB }, + ], + }, + shown: ["Alpha thread", "Beta thread"], + hidden: ["Hidden thread"], + }, + { + name: "one checkout", + scope: { + kind: "checkout" as const, + environmentIds: [envB], + members: [{ id: "project-b", environmentId: envB }], + }, + shown: ["Beta thread"], + hidden: ["Alpha thread", "Hidden thread"], + }, + ])("shows archived threads for $name", ({ scope, shown, hidden }) => { + state.scope = scope; + state.connectedEnvironmentIds = scope.environmentIds; + const renderer = renderPanel(); + const renderedText = text(renderer); + + expect(state.requestedEnvironmentIds).toEqual(scope.environmentIds); + for (const title of shown) expect(renderedText).toContain(title); + for (const title of hidden) expect(renderedText).not.toContain(title); + }); + + it.each([false, true])( + "includes archived duplicate registrations in a scoped workspace (checkout=%s)", + (checkout) => { + const projects = [ + { id: "old", workspaceRoot: "/repos/alpha/", environmentId: envA }, + { id: "new", workspaceRoot: "/repos/alpha", environmentId: envA }, + { id: "other", workspaceRoot: "/repos/other", environmentId: envA }, + { id: "remote", workspaceRoot: "/repos/alpha", environmentId: envB }, + ].map((project, index) => ({ + ...project, + id: ProjectId.make(project.id), + title: project.id, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: `2026-09-0${index + 1}T00:00:00.000Z`, + })); + const groups = buildSidebarProjectSnapshots({ + projects, + settings: { sidebarProjectGroupingMode: "separate", sidebarProjectGroupingOverrides: {} }, + primaryEnvironmentId: envA, + resolveEnvironmentLabel: () => "Environment", + }); + const group = groups.find((entry) => + entry.memberProjectRefs.some((ref) => ref.projectId === "old"), + )!; + expect(group.memberProjects.map((member) => member.id)).toEqual(["new"]); + state.scope = resolveSettingsScope( + { + project: group.projectKey, + machine: envA, + ...(checkout ? { checkout: group.memberProjects[0]!.physicalProjectKey } : {}), + }, + groups, + [ + { environmentId: envA, label: "A" }, + { environmentId: envB, label: "B" }, + ], + ); + state.archive.snapshots = [envA, envB].map((environmentId) => + snapshot( + environmentId, + projects.filter((project) => project.environmentId === environmentId), + projects + .filter((project) => project.environmentId === environmentId) + .map((project) => ({ + id: `thread-${project.id}`, + projectId: project.id, + title: `Archive ${project.id}`, + })), + ), + ); + const renderedText = text(renderPanel()); + expect(renderedText).toContain("Archive old"); + expect(renderedText).toContain("Archive new"); + expect(renderedText).not.toContain("Archive other"); + expect(renderedText).not.toContain("Archive remote"); + }, + ); + + it("keeps matching project IDs in different environments separate and sorts newest first", () => { + state.archive.snapshots = [ + snapshot( + envA, + [{ id: "shared-id", title: "Alpha" }], + [ + { id: "older", projectId: "shared-id", title: "Older Alpha" }, + { id: "newer", projectId: "shared-id", title: "Newer Alpha" }, + ], + ), + snapshot( + envB, + [{ id: "shared-id", title: "Beta" }], + [{ id: "beta", projectId: "shared-id", title: "Beta thread" }], + ), + ]; + + const renderer = renderPanel(); + const sections = renderer.root.findAllByType("section"); + expect(sections).toHaveLength(2); + expect( + sections.map((section) => + section + .findAll((node) => node.props["data-testid"] === "row-title") + .map((node) => node.children.join("")), + ), + ).toEqual([["Newer Alpha", "Older Alpha"], ["Beta thread"]]); + }); + + it("queries only connected environments while preserving the selected scope", () => { + state.scope = { + kind: "project", + environmentIds: [envA, envB], + members: [ + { id: "project-a", environmentId: envA }, + { id: "project-b", environmentId: envB }, + ], + }; + state.connectedEnvironmentIds = [envA]; + + const renderedText = text(renderPanel()); + + expect(state.requestedEnvironmentIds).toEqual([envA]); + expect(renderedText).toContain("Alpha thread"); + expect(renderedText).not.toContain("Beta thread"); + }); + + it("loads archives from a connected environment before its server config arrives", () => { + state.configsReady = false; + state.scope = { kind: "environment", environmentIds: [envA], members: [] }; + state.connectedEnvironmentIds = [envA]; + state.archive.isLoading = true; + state.archive.snapshots = []; + const renderer = renderPanel(); + + expect(state.requestedEnvironmentIds).toEqual([envA]); + expect(text(renderer)).toContain("Loading archived threads"); + expect(text(renderer)).not.toContain("No archived threads"); + + state.archive.isLoading = false; + state.archive.snapshots = [ + snapshot( + envA, + [{ id: "project-a", title: "Alpha" }], + [{ id: "thread-a", projectId: "project-a", title: "Alpha thread" }], + ), + ]; + act(() => renderer.update()); + expect(text(renderer)).toContain("Alpha thread"); + }); + + it.each([ + ["/settings/archived", false], + ["/settings/general", true], + ] as const)("keeps the reconnect notice for %s when connected=%s", (pathname, connected) => { + state.scope = { kind: "environment", environmentIds: [envA], members: [] }; + state.configsReady = false; + state.connectedEnvironmentIds = connected ? [envA] : []; + let renderer!: ReactTestRenderer; + act(() => { + renderer = create( + + + , + ); + }); + renderers.push(renderer); + + expect(renderer.root.findByType("p").children.join("")).toContain("Reconnect"); + expect(state.requestedEnvironmentIds).toEqual([]); + }); + + it.each(["all", "project", "checkout"] as const)( + "shows unavailable archives for a disconnected %s scope, then recovers on connection", + (kind) => { + state.scope = { + kind, + environmentIds: [envA], + members: [{ id: "project-a", environmentId: envA }], + }; + state.connectedEnvironmentIds = []; + const renderer = renderPanel(); + expect(state.requestedEnvironmentIds).toEqual([]); + expect(text(renderer)).toContain("No connected environments"); + expect(text(renderer)).not.toContain("No archived threads"); + expect( + renderer.root.find((node) => node.props["data-testid"] === "row-description").children, + ).toContain("Connect an environment in this scope to view archived threads."); + + state.connectedEnvironmentIds = [envA]; + act(() => renderer.update()); + expect(text(renderer)).toContain("Alpha thread"); + expect(text(renderer)).not.toContain("No connected environments"); + }, + ); + + it.each(["project-missing", "checkout-missing"])( + "does not reject a cached %s scope before live snapshots", + (reason) => { + state.scope = { + kind: "unavailable", + reason, + message: "This project is no longer available.", + environmentIds: [], + members: [], + }; + state.projectSnapshotsReady = false; + const renderer = renderPanel(); + expect(renderer.root.findByType("p").children.join("")).toBe("Loading archived threads"); + + state.connectedEnvironmentIds = [envA]; + act(() => renderer.update()); + expect(renderer.root.findByType("p").children.join("")).toContain("offline or syncing"); + expect(renderer.root.findByType("p").children.join("")).not.toContain("no longer available"); + + state.disabledEnvironmentIds = [envB]; + act(() => renderer.update()); + expect(renderer.root.findByType("p").children.join("")).toBe("Loading archived threads"); + + state.disabledEnvironmentIds = [envA, envB]; + act(() => renderer.update()); + expect(renderer.root.findByType("p").children.join("")).toContain("offline or syncing"); + + state.disabledEnvironmentIds = []; + state.projectSnapshotsReady = true; + act(() => renderer.update()); + expect(renderer.root.findByType("p").children.join("")).toBe( + "This project is no longer available.", + ); + }, + ); + + it("keeps a resolved project usable while another environment lacks a live snapshot", () => { + state.scope = { + kind: "project", + environmentIds: [envA], + members: [{ id: "project-a", environmentId: envA }], + }; + state.projectSnapshotsReady = false; + state.connectedEnvironmentIds = [envA]; + expect(text(renderPanel())).toContain("Alpha thread"); + }); + + it.each([true, false])("waits for a saved project scope to resolve (exists=%s)", (exists) => { + state.scope = { + kind: "unavailable", + environmentIds: [], + members: [], + message: "This project is no longer available.", + }; + state.scopeReady = false; + const renderer = renderPanel(); + expect(text(renderer)).toContain("Loading archived threads"); + expect(renderer.root.findAllByType("p")).toHaveLength(0); + + state.scopeReady = true; + if (exists) { + state.scope = { + kind: "project", + environmentIds: [envA], + members: [{ id: "project-a", environmentId: envA }], + }; + } + act(() => renderer.update()); + if (exists) { + expect(text(renderer)).toContain("Alpha thread"); + expect(renderer.root.findAllByType("p")).toHaveLength(0); + } else { + expect(renderer.root.findByType("p").children.join("")).toBe( + "This project is no longer available.", + ); + } + }); + + it("waits for scope discovery before showing an empty archive", () => { + state.scopeReady = false; + state.connectedEnvironmentIds = []; + state.archive.snapshots = []; + const renderer = renderPanel(); + expect(text(renderer)).toContain("Loading archived threads"); + expect(text(renderer)).not.toContain("No archived threads"); + + state.scopeReady = true; + state.connectedEnvironmentIds = [envA]; + act(() => renderer.update()); + expect(text(renderer)).toContain("No archived threads"); + expect(text(renderer)).not.toContain("Loading archived threads"); + }); + + it.each([ + [true, null, "Loading archived threads", "Checking connected environments."], + [ + false, + "Archive service unavailable", + "Could not load archived threads", + "Archive service unavailable", + ], + [false, null, "No archived threads", "Archived threads will appear here."], + ] as const)("renders empty state loading=%s error=%s", (isLoading, error, title, description) => { + state.archive.snapshots = []; + state.archive.isLoading = isLoading; + state.archive.error = error; + const renderer = renderPanel(); + + expect(text(renderer)).toContain(title); + expect( + renderer.root.find((node) => node.props["data-testid"] === "row-description").children, + ).toContain(description); + }); + + it("deletes with the complete environment snapshot even when project scope hides a checkout", async () => { + state.scope = { + kind: "project", + environmentIds: [envA], + members: [{ id: "project-a", environmentId: envA }], + }; + state.contextMenu.mockResolvedValue("delete"); + let markDeleteInvoked!: () => void; + const deleteInvoked = new Promise((resolve) => { + markDeleteInvoked = resolve; + }); + state.confirmAndDeleteThread.mockImplementation(() => { + markDeleteInvoked(); + return Promise.resolve(AsyncResult.success(undefined)); + }); + const renderer = renderPanel(); + const visibleRow = renderer.root + .findAll((node) => node.props["data-testid"] === "settings-row") + .find((row) => + row + .find((node) => node.props["data-testid"] === "row-title") + .children.includes("Alpha thread"), + ); + + await act(async () => { + visibleRow?.props.onContextMenu({ + preventDefault: vi.fn(), + clientX: 10, + clientY: 20, + }); + await deleteInvoked; + }); + + expect(state.confirmAndDeleteThread).toHaveBeenCalledWith( + { environmentId: envA, threadId: "thread-a" }, + { + archivedThreads: expect.arrayContaining([ + expect.objectContaining({ id: "thread-a", environmentId: envA }), + expect.objectContaining({ id: "thread-hidden", environmentId: envA }), + ]), + }, + ); + expect(state.confirmAndDeleteThread.mock.calls[0]?.[1].archivedThreads).toHaveLength(2); + }); +}); diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.test.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.test.tsx deleted file mode 100644 index b23200c5559b..000000000000 --- a/apps/web/src/components/settings/SettingsBreadcrumb.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { - Outlet, - RouterContextProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, - useLocation, -} from "@tanstack/react-router"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { SettingsBreadcrumb } from "./SettingsBreadcrumb"; - -vi.mock("../../lib/archivedThreadsState", () => ({ - useArchivedProjectModel: () => ({ - isLoading: false, - projectGroups: [{ projectKey: "project-alpha", displayName: "Alpha Project" }], - }), -})); - -function createArchiveRouter(initialEntry: string, pauseArchive: boolean) { - let markArchiveStarted = () => {}; - let releaseArchive = () => {}; - const archiveStarted = new Promise((resolve) => { - markArchiveStarted = resolve; - }); - const archiveReleased = new Promise((resolve) => { - releaseArchive = resolve; - }); - - const rootRoute = createRootRoute({ - component: Outlet, - }); - const settingsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "settings", - component: SettingsTestLayout, - }); - const generalRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: "general", - component: () =>
General settings
, - }); - const archivedRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: "archived", - validateSearch: (search): { project?: string } => - typeof search.project === "string" ? { project: search.project } : {}, - beforeLoad: async () => { - if (!pauseArchive) return; - markArchiveStarted(); - await archiveReleased; - }, - component: () =>
Archived threads
, - }); - const routeTree = rootRoute.addChildren([ - settingsRoute.addChildren([generalRoute, archivedRoute]), - ]); - const router = createRouter({ - routeTree, - history: createMemoryHistory({ initialEntries: [initialEntry] }), - }); - - return { archiveStarted, releaseArchive, router }; -} - -function SettingsTestLayout() { - const pathname = useLocation({ select: (location) => location.pathname }); - return ( - <> - - - - ); -} - -function renderBreadcrumb(router: ReturnType["router"]) { - return renderToStaticMarkup( - - - , - ); -} - -describe("SettingsBreadcrumb", () => { - it("waits for the Archive match before reading its search during navigation", async () => { - const { archiveStarted, releaseArchive, router } = createArchiveRouter( - "/settings/general", - true, - ); - await router.load(); - - const navigation = router.navigate({ - to: "/settings/archived", - search: { project: "project-alpha" }, - }); - await archiveStarted; - - expect(router.state.location.pathname).toBe("/settings/archived"); - expect(router.state.matches.some((match) => match.routeId === "/settings/archived")).toBe( - false, - ); - const pendingMarkup = renderBreadcrumb(router); - expect(pendingMarkup).toContain('aria-label="Settings breadcrumb"'); - expect(pendingMarkup).toContain("Settings"); - expect(pendingMarkup).toContain("Archive"); - - releaseArchive(); - await navigation; - - const archiveMarkup = renderBreadcrumb(router); - expect(archiveMarkup).toContain("Archive"); - expect(archiveMarkup).toContain("Alpha Project"); - - await router.navigate({ to: "/settings/general" }); - const generalMarkup = renderBreadcrumb(router); - expect(generalMarkup).toContain("General"); - expect(generalMarkup).not.toContain("Alpha Project"); - }); - - it("preserves the selected project on a direct Archive deep link", async () => { - const { router } = createArchiveRouter("/settings/archived?project=project-alpha", false); - await router.load(); - - const markup = renderBreadcrumb(router); - expect(router.state.location.search.project).toBe("project-alpha"); - expect(markup).toContain("Alpha Project"); - }); -}); diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.tsx index 4a194dbb69a4..a21b58d03e0b 100644 --- a/apps/web/src/components/settings/SettingsBreadcrumb.tsx +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -1,15 +1,12 @@ -import { useNavigate, useSearch } from "@tanstack/react-router"; import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { LayersIcon } from "lucide-react"; import type { ReactNode } from "react"; import { cn } from "../../lib/utils"; -import { useArchivedProjectModel } from "../../lib/archivedThreadsState"; import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; import type { EnvironmentPresentation } from "../../state/environments"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { ProjectFavicon } from "../ProjectFavicon"; -import { ProjectScopeBreadcrumb } from "../ProjectScopeBreadcrumb"; import { Menu, MenuPopup, @@ -68,15 +65,6 @@ export function SettingsBreadcrumb({ pathname: string; scope?: SettingsScopeBreadcrumbProps | undefined; }) { - const archiveProjectKey = useSearch({ - from: "/settings/archived", - shouldThrow: false, - select: (search) => search.project ?? null, - }); - const normalizedPathname = pathname.replace(/\/+$/, "") || "/"; - if (normalizedPathname === "/settings/archived" && archiveProjectKey !== undefined) { - return ; - } const sectionLabel = settingsBreadcrumbLabel(pathname); return ( @@ -105,6 +93,7 @@ export function SettingsBreadcrumb({ ); } + function ScopeMenu({ ariaLabel, icon, @@ -244,26 +233,3 @@ function ProjectScopeMenu({ value, groups, onChange }: SettingsScopeBreadcrumbPr ); } - -function ArchivedThreadsBreadcrumb({ projectKey }: { projectKey: string | null }) { - const navigate = useNavigate({ from: "/settings/archived" }); - const { isLoading, projectGroups } = useArchivedProjectModel(); - - return ( - ({ id: group.projectKey, label: group.displayName }))} - onSelect={(projectKey) => { - void navigate({ - search: projectKey === null ? {} : { project: projectKey }, - replace: true, - hashScrollIntoView: false, - }); - }} - rootLabel="Archive" - selectedKey={projectKey} - unavailableLabel={isLoading ? "Loading project" : "Unavailable project"} - /> - ); -} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 78b560e3f89f..d5408135d085 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -94,8 +94,8 @@ import { import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; -import { useArchivedProjectModel } from "../../lib/archivedThreadsState"; -import { filterArchivedProjectGroups } from "../../archiveProjectFiltering"; +import { derivePhysicalProjectKey } from "../../logicalProject"; +import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { AlertDialog, @@ -3240,23 +3240,78 @@ export function GeneralSettingsPanel() { ); } -export function ArchivedThreadsPanel({ projectKey }: { projectKey: string | null }) { +export function ArchivedThreadsPanel() { + const { scope, environments, isReady: isScopeReady } = useSettingsScope(); + // Archive reads need a connection, not the server config required by settings writes. + const environmentIds = useMemo( + () => + environments + .filter((environment) => environment.connection.phase === "connected") + .map((environment) => environment.environmentId), + [environments], + ); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); const { - archivedGroups, + snapshots: archivedSnapshots, error: archiveError, - isLoading: isLoadingArchive, - projectGroups, + isLoading: isLoadingSnapshots, refresh: refreshArchivedThreads, - } = useArchivedProjectModel(); - const selectedProject = - projectKey === null - ? null - : (projectGroups.find((group) => group.projectKey === projectKey) ?? null); - const visibleArchivedGroups = useMemo( - () => filterArchivedProjectGroups(archivedGroups, projectKey), - [archivedGroups, projectKey], - ); + } = useArchivedThreadSnapshots(environmentIds); + const isLoadingArchive = !isScopeReady || isLoadingSnapshots; + + const archivedGroups = useMemo(() => { + const selectedProjectKeys = + scope.kind === "project" || scope.kind === "checkout" + ? new Set(scope.members.map((member) => member.physicalProjectKey)) + : null; + const projectsByEnvironmentAndId = new Map( + archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.projects + .filter( + (project) => + selectedProjectKeys === null || + selectedProjectKeys.has(derivePhysicalProjectKey({ ...project, environmentId })), + ) + .map( + (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const, + ), + ), + ); + const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ + ...thread, + environmentId, + })), + ); + + const threadsByProject = new Map>(); + for (const thread of threads) { + const key = `${thread.environmentId}:${thread.projectId}`; + const projectThreads = threadsByProject.get(key); + if (projectThreads) projectThreads.push(thread); + else threadsByProject.set(key, [thread]); + } + + 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 = threadsByProject.get(`${project.environmentId}:${project.id}`); + if (projectThreads && 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, scope]); const handleArchivedThreadContextMenu = useCallback( async (thread: EnvironmentThreadShell, position: { x: number; y: number }) => { @@ -3289,9 +3344,11 @@ export function ArchivedThreadsPanel({ projectKey }: { projectKey: string | null } if (clicked === "delete") { - const archivedThreads = archivedGroups - .filter((group) => group.project.environmentId === thread.environmentId) - .flatMap((group) => group.threads); + const archivedThreads = archivedSnapshots + .filter((entry) => entry.environmentId === thread.environmentId) + .flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((archivedThread) => ({ ...archivedThread, environmentId })), + ); const result = await confirmAndDeleteThread(threadRef, { archivedThreads }); if (result._tag === "Success") { refreshArchivedThreads(); @@ -3307,12 +3364,12 @@ export function ArchivedThreadsPanel({ projectKey }: { projectKey: string | null } } }, - [archivedGroups, confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], + [archivedSnapshots, confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], ); return ( - {visibleArchivedGroups.length === 0 ? ( + {archivedGroups.length === 0 ? ( } description={ isLoadingArchive ? "Checking connected environments." : (archiveError ?? - (projectKey === null - ? "Archived threads will appear here." - : "Choose another project or All.")) + (environmentIds.length === 0 + ? "Connect an environment in this scope to view archived threads." + : "Archived threads will appear here.")) } /> ) : ( - visibleArchivedGroups.map(({ project, threads: projectThreads }, index) => ( + archivedGroups.map(({ project, threads: projectThreads }, index) => ( { const scope = resolveSettingsScope(search, groups, availableEnvironments); const selected = selectScopedSettingsEnvironments( @@ -25,8 +32,8 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) { ) ?? targets[0] ?? null; - return { scope, groups, ...selected, targets, target }; - }, [availableEnvironments, groups, primaryEnvironmentId, search]); + return { scope, groups, ...selected, targets, target, isReady, projectSnapshotsReady }; + }, [availableEnvironments, groups, isReady, primaryEnvironmentId, projectSnapshotsReady, search]); } const SettingsScopeContext = createContext< diff --git a/apps/web/src/components/settings/settingsScopeNavigation.test.ts b/apps/web/src/components/settings/settingsScopeNavigation.test.ts index a613f3adf42a..cee123cc3207 100644 --- a/apps/web/src/components/settings/settingsScopeNavigation.test.ts +++ b/apps/web/src/components/settings/settingsScopeNavigation.test.ts @@ -30,6 +30,7 @@ function createSettingsRouter(initialEntry = "/settings/general") { } }, }); + const archived = createRoute({ getParentRoute: () => settings, path: "archived" }); const general = createRoute({ getParentRoute: () => settings, path: "general" }); const projects = createRoute({ getParentRoute: () => settings, path: "projects" }); const integrations = createRoute({ getParentRoute: () => settings, path: "integrations" }); @@ -59,7 +60,7 @@ function createSettingsRouter(initialEntry = "/settings/general") { }); return createRouter({ routeTree: root.addChildren([ - settings.addChildren([general, projects, integrations, sourceControl, providers]), + settings.addChildren([general, projects, integrations, sourceControl, providers, archived]), legacyProject, ]), history: createMemoryHistory({ initialEntries: [initialEntry] }), @@ -80,6 +81,30 @@ describe("settings scope navigation", () => { expect(router.state.location.search).toEqual({ project: "another-project" }); }); + it("keeps Archive scope through search navigation and clears only the selected project", async () => { + const router = createSettingsRouter(); + await router.load(); + await router.navigate({ to: "/settings/general", search: checkoutSearch }); + await router.navigate({ to: "/settings/archived", hash: "archive" }); + expect(router.state.location.search).toEqual(checkoutSearch); + + await router.navigate({ + from: "/settings", + to: "/settings/archived", + search: () => ({ project: undefined, checkout: undefined, machine: "remote-server" }), + hash: "", + }); + expect(router.state.location.pathname).toBe("/settings/archived"); + expect(router.state.location.search).toEqual({ machine: "remote-server" }); + + await router.navigate({ + from: "/settings", + to: "/settings/archived", + search: () => ({ project: undefined, checkout: undefined, machine: undefined }), + }); + expect(router.state.location.search).toEqual({}); + }); + it("clears a checkout when selecting all environments and all projects", async () => { const router = createSettingsRouter(); await router.navigate({ to: "/settings/general", search: checkoutSearch, hash: "old-setting" }); @@ -106,7 +131,12 @@ describe("settings scope navigation", () => { expect(router.state.location.search).toEqual(checkoutSearch); }); - it.each(["/settings/projects", "/settings/integrations", "/settings/source-control"] as const)( + it.each([ + "/settings/projects", + "/settings/integrations", + "/settings/source-control", + "/settings/archived", + ] as const)( "keeps %s when regrouping or selecting a target from the shared settings layout", async (to) => { const router = createSettingsRouter(); diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index 4c6a7b1d8999..96fd777fe01d 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -10,16 +10,8 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; -import { - buildArchivedProjectModel, - connectedArchiveEnvironmentIds, -} from "../archiveProjectFiltering"; -import { selectProjectGroupingSettings } from "../logicalProject"; -import { useClientSettings, useClientSettingsHydrated } from "../hooks/useSettings"; import { orchestrationEnvironment } from "../state/orchestration"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { isHostedStaticApp } from "../hostedPairing"; function archivedSnapshotAtom(environmentId: EnvironmentId) { return orchestrationEnvironment.archivedShellSnapshot({ @@ -62,7 +54,7 @@ export function loadArchivedThreadsForEnvironment( }); } -function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { +export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { readonly snapshots: ReadonlyArray; readonly error: string | null; readonly isLoading: boolean; @@ -84,46 +76,3 @@ function useArchivedThreadSnapshots(environmentIds: ReadonlyArray refresh, }; } - -export function useArchivedProjectModel() { - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const settingsHydrated = useClientSettingsHydrated(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const { environments, isReady: environmentsReady } = useEnvironments(); - const environmentIds = useMemo( - () => connectedArchiveEnvironmentIds(environments), - [environments], - ); - 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]); - const environmentTopologyReady = isHostedStaticApp() || primaryEnvironmentId !== null; - const isLoading = - archiveState.isLoading || !environmentsReady || !environmentTopologyReady || !settingsHydrated; - - return { - ...archiveState, - ...model, - isLoading, - }; -} diff --git a/apps/web/src/routes/settings.archived.tsx b/apps/web/src/routes/settings.archived.tsx index 3212b251adab..3ad690afc027 100644 --- a/apps/web/src/routes/settings.archived.tsx +++ b/apps/web/src/routes/settings.archived.tsx @@ -1,14 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; -import { validateArchivedThreadsSearch } from "../archiveProjectFiltering"; import { ArchivedThreadsPanel } from "../components/settings/SettingsPanels"; export const Route = createFileRoute("/settings/archived")({ - validateSearch: validateArchivedThreadsSearch, - component: ArchivedThreadsRouteView, + component: ArchivedThreadsPanel, }); - -function ArchivedThreadsRouteView() { - const search = Route.useSearch(); - return ; -} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index cd6ca454649b..a846ef2b5aae 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -54,14 +54,48 @@ const DEVICE_ONLY_PATHS = new Set([ "/settings/connections", ]); -function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) { - const { scope, connectedEnvironments } = useSettingsScope(); +export function SettingsScopeBoundary({ + pathname, + children, +}: { + pathname: string; + children: ReactNode; +}) { + const { + scope, + connectedEnvironments, + environments: scopedEnvironments, + isReady, + projectSnapshotsReady, + } = useSettingsScope(); const { environments } = useEnvironments(); const hash = useLocation({ select: (location) => location.hash }); const searchTarget = getSettingsSearchTargetScope(hash); const autoSettlementAvailability = searchTarget?.requiresThreadAutoSettlement ? getThreadAutoSettlementSearchAvailability(environments, scope) : null; + // Let Archive show its loading state before declaring an unresolved saved scope unavailable. + if (pathname === "/settings/archived" && !isReady) return children; + if ( + pathname === "/settings/archived" && + scope.kind === "unavailable" && + (scope.reason === "project-missing" || scope.reason === "checkout-missing") && + !projectSnapshotsReady + ) { + return ( +

+ {!environments.some((environment) => environment.entry.enabled) || + environments.some( + (environment) => + environment.entry.enabled && + environment.connection.phase !== "connected" && + environment.connection.phase !== "connecting", + ) + ? "The selected project cannot be resolved while environments are offline or syncing. Reconnect to check its archived threads." + : "Loading archived threads"} +

+ ); + } if ( scope.kind !== "unavailable" && searchTarget && @@ -104,7 +138,11 @@ function SettingsScopeBoundary({ pathname, children }: { pathname: string; child } if (scope.kind === "unavailable") return

{scope.message}

; - if (scope.kind === "environment" && connectedEnvironments.length === 0) { + // Archive reads can start before configs are ready for settings writes. + const canReadArchive = + pathname === "/settings/archived" && + scopedEnvironments.some((environment) => environment.connection.phase === "connected"); + if (scope.kind === "environment" && connectedEnvironments.length === 0 && !canReadArchive) { return (

Reconnect {scope.label} to change its settings.