diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md new file mode 100644 index 000000000000..8cc2d1e06a02 --- /dev/null +++ b/BRANCH_DETAILS.md @@ -0,0 +1,41 @@ +# 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. Native project sections show environment labels and the header controls can filter the archive to all environments or one configured environment. +- Native Settings exposes `Archived Threads` in both local-only and T3 Connect-configured modes through a shared `General`, `Appearance`, `Beta`, `Archive`, and `App` tail, preserving that order while each mode keeps its own account and configuration controls. The native settings contract owns the `SettingsArchive` route name, link, and title plus the legacy `SettingsWaitlist` alias metadata, while the native stack keeps that distinct alias pointed at `SettingsAuth`. +- 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. Native incremental search updates the existing list without remounting it for every keystroke, preserving scroll position and transient row state. +- 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 row and bulk actions share collision-safe per-thread reservations and action-executor identity keys, reserve bulk targets before confirmation, expose busy state only after confirmation, disable overlapping swipe/menu controls while reserved, and distinguish rows skipped because the same thread action is already in progress from commands that actually fail. +- Web row and project actions reserve collision-safe per-thread locks before confirmation, expose busy state only for the threads owned by actions that have started after confirmation, disable overlapping controls while mutations run, give explicit feedback for rejected duplicates, and refresh archived snapshots once after bulk attempts instead of between concurrent mutations. +- 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, preserve the completed success/failure/skipped outcome counts, 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. +- The user guide documents Archive as a reversible thread-lifecycle action, covers the web, desktop, and mobile controls and safeguards, and explains that search scopes project bulk actions to visible matches. The documentation index links the guide, and the encyclopedia defines the Archive term separately from permanent deletion. + +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/Stack.tsx` +- `apps/mobile/src/features/settings/SettingsRouteScreen.tsx` +- `apps/mobile/src/features/settings/settingsContract.ts` +- `apps/mobile/src/features/settings/settingsContract.test.ts` +- `apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx` +- `apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx` +- `apps/mobile/src/features/archive/archivedThreadList.ts` +- `docs/user/archive.md` +- `docs/reference/encyclopedia.md` +- `docs/README.md` + +## Development Ports + +- Web: `5734` +- Server/WebSocket: `13774` diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 3aedb4e4e3ee..e2dc5011344f 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -47,6 +47,10 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; +import { + SETTINGS_ARCHIVE_ROUTE_CONTRACT, + SETTINGS_WAITLIST_ALIAS_ROUTE_CONTRACT, +} from "./features/settings/settingsContract"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, @@ -160,11 +164,11 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Add Environment", }, }), - SettingsArchive: createNativeStackScreen({ + [SETTINGS_ARCHIVE_ROUTE_CONTRACT.name]: createNativeStackScreen({ screen: ArchivedThreadsRouteScreen, - linking: "archive", + linking: SETTINGS_ARCHIVE_ROUTE_CONTRACT.linking, options: { - title: "Archived Threads", + title: SETTINGS_ARCHIVE_ROUTE_CONTRACT.title, }, }), SettingsAppearance: createNativeStackScreen({ @@ -188,12 +192,12 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Sign in", }, }), - SettingsWaitlist: createNativeStackScreen({ + [SETTINGS_WAITLIST_ALIAS_ROUTE_CONTRACT.name]: createNativeStackScreen({ // Keep the old deep link working after the Connect GA launch. screen: SettingsAuthRouteScreen, - linking: "waitlist", + linking: SETTINGS_WAITLIST_ALIAS_ROUTE_CONTRACT.linking, options: { - title: "Sign in", + title: SETTINGS_WAITLIST_ALIAS_ROUTE_CONTRACT.title, }, }), }, diff --git a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx index c2381ef25805..675264fb7609 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx @@ -2,8 +2,10 @@ import type { EnvironmentId } from "@t3tools/contracts"; 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 { useCallback, useMemo, useRef, 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,62 @@ import { ArchivedThreadsScreen, type ArchivedThreadsHeaderEnvironment, } from "./ArchivedThreadsScreen"; -import { buildArchivedThreadGroups, type ArchivedThreadSortOrder } from "./archivedThreadList"; import { - refreshArchivedThreadsForEnvironment, - useArchivedThreadSnapshots, -} from "./useArchivedThreadSnapshots"; + archivedThreadActionExceptionDescription, + archivedThreadActionSummaryDescription, + buildArchivedThreadGroups, + parseArchivedThreadSearchInput, + releaseArchivedThreadActionLock, + runArchivedThreadActions, + tryAcquireArchivedThreadActionLock, + type ArchivedThreadSortState, +} from "./archivedThreadList"; +import { useArchivedThreadSnapshots } from "./useArchivedThreadSnapshots"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; + +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 reservedThreadKeysRef = useRef(new Set()); + const [reservedThreadKeys, setReservedThreadKeys] = useState>( + () => new Set(), + ); + const [busyThreadKeys, setBusyThreadKeys] = useState>(() => new Set()); const environments = useMemo>( () => Arr.sort( @@ -40,33 +86,155 @@ 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 }) => { - refreshArchivedThreadsForEnvironment(thread.environmentId); + const { unarchiveThread, deleteThread } = useArchivedThreadListActions(); + const tryReserveThreadActions = useCallback( + ( + threads: ReadonlyArray, + ): { readonly start: () => void; readonly finish: () => void } | null => { + const lock = tryAcquireArchivedThreadActionLock(reservedThreadKeysRef.current, threads); + if (!lock) { + Alert.alert( + "Archive action already in progress", + "Wait for the current archived thread action to finish.", + ); + return null; + } + setReservedThreadKeys(new Set(reservedThreadKeysRef.current)); + let started = false; + return { + start: () => { + if (started) return; + started = true; + setBusyThreadKeys((current) => { + const next = new Set(current); + for (const key of lock.keys) next.add(key); + return next; + }); + }, + finish: () => { + releaseArchivedThreadActionLock(reservedThreadKeysRef.current, lock); + setReservedThreadKeys(new Set(reservedThreadKeysRef.current)); + if (!started) return; + setBusyThreadKeys((current) => { + const next = new Set(current); + for (const key of lock.keys) next.delete(key); + return next; + }); + }, + }; }, [], ); - const { unarchiveThread, confirmDeleteThread } = - useArchivedThreadListActions(refreshChangedEnvironment); + const showSkippedActionFeedback = useCallback(() => { + Alert.alert( + "Archive action already in progress", + "Wait for the current archived thread action to finish.", + ); + }, []); + const handleUnarchiveThread = useCallback( + async (thread: EnvironmentThreadShell) => { + const reservation = tryReserveThreadActions([thread]); + if (!reservation) return; + reservation.start(); + try { + const result = await unarchiveThread(thread); + if (result === "skipped") showSkippedActionFeedback(); + } finally { + reservation.finish(); + } + }, + [showSkippedActionFeedback, tryReserveThreadActions, unarchiveThread], + ); + const handleDeleteThread = useCallback( + async (thread: EnvironmentThreadShell) => { + const reservation = tryReserveThreadActions([thread]); + if (!reservation) return; + try { + const confirmed = await confirmArchivedProjectAction({ + title: "Delete thread?", + message: `“${thread.title}” will be permanently deleted, including its terminal history.`, + confirmText: "Delete", + destructive: true, + }); + if (!confirmed) return; + reservation.start(); + const result = await deleteThread(thread); + if (result === "skipped") showSkippedActionFeedback(); + } finally { + reservation.finish(); + } + }, + [deleteThread, showSkippedActionFeedback, tryReserveThreadActions], + ); + const handleProjectAction = useCallback( + async ( + projectTitle: string, + threads: ReadonlyArray, + scope: "all" | "matching", + action: "unarchive" | "delete", + ) => { + const reservation = tryReserveThreadActions(threads); + if (!reservation) return; + try { + 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; + + reservation.start(); + try { + const summary = await runArchivedThreadActions(threads, (thread) => + action === "unarchive" + ? unarchiveThread(thread, { + reportFailure: false, + refreshArchivedThreads: false, + }) + : deleteThread(thread, { + reportFailure: false, + refreshArchivedThreads: 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 { + refresh(); + } + } finally { + reservation.finish(); + } + }, + [deleteThread, refresh, tryReserveThreadActions, unarchiveThread], + ); useFocusEffect( useCallback(() => { @@ -81,15 +249,20 @@ export function ArchivedThreadsRouteScreen() { error={error} groups={groups} isLoading={isLoading} - onDeleteThread={confirmDeleteThread} + onDeleteThread={(thread) => void handleDeleteThread(thread)} 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} + reservedThreadKeys={reservedThreadKeys} /> ); } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 916802e9faf5..5daed25c76b8 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 type { MenuAction, NativeActionEvent } 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 { + archivedThreadActionKey, + formatArchivedThreadRelativeTime, + archivedThreadTimestampValue, + nextArchivedThreadSortState, + type ArchivedThreadGroup, + type ArchivedThreadSortField, + type ArchivedThreadSortState, +} from "./archivedThreadList"; export interface ArchivedThreadsHeaderEnvironment { readonly environmentId: EnvironmentId; @@ -42,7 +46,11 @@ 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 isReserved: boolean; + readonly isBusy: boolean; } | { readonly kind: "thread"; @@ -57,16 +65,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 +105,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 +151,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 +261,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 +391,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 +425,161 @@ 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 isReserved: 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 ? ( + + + + ) : props.isReserved ? ( + + + + ) : ( + { + if (nativeEvent.event === "unarchive" || nativeEvent.event === "delete") { + props.onProjectAction(nativeEvent.event); + } + }} + > + + + + + )} + + {props.expanded ? ( + + + Conversation + + + + ) : null} ); @@ -383,6 +589,8 @@ function ArchivedThreadRow(props: { readonly environmentLabel: string | null; readonly isFirst: boolean; readonly isLast: boolean; + readonly isBusy: boolean; + readonly isReserved: boolean; readonly onDelete: () => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; @@ -396,10 +604,61 @@ 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 isBlocked = props.isReserved || props.isBusy; + const onDelete = isBlocked ? () => 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} - - - )} + {() => + isBlocked ? ( + rowContent + ) : ( + + {rowContent} + + ) + } ); } @@ -493,15 +723,27 @@ export function ArchivedThreadsScreen(props: { readonly isLoading: boolean; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; - readonly sortOrder: ArchivedThreadSortOrder; + readonly sort: ArchivedThreadSortState; + readonly busyThreadKeys: ReadonlySet; + readonly reservedThreadKeys: 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 +754,32 @@ 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, + isReserved: group.threads.some((thread) => + props.reservedThreadKeys.has(archivedThreadActionKey(thread)), + ), + isBusy: group.threads.some((thread) => + props.busyThreadKeys.has(archivedThreadActionKey(thread)), + ), }); + if (!expanded) continue; group.threads.forEach((thread, index) => { items.push({ kind: "thread", - key: `${thread.environmentId}:${thread.id}`, + key: archivedThreadActionKey(thread), environmentLabel, isFirst: index === 0, isLast: index === group.threads.length - 1, @@ -535,7 +788,22 @@ export function ArchivedThreadsScreen(props: { }); } return items; - }, [environmentLabelsById, props.groups]); + }, [ + environmentLabelsById, + expandedProjectKeys, + isSearching, + props.busyThreadKeys, + props.groups, + props.reservedThreadKeys, + ]); + 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 +821,25 @@ 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 +848,8 @@ export function ArchivedThreadsScreen(props: { environmentLabel={item.environmentLabel} isFirst={item.isFirst} isLast={item.isLast} + isBusy={props.busyThreadKeys.has(archivedThreadActionKey(item.thread))} + isReserved={props.reservedThreadKeys.has(archivedThreadActionKey(item.thread))} onDelete={() => onDeleteThread(item.thread)} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -579,6 +865,12 @@ export function ArchivedThreadsScreen(props: { handleSwipeableWillOpen, onDeleteThread, onUnarchiveThread, + props.busyThreadKeys, + props.reservedThreadKeys, + props.onProjectAction, + props.onSortChange, + props.sort, + toggleProject, ], ); const listEmptyComponent = useMemo(() => { @@ -611,14 +903,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 +931,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, @@ -62,8 +77,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"), @@ -77,51 +106,207 @@ 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 mixedProject = makeProject({ id: ProjectId.make("project-mixed"), title: "Mixed" }); + const middleProject = makeProject({ id: ProjectId.make("project-middle"), title: "Middle" }); + const oldestCreated = makeThread({ + archivedAt: "2026-06-02T00:00:00.000Z", + createdAt: "2026-05-01T00:00:00.000Z", + id: ThreadId.make("thread-older-created"), + projectId: mixedProject.id, + title: "Oldest created", + }); + const newestCreated = makeThread({ + archivedAt: "2026-06-04T00:00:00.000Z", + createdAt: "2026-06-03T00:00:00.000Z", + id: ThreadId.make("thread-newer-created"), + projectId: mixedProject.id, + title: "Newest created", + }); + const middleCreated = makeThread({ + archivedAt: "2026-06-03T00:00:00.000Z", + createdAt: "2026-06-01T00:00:00.000Z", + id: ThreadId.make("thread-middle-created"), + projectId: middleProject.id, + title: "Middle created", + }); + + const snapshots = [ + makeSnapshot([middleProject, mixedProject], [middleCreated, newestCreated, oldestCreated]), + ]; + const ascending = buildGroups({ + snapshots, + sort: { field: "createdAt", direction: "asc" }, + }); + const descending = buildGroups({ + snapshots, + sort: { field: "createdAt", direction: "desc" }, + }); + + expect(ascending.map((group) => group.project.id)).toEqual(["project-mixed", "project-middle"]); + expect(descending.map((group) => group.project.id)).toEqual([ + "project-mixed", + "project-middle", + ]); + }); + + 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", + title: "Build archive settings route remotely", }); - const snapshots = [ - makeSnapshot([firstProject], [firstThread]), - makeSnapshot([secondProject], [secondThread], secondEnvironmentId), - ]; - const result = buildArchivedThreadGroups({ - snapshots, - environmentLabels: { - [environmentId]: "Local", - [secondEnvironmentId]: "Remote", - }, + 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", () => { @@ -133,14 +318,161 @@ 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", + ); + }); + + it("preserves completed bulk action counts when an action throws", async () => { + let caughtError: unknown; + + try { + await runArchivedThreadActions( + [1, 2, 3, 4, 5, 6], + async (value) => { + await Promise.resolve(); + if (value === 1) throw new Error("Connection failed"); + if (value === 3) return "failed"; + if (value === 4) return "skipped"; + return "succeeded"; + }, + { concurrency: 4 }, + ); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(ArchivedThreadActionError); + expect((caughtError as ArchivedThreadActionError).summary).toEqual({ + succeeded: 1, + failed: 1, + skipped: 1, + }); + expect(archivedThreadActionExceptionDescription(caughtError)).toBe( + "Partial outcome: 1 succeeded, 1 failed, 1 skipped because already in progress, 1 failed unexpectedly, 2 not attempted. Connection failed", + ); + }); +}); + +describe("archived thread action locks", () => { + const firstThread = { + environmentId, + id: ThreadId.make("thread-1"), + }; + const secondThread = { + environmentId, + id: ThreadId.make("thread-2"), + }; + + it("blocks overlapping row and bulk actions until the original lock is released", () => { + const reservedThreadKeys = new Set(); + const bulkLock = tryAcquireArchivedThreadActionLock(reservedThreadKeys, [ + firstThread, + secondThread, + ]); + + expect(bulkLock).not.toBeNull(); + expect(tryAcquireArchivedThreadActionLock(reservedThreadKeys, [firstThread])).toBeNull(); + + releaseArchivedThreadActionLock(reservedThreadKeys, bulkLock!); + + expect(tryAcquireArchivedThreadActionLock(reservedThreadKeys, [firstThread])).not.toBeNull(); + }); + + it("uses collision-safe environment and thread identity", () => { + const firstKey = archivedThreadActionKey({ + environmentId: EnvironmentId.make("environment:a"), + id: ThreadId.make("thread"), + }); + const secondKey = archivedThreadActionKey({ + environmentId: EnvironmentId.make("environment"), + id: ThreadId.make("a:thread"), }); - expect(result).toEqual([]); + expect(firstKey).not.toBe(secondKey); + }); +}); + +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 6146bba20447..ff7e0db4f9b3 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.ts @@ -6,101 +6,375 @@ 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; +} + +export class ArchivedThreadActionError extends AggregateError { + readonly summary: ArchivedThreadActionSummary; + readonly totalCount: number; + + constructor(errors: Iterable, summary: ArchivedThreadActionSummary, totalCount: number) { + super(errors, "Archived thread action failed"); + this.name = "ArchivedThreadActionError"; + this.summary = summary; + this.totalCount = totalCount; + } +} + +export type ArchivedThreadActionResult = "succeeded" | "failed" | "skipped"; + +export interface ArchivedThreadActionLock { + readonly keys: ReadonlyArray; +} + +export function archivedThreadActionKey( + thread: Pick, +): string { + return JSON.stringify([thread.environmentId, thread.id]); +} + +export function tryAcquireArchivedThreadActionLock( + reservedThreadKeys: Set, + threads: ReadonlyArray>, +): ArchivedThreadActionLock | null { + const keys = [...new Set(threads.map(archivedThreadActionKey))]; + if (keys.some((key) => reservedThreadKeys.has(key))) { + return null; + } + for (const key of keys) { + reservedThreadKeys.add(key); + } + return { keys }; +} + +export function releaseArchivedThreadActionLock( + reservedThreadKeys: Set, + lock: ArchivedThreadActionLock, +): void { + for (const key of lock.keys) { + reservedThreadKeys.delete(key); + } +} + +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); + const outcome = + error instanceof ArchivedThreadActionError + ? (() => { + const notAttemptedCount = Math.max( + 0, + error.totalCount - + error.summary.succeeded - + error.summary.failed - + error.summary.skipped - + error.errors.length, + ); + const parts = [`${error.summary.succeeded} succeeded`]; + if (error.summary.failed > 0) parts.push(`${error.summary.failed} failed`); + if (error.summary.skipped > 0) { + parts.push(`${error.summary.skipped} skipped because already in progress`); + } + parts.push( + `${error.errors.length} failed unexpectedly`, + `${notAttemptedCount} not attempted`, + ); + return `Partial outcome: ${parts.join(", ")}.`; + })() + : "One or more archived thread actions failed unexpectedly."; + return [ + outcome, + 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]); } -function archiveTimestamp(thread: EnvironmentThreadShell): number { - const timestamp = Date.parse(thread.archivedAt ?? thread.updatedAt); +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 }), - (group: ArchivedThreadGroup) => ({ - timestamp: group.threads[0] ? archiveTimestamp(group.threads[0]) : 0, - title: group.project.title, - key: group.key, + Order.Struct({ + timestamp: input.sort.direction === "asc" ? Order.Number : Order.flip(Order.Number), + title: Order.String, + key: Order.String, }), + (group: ArchivedThreadGroup) => { + let timestamp = archivedThreadTimestamp(group.threads[0]!, input.sort.field); + for (let index = 1; index < group.threads.length; index += 1) { + const candidate = archivedThreadTimestamp(group.threads[index]!, input.sort.field); + timestamp = + input.sort.direction === "asc" + ? Math.min(timestamp, candidate) + : Math.max(timestamp, candidate); + } + return { + timestamp, + 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 ArchivedThreadActionError(thrownErrors, { succeeded, failed, skipped }, items.length); + } + return { succeeded, failed, skipped }; +} diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts new file mode 100644 index 000000000000..f86b59af4493 --- /dev/null +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -0,0 +1,216 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import * as Cause from "effect/Cause"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => { + const commands = { + archive: {}, + delete: {}, + settle: {}, + unarchive: {}, + unsettle: {}, + }; + + return { + alert: vi.fn(), + archiveMutation: vi.fn(), + canSettle: vi.fn(), + commands, + deleteMutation: vi.fn(), + impactAsync: vi.fn(), + refreshArchivedThreadsForEnvironment: vi.fn(), + serverConfigs: new Map(), + settleMutation: vi.fn(), + unarchiveMutation: vi.fn(), + unsettleMutation: vi.fn(), + }; +}); + +vi.mock("react", () => ({ + useCallback: (callback: A) => callback, + useRef: (initialValue: A) => ({ current: initialValue }), +})); + +vi.mock("react-native", () => ({ + Alert: { alert: mocks.alert }, +})); + +vi.mock("expo-haptics", () => ({ + ImpactFeedbackStyle: { Light: "light" }, + impactAsync: mocks.impactAsync, +})); + +vi.mock("@t3tools/client-runtime/state/thread-settled", () => ({ + canSettle: mocks.canSettle, +})); + +vi.mock("../../components/ConfirmDialogHost", () => ({ + showConfirmDialog: vi.fn(), +})); + +vi.mock("../archive/useArchivedThreadSnapshots", () => ({ + refreshArchivedThreadsForEnvironment: mocks.refreshArchivedThreadsForEnvironment, +})); + +vi.mock("../../state/atom-registry", () => ({ + appAtomRegistry: { + get: () => mocks.serverConfigs, + }, +})); + +vi.mock("../../state/server", () => ({ + environmentServerConfigsAtom: {}, +})); + +vi.mock("../../state/threads", () => ({ + threadEnvironment: mocks.commands, +})); + +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (command: object) => { + if (command === mocks.commands.archive) return mocks.archiveMutation; + if (command === mocks.commands.unarchive) return mocks.unarchiveMutation; + if (command === mocks.commands.delete) return mocks.deleteMutation; + if (command === mocks.commands.settle) return mocks.settleMutation; + if (command === mocks.commands.unsettle) return mocks.unsettleMutation; + throw new Error("Unexpected thread command"); + }, +})); + +import { useArchivedThreadListActions, useThreadListActions } from "./useThreadListActions"; + +const success = { _tag: "Success", value: undefined } as const; + +function failure(message: string) { + return { _tag: "Failure", cause: Cause.fail(new Error(message)) } as const; +} + +function makeThread(id = "thread-1", environmentId = "environment-1"): EnvironmentThreadShell { + return { + environmentId, + id, + title: "Archive settings", + session: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + } as unknown as EnvironmentThreadShell; +} + +describe("useThreadListActions merged archive and settlement contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.archiveMutation.mockResolvedValue(success); + mocks.unarchiveMutation.mockResolvedValue(success); + mocks.deleteMutation.mockResolvedValue(success); + mocks.settleMutation.mockResolvedValue(success); + mocks.unsettleMutation.mockResolvedValue(success); + mocks.canSettle.mockReturnValue(true); + mocks.impactAsync.mockResolvedValue(undefined); + mocks.serverConfigs.clear(); + mocks.serverConfigs.set("environment-1", { + environment: { capabilities: { threadSettlement: true } }, + }); + }); + + it("returns tri-state archive results and suppresses per-row bulk feedback", async () => { + const thread = makeThread(); + const actions = useArchivedThreadListActions(); + + await expect(actions.unarchiveThread(thread)).resolves.toBe("succeeded"); + expect(mocks.refreshArchivedThreadsForEnvironment).toHaveBeenCalledWith("environment-1"); + + mocks.deleteMutation.mockResolvedValueOnce(failure("delete denied")); + await expect(actions.deleteThread(thread, { reportFailure: false })).resolves.toBe("failed"); + expect(mocks.alert).not.toHaveBeenCalled(); + + mocks.refreshArchivedThreadsForEnvironment.mockClear(); + await expect( + actions.unarchiveThread(thread, { + reportFailure: false, + refreshArchivedThreads: false, + }), + ).resolves.toBe("succeeded"); + expect(mocks.refreshArchivedThreadsForEnvironment).not.toHaveBeenCalled(); + }); + + it("reports a duplicate archived-thread action as skipped while the first action settles", async () => { + const thread = makeThread(); + let completeFirst!: (result: typeof success) => void; + mocks.unarchiveMutation.mockReturnValueOnce( + new Promise((resolve) => { + completeFirst = resolve; + }), + ); + const actions = useArchivedThreadListActions(); + + const first = actions.unarchiveThread(thread, { reportFailure: false }); + await expect(actions.unarchiveThread(thread, { reportFailure: false })).resolves.toBe( + "skipped", + ); + expect(mocks.unarchiveMutation).toHaveBeenCalledOnce(); + expect(mocks.impactAsync).toHaveBeenCalledOnce(); + + completeFirst(success); + await expect(first).resolves.toBe("succeeded"); + }); + + it("does not deduplicate distinct scoped threads whose ids contain separators", async () => { + const firstThread = makeThread("thread", "environment:one"); + const secondThread = makeThread("one:thread", "environment"); + let completeFirst!: (result: typeof success) => void; + mocks.unarchiveMutation.mockReturnValueOnce( + new Promise((resolve) => { + completeFirst = resolve; + }), + ); + const actions = useArchivedThreadListActions(); + + const first = actions.unarchiveThread(firstThread, { reportFailure: false }); + await expect(actions.unarchiveThread(secondThread, { reportFailure: false })).resolves.toBe( + "succeeded", + ); + expect(mocks.unarchiveMutation).toHaveBeenCalledTimes(2); + + completeFirst(success); + await expect(first).resolves.toBe("succeeded"); + }); + + it("keeps the void archive adapter deduplicated and refreshes after success", async () => { + const thread = makeThread(); + let completeArchive!: (result: typeof success) => void; + mocks.archiveMutation.mockReturnValueOnce( + new Promise((resolve) => { + completeArchive = resolve; + }), + ); + const actions = useThreadListActions(); + + actions.archiveThread(thread); + actions.archiveThread(thread); + expect(mocks.archiveMutation).toHaveBeenCalledOnce(); + + completeArchive(success); + await vi.waitFor(() => { + expect(mocks.refreshArchivedThreadsForEnvironment).toHaveBeenCalledWith("environment-1"); + }); + }); + + it("adapts settlement success and failure to booleans for Thread List v2", async () => { + const thread = makeThread(); + mocks.unsettleMutation.mockResolvedValueOnce(failure("unsettle denied")); + const actions = useThreadListActions(); + + await expect(actions.settleThread(thread)).resolves.toBe(true); + await expect(actions.unsettleThread(thread)).resolves.toBe(false); + + expect(mocks.settleMutation).toHaveBeenCalledWith({ + environmentId: "environment-1", + input: { threadId: "thread-1" }, + }); + expect(mocks.unsettleMutation).toHaveBeenCalledWith({ + environmentId: "environment-1", + input: { threadId: "thread-1", reason: "user" }, + }); + expect(mocks.alert).toHaveBeenCalledWith("Could not un-settle thread", "unsettle denied"); + }); +}); diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index e200eb7acde5..ef5eb216a2cc 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -6,7 +6,6 @@ import { useCallback, useRef } from "react"; import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; -import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -23,6 +22,12 @@ function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["en } type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; +export type ThreadListActionResult = "succeeded" | "failed" | "skipped"; + +interface ThreadActionOptions { + readonly reportFailure?: boolean; + readonly refreshArchivedThreads?: boolean; +} const ACTION_VERBS: Record = { archive: "archived", @@ -52,10 +57,12 @@ function actionFailureTitle(action: ThreadListAction): string { return "Could not delete thread"; } -/** Resolves to true iff the action was dispatched and succeeded. */ -function useThreadActionExecutor( - onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, -) { +function threadActionKey(thread: Pick): string { + return JSON.stringify([thread.environmentId, thread.id]); +} + +/** Distinguishes successful, failed, and already-in-flight actions for bulk-action summaries. */ +function useThreadActionExecutor() { const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); @@ -64,10 +71,14 @@ function useThreadActionExecutor( const inFlightThreadKeys = useRef(new Set()); const executeAction = useCallback( - async (action: ThreadListAction, thread: EnvironmentThreadShell) => { - const key = scopedThreadKey(thread.environmentId, thread.id); + async ( + action: ThreadListAction, + thread: EnvironmentThreadShell, + options: ThreadActionOptions = {}, + ): Promise => { + const key = threadActionKey(thread); if (inFlightThreadKeys.current.has(key)) { - return false; + return "skipped"; } inFlightThreadKeys.current.add(key); @@ -77,21 +88,25 @@ function useThreadActionExecutor( (action === "settle" || action === "unsettle") && !environmentSupportsSettlement(thread.environmentId) ) { - Alert.alert( - actionFailureTitle(action), - "This environment's server does not support settling yet. Update the server to use Settle.", - ); - return false; + if (options.reportFailure !== false) { + Alert.alert( + actionFailureTitle(action), + "This environment's server does not support settling yet. Update the server to use Settle.", + ); + } + return "failed"; } // Settle may only target what effectiveSettled could classify as // settled: not starting/running sessions, not threads waiting on // approvals or user input. Anything else would hide live work. if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { - Alert.alert( - actionFailureTitle(action), - "This thread still needs attention. Resolve or interrupt it first, then try again.", - ); - return false; + if (options.reportFailure !== false) { + Alert.alert( + actionFailureTitle(action), + "This thread still needs attention. Resolve or interrupt it first, then try again.", + ); + } + return "failed"; } // Archive keeps its original, narrower guard: never interrupt a // thread mid-turn. @@ -100,11 +115,13 @@ function useThreadActionExecutor( thread.session?.status === "running" && thread.session.activeTurnId != null ) { - Alert.alert( - actionFailureTitle(action), - "This thread is working. Interrupt it first, then try again.", - ); - return false; + if (options.reportFailure !== false) { + Alert.alert( + actionFailureTitle(action), + "This thread is working. Interrupt it first, then try again.", + ); + } + return "failed"; } const result = action === "unsettle" @@ -127,35 +144,36 @@ function useThreadActionExecutor( input: { threadId: thread.id }, }); if (result._tag === "Failure") { - Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); - return false; + if (options.reportFailure !== false) { + Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); + } + return "failed"; } // Settled threads stay in the live shell stream; only the archive // lifecycle still feeds the archived-snapshot surface. - if (action === "archive" || action === "unarchive" || action === "delete") { + if ( + options.refreshArchivedThreads !== false && + (action === "archive" || action === "unarchive" || action === "delete") + ) { refreshArchivedThreadsForEnvironment(thread.environmentId); } - onCompleted?.(action, thread); - return true; + return "succeeded"; } finally { inFlightThreadKeys.current.delete(key); } }, - [ - archiveMutation, - deleteMutation, - onCompleted, - settleMutation, - unarchiveMutation, - unsettleMutation, - ], + [archiveMutation, deleteMutation, settleMutation, unarchiveMutation, unsettleMutation], ); return executeAction; } function useConfirmDeleteThread( - executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, + executeAction: ( + action: ThreadListAction, + thread: EnvironmentThreadShell, + options?: ThreadActionOptions, + ) => Promise, ) { return useCallback( (thread: EnvironmentThreadShell) => { @@ -203,11 +221,13 @@ export function useThreadListActions(): { [executeAction], ); const settleThread = useCallback( - async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, + async (thread: EnvironmentThreadShell) => + (await executeAction("settle", thread)) === "succeeded", [executeAction], ); const unsettleThread = useCallback( - async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, + async (thread: EnvironmentThreadShell) => + (await executeAction("unsettle", thread)) === "succeeded", [executeAction], ); @@ -216,26 +236,29 @@ export function useThreadListActions(): { return { archiveThread, confirmDeleteThread, settleThread, unsettleThread }; } -export function useArchivedThreadListActions( - onCompleted: (thread: EnvironmentThreadShell) => void, -): { - readonly unarchiveThread: (thread: EnvironmentThreadShell) => void; +export function useArchivedThreadListActions(): { + readonly unarchiveThread: ( + thread: EnvironmentThreadShell, + options?: ThreadActionOptions, + ) => Promise; + readonly deleteThread: ( + thread: EnvironmentThreadShell, + options?: ThreadActionOptions, + ) => Promise; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; } { - const handleCompleted = useCallback( - (_action: ThreadListAction, thread: EnvironmentThreadShell) => { - onCompleted(thread); - }, - [onCompleted], - ); - const executeAction = useThreadActionExecutor(handleCompleted); + const executeAction = useThreadActionExecutor(); 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/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index f354bcd29acd..ba821ee4b581 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -8,7 +8,15 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { + type ComponentType, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { ActivityIndicator, Alert, @@ -51,6 +59,11 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { + SHARED_SETTINGS_TAIL_SECTION_IDS_BY_MODE, + type SettingsMode, + type SharedSettingsTailSectionId, +} from "./settingsContract"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -129,17 +142,7 @@ function LocalSettingsRouteScreen() { /> - - - - - - - - - - - + ); @@ -517,22 +520,31 @@ function ConfiguredSettingsRouteScreen() { /> - - - - - - - - - - - + ); } +const SHARED_SETTINGS_TAIL_COMPONENTS = { + general: GeneralSettingsSection, + appearance: AppearanceSettingsSection, + beta: BetaSettingsSection, + archive: ArchivedThreadsSettingsSection, + app: AppSettingsSection, +} satisfies Record; + +function SharedSettingsTail({ mode }: { mode: SettingsMode }) { + return ( + <> + {SHARED_SETTINGS_TAIL_SECTION_IDS_BY_MODE[mode].map((sectionId) => { + const Section = SHARED_SETTINGS_TAIL_COMPONENTS[sectionId]; + return
; + })} + + ); +} + function GeneralSettingsSection() { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -552,6 +564,14 @@ function GeneralSettingsSection() { ); } +function AppearanceSettingsSection() { + return ( + + + + ); +} + /** * Device-local beta toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → Beta backed by mobile preferences. diff --git a/apps/mobile/src/features/settings/settingsContract.test.ts b/apps/mobile/src/features/settings/settingsContract.test.ts new file mode 100644 index 000000000000..2b0e4c83fd91 --- /dev/null +++ b/apps/mobile/src/features/settings/settingsContract.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + SETTINGS_ARCHIVE_ROUTE_CONTRACT, + SETTINGS_WAITLIST_ALIAS_ROUTE_CONTRACT, + SHARED_SETTINGS_TAIL_SECTION_IDS_BY_MODE, +} from "./settingsContract"; + +describe("native settings contract", () => { + it.each(["local", "configured"] as const)("exposes archived threads in %s settings", (mode) => { + expect(SHARED_SETTINGS_TAIL_SECTION_IDS_BY_MODE[mode]).toEqual([ + "general", + "appearance", + "beta", + "archive", + "app", + ]); + }); + + it("keeps the archive route registered", () => { + expect(SETTINGS_ARCHIVE_ROUTE_CONTRACT).toEqual({ + name: "SettingsArchive", + linking: "archive", + title: "Archived Threads", + }); + }); + + it("keeps the legacy waitlist alias alongside the archive route", () => { + expect(SETTINGS_WAITLIST_ALIAS_ROUTE_CONTRACT).toEqual({ + name: "SettingsWaitlist", + linking: "waitlist", + title: "Sign in", + }); + expect(SETTINGS_WAITLIST_ALIAS_ROUTE_CONTRACT.name).not.toBe( + SETTINGS_ARCHIVE_ROUTE_CONTRACT.name, + ); + }); +}); diff --git a/apps/mobile/src/features/settings/settingsContract.ts b/apps/mobile/src/features/settings/settingsContract.ts new file mode 100644 index 000000000000..284ce6a12351 --- /dev/null +++ b/apps/mobile/src/features/settings/settingsContract.ts @@ -0,0 +1,28 @@ +export const SHARED_SETTINGS_TAIL_SECTION_IDS = [ + "general", + "appearance", + "beta", + "archive", + "app", +] as const; + +export type SharedSettingsTailSectionId = (typeof SHARED_SETTINGS_TAIL_SECTION_IDS)[number]; + +export type SettingsMode = "local" | "configured"; + +export const SHARED_SETTINGS_TAIL_SECTION_IDS_BY_MODE = { + local: SHARED_SETTINGS_TAIL_SECTION_IDS, + configured: SHARED_SETTINGS_TAIL_SECTION_IDS, +} as const; + +export const SETTINGS_ARCHIVE_ROUTE_CONTRACT = { + name: "SettingsArchive", + linking: "archive", + title: "Archived Threads", +} as const; + +export const SETTINGS_WAITLIST_ALIAS_ROUTE_CONTRACT = { + name: "SettingsWaitlist", + linking: "waitlist", + title: "Sign in", +} as const; diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index fea36b5717fe..6b477d23b3b1 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -154,6 +154,66 @@ it.layer(NodeServices.layer)("decider deletion flows", (it) => { }), ); + it.effect("rejects deleting a project whose only threads are archived without force", () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + const archivedAt = "2026-01-02T00:00:00.000Z"; + const withFirstThreadArchived = yield* projectEvent(readModel, { + sequence: 4, + eventId: asEventId("evt-thread-archive-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-1"), + type: "thread.archived", + occurredAt: archivedAt, + commandId: asCommandId("cmd-thread-archive-1"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-archive-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-1"), + archivedAt, + updatedAt: archivedAt, + }, + }); + const withOnlyArchivedThreads = yield* projectEvent(withFirstThreadArchived, { + sequence: 5, + eventId: asEventId("evt-thread-archive-2"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-delete-2"), + type: "thread.archived", + occurredAt: archivedAt, + commandId: asCommandId("cmd-thread-archive-2"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-archive-2"), + metadata: {}, + payload: { + threadId: asThreadId("thread-delete-2"), + archivedAt, + updatedAt: archivedAt, + }, + }); + + expect(withOnlyArchivedThreads.threads).toHaveLength(2); + expect(withOnlyArchivedThreads.threads.every((thread) => thread.archivedAt !== null)).toBe( + true, + ); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.delete", + commandId: asCommandId("cmd-project-delete-archived-no-force"), + projectId: asProjectId("project-delete"), + }, + readModel: withOnlyArchivedThreads, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("cannot be deleted without force=true"); + }), + ); + it.effect("reuses thread.delete semantics when force-deleting a non-empty project", () => Effect.gen(function* () { const readModel = yield* seedReadModel; diff --git a/apps/web/src/components/settings/ArchiveSettings.tsx b/apps/web/src/components/settings/ArchiveSettings.tsx new file mode 100644 index 000000000000..39409dda821c --- /dev/null +++ b/apps/web/src/components/settings/ArchiveSettings.tsx @@ -0,0 +1,769 @@ +import { + ArchiveIcon, + ArchiveX, + ArrowDownIcon, + ArrowUpIcon, + ChevronDownIcon, + ChevronRightIcon, + EllipsisIcon, + LoaderIcon, + Trash2Icon, +} from "lucide-react"; +import { type ReactNode, useCallback, useMemo, useRef, 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 { + archivedProjectBulkActionExceptionDescription, + archivedProjectBulkScopeLabel, + archivedProjectBulkFailureDescription, + archivedThreadTimestampValue, + archivedThreadActionKey, + type ArchivedProjectBulkFailure, + type ArchivedProjectBulkScope, + type ArchivedProjectBulkThread, + type ArchivedThreadSortField, + type ArchivedThreadSortState, + buildArchivedThreadGroups, + hasArchivedThreads as archiveHasThreads, + nextArchivedThreadSortState, + parseArchivedThreadSearchInput, + releaseArchivedThreadActionLock, + resolveArchivedProjectEnvironmentLabel, + runArchivedProjectThreadActions, + tryAcquireArchivedThreadActionLock, +} 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, + disabled = false, + onClick, + children, +}: { + readonly label: string; + readonly destructive?: boolean; + readonly disabled?: 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 inFlightArchivedThreadKeysRef = useRef(new Set()); + const [inFlightArchivedThreadKeys, setInFlightArchivedThreadKeys] = useState>( + () => new Set(), + ); + 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 tryReserveArchivedThreadActions = useCallback( + ( + threadRefs: ReadonlyArray, + ): { readonly start: () => void; readonly finish: () => void } | null => { + const lock = tryAcquireArchivedThreadActionLock( + inFlightArchivedThreadKeysRef.current, + threadRefs, + ); + if (!lock) { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Archive action already in progress", + description: "Wait for the current archived thread action to finish.", + }), + ); + return null; + } + let started = false; + return { + start: () => { + if (started) return; + started = true; + setInFlightArchivedThreadKeys((current) => { + const next = new Set(current); + for (const key of lock.keys) next.add(key); + return next; + }); + }, + finish: () => { + releaseArchivedThreadActionLock(inFlightArchivedThreadKeysRef.current, lock); + if (!started) return; + setInFlightArchivedThreadKeys((current) => { + const next = new Set(current); + for (const key of lock.keys) next.delete(key); + return next; + }); + }, + }; + }, + [], + ); + + 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) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: archivedProjectBulkActionExceptionDescription(error), + }), + ); + }, []); + + const handleUnarchiveThread = useCallback( + async (threadRef: ScopedThreadRef) => { + const reservation = tryReserveArchivedThreadActions([threadRef]); + if (!reservation) return; + reservation.start(); + try { + const result = await unarchiveThread(threadRef); + showArchivedActionFailure("Failed to unarchive thread", result); + } finally { + reservation.finish(); + } + }, + [showArchivedActionFailure, tryReserveArchivedThreadActions, unarchiveThread], + ); + + const handleDeleteArchivedThread = useCallback( + async (threadRef: ScopedThreadRef, title: string) => { + const reservation = tryReserveArchivedThreadActions([threadRef]); + if (!reservation) return; + try { + if (confirmThreadDelete) { + const confirmed = await confirmArchivedAction( + [ + `Delete archived conversation "${title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) return; + } + reservation.start(); + const result = await deleteThread(threadRef); + showArchivedActionFailure("Failed to delete thread", result); + } finally { + reservation.finish(); + } + }, + [ + confirmArchivedAction, + confirmThreadDelete, + deleteThread, + showArchivedActionFailure, + tryReserveArchivedThreadActions, + ], + ); + + const handleUnarchiveProjectThreads = useCallback( + async ( + projectName: string, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, + ) => { + const threadRefs = threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)); + const reservation = tryReserveArchivedThreadActions(threadRefs); + if (!reservation) return; + try { + 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; + reservation.start(); + try { + const failures = await runArchivedProjectThreadActions(threads, (thread) => + unarchiveThread(scopeThreadRef(thread.environmentId, thread.id), { + refreshArchivedThreads: false, + }), + ); + if (failures.length > 0) { + showArchivedBulkActionFailure( + "Archived threads not fully unarchived", + failures, + threads.length, + ); + } + } catch (error) { + showArchivedBulkActionException("Archived threads not fully unarchived", error); + } finally { + refreshArchivedThreads(); + } + } finally { + reservation.finish(); + } + }, + [ + confirmArchivedAction, + refreshArchivedThreads, + showArchivedBulkActionException, + showArchivedBulkActionFailure, + tryReserveArchivedThreadActions, + unarchiveThread, + ], + ); + + const handleDeleteProjectThreads = useCallback( + async ( + projectName: string, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, + ) => { + const threadRefs = threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)); + const reservation = tryReserveArchivedThreadActions(threadRefs); + if (!reservation) return; + try { + 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; + } + reservation.start(); + try { + const failures = await runArchivedProjectThreadActions(threads, (thread) => + deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + refreshArchivedThreads: false, + }), + ); + if (failures.length > 0) { + showArchivedBulkActionFailure( + "Archived threads not fully deleted", + failures, + threads.length, + ); + } + } catch (error) { + showArchivedBulkActionException("Archived threads not fully deleted", error); + } finally { + refreshArchivedThreads(); + } + } finally { + reservation.finish(); + } + }, + [ + confirmArchivedAction, + confirmThreadDelete, + deleteThread, + refreshArchivedThreads, + showArchivedBulkActionException, + showArchivedBulkActionFailure, + tryReserveArchivedThreadActions, + ], + ); + + 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 projectHasInFlightAction = projectThreads.some((thread) => + inFlightArchivedThreadKeys.has( + archivedThreadActionKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ); + const environmentLabel = resolveArchivedProjectEnvironmentLabel({ + environment: archiveEnvironmentsById.get(project.environmentId) ?? null, + hasMultipleEnvironments: environments.length > 1, + }); + return ( +
+
{ + event.preventDefault(); + if (projectHasInFlightAction) return; + 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) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadHasInFlightAction = inFlightArchivedThreadKeys.has( + archivedThreadActionKey(threadRef), + ); + return ( +
{ + event.preventDefault(); + if (threadHasInFlightAction) return; + void (async () => { + const result = await settlePromise(() => + handleArchivedThreadContextMenu(threadRef, 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 d0bdb58db2e3..ee5c385756e7 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -1,23 +1,619 @@ import { DEFAULT_SERVER_SETTINGS, DEFAULT_UNIFIED_SETTINGS, + EnvironmentId, + ProjectId, ProviderDriverKind, ProviderInstanceId, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationThreadShell, type ProviderInstanceConfig, } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { getBackgroundActivityPresetSettings } from "@t3tools/shared/backgroundActivitySettings"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; +import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; +import { AsyncResult } from "effect/unstable/reactivity"; import { describe, expect, it } from "vite-plus/test"; import { + archivedProjectBulkActionExceptionDescription, + ArchivedProjectBulkActionError, + archivedProjectBulkFailureDescription, + archivedThreadActionKey, + archivedThreadSearchScore, + archivedThreadTimestampValue, backgroundActivitySharedPolicySettings, + buildArchivedThreadGroups, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, hasChangedBackgroundActivitySettings, + hasArchivedThreads, isProjectGroupingEnabled, + nextArchivedThreadSortState, + parseArchivedThreadSearchInput, projectGroupingModeFromToggle, + resolveArchivedProjectEnvironmentLabel, resolveBackgroundActivityProfileOption, + releaseArchivedThreadActionLock, + runArchivedProjectThreadActions, + tryAcquireArchivedThreadActionLock, } 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, + settledOverride: null, + settledAt: 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 and snoozed-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 snoozedActiveThread = makeThread({ + archivedAt: null, + id: ThreadId.make("thread-snoozed-active"), + projectId: project.id, + snoozedAt: "2026-06-03T00:00:00.000Z", + snoozedUntil: "2026-06-05T00:00:00.000Z", + title: "Snoozed active thread", + }); + const search = parseArchivedThreadSearchInput(""); + + const result = buildArchivedThreadGroups({ + snapshots: [makeSnapshot([project], [activeThread, snoozedActiveThread])], + 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 and snoozed-active threads when determining archive 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 snoozedActiveThread = makeThread({ + archivedAt: null, + id: ThreadId.make("thread-snoozed-active"), + projectId: project.id, + snoozedAt: "2026-06-03T00:00:00.000Z", + snoozedUntil: "2026-06-05T00:00:00.000Z", + title: "Snoozed active thread", + }); + const archivedThread = makeThread({ + id: ThreadId.make("thread-archived"), + projectId: project.id, + title: "Archived thread", + }); + + expect(hasArchivedThreads([makeSnapshot([project], [activeThread, snoozedActiveThread])])).toBe( + false, + ); + expect( + hasArchivedThreads([ + makeSnapshot([project], [activeThread, snoozedActiveThread, 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"); + } + if (thread.id === "thread-2") { + return failureResult(new Error("command failed")); + } + return successResult(); + } finally { + activeCount -= 1; + } + }); + } catch (error) { + caughtError = error; + } + + expect(activeCount).toBe(0); + expect(caughtError).toBeInstanceOf(ArchivedProjectBulkActionError); + expect((caughtError as ArchivedProjectBulkActionError).errors).toHaveLength(2); + expect((caughtError as ArchivedProjectBulkActionError).summary).toEqual({ + succeeded: 1, + failures: [failureResult(new Error("command failed"))], + }); + expect(archivedProjectBulkActionExceptionDescription(caughtError)).toBe( + "Partial outcome: 1 succeeded, 1 failed, 2 failed unexpectedly, 2 not attempted. Failures: failed; command failed", + ); + expect(attemptedThreadIds).toHaveLength(4); + expect(new Set(attemptedThreadIds)).toEqual( + new Set(["thread-0", "thread-1", "thread-2", "thread-3"]), + ); + }); +}); + +describe("archived thread action locks", () => { + const firstThreadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1")); + const secondThreadRef = scopeThreadRef(environmentId, ThreadId.make("thread-2")); + + it("blocks overlapping row and bulk actions until the original lock is released", () => { + const inFlightThreadKeys = new Set(); + const bulkLock = tryAcquireArchivedThreadActionLock(inFlightThreadKeys, [ + firstThreadRef, + secondThreadRef, + ]); + + expect(bulkLock).not.toBeNull(); + expect(tryAcquireArchivedThreadActionLock(inFlightThreadKeys, [firstThreadRef])).toBeNull(); + + releaseArchivedThreadActionLock(inFlightThreadKeys, bulkLock!); + + expect(tryAcquireArchivedThreadActionLock(inFlightThreadKeys, [firstThreadRef])).not.toBeNull(); + }); + + it("uses collision-safe environment and thread identity", () => { + const firstKey = archivedThreadActionKey( + scopeThreadRef(EnvironmentId.make("environment:a"), ThreadId.make("thread")), + ); + const secondKey = archivedThreadActionKey( + scopeThreadRef(EnvironmentId.make("environment"), ThreadId.make("a:thread")), + ); + + expect(firstKey).not.toBe(secondKey); + }); +}); + +describe("archivedProjectBulkFailureDescription", () => { + it("reports interrupted-only partial outcomes", () => { + expect( + archivedProjectBulkFailureDescription([AsyncResult.failure(Cause.interrupt(1))], 2), + ).toBe("1 succeeded, 0 failed, 1 interrupted."); + }); +}); + describe("background activity settings restore", () => { it("detects legacy interval values even when the structured setting is at its default", () => { expect( diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 1d4baefa53a5..e9893d0671b6 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -1,20 +1,512 @@ import type { BackgroundActivityProfile, BackgroundActivitySettings, + EnvironmentId, + OrchestrationProjectShell, + OrchestrationThreadShell, ProviderDriverKind, ProviderInstanceConfig, ProviderInstanceId, + ScopedThreadRef, + ThreadId, ServerSettings, SidebarProjectGroupingMode, 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 { normalizeBackgroundActivitySettings, normalizeServerBackgroundActivitySettings, resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; +import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; import * as Equal from "effect/Equal"; +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; +} + +export interface ArchivedProjectBulkActionSummary { + readonly succeeded: number; + readonly failures: ReadonlyArray; +} + +export class ArchivedProjectBulkActionError extends AggregateError { + readonly summary: ArchivedProjectBulkActionSummary; + readonly totalCount: number; + + constructor( + errors: Iterable, + summary: ArchivedProjectBulkActionSummary, + totalCount: number, + ) { + super(errors, "Archived project thread action failed"); + this.name = "ArchivedProjectBulkActionError"; + this.summary = summary; + this.totalCount = totalCount; + } +} + +export interface ArchivedThreadActionLock { + readonly keys: ReadonlyArray; +} + +function archivedProjectGroupKey( + environmentId: EnvironmentId, + projectId: OrchestrationProjectShell["id"], +): string { + return JSON.stringify([environmentId, projectId]); +} + +export function archivedThreadActionKey(threadRef: ScopedThreadRef): string { + return JSON.stringify([threadRef.environmentId, threadRef.threadId]); +} + +export function tryAcquireArchivedThreadActionLock( + inFlightThreadKeys: Set, + threadRefs: ReadonlyArray, +): ArchivedThreadActionLock | null { + const keys = [...new Set(threadRefs.map(archivedThreadActionKey))]; + if (keys.some((key) => inFlightThreadKeys.has(key))) { + return null; + } + for (const key of keys) { + inFlightThreadKeys.add(key); + } + return { keys }; +} + +export function releaseArchivedThreadActionLock( + inFlightThreadKeys: Set, + lock: ArchivedThreadActionLock, +): void { + for (const key of lock.keys) { + inFlightThreadKeys.delete(key); + } +} + +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[] = []; + let succeeded = 0; + 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); + } else { + succeeded += 1; + } + } 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 ArchivedProjectBulkActionError(thrownErrors, { succeeded, failures }, threads.length); + } + 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 archivedProjectBulkActionExceptionDescription(error: unknown): string { + const errors = error instanceof AggregateError ? error.errors : [error]; + const commandFailures = + error instanceof ArchivedProjectBulkActionError + ? error.summary.failures + .filter((failure) => !isAtomCommandInterrupted(failure)) + .map((failure) => squashAtomCommandFailure(failure)) + : []; + const failureMessages = [ + ...new Set( + [...errors, ...commandFailures].map((entry) => + entry instanceof Error ? entry.message : "An error occurred.", + ), + ), + ]; + const shownFailureMessages = failureMessages.slice(0, 3); + const outcome = + error instanceof ArchivedProjectBulkActionError + ? (() => { + const visibleFailures = error.summary.failures.filter( + (failure) => !isAtomCommandInterrupted(failure), + ); + const interruptedCount = error.summary.failures.length - visibleFailures.length; + const notAttemptedCount = Math.max( + 0, + error.totalCount - + error.summary.succeeded - + error.summary.failures.length - + error.errors.length, + ); + const parts = [`${error.summary.succeeded} succeeded`]; + if (visibleFailures.length > 0) parts.push(`${visibleFailures.length} failed`); + if (interruptedCount > 0) parts.push(`${interruptedCount} interrupted`); + parts.push( + `${error.errors.length} failed unexpectedly`, + `${notAttemptedCount} not attempted`, + ); + return `Partial outcome: ${parts.join(", ")}.`; + })() + : "One or more archived thread actions failed unexpectedly."; + + return [ + outcome, + failureMessages.length <= 1 + ? (shownFailureMessages[0] ?? "An error occurred.") + : `Failures: ${shownFailureMessages.join("; ")}${ + failureMessages.length > shownFailureMessages.length + ? `; ${failureMessages.length - shownFailureMessages.length} more` + : "" + }`, + ].join(" "); +} + +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; +} export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { return mode !== "separate"; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5385751924e1..e0cd55c204dd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,12 +1,4 @@ -import { - ArchiveIcon, - ArchiveX, - InfoIcon, - LoaderIcon, - PlusIcon, - RefreshCwIcon, - SettingsIcon, -} from "lucide-react"; +import { InfoIcon, LoaderIcon, PlusIcon, RefreshCwIcon, SettingsIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties } from "react"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -20,14 +12,11 @@ import { ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, - type ScopedThreadRef, type SidebarProjectGroupingMode, } 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 { @@ -65,7 +54,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, @@ -83,9 +71,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 { Dialog, @@ -136,7 +122,6 @@ import { SettingsSection, useRelativeTimeTick, } from "./settingsLayout"; -import { ProjectFavicon } from "../ProjectFavicon"; import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ @@ -2204,226 +2189,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/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 91a3779a0575..44244ab556d0 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -201,12 +201,12 @@ export function useThreadActions() { ); const unarchiveThread = useCallback( - async (target: ScopedThreadRef) => { + async (target: ScopedThreadRef, opts: { readonly refreshArchivedThreads?: boolean } = {}) => { const result = await unarchiveThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); - if (result._tag === "Success") { + if (result._tag === "Success" && opts.refreshArchivedThreads !== false) { refreshArchivedThreadsForEnvironment(target.environmentId); } return result; @@ -215,7 +215,13 @@ export function useThreadActions() { ); const deleteThread = useCallback( - async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { + async ( + target: ScopedThreadRef, + opts: { + readonly deletedThreadKeys?: ReadonlySet; + readonly refreshArchivedThreads?: boolean; + } = {}, + ) => { const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. @@ -223,7 +229,7 @@ export function useThreadActions() { environmentId: target.environmentId, input: { threadId: target.threadId }, }); - if (result._tag === "Success") { + if (result._tag === "Success" && opts.refreshArchivedThreads !== false) { refreshArchivedThreadsForEnvironment(target.environmentId); } return result; @@ -307,7 +313,9 @@ export function useThreadActions() { if (deleteResult._tag === "Failure") { return deleteResult; } - refreshArchivedThreadsForEnvironment(threadRef.environmentId); + if (opts.refreshArchivedThreads !== false) { + refreshArchivedThreadsForEnvironment(threadRef.environmentId); + } clearComposerDraftForThread(threadRef); clearProjectDraftThreadById( scopeProjectRef(threadRef.environmentId, thread.projectId), diff --git a/apps/web/src/routes/settings.archived.tsx b/apps/web/src/routes/settings.archived.tsx index 3ad690afc027..28892668f93a 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/docs/README.md b/docs/README.md index fe473094bbcf..8a3370d8813c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ - [Remote environments](./architecture/remote.md) - [Server updates](./architecture/server-updates.md) - User guides + - [Archive](./user/archive.md) - [Background service](./user/background-service.md) - [Remote access](./user/remote-access.md) - [Keeping T3 Code in sync](./user/server-updates.md) diff --git a/docs/reference/encyclopedia.md b/docs/reference/encyclopedia.md index 82a58fd959c9..39e053413304 100644 --- a/docs/reference/encyclopedia.md +++ b/docs/reference/encyclopedia.md @@ -6,6 +6,7 @@ This is a living glossary for T3 Code. It explains what common terms mean in thi - [Project and workspace](#project-and-workspace) - [Thread timeline](#thread-timeline) +- [Thread lifecycle](#thread-lifecycle) - [Orchestration](#orchestration) - [Provider runtime](#provider-runtime) - [Checkpointing](#checkpointing) @@ -40,6 +41,16 @@ A single user-to-assistant work cycle inside a thread. It starts with user input A user-visible log item attached to a thread. In [the contracts][1], activities cover important non-message events like approvals, tool actions, and failures. They are projected into thread state in [projector.ts][4]. +### Thread lifecycle + +#### Archive + +A reversible action that removes a thread from active thread lists without deleting its conversation +history. An archived thread has an archive timestamp in [the orchestration contracts][1] and remains +available from the Archive screen until it is unarchived or deleted. Unarchiving restores it to the +active thread list; deleting it permanently clears the thread and its history. See the +[Archive user guide][25]. + ### Orchestration Orchestration is the server-side domain layer that turns runtime activity into stable app state. The main entry point is [OrchestrationEngine.ts][7], with core logic in [decider.ts][8] and [projector.ts][4]. @@ -146,6 +157,7 @@ The file patch and changed-file summary for one turn. It is usually computed in - If you see `receipt`, think "async milestone signal". - If you see `checkpoint`, think "workspace snapshot for diff/restore". - If you see `quiesced`, think "all relevant follow-up work has gone idle". +- If you see `archive`, think "hide a thread from active lists without deleting its history". ## Related Docs @@ -153,6 +165,7 @@ The file patch and changed-file summary for one turn. It is usually computed in - [provider-architecture.md][16] - [runtime-modes.md][18] - [workspace-layout.md][2] +- [Archive user guide][25] [1]: ../packages/contracts/src/orchestration.ts [2]: ./workspace-layout.md @@ -178,3 +191,4 @@ The file patch and changed-file summary for one turn. It is usually computed in [22]: ../apps/server/src/checkpointing/Utils.ts [23]: ../apps/server/src/checkpointing/Diffs.ts [24]: ./architecture.md +[25]: ../user/archive.md diff --git a/docs/user/archive.md b/docs/user/archive.md new file mode 100644 index 000000000000..2eed7716c861 --- /dev/null +++ b/docs/user/archive.md @@ -0,0 +1,59 @@ +# Archive + +Archiving removes a thread from active thread lists without deleting its conversation history. You +can browse archived threads, restore them with **Unarchive**, or permanently remove them with +**Delete**. + +To open the archive: + +- In the web or desktop app, open **Settings** → **Archive**. +- In the mobile app, open **Settings** → **Archived Threads**. + +## Find an Archived Thread + +Archived threads are grouped by project. Project groups start collapsed so large archives remain +easy to scan. Expand a project to see each thread's relative **Archived** and **Created** times. + +The archive includes every configured environment, including environments that currently have no +active projects but still contain archived threads. On web and desktop, project headings identify +their environment when more than one is configured. A single remote environment is also labeled, +while a single primary environment remains implicit. Mobile shows environment labels and can +filter the archive to one environment. + +Use search to filter thread titles across all projects. Search is case-insensitive and supports +multiple words. It shows titles matching any search term, prioritizes exact phrase matches, then +titles matching every term, and opens matching project groups while the search is active. + +The default order is newest archived thread first. On web and desktop, select **Archived** or +**Created** in an expanded project heading to change the sort field or reverse its direction. On +mobile, use the header options to choose the environment and sort order. + +## Restore or Delete a Thread + +On web and desktop, hover over or focus a thread row to reveal **Unarchive** and **Delete**. The same +actions are available from the row's context menu. + +On mobile, swipe a thread row or open its long-press menu. + +- **Unarchive** restores the thread to the active thread list. +- **Delete** permanently clears the thread and its conversation history. + +Web and desktop deletion follows the **Confirm thread deletion** setting. Mobile deletion always +uses its guarded confirmation flow. + +Controls are temporarily disabled while the same thread is already being changed. If an action is +already in progress, or if an operation cannot be completed, T3 Code reports that result instead of +starting a conflicting action. + +## Act on a Project + +Open a project heading's actions menu to **Unarchive all** or **Delete all** archived threads in +that project. + +When search is active, these actions become **Unarchive matching** and **Delete matching** and apply +only to the visible matches. Clear the search before using a project action if you want it to apply +to the whole project. + +Bulk unarchive always asks for confirmation. Bulk delete follows the web or desktop deletion +setting and remains explicitly guarded on mobile. If only part of a bulk operation succeeds, T3 +Code reports the completed, failed, and skipped work without claiming that every thread failed.