From 4c51b4c9b6a85d96a22e0df41d5cfd2d8fc9901d Mon Sep 17 00:00:00 2001 From: Illia Panasenko Date: Fri, 28 Aug 2026 12:04:32 +0200 Subject: [PATCH] feat(web): toggle thread pin from the keyboard Add a mod+shift+p shortcut that pins or unpins the active thread. Ignore the shortcut during terminal focus and when the server does not support thread pinning. --- 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 1f05604934e41cfb967f0dfe723e06342757aa58..391e040f6d598154d79d1f747f48616d71d1d727 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 8c9fa32eb96b3e2e8dc46ef1b691e9495a66ff3b..aafd3f7d3af7c3ed0a46d247c4a85f72a33ff318 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1273,7 +1273,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], @@ -4429,6 +4429,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 = @@ -5141,6 +5143,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(); @@ -5245,6 +5266,7 @@ function ChatViewContent(props: ChatViewProps) { activeRightPanelSurface, addTerminalSurface, activeThreadRef, + activeThreadPinned, activeThreadSettled, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, @@ -5260,8 +5282,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 6bf89d091f7355b78cc1216115bb1359af501220..77176642517221d5beb29e8f3616bf875e75df19 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 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 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 51eca1e732071e0d628075b1207f35e58c12d8ca..1752fd83f8bdad1be05bff5a5b49155f76999875 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 bb80ca5287f090160b639e4541745d301333d47b..eb132d5d28449d58a008591fb618e5c0435cb256 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 1e6e4f5f39ef11b439d7c6be02e9dd1b1eb55397..56abec02dbd8d501ec16bac4aface3a5d73091ee 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, -- 2.54.0