authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 02:00:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 02:00:54-07:00
log6edd44f405bf874a85d8d4f7559149a42bf6c547
treed1a3394935ca8ef0ae0178b66508a464053da581
parente6626ee1643f62c53ca0c2680b46cf11cab9942d
signature Signed by PGP key B5690EEEBB952194

fix(web): settle shortcut moves to the next thread like the sidebar (#19328)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 72 insertions(+), 0 deletions(-)

apps/web/src/components/ChatView.tsx+3
...@@ -242,6 +242,7 @@ import {...@@ -242,6 +242,7 @@ import {
242import { useTheme } from "../hooks/useTheme";242import { useTheme } from "../hooks/useTheme";
243import { writeTextToClipboard } from "../hooks/useCopyToClipboard";243import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
244import { isCommandPaletteOpen } from "../commandPaletteBus";244import { isCommandPaletteOpen } from "../commandPaletteBus";
245import { settleAndAdvance } from "../settleAndAdvance";
245import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot";246import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot";
246import { useMediaQuery } from "../hooks/useMediaQuery";247import { useMediaQuery } from "../hooks/useMediaQuery";
247import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";248import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
...@@ -8109,6 +8110,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -8109,6 +8110,8 @@ export default function ChatView(props: ChatViewProps) {
8109 void handleUnsettleActiveThread();8110 void handleUnsettleActiveThread();
8110 return;8111 return;
8111 }8112 }
8113 // Same as the sidebar Settle button: settle, then open the next thread.
8114 if (settleAndAdvance(activeThreadRef)) return;
81128115
8113 void settleThread(activeThreadRef).then((result) => {8116 void settleThread(activeThreadRef).then((result) => {
8114 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;8117 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
apps/web/src/components/Sidebar.tsx+3
...@@ -145,6 +145,7 @@ import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadAc...@@ -145,6 +145,7 @@ import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadAc
145import { useHandleNewThread } from "../hooks/useHandleNewThread";145import { useHandleNewThread } from "../hooks/useHandleNewThread";
146import { useTerminalFocus } from "../hooks/useTerminalFocus";146import { useTerminalFocus } from "../hooks/useTerminalFocus";
147import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";147import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
148import { registerSettleAndAdvance } from "../settleAndAdvance";
148import { startNewThreadFromContext } from "../lib/chatThreadActions";149import { startNewThreadFromContext } from "../lib/chatThreadActions";
149import { useClientSettings } from "../hooks/useSettings";150import { useClientSettings } from "../hooks/useSettings";
150import { useCopyToClipboard } from "../hooks/useCopyToClipboard";151import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
...@@ -3454,6 +3455,8 @@ export default function Sidebar() {...@@ -3454,6 +3455,8 @@ export default function Sidebar() {
3454 },3455 },
3455 [planForwardNavigation, settleThread],3456 [planForwardNavigation, settleThread],
3456 );3457 );
3458 // The thread.settle shortcut parks the open thread through this same path.
3459 useEffect(() => registerSettleAndAdvance(attemptSettle), [attemptSettle]);
3457 // Post-settle navigation must skip threads settling in this same batch —3460 // Post-settle navigation must skip threads settling in this same batch —
3458 // they are all leaving the card block together. Rows that are already3461 // they are all leaving the card block together. Rows that are already
3459 // explicitly settled are skipped: nothing to do on a valid mixed selection.3462 // explicitly settled are skipped: nothing to do on a valid mixed selection.
apps/web/src/settleAndAdvance.test.ts created+37
...@@ -0,0 +1,37 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import { registerSettleAndAdvance, settleAndAdvance } from "./settleAndAdvance";
6
7const threadRef = scopeThreadRef(
8 EnvironmentId.make("environment-local"),
9 ThreadId.make("thread-1"),
10);
11
12describe("settleAndAdvance", () => {
13 it("hands the open thread to the mounted sidebar so it can move forward", () => {
14 const settled: ScopedThreadRef[] = [];
15 const unregister = registerSettleAndAdvance((ref) => settled.push(ref));
16
17 expect(settleAndAdvance(threadRef)).toBe(true);
18 expect(settled).toEqual([threadRef]);
19 unregister();
20 });
21
22 it("asks the caller to settle in place when no sidebar is mounted", () => {
23 expect(settleAndAdvance(threadRef)).toBe(false);
24 });
25
26 it("keeps a remounted sidebar's handler when the old one unmounts late", () => {
27 const settledBy: string[] = [];
28 const unregisterOld = registerSettleAndAdvance(() => settledBy.push("old"));
29 const unregisterNew = registerSettleAndAdvance(() => settledBy.push("new"));
30 unregisterOld();
31
32 expect(settleAndAdvance(threadRef)).toBe(true);
33 expect(settledBy).toEqual(["new"]);
34 unregisterNew();
35 expect(settleAndAdvance(threadRef)).toBe(false);
36 });
37});
apps/web/src/settleAndAdvance.ts created+29
...@@ -0,0 +1,29 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2
3type SettleAndAdvance = (threadRef: ScopedThreadRef) => void;
4
5// Only the mounted thread sidebar knows the card order that picks the next
6// thread, so it registers its settle action here for other entry points.
7let registeredSettleAndAdvance: SettleAndAdvance | null = null;
8
9/**
10 * Called by the thread sidebar while it is mounted. Returns the unregister
11 * function, which leaves a newer registration in place.
12 */
13export function registerSettleAndAdvance(handler: SettleAndAdvance): () => void {
14 registeredSettleAndAdvance = handler;
15 return () => {
16 if (registeredSettleAndAdvance === handler) registeredSettleAndAdvance = null;
17 };
18}
19
20/**
21 * Settles the open thread the way the sidebar Settle button does: settle,
22 * then move to the next thread. Used by the `thread.settle` shortcut.
23 * Returns false when no sidebar is mounted, so the caller settles in place.
24 */
25export function settleAndAdvance(threadRef: ScopedThreadRef): boolean {
26 if (!registeredSettleAndAdvance) return false;
27 registeredSettleAndAdvance(threadRef);
28 return true;
29}