{
- if (event.button !== 0) return;
- const container = containerRef.current;
- if (!container) return;
- const bounds = container.getBoundingClientRect();
- if (bounds.width <= 0) return;
- event.preventDefault();
- event.currentTarget.setPointerCapture(event.pointerId);
- event.currentTarget.dataset.dragging = "true";
- dragStateRef.current = {
- pointerId: event.pointerId,
- ratio: useThreadSplitStore.getState().splitRatio,
- boundsLeft: bounds.left,
- boundsWidth: bounds.width,
- };
- }}
- onPointerMove={(event) => {
- const dragState = dragStateRef.current;
- if (!dragState || dragState.pointerId !== event.pointerId) return;
- dragState.ratio = clampSplitRatio(
- (event.clientX - dragState.boundsLeft) / dragState.boundsWidth,
- );
- if (frameRef.current === null) {
- frameRef.current = window.requestAnimationFrame(() => {
- frameRef.current = null;
- const current = dragStateRef.current;
- if (current) applyRatio(current.ratio);
- });
- }
- }}
- onPointerUp={(event) => {
- const dragState = dragStateRef.current;
- if (!dragState || dragState.pointerId !== event.pointerId) return;
- dragStateRef.current = null;
- delete event.currentTarget.dataset.dragging;
- if (frameRef.current !== null) {
- window.cancelAnimationFrame(frameRef.current);
- frameRef.current = null;
- }
- setSplitRatio(dragState.ratio);
- }}
- onPointerCancel={(event) => {
- dragStateRef.current = null;
- delete event.currentTarget.dataset.dragging;
- if (frameRef.current !== null) {
- window.cancelAnimationFrame(frameRef.current);
- frameRef.current = null;
+ ref={wrapperRef}
+ className="group/split-handle relative z-40 h-full w-1 shrink-0 bg-border/60 hover:bg-primary/40 data-[dragging=true]:bg-primary/60"
+ >
+
{
+ if (event.button !== 0) return;
+ const container = containerRef.current;
+ if (!container) return;
+ const bounds = container.getBoundingClientRect();
+ if (bounds.width <= 0) return;
+ event.preventDefault();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ if (wrapperRef.current) {
+ wrapperRef.current.dataset.dragging = "true";
+ }
+ dragStateRef.current = {
+ pointerId: event.pointerId,
+ ratio: useThreadSplitStore.getState().splitRatio,
+ boundsLeft: bounds.left,
+ boundsWidth: bounds.width,
+ };
+ }}
+ onPointerMove={(event) => {
+ const dragState = dragStateRef.current;
+ if (!dragState || dragState.pointerId !== event.pointerId) return;
+ dragState.ratio = clampSplitRatio(
+ (event.clientX - dragState.boundsLeft) / dragState.boundsWidth,
+ );
+ if (frameRef.current === null) {
+ frameRef.current = window.requestAnimationFrame(() => {
+ frameRef.current = null;
+ const current = dragStateRef.current;
+ if (current) applyRatio(current.ratio);
+ });
+ }
+ }}
+ onPointerUp={(event) => {
+ const dragState = dragStateRef.current;
+ if (!dragState || dragState.pointerId !== event.pointerId) return;
+ dragStateRef.current = null;
+ endDrag();
+ setSplitRatio(dragState.ratio);
+ }}
+ onPointerCancel={() => {
+ dragStateRef.current = null;
+ endDrag();
+ // Snap back to the committed ratio.
+ applyRatio(useThreadSplitStore.getState().splitRatio);
+ }}
+ />
+
+
+ );
+}
+
+/**
+ * Hover-revealed pane controls anchored near the top of the divider. They
+ * live on the boundary instead of either pane's titlebar so they never cover
+ * the header actions, and they read as owning the split rather than one
+ * side. Reveal is a one-shot opacity transition (no continuous animation),
+ * slightly delayed so a pointer merely crossing the divider does not flash
+ * the cluster; focus-within keeps the buttons reachable by keyboard, and
+ * coarse pointers see the cluster pinned visible — a touch user has no hover
+ * to reveal it with.
+ */
+function SplitPaneControls() {
+ const router = useRouter();
+ const routeThreadRef = useParams({
+ strict: false,
+ select: (params) => resolveThreadRouteRef(params),
+ });
+ const swapPending = useThreadSplitStore((state) => state.pendingSwap !== null);
+ const closeSplit = useThreadSplitStore((state) => state.closeSplit);
+ const swapEnabled = routeThreadRef !== null && !swapPending;
+
+ return (
+
event.stopPropagation()}
+ >
+
+ tooltipSide="right"
+ disabled={!swapEnabled}
+ onClick={() => {
+ void swapThreadPanes({
+ routeThreadRef,
+ navigateToThread: (ref) =>
+ router.navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(ref),
+ }),
+ });
+ }}
+ >
+
+
+
openCommandPalette({ open: "open-in-split" })}
+ >
+
+
+
+
+
+
);
}
diff --git a/apps/web/src/components/thread-split/ThreadPaneControls.tsx b/apps/web/src/components/thread-split/ThreadPaneControls.tsx
deleted file mode 100644
index 3620b5541e5a..000000000000
--- a/apps/web/src/components/thread-split/ThreadPaneControls.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { ArrowLeftRightIcon, Columns2Icon, XIcon } from "lucide-react";
-
-import { Button } from "../ui/button";
-import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
-import { openCommandPalette } from "../../commandPaletteBus";
-import { useMediaQuery } from "../../hooks/useMediaQuery";
-import { useThreadPaneId } from "./threadPaneContext";
-import { THREAD_SPLIT_MEDIA_QUERY, useThreadSplitStore } from "./threadSplitStore";
-
-/**
- * Split-view controls for ChatView's titlebar control cluster. The primary
- * pane offers opening a split; the secondary pane offers switching its thread
- * and closing the split.
- */
-export function ThreadPaneControls() {
- const paneId = useThreadPaneId();
- const splitActive = useThreadSplitStore((state) => state.secondaryRef !== null);
- const closeSplit = useThreadSplitStore((state) => state.closeSplit);
- const isWideEnoughForSplit = useMediaQuery(THREAD_SPLIT_MEDIA_QUERY);
-
- if (!isWideEnoughForSplit) {
- return null;
- }
-
- if (paneId === "secondary") {
- return (
-
-
openCommandPalette({ open: "open-in-split" })}
- >
-
-
-
-
-
-
- );
- }
-
- if (splitActive) {
- return null;
- }
-
- return (
-
-
openCommandPalette({ open: "open-in-split" })}
- >
-
-
-
- );
-}
-
-function PaneControlButton({
- children,
- label,
- onClick,
-}: {
- children: React.ReactNode;
- label: string;
- onClick: () => void;
-}) {
- return (
-
-
- {children}
-
- }
- />
- {label}
-
- );
-}
diff --git a/apps/web/src/components/thread-split/splitPaletteItems.test.ts b/apps/web/src/components/thread-split/splitPaletteItems.test.ts
index 030ab73f5bd7..5e40192c156b 100644
--- a/apps/web/src/components/thread-split/splitPaletteItems.test.ts
+++ b/apps/web/src/components/thread-split/splitPaletteItems.test.ts
@@ -3,7 +3,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { CommandPaletteActionItem } from "../CommandPalette.logic";
import { buildOpenInSplitThreadItems } from "./splitPaletteItems";
-import { useThreadSplitStore } from "./threadSplitStore";
+import { capturePaletteOwnerPane, useThreadSplitStore } from "./threadSplitStore";
const threadRef = (environmentId: string, threadId: string) => ({
environmentId: EnvironmentId.make(environmentId),
@@ -27,6 +27,7 @@ beforeEach(() => {
splitMounted: false,
activePaneId: "primary",
splitRatio: 0.5,
+ pendingSwap: null,
});
});
@@ -44,6 +45,21 @@ describe("buildOpenInSplitThreadItems", () => {
expect(useThreadSplitStore.getState().activePaneId).toBe("secondary");
});
+ it("opens in the secondary pane regardless of the palette owner snapshot", async () => {
+ // The generic thread list routes picks by owner pane; the explicit
+ // open-in-split submenu must stay pinned to the secondary pane.
+ useThreadSplitStore.getState().setSplitMounted(true);
+ capturePaletteOwnerPane();
+
+ const items = buildOpenInSplitThreadItems({
+ threadItems: [threadItem("thread:env-a:thread-1")],
+ routeThreadRef: null,
+ secondaryRef: null,
+ });
+ await items[0]?.run();
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(threadRef("env-a", "thread-1"));
+ });
+
it("excludes the routed thread and the current secondary thread", () => {
const items = buildOpenInSplitThreadItems({
threadItems: [
diff --git a/apps/web/src/components/thread-split/splitPaletteItems.ts b/apps/web/src/components/thread-split/splitPaletteItems.ts
index 2df4248aeb30..5b0b73bf7810 100644
--- a/apps/web/src/components/thread-split/splitPaletteItems.ts
+++ b/apps/web/src/components/thread-split/splitPaletteItems.ts
@@ -2,7 +2,7 @@ import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/e
import type { ScopedThreadRef } from "@t3tools/contracts";
import type { CommandPaletteActionItem } from "../CommandPalette.logic";
-import { useThreadSplitStore } from "./threadSplitStore";
+import { requestThreadPaneFocus, useThreadSplitStore } from "./threadSplitStore";
const THREAD_ITEM_VALUE_PREFIX = "thread:";
@@ -42,6 +42,10 @@ export function buildOpenInSplitThreadItems(input: {
...item,
value: `open-in-split:${threadKey}`,
run: async () => {
+ // The closing palette restores focus to its trigger in the primary
+ // pane a beat later; the intent bounces that restore into the pane
+ // the user just opened.
+ requestThreadPaneFocus("secondary");
useThreadSplitStore.getState().openSecondaryThread(threadRef);
},
},
diff --git a/apps/web/src/components/thread-split/swapThreadPanes.ts b/apps/web/src/components/thread-split/swapThreadPanes.ts
new file mode 100644
index 000000000000..568ae10ce81a
--- /dev/null
+++ b/apps/web/src/components/thread-split/swapThreadPanes.ts
@@ -0,0 +1,77 @@
+/**
+ * Swap the two split panes' threads (fork feature). The secondary side is
+ * plain store state, but the primary side is the URL route, so a swap is
+ * "set secondary to the old primary, then navigate to the old secondary"
+ * under the store's pendingSwap latch (see threadSplitStore).
+ */
+import type { ScopedThreadRef } from "@t3tools/contracts";
+
+import { stackedThreadToast, toastManager } from "../ui/toast";
+import { activateThreadPane, useThreadSplitStore } from "./threadSplitStore";
+
+/**
+ * Swap is offered only while two server threads are actually on screen: a
+ * draft primary has nothing to hand to the secondary pane, and a swap
+ * already in flight must finish first.
+ */
+export function canSwapThreadPanes(routeThreadRef: ScopedThreadRef | null): boolean {
+ const state = useThreadSplitStore.getState();
+ return (
+ state.splitMounted &&
+ state.secondaryRef !== null &&
+ routeThreadRef !== null &&
+ state.pendingSwap === null
+ );
+}
+
+/**
+ * Run the swap. Returns false when it could not start or the navigation was
+ * rejected (the store is rolled back and a toast shown). On success the
+ * latch stays set — SplitThreadLayout's route effect settles it when the
+ * expected route arrives, which is also what keeps the same physical side
+ * active.
+ */
+// The most recent swap this module started. A swap whose latch expired can
+// still see its navigation settle much later; comparing against this token
+// keeps such a stale continuation from aborting, toasting over, or stealing
+// focus from a newer swap.
+let latestSwapToken: object | null = null;
+
+export async function swapThreadPanes(input: {
+ routeThreadRef: ScopedThreadRef | null;
+ navigateToThread: (ref: ScopedThreadRef) => void | Promise
;
+}): Promise {
+ if (input.routeThreadRef === null) {
+ return false;
+ }
+ const begun = useThreadSplitStore.getState().beginPaneSwap(input.routeThreadRef);
+ if (begun === null) {
+ return false;
+ }
+ latestSwapToken = begun.pendingSwap;
+ try {
+ await input.navigateToThread(begun.target);
+ } catch {
+ useThreadSplitStore.getState().abortPaneSwap(begun.pendingSwap);
+ if (latestSwapToken === begun.pendingSwap) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Could not swap split threads",
+ description: "Navigation to the other pane's thread failed.",
+ }),
+ );
+ }
+ return false;
+ }
+ if (latestSwapToken !== begun.pendingSwap) {
+ // Superseded mid-flight; the newer swap's completion owns focus.
+ return false;
+ }
+ // The active side keeps its role, but its ChatView remounted with the other
+ // thread (and a triggering button or shortcut may hold DOM focus) — reclaim
+ // focus here so every invocation path behaves the same. The queued intent
+ // also survives an overlay's close-time restore (palette action).
+ activateThreadPane(useThreadSplitStore.getState().activePaneId);
+ return true;
+}
diff --git a/apps/web/src/components/thread-split/threadOpenTarget.test.ts b/apps/web/src/components/thread-split/threadOpenTarget.test.ts
new file mode 100644
index 000000000000..a9f0fae834ac
--- /dev/null
+++ b/apps/web/src/components/thread-split/threadOpenTarget.test.ts
@@ -0,0 +1,252 @@
+import { beforeEach, describe, expect, it } from "vite-plus/test";
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { scopedThreadKey } from "@t3tools/client-runtime/environment";
+
+import {
+ openThreadInActivePane,
+ planThreadOpen,
+ shouldCloseSplitForRoute,
+} from "./threadOpenTarget";
+import { useThreadSplitStore, type PendingPaneSwap } from "./threadSplitStore";
+
+const threadRef = (environmentId: string, threadId: string) => ({
+ environmentId: EnvironmentId.make(environmentId),
+ threadId: ThreadId.make(threadId),
+});
+
+const ROUTE_REF = threadRef("env-a", "thread-route");
+const SECONDARY_REF = threadRef("env-a", "thread-secondary");
+const OTHER_REF = threadRef("env-b", "thread-other");
+
+beforeEach(() => {
+ useThreadSplitStore.setState({
+ secondaryRef: null,
+ splitMounted: false,
+ activePaneId: "primary",
+ splitRatio: 0.5,
+ pendingSwap: null,
+ });
+});
+
+describe("planThreadOpen", () => {
+ const mountedSplit = {
+ routeThreadRef: ROUTE_REF,
+ splitMounted: true,
+ activePaneId: "primary" as const,
+ secondaryRef: SECONDARY_REF,
+ };
+
+ it("navigates the primary route while no split is rendered", () => {
+ // Also covers a parked secondaryRef on a too-narrow viewport.
+ expect(
+ planThreadOpen({
+ targetRef: OTHER_REF,
+ routeThreadRef: ROUTE_REF,
+ splitMounted: false,
+ activePaneId: "secondary",
+ secondaryRef: SECONDARY_REF,
+ }),
+ ).toEqual({ kind: "navigate-primary" });
+ });
+
+ it("focuses the primary pane when the target is already routed", () => {
+ expect(planThreadOpen({ ...mountedSplit, targetRef: ROUTE_REF })).toEqual({
+ kind: "focus-pane",
+ paneId: "primary",
+ });
+ });
+
+ it("focuses the secondary pane when the target is already open there", () => {
+ // The reported bug: navigating instead would trip the duplicate-thread
+ // guard and fold the split.
+ expect(
+ planThreadOpen({ ...mountedSplit, targetRef: SECONDARY_REF, activePaneId: "secondary" }),
+ ).toEqual({ kind: "focus-pane", paneId: "secondary" });
+ });
+
+ it("focuses the secondary pane even with a draft primary", () => {
+ expect(
+ planThreadOpen({
+ targetRef: SECONDARY_REF,
+ routeThreadRef: null,
+ splitMounted: true,
+ activePaneId: "secondary",
+ secondaryRef: SECONDARY_REF,
+ }),
+ ).toEqual({ kind: "focus-pane", paneId: "secondary" });
+ });
+
+ it("focuses primary when the target is an in-flight swap's destination", () => {
+ // Mid-swap the route still shows the old primary while heading to the
+ // old secondary; picking that destination must not reopen it in the
+ // secondary pane, or the arrival becomes a duplicate and folds the split.
+ expect(
+ planThreadOpen({
+ ...mountedSplit,
+ targetRef: OTHER_REF,
+ activePaneId: "secondary",
+ pendingSwap: {
+ expectedRouteKey: scopedThreadKey(OTHER_REF),
+ startedRouteKey: scopedThreadKey(ROUTE_REF),
+ restoreSecondaryRef: OTHER_REF,
+ expiresAt: 10_000,
+ },
+ }),
+ ).toEqual({ kind: "focus-pane", paneId: "primary" });
+ });
+
+ it("routes a fresh target to the active pane", () => {
+ expect(planThreadOpen({ ...mountedSplit, targetRef: OTHER_REF })).toEqual({
+ kind: "navigate-primary",
+ });
+ expect(
+ planThreadOpen({ ...mountedSplit, targetRef: OTHER_REF, activePaneId: "secondary" }),
+ ).toEqual({ kind: "open-secondary" });
+ });
+
+ it("lets a pane override beat the live active pane", () => {
+ expect(
+ planThreadOpen({
+ ...mountedSplit,
+ targetRef: OTHER_REF,
+ activePaneId: "primary",
+ paneOverride: "secondary",
+ }),
+ ).toEqual({ kind: "open-secondary" });
+ });
+});
+
+describe("openThreadInActivePane", () => {
+ it("replaces the secondary thread when the secondary pane is active", () => {
+ useThreadSplitStore.getState().openSecondaryThread(SECONDARY_REF);
+ useThreadSplitStore.getState().setSplitMounted(true);
+
+ let navigated = 0;
+ openThreadInActivePane({
+ targetRef: OTHER_REF,
+ routeThreadRef: ROUTE_REF,
+ navigateToPrimary: () => {
+ navigated += 1;
+ },
+ });
+ expect(navigated).toBe(0);
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(OTHER_REF);
+ });
+
+ it("activates the pane already showing the target instead of navigating", () => {
+ useThreadSplitStore.getState().openSecondaryThread(SECONDARY_REF);
+ useThreadSplitStore.getState().setSplitMounted(true);
+
+ let navigated = 0;
+ openThreadInActivePane({
+ targetRef: SECONDARY_REF,
+ routeThreadRef: ROUTE_REF,
+ paneOverride: "primary",
+ navigateToPrimary: () => {
+ navigated += 1;
+ },
+ });
+ expect(navigated).toBe(0);
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(SECONDARY_REF);
+ expect(useThreadSplitStore.getState().activePaneId).toBe("secondary");
+ });
+
+ it("navigates the primary route when the primary pane is active", () => {
+ useThreadSplitStore.getState().openSecondaryThread(SECONDARY_REF);
+ useThreadSplitStore.getState().setSplitMounted(true);
+ useThreadSplitStore.getState().setActivePane("primary");
+
+ let navigated = 0;
+ openThreadInActivePane({
+ targetRef: OTHER_REF,
+ routeThreadRef: ROUTE_REF,
+ navigateToPrimary: () => {
+ navigated += 1;
+ },
+ });
+ expect(navigated).toBe(1);
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(SECONDARY_REF);
+ });
+
+ it("reports the executed plan and surfaces the navigation promise", async () => {
+ // The command palette awaited navigation before the split routing landed;
+ // a rejection must still reach its run-command error handling.
+ const failure = new Error("navigation rejected");
+ const navigated = openThreadInActivePane({
+ targetRef: OTHER_REF,
+ routeThreadRef: ROUTE_REF,
+ navigateToPrimary: () => Promise.reject(failure),
+ });
+ expect(navigated.plan).toEqual({ kind: "navigate-primary" });
+ await expect(navigated.completion).rejects.toBe(failure);
+
+ useThreadSplitStore.getState().openSecondaryThread(SECONDARY_REF);
+ useThreadSplitStore.getState().setSplitMounted(true);
+ const opened = openThreadInActivePane({
+ targetRef: OTHER_REF,
+ routeThreadRef: ROUTE_REF,
+ navigateToPrimary: () => undefined,
+ });
+ expect(opened.plan).toEqual({ kind: "open-secondary" });
+ expect(opened.completion).toBeNull();
+ });
+});
+
+describe("shouldCloseSplitForRoute", () => {
+ const duplicateKey = scopedThreadKey(SECONDARY_REF);
+ const freshSwap: PendingPaneSwap = {
+ expectedRouteKey: scopedThreadKey(OTHER_REF),
+ startedRouteKey: duplicateKey,
+ restoreSecondaryRef: OTHER_REF,
+ expiresAt: 10_000,
+ };
+
+ it("closes on a plain duplicate and stays quiet otherwise", () => {
+ expect(
+ shouldCloseSplitForRoute({
+ routeThreadKey: duplicateKey,
+ secondaryKey: duplicateKey,
+ pendingSwap: null,
+ now: 0,
+ }),
+ ).toBe(true);
+ expect(
+ shouldCloseSplitForRoute({
+ routeThreadKey: scopedThreadKey(ROUTE_REF),
+ secondaryKey: duplicateKey,
+ pendingSwap: null,
+ now: 0,
+ }),
+ ).toBe(false);
+ expect(
+ shouldCloseSplitForRoute({
+ routeThreadKey: null,
+ secondaryKey: duplicateKey,
+ pendingSwap: null,
+ now: 0,
+ }),
+ ).toBe(false);
+ });
+
+ it("holds off while a fresh swap latch vouches for the duplicate", () => {
+ expect(
+ shouldCloseSplitForRoute({
+ routeThreadKey: duplicateKey,
+ secondaryKey: duplicateKey,
+ pendingSwap: freshSwap,
+ now: 5_000,
+ }),
+ ).toBe(false);
+ });
+
+ it("treats an expired latch as no latch", () => {
+ expect(
+ shouldCloseSplitForRoute({
+ routeThreadKey: duplicateKey,
+ secondaryKey: duplicateKey,
+ pendingSwap: freshSwap,
+ now: 10_001,
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/apps/web/src/components/thread-split/threadOpenTarget.ts b/apps/web/src/components/thread-split/threadOpenTarget.ts
new file mode 100644
index 000000000000..00719c112ca4
--- /dev/null
+++ b/apps/web/src/components/thread-split/threadOpenTarget.ts
@@ -0,0 +1,125 @@
+/**
+ * Active-pane-aware thread opening (fork feature). The sidebar and the
+ * command palette both funnel plain thread picks through here so the picked
+ * thread lands in the pane the user is working in, instead of always
+ * replacing the primary route.
+ */
+import type { ScopedThreadRef } from "@t3tools/contracts";
+import { scopedThreadKey } from "@t3tools/client-runtime/environment";
+
+import type { PendingPaneSwap, ThreadPaneId } from "./threadSplitStore";
+import { activateThreadPane, useThreadSplitStore } from "./threadSplitStore";
+
+export type ThreadOpenPlan =
+ | { kind: "navigate-primary" }
+ | { kind: "open-secondary" }
+ | { kind: "focus-pane"; paneId: ThreadPaneId };
+
+/**
+ * Decide where a picked thread should open. Pure so the branch matrix is
+ * testable without a DOM:
+ *
+ * 1. No rendered split → plain primary navigation (also covers a parked
+ * secondaryRef on a too-narrow viewport).
+ * 2. Already the routed thread — including the destination of an in-flight
+ * pane swap, which the route is about to become → focus the primary
+ * pane, never re-navigate. Opening a swap destination in the secondary
+ * instead would recreate the duplicate the guard folds on arrival.
+ * 3. Already the secondary thread → focus the secondary pane. Navigating
+ * instead would trip the duplicate-thread guard and fold the split.
+ * 4. Otherwise the active pane (or the caller's snapshot of it) receives it.
+ */
+export function planThreadOpen(input: {
+ targetRef: ScopedThreadRef;
+ routeThreadRef: ScopedThreadRef | null;
+ splitMounted: boolean;
+ activePaneId: ThreadPaneId;
+ secondaryRef: ScopedThreadRef | null;
+ pendingSwap?: PendingPaneSwap | null;
+ paneOverride?: ThreadPaneId;
+}): ThreadOpenPlan {
+ if (!input.splitMounted) {
+ return { kind: "navigate-primary" };
+ }
+ const targetKey = scopedThreadKey(input.targetRef);
+ if (input.routeThreadRef !== null && scopedThreadKey(input.routeThreadRef) === targetKey) {
+ return { kind: "focus-pane", paneId: "primary" };
+ }
+ if (input.pendingSwap != null && input.pendingSwap.expectedRouteKey === targetKey) {
+ return { kind: "focus-pane", paneId: "primary" };
+ }
+ if (input.secondaryRef !== null && scopedThreadKey(input.secondaryRef) === targetKey) {
+ return { kind: "focus-pane", paneId: "secondary" };
+ }
+ if ((input.paneOverride ?? input.activePaneId) === "primary") {
+ return { kind: "navigate-primary" };
+ }
+ return { kind: "open-secondary" };
+}
+
+export interface ThreadOpenResult {
+ /** Which branch executed — callers key follow-up work off this. */
+ plan: ThreadOpenPlan;
+ /**
+ * The primary navigation's promise when the plan navigated, so callers
+ * that awaited navigation before (the command palette's error handling)
+ * still see rejections. Null for the store-only branches.
+ */
+ completion: Promise | null;
+}
+
+/**
+ * Execute a thread pick against the live split state. `paneOverride` is for
+ * the command palette, which must use the pane snapshotted at open time
+ * rather than the live value.
+ */
+export function openThreadInActivePane(input: {
+ targetRef: ScopedThreadRef;
+ routeThreadRef: ScopedThreadRef | null;
+ paneOverride?: ThreadPaneId;
+ navigateToPrimary: () => void | Promise;
+}): ThreadOpenResult {
+ const state = useThreadSplitStore.getState();
+ const plan = planThreadOpen({
+ targetRef: input.targetRef,
+ routeThreadRef: input.routeThreadRef,
+ splitMounted: state.splitMounted,
+ activePaneId: state.activePaneId,
+ secondaryRef: state.secondaryRef,
+ pendingSwap: state.pendingSwap,
+ ...(input.paneOverride ? { paneOverride: input.paneOverride } : {}),
+ });
+ switch (plan.kind) {
+ case "navigate-primary": {
+ const navigation = input.navigateToPrimary();
+ return { plan, completion: navigation instanceof Promise ? navigation : null };
+ }
+ case "open-secondary":
+ state.openSecondaryThread(input.targetRef);
+ return { plan, completion: null };
+ case "focus-pane":
+ activateThreadPane(plan.paneId);
+ return { plan, completion: null };
+ }
+}
+
+/**
+ * The duplicate-thread guard's decision: the primary route landing on the
+ * secondary's thread normally folds the split ("the thread moved there"),
+ * except while a fresh pane swap is in flight — mid-swap the route briefly
+ * equals the new secondary by construction.
+ */
+export function shouldCloseSplitForRoute(input: {
+ routeThreadKey: string | null;
+ secondaryKey: string | null;
+ pendingSwap: PendingPaneSwap | null;
+ now: number;
+}): boolean {
+ if (input.routeThreadKey === null || input.secondaryKey === null) {
+ return false;
+ }
+ if (input.routeThreadKey !== input.secondaryKey) {
+ return false;
+ }
+ return input.pendingSwap === null || input.now > input.pendingSwap.expiresAt;
+}
diff --git a/apps/web/src/components/thread-split/threadSplitStore.test.ts b/apps/web/src/components/thread-split/threadSplitStore.test.ts
index 35b712ad6333..e49a7ca9aacc 100644
--- a/apps/web/src/components/thread-split/threadSplitStore.test.ts
+++ b/apps/web/src/components/thread-split/threadSplitStore.test.ts
@@ -4,11 +4,15 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { ComposerHandleRef } from "../../composerHandleContext";
import {
+ activateThreadPane,
+ capturePaletteOwnerPane,
clampSplitRatio,
focusOtherThreadPane,
isThreadPaneActive,
MAX_SPLIT_RATIO,
MIN_SPLIT_RATIO,
+ paletteOwnerPane,
+ PENDING_SWAP_TTL_MS,
registerThreadPaneComposer,
useThreadSplitStore,
} from "./threadSplitStore";
@@ -32,6 +36,7 @@ beforeEach(() => {
splitMounted: false,
activePaneId: "primary",
splitRatio: 0.5,
+ pendingSwap: null,
});
registerThreadPaneComposer("primary", null);
registerThreadPaneComposer("secondary", null);
@@ -121,6 +126,155 @@ describe("focusOtherThreadPane", () => {
});
});
+describe("pane swap latch", () => {
+ const ROUTE_REF = threadRef("env-a", "thread-route");
+
+ function openMountedSplit() {
+ useThreadSplitStore.getState().openSecondaryThread(REF_A);
+ mountSplit();
+ }
+
+ it("swaps the secondary, returns the navigation target, and keeps the active side", () => {
+ openMountedSplit();
+ useThreadSplitStore.getState().setActivePane("secondary");
+
+ const begun = useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF);
+ expect(begun?.target).toEqual(REF_A);
+ expect(begun?.pendingSwap).toBe(useThreadSplitStore.getState().pendingSwap);
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(ROUTE_REF);
+ expect(useThreadSplitStore.getState().activePaneId).toBe("secondary");
+ expect(useThreadSplitStore.getState().pendingSwap).not.toBeNull();
+ });
+
+ it("refuses without a secondary, while unmounted, or while a swap is in flight", () => {
+ expect(useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF)).toBeNull();
+
+ useThreadSplitStore.getState().openSecondaryThread(REF_A);
+ expect(useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF)).toBeNull();
+
+ mountSplit();
+ expect(useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF)).not.toBeNull();
+ expect(useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF)).toBeNull();
+ });
+
+ it("settle clears the latch and keeps the swapped secondary", () => {
+ openMountedSplit();
+ useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF);
+ useThreadSplitStore.getState().settlePaneSwap();
+ expect(useThreadSplitStore.getState().pendingSwap).toBeNull();
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(ROUTE_REF);
+ });
+
+ it("abort restores the old secondary", () => {
+ openMountedSplit();
+ useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF);
+ useThreadSplitStore.getState().abortPaneSwap();
+ expect(useThreadSplitStore.getState().pendingSwap).toBeNull();
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(REF_A);
+ });
+
+ it("abort yields to a secondary the user replaced mid-flight", () => {
+ openMountedSplit();
+ useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF);
+ useThreadSplitStore.getState().openSecondaryThread(REF_B);
+ // The latch survives the pick (its late route arrival must still be
+ // recognized), but aborting must not clobber the newer secondary.
+ expect(useThreadSplitStore.getState().pendingSwap).not.toBeNull();
+ useThreadSplitStore.getState().abortPaneSwap();
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(REF_B);
+ expect(useThreadSplitStore.getState().pendingSwap).toBeNull();
+ });
+
+ it("a token-scoped abort from a stale swap leaves a newer latch alone", () => {
+ openMountedSplit();
+ const first = useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF);
+ useThreadSplitStore.getState().settlePaneSwap();
+ const second = useThreadSplitStore.getState().beginPaneSwap(REF_B);
+ expect(second).not.toBeNull();
+
+ // Swap 1's navigation rejecting late must not roll back swap 2.
+ useThreadSplitStore.getState().abortPaneSwap(first?.pendingSwap);
+ expect(useThreadSplitStore.getState().pendingSwap).toBe(second?.pendingSwap);
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(REF_B);
+ });
+
+ it("closeSplit clears an in-flight latch", () => {
+ openMountedSplit();
+ useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF);
+ expect(useThreadSplitStore.getState().pendingSwap).not.toBeNull();
+ useThreadSplitStore.getState().closeSplit();
+ expect(useThreadSplitStore.getState().pendingSwap).toBeNull();
+ });
+});
+
+describe("pane swap expiry", () => {
+ const ROUTE_REF = threadRef("env-a", "thread-route");
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("aborts and restores the old secondary when the navigation never arrives", () => {
+ // No re-render happens while a navigation stalls, so only the store's
+ // own timer can release the latch (and re-enable every swap affordance).
+ useThreadSplitStore.getState().openSecondaryThread(REF_A);
+ mountSplit();
+ useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF);
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(ROUTE_REF);
+
+ vi.advanceTimersByTime(PENDING_SWAP_TTL_MS);
+ expect(useThreadSplitStore.getState().pendingSwap).toBeNull();
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(REF_A);
+ });
+
+ it("a settled swap's expiry timer never fires", () => {
+ useThreadSplitStore.getState().openSecondaryThread(REF_A);
+ mountSplit();
+ useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF);
+ useThreadSplitStore.getState().settlePaneSwap();
+
+ vi.advanceTimersByTime(PENDING_SWAP_TTL_MS * 2);
+ expect(useThreadSplitStore.getState().secondaryRef).toEqual(ROUTE_REF);
+ expect(useThreadSplitStore.getState().pendingSwap).toBeNull();
+ });
+});
+
+describe("activateThreadPane", () => {
+ it("activates the pane and focuses its composer handle", () => {
+ const focused: string[] = [];
+ const secondaryComposerRef = {
+ current: { focusAtEnd: () => focused.push("secondary") },
+ } as unknown as ComposerHandleRef;
+ registerThreadPaneComposer("secondary", secondaryComposerRef);
+ useThreadSplitStore.getState().openSecondaryThread(REF_A);
+ mountSplit();
+ useThreadSplitStore.getState().setActivePane("primary");
+
+ activateThreadPane("secondary");
+ expect(useThreadSplitStore.getState().activePaneId).toBe("secondary");
+ expect(focused).toEqual(["secondary"]);
+ });
+});
+
+describe("paletteOwnerPane", () => {
+ it("round-trips the active pane captured at palette open", () => {
+ useThreadSplitStore.getState().openSecondaryThread(REF_A);
+ mountSplit();
+ capturePaletteOwnerPane();
+ expect(paletteOwnerPane()).toBe("secondary");
+
+ // Live pane moves (dialog focus traffic) must not leak into the snapshot.
+ useThreadSplitStore.getState().setActivePane("primary");
+ expect(paletteOwnerPane()).toBe("secondary");
+
+ capturePaletteOwnerPane();
+ expect(paletteOwnerPane()).toBe("primary");
+ });
+});
+
describe("clampSplitRatio", () => {
it("clamps into the allowed pane range and rejects junk", () => {
expect(clampSplitRatio(0.1)).toBe(MIN_SPLIT_RATIO);
diff --git a/apps/web/src/components/thread-split/threadSplitStore.ts b/apps/web/src/components/thread-split/threadSplitStore.ts
index 11fe76ffd24f..6034b4f4ebe7 100644
--- a/apps/web/src/components/thread-split/threadSplitStore.ts
+++ b/apps/web/src/components/thread-split/threadSplitStore.ts
@@ -66,6 +66,38 @@ function readStoredSplitRatio(): number {
return clampSplitRatio(Number(raw));
}
+/**
+ * In-flight pane swap. Swapping sets the secondary to the old primary and
+ * then navigates the primary route to the old secondary — between those two
+ * steps the route still equals the new secondary, which the duplicate-thread
+ * guard would read as "moved" and fold the split. The latch tells the guard
+ * to hold off until the route arrives (or the swap visibly failed).
+ */
+export interface PendingPaneSwap {
+ /** Where the primary route is headed: the old secondary thread. */
+ expectedRouteKey: string;
+ /** The route (old primary) at swap start — also the new secondary's key. */
+ startedRouteKey: string;
+ /** The old secondary ref, restored if the swap aborts. */
+ restoreSecondaryRef: ScopedThreadRef;
+ expiresAt: number;
+}
+
+export const PENDING_SWAP_TTL_MS = 5_000;
+
+// Store-owned expiry: without a timer the TTL is only observed when something
+// re-renders, so a stalled swap navigation would leave the latch (and every
+// swap affordance) stuck indefinitely. The latch object doubles as the token,
+// so a timer can never abort a swap it did not start.
+let pendingSwapExpiryTimer: ReturnType | null = null;
+
+function clearPendingSwapExpiry(): void {
+ if (pendingSwapExpiryTimer !== null) {
+ clearTimeout(pendingSwapExpiryTimer);
+ pendingSwapExpiryTimer = null;
+ }
+}
+
interface ThreadSplitStore {
secondaryRef: ScopedThreadRef | null;
/**
@@ -78,11 +110,22 @@ interface ThreadSplitStore {
splitMounted: boolean;
activePaneId: ThreadPaneId;
splitRatio: number;
+ pendingSwap: PendingPaneSwap | null;
openSecondaryThread: (ref: ScopedThreadRef) => void;
closeSplit: () => void;
setSplitMounted: (mounted: boolean) => void;
setActivePane: (paneId: ThreadPaneId) => void;
setSplitRatio: (ratio: number) => void;
+ beginPaneSwap: (
+ routeThreadRef: ScopedThreadRef,
+ ) => { target: ScopedThreadRef; pendingSwap: PendingPaneSwap } | null;
+ settlePaneSwap: () => void;
+ /**
+ * With `onlyIfCurrent`, aborts only while that exact latch is still in
+ * flight — an async caller (a swap's navigation catch, the expiry timer)
+ * must never abort a newer swap it does not own.
+ */
+ abortPaneSwap: (onlyIfCurrent?: PendingPaneSwap) => void;
}
export const useThreadSplitStore = create((set, get) => ({
@@ -90,12 +133,17 @@ export const useThreadSplitStore = create((set, get) => ({
splitMounted: false,
activePaneId: "primary",
splitRatio: readStoredSplitRatio(),
+ pendingSwap: null,
openSecondaryThread: (ref) => {
- // The pick usually comes from the command palette, whose close restores
- // focus to the trigger in the primary pane a beat later — the intent
- // makes that restore bounce into the pane the user just opened.
- requestThreadPaneFocus("secondary");
+ // Overlay callers (command palette) queue their own pane-focus intent —
+ // the store must not, or a sidebar pick would leave a stray intent that
+ // bounces the next deliberate pane switch back here.
+ //
+ // An in-flight swap latch deliberately survives a pick: abortPaneSwap's
+ // restore guard already yields to the newer secondary, and clearing the
+ // latch early would let the swap's late route arrival re-activate the
+ // primary pane over this pick (or fold the split on a transient match).
const current = get().secondaryRef;
if (current && scopedThreadKey(current) === scopedThreadKey(ref)) {
set({ activePaneId: "secondary" });
@@ -106,12 +154,64 @@ export const useThreadSplitStore = create((set, get) => ({
closeSplit: () => {
if (get().secondaryRef === null) return;
- set({ secondaryRef: null, activePaneId: "primary" });
+ clearPendingSwapExpiry();
+ set({ secondaryRef: null, activePaneId: "primary", pendingSwap: null });
// The close control usually lives in the secondary pane, whose unmount
// would drop DOM focus to ; hand it to the surviving pane instead.
focusThreadPane("primary");
},
+ beginPaneSwap: (routeThreadRef) => {
+ const state = get();
+ if (state.secondaryRef === null || !state.splitMounted) {
+ return null;
+ }
+ if (state.pendingSwap !== null && Date.now() <= state.pendingSwap.expiresAt) {
+ return null;
+ }
+ const target = state.secondaryRef;
+ const pendingSwap: PendingPaneSwap = {
+ expectedRouteKey: scopedThreadKey(target),
+ startedRouteKey: scopedThreadKey(routeThreadRef),
+ restoreSecondaryRef: target,
+ expiresAt: Date.now() + PENDING_SWAP_TTL_MS,
+ };
+ // Deliberately leaves activePaneId alone: the threads trade sides, and the
+ // side the user was working on should stay the one that owns shortcuts.
+ set({ secondaryRef: routeThreadRef, pendingSwap });
+ clearPendingSwapExpiry();
+ pendingSwapExpiryTimer = setTimeout(() => {
+ pendingSwapExpiryTimer = null;
+ get().abortPaneSwap(pendingSwap);
+ }, PENDING_SWAP_TTL_MS);
+ return { target, pendingSwap };
+ },
+
+ settlePaneSwap: () => {
+ if (get().pendingSwap === null) return;
+ clearPendingSwapExpiry();
+ set({ pendingSwap: null });
+ },
+
+ abortPaneSwap: (onlyIfCurrent) => {
+ const state = get();
+ const pending = state.pendingSwap;
+ if (pending === null) return;
+ // A stale caller must also leave the current latch's timer armed.
+ if (onlyIfCurrent !== undefined && pending !== onlyIfCurrent) return;
+ clearPendingSwapExpiry();
+ // Restore only when the secondary is still the one the swap installed —
+ // a pick that replaced it mid-flight is newer user intent and wins.
+ if (
+ state.secondaryRef !== null &&
+ scopedThreadKey(state.secondaryRef) === pending.startedRouteKey
+ ) {
+ set({ secondaryRef: pending.restoreSecondaryRef, pendingSwap: null });
+ return;
+ }
+ set({ pendingSwap: null });
+ },
+
setSplitMounted: (mounted) => {
if (get().splitMounted === mounted) return;
set({ splitMounted: mounted });
@@ -283,6 +383,35 @@ export function reclaimThreadPaneFocus(paneId: ThreadPaneId): void {
}, 0);
}
+/**
+ * Make a pane active and hand it focus. Used when a sidebar click or palette
+ * pick targets a thread that is already on screen: nothing navigates, so the
+ * pane must be activated explicitly. The focus intent covers the palette case
+ * (the closing dialog's focus restore would otherwise bounce activation back);
+ * the direct focus covers the sidebar case, where no overlay close follows.
+ */
+export function activateThreadPane(paneId: ThreadPaneId): void {
+ requestThreadPaneFocus(paneId);
+ useThreadSplitStore.getState().setActivePane(paneId);
+ focusThreadPane(paneId);
+}
+
+/**
+ * The pane that owned focus when the command palette opened. Thread picks
+ * must target this snapshot, not the live active pane: the dialog's own
+ * focus juggling (close-time restores, list focus) can move the live value
+ * while the palette is open.
+ */
+let paletteOwnerPaneId: ThreadPaneId = "primary";
+
+export function capturePaletteOwnerPane(): void {
+ paletteOwnerPaneId = useThreadSplitStore.getState().activePaneId;
+}
+
+export function paletteOwnerPane(): ThreadPaneId {
+ return paletteOwnerPaneId;
+}
+
/** Jump focus to the other pane. Returns false while the split is not rendered. */
export function focusOtherThreadPane(): boolean {
const state = useThreadSplitStore.getState();
diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts
index 7d07610c6430..ce91abb0622a 100644
--- a/apps/web/src/keybindings.test.ts
+++ b/apps/web/src/keybindings.test.ts
@@ -140,6 +140,11 @@ const DEFAULT_BINDINGS = compile([
{ shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
{ shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" },
{ shortcut: modShortcut("o"), command: "editor.openFavorite" },
+ {
+ shortcut: modShortcut("\\", { shiftKey: true }),
+ command: "threadPane.swap",
+ whenAst: whenNot(whenIdentifier("terminalFocus")),
+ },
{ shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" },
{ shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" },
{ shortcut: modShortcut("1"), command: "thread.jump.1" },
@@ -710,6 +715,21 @@ describe("resolveShortcutCommand", () => {
);
});
+ it("matches the swap-panes shortcut using the physical key code", () => {
+ // Shift+Backslash types "|" on a US layout, so without the Backslash
+ // code alias the default mod+shift+\ binding would never resolve.
+ assert.strictEqual(
+ resolveShortcutCommand(
+ event({ key: "|", code: "Backslash", metaKey: true, shiftKey: true }),
+ DEFAULT_BINDINGS,
+ {
+ platform: "MacIntel",
+ },
+ ),
+ "threadPane.swap",
+ );
+ });
+
it("matches bracket shortcuts using the physical key code", () => {
assert.strictEqual(
resolveShortcutCommand(
diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts
index eb4637df21be..4d2b961f6f8c 100644
--- a/apps/web/src/keybindings.ts
+++ b/apps/web/src/keybindings.ts
@@ -50,6 +50,7 @@ const TERMINAL_LINE_START = "\u0001";
const TERMINAL_LINE_END = "\u0005";
const TERMINAL_DELETE_TO_LINE_START = "\u0015";
const EVENT_CODE_KEY_ALIASES: Readonly> = {
+ Backslash: ["\\"],
BracketLeft: ["["],
BracketRight: ["]"],
Digit0: ["0"],
diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx
index 5783d947c713..3356f3e9b453 100644
--- a/apps/web/src/routes/_chat.tsx
+++ b/apps/web/src/routes/_chat.tsx
@@ -26,6 +26,7 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
import { useThreadSelectionStore } from "../threadSelectionStore";
import { stackedThreadToast, toastManager } from "~/components/ui/toast";
import { SplitThreadLayout } from "~/components/thread-split/SplitThreadLayout";
+import { canSwapThreadPanes, swapThreadPanes } from "~/components/thread-split/swapThreadPanes";
import {
focusOtherThreadPane,
useThreadSplitStore,
@@ -249,6 +250,24 @@ function ChatRouteGlobalShortcuts() {
return;
}
+ if (command === "threadPane.swap") {
+ // Swaps the primary route thread with the secondary pane's — needs
+ // two server threads on screen, so a draft primary leaves the key
+ // to its default behavior.
+ if (!canSwapThreadPanes(routeThreadRef)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ void swapThreadPanes({
+ routeThreadRef,
+ navigateToThread: (ref) =>
+ router.navigate({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(ref),
+ }),
+ });
+ return;
+ }
+
if (command === "thread.archive") {
if (!shortcutThreadRef) return;
if (hasOpenArchiveUndoBlockingLayer()) return;
@@ -317,6 +336,7 @@ function ChatRouteGlobalShortcuts() {
defaultProjectRef,
previewOpen,
projectGroupCount,
+ routeThreadRef,
shortcutThreadRef,
selectedThreadKeysSize,
legacySidebarEnabled,
diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts
index 639d95d9cdcc..f008fa521280 100644
--- a/packages/contracts/src/keybindings.ts
+++ b/packages/contracts/src/keybindings.ts
@@ -73,6 +73,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
"chat.newLocal",
"editor.openFavorite",
"threadPane.focusOther",
+ "threadPane.swap",
...MODEL_PICKER_KEYBINDING_COMMANDS,
...THREAD_KEYBINDING_COMMANDS,
] as const;
diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts
index 421daad6fe95..f24bcd1bebec 100644
--- a/packages/shared/src/keybindings.ts
+++ b/packages/shared/src/keybindings.ts
@@ -45,6 +45,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [
{ key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },
{ key: "mod+o", command: "editor.openFavorite" },
{ key: "mod+\\", command: "threadPane.focusOther", when: "!terminalFocus" },
+ { key: "mod+shift+\\", command: "threadPane.swap", when: "!terminalFocus" },
{ key: "mod+shift+e", command: "thread.archive", when: "!terminalFocus" },
{ key: "mod+shift+[", command: "thread.previous" },
{ key: "mod+shift+]", command: "thread.next" },