authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-08-29 05:05:47+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-29 05:05:47+05:30
log22c311ddecfbab2e541a374a46f2df87d4fc6305
tree3ccb28628e0656f6491fc25cf50f34260a11950b
parentac3b2adf96e755580fc09e149b7c7eb057d0b282
signature Signed by PGP key B5690EEEBB952194

feat(web): add toggleable confirmation before unpinning a thread (#7313)

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 = {
2121 confirmQuit: true,
2222 confirmThreadArchive: true,
2323 confirmThreadDelete: false,
24 confirmThreadUnpin: false,
2425 dismissedProviderUpdateNotificationKeys: [],
2526 diffIgnoreWhitespace: true,
2627 environmentIdentificationMode: "artwork",
apps/web/src/components/ChatView.tsx+15-13
......@@ -1273,7 +1273,7 @@ function ChatViewContent(props: ChatViewProps) {
12731273 const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;
12741274 const threadDetailLoading = threadSyncPhase === "loading";
12751275 const handleNewThread = useNewThreadHandler();
1276 const { settleThread, pinThread, unpinThread } = useThreadActions();
1276 const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();
12771277 const routeThreadRef = useMemo(
12781278 () => scopeThreadRef(environmentId, threadId),
12791279 [environmentId, threadId],
......@@ -5146,17 +5146,19 @@ function ChatViewContent(props: ChatViewProps) {
51465146 event.stopPropagation();
51475147 if (!isServerThread || !activeThreadRef || !supportsPinning) return;
51485148 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 );
51605162 return;
51615163 }
51625164
......@@ -5284,7 +5286,7 @@ function ChatViewContent(props: ChatViewProps) {
52845286 settleThread,
52855287 supportsPinning,
52865288 supportsSettlement,
5287 unpinThread,
5289 confirmAndUnpinThread,
52885290 toggleRightPanel,
52895291 toggleRightPanelMaximized,
52905292 toggleTerminalVisibility,
apps/web/src/components/Sidebar.tsx+3-3
......@@ -1749,7 +1749,7 @@ export default function Sidebar() {
17491749 snoozeThread,
17501750 unsnoozeThread,
17511751 pinThread,
1752 unpinThread,
1752 confirmAndUnpinThread,
17531753 reorderPinnedThread,
17541754 archiveThread,
17551755 deleteThread,
......@@ -2709,7 +2709,7 @@ export default function Sidebar() {
27092709 const attemptUnpin = useCallback(
27102710 (threadRef: ScopedThreadRef) => {
27112711 void (async () => {
2712 const result = await unpinThread(threadRef);
2712 const result = await confirmAndUnpinThread(threadRef);
27132713 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
27142714 const error = squashAtomCommandFailure(result);
27152715 toastManager.add(
......@@ -2722,7 +2722,7 @@ export default function Sidebar() {
27222722 }
27232723 })();
27242724 },
2725 [unpinThread],
2725 [confirmAndUnpinThread],
27262726 );
27272727
27282728 const handlePinnedDragEnd = useCallback(
apps/web/src/components/settings/SettingsPanels.tsx+31
......@@ -526,6 +526,9 @@ export function useSettingsRestore(onRestored?: () => void) {
526526 ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory
527527 ? ["Add project base directory"]
528528 : []),
529 ...(settings.confirmThreadUnpin !== DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin
530 ? ["Unpin confirmation"]
531 : []),
529532 ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive
530533 ? ["Archive confirmation"]
531534 : []),
......@@ -554,6 +557,7 @@ export function useSettingsRestore(onRestored?: () => void) {
554557 settings.confirmQuit,
555558 settings.confirmThreadArchive,
556559 settings.confirmThreadDelete,
560 settings.confirmThreadUnpin,
557561 settings.addProjectBaseDirectory,
558562 settings.defaultThreadEnvMode,
559563 settings.newWorktreesStartFromOrigin,
......@@ -666,6 +670,7 @@ export function useSettingsRestore(onRestored?: () => void) {
666670 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
667671 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
668672 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
673 confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin,
669674 confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit,
670675 textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
671676 fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
......@@ -2313,6 +2318,32 @@ export function GeneralSettingsPanel() {
23132318 }
23142319 />
23152320
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
23162347 <SettingsRow
23172348 {...searchableSetting("archive-confirmation")}
23182349 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", () => {
5656 ]);
5757 });
5858
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
5967 it("returns no results for an empty query", () => {
6068 expect(searchSettings(" ", ITEMS)).toEqual([]);
6169 });
apps/web/src/components/settings/settingsSearch.ts+5
......@@ -152,6 +152,11 @@ export const SETTINGS_SEARCH_ITEMS = [
152152 title: "Add project starts in",
153153 to: "/settings/general",
154154 },
155 {
156 id: "unpin-confirmation",
157 title: "Unpin confirmation",
158 to: "/settings/general",
159 },
155160 {
156161 id: "archive-confirmation",
157162 title: "Archive confirmation",
apps/web/src/hooks/useThreadActionMenu.ts+5-4
......@@ -71,7 +71,7 @@ export function useThreadActionMenu(input: {
7171 snoozeThread,
7272 unsnoozeThread,
7373 pinThread,
74 unpinThread,
74 confirmAndUnpinThread,
7575 archiveThread,
7676 deleteThread,
7777 } = useThreadActions();
......@@ -215,9 +215,10 @@ export function useThreadActionMenu(input: {
215215 case "pin":
216216 await reportFailure("Failed to pin thread", () => pinThread(threadRef));
217217 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));
220220 return;
221 }
221222 case "rename":
222223 onStartRename();
223224 return;
......@@ -315,6 +316,7 @@ export function useThreadActionMenu(input: {
315316 changeRequest,
316317 confirmThreadArchive,
317318 confirmThreadDelete,
319 confirmAndUnpinThread,
318320 copyBranchToClipboard,
319321 copyPathToClipboard,
320322 copyThreadIdToClipboard,
......@@ -328,7 +330,6 @@ export function useThreadActionMenu(input: {
328330 snoozeThread,
329331 threadRef,
330332 timestampFormat,
331 unpinThread,
332333 unsettleThread,
333334 unsnoozeThread,
334335 updateThreadMetadata,
apps/web/src/hooks/useThreadActions.test.ts+55-1
......@@ -1,7 +1,7 @@
11import { EnvironmentId, ThreadId } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
4import { ThreadArchiveBlockedError } from "./useThreadActions";
4import { requestThreadUnpinConfirmation, ThreadArchiveBlockedError } from "./useThreadActions";
55
66describe("ThreadArchiveBlockedError", () => {
77 it("keeps the blocked thread context with the fixed message", () => {
......@@ -17,3 +17,57 @@ describe("ThreadArchiveBlockedError", () => {
1717 expect(error.message).toBe("Cannot archive a running thread.");
1818 });
1919});
20
21describe("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
136136 }
137137}
138138
139export 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
139159export function useThreadActions() {
140160 const closeTerminal = useAtomCommand(terminalEnvironment.close);
141161 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
......@@ -177,6 +197,7 @@ export function useThreadActions() {
177197 });
178198 const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder);
179199 const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete);
200 const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin);
180201 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
181202 const clearProjectDraftThreadById = useComposerDraftStore(
182203 (store) => store.clearProjectDraftThreadById,
......@@ -590,6 +611,26 @@ export function useThreadActions() {
590611 [unpinThreadMutation],
591612 );
592613
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
593634 const reorderPinnedThread = useCallback(
594635 async (target: ScopedThreadRef, orderKey: string) => {
595636 // Callers (the sidebar drag handler) only enable dragging on
......@@ -709,11 +750,13 @@ export function useThreadActions() {
709750 unsnoozeThread,
710751 pinThread,
711752 unpinThread,
753 confirmAndUnpinThread,
712754 reorderPinnedThread,
713755 }),
714756 [
715757 archiveThread,
716758 confirmAndDeleteThread,
759 confirmAndUnpinThread,
717760 deleteThread,
718761 pinThread,
719762 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
44`mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of
55their project, including when you connect to more than one environment.
66
7To require confirmation before unpinning, enable **Settings → General → Unpin confirmation**. The
8confirmation applies to the sidebar controls, thread menus, and the `mod+shift+p` shortcut.
9
710Pinned threads follow the auto-settle policy in **Settings → General**. Choose exactly one policy:
811settle when a pull request merges or closes, settle after a configurable period of inactivity, or
912never 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", () => {
141141 );
142142 });
143143
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
144150 it("allows auto-settle by inactivity to be disabled", () => {
145151 expect(
146152 decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays,
packages/contracts/src/settings.ts+2
......@@ -170,6 +170,7 @@ export const ClientSettingsSchema = Schema.Struct({
170170 confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
171171 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
172172 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
173 confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
173174 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
174175 Schema.withDecodingDefault(Effect.succeed([])),
175176 ),
......@@ -913,6 +914,7 @@ export const ClientSettingsPatch = Schema.Struct({
913914 confirmQuit: Schema.optionalKey(Schema.Boolean),
914915 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
915916 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
917 confirmThreadUnpin: Schema.optionalKey(Schema.Boolean),
916918 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),
917919 environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),
918920 glassOpacity: Schema.optionalKey(GlassOpacity),