From a5bff501ef99b4d3d8875f7fd40d2ca375340b2b Mon Sep 17 00:00:00 2001 From: pandec Date: Sat, 22 Aug 2026 21:19:19 +0200 Subject: [PATCH 1/5] feat(web): two-pane thread split view with cross-environment support Adds an optional second thread pane on web/desktop: fork-owned components/thread-split module (pane store, layout, secondary host, controls, palette picker items), a threadPane.focusOther keybinding, active-pane gating for every window-level shortcut listener, pane-scoped terminal ownership, sheet-mode panels while split, and cross-environment keying fixes (palette metadata, editor dispatch, branch-mismatch and script-preference keys, steer-pending leases, basename lookups, drag payload scope, toast filtering). Implemented by Claude Fable 5 in Claude Code; steer-pending refcount by gpt-5.6-sol. --- README.md | 1 + apps/web/src/commandPaletteBus.ts | 2 +- apps/web/src/components/ChatMarkdown.tsx | 14 +- apps/web/src/components/ChatView.logic.ts | 15 +- apps/web/src/components/ChatView.tsx | 115 +++++++- .../components/CommandPalette.logic.test.ts | 49 +++- .../src/components/CommandPalette.logic.ts | 20 +- apps/web/src/components/CommandPalette.tsx | 174 ++++++++++-- apps/web/src/components/DiffPanel.tsx | 10 +- .../src/components/DiffWorkerPoolProvider.tsx | 7 + apps/web/src/components/RightPanelTabs.tsx | 7 +- apps/web/src/components/chat/ChatComposer.tsx | 15 ++ .../chat/ComposerPendingUserInputPanel.tsx | 9 +- .../src/components/chat/ComposerStashMenu.tsx | 8 +- .../components/chat/ExpandedImageDialog.tsx | 8 +- .../components/chat/ModelPickerContent.tsx | 10 +- apps/web/src/components/chat/OpenInPicker.tsx | 8 +- .../components/chat/composerMentionDrag.ts | 18 ++ .../components/chat/composerPickerMenu.tsx | 7 +- .../src/components/files/FileBrowserPanel.tsx | 3 +- .../components/files/fileTreeDragMention.ts | 6 + .../src/components/preview/PreviewView.tsx | 8 +- .../thread-split/ServerThreadPaneHost.tsx | 99 +++++++ .../thread-split/SplitThreadLayout.tsx | 254 ++++++++++++++++++ .../thread-split/ThreadPaneControls.tsx | 84 ++++++ .../thread-split/splitPaletteItems.test.ts | 61 +++++ .../thread-split/splitPaletteItems.ts | 50 ++++ .../thread-split/threadPaneContext.ts | 14 + .../thread-split/threadSplitStore.test.ts | 131 +++++++++ .../thread-split/threadSplitStore.ts | 170 ++++++++++++ apps/web/src/components/ui/toast.tsx | 24 +- apps/web/src/routes/_chat.tsx | 43 ++- apps/web/src/workspaceBasenameLookup.test.ts | 13 + apps/web/src/workspaceBasenameLookup.ts | 15 +- .../src/state/threadSteerPending.test.ts | 40 +-- .../src/state/threadSteerPending.ts | 51 ++-- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 38 files changed, 1429 insertions(+), 136 deletions(-) create mode 100644 apps/web/src/components/thread-split/ServerThreadPaneHost.tsx create mode 100644 apps/web/src/components/thread-split/SplitThreadLayout.tsx create mode 100644 apps/web/src/components/thread-split/ThreadPaneControls.tsx create mode 100644 apps/web/src/components/thread-split/splitPaletteItems.test.ts create mode 100644 apps/web/src/components/thread-split/splitPaletteItems.ts create mode 100644 apps/web/src/components/thread-split/threadPaneContext.ts create mode 100644 apps/web/src/components/thread-split/threadSplitStore.test.ts create mode 100644 apps/web/src/components/thread-split/threadSplitStore.ts diff --git a/README.md b/README.md index d0a9c4e5b222..499458e86f52 100644 --- a/README.md +++ b/README.md @@ -13,6 +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. - **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/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992c..359c665410da 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -3,7 +3,7 @@ const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; export interface CommandPaletteOpenDetail { - readonly open?: "add-project" | "new-thread-in"; + readonly open?: "add-project" | "new-thread-in" | "open-in-split"; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 81f901d7f015..2691b50fddce 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1374,7 +1374,12 @@ function ChatMarkdown({ reportFailure: false, }); const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); - const environmentId = useActiveEnvironmentId(); + const activeEnvironmentId = useActiveEnvironmentId(); + // The thread's own environment must win: the singleton "active" environment + // tracks the routed thread, which is the wrong server for markdown rendered + // in the secondary split pane (file links would open via another machine's + // editor and RPC). + const environmentId = threadRef?.environmentId ?? activeEnvironmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( environmentId, @@ -1478,8 +1483,11 @@ function ChatMarkdown({ (workspaceRelativePath: string, line: number | undefined) => { if (!threadRef) return; // Claimed on every open so a synchronous one supersedes a lookup already - // in flight. - const isLatestLookup = claimWorkspaceBasenameLookup(); + // in flight. Scoped per thread so a click in one split pane cannot + // cancel the other pane's lookup. + const isLatestLookup = claimWorkspaceBasenameLookup( + `${threadRef.environmentId}:${threadRef.threadId}`, + ); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 9ac52c93d952..13520c64d0d1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,7 +1,6 @@ import { type EnvironmentId, isProviderDriverKind, - ProjectId, type ModelSelection, type OrchestrationSessionStatus, type ProviderDriverKind, @@ -24,12 +23,15 @@ import { import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; -export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; +// v2: keyed by scoped `environmentId:projectId`. Bare project ids collide +// across environments (cloned state), so v1 entries were ambiguous and are +// deliberately abandoned rather than migrated. +export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project-v2"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; -export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export const LastInvokedScriptByProjectSchema = Schema.Record(Schema.String, Schema.String); export function shouldDockDraftHeroForSubmission(input: { isDraftHeroState: boolean; @@ -462,13 +464,14 @@ export function buildExpiredTerminalContextToastCopy( } export function branchMismatchKey( - threadId: string | null, + /** Scoped `environmentId:threadId` key β€” bare ids collide across environments. */ + threadKey: string | null, mismatch: { threadBranch: string; currentBranch: string } | null, ): string | null { - if (!threadId || !mismatch) { + if (!threadKey || !mismatch) { return null; } - return `${threadId}:${mismatch.threadBranch}:${mismatch.currentBranch}`; + return `${threadKey}:${mismatch.threadBranch}:${mismatch.currentBranch}`; } // The mismatch banner only matters when the user is about to send: passive diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 42e813a10b20..a64c9441a4dd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -393,6 +393,9 @@ import { 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"; import { previewEnvironment } from "../state/preview"; @@ -1305,6 +1308,15 @@ function ChatViewContent(props: ChatViewProps) { const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); const routeThreadKeyRef = useRef(routeThreadKey); routeThreadKeyRef.current = routeThreadKey; + // Split view (fork): which pane hosts this ChatView, and the other pane's + // thread key while the split is open. Window-level listeners gate on the + // active pane so shortcuts never fire in both panes at once. + const threadPaneId = useThreadPaneId(); + const isSecondaryPane = threadPaneId === "secondary"; + const splitSecondaryThreadKey = useThreadSplitStore((state) => + state.splitMounted && state.secondaryRef !== null ? scopedThreadKey(state.secondaryRef) : null, + ); + const threadSplitActive = splitSecondaryThreadKey !== null; const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, @@ -1478,7 +1490,10 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); + // Split panes are roughly half a window wide, so the inline right panel + // would crush the chat column β€” force the narrow-viewport sheet behavior. + const shouldUseRightPanelSheet = + useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY) || threadSplitActive; const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -1809,8 +1824,23 @@ function ChatViewContent(props: ChatViewProps) { const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); + return openTerminalThreadKeys.filter((nextThreadKey) => { + if (!existingThreadKeys.has(nextThreadKey)) return false; + // With the split open each terminal needs exactly one owner, or two + // ChatViews would mount duplicate surfaces for every open terminal: + // the secondary pane owns only its own thread's terminals and the + // primary pane owns everything else. + if (isSecondaryPane) return nextThreadKey === routeThreadKey; + return splitSecondaryThreadKey === null || nextThreadKey !== splitSecondaryThreadKey; + }); + }, [ + draftThreadKeys, + isSecondaryPane, + openTerminalThreadKeys, + routeThreadKey, + serverThreadKeys, + splitSecondaryThreadKey, + ]); const activeLatestTurn = activeThread?.latestTurn ?? null; // Reading a finished thread clears the sidebar's Done badge. The visit is // stamped at the turn's completion time β€” not now/updatedAt β€” so it clears @@ -1853,8 +1883,21 @@ function ChatViewContent(props: ChatViewProps) { ); const activeProject = useProject(activeProjectRef); const handleNewThreadInActiveProject = useCallback(() => { + // New-thread flows mutate then navigate the router, i.e. the primary + // pane. From the secondary pane that continuation would land in the + // wrong pane, so v1 declines instead of half-working. + if (isSecondaryPane) { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Not available in the split pane", + description: "Open this thread in the main pane to start a new thread from it.", + }), + ); + return; + } startNewThreadForProject(activeProjectRef, handleNewThread); - }, [activeProjectRef, handleNewThread]); + }, [activeProjectRef, handleNewThread, isSecondaryPane]); const activeEnvironmentShell = useEnvironmentQuery( activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); @@ -2813,7 +2856,12 @@ function ChatViewContent(props: ChatViewProps) { }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const primaryAvailableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + // Editors must come from this thread's own environment β€” the primary + // server's list would offer/submit editor ids the pane's server may not + // have (visible with cross-environment split panes). + const paneServerConfig = serverConfigs.get(environmentId); + const availableEditors = paneServerConfig?.availableEditors ?? primaryAvailableEditors; // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather // than the default Codex's. Falls back to first-match-by-kind when no @@ -3176,9 +3224,10 @@ function ChatViewContent(props: ChatViewProps) { ) => { if (!activeThreadId || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { + const scriptPreferenceKey = `${activeProject.environmentId}:${activeProject.id}`; setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; + if (current[scriptPreferenceKey] === script.id) return current; + return { ...current, [scriptPreferenceKey]: script.id }; }); } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; @@ -3818,9 +3867,12 @@ function ChatViewContent(props: ChatViewProps) { useEffect( () => subscribePreviewAction((action) => { + // The bus is untargeted; with the split open only the active pane + // reacts, or one shortcut would toggle previews in both panes. + if (!isThreadPaneActive(threadPaneId)) return; if (action === "toggle-panel") togglePreviewPanel(); }), - [togglePreviewPanel], + [threadPaneId, togglePreviewPanel], ); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -4578,7 +4630,11 @@ function ChatViewContent(props: ChatViewProps) { return composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)); }); const activeBranchMismatchKey = branchMismatchKey( - activeThread?.id ?? null, + // Scoped key: bare thread ids can repeat across environments (cloned + // state), which would share dismissals between unrelated threads. + activeThread + ? scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)) + : null, localCheckoutBranchMismatch, ); const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ @@ -4976,6 +5032,11 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { + // With the split open, only the active pane's ChatView may handle + // window shortcuts β€” both panes install this capture listener. + if (!isThreadPaneActive(threadPaneId)) { + return; + } if (preventRepeatedTerminalCloseShortcut(event, keybindings)) { event.stopPropagation(); return; @@ -5012,9 +5073,21 @@ function ChatViewContent(props: ChatViewProps) { } } - const command = resolveShortcutCommand(event, keybindings, { + let command = resolveShortcutCommand(event, keybindings, { context: shortcutContext, }); + if (!command && paneServerConfig?.keybindings) { + // Project-script chords are written to the thread's own environment, + // so a pane on a non-primary server resolves them from that server's + // config. Only script commands are accepted here β€” app-global + // commands stay bound to the primary server's keybindings. + const paneCommand = resolveShortcutCommand(event, paneServerConfig.keybindings, { + context: shortcutContext, + }); + if (paneCommand && projectScriptIdFromCommand(paneCommand)) { + command = paneCommand; + } + } if (!command) return; if (command === "terminal.toggle") { @@ -5131,6 +5204,8 @@ function ChatViewContent(props: ChatViewProps) { splitTerminal, splitPanelTerminal, keybindings, + paneServerConfig?.keybindings, + threadPaneId, onToggleDiff, toggleRightPanel, toggleRightPanelMaximized, @@ -6666,6 +6741,17 @@ function ChatViewContent(props: ChatViewProps) { ) { return; } + if (isSecondaryPane) { + // Creates a thread and navigates the router (the primary pane). + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Not available in the split pane", + description: "Open this thread in the main pane to implement the plan in a new thread.", + }), + ); + return; + } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx?.providerAvailable) { @@ -6803,6 +6889,7 @@ function ChatViewContent(props: ChatViewProps) { createThread, deleteThread, isConnecting, + isSecondaryPane, isSendBusy, isServerThread, navigate, @@ -7017,6 +7104,7 @@ function ChatViewContent(props: ChatViewProps) { onToggle={toggleRightPanelMaximized} /> ) : null} + {panelToggleControls} ); @@ -7057,6 +7145,7 @@ function ChatViewContent(props: ChatViewProps) { @@ -7173,7 +7262,11 @@ function ChatViewContent(props: ChatViewProps) { openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ - activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null + activeProject + ? (lastInvokedScriptByProjectId[ + `${activeProject.environmentId}:${activeProject.id}` + ] ?? null) + : null } keybindings={keybindings} availableEditors={availableEditors} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 90bb81fc29b1..2b396118f372 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -445,15 +445,15 @@ describe("buildThreadActionItems", () => { updatedAt: "2026-03-20T00:00:00.000Z", }), ], - projectTitleById: new Map([[PROJECT_ID, "Project"]]), + projectTitleByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, "Project"]]), sortOrder: "updated_at", icon: null, runThread: async (_thread) => undefined, }); expect(items.map((item) => item.value)).toEqual([ - "thread:thread-older", - "thread:thread-newer", + "thread:environment-local:thread-older", + "thread:environment-local:thread-newer", ]); expect(items[0]?.timestamp).toBe("1d ago"); expect(items[1]?.timestamp).toBe("5d ago"); @@ -477,7 +477,7 @@ describe("buildThreadActionItems", () => { updatedAt: "2026-03-19T00:00:00.000Z", }), ], - projectTitleById: new Map([[PROJECT_ID, "Project"]]), + projectTitleByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, "Project"]]), sortOrder: "updated_at", icon: null, runThread: async (_thread) => undefined, @@ -494,8 +494,8 @@ describe("buildThreadActionItems", () => { expect(groups).toHaveLength(1); expect(groups[0]?.value).toBe("threads-search"); expect(groups[0]?.items.map((item) => item.value)).toEqual([ - "thread:thread-title-match", - "thread:thread-context-match", + "thread:environment-local:thread-title-match", + "thread:environment-local:thread-context-match", ]); }); @@ -531,7 +531,7 @@ describe("buildThreadActionItems", () => { it("keeps message excerpts searchable without replacing thread metadata", () => { const [item] = buildThreadActionItems({ threads: [makeThread({ branch: "feat/search" })], - projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), + projectTitleByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, "T3 Code"]]), sortOrder: "updated_at", icon: null, getContentMatch: () => ({ @@ -554,7 +554,7 @@ describe("buildThreadActionItems", () => { it("prefers renderDescription when provided", () => { const [item] = buildThreadActionItems({ threads: [makeThread({ branch: "feat/search", worktreePath: "/tmp/wt" })], - projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), + projectTitleByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, "T3 Code"]]), sortOrder: "updated_at", icon: null, renderDescription: (thread, { projectTitle }) => @@ -581,13 +581,42 @@ describe("buildThreadActionItems", () => { updatedAt: "2026-03-20T00:00:00.000Z", }), ], - projectTitleById: new Map([[PROJECT_ID, "Project"]]), + projectTitleByKey: new Map([[`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, "Project"]]), sortOrder: "updated_at", icon: null, runThread: async (_thread) => undefined, }); - expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); + expect(items.map((item) => item.value)).toEqual(["thread:environment-local:thread-active"]); + }); + + it("keeps same-id threads from different environments distinct", () => { + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const items = buildThreadActionItems({ + threads: [ + makeThread({ title: "Local copy", updatedAt: "2026-03-20T00:00:00.000Z" }), + makeThread({ + environmentId: remoteEnvironmentId, + title: "Remote copy", + updatedAt: "2026-03-19T00:00:00.000Z", + }), + ], + activeThreadKey: `${LOCAL_ENVIRONMENT_ID}:thread-1`, + projectTitleByKey: new Map([ + [`${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`, "Local project"], + [`${remoteEnvironmentId}:${PROJECT_ID}`, "Remote project"], + ]), + sortOrder: "updated_at", + icon: null, + runThread: async (_thread) => undefined, + }); + + expect(items.map((item) => item.value)).toEqual([ + "thread:environment-local:thread-1", + "thread:environment-remote:thread-1", + ]); + expect(items[0]?.description).toBe("Local project Β· Current thread"); + expect(items[1]?.description).toBe("Remote project"); }); }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 7a7a04b3cbd5..1d720d94562c 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,7 +15,7 @@ import * as Result from "effect/Result"; import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; import { formatRelativeTimeLabel } from "../timestampFormat"; -import { type Project, type SidebarThreadSummary, type Thread } from "../types"; +import { type Project, type SidebarThreadSummary } from "../types"; export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; @@ -43,7 +43,7 @@ export function browseInputEndPaddingClass(input: { export type SearchOverlayMode = "command" | "files" | "content"; export interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; + readonly kind: "add-project" | "new-thread-in" | "open-in-split"; } export interface CommandPaletteUiState { @@ -57,6 +57,7 @@ export type CommandPaletteUiAction = | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } + | { readonly _tag: "OpenInSplit" } | { readonly _tag: "ClearOpenIntent" }; export function reduceCommandPaletteUiState( @@ -76,6 +77,8 @@ export function reduceCommandPaletteUiState( return { open: true, mode: "command", openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; + case "OpenInSplit": + return { open: true, mode: "command", openIntent: { kind: "open-in-split" } }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; } @@ -438,8 +441,10 @@ export type BuildThreadActionItemsThread = Pick< export function buildThreadActionItems(input: { threads: ReadonlyArray; - activeThreadId?: Thread["id"]; - projectTitleById: ReadonlyMap; + /** Scoped `environmentId:threadId` key β€” bare thread ids collide across environments. */ + activeThreadKey?: string; + /** Keyed by scoped `environmentId:projectId` β€” bare project ids collide across environments. */ + projectTitleByKey: ReadonlyMap; sortOrder: SidebarThreadSortOrder; icon: ReactNode; /** Optional content rendered inline before the title text per-thread. */ @@ -460,7 +465,8 @@ export function buildThreadActionItems { - const projectTitle = input.projectTitleById.get(thread.projectId); + const threadKey = `${thread.environmentId}:${thread.id}`; + const projectTitle = input.projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`); const descriptionParts: string[] = []; if (projectTitle) { @@ -469,7 +475,7 @@ export function buildThreadActionItems dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); + const openInSplit = useCallback(() => dispatch({ _tag: "OpenInSplit" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { theme, themeHalves, resolvedTheme } = useTheme(); @@ -547,11 +555,13 @@ export function CommandPalette({ children }: { children: ReactNode }) { openNewThreadIn(); } else if (detail.open === "add-project") { openAddProject(); + } else if (detail.open === "open-in-split") { + openInSplit(); } else { setOpen(true); } }), - [openAddProject, openNewThreadIn, setOpen], + [openAddProject, openInSplit, openNewThreadIn, setOpen], ); return ( @@ -979,26 +989,49 @@ function OpenCommandPaletteDialog(props: { [environments], ); - const projectCwdById = useMemo( + // Project metadata maps are keyed by scoped `environmentId:projectId` β€” + // bare project ids can collide across environments (state gets cloned + // between machines) and would overwrite each other's cwd/title/favicon. + const projectCwdByKey = useMemo( () => - new Map(projects.map((project) => [project.id, project.workspaceRoot])), + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.workspaceRoot, + ]), + ), [projects], ); - const projectFaviconPathById = useMemo( - () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)), + const projectFaviconPathByKey = useMemo( + () => + new Map( + projects.map( + (project) => + [`${project.environmentId}:${project.id}`, project.faviconPath ?? null] as const, + ), + ), [projects], ); - const projectTitleById = useMemo( - () => new Map(projects.map((project) => [project.id, project.title])), + const projectTitleByKey = useMemo( + () => + new Map( + projects.map((project) => [`${project.environmentId}:${project.id}`, project.title]), + ), [projects], ); - const activeThreadId = activeThread?.id; + const activeThreadKey = activeThread + ? `${activeThread.environmentId}:${activeThread.id}` + : undefined; const currentProjectEnvironmentId = activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null; const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null; - const currentProjectCwd = currentProjectId - ? (projectCwdById.get(currentProjectId) ?? null) + const currentProjectKey = + currentProjectEnvironmentId !== null && currentProjectId !== null + ? `${currentProjectEnvironmentId}:${currentProjectId}` + : null; + const currentProjectCwd = currentProjectKey + ? (projectCwdByKey.get(currentProjectKey) ?? null) : null; const currentProjectCwdForBrowse = browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId @@ -1206,8 +1239,8 @@ function OpenCommandPaletteDialog(props: { () => buildThreadActionItems({ threads, - ...(activeThreadId ? { activeThreadId } : {}), - projectTitleById, + ...(activeThreadKey ? { activeThreadKey } : {}), + projectTitleByKey, sortOrder: clientSettings.sidebarThreadSortOrder, icon: , renderLeadingContent: (thread) => , @@ -1222,12 +1255,16 @@ function OpenCommandPaletteDialog(props: { return ( state.secondaryRef); + const splitSupported = useMediaQuery(THREAD_SPLIT_MEDIA_QUERY); + const openInSplitItems = useMemo( + () => + buildOpenInSplitThreadItems({ + threadItems: allThreadItems, + routeThreadRef, + secondaryRef: splitSecondaryRef, + }), + [allThreadItems, routeThreadRef, splitSecondaryRef], + ); + const pushPaletteView = useCallback( (view: CommandPaletteView): void => { browseNavigation.invalidate(); @@ -1605,12 +1654,48 @@ function OpenCommandPaletteDialog(props: { pushPaletteView, ]); + useLayoutEffect(() => { + if (openIntent?.kind !== "open-in-split") { + return; + } + if (openInSplitItems.length === 0) { + // Routine for one-thread setups (unlike the new-thread-in guard, which + // only trips with zero projects) β€” say so instead of silently opening + // the plain palette. + clearOpenIntent(); + setOpen(false); + toastManager.add( + stackedThreadToast({ + type: "info", + title: "No other threads to show in a split", + description: "Every other thread is already open in a pane or archived.", + }), + ); + return; + } + clearOpenIntent(); + browseNavigation.invalidate(); + setAddProjectCloneFlow(null); + setViewStack([]); + setQuery(""); + pushPaletteView({ + addonIcon: , + groups: [ + { + value: "open-in-split-threads", + label: "Threads", + items: enumerateCommandPaletteItems(openInSplitItems), + }, + ], + }); + }, [browseNavigation, clearOpenIntent, openInSplitItems, openIntent, pushPaletteView, setOpen]); + const actionItems: Array = []; if (projects.length > 0) { const activeProjectTitle = projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? - (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); + (currentProjectKey ? (projectTitleByKey.get(currentProjectKey) ?? null) : null); if (activeProjectTitle) { actionItems.push({ @@ -1794,6 +1879,51 @@ function OpenCommandPaletteDialog(props: { }), ); + if (splitSupported && openInSplitItems.length > 0) { + actionItems.push({ + kind: "submenu", + value: "action:open-in-split", + searchTerms: ["split", "split view", "side by side", "second pane", "open in split"], + title: splitSecondaryRef ? "Switch split thread..." : "Open thread in split view...", + icon: , + addonIcon: , + groups: [ + { + value: "open-in-split-threads", + label: "Threads", + items: enumerateCommandPaletteItems(openInSplitItems), + }, + ], + }); + } + if (splitSupported && splitSecondaryRef !== null) { + actionItems.push({ + kind: "action", + value: "action:focus-other-pane", + searchTerms: ["split", "pane", "focus", "other", "jump", "switch pane"], + title: "Focus other split pane", + icon: , + shortcutCommand: "threadPane.focusOther", + run: async () => { + focusOtherThreadPane(); + }, + }); + } + // 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) { + actionItems.push({ + kind: "action", + value: "action:close-split", + searchTerms: ["split", "close", "pane", "single", "unsplit"], + title: "Close split view", + icon: , + run: async () => { + useThreadSplitStore.getState().closeSplit(); + }, + }); + } + actionItems.push({ kind: "action", value: "action:open-file-picker", @@ -1902,7 +2032,7 @@ function OpenCommandPaletteDialog(props: { }) : null, projectTitle: - currentProjectId !== null ? (projectTitleById.get(currentProjectId) ?? null) : null, + currentProjectKey !== null ? (projectTitleByKey.get(currentProjectKey) ?? null) : null, icon: , openArchived: async (projectFilterKey) => { await navigate({ diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 53de0fdb8dd2..5503a3fc2475 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -88,6 +88,12 @@ const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); interface DiffPanelProps { mode?: DiffPanelMode; + /** + * The thread whose repo this panel diffs. Without it the panel falls back + * to the routed thread, which is wrong for any host that isn't the routed + * pane (e.g. the secondary split pane) β€” hosts should always pass it. + */ + threadRef?: ScopedThreadRef | null; composerDraftTarget: ScopedThreadRef | DraftId; initialGitScope: "branch" | "unstaged"; } @@ -96,6 +102,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline", + threadRef: threadRefProp, composerDraftTarget, initialGitScope: initialGitScopeProp, }: DiffPanelProps) { @@ -118,10 +125,11 @@ export default function DiffPanel({ readonly turnId: TurnId | null; } | null>(null); - const routeThreadRef = useParams({ + const routeParamsThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), }); + const routeThreadRef = threadRefProp ?? routeParamsThreadRef; const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useThread(routeThreadRef); const activeProjectId = activeThread?.projectId ?? null; diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 3ec748c6bcb2..6dcbdcf2cae8 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -46,6 +46,7 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { } export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { + const existingWorkerPool = useWorkerPool(); const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); const workerPoolSize = useMemo(() => { @@ -54,6 +55,12 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { return Math.max(2, Math.min(6, Math.floor(cores / 2))); }, []); + // An ancestor already owns a pool (e.g. the split-view layout hosting two + // ChatViews): reuse it instead of spawning a second worker fleet. + if (existingWorkerPool) { + return children; + } + return ( { shortcutActionsRef.current = availableActions; }); useEffect(() => { const handler = (event: KeyboardEvent) => { + // Split view: only the active pane's launcher may claim letter keys. + if (!isThreadPaneActive(threadPaneId)) return; const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event); if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; @@ -353,7 +358,7 @@ function RightPanelEmptyState(props: { }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, []); + }, [threadPaneId]); const handleKeyDown = (event: ReactKeyboardEvent) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b258e66e745a..c307f19225e0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -88,6 +88,8 @@ import { import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; +import { useThreadPaneId } from "../thread-split/threadPaneContext"; +import { isThreadPaneActive } from "../thread-split/threadSplitStore"; import { resolveShortcutCommand } from "../../keybindings"; import { type TerminalContextDraft, @@ -729,6 +731,7 @@ export interface ChatComposerProps { export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { const providerUsageSettingsHydrated = useClientSettingsHydrated(); + const threadPaneId = useThreadPaneId(); const { composerDraftTarget, environmentId, @@ -3088,6 +3091,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { + // Split view: both panes' composers install this window listener β€” + // only the active pane's composer may claim the stash shortcut. + if (!isThreadPaneActive(threadPaneId)) return; const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: getTerminalFocusOwner() !== null, @@ -3123,6 +3129,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectSelectionRequired, stashCurrentPrompt, terminalOpen, + threadPaneId, ]); // ------------------------------------------------------------------ @@ -3261,6 +3268,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // editor never sees the drop; the load-bearing rules (native stop, "move" // effect, no eager focus) live in makeComposerMentionDragHandlers. const composerMentionDragHandlers = makeComposerMentionDragHandlers({ + mentionScope: environmentId, insertMentionAtEnd: (text) => insertComposerTextAtEnd(text, { ensureLeadingBoundary: true }), setDragActive: setIsDragOverComposer, onInsertRejected: () => { @@ -3270,6 +3278,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) description: "The composer is busy; try again once it is ready.", }); }, + onScopeMismatch: () => { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "That file lives in another environment, so this thread can't reference it.", + }); + }, }); const onComposerMentionDragLeaveCapture = (event: React.DragEvent) => { diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index c121110bb3f0..3aa7ec32603a 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -7,6 +7,8 @@ import { } from "../../pendingUserInput"; import { CheckIcon, ChevronDownIcon } from "lucide-react"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { useThreadPaneId } from "../thread-split/threadPaneContext"; +import { isThreadPaneActive } from "../thread-split/threadSplitStore"; import { cn } from "~/lib/utils"; interface PendingUserInputPanelProps { @@ -132,9 +134,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( // outside editable fields. Multi-select prompts toggle options in place; single- // select prompts keep the existing auto-advance behavior. Collapsed prompts opt // out, since the numbers they refer to are not on screen. + const threadPaneId = useThreadPaneId(); useEffect(() => { if (!activeQuestion || isResponding || isCollapsed) return; const handler = (event: globalThis.KeyboardEvent) => { + // Split view: both panes can show a pending question at once, and this + // is a document-level listener β€” only the active pane may answer, or + // one keypress would answer (approve!) both panes' prompts. + if (!isThreadPaneActive(threadPaneId)) return; if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { @@ -157,7 +164,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, handleOptionSelection, isCollapsed, isResponding]); + }, [activeQuestion, handleOptionSelection, isCollapsed, isResponding, threadPaneId]); if (!activeQuestion) { return null; diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index fc12831025b7..63e99384febe 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -6,6 +6,8 @@ import { cn } from "~/lib/utils"; import { type PromptStashEntry } from "../../promptStashStore"; import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; import { Button } from "../ui/button"; +import { useThreadPaneId } from "../thread-split/threadPaneContext"; +import { isThreadPaneActive } from "../thread-split/threadSplitStore"; const SNIPPET_MAX_CHARS = 90; @@ -36,6 +38,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { onClose: () => void; }) { const { entries, onRestore, onDelete, onClose } = props; + const threadPaneId = useThreadPaneId(); const drawerRef = useRef(null); const [highlightedId, setHighlightedId] = useState(entries[0]?.id ?? null); @@ -66,6 +69,9 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { useEffect(() => { const handler = (event: KeyboardEvent) => { + // Split view: gate the window listener to the active pane so two open + // stash menus can't both consume navigation keys. + if (!isThreadPaneActive(threadPaneId)) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); @@ -104,7 +110,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore]); + }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore, threadPaneId]); return ( diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index fd14c68b0c4d..d27bdc9f6967 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -2,6 +2,8 @@ import { memo, useCallback, useEffect, useState } from "react"; import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; import { Button } from "../ui/button"; import type { ExpandedImagePreview } from "./ExpandedImagePreview"; +import { useThreadPaneId } from "../thread-split/threadPaneContext"; +import { isThreadPaneActive } from "../thread-split/threadSplitStore"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; @@ -12,6 +14,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ preview, onClose, }: ExpandedImageDialogProps) { + const threadPaneId = useThreadPaneId(); const [imageOffset, setImageOffset] = useState(0); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; @@ -21,6 +24,9 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { + // Split view: two dialogs can be open (one per pane) β€” only the + // active pane's dialog owns Escape and the arrow keys. + if (!isThreadPaneActive(threadPaneId)) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); @@ -41,7 +47,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [navigateImage, onClose, preview.images.length]); + }, [navigateImage, onClose, preview.images.length, threadPaneId]); const item = preview.images[index]; if (!item) return null; diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index ee5a26a29b66..c543cb100e0e 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -33,6 +33,8 @@ import { } from "../../keybindings"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; +import { useThreadPaneId } from "../thread-split/threadPaneContext"; +import { isThreadPaneActive } from "../thread-split/threadSplitStore"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { TooltipProvider } from "../ui/tooltip"; import { @@ -97,6 +99,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { getModelDisabledReason, onInstanceModelChange, } = props; + const threadPaneId = useThreadPaneId(); const [searchQuery, setSearchQuery] = useState(""); const [showTopScrollFade, setShowTopScrollFade] = useState(false); const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); @@ -550,6 +553,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (event.defaultPrevented || event.repeat) { return; } + // Split view: only the active pane's open picker may consume model + // jump shortcuts. + if (!isThreadPaneActive(threadPaneId)) { + return; + } const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, @@ -578,7 +586,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return () => { window.removeEventListener("keydown", onWindowKeyDown, true); }; - }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); + }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext, threadPaneId]); useLayoutEffect(() => { setShowTopScrollFade(false); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index afe35e185203..e04e3fd9e338 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -46,6 +46,8 @@ import { import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; +import { useThreadPaneId } from "../thread-split/threadPaneContext"; +import { isThreadPaneActive } from "../thread-split/threadSplitStore"; type OpenInOption = { label: string; @@ -273,9 +275,13 @@ export const OpenInPicker = memo(function OpenInPicker({ [keybindings], ); + const threadPaneId = useThreadPaneId(); useEffect(() => { if (!enableShortcut) return; const handler = (e: globalThis.KeyboardEvent) => { + // Split view: both panes' headers mount this picker β€” only the active + // pane's shortcut may open its cwd (possibly on another machine). + if (!isThreadPaneActive(threadPaneId)) return; if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!openInCwd) return; if (!preferredEditor) return; @@ -285,7 +291,7 @@ export const OpenInPicker = memo(function OpenInPicker({ }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); + }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor, threadPaneId]); return ( diff --git a/apps/web/src/components/chat/composerMentionDrag.ts b/apps/web/src/components/chat/composerMentionDrag.ts index 43b1bfd800dc..abbca36375bd 100644 --- a/apps/web/src/components/chat/composerMentionDrag.ts +++ b/apps/web/src/components/chat/composerMentionDrag.ts @@ -7,6 +7,16 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; */ export const COMPOSER_MENTION_DRAG_TYPE = "application/x-t3code-composer-mention"; +/** + * Environment id the dragged paths belong to. Mentions are workspace-relative + * text, so a drop into another environment's composer (split view across + * machines) would silently mention a file that machine doesn't have. + * Environment-only on purpose: worktree-aware cwds differ between the tree + * and the composer within one thread, so a cwd-level scope would false-reject + * ordinary in-pane drops. + */ +export const COMPOSER_MENTION_DRAG_SCOPE_TYPE = "application/x-t3code-composer-mention-scope"; + export function composerMentionFromTreePath(treePath: string): string | null { const relativePath = treePath.replace(/\/+$/, ""); if (relativePath.length === 0) { @@ -40,9 +50,12 @@ export interface ComposerMentionDragEvent { * the next frame, after the editor has caught up. */ export interface ComposerMentionDropHost { + /** Environment the composer targets; drops tagged with another one are declined. */ + mentionScope?: string | null; insertMentionAtEnd(text: string): boolean; setDragActive(active: boolean): void; onInsertRejected(): void; + onScopeMismatch?(): void; } export interface ComposerMentionDragHandlers { @@ -90,6 +103,11 @@ export function makeComposerMentionDragHandlers( if (mention.length === 0) { return; } + const dragScope = event.dataTransfer.getData(COMPOSER_MENTION_DRAG_SCOPE_TYPE); + if (host.mentionScope && dragScope.length > 0 && dragScope !== host.mentionScope) { + host.onScopeMismatch?.(); + return; + } if (!host.insertMentionAtEnd(`${mention} `)) { host.onInsertRejected(); } diff --git a/apps/web/src/components/chat/composerPickerMenu.tsx b/apps/web/src/components/chat/composerPickerMenu.tsx index 7ecd7e9bb257..a9d86498e678 100644 --- a/apps/web/src/components/chat/composerPickerMenu.tsx +++ b/apps/web/src/components/chat/composerPickerMenu.tsx @@ -10,6 +10,8 @@ import { } from "react"; import { Command, CommandGroup, CommandGroupLabel, CommandList } from "../ui/command"; +import { useThreadPaneId } from "../thread-split/threadPaneContext"; +import { isThreadPaneActive } from "../thread-split/threadSplitStore"; /** Filter-query state for a composer picker; feed the filtered entries into `useComposerPickerKeyboard` below. */ @@ -41,6 +43,7 @@ export function useComposerPickerKeyboard(inp readonly menuRef: RefObject; } { const { entries, onPick, onClose, setQuery } = input; + const threadPaneId = useThreadPaneId(); const [highlightedId, setHighlightedId] = useState(null); const inputRef = useRef(null); const menuRef = useRef(null); @@ -76,6 +79,8 @@ export function useComposerPickerKeyboard(inp useEffect(() => { const handler = (event: KeyboardEvent) => { + // Split view: only the active pane's picker owns the window keys. + if (!isThreadPaneActive(threadPaneId)) return; // During IME composition the keys belong to the IME, not the picker. if (event.isComposing || event.keyCode === 229) return; if (event.key === "Escape") { @@ -125,7 +130,7 @@ export function useComposerPickerKeyboard(inp }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [entries, highlightedEntry, highlightedId, onClose, onPick, setQuery]); + }, [entries, highlightedEntry, highlightedId, onClose, onPick, setQuery, threadPaneId]); return { highlightedEntry, highlightedId, setHighlightedId, inputRef, menuRef }; } diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index c62f3f4e0943..7493589429c0 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -206,8 +206,9 @@ export default function FileBrowserPanel({ () => createFileTreeDragMentionController({ deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(), + mentionScope: environmentId, }), - [], + [environmentId], ); const { model } = useFileTree({ composition: { diff --git a/apps/web/src/components/files/fileTreeDragMention.ts b/apps/web/src/components/files/fileTreeDragMention.ts index 7c17639a649d..97b15b674bb1 100644 --- a/apps/web/src/components/files/fileTreeDragMention.ts +++ b/apps/web/src/components/files/fileTreeDragMention.ts @@ -1,4 +1,5 @@ import { + COMPOSER_MENTION_DRAG_SCOPE_TYPE, COMPOSER_MENTION_DRAG_TYPE, composerMentionFromTreePath, } from "~/components/chat/composerMentionDrag"; @@ -15,6 +16,8 @@ export interface FileTreeDragStartEvent { export interface FileTreeDragMentionHost { /** Drop the tree's gesture-applied selection of the dragged row. */ deselect(treePath: string): void; + /** Environment the dragged paths live in; stamped on the drag payload. */ + mentionScope?: string; } export interface FileTreeDragMentionController { @@ -81,6 +84,9 @@ export function createFileTreeDragMentionController( } draggedPaths = dragged; event.dataTransfer.setData(COMPOSER_MENTION_DRAG_TYPE, mentions.join(" ")); + if (host.mentionScope) { + event.dataTransfer.setData(COMPOSER_MENTION_DRAG_SCOPE_TYPE, host.mentionScope); + } }, handleDragEnd() { if (draggedPaths.length === 0) { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 9a812923717a..978eab7f17df 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -35,6 +35,8 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { previewBridge } from "./previewBridge"; import { subscribePreviewAction } from "./previewActionBus"; +import { useThreadPaneId } from "../thread-split/threadPaneContext"; +import { isThreadPaneActive } from "../thread-split/threadSplitStore"; import { openPreviewSession } from "./openPreviewSession"; import { PreviewChromeRow } from "./PreviewChromeRow"; import { PreviewEmptyState } from "./PreviewEmptyState"; @@ -626,9 +628,13 @@ export function PreviewView({ // Subscribe only while visible; `toggle-panel` is owned by ChatView's // URL-aware handler regardless of whether the panel is currently mounted. + const threadPaneId = useThreadPaneId(); useEffect(() => { if (!visible) return; return subscribePreviewAction((action) => { + // Split view: both panes can show a visible preview β€” the bus is + // untargeted, so only the active pane's preview reacts. + if (!isThreadPaneActive(threadPaneId)) return; switch (action) { case "refresh": handleRefresh(); @@ -649,7 +655,7 @@ export function PreviewView({ return; } }); - }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]); + }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, threadPaneId, visible]); return (
({ threadPaneKey: null, started: false }); + useEffect(() => { + setDetailLoad((current) => { + if (current.threadPaneKey !== threadPaneKey) { + return { threadPaneKey, started: serverThreadStatus !== "empty" }; + } + if (!current.started && serverThreadStatus !== "empty") { + return { ...current, started: true }; + } + return current; + }); + }, [serverThreadStatus, threadPaneKey]); + const detailLoadStarted = detailLoad.threadPaneKey === threadPaneKey && detailLoad.started; + const bootstrapComplete = shell.data?.snapshot._tag === "Some"; + const renderState = resolveThreadRouteRenderState({ + bootstrapComplete, + serverThreadDetailExists: serverThreadDetail !== null, + serverThreadDetailUnavailable: + serverThreadStatus === "deleted" || (detailLoadStarted && serverThreadStatus === "empty"), + draftThreadExists: false, + }); + const threadSyncPhase = resolveThreadSyncPhase({ + detailExists: serverThreadDetail !== null, + shellExists: serverThreadShell !== null, + status: serverThreadStatus, + }); + + if (renderState === "missing") { + return ; + } + + return ( +
+ {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? ( + + ) : null} +
+ ); +} + +function ThreadPaneUnavailable() { + const closeSplit = useThreadSplitStore((state) => state.closeSplit); + return ( +
+
Thread unavailable
+
+ This thread was deleted or its environment is gone. +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/thread-split/SplitThreadLayout.tsx b/apps/web/src/components/thread-split/SplitThreadLayout.tsx new file mode 100644 index 000000000000..85c939f0b8fd --- /dev/null +++ b/apps/web/src/components/thread-split/SplitThreadLayout.tsx @@ -0,0 +1,254 @@ +import { useParams } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, type ReactNode } from "react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { ComposerHandleContext, useComposerHandleContext } from "../../composerHandleContext"; +import type { ChatComposerHandle } from "../chat/ChatComposer"; +import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { resolveThreadRouteRef } from "../../threadRoutes"; +import { ServerThreadPaneHost } from "./ServerThreadPaneHost"; +import { ThreadPaneContext } from "./threadPaneContext"; +import { + clampSplitRatio, + noteThreadPaneFocus, + registerThreadPaneComposer, + registerThreadPaneRoot, + THREAD_SPLIT_MEDIA_QUERY, + useThreadSplitStore, + type ThreadPaneId, +} from "./threadSplitStore"; + +// The 20rem pane floor yields to a percentage in narrow containers (the grid +// sits beside a user-resizable sidebar), so the tracks can never overflow the +// clipped grid root and push the secondary pane off-screen. +const SPLIT_GRID_TEMPLATE_COLUMNS = + "minmax(min(20rem, 45%), var(--thread-split-a)) auto minmax(min(20rem, 45%), var(--thread-split-b))"; +const SINGLE_GRID_TEMPLATE_COLUMNS = "minmax(0, 1fr)"; + +/** + * Wraps the chat routes' content. The tree shape is identical whether the + * split is open or closed β€” only the separator and the secondary pane mount + * and unmount β€” so toggling the split never remounts the routed ChatView + * (which would reload previews, terminals, and scroll positions). The shared + * DiffWorkerPoolProvider up here also means both panes reuse one worker pool + * instead of spawning one per ChatView. + */ +export function SplitThreadLayout({ children }: { children: ReactNode }) { + const secondaryRef = useThreadSplitStore((state) => state.secondaryRef); + const closeSplit = useThreadSplitStore((state) => state.closeSplit); + const isWideEnoughForSplit = useMediaQuery(THREAD_SPLIT_MEDIA_QUERY); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + + // The primary composer handle is the app-root one (the secondary pane gets + // its own provider below, so the root ref only ever belongs to the primary). + const primaryComposerRef = useComposerHandleContext(); + useEffect(() => { + registerThreadPaneComposer("primary", primaryComposerRef); + return () => registerThreadPaneComposer("primary", null); + }, [primaryComposerRef]); + + // No duplicate thread across panes: navigating the primary pane onto the + // secondary's thread means it "moved" there, so the split closes. + useEffect(() => { + if ( + secondaryRef !== null && + routeThreadRef !== null && + scopedThreadKey(routeThreadRef) === scopedThreadKey(secondaryRef) + ) { + closeSplit(); + } + }, [closeSplit, routeThreadRef, secondaryRef]); + + const splitOpen = secondaryRef !== null && isWideEnoughForSplit; + + return ( + + {children} + + ); +} + +function SplitThreadPanes({ + children, + secondaryRef, +}: { + children: ReactNode; + secondaryRef: ScopedThreadRef | null; +}) { + const splitRatio = useThreadSplitStore((state) => state.splitRatio); + const setSplitMounted = useThreadSplitStore((state) => state.setSplitMounted); + const containerRef = useRef(null); + const splitOpen = secondaryRef !== null; + + // splitMounted is the store's single source of truth for "two panes are on + // screen"; every active-pane gate reads it, so it must track the rendered + // state exactly β€” including this layout unmounting on a non-chat route. + useEffect(() => { + setSplitMounted(splitOpen); + return () => setSplitMounted(false); + }, [setSplitMounted, splitOpen]); + + return ( +
+ + {children} + + {splitOpen ? : null} + {secondaryRef !== null ? ( + + + + + + + + ) : null} +
+ ); +} + +function SecondaryPaneComposerScope({ children }: { children: ReactNode }) { + // A fresh handle ref shields the secondary composer from the app-root + // ComposerHandleContext, whose single ref would otherwise be claimed by + // whichever ChatComposer mounted last. + const composerRef = useRef(null); + useEffect(() => { + registerThreadPaneComposer("secondary", composerRef); + return () => registerThreadPaneComposer("secondary", null); + }, []); + return {children}; +} + +function ThreadPaneSection({ + children, + paneId, + showActiveIndicator, +}: { + children: ReactNode; + paneId: ThreadPaneId; + showActiveIndicator: boolean; +}) { + const isActive = useThreadSplitStore((state) => state.activePaneId === paneId); + const setActivePane = useThreadSplitStore((state) => state.setActivePane); + + const attachRoot = useCallback( + (element: HTMLElement | null) => { + registerThreadPaneRoot(paneId, element); + }, + [paneId], + ); + + return ( +
setActivePane(paneId)} + onFocusCapture={(event) => { + setActivePane(paneId); + noteThreadPaneFocus(paneId, event.target); + }} + > + {children} + {showActiveIndicator && isActive ? ( +
+ ) : null} +
+ ); +} + +function SplitResizeHandle({ + containerRef, +}: { + containerRef: React.RefObject; +}) { + const setSplitRatio = useThreadSplitStore((state) => state.setSplitRatio); + const dragStateRef = useRef<{ pointerId: number; ratio: number } | null>(null); + const frameRef = useRef(null); + + const applyRatio = useCallback( + (ratio: number) => { + const container = containerRef.current; + if (!container) return; + container.style.setProperty("--thread-split-a", `${ratio}fr`); + container.style.setProperty("--thread-split-b", `${1 - ratio}fr`); + }, + [containerRef], + ); + + return ( +
{ + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + event.currentTarget.dataset.dragging = "true"; + dragStateRef.current = { + pointerId: event.pointerId, + ratio: useThreadSplitStore.getState().splitRatio, + }; + }} + onPointerMove={(event) => { + const dragState = dragStateRef.current; + const container = containerRef.current; + if (!dragState || dragState.pointerId !== event.pointerId || !container) return; + const bounds = container.getBoundingClientRect(); + if (bounds.width <= 0) return; + dragState.ratio = clampSplitRatio((event.clientX - bounds.left) / bounds.width); + 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={() => { + dragStateRef.current = null; + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + // Snap back to the committed ratio. + applyRatio(useThreadSplitStore.getState().splitRatio); + }} + /> + ); +} diff --git a/apps/web/src/components/thread-split/ThreadPaneControls.tsx b/apps/web/src/components/thread-split/ThreadPaneControls.tsx new file mode 100644 index 000000000000..3620b5541e5a --- /dev/null +++ b/apps/web/src/components/thread-split/ThreadPaneControls.tsx @@ -0,0 +1,84 @@ +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 new file mode 100644 index 000000000000..030ab73f5bd7 --- /dev/null +++ b/apps/web/src/components/thread-split/splitPaletteItems.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import type { CommandPaletteActionItem } from "../CommandPalette.logic"; +import { buildOpenInSplitThreadItems } from "./splitPaletteItems"; +import { useThreadSplitStore } from "./threadSplitStore"; + +const threadRef = (environmentId: string, threadId: string) => ({ + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), +}); + +function threadItem(value: string): CommandPaletteActionItem { + return { + kind: "action", + value, + searchTerms: [value], + title: value, + icon: null, + run: async () => undefined, + }; +} + +beforeEach(() => { + useThreadSplitStore.setState({ + secondaryRef: null, + splitMounted: false, + activePaneId: "primary", + splitRatio: 0.5, + }); +}); + +describe("buildOpenInSplitThreadItems", () => { + it("rebinds thread items to open in the secondary pane", async () => { + const items = buildOpenInSplitThreadItems({ + threadItems: [threadItem("thread:env-a:thread-1"), threadItem("action:new-thread")], + routeThreadRef: null, + secondaryRef: null, + }); + + expect(items.map((item) => item.value)).toEqual(["open-in-split:env-a:thread-1"]); + await items[0]?.run(); + expect(useThreadSplitStore.getState().secondaryRef).toEqual(threadRef("env-a", "thread-1")); + expect(useThreadSplitStore.getState().activePaneId).toBe("secondary"); + }); + + it("excludes the routed thread and the current secondary thread", () => { + const items = buildOpenInSplitThreadItems({ + threadItems: [ + threadItem("thread:env-a:thread-1"), + threadItem("thread:env-a:thread-2"), + threadItem("thread:env-b:thread-1"), + ], + routeThreadRef: threadRef("env-a", "thread-1"), + secondaryRef: threadRef("env-a", "thread-2"), + }); + + // Same bare thread id in another environment stays offered. + expect(items.map((item) => item.value)).toEqual(["open-in-split:env-b:thread-1"]); + }); +}); diff --git a/apps/web/src/components/thread-split/splitPaletteItems.ts b/apps/web/src/components/thread-split/splitPaletteItems.ts new file mode 100644 index 000000000000..2df4248aeb30 --- /dev/null +++ b/apps/web/src/components/thread-split/splitPaletteItems.ts @@ -0,0 +1,50 @@ +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import type { CommandPaletteActionItem } from "../CommandPalette.logic"; +import { useThreadSplitStore } from "./threadSplitStore"; + +const THREAD_ITEM_VALUE_PREFIX = "thread:"; + +/** + * Derives "open in split" picker items from the palette's existing thread + * items (same titles, descriptions, icons, and search behavior) by rebinding + * each item's action to the secondary pane. Relies on thread item values + * carrying the scoped thread key (`thread::`). + */ +export function buildOpenInSplitThreadItems(input: { + threadItems: ReadonlyArray; + routeThreadRef: ScopedThreadRef | null; + secondaryRef: ScopedThreadRef | null; +}): CommandPaletteActionItem[] { + const excludedThreadKeys = new Set(); + if (input.routeThreadRef) { + excludedThreadKeys.add(scopedThreadKey(input.routeThreadRef)); + } + if (input.secondaryRef) { + excludedThreadKeys.add(scopedThreadKey(input.secondaryRef)); + } + + return input.threadItems.flatMap((item) => { + if (!item.value.startsWith(THREAD_ITEM_VALUE_PREFIX)) { + return []; + } + const threadKey = item.value.slice(THREAD_ITEM_VALUE_PREFIX.length); + if (excludedThreadKeys.has(threadKey)) { + return []; + } + const threadRef = parseScopedThreadKey(threadKey); + if (threadRef === null) { + return []; + } + return [ + { + ...item, + value: `open-in-split:${threadKey}`, + run: async () => { + useThreadSplitStore.getState().openSecondaryThread(threadRef); + }, + }, + ]; + }); +} diff --git a/apps/web/src/components/thread-split/threadPaneContext.ts b/apps/web/src/components/thread-split/threadPaneContext.ts new file mode 100644 index 000000000000..5c4ae3d153cd --- /dev/null +++ b/apps/web/src/components/thread-split/threadPaneContext.ts @@ -0,0 +1,14 @@ +import { createContext, use } from "react"; + +import type { ThreadPaneId } from "./threadSplitStore"; + +/** + * Which split pane the current subtree renders in. Everything defaults to + * "primary" so the whole app behaves as before unless a subtree is explicitly + * mounted as the secondary pane by SplitThreadLayout. + */ +export const ThreadPaneContext = createContext("primary"); + +export function useThreadPaneId(): ThreadPaneId { + return use(ThreadPaneContext); +} diff --git a/apps/web/src/components/thread-split/threadSplitStore.test.ts b/apps/web/src/components/thread-split/threadSplitStore.test.ts new file mode 100644 index 000000000000..cb3de482726b --- /dev/null +++ b/apps/web/src/components/thread-split/threadSplitStore.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import type { ComposerHandleRef } from "../../composerHandleContext"; + +import { + clampSplitRatio, + focusOtherThreadPane, + isThreadPaneActive, + MAX_SPLIT_RATIO, + MIN_SPLIT_RATIO, + registerThreadPaneComposer, + useThreadSplitStore, +} from "./threadSplitStore"; + +const threadRef = (environmentId: string, threadId: string) => ({ + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), +}); + +const REF_A = threadRef("env-a", "thread-1"); +const REF_B = threadRef("env-b", "thread-1"); + +// SplitThreadLayout owns splitMounted via an effect; tests simulate it. +function mountSplit() { + useThreadSplitStore.getState().setSplitMounted(true); +} + +beforeEach(() => { + useThreadSplitStore.setState({ + secondaryRef: null, + splitMounted: false, + activePaneId: "primary", + splitRatio: 0.5, + }); + registerThreadPaneComposer("primary", null); + registerThreadPaneComposer("secondary", null); +}); + +describe("useThreadSplitStore", () => { + it("opens the secondary pane focused and closes back to primary", () => { + useThreadSplitStore.getState().openSecondaryThread(REF_A); + expect(useThreadSplitStore.getState().secondaryRef).toEqual(REF_A); + expect(useThreadSplitStore.getState().activePaneId).toBe("secondary"); + + useThreadSplitStore.getState().closeSplit(); + expect(useThreadSplitStore.getState().secondaryRef).toBeNull(); + expect(useThreadSplitStore.getState().activePaneId).toBe("primary"); + }); + + it("treats same thread id in another environment as a different thread", () => { + useThreadSplitStore.getState().openSecondaryThread(REF_A); + useThreadSplitStore.getState().openSecondaryThread(REF_B); + expect(useThreadSplitStore.getState().secondaryRef).toEqual(REF_B); + }); + + it("re-picking the current secondary thread only refocuses the pane", () => { + useThreadSplitStore.getState().openSecondaryThread(REF_A); + useThreadSplitStore.getState().setActivePane("primary"); + useThreadSplitStore.getState().openSecondaryThread(REF_A); + expect(useThreadSplitStore.getState().secondaryRef).toEqual(REF_A); + expect(useThreadSplitStore.getState().activePaneId).toBe("secondary"); + }); +}); + +describe("isThreadPaneActive", () => { + it("treats every pane as active while the split is closed", () => { + expect(isThreadPaneActive("primary")).toBe(true); + expect(isThreadPaneActive("secondary")).toBe(true); + }); + + it("treats every pane as active while a picked split is not rendered", () => { + // A secondary thread stays picked when the viewport narrows or a + // non-chat route unmounts the layout; the single visible pane must keep + // owning every shortcut or the app goes keyboard-dead. + useThreadSplitStore.getState().openSecondaryThread(REF_A); + expect(useThreadSplitStore.getState().activePaneId).toBe("secondary"); + expect(isThreadPaneActive("primary")).toBe(true); + expect(isThreadPaneActive("secondary")).toBe(true); + }); + + it("grants ownership only to the active pane while the split is rendered", () => { + useThreadSplitStore.getState().openSecondaryThread(REF_A); + mountSplit(); + expect(isThreadPaneActive("secondary")).toBe(true); + expect(isThreadPaneActive("primary")).toBe(false); + + useThreadSplitStore.getState().setActivePane("primary"); + expect(isThreadPaneActive("primary")).toBe(true); + expect(isThreadPaneActive("secondary")).toBe(false); + }); +}); + +describe("focusOtherThreadPane", () => { + it("is a no-op while the split is closed", () => { + expect(focusOtherThreadPane()).toBe(false); + expect(useThreadSplitStore.getState().activePaneId).toBe("primary"); + }); + + it("is a no-op while a picked split is not rendered", () => { + useThreadSplitStore.getState().openSecondaryThread(REF_A); + expect(focusOtherThreadPane()).toBe(false); + expect(useThreadSplitStore.getState().activePaneId).toBe("secondary"); + }); + + it("toggles the active pane and focuses its composer handle", () => { + const focused: string[] = []; + const primaryComposerRef = { + current: { focusAtEnd: () => focused.push("primary") }, + } as unknown as ComposerHandleRef; + registerThreadPaneComposer("primary", primaryComposerRef); + useThreadSplitStore.getState().openSecondaryThread(REF_A); + mountSplit(); + + expect(focusOtherThreadPane()).toBe(true); + expect(useThreadSplitStore.getState().activePaneId).toBe("primary"); + expect(focused).toEqual(["primary"]); + + expect(focusOtherThreadPane()).toBe(true); + expect(useThreadSplitStore.getState().activePaneId).toBe("secondary"); + }); +}); + +describe("clampSplitRatio", () => { + it("clamps into the allowed pane range and rejects junk", () => { + expect(clampSplitRatio(0.1)).toBe(MIN_SPLIT_RATIO); + expect(clampSplitRatio(0.9)).toBe(MAX_SPLIT_RATIO); + expect(clampSplitRatio(0.6)).toBe(0.6); + expect(clampSplitRatio(Number.NaN)).toBe(0.5); + }); +}); diff --git a/apps/web/src/components/thread-split/threadSplitStore.ts b/apps/web/src/components/thread-split/threadSplitStore.ts new file mode 100644 index 000000000000..cc399921a3a5 --- /dev/null +++ b/apps/web/src/components/thread-split/threadSplitStore.ts @@ -0,0 +1,170 @@ +/** + * Session-local state for the two-pane thread split view (fork feature). + * + * The primary pane is always the routed thread β€” the URL never learns about + * the split. The secondary pane shows one server thread picked by the user + * and lives only for the session: no persistence, no route, no drafts. + */ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { create } from "zustand"; + +import type { ComposerHandleRef } from "../../composerHandleContext"; + +export type ThreadPaneId = "primary" | "secondary"; + +/** Split is only offered when both panes get a workable column. */ +export const THREAD_SPLIT_MEDIA_QUERY = "(min-width: 64rem)"; + +export const MIN_SPLIT_RATIO = 0.25; +export const MAX_SPLIT_RATIO = 0.75; +const DEFAULT_SPLIT_RATIO = 0.5; +const SPLIT_RATIO_STORAGE_KEY = "t3code:thread-split-ratio"; + +export function clampSplitRatio(ratio: number): number { + if (!Number.isFinite(ratio)) { + return DEFAULT_SPLIT_RATIO; + } + return Math.min(MAX_SPLIT_RATIO, Math.max(MIN_SPLIT_RATIO, ratio)); +} + +function readStoredSplitRatio(): number { + if (typeof window === "undefined") { + return DEFAULT_SPLIT_RATIO; + } + const raw = window.localStorage.getItem(SPLIT_RATIO_STORAGE_KEY); + if (raw === null) { + return DEFAULT_SPLIT_RATIO; + } + return clampSplitRatio(Number(raw)); +} + +interface ThreadSplitStore { + secondaryRef: ScopedThreadRef | null; + /** + * True only while SplitThreadLayout actually renders two panes. This is + * stricter than `secondaryRef !== null`: a picked secondary thread survives + * a too-narrow viewport or a visit to a non-chat route, but during that + * time the app must behave as a single pane or every active-pane gate + * would go dead in the only visible view. + */ + splitMounted: boolean; + activePaneId: ThreadPaneId; + splitRatio: number; + openSecondaryThread: (ref: ScopedThreadRef) => void; + closeSplit: () => void; + setSplitMounted: (mounted: boolean) => void; + setActivePane: (paneId: ThreadPaneId) => void; + setSplitRatio: (ratio: number) => void; +} + +export const useThreadSplitStore = create((set, get) => ({ + secondaryRef: null, + splitMounted: false, + activePaneId: "primary", + splitRatio: readStoredSplitRatio(), + + openSecondaryThread: (ref) => { + const current = get().secondaryRef; + if (current && scopedThreadKey(current) === scopedThreadKey(ref)) { + set({ activePaneId: "secondary" }); + return; + } + set({ secondaryRef: ref, activePaneId: "secondary" }); + }, + + closeSplit: () => { + if (get().secondaryRef === null) return; + set({ secondaryRef: null, activePaneId: "primary" }); + }, + + setSplitMounted: (mounted) => { + if (get().splitMounted === mounted) return; + set({ splitMounted: mounted }); + }, + + setActivePane: (paneId) => { + if (get().activePaneId === paneId) return; + set({ activePaneId: paneId }); + }, + + setSplitRatio: (ratio) => { + const next = clampSplitRatio(ratio); + if (get().splitRatio === next) return; + set({ splitRatio: next }); + if (typeof window !== "undefined") { + window.localStorage.setItem(SPLIT_RATIO_STORAGE_KEY, String(next)); + } + }, +})); + +/** + * Event-time ownership check for window-level listeners that exist once per + * mounted ChatView. While the split is not actually rendered every pane is + * the owner; while it is, only the active pane may act, so a shortcut can + * never fire in both panes at once. + */ +export function isThreadPaneActive(paneId: ThreadPaneId): boolean { + const state = useThreadSplitStore.getState(); + return !state.splitMounted || state.activePaneId === paneId; +} + +// Focus plumbing lives outside the zustand state: DOM nodes and imperative +// composer handles are not render state, and focus moves must read them at +// event time without subscribing anyone. +interface ThreadPaneRuntime { + root: HTMLElement | null; + lastFocused: HTMLElement | null; + composer: ComposerHandleRef | null; +} + +const paneRuntimes: Record = { + primary: { root: null, lastFocused: null, composer: null }, + secondary: { root: null, lastFocused: null, composer: null }, +}; + +export function registerThreadPaneRoot(paneId: ThreadPaneId, root: HTMLElement | null): void { + paneRuntimes[paneId].root = root; + if (root === null) { + paneRuntimes[paneId].lastFocused = null; + } +} + +export function registerThreadPaneComposer( + paneId: ThreadPaneId, + composer: ComposerHandleRef | null, +): void { + paneRuntimes[paneId].composer = composer; +} + +export function noteThreadPaneFocus(paneId: ThreadPaneId, element: EventTarget | null): void { + if (element instanceof HTMLElement) { + paneRuntimes[paneId].lastFocused = element; + } +} + +function focusThreadPane(paneId: ThreadPaneId): void { + const runtime = paneRuntimes[paneId]; + const lastFocused = runtime.lastFocused; + if (lastFocused && lastFocused.isConnected) { + lastFocused.focus(); + return; + } + if (runtime.composer?.current) { + runtime.composer.current.focusAtEnd(); + return; + } + runtime.root?.focus(); +} + +/** Jump focus to the other pane. Returns false while the split is not rendered. */ +export function focusOtherThreadPane(): boolean { + const state = useThreadSplitStore.getState(); + if (!state.splitMounted || state.secondaryRef === null) { + return false; + } + const nextPaneId: ThreadPaneId = state.activePaneId === "primary" ? "secondary" : "primary"; + state.setActivePane(nextPaneId); + focusThreadPane(nextPaneId); + return true; +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 69fd0ebf3664..0697e7f332b5 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -28,6 +28,7 @@ import { import { cn } from "~/lib/utils"; import { Button, buttonVariants } from "~/components/ui/button"; import { useComposerDraftStore } from "~/composerDraftStore"; +import { useThreadSplitStore } from "~/components/thread-split/threadSplitStore"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { resolveThreadRouteTarget } from "~/threadRoutes"; import { @@ -540,9 +541,18 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv function Toasts({ position }: { position: ToastPosition }) { const { toasts } = Toast.useToastManager(); const activeThreadRef = useActiveThreadRefFromRoute(); + // Split view: while two panes are rendered, the secondary pane's thread is + // on screen too, so its thread-scoped toasts must not be filtered away by + // the route check. + const secondaryThreadRef = useThreadSplitStore((state) => + state.splitMounted ? state.secondaryRef : null, + ); const isTop = position.startsWith("top"); - const visibleToasts = toasts.filter((toast) => - shouldRenderThreadScopedToast(toast.data, activeThreadRef), + const visibleToasts = toasts.filter( + (toast) => + shouldRenderThreadScopedToast(toast.data, activeThreadRef) || + (secondaryThreadRef !== null && + shouldRenderThreadScopedToast(toast.data, secondaryThreadRef)), ); const visibleToastLayout = buildVisibleToastLayout(visibleToasts); @@ -713,12 +723,20 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) { function AnchoredToasts() { const { toasts } = Toast.useToastManager(); const activeThreadRef = useActiveThreadRefFromRoute(); + const secondaryThreadRef = useThreadSplitStore((state) => + state.splitMounted ? state.secondaryRef : null, + ); return ( {toasts - .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef)) + .filter( + (toast) => + shouldRenderThreadScopedToast(toast.data, activeThreadRef) || + (secondaryThreadRef !== null && + shouldRenderThreadScopedToast(toast.data, secondaryThreadRef)), + ) .map((toast) => { const tooltipStyle = toast.data?.tooltipStyle ?? false; const positionerProps = toast.positionerProps; diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index f321587d7df5..5783d947c713 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -25,6 +25,11 @@ import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { SplitThreadLayout } from "~/components/thread-split/SplitThreadLayout"; +import { + focusOtherThreadPane, + useThreadSplitStore, +} from "~/components/thread-split/threadSplitStore"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { threadActionUndoHistory, @@ -92,17 +97,25 @@ function ChatRouteGlobalShortcuts() { }).length, [primaryEnvironmentId, projectGroupingSettings, projects], ); + // Split view: thread-targeted shortcuts and their `when` context follow the + // active pane, so mod+shift+E can never archive the thread the user is not + // looking at while the secondary pane holds focus. + const secondaryActiveThreadRef = useThreadSplitStore((state) => + state.splitMounted && state.activePaneId === "secondary" ? state.secondaryRef : null, + ); + const shortcutThreadRef = secondaryActiveThreadRef ?? routeThreadRef; const terminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + shortcutThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, shortcutThreadRef) + .terminalOpen : false, ); // The `previewOpen` shortcut-context flag here uses the store-only value; // the URL-aware arbitration lives inside ChatView's `onTogglePreview`, // which we invoke via the action bus to avoid duplicating the rule. const previewOpen = useRightPanelStore((state) => - routeThreadRef - ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" + shortcutThreadRef + ? selectActiveRightPanel(state.byThreadKey, shortcutThreadRef) === "preview" : false, ); useEffect(() => { @@ -228,20 +241,28 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "threadPane.focusOther") { + if (focusOtherThreadPane()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + if (command === "thread.archive") { - if (!routeThreadRef) return; + if (!shortcutThreadRef) return; if (hasOpenArchiveUndoBlockingLayer()) return; event.preventDefault(); event.stopPropagation(); - if (readThreadShell(routeThreadRef)?.archivedAt !== null) return; - void attemptArchiveThread(routeThreadRef); + if (readThreadShell(shortcutThreadRef)?.archivedAt !== null) return; + void attemptArchiveThread(shortcutThreadRef); return; } if (command === "preview.toggle") { event.preventDefault(); event.stopPropagation(); - if (!routeThreadRef) return; + if (!shortcutThreadRef) return; if (!isPreviewSupportedInRuntime()) { toastManager.add( stackedThreadToast({ @@ -296,7 +317,7 @@ function ChatRouteGlobalShortcuts() { defaultProjectRef, previewOpen, projectGroupCount, - routeThreadRef, + shortcutThreadRef, selectedThreadKeysSize, legacySidebarEnabled, terminalOpen, @@ -312,7 +333,9 @@ function ChatRouteLayout() { return ( <> - + + + ); } diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index b4a1376e79c3..cba54b25f344 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -89,4 +89,17 @@ describe("claimWorkspaceBasenameLookup", () => { const only = claimWorkspaceBasenameLookup(); expect(only()).toBe(true); }); + + it("scopes claims so one pane's click cannot cancel another pane's lookup", () => { + const paneA = claimWorkspaceBasenameLookup("env-a:thread-1"); + const paneB = claimWorkspaceBasenameLookup("env-b:thread-2"); + + expect(paneA()).toBe(true); + expect(paneB()).toBe(true); + + const paneANewer = claimWorkspaceBasenameLookup("env-a:thread-1"); + expect(paneA()).toBe(false); + expect(paneANewer()).toBe(true); + expect(paneB()).toBe(true); + }); }); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index b99d3ba4ded9..700408f9dedb 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -2,15 +2,16 @@ // asking for a full listing on a single click. export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25; -// One counter for every caller: they all open the same panel, so the newest -// click wins regardless of which one started the lookup. -let latestLookupSequence = 0; +// One counter per scope (scoped thread key): within a pane the newest click +// wins, but a click in one split pane must not cancel the other pane's +// in-flight lookup β€” they open different panels. +const latestLookupSequenceByScope = new Map(); /** Call the returned predicate when the search settles; false means a later click superseded it. */ -export function claimWorkspaceBasenameLookup(): () => boolean { - latestLookupSequence += 1; - const claimed = latestLookupSequence; - return () => claimed === latestLookupSequence; +export function claimWorkspaceBasenameLookup(scopeKey = ""): () => boolean { + const claimed = (latestLookupSequenceByScope.get(scopeKey) ?? 0) + 1; + latestLookupSequenceByScope.set(scopeKey, claimed); + return () => claimed === latestLookupSequenceByScope.get(scopeKey); } export interface WorkspaceEntryCandidate { diff --git a/packages/client-runtime/src/state/threadSteerPending.test.ts b/packages/client-runtime/src/state/threadSteerPending.test.ts index c5c1208d789d..30c110244596 100644 --- a/packages/client-runtime/src/state/threadSteerPending.test.ts +++ b/packages/client-runtime/src/state/threadSteerPending.test.ts @@ -287,43 +287,51 @@ describe("createThreadSteerPendingStore", () => { expect(read()).toStrictEqual({}); }); - it("keeps only the active thread and rejects late deliveries after release", () => { + it("retains different threads independently", () => { const { store, read } = makeStore(); store.retain("env:left"); + store.retain("env:right"); store.track("env:left", pendingSteer); + store.track("env:right", pendingSteer); + expect(read()).toStrictEqual({ + "env:left": [pendingSteer], + "env:right": [pendingSteer], + }); - store.retain("env:open"); - store.track("env:left", pendingSteer); - store.track("env:open", pendingSteer); - expect(Object.keys(read())).toStrictEqual(["env:open"]); + store.release("env:left"); - store.release("env:open"); - store.track("env:open", pendingSteer); - expect(read()).toStrictEqual({}); + expect(read()).toStrictEqual({ "env:right": [pendingSteer] }); + store.track("env:left", pendingSteer); + expect(read()).toStrictEqual({ "env:right": [pendingSteer] }); }); - it("holds the lease until the last view of a thread releases it", () => { + it("holds a thread's markers until its last view releases it", () => { const { store, read } = makeStore(); // Mobile's files route stacks a second view of the same thread over the // first; closing it must not stop the still-mounted one from marking. store.retain("env:thread"); store.retain("env:thread"); - store.release("env:thread"); store.track("env:thread", pendingSteer); + + store.release("env:thread"); expect(read()["env:thread"]).toStrictEqual([pendingSteer]); store.release("env:thread"); + expect(read()).toStrictEqual({}); store.track("env:thread", pendingSteer); expect(read()).toStrictEqual({}); }); - it("ignores a release from a view left behind by an earlier thread", () => { + it("purges a single view's markers on release", () => { const { store, read } = makeStore(); - store.retain("env:left"); - store.retain("env:open"); - store.release("env:left"); - store.track("env:open", pendingSteer); - expect(read()["env:open"]).toStrictEqual([pendingSteer]); + store.retain("env:thread"); + store.track("env:thread", pendingSteer); + + store.release("env:thread"); + + expect(read()).toStrictEqual({}); + store.track("env:thread", pendingSteer); + expect(read()).toStrictEqual({}); }); it("caps the active thread at eight pending steers", () => { diff --git a/packages/client-runtime/src/state/threadSteerPending.ts b/packages/client-runtime/src/state/threadSteerPending.ts index dd9da76b7e0d..38d198628eb6 100644 --- a/packages/client-runtime/src/state/threadSteerPending.ts +++ b/packages/client-runtime/src/state/threadSteerPending.ts @@ -247,14 +247,12 @@ export function createThreadSteerPendingStore(options: ThreadSteerPendingStoreOp const pendingByThreadKeyAtom = Atom.make>>( {}, ).pipe(Atom.keepAlive, Atom.withLabel(options.atomLabel ?? "thread-steer-pending")); - let retainedThreadKey: string | null = null; /** - * How many mounted views are showing `retainedThreadKey`. A thread can be on + * Mounted views lease their own thread independently. A thread can also be on * screen more than once β€” mobile's files route stacks a second thread view - * over the first β€” and the one that unmounts first must not revoke the lease - * the one still on screen depends on. + * over the first β€” so each key keeps a reference count. */ - let retainedViewCount = 0; + const retainedViewCountByThreadKey = new Map(); const read = (): Record> => options.registry.get(pendingByThreadKeyAtom); @@ -262,7 +260,7 @@ export function createThreadSteerPendingStore(options: ThreadSteerPendingStoreOp const track = (threadKey: string, dispatch: PendingSteerDispatch): void => { // The outbox drains every thread. A delivery that finishes after navigation // must not recreate state for a thread whose marker can no longer be shown. - if (threadKey !== retainedThreadKey) { + if (!retainedViewCountByThreadKey.has(threadKey)) { return; } const current = read(); @@ -291,44 +289,37 @@ export function createThreadSteerPendingStore(options: ThreadSteerPendingStoreOp }; /** - * Leases the store to the thread on screen and forgets every other one. A - * marker only means anything while its thread is open, and this keeps a steer - * dispatched into a thread the user has since left from lingering in memory. - * Every call must be paired with a `release` for the same key. + * Leases the store to a thread on screen. Every call must be paired with a + * `release` for the same key. */ const retain = (threadKey: string | null): void => { - if (threadKey !== null && threadKey === retainedThreadKey) { - retainedViewCount += 1; + if (threadKey === null) { return; } - retainedThreadKey = threadKey; - retainedViewCount = threadKey === null ? 0 : 1; - const current = read(); - const keys = Object.keys(current); - if (keys.length === 0 || (keys.length === 1 && keys[0] === threadKey)) { - return; - } - const retained = threadKey === null ? undefined : current[threadKey]; - options.registry.set( - pendingByThreadKeyAtom, - retained === undefined ? {} : { [threadKey as string]: retained }, + retainedViewCountByThreadKey.set( + threadKey, + (retainedViewCountByThreadKey.get(threadKey) ?? 0) + 1, ); }; /** - * Stops accepting late deliveries for a thread once its last view unmounts. - * Releasing a key the store is not leased to is a no-op, so a view left - * behind by an earlier navigation cannot revoke the current lease. + * Stops accepting late deliveries and forgets pending markers once the last + * view of this thread unmounts. Releasing an unleased key is a no-op. */ const release = (threadKey: string | null): void => { - if (threadKey === null || threadKey !== retainedThreadKey) { + if (threadKey === null) { + return; + } + const retainedViewCount = retainedViewCountByThreadKey.get(threadKey); + if (retainedViewCount === undefined) { return; } - retainedViewCount -= 1; - if (retainedViewCount > 0) { + if (retainedViewCount > 1) { + retainedViewCountByThreadKey.set(threadKey, retainedViewCount - 1); return; } - retain(null); + retainedViewCountByThreadKey.delete(threadKey); + setThread(threadKey, []); }; return { pendingByThreadKeyAtom, track, setThread, retain, release }; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c830a775bc8f..639d95d9cdcc 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -72,6 +72,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "chat.new", "chat.newLocal", "editor.openFavorite", + "threadPane.focusOther", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, ] as const; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 26a631bf97c2..421daad6fe95 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -44,6 +44,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { 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+e", command: "thread.archive", when: "!terminalFocus" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, From 92ca7be17e4f04040d5de56fe6543d0e0a31652a Mon Sep 17 00:00:00 2001 From: pandec Date: Sat, 22 Aug 2026 22:06:44 +0200 Subject: [PATCH 2/5] fix(web): harden split view from review findings Focus now follows pointer activation across panes, so a preventDefault click can no longer leave terminal shortcuts acting on the wrong pane's terminal, and terminal ownership swaps in the same commit the secondary thread is picked or dropped. Dropped the hoisted diff worker pool: the pool is a module singleton, and mounting it at the route level spawned workers on the landing screen. The resize handle caches its bounds at pointerdown instead of forcing layout per move, and the split sheet no longer blurs the other pane. A disconnected or deleted environment gets a pane-local notice instead of a blank pane, the open-in-split picker waits for thread shells to hydrate, a lone image dialog keeps Escape from the inactive pane, and editors come only from the pane's own server. Review by gpt-5.6-sol and Claude Opus 5; fixes by Claude Fable 5 in Claude Code. --- apps/web/src/components/ChatView.tsx | 34 ++++++++++---- apps/web/src/components/CommandPalette.tsx | 22 +++++++-- .../src/components/DiffWorkerPoolProvider.tsx | 7 --- apps/web/src/components/RightPanelSheet.tsx | 2 + .../components/chat/ExpandedImageDialog.tsx | 13 +++++- .../thread-split/ServerThreadPaneHost.tsx | 39 ++++++++++++---- .../thread-split/SplitThreadLayout.tsx | 46 +++++++++++++------ .../thread-split/threadSplitStore.ts | 22 ++++++++- apps/web/src/components/ui/sheet.tsx | 4 +- 9 files changed, 142 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a64c9441a4dd..77a7348032e8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -282,7 +282,6 @@ import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { - primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, primaryServerSettingsAtom, serverEnvironment, @@ -428,6 +427,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_THREAD_MESSAGES: ReadonlyArray = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_PANE_AVAILABLE_EDITORS: never[] = []; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); @@ -1317,6 +1317,13 @@ function ChatViewContent(props: ChatViewProps) { state.splitMounted && state.secondaryRef !== null ? scopedThreadKey(state.secondaryRef) : null, ); const threadSplitActive = splitSecondaryThreadKey !== null; + // Deliberately NOT mounted-gated like the key above: terminal ownership + // must swap in the same commit the secondary thread is picked or dropped, + // or both panes would attach the same terminal for a frame. While a picked + // split is not rendered this only costs a prewarmed hidden surface. + const splitReservedTerminalThreadKey = useThreadSplitStore((state) => + state.secondaryRef !== null ? scopedThreadKey(state.secondaryRef) : null, + ); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, @@ -1831,7 +1838,9 @@ function ChatViewContent(props: ChatViewProps) { // the secondary pane owns only its own thread's terminals and the // primary pane owns everything else. if (isSecondaryPane) return nextThreadKey === routeThreadKey; - return splitSecondaryThreadKey === null || nextThreadKey !== splitSecondaryThreadKey; + return ( + splitReservedTerminalThreadKey === null || nextThreadKey !== splitReservedTerminalThreadKey + ); }); }, [ draftThreadKeys, @@ -1839,7 +1848,7 @@ function ChatViewContent(props: ChatViewProps) { openTerminalThreadKeys, routeThreadKey, serverThreadKeys, - splitSecondaryThreadKey, + splitReservedTerminalThreadKey, ]); const activeLatestTurn = activeThread?.latestTurn ?? null; // Reading a finished thread clears the sidebar's Done badge. The visit is @@ -2856,12 +2865,12 @@ function ChatViewContent(props: ChatViewProps) { }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const primaryAvailableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - // Editors must come from this thread's own environment β€” the primary - // server's list would offer/submit editor ids the pane's server may not - // have (visible with cross-environment split panes). + // Editors must come from this thread's own environment β€” another server's + // list would offer/submit editor ids this pane's server may not have + // (visible with cross-environment split panes). While the config is still + // unresolved, offering none beats offering a wrong list. const paneServerConfig = serverConfigs.get(environmentId); - const availableEditors = paneServerConfig?.availableEditors ?? primaryAvailableEditors; + const availableEditors = paneServerConfig?.availableEditors ?? EMPTY_PANE_AVAILABLE_EDITORS; // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather // than the default Codex's. Falls back to first-match-by-kind when no @@ -7701,7 +7710,14 @@ function ChatViewContent(props: ChatViewProps) { ) : null} {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( - + = []; diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 6dcbdcf2cae8..3ec748c6bcb2 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -46,7 +46,6 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { } export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { - const existingWorkerPool = useWorkerPool(); const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); const workerPoolSize = useMemo(() => { @@ -55,12 +54,6 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { return Math.max(2, Math.min(6, Math.floor(cores / 2))); }, []); - // An ancestor already owns a pool (e.g. the split-view layout hosting two - // ChatViews): reuse it instead of spawning a second worker fleet. - if (existingWorkerPool) { - return children; - } - return ( void; + backdropClassName?: string | undefined; }) { return ( {props.children} diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index d27bdc9f6967..2a9f042e7647 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -25,8 +25,16 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { // Split view: two dialogs can be open (one per pane) β€” only the - // active pane's dialog owns Escape and the arrow keys. - if (!isThreadPaneActive(threadPaneId)) return; + // active pane's dialog owns Escape and the arrow keys then. A lone + // dialog keeps them even from the inactive pane: it covers the whole + // window, so gating it off would strand a keyboard-undismissable modal + // (reachable via mod+\ while the dialog is open). + if ( + !isThreadPaneActive(threadPaneId) && + document.querySelectorAll("[data-expanded-image-dialog]").length > 1 + ) { + return; + } if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); @@ -58,6 +66,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ role="dialog" aria-modal="true" aria-label="Expanded image preview" + data-expanded-image-dialog >