From cacf444d20b5e0cbf55f622a44e513d9de908bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 24 Jun 2026 21:55:13 +0100 Subject: [PATCH 01/24] Refine archived settings panel UX - Group archived threads by project with collapsible sections - Add sortable archived/created columns and inline row actions - Support direct delete and bulk project actions with confirmation --- .../components/settings/SettingsPanels.tsx | 528 ++++++++++++++---- 1 file changed, 414 insertions(+), 114 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 40017d56314..07c2b31b6c8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,6 +1,17 @@ -import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { + ArchiveIcon, + ArchiveX, + ArrowDownIcon, + ArrowUpIcon, + ChevronDownIcon, + ChevronRightIcon, + LoaderIcon, + PlusIcon, + RefreshCwIcon, + Trash2Icon, +} from "lucide-react"; import { Link } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, @@ -14,6 +25,7 @@ import { import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { + type AtomCommandResult, isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, @@ -1420,9 +1432,119 @@ export function ProviderSettingsPanel() { ); } +type ArchivedThreadSortField = "archivedAt" | "createdAt"; +type ArchivedThreadSortDirection = "asc" | "desc"; + +interface ArchivedThreadSortState { + readonly field: ArchivedThreadSortField; + readonly direction: ArchivedThreadSortDirection; +} + +function archivedThreadSortTimestamp( + thread: { readonly archivedAt: string | null; readonly createdAt: string }, + field: ArchivedThreadSortField, +): number { + const timestamp = Date.parse( + field === "archivedAt" ? (thread.archivedAt ?? thread.createdAt) : thread.createdAt, + ); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +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); +} + +function nextArchivedThreadSortState( + current: ArchivedThreadSortState, + field: ArchivedThreadSortField, +): ArchivedThreadSortState { + if (current.field !== field) { + return { field, direction: "desc" }; + } + return { field, direction: current.direction === "desc" ? "asc" : "desc" }; +} + +function ArchivedSortButton({ + field, + label, + sort, + onClick, +}: { + readonly field: ArchivedThreadSortField; + readonly label: string; + readonly sort: ArchivedThreadSortState; + readonly onClick: () => void; +}) { + const active = sort.field === field; + const SortIcon = sort.direction === "asc" ? ArrowUpIcon : ArrowDownIcon; + return ( + + ); +} + +function ArchivedIconButton({ + label, + destructive = false, + onClick, + children, +}: { + readonly label: string; + readonly destructive?: boolean; + readonly onClick: () => void; + readonly children: ReactNode; +}) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + > + {children} + + } + /> + {label} + + ); +} + export function ArchivedThreadsPanel() { const projects = useProjects(); - const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const { unarchiveThread, deleteThread } = useThreadActions(); + const [expandedProjectKeys, setExpandedProjectKeys] = useState>( + () => new Set(), + ); + const [sort, setSort] = useState({ + field: "archivedAt", + direction: "desc", + }); + useRelativeTimeTick(); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], [projects], @@ -1473,19 +1595,152 @@ export function ArchivedThreadsPanel() { 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); - }), + threads: projectThreads.toSorted((left, right) => + compareArchivedThreads(left, right, sort), + ), }); } } return groups; - }, [archivedSnapshots]); + }, [archivedSnapshots, sort]); + + const toggleProjectExpanded = useCallback((projectKey: string) => { + setExpandedProjectKeys((current) => { + const next = new Set(current); + if (next.has(projectKey)) { + next.delete(projectKey); + } else { + next.add(projectKey); + } + return next; + }); + }, []); + + const handleSortClick = useCallback((field: ArchivedThreadSortField) => { + setSort((current) => nextArchivedThreadSortState(current, field)); + }, []); + + const confirmArchivedAction = useCallback(async (message: string) => { + const confirmationResult = await settlePromise(() => + (readLocalApi() ?? ensureLocalApi()).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 handleUnarchiveThread = useCallback( + async (threadRef: ScopedThreadRef) => { + const result = await unarchiveThread(threadRef); + if (result._tag === "Success") { + refreshArchivedThreads(); + return; + } + showArchivedActionFailure("Failed to unarchive thread", result); + }, + [refreshArchivedThreads, showArchivedActionFailure, unarchiveThread], + ); + + const handleDeleteArchivedThread = useCallback( + async (threadRef: ScopedThreadRef, title: string) => { + const confirmed = await confirmArchivedAction( + [ + `Delete archived conversation "${title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) return; + const result = await deleteThread(threadRef); + if (result._tag === "Success") { + refreshArchivedThreads(); + return; + } + showArchivedActionFailure("Failed to delete thread", result); + }, + [confirmArchivedAction, deleteThread, refreshArchivedThreads, showArchivedActionFailure], + ); + + const handleUnarchiveProjectThreads = useCallback( + async ( + projectName: string, + threads: ReadonlyArray<{ + readonly id: ScopedThreadRef["threadId"]; + readonly environmentId: ScopedThreadRef["environmentId"]; + }>, + ) => { + const confirmed = await confirmArchivedAction( + [ + `Unarchive all archived conversations in "${projectName}"?`, + `This will restore ${threads.length} conversation${threads.length === 1 ? "" : "s"}.`, + ].join("\n"), + ); + if (!confirmed) return; + for (const thread of threads) { + const result = await unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)); + if (result._tag === "Failure") { + showArchivedActionFailure("Failed to unarchive every thread", result); + break; + } + } + refreshArchivedThreads(); + }, + [confirmArchivedAction, refreshArchivedThreads, showArchivedActionFailure, unarchiveThread], + ); + + const handleDeleteProjectThreads = useCallback( + async ( + projectName: string, + threads: ReadonlyArray<{ + readonly id: ScopedThreadRef["threadId"]; + readonly environmentId: ScopedThreadRef["environmentId"]; + }>, + ) => { + const confirmed = await confirmArchivedAction( + [ + `Delete all archived conversations in "${projectName}"?`, + `This permanently clears conversation history for ${threads.length} conversation${threads.length === 1 ? "" : "s"}.`, + ].join("\n"), + ); + if (!confirmed) return; + for (const thread of threads) { + const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id)); + if (result._tag === "Failure") { + showArchivedActionFailure("Failed to delete every thread", result); + break; + } + } + refreshArchivedThreads(); + }, + [confirmArchivedAction, deleteThread, refreshArchivedThreads, showArchivedActionFailure], + ); const handleArchivedThreadContextMenu = useCallback( - async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + async (threadRef: ScopedThreadRef, title: string, position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; const clicked = await api.contextMenu.show( @@ -1497,39 +1752,15 @@ export function ArchivedThreadsPanel() { ); 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.", - }), - ); - } + await handleUnarchiveThread(threadRef); 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.", - }), - ); - } + await handleDeleteArchivedThread(threadRef, title); } }, - [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], + [handleDeleteArchivedThread, handleUnarchiveThread], ); return ( @@ -1559,85 +1790,154 @@ export function ArchivedThreadsPanel() { /> ) : ( - 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={ - - } - /> - ))} - - )) + {isExpanded ? ( + + ) : ( + + )} + + + {project.name} + + + {projectThreads.length} + + + {isExpanded ? ( + <> + handleSortClick("archivedAt")} + /> + handleSortClick("createdAt")} + /> + + ) : null} +
+ { + void handleUnarchiveProjectThreads(project.name, projectThreads); + }} + > + + + { + void handleDeleteProjectThreads(project.name, projectThreads); + }} + > + + +
+ + {isExpanded ? ( +
+ {projectThreads.map((thread) => ( +
{ + event.preventDefault(); + void (async () => { + const result = await settlePromise(() => + handleArchivedThreadContextMenu( + scopeThreadRef(thread.environmentId, thread.id), + thread.title, + { + x: event.clientX, + y: event.clientY, + }, + ), + ); + 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.", + }), + ); + } + })(); + }} + > +
+ {thread.title} +
+
+ {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} +
+
+ {formatRelativeTimeLabel(thread.createdAt)} +
+
event.stopPropagation()} + > + { + void handleUnarchiveThread( + scopeThreadRef(thread.environmentId, thread.id), + ); + }} + > + + + { + void handleDeleteArchivedThread( + scopeThreadRef(thread.environmentId, thread.id), + thread.title, + ); + }} + > + + +
+
+ ))} +
+ ) : null} + + ); + })} + )} ); From 57a1e7617b5f3e89899529f28c41fc910eedeb89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 25 Jun 2026 09:47:29 +0100 Subject: [PATCH 02/24] Move archived project actions into context menu - Replace inline project-level unarchive/delete buttons with a right-click menu - Keep the existing confirmation flow for bulk archived-thread actions --- .../components/settings/SettingsPanels.tsx | 76 ++++++++++++++----- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 07c2b31b6c8..069abacc595 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1763,6 +1763,37 @@ export function ArchivedThreadsPanel() { [handleDeleteArchivedThread, handleUnarchiveThread], ); + const handleArchivedProjectContextMenu = useCallback( + async ( + projectName: string, + threads: ReadonlyArray<{ + readonly id: ScopedThreadRef["threadId"]; + readonly environmentId: ScopedThreadRef["environmentId"]; + }>, + position: { x: number; y: number }, + ) => { + const api = readLocalApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "unarchive-all", label: "Unarchive all" }, + { id: "delete-all", label: "Delete all", destructive: true }, + ], + position, + ); + + if (clicked === "unarchive-all") { + await handleUnarchiveProjectThreads(projectName, threads); + return; + } + + if (clicked === "delete-all") { + await handleDeleteProjectThreads(projectName, threads); + } + }, + [handleDeleteProjectThreads, handleUnarchiveProjectThreads], + ); + return ( {archivedGroups.length === 0 ? ( @@ -1802,9 +1833,31 @@ export function ArchivedThreadsPanel() {
{ + event.preventDefault(); + void (async () => { + const result = await settlePromise(() => + handleArchivedProjectContextMenu(project.name, projectThreads, { + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Archived project action failed", + description: + error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }} >
{isExpanded ? (
From 67a0477aed9786d03b387e6ea1b6a309936d17bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 25 Jun 2026 10:18:38 +0100 Subject: [PATCH 03/24] Add archive thread search and ranked filtering - Add case-insensitive search across archived thread titles - Rank exact phrase, full-term, and partial matches; auto-expand matching projects - Update archive empty states and preserve project actions under search --- .../components/settings/SettingsPanels.tsx | 142 ++++++++++++++++-- 1 file changed, 131 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 069abacc595..1d0fc18ad64 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -32,6 +32,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; +import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; @@ -73,6 +74,7 @@ import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; +import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -1470,6 +1472,62 @@ function nextArchivedThreadSortState( return { field, direction: current.direction === "desc" ? "asc" : "desc" }; } +function archivedThreadSearchScore(input: { + readonly title: string; + readonly normalizedQuery: string; + readonly tokens: ReadonlyArray; +}): number | null { + if (input.normalizedQuery.length === 0) { + return 0; + } + + const title = normalizeSearchQuery(input.title); + if (!title) { + return null; + } + + const phraseScore = scoreQueryMatch({ + value: title, + query: input.normalizedQuery, + exactBase: 0, + prefixBase: 1, + boundaryBase: 2, + includesBase: 3, + }); + if (phraseScore !== null) { + return phraseScore; + } + + let matchedTokenCount = 0; + let tokenScore = 0; + for (const token of input.tokens) { + const score = scoreQueryMatch({ + value: title, + 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 1_000 + tokenScore; + } + + return 5_000 + (input.tokens.length - matchedTokenCount) * 1_000 + tokenScore; +} + function ArchivedSortButton({ field, label, @@ -1540,6 +1598,7 @@ export function ArchivedThreadsPanel() { const [expandedProjectKeys, setExpandedProjectKeys] = useState>( () => new Set(), ); + const [archiveSearchQuery, setArchiveSearchQuery] = useState(""); const [sort, setSort] = useState({ field: "archivedAt", direction: "desc", @@ -1555,6 +1614,19 @@ export function ArchivedThreadsPanel() { isLoading: isLoadingArchive, refresh: refreshArchivedThreads, } = useArchivedThreadSnapshots(environmentIds); + const normalizedArchiveSearchQuery = useMemo( + () => normalizeSearchQuery(archiveSearchQuery), + [archiveSearchQuery], + ); + const archiveSearchTokens = useMemo( + () => normalizedArchiveSearchQuery.split(/\s+/u).filter((token) => token.length > 0), + [normalizedArchiveSearchQuery], + ); + const isSearchingArchive = normalizedArchiveSearchQuery.length > 0; + const hasArchivedThreads = useMemo( + () => archivedSnapshots.some(({ snapshot }) => snapshot.threads.length > 0), + [archivedSnapshots], + ); const archivedGroups = useMemo(() => { const projectsByEnvironmentAndId = new Map( @@ -1581,28 +1653,62 @@ export function ArchivedThreadsPanel() { ); const archivedProjects = Array.from(projectsByEnvironmentAndId.values()); + type ArchivedThreadWithSearchScore = (typeof threads)[number] & { + readonly searchScore: number; + }; const groups: Array<{ readonly project: (typeof archivedProjects)[number]; - readonly threads: Array<(typeof threads)[number]>; + readonly threads: Array; + readonly actionThreads: Array<(typeof threads)[number]>; + readonly searchScore: number; }> = []; for (const project of archivedProjects) { - const projectThreads: Array<(typeof threads)[number]> = []; + const actionThreads: Array<(typeof threads)[number]> = []; + const projectThreads: Array = []; for (const thread of threads) { if (thread.projectId === project.id && thread.environmentId === project.environmentId) { - projectThreads.push(thread); + actionThreads.push(thread); + const searchScore = archivedThreadSearchScore({ + title: thread.title, + normalizedQuery: normalizedArchiveSearchQuery, + tokens: archiveSearchTokens, + }); + if (searchScore === null) { + continue; + } + projectThreads.push({ + ...thread, + searchScore, + }); } } if (projectThreads.length > 0) { groups.push({ project, threads: projectThreads.toSorted((left, right) => - compareArchivedThreads(left, right, sort), + isSearchingArchive + ? left.searchScore - right.searchScore || compareArchivedThreads(left, right, sort) + : compareArchivedThreads(left, right, sort), ), + actionThreads, + searchScore: Math.min(...projectThreads.map((thread) => thread.searchScore)), }); } } - return groups; - }, [archivedSnapshots, sort]); + return isSearchingArchive + ? groups.toSorted( + (left, right) => + left.searchScore - right.searchScore || + left.project.name.localeCompare(right.project.name), + ) + : groups; + }, [ + archiveSearchTokens, + archivedSnapshots, + isSearchingArchive, + normalizedArchiveSearchQuery, + sort, + ]); const toggleProjectExpanded = useCallback((projectKey: string) => { setExpandedProjectKeys((current) => { @@ -1796,6 +1902,14 @@ export function ArchivedThreadsPanel() { return ( + setArchiveSearchQuery(event.currentTarget.value)} + placeholder="Search archived conversations" + aria-label="Search archived conversations" + /> {archivedGroups.length === 0 ? ( } description={ isLoadingArchive ? "Checking connected environments." - : (archiveError ?? "Archived threads will appear here.") + : archiveError + ? archiveError + : isSearchingArchive && hasArchivedThreads + ? `No archived conversation titles match "${archiveSearchQuery.trim()}".` + : "Archived threads will appear here." } /> ) : (
- {archivedGroups.map(({ project, threads: projectThreads }) => { + {archivedGroups.map(({ actionThreads, project, threads: projectThreads }) => { const projectKey = `${project.environmentId}:${project.id}`; - const isExpanded = expandedProjectKeys.has(projectKey); + const isExpanded = isSearchingArchive || expandedProjectKeys.has(projectKey); return (
{ const result = await settlePromise(() => - handleArchivedProjectContextMenu(project.name, projectThreads, { + handleArchivedProjectContextMenu(project.name, actionThreads, { x: event.clientX, y: event.clientY, }), From da195620ee4cba6f8948da8c0d163f2d8dc64444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 25 Jun 2026 10:36:48 +0100 Subject: [PATCH 04/24] Scope archive bulk actions to visible threads - Use filtered archive rows for project actions during search --- apps/web/src/components/settings/SettingsPanels.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1d0fc18ad64..ba7ce92613d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1945,6 +1945,7 @@ export function ArchivedThreadsPanel() { {archivedGroups.map(({ actionThreads, project, threads: projectThreads }) => { const projectKey = `${project.environmentId}:${project.id}`; const isExpanded = isSearchingArchive || expandedProjectKeys.has(projectKey); + const projectActionThreads = isSearchingArchive ? projectThreads : actionThreads; return (
{ const result = await settlePromise(() => - handleArchivedProjectContextMenu(project.name, actionThreads, { + handleArchivedProjectContextMenu(project.name, projectActionThreads, { x: event.clientX, y: event.clientY, }), From 5d9ac5a2f2ca91a9a9d6757157c167328584c57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 25 Jun 2026 10:42:51 +0100 Subject: [PATCH 05/24] Fix archive project bulk actions during search - Use the full project archive list for bulk context actions - Keep search filtering from narrowing project-wide confirmations --- apps/web/src/components/settings/SettingsPanels.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ba7ce92613d..1d0fc18ad64 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1945,7 +1945,6 @@ export function ArchivedThreadsPanel() { {archivedGroups.map(({ actionThreads, project, threads: projectThreads }) => { const projectKey = `${project.environmentId}:${project.id}`; const isExpanded = isSearchingArchive || expandedProjectKeys.has(projectKey); - const projectActionThreads = isSearchingArchive ? projectThreads : actionThreads; return (
{ const result = await settlePromise(() => - handleArchivedProjectContextMenu(project.name, projectActionThreads, { + handleArchivedProjectContextMenu(project.name, actionThreads, { x: event.clientX, y: event.clientY, }), From d8cb38bbbd118d24a9dd3c9bb8788131170bb188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 25 Jun 2026 10:54:04 +0100 Subject: [PATCH 06/24] Scope archive bulk actions to visible rows - Limit project archive actions to filtered visible threads - Add archive search ranking tests --- .../settings/SettingsPanels.logic.test.ts | 30 +++ .../settings/SettingsPanels.logic.ts | 64 ++++++ .../components/settings/SettingsPanels.tsx | 212 ++++++++++-------- 3 files changed, 208 insertions(+), 98 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index d783d16c7ad..d7ca6dd4959 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -4,12 +4,42 @@ import { ProviderInstanceId, type ProviderInstanceConfig, } from "@t3tools/contracts"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import { describe, expect, it } from "vite-plus/test"; import { + archivedThreadSearchScore, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, } from "./SettingsPanels.logic"; +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), + }); +} + +describe("archivedThreadSearchScore", () => { + it("ranks phrase matches ahead of all-token and partial-token matches", () => { + const phraseMatch = scoreArchivedTitle("Alpha Beta cleanup", "alpha beta"); + const allTokenMatch = scoreArchivedTitle("Alpha cleanup Beta", "alpha beta"); + const partialTokenMatch = scoreArchivedTitle("Alpha cleanup", "alpha beta"); + + expect(phraseMatch).not.toBeNull(); + expect(allTokenMatch).not.toBeNull(); + expect(partialTokenMatch).not.toBeNull(); + expect(phraseMatch!).toBeLessThan(allTokenMatch!); + expect(allTokenMatch!).toBeLessThan(partialTokenMatch!); + }); + + it("matches titles case-insensitively and rejects unrelated titles", () => { + expect(scoreArchivedTitle("Release Candidate Notes", "candidate")).not.toBeNull(); + expect(scoreArchivedTitle("Release Candidate Notes", "missing")).toBeNull(); + }); +}); + describe("formatDiagnosticsDescription", () => { it("collapses trace and metric URLs that share the same OTEL base path", () => { expect( diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 99d7052965a..b07d14b8cec 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -6,6 +6,70 @@ import type { UnifiedSettings, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { scoreQueryMatch } from "@t3tools/shared/searchRanking"; + +const ARCHIVED_THREAD_ALL_TOKENS_SCORE_OFFSET = 1_000; +const ARCHIVED_THREAD_PARTIAL_TOKENS_SCORE_OFFSET = 5_000; + +export function archivedThreadSearchScore(input: { + readonly normalizedTitle: string; + readonly normalizedQuery: string; + readonly tokens: ReadonlyArray; +}): number | null { + if (input.normalizedQuery.length === 0) { + return 0; + } + + if (!input.normalizedTitle) { + return null; + } + + const phraseScore = scoreQueryMatch({ + value: input.normalizedTitle, + query: input.normalizedQuery, + exactBase: 0, + prefixBase: 1, + boundaryBase: 2, + includesBase: 3, + }); + if (phraseScore !== null) { + return phraseScore; + } + + let matchedTokenCount = 0; + let tokenScore = 0; + for (const token of input.tokens) { + const score = scoreQueryMatch({ + value: input.normalizedTitle, + query: token, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + ...(token.length >= 3 ? { fuzzyBase: 100 } : {}), + }); + if (score === null) { + continue; + } + + matchedTokenCount += 1; + tokenScore += score; + } + + if (matchedTokenCount === 0) { + return null; + } + + if (matchedTokenCount === input.tokens.length) { + return ARCHIVED_THREAD_ALL_TOKENS_SCORE_OFFSET + tokenScore; + } + + return ( + ARCHIVED_THREAD_PARTIAL_TOKENS_SCORE_OFFSET + + (input.tokens.length - matchedTokenCount) * 1_000 + + tokenScore + ); +} function collapseOtelSignalsUrl(input: { readonly tracesUrl: string; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1d0fc18ad64..f7a7d0981ab 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -32,7 +32,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; -import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; @@ -90,6 +90,7 @@ import { import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { + archivedThreadSearchScore, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, } from "./SettingsPanels.logic"; @@ -1436,12 +1437,57 @@ export function ProviderSettingsPanel() { type ArchivedThreadSortField = "archivedAt" | "createdAt"; type ArchivedThreadSortDirection = "asc" | "desc"; +type ArchivedProjectBulkScope = "all" | "matching"; interface ArchivedThreadSortState { readonly field: ArchivedThreadSortField; readonly direction: ArchivedThreadSortDirection; } +type ArchivedProjectBulkThread = { + readonly id: ScopedThreadRef["threadId"]; + readonly environmentId: ScopedThreadRef["environmentId"]; +}; +type ArchivedProjectBulkFailure = Extract< + AtomCommandResult, + { readonly _tag: "Failure" } +>; + +const ARCHIVED_PROJECT_BULK_ACTION_CONCURRENCY = 4; + +async function runArchivedProjectThreadActions( + threads: ReadonlyArray, + action: (thread: ArchivedProjectBulkThread) => Promise>, +): Promise> { + const failures: Array = []; + let nextThreadIndex = 0; + async function worker() { + for (;;) { + const thread = threads[nextThreadIndex]; + nextThreadIndex += 1; + if (!thread) { + return; + } + const result = await action(thread); + if (result._tag === "Failure") { + failures.push(result); + } + } + } + + await Promise.all( + Array.from( + { length: Math.min(ARCHIVED_PROJECT_BULK_ACTION_CONCURRENCY, threads.length) }, + worker, + ), + ); + return failures; +} + +function archivedProjectBulkScopeLabel(scope: ArchivedProjectBulkScope): string { + return scope === "matching" ? "matching archived conversations" : "all archived conversations"; +} + function archivedThreadSortTimestamp( thread: { readonly archivedAt: string | null; readonly createdAt: string }, field: ArchivedThreadSortField, @@ -1472,62 +1518,6 @@ function nextArchivedThreadSortState( return { field, direction: current.direction === "desc" ? "asc" : "desc" }; } -function archivedThreadSearchScore(input: { - readonly title: string; - readonly normalizedQuery: string; - readonly tokens: ReadonlyArray; -}): number | null { - if (input.normalizedQuery.length === 0) { - return 0; - } - - const title = normalizeSearchQuery(input.title); - if (!title) { - return null; - } - - const phraseScore = scoreQueryMatch({ - value: title, - query: input.normalizedQuery, - exactBase: 0, - prefixBase: 1, - boundaryBase: 2, - includesBase: 3, - }); - if (phraseScore !== null) { - return phraseScore; - } - - let matchedTokenCount = 0; - let tokenScore = 0; - for (const token of input.tokens) { - const score = scoreQueryMatch({ - value: title, - 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 1_000 + tokenScore; - } - - return 5_000 + (input.tokens.length - matchedTokenCount) * 1_000 + tokenScore; -} - function ArchivedSortButton({ field, label, @@ -1649,6 +1639,7 @@ export function ArchivedThreadsPanel() { snapshot.threads.map((thread) => ({ ...thread, environmentId, + normalizedTitle: normalizeSearchQuery(thread.title), })), ); @@ -1659,17 +1650,14 @@ export function ArchivedThreadsPanel() { const groups: Array<{ readonly project: (typeof archivedProjects)[number]; readonly threads: Array; - readonly actionThreads: Array<(typeof threads)[number]>; readonly searchScore: number; }> = []; for (const project of archivedProjects) { - const actionThreads: Array<(typeof threads)[number]> = []; const projectThreads: Array = []; for (const thread of threads) { if (thread.projectId === project.id && thread.environmentId === project.environmentId) { - actionThreads.push(thread); const searchScore = archivedThreadSearchScore({ - title: thread.title, + normalizedTitle: thread.normalizedTitle, normalizedQuery: normalizedArchiveSearchQuery, tokens: archiveSearchTokens, }); @@ -1690,7 +1678,6 @@ export function ArchivedThreadsPanel() { ? left.searchScore - right.searchScore || compareArchivedThreads(left, right, sort) : compareArchivedThreads(left, right, sort), ), - actionThreads, searchScore: Math.min(...projectThreads.map((thread) => thread.searchScore)), }); } @@ -1760,6 +1747,26 @@ export function ArchivedThreadsPanel() { [], ); + const showArchivedBulkActionFailure = useCallback( + (title: string, failures: ReadonlyArray, totalCount: number) => { + const visibleFailures = failures.filter((failure) => !isAtomCommandInterrupted(failure)); + if (visibleFailures.length === 0) return; + const error = squashAtomCommandFailure(visibleFailures[0]!); + const successCount = totalCount - failures.length; + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: [ + `${successCount} succeeded, ${visibleFailures.length} failed.`, + error instanceof Error ? error.message : "An error occurred.", + ].join(" "), + }), + ); + }, + [], + ); + const handleUnarchiveThread = useCallback( async (threadRef: ScopedThreadRef) => { const result = await unarchiveThread(threadRef); @@ -1794,55 +1801,59 @@ export function ArchivedThreadsPanel() { const handleUnarchiveProjectThreads = useCallback( async ( projectName: string, - threads: ReadonlyArray<{ - readonly id: ScopedThreadRef["threadId"]; - readonly environmentId: ScopedThreadRef["environmentId"]; - }>, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, ) => { + const scopeLabel = archivedProjectBulkScopeLabel(scope); const confirmed = await confirmArchivedAction( [ - `Unarchive all archived conversations in "${projectName}"?`, + `Unarchive ${scopeLabel} in "${projectName}"?`, `This will restore ${threads.length} conversation${threads.length === 1 ? "" : "s"}.`, ].join("\n"), ); if (!confirmed) return; - for (const thread of threads) { - const result = await unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)); - if (result._tag === "Failure") { - showArchivedActionFailure("Failed to unarchive every thread", result); - break; - } + const failures = await runArchivedProjectThreadActions(threads, (thread) => + unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)), + ); + if (failures.length > 0) { + showArchivedBulkActionFailure( + "Failed to unarchive every archived thread", + failures, + threads.length, + ); } refreshArchivedThreads(); }, - [confirmArchivedAction, refreshArchivedThreads, showArchivedActionFailure, unarchiveThread], + [confirmArchivedAction, refreshArchivedThreads, showArchivedBulkActionFailure, unarchiveThread], ); const handleDeleteProjectThreads = useCallback( async ( projectName: string, - threads: ReadonlyArray<{ - readonly id: ScopedThreadRef["threadId"]; - readonly environmentId: ScopedThreadRef["environmentId"]; - }>, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, ) => { + const scopeLabel = archivedProjectBulkScopeLabel(scope); const confirmed = await confirmArchivedAction( [ - `Delete all archived conversations in "${projectName}"?`, + `Delete ${scopeLabel} in "${projectName}"?`, `This permanently clears conversation history for ${threads.length} conversation${threads.length === 1 ? "" : "s"}.`, ].join("\n"), ); if (!confirmed) return; - for (const thread of threads) { - const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id)); - if (result._tag === "Failure") { - showArchivedActionFailure("Failed to delete every thread", result); - break; - } + const failures = await runArchivedProjectThreadActions(threads, (thread) => + deleteThread(scopeThreadRef(thread.environmentId, thread.id)), + ); + if (failures.length > 0) { + showArchivedBulkActionFailure( + "Failed to delete every archived thread", + failures, + threads.length, + ); } refreshArchivedThreads(); }, - [confirmArchivedAction, deleteThread, refreshArchivedThreads, showArchivedActionFailure], + [confirmArchivedAction, deleteThread, refreshArchivedThreads, showArchivedBulkActionFailure], ); const handleArchivedThreadContextMenu = useCallback( @@ -1872,10 +1883,8 @@ export function ArchivedThreadsPanel() { const handleArchivedProjectContextMenu = useCallback( async ( projectName: string, - threads: ReadonlyArray<{ - readonly id: ScopedThreadRef["threadId"]; - readonly environmentId: ScopedThreadRef["environmentId"]; - }>, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, position: { x: number; y: number }, ) => { const api = readLocalApi(); @@ -1889,12 +1898,12 @@ export function ArchivedThreadsPanel() { ); if (clicked === "unarchive-all") { - await handleUnarchiveProjectThreads(projectName, threads); + await handleUnarchiveProjectThreads(projectName, threads, scope); return; } if (clicked === "delete-all") { - await handleDeleteProjectThreads(projectName, threads); + await handleDeleteProjectThreads(projectName, threads, scope); } }, [handleDeleteProjectThreads, handleUnarchiveProjectThreads], @@ -1942,9 +1951,10 @@ export function ArchivedThreadsPanel() { ) : (
- {archivedGroups.map(({ actionThreads, project, threads: projectThreads }) => { + {archivedGroups.map(({ project, threads: projectThreads }) => { const projectKey = `${project.environmentId}:${project.id}`; const isExpanded = isSearchingArchive || expandedProjectKeys.has(projectKey); + const projectBulkScope = isSearchingArchive ? "matching" : "all"; return (
{ const result = await settlePromise(() => - handleArchivedProjectContextMenu(project.name, actionThreads, { - x: event.clientX, - y: event.clientY, - }), + handleArchivedProjectContextMenu( + project.name, + projectThreads, + projectBulkScope, + { + x: event.clientX, + y: event.clientY, + }, + ), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); @@ -1981,7 +1996,8 @@ export function ArchivedThreadsPanel() { > + ); +} + +function ArchivedIconButton({ + label, + destructive = false, + onClick, + children, +}: { + readonly label: string; + readonly destructive?: boolean; + readonly onClick: () => void; + readonly children: ReactNode; +}) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + > + {children} + + } + /> + {label} + + ); +} + +export function ArchivedThreadsPanel() { + const projects = useProjects(); + const { unarchiveThread, deleteThread } = useThreadActions(); + const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); + const [expandedProjectKeys, setExpandedProjectKeys] = useState>( + () => new Set(), + ); + const [archiveSearchQuery, setArchiveSearchQuery] = useState(""); + const [sort, setSort] = useState({ + field: "archivedAt", + direction: "desc", + }); + useRelativeTimeTick(); + const environmentIds = useMemo( + () => [...new Set(projects.map((project) => project.environmentId))], + [projects], + ); + const { + snapshots: archivedSnapshots, + error: archiveError, + isLoading: isLoadingArchive, + refresh: refreshArchivedThreads, + } = useArchivedThreadSnapshots(environmentIds); + const archiveSearch = useMemo( + () => parseArchivedThreadSearchInput(archiveSearchQuery), + [archiveSearchQuery], + ); + const hasArchivedThreads = useMemo( + () => archivedSnapshots.some(({ snapshot }) => snapshot.threads.length > 0), + [archivedSnapshots], + ); + + const archivedGroups = useMemo( + () => + buildArchivedThreadGroups({ + snapshots: archivedSnapshots, + normalizedSearchQuery: archiveSearch.normalizedQuery, + searchTokens: archiveSearch.tokens, + isSearching: archiveSearch.isSearching, + sort, + }), + [ + archiveSearch.isSearching, + archiveSearch.normalizedQuery, + archiveSearch.tokens, + archivedSnapshots, + sort, + ], + ); + + const toggleProjectExpanded = useCallback((projectKey: string) => { + setExpandedProjectKeys((current) => { + const next = new Set(current); + if (next.has(projectKey)) { + next.delete(projectKey); + } else { + next.add(projectKey); + } + return next; + }); + }, []); + + const handleSortClick = useCallback((field: ArchivedThreadSortField) => { + setSort((current) => nextArchivedThreadSortState(current, field)); + }, []); + + const confirmArchivedAction = useCallback(async (message: string) => { + const localApi = readLocalApi(); + if (!localApi) 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 visibleFailures = failures.filter((failure) => !isAtomCommandInterrupted(failure)); + if (visibleFailures.length === 0) return; + const error = squashAtomCommandFailure(visibleFailures[0]!); + const interruptedCount = failures.length - visibleFailures.length; + const successCount = totalCount - failures.length; + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: [ + `${successCount} succeeded, ${visibleFailures.length} failed${ + interruptedCount > 0 ? `, ${interruptedCount} interrupted` : "" + }.`, + error instanceof Error ? error.message : "An error occurred.", + ].join(" "), + }), + ); + }, + [], + ); + + const handleUnarchiveThread = useCallback( + async (threadRef: ScopedThreadRef) => { + const result = await unarchiveThread(threadRef); + if (result._tag === "Success") { + refreshArchivedThreads(); + return; + } + showArchivedActionFailure("Failed to unarchive thread", result); + }, + [refreshArchivedThreads, showArchivedActionFailure, unarchiveThread], + ); + + const handleDeleteArchivedThread = useCallback( + async (threadRef: ScopedThreadRef, title: string) => { + if (confirmThreadDelete) { + const confirmed = await confirmArchivedAction( + [ + `Delete archived conversation "${title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) return; + } + const result = await deleteThread(threadRef); + if (result._tag === "Success") { + refreshArchivedThreads(); + return; + } + showArchivedActionFailure("Failed to delete thread", result); + }, + [ + confirmArchivedAction, + confirmThreadDelete, + deleteThread, + refreshArchivedThreads, + showArchivedActionFailure, + ], + ); + + const handleUnarchiveProjectThreads = useCallback( + async ( + projectName: string, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, + ) => { + const scopeLabel = archivedProjectBulkScopeLabel(scope); + const confirmed = await confirmArchivedAction( + [ + `Unarchive ${scopeLabel} in "${projectName}"?`, + `This will restore ${threads.length} conversation${threads.length === 1 ? "" : "s"}.`, + ].join("\n"), + ); + if (!confirmed) return; + const failures = await runArchivedProjectThreadActions(threads, (thread) => + unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)), + ); + if (failures.length > 0) { + showArchivedBulkActionFailure( + "Archived threads not fully unarchived", + failures, + threads.length, + ); + } + refreshArchivedThreads(); + }, + [confirmArchivedAction, refreshArchivedThreads, showArchivedBulkActionFailure, unarchiveThread], + ); + + const handleDeleteProjectThreads = useCallback( + async ( + projectName: string, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, + ) => { + const scopeLabel = archivedProjectBulkScopeLabel(scope); + if (confirmThreadDelete) { + const confirmed = await confirmArchivedAction( + [ + `Delete ${scopeLabel} in "${projectName}"?`, + `This permanently clears conversation history for ${threads.length} conversation${threads.length === 1 ? "" : "s"}.`, + ].join("\n"), + ); + if (!confirmed) return; + } + const failures = await runArchivedProjectThreadActions(threads, (thread) => + deleteThread(scopeThreadRef(thread.environmentId, thread.id)), + ); + if (failures.length > 0) { + showArchivedBulkActionFailure( + "Archived threads not fully deleted", + failures, + threads.length, + ); + } + refreshArchivedThreads(); + }, + [ + confirmArchivedAction, + confirmThreadDelete, + deleteThread, + refreshArchivedThreads, + showArchivedBulkActionFailure, + ], + ); + + const handleArchivedThreadContextMenu = useCallback( + async (threadRef: ScopedThreadRef, title: string, position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "unarchive", label: "Unarchive" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + + if (clicked === "unarchive") { + await handleUnarchiveThread(threadRef); + return; + } + + if (clicked === "delete") { + await handleDeleteArchivedThread(threadRef, title); + } + }, + [handleDeleteArchivedThread, handleUnarchiveThread], + ); + + const handleArchivedProjectContextMenu = useCallback( + async ( + projectName: string, + threads: ReadonlyArray, + scope: ArchivedProjectBulkScope, + position: { x: number; y: number }, + ) => { + const api = readLocalApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "unarchive-all", label: "Unarchive all" }, + { id: "delete-all", label: "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], + ); + + return ( + + setArchiveSearchQuery(event.currentTarget.value)} + placeholder="Search archived conversations" + aria-label="Search archived conversations" + /> + {archivedGroups.length === 0 ? ( + + + {isLoadingArchive ? ( + + ) : ( + + )} + {isLoadingArchive + ? "Loading archived threads" + : archiveError + ? "Could not load archived threads" + : archiveSearch.isSearching && hasArchivedThreads + ? "No matching archived threads" + : "No archived threads"} + + } + description={ + isLoadingArchive + ? "Checking connected environments." + : archiveError + ? archiveError + : archiveSearch.isSearching && hasArchivedThreads + ? `No archived conversation titles match "${archiveSearchQuery.trim()}".` + : "Archived threads will appear here." + } + /> + + ) : ( +
+ {archivedGroups.map(({ key: projectKey, project, threads: projectThreads }) => { + const isExpanded = archiveSearch.isSearching || expandedProjectKeys.has(projectKey); + const bulkScope = archiveSearch.isSearching ? "matching" : "all"; + return ( +
+
{ + event.preventDefault(); + void (async () => { + const result = await settlePromise(() => + handleArchivedProjectContextMenu(project.name, projectThreads, bulkScope, { + x: event.clientX, + y: event.clientY, + }), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Archived project action failed", + description: + error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }} + > + + {isExpanded ? ( + <> + handleSortClick("archivedAt")} + /> + handleSortClick("createdAt")} + /> + + ) : null} +
+ {isExpanded ? ( +
+ {projectThreads.map((thread) => ( +
{ + event.preventDefault(); + void (async () => { + const result = await settlePromise(() => + handleArchivedThreadContextMenu( + scopeThreadRef(thread.environmentId, thread.id), + thread.title, + { + x: event.clientX, + y: event.clientY, + }, + ), + ); + 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.", + }), + ); + } + })(); + }} + > +
+ {thread.title} +
+
+ {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} +
+
+ {formatRelativeTimeLabel(thread.createdAt)} +
+
event.stopPropagation()} + > + { + void handleUnarchiveThread( + scopeThreadRef(thread.environmentId, thread.id), + ); + }} + > + + + { + void handleDeleteArchivedThread( + scopeThreadRef(thread.environmentId, thread.id), + thread.title, + ); + }} + > + + +
+
+ ))} +
+ ) : null} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 832efeca9ba..6c0905a879c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,17 +1,6 @@ -import { - ArchiveIcon, - ArchiveX, - ArrowDownIcon, - ArrowUpIcon, - ChevronDownIcon, - ChevronRightIcon, - LoaderIcon, - PlusIcon, - RefreshCwIcon, - Trash2Icon, -} from "lucide-react"; +import { LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; -import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, @@ -20,14 +9,10 @@ import { ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, - type ScopedThreadRef, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { - type AtomCommandResult, isAtomCommandInterrupted, - settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; @@ -49,12 +34,7 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; -import { - useClientSettings, - usePrimarySettings, - useUpdatePrimarySettings, -} from "../../hooks/useSettings"; -import { useThreadActions } from "../../hooks/useThreadActions"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, @@ -72,12 +52,9 @@ import { serverEnvironment, } from "../../state/server"; import { usePrimaryEnvironment } from "../../state/environments"; -import { useProjects } from "../../state/entities"; -import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { formatRelativeTime } from "../../timestampFormat"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; -import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -93,18 +70,8 @@ import { import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { - archivedProjectBulkScopeLabel, - type ArchivedProjectBulkFailure, - type ArchivedProjectBulkScope, - type ArchivedProjectBulkThread, - type ArchivedThreadSortField, - type ArchivedThreadSortState, - buildArchivedThreadGroups, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, - nextArchivedThreadSortState, - parseArchivedThreadSearchInput, - runArchivedProjectThreadActions, } from "./SettingsPanels.logic"; import { SettingResetButton, @@ -113,8 +80,8 @@ import { SettingsSection, useRelativeTimeTick, } from "./settingsLayout"; -import { ProjectFavicon } from "../ProjectFavicon"; import { useAtomCommand } from "../../state/use-atom-command"; +export { ArchivedThreadsPanel } from "./ArchiveSettings"; const THEME_OPTIONS = [ { @@ -1446,546 +1413,3 @@ export function ProviderSettingsPanel() { ); } - -function ArchivedSortButton({ - field, - label, - sort, - onClick, -}: { - readonly field: ArchivedThreadSortField; - readonly label: string; - readonly sort: ArchivedThreadSortState; - readonly onClick: () => void; -}) { - const active = sort.field === field; - const SortIcon = sort.direction === "asc" ? ArrowUpIcon : ArrowDownIcon; - return ( - - ); -} - -function ArchivedIconButton({ - label, - destructive = false, - onClick, - children, -}: { - readonly label: string; - readonly destructive?: boolean; - readonly onClick: () => void; - readonly children: ReactNode; -}) { - return ( - - { - event.stopPropagation(); - onClick(); - }} - > - {children} - - } - /> - {label} - - ); -} - -export function ArchivedThreadsPanel() { - const projects = useProjects(); - const { unarchiveThread, deleteThread } = useThreadActions(); - const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); - const [expandedProjectKeys, setExpandedProjectKeys] = useState>( - () => new Set(), - ); - const [archiveSearchQuery, setArchiveSearchQuery] = useState(""); - const [sort, setSort] = useState({ - field: "archivedAt", - direction: "desc", - }); - useRelativeTimeTick(); - const environmentIds = useMemo( - () => [...new Set(projects.map((project) => project.environmentId))], - [projects], - ); - const { - snapshots: archivedSnapshots, - error: archiveError, - isLoading: isLoadingArchive, - refresh: refreshArchivedThreads, - } = useArchivedThreadSnapshots(environmentIds); - const archiveSearch = useMemo( - () => parseArchivedThreadSearchInput(archiveSearchQuery), - [archiveSearchQuery], - ); - const hasArchivedThreads = useMemo( - () => archivedSnapshots.some(({ snapshot }) => snapshot.threads.length > 0), - [archivedSnapshots], - ); - - const archivedGroups = useMemo( - () => - buildArchivedThreadGroups({ - snapshots: archivedSnapshots, - normalizedSearchQuery: archiveSearch.normalizedQuery, - searchTokens: archiveSearch.tokens, - isSearching: archiveSearch.isSearching, - sort, - }), - [ - archiveSearch.isSearching, - archiveSearch.normalizedQuery, - archiveSearch.tokens, - archivedSnapshots, - sort, - ], - ); - - const toggleProjectExpanded = useCallback((projectKey: string) => { - setExpandedProjectKeys((current) => { - const next = new Set(current); - if (next.has(projectKey)) { - next.delete(projectKey); - } else { - next.add(projectKey); - } - return next; - }); - }, []); - - const handleSortClick = useCallback((field: ArchivedThreadSortField) => { - setSort((current) => nextArchivedThreadSortState(current, field)); - }, []); - - const confirmArchivedAction = useCallback(async (message: string) => { - const localApi = readLocalApi(); - if (!localApi) 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 visibleFailures = failures.filter((failure) => !isAtomCommandInterrupted(failure)); - if (visibleFailures.length === 0) return; - const error = squashAtomCommandFailure(visibleFailures[0]!); - const interruptedCount = failures.length - visibleFailures.length; - const successCount = totalCount - failures.length; - toastManager.add( - stackedThreadToast({ - type: "error", - title, - description: [ - `${successCount} succeeded, ${visibleFailures.length} failed${ - interruptedCount > 0 ? `, ${interruptedCount} interrupted` : "" - }.`, - error instanceof Error ? error.message : "An error occurred.", - ].join(" "), - }), - ); - }, - [], - ); - - const handleUnarchiveThread = useCallback( - async (threadRef: ScopedThreadRef) => { - const result = await unarchiveThread(threadRef); - if (result._tag === "Success") { - refreshArchivedThreads(); - return; - } - showArchivedActionFailure("Failed to unarchive thread", result); - }, - [refreshArchivedThreads, showArchivedActionFailure, unarchiveThread], - ); - - const handleDeleteArchivedThread = useCallback( - async (threadRef: ScopedThreadRef, title: string) => { - if (confirmThreadDelete) { - const confirmed = await confirmArchivedAction( - [ - `Delete archived conversation "${title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), - ); - if (!confirmed) return; - } - const result = await deleteThread(threadRef); - if (result._tag === "Success") { - refreshArchivedThreads(); - return; - } - showArchivedActionFailure("Failed to delete thread", result); - }, - [ - confirmArchivedAction, - confirmThreadDelete, - deleteThread, - refreshArchivedThreads, - showArchivedActionFailure, - ], - ); - - const handleUnarchiveProjectThreads = useCallback( - async ( - projectName: string, - threads: ReadonlyArray, - scope: ArchivedProjectBulkScope, - ) => { - const scopeLabel = archivedProjectBulkScopeLabel(scope); - const confirmed = await confirmArchivedAction( - [ - `Unarchive ${scopeLabel} in "${projectName}"?`, - `This will restore ${threads.length} conversation${threads.length === 1 ? "" : "s"}.`, - ].join("\n"), - ); - if (!confirmed) return; - const failures = await runArchivedProjectThreadActions(threads, (thread) => - unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)), - ); - if (failures.length > 0) { - showArchivedBulkActionFailure( - "Archived threads not fully unarchived", - failures, - threads.length, - ); - } - refreshArchivedThreads(); - }, - [confirmArchivedAction, refreshArchivedThreads, showArchivedBulkActionFailure, unarchiveThread], - ); - - const handleDeleteProjectThreads = useCallback( - async ( - projectName: string, - threads: ReadonlyArray, - scope: ArchivedProjectBulkScope, - ) => { - const scopeLabel = archivedProjectBulkScopeLabel(scope); - if (confirmThreadDelete) { - const confirmed = await confirmArchivedAction( - [ - `Delete ${scopeLabel} in "${projectName}"?`, - `This permanently clears conversation history for ${threads.length} conversation${threads.length === 1 ? "" : "s"}.`, - ].join("\n"), - ); - if (!confirmed) return; - } - const failures = await runArchivedProjectThreadActions(threads, (thread) => - deleteThread(scopeThreadRef(thread.environmentId, thread.id)), - ); - if (failures.length > 0) { - showArchivedBulkActionFailure( - "Archived threads not fully deleted", - failures, - threads.length, - ); - } - refreshArchivedThreads(); - }, - [ - confirmArchivedAction, - confirmThreadDelete, - deleteThread, - refreshArchivedThreads, - showArchivedBulkActionFailure, - ], - ); - - const handleArchivedThreadContextMenu = useCallback( - async (threadRef: ScopedThreadRef, title: string, position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "unarchive", label: "Unarchive" }, - { id: "delete", label: "Delete", destructive: true }, - ], - position, - ); - - if (clicked === "unarchive") { - await handleUnarchiveThread(threadRef); - return; - } - - if (clicked === "delete") { - await handleDeleteArchivedThread(threadRef, title); - } - }, - [handleDeleteArchivedThread, handleUnarchiveThread], - ); - - const handleArchivedProjectContextMenu = useCallback( - async ( - projectName: string, - threads: ReadonlyArray, - scope: ArchivedProjectBulkScope, - position: { x: number; y: number }, - ) => { - const api = readLocalApi(); - if (!api) return; - const clicked = await api.contextMenu.show( - [ - { id: "unarchive-all", label: "Unarchive all" }, - { id: "delete-all", label: "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], - ); - - return ( - - setArchiveSearchQuery(event.currentTarget.value)} - placeholder="Search archived conversations" - aria-label="Search archived conversations" - /> - {archivedGroups.length === 0 ? ( - - - {isLoadingArchive ? ( - - ) : ( - - )} - {isLoadingArchive - ? "Loading archived threads" - : archiveError - ? "Could not load archived threads" - : archiveSearch.isSearching && hasArchivedThreads - ? "No matching archived threads" - : "No archived threads"} - - } - description={ - isLoadingArchive - ? "Checking connected environments." - : archiveError - ? archiveError - : archiveSearch.isSearching && hasArchivedThreads - ? `No archived conversation titles match "${archiveSearchQuery.trim()}".` - : "Archived threads will appear here." - } - /> - - ) : ( -
- {archivedGroups.map(({ key: projectKey, project, threads: projectThreads }) => { - const isExpanded = archiveSearch.isSearching || expandedProjectKeys.has(projectKey); - const bulkScope = archiveSearch.isSearching ? "matching" : "all"; - return ( -
-
{ - event.preventDefault(); - void (async () => { - const result = await settlePromise(() => - handleArchivedProjectContextMenu(project.name, projectThreads, bulkScope, { - x: event.clientX, - y: event.clientY, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Archived project action failed", - description: - error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }} - > - - {isExpanded ? ( - <> - handleSortClick("archivedAt")} - /> - handleSortClick("createdAt")} - /> - - ) : null} -
- {isExpanded ? ( -
- {projectThreads.map((thread) => ( -
{ - event.preventDefault(); - void (async () => { - const result = await settlePromise(() => - handleArchivedThreadContextMenu( - scopeThreadRef(thread.environmentId, thread.id), - thread.title, - { - x: event.clientX, - y: event.clientY, - }, - ), - ); - 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.", - }), - ); - } - })(); - }} - > -
- {thread.title} -
-
- {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} -
-
- {formatRelativeTimeLabel(thread.createdAt)} -
-
event.stopPropagation()} - > - { - void handleUnarchiveThread( - scopeThreadRef(thread.environmentId, thread.id), - ); - }} - > - - - { - void handleDeleteArchivedThread( - scopeThreadRef(thread.environmentId, thread.id), - thread.title, - ); - }} - > - - -
-
- ))} -
- ) : null} -
- ); - })} -
- )} -
- ); -} From 39e2452bb4a1d2ebc1bdd5b0c400177b11e1ed3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 27 Jun 2026 19:11:49 +0100 Subject: [PATCH 18/24] Fix archived project action access - Keep archived delete confirmations when browser APIs are used - Add an explicit project actions menu button --- .../components/settings/ArchiveSettings.tsx | 85 +++++++++++++++---- 1 file changed, 70 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/settings/ArchiveSettings.tsx b/apps/web/src/components/settings/ArchiveSettings.tsx index 2058091b067..6d594d7f0e8 100644 --- a/apps/web/src/components/settings/ArchiveSettings.tsx +++ b/apps/web/src/components/settings/ArchiveSettings.tsx @@ -5,6 +5,7 @@ import { ArrowUpIcon, ChevronDownIcon, ChevronRightIcon, + EllipsisIcon, LoaderIcon, Trash2Icon, } from "lucide-react"; @@ -179,8 +180,13 @@ export function ArchivedThreadsPanel() { const confirmArchivedAction = useCallback(async (message: string) => { const localApi = readLocalApi(); - if (!localApi) return true; - const confirmationResult = await settlePromise(() => localApi.dialogs.confirm(message)); + const confirmationResult = await settlePromise(() => + localApi + ? localApi.dialogs.confirm(message) + : typeof window !== "undefined" + ? Promise.resolve(window.confirm(message)) + : Promise.resolve(false), + ); if (confirmationResult._tag === "Failure") { const error = squashAtomCommandFailure(confirmationResult); toastManager.add( @@ -234,6 +240,21 @@ export function ArchivedThreadsPanel() { [], ); + const showArchivedProjectMenuFailure = useCallback( + (result: AtomCommandResult) => { + if (result._tag === "Success") return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Archived project action failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + [], + ); + const handleUnarchiveThread = useCallback( async (threadRef: ScopedThreadRef) => { const result = await unarchiveThread(threadRef); @@ -392,6 +413,25 @@ export function ArchivedThreadsPanel() { [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, + }), + ); + showArchivedProjectMenuFailure(result); + }, + [handleArchivedProjectContextMenu, showArchivedProjectMenuFailure], + ); + return ( { event.preventDefault(); @@ -457,17 +497,7 @@ export function ArchivedThreadsPanel() { y: event.clientY, }), ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Archived project action failed", - description: - error instanceof Error ? error.message : "An error occurred.", - }), - ); - } + showArchivedProjectMenuFailure(result); })(); }} > @@ -507,6 +537,31 @@ export function ArchivedThreadsPanel() { /> ) : null} + + { + event.stopPropagation(); + void handleArchivedProjectMenuButton( + project.name, + projectThreads, + bulkScope, + event.currentTarget, + ); + }} + > + + + } + /> + Project actions +
{isExpanded ? (
From 19e1ffa927c38b3e77f34d3e7fbbdd76a229521d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 27 Jun 2026 19:19:21 +0100 Subject: [PATCH 19/24] Align archived thread rows with headers - Add the project action spacer column to archived thread rows --- apps/web/src/components/settings/ArchiveSettings.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ArchiveSettings.tsx b/apps/web/src/components/settings/ArchiveSettings.tsx index 6d594d7f0e8..2e767ad7207 100644 --- a/apps/web/src/components/settings/ArchiveSettings.tsx +++ b/apps/web/src/components/settings/ArchiveSettings.tsx @@ -568,7 +568,7 @@ export function ArchivedThreadsPanel() { {projectThreads.map((thread) => (
{ event.preventDefault(); void (async () => { @@ -605,6 +605,7 @@ export function ArchivedThreadsPanel() {
{formatRelativeTimeLabel(thread.createdAt)}
+