From 02805f343c976030f071a01e65672604218b86bf Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 06:20:39 -0700 Subject: [PATCH 01/25] Add shortcut to settle the open thread --- apps/server/src/keybindings.test.ts | 1 + apps/web/src/components/SidebarV2.tsx | 67 ++++++++++++++++++++++++++- apps/web/src/keybindings.test.ts | 28 +++++++++++ docs/user/keybindings.md | 4 +- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 6 files changed, 100 insertions(+), 2 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..0ac30ceca92 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+enter"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index e018491348e..ea7e74ac0f4 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -84,11 +84,21 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Button } from "./ui/button"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -2114,9 +2124,14 @@ export default function SidebarV2() { ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); + const [settleConfirmationThreadKey, setSettleConfirmationThreadKey] = useState( + null, + ); + const settleConfirmationButtonRef = useRef(null); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.repeat) return; + if (isCommandPaletteOpen() || isModelPickerOpen()) return; const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, context: { @@ -2134,6 +2149,19 @@ export default function SidebarV2() { navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); return true; }; + if (command === "thread.settle") { + event.preventDefault(); + event.stopPropagation(); + if (!routeThreadKey) return; + const thread = threadByKey.get(routeThreadKey); + const supportsSettlement = + thread && + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true; + if (!thread || !supportsSettlement || settledThreadKeys.has(routeThreadKey)) return; + setSettleConfirmationThreadKey(routeThreadKey); + return; + } const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { navigateToThreadKey( @@ -2157,9 +2185,22 @@ export default function SidebarV2() { orderedThreadKeys, routeTerminalOpen, routeThreadKey, + serverConfigs, + settledThreadKeys, threadByKey, ]); + const settleConfirmationThread = settleConfirmationThreadKey + ? (threadByKey.get(settleConfirmationThreadKey) ?? null) + : null; + const confirmShortcutSettle = useCallback(() => { + if (!settleConfirmationThread) return; + setSettleConfirmationThreadKey(null); + attemptSettle( + scopeThreadRef(settleConfirmationThread.environmentId, settleConfirmationThread.id), + ); + }, [attemptSettle, settleConfirmationThread]); + // Same predicate as v1: hints show only while the held modifiers exactly // match a thread-jump binding. Adding Shift (screenshots) or Alt no // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. @@ -2720,6 +2761,30 @@ export default function SidebarV2() { + { + if (!open) setSettleConfirmationThreadKey(null); + }} + > + + + Settle this thread? + + {settleConfirmationThread + ? `“${settleConfirmationThread.title}” will move out of active work. You can un-settle it later.` + : "This thread will move out of active work. You can un-settle it later."} + + + + }>Cancel + + + + ); } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..cc9ff5b9640 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -126,6 +126,11 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, + { + shortcut: modShortcut("enter", { shiftKey: true }), + command: "thread.settle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, { shortcut: modShortcut("1"), command: "thread.jump.1" }, @@ -148,6 +153,29 @@ const DEFAULT_BINDINGS = compile([ }, ]); +describe("thread settle shortcut", () => { + it("resolves Mod+Shift+Enter outside the terminal", () => { + assert.strictEqual( + resolveShortcutCommand( + event({ key: "Enter", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { platform: "MacIntel" }, + ), + "thread.settle", + ); + }); + + it("does not resolve while the terminal owns focus", () => { + assert.isNull( + resolveShortcutCommand( + event({ key: "Enter", ctrlKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { platform: "Linux", context: { terminalFocus: true } }, + ), + ); + }); +}); + describe("isTerminalToggleShortcut", () => { it("matches Cmd+J on macOS", () => { assert.isTrue( diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 254aa92c6a0..700e5927954 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -33,7 +33,8 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, - { "key": "mod+o", "command": "editor.openFavorite" } + { "key": "mod+o", "command": "editor.openFavorite" }, + { "key": "mod+shift+enter", "command": "thread.settle", "when": "!terminalFocus" } ] ``` @@ -67,6 +68,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor +- `thread.settle`: confirm and settle the open thread, then move to the next active thread - `script.{id}.run`: run a project script by id (for example `script.test.run`) ### Key Syntax diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..cb921e55ec5 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -35,6 +35,7 @@ export type ModelPickerJumpKeybindingCommand = (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; export const THREAD_KEYBINDING_COMMANDS = [ + "thread.settle", "thread.previous", "thread.next", ...THREAD_JUMP_KEYBINDING_COMMANDS, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..202629660dd 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -40,6 +40,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, + { key: "mod+shift+enter", command: "thread.settle", when: "!terminalFocus" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ From 5503abb49424f3023fb9fa320e24d3b3d98e369c Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 07:52:11 -0700 Subject: [PATCH 02/25] Fix settle shortcut interaction guards --- apps/web/src/components/Sidebar.tsx | 171 ++++++++++++++++++++------ apps/web/src/components/SidebarV2.tsx | 82 ++++++------ apps/web/src/keybindings.test.ts | 52 +++++++- apps/web/src/keybindings.ts | 68 ++++++++++ 4 files changed, 290 insertions(+), 83 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..98fe4e09f75 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -96,11 +96,10 @@ import { } from "../uiStateStore"; import { resolveShortcutCommand, + resolveThreadSidebarShortcutAction, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, } from "../keybindings"; import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; @@ -134,6 +133,15 @@ import { shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; import { Button } from "./ui/button"; import { Dialog, @@ -168,12 +176,11 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, - resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, resolveProjectStatusIndicator, @@ -3000,7 +3007,8 @@ export default function Sidebar() { const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread } = useThreadActions(); + const { archiveThread, deleteThread, settleThread } = useThreadActions(); + const serverConfigs = useServerConfigs(); const { isMobile, setOpenMobile } = useSidebar(); const routeTarget = useParams({ strict: false, @@ -3014,6 +3022,12 @@ export default function Sidebar() { [routeDraftThread, routeTarget], ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeThreadKeyRef = useRef(routeThreadKey); + routeThreadKeyRef.current = routeThreadKey; + const [settleConfirmationThreadKey, setSettleConfirmationThreadKey] = useState( + null, + ); + const settleConfirmationButtonRef = useRef(null); const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -3396,6 +3410,51 @@ export default function Sidebar() { }), [prewarmedSidebarThreadKeys], ); + const settlingThreadKeysRef = useRef(new Set()); + + const attemptShortcutSettle = useCallback( + (threadKey: string) => { + const thread = sidebarThreadByKey.get(threadKey); + if (!thread || settlingThreadKeysRef.current.has(threadKey)) return; + settlingThreadKeysRef.current.add(threadKey); + setSettleConfirmationThreadKey((current) => (current === threadKey ? null : current)); + + const currentIndex = orderedSidebarThreadKeys.indexOf(threadKey); + const nextThreadKey = + currentIndex === -1 + ? null + : ([ + ...orderedSidebarThreadKeys.slice(currentIndex + 1), + ...orderedSidebarThreadKeys.slice(0, currentIndex), + ][0] ?? null); + const nextThread = nextThreadKey ? sidebarThreadByKey.get(nextThreadKey) : null; + + void (async () => { + try { + const result = await settleThread(scopeThreadRef(thread.environmentId, thread.id)); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + if (routeThreadKeyRef.current === threadKey && nextThread) { + navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)); + } + } finally { + settlingThreadKeysRef.current.delete(threadKey); + } + })(); + }, + [navigateToThread, orderedSidebarThreadKeys, settleThread, sidebarThreadByKey], + ); useEffect(() => { updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); @@ -3408,48 +3467,37 @@ export default function Sidebar() { if (event.defaultPrevented || event.repeat) { return; } + if (isCommandPaletteOpen() || isModelPickerOpen()) return; const command = resolveShortcutCommand(event, keybindings, { platform, context: shortcutContext, }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - + const routeThread = routeThreadKey ? sidebarThreadByKey.get(routeThreadKey) : null; + const action = resolveThreadSidebarShortcutAction({ + command, + orderedThreadKeys: orderedSidebarThreadKeys, + jumpThreadKeys: threadJumpThreadKeys, + routeThreadKey, + settleConfirmationThreadKey, + canSettleRouteThread: + routeThread != null && + serverConfigs.get(routeThread.environmentId)?.environment.capabilities + .threadSettlement === true && + routeThread.settledOverride !== "settled", + isRouteThreadSettling: + routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), + }); + if (action.type === "none") return; event.preventDefault(); event.stopPropagation(); + if (action.type === "consume") return; + if (action.type === "confirm-settle") { + setSettleConfirmationThreadKey(action.threadKey); + return; + } + const targetThread = sidebarThreadByKey.get(action.threadKey); + if (!targetThread) return; navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); }; @@ -3465,10 +3513,29 @@ export default function Sidebar() { orderedSidebarThreadKeys, platform, routeThreadKey, + serverConfigs, + settleConfirmationThreadKey, sidebarThreadByKey, threadJumpThreadKeys, ]); + const settleConfirmationThread = settleConfirmationThreadKey + ? (sidebarThreadByKey.get(settleConfirmationThreadKey) ?? null) + : null; + const confirmShortcutSettle = useCallback(() => { + if (!settleConfirmationThreadKey) return; + attemptShortcutSettle(settleConfirmationThreadKey); + }, [attemptShortcutSettle, settleConfirmationThreadKey]); + + useEffect(() => { + if ( + settleConfirmationThreadKey !== null && + (settleConfirmationThread === null || settleConfirmationThread.settledOverride === "settled") + ) { + setSettleConfirmationThreadKey(null); + } + }, [settleConfirmationThread, settleConfirmationThreadKey]); + useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { if (!useThreadSelectionStore.getState().hasSelection()) return; @@ -3638,6 +3705,30 @@ export default function Sidebar() { )} + { + if (!open) setSettleConfirmationThreadKey(null); + }} + > + + + Settle this thread? + + {settleConfirmationThread + ? `“${settleConfirmationThread.title}” will move out of active work. You can un-settle it later.` + : "This thread will move out of active work. You can un-settle it later."} + + + + }>Cancel + + + + ); } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ea7e74ac0f4..837a51310d2 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -57,11 +57,10 @@ import { import { isElectron } from "../env"; import { resolveShortcutCommand, + resolveThreadSidebarShortcutAction, shortcutLabelForCommand, shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; import { isTerminalFocused } from "../lib/terminalFocus"; @@ -117,7 +116,6 @@ import { hasUnseenCompletion, isTrailingDoubleClick, orderItemsByPreferredIds, - resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, resolveWorkingStartedAt, @@ -1565,6 +1563,10 @@ export default function SidebarV2() { ); const snoozedThreadKeysRef = useRef(snoozedThreadKeys); snoozedThreadKeysRef.current = snoozedThreadKeys; + const [settleConfirmationThreadKey, setSettleConfirmationThreadKey] = useState( + null, + ); + const settleConfirmationButtonRef = useRef(null); const jumpLabelByKey = useMemo(() => { const mapping = new Map(); @@ -1696,6 +1698,7 @@ export default function SidebarV2() { const threadKey = scopedThreadKey(threadRef); if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); + setSettleConfirmationThreadKey((current) => (current === threadKey ? null : current)); try { const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); const result = await settleThread(threadRef); @@ -2124,10 +2127,6 @@ export default function SidebarV2() { ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); - const [settleConfirmationThreadKey, setSettleConfirmationThreadKey] = useState( - null, - ); - const settleConfirmationButtonRef = useRef(null); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.repeat) return; @@ -2140,42 +2139,31 @@ export default function SidebarV2() { modelPickerOpen: isModelPickerOpen(), }, }); - const navigateToThreadKey = (targetThreadKey: string | null) => { - if (!targetThreadKey) return false; - const targetThread = threadByKey.get(targetThreadKey); - if (!targetThread) return false; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return true; - }; - if (command === "thread.settle") { - event.preventDefault(); - event.stopPropagation(); - if (!routeThreadKey) return; - const thread = threadByKey.get(routeThreadKey); - const supportsSettlement = - thread && - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === - true; - if (!thread || !supportsSettlement || settledThreadKeys.has(routeThreadKey)) return; - setSettleConfirmationThreadKey(routeThreadKey); - return; - } - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - navigateToThreadKey( - resolveAdjacentThreadId({ - threadIds: orderedThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }), - ); + const routeThread = routeThreadKey ? threadByKey.get(routeThreadKey) : null; + const action = resolveThreadSidebarShortcutAction({ + command, + orderedThreadKeys, + routeThreadKey, + settleConfirmationThreadKey, + canSettleRouteThread: + routeThread != null && + serverConfigs.get(routeThread.environmentId)?.environment.capabilities + .threadSettlement === true && + !settledThreadKeys.has(routeThreadKey ?? ""), + isRouteThreadSettling: + routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), + }); + if (action.type === "none") return; + event.preventDefault(); + event.stopPropagation(); + if (action.type === "consume") return; + if (action.type === "confirm-settle") { + setSettleConfirmationThreadKey(action.threadKey); return; } - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) return; - navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); + const targetThread = threadByKey.get(action.threadKey); + if (!targetThread) return; + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); }; window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); @@ -2186,6 +2174,7 @@ export default function SidebarV2() { routeTerminalOpen, routeThreadKey, serverConfigs, + settleConfirmationThreadKey, settledThreadKeys, threadByKey, ]); @@ -2195,12 +2184,21 @@ export default function SidebarV2() { : null; const confirmShortcutSettle = useCallback(() => { if (!settleConfirmationThread) return; - setSettleConfirmationThreadKey(null); attemptSettle( scopeThreadRef(settleConfirmationThread.environmentId, settleConfirmationThread.id), ); }, [attemptSettle, settleConfirmationThread]); + useEffect(() => { + if ( + settleConfirmationThreadKey !== null && + (settledThreadKeys.has(settleConfirmationThreadKey) || + !threadByKey.has(settleConfirmationThreadKey)) + ) { + setSettleConfirmationThreadKey(null); + } + }, [settleConfirmationThreadKey, settledThreadKeys, threadByKey]); + // Same predicate as v1: hints show only while the held modifiers exactly // match a thread-jump binding. Adding Shift (screenshots) or Alt no // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index cc9ff5b9640..9f201486757 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1,4 +1,4 @@ -import { assert, describe, it } from "vite-plus/test"; +import { assert, describe, expect, it } from "vite-plus/test"; import { type KeybindingCommand, @@ -21,6 +21,7 @@ import { isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, resolveShortcutCommand, + resolveThreadSidebarShortcutAction, shouldShowModelPickerJumpHints, shouldShowThreadJumpHints, shortcutLabelForCommand, @@ -174,6 +175,55 @@ describe("thread settle shortcut", () => { ), ); }); + + it("routes an eligible settle command to the shared confirmation flow", () => { + expect( + resolveThreadSidebarShortcutAction({ + command: "thread.settle", + orderedThreadKeys: ["thread-1", "thread-2"], + routeThreadKey: "thread-1", + settleConfirmationThreadKey: null, + canSettleRouteThread: true, + isRouteThreadSettling: false, + }), + ).toEqual({ type: "confirm-settle", threadKey: "thread-1" }); + }); + + it("consumes thread navigation while settle confirmation is open", () => { + expect( + resolveThreadSidebarShortcutAction({ + command: "thread.next", + orderedThreadKeys: ["thread-1", "thread-2"], + routeThreadKey: "thread-1", + settleConfirmationThreadKey: "thread-1", + canSettleRouteThread: true, + isRouteThreadSettling: false, + }), + ).toEqual({ type: "consume" }); + expect( + resolveThreadSidebarShortcutAction({ + command: "thread.jump.2", + orderedThreadKeys: ["thread-1", "thread-2"], + routeThreadKey: "thread-1", + settleConfirmationThreadKey: "thread-1", + canSettleRouteThread: true, + isRouteThreadSettling: false, + }), + ).toEqual({ type: "consume" }); + }); + + it("consumes settle without reopening confirmation while the route thread is settling", () => { + expect( + resolveThreadSidebarShortcutAction({ + command: "thread.settle", + orderedThreadKeys: ["thread-1"], + routeThreadKey: "thread-1", + settleConfirmationThreadKey: null, + canSettleRouteThread: true, + isRouteThreadSettling: true, + }), + ).toEqual({ type: "consume" }); + }); }); describe("isTerminalToggleShortcut", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 9d6109a7780..3d1876e5340 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -282,6 +282,74 @@ export function threadTraversalDirectionFromCommand( return null; } +export type ThreadSidebarShortcutAction = + | { readonly type: "none" } + | { readonly type: "consume" } + | { readonly type: "navigate"; readonly threadKey: TThreadKey } + | { readonly type: "confirm-settle"; readonly threadKey: TThreadKey }; + +/** + * Resolves the thread commands shared by both sidebar implementations. + * A settle confirmation is modal with respect to thread shortcuts: navigation + * must not change the route behind it, and a second settle shortcut must not + * replace or reopen it. + */ +export function resolveThreadSidebarShortcutAction(input: { + readonly command: KeybindingCommand | null; + readonly orderedThreadKeys: readonly TThreadKey[]; + readonly jumpThreadKeys?: readonly TThreadKey[]; + readonly routeThreadKey: TThreadKey | null; + readonly settleConfirmationThreadKey: TThreadKey | null; + readonly canSettleRouteThread: boolean; + readonly isRouteThreadSettling: boolean; +}): ThreadSidebarShortcutAction { + const traversalDirection = threadTraversalDirectionFromCommand(input.command); + const jumpIndex = threadJumpIndexFromCommand(input.command ?? ""); + const isThreadCommand = + input.command === "thread.settle" || traversalDirection !== null || jumpIndex !== null; + + if (!isThreadCommand) return { type: "none" }; + if (input.settleConfirmationThreadKey !== null) return { type: "consume" }; + + if (input.command === "thread.settle") { + if ( + input.routeThreadKey === null || + !input.canSettleRouteThread || + input.isRouteThreadSettling + ) { + return { type: "consume" }; + } + return { type: "confirm-settle", threadKey: input.routeThreadKey }; + } + + if (traversalDirection !== null) { + const threadKey = resolveAdjacentItem({ + items: input.orderedThreadKeys, + current: input.routeThreadKey, + direction: traversalDirection, + }); + return threadKey === null ? { type: "none" } : { type: "navigate", threadKey }; + } + + const threadKey = (input.jumpThreadKeys ?? input.orderedThreadKeys)[jumpIndex ?? -1]; + return threadKey === undefined ? { type: "none" } : { type: "navigate", threadKey }; +} + +function resolveAdjacentItem(input: { + readonly items: readonly T[]; + readonly current: T | null; + readonly direction: "previous" | "next"; +}): T | null { + if (input.items.length === 0) return null; + if (input.current === null) { + return input.direction === "previous" ? (input.items.at(-1) ?? null) : (input.items[0] ?? null); + } + const currentIndex = input.items.indexOf(input.current); + if (currentIndex === -1) return null; + const targetIndex = input.direction === "previous" ? currentIndex - 1 : currentIndex + 1; + return input.items[targetIndex] ?? null; +} + export function shouldShowThreadJumpHints( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, From f66eaf19b3c1fcc413480fdb02de8bfe1c447c51 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 07:58:12 -0700 Subject: [PATCH 03/25] Handle legacy settle navigation edge cases --- apps/web/src/components/Sidebar.logic.test.ts | 30 +++++++++++++ apps/web/src/components/Sidebar.logic.ts | 14 ++++++ apps/web/src/components/Sidebar.tsx | 43 +++++++++++-------- apps/web/src/components/SidebarV2.tsx | 16 ++++--- 4 files changed, 78 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac..5d3b9f90ae8 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -6,6 +6,7 @@ import { getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, + resolveNextActiveThreadIdAfterSettle, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, @@ -514,6 +515,35 @@ describe("resolveAdjacentThreadId", () => { }); }); +describe("resolveNextActiveThreadIdAfterSettle", () => { + it("wraps to the next active thread while skipping settled threads", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["thread-1", "thread-2", "thread-3"], + settledThreadId: "thread-1", + isActive: (threadId) => threadId === "thread-3", + }), + ).toBe("thread-3"); + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["thread-1", "thread-2", "thread-3"], + settledThreadId: "thread-3", + isActive: (threadId) => threadId === "thread-1", + }), + ).toBe("thread-1"); + }); + + it("returns null when settling leaves no active thread", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["thread-1", "thread-2"], + settledThreadId: "thread-1", + isActive: () => false, + }), + ).toBeNull(); + }); +}); + describe("getVisibleSidebarThreadIds", () => { it("returns only the rendered visible thread order across projects", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7aee3100d0e..a85465817c6 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -351,6 +351,20 @@ export function shouldNavigateAfterProjectRemoval(input: { ); } +export function resolveNextActiveThreadIdAfterSettle(input: { + threadIds: readonly T[]; + settledThreadId: T; + isActive: (threadId: T) => boolean; +}): T | null { + const currentIndex = input.threadIds.indexOf(input.settledThreadId); + if (currentIndex === -1) return null; + return ( + [...input.threadIds.slice(currentIndex + 1), ...input.threadIds.slice(0, currentIndex)].find( + input.isActive, + ) ?? null + ); +} + export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 98fe4e09f75..d69188a0851 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -183,6 +183,7 @@ import { getSidebarThreadIdsToPrewarm, isContextMenuPointerDown, isTrailingDoubleClick, + resolveNextActiveThreadIdAfterSettle, resolveProjectStatusIndicator, resolveThreadRowClassName, resolveThreadStatusPill, @@ -3419,14 +3420,14 @@ export default function Sidebar() { settlingThreadKeysRef.current.add(threadKey); setSettleConfirmationThreadKey((current) => (current === threadKey ? null : current)); - const currentIndex = orderedSidebarThreadKeys.indexOf(threadKey); - const nextThreadKey = - currentIndex === -1 - ? null - : ([ - ...orderedSidebarThreadKeys.slice(currentIndex + 1), - ...orderedSidebarThreadKeys.slice(0, currentIndex), - ][0] ?? null); + const nextThreadKey = resolveNextActiveThreadIdAfterSettle({ + threadIds: orderedSidebarThreadKeys, + settledThreadId: threadKey, + isActive: (candidateKey) => { + const candidate = sidebarThreadByKey.get(candidateKey); + return candidate != null && candidate.settledOverride !== "settled"; + }, + }); const nextThread = nextThreadKey ? sidebarThreadByKey.get(nextThreadKey) : null; void (async () => { @@ -3445,15 +3446,19 @@ export default function Sidebar() { } return; } - if (routeThreadKeyRef.current === threadKey && nextThread) { - navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)); + if (routeThreadKeyRef.current === threadKey) { + if (nextThread) { + navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)); + } else { + void handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId)); + } } } finally { settlingThreadKeysRef.current.delete(threadKey); } })(); }, - [navigateToThread, orderedSidebarThreadKeys, settleThread, sidebarThreadByKey], + [handleNewThread, navigateToThread, orderedSidebarThreadKeys, settleThread, sidebarThreadByKey], ); useEffect(() => { @@ -3489,16 +3494,18 @@ export default function Sidebar() { routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), }); if (action.type === "none") return; + if (action.type === "navigate") { + const targetThread = sidebarThreadByKey.get(action.threadKey); + if (!targetThread) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return; + } event.preventDefault(); event.stopPropagation(); if (action.type === "consume") return; - if (action.type === "confirm-settle") { - setSettleConfirmationThreadKey(action.threadKey); - return; - } - const targetThread = sidebarThreadByKey.get(action.threadKey); - if (!targetThread) return; - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + setSettleConfirmationThreadKey(action.threadKey); }; window.addEventListener("keydown", onWindowKeyDown); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 837a51310d2..000b3ba0dcf 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -2154,16 +2154,18 @@ export default function SidebarV2() { routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), }); if (action.type === "none") return; + if (action.type === "navigate") { + const targetThread = threadByKey.get(action.threadKey); + if (!targetThread) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return; + } event.preventDefault(); event.stopPropagation(); if (action.type === "consume") return; - if (action.type === "confirm-settle") { - setSettleConfirmationThreadKey(action.threadKey); - return; - } - const targetThread = threadByKey.get(action.threadKey); - if (!targetThread) return; - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + setSettleConfirmationThreadKey(action.threadKey); }; window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); From c90ccfe629a7e37b775a78630d453ac807308ada Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 08:06:22 -0700 Subject: [PATCH 04/25] fix(web): navigate to hidden active thread after settle --- apps/web/src/components/Sidebar.logic.test.ts | 22 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 20 ++++++++++++----- apps/web/src/components/Sidebar.tsx | 22 ++++++++++++++++++- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5d3b9f90ae8..e5c8e667c54 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -542,6 +542,28 @@ describe("resolveNextActiveThreadIdAfterSettle", () => { }), ).toBeNull(); }); + + it("falls back to an active thread hidden outside the visible keyboard order", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["visible-current", "visible-settled"], + fallbackThreadIds: ["visible-current", "preview-hidden-active", "collapsed-project-active"], + settledThreadId: "visible-current", + isActive: (threadId) => threadId.endsWith("active"), + }), + ).toBe("preview-hidden-active"); + }); + + it("prefers the next visible active thread before hidden fallbacks", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["visible-current", "visible-next"], + fallbackThreadIds: ["visible-current", "hidden-active"], + settledThreadId: "visible-current", + isActive: (threadId) => threadId !== "visible-current", + }), + ).toBe("visible-next"); + }); }); describe("getVisibleSidebarThreadIds", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a85465817c6..73584d6df64 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -353,16 +353,24 @@ export function shouldNavigateAfterProjectRemoval(input: { export function resolveNextActiveThreadIdAfterSettle(input: { threadIds: readonly T[]; + fallbackThreadIds?: readonly T[]; settledThreadId: T; isActive: (threadId: T) => boolean; }): T | null { const currentIndex = input.threadIds.indexOf(input.settledThreadId); - if (currentIndex === -1) return null; - return ( - [...input.threadIds.slice(currentIndex + 1), ...input.threadIds.slice(0, currentIndex)].find( - input.isActive, - ) ?? null - ); + const preferredThreadIds = + currentIndex === -1 + ? [] + : [...input.threadIds.slice(currentIndex + 1), ...input.threadIds.slice(0, currentIndex)]; + const seen = new Set([input.settledThreadId]); + + for (const threadId of [...preferredThreadIds, ...(input.fallbackThreadIds ?? [])]) { + if (seen.has(threadId)) continue; + seen.add(threadId); + if (input.isActive(threadId)) return threadId; + } + + return null; } export function isContextMenuPointerDown(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d69188a0851..741bcf59f38 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3399,6 +3399,18 @@ export default function Sidebar() { ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; + const allUnarchivedSidebarThreadKeys = useMemo( + () => + sortedProjects.flatMap((project) => + sortThreads( + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), + sidebarThreadSortOrder, + ).map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + [sidebarThreadSortOrder, sortedProjects, threadsByProjectKey], + ); const prewarmedSidebarThreadKeys = useMemo( () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), [visibleSidebarThreadKeys], @@ -3422,6 +3434,7 @@ export default function Sidebar() { const nextThreadKey = resolveNextActiveThreadIdAfterSettle({ threadIds: orderedSidebarThreadKeys, + fallbackThreadIds: allUnarchivedSidebarThreadKeys, settledThreadId: threadKey, isActive: (candidateKey) => { const candidate = sidebarThreadByKey.get(candidateKey); @@ -3458,7 +3471,14 @@ export default function Sidebar() { } })(); }, - [handleNewThread, navigateToThread, orderedSidebarThreadKeys, settleThread, sidebarThreadByKey], + [ + allUnarchivedSidebarThreadKeys, + handleNewThread, + navigateToThread, + orderedSidebarThreadKeys, + settleThread, + sidebarThreadByKey, + ], ); useEffect(() => { From 9e5282d1e61ccd267e12cfdf3543503c1af66d26 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 08:12:54 -0700 Subject: [PATCH 05/25] fix(web): harden settle shortcut navigation --- apps/web/src/components/Sidebar.logic.test.ts | 27 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 17 +++++++----- apps/web/src/components/Sidebar.tsx | 4 ++- apps/web/src/components/SidebarV2.tsx | 2 ++ apps/web/src/keybindings.test.ts | 13 +++++++++ 5 files changed, 56 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e5c8e667c54..6dc7a40b4d8 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -564,6 +564,33 @@ describe("resolveNextActiveThreadIdAfterSettle", () => { }), ).toBe("visible-next"); }); + + it("rotates the full logical order when the settled thread is hidden", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["visible-earlier", "visible-later"], + fallbackThreadIds: [ + "visible-earlier", + "hidden-current", + "logical-next-active", + "earlier-active", + ], + settledThreadId: "hidden-current", + isActive: (threadId) => threadId.endsWith("active"), + }), + ).toBe("logical-next-active"); + }); + + it("rotates hidden fallbacks after exhausting visible active threads", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["visible-current", "visible-settled"], + fallbackThreadIds: ["earlier-active", "visible-current", "hidden-next-active"], + settledThreadId: "visible-current", + isActive: (threadId) => threadId.endsWith("active"), + }), + ).toBe("hidden-next-active"); + }); }); describe("getVisibleSidebarThreadIds", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 73584d6df64..702e20a1d28 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -357,14 +357,19 @@ export function resolveNextActiveThreadIdAfterSettle(input: { settledThreadId: T; isActive: (threadId: T) => boolean; }): T | null { - const currentIndex = input.threadIds.indexOf(input.settledThreadId); - const preferredThreadIds = - currentIndex === -1 - ? [] - : [...input.threadIds.slice(currentIndex + 1), ...input.threadIds.slice(0, currentIndex)]; + const rotateAfterSettledThread = (threadIds: readonly T[]): readonly T[] => { + const currentIndex = threadIds.indexOf(input.settledThreadId); + return currentIndex === -1 + ? threadIds + : [...threadIds.slice(currentIndex + 1), ...threadIds.slice(0, currentIndex)]; + }; + const preferredThreadIds = input.threadIds.includes(input.settledThreadId) + ? rotateAfterSettledThread(input.threadIds) + : []; + const fallbackThreadIds = rotateAfterSettledThread(input.fallbackThreadIds ?? []); const seen = new Set([input.settledThreadId]); - for (const threadId of [...preferredThreadIds, ...(input.fallbackThreadIds ?? [])]) { + for (const threadId of [...preferredThreadIds, ...fallbackThreadIds]) { if (seen.has(threadId)) continue; seen.add(threadId); if (input.isActive(threadId)) return threadId; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 741bcf59f38..7a83c199ae5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -62,6 +62,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -3509,7 +3510,8 @@ export default function Sidebar() { routeThread != null && serverConfigs.get(routeThread.environmentId)?.environment.capabilities .threadSettlement === true && - routeThread.settledOverride !== "settled", + routeThread.settledOverride !== "settled" && + canSettle(routeThread, { now: new Date().toISOString() }), isRouteThreadSettling: routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), }); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 000b3ba0dcf..67527e01077 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,6 +1,7 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import { + canSettle, canSnooze, effectiveSettled, effectiveSnoozed, @@ -2149,6 +2150,7 @@ export default function SidebarV2() { routeThread != null && serverConfigs.get(routeThread.environmentId)?.environment.capabilities .threadSettlement === true && + canSettle(routeThread, { now: new Date().toISOString() }) && !settledThreadKeys.has(routeThreadKey ?? ""), isRouteThreadSettling: routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 9f201486757..66f37f814b6 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -189,6 +189,19 @@ describe("thread settle shortcut", () => { ).toEqual({ type: "confirm-settle", threadKey: "thread-1" }); }); + it("consumes settle when live work makes the route thread ineligible", () => { + expect( + resolveThreadSidebarShortcutAction({ + command: "thread.settle", + orderedThreadKeys: ["thread-1"], + routeThreadKey: "thread-1", + settleConfirmationThreadKey: null, + canSettleRouteThread: false, + isRouteThreadSettling: false, + }), + ).toEqual({ type: "consume" }); + }); + it("consumes thread navigation while settle confirmation is open", () => { expect( resolveThreadSidebarShortcutAction({ From ca2c3909d3655dce81d728c0e0748b60a7243893 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:27:51 -0700 Subject: [PATCH 06/25] fix(web): keep settle shortcut aligned with route state --- apps/web/src/components/Sidebar.logic.test.ts | 112 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 37 ++++++ apps/web/src/components/Sidebar.tsx | 96 +++++++++++++-- apps/web/src/components/SidebarV2.tsx | 88 +++++++++----- 4 files changed, 298 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 6dc7a40b4d8..5d2de32f192 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -12,6 +12,7 @@ import { getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, + isSidebarThreadEffectivelySettled, isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, @@ -25,6 +26,7 @@ import { shouldClearThreadSelectionOnMouseDown, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, + shouldDismissThreadSettleConfirmation, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -42,6 +44,7 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, + type SidebarThreadSummary, type Thread, } from "../types"; @@ -593,6 +596,104 @@ describe("resolveNextActiveThreadIdAfterSettle", () => { }); }); +describe("isSidebarThreadEffectivelySettled", () => { + const now = "2026-03-15T12:00:00.000Z"; + + it("classifies inactivity and closed change requests as settled", () => { + const thread = makeThreadShell({ + latestUserMessageAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + expect( + isSidebarThreadEffectivelySettled({ + thread, + settlementSupported: true, + now, + autoSettleAfterDays: 3, + }), + ).toBe(true); + expect( + isSidebarThreadEffectivelySettled({ + thread: makeThreadShell(), + settlementSupported: true, + now, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + }); + + it("keeps auto-settlement disabled on servers without lifecycle support", () => { + expect( + isSidebarThreadEffectivelySettled({ + thread: makeThreadShell(), + settlementSupported: false, + now, + autoSettleAfterDays: null, + changeRequestState: "merged", + }), + ).toBe(false); + }); + + it("keeps auto-settled threads out of post-settle navigation targets", () => { + const threads = new Map([ + ["current", makeThreadShell({ id: ThreadId.make("current") })], + [ + "inactive", + makeThreadShell({ + id: ThreadId.make("inactive"), + latestUserMessageAt: "2026-03-01T12:00:00.000Z", + }), + ], + ["active", makeThreadShell({ id: ThreadId.make("active") })], + ]); + + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["current", "inactive", "active"], + settledThreadId: "current", + isActive: (threadId) => { + const thread = threads.get(threadId); + return ( + thread !== undefined && + !isSidebarThreadEffectivelySettled({ + thread, + settlementSupported: true, + now, + autoSettleAfterDays: 3, + }) + ); + }, + }), + ).toBe("active"); + }); +}); + +describe("shouldDismissThreadSettleConfirmation", () => { + it("dismisses a confirmation when the route changes away from its target", () => { + expect( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: "thread-1", + routeThreadKey: "thread-2", + targetExists: true, + targetSettled: false, + }), + ).toBe(true); + }); + + it("keeps a confirmation open while its unsettled target remains the route", () => { + expect( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: "thread-1", + routeThreadKey: "thread-1", + targetExists: true, + targetSettled: false, + }), + ).toBe(false); + }); +}); + describe("getVisibleSidebarThreadIds", () => { it("returns only the rendered visible thread order across projects", () => { expect( @@ -1171,6 +1272,17 @@ function makeThread(overrides: Partial = {}): Thread { }; } +function makeThreadShell(overrides: Partial = {}): SidebarThreadSummary { + return { + ...makeThread(), + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + describe("getFallbackThreadIdAfterDelete", () => { it("returns the top remaining thread in the deleted thread's project sidebar order", () => { const fallbackThreadId = getFallbackThreadIdAfterDelete({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 702e20a1d28..efdbfd09156 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,9 @@ import * as React from "react"; import type { ContextMenuItem } from "@t3tools/contracts"; +import { + effectiveSettled, + type ChangeRequestStateLike, +} from "@t3tools/client-runtime/state/thread-settled"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, @@ -45,6 +49,39 @@ type LogicalSidebarProject = SidebarProject & { export type ThreadTraversalDirection = "previous" | "next"; +export function shouldDismissThreadSettleConfirmation(input: { + readonly confirmationThreadKey: T | null; + readonly routeThreadKey: T | null; + readonly targetExists: boolean; + readonly targetSettled: boolean; +}): boolean { + return ( + input.confirmationThreadKey !== null && + (input.confirmationThreadKey !== input.routeThreadKey || + !input.targetExists || + input.targetSettled) + ); +} + +export function isSidebarThreadEffectivelySettled(input: { + readonly thread: SidebarThreadSummary; + readonly settlementSupported: boolean; + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly changeRequestState?: ChangeRequestStateLike | null; +}): boolean { + return ( + input.settlementSupported && + effectiveSettled(input.thread, { + now: input.now, + autoSettleAfterDays: input.autoSettleAfterDays, + ...(input.changeRequestState !== undefined + ? { changeRequestState: input.changeRequestState } + : {}), + }) + ); +} + export async function archiveSelectedThreadEntries< TEntry extends { readonly threadKey: string }, TResult extends { readonly _tag: "Success" | "Failure" }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7a83c199ae5..ca7016bf0cb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -62,7 +62,10 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; +import { + canSettle, + type ChangeRequestStateLike, +} from "@t3tools/client-runtime/state/thread-settled"; import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -183,6 +186,7 @@ import { buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, isContextMenuPointerDown, + isSidebarThreadEffectivelySettled, isTrailingDoubleClick, resolveNextActiveThreadIdAfterSettle, resolveProjectStatusIndicator, @@ -190,6 +194,7 @@ import { resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, + shouldDismissThreadSettleConfirmation, sortProjectsForSidebar, useThreadJumpHintVisibility, ThreadStatusPill, @@ -346,6 +351,7 @@ interface SidebarThreadRowProps { cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; } export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -372,6 +378,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr cancelRename, attemptArchiveThread, openPrLink, + onChangeRequestState, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); @@ -464,6 +471,10 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr hasDedicatedWorktree: thread.worktreePath !== null, }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const prState = pr?.state ?? null; + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -930,6 +941,7 @@ interface SidebarProjectThreadListProps { cancelRename: () => void; attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; openPrLink: (event: React.MouseEvent, prUrl: string) => void; + onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; } @@ -970,6 +982,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename, attemptArchiveThread, openPrLink, + onChangeRequestState, expandThreadListForProject, collapseThreadListForProject, } = props; @@ -1021,6 +1034,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} + onChangeRequestState={onChangeRequestState} /> ); })} @@ -1071,6 +1085,7 @@ interface SidebarProjectItemProps { archiveThread: ReturnType["archiveThread"]; deleteThread: ReturnType["deleteThread"]; threadJumpLabelByKey: ReadonlyMap; + onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; @@ -1091,6 +1106,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec archiveThread, deleteThread, threadJumpLabelByKey, + onChangeRequestState, attachThreadListAutoAnimateRef, expandThreadListForProject, collapseThreadListForProject, @@ -2364,6 +2380,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec cancelRename={cancelRename} attemptArchiveThread={attemptArchiveThread} openPrLink={openPrLink} + onChangeRequestState={onChangeRequestState} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} /> @@ -2762,6 +2779,7 @@ interface SidebarProjectsContentProps { newThreadShortcutLabel: string | null; commandPaletteShortcutLabel: string | null; threadJumpLabelByKey: ReadonlyMap; + onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; collapseThreadListForProject: (projectKey: string) => void; @@ -2802,6 +2820,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( newThreadShortcutLabel, commandPaletteShortcutLabel, threadJumpLabelByKey, + onChangeRequestState, attachThreadListAutoAnimateRef, expandThreadListForProject, collapseThreadListForProject, @@ -2939,6 +2958,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( archiveThread={archiveThread} deleteThread={deleteThread} threadJumpLabelByKey={threadJumpLabelByKey} + onChangeRequestState={onChangeRequestState} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} @@ -2971,6 +2991,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( archiveThread={archiveThread} deleteThread={deleteThread} threadJumpLabelByKey={threadJumpLabelByKey} + onChangeRequestState={onChangeRequestState} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} @@ -3007,6 +3028,7 @@ export default function Sidebar() { const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const sidebarAutoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread, settleThread } = useThreadActions(); @@ -3030,6 +3052,50 @@ export default function Sidebar() { null, ); const settleConfirmationButtonRef = useRef(null); + const [settlementNowMinute, setSettlementNowMinute] = useState(() => + new Date().toISOString().slice(0, 16), + ); + useEffect(() => { + const id = window.setInterval( + () => setSettlementNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + return () => window.clearInterval(id); + }, []); + const settlementNow = `${settlementNowMinute}:00.000Z`; + const [changeRequestStateByThreadKey, setChangeRequestStateByThreadKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: ChangeRequestStateLike | null) => { + setChangeRequestStateByThreadKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + const isThreadEffectivelySettled = useCallback( + (thread: SidebarThreadSummary) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return isSidebarThreadEffectivelySettled({ + thread, + settlementSupported: + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true, + now: settlementNow, + autoSettleAfterDays: sidebarAutoSettleAfterDays, + changeRequestState: changeRequestStateByThreadKey.get(threadKey) ?? null, + }); + }, + [changeRequestStateByThreadKey, serverConfigs, settlementNow, sidebarAutoSettleAfterDays], + ); const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -3439,7 +3505,7 @@ export default function Sidebar() { settledThreadId: threadKey, isActive: (candidateKey) => { const candidate = sidebarThreadByKey.get(candidateKey); - return candidate != null && candidate.settledOverride !== "settled"; + return candidate != null && !isThreadEffectivelySettled(candidate); }, }); const nextThread = nextThreadKey ? sidebarThreadByKey.get(nextThreadKey) : null; @@ -3475,6 +3541,7 @@ export default function Sidebar() { [ allUnarchivedSidebarThreadKeys, handleNewThread, + isThreadEffectivelySettled, navigateToThread, orderedSidebarThreadKeys, settleThread, @@ -3510,7 +3577,7 @@ export default function Sidebar() { routeThread != null && serverConfigs.get(routeThread.environmentId)?.environment.capabilities .threadSettlement === true && - routeThread.settledOverride !== "settled" && + !isThreadEffectivelySettled(routeThread) && canSettle(routeThread, { now: new Date().toISOString() }), isRouteThreadSettling: routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), @@ -3537,6 +3604,7 @@ export default function Sidebar() { }; }, [ getCurrentSidebarShortcutContext, + isThreadEffectivelySettled, keybindings, navigateToThread, orderedSidebarThreadKeys, @@ -3552,18 +3620,31 @@ export default function Sidebar() { ? (sidebarThreadByKey.get(settleConfirmationThreadKey) ?? null) : null; const confirmShortcutSettle = useCallback(() => { - if (!settleConfirmationThreadKey) return; + if (!settleConfirmationThreadKey || settleConfirmationThreadKey !== routeThreadKeyRef.current) { + setSettleConfirmationThreadKey(null); + return; + } attemptShortcutSettle(settleConfirmationThreadKey); }, [attemptShortcutSettle, settleConfirmationThreadKey]); useEffect(() => { if ( - settleConfirmationThreadKey !== null && - (settleConfirmationThread === null || settleConfirmationThread.settledOverride === "settled") + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: settleConfirmationThreadKey, + routeThreadKey, + targetExists: settleConfirmationThread !== null, + targetSettled: + settleConfirmationThread !== null && isThreadEffectivelySettled(settleConfirmationThread), + }) ) { setSettleConfirmationThreadKey(null); } - }, [settleConfirmationThread, settleConfirmationThreadKey]); + }, [ + isThreadEffectivelySettled, + routeThreadKey, + settleConfirmationThread, + settleConfirmationThreadKey, + ]); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { @@ -3720,6 +3801,7 @@ export default function Sidebar() { newThreadShortcutLabel={newThreadShortcutLabel} commandPaletteShortcutLabel={commandPaletteShortcutLabel} threadJumpLabelByKey={visibleThreadJumpLabelByKey} + onChangeRequestState={handleChangeRequestState} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} collapseThreadListForProject={collapseThreadListForProject} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 67527e01077..5a06c7a5b75 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -3,7 +3,6 @@ import { useAtomValue } from "@effect/atom-react"; import { canSettle, canSnooze, - effectiveSettled, effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; @@ -115,6 +114,7 @@ import { formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, + isSidebarThreadEffectivelySettled, isTrailingDoubleClick, orderItemsByPreferredIds, resolveSettledTimestamp, @@ -124,6 +124,7 @@ import { sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, + shouldDismissThreadSettleConfirmation, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; @@ -1361,8 +1362,22 @@ export default function SidebarV2() { // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const isThreadEffectivelySettled = useCallback( + (thread: SidebarThreadSummary) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return isSidebarThreadEffectivelySettled({ + thread, + settlementSupported: + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true, + now: `${nowMinute}:00.000Z`, + autoSettleAfterDays, + changeRequestState: changeRequestStateByKey.get(threadKey) ?? null, + }); + }, + [autoSettleAfterDays, changeRequestStateByKey, nowMinute, serverConfigs], + ); const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; // Snooze classification uses a REAL clock, not the quantized minute: // wake times are second-precise and a woken thread must not linger on // the shelf for the rest of the minute. snoozeWakeTick re-runs this @@ -1379,25 +1394,14 @@ export default function SidebarV2() { const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of visible) { - // Threads on servers without the settlement capability (old server, - // or descriptor not loaded yet) never classify as settled: the user - // could neither un-settle nor pin them, so auto-settling them would - // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; // Snooze outranks settled classification: an explicitly snoozed thread // belongs to the shelf even if it would also auto-settle (the shelf's // wake time is a stronger statement about when it matters again). if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { + } else if (isThreadEffectivelySettled(thread)) { settled.push(thread); } else { active.push(thread); @@ -1415,9 +1419,7 @@ export default function SidebarV2() { snoozeNow: preciseNow, }; }, [ - autoSettleAfterDays, - changeRequestStateByKey, - nowMinute, + isThreadEffectivelySettled, scopedProjectKeys, serverConfigs, snoozeWakeTick, @@ -1523,6 +1525,23 @@ export default function SidebarV2() { // rendered at click time. const orderedThreadKeysRef = useRef(orderedThreadKeys); orderedThreadKeysRef.current = orderedThreadKeys; + // Route actions are independent of the selected project chip. Keep a map + // of every live shell for eligibility and confirmation; rendered traversal + // continues to use the scoped map below. + const allThreadByKey = useMemo( + () => + new Map( + threads + .filter((thread) => thread.archivedAt === null) + .map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [threads], + ); + const allThreadByKeyRef = useRef(allThreadByKey); + allThreadByKeyRef.current = allThreadByKey; const threadByKey = useMemo( () => new Map( @@ -1671,7 +1690,7 @@ export default function SidebarV2() { const planForwardNavigation = useCallback( (threadKey: string, coParkingKeys?: ReadonlySet): (() => void) | null => { if (routeThreadKeyRef.current !== threadKey) return null; - const shell = threadByKeyRef.current.get(threadKey); + const shell = allThreadByKeyRef.current.get(threadKey); const orderedKeys = orderedThreadKeysRef.current; const settledKeys = settledThreadKeysRef.current; const snoozedKeys = snoozedThreadKeysRef.current; @@ -2140,7 +2159,7 @@ export default function SidebarV2() { modelPickerOpen: isModelPickerOpen(), }, }); - const routeThread = routeThreadKey ? threadByKey.get(routeThreadKey) : null; + const routeThread = routeThreadKey ? allThreadByKey.get(routeThreadKey) : null; const action = resolveThreadSidebarShortcutAction({ command, orderedThreadKeys, @@ -2151,7 +2170,7 @@ export default function SidebarV2() { serverConfigs.get(routeThread.environmentId)?.environment.capabilities .threadSettlement === true && canSettle(routeThread, { now: new Date().toISOString() }) && - !settledThreadKeys.has(routeThreadKey ?? ""), + !isThreadEffectivelySettled(routeThread), isRouteThreadSettling: routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), }); @@ -2172,6 +2191,8 @@ export default function SidebarV2() { window.addEventListener("keydown", onWindowKeyDown); return () => window.removeEventListener("keydown", onWindowKeyDown); }, [ + allThreadByKey, + isThreadEffectivelySettled, keybindings, navigateToThread, orderedThreadKeys, @@ -2179,29 +2200,40 @@ export default function SidebarV2() { routeThreadKey, serverConfigs, settleConfirmationThreadKey, - settledThreadKeys, threadByKey, ]); const settleConfirmationThread = settleConfirmationThreadKey - ? (threadByKey.get(settleConfirmationThreadKey) ?? null) + ? (allThreadByKey.get(settleConfirmationThreadKey) ?? null) : null; const confirmShortcutSettle = useCallback(() => { - if (!settleConfirmationThread) return; + if (!settleConfirmationThread || settleConfirmationThreadKey !== routeThreadKeyRef.current) { + setSettleConfirmationThreadKey(null); + return; + } attemptSettle( scopeThreadRef(settleConfirmationThread.environmentId, settleConfirmationThread.id), ); - }, [attemptSettle, settleConfirmationThread]); + }, [attemptSettle, settleConfirmationThread, settleConfirmationThreadKey]); useEffect(() => { if ( - settleConfirmationThreadKey !== null && - (settledThreadKeys.has(settleConfirmationThreadKey) || - !threadByKey.has(settleConfirmationThreadKey)) + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: settleConfirmationThreadKey, + routeThreadKey, + targetExists: settleConfirmationThread !== null, + targetSettled: + settleConfirmationThread !== null && isThreadEffectivelySettled(settleConfirmationThread), + }) ) { setSettleConfirmationThreadKey(null); } - }, [settleConfirmationThreadKey, settledThreadKeys, threadByKey]); + }, [ + isThreadEffectivelySettled, + routeThreadKey, + settleConfirmationThread, + settleConfirmationThreadKey, + ]); // Same predicate as v1: hints show only while the held modifiers exactly // match a thread-jump binding. Adding Shift (screenshots) or Alt no From 14cb2a784c50098c7e8788d3b15d8380a8d7a68e Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:48:03 -0700 Subject: [PATCH 07/25] fix(web): harden settle fallback lifecycle --- apps/web/src/components/Sidebar.logic.test.ts | 78 ++++++++++++++++++- apps/web/src/components/Sidebar.logic.ts | 15 +++- apps/web/src/components/Sidebar.tsx | 23 +++--- apps/web/src/components/SidebarV2.tsx | 76 +++++++++++++----- 4 files changed, 158 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5d2de32f192..351ce349a81 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -15,6 +15,7 @@ import { isSidebarThreadEffectivelySettled, isTrailingDoubleClick, orderItemsByPreferredIds, + registerMountedThreadChangeRequestState, resolveProjectStatusIndicator, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, @@ -584,6 +585,22 @@ describe("resolveNextActiveThreadIdAfterSettle", () => { ).toBe("logical-next-active"); }); + it("finds an active full-list target when V2's scoped order excludes the current route", () => { + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["scoped-settled"], + fallbackThreadIds: [ + "full-list-before", + "hidden-current", + "hidden-settled", + "hidden-active", + ], + settledThreadId: "hidden-current", + isActive: (threadId) => threadId === "hidden-active", + }), + ).toBe("hidden-active"); + }); + it("rotates hidden fallbacks after exhausting visible active threads", () => { expect( resolveNextActiveThreadIdAfterSettle({ @@ -677,7 +694,7 @@ describe("shouldDismissThreadSettleConfirmation", () => { confirmationThreadKey: "thread-1", routeThreadKey: "thread-2", targetExists: true, - targetSettled: false, + targetExplicitlySettled: false, }), ).toBe(true); }); @@ -688,10 +705,67 @@ describe("shouldDismissThreadSettleConfirmation", () => { confirmationThreadKey: "thread-1", routeThreadKey: "thread-1", targetExists: true, - targetSettled: false, + targetExplicitlySettled: false, }), ).toBe(false); }); + + it("keeps a confirmation open when inactivity only makes the target effectively settled", () => { + const target = makeThreadShell({ + latestUserMessageAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + settledOverride: null, + }); + + expect( + isSidebarThreadEffectivelySettled({ + thread: target, + settlementSupported: true, + now: "2026-03-15T12:00:00.000Z", + autoSettleAfterDays: 3, + }), + ).toBe(true); + expect( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: "thread-1", + routeThreadKey: "thread-1", + targetExists: true, + targetExplicitlySettled: target.settledOverride === "settled", + }), + ).toBe(false); + }); + + it("dismisses a confirmation after the server explicitly settles its target", () => { + expect( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: "thread-1", + routeThreadKey: "thread-1", + targetExists: true, + targetExplicitlySettled: true, + }), + ).toBe(true); + }); +}); + +describe("registerMountedThreadChangeRequestState", () => { + it("removes a row's PR state when the row unmounts", () => { + const states = new Map(); + const unregister = registerMountedThreadChangeRequestState({ + threadKey: "thread-1", + state: "merged" as const, + onChange: (threadKey, state) => { + if (state === null) { + states.delete(threadKey); + } else { + states.set(threadKey, state); + } + }, + }); + + expect(states.get("thread-1")).toBe("merged"); + unregister(); + expect(states.has("thread-1")).toBe(false); + }); }); describe("getVisibleSidebarThreadIds", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index efdbfd09156..f3819e421e6 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -53,16 +53,27 @@ export function shouldDismissThreadSettleConfirmation(input: { readonly confirmationThreadKey: T | null; readonly routeThreadKey: T | null; readonly targetExists: boolean; - readonly targetSettled: boolean; + readonly targetExplicitlySettled: boolean; }): boolean { return ( input.confirmationThreadKey !== null && (input.confirmationThreadKey !== input.routeThreadKey || !input.targetExists || - input.targetSettled) + input.targetExplicitlySettled) ); } +export function registerMountedThreadChangeRequestState(input: { + readonly threadKey: string; + readonly state: TState | null; + readonly onChange: (threadKey: string, state: TState | null) => void; +}): () => void { + input.onChange(input.threadKey, input.state); + return () => { + input.onChange(input.threadKey, null); + }; +} + export function isSidebarThreadEffectivelySettled(input: { readonly thread: SidebarThreadSummary; readonly settlementSupported: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ca7016bf0cb..925e231418b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -193,6 +193,7 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + registerMountedThreadChangeRequestState, shouldClearThreadSelectionOnMouseDown, shouldDismissThreadSettleConfirmation, sortProjectsForSidebar, @@ -472,9 +473,15 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const prState = pr?.state ?? null; - useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + useEffect( + () => + registerMountedThreadChangeRequestState({ + threadKey, + state: prState, + onChange: onChangeRequestState, + }), + [onChangeRequestState, prState, threadKey], + ); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -3633,18 +3640,12 @@ export default function Sidebar() { confirmationThreadKey: settleConfirmationThreadKey, routeThreadKey, targetExists: settleConfirmationThread !== null, - targetSettled: - settleConfirmationThread !== null && isThreadEffectivelySettled(settleConfirmationThread), + targetExplicitlySettled: settleConfirmationThread?.settledOverride === "settled", }) ) { setSettleConfirmationThreadKey(null); } - }, [ - isThreadEffectivelySettled, - routeThreadKey, - settleConfirmationThread, - settleConfirmationThreadKey, - ]); + }, [routeThreadKey, settleConfirmationThread, settleConfirmationThreadKey]); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 5a06c7a5b75..f880699a0a4 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -118,6 +118,7 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveSettledTimestamp, + resolveNextActiveThreadIdAfterSettle, resolveSidebarV2Status, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, @@ -1377,6 +1378,8 @@ export default function SidebarV2() { }, [autoSettleAfterDays, changeRequestStateByKey, nowMinute, serverConfigs], ); + const isThreadEffectivelySettledRef = useRef(isThreadEffectivelySettled); + isThreadEffectivelySettledRef.current = isThreadEffectivelySettled; const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { // Snooze classification uses a REAL clock, not the quantized minute: // wake times are second-precise and a woken thread must not linger on @@ -1525,20 +1528,31 @@ export default function SidebarV2() { // rendered at click time. const orderedThreadKeysRef = useRef(orderedThreadKeys); orderedThreadKeysRef.current = orderedThreadKeys; - // Route actions are independent of the selected project chip. Keep a map - // of every live shell for eligibility and confirmation; rendered traversal - // continues to use the scoped map below. + // Route actions are independent of the selected project chip. Keep every + // live shell in stable creation order for eligibility, confirmation, and + // post-settle fallback; rendered keyboard traversal stays scoped below. + const allUnarchivedThreads = useMemo( + () => sortThreadsForSidebarV2(threads.filter((thread) => thread.archivedAt === null)), + [threads], + ); + const allUnarchivedThreadKeys = useMemo( + () => + allUnarchivedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [allUnarchivedThreads], + ); + const allUnarchivedThreadKeysRef = useRef(allUnarchivedThreadKeys); + allUnarchivedThreadKeysRef.current = allUnarchivedThreadKeys; const allThreadByKey = useMemo( () => new Map( - threads - .filter((thread) => thread.archivedAt === null) - .map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), + allUnarchivedThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), ), - [threads], + [allUnarchivedThreads], ); const allThreadByKeyRef = useRef(allThreadByKey); allThreadByKeyRef.current = allThreadByKey; @@ -1720,7 +1734,37 @@ export default function SidebarV2() { settlingThreadKeysRef.current.add(threadKey); setSettleConfirmationThreadKey((current) => (current === threadKey ? null : current)); try { - const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); + // Settling the thread you're looking at moves you forward: the next + // remaining card (never a settled row, never one settling in the + // same batch), or a fresh draft in this project when it was the + // last active one. Snapshot the target before the settle mutates + // the partition. Background settles never navigate. + const shell = allThreadByKeyRef.current.get(threadKey); + let navigateAfterSettle: (() => void) | null = null; + if (routeThreadKey === threadKey) { + const nextThreadKey = resolveNextActiveThreadIdAfterSettle({ + threadIds: orderedThreadKeysRef.current, + fallbackThreadIds: allUnarchivedThreadKeysRef.current, + settledThreadId: threadKey, + isActive: (candidateKey) => { + const candidate = allThreadByKeyRef.current.get(candidateKey); + return ( + candidate !== undefined && + !opts.coSettlingKeys?.has(candidateKey) && + !isThreadEffectivelySettledRef.current(candidate) + ); + }, + }); + const nextThread = nextThreadKey ? allThreadByKeyRef.current.get(nextThreadKey) : null; + navigateAfterSettle = nextThread + ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) + : shell + ? () => + void handleNewThreadRef.current( + scopeProjectRef(shell.environmentId, shell.projectId), + ) + : () => void router.navigate({ to: "/" }); + } const result = await settleThread(threadRef); if (result._tag === "Failure") { // Never navigate away from a thread that did not settle. @@ -2222,18 +2266,12 @@ export default function SidebarV2() { confirmationThreadKey: settleConfirmationThreadKey, routeThreadKey, targetExists: settleConfirmationThread !== null, - targetSettled: - settleConfirmationThread !== null && isThreadEffectivelySettled(settleConfirmationThread), + targetExplicitlySettled: settleConfirmationThread?.settledOverride === "settled", }) ) { setSettleConfirmationThreadKey(null); } - }, [ - isThreadEffectivelySettled, - routeThreadKey, - settleConfirmationThread, - settleConfirmationThreadKey, - ]); + }, [routeThreadKey, settleConfirmationThread, settleConfirmationThreadKey]); // Same predicate as v1: hints show only while the held modifiers exactly // match a thread-jump binding. Adding Shift (screenshots) or Alt no From e43845f762b443669733c5b4c2ca26b244497d28 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:56:45 -0700 Subject: [PATCH 08/25] fix(web): keep hidden thread PR state live --- apps/web/src/components/Sidebar.logic.test.ts | 28 +++---- apps/web/src/components/Sidebar.logic.ts | 14 ++-- apps/web/src/components/Sidebar.tsx | 73 ++++++++++++++++--- 3 files changed, 77 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 351ce349a81..fd6a5f8649a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -10,12 +10,12 @@ import { getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, + getSidebarThreadKeysNeedingChangeRequestReporter, hasUnseenCompletion, isContextMenuPointerDown, isSidebarThreadEffectivelySettled, isTrailingDoubleClick, orderItemsByPreferredIds, - registerMountedThreadChangeRequestState, resolveProjectStatusIndicator, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, @@ -747,24 +747,14 @@ describe("shouldDismissThreadSettleConfirmation", () => { }); }); -describe("registerMountedThreadChangeRequestState", () => { - it("removes a row's PR state when the row unmounts", () => { - const states = new Map(); - const unregister = registerMountedThreadChangeRequestState({ - threadKey: "thread-1", - state: "merged" as const, - onChange: (threadKey, state) => { - if (state === null) { - states.delete(threadKey); - } else { - states.set(threadKey, state); - } - }, - }); - - expect(states.get("thread-1")).toBe("merged"); - unregister(); - expect(states.has("thread-1")).toBe(false); +describe("getSidebarThreadKeysNeedingChangeRequestReporter", () => { + it("keeps live PR-state reporters for collapsed and preview-hidden rows", () => { + expect( + getSidebarThreadKeysNeedingChangeRequestReporter( + ["visible", "preview-hidden", "collapsed"], + new Set(["visible"]), + ), + ).toEqual(["preview-hidden", "collapsed"]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f3819e421e6..3f5c3ce48cf 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -63,15 +63,11 @@ export function shouldDismissThreadSettleConfirmation(input: { ); } -export function registerMountedThreadChangeRequestState(input: { - readonly threadKey: string; - readonly state: TState | null; - readonly onChange: (threadKey: string, state: TState | null) => void; -}): () => void { - input.onChange(input.threadKey, input.state); - return () => { - input.onChange(input.threadKey, null); - }; +export function getSidebarThreadKeysNeedingChangeRequestReporter( + allThreadKeys: readonly T[], + visibleThreadKeys: ReadonlySet, +): T[] { + return allThreadKeys.filter((threadKey) => !visibleThreadKeys.has(threadKey)); } export function isSidebarThreadEffectivelySettled(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 925e231418b..791f0e2f5e1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -184,6 +184,7 @@ import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + getSidebarThreadKeysNeedingChangeRequestReporter, getSidebarThreadIdsToPrewarm, isContextMenuPointerDown, isSidebarThreadEffectivelySettled, @@ -193,7 +194,6 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, - registerMountedThreadChangeRequestState, shouldClearThreadSelectionOnMouseDown, shouldDismissThreadSettleConfirmation, sortProjectsForSidebar, @@ -316,6 +316,36 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } +function SidebarHiddenThreadChangeRequestStateReporter(props: { + thread: SidebarThreadSummary; + onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; +}) { + const { thread, onChangeRequestState } = props; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const threadProject = useProject( + useMemo( + () => scopeProjectRef(thread.environmentId, thread.projectId), + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = thread.worktreePath ?? threadProject?.workspaceRoot ?? null; + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const prState = resolveThreadPr(thread.branch, gitStatus.data)?.state ?? null; + + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + return null; +} + interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; @@ -473,15 +503,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const prState = pr?.state ?? null; - useEffect( - () => - registerMountedThreadChangeRequestState({ - threadKey, - state: prState, - onChange: onChangeRequestState, - }), - [onChangeRequestState, prState, threadKey], - ); + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -3485,6 +3509,25 @@ export default function Sidebar() { ), [sidebarThreadSortOrder, sortedProjects, threadsByProjectKey], ); + const visibleSidebarThreadKeySet = useMemo( + () => new Set(visibleSidebarThreadKeys), + [visibleSidebarThreadKeys], + ); + const changeRequestReporterThreadKeys = useMemo( + () => + getSidebarThreadKeysNeedingChangeRequestReporter( + allUnarchivedSidebarThreadKeys, + visibleSidebarThreadKeySet, + ), + [allUnarchivedSidebarThreadKeys, visibleSidebarThreadKeySet], + ); + useEffect(() => { + const liveThreadKeys = new Set(allUnarchivedSidebarThreadKeys); + setChangeRequestStateByThreadKey((current) => { + if ([...current.keys()].every((threadKey) => liveThreadKeys.has(threadKey))) return current; + return new Map([...current].filter(([threadKey]) => liveThreadKeys.has(threadKey))); + }); + }, [allUnarchivedSidebarThreadKeys]); const prewarmedSidebarThreadKeys = useMemo( () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), [visibleSidebarThreadKeys], @@ -3766,6 +3809,16 @@ export default function Sidebar() { return ( <> + {changeRequestReporterThreadKeys.map((threadKey) => { + const thread = sidebarThreadByKey.get(threadKey); + return thread ? ( + + ) : null; + })} {prewarmedSidebarThreadRefs.map((threadRef) => ( ))} From e40dcf82ec2cbe49b9796440977651dcc1f4535c Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 10:04:42 -0700 Subject: [PATCH 09/25] fix(web): preserve hidden thread project fallback --- apps/web/src/components/Sidebar.logic.test.ts | 27 ++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 8 +++++ apps/web/src/components/Sidebar.tsx | 32 +++++++++++++++++-- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fd6a5f8649a..e9f5a68dd7f 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -17,6 +17,7 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, + resolveSidebarThreadGitCwd, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveSidebarV2Status, @@ -758,6 +759,32 @@ describe("getSidebarThreadKeysNeedingChangeRequestReporter", () => { }); }); +describe("resolveSidebarThreadGitCwd", () => { + it("uses the same fallback order for visible and hidden thread reporters", () => { + expect( + resolveSidebarThreadGitCwd({ + worktreePath: "/worktree", + threadProjectCwd: "/thread-project", + sidebarProjectCwd: "/sidebar-project", + }), + ).toBe("/worktree"); + expect( + resolveSidebarThreadGitCwd({ + worktreePath: null, + threadProjectCwd: "/thread-project", + sidebarProjectCwd: "/sidebar-project", + }), + ).toBe("/thread-project"); + expect( + resolveSidebarThreadGitCwd({ + worktreePath: null, + threadProjectCwd: null, + sidebarProjectCwd: "/sidebar-project", + }), + ).toBe("/sidebar-project"); + }); +}); + describe("getVisibleSidebarThreadIds", () => { it("returns only the rendered visible thread order across projects", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3f5c3ce48cf..0e7c54007d4 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -70,6 +70,14 @@ export function getSidebarThreadKeysNeedingChangeRequestReporter( return allThreadKeys.filter((threadKey) => !visibleThreadKeys.has(threadKey)); } +export function resolveSidebarThreadGitCwd(input: { + readonly worktreePath: string | null; + readonly threadProjectCwd: string | null; + readonly sidebarProjectCwd: string | null; +}): string | null { + return input.worktreePath ?? input.threadProjectCwd ?? input.sidebarProjectCwd; +} + export function isSidebarThreadEffectivelySettled(input: { readonly thread: SidebarThreadSummary; readonly settlementSupported: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 791f0e2f5e1..ef8124550aa 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -191,6 +191,7 @@ import { isTrailingDoubleClick, resolveNextActiveThreadIdAfterSettle, resolveProjectStatusIndicator, + resolveSidebarThreadGitCwd, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -318,9 +319,10 @@ function buildThreadJumpLabelMap(input: { function SidebarHiddenThreadChangeRequestStateReporter(props: { thread: SidebarThreadSummary; + projectCwd: string | null; onChangeRequestState: (threadKey: string, state: ChangeRequestStateLike | null) => void; }) { - const { thread, onChangeRequestState } = props; + const { thread, projectCwd, onChangeRequestState } = props; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const threadProject = useProject( useMemo( @@ -328,7 +330,11 @@ function SidebarHiddenThreadChangeRequestStateReporter(props: { [thread.environmentId, thread.projectId], ), ); - const gitCwd = thread.worktreePath ?? threadProject?.workspaceRoot ?? null; + const gitCwd = resolveSidebarThreadGitCwd({ + worktreePath: thread.worktreePath, + threadProjectCwd: threadProject?.workspaceRoot ?? null, + sidebarProjectCwd: projectCwd, + }); const gitStatus = useEnvironmentQuery( thread.branch != null && gitCwd !== null ? vcsEnvironment.status({ @@ -453,7 +459,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ), ); const threadProjectCwd = threadProject?.workspaceRoot ?? null; - const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; + const gitCwd = resolveSidebarThreadGitCwd({ + worktreePath: thread.worktreePath, + threadProjectCwd, + sidebarProjectCwd: props.projectCwd, + }); const gitStatus = useEnvironmentQuery( thread.branch != null && gitCwd !== null ? vcsEnvironment.status({ @@ -3264,6 +3274,21 @@ export default function Sidebar() { } return next; }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + const sidebarProjectCwdByThreadKey = useMemo( + () => + new Map( + sidebarThreads.map((thread) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const scopedProject = scopedProjectKey( + scopeProjectRef(thread.environmentId, thread.projectId), + ); + const physicalKey = projectPhysicalKeyByScopedRef.get(scopedProject) ?? scopedProject; + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + return [threadKey, sidebarProjectByKey.get(logicalKey)?.workspaceRoot ?? null] as const; + }), + ), + [physicalToLogicalKey, projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarThreads], + ); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -3815,6 +3840,7 @@ export default function Sidebar() { ) : null; From ba45fd528f7b0ce6109261bc7cd78f26e9842e16 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 10:09:26 -0700 Subject: [PATCH 10/25] fix(web): report sidebar PR state on settings routes --- apps/web/src/components/Sidebar.logic.test.ts | 11 +++++++++++ apps/web/src/components/Sidebar.logic.ts | 5 ++++- apps/web/src/components/Sidebar.tsx | 3 ++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e9f5a68dd7f..0e4cbd5a0f6 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -754,9 +754,20 @@ describe("getSidebarThreadKeysNeedingChangeRequestReporter", () => { getSidebarThreadKeysNeedingChangeRequestReporter( ["visible", "preview-hidden", "collapsed"], new Set(["visible"]), + true, ), ).toEqual(["preview-hidden", "collapsed"]); }); + + it("reports every thread when settings replaces the sidebar thread rows", () => { + expect( + getSidebarThreadKeysNeedingChangeRequestReporter( + ["would-be-visible", "preview-hidden", "collapsed"], + new Set(["would-be-visible"]), + false, + ), + ).toEqual(["would-be-visible", "preview-hidden", "collapsed"]); + }); }); describe("resolveSidebarThreadGitCwd", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 0e7c54007d4..8e4b586006f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -66,8 +66,11 @@ export function shouldDismissThreadSettleConfirmation(input: { export function getSidebarThreadKeysNeedingChangeRequestReporter( allThreadKeys: readonly T[], visibleThreadKeys: ReadonlySet, + sidebarThreadRowsMounted: boolean, ): T[] { - return allThreadKeys.filter((threadKey) => !visibleThreadKeys.has(threadKey)); + return allThreadKeys.filter( + (threadKey) => !sidebarThreadRowsMounted || !visibleThreadKeys.has(threadKey), + ); } export function resolveSidebarThreadGitCwd(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ef8124550aa..8e5ff5bc94e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3543,8 +3543,9 @@ export default function Sidebar() { getSidebarThreadKeysNeedingChangeRequestReporter( allUnarchivedSidebarThreadKeys, visibleSidebarThreadKeySet, + !isOnSettings, ), - [allUnarchivedSidebarThreadKeys, visibleSidebarThreadKeySet], + [allUnarchivedSidebarThreadKeys, isOnSettings, visibleSidebarThreadKeySet], ); useEffect(() => { const liveThreadKeys = new Set(allUnarchivedSidebarThreadKeys); From 6a2f49c7aa0cc5e6dae59220cbaf2c704380bde7 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 16:02:24 -0700 Subject: [PATCH 11/25] Change settle shortcut to Mod+Shift+X --- apps/server/src/keybindings.test.ts | 2 +- apps/web/src/keybindings.test.ts | 21 +++++++++------------ docs/user/keybindings.md | 2 +- packages/shared/src/keybindings.ts | 2 +- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 0ac30ceca92..05bcabe7677 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,7 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); - assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+enter"); + assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+x"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 66f37f814b6..22a9aa23672 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -128,7 +128,7 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { - shortcut: modShortcut("enter", { shiftKey: true }), + shortcut: modShortcut("x", { shiftKey: true }), command: "thread.settle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, @@ -155,24 +155,21 @@ const DEFAULT_BINDINGS = compile([ ]); describe("thread settle shortcut", () => { - it("resolves Mod+Shift+Enter outside the terminal", () => { + it("resolves Mod+Shift+X outside the terminal", () => { assert.strictEqual( - resolveShortcutCommand( - event({ key: "Enter", metaKey: true, shiftKey: true }), - DEFAULT_BINDINGS, - { platform: "MacIntel" }, - ), + resolveShortcutCommand(event({ key: "x", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), "thread.settle", ); }); it("does not resolve while the terminal owns focus", () => { assert.isNull( - resolveShortcutCommand( - event({ key: "Enter", ctrlKey: true, shiftKey: true }), - DEFAULT_BINDINGS, - { platform: "Linux", context: { terminalFocus: true } }, - ), + resolveShortcutCommand(event({ key: "x", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), ); }); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 700e5927954..ca252dc134a 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -34,7 +34,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, { "key": "mod+o", "command": "editor.openFavorite" }, - { "key": "mod+shift+enter", "command": "thread.settle", "when": "!terminalFocus" } + { "key": "mod+shift+x", "command": "thread.settle", "when": "!terminalFocus" } ] ``` diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 202629660dd..0e6afc876cd 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -40,7 +40,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, - { key: "mod+shift+enter", command: "thread.settle", when: "!terminalFocus" }, + { key: "mod+shift+x", command: "thread.settle", when: "!terminalFocus" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ From f536f990a734ecb03c666951b8e8d01597d912fb Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 16:42:52 -0700 Subject: [PATCH 12/25] fix(web): adapt settle reporter to current PR status API --- apps/web/src/components/Sidebar.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8e5ff5bc94e..72da06ee315 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -343,7 +343,12 @@ function SidebarHiddenThreadChangeRequestStateReporter(props: { }) : null, ); - const prState = resolveThreadPr(thread.branch, gitStatus.data)?.state ?? null; + const prState = + resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + })?.state ?? null; useEffect(() => { onChangeRequestState(threadKey, prState); From 95b70a52f7a0d1b542bd20c2193ce8063870aad2 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 18:23:52 -0700 Subject: [PATCH 13/25] fix(web): keep PR state live for hidden sidebar threads --- apps/web/src/components/SidebarV2.tsx | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f880699a0a4..62d0dba8b76 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -113,6 +113,7 @@ import { cn } from "~/lib/utils"; import { formatWorkingDurationLabel, firstValidTimestampMs, + getSidebarThreadKeysNeedingChangeRequestReporter, hasUnseenCompletion, isSidebarThreadEffectivelySettled, isTrailingDoubleClick, @@ -165,6 +166,36 @@ import { useComposerDraftStore } from "../composerDraftStore"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; + +function SidebarV2HiddenThreadChangeRequestStateReporter(props: { + thread: SidebarThreadSummary; + projectCwd: string | null; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; +}) { + const { thread, projectCwd, onChangeRequestState } = props; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const gitCwd = thread.worktreePath ?? projectCwd; + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const prState = + resolveThreadPr({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + hasDedicatedWorktree: thread.worktreePath !== null, + })?.state ?? null; + + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + return null; +} const SETTLED_TAIL_PAGE_COUNT = 25; const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", @@ -1566,6 +1597,16 @@ export default function SidebarV2() { ), [orderedThreads], ); + const visibleThreadKeySet = useMemo(() => new Set(orderedThreadKeys), [orderedThreadKeys]); + const hiddenChangeRequestReporterThreadKeys = useMemo( + () => + getSidebarThreadKeysNeedingChangeRequestReporter( + allUnarchivedThreadKeys, + visibleThreadKeySet, + true, + ), + [allUnarchivedThreadKeys, visibleThreadKeySet], + ); // Handlers read these through refs: depending on per-update Map/Set // identities would give every row a fresh callback prop on each shell // event and defeat row memoization during streaming. @@ -2319,6 +2360,17 @@ export default function SidebarV2() { shortcutLabelForCommand(keybindings, "chat.new"); return ( <> + {hiddenChangeRequestReporterThreadKeys.map((threadKey) => { + const thread = allThreadByKey.get(threadKey); + return thread ? ( + + ) : null; + })} From 97cf70a15e019d063a350fc6c890295b8a54613b Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 18:35:58 -0700 Subject: [PATCH 14/25] fix(web): report worktree PR state for hidden threads --- apps/web/src/components/SidebarV2.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 62d0dba8b76..89d5c2be900 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -176,7 +176,7 @@ function SidebarV2HiddenThreadChangeRequestStateReporter(props: { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const gitCwd = thread.worktreePath ?? projectCwd; const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, From 8a10b5cddc4e8b595566096945c9b0a9415d3322 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 09:07:50 -0700 Subject: [PATCH 15/25] fix(web): restore server config hook after rebase --- apps/web/src/components/Sidebar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 72da06ee315..47417f94e2e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -84,6 +84,7 @@ import { readThreadShell, useProject, useProjects, + useServerConfigs, useThreadShells, useThreadShellsForProjectRefs, } from "../state/entities"; From 5b3272d197401bc97dac9d071eaace0f292c9714 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 09:14:50 -0700 Subject: [PATCH 16/25] fix(web): deduplicate sidebar button import after rebase --- apps/web/src/components/SidebarV2.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 89d5c2be900..ebe20f81d67 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -97,7 +97,6 @@ import { AlertDialogPopup, AlertDialogTitle, } from "./ui/alert-dialog"; -import { Button } from "./ui/button"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; From 8098fc0d2b5d518319e030667ffd0e2f9aeee28e Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 09:30:05 -0700 Subject: [PATCH 17/25] fix(web): keep sidebar PR state current --- apps/web/src/components/Sidebar.logic.test.ts | 21 +++++++++++++ apps/web/src/components/Sidebar.logic.ts | 13 ++++++++ apps/web/src/components/SidebarV2.tsx | 31 ++++++++++++++++--- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0e4cbd5a0f6..70b407bd8de 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -17,6 +17,7 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, + pruneSidebarChangeRequestStates, resolveSidebarThreadGitCwd, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, @@ -796,6 +797,26 @@ describe("resolveSidebarThreadGitCwd", () => { }); }); +describe("pruneSidebarChangeRequestStates", () => { + it("drops state for removed and archived threads", () => { + expect( + pruneSidebarChangeRequestStates( + new Map([ + ["active", "open"], + ["removed", "closed"], + ["archived", "merged"], + ]), + new Set(["active"]), + ), + ).toEqual(new Map([["active", "open"]])); + }); + + it("preserves map identity when every state still belongs to a live thread", () => { + const current = new Map([["active", "open"]]); + expect(pruneSidebarChangeRequestStates(current, new Set(["active"]))).toBe(current); + }); +}); + describe("getVisibleSidebarThreadIds", () => { it("returns only the rendered visible thread order across projects", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8e4b586006f..86ab2c33376 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -81,6 +81,19 @@ export function resolveSidebarThreadGitCwd(input: { return input.worktreePath ?? input.threadProjectCwd ?? input.sidebarProjectCwd; } +export function pruneSidebarChangeRequestStates( + current: ReadonlyMap, + liveThreadKeys: ReadonlySet, +): ReadonlyMap { + let next: Map | null = null; + for (const threadKey of current.keys()) { + if (liveThreadKeys.has(threadKey)) continue; + next ??= new Map(current); + next.delete(threadKey); + } + return next ?? current; +} + export function isSidebarThreadEffectivelySettled(input: { readonly thread: SidebarThreadSummary; readonly settlementSupported: boolean; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ebe20f81d67..23f375883e6 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -118,7 +118,9 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, resolveSettledTimestamp, + pruneSidebarChangeRequestStates, resolveNextActiveThreadIdAfterSettle, + resolveSidebarThreadGitCwd, resolveSidebarV2Status, resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, @@ -168,12 +170,16 @@ const SETTLED_TAIL_INITIAL_COUNT = 10; function SidebarV2HiddenThreadChangeRequestStateReporter(props: { thread: SidebarThreadSummary; - projectCwd: string | null; + threadProjectCwd: string | null; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { - const { thread, projectCwd, onChangeRequestState } = props; + const { thread, threadProjectCwd, onChangeRequestState } = props; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const gitCwd = thread.worktreePath ?? projectCwd; + const gitCwd = resolveSidebarThreadGitCwd({ + worktreePath: thread.worktreePath, + threadProjectCwd, + sidebarProjectCwd: null, + }); const gitStatus = useEnvironmentQuery( (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ @@ -518,7 +524,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { } : null; - const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitCwd = resolveSidebarThreadGitCwd({ + worktreePath: thread.worktreePath, + threadProjectCwd: props.projectCwd, + sidebarProjectCwd: null, + }); const gitStatus = useEnvironmentQuery( (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ @@ -1572,6 +1582,15 @@ export default function SidebarV2() { ), [allUnarchivedThreads], ); + const allUnarchivedThreadKeySet = useMemo( + () => new Set(allUnarchivedThreadKeys), + [allUnarchivedThreadKeys], + ); + useEffect(() => { + setChangeRequestStateByKey((current) => + pruneSidebarChangeRequestStates(current, allUnarchivedThreadKeySet), + ); + }, [allUnarchivedThreadKeySet]); const allUnarchivedThreadKeysRef = useRef(allUnarchivedThreadKeys); allUnarchivedThreadKeysRef.current = allUnarchivedThreadKeys; const allThreadByKey = useMemo( @@ -2365,7 +2384,9 @@ export default function SidebarV2() { ) : null; From f82c578e46367c2d2a8f611eb1d6945ccd72612a Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 10:06:09 -0700 Subject: [PATCH 18/25] fix(web): query PR state for worktree-only threads --- apps/web/src/components/Sidebar.logic.test.ts | 30 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 8 +++++ apps/web/src/components/Sidebar.tsx | 13 ++++++-- apps/web/src/components/SidebarV2.tsx | 13 ++++++-- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 70b407bd8de..15a5a0aabbd 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -30,6 +30,7 @@ import { sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, shouldDismissThreadSettleConfirmation, + shouldQuerySidebarThreadGitStatus, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -53,6 +54,35 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("shouldQuerySidebarThreadGitStatus", () => { + it("queries worktree-only threads so their pull request state can be reported", () => { + expect( + shouldQuerySidebarThreadGitStatus({ + branch: null, + worktreePath: "/repo/.t3/worktrees/feature", + gitCwd: "/repo/.t3/worktrees/feature", + }), + ).toBe(true); + }); + + it("requires a cwd and either a branch or worktree", () => { + expect( + shouldQuerySidebarThreadGitStatus({ + branch: "feature", + worktreePath: null, + gitCwd: null, + }), + ).toBe(false); + expect( + shouldQuerySidebarThreadGitStatus({ + branch: null, + worktreePath: null, + gitCwd: "/repo", + }), + ).toBe(false); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 86ab2c33376..0acdfec2453 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -81,6 +81,14 @@ export function resolveSidebarThreadGitCwd(input: { return input.worktreePath ?? input.threadProjectCwd ?? input.sidebarProjectCwd; } +export function shouldQuerySidebarThreadGitStatus(input: { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly gitCwd: string | null; +}): boolean { + return (input.branch !== null || input.worktreePath !== null) && input.gitCwd !== null; +} + export function pruneSidebarChangeRequestStates( current: ReadonlyMap, liveThreadKeys: ReadonlySet, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 47417f94e2e..84c6d373eb8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -196,6 +196,7 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + shouldQuerySidebarThreadGitStatus, shouldClearThreadSelectionOnMouseDown, shouldDismissThreadSettleConfirmation, sortProjectsForSidebar, @@ -337,7 +338,11 @@ function SidebarHiddenThreadChangeRequestStateReporter(props: { sidebarProjectCwd: projectCwd, }); const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -471,7 +476,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr sidebarProjectCwd: props.projectCwd, }); const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 23f375883e6..610e9b5b12f 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -123,6 +123,7 @@ import { resolveSidebarThreadGitCwd, resolveSidebarV2Status, resolveWorkingStartedAt, + shouldQuerySidebarThreadGitStatus, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, @@ -181,7 +182,11 @@ function SidebarV2HiddenThreadChangeRequestStateReporter(props: { sidebarProjectCwd: null, }); const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -530,7 +535,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { sidebarProjectCwd: null, }); const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, From 76459a5933d137e42fc3c1b9cca5f8aa18d8f7c5 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 13:21:50 -0700 Subject: [PATCH 19/25] fix(web): narrow sidebar git status cwd --- apps/web/src/components/Sidebar.tsx | 22 ++++++++++++---------- apps/web/src/components/SidebarV2.tsx | 22 ++++++++++++---------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 84c6d373eb8..c6982c26719 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -338,11 +338,12 @@ function SidebarHiddenThreadChangeRequestStateReporter(props: { sidebarProjectCwd: projectCwd, }); const gitStatus = useEnvironmentQuery( - shouldQuerySidebarThreadGitStatus({ - branch: thread.branch, - worktreePath: thread.worktreePath, - gitCwd, - }) + gitCwd !== null && + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -476,11 +477,12 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr sidebarProjectCwd: props.projectCwd, }); const gitStatus = useEnvironmentQuery( - shouldQuerySidebarThreadGitStatus({ - branch: thread.branch, - worktreePath: thread.worktreePath, - gitCwd, - }) + gitCwd !== null && + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 610e9b5b12f..10703ec8efe 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -182,11 +182,12 @@ function SidebarV2HiddenThreadChangeRequestStateReporter(props: { sidebarProjectCwd: null, }); const gitStatus = useEnvironmentQuery( - shouldQuerySidebarThreadGitStatus({ - branch: thread.branch, - worktreePath: thread.worktreePath, - gitCwd, - }) + gitCwd !== null && + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -535,11 +536,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { sidebarProjectCwd: null, }); const gitStatus = useEnvironmentQuery( - shouldQuerySidebarThreadGitStatus({ - branch: thread.branch, - worktreePath: thread.worktreePath, - gitCwd, - }) + gitCwd !== null && + shouldQuerySidebarThreadGitStatus({ + branch: thread.branch, + worktreePath: thread.worktreePath, + gitCwd, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, From 8eff375d2b50b0058d57fb6ba96e28621152ccb0 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 13:43:26 -0700 Subject: [PATCH 20/25] fix(web): retain PR state during sidebar refresh --- apps/web/src/components/Sidebar.logic.test.ts | 11 +++++++++++ apps/web/src/components/Sidebar.logic.ts | 4 ++++ apps/web/src/components/Sidebar.tsx | 7 +++++-- apps/web/src/components/SidebarV2.tsx | 7 +++++-- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 15a5a0aabbd..34be6d0c481 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -31,6 +31,7 @@ import { sortSettledThreadsForSidebarV2, shouldDismissThreadSettleConfirmation, shouldQuerySidebarThreadGitStatus, + shouldPublishSidebarChangeRequestState, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -83,6 +84,16 @@ describe("shouldQuerySidebarThreadGitStatus", () => { }); }); +describe("shouldPublishSidebarChangeRequestState", () => { + it("preserves the last known state while git status is pending", () => { + expect(shouldPublishSidebarChangeRequestState(true)).toBe(false); + }); + + it("publishes the resolved state after git status settles", () => { + expect(shouldPublishSidebarChangeRequestState(false)).toBe(true); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 0acdfec2453..9f87f5ad1d0 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -89,6 +89,10 @@ export function shouldQuerySidebarThreadGitStatus(input: { return (input.branch !== null || input.worktreePath !== null) && input.gitCwd !== null; } +export function shouldPublishSidebarChangeRequestState(isGitStatusPending: boolean): boolean { + return !isGitStatusPending; +} + export function pruneSidebarChangeRequestStates( current: ReadonlyMap, liveThreadKeys: ReadonlySet, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c6982c26719..4f577a31831 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -196,6 +196,7 @@ import { resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + shouldPublishSidebarChangeRequestState, shouldQuerySidebarThreadGitStatus, shouldClearThreadSelectionOnMouseDown, shouldDismissThreadSettleConfirmation, @@ -358,8 +359,9 @@ function SidebarHiddenThreadChangeRequestStateReporter(props: { })?.state ?? null; useEffect(() => { + if (!shouldPublishSidebarChangeRequestState(gitStatus.isPending)) return; onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + }, [gitStatus.isPending, onChangeRequestState, prState, threadKey]); return null; } @@ -531,8 +533,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const prState = pr?.state ?? null; useEffect(() => { + if (!shouldPublishSidebarChangeRequestState(gitStatus.isPending)) return; onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + }, [gitStatus.isPending, onChangeRequestState, prState, threadKey]); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 10703ec8efe..0c778a2e6d5 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -123,6 +123,7 @@ import { resolveSidebarThreadGitCwd, resolveSidebarV2Status, resolveWorkingStartedAt, + shouldPublishSidebarChangeRequestState, shouldQuerySidebarThreadGitStatus, shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, @@ -202,8 +203,9 @@ function SidebarV2HiddenThreadChangeRequestStateReporter(props: { })?.state ?? null; useEffect(() => { + if (!shouldPublishSidebarChangeRequestState(gitStatus.isPending)) return; onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + }, [gitStatus.isPending, onChangeRequestState, prState, threadKey]); return null; } @@ -564,8 +566,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // and a merged/closed PR auto-settles a thread — data only rows have. const prState = pr?.state ?? null; useEffect(() => { + if (!shouldPublishSidebarChangeRequestState(gitStatus.isPending)) return; onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + }, [gitStatus.isPending, onChangeRequestState, prState, threadKey]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; From ab30e668ea0a327789d9e2d3360111b10f8b9a44 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 16:23:37 -0700 Subject: [PATCH 21/25] fix(web): align legacy settle shortcut eligibility --- apps/web/src/components/Sidebar.logic.test.ts | 71 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 13 ++++ apps/web/src/components/Sidebar.tsx | 20 +++--- 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 34be6d0c481..2bddf2452ec 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + canSettleLegacySidebarRouteThread, createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, @@ -730,6 +731,76 @@ describe("isSidebarThreadEffectivelySettled", () => { }); }); +describe("canSettleLegacySidebarRouteThread", () => { + const now = "2026-03-15T12:00:00.000Z"; + + it("keeps inactive and pull-request-idle threads eligible for explicit settlement", () => { + const inactiveThread = makeThreadShell({ + latestUserMessageAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const pullRequestIdleThread = makeThreadShell({ + latestUserMessageAt: "2026-03-15T10:00:00.000Z", + updatedAt: "2026-03-15T10:00:00.000Z", + }); + + expect( + isSidebarThreadEffectivelySettled({ + thread: inactiveThread, + settlementSupported: true, + now, + autoSettleAfterDays: 3, + }), + ).toBe(true); + expect( + isSidebarThreadEffectivelySettled({ + thread: pullRequestIdleThread, + settlementSupported: true, + now, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + + for (const thread of [inactiveThread, pullRequestIdleThread]) { + expect( + canSettleLegacySidebarRouteThread({ + thread, + settlementSupported: true, + now, + }), + ).toBe(true); + } + }); + + it("uses the settle operation's capability and live-work guards", () => { + expect( + canSettleLegacySidebarRouteThread({ + thread: makeThreadShell(), + settlementSupported: false, + now, + }), + ).toBe(false); + expect( + canSettleLegacySidebarRouteThread({ + thread: makeThreadShell({ + session: { + threadId: ThreadId.make("thread-running"), + status: "running", + providerName: "Codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-running" as never, + lastError: null, + updatedAt: now, + }, + }), + settlementSupported: true, + now, + }), + ).toBe(false); + }); +}); + describe("shouldDismissThreadSettleConfirmation", () => { it("dismisses a confirmation when the route changes away from its target", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9f87f5ad1d0..602c6a63964 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,6 +1,7 @@ import * as React from "react"; import type { ContextMenuItem } from "@t3tools/contracts"; import { + canSettle, effectiveSettled, type ChangeRequestStateLike, } from "@t3tools/client-runtime/state/thread-settled"; @@ -125,6 +126,18 @@ export function isSidebarThreadEffectivelySettled(input: { ); } +export function canSettleLegacySidebarRouteThread(input: { + readonly thread: SidebarThreadSummary | null; + readonly settlementSupported: boolean; + readonly now: string; +}): boolean { + return ( + input.thread !== null && + input.settlementSupported && + canSettle(input.thread, { now: input.now }) + ); +} + export async function archiveSelectedThreadEntries< TEntry extends { readonly threadKey: string }, TResult extends { readonly _tag: "Success" | "Failure" }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4f577a31831..91ce14af871 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -62,10 +62,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { - canSettle, - type ChangeRequestStateLike, -} from "@t3tools/client-runtime/state/thread-settled"; +import { type ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled"; import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -185,6 +182,7 @@ import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + canSettleLegacySidebarRouteThread, getSidebarThreadKeysNeedingChangeRequestReporter, getSidebarThreadIdsToPrewarm, isContextMenuPointerDown, @@ -3669,12 +3667,14 @@ export default function Sidebar() { jumpThreadKeys: threadJumpThreadKeys, routeThreadKey, settleConfirmationThreadKey, - canSettleRouteThread: - routeThread != null && - serverConfigs.get(routeThread.environmentId)?.environment.capabilities - .threadSettlement === true && - !isThreadEffectivelySettled(routeThread) && - canSettle(routeThread, { now: new Date().toISOString() }), + canSettleRouteThread: canSettleLegacySidebarRouteThread({ + thread: routeThread ?? null, + settlementSupported: + routeThread != null && + serverConfigs.get(routeThread.environmentId)?.environment.capabilities + .threadSettlement === true, + now: new Date().toISOString(), + }), isRouteThreadSettling: routeThreadKey !== null && settlingThreadKeysRef.current.has(routeThreadKey), }); From e24715aca1f422a96c205f91743a4b19a3e6044d Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 16:33:03 -0700 Subject: [PATCH 22/25] fix(web): skip explicitly settled legacy threads --- apps/web/src/components/Sidebar.logic.test.ts | 10 ++++++++++ apps/web/src/components/Sidebar.logic.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 2bddf2452ec..85ebc6b99b3 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -773,6 +773,16 @@ describe("canSettleLegacySidebarRouteThread", () => { } }); + it("does not reopen settlement for an explicitly settled thread", () => { + expect( + canSettleLegacySidebarRouteThread({ + thread: makeThreadShell({ settledOverride: "settled" }), + settlementSupported: true, + now, + }), + ).toBe(false); + }); + it("uses the settle operation's capability and live-work guards", () => { expect( canSettleLegacySidebarRouteThread({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 602c6a63964..3bbad8c92bb 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -134,6 +134,7 @@ export function canSettleLegacySidebarRouteThread(input: { return ( input.thread !== null && input.settlementSupported && + input.thread.settledOverride !== "settled" && canSettle(input.thread, { now: input.now }) ); } From f969fb530b4f3609862c67616072526247fcbde1 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 19:37:19 -0700 Subject: [PATCH 23/25] fix(web): use current route for settle navigation --- apps/web/src/components/SidebarV2.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 0c778a2e6d5..84a178e171d 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1814,7 +1814,7 @@ export default function SidebarV2() { // the partition. Background settles never navigate. const shell = allThreadByKeyRef.current.get(threadKey); let navigateAfterSettle: (() => void) | null = null; - if (routeThreadKey === threadKey) { + if (routeThreadKeyRef.current === threadKey) { const nextThreadKey = resolveNextActiveThreadIdAfterSettle({ threadIds: orderedThreadKeysRef.current, fallbackThreadIds: allUnarchivedThreadKeysRef.current, @@ -1863,7 +1863,7 @@ export default function SidebarV2() { } })(); }, - [planForwardNavigation, settleThread], + [navigateToThread, router, settleThread], ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { From 2df90f6f0361db3c4940ec9739521d4c6765a149 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 23:08:05 -0700 Subject: [PATCH 24/25] fix(web): close stale settle confirmations --- apps/web/src/components/Sidebar.logic.test.ts | 16 +++++++ apps/web/src/components/Sidebar.logic.ts | 4 +- apps/web/src/components/Sidebar.tsx | 45 ++++++++++++------- apps/web/src/components/SidebarV2.tsx | 42 ++++++++++++----- 4 files changed, 79 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 85ebc6b99b3..54d1079351e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -819,6 +819,7 @@ describe("shouldDismissThreadSettleConfirmation", () => { routeThreadKey: "thread-2", targetExists: true, targetExplicitlySettled: false, + targetCanSettle: true, }), ).toBe(true); }); @@ -830,6 +831,7 @@ describe("shouldDismissThreadSettleConfirmation", () => { routeThreadKey: "thread-1", targetExists: true, targetExplicitlySettled: false, + targetCanSettle: true, }), ).toBe(false); }); @@ -855,6 +857,7 @@ describe("shouldDismissThreadSettleConfirmation", () => { routeThreadKey: "thread-1", targetExists: true, targetExplicitlySettled: target.settledOverride === "settled", + targetCanSettle: true, }), ).toBe(false); }); @@ -866,6 +869,19 @@ describe("shouldDismissThreadSettleConfirmation", () => { routeThreadKey: "thread-1", targetExists: true, targetExplicitlySettled: true, + targetCanSettle: true, + }), + ).toBe(true); + }); + + it("dismisses a confirmation when live work makes the target unsettleable", () => { + expect( + shouldDismissThreadSettleConfirmation({ + confirmationThreadKey: "thread-1", + routeThreadKey: "thread-1", + targetExists: true, + targetExplicitlySettled: false, + targetCanSettle: false, }), ).toBe(true); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3bbad8c92bb..7ade7093952 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -55,12 +55,14 @@ export function shouldDismissThreadSettleConfirmation(input: { readonly routeThreadKey: T | null; readonly targetExists: boolean; readonly targetExplicitlySettled: boolean; + readonly targetCanSettle: boolean; }): boolean { return ( input.confirmationThreadKey !== null && (input.confirmationThreadKey !== input.routeThreadKey || !input.targetExists || - input.targetExplicitlySettled) + input.targetExplicitlySettled || + !input.targetCanSettle) ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 91ce14af871..33eb0b6e287 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -206,6 +206,7 @@ import { sortThreads } from "../lib/threadSort"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; +import { useNowMinute } from "~/hooks/useNowMinute"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { primaryServerKeybindingsAtom } from "../state/server"; @@ -3111,16 +3112,7 @@ export default function Sidebar() { null, ); const settleConfirmationButtonRef = useRef(null); - const [settlementNowMinute, setSettlementNowMinute] = useState(() => - new Date().toISOString().slice(0, 16), - ); - useEffect(() => { - const id = window.setInterval( - () => setSettlementNowMinute(new Date().toISOString().slice(0, 16)), - 60_000, - ); - return () => window.clearInterval(id); - }, []); + const settlementNowMinute = useNowMinute(); const settlementNow = `${settlementNowMinute}:00.000Z`; const [changeRequestStateByThreadKey, setChangeRequestStateByThreadKey] = useState< ReadonlyMap @@ -3715,13 +3707,26 @@ export default function Sidebar() { const settleConfirmationThread = settleConfirmationThreadKey ? (sidebarThreadByKey.get(settleConfirmationThreadKey) ?? null) : null; + const settleConfirmationCanSettle = + settleConfirmationThread !== null && + serverConfigs.get(settleConfirmationThread.environmentId)?.environment.capabilities + .threadSettlement === true && + canSettleLegacySidebarRouteThread({ + thread: settleConfirmationThread, + settlementSupported: true, + now: new Date().toISOString(), + }); const confirmShortcutSettle = useCallback(() => { - if (!settleConfirmationThreadKey || settleConfirmationThreadKey !== routeThreadKeyRef.current) { + if ( + !settleConfirmationThreadKey || + settleConfirmationThreadKey !== routeThreadKeyRef.current || + !settleConfirmationCanSettle + ) { setSettleConfirmationThreadKey(null); return; } attemptShortcutSettle(settleConfirmationThreadKey); - }, [attemptShortcutSettle, settleConfirmationThreadKey]); + }, [attemptShortcutSettle, settleConfirmationCanSettle, settleConfirmationThreadKey]); useEffect(() => { if ( @@ -3730,11 +3735,17 @@ export default function Sidebar() { routeThreadKey, targetExists: settleConfirmationThread !== null, targetExplicitlySettled: settleConfirmationThread?.settledOverride === "settled", + targetCanSettle: settleConfirmationCanSettle, }) ) { setSettleConfirmationThreadKey(null); } - }, [routeThreadKey, settleConfirmationThread, settleConfirmationThreadKey]); + }, [ + routeThreadKey, + settleConfirmationCanSettle, + settleConfirmationThread, + settleConfirmationThreadKey, + ]); useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { @@ -3918,7 +3929,7 @@ export default function Sidebar() { )} { if (!open) setSettleConfirmationThreadKey(null); }} @@ -3934,7 +3945,11 @@ export default function Sidebar() { }>Cancel - diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 84a178e171d..fe61270739b 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1475,13 +1475,7 @@ export default function SidebarV2() { settledThreads: sortSettledThreadsForSidebarV2(settled), snoozeNow: preciseNow, }; - }, [ - isThreadEffectivelySettled, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + }, [isThreadEffectivelySettled, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted @@ -2323,15 +2317,29 @@ export default function SidebarV2() { const settleConfirmationThread = settleConfirmationThreadKey ? (allThreadByKey.get(settleConfirmationThreadKey) ?? null) : null; + const settleConfirmationCanSettle = + settleConfirmationThread !== null && + serverConfigs.get(settleConfirmationThread.environmentId)?.environment.capabilities + .threadSettlement === true && + canSettle(settleConfirmationThread, { now: new Date().toISOString() }); const confirmShortcutSettle = useCallback(() => { - if (!settleConfirmationThread || settleConfirmationThreadKey !== routeThreadKeyRef.current) { + if ( + !settleConfirmationThread || + settleConfirmationThreadKey !== routeThreadKeyRef.current || + !settleConfirmationCanSettle + ) { setSettleConfirmationThreadKey(null); return; } attemptSettle( scopeThreadRef(settleConfirmationThread.environmentId, settleConfirmationThread.id), ); - }, [attemptSettle, settleConfirmationThread, settleConfirmationThreadKey]); + }, [ + attemptSettle, + settleConfirmationCanSettle, + settleConfirmationThread, + settleConfirmationThreadKey, + ]); useEffect(() => { if ( @@ -2340,11 +2348,17 @@ export default function SidebarV2() { routeThreadKey, targetExists: settleConfirmationThread !== null, targetExplicitlySettled: settleConfirmationThread?.settledOverride === "settled", + targetCanSettle: settleConfirmationCanSettle, }) ) { setSettleConfirmationThreadKey(null); } - }, [routeThreadKey, settleConfirmationThread, settleConfirmationThreadKey]); + }, [ + routeThreadKey, + settleConfirmationCanSettle, + settleConfirmationThread, + settleConfirmationThreadKey, + ]); // Same predicate as v1: hints show only while the held modifiers exactly // match a thread-jump binding. Adding Shift (screenshots) or Alt no @@ -2920,7 +2934,7 @@ export default function SidebarV2() { { if (!open) setSettleConfirmationThreadKey(null); }} @@ -2936,7 +2950,11 @@ export default function SidebarV2() { }>Cancel - From a4b2ed09502a1ae11a051d0110b0a414a6f02159 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 23:22:42 -0700 Subject: [PATCH 25/25] fix(web): skip snoozed settle fallbacks --- apps/web/src/components/Sidebar.logic.test.ts | 70 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 9 +++ apps/web/src/components/SidebarV2.tsx | 34 ++++++--- 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 54d1079351e..8a8a5bb171b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -14,6 +14,7 @@ import { getSidebarThreadKeysNeedingChangeRequestReporter, hasUnseenCompletion, isContextMenuPointerDown, + isSidebarThreadEffectivelySnoozed, isSidebarThreadEffectivelySettled, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -731,6 +732,75 @@ describe("isSidebarThreadEffectivelySettled", () => { }); }); +describe("isSidebarThreadEffectivelySnoozed", () => { + const now = "2026-04-10T12:00:00.000Z"; + + it("requires server snooze support and a future wake time", () => { + const snoozedThread = makeThreadShell({ + snoozedAt: "2026-04-10T09:00:00.000Z", + snoozedUntil: "2026-04-11T09:00:00.000Z", + }); + + expect( + isSidebarThreadEffectivelySnoozed({ + thread: snoozedThread, + snoozeSupported: true, + now, + }), + ).toBe(true); + expect( + isSidebarThreadEffectivelySnoozed({ + thread: snoozedThread, + snoozeSupported: false, + now, + }), + ).toBe(false); + expect( + isSidebarThreadEffectivelySnoozed({ + thread: makeThreadShell({ + snoozedAt: "2026-04-09T09:00:00.000Z", + snoozedUntil: "2026-04-10T09:00:00.000Z", + }), + snoozeSupported: true, + now, + }), + ).toBe(false); + }); + + it("keeps snoozed threads out of post-settle navigation targets", () => { + const threads = new Map([ + ["current", makeThreadShell({ id: ThreadId.make("current") })], + [ + "snoozed", + makeThreadShell({ + id: ThreadId.make("snoozed"), + snoozedAt: "2026-04-10T09:00:00.000Z", + snoozedUntil: "2026-04-11T09:00:00.000Z", + }), + ], + ["active", makeThreadShell({ id: ThreadId.make("active") })], + ]); + + expect( + resolveNextActiveThreadIdAfterSettle({ + threadIds: ["current", "snoozed", "active"], + settledThreadId: "current", + isActive: (threadId) => { + const thread = threads.get(threadId); + return ( + thread !== undefined && + !isSidebarThreadEffectivelySnoozed({ + thread, + snoozeSupported: true, + now, + }) + ); + }, + }), + ).toBe("active"); + }); +}); + describe("canSettleLegacySidebarRouteThread", () => { const now = "2026-03-15T12:00:00.000Z"; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7ade7093952..e7731dc64c5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -2,6 +2,7 @@ import * as React from "react"; import type { ContextMenuItem } from "@t3tools/contracts"; import { canSettle, + effectiveSnoozed, effectiveSettled, type ChangeRequestStateLike, } from "@t3tools/client-runtime/state/thread-settled"; @@ -128,6 +129,14 @@ export function isSidebarThreadEffectivelySettled(input: { ); } +export function isSidebarThreadEffectivelySnoozed(input: { + readonly thread: SidebarThreadSummary; + readonly snoozeSupported: boolean; + readonly now: string; +}): boolean { + return input.snoozeSupported && effectiveSnoozed(input.thread, { now: input.now }); +} + export function canSettleLegacySidebarRouteThread(input: { readonly thread: SidebarThreadSummary | null; readonly settlementSupported: boolean; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index fe61270739b..367ed1ce78c 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,11 +1,6 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; -import { - canSettle, - canSnooze, - effectiveSnoozed, - threadWokeAt, -} from "@t3tools/client-runtime/state/thread-settled"; +import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, @@ -114,6 +109,7 @@ import { firstValidTimestampMs, getSidebarThreadKeysNeedingChangeRequestReporter, hasUnseenCompletion, + isSidebarThreadEffectivelySnoozed, isSidebarThreadEffectivelySettled, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -1434,6 +1430,18 @@ export default function SidebarV2() { ); const isThreadEffectivelySettledRef = useRef(isThreadEffectivelySettled); isThreadEffectivelySettledRef.current = isThreadEffectivelySettled; + const isThreadEffectivelySnoozed = useCallback( + (thread: SidebarThreadSummary, now: string) => + isSidebarThreadEffectivelySnoozed({ + thread, + snoozeSupported: + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true, + now, + }), + [serverConfigs], + ); + const isThreadEffectivelySnoozedRef = useRef(isThreadEffectivelySnoozed); + isThreadEffectivelySnoozedRef.current = isThreadEffectivelySnoozed; const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { // Snooze classification uses a REAL clock, not the quantized minute: // wake times are second-precise and a woken thread must not linger on @@ -1451,12 +1459,10 @@ export default function SidebarV2() { const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; for (const thread of visible) { - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; // Snooze outranks settled classification: an explicitly snoozed thread // belongs to the shelf even if it would also auto-settle (the shelf's // wake time is a stronger statement about when it matters again). - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + if (isThreadEffectivelySnoozed(thread, preciseNow)) { snoozed.push(thread); } else if (isThreadEffectivelySettled(thread)) { settled.push(thread); @@ -1475,7 +1481,13 @@ export default function SidebarV2() { settledThreads: sortSettledThreadsForSidebarV2(settled), snoozeNow: preciseNow, }; - }, [isThreadEffectivelySettled, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); + }, [ + isThreadEffectivelySettled, + isThreadEffectivelySnoozed, + scopedProjectKeys, + snoozeWakeTick, + threads, + ]); // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted @@ -1809,6 +1821,7 @@ export default function SidebarV2() { const shell = allThreadByKeyRef.current.get(threadKey); let navigateAfterSettle: (() => void) | null = null; if (routeThreadKeyRef.current === threadKey) { + const navigationNow = new Date().toISOString(); const nextThreadKey = resolveNextActiveThreadIdAfterSettle({ threadIds: orderedThreadKeysRef.current, fallbackThreadIds: allUnarchivedThreadKeysRef.current, @@ -1818,6 +1831,7 @@ export default function SidebarV2() { return ( candidate !== undefined && !opts.coSettlingKeys?.has(candidateKey) && + !isThreadEffectivelySnoozedRef.current(candidate, navigationNow) && !isThreadEffectivelySettledRef.current(candidate) ); },