diff --git a/apps/web/src/archiveProjectFiltering.test.ts b/apps/web/src/archiveProjectFiltering.test.ts new file mode 100644 index 000000000000..586aa6dec7c7 --- /dev/null +++ b/apps/web/src/archiveProjectFiltering.test.ts @@ -0,0 +1,172 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildArchivedProjectModel, + filterArchivedProjectGroups, + validateArchivedThreadsSearch, +} from "./archiveProjectFiltering"; + +const primaryEnvironmentId = EnvironmentId.make("env-primary"); +const remoteEnvironmentId = EnvironmentId.make("env-remote"); +const groupingSettings = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, +}; +const repositoryIdentity = { + canonicalKey: "github.com/example/shared-repo", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, +}; + +function makeProject(overrides: Partial = {}): EnvironmentProject { + 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, true)).toHaveLength(2); + + const alphaKey = model.projectGroups.find((group) => group.displayName === "Alpha")?.projectKey; + expect(alphaKey).toBeDefined(); + expect(filterArchivedProjectGroups(model.archivedGroups, alphaKey ?? null, true)).toEqual([ + expect.objectContaining({ project: expect.objectContaining({ title: "Alpha" }) }), + ]); + }); + + it("selects every physical member of one logical project", () => { + const local = makeProject({ repositoryIdentity, title: "Shared" }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + workspaceRoot: "/srv/shared", + repositoryIdentity, + title: "Shared remote", + }); + const model = buildModel([local, remote], [makeThread(local), makeThread(remote)]); + + expect(model.projectGroups).toHaveLength(1); + expect( + filterArchivedProjectGroups(model.archivedGroups, model.projectGroups[0]!.projectKey, true), + ).toHaveLength(2); + }); + + it("keeps duplicate project ids scoped to their environments", () => { + const local = makeProject({ title: "Local", workspaceRoot: "/tmp/local" }); + const remote = makeProject({ + environmentId: remoteEnvironmentId, + title: "Remote", + workspaceRoot: "/srv/remote", + }); + const model = buildModel([local, remote], [makeThread(local), makeThread(remote)]); + + expect(model.archivedGroups).toHaveLength(2); + expect(model.archivedGroups.map((group) => group.threads[0]?.environmentId)).toEqual([ + primaryEnvironmentId, + remoteEnvironmentId, + ]); + }); + + it("keeps an archived-only project as an individual picker item", () => { + const archivedOnly = makeProject({ title: "Removed project" }); + const model = buildModel([archivedOnly], [makeThread(archivedOnly)]); + + expect(model.projectGroups.map((group) => group.displayName)).toEqual(["Removed project"]); + }); + + it("does not widen an unavailable project filter to All", () => { + const project = makeProject(); + const model = buildModel([project], [makeThread(project)]); + + expect(filterArchivedProjectGroups(model.archivedGroups, "pending-project", true)).toEqual([]); + }); + + it("keeps a known project scope empty until project identity is stable", () => { + const project = makeProject(); + const model = buildModel([project], [makeThread(project)]); + const projectKey = model.projectGroups[0]!.projectKey; + + expect(filterArchivedProjectGroups(model.archivedGroups, projectKey, false)).toEqual([]); + expect(filterArchivedProjectGroups(model.archivedGroups, projectKey, true)).toHaveLength(1); + }); + + it("preserves a project key longer than 500 characters during route validation", () => { + const projectKey = `environment:${"nested-worktree/".repeat(40)}`; + + expect(projectKey.length).toBeGreaterThan(500); + expect(validateArchivedThreadsSearch({ project: projectKey })).toEqual({ project: projectKey }); + }); +}); diff --git a/apps/web/src/archiveProjectFiltering.ts b/apps/web/src/archiveProjectFiltering.ts new file mode 100644 index 000000000000..9d4a0869ce79 --- /dev/null +++ b/apps/web/src/archiveProjectFiltering.ts @@ -0,0 +1,101 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { derivePhysicalProjectKey, type ProjectGroupingSettings } from "./logicalProject"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectSnapshot, +} from "./sidebarProjectGrouping"; + +export interface ArchivedProjectGroup { + readonly logicalProjectKey: string; + readonly project: EnvironmentProject; + readonly threads: ReadonlyArray; +} + +export interface ArchivedProjectModel { + readonly archivedGroups: ReadonlyArray; + 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}`; +} + +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, + scopeReady: boolean, +): ReadonlyArray { + if (projectKey === null) return archivedGroups; + if (!scopeReady) return []; + return 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..090b54f91782 --- /dev/null +++ b/apps/web/src/components/ProjectScopeBreadcrumb.tsx @@ -0,0 +1,96 @@ +import { settlePromise } from "@t3tools/client-runtime/state/runtime"; +import type { ContextMenuItem } from "@t3tools/contracts"; +import { ChevronDownIcon } from "lucide-react"; +import type { MouseEvent as ReactMouseEvent } from "react"; + +import { readLocalApi } from "../localApi"; +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "./WorkspaceBreadcrumb"; + +const ALL_PROJECTS_MENU_ID = "all"; + +export interface ProjectScopeBreadcrumbItem { + readonly id: string; + readonly label: string; +} + +export function ProjectScopeBreadcrumb(props: { + readonly allLabel?: string | undefined; + readonly ariaLabel: string; + readonly items: ReadonlyArray; + 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/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.test.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.test.tsx new file mode 100644 index 000000000000..b23200c5559b --- /dev/null +++ b/apps/web/src/components/settings/SettingsBreadcrumb.test.tsx @@ -0,0 +1,130 @@ +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 bb631187cb1e..7217989d049d 100644 --- a/apps/web/src/components/settings/SettingsBreadcrumb.tsx +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -1,3 +1,7 @@ +import { useNavigate, useSearch } from "@tanstack/react-router"; + +import { useArchivedProjectModel } from "../../lib/archivedThreadsState"; +import { ProjectScopeBreadcrumb } from "../ProjectScopeBreadcrumb"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, @@ -16,6 +20,15 @@ function settingsBreadcrumbLabel(pathname: string): string | null { } export function SettingsBreadcrumb({ pathname }: { pathname: string }) { + 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 ( @@ -32,3 +45,26 @@ export function SettingsBreadcrumb({ pathname }: { pathname: string }) { ); } + +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 9539f95914cb..a5c550e27b90 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -78,8 +78,8 @@ 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 } from "../../archiveProjectFiltering"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; @@ -2404,70 +2404,28 @@ 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, - ), + } = useArchivedProjectModel(); + const selectedProject = + projectKey === null + ? null + : (projectGroups.find((group) => group.projectKey === projectKey) ?? null); + const visibleArchivedGroups = useMemo( + () => + filterArchivedProjectGroups( + archivedGroups, + projectKey, + !isLoadingArchive && archiveError === null, ), - ); - 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]); + [archiveError, archivedGroups, isLoadingArchive, projectKey], + ); const handleArchivedThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { @@ -2519,7 +2477,7 @@ export function ArchivedThreadsPanel() { return ( - {archivedGroups.length === 0 ? ( + {visibleArchivedGroups.length === 0 ? ( } description={ isLoadingArchive ? "Checking connected environments." - : (archiveError ?? "Archived threads will appear here.") + : (archiveError ?? + (projectKey === null + ? "Archived threads will appear here." + : "Choose another project or All.")) } /> ) : ( - 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..0fb57cf360b4 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -7,8 +7,14 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useMemo } from "react"; +import { buildArchivedProjectModel } from "../archiveProjectFiltering"; +import { selectProjectGroupingSettings } from "../logicalProject"; +import { useClientSettings, useClientSettingsHydrated } from "../hooks/useSettings"; import { orchestrationEnvironment } from "../state/orchestration"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useProjects } from "../state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { isHostedStaticApp } from "../hostedPairing"; function archivedSnapshotAtom(environmentId: EnvironmentId) { return orchestrationEnvironment.archivedShellSnapshot({ @@ -48,3 +54,53 @@ 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]); + 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 3ad690afc027..3212b251adab 100644 --- a/apps/web/src/routes/settings.archived.tsx +++ b/apps/web/src/routes/settings.archived.tsx @@ -1,7 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; +import { validateArchivedThreadsSearch } from "../archiveProjectFiltering"; import { ArchivedThreadsPanel } from "../components/settings/SettingsPanels"; export const Route = createFileRoute("/settings/archived")({ - component: ArchivedThreadsPanel, + validateSearch: validateArchivedThreadsSearch, + component: ArchivedThreadsRouteView, }); + +function ArchivedThreadsRouteView() { + const search = Route.useSearch(); + return ; +}