From 0b7a09f40af9a9fdbcc7303e783bc1fd23b11a83 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 21:26:47 +0200 Subject: [PATCH 1/2] feat(web): toggle a thread's pin from the keyboard Pinning a thread needed the sidebar or chat-header context menu. Added a `thread.pin` command that toggles the pin on the thread you have open, defaulting to `mod+shift+p` and inactive while the terminal has focus. The handler sits next to `thread.settle` in ChatView and dispatches through the shared `pinThread`/`unpinThread` actions, so a fresh pin lands at the top of the pinned run and the `threadPinning` capability gate keeps the command away from older servers. Model: Claude Opus 5. Harness: Claude Code. --- apps/server/src/keybindings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 27 ++++++++++++++++++++++++++- docs/user/keybindings.md | 4 ++++ docs/user/thread-sidebar.md | 4 ++-- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 1f05604934e4..391e040f6d59 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -196,6 +196,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+s"); + assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); 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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0188af478c0..81aa0c1c34cf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1272,7 +1272,7 @@ function ChatViewContent(props: ChatViewProps) { const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); - const { settleThread } = useThreadActions(); + const { settleThread, pinThread, unpinThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -4428,6 +4428,8 @@ function ChatViewContent(props: ChatViewProps) { ); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; + const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; + const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null; const nowMinute = useNowMinute(); const snoozeNow = new Date().toISOString(); const activeThreadSnoozed = @@ -5140,6 +5142,25 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (command === "thread.pin") { + event.preventDefault(); + event.stopPropagation(); + if (!isServerThread || !activeThreadRef || !supportsPinning) return; + const pinned = activeThreadPinned; + void (pinned ? unpinThread(activeThreadRef) : pinThread(activeThreadRef)).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: pinned ? "Failed to unpin thread" : "Failed to pin thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + return; + } + if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); @@ -5244,6 +5265,7 @@ function ChatViewContent(props: ChatViewProps) { activeRightPanelSurface, addTerminalSurface, activeThreadRef, + activeThreadPinned, activeThreadSettled, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, @@ -5259,8 +5281,11 @@ function ChatViewContent(props: ChatViewProps) { handleUnsettleActiveThread, isServerThread, onToggleDiff, + pinThread, settleThread, + supportsPinning, supportsSettlement, + unpinThread, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 6bf89d091f73..e1aed78d4505 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -55,6 +55,10 @@ so add one in **Settings** → **Keybindings** if you want to use it. `thread.settle` settles the active thread or restores it when it is already settled. Its default shortcut is `mod+shift+s`, and it does not run while the terminal has focus. +`thread.pin` pins the active thread to the top of the sidebar, or unpins it when it is already +pinned. A fresh pin lands at the top of the pinned list. Its default shortcut is `mod+shift+p`, and +it does not run while the terminal has focus. + The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while keeping the thread's project, branch, and machine context visible. Message search begins after two diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 51eca1e73207..1752fd83f8bd 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -1,8 +1,8 @@ # Organizing threads Pin a thread from its context menu to keep it in the pinned section above your active work. -Pinned threads are shown independently of their project, including when you connect to more than -one environment. +`mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of +their project, including when you connect to more than one environment. Pinned threads still move to **Settled** when they become inactive. They also move when their pull request merges if **Auto-settle merged threads** is enabled. diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index bb80ca5287f0..eb132d5d2844 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -38,6 +38,7 @@ export const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", "thread.settle", + "thread.pin", ...THREAD_JUMP_KEYBINDING_COMMANDS, ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 1e6e4f5f39ef..56abec02dbd8 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -47,6 +47,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, + { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command, From 1950a4c2d23bce2e5467d887650f7fe1a245f422 Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Thu, 27 Aug 2026 21:35:15 +0200 Subject: [PATCH 2/2] docs: stop promising top placement for a fresh thread pin Environments without pin reordering keep keyless pins in newest-first order below arranged ones, so an older thread pinned there lands below newer pins. Point at the sidebar doc, which already owns those rules. --- docs/user/keybindings.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index e1aed78d4505..771766425172 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -55,9 +55,9 @@ so add one in **Settings** → **Keybindings** if you want to use it. `thread.settle` settles the active thread or restores it when it is already settled. Its default shortcut is `mod+shift+s`, and it does not run while the terminal has focus. -`thread.pin` pins the active thread to the top of the sidebar, or unpins it when it is already -pinned. A fresh pin lands at the top of the pinned list. Its default shortcut is `mod+shift+p`, and -it does not run while the terminal has focus. +`thread.pin` pins the active thread to the pinned section of the sidebar, or unpins it when it is +already pinned. Its default shortcut is `mod+shift+p`, and it does not run while the terminal has +focus. See [Organizing threads](./thread-sidebar.md) for how pinned threads are ordered. The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while