authorgravatar for hello@ipanasenko.meIllia Panasenko <hello@ipanasenko.me> 2026-08-28 12:04:32+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 03:04:32-07:00
log4c51b4c9b6a85d96a22e0df41d5cfd2d8fc9901d
tree319728ae4d8df35d037d85532b0e256c56795da7
parent8f49132214a40c85cf46bf5e3d8ea11c04a9610a
signature Signed by PGP key B5690EEEBB952194

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.

6 files changed, 35 insertions(+), 3 deletions(-)

apps/server/src/keybindings.test.ts+1
...@@ -196,6 +196,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -196,6 +196,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
196 assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");196 assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
197 assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");197 assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
198 assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s");198 assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s");
199 assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p");
199 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");200 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
200 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");201 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
201 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");202 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
apps/web/src/components/ChatView.tsx+26-1
...@@ -1273,7 +1273,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1273,7 +1273,7 @@ function ChatViewContent(props: ChatViewProps) {
1273 const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;1273 const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;
1274 const threadDetailLoading = threadSyncPhase === "loading";1274 const threadDetailLoading = threadSyncPhase === "loading";
1275 const handleNewThread = useNewThreadHandler();1275 const handleNewThread = useNewThreadHandler();
1276 const { settleThread } = useThreadActions();1276 const { settleThread, pinThread, unpinThread } = useThreadActions();
1277 const routeThreadRef = useMemo(1277 const routeThreadRef = useMemo(
1278 () => scopeThreadRef(environmentId, threadId),1278 () => scopeThreadRef(environmentId, threadId),
1279 [environmentId, threadId],1279 [environmentId, threadId],
...@@ -4429,6 +4429,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4429,6 +4429,8 @@ function ChatViewContent(props: ChatViewProps) {
4429 );4429 );
4430 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;4430 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
4431 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;4431 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
4432 const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true;
4433 const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null;
4432 const nowMinute = useNowMinute();4434 const nowMinute = useNowMinute();
4433 const snoozeNow = new Date().toISOString();4435 const snoozeNow = new Date().toISOString();
4434 const activeThreadSnoozed =4436 const activeThreadSnoozed =
...@@ -5141,6 +5143,25 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5141,6 +5143,25 @@ function ChatViewContent(props: ChatViewProps) {
5141 return;5143 return;
5142 }5144 }
51435145
5146 if (command === "thread.pin") {
5147 event.preventDefault();
5148 event.stopPropagation();
5149 if (!isServerThread || !activeThreadRef || !supportsPinning) return;
5150 const pinned = activeThreadPinned;
5151 void (pinned ? unpinThread(activeThreadRef) : pinThread(activeThreadRef)).then((result) => {
5152 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
5153 const error = squashAtomCommandFailure(result);
5154 toastManager.add(
5155 stackedThreadToast({
5156 type: "error",
5157 title: pinned ? "Failed to unpin thread" : "Failed to pin thread",
5158 description: error instanceof Error ? error.message : "An error occurred.",
5159 }),
5160 );
5161 });
5162 return;
5163 }
5164
5144 if (command === "terminal.toggle") {5165 if (command === "terminal.toggle") {
5145 event.preventDefault();5166 event.preventDefault();
5146 event.stopPropagation();5167 event.stopPropagation();
...@@ -5245,6 +5266,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5245,6 +5266,7 @@ function ChatViewContent(props: ChatViewProps) {
5245 activeRightPanelSurface,5266 activeRightPanelSurface,
5246 addTerminalSurface,5267 addTerminalSurface,
5247 activeThreadRef,5268 activeThreadRef,
5269 activeThreadPinned,
5248 activeThreadSettled,5270 activeThreadSettled,
5249 terminalUiState.terminalOpen,5271 terminalUiState.terminalOpen,
5250 terminalUiState.activeTerminalId,5272 terminalUiState.activeTerminalId,
...@@ -5260,8 +5282,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5260,8 +5282,11 @@ function ChatViewContent(props: ChatViewProps) {
5260 handleUnsettleActiveThread,5282 handleUnsettleActiveThread,
5261 isServerThread,5283 isServerThread,
5262 onToggleDiff,5284 onToggleDiff,
5285 pinThread,
5263 settleThread,5286 settleThread,
5287 supportsPinning,
5264 supportsSettlement,5288 supportsSettlement,
5289 unpinThread,
5265 toggleRightPanel,5290 toggleRightPanel,
5266 toggleRightPanelMaximized,5291 toggleRightPanelMaximized,
5267 toggleTerminalVisibility,5292 toggleTerminalVisibility,
docs/user/keybindings.md+4
...@@ -55,6 +55,10 @@ so add one in **Settings** → **Keybindings** if you want to use it....@@ -55,6 +55,10 @@ so add one in **Settings** → **Keybindings** if you want to use it.
55`thread.settle` settles the active thread or restores it when it is already settled. Its default55`thread.settle` settles the active thread or restores it when it is already settled. Its default
56shortcut is `mod+shift+s`, and it does not run while the terminal has focus.56shortcut is `mod+shift+s`, and it does not run while the terminal has focus.
5757
58`thread.pin` pins the active thread to the pinned section of the sidebar, or unpins it when it is
59already pinned. Its default shortcut is `mod+shift+p`, and it does not run while the terminal has
60focus. See [Organizing threads](./thread-sidebar.md) for how pinned threads are ordered.
61
58The command palette searches active thread titles, projects, branches, user messages, and final62The command palette searches active thread titles, projects, branches, user messages, and final
59agent responses across connected environments. Message matches show one labeled excerpt while63agent responses across connected environments. Message matches show one labeled excerpt while
60keeping the thread's project, branch, and machine context visible. Message search begins after two64keeping the thread's project, branch, and machine context visible. Message search begins after two
docs/user/thread-sidebar.md+2-2
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1# Organizing threads1# Organizing threads
22
3Pin a thread from its context menu to keep it in the pinned section above your active work.3Pin a thread from its context menu to keep it in the pinned section above your active work.
4Pinned threads are shown independently of their project, including when you connect to more than4`mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of
5one environment.5their project, including when you connect to more than one environment.
66
7Pinned threads still move to **Settled** when they become inactive. They also move when their pull7Pinned threads still move to **Settled** when they become inactive. They also move when their pull
8request merges if **Auto-settle merged threads** is enabled.8request merges if **Auto-settle merged threads** is enabled.
packages/contracts/src/keybindings.ts+1
...@@ -38,6 +38,7 @@ export const THREAD_KEYBINDING_COMMANDS = [...@@ -38,6 +38,7 @@ export const THREAD_KEYBINDING_COMMANDS = [
38 "thread.previous",38 "thread.previous",
39 "thread.next",39 "thread.next",
40 "thread.settle",40 "thread.settle",
41 "thread.pin",
41 ...THREAD_JUMP_KEYBINDING_COMMANDS,42 ...THREAD_JUMP_KEYBINDING_COMMANDS,
42] as const;43] as const;
43export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];44export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
packages/shared/src/keybindings.ts+1
...@@ -47,6 +47,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -47,6 +47,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
47 { key: "mod+shift+[", command: "thread.previous" },47 { key: "mod+shift+[", command: "thread.previous" },
48 { key: "mod+shift+]", command: "thread.next" },48 { key: "mod+shift+]", command: "thread.next" },
49 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },49 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },
50 { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" },
50 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({51 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
51 key: `mod+${index + 1}`,52 key: `mod+${index + 1}`,
52 command,53 command,