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