diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3d5bbe7236d1d3c45fb55fec2e32e8bf21829095..270f1f26ac5ec7477681852c1fa7e234c66ce3de 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -242,6 +242,7 @@ import { import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isCommandPaletteOpen } from "../commandPaletteBus"; +import { settleAndAdvance } from "../settleAndAdvance"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -8109,6 +8110,8 @@ export default function ChatView(props: ChatViewProps) { void handleUnsettleActiveThread(); return; } + // Same as the sidebar Settle button: settle, then open the next thread. + if (settleAndAdvance(activeThreadRef)) return; void settleThread(activeThreadRef).then((result) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0243fc502a444e2a053ae53252e51ff2f13c9e55..d280ba557689057789d6adb6588cfd10da7a224c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -145,6 +145,7 @@ import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadAc import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; +import { registerSettleAndAdvance } from "../settleAndAdvance"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; @@ -3454,6 +3455,8 @@ export default function Sidebar() { }, [planForwardNavigation, settleThread], ); + // The thread.settle shortcut parks the open thread through this same path. + useEffect(() => registerSettleAndAdvance(attemptSettle), [attemptSettle]); // Post-settle navigation must skip threads settling in this same batch — // they are all leaving the card block together. Rows that are already // explicitly settled are skipped: nothing to do on a valid mixed selection. diff --git a/apps/web/src/settleAndAdvance.test.ts b/apps/web/src/settleAndAdvance.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..764f60e2e2111af0c4aca6dd44ba363752dc2474 --- /dev/null +++ b/apps/web/src/settleAndAdvance.test.ts @@ -0,0 +1,37 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { registerSettleAndAdvance, settleAndAdvance } from "./settleAndAdvance"; + +const threadRef = scopeThreadRef( + EnvironmentId.make("environment-local"), + ThreadId.make("thread-1"), +); + +describe("settleAndAdvance", () => { + it("hands the open thread to the mounted sidebar so it can move forward", () => { + const settled: ScopedThreadRef[] = []; + const unregister = registerSettleAndAdvance((ref) => settled.push(ref)); + + expect(settleAndAdvance(threadRef)).toBe(true); + expect(settled).toEqual([threadRef]); + unregister(); + }); + + it("asks the caller to settle in place when no sidebar is mounted", () => { + expect(settleAndAdvance(threadRef)).toBe(false); + }); + + it("keeps a remounted sidebar's handler when the old one unmounts late", () => { + const settledBy: string[] = []; + const unregisterOld = registerSettleAndAdvance(() => settledBy.push("old")); + const unregisterNew = registerSettleAndAdvance(() => settledBy.push("new")); + unregisterOld(); + + expect(settleAndAdvance(threadRef)).toBe(true); + expect(settledBy).toEqual(["new"]); + unregisterNew(); + expect(settleAndAdvance(threadRef)).toBe(false); + }); +}); diff --git a/apps/web/src/settleAndAdvance.ts b/apps/web/src/settleAndAdvance.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec68af9be0a101df8fdb0fafed41266904975a62 --- /dev/null +++ b/apps/web/src/settleAndAdvance.ts @@ -0,0 +1,29 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +type SettleAndAdvance = (threadRef: ScopedThreadRef) => void; + +// Only the mounted thread sidebar knows the card order that picks the next +// thread, so it registers its settle action here for other entry points. +let registeredSettleAndAdvance: SettleAndAdvance | null = null; + +/** + * Called by the thread sidebar while it is mounted. Returns the unregister + * function, which leaves a newer registration in place. + */ +export function registerSettleAndAdvance(handler: SettleAndAdvance): () => void { + registeredSettleAndAdvance = handler; + return () => { + if (registeredSettleAndAdvance === handler) registeredSettleAndAdvance = null; + }; +} + +/** + * Settles the open thread the way the sidebar Settle button does: settle, + * then move to the next thread. Used by the `thread.settle` shortcut. + * Returns false when no sidebar is mounted, so the caller settles in place. + */ +export function settleAndAdvance(threadRef: ScopedThreadRef): boolean { + if (!registeredSettleAndAdvance) return false; + registeredSettleAndAdvance(threadRef); + return true; +}