diff --git a/apps/web/src/components/settings/ArchiveSettings.tsx b/apps/web/src/components/settings/ArchiveSettings.tsx new file mode 100644 index 00000000000..2c19423d453 --- /dev/null +++ b/apps/web/src/components/settings/ArchiveSettings.tsx @@ -0,0 +1,687 @@ +import { + ArchiveIcon, + ArchiveX, + ArrowDownIcon, + ArrowUpIcon, + ChevronDownIcon, + ChevronRightIcon, + EllipsisIcon, + LoaderIcon, + Trash2Icon, +} from "lucide-react"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useClientSettings } from "../../hooks/useSettings"; +import { useThreadActions } from "../../hooks/useThreadActions"; +import { useEnvironments } from "../../state/environments"; +import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; +import { readLocalApi } from "../../localApi"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { + archivedProjectBulkScopeLabel, + type ArchivedProjectBulkFailure, + type ArchivedProjectBulkScope, + type ArchivedProjectBulkThread, + type ArchivedThreadSortField, + type ArchivedThreadSortState, + buildArchivedThreadGroups, + nextArchivedThreadSortState, + parseArchivedThreadSearchInput, + runArchivedProjectThreadActions, +} from "./SettingsPanels.logic"; +import { + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; + +function ArchivedSortButton({ + field, + label, + sort, + onClick, +}: { + readonly field: ArchivedThreadSortField; + readonly label: string; + readonly sort: ArchivedThreadSortState; + readonly onClick: () => void; +}) { + const active = sort.field === field; + const SortIcon = sort.direction === "asc" ? ArrowUpIcon : ArrowDownIcon; + return ( + + ); +} + +function ArchivedIconButton({ + label, + destructive = false, + onClick, + children, +}: { + readonly label: string; + readonly destructive?: boolean; + readonly onClick: () => void; + readonly children: ReactNode; +}) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + > + {children} + + } + /> + {label} + + ); +} + +export function ArchivedThreadsPanel() { + const { environments } = useEnvironments(); + const { unarchiveThread, deleteThread } = useThreadActions(); + const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); + const [expandedProjectKeys, setExpandedProjectKeys] = useState>( + () => new Set(), + ); + const [archiveSearchQuery, setArchiveSearchQuery] = useState(""); + const [sort, setSort] = useState({ + field: "archivedAt", + direction: "desc", + }); + useRelativeTimeTick(); + const environmentIds = useMemo( + () => environments.map((environment) => environment.environmentId), + [environments], + ); + const { + snapshots: archivedSnapshots, + error: archiveError, + isLoading: isLoadingArchive, + refresh: refreshArchivedThreads, + } = useArchivedThreadSnapshots(environmentIds); + const archiveSearch = useMemo( + () => parseArchivedThreadSearchInput(archiveSearchQuery), + [archiveSearchQuery], + ); + const hasArchivedThreads = useMemo( + () => archivedSnapshots.some(({ snapshot }) => snapshot.threads.length > 0), + [archivedSnapshots], + ); + + const archivedGroups = useMemo( + () => + buildArchivedThreadGroups({ + snapshots: archivedSnapshots, + normalizedSearchQuery: archiveSearch.normalizedQuery, + searchTokens: archiveSearch.tokens, + isSearching: archiveSearch.isSearching, + sort, + }), + [ + archiveSearch.isSearching, + archiveSearch.normalizedQuery, + archiveSearch.tokens, + archivedSnapshots, + sort, + ], + ); + + const toggleProjectExpanded = useCallback((projectKey: string) => { + setExpandedProjectKeys((current) => { + const next = new Set(current); + if (next.has(projectKey)) { + next.delete(projectKey); + } else { + next.add(projectKey); + } + return next; + }); + }, []); + + const handleSortClick = useCallback((field: ArchivedThreadSortField) => { + setSort((current) => nextArchivedThreadSortState(current, field)); + }, []); + + const confirmArchivedAction = useCallback(async (message: string) => { + const localApi = readLocalApi(); + if (!localApi) { + if (typeof window === "undefined") return false; + if (typeof window.confirm !== "function") return false; + return window.confirm(message); + } + const confirmationResult = await settlePromise(() => localApi.dialogs.confirm(message)); + if (confirmationResult._tag === "Failure") { + const error = squashAtomCommandFailure(confirmationResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Archived thread confirmation failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return false; + } + return confirmationResult.value; + }, []); + + const showArchivedActionFailure = useCallback( + (title: string, result: AtomCommandResult) => { + if (result._tag === "Success") return; + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + [], + ); + + const showArchivedBulkActionFailure = useCallback( + (title: string, failures: ReadonlyArray, totalCount: number) => { + const visibleFailures = failures.filter((failure) => !isAtomCommandInterrupted(failure)); + if (visibleFailures.length === 0) return; + const failureMessages = [ + ...new Set( + visibleFailures.map((failure) => { + const error = squashAtomCommandFailure(failure); + return error instanceof Error ? error.message : "An error occurred."; + }), + ), + ]; + const shownFailureMessages = failureMessages.slice(0, 3); + const interruptedCount = failures.length - visibleFailures.length; + const successCount = totalCount - failures.length; + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: [ + `${successCount} succeeded, ${visibleFailures.length} failed${ + interruptedCount > 0 ? `, ${interruptedCount} interrupted` : "" + }.`, + visibleFailures.length === 1 + ? (shownFailureMessages[0] ?? "An error occurred.") + : `Failures: ${shownFailureMessages.join("; ")}${ + failureMessages.length > shownFailureMessages.length + ? `; ${failureMessages.length - shownFailureMessages.length} more` + : "" + }`, + ].join(" "), + }), + ); + }, + [], + ); + + const showArchivedBulkActionException = useCallback((title: string, error: unknown) => { + const errors = error instanceof AggregateError ? error.errors : [error]; + const failureMessages = [ + ...new Set( + errors.map((entry) => (entry instanceof Error ? entry.message : "An error occurred.")), + ), + ]; + const shownFailureMessages = failureMessages.slice(0, 3); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: [ + `One or more archived thread actions failed unexpectedly.`, + failureMessages.length <= 1 + ? (shownFailureMessages[0] ?? "An error occurred.") + : `Failures: ${shownFailureMessages.join("; ")}${ + failureMessages.length > shownFailureMessages.length + ? `; ${failureMessages.length - shownFailureMessages.length} more` + : "" + }`, + ].join(" "), + }), + ); + }, []); + + const handleUnarchiveThread = useCallback( + async (threadRef: ScopedThreadRef) => { + const result = await unarchiveThread(threadRef); + if (result._tag === "Success") { + refreshArchivedThreads(); + return; + } + showArchivedActionFailure("Failed to unarchive thread", result); + }, + [refreshArchivedThreads, showArchivedActionFailure, unarchiveThread], + ); + + const handleDeleteArchivedThread = useCallback( + async (threadRef: ScopedThreadRef, title: string) => { + if (confirmThreadDelete) { + const confirmed = await confirmArchivedAction( + [ + `Delete archived conversation "${title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) return; + } + const result = await deleteThread(threadRef); + if (result._tag === "Success") { + refreshArchivedThreads(); + return; + } + showArchivedActionFailure("Failed to delete thread", result); + }, + [ + confirmArchivedAction, + confirmThreadDelete, + deleteThread, + refreshArchivedThreads, + showArchivedActionFailure, + ], + ); + + const handleUnarchiveProjectThreads = useCallback( + async ( + projectName: string, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, + ) => { + const scopeLabel = archivedProjectBulkScopeLabel(scope); + // Bulk unarchive always asks because there is no unarchive confirmation preference. + const confirmed = await confirmArchivedAction( + [ + `Unarchive ${scopeLabel} in "${projectName}"?`, + `This will restore ${threads.length} conversation${threads.length === 1 ? "" : "s"}.`, + ].join("\n"), + ); + if (!confirmed) return; + try { + const failures = await runArchivedProjectThreadActions(threads, (thread) => + unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)), + ); + if (failures.length > 0) { + showArchivedBulkActionFailure( + "Archived threads not fully unarchived", + failures, + threads.length, + ); + } + } catch (error) { + showArchivedBulkActionException("Archived threads not fully unarchived", error); + } finally { + refreshArchivedThreads(); + } + }, + [ + confirmArchivedAction, + refreshArchivedThreads, + showArchivedBulkActionException, + showArchivedBulkActionFailure, + unarchiveThread, + ], + ); + + const handleDeleteProjectThreads = useCallback( + async ( + projectName: string, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, + ) => { + const scopeLabel = archivedProjectBulkScopeLabel(scope); + if (confirmThreadDelete) { + const confirmed = await confirmArchivedAction( + [ + `Delete ${scopeLabel} in "${projectName}"?`, + `This permanently clears conversation history for ${threads.length} conversation${threads.length === 1 ? "" : "s"}.`, + ].join("\n"), + ); + if (!confirmed) return; + } + try { + const failures = await runArchivedProjectThreadActions(threads, (thread) => + deleteThread(scopeThreadRef(thread.environmentId, thread.id)), + ); + if (failures.length > 0) { + showArchivedBulkActionFailure( + "Archived threads not fully deleted", + failures, + threads.length, + ); + } + } catch (error) { + showArchivedBulkActionException("Archived threads not fully deleted", error); + } finally { + refreshArchivedThreads(); + } + }, + [ + confirmArchivedAction, + confirmThreadDelete, + deleteThread, + refreshArchivedThreads, + showArchivedBulkActionException, + showArchivedBulkActionFailure, + ], + ); + + const handleArchivedThreadContextMenu = useCallback( + async (threadRef: ScopedThreadRef, title: string, position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "unarchive", label: "Unarchive" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + + if (clicked === "unarchive") { + await handleUnarchiveThread(threadRef); + return; + } + + if (clicked === "delete") { + await handleDeleteArchivedThread(threadRef, title); + } + }, + [handleDeleteArchivedThread, handleUnarchiveThread], + ); + + const handleArchivedProjectContextMenu = useCallback( + async ( + projectName: string, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, + position: { x: number; y: number }, + ) => { + const api = readLocalApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { + id: "unarchive-all", + label: scope === "matching" ? "Unarchive matching" : "Unarchive all", + }, + { + id: "delete-all", + label: scope === "matching" ? "Delete matching" : "Delete all", + destructive: true, + }, + ], + position, + ); + + if (clicked === "unarchive-all") { + await handleUnarchiveProjectThreads(projectName, threads, scope); + return; + } + + if (clicked === "delete-all") { + await handleDeleteProjectThreads(projectName, threads, scope); + } + }, + [handleDeleteProjectThreads, handleUnarchiveProjectThreads], + ); + + const handleArchivedProjectMenuButton = useCallback( + async ( + projectName: string, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, + trigger: HTMLElement, + ) => { + const rect = trigger.getBoundingClientRect(); + const result = await settlePromise(() => + handleArchivedProjectContextMenu(projectName, threads, scope, { + x: rect.right, + y: rect.bottom, + }), + ); + showArchivedActionFailure("Archived project action failed", result); + }, + [handleArchivedProjectContextMenu, showArchivedActionFailure], + ); + + return ( + + setArchiveSearchQuery(event.currentTarget.value)} + placeholder="Search archived conversations" + aria-label="Search archived conversations" + /> + {archivedGroups.length === 0 ? ( + + + {isLoadingArchive ? ( + + ) : ( + + )} + {isLoadingArchive + ? "Loading archived threads" + : archiveError + ? "Could not load archived threads" + : archiveSearch.isSearching && hasArchivedThreads + ? "No matching archived threads" + : "No archived threads"} + + } + description={ + isLoadingArchive + ? "Checking connected environments." + : archiveError + ? archiveError + : archiveSearch.isSearching && hasArchivedThreads + ? `No archived conversation titles match "${archiveSearchQuery.trim()}".` + : "Archived threads will appear here." + } + /> + + ) : ( +
+ {archivedGroups.map(({ key: projectKey, project, threads: projectThreads }) => { + const isExpanded = archiveSearch.isSearching || expandedProjectKeys.has(projectKey); + const bulkScope = archiveSearch.isSearching ? "matching" : "all"; + return ( +
+
{ + event.preventDefault(); + void (async () => { + const result = await settlePromise(() => + handleArchivedProjectContextMenu(project.name, projectThreads, bulkScope, { + x: event.clientX, + y: event.clientY, + }), + ); + showArchivedActionFailure("Archived project action failed", result); + })(); + }} + > + + {isExpanded ? ( + <> + handleSortClick("archivedAt")} + /> + handleSortClick("createdAt")} + /> + + ) : null} + + { + event.stopPropagation(); + void handleArchivedProjectMenuButton( + project.name, + projectThreads, + bulkScope, + event.currentTarget, + ); + }} + > + + + } + /> + Project actions + +
+ {isExpanded ? ( +
+ {projectThreads.map((thread) => ( +
{ + event.preventDefault(); + void (async () => { + const result = await settlePromise(() => + handleArchivedThreadContextMenu( + scopeThreadRef(thread.environmentId, thread.id), + thread.title, + { + x: event.clientX, + y: event.clientY, + }, + ), + ); + showArchivedActionFailure("Archived thread action failed", result); + })(); + }} + > +
+ {thread.title} +
+
+ {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} +
+
+ {formatRelativeTimeLabel(thread.createdAt)} +
+ {/* Keeps row text columns aligned with the header action column. */} + + ))} +
+ ) : null} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index d783d16c7ad..d13aa038d53 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -1,15 +1,380 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, ProviderDriverKind, ProviderInstanceId, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationThreadShell, type ProviderInstanceConfig, } from "@t3tools/contracts"; +import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { describe, expect, it } from "vite-plus/test"; import { + archivedThreadSearchScore, + buildArchivedThreadGroups, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, + nextArchivedThreadSortState, + parseArchivedThreadSearchInput, + runArchivedProjectThreadActions, } from "./SettingsPanels.logic"; +const environmentId = EnvironmentId.make("environment-1"); + +function scoreArchivedTitle(title: string, query: string): number | null { + const normalizedQuery = normalizeSearchQuery(query); + return archivedThreadSearchScore({ + normalizedTitle: normalizeSearchQuery(title), + normalizedQuery, + tokens: normalizedQuery.split(/\s+/u).filter((token) => token.length > 0), + }); +} + +function makeProject( + input: Partial & Pick, +): OrchestrationProjectShell { + return { + workspaceRoot: `/workspaces/${input.id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + ...input, + }; +} + +function makeThread( + input: Partial & + Pick, +): OrchestrationThreadShell { + return { + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: "2026-06-02T00:00:00.000Z", + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +function makeSnapshot( + projects: ReadonlyArray, + threads: ReadonlyArray, + targetEnvironmentId = environmentId, +): ArchivedSnapshotEntry { + return { + environmentId: targetEnvironmentId, + snapshot: { + snapshotSequence: 1, + projects, + threads, + updatedAt: "2026-06-04T00:00:00.000Z", + }, + }; +} + +function successResult(value: unknown = null): AtomCommandResult { + return AsyncResult.success(value); +} + +function failureResult(cause: unknown): AtomCommandResult { + return AsyncResult.failure(Cause.fail(cause)); +} + +function waitForMacrotask(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("archivedThreadSearchScore", () => { + it("ranks phrase matches ahead of all-token and partial-token matches", () => { + const phraseMatch = scoreArchivedTitle("Alpha Beta cleanup", "alpha beta"); + const allTokenMatch = scoreArchivedTitle("Alpha cleanup Beta", "alpha beta"); + const partialTokenMatch = scoreArchivedTitle("Alpha cleanup", "alpha beta"); + + expect(phraseMatch).not.toBeNull(); + expect(allTokenMatch).not.toBeNull(); + expect(partialTokenMatch).not.toBeNull(); + expect(phraseMatch!).toBeLessThan(allTokenMatch!); + expect(allTokenMatch!).toBeLessThan(partialTokenMatch!); + }); + + it("matches titles case-insensitively and rejects unrelated titles", () => { + expect(scoreArchivedTitle("Release Candidate Notes", "candidate")).not.toBeNull(); + expect(scoreArchivedTitle("Release Candidate Notes", "missing")).toBeNull(); + }); +}); + +describe("buildArchivedThreadGroups", () => { + it("keeps project order when not searching and sorts threads by archive timestamp", () => { + const firstProject = makeProject({ id: ProjectId.make("project-1"), title: "First" }); + const secondProject = makeProject({ id: ProjectId.make("project-2"), title: "Second" }); + const older = makeThread({ + id: ThreadId.make("thread-older"), + projectId: firstProject.id, + title: "Older", + }); + const newer = makeThread({ + archivedAt: "2026-06-03T00:00:00.000Z", + id: ThreadId.make("thread-newer"), + projectId: firstProject.id, + title: "Newer", + }); + const search = parseArchivedThreadSearchInput(""); + + const result = buildArchivedThreadGroups({ + snapshots: [makeSnapshot([firstProject, secondProject], [older, newer])], + normalizedSearchQuery: search.normalizedQuery, + searchTokens: search.tokens, + isSearching: search.isSearching, + sort: { field: "archivedAt", direction: "desc" }, + }); + + expect(result.map((group) => group.project.id)).toEqual(["project-1"]); + expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-newer", "thread-older"]); + }); + + it("filters ranked title matches and sorts matching projects by best score", () => { + const partialProject = makeProject({ id: ProjectId.make("project-partial"), title: "Partial" }); + const phraseProject = makeProject({ id: ProjectId.make("project-phrase"), title: "Phrase" }); + const partialThread = makeThread({ + id: ThreadId.make("thread-partial"), + projectId: partialProject.id, + title: "Alpha cleanup", + }); + const phraseThread = makeThread({ + id: ThreadId.make("thread-phrase"), + projectId: phraseProject.id, + title: "Alpha Beta cleanup", + }); + const missingThread = makeThread({ + id: ThreadId.make("thread-missing"), + projectId: partialProject.id, + title: "Gamma cleanup", + }); + const search = parseArchivedThreadSearchInput("alpha beta"); + + const result = buildArchivedThreadGroups({ + snapshots: [ + makeSnapshot([partialProject, phraseProject], [partialThread, phraseThread, missingThread]), + ], + normalizedSearchQuery: search.normalizedQuery, + searchTokens: search.tokens, + isSearching: search.isSearching, + sort: { field: "archivedAt", direction: "desc" }, + }); + + expect(result.map((group) => group.project.id)).toEqual(["project-phrase", "project-partial"]); + expect(result.flatMap((group) => group.threads.map((thread) => thread.id))).toEqual([ + "thread-phrase", + "thread-partial", + ]); + }); + + it("uses the latest duplicate project metadata and ignores threads without projects", () => { + const sharedProjectId = ProjectId.make("project-shared"); + const remoteEnvironmentId = EnvironmentId.make("environment-2"); + const olderProject = makeProject({ id: sharedProjectId, title: "Older Local Project" }); + const latestProject = makeProject({ + id: sharedProjectId, + title: "Latest Local Project", + workspaceRoot: "/workspaces/latest-local", + }); + const remoteProject = makeProject({ + id: sharedProjectId, + title: "Remote Project", + workspaceRoot: "/workspaces/remote", + }); + const localThread = makeThread({ + id: ThreadId.make("thread-local"), + projectId: sharedProjectId, + title: "Local thread", + }); + const remoteThread = makeThread({ + id: ThreadId.make("thread-remote"), + projectId: sharedProjectId, + title: "Remote thread", + }); + const orphanThread = makeThread({ + id: ThreadId.make("thread-orphan"), + projectId: ProjectId.make("project-missing"), + title: "Missing project thread", + }); + const search = parseArchivedThreadSearchInput(""); + + const result = buildArchivedThreadGroups({ + snapshots: [ + makeSnapshot([olderProject], [orphanThread]), + makeSnapshot([latestProject], [localThread]), + makeSnapshot([remoteProject], [remoteThread], remoteEnvironmentId), + ], + normalizedSearchQuery: search.normalizedQuery, + searchTokens: search.tokens, + isSearching: search.isSearching, + sort: { field: "archivedAt", direction: "desc" }, + }); + + expect(result).toHaveLength(2); + expect(result.map((group) => `${group.project.environmentId}:${group.project.name}`)).toEqual([ + "environment-1:Latest Local Project", + "environment-2:Remote Project", + ]); + expect(result.map((group) => group.project.cwd)).toEqual([ + "/workspaces/latest-local", + "/workspaces/remote", + ]); + expect(result.flatMap((group) => group.threads.map((thread) => thread.id))).toEqual([ + "thread-local", + "thread-remote", + ]); + }); + + it("keeps projects separate when environment and project ids contain colons", () => { + const firstEnvironmentId = EnvironmentId.make("environment:one"); + const secondEnvironmentId = EnvironmentId.make("environment"); + const firstProject = makeProject({ + id: ProjectId.make("project"), + title: "First Project", + }); + const secondProject = makeProject({ + id: ProjectId.make("one:project"), + title: "Second Project", + }); + const firstThread = makeThread({ + id: ThreadId.make("thread-first"), + projectId: firstProject.id, + title: "First thread", + }); + const secondThread = makeThread({ + id: ThreadId.make("thread-second"), + projectId: secondProject.id, + title: "Second thread", + }); + const search = parseArchivedThreadSearchInput(""); + + const result = buildArchivedThreadGroups({ + snapshots: [ + makeSnapshot([firstProject], [firstThread], firstEnvironmentId), + makeSnapshot([secondProject], [secondThread], secondEnvironmentId), + ], + normalizedSearchQuery: search.normalizedQuery, + searchTokens: search.tokens, + isSearching: search.isSearching, + sort: { field: "archivedAt", direction: "desc" }, + }); + + expect( + result.map((group) => ({ + key: group.key, + environmentId: group.project.environmentId, + projectId: group.project.id, + threadIds: group.threads.map((thread) => thread.id), + })), + ).toEqual([ + { + key: '["environment:one","project"]', + environmentId: "environment:one", + projectId: "project", + threadIds: ["thread-first"], + }, + { + key: '["environment","one:project"]', + environmentId: "environment", + projectId: "one:project", + threadIds: ["thread-second"], + }, + ]); + }); +}); + +describe("nextArchivedThreadSortState", () => { + it("toggles the active sort field and defaults new fields to descending", () => { + expect( + nextArchivedThreadSortState({ field: "archivedAt", direction: "desc" }, "archivedAt"), + ).toEqual({ field: "archivedAt", direction: "asc" }); + expect( + nextArchivedThreadSortState({ field: "archivedAt", direction: "asc" }, "createdAt"), + ).toEqual({ field: "createdAt", direction: "desc" }); + }); +}); + +describe("runArchivedProjectThreadActions", () => { + it("runs all archived project thread actions and returns failures", async () => { + const threads = Array.from({ length: 6 }, (_, index) => ({ + id: ThreadId.make(`thread-${index}`), + environmentId, + })); + let activeCount = 0; + let maxActiveCount = 0; + const attemptedThreadIds: string[] = []; + + const failures = await runArchivedProjectThreadActions(threads, async (thread) => { + attemptedThreadIds.push(thread.id); + activeCount += 1; + maxActiveCount = Math.max(maxActiveCount, activeCount); + await waitForMacrotask(); + activeCount -= 1; + return thread.id === "thread-2" ? failureResult(new Error("failed")) : successResult(); + }); + + expect(failures).toHaveLength(1); + expect(attemptedThreadIds).toHaveLength(threads.length); + expect(new Set(attemptedThreadIds)).toEqual(new Set(threads.map((thread) => thread.id))); + expect(maxActiveCount).toBe(4); + }); + + it("waits for active archived project thread actions before rethrowing aggregate errors", async () => { + const threads = Array.from({ length: 6 }, (_, index) => ({ + id: ThreadId.make(`thread-${index}`), + environmentId, + })); + let activeCount = 0; + const attemptedThreadIds: string[] = []; + let caughtError: unknown; + + try { + await runArchivedProjectThreadActions(threads, async (thread) => { + attemptedThreadIds.push(thread.id); + activeCount += 1; + try { + await waitForMacrotask(); + if (thread.id === "thread-0" || thread.id === "thread-1") { + throw new Error("failed"); + } + return successResult(); + } finally { + activeCount -= 1; + } + }); + } catch (error) { + caughtError = error; + } + + expect(activeCount).toBe(0); + expect(caughtError).toBeInstanceOf(AggregateError); + expect((caughtError as AggregateError).errors).toHaveLength(2); + expect(attemptedThreadIds).toHaveLength(4); + expect(new Set(attemptedThreadIds)).toEqual( + new Set(["thread-0", "thread-1", "thread-2", "thread-3"]), + ); + }); +}); + describe("formatDiagnosticsDescription", () => { it("collapses trace and metric URLs that share the same OTEL base path", () => { expect( diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 99d7052965a..8eef9646b0d 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -1,11 +1,308 @@ import type { + EnvironmentId, + OrchestrationProjectShell, + OrchestrationThreadShell, ProviderDriverKind, ProviderInstanceConfig, ProviderInstanceId, + ThreadId, ServerSettings, UnifiedSettings, } from "@t3tools/contracts"; +import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; + +const ARCHIVED_THREAD_ALL_TOKENS_SCORE_OFFSET = 1_000; +const ARCHIVED_THREAD_PARTIAL_TOKENS_SCORE_OFFSET = 5_000; +const DEFAULT_ARCHIVED_PROJECT_BULK_ACTION_CONCURRENCY = 4; + +export type ArchivedThreadSortField = "archivedAt" | "createdAt"; +export type ArchivedThreadSortDirection = "asc" | "desc"; +export type ArchivedProjectBulkScope = "all" | "matching"; + +export interface ArchivedThreadSortState { + readonly field: ArchivedThreadSortField; + readonly direction: ArchivedThreadSortDirection; +} + +export type ArchivedProjectBulkThread = { + readonly id: ThreadId; + readonly environmentId: EnvironmentId; +}; + +export type ArchivedProjectBulkFailure = Extract< + AtomCommandResult, + { readonly _tag: "Failure" } +>; + +export interface ArchivedThreadGroupProject { + readonly id: OrchestrationProjectShell["id"]; + readonly environmentId: EnvironmentId; + readonly name: string; + readonly cwd: string; +} + +export type ArchivedThreadGroupThread = OrchestrationThreadShell & { + readonly environmentId: EnvironmentId; + readonly normalizedTitle: string; + readonly searchScore: number; +}; + +export interface ArchivedThreadGroup { + readonly key: string; + readonly project: ArchivedThreadGroupProject; + readonly threads: ReadonlyArray; + readonly searchScore: number; +} + +export interface ArchivedThreadSearchInput { + readonly normalizedQuery: string; + readonly tokens: ReadonlyArray; + readonly isSearching: boolean; +} + +export interface ArchivedProjectBulkActionOptions { + readonly concurrency?: number; +} + +function archivedProjectGroupKey( + environmentId: EnvironmentId, + projectId: OrchestrationProjectShell["id"], +): string { + return JSON.stringify([environmentId, projectId]); +} + +export function parseArchivedThreadSearchInput(query: string): ArchivedThreadSearchInput { + const normalizedQuery = normalizeSearchQuery(query); + return { + normalizedQuery, + tokens: normalizedQuery.split(/\s+/u).filter((token) => token.length > 0), + isSearching: normalizedQuery.length > 0, + }; +} + +// Lower search scores are more relevant, matching the shared search-ranking helpers. +export function archivedThreadSearchScore(input: { + readonly normalizedTitle: string; + readonly normalizedQuery: string; + readonly tokens: ReadonlyArray; +}): number | null { + if (input.normalizedQuery.length === 0) { + return 0; + } + + if (!input.normalizedTitle) { + return null; + } + + const phraseScore = scoreQueryMatch({ + value: input.normalizedTitle, + query: input.normalizedQuery, + exactBase: 0, + prefixBase: 1, + boundaryBase: 2, + includesBase: 3, + }); + if (phraseScore !== null) { + return phraseScore; + } + + let matchedTokenCount = 0; + let tokenScore = 0; + for (const token of input.tokens) { + const score = scoreQueryMatch({ + value: input.normalizedTitle, + query: token, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + ...(token.length >= 3 ? { fuzzyBase: 100 } : {}), + }); + if (score === null) { + continue; + } + + matchedTokenCount += 1; + tokenScore += score; + } + + if (matchedTokenCount === 0) { + return null; + } + + if (matchedTokenCount === input.tokens.length) { + return ARCHIVED_THREAD_ALL_TOKENS_SCORE_OFFSET + tokenScore; + } + + return ( + ARCHIVED_THREAD_PARTIAL_TOKENS_SCORE_OFFSET + + (input.tokens.length - matchedTokenCount) * 1_000 + + tokenScore + ); +} + +export async function runArchivedProjectThreadActions( + threads: ReadonlyArray, + action: (thread: ArchivedProjectBulkThread) => Promise>, + options: ArchivedProjectBulkActionOptions = {}, +): Promise> { + const failures: Array = []; + const thrownErrors: unknown[] = []; + const concurrency = + options.concurrency === undefined || !Number.isFinite(options.concurrency) + ? DEFAULT_ARCHIVED_PROJECT_BULK_ACTION_CONCURRENCY + : Math.max(1, Math.floor(options.concurrency)); + let nextThreadIndex = 0; + let shouldStop = false; + async function worker() { + for (;;) { + if (shouldStop) { + return; + } + const threadIndex = nextThreadIndex; + if (threadIndex >= threads.length) { + return; + } + nextThreadIndex += 1; + const thread = threads[threadIndex]!; + try { + const result = await action(thread); + if (result._tag === "Failure") { + failures.push(result); + } + } catch (error) { + thrownErrors.push(error); + shouldStop = true; + return; + } + } + } + + const workers: Array> = []; + for (let index = 0; index < Math.min(concurrency, threads.length); index += 1) { + workers.push(worker()); + } + await Promise.all(workers); + if (thrownErrors.length > 0) { + throw new AggregateError(thrownErrors, "Archived project thread action failed"); + } + return failures; +} + +export function archivedProjectBulkScopeLabel(scope: ArchivedProjectBulkScope): string { + return scope === "matching" ? "matching archived conversations" : "all archived conversations"; +} + +function archivedThreadSortTimestamp( + thread: { readonly archivedAt: string | null; readonly createdAt: string }, + field: ArchivedThreadSortField, +): number { + const timestamp = Date.parse( + field === "archivedAt" ? (thread.archivedAt ?? thread.createdAt) : thread.createdAt, + ); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +export function compareArchivedThreads< + T extends { readonly id: string; readonly archivedAt: string | null; readonly createdAt: string }, +>(left: T, right: T, sort: ArchivedThreadSortState): number { + const leftTimestamp = archivedThreadSortTimestamp(left, sort.field); + const rightTimestamp = archivedThreadSortTimestamp(right, sort.field); + const timestampComparison = + sort.direction === "asc" ? leftTimestamp - rightTimestamp : rightTimestamp - leftTimestamp; + return timestampComparison || left.id.localeCompare(right.id); +} + +export function nextArchivedThreadSortState( + current: ArchivedThreadSortState, + field: ArchivedThreadSortField, +): ArchivedThreadSortState { + if (current.field !== field) { + return { field, direction: "desc" }; + } + return { field, direction: current.direction === "desc" ? "asc" : "desc" }; +} + +export function buildArchivedThreadGroups(input: { + readonly snapshots: ReadonlyArray; + readonly normalizedSearchQuery: string; + readonly searchTokens: ReadonlyArray; + readonly isSearching: boolean; + readonly sort: ArchivedThreadSortState; +}): ReadonlyArray { + const projectsByEnvironmentAndId = new Map(); + const threadsByEnvironmentAndProjectId = new Map(); + + for (const { environmentId, snapshot } of input.snapshots) { + for (const project of snapshot.projects) { + const key = archivedProjectGroupKey(environmentId, project.id); + // Later snapshots for the same environment/project replace older project metadata. + projectsByEnvironmentAndId.set(key, { + id: project.id, + environmentId, + name: project.title, + cwd: project.workspaceRoot, + }); + } + + for (const thread of snapshot.threads) { + const normalizedTitle = normalizeSearchQuery(thread.title); + const searchScore = archivedThreadSearchScore({ + normalizedTitle, + normalizedQuery: input.normalizedSearchQuery, + tokens: input.searchTokens, + }); + if (searchScore === null) { + continue; + } + const key = archivedProjectGroupKey(environmentId, thread.projectId); + const projectThreads = threadsByEnvironmentAndProjectId.get(key); + const archivedThread = { + ...thread, + environmentId, + normalizedTitle, + searchScore, + }; + if (projectThreads) { + projectThreads.push(archivedThread); + } else { + threadsByEnvironmentAndProjectId.set(key, [archivedThread]); + } + } + } + + const groups: ArchivedThreadGroup[] = []; + for (const [projectKey, project] of projectsByEnvironmentAndId.entries()) { + const projectThreads = threadsByEnvironmentAndProjectId.get(projectKey); + if (projectThreads && projectThreads.length > 0) { + const searchScore = projectThreads.reduce( + (minimumScore, thread) => Math.min(minimumScore, thread.searchScore), + Number.POSITIVE_INFINITY, + ); + groups.push({ + key: projectKey, + project, + threads: projectThreads.toSorted((left, right) => + input.isSearching + ? left.searchScore - right.searchScore || + compareArchivedThreads(left, right, input.sort) + : compareArchivedThreads(left, right, input.sort), + ), + searchScore, + }); + } + } + return input.isSearching + ? groups.toSorted( + (left, right) => + left.searchScore - right.searchScore || + left.project.name.localeCompare(right.project.name), + ) + : groups; +} function collapseOtelSignalsUrl(input: { readonly tracesUrl: string; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 40017d56314..0e111695d2b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,4 +1,4 @@ -import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import { useCallback, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; @@ -9,13 +9,10 @@ import { ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, - type ScopedThreadRef, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, - settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; @@ -38,7 +35,6 @@ import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; -import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, @@ -56,9 +52,7 @@ import { serverEnvironment, } from "../../state/server"; import { usePrimaryEnvironment } from "../../state/environments"; -import { useProjects } from "../../state/entities"; -import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { formatRelativeTime } from "../../timestampFormat"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -86,7 +80,6 @@ import { SettingsSection, useRelativeTimeTick, } from "./settingsLayout"; -import { ProjectFavicon } from "../ProjectFavicon"; import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ @@ -1419,226 +1412,3 @@ export function ProviderSettingsPanel() { ); } - -export function ArchivedThreadsPanel() { - const projects = useProjects(); - const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); - const environmentIds = useMemo( - () => [...new Set(projects.map((project) => project.environmentId))], - [projects], - ); - const { - snapshots: archivedSnapshots, - error: archiveError, - isLoading: isLoadingArchive, - 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, - }, - ] 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]); - - const handleArchivedThreadContextMenu = useCallback( - async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "unarchive", label: "Unarchive" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); - - if (clicked === "unarchive") { - const result = await unarchiveThread(threadRef); - if (result._tag === "Success") { - refreshArchivedThreads(); - } else if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to unarchive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - - if (clicked === "delete") { - const result = await confirmAndDeleteThread(threadRef); - if (result._tag === "Success") { - refreshArchivedThreads(); - } else if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - } - }, - [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], - ); - - return ( - - {archivedGroups.length === 0 ? ( - - - {isLoadingArchive ? ( - - ) : ( - - )} - {isLoadingArchive - ? "Loading archived threads" - : archiveError - ? "Could not load archived threads" - : "No archived threads"} - - } - description={ - isLoadingArchive - ? "Checking connected environments." - : (archiveError ?? "Archived threads will appear here.") - } - /> - - ) : ( - archivedGroups.map(({ project, threads: projectThreads }) => ( - } - > - {projectThreads.map((thread) => ( - { - event.preventDefault(); - void (async () => { - const result = await settlePromise(() => - handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - { - x: event.clientX, - y: event.clientY, - }, - ), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Archived thread action failed", - description: - error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }} - title={thread.title} - description={ - <> - Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} - {" \u00b7 Created "} - {formatRelativeTimeLabel(thread.createdAt)} - - } - control={ - - } - /> - ))} - - )) - )} - - ); -} diff --git a/apps/web/src/routes/settings.archived.tsx b/apps/web/src/routes/settings.archived.tsx index 3ad690afc02..28892668f93 100644 --- a/apps/web/src/routes/settings.archived.tsx +++ b/apps/web/src/routes/settings.archived.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ArchivedThreadsPanel } from "../components/settings/SettingsPanels"; +import { ArchivedThreadsPanel } from "../components/settings/ArchiveSettings"; export const Route = createFileRoute("/settings/archived")({ component: ArchivedThreadsPanel,