From 2e0164ecf70f6172099b50fc336881bca9e16467 Mon Sep 17 00:00:00 2001 From: pandec Date: Sun, 23 Aug 2026 13:39:35 +0200 Subject: [PATCH 1/3] feat(web): split-view divider controls, active-pane opening, and pane swap The split-view pane controls lived in ChatView's titlebar overlay and covered the git actions; sidebar and palette picks always retargeted the primary route, and clicking the secondary pane's thread while the primary showed a draft folded the whole split. Secondary-pane controls now sit in a hover cluster on the resize divider (swap / switch / close), and Open split view moved into the chat header's action row. Sidebar clicks and cmd+K picks open into the active pane; a thread already on screen just gets its pane focused, so a pick can never fold the split. New threadPane.swap command (default mod+shift+\) trades the two panes' threads under a store latch that keeps the duplicate-thread guard quiet mid-swap. Sidebar rows mark the two open threads with pane glyphs. --- README.md | 2 +- apps/web/src/components/ChatView.tsx | 2 - apps/web/src/components/CommandPalette.tsx | 48 +++- apps/web/src/components/Sidebar.tsx | 71 ++++- apps/web/src/components/chat/ChatHeader.tsx | 2 + .../thread-split/SplitThreadLayout.tsx | 249 +++++++++++++----- .../thread-split/ThreadPaneControls.tsx | 57 ++-- .../thread-split/splitPaletteItems.test.ts | 18 +- .../thread-split/swapThreadPanes.ts | 59 +++++ .../thread-split/threadOpenTarget.test.ts | 210 +++++++++++++++ .../thread-split/threadOpenTarget.ts | 105 ++++++++ .../thread-split/threadSplitStore.test.ts | 105 ++++++++ .../thread-split/threadSplitStore.ts | 105 +++++++- .../thread-split/useSplitPaneMarkers.ts | 22 ++ apps/web/src/routes/_chat.tsx | 20 ++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 17 files changed, 954 insertions(+), 123 deletions(-) create mode 100644 apps/web/src/components/thread-split/swapThreadPanes.ts create mode 100644 apps/web/src/components/thread-split/threadOpenTarget.test.ts create mode 100644 apps/web/src/components/thread-split/threadOpenTarget.ts create mode 100644 apps/web/src/components/thread-split/useSplitPaneMarkers.ts diff --git a/README.md b/README.md index ad8da06ddbb5..feb24ccbec89 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod - **Indefinite snooze** — an "Until I wake it" preset in the thread sidebar snooze menus parks a thread on the snoozed shelf with no timer: it comes back only when explicitly woken or when you send it a message. Indefinite rows sort after every timed wake and carry a "parked" label; the T3 CLI reports them as `Snoozed: until woken`. - **Conversation forking** — fork a Codex or Claude thread mid-conversation into a new thread, including immediately after importing an external session; forked threads are titled with a 🔱 prefix. -- **Two-pane split view** — web and desktop can show a second thread beside the routed one, including a thread from a different environment. Open it from the titlebar button or the ⌘K "Open thread in split view..." action; the secondary pane carries its own switch and close controls, the URL and sidebar keep driving only the main pane, and opening the secondary thread in the main pane folds the split. A ring marks the active pane, a rebindable shortcut (default mod+\) jumps between panes, and window shortcuts — including permission-prompt number keys — act only on the active pane. While split, each pane's right panel opens as an overlay sheet, terminals stay with the pane that owns their thread, the divider drags and remembers its position, and actions that create a new thread (new thread, implement plan) stay in the main pane. +- **Two-pane split view** — web and desktop can show a second thread beside the routed one, including a thread from a different environment. Open it from the chat header button (between the git actions and the terminal toggle) or the ⌘K "Open thread in split view..." action. While split, hovering the divider reveals its controls near the top: swap the two threads (also ⌘K "Swap split threads", rebindable, default mod+shift+\), switch the right pane's thread, and close the split. Sidebar clicks and ⌘K thread picks open into the active pane — picking a thread already on screen just focuses its pane, so neither path can accidentally fold the split — and the sidebar marks both open threads with left/right pane glyphs. A ring marks the active pane, a rebindable shortcut (default mod+\) jumps between panes, and window shortcuts — including permission-prompt number keys — act only on the active pane. Each pane's right panel opens as an overlay sheet, terminals stay with the pane that owns their thread, the divider drags and remembers its position, and actions that create a new thread (new thread, implement plan) stay in the main pane. - **Mobile swipe-right actions** — swiping a thread row right reveals Pin (Unpin on a pinned row), Fork, and Archive, in that order. Each action only appears where it applies — pin needs a server that supports pinning, fork needs a forkable Codex or Claude conversation — and every one of them has a twin in the row's long-press menu. Pin and Archive keep you on the list; Fork opens the new copy. Archive stays innermost, so a full swipe right still archives. - **Session import** — import external Claude Code and Codex CLI sessions as native T3 threads from thread-sidebar project settings (choosing the environment when the project is grouped across several) or the desktop project context menu, including strict resume/continuation, Claude-generated and explicit CLI session titles, and explicit instance labels when more than one provider instance can import sessions. Sessions already owned by a T3 thread stay visible as linked candidates; importing one again forks the provider history into a fresh continuation so the original thread keeps sole ownership of its native session. - **Archived-thread recents, search, grouping & undo** — the final thread-sidebar section on desktop and mobile keeps a configurable recent archive close at hand, with one-action restore, archive-aware menus, and automatic restore when a new message is sent; the full archive remains searchable, grouped, and filterable by project. The open web thread can be archived from the command palette or a rebindable shortcut (default mod+shift+E), a mobile thread row archives with a full swipe right, and on web Cmd+Z outside the composer reverses the most recent archive or snooze, whichever happened last — an empty new-thread screen reopens the restored thread, while another active conversation stays in place. The section folds on every client and starts folded, since archived threads are the ones you deliberately put away; the header then reports the archive count, and the thread you have open keeps its row while the section is folded. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index da0b0bdb0509..f825391fdf32 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -393,7 +393,6 @@ import type { ThreadSyncPhase } from "../threadSync"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; import { useThreadPaneId } from "./thread-split/threadPaneContext"; -import { ThreadPaneControls } from "./thread-split/ThreadPaneControls"; import { isThreadPaneActive, useThreadSplitStore } from "./thread-split/threadSplitStore"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; @@ -7125,7 +7124,6 @@ function ChatViewContent(props: ChatViewProps) { onToggle={toggleRightPanelMaximized} /> ) : null} - {panelToggleControls} ); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index be9420522b32..f619602b30c9 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -43,6 +43,7 @@ import { ArrowUpToLineIcon, CircleCheckIcon, CircleDotIcon, + ArrowLeftRightIcon, Columns2Icon, CopyIcon, CornerLeftUpIcon, @@ -198,10 +199,14 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; import { buildOpenInSplitThreadItems } from "./thread-split/splitPaletteItems"; +import { swapThreadPanes } from "./thread-split/swapThreadPanes"; +import { openThreadInActivePane } from "./thread-split/threadOpenTarget"; import { activeThreadPaneComposerHandle, + capturePaletteOwnerPane, focusActiveThreadPane, focusOtherThreadPane, + paletteOwnerPane, THREAD_SPLIT_MEDIA_QUERY, useThreadSplitStore, } from "./thread-split/threadSplitStore"; @@ -566,6 +571,14 @@ export function CommandPalette({ children }: { children: ReactNode }) { [openAddProject, openInSplit, openNewThreadIn, setOpen], ); + // Thread picks must target the pane that owned focus when the palette + // OPENED. The dialog's own focus traffic (list focus, close-time restores) + // moves the live active pane while it is up, so run callbacks read this + // snapshot instead. + useEffect(() => { + if (state.open) capturePaletteOwnerPane(); + }, [state.open]); + return ( { - await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + const targetRef = scopeThreadRef(thread.environmentId, thread.id); + openThreadInActivePane({ + targetRef, + routeThreadRef, + paneOverride: paletteOwnerPane(), + navigateToPrimary: () => + navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(targetRef), + }), }); }, }), [ activeThreadKey, + routeThreadRef, clientSettings.sidebarThreadSortOrder, navigate, projectCwdByKey, @@ -1317,6 +1338,7 @@ function OpenCommandPaletteDialog(props: { const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); const splitSecondaryRef = useThreadSplitStore((state) => state.secondaryRef); + const splitMounted = useThreadSplitStore((state) => state.splitMounted); const splitSupported = useMediaQuery(THREAD_SPLIT_MEDIA_QUERY); const openInSplitItems = useMemo( () => @@ -1930,6 +1952,26 @@ function OpenCommandPaletteDialog(props: { }, }); } + if (splitSupported && splitMounted && splitSecondaryRef !== null && routeThreadRef !== null) { + actionItems.push({ + kind: "action", + value: "action:swap-split-threads", + searchTerms: ["split", "swap", "pane", "exchange", "flip", "trade"], + title: "Swap split threads", + icon: , + shortcutCommand: "threadPane.swap", + run: async () => { + await swapThreadPanes({ + routeThreadRef, + navigateToThread: (ref) => + navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(ref), + }), + }); + }, + }); + } // Not gated on viewport width: a split opened on a wide window must stay // closable after the window shrinks below the split breakpoint. if (splitSecondaryRef !== null) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 871344cfb376..2bf823a58972 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -67,6 +67,8 @@ import { EyeOffIcon, ListFilterIcon, MessageSquareIcon, + PanelLeftIcon, + PanelRightIcon, PinIcon, PlusIcon, SearchIcon, @@ -151,6 +153,11 @@ import { import { formatCompactRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { openThreadInActivePane } from "./thread-split/threadOpenTarget"; +import { + useSplitSecondaryThreadKey, + type SplitPaneMarker, +} from "./thread-split/useSplitPaneMarkers"; import { SidebarEnvironmentFilterMenu } from "./sidebar/SidebarEnvironmentFilter"; import { resolveSidebarEmptyStateCause } from "./sidebar/sidebarEmptyState"; import { useSidebarEnvironmentFilter } from "./sidebar/useSidebarEnvironmentFilter"; @@ -831,6 +838,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // the user visits the thread. wokeAt: string | null; isActive: boolean; + // Which split pane shows this thread, while the split view is on screen. + splitPaneMarker: SplitPaneMarker | null; openPullRequestsInRightPanel: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -1351,6 +1360,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; + // Static glyph naming the split pane this thread occupies. Only the two + // affected rows ever get a non-null marker, so the list stays quiet. + const splitPaneIcon = props.splitPaneMarker ? ( + + {props.splitPaneMarker === "left" ? ( + + ) : ( + + )} + + ) : null; const diff = latestTurnDiff(thread); const cardTrailingMetadata = !isRenaming ? ( <> @@ -1358,6 +1384,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { cards do not render. Carrying it here covers both card shapes instead of only the expanded one. Slim rows (settled, snoozed) build their own metadata below and stay unmarked, as they do upstream. */} + {splitPaneIcon} {terminalStatusIcon} {prBadge} @@ -2167,6 +2194,7 @@ export default function Sidebar() { [routeDraftThread, routeTarget], ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const splitSecondaryKey = useSplitSecondaryThreadKey(); // Post-settle navigation validates against the CURRENT route, not the one // captured when the settle started: if the user navigated elsewhere while // the command was in flight, completing it must not yank them away. @@ -3066,6 +3094,32 @@ export default function Sidebar() { }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + // Direct picks (row clicks, search results) route through the split-aware + // helper so the thread lands in the active pane; forward navigation after + // settle/archive keeps using navigateToThread — it must always retarget + // the primary route. The sidebar sits outside both pane roots, so the + // click's own pointerdown never changes which pane is active. + const openThreadFromSidebar = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + openThreadInActivePane({ + targetRef: threadRef, + routeThreadRef, + navigateToPrimary: () => + router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }), + }); + }, + [clearSelection, isMobile, routeThreadRef, router, setOpenMobile, setSelectionAnchor], + ); const attemptUnarchive = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { @@ -3144,9 +3198,9 @@ export default function Sidebar() { const selectThreadSearchResult = useCallback( (thread: EnvironmentThreadShell) => { clearThreadSearch(); - navigateToThread(scopeThreadRef(thread.environmentId, thread.id)); + openThreadFromSidebar(scopeThreadRef(thread.environmentId, thread.id)); }, - [clearThreadSearch, navigateToThread], + [clearThreadSearch, openThreadFromSidebar], ); const handleThreadSearchKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -3242,9 +3296,9 @@ export default function Sidebar() { if (isTrailingDoubleClick(event.detail)) { return; } - navigateToThread(threadRef); + openThreadFromSidebar(threadRef); }, - [navigateToThread, rangeSelectTo, toggleThreadSelection], + [openThreadFromSidebar, rangeSelectTo, toggleThreadSelection], ); const attemptArchive = useCallback( @@ -4692,6 +4746,15 @@ export default function Sidebar() { // rows resolve to null on their own. wokeAt={threadWokeAt(thread, { now: snoozeNow })} isActive={routeThreadKey === threadKey} + splitPaneMarker={ + splitSecondaryKey === null + ? null + : threadKey === splitSecondaryKey + ? "right" + : threadKey === routeThreadKey + ? "left" + : null + } openPullRequestsInRightPanel={routeThreadRef !== null} jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} currentEnvironmentId={primaryEnvironmentId} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index dbba327489ac..91a44459f317 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -32,6 +32,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; +import { OpenSplitViewControl } from "../thread-split/ThreadPaneControls"; import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; @@ -408,6 +409,7 @@ export const ChatHeader = memo(function ChatHeader({ {...(draftId ? { draftId } : {})} /> )} + ); diff --git a/apps/web/src/components/thread-split/SplitThreadLayout.tsx b/apps/web/src/components/thread-split/SplitThreadLayout.tsx index 1f613e21e131..4e4454d46102 100644 --- a/apps/web/src/components/thread-split/SplitThreadLayout.tsx +++ b/apps/web/src/components/thread-split/SplitThreadLayout.tsx @@ -1,15 +1,21 @@ -import { useParams } from "@tanstack/react-router"; +import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; +import { ArrowLeftRightIcon, ReplaceIcon, XIcon } from "lucide-react"; +import { openCommandPalette } from "../../commandPaletteBus"; import { ComposerHandleContext, useComposerHandleContext } from "../../composerHandleContext"; import type { ChatComposerHandle } from "../chat/ChatComposer"; import { useMediaQuery } from "../../hooks/useMediaQuery"; -import { resolveThreadRouteRef } from "../../threadRoutes"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../../threadRoutes"; +import { swapThreadPanes } from "./swapThreadPanes"; import { ServerThreadPaneHost } from "./ServerThreadPaneHost"; +import { shouldCloseSplitForRoute } from "./threadOpenTarget"; import { ThreadPaneContext } from "./threadPaneContext"; +import { PaneControlButton } from "./ThreadPaneControls"; import { + activateThreadPane, cancelPendingThreadPaneFocus, clampSplitRatio, noteThreadPaneFocus, @@ -64,43 +70,68 @@ export function SplitThreadLayout({ children }: { children: ReactNode }) { }, [primaryComposerRef]); // No duplicate thread across panes: navigating the primary pane onto the - // secondary's thread means it "moved" there, so the split closes. + // secondary's thread means it "moved" there, so the split closes — except + // mid-swap, where that duplicate is a transient the latch vouches for. An + // expired latch means the swap navigation never arrived; restoring the old + // secondary is the better degraded outcome than folding the split. + const pendingSwap = useThreadSplitStore((state) => state.pendingSwap); + const abortPaneSwap = useThreadSplitStore((state) => state.abortPaneSwap); + const routeThreadKey = routeThreadRef === null ? null : scopedThreadKey(routeThreadRef); useEffect(() => { - if ( - secondaryRef !== null && - routeThreadRef !== null && - scopedThreadKey(routeThreadRef) === scopedThreadKey(secondaryRef) - ) { - closeSplit(); + const shouldClose = shouldCloseSplitForRoute({ + routeThreadKey, + secondaryKey: secondaryRef === null ? null : scopedThreadKey(secondaryRef), + pendingSwap, + now: Date.now(), + }); + if (!shouldClose) return; + if (pendingSwap !== null) { + abortPaneSwap(); + return; } - }, [closeSplit, routeThreadRef, secondaryRef]); + closeSplit(); + }, [abortPaneSwap, closeSplit, pendingSwap, routeThreadKey, secondaryRef]); - // Sidebar and palette navigation happen outside both pane roots, yet they - // retarget the primary pane — attention (and shortcut ownership, e.g. - // mod+shift+E archive) must follow, or the secondary pane would keep - // swallowing thread-targeted shortcuts after the user switched threads. - const routeThreadKey = routeThreadRef === null ? null : scopedThreadKey(routeThreadRef); + // A primary route change made outside both pane roots (sidebar, palette, + // deep link) retargets the primary pane — attention (and shortcut + // ownership, e.g. mod+shift+E archive) must follow, or the secondary pane + // would keep swallowing thread-targeted shortcuts after the user switched + // threads. A pane swap is the exception: the threads traded sides on + // purpose, so the side the user was working on keeps its activation. useEffect(() => { if (lastSeenRouteThreadKey.value === routeThreadKey) return; const isFirstObservation = lastSeenRouteThreadKey.value === UNOBSERVED_ROUTE_THREAD_KEY; lastSeenRouteThreadKey.value = routeThreadKey; if (isFirstObservation) return; - useThreadSplitStore.getState().setActivePane("primary"); + const state = useThreadSplitStore.getState(); + const pending = state.pendingSwap; + if (pending !== null) { + state.settlePaneSwap(); + if (routeThreadKey === pending.expectedRouteKey) return; + } + state.setActivePane("primary"); }, [routeThreadKey]); const splitOpen = secondaryRef !== null && isWideEnoughForSplit; return ( - {children} + + {children} + ); } function SplitThreadPanes({ children, secondaryRef, + routeThreadRef, }: { children: ReactNode; secondaryRef: ScopedThreadRef | null; + routeThreadRef: ScopedThreadRef | null; }) { const splitRatio = useThreadSplitStore((state) => state.splitRatio); const setSplitMounted = useThreadSplitStore((state) => state.setSplitMounted); @@ -136,7 +167,9 @@ function SplitThreadPanes({ {children} - {splitOpen ? : null} + {splitOpen ? ( + + ) : null} {secondaryRef !== null ? ( @@ -213,10 +246,13 @@ function ThreadPaneSection({ function SplitResizeHandle({ containerRef, + routeThreadRef, }: { containerRef: React.RefObject; + routeThreadRef: ScopedThreadRef | null; }) { const setSplitRatio = useThreadSplitStore((state) => state.setSplitRatio); + const wrapperRef = useRef(null); const dragStateRef = useRef<{ pointerId: number; ratio: number; @@ -238,62 +274,131 @@ function SplitResizeHandle({ [containerRef], ); + const endDrag = useCallback(() => { + delete wrapperRef.current?.dataset.dragging; + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }, []); + + // The wrapper is the visible 1px line and owns the grid track; the drag + // surface is a wider invisible child so the divider is grabbable without + // fattening the line. The control cluster is a sibling of the drag surface, + // so its clicks can never start a drag, and data-dragging on the wrapper + // hides the cluster while resizing so a drag can't end on a button. return (
{ - 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); + * focus-within keeps the buttons reachable by keyboard. + */ +function SplitPaneControls({ routeThreadRef }: { routeThreadRef: ScopedThreadRef | null }) { + const router = useRouter(); + 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), + }), + }).then((swapped) => { + // The click parked focus on this cluster button; hand it back to + // the pane that owns the shortcuts. + if (swapped) activateThreadPane(useThreadSplitStore.getState().activePaneId); + }); + }} + > + + + 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 index 3620b5541e5a..43c6d8880af5 100644 --- a/apps/web/src/components/thread-split/ThreadPaneControls.tsx +++ b/apps/web/src/components/thread-split/ThreadPaneControls.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftRightIcon, Columns2Icon, XIcon } from "lucide-react"; +import { Columns2Icon } from "lucide-react"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -8,60 +8,42 @@ 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. + * "Open split view" for the chat header's action row. Fully self-gated so + * the ChatHeader seam stays a bare render: nothing shows in the secondary + * pane, while a split is already open (its controls live on the divider — + * see SplitPaneControls), or on viewports too narrow for a split. */ -export function ThreadPaneControls() { +export function OpenSplitViewControl() { 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) { + if (paneId === "secondary" || splitActive || !isWideEnoughForSplit) { return null; } return ( -
- openCommandPalette({ open: "open-in-split" })} - > - - -
+ openCommandPalette({ open: "open-in-split" })} + > + + ); } -function PaneControlButton({ +export function PaneControlButton({ children, label, onClick, + disabled = false, + tooltipSide = "bottom", }: { children: React.ReactNode; label: string; onClick: () => void; + disabled?: boolean; + tooltipSide?: "top" | "bottom" | "left" | "right"; }) { return ( @@ -72,13 +54,14 @@ function PaneControlButton({ aria-label={label} variant="ghost" size="sm" + disabled={disabled} onClick={onClick} > {children} } /> - {label} + {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/swapThreadPanes.ts b/apps/web/src/components/thread-split/swapThreadPanes.ts new file mode 100644 index 000000000000..538247c28db7 --- /dev/null +++ b/apps/web/src/components/thread-split/swapThreadPanes.ts @@ -0,0 +1,59 @@ +/** + * 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 { 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. + */ +export async function swapThreadPanes(input: { + routeThreadRef: ScopedThreadRef | null; + navigateToThread: (ref: ScopedThreadRef) => void | Promise; +}): Promise { + if (input.routeThreadRef === null) { + return false; + } + const target = useThreadSplitStore.getState().beginPaneSwap(input.routeThreadRef); + if (target === null) { + return false; + } + try { + await input.navigateToThread(target); + } catch { + useThreadSplitStore.getState().abortPaneSwap(); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not swap split threads", + description: "Navigation to the other pane's thread failed.", + }), + ); + return false; + } + 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..338e3dea6245 --- /dev/null +++ b/apps/web/src/components/thread-split/threadOpenTarget.test.ts @@ -0,0 +1,210 @@ +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("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); + }); +}); + +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..0ae081e652e5 --- /dev/null +++ b/apps/web/src/components/thread-split/threadOpenTarget.ts @@ -0,0 +1,105 @@ +/** + * 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 → focus the primary pane, never re-navigate. + * 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; + 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.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" }; +} + +/** + * 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; +}): void { + const state = useThreadSplitStore.getState(); + const plan = planThreadOpen({ + targetRef: input.targetRef, + routeThreadRef: input.routeThreadRef, + splitMounted: state.splitMounted, + activePaneId: state.activePaneId, + secondaryRef: state.secondaryRef, + ...(input.paneOverride ? { paneOverride: input.paneOverride } : {}), + }); + switch (plan.kind) { + case "navigate-primary": + void input.navigateToPrimary(); + return; + case "open-secondary": + state.openSecondaryThread(input.targetRef); + return; + case "focus-pane": + activateThreadPane(plan.paneId); + return; + } +} + +/** + * 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..c584567f4353 100644 --- a/apps/web/src/components/thread-split/threadSplitStore.test.ts +++ b/apps/web/src/components/thread-split/threadSplitStore.test.ts @@ -4,11 +4,14 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { ComposerHandleRef } from "../../composerHandleContext"; import { + activateThreadPane, + capturePaletteOwnerPane, clampSplitRatio, focusOtherThreadPane, isThreadPaneActive, MAX_SPLIT_RATIO, MIN_SPLIT_RATIO, + paletteOwnerPane, registerThreadPaneComposer, useThreadSplitStore, } from "./threadSplitStore"; @@ -32,6 +35,7 @@ beforeEach(() => { splitMounted: false, activePaneId: "primary", splitRatio: 0.5, + pendingSwap: null, }); registerThreadPaneComposer("primary", null); registerThreadPaneComposer("secondary", null); @@ -121,6 +125,107 @@ 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 target = useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF); + expect(target).toEqual(REF_A); + 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 keeps a secondary the user replaced mid-flight", () => { + openMountedSplit(); + useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF); + useThreadSplitStore.getState().openSecondaryThread(REF_B); + // The explicit pick already cleared the latch; a late abort is inert. + useThreadSplitStore.getState().abortPaneSwap(); + expect(useThreadSplitStore.getState().secondaryRef).toEqual(REF_B); + }); + + it("closeSplit and openSecondaryThread clear an in-flight latch", () => { + openMountedSplit(); + useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF); + useThreadSplitStore.getState().openSecondaryThread(REF_B); + expect(useThreadSplitStore.getState().pendingSwap).toBeNull(); + + useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF); + expect(useThreadSplitStore.getState().pendingSwap).not.toBeNull(); + useThreadSplitStore.getState().closeSplit(); + 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..3448c34964dd 100644 --- a/apps/web/src/components/thread-split/threadSplitStore.ts +++ b/apps/web/src/components/thread-split/threadSplitStore.ts @@ -66,6 +66,25 @@ 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; +} + +const PENDING_SWAP_TTL_MS = 5_000; + interface ThreadSplitStore { secondaryRef: ScopedThreadRef | null; /** @@ -78,11 +97,15 @@ 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) => ScopedThreadRef | null; + settlePaneSwap: () => void; + abortPaneSwap: () => void; } export const useThreadSplitStore = create((set, get) => ({ @@ -90,6 +113,7 @@ 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 @@ -97,21 +121,67 @@ export const useThreadSplitStore = create((set, get) => ({ // makes that restore bounce into the pane the user just opened. requestThreadPaneFocus("secondary"); const current = get().secondaryRef; + // An explicit pick overrides any in-flight swap: keeping the latch alive + // would let its abort path later clobber the thread the user just chose. if (current && scopedThreadKey(current) === scopedThreadKey(ref)) { - set({ activePaneId: "secondary" }); + set({ activePaneId: "secondary", pendingSwap: null }); return; } - set({ secondaryRef: ref, activePaneId: "secondary" }); + set({ secondaryRef: ref, activePaneId: "secondary", pendingSwap: null }); }, closeSplit: () => { if (get().secondaryRef === null) return; - set({ secondaryRef: null, activePaneId: "primary" }); + 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; + // 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: { + expectedRouteKey: scopedThreadKey(target), + startedRouteKey: scopedThreadKey(routeThreadRef), + restoreSecondaryRef: target, + expiresAt: Date.now() + PENDING_SWAP_TTL_MS, + }, + }); + return target; + }, + + settlePaneSwap: () => { + if (get().pendingSwap === null) return; + set({ pendingSwap: null }); + }, + + abortPaneSwap: () => { + const state = get(); + const pending = state.pendingSwap; + if (pending === null) return; + // 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 +353,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/components/thread-split/useSplitPaneMarkers.ts b/apps/web/src/components/thread-split/useSplitPaneMarkers.ts new file mode 100644 index 000000000000..ed6449ae80b5 --- /dev/null +++ b/apps/web/src/components/thread-split/useSplitPaneMarkers.ts @@ -0,0 +1,22 @@ +/** + * Sidebar decoration source for the split view (fork feature). One + * subscription with a string-stable selector — call it once at the list + * level and compare row keys against it, never subscribe per row. + */ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; + +import { useThreadSplitStore } from "./threadSplitStore"; + +/** Which side of the split a sidebar row's thread occupies, if any. */ +export type SplitPaneMarker = "left" | "right"; + +/** + * The secondary pane's thread key while two panes are actually rendered, + * null otherwise. The primary side needs no hook: it is the route thread the + * sidebar already tracks. + */ +export function useSplitSecondaryThreadKey(): string | null { + return useThreadSplitStore((state) => + state.splitMounted && state.secondaryRef !== null ? scopedThreadKey(state.secondaryRef) : null, + ); +} 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" }, From aa96505e04437141cadfe2997b65c5f964385e93 Mon Sep 17 00:00:00 2001 From: pandec Date: Sun, 23 Aug 2026 14:21:44 +0200 Subject: [PATCH 2/3] fix(web): harden split-view swap latch, routing coverage, and divider controls Review round (sol + opus on PR #170): - The swap latch now expires through a store-owned timer; without one a stalled navigation left it (and every swap affordance) stuck, since the TTL was only read on re-renders. Mid-swap secondary picks keep the latch so the late route arrival is still recognized; abort already yields to the newer pick. - mod+shift+\ never matched: Shift+Backslash types "|" and the resolver had no Backslash code alias. Added, with a regression test; both new behaviors were mutation-tested. - Enter/Space row activation and archived-row opens now route through the active-pane helper like clicks; its primary branch delegates to the upstream navigateToThread instead of copying it. - openThreadInActivePane returns the executed plan and the navigation promise, so palette picks surface rejections again; post-swap focus reclaim moved into swapThreadPanes so shortcut, palette, and divider paths behave alike; the overlay focus intent moved out of the store into the palette call sites so sidebar picks stop leaving stray intents. - Divider: drag overhang biased off the primary pane's scrollbar lane, reveal delayed to stop crossing flashes, cluster pinned visible on coarse pointers, group label added, pane ring lowered below it, dead no-drag removed, disabled swap button's tooltip made reachable. - Pane glyphs ride slim rows too; stale-named modules renamed (PaneControls, SplitPaneMarker). --- apps/web/src/components/CommandPalette.tsx | 11 +++- apps/web/src/components/Sidebar.tsx | 63 ++++++++----------- apps/web/src/components/chat/ChatHeader.tsx | 2 +- ...hreadPaneControls.tsx => PaneControls.tsx} | 29 ++++----- ...plitPaneMarkers.ts => SplitPaneMarker.tsx} | 18 ++++++ .../thread-split/SplitThreadLayout.tsx | 57 ++++++++--------- .../thread-split/splitPaletteItems.ts | 6 +- .../thread-split/swapThreadPanes.ts | 7 ++- .../thread-split/threadOpenTarget.test.ts | 23 +++++++ .../thread-split/threadOpenTarget.ts | 24 +++++-- .../thread-split/threadSplitStore.test.ts | 47 ++++++++++++-- .../thread-split/threadSplitStore.ts | 59 +++++++++++------ apps/web/src/keybindings.test.ts | 20 ++++++ apps/web/src/keybindings.ts | 1 + 14 files changed, 253 insertions(+), 114 deletions(-) rename apps/web/src/components/thread-split/{ThreadPaneControls.tsx => PaneControls.tsx} (74%) rename apps/web/src/components/thread-split/{useSplitPaneMarkers.ts => SplitPaneMarker.tsx} (59%) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index f619602b30c9..623e461016f8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -207,6 +207,7 @@ import { focusActiveThreadPane, focusOtherThreadPane, paletteOwnerPane, + requestThreadPaneFocus, THREAD_SPLIT_MEDIA_QUERY, useThreadSplitStore, } from "./thread-split/threadSplitStore"; @@ -1309,7 +1310,7 @@ function OpenCommandPaletteDialog(props: { }, runThread: async (thread) => { const targetRef = scopeThreadRef(thread.environmentId, thread.id); - openThreadInActivePane({ + const { plan, completion } = openThreadInActivePane({ targetRef, routeThreadRef, paneOverride: paletteOwnerPane(), @@ -1319,6 +1320,14 @@ function OpenCommandPaletteDialog(props: { params: buildThreadRouteParams(targetRef), }), }); + if (plan.kind === "open-secondary") { + // The closing palette restores focus to its trigger a beat later; + // the intent bounces that restore into the pane just opened. + requestThreadPaneFocus("secondary"); + } + // Awaited so a navigation rejection still reaches the palette's + // run-command error handling, as it did before the split routing. + if (completion) await completion; }, }), [ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2bf823a58972..2b53af92bbbe 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -67,8 +67,6 @@ import { EyeOffIcon, ListFilterIcon, MessageSquareIcon, - PanelLeftIcon, - PanelRightIcon, PinIcon, PlusIcon, SearchIcon, @@ -155,9 +153,10 @@ import type { SidebarThreadSummary } from "../types"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { openThreadInActivePane } from "./thread-split/threadOpenTarget"; import { + SplitPaneMarkerIcon, useSplitSecondaryThreadKey, type SplitPaneMarker, -} from "./thread-split/useSplitPaneMarkers"; +} from "./thread-split/SplitPaneMarker"; import { SidebarEnvironmentFilterMenu } from "./sidebar/SidebarEnvironmentFilter"; import { resolveSidebarEmptyStateCause } from "./sidebar/sidebarEmptyState"; import { useSidebarEnvironmentFilter } from "./sidebar/useSidebarEnvironmentFilter"; @@ -1360,31 +1359,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; - // Static glyph naming the split pane this thread occupies. Only the two - // affected rows ever get a non-null marker, so the list stays quiet. + // Only the two threads on screen ever get a non-null marker, so the list + // stays quiet. const splitPaneIcon = props.splitPaneMarker ? ( - - {props.splitPaneMarker === "left" ? ( - - ) : ( - - )} - + ) : null; const diff = latestTurnDiff(thread); const cardTrailingMetadata = !isRenaming ? ( <> + {splitPaneIcon} {/* Upstream anchors the worktree marker to the branch, which compact cards do not render. Carrying it here covers both card shapes instead of only the expanded one. Slim rows (settled, snoozed) build their own metadata below and stay unmarked, as they do upstream. */} - {splitPaneIcon} {terminalStatusIcon} {prBadge} @@ -1470,6 +1457,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { visibility={props.providerIconVisibility} /> ) : null} + {/* A settled or snoozed thread can still occupy a split pane, so + the marker rides slim rows too — like the terminal and PR + icons already do. */} + {splitPaneIcon} {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -3094,13 +3085,22 @@ export default function Sidebar() { }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); - // Direct picks (row clicks, search results) route through the split-aware - // helper so the thread lands in the active pane; forward navigation after - // settle/archive keeps using navigateToThread — it must always retarget - // the primary route. The sidebar sits outside both pane roots, so the - // click's own pointerdown never changes which pane is active. + // Direct picks (row clicks, Enter/Space, search results, archived rows) + // route through the split-aware helper so the thread lands in the active + // pane; forward navigation after settle/archive keeps using + // navigateToThread — it must always retarget the primary route. The + // primary branch delegates to navigateToThread wholesale, so only the + // split-local branches repeat its selection/mobile prep. The sidebar sits + // outside both pane roots, so the click's own pointerdown never changes + // which pane is active. const openThreadFromSidebar = useCallback( (threadRef: ScopedThreadRef) => { + const { plan } = openThreadInActivePane({ + targetRef: threadRef, + routeThreadRef, + navigateToPrimary: () => navigateToThread(threadRef), + }); + if (plan.kind === "navigate-primary") return; if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { clearSelection(); } @@ -3108,17 +3108,8 @@ export default function Sidebar() { if (isMobile) { setOpenMobile(false); } - openThreadInActivePane({ - targetRef: threadRef, - routeThreadRef, - navigateToPrimary: () => - router.navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }), - }); }, - [clearSelection, isMobile, routeThreadRef, router, setOpenMobile, setSelectionAnchor], + [clearSelection, isMobile, navigateToThread, routeThreadRef, setOpenMobile, setSelectionAnchor], ); const attemptUnarchive = useCallback( (threadRef: ScopedThreadRef) => { @@ -4786,7 +4777,7 @@ export default function Sidebar() { } timestampFormat={timestampFormat} onThreadClick={handleThreadClick} - onThreadActivate={navigateToThread} + onThreadActivate={openThreadFromSidebar} onStartRename={startThreadRename} onRenameTitleChange={setRenamingTitle} onCommitRename={commitThreadRename} @@ -5086,7 +5077,7 @@ export default function Sidebar() { routeThreadKey === scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) } - onOpen={navigateToThread} + onOpen={openThreadFromSidebar} onUnarchive={attemptUnarchive} onContextMenu={handleArchivedThreadContextMenu} /> diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 91a44459f317..1f8148db574b 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -32,7 +32,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; -import { OpenSplitViewControl } from "../thread-split/ThreadPaneControls"; +import { OpenSplitViewControl } from "../thread-split/PaneControls"; import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; diff --git a/apps/web/src/components/thread-split/ThreadPaneControls.tsx b/apps/web/src/components/thread-split/PaneControls.tsx similarity index 74% rename from apps/web/src/components/thread-split/ThreadPaneControls.tsx rename to apps/web/src/components/thread-split/PaneControls.tsx index 43c6d8880af5..3e56f026b205 100644 --- a/apps/web/src/components/thread-split/ThreadPaneControls.tsx +++ b/apps/web/src/components/thread-split/PaneControls.tsx @@ -47,20 +47,21 @@ export function PaneControlButton({ }) { return ( - - {children} - - } - /> + {/* The span, not the button, triggers the tooltip: a disabled button + receives no pointer events, and its label is exactly the state that + needs explaining (same pattern as PanelLayoutControls). */} + }> + + {label} ); diff --git a/apps/web/src/components/thread-split/useSplitPaneMarkers.ts b/apps/web/src/components/thread-split/SplitPaneMarker.tsx similarity index 59% rename from apps/web/src/components/thread-split/useSplitPaneMarkers.ts rename to apps/web/src/components/thread-split/SplitPaneMarker.tsx index ed6449ae80b5..96e1f5e87a26 100644 --- a/apps/web/src/components/thread-split/useSplitPaneMarkers.ts +++ b/apps/web/src/components/thread-split/SplitPaneMarker.tsx @@ -3,6 +3,7 @@ * subscription with a string-stable selector — call it once at the list * level and compare row keys against it, never subscribe per row. */ +import { PanelLeftIcon, PanelRightIcon } from "lucide-react"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { useThreadSplitStore } from "./threadSplitStore"; @@ -20,3 +21,20 @@ export function useSplitSecondaryThreadKey(): string | null { state.splitMounted && state.secondaryRef !== null ? scopedThreadKey(state.secondaryRef) : null, ); } + +/** Static row glyph naming the split pane a thread occupies. */ +export function SplitPaneMarkerIcon({ marker }: { marker: SplitPaneMarker }) { + return ( + + {marker === "left" ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/web/src/components/thread-split/SplitThreadLayout.tsx b/apps/web/src/components/thread-split/SplitThreadLayout.tsx index 4e4454d46102..6c5b1e3a6e54 100644 --- a/apps/web/src/components/thread-split/SplitThreadLayout.tsx +++ b/apps/web/src/components/thread-split/SplitThreadLayout.tsx @@ -13,9 +13,8 @@ import { swapThreadPanes } from "./swapThreadPanes"; import { ServerThreadPaneHost } from "./ServerThreadPaneHost"; import { shouldCloseSplitForRoute } from "./threadOpenTarget"; import { ThreadPaneContext } from "./threadPaneContext"; -import { PaneControlButton } from "./ThreadPaneControls"; +import { PaneControlButton } from "./PaneControls"; import { - activateThreadPane, cancelPendingThreadPaneFocus, clampSplitRatio, noteThreadPaneFocus, @@ -115,23 +114,16 @@ export function SplitThreadLayout({ children }: { children: ReactNode }) { const splitOpen = secondaryRef !== null && isWideEnoughForSplit; return ( - - {children} - + {children} ); } function SplitThreadPanes({ children, secondaryRef, - routeThreadRef, }: { children: ReactNode; secondaryRef: ScopedThreadRef | null; - routeThreadRef: ScopedThreadRef | null; }) { const splitRatio = useThreadSplitStore((state) => state.splitRatio); const setSplitMounted = useThreadSplitStore((state) => state.setSplitMounted); @@ -167,9 +159,7 @@ function SplitThreadPanes({ {children} - {splitOpen ? ( - - ) : null} + {splitOpen ? : null} {secondaryRef !== null ? ( @@ -235,9 +225,11 @@ function ThreadPaneSection({ > {children} {showActiveIndicator && isActive ? ( + // Below the divider wrapper's z-40 on purpose: the ring's inset edge + // must not draw across the divider's hover control cluster.
) : null} @@ -246,10 +238,8 @@ function ThreadPaneSection({ function SplitResizeHandle({ containerRef, - routeThreadRef, }: { containerRef: React.RefObject; - routeThreadRef: ScopedThreadRef | null; }) { const setSplitRatio = useThreadSplitStore((state) => state.setSplitRatio); const wrapperRef = useRef(null); @@ -282,11 +272,13 @@ function SplitResizeHandle({ } }, []); - // The wrapper is the visible 1px line and owns the grid track; the drag + // The wrapper is the visible divider line and owns the grid track; the drag // surface is a wider invisible child so the divider is grabbable without - // fattening the line. The control cluster is a sibling of the drag surface, - // so its clicks can never start a drag, and data-dragging on the wrapper - // hides the cluster while resizing so a drag can't end on a button. + // fattening the line. Its overhang extends only into the secondary pane: + // the primary pane's right edge carries a classic scrollbar lane that must + // keep winning hit-testing. The control cluster is a sibling of the drag + // surface, so its clicks can never start a drag, and data-dragging on the + // wrapper hides the cluster while resizing so a drag can't end on a button. return (
{ if (event.button !== 0) return; const container = containerRef.current; @@ -342,7 +334,7 @@ function SplitResizeHandle({ applyRatio(useThreadSplitStore.getState().splitRatio); }} /> - +
); } @@ -351,11 +343,18 @@ function SplitResizeHandle({ * 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); - * focus-within keeps the buttons reachable by keyboard. + * 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({ routeThreadRef }: { routeThreadRef: ScopedThreadRef | null }) { +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; @@ -363,7 +362,9 @@ function SplitPaneControls({ routeThreadRef }: { routeThreadRef: ScopedThreadRef return (
event.stopPropagation()} > { - // The click parked focus on this cluster button; hand it back to - // the pane that owns the shortcuts. - if (swapped) activateThreadPane(useThreadSplitStore.getState().activePaneId); }); }} > 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 index 538247c28db7..f891b19f1ba2 100644 --- a/apps/web/src/components/thread-split/swapThreadPanes.ts +++ b/apps/web/src/components/thread-split/swapThreadPanes.ts @@ -7,7 +7,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { useThreadSplitStore } from "./threadSplitStore"; +import { activateThreadPane, useThreadSplitStore } from "./threadSplitStore"; /** * Swap is offered only while two server threads are actually on screen: a @@ -55,5 +55,10 @@ export async function swapThreadPanes(input: { ); 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 index 338e3dea6245..6cfc091821d7 100644 --- a/apps/web/src/components/thread-split/threadOpenTarget.test.ts +++ b/apps/web/src/components/thread-split/threadOpenTarget.test.ts @@ -148,6 +148,29 @@ describe("openThreadInActivePane", () => { 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", () => { diff --git a/apps/web/src/components/thread-split/threadOpenTarget.ts b/apps/web/src/components/thread-split/threadOpenTarget.ts index 0ae081e652e5..71e346ba5e37 100644 --- a/apps/web/src/components/thread-split/threadOpenTarget.ts +++ b/apps/web/src/components/thread-split/threadOpenTarget.ts @@ -50,6 +50,17 @@ export function planThreadOpen(input: { 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 @@ -60,7 +71,7 @@ export function openThreadInActivePane(input: { routeThreadRef: ScopedThreadRef | null; paneOverride?: ThreadPaneId; navigateToPrimary: () => void | Promise; -}): void { +}): ThreadOpenResult { const state = useThreadSplitStore.getState(); const plan = planThreadOpen({ targetRef: input.targetRef, @@ -71,15 +82,16 @@ export function openThreadInActivePane(input: { ...(input.paneOverride ? { paneOverride: input.paneOverride } : {}), }); switch (plan.kind) { - case "navigate-primary": - void input.navigateToPrimary(); - return; + case "navigate-primary": { + const navigation = input.navigateToPrimary(); + return { plan, completion: navigation instanceof Promise ? navigation : null }; + } case "open-secondary": state.openSecondaryThread(input.targetRef); - return; + return { plan, completion: null }; case "focus-pane": activateThreadPane(plan.paneId); - return; + return { plan, completion: null }; } } diff --git a/apps/web/src/components/thread-split/threadSplitStore.test.ts b/apps/web/src/components/thread-split/threadSplitStore.test.ts index c584567f4353..96e8bfec60fe 100644 --- a/apps/web/src/components/thread-split/threadSplitStore.test.ts +++ b/apps/web/src/components/thread-split/threadSplitStore.test.ts @@ -12,6 +12,7 @@ import { MAX_SPLIT_RATIO, MIN_SPLIT_RATIO, paletteOwnerPane, + PENDING_SWAP_TTL_MS, registerThreadPaneComposer, useThreadSplitStore, } from "./threadSplitStore"; @@ -171,24 +172,58 @@ describe("pane swap latch", () => { expect(useThreadSplitStore.getState().secondaryRef).toEqual(REF_A); }); - it("abort keeps a secondary the user replaced mid-flight", () => { + it("abort yields to a secondary the user replaced mid-flight", () => { openMountedSplit(); useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF); useThreadSplitStore.getState().openSecondaryThread(REF_B); - // The explicit pick already cleared the latch; a late abort is inert. + // 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("closeSplit and openSecondaryThread clear an in-flight latch", () => { + it("closeSplit clears an in-flight latch", () => { openMountedSplit(); useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF); - useThreadSplitStore.getState().openSecondaryThread(REF_B); + 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().pendingSwap).not.toBeNull(); - useThreadSplitStore.getState().closeSplit(); + 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(); }); }); diff --git a/apps/web/src/components/thread-split/threadSplitStore.ts b/apps/web/src/components/thread-split/threadSplitStore.ts index 3448c34964dd..82e1a0ad292a 100644 --- a/apps/web/src/components/thread-split/threadSplitStore.ts +++ b/apps/web/src/components/thread-split/threadSplitStore.ts @@ -83,7 +83,20 @@ export interface PendingPaneSwap { expiresAt: number; } -const PENDING_SWAP_TTL_MS = 5_000; +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; @@ -116,22 +129,25 @@ export const useThreadSplitStore = create((set, get) => ({ 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; - // An explicit pick overrides any in-flight swap: keeping the latch alive - // would let its abort path later clobber the thread the user just chose. if (current && scopedThreadKey(current) === scopedThreadKey(ref)) { - set({ activePaneId: "secondary", pendingSwap: null }); + set({ activePaneId: "secondary" }); return; } - set({ secondaryRef: ref, activePaneId: "secondary", pendingSwap: null }); + set({ secondaryRef: ref, activePaneId: "secondary" }); }, closeSplit: () => { if (get().secondaryRef === null) return; + 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. @@ -147,22 +163,28 @@ export const useThreadSplitStore = create((set, get) => ({ 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: { - expectedRouteKey: scopedThreadKey(target), - startedRouteKey: scopedThreadKey(routeThreadRef), - restoreSecondaryRef: target, - expiresAt: Date.now() + PENDING_SWAP_TTL_MS, - }, - }); + set({ secondaryRef: routeThreadRef, pendingSwap }); + clearPendingSwapExpiry(); + pendingSwapExpiryTimer = setTimeout(() => { + pendingSwapExpiryTimer = null; + if (get().pendingSwap === pendingSwap) { + get().abortPaneSwap(); + } + }, PENDING_SWAP_TTL_MS); return target; }, settlePaneSwap: () => { if (get().pendingSwap === null) return; + clearPendingSwapExpiry(); set({ pendingSwap: null }); }, @@ -170,6 +192,7 @@ export const useThreadSplitStore = create((set, get) => ({ const state = get(); const pending = state.pendingSwap; if (pending === null) 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 ( 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"], From 84ee0eb25d500d149117ec92ab1617a12b8d435e Mon Sep 17 00:00:00 2001 From: pandec Date: Sun, 23 Aug 2026 14:31:06 +0200 Subject: [PATCH 3/3] fix(web): token-scope stale swap continuations and swap-destination picks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification round follow-ups: a swap whose latch expired could still see its navigation settle later and abort, toast over, or steal focus from a newer swap — abortPaneSwap now takes the owning latch and stale continuations no-op. Picking the in-flight swap's destination thread from the sidebar or palette now focuses the primary pane (the route is already heading there) instead of reopening it in the secondary, which folded the split on arrival. --- .../thread-split/swapThreadPanes.ts | 35 +++++++++++++------ .../thread-split/threadOpenTarget.test.ts | 19 ++++++++++ .../thread-split/threadOpenTarget.ts | 10 +++++- .../thread-split/threadSplitStore.test.ts | 18 ++++++++-- .../thread-split/threadSplitStore.ts | 21 +++++++---- 5 files changed, 82 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/thread-split/swapThreadPanes.ts b/apps/web/src/components/thread-split/swapThreadPanes.ts index f891b19f1ba2..568ae10ce81a 100644 --- a/apps/web/src/components/thread-split/swapThreadPanes.ts +++ b/apps/web/src/components/thread-split/swapThreadPanes.ts @@ -31,6 +31,12 @@ export function canSwapThreadPanes(routeThreadRef: ScopedThreadRef | null): bool * 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; @@ -38,21 +44,28 @@ export async function swapThreadPanes(input: { if (input.routeThreadRef === null) { return false; } - const target = useThreadSplitStore.getState().beginPaneSwap(input.routeThreadRef); - if (target === null) { + const begun = useThreadSplitStore.getState().beginPaneSwap(input.routeThreadRef); + if (begun === null) { return false; } + latestSwapToken = begun.pendingSwap; try { - await input.navigateToThread(target); + await input.navigateToThread(begun.target); } catch { - useThreadSplitStore.getState().abortPaneSwap(); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not swap split threads", - description: "Navigation to the other pane's thread failed.", - }), - ); + 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 diff --git a/apps/web/src/components/thread-split/threadOpenTarget.test.ts b/apps/web/src/components/thread-split/threadOpenTarget.test.ts index 6cfc091821d7..a9f0fae834ac 100644 --- a/apps/web/src/components/thread-split/threadOpenTarget.test.ts +++ b/apps/web/src/components/thread-split/threadOpenTarget.test.ts @@ -76,6 +76,25 @@ describe("planThreadOpen", () => { ).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", diff --git a/apps/web/src/components/thread-split/threadOpenTarget.ts b/apps/web/src/components/thread-split/threadOpenTarget.ts index 71e346ba5e37..00719c112ca4 100644 --- a/apps/web/src/components/thread-split/threadOpenTarget.ts +++ b/apps/web/src/components/thread-split/threadOpenTarget.ts @@ -21,7 +21,10 @@ export type ThreadOpenPlan = * * 1. No rendered split → plain primary navigation (also covers a parked * secondaryRef on a too-narrow viewport). - * 2. Already the routed thread → focus the primary pane, never re-navigate. + * 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. @@ -32,6 +35,7 @@ export function planThreadOpen(input: { splitMounted: boolean; activePaneId: ThreadPaneId; secondaryRef: ScopedThreadRef | null; + pendingSwap?: PendingPaneSwap | null; paneOverride?: ThreadPaneId; }): ThreadOpenPlan { if (!input.splitMounted) { @@ -41,6 +45,9 @@ export function planThreadOpen(input: { 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" }; } @@ -79,6 +86,7 @@ export function openThreadInActivePane(input: { splitMounted: state.splitMounted, activePaneId: state.activePaneId, secondaryRef: state.secondaryRef, + pendingSwap: state.pendingSwap, ...(input.paneOverride ? { paneOverride: input.paneOverride } : {}), }); switch (plan.kind) { diff --git a/apps/web/src/components/thread-split/threadSplitStore.test.ts b/apps/web/src/components/thread-split/threadSplitStore.test.ts index 96e8bfec60fe..e49a7ca9aacc 100644 --- a/apps/web/src/components/thread-split/threadSplitStore.test.ts +++ b/apps/web/src/components/thread-split/threadSplitStore.test.ts @@ -138,8 +138,9 @@ describe("pane swap latch", () => { openMountedSplit(); useThreadSplitStore.getState().setActivePane("secondary"); - const target = useThreadSplitStore.getState().beginPaneSwap(ROUTE_REF); - expect(target).toEqual(REF_A); + 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(); @@ -184,6 +185,19 @@ describe("pane swap latch", () => { 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); diff --git a/apps/web/src/components/thread-split/threadSplitStore.ts b/apps/web/src/components/thread-split/threadSplitStore.ts index 82e1a0ad292a..6034b4f4ebe7 100644 --- a/apps/web/src/components/thread-split/threadSplitStore.ts +++ b/apps/web/src/components/thread-split/threadSplitStore.ts @@ -116,9 +116,16 @@ interface ThreadSplitStore { setSplitMounted: (mounted: boolean) => void; setActivePane: (paneId: ThreadPaneId) => void; setSplitRatio: (ratio: number) => void; - beginPaneSwap: (routeThreadRef: ScopedThreadRef) => ScopedThreadRef | null; + beginPaneSwap: ( + routeThreadRef: ScopedThreadRef, + ) => { target: ScopedThreadRef; pendingSwap: PendingPaneSwap } | null; settlePaneSwap: () => void; - abortPaneSwap: () => 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) => ({ @@ -175,11 +182,9 @@ export const useThreadSplitStore = create((set, get) => ({ clearPendingSwapExpiry(); pendingSwapExpiryTimer = setTimeout(() => { pendingSwapExpiryTimer = null; - if (get().pendingSwap === pendingSwap) { - get().abortPaneSwap(); - } + get().abortPaneSwap(pendingSwap); }, PENDING_SWAP_TTL_MS); - return target; + return { target, pendingSwap }; }, settlePaneSwap: () => { @@ -188,10 +193,12 @@ export const useThreadSplitStore = create((set, get) => ({ set({ pendingSwap: null }); }, - abortPaneSwap: () => { + 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.