| author | |
| committer | |
| log | 22c311ddecfbab2e541a374a46f2df87d4fc6305 |
| tree | 3ccb28628e0656f6491fc25cf50f34260a11950b |
| parent | ac3b2adf96e755580fc09e149b7c7eb057d0b282 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com>12 files changed, 177 insertions(+), 21 deletions(-)
apps/desktop/src/settings/DesktopClientSettings.test.ts+1| ... | @@ -21,6 +21,7 @@ const clientSettings: ClientSettings = { | ... | @@ -21,6 +21,7 @@ const clientSettings: ClientSettings = { |
| 21 | confirmQuit: true, | 21 | confirmQuit: true, |
| 22 | confirmThreadArchive: true, | 22 | confirmThreadArchive: true, |
| 23 | confirmThreadDelete: false, | 23 | confirmThreadDelete: false, |
| 24 | confirmThreadUnpin: false, | ||
| 24 | dismissedProviderUpdateNotificationKeys: [], | 25 | dismissedProviderUpdateNotificationKeys: [], |
| 25 | diffIgnoreWhitespace: true, | 26 | diffIgnoreWhitespace: true, |
| 26 | environmentIdentificationMode: "artwork", | 27 | environmentIdentificationMode: "artwork", |
apps/web/src/components/ChatView.tsx+15-13| ... | @@ -1273,7 +1273,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1273,7 +1273,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1273 | const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; | 1273 | const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; |
| 1274 | const threadDetailLoading = threadSyncPhase === "loading"; | 1274 | const threadDetailLoading = threadSyncPhase === "loading"; |
| 1275 | const handleNewThread = useNewThreadHandler(); | 1275 | const handleNewThread = useNewThreadHandler(); |
| 1276 | const { settleThread, pinThread, unpinThread } = useThreadActions(); | 1276 | const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); |
| 1277 | const routeThreadRef = useMemo( | 1277 | const routeThreadRef = useMemo( |
| 1278 | () => scopeThreadRef(environmentId, threadId), | 1278 | () => scopeThreadRef(environmentId, threadId), |
| 1279 | [environmentId, threadId], | 1279 | [environmentId, threadId], |
| ... | @@ -5146,17 +5146,19 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5146,17 +5146,19 @@ function ChatViewContent(props: ChatViewProps) { |
| 5146 | event.stopPropagation(); | 5146 | event.stopPropagation(); |
| 5147 | if (!isServerThread || !activeThreadRef || !supportsPinning) return; | 5147 | if (!isServerThread || !activeThreadRef || !supportsPinning) return; |
| 5148 | const pinned = activeThreadPinned; | 5148 | const pinned = activeThreadPinned; |
| 5149 | void (pinned ? unpinThread(activeThreadRef) : pinThread(activeThreadRef)).then((result) => { | 5149 | void (pinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef)).then( |
| 5150 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; | 5150 | (result) => { |
| 5151 | const error = squashAtomCommandFailure(result); | 5151 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; |
| 5152 | toastManager.add( | 5152 | const error = squashAtomCommandFailure(result); |
| 5153 | stackedThreadToast({ | 5153 | toastManager.add( |
| 5154 | type: "error", | 5154 | stackedThreadToast({ |
| 5155 | title: pinned ? "Failed to unpin thread" : "Failed to pin thread", | 5155 | type: "error", |
| 5156 | description: error instanceof Error ? error.message : "An error occurred.", | 5156 | title: pinned ? "Failed to unpin thread" : "Failed to pin thread", |
| 5157 | }), | 5157 | description: error instanceof Error ? error.message : "An error occurred.", |
| 5158 | ); | 5158 | }), |
| 5159 | }); | 5159 | ); |
| 5160 | }, | ||
| 5161 | ); | ||
| 5160 | return; | 5162 | return; |
| 5161 | } | 5163 | } |
| 5162 | 5164 | ||
| ... | @@ -5284,7 +5286,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5284,7 +5286,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5284 | settleThread, | 5286 | settleThread, |
| 5285 | supportsPinning, | 5287 | supportsPinning, |
| 5286 | supportsSettlement, | 5288 | supportsSettlement, |
| 5287 | unpinThread, | 5289 | confirmAndUnpinThread, |
| 5288 | toggleRightPanel, | 5290 | toggleRightPanel, |
| 5289 | toggleRightPanelMaximized, | 5291 | toggleRightPanelMaximized, |
| 5290 | toggleTerminalVisibility, | 5292 | toggleTerminalVisibility, |
apps/web/src/components/Sidebar.tsx+3-3| ... | @@ -1749,7 +1749,7 @@ export default function Sidebar() { | ... | @@ -1749,7 +1749,7 @@ export default function Sidebar() { |
| 1749 | snoozeThread, | 1749 | snoozeThread, |
| 1750 | unsnoozeThread, | 1750 | unsnoozeThread, |
| 1751 | pinThread, | 1751 | pinThread, |
| 1752 | unpinThread, | 1752 | confirmAndUnpinThread, |
| 1753 | reorderPinnedThread, | 1753 | reorderPinnedThread, |
| 1754 | archiveThread, | 1754 | archiveThread, |
| 1755 | deleteThread, | 1755 | deleteThread, |
| ... | @@ -2709,7 +2709,7 @@ export default function Sidebar() { | ... | @@ -2709,7 +2709,7 @@ export default function Sidebar() { |
| 2709 | const attemptUnpin = useCallback( | 2709 | const attemptUnpin = useCallback( |
| 2710 | (threadRef: ScopedThreadRef) => { | 2710 | (threadRef: ScopedThreadRef) => { |
| 2711 | void (async () => { | 2711 | void (async () => { |
| 2712 | const result = await unpinThread(threadRef); | 2712 | const result = await confirmAndUnpinThread(threadRef); |
| 2713 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 2713 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 2714 | const error = squashAtomCommandFailure(result); | 2714 | const error = squashAtomCommandFailure(result); |
| 2715 | toastManager.add( | 2715 | toastManager.add( |
| ... | @@ -2722,7 +2722,7 @@ export default function Sidebar() { | ... | @@ -2722,7 +2722,7 @@ export default function Sidebar() { |
| 2722 | } | 2722 | } |
| 2723 | })(); | 2723 | })(); |
| 2724 | }, | 2724 | }, |
| 2725 | [unpinThread], | 2725 | [confirmAndUnpinThread], |
| 2726 | ); | 2726 | ); |
| 2727 | 2727 | ||
| 2728 | const handlePinnedDragEnd = useCallback( | 2728 | const handlePinnedDragEnd = useCallback( |
apps/web/src/components/settings/SettingsPanels.tsx+31| ... | @@ -526,6 +526,9 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -526,6 +526,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 526 | ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory | 526 | ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory |
| 527 | ? ["Add project base directory"] | 527 | ? ["Add project base directory"] |
| 528 | : []), | 528 | : []), |
| 529 | ...(settings.confirmThreadUnpin !== DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin | ||
| 530 | ? ["Unpin confirmation"] | ||
| 531 | : []), | ||
| 529 | ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive | 532 | ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive |
| 530 | ? ["Archive confirmation"] | 533 | ? ["Archive confirmation"] |
| 531 | : []), | 534 | : []), |
| ... | @@ -554,6 +557,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -554,6 +557,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 554 | settings.confirmQuit, | 557 | settings.confirmQuit, |
| 555 | settings.confirmThreadArchive, | 558 | settings.confirmThreadArchive, |
| 556 | settings.confirmThreadDelete, | 559 | settings.confirmThreadDelete, |
| 560 | settings.confirmThreadUnpin, | ||
| 557 | settings.addProjectBaseDirectory, | 561 | settings.addProjectBaseDirectory, |
| 558 | settings.defaultThreadEnvMode, | 562 | settings.defaultThreadEnvMode, |
| 559 | settings.newWorktreesStartFromOrigin, | 563 | settings.newWorktreesStartFromOrigin, |
| ... | @@ -666,6 +670,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -666,6 +670,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 666 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, | 670 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, |
| 667 | confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, | 671 | confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, |
| 668 | confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, | 672 | confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, |
| 673 | confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, | ||
| 669 | confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, | 674 | confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, |
| 670 | textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, | 675 | textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, |
| 671 | fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, | 676 | fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, |
| ... | @@ -2313,6 +2318,32 @@ export function GeneralSettingsPanel() { | ... | @@ -2313,6 +2318,32 @@ export function GeneralSettingsPanel() { |
| 2313 | } | 2318 | } |
| 2314 | /> | 2319 | /> |
| 2315 | 2320 | ||
| 2321 | <SettingsRow | ||
| 2322 | {...searchableSetting("unpin-confirmation")} | ||
| 2323 | description="Ask before unpinning a thread from the pinned section." | ||
| 2324 | resetAction={ | ||
| 2325 | settings.confirmThreadUnpin !== DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin ? ( | ||
| 2326 | <SettingResetButton | ||
| 2327 | label="unpin confirmation" | ||
| 2328 | onClick={() => | ||
| 2329 | updateSettings({ | ||
| 2330 | confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, | ||
| 2331 | }) | ||
| 2332 | } | ||
| 2333 | /> | ||
| 2334 | ) : null | ||
| 2335 | } | ||
| 2336 | control={ | ||
| 2337 | <Switch | ||
| 2338 | checked={settings.confirmThreadUnpin} | ||
| 2339 | onCheckedChange={(checked) => | ||
| 2340 | updateSettings({ confirmThreadUnpin: Boolean(checked) }) | ||
| 2341 | } | ||
| 2342 | aria-label="Confirm thread unpinning" | ||
| 2343 | /> | ||
| 2344 | } | ||
| 2345 | /> | ||
| 2346 | |||
| 2316 | <SettingsRow | 2347 | <SettingsRow |
| 2317 | {...searchableSetting("archive-confirmation")} | 2348 | {...searchableSetting("archive-confirmation")} |
| 2318 | description="Require a second click on the inline archive action before a thread is archived." | 2349 | description="Require a second click on the inline archive action before a thread is archived." |
apps/web/src/components/settings/settingsSearch.test.ts+8| ... | @@ -56,6 +56,14 @@ describe("searchSettings", () => { | ... | @@ -56,6 +56,14 @@ describe("searchSettings", () => { |
| 56 | ]); | 56 | ]); |
| 57 | }); | 57 | }); |
| 58 | 58 | ||
| 59 | it("lists thread confirmations in panel order", () => { | ||
| 60 | expect(searchSettings("confirmation").map((item) => item.id)).toEqual([ | ||
| 61 | "unpin-confirmation", | ||
| 62 | "archive-confirmation", | ||
| 63 | "delete-confirmation", | ||
| 64 | ]); | ||
| 65 | }); | ||
| 66 | |||
| 59 | it("returns no results for an empty query", () => { | 67 | it("returns no results for an empty query", () => { |
| 60 | expect(searchSettings(" ", ITEMS)).toEqual([]); | 68 | expect(searchSettings(" ", ITEMS)).toEqual([]); |
| 61 | }); | 69 | }); |
apps/web/src/components/settings/settingsSearch.ts+5| ... | @@ -152,6 +152,11 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -152,6 +152,11 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 152 | title: "Add project starts in", | 152 | title: "Add project starts in", |
| 153 | to: "/settings/general", | 153 | to: "/settings/general", |
| 154 | }, | 154 | }, |
| 155 | { | ||
| 156 | id: "unpin-confirmation", | ||
| 157 | title: "Unpin confirmation", | ||
| 158 | to: "/settings/general", | ||
| 159 | }, | ||
| 155 | { | 160 | { |
| 156 | id: "archive-confirmation", | 161 | id: "archive-confirmation", |
| 157 | title: "Archive confirmation", | 162 | title: "Archive confirmation", |
apps/web/src/hooks/useThreadActionMenu.ts+5-4| ... | @@ -71,7 +71,7 @@ export function useThreadActionMenu(input: { | ... | @@ -71,7 +71,7 @@ export function useThreadActionMenu(input: { |
| 71 | snoozeThread, | 71 | snoozeThread, |
| 72 | unsnoozeThread, | 72 | unsnoozeThread, |
| 73 | pinThread, | 73 | pinThread, |
| 74 | unpinThread, | 74 | confirmAndUnpinThread, |
| 75 | archiveThread, | 75 | archiveThread, |
| 76 | deleteThread, | 76 | deleteThread, |
| 77 | } = useThreadActions(); | 77 | } = useThreadActions(); |
| ... | @@ -215,9 +215,10 @@ export function useThreadActionMenu(input: { | ... | @@ -215,9 +215,10 @@ export function useThreadActionMenu(input: { |
| 215 | case "pin": | 215 | case "pin": |
| 216 | await reportFailure("Failed to pin thread", () => pinThread(threadRef)); | 216 | await reportFailure("Failed to pin thread", () => pinThread(threadRef)); |
| 217 | return; | 217 | return; |
| 218 | case "unpin": | 218 | case "unpin": { |
| 219 | await reportFailure("Failed to unpin thread", () => unpinThread(threadRef)); | 219 | await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef)); |
| 220 | return; | 220 | return; |
| 221 | } | ||
| 221 | case "rename": | 222 | case "rename": |
| 222 | onStartRename(); | 223 | onStartRename(); |
| 223 | return; | 224 | return; |
| ... | @@ -315,6 +316,7 @@ export function useThreadActionMenu(input: { | ... | @@ -315,6 +316,7 @@ export function useThreadActionMenu(input: { |
| 315 | changeRequest, | 316 | changeRequest, |
| 316 | confirmThreadArchive, | 317 | confirmThreadArchive, |
| 317 | confirmThreadDelete, | 318 | confirmThreadDelete, |
| 319 | confirmAndUnpinThread, | ||
| 318 | copyBranchToClipboard, | 320 | copyBranchToClipboard, |
| 319 | copyPathToClipboard, | 321 | copyPathToClipboard, |
| 320 | copyThreadIdToClipboard, | 322 | copyThreadIdToClipboard, |
| ... | @@ -328,7 +330,6 @@ export function useThreadActionMenu(input: { | ... | @@ -328,7 +330,6 @@ export function useThreadActionMenu(input: { |
| 328 | snoozeThread, | 330 | snoozeThread, |
| 329 | threadRef, | 331 | threadRef, |
| 330 | timestampFormat, | 332 | timestampFormat, |
| 331 | unpinThread, | ||
| 332 | unsettleThread, | 333 | unsettleThread, |
| 333 | unsnoozeThread, | 334 | unsnoozeThread, |
| 334 | updateThreadMetadata, | 335 | updateThreadMetadata, |
apps/web/src/hooks/useThreadActions.test.ts+55-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { ThreadArchiveBlockedError } from "./useThreadActions"; | 4 | import { requestThreadUnpinConfirmation, ThreadArchiveBlockedError } from "./useThreadActions"; |
| 5 | 5 | ||
| 6 | describe("ThreadArchiveBlockedError", () => { | 6 | describe("ThreadArchiveBlockedError", () => { |
| 7 | it("keeps the blocked thread context with the fixed message", () => { | 7 | it("keeps the blocked thread context with the fixed message", () => { |
| ... | @@ -17,3 +17,57 @@ describe("ThreadArchiveBlockedError", () => { | ... | @@ -17,3 +17,57 @@ describe("ThreadArchiveBlockedError", () => { |
| 17 | expect(error.message).toBe("Cannot archive a running thread."); | 17 | expect(error.message).toBe("Cannot archive a running thread."); |
| 18 | }); | 18 | }); |
| 19 | }); | 19 | }); |
| 20 | |||
| 21 | describe("requestThreadUnpinConfirmation", () => { | ||
| 22 | it("skips the dialog when confirmation is disabled", async () => { | ||
| 23 | let callCount = 0; | ||
| 24 | const result = await requestThreadUnpinConfirmation({ | ||
| 25 | enabled: false, | ||
| 26 | title: "Pinned thread", | ||
| 27 | confirm: async () => { | ||
| 28 | callCount += 1; | ||
| 29 | return false; | ||
| 30 | }, | ||
| 31 | }); | ||
| 32 | |||
| 33 | expect(result).toMatchObject({ _tag: "Success", value: true }); | ||
| 34 | expect(callCount).toBe(0); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it("degrades gracefully when dialogs are unavailable", async () => { | ||
| 38 | const result = await requestThreadUnpinConfirmation({ | ||
| 39 | enabled: true, | ||
| 40 | title: "Pinned thread", | ||
| 41 | confirm: null, | ||
| 42 | }); | ||
| 43 | |||
| 44 | expect(result).toMatchObject({ _tag: "Success", value: true }); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("uses the thread title and returns the user's decision", async () => { | ||
| 48 | let message = ""; | ||
| 49 | const result = await requestThreadUnpinConfirmation({ | ||
| 50 | enabled: true, | ||
| 51 | title: "Release prep", | ||
| 52 | confirm: async (nextMessage) => { | ||
| 53 | message = nextMessage; | ||
| 54 | return false; | ||
| 55 | }, | ||
| 56 | }); | ||
| 57 | |||
| 58 | expect(message).toBe( | ||
| 59 | 'Unpin thread "Release prep"?\nThis will move the thread out of your pinned section.', | ||
| 60 | ); | ||
| 61 | expect(result).toMatchObject({ _tag: "Success", value: false }); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("keeps dialog failures observable", async () => { | ||
| 65 | const result = await requestThreadUnpinConfirmation({ | ||
| 66 | enabled: true, | ||
| 67 | title: "Pinned thread", | ||
| 68 | confirm: () => Promise.reject(new Error("dialog unavailable")), | ||
| 69 | }); | ||
| 70 | |||
| 71 | expect(result._tag).toBe("Failure"); | ||
| 72 | }); | ||
| 73 | }); |
apps/web/src/hooks/useThreadActions.ts+43| ... | @@ -136,6 +136,26 @@ export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass<Th | ... | @@ -136,6 +136,26 @@ export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass<Th |
| 136 | } | 136 | } |
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | export async function requestThreadUnpinConfirmation(input: { | ||
| 140 | enabled: boolean; | ||
| 141 | title: string; | ||
| 142 | confirm: ((message: string) => Promise<boolean>) | null; | ||
| 143 | }) { | ||
| 144 | const { confirm } = input; | ||
| 145 | if (!input.enabled || confirm === null) { | ||
| 146 | return AsyncResult.success(true); | ||
| 147 | } | ||
| 148 | |||
| 149 | return settlePromise(() => | ||
| 150 | confirm( | ||
| 151 | [ | ||
| 152 | `Unpin thread "${input.title}"?`, | ||
| 153 | "This will move the thread out of your pinned section.", | ||
| 154 | ].join("\n"), | ||
| 155 | ), | ||
| 156 | ); | ||
| 157 | } | ||
| 158 | |||
| 139 | export function useThreadActions() { | 159 | export function useThreadActions() { |
| 140 | const closeTerminal = useAtomCommand(terminalEnvironment.close); | 160 | const closeTerminal = useAtomCommand(terminalEnvironment.close); |
| 141 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { | 161 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { |
| ... | @@ -177,6 +197,7 @@ export function useThreadActions() { | ... | @@ -177,6 +197,7 @@ export function useThreadActions() { |
| 177 | }); | 197 | }); |
| 178 | const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); | 198 | const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); |
| 179 | const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); | 199 | const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); |
| 200 | const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); | ||
| 180 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); | 201 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 181 | const clearProjectDraftThreadById = useComposerDraftStore( | 202 | const clearProjectDraftThreadById = useComposerDraftStore( |
| 182 | (store) => store.clearProjectDraftThreadById, | 203 | (store) => store.clearProjectDraftThreadById, |
| ... | @@ -590,6 +611,26 @@ export function useThreadActions() { | ... | @@ -590,6 +611,26 @@ export function useThreadActions() { |
| 590 | [unpinThreadMutation], | 611 | [unpinThreadMutation], |
| 591 | ); | 612 | ); |
| 592 | 613 | ||
| 614 | const confirmAndUnpinThread = useCallback( | ||
| 615 | async (target: ScopedThreadRef) => { | ||
| 616 | const localApi = readLocalApi(); | ||
| 617 | const resolved = resolveThreadTarget(target); | ||
| 618 | const confirmationResult = await requestThreadUnpinConfirmation({ | ||
| 619 | enabled: confirmThreadUnpin, | ||
| 620 | title: resolved?.thread.title ?? "this thread", | ||
| 621 | confirm: localApi ? (message) => localApi.dialogs.confirm(message) : null, | ||
| 622 | }); | ||
| 623 | if (confirmationResult._tag === "Failure") { | ||
| 624 | return confirmationResult; | ||
| 625 | } | ||
| 626 | if (!confirmationResult.value) { | ||
| 627 | return AsyncResult.success(undefined); | ||
| 628 | } | ||
| 629 | return unpinThread(target); | ||
| 630 | }, | ||
| 631 | [confirmThreadUnpin, resolveThreadTarget, unpinThread], | ||
| 632 | ); | ||
| 633 | |||
| 593 | const reorderPinnedThread = useCallback( | 634 | const reorderPinnedThread = useCallback( |
| 594 | async (target: ScopedThreadRef, orderKey: string) => { | 635 | async (target: ScopedThreadRef, orderKey: string) => { |
| 595 | // Callers (the sidebar drag handler) only enable dragging on | 636 | // Callers (the sidebar drag handler) only enable dragging on |
| ... | @@ -709,11 +750,13 @@ export function useThreadActions() { | ... | @@ -709,11 +750,13 @@ export function useThreadActions() { |
| 709 | unsnoozeThread, | 750 | unsnoozeThread, |
| 710 | pinThread, | 751 | pinThread, |
| 711 | unpinThread, | 752 | unpinThread, |
| 753 | confirmAndUnpinThread, | ||
| 712 | reorderPinnedThread, | 754 | reorderPinnedThread, |
| 713 | }), | 755 | }), |
| 714 | [ | 756 | [ |
| 715 | archiveThread, | 757 | archiveThread, |
| 716 | confirmAndDeleteThread, | 758 | confirmAndDeleteThread, |
| 759 | confirmAndUnpinThread, | ||
| 717 | deleteThread, | 760 | deleteThread, |
| 718 | pinThread, | 761 | pinThread, |
| 719 | reorderPinnedThread, | 762 | reorderPinnedThread, |
docs/user/thread-sidebar.md+3| ... | @@ -4,6 +4,9 @@ Pin a thread from its context menu to keep it in the pinned section above your a | ... | @@ -4,6 +4,9 @@ Pin a thread from its context menu to keep it in the pinned section above your a |
| 4 | `mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of | 4 | `mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of |
| 5 | their project, including when you connect to more than one environment. | 5 | their project, including when you connect to more than one environment. |
| 6 | 6 | ||
| 7 | To require confirmation before unpinning, enable **Settings → General → Unpin confirmation**. The | ||
| 8 | confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p` shortcut. | ||
| 9 | |||
| 7 | Pinned threads follow the auto-settle policy in **Settings → General**. Choose exactly one policy: | 10 | Pinned threads follow the auto-settle policy in **Settings → General**. Choose exactly one policy: |
| 8 | settle when a pull request merges or closes, settle after a configurable period of inactivity, or | 11 | settle when a pull request merges or closes, settle after a configurable period of inactivity, or |
| 9 | never auto-settle. **Never** leaves every automatic path off, so threads settle only when you choose | 12 | never auto-settle. **Never** leaves every automatic path off, so threads settle only when you choose |
packages/contracts/src/settings.test.ts+6| ... | @@ -141,6 +141,12 @@ describe("ClientSettings sidebar", () => { | ... | @@ -141,6 +141,12 @@ describe("ClientSettings sidebar", () => { |
| 141 | ); | 141 | ); |
| 142 | }); | 142 | }); |
| 143 | 143 | ||
| 144 | it("keeps unpin confirmation opt-in and patchable", () => { | ||
| 145 | expect(decodeClientSettings({}).confirmThreadUnpin).toBe(false); | ||
| 146 | expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true); | ||
| 147 | expect(() => decodeClientSettingsPatch({ confirmThreadUnpin: "yes" })).toThrow(); | ||
| 148 | }); | ||
| 149 | |||
| 144 | it("allows auto-settle by inactivity to be disabled", () => { | 150 | it("allows auto-settle by inactivity to be disabled", () => { |
| 145 | expect( | 151 | expect( |
| 146 | decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, | 152 | decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, |
packages/contracts/src/settings.ts+2| ... | @@ -170,6 +170,7 @@ export const ClientSettingsSchema = Schema.Struct({ | ... | @@ -170,6 +170,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 170 | confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 170 | confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 171 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 171 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 172 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 172 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 173 | confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | ||
| 173 | dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( | 174 | dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( |
| 174 | Schema.withDecodingDefault(Effect.succeed([])), | 175 | Schema.withDecodingDefault(Effect.succeed([])), |
| 175 | ), | 176 | ), |
| ... | @@ -913,6 +914,7 @@ export const ClientSettingsPatch = Schema.Struct({ | ... | @@ -913,6 +914,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 913 | confirmQuit: Schema.optionalKey(Schema.Boolean), | 914 | confirmQuit: Schema.optionalKey(Schema.Boolean), |
| 914 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), | 915 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 915 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), | 916 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |
| 917 | confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), | ||
| 916 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), | 918 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), |
| 917 | environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), | 919 | environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), |
| 918 | glassOpacity: Schema.optionalKey(GlassOpacity), | 920 | glassOpacity: Schema.optionalKey(GlassOpacity), |