| author | |
| committer | |
| log | df65a6c3ad605feb2f326c0ad78d056c10476770 |
| tree | 4336777a772ee5561f7c256823c77f2027ec4676 |
| parent | 3235658c080bc12fcd1ffaa275aced98d225f2f6 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 180 insertions(+), 14 deletions(-)
apps/web/src/components/Sidebar.logic.test.ts+69| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { | 2 | import { |
| 3 | archiveSelectedThreadEntries, | ||
| 4 | buildMultiSelectThreadContextMenuItems, | ||
| 3 | createThreadJumpHintVisibilityController, | 5 | createThreadJumpHintVisibilityController, |
| 4 | getSidebarThreadIdsToPrewarm, | 6 | getSidebarThreadIdsToPrewarm, |
| 5 | getVisibleSidebarThreadIds, | 7 | getVisibleSidebarThreadIds, |
| ... | @@ -38,6 +40,73 @@ import { | ... | @@ -38,6 +40,73 @@ import { |
| 38 | 40 | ||
| 39 | const localEnvironmentId = EnvironmentId.make("environment-local"); | 41 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 40 | 42 | ||
| 43 | describe("archiveSelectedThreadEntries", () => { | ||
| 44 | const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; | ||
| 45 | const success = { _tag: "Success" } as const; | ||
| 46 | const failure = { _tag: "Failure" } as const; | ||
| 47 | |||
| 48 | it("records every entry after full success", async () => { | ||
| 49 | const outcome = await archiveSelectedThreadEntries({ | ||
| 50 | entries, | ||
| 51 | archive: async (_entry, onArchived) => { | ||
| 52 | onArchived(); | ||
| 53 | return success; | ||
| 54 | }, | ||
| 55 | }); | ||
| 56 | |||
| 57 | expect(outcome).toEqual({ | ||
| 58 | archivedThreadKeys: ["one", "two", "three"], | ||
| 59 | mutationFailure: null, | ||
| 60 | followupFailures: [], | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("stops at a mutation failure and retains prior successes", async () => { | ||
| 65 | const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { | ||
| 66 | if (entry.threadKey === "two") return failure; | ||
| 67 | onArchived(); | ||
| 68 | return success; | ||
| 69 | }); | ||
| 70 | const outcome = await archiveSelectedThreadEntries({ entries, archive }); | ||
| 71 | |||
| 72 | expect(archive).toHaveBeenCalledTimes(2); | ||
| 73 | expect(outcome).toEqual({ | ||
| 74 | archivedThreadKeys: ["one"], | ||
| 75 | mutationFailure: failure, | ||
| 76 | followupFailures: [], | ||
| 77 | }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("continues after a post-archive failure", async () => { | ||
| 81 | const archive = vi.fn(async (entry: (typeof entries)[number], onArchived: () => void) => { | ||
| 82 | onArchived(); | ||
| 83 | return entry.threadKey === "two" ? failure : success; | ||
| 84 | }); | ||
| 85 | const outcome = await archiveSelectedThreadEntries({ entries, archive }); | ||
| 86 | |||
| 87 | expect(archive).toHaveBeenCalledTimes(3); | ||
| 88 | expect(outcome).toEqual({ | ||
| 89 | archivedThreadKeys: ["one", "two", "three"], | ||
| 90 | mutationFailure: null, | ||
| 91 | followupFailures: [failure], | ||
| 92 | }); | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | describe("buildMultiSelectThreadContextMenuItems", () => { | ||
| 97 | it("offers bulk archive with the selected count", () => { | ||
| 98 | expect( | ||
| 99 | buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }), | ||
| 100 | ).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false }); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("disables bulk archive when a selected thread is running", () => { | ||
| 104 | expect( | ||
| 105 | buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }), | ||
| 106 | ).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true }); | ||
| 107 | }); | ||
| 108 | }); | ||
| 109 | |||
| 41 | describe("resolveSidebarStageBadgeLabel", () => { | 110 | describe("resolveSidebarStageBadgeLabel", () => { |
| 42 | it("returns Nightly for nightly primary server versions", () => { | 111 | it("returns Nightly for nightly primary server versions", () => { |
| 43 | expect( | 112 | expect( |
apps/web/src/components/Sidebar.logic.ts+50| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import * as React from "react"; | 1 | import * as React from "react"; |
| 2 | import type { ContextMenuItem } from "@t3tools/contracts"; | ||
| 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | 3 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 3 | import { | 4 | import { |
| 4 | getThreadSortTimestamp, | 5 | getThreadSortTimestamp, |
| ... | @@ -36,6 +37,55 @@ type ScopedSidebarThread = ThreadSortInput & { | ... | @@ -36,6 +37,55 @@ type ScopedSidebarThread = ThreadSortInput & { |
| 36 | 37 | ||
| 37 | export type ThreadTraversalDirection = "previous" | "next"; | 38 | export type ThreadTraversalDirection = "previous" | "next"; |
| 38 | 39 | ||
| 40 | export async function archiveSelectedThreadEntries< | ||
| 41 | TEntry extends { readonly threadKey: string }, | ||
| 42 | TResult extends { readonly _tag: "Success" | "Failure" }, | ||
| 43 | >(input: { | ||
| 44 | entries: readonly TEntry[]; | ||
| 45 | archive: (entry: TEntry, onArchived: () => void) => Promise<TResult>; | ||
| 46 | }): Promise<{ | ||
| 47 | archivedThreadKeys: readonly string[]; | ||
| 48 | mutationFailure: Extract<TResult, { readonly _tag: "Failure" }> | null; | ||
| 49 | followupFailures: readonly Extract<TResult, { readonly _tag: "Failure" }>[]; | ||
| 50 | }> { | ||
| 51 | const archivedThreadKeys: string[] = []; | ||
| 52 | const followupFailures: Extract<TResult, { readonly _tag: "Failure" }>[] = []; | ||
| 53 | |||
| 54 | for (const entry of input.entries) { | ||
| 55 | let didArchive = false; | ||
| 56 | const result = await input.archive(entry, () => { | ||
| 57 | didArchive = true; | ||
| 58 | }); | ||
| 59 | if (didArchive || result._tag === "Success") { | ||
| 60 | archivedThreadKeys.push(entry.threadKey); | ||
| 61 | } | ||
| 62 | if (result._tag === "Success") continue; | ||
| 63 | const failure = result as Extract<TResult, { readonly _tag: "Failure" }>; | ||
| 64 | if (didArchive) { | ||
| 65 | followupFailures.push(failure); | ||
| 66 | continue; | ||
| 67 | } | ||
| 68 | return { archivedThreadKeys, mutationFailure: failure, followupFailures }; | ||
| 69 | } | ||
| 70 | |||
| 71 | return { archivedThreadKeys, mutationFailure: null, followupFailures }; | ||
| 72 | } | ||
| 73 | |||
| 74 | export function buildMultiSelectThreadContextMenuItems(input: { | ||
| 75 | count: number; | ||
| 76 | hasRunningThread: boolean; | ||
| 77 | }): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { | ||
| 78 | return [ | ||
| 79 | { id: "mark-unread", label: `Mark unread (${input.count})` }, | ||
| 80 | { | ||
| 81 | id: "archive", | ||
| 82 | label: `Archive (${input.count})`, | ||
| 83 | disabled: input.hasRunningThread, | ||
| 84 | }, | ||
| 85 | { id: "delete", label: `Delete (${input.count})`, destructive: true }, | ||
| 86 | ]; | ||
| 87 | } | ||
| 88 | |||
| 39 | export interface ThreadStatusPill { | 89 | export interface ThreadStatusPill { |
| 40 | label: | 90 | label: |
| 41 | | "Working" | 91 | | "Working" |
apps/web/src/components/Sidebar.tsx+58-11| ... | @@ -185,6 +185,8 @@ import { | ... | @@ -185,6 +185,8 @@ import { |
| 185 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 185 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 186 | import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; | 186 | import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; |
| 187 | import { | 187 | import { |
| 188 | archiveSelectedThreadEntries, | ||
| 189 | buildMultiSelectThreadContextMenuItems, | ||
| 188 | getSidebarThreadIdsToPrewarm, | 190 | getSidebarThreadIdsToPrewarm, |
| 189 | resolveAdjacentThreadId, | 191 | resolveAdjacentThreadId, |
| 190 | isContextMenuPointerDown, | 192 | isContextMenuPointerDown, |
| ... | @@ -1775,24 +1777,69 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1775,24 +1777,69 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1775 | const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; | 1777 | const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; |
| 1776 | if (threadKeys.length === 0) return; | 1778 | if (threadKeys.length === 0) return; |
| 1777 | const count = threadKeys.length; | 1779 | const count = threadKeys.length; |
| 1780 | const selectedThreadEntries = threadKeys.flatMap((threadKey) => { | ||
| 1781 | const threadRef = parseScopedThreadKey(threadKey); | ||
| 1782 | const thread = threadRef ? readThreadShell(threadRef) : null; | ||
| 1783 | return threadRef && thread ? [{ threadKey, threadRef, thread }] : []; | ||
| 1784 | }); | ||
| 1785 | const hasRunningThread = selectedThreadEntries.some( | ||
| 1786 | ({ thread }) => thread.session?.status === "running" && thread.session.activeTurnId != null, | ||
| 1787 | ); | ||
| 1778 | 1788 | ||
| 1779 | const clicked = await api.contextMenu.show( | 1789 | const clicked = await api.contextMenu.show( |
| 1780 | [ | 1790 | buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }), |
| 1781 | { id: "mark-unread", label: `Mark unread (${count})` }, | ||
| 1782 | { id: "delete", label: `Delete (${count})`, destructive: true }, | ||
| 1783 | ], | ||
| 1784 | position, | 1791 | position, |
| 1785 | ); | 1792 | ); |
| 1786 | 1793 | ||
| 1787 | if (clicked === "mark-unread") { | 1794 | if (clicked === "mark-unread") { |
| 1788 | for (const threadKey of threadKeys) { | 1795 | for (const { threadKey, thread } of selectedThreadEntries) { |
| 1789 | const thread = sidebarThreadByKeyRef.current.get(threadKey); | 1796 | markThreadUnread(threadKey, thread.latestTurn?.completedAt); |
| 1790 | markThreadUnread(threadKey, thread?.latestTurn?.completedAt); | ||
| 1791 | } | 1797 | } |
| 1792 | clearSelection(); | 1798 | clearSelection(); |
| 1793 | return; | 1799 | return; |
| 1794 | } | 1800 | } |
| 1795 | 1801 | ||
| 1802 | if (clicked === "archive") { | ||
| 1803 | if (appSettingsConfirmThreadArchive) { | ||
| 1804 | const confirmed = await api.dialogs.confirm( | ||
| 1805 | `Archive ${count} thread${count === 1 ? "" : "s"}?`, | ||
| 1806 | ); | ||
| 1807 | if (!confirmed) return; | ||
| 1808 | } | ||
| 1809 | |||
| 1810 | const archiveOutcome = await archiveSelectedThreadEntries({ | ||
| 1811 | entries: selectedThreadEntries, | ||
| 1812 | archive: ({ threadRef }, onArchived) => archiveThread(threadRef, { onArchived }), | ||
| 1813 | }); | ||
| 1814 | for (const failure of archiveOutcome.followupFailures) { | ||
| 1815 | if (isAtomCommandInterrupted(failure)) continue; | ||
| 1816 | const error = squashAtomCommandFailure(failure); | ||
| 1817 | toastManager.add( | ||
| 1818 | stackedThreadToast({ | ||
| 1819 | type: "error", | ||
| 1820 | title: "Thread archived, but navigation failed", | ||
| 1821 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1822 | }), | ||
| 1823 | ); | ||
| 1824 | } | ||
| 1825 | if (archiveOutcome.mutationFailure) { | ||
| 1826 | removeFromSelection(archiveOutcome.archivedThreadKeys); | ||
| 1827 | if (!isAtomCommandInterrupted(archiveOutcome.mutationFailure)) { | ||
| 1828 | const error = squashAtomCommandFailure(archiveOutcome.mutationFailure); | ||
| 1829 | toastManager.add( | ||
| 1830 | stackedThreadToast({ | ||
| 1831 | type: "error", | ||
| 1832 | title: "Failed to archive threads", | ||
| 1833 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 1834 | }), | ||
| 1835 | ); | ||
| 1836 | } | ||
| 1837 | return; | ||
| 1838 | } | ||
| 1839 | removeFromSelection(threadKeys); | ||
| 1840 | return; | ||
| 1841 | } | ||
| 1842 | |||
| 1796 | if (clicked !== "delete") return; | 1843 | if (clicked !== "delete") return; |
| 1797 | 1844 | ||
| 1798 | if (appSettingsConfirmThreadDelete) { | 1845 | if (appSettingsConfirmThreadDelete) { |
| ... | @@ -1806,10 +1853,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1806,10 +1853,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1806 | } | 1853 | } |
| 1807 | 1854 | ||
| 1808 | const deletedThreadKeys = new Set(threadKeys); | 1855 | const deletedThreadKeys = new Set(threadKeys); |
| 1809 | for (const threadKey of threadKeys) { | 1856 | for (const { threadRef } of selectedThreadEntries) { |
| 1810 | const thread = sidebarThreadByKeyRef.current.get(threadKey); | 1857 | const result = await deleteThread(threadRef, { |
| 1811 | if (!thread) continue; | ||
| 1812 | const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { | ||
| 1813 | deletedThreadKeys, | 1858 | deletedThreadKeys, |
| 1814 | }); | 1859 | }); |
| 1815 | if (result._tag === "Failure") { | 1860 | if (result._tag === "Failure") { |
| ... | @@ -1829,7 +1874,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1829,7 +1874,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1829 | removeFromSelection(threadKeys); | 1874 | removeFromSelection(threadKeys); |
| 1830 | }, | 1875 | }, |
| 1831 | [ | 1876 | [ |
| 1877 | appSettingsConfirmThreadArchive, | ||
| 1832 | appSettingsConfirmThreadDelete, | 1878 | appSettingsConfirmThreadDelete, |
| 1879 | archiveThread, | ||
| 1833 | clearSelection, | 1880 | clearSelection, |
| 1834 | deleteThread, | 1881 | deleteThread, |
| 1835 | markThreadUnread, | 1882 | markThreadUnread, |
apps/web/src/hooks/useThreadActions.ts+3-3| ... | @@ -89,7 +89,7 @@ export function useThreadActions() { | ... | @@ -89,7 +89,7 @@ export function useThreadActions() { |
| 89 | }, [router]); | 89 | }, [router]); |
| 90 | 90 | ||
| 91 | const archiveThread = useCallback( | 91 | const archiveThread = useCallback( |
| 92 | async (target: ScopedThreadRef) => { | 92 | async (target: ScopedThreadRef, opts: { onArchived?: () => void } = {}) => { |
| 93 | const resolved = resolveThreadTarget(target); | 93 | const resolved = resolveThreadTarget(target); |
| 94 | if (!resolved) return AsyncResult.success(undefined); | 94 | if (!resolved) return AsyncResult.success(undefined); |
| 95 | const { thread, threadRef } = resolved; | 95 | const { thread, threadRef } = resolved; |
| ... | @@ -115,6 +115,8 @@ export function useThreadActions() { | ... | @@ -115,6 +115,8 @@ export function useThreadActions() { |
| 115 | if (archiveResult._tag === "Failure") { | 115 | if (archiveResult._tag === "Failure") { |
| 116 | return archiveResult; | 116 | return archiveResult; |
| 117 | } | 117 | } |
| 118 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | ||
| 119 | opts.onArchived?.(); | ||
| 118 | 120 | ||
| 119 | if (shouldNavigateToDraft) { | 121 | if (shouldNavigateToDraft) { |
| 120 | const navigationResult = await settlePromise(() => | 122 | const navigationResult = await settlePromise(() => |
| ... | @@ -123,11 +125,9 @@ export function useThreadActions() { | ... | @@ -123,11 +125,9 @@ export function useThreadActions() { |
| 123 | if (navigationResult._tag === "Failure") { | 125 | if (navigationResult._tag === "Failure") { |
| 124 | return navigationResult; | 126 | return navigationResult; |
| 125 | } | 127 | } |
| 126 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | ||
| 127 | return archiveResult; | 128 | return archiveResult; |
| 128 | } | 129 | } |
| 129 | 130 | ||
| 130 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | ||
| 131 | return archiveResult; | 131 | return archiveResult; |
| 132 | }, | 132 | }, |
| 133 | [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], | 133 | [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], |