diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 1f0560493..391e040f6 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 bc6ec1d72..6022fd510 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1315,7 +1315,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], @@ -4657,6 +4657,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 = @@ -5509,6 +5511,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(); @@ -5613,6 +5634,7 @@ function ChatViewContent(props: ChatViewProps) { activeRightPanelSurface, addTerminalSurface, activeThreadRef, + activeThreadPinned, activeThreadSettled, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, @@ -5628,8 +5650,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 df6c83726..1d4c646fc 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 2ab90080a..3938c0287 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 bb80ca528..eb132d5d2 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 1e6e4f5f3..56abec02d 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,