From 8bbce7168b841f14127884baef779614ebade730 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 22:09:21 +0000 Subject: [PATCH] feat(web): add shortcut to unsettle the last thread Signed-off-by: maria-rcks --- apps/web/src/components/ChatView.tsx | 39 ++++++++++++++++++++++ apps/web/src/keybindings.test.ts | 13 ++++++++ apps/web/src/routes/_chat.tsx | 47 ++++++++++++++++++++++++++- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 5 files changed, 100 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d8a0439e6bfe..c895286266ae 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -363,6 +363,8 @@ import { useThread, useThreadRefs, useThreadShell, + readThreadShells, + readEnvironmentSupportsSettlement, } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; @@ -6105,6 +6107,35 @@ export default function ChatView(props: ChatViewProps) { setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsettleThreadMutation]); + const handleUnsettleLastThread = useCallback(async () => { + const latest = readThreadShells() + .filter((thread) => { + if ( + thread.archivedAt !== null || + !readEnvironmentSupportsSettlement(thread.environmentId) || + thread.settledOverride !== "settled" || + thread.settledAt === null + ) + return false; + return Number.isFinite(Date.parse(thread.settledAt)); + }) + .toSorted((left, right) => Date.parse(right.settledAt!) - Date.parse(left.settledAt!))[0]; + if (!latest) return; + const result = await unsettleThreadMutation({ + environmentId: latest.environmentId, + input: { threadId: latest.id, reason: "user" }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, [unsettleThreadMutation]); const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false, }); @@ -6762,6 +6793,13 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "thread.unsettleLast") { + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) void handleUnsettleLastThread(); + return; + } + if (command === "thread.pin") { event.preventDefault(); event.stopPropagation(); @@ -6962,6 +7000,7 @@ export default function ChatView(props: ChatViewProps) { splitPanelTerminal, keybindings, handleUnsettleActiveThread, + handleUnsettleLastThread, isServerThread, onInterrupt, onToggleDiff, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d9c3eccbeb4e..fc5d3c1499bb 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1331,3 +1331,16 @@ describe("composer and pull request shortcuts", () => { }); } }); + +describe("unsettle last thread shortcut", () => { + it("resolves mod+shift+u outside the terminal", () => { + assert.equal( + resolveShortcutCommand( + event({ key: "u", metaKey: true, shiftKey: true }), + DEFAULT_RESOLVED_KEYBINDINGS, + { platform: "MacIntel", context: { terminalFocus: false } }, + ), + "thread.unsettleLast", + ); + }); +}); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 980177004daa..899c4c4637ce 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -7,7 +7,17 @@ import { ThreadRouteView } from "../components/ThreadRouteView"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; -import { useProjects } from "../state/entities"; +import { + readEnvironmentSupportsSettlement, + readThreadShells, + useProjects, +} from "../state/entities"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { usePrimaryEnvironmentId } from "../state/environments"; import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; @@ -25,6 +35,7 @@ import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { primaryServerKeybindingsAtom } from "~/state/server"; function ChatRouteGlobalShortcuts() { + const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const clearSelection = useThreadSelectionStore((state) => state.clearSelection); const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = @@ -73,6 +84,39 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "thread.unsettleLast") { + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + const latest = readThreadShells() + .filter( + (thread) => + thread.archivedAt === null && + readEnvironmentSupportsSettlement(thread.environmentId) && + thread.settledOverride === "settled" && + thread.settledAt !== null && + Number.isFinite(Date.parse(thread.settledAt)), + ) + .toSorted((left, right) => Date.parse(right.settledAt!) - Date.parse(left.settledAt!))[0]; + if (!latest) return; + void unsettleThread({ + environmentId: latest.environmentId, + input: { threadId: latest.id, reason: "user" }, + }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }); + return; + } + if (event.key === "Escape" && selectedThreadKeysSize > 0) { event.preventDefault(); clearSelection(); @@ -171,6 +215,7 @@ function ChatRouteGlobalShortcuts() { selectedThreadKeysSize, legacySidebarEnabled, terminalOpen, + unsettleThread, ]); return null; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 44d76ded1703..9a875b0384f8 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -41,6 +41,7 @@ const THREAD_KEYBINDING_COMMANDS = [ "thread.next", "thread.copyReference", "thread.settle", + "thread.unsettleLast", "thread.pin", ...THREAD_JUMP_KEYBINDING_COMMANDS, ] as const; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index d76d4c248a97..6a7605633f01 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -61,6 +61,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+]", command: "thread.next" }, { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, + { key: "mod+shift+u", command: "thread.unsettleLast", when: "!terminalFocus" }, { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`,