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) => {
196196 assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
197197 assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
198198 assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s");
199 assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p");
199200 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
200201 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
201202 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) {
12731273 const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null;
12741274 const threadDetailLoading = threadSyncPhase === "loading";
12751275 const handleNewThread = useNewThreadHandler();
1276 const { settleThread } = useThreadActions();
1276 const { settleThread, pinThread, unpinThread } = useThreadActions();
12771277 const routeThreadRef = useMemo(
12781278 () => scopeThreadRef(environmentId, threadId),
12791279 [environmentId, threadId],
......@@ -4429,6 +4429,8 @@ function ChatViewContent(props: ChatViewProps) {
44294429 );
44304430 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
44314431 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
4432 const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true;
4433 const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null;
44324434 const nowMinute = useNowMinute();
44334435 const snoozeNow = new Date().toISOString();
44344436 const activeThreadSnoozed =
......@@ -5141,6 +5143,25 @@ function ChatViewContent(props: ChatViewProps) {
51415143 return;
51425144 }
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
51445165 if (command === "terminal.toggle") {
51455166 event.preventDefault();
51465167 event.stopPropagation();
......@@ -5245,6 +5266,7 @@ function ChatViewContent(props: ChatViewProps) {
52455266 activeRightPanelSurface,
52465267 addTerminalSurface,
52475268 activeThreadRef,
5269 activeThreadPinned,
52485270 activeThreadSettled,
52495271 terminalUiState.terminalOpen,
52505272 terminalUiState.activeTerminalId,
......@@ -5260,8 +5282,11 @@ function ChatViewContent(props: ChatViewProps) {
52605282 handleUnsettleActiveThread,
52615283 isServerThread,
52625284 onToggleDiff,
5285 pinThread,
52635286 settleThread,
5287 supportsPinning,
52645288 supportsSettlement,
5289 unpinThread,
52655290 toggleRightPanel,
52665291 toggleRightPanelMaximized,
52675292 toggleTerminalVisibility,
docs/user/keybindings.md+4
......@@ -55,6 +55,10 @@ so add one in **Settings** → **Keybindings** if you want to use it.
5555`thread.settle` settles the active thread or restores it when it is already settled. Its default
5656shortcut 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
5862The command palette searches active thread titles, projects, branches, user messages, and final
5963agent responses across connected environments. Message matches show one labeled excerpt while
6064keeping 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 @@
11# Organizing threads
22
33Pin 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 than
5one environment.
4`mod+shift+p` pins or unpins the thread you have open. Pinned threads are shown independently of
5their project, including when you connect to more than one environment.
66
77Pinned threads still move to **Settled** when they become inactive. They also move when their pull
88request merges if **Auto-settle merged threads** is enabled.
packages/contracts/src/keybindings.ts+1
......@@ -38,6 +38,7 @@ export const THREAD_KEYBINDING_COMMANDS = [
3838 "thread.previous",
3939 "thread.next",
4040 "thread.settle",
41 "thread.pin",
4142 ...THREAD_JUMP_KEYBINDING_COMMANDS,
4243] as const;
4344export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
packages/shared/src/keybindings.ts+1
......@@ -47,6 +47,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
4747 { key: "mod+shift+[", command: "thread.previous" },
4848 { key: "mod+shift+]", command: "thread.next" },
4949 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },
50 { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" },
5051 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
5152 key: `mod+${index + 1}`,
5253 command,