| 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 | 21 | confirmQuit: true, |
| 22 | 22 | confirmThreadArchive: true, |
| 23 | 23 | confirmThreadDelete: false, |
| 24 | confirmThreadUnpin: false, | |
| 24 | 25 | dismissedProviderUpdateNotificationKeys: [], |
| 25 | 26 | diffIgnoreWhitespace: true, |
| 26 | 27 | environmentIdentificationMode: "artwork", |
apps/web/src/components/ChatView.tsx+15-13| ... | ... | @@ -1273,7 +1273,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 1273 | 1273 | const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; |
| 1274 | 1274 | const threadDetailLoading = threadSyncPhase === "loading"; |
| 1275 | 1275 | const handleNewThread = useNewThreadHandler(); |
| 1276 | const { settleThread, pinThread, unpinThread } = useThreadActions(); | |
| 1276 | const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); | |
| 1277 | 1277 | const routeThreadRef = useMemo( |
| 1278 | 1278 | () => scopeThreadRef(environmentId, threadId), |
| 1279 | 1279 | [environmentId, threadId], |
| ... | ... | @@ -5146,17 +5146,19 @@ function ChatViewContent(props: ChatViewProps) { |
| 5146 | 5146 | event.stopPropagation(); |
| 5147 | 5147 | if (!isServerThread || !activeThreadRef || !supportsPinning) return; |
| 5148 | 5148 | const pinned = activeThreadPinned; |
| 5149 | void (pinned ? unpinThread(activeThreadRef) : pinThread(activeThreadRef)).then((result) => { | |
| 5150 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; | |
| 5151 | const error = squashAtomCommandFailure(result); | |
| 5152 | toastManager.add( | |
| 5153 | stackedThreadToast({ | |
| 5154 | type: "error", | |
| 5155 | title: pinned ? "Failed to unpin thread" : "Failed to pin thread", | |
| 5156 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 5157 | }), | |
| 5158 | ); | |
| 5159 | }); | |
| 5149 | void (pinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef)).then( | |
| 5150 | (result) => { | |
| 5151 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; | |
| 5152 | const error = squashAtomCommandFailure(result); | |
| 5153 | toastManager.add( | |
| 5154 | stackedThreadToast({ | |
| 5155 | type: "error", | |
| 5156 | title: pinned ? "Failed to unpin thread" : "Failed to pin thread", | |
| 5157 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 5158 | }), | |
| 5159 | ); | |
| 5160 | }, | |
| 5161 | ); | |
| 5160 | 5162 | return; |
| 5161 | 5163 | } |
| 5162 | 5164 | |
| ... | ... | @@ -5284,7 +5286,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5284 | 5286 | settleThread, |
| 5285 | 5287 | supportsPinning, |
| 5286 | 5288 | supportsSettlement, |
| 5287 | unpinThread, | |
| 5289 | confirmAndUnpinThread, | |
| 5288 | 5290 | toggleRightPanel, |
| 5289 | 5291 | toggleRightPanelMaximized, |
| 5290 | 5292 | toggleTerminalVisibility, |
apps/web/src/components/Sidebar.tsx+3-3| ... | ... | @@ -1749,7 +1749,7 @@ export default function Sidebar() { |
| 1749 | 1749 | snoozeThread, |
| 1750 | 1750 | unsnoozeThread, |
| 1751 | 1751 | pinThread, |
| 1752 | unpinThread, | |
| 1752 | confirmAndUnpinThread, | |
| 1753 | 1753 | reorderPinnedThread, |
| 1754 | 1754 | archiveThread, |
| 1755 | 1755 | deleteThread, |
| ... | ... | @@ -2709,7 +2709,7 @@ export default function Sidebar() { |
| 2709 | 2709 | const attemptUnpin = useCallback( |
| 2710 | 2710 | (threadRef: ScopedThreadRef) => { |
| 2711 | 2711 | void (async () => { |
| 2712 | const result = await unpinThread(threadRef); | |
| 2712 | const result = await confirmAndUnpinThread(threadRef); | |
| 2713 | 2713 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 2714 | 2714 | const error = squashAtomCommandFailure(result); |
| 2715 | 2715 | toastManager.add( |
| ... | ... | @@ -2722,7 +2722,7 @@ export default function Sidebar() { |
| 2722 | 2722 | } |
| 2723 | 2723 | })(); |
| 2724 | 2724 | }, |
| 2725 | [unpinThread], | |
| 2725 | [confirmAndUnpinThread], | |
| 2726 | 2726 | ); |
| 2727 | 2727 | |
| 2728 | 2728 | const handlePinnedDragEnd = useCallback( |
apps/web/src/components/settings/SettingsPanels.tsx+31| ... | ... | @@ -526,6 +526,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 526 | 526 | ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory |
| 527 | 527 | ? ["Add project base directory"] |
| 528 | 528 | : []), |
| 529 | ...(settings.confirmThreadUnpin !== DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin | |
| 530 | ? ["Unpin confirmation"] | |
| 531 | : []), | |
| 529 | 532 | ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive |
| 530 | 533 | ? ["Archive confirmation"] |
| 531 | 534 | : []), |
| ... | ... | @@ -554,6 +557,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 554 | 557 | settings.confirmQuit, |
| 555 | 558 | settings.confirmThreadArchive, |
| 556 | 559 | settings.confirmThreadDelete, |
| 560 | settings.confirmThreadUnpin, | |
| 557 | 561 | settings.addProjectBaseDirectory, |
| 558 | 562 | settings.defaultThreadEnvMode, |
| 559 | 563 | settings.newWorktreesStartFromOrigin, |
| ... | ... | @@ -666,6 +670,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 666 | 670 | addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, |
| 667 | 671 | confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, |
| 668 | 672 | confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, |
| 673 | confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin, | |
| 669 | 674 | confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, |
| 670 | 675 | textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, |
| 671 | 676 | fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, |
| ... | ... | @@ -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 | 2347 | <SettingsRow |
| 2317 | 2348 | {...searchableSetting("archive-confirmation")} |
| 2318 | 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 | 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 | 67 | it("returns no results for an empty query", () => { |
| 60 | 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 | 152 | title: "Add project starts in", |
| 153 | 153 | to: "/settings/general", |
| 154 | 154 | }, |
| 155 | { | |
| 156 | id: "unpin-confirmation", | |
| 157 | title: "Unpin confirmation", | |
| 158 | to: "/settings/general", | |
| 159 | }, | |
| 155 | 160 | { |
| 156 | 161 | id: "archive-confirmation", |
| 157 | 162 | title: "Archive confirmation", |
apps/web/src/hooks/useThreadActionMenu.ts+5-4| ... | ... | @@ -71,7 +71,7 @@ export function useThreadActionMenu(input: { |
| 71 | 71 | snoozeThread, |
| 72 | 72 | unsnoozeThread, |
| 73 | 73 | pinThread, |
| 74 | unpinThread, | |
| 74 | confirmAndUnpinThread, | |
| 75 | 75 | archiveThread, |
| 76 | 76 | deleteThread, |
| 77 | 77 | } = useThreadActions(); |
| ... | ... | @@ -215,9 +215,10 @@ export function useThreadActionMenu(input: { |
| 215 | 215 | case "pin": |
| 216 | 216 | await reportFailure("Failed to pin thread", () => pinThread(threadRef)); |
| 217 | 217 | return; |
| 218 | case "unpin": | |
| 219 | await reportFailure("Failed to unpin thread", () => unpinThread(threadRef)); | |
| 218 | case "unpin": { | |
| 219 | await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef)); | |
| 220 | 220 | return; |
| 221 | } | |
| 221 | 222 | case "rename": |
| 222 | 223 | onStartRename(); |
| 223 | 224 | return; |
| ... | ... | @@ -315,6 +316,7 @@ export function useThreadActionMenu(input: { |
| 315 | 316 | changeRequest, |
| 316 | 317 | confirmThreadArchive, |
| 317 | 318 | confirmThreadDelete, |
| 319 | confirmAndUnpinThread, | |
| 318 | 320 | copyBranchToClipboard, |
| 319 | 321 | copyPathToClipboard, |
| 320 | 322 | copyThreadIdToClipboard, |
| ... | ... | @@ -328,7 +330,6 @@ export function useThreadActionMenu(input: { |
| 328 | 330 | snoozeThread, |
| 329 | 331 | threadRef, |
| 330 | 332 | timestampFormat, |
| 331 | unpinThread, | |
| 332 | 333 | unsettleThread, |
| 333 | 334 | unsnoozeThread, |
| 334 | 335 | updateThreadMetadata, |
apps/web/src/hooks/useThreadActions.test.ts+55-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | 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 | 6 | describe("ThreadArchiveBlockedError", () => { |
| 7 | 7 | it("keeps the blocked thread context with the fixed message", () => { |
| ... | ... | @@ -17,3 +17,57 @@ describe("ThreadArchiveBlockedError", () => { |
| 17 | 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 | 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 | 159 | export function useThreadActions() { |
| 140 | 160 | const closeTerminal = useAtomCommand(terminalEnvironment.close); |
| 141 | 161 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { |
| ... | ... | @@ -177,6 +197,7 @@ export function useThreadActions() { |
| 177 | 197 | }); |
| 178 | 198 | const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); |
| 179 | 199 | const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); |
| 200 | const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); | |
| 180 | 201 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); |
| 181 | 202 | const clearProjectDraftThreadById = useComposerDraftStore( |
| 182 | 203 | (store) => store.clearProjectDraftThreadById, |
| ... | ... | @@ -590,6 +611,26 @@ export function useThreadActions() { |
| 590 | 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 | 634 | const reorderPinnedThread = useCallback( |
| 594 | 635 | async (target: ScopedThreadRef, orderKey: string) => { |
| 595 | 636 | // Callers (the sidebar drag handler) only enable dragging on |
| ... | ... | @@ -709,11 +750,13 @@ export function useThreadActions() { |
| 709 | 750 | unsnoozeThread, |
| 710 | 751 | pinThread, |
| 711 | 752 | unpinThread, |
| 753 | confirmAndUnpinThread, | |
| 712 | 754 | reorderPinnedThread, |
| 713 | 755 | }), |
| 714 | 756 | [ |
| 715 | 757 | archiveThread, |
| 716 | 758 | confirmAndDeleteThread, |
| 759 | confirmAndUnpinThread, | |
| 717 | 760 | deleteThread, |
| 718 | 761 | pinThread, |
| 719 | 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 | 4 | `mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of |
| 5 | 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 | 10 | Pinned threads follow the auto-settle policy in **Settings → General**. Choose exactly one policy: |
| 8 | 11 | settle when a pull request merges or closes, settle after a configurable period of inactivity, or |
| 9 | 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 | 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 | 150 | it("allows auto-settle by inactivity to be disabled", () => { |
| 145 | 151 | expect( |
| 146 | 152 | decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, |
packages/contracts/src/settings.ts+2| ... | ... | @@ -170,6 +170,7 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 170 | 170 | confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 171 | 171 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 172 | 172 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 173 | confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 173 | 174 | dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( |
| 174 | 175 | Schema.withDecodingDefault(Effect.succeed([])), |
| 175 | 176 | ), |
| ... | ... | @@ -913,6 +914,7 @@ export const ClientSettingsPatch = Schema.Struct({ |
| 913 | 914 | confirmQuit: Schema.optionalKey(Schema.Boolean), |
| 914 | 915 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), |
| 915 | 916 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), |
| 917 | confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), | |
| 916 | 918 | diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), |
| 917 | 919 | environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), |
| 918 | 920 | glassOpacity: Schema.optionalKey(GlassOpacity), |