diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md new file mode 100644 index 00000000000..4c0d6498a79 --- /dev/null +++ b/BRANCH_DETAILS.md @@ -0,0 +1,31 @@ +# Archive Settings UX + +The settings Archive panel uses a dense layout so large archives remain scannable. The native mobile Archived Threads screen mirrors the same information hierarchy and behavior with mobile-native project sections, swipe actions, long-press menus, and header controls. + +Expected behavior: + +- Archived conversations are grouped by project, and each project group is collapsed by default. +- The Archive panel fetches archived thread snapshots from all configured environments, not only environments that currently have active projects, so archived-only workspaces remain visible, while active rows returned in those snapshots remain excluded from archive content and empty-state counts. +- Web project headers show environment labels whenever multiple environments are configured and keep a sole remote environment labeled, while a sole primary environment remains implicit. +- The page includes a search box that filters archived thread titles across all projects case-insensitively. Multi-word searches match any term, rank exact phrase matches first, rank titles matching every term ahead of partial term matches, and auto-open matching project groups while search is active. +- Expanded project headers include sortable `Archived` and `Created` columns; clicking either header toggles ascending/descending order for the conversations inside each group, with `Archived` descending as the default. +- Native project-section ordering follows the selected archive sort field and direction. Invalid archived timestamps fall back to the conversation's created timestamp for sorting and display on both surfaces. +- Native bulk actions distinguish rows skipped because the same thread action is already in progress from commands that actually fail, and report both outcomes accurately. +- Conversation rows show only the relative archived and created ages inline with the title by default. On web row hover or keyboard focus, those age labels fade out and icon-only unarchive/delete actions appear as a right-side overlay with tooltips, matching the sidebar and source-control list-row action pattern. Native rows keep both age columns visible and expose the same actions through swipe gestures and the standard long-press context menu. +- Archived conversations can be deleted directly from the Archive panel without unarchiving first. Web delete actions respect the shared `confirmThreadDelete` client setting, while native keeps its standard guarded delete flow. +- Project group context menus expose `unarchive all` and `delete all` actions. While search is active, those bulk actions apply to the visible matching archived conversations and use matching-specific menu labels; otherwise they apply to all archived conversations in the project. Delete confirmations respect `confirmThreadDelete` on web and remain explicitly guarded on native; unarchive bulk actions remain guarded on both surfaces, and partial failures surface as not-fully-completed feedback instead of implying every archived thread failed. +- Archive grouping, search ranking, sort state, and project bulk-action concurrency live in `apps/web/src/components/settings/SettingsPanels.logic.ts` on web and `apps/mobile/src/features/archive/archivedThreadList.ts` on native so the dense Archive behavior stays covered without growing the React components. Project groups expose and reuse collision-safe keys so project ids containing separator characters do not collapse expansion state or React row identity. Bulk actions stop scheduling new work after thrown failures, wait for active workers to settle, show incomplete-operation feedback, and surface the underlying exception messages instead of only a generic aggregate error. The Archive surfaces refresh archived threads after bulk unarchive/delete attempts even when the action runner throws. + +Primary files: + +- `apps/web/src/components/settings/ArchiveSettings.tsx` +- `apps/web/src/components/settings/SettingsPanels.tsx` +- `apps/web/src/components/settings/SettingsPanels.logic.ts` +- `apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx` +- `apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx` +- `apps/mobile/src/features/archive/archivedThreadList.ts` + +## Development Ports + +- Web: `5734` +- Server/WebSocket: `13774` diff --git a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx index c2381ef2580..9eb6c6f11e8 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx @@ -3,7 +3,9 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useMemo, useState } from "react"; +import { Alert, Platform } from "react-native"; +import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; import { useArchivedThreadListActions } from "../home/useThreadListActions"; @@ -11,18 +13,60 @@ import { ArchivedThreadsScreen, type ArchivedThreadsHeaderEnvironment, } from "./ArchivedThreadsScreen"; -import { buildArchivedThreadGroups, type ArchivedThreadSortOrder } from "./archivedThreadList"; +import { + archivedThreadActionExceptionDescription, + archivedThreadActionSummaryDescription, + buildArchivedThreadGroups, + parseArchivedThreadSearchInput, + runArchivedThreadActions, + type ArchivedThreadSortState, +} from "./archivedThreadList"; import { refreshArchivedThreadsForEnvironment, useArchivedThreadSnapshots, } from "./useArchivedThreadSnapshots"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { scopedThreadKey } from "../../lib/scopedEntities"; + +function confirmArchivedProjectAction(input: { + readonly title: string; + readonly message: string; + readonly confirmText: string; + readonly destructive?: boolean; +}): Promise { + return new Promise((resolve) => { + if (Platform.OS === "ios") { + Alert.alert(input.title, input.message, [ + { text: "Cancel", style: "cancel", onPress: () => resolve(false) }, + { + text: input.confirmText, + style: input.destructive ? "destructive" : "default", + onPress: () => resolve(true), + }, + ]); + return; + } + showConfirmDialog({ + title: input.title, + message: input.message, + confirmText: input.confirmText, + destructive: input.destructive, + onCancel: () => resolve(false), + onConfirm: () => resolve(true), + }); + }); +} export function ArchivedThreadsRouteScreen() { const { expand } = useClerkSettingsSheetDetent(); const { savedConnectionsById } = useSavedRemoteConnections(); const [searchQuery, setSearchQuery] = useState(""); const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); - const [sortOrder, setSortOrder] = useState("newest"); + const [sort, setSort] = useState({ + field: "archivedAt", + direction: "desc", + }); + const [busyThreadKeys, setBusyThreadKeys] = useState>(() => new Set()); const environments = useMemo>( () => Arr.sort( @@ -40,24 +84,17 @@ export function ArchivedThreadsRouteScreen() { () => environments.map((environment) => environment.environmentId), [environments], ); - const environmentLabels = useMemo( - () => - Object.fromEntries( - environments.map((environment) => [environment.environmentId, environment.label]), - ), - [environments], - ); const { error, isLoading, refresh, snapshots } = useArchivedThreadSnapshots(environmentIds); + const search = useMemo(() => parseArchivedThreadSearchInput(searchQuery), [searchQuery]); const groups = useMemo( () => buildArchivedThreadGroups({ snapshots, - environmentLabels, environmentId: selectedEnvironmentId, - searchQuery, - sortOrder, + search, + sort, }), - [environmentLabels, searchQuery, selectedEnvironmentId, snapshots, sortOrder], + [search, selectedEnvironmentId, snapshots, sort], ); const refreshChangedEnvironment = useCallback( (thread: { readonly environmentId: EnvironmentId }) => { @@ -65,8 +102,79 @@ export function ArchivedThreadsRouteScreen() { }, [], ); - const { unarchiveThread, confirmDeleteThread } = + const { unarchiveThread, deleteThread, confirmDeleteThread } = useArchivedThreadListActions(refreshChangedEnvironment); + const updateBusyThreads = useCallback( + (threads: ReadonlyArray, busy: boolean) => { + setBusyThreadKeys((current) => { + const next = new Set(current); + for (const thread of threads) { + const key = scopedThreadKey(thread.environmentId, thread.id); + if (busy) next.add(key); + else next.delete(key); + } + return next; + }); + }, + [], + ); + const handleUnarchiveThread = useCallback( + async (thread: EnvironmentThreadShell) => { + updateBusyThreads([thread], true); + try { + await unarchiveThread(thread); + } finally { + updateBusyThreads([thread], false); + } + }, + [unarchiveThread, updateBusyThreads], + ); + const handleProjectAction = useCallback( + async ( + projectTitle: string, + threads: ReadonlyArray, + scope: "all" | "matching", + action: "unarchive" | "delete", + ) => { + const scopeLabel = + scope === "matching" ? "matching archived conversations" : "all archived conversations"; + const actionLabel = action === "unarchive" ? "Unarchive" : "Delete"; + const confirmed = await confirmArchivedProjectAction({ + title: `${actionLabel} ${scopeLabel}?`, + message: + action === "unarchive" + ? `Restore ${threads.length} conversation${threads.length === 1 ? "" : "s"} from “${projectTitle}”?` + : `Permanently delete ${threads.length} conversation${threads.length === 1 ? "" : "s"} from “${projectTitle}”? This also clears their terminal history.`, + confirmText: actionLabel, + destructive: action === "delete", + }); + if (!confirmed) return; + + updateBusyThreads(threads, true); + try { + const summary = await runArchivedThreadActions(threads, (thread) => + action === "unarchive" + ? unarchiveThread(thread, { reportFailure: false }) + : deleteThread(thread, { reportFailure: false }), + ); + if (summary.failed > 0 || summary.skipped > 0) { + Alert.alert( + `Archived threads not fully ${action === "unarchive" ? "unarchived" : "deleted"}`, + archivedThreadActionSummaryDescription(summary), + ); + } + } catch (error) { + Alert.alert( + `Archived threads not fully ${action === "unarchive" ? "unarchived" : "deleted"}`, + archivedThreadActionExceptionDescription(error), + ); + } finally { + updateBusyThreads(threads, false); + refresh(); + } + }, + [deleteThread, refresh, unarchiveThread, updateBusyThreads], + ); useFocusEffect( useCallback(() => { @@ -83,13 +191,17 @@ export function ArchivedThreadsRouteScreen() { isLoading={isLoading} onDeleteThread={confirmDeleteThread} onEnvironmentChange={setSelectedEnvironmentId} + onProjectAction={(projectTitle, threads, scope, action) => + void handleProjectAction(projectTitle, threads, scope, action) + } onRefresh={refresh} onSearchQueryChange={setSearchQuery} - onSortOrderChange={setSortOrder} - onUnarchiveThread={unarchiveThread} + onSortChange={setSort} + onUnarchiveThread={(thread) => void handleUnarchiveThread(thread)} searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} - sortOrder={sortOrder} + sort={sort} + busyThreadKeys={busyThreadKeys} /> ); } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 916802e9faf..767ee72465d 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -1,17 +1,14 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { LegendList } from "@legendapp/list/react-native"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; import { useNavigation } from "@react-navigation/native"; -import { useCallback, useMemo, useRef, type ComponentProps } from "react"; +import { useCallback, useMemo, useRef, useState, type ComponentProps } from "react"; import { TextInput, ActivityIndicator, + FlatList, Platform, Pressable, RefreshControl, @@ -26,11 +23,18 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; -import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; +import { + formatArchivedThreadRelativeTime, + archivedThreadTimestampValue, + nextArchivedThreadSortState, + type ArchivedThreadGroup, + type ArchivedThreadSortField, + type ArchivedThreadSortState, +} from "./archivedThreadList"; +import { scopedThreadKey } from "../../lib/scopedEntities"; export interface ArchivedThreadsHeaderEnvironment { readonly environmentId: EnvironmentId; @@ -42,7 +46,10 @@ type ArchivedThreadListItem = readonly kind: "project"; readonly key: string; readonly environmentLabel: string | null; - readonly project: EnvironmentProject; + readonly expanded: boolean; + readonly group: ArchivedThreadGroup; + readonly isSearching: boolean; + readonly isBusy: boolean; } | { readonly kind: "thread"; @@ -57,16 +64,19 @@ function ArchivedThreadsHeader(props: { readonly environments: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; - readonly sortOrder: ArchivedThreadSortOrder; + readonly sort: ArchivedThreadSortState; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onRefresh: () => void; readonly onSearchQueryChange: (query: string) => void; - readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; + readonly onSortChange: (sort: ArchivedThreadSortState) => void; }) { const { width } = useWindowDimensions(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; + const hasCustomFilter = + props.selectedEnvironmentId !== null || + props.sort.field !== "archivedAt" || + props.sort.direction !== "desc"; const searchIconColor = useThemeColor("--color-icon"); const searchTextColor = useThemeColor("--color-foreground"); const usesNativeChrome = Platform.OS === "ios"; @@ -94,22 +104,44 @@ function ArchivedThreadsHeader(props: { }, { id: "sort", - title: "Sort by archived date", + title: "Sort archived threads", subactions: [ { - id: "sort:newest", - title: "Newest first", - state: props.sortOrder === "newest" ? ("on" as const) : undefined, + id: "sort:archivedAt:desc", + title: "Archived: newest first", + state: + props.sort.field === "archivedAt" && props.sort.direction === "desc" + ? ("on" as const) + : undefined, + }, + { + id: "sort:archivedAt:asc", + title: "Archived: oldest first", + state: + props.sort.field === "archivedAt" && props.sort.direction === "asc" + ? ("on" as const) + : undefined, + }, + { + id: "sort:createdAt:desc", + title: "Created: newest first", + state: + props.sort.field === "createdAt" && props.sort.direction === "desc" + ? ("on" as const) + : undefined, }, { - id: "sort:oldest", - title: "Oldest first", - state: props.sortOrder === "oldest" ? ("on" as const) : undefined, + id: "sort:createdAt:asc", + title: "Created: oldest first", + state: + props.sort.field === "createdAt" && props.sort.direction === "asc" + ? ("on" as const) + : undefined, }, ], }, ], - [props.environments, props.selectedEnvironmentId, props.sortOrder], + [props.environments, props.selectedEnvironmentId, props.sort], ); const handleAndroidFilterAction = useCallback( (event: { nativeEvent: { event: string } }) => { @@ -118,13 +150,17 @@ function ArchivedThreadsHeader(props: { props.onEnvironmentChange(null); } else if (action.startsWith("environment:")) { props.onEnvironmentChange(action.slice("environment:".length) as EnvironmentId); - } else if (action === "sort:newest") { - props.onSortOrderChange("newest"); - } else if (action === "sort:oldest") { - props.onSortOrderChange("oldest"); + } else if (action.startsWith("sort:")) { + const [, field, direction] = action.split(":"); + if ( + (field === "archivedAt" || field === "createdAt") && + (direction === "asc" || direction === "desc") + ) { + props.onSortChange({ field, direction }); + } } }, - [props.onEnvironmentChange, props.onSortOrderChange], + [props.onEnvironmentChange, props.onSortChange], ); if (Platform.OS === "android") { @@ -224,19 +260,43 @@ function ArchivedThreadsHeader(props: { }, { type: "submenu" as const, - title: "Sort by archived date", + title: "Sort archived threads", items: [ { type: "action" as const, - title: "Newest first", - state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const), - onPress: () => props.onSortOrderChange("newest"), + title: "Archived: newest first", + state: + props.sort.field === "archivedAt" && props.sort.direction === "desc" + ? ("on" as const) + : ("off" as const), + onPress: () => props.onSortChange({ field: "archivedAt", direction: "desc" }), }, { type: "action" as const, - title: "Oldest first", - state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const), - onPress: () => props.onSortOrderChange("oldest"), + title: "Archived: oldest first", + state: + props.sort.field === "archivedAt" && props.sort.direction === "asc" + ? ("on" as const) + : ("off" as const), + onPress: () => props.onSortChange({ field: "archivedAt", direction: "asc" }), + }, + { + type: "action" as const, + title: "Created: newest first", + state: + props.sort.field === "createdAt" && props.sort.direction === "desc" + ? ("on" as const) + : ("off" as const), + onPress: () => props.onSortChange({ field: "createdAt", direction: "desc" }), + }, + { + type: "action" as const, + title: "Created: oldest first", + state: + props.sort.field === "createdAt" && props.sort.direction === "asc" + ? ("on" as const) + : ("off" as const), + onPress: () => props.onSortChange({ field: "createdAt", direction: "asc" }), }, ], }, @@ -330,19 +390,31 @@ function ArchivedThreadsHeader(props: { ))} - - Sort by archived date + + Sort archived threads + props.onSortChange({ field: "archivedAt", direction: "desc" })} + > + Archived: newest first + + props.onSortChange({ field: "archivedAt", direction: "asc" })} + > + Archived: oldest first + props.onSortOrderChange("newest")} + isOn={props.sort.field === "createdAt" && props.sort.direction === "desc"} + onPress={() => props.onSortChange({ field: "createdAt", direction: "desc" })} > - Newest first + Created: newest first props.onSortOrderChange("oldest")} + isOn={props.sort.field === "createdAt" && props.sort.direction === "asc"} + onPress={() => props.onSortChange({ field: "createdAt", direction: "asc" })} > - Oldest first + Created: oldest first @@ -352,28 +424,151 @@ function ArchivedThreadsHeader(props: { ); } -function ProjectGroupLabel(props: { - readonly environmentLabel: string | null; - readonly project: EnvironmentProject; +function ArchivedSortButton(props: { + readonly field: ArchivedThreadSortField; + readonly label: string; + readonly sort: ArchivedThreadSortState; + readonly onSortChange: (sort: ArchivedThreadSortState) => void; }) { + const iconColor = useThemeColor("--color-icon-subtle"); + const active = props.sort.field === props.field; return ( - - - - {props.project.title} + props.onSortChange(nextArchivedThreadSortState(props.sort, props.field))} + > + + {props.label} - {props.environmentLabel ? ( - - {props.environmentLabel} - + {active ? ( + + ) : ( + + )} + + ); +} + +function ProjectGroupHeader(props: { + readonly environmentLabel: string | null; + readonly expanded: boolean; + readonly group: ArchivedThreadGroup; + readonly isBusy: boolean; + readonly isSearching: boolean; + readonly onProjectAction: (action: "unarchive" | "delete") => void; + readonly onSortChange: (sort: ArchivedThreadSortState) => void; + readonly onToggle: () => void; + readonly sort: ArchivedThreadSortState; +}) { + const iconColor = useThemeColor("--color-icon-subtle"); + const scopeLabel = props.isSearching ? "matching" : "all"; + const actions = useMemo( + () => [ + { + id: "unarchive", + title: `Unarchive ${scopeLabel}`, + image: "arrow.uturn.backward", + }, + { + id: "delete", + title: `Delete ${scopeLabel}`, + image: "trash", + attributes: { destructive: true }, + }, + ], + [scopeLabel], + ); + return ( + + + + + + + {props.group.project.title} + + + {props.group.threads.length} + + {props.environmentLabel ? ( + + {props.environmentLabel} + + ) : null} + + {props.isBusy ? ( + + + + ) : ( + { + if (nativeEvent.event === "unarchive" || nativeEvent.event === "delete") { + props.onProjectAction(nativeEvent.event); + } + }} + > + + + + + )} + + {props.expanded ? ( + + + Conversation + + + + ) : null} ); @@ -383,6 +578,7 @@ function ArchivedThreadRow(props: { readonly environmentLabel: string | null; readonly isFirst: boolean; readonly isLast: boolean; + readonly isBusy: boolean; readonly onDelete: () => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; @@ -396,10 +592,60 @@ function ArchivedThreadRow(props: { const cardColor = useThemeColor("--color-card"); const iconColor = useThemeColor("--color-icon-subtle"); const separatorColor = useThemeColor("--color-separator"); - const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); + const archivedTimestamp = formatArchivedThreadRelativeTime( + archivedThreadTimestampValue(props.thread, "archivedAt"), + ); + const createdTimestamp = formatArchivedThreadRelativeTime(props.thread.createdAt); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); + const onDelete = props.isBusy ? () => undefined : props.onDelete; + const menuActions = useMemo( + () => [ + { id: "unarchive", title: "Unarchive", image: "arrow.uturn.backward" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, + ], + [], + ); + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "unarchive") props.onUnarchive(); + if (nativeEvent.event === "delete") props.onDelete(); + }, + [props.onDelete, props.onUnarchive], + ); + const rowContent = ( + + + + {props.isBusy ? : null} + + {props.thread.title} + + + {subtitle.length > 0 ? ( + + {subtitle.join(" · ")} + + ) : null} + + + {archivedTimestamp ?? "—"} + + + {createdTimestamp ?? "—"} + + + ); return ( undefined : props.onUnarchive, }} simultaneousWithExternalGesture={props.simultaneousSwipeGesture} threadTitle={props.thread.title} > - {() => ( - - - - - - - - - {props.thread.title} - - - {timestamp} - - - {subtitle.length > 0 ? ( - - - - {subtitle.join(" · ")} - - - ) : null} - - - )} + {() => + props.isBusy ? ( + rowContent + ) : ( + + {rowContent} + + ) + } ); } @@ -493,15 +710,26 @@ export function ArchivedThreadsScreen(props: { readonly isLoading: boolean; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; - readonly sortOrder: ArchivedThreadSortOrder; + readonly sort: ArchivedThreadSortState; + readonly busyThreadKeys: ReadonlySet; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onProjectAction: ( + projectTitle: string, + threads: ReadonlyArray, + scope: "all" | "matching", + action: "unarchive" | "delete", + ) => void; readonly onRefresh: () => void; readonly onSearchQueryChange: (query: string) => void; - readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; + readonly onSortChange: (sort: ArchivedThreadSortState) => void; readonly onUnarchiveThread: (thread: EnvironmentThreadShell) => void; }) { const { onDeleteThread, onUnarchiveThread } = props; + const [expandedProjectKeys, setExpandedProjectKeys] = useState>( + () => new Set(), + ); + const [listViewportHeight, setListViewportHeight] = useState(0); const openSwipeableRef = useRef(null); const archiveScrollGesture = useMemo(() => Gesture.Native(), []); const refreshTint = useThemeColor("--color-icon"); @@ -512,21 +740,29 @@ export function ArchivedThreadsScreen(props: { ), [props.environments], ); + const isSearching = props.searchQuery.trim().length > 0; const listItems = useMemo>(() => { const items: ArchivedThreadListItem[] = []; for (const group of props.groups) { const environmentLabel = environmentLabelsById.get(group.project.environmentId) ?? null; + const expanded = isSearching || expandedProjectKeys.has(group.key); items.push({ kind: "project", key: `${group.key}:project`, environmentLabel, - project: group.project, + expanded, + group, + isSearching, + isBusy: group.threads.some((thread) => + props.busyThreadKeys.has(scopedThreadKey(thread.environmentId, thread.id)), + ), }); + if (!expanded) continue; group.threads.forEach((thread, index) => { items.push({ kind: "thread", - key: `${thread.environmentId}:${thread.id}`, + key: scopedThreadKey(thread.environmentId, thread.id), environmentLabel, isFirst: index === 0, isLast: index === group.threads.length - 1, @@ -535,7 +771,15 @@ export function ArchivedThreadsScreen(props: { }); } return items; - }, [environmentLabelsById, props.groups]); + }, [environmentLabelsById, expandedProjectKeys, isSearching, props.busyThreadKeys, props.groups]); + const toggleProject = useCallback((projectKey: string) => { + setExpandedProjectKeys((current) => { + const next = new Set(current); + if (next.has(projectKey)) next.delete(projectKey); + else next.add(projectKey); + return next; + }); + }, []); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current && openSwipeableRef.current !== methods) { openSwipeableRef.current.close(); @@ -553,9 +797,24 @@ export function ArchivedThreadsScreen(props: { ({ item }: { item: ArchivedThreadListItem }) => { if (item.kind === "project") { return ( - - - + + props.onProjectAction( + item.group.project.title, + item.group.threads, + item.isSearching ? "matching" : "all", + action, + ) + } + onSortChange={props.onSortChange} + onToggle={() => toggleProject(item.group.key)} + sort={props.sort} + /> ); } @@ -564,6 +823,9 @@ export function ArchivedThreadsScreen(props: { environmentLabel={item.environmentLabel} isFirst={item.isFirst} isLast={item.isLast} + isBusy={props.busyThreadKeys.has( + scopedThreadKey(item.thread.environmentId, item.thread.id), + )} onDelete={() => onDeleteThread(item.thread)} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -579,6 +841,11 @@ export function ArchivedThreadsScreen(props: { handleSwipeableWillOpen, onDeleteThread, onUnarchiveThread, + props.busyThreadKeys, + props.onProjectAction, + props.onSortChange, + props.sort, + toggleProject, ], ); const listEmptyComponent = useMemo(() => { @@ -611,14 +878,19 @@ export function ArchivedThreadsScreen(props: { onEnvironmentChange={props.onEnvironmentChange} onRefresh={props.onRefresh} onSearchQueryChange={props.onSearchQueryChange} - onSortOrderChange={props.onSortOrderChange} + onSortChange={props.onSortChange} selectedEnvironmentId={props.selectedEnvironmentId} - sortOrder={props.sortOrder} + sort={props.sort} /> - item.kind} + extraData={props.searchQuery} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" keyExtractor={(item) => item.key} @@ -635,6 +906,12 @@ export function ArchivedThreadsScreen(props: { ListHeaderComponent={ props.error ? : null } + onLayout={(event) => { + const nextHeight = Math.round(event.nativeEvent.layout.height); + setListViewportHeight((currentHeight) => + currentHeight === nextHeight ? currentHeight : nextHeight, + ); + }} onScrollBeginDrag={() => openSwipeableRef.current?.close()} refreshControl={ & Pick, @@ -60,8 +71,22 @@ function makeSnapshot( }; } +function buildGroups(input: { + readonly snapshots: ReadonlyArray; + readonly query?: string; + readonly environmentId?: EnvironmentId | null; + readonly sort?: ArchivedThreadSortState; +}) { + return buildArchivedThreadGroups({ + snapshots: input.snapshots, + environmentId: input.environmentId ?? null, + search: parseArchivedThreadSearchInput(input.query ?? ""), + sort: input.sort ?? defaultSort, + }); +} + describe("buildArchivedThreadGroups", () => { - it("groups archived threads by project and sorts newest first", () => { + it("groups archived threads by project and sorts archived newest first", () => { const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); const older = makeThread({ id: ThreadId.make("thread-older"), @@ -75,51 +100,189 @@ describe("buildArchivedThreadGroups", () => { title: "Newer", }); - const result = buildArchivedThreadGroups({ - snapshots: [makeSnapshot([project], [older, newer])], - environmentLabels: { [environmentId]: "Julius's MacBook Pro" }, - environmentId: null, - searchQuery: "", - sortOrder: "newest", - }); + const result = buildGroups({ snapshots: [makeSnapshot([project], [older, newer])] }); expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-newer", "thread-older"]); }); - it("filters by environment and matches project, thread, and branch text", () => { + it("sorts by created date independently of archived date", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const olderCreated = makeThread({ + archivedAt: "2026-06-04T00:00:00.000Z", + createdAt: "2026-05-01T00:00:00.000Z", + id: ThreadId.make("thread-older-created"), + projectId: project.id, + title: "Older created", + }); + const newerCreated = makeThread({ + archivedAt: "2026-06-02T00:00:00.000Z", + createdAt: "2026-06-01T00:00:00.000Z", + id: ThreadId.make("thread-newer-created"), + projectId: project.id, + title: "Newer created", + }); + + const result = buildGroups({ + snapshots: [makeSnapshot([project], [olderCreated, newerCreated])], + sort: { field: "createdAt", direction: "asc" }, + }); + + expect(result[0]?.threads.map((thread) => thread.id)).toEqual([ + "thread-older-created", + "thread-newer-created", + ]); + }); + + it("orders project sections by the selected field and direction", () => { + const olderProject = makeProject({ id: ProjectId.make("project-older"), title: "Older" }); + const newerProject = makeProject({ id: ProjectId.make("project-newer"), title: "Newer" }); + const olderCreated = makeThread({ + archivedAt: "2026-06-02T00:00:00.000Z", + createdAt: "2026-05-01T00:00:00.000Z", + id: ThreadId.make("thread-older-created"), + projectId: olderProject.id, + title: "Older created", + }); + const newerCreated = makeThread({ + archivedAt: "2026-06-04T00:00:00.000Z", + createdAt: "2026-06-01T00:00:00.000Z", + id: ThreadId.make("thread-newer-created"), + projectId: newerProject.id, + title: "Newer created", + }); + + const result = buildGroups({ + snapshots: [makeSnapshot([newerProject, olderProject], [newerCreated, olderCreated])], + sort: { field: "createdAt", direction: "asc" }, + }); + + expect(result.map((group) => group.project.id)).toEqual(["project-older", "project-newer"]); + }); + + it("falls back to created time when an archived timestamp is invalid", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const invalidArchivedAt = makeThread({ + archivedAt: "not-a-timestamp", + createdAt: "2026-06-05T00:00:00.000Z", + id: ThreadId.make("thread-invalid-archive"), + projectId: project.id, + title: "Invalid archived time", + }); + const validArchivedAt = makeThread({ + archivedAt: "2026-06-03T00:00:00.000Z", + createdAt: "2026-06-01T00:00:00.000Z", + id: ThreadId.make("thread-valid-archive"), + projectId: project.id, + title: "Valid archived time", + }); + + const result = buildGroups({ + snapshots: [makeSnapshot([project], [validArchivedAt, invalidArchivedAt])], + }); + + expect(result[0]?.threads.map((thread) => thread.id)).toEqual([ + "thread-invalid-archive", + "thread-valid-archive", + ]); + expect(archivedThreadTimestampValue(invalidArchivedAt, "archivedAt")).toBe( + invalidArchivedAt.createdAt, + ); + }); + + it("ranks phrase and all-token title matches ahead of partial token matches", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const partial = makeThread({ + id: ThreadId.make("thread-partial"), + projectId: project.id, + title: "Archive cleanup", + }); + const allTokens = makeThread({ + id: ThreadId.make("thread-all"), + projectId: project.id, + title: "Settings for the archive", + }); + const phrase = makeThread({ + id: ThreadId.make("thread-phrase"), + projectId: project.id, + title: "Archive settings screen", + }); + + const result = buildGroups({ + snapshots: [makeSnapshot([project], [partial, allTokens, phrase])], + query: "archive settings", + }); + + expect(result[0]?.threads.map((thread) => thread.id)).toEqual([ + "thread-phrase", + "thread-all", + "thread-partial", + ]); + }); + + it("preserves search ranking tiers for matches late in long titles", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const latePhrase = makeThread({ + id: ThreadId.make("thread-late-phrase"), + projectId: project.id, + title: `${"x".repeat(600)} archive settings`, + }); + const earlyAllTokens = makeThread({ + id: ThreadId.make("thread-early-all"), + projectId: project.id, + title: "Archive tools Settings", + }); + const lateAllTokens = makeThread({ + id: ThreadId.make("thread-late-all"), + projectId: project.id, + title: `Archive ${"x".repeat(3_000)} Settings`, + }); + const earlyPartial = makeThread({ + id: ThreadId.make("thread-early-partial"), + projectId: project.id, + title: "Archive only", + }); + + const result = buildGroups({ + snapshots: [ + makeSnapshot([project], [earlyPartial, lateAllTokens, earlyAllTokens, latePhrase]), + ], + query: "archive settings", + }); + + expect(result[0]?.threads.map((thread) => thread.id)).toEqual([ + "thread-late-phrase", + "thread-early-all", + "thread-late-all", + "thread-early-partial", + ]); + }); + + it("filters archived title matches by environment", () => { const secondEnvironmentId = EnvironmentId.make("environment-2"); const firstProject = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); const secondProject = makeProject({ id: ProjectId.make("project-2"), title: "Website" }); const firstThread = makeThread({ - branch: "fix/archive-screen", id: ThreadId.make("thread-1"), projectId: firstProject.id, - title: "Build settings route", + title: "Build archive settings route", }); const secondThread = makeThread({ id: ThreadId.make("thread-2"), projectId: secondProject.id, - title: "Unrelated", - }); - const snapshots = [ - makeSnapshot([firstProject], [firstThread]), - makeSnapshot([secondProject], [secondThread], secondEnvironmentId), - ]; - - const result = buildArchivedThreadGroups({ - snapshots, - environmentLabels: { - [environmentId]: "Local", - [secondEnvironmentId]: "Remote", - }, + title: "Build archive settings route remotely", + }); + + const result = buildGroups({ + snapshots: [ + makeSnapshot([firstProject], [firstThread]), + makeSnapshot([secondProject], [secondThread], secondEnvironmentId), + ], environmentId, - searchQuery: "archive-screen", - sortOrder: "oldest", + query: "archive settings", }); expect(result).toHaveLength(1); expect(result[0]?.project.environmentId).toBe(environmentId); - expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-1"]); }); it("ignores non-archived entries returned in a snapshot", () => { @@ -131,14 +294,92 @@ describe("buildArchivedThreadGroups", () => { title: "Active", }); - const result = buildArchivedThreadGroups({ - snapshots: [makeSnapshot([project], [active])], - environmentLabels: {}, - environmentId: null, - searchQuery: "", - sortOrder: "newest", + expect(buildGroups({ snapshots: [makeSnapshot([project], [active])] })).toEqual([]); + }); + + it("keeps archive group keys distinct when scoped ids contain colons", () => { + const firstEnvironmentId = EnvironmentId.make("environment:one"); + const secondEnvironmentId = EnvironmentId.make("environment"); + const firstProject = makeProject({ id: ProjectId.make("project"), title: "First" }); + const secondProject = makeProject({ id: ProjectId.make("one:project"), title: "Second" }); + 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 result = buildGroups({ + snapshots: [ + makeSnapshot([firstProject], [firstThread], firstEnvironmentId), + makeSnapshot([secondProject], [secondThread], secondEnvironmentId), + ], + }); + + expect(result.map((group) => group.key)).toEqual([ + '["environment:one","project"]', + '["environment","one:project"]', + ]); + }); +}); + +describe("archive list controls", () => { + it("toggles a selected sort field and defaults a new field to descending", () => { + expect(nextArchivedThreadSortState(defaultSort, "archivedAt")).toEqual({ + field: "archivedAt", + direction: "asc", }); + expect(nextArchivedThreadSortState(defaultSort, "createdAt")).toEqual({ + field: "createdAt", + direction: "desc", + }); + }); + + it("runs bulk actions with bounded concurrency and reports partial failures", async () => { + let active = 0; + let maximumActive = 0; + const summary = await runArchivedThreadActions( + [1, 2, 3, 4, 5], + async (value) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + await Promise.resolve(); + active -= 1; + if (value === 3) return "failed"; + if (value === 4) return "skipped"; + return "succeeded"; + }, + { concurrency: 2 }, + ); + + expect(maximumActive).toBe(2); + expect(summary).toEqual({ succeeded: 3, failed: 1, skipped: 1 }); + expect(archivedThreadActionSummaryDescription(summary)).toBe( + "3 succeeded, 1 failed, and 1 skipped because already in progress.", + ); + }); + + it("surfaces distinct underlying bulk action exceptions", () => { + const error = new AggregateError([ + new Error("Connection failed"), + new Error("Connection failed"), + "unknown failure", + new Error("Permission denied"), + new Error("Session expired"), + ]); + + expect(archivedThreadActionExceptionDescription(error)).toBe( + "One or more archived thread actions failed unexpectedly. Failures: Connection failed; An error occurred.; Permission denied; 1 more", + ); + }); +}); - expect(result).toEqual([]); +describe("formatArchivedThreadRelativeTime", () => { + it("omits invalid archive timestamps instead of presenting them as recent", () => { + expect(formatArchivedThreadRelativeTime("not-a-timestamp")).toBeNull(); }); }); diff --git a/apps/mobile/src/features/archive/archivedThreadList.ts b/apps/mobile/src/features/archive/archivedThreadList.ts index 6146bba2044..cadb5263e87 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.ts @@ -6,101 +6,299 @@ import { type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId } from "@t3tools/contracts"; +import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; -import { scopedProjectKey } from "../../lib/scopedEntities"; +import { relativeTime } from "../../lib/time"; -export type ArchivedThreadSortOrder = "newest" | "oldest"; +const ARCHIVED_THREAD_ALL_TOKENS_SCORE_OFFSET = 1_000; +const ARCHIVED_THREAD_PARTIAL_TOKENS_SCORE_OFFSET = 5_000; +const ARCHIVED_THREAD_PHRASE_SCORE_MAX = ARCHIVED_THREAD_ALL_TOKENS_SCORE_OFFSET - 1; +const ARCHIVED_THREAD_ALL_TOKENS_SCORE_MAX = + ARCHIVED_THREAD_PARTIAL_TOKENS_SCORE_OFFSET - ARCHIVED_THREAD_ALL_TOKENS_SCORE_OFFSET - 1; +const DEFAULT_ARCHIVED_THREAD_ACTION_CONCURRENCY = 4; + +export type ArchivedThreadSortField = "archivedAt" | "createdAt"; +export type ArchivedThreadSortDirection = "asc" | "desc"; + +export interface ArchivedThreadSortState { + readonly field: ArchivedThreadSortField; + readonly direction: ArchivedThreadSortDirection; +} + +export interface ArchivedThreadSearchInput { + readonly normalizedQuery: string; + readonly tokens: ReadonlyArray; + readonly isSearching: boolean; +} export interface ArchivedThreadGroup { readonly key: string; readonly project: EnvironmentProject; readonly threads: ReadonlyArray; + readonly searchScore: number; +} + +export interface ArchivedThreadActionSummary { + readonly succeeded: number; + readonly failed: number; + readonly skipped: number; } -function archiveTimestamp(thread: EnvironmentThreadShell): number { - const timestamp = Date.parse(thread.archivedAt ?? thread.updatedAt); +export type ArchivedThreadActionResult = "succeeded" | "failed" | "skipped"; + +export function archivedThreadActionSummaryDescription( + summary: ArchivedThreadActionSummary, +): string { + const parts = [`${summary.succeeded} succeeded`]; + if (summary.failed > 0) parts.push(`${summary.failed} failed`); + if (summary.skipped > 0) { + parts.push(`${summary.skipped} skipped because already in progress`); + } + return `${parts.length === 2 ? parts.join(" and ") : parts.join(", ").replace(/, ([^,]*)$/u, ", and $1")}.`; +} + +export function archivedThreadActionExceptionDescription(error: unknown): string { + 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); + return [ + "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(" "); +} + +function archivedProjectGroupKey(environmentId: EnvironmentId, projectId: string): string { + return JSON.stringify([environmentId, projectId]); +} + +export function archivedThreadTimestampValue( + thread: Pick, + field: ArchivedThreadSortField, +): string { + if (field === "createdAt" || thread.archivedAt === null) return thread.createdAt; + return Number.isNaN(Date.parse(thread.archivedAt)) ? thread.createdAt : thread.archivedAt; +} + +function archivedThreadTimestamp( + thread: Pick, + field: ArchivedThreadSortField, +): number { + const timestamp = Date.parse(archivedThreadTimestampValue(thread, field)); return Number.isNaN(timestamp) ? 0 : timestamp; } -function matchesQuery(value: string | null, query: string): boolean { - return value?.toLocaleLowerCase().includes(query) ?? false; +export function formatArchivedThreadRelativeTime(input: string): string | null { + return Number.isNaN(Date.parse(input)) ? null : relativeTime(input); +} + +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 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 Math.min(phraseScore, ARCHIVED_THREAD_PHRASE_SCORE_MAX); + + 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 + + Math.min(tokenScore, ARCHIVED_THREAD_ALL_TOKENS_SCORE_MAX) + ); + } + return ( + ARCHIVED_THREAD_PARTIAL_TOKENS_SCORE_OFFSET + + (input.tokens.length - matchedTokenCount) * 1_000 + + tokenScore + ); +} + +export function compareArchivedThreads( + left: EnvironmentThreadShell, + right: EnvironmentThreadShell, + sort: ArchivedThreadSortState, +): number { + const leftTimestamp = archivedThreadTimestamp(left, sort.field); + const rightTimestamp = archivedThreadTimestamp(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 environmentLabels: Readonly>; readonly environmentId: EnvironmentId | null; - readonly searchQuery: string; - readonly sortOrder: ArchivedThreadSortOrder; + readonly search: ArchivedThreadSearchInput; + readonly sort: ArchivedThreadSortState; }): ReadonlyArray { - const query = input.searchQuery.trim().toLocaleLowerCase(); const groups: ArchivedThreadGroup[] = []; for (const entry of input.snapshots) { - if (input.environmentId !== null && input.environmentId !== entry.environmentId) { - continue; - } + if (input.environmentId !== null && input.environmentId !== entry.environmentId) continue; - const environmentLabel = input.environmentLabels[entry.environmentId] ?? null; - const threadsByProjectId = new Map(); - for (const thread of entry.snapshot.threads) { - if (thread.archivedAt === null) { - continue; - } - const threads = threadsByProjectId.get(thread.projectId) ?? []; - threads.push(scopeThreadShell(entry.environmentId, thread)); - threadsByProjectId.set(thread.projectId, threads); + const threadsByProjectId = new Map< + string, + Array<{ readonly thread: EnvironmentThreadShell; readonly searchScore: number }> + >(); + for (const rawThread of entry.snapshot.threads) { + if (rawThread.archivedAt === null) continue; + const searchScore = archivedThreadSearchScore({ + normalizedTitle: normalizeSearchQuery(rawThread.title), + normalizedQuery: input.search.normalizedQuery, + tokens: input.search.tokens, + }); + if (searchScore === null) continue; + const threads = threadsByProjectId.get(rawThread.projectId) ?? []; + threads.push({ thread: scopeThreadShell(entry.environmentId, rawThread), searchScore }); + threadsByProjectId.set(rawThread.projectId, threads); } for (const rawProject of entry.snapshot.projects) { const project = scopeProject(entry.environmentId, rawProject); - const projectThreads = threadsByProjectId.get(project.id) ?? []; - const groupMatches = - query.length === 0 || - matchesQuery(project.title, query) || - matchesQuery(project.workspaceRoot, query) || - matchesQuery(environmentLabel, query); - const matchingThreads = groupMatches - ? projectThreads - : projectThreads.filter( - (thread) => matchesQuery(thread.title, query) || matchesQuery(thread.branch, query), - ); - - if (matchingThreads.length === 0) { - continue; - } - - const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; + const projectThreads = threadsByProjectId.get(project.id); + if (!projectThreads || projectThreads.length === 0) continue; + const searchScore = projectThreads.reduce( + (minimum, entry) => Math.min(minimum, entry.searchScore), + Number.POSITIVE_INFINITY, + ); groups.push({ - key: scopedProjectKey(project.environmentId, project.id), + key: archivedProjectGroupKey(project.environmentId, project.id), project, - threads: Arr.sort( - matchingThreads, - Order.mapInput( - Order.Struct({ timestamp: timestampOrder, title: Order.String, id: Order.String }), - (thread: EnvironmentThreadShell) => ({ - timestamp: archiveTimestamp(thread), - title: thread.title, - id: thread.id, - }), - ), - ), + threads: projectThreads + .sort((left, right) => + input.search.isSearching + ? left.searchScore - right.searchScore || + compareArchivedThreads(left.thread, right.thread, input.sort) + : compareArchivedThreads(left.thread, right.thread, input.sort), + ) + .map((entry) => entry.thread), + searchScore, }); } } - const timestampOrder = input.sortOrder === "newest" ? Order.flip(Order.Number) : Order.Number; + if (input.search.isSearching) { + return groups.sort( + (left, right) => + left.searchScore - right.searchScore || + left.project.title.localeCompare(right.project.title), + ); + } + return Arr.sort( groups, Order.mapInput( - Order.Struct({ timestamp: timestampOrder, title: Order.String, key: Order.String }), + Order.Struct({ + timestamp: input.sort.direction === "asc" ? Order.Number : Order.flip(Order.Number), + title: Order.String, + key: Order.String, + }), (group: ArchivedThreadGroup) => ({ - timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : 0, + timestamp: Math.max( + ...group.threads.map((thread) => archivedThreadTimestamp(thread, input.sort.field)), + ), title: group.project.title, key: group.key, }), ), ); } + +export async function runArchivedThreadActions( + items: ReadonlyArray, + action: (item: T) => Promise, + options: { readonly concurrency?: number } = {}, +): Promise { + const concurrency = + options.concurrency === undefined || !Number.isFinite(options.concurrency) + ? DEFAULT_ARCHIVED_THREAD_ACTION_CONCURRENCY + : Math.max(1, Math.floor(options.concurrency)); + const thrownErrors: unknown[] = []; + let nextItemIndex = 0; + let succeeded = 0; + let failed = 0; + let skipped = 0; + let shouldStop = false; + + async function worker() { + for (;;) { + if (shouldStop) return; + const itemIndex = nextItemIndex; + if (itemIndex >= items.length) return; + nextItemIndex += 1; + try { + const result = await action(items[itemIndex]!); + if (result === "succeeded") succeeded += 1; + else if (result === "failed") failed += 1; + else skipped += 1; + } catch (error) { + thrownErrors.push(error); + shouldStop = true; + return; + } + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); + if (thrownErrors.length > 0) { + throw new AggregateError(thrownErrors, "Archived thread action failed"); + } + return { succeeded, failed, skipped }; +} diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8effdc942d9..920dffda037 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -10,6 +10,11 @@ import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; type ThreadListAction = "archive" | "unarchive" | "delete"; +export type ThreadListActionResult = "succeeded" | "failed" | "skipped"; + +interface ThreadActionOptions { + readonly reportFailure?: boolean; +} function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause): string { const error = Cause.squash(cause); @@ -40,10 +45,14 @@ function useThreadActionExecutor( const inFlightThreadKeys = useRef(new Set()); const executeAction = useCallback( - async (action: ThreadListAction, thread: EnvironmentThreadShell) => { + async ( + action: ThreadListAction, + thread: EnvironmentThreadShell, + options: ThreadActionOptions = {}, + ): Promise => { const key = scopedThreadKey(thread.environmentId, thread.id); if (inFlightThreadKeys.current.has(key)) { - return; + return "skipped"; } inFlightThreadKeys.current.add(key); @@ -60,10 +69,13 @@ function useThreadActionExecutor( input: { threadId: thread.id }, }); if (result._tag === "Failure") { - Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); - return; + if (options.reportFailure !== false) { + Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); + } + return "failed"; } onCompleted?.(action, thread); + return "succeeded"; } finally { inFlightThreadKeys.current.delete(key); } @@ -75,7 +87,11 @@ function useThreadActionExecutor( } function useConfirmDeleteThread( - executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, + executeAction: ( + action: ThreadListAction, + thread: EnvironmentThreadShell, + options?: ThreadActionOptions, + ) => Promise, ) { return useCallback( (thread: EnvironmentThreadShell) => { @@ -129,7 +145,14 @@ export function useThreadListActions(): { export function useArchivedThreadListActions( onCompleted: (thread: EnvironmentThreadShell) => void, ): { - readonly unarchiveThread: (thread: EnvironmentThreadShell) => void; + readonly unarchiveThread: ( + thread: EnvironmentThreadShell, + options?: ThreadActionOptions, + ) => Promise; + readonly deleteThread: ( + thread: EnvironmentThreadShell, + options?: ThreadActionOptions, + ) => Promise; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; } { const handleCompleted = useCallback( @@ -140,12 +163,16 @@ export function useArchivedThreadListActions( ); const executeAction = useThreadActionExecutor(handleCompleted); const unarchiveThread = useCallback( - (thread: EnvironmentThreadShell) => { - void executeAction("unarchive", thread); - }, + (thread: EnvironmentThreadShell, options?: ThreadActionOptions) => + executeAction("unarchive", thread, options), + [executeAction], + ); + const deleteThread = useCallback( + (thread: EnvironmentThreadShell, options?: ThreadActionOptions) => + executeAction("delete", thread, options), [executeAction], ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); - return { unarchiveThread, confirmDeleteThread }; + return { unarchiveThread, deleteThread, confirmDeleteThread }; } diff --git a/apps/web/src/components/settings/ArchiveSettings.tsx b/apps/web/src/components/settings/ArchiveSettings.tsx new file mode 100644 index 00000000000..4c24dc87886 --- /dev/null +++ b/apps/web/src/components/settings/ArchiveSettings.tsx @@ -0,0 +1,694 @@ +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, usePrimaryEnvironmentId } 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, + archivedProjectBulkFailureDescription, + archivedThreadTimestampValue, + type ArchivedProjectBulkFailure, + type ArchivedProjectBulkScope, + type ArchivedProjectBulkThread, + type ArchivedThreadSortField, + type ArchivedThreadSortState, + buildArchivedThreadGroups, + hasArchivedThreads as archiveHasThreads, + nextArchivedThreadSortState, + parseArchivedThreadSearchInput, + resolveArchivedProjectEnvironmentLabel, + 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 primaryEnvironmentId = usePrimaryEnvironmentId(); + 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 archiveEnvironmentsById = useMemo( + () => + new Map( + environments.map((environment) => [ + environment.environmentId, + { + environmentId: environment.environmentId, + label: environment.label, + isPrimary: environment.environmentId === primaryEnvironmentId, + }, + ]), + ), + [environments, primaryEnvironmentId], + ); + const { + snapshots: archivedSnapshots, + error: archiveError, + isLoading: isLoadingArchive, + refresh: refreshArchivedThreads, + } = useArchivedThreadSnapshots(environmentIds); + const archiveSearch = useMemo( + () => parseArchivedThreadSearchInput(archiveSearchQuery), + [archiveSearchQuery], + ); + const hasArchivedThreads = useMemo( + () => archiveHasThreads(archivedSnapshots), + [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) return true; + 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 description = archivedProjectBulkFailureDescription(failures, totalCount); + if (!description) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description, + }), + ); + }, + [], + ); + + 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"; + const environmentLabel = resolveArchivedProjectEnvironmentLabel({ + environment: archiveEnvironmentsById.get(project.environmentId) ?? null, + hasMultipleEnvironments: environments.length > 1, + }); + 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( + archivedThreadTimestampValue(thread, "archivedAt"), + )} +
+
+ {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..5031e114bde 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -1,15 +1,542 @@ 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 { + archivedProjectBulkFailureDescription, + archivedThreadSearchScore, + archivedThreadTimestampValue, + buildArchivedThreadGroups, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, + hasArchivedThreads, + nextArchivedThreadSortState, + parseArchivedThreadSearchInput, + resolveArchivedProjectEnvironmentLabel, + 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("resolveArchivedProjectEnvironmentLabel", () => { + const primaryEnvironment = { + environmentId, + label: "Local environment", + isPrimary: true, + } as const; + const remoteEnvironment = { + environmentId: EnvironmentId.make("environment-remote"), + label: "Build box", + isPrimary: false, + } as const; + + it("shows a sole remote environment label", () => { + expect( + resolveArchivedProjectEnvironmentLabel({ + environment: remoteEnvironment, + hasMultipleEnvironments: false, + }), + ).toBe("Build box"); + }); + + it("shows a remote environment label when multiple environments exist", () => { + expect( + resolveArchivedProjectEnvironmentLabel({ + environment: remoteEnvironment, + hasMultipleEnvironments: true, + }), + ).toBe("Build box"); + }); + + it("hides a sole primary environment label", () => { + expect( + resolveArchivedProjectEnvironmentLabel({ + environment: primaryEnvironment, + hasMultipleEnvironments: false, + }), + ).toBeNull(); + }); + + it("shows and normalizes the primary label when multiple environments exist", () => { + expect( + resolveArchivedProjectEnvironmentLabel({ + environment: primaryEnvironment, + hasMultipleEnvironments: true, + }), + ).toBe("This device"); + }); + + it("hides the label when the environment is unknown", () => { + expect( + resolveArchivedProjectEnvironmentLabel({ + environment: null, + hasMultipleEnvironments: true, + }), + ).toBeNull(); + }); +}); + +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("preserves search ranking tiers for matches late in long titles", () => { + const latePhraseMatch = scoreArchivedTitle(`${"x".repeat(600)} alpha beta`, "alpha beta"); + const earlyAllTokenMatch = scoreArchivedTitle("Alpha cleanup Beta", "alpha beta"); + const lateAllTokenMatch = scoreArchivedTitle(`Alpha ${"x".repeat(3_000)} Beta`, "alpha beta"); + const earlyPartialTokenMatch = scoreArchivedTitle("Alpha cleanup", "alpha beta"); + + expect(latePhraseMatch).not.toBeNull(); + expect(earlyAllTokenMatch).not.toBeNull(); + expect(lateAllTokenMatch).not.toBeNull(); + expect(earlyPartialTokenMatch).not.toBeNull(); + expect(latePhraseMatch!).toBeLessThan(earlyAllTokenMatch!); + expect(lateAllTokenMatch!).toBeLessThan(earlyPartialTokenMatch!); + }); + + 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("ignores active threads returned in archive snapshots", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const activeThread = makeThread({ + archivedAt: null, + id: ThreadId.make("thread-active"), + projectId: project.id, + title: "Active thread", + }); + const search = parseArchivedThreadSearchInput(""); + + const result = buildArchivedThreadGroups({ + snapshots: [makeSnapshot([project], [activeThread])], + normalizedSearchQuery: search.normalizedQuery, + searchTokens: search.tokens, + isSearching: search.isSearching, + sort: { field: "archivedAt", direction: "desc" }, + }); + + expect(result).toEqual([]); + }); + + it("falls back to created time when an archived timestamp is invalid", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const invalidArchivedAt = makeThread({ + archivedAt: "not-a-timestamp", + createdAt: "2026-06-05T00:00:00.000Z", + id: ThreadId.make("thread-invalid-archive"), + projectId: project.id, + title: "Invalid archived time", + }); + const validArchivedAt = makeThread({ + archivedAt: "2026-06-03T00:00:00.000Z", + createdAt: "2026-06-01T00:00:00.000Z", + id: ThreadId.make("thread-valid-archive"), + projectId: project.id, + title: "Valid archived time", + }); + const search = parseArchivedThreadSearchInput(""); + + const result = buildArchivedThreadGroups({ + snapshots: [makeSnapshot([project], [validArchivedAt, invalidArchivedAt])], + normalizedSearchQuery: search.normalizedQuery, + searchTokens: search.tokens, + isSearching: search.isSearching, + sort: { field: "archivedAt", direction: "desc" }, + }); + + expect(result[0]?.threads.map((thread) => thread.id)).toEqual([ + "thread-invalid-archive", + "thread-valid-archive", + ]); + expect(archivedThreadTimestampValue(invalidArchivedAt, "archivedAt")).toBe( + invalidArchivedAt.createdAt, + ); + }); + + 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("hasArchivedThreads", () => { + it("ignores active threads when determining whether the archive has content", () => { + const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); + const activeThread = makeThread({ + archivedAt: null, + id: ThreadId.make("thread-active"), + projectId: project.id, + title: "Active thread", + }); + const archivedThread = makeThread({ + id: ThreadId.make("thread-archived"), + projectId: project.id, + title: "Archived thread", + }); + + expect(hasArchivedThreads([makeSnapshot([project], [activeThread])])).toBe(false); + expect(hasArchivedThreads([makeSnapshot([project], [activeThread, archivedThread])])).toBe( + true, + ); + }); +}); + +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("archivedProjectBulkFailureDescription", () => { + it("reports interrupted-only partial outcomes", () => { + expect( + archivedProjectBulkFailureDescription([AsyncResult.failure(Cause.interrupt(1))], 2), + ).toBe("1 succeeded, 0 failed, 1 interrupted."); + }); +}); + 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..e1e1f1f31c8 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -1,11 +1,394 @@ import type { + EnvironmentId, + OrchestrationProjectShell, + OrchestrationThreadShell, ProviderDriverKind, ProviderInstanceConfig, ProviderInstanceId, + ThreadId, ServerSettings, UnifiedSettings, } from "@t3tools/contracts"; +import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; +import { + resolveEnvironmentOptionLabel, + shouldShowEnvironmentIndicator, +} from "../BranchToolbar.logic"; + +const ARCHIVED_THREAD_ALL_TOKENS_SCORE_OFFSET = 1_000; +const ARCHIVED_THREAD_PARTIAL_TOKENS_SCORE_OFFSET = 5_000; +const ARCHIVED_THREAD_PHRASE_SCORE_MAX = ARCHIVED_THREAD_ALL_TOKENS_SCORE_OFFSET - 1; +const ARCHIVED_THREAD_ALL_TOKENS_SCORE_MAX = + ARCHIVED_THREAD_PARTIAL_TOKENS_SCORE_OFFSET - ARCHIVED_THREAD_ALL_TOKENS_SCORE_OFFSET - 1; +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 resolveArchivedProjectEnvironmentLabel(input: { + readonly environment: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPrimary: boolean; + } | null; + readonly hasMultipleEnvironments: boolean; +}): string | null { + if ( + !shouldShowEnvironmentIndicator({ + activeEnvironment: input.environment, + canPickEnvironment: input.hasMultipleEnvironments, + }) + ) { + return null; + } + + const environment = input.environment; + if (environment === null) return null; + return resolveEnvironmentOptionLabel({ + isPrimary: environment.isPrimary, + environmentId: environment.environmentId, + runtimeLabel: environment.label, + }); +} + +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, + }; +} + +export function hasArchivedThreads(snapshots: ReadonlyArray): boolean { + return snapshots.some(({ snapshot }) => + snapshot.threads.some((thread) => thread.archivedAt !== null), + ); +} + +// 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 Math.min(phraseScore, ARCHIVED_THREAD_PHRASE_SCORE_MAX); + } + + 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 + + Math.min(tokenScore, ARCHIVED_THREAD_ALL_TOKENS_SCORE_MAX) + ); + } + + 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"; +} + +export function archivedThreadTimestampValue( + thread: { readonly archivedAt: string | null; readonly createdAt: string }, + field: ArchivedThreadSortField, +): string { + if (field === "createdAt" || thread.archivedAt === null) return thread.createdAt; + return Number.isNaN(Date.parse(thread.archivedAt)) ? thread.createdAt : thread.archivedAt; +} + +function archivedThreadSortTimestamp( + thread: { readonly archivedAt: string | null; readonly createdAt: string }, + field: ArchivedThreadSortField, +): number { + const timestamp = Date.parse(archivedThreadTimestampValue(thread, field)); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +export function archivedProjectBulkFailureDescription( + failures: ReadonlyArray, + totalCount: number, +): string | null { + if (failures.length === 0) return null; + const visibleFailures = failures.filter((failure) => !isAtomCommandInterrupted(failure)); + const interruptedCount = failures.length - visibleFailures.length; + const successCount = totalCount - failures.length; + const outcome = `${successCount} succeeded, ${visibleFailures.length} failed${ + interruptedCount > 0 ? `, ${interruptedCount} interrupted` : "" + }.`; + if (visibleFailures.length === 0) return outcome; + + 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 details = + visibleFailures.length === 1 + ? (shownFailureMessages[0] ?? "An error occurred.") + : `Failures: ${shownFailureMessages.join("; ")}${ + failureMessages.length > shownFailureMessages.length + ? `; ${failureMessages.length - shownFailureMessages.length} more` + : "" + }`; + return `${outcome} ${details}`; +} + +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) { + if (thread.archivedAt === null) continue; + 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 5b29c5494cc..fca8b599cd2 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 { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; +import { getRelativeTimeState } 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 = [ @@ -1429,226 +1422,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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c58ccffc420..0cac51a9eb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ overrides: vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 -packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= +packageExtensionsChecksum: sha256-dL4kgB3oS88+usby/QZU7EK4kxNoz9EGcSH5yDZBXZM= patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f @@ -153,7 +153,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -419,7 +419,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -471,7 +471,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -616,7 +616,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) @@ -682,7 +682,7 @@ importers: version: link:../../packages/shared alchemy: specifier: https://pkg.ing/alchemy/078ff00 - version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) + version: https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c) drizzle-orm: specifier: 1.0.0-rc.3 version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -698,7 +698,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -726,7 +726,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -745,7 +745,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -758,7 +758,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -777,7 +777,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -799,7 +799,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -833,7 +833,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -858,7 +858,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -880,7 +880,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -911,7 +911,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -2012,6 +2012,10 @@ packages: resolution: {integrity: sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ==} peerDependencies: effect: 4.0.0-beta.78 + vitest: '*' + peerDependenciesMeta: + vitest: + optional: true '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} @@ -11761,9 +11765,43 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0)': dependencies: effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + optionalDependencies: + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml '@egjs/hammerjs@2.0.17': dependencies: @@ -15302,7 +15340,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39): + alchemy@https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c): dependencies: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 @@ -15316,7 +15354,7 @@ snapshots: '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@smithy/node-config-provider': 4.4.6 @@ -15349,12 +15387,39 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' - '@types/node' - '@types/react' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw - pg-native + - publint - react-devtools-core + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun - utf-8-validate + - vitest - workerd alien-signals@2.0.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22757702fc8..a3b0d78ee2c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,8 @@ packages: - packages/* - scripts +enableGlobalVirtualStore: true + # Successor to onlyBuiltDependencies/ignoredBuiltDependencies (pnpm 11). # true = allowed to run build scripts; false mirrors the pnpm 10 behavior # where anything outside onlyBuiltDependencies was silently not built. @@ -95,9 +97,11 @@ packageExtensions: "@clerk/expo@*": dependencies: "@expo/config-plugins": 56.0.9 - "@effect/vitest@*": + # Wildcard semver excludes prereleases, so target the pinned beta exactly. + "@effect/vitest@4.0.0-beta.78": dependencies: - vite-plus: "catalog:" + # The patched package imports vite-plus inside the shared graph. + vite-plus: 0.2.2 peerDependenciesMeta: vitest: optional: true