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..771766425172 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 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,