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..da0b0bdb0509 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, @@ -393,6 +392,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"; @@ -425,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); @@ -1305,6 +1308,22 @@ 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; + // 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, @@ -1478,7 +1497,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 +1831,25 @@ 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 ( + splitReservedTerminalThreadKey === null || nextThreadKey !== splitReservedTerminalThreadKey + ); + }); + }, [ + draftThreadKeys, + isSecondaryPane, + openTerminalThreadKeys, + routeThreadKey, + serverThreadKeys, + splitReservedTerminalThreadKey, + ]); 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 +1892,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 +2865,12 @@ function ChatViewContent(props: ChatViewProps) { }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + // 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 ?? 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 @@ -3176,9 +3233,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 +3876,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: { @@ -4278,21 +4339,33 @@ function ChatViewContent(props: ChatViewProps) { showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); // activeThreadRef resets transitively with the active thread. - }, [activeThread?.id]); + // Scoped key: cloned state can repeat a bare thread id across + // environments, and per-thread view state must still reset then. + }, [activeThreadKey]); useEffect(() => { setIsRevertingCheckpoint(false); - }, [activeThread?.id]); + }, [activeThreadKey]); useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { + // Split view: both panes mount fresh when the chat layout remounts + // (e.g. returning from Settings) โ€” only the active pane's autofocus + // may run, or whichever pane's frame fires last steals ownership. + if (!isThreadPaneActive(threadPaneId)) return; focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]); + }, [ + activeThread?.id, + activeThreadKey, + focusComposer, + terminalUiState.terminalOpen, + threadPaneId, + ]); useEffect(() => { if (!activeThread?.id) return; @@ -4578,7 +4651,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({ @@ -4899,7 +4976,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPendingServerThreadEnvMode(null); setPendingServerThreadBranch(undefined); - }, [activeThread?.id]); + }, [activeThreadKey]); useEffect(() => { if (canOverrideServerThreadEnvMode) { @@ -4976,6 +5053,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 +5094,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 +5225,8 @@ function ChatViewContent(props: ChatViewProps) { splitTerminal, splitPanelTerminal, keybindings, + paneServerConfig?.keybindings, + threadPaneId, onToggleDiff, toggleRightPanel, toggleRightPanelMaximized, @@ -6666,6 +6762,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 +6910,7 @@ function ChatViewContent(props: ChatViewProps) { createThread, deleteThread, isConnecting, + isSecondaryPane, isSendBusy, isServerThread, navigate, @@ -7017,6 +7125,7 @@ function ChatViewContent(props: ChatViewProps) { onToggle={toggleRightPanelMaximized} /> ) : null} + {panelToggleControls} ); @@ -7057,6 +7166,7 @@ function ChatViewContent(props: ChatViewProps) { @@ -7116,6 +7226,7 @@ function ChatViewContent(props: ChatViewProps) { environmentId={activeProject.environmentId} cwd={activeWorkspaceRoot} projectName={activeProject.title} + mentionScope={`${activeProject.environmentId}:${activeProject.id}`} threadRef={activeThreadRef} composerDraftTarget={composerDraftTarget} keybindings={keybindings} @@ -7173,7 +7284,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} @@ -7366,6 +7481,17 @@ function ChatViewContent(props: ChatViewProps) { composerRef={composerRef} composerDraftTarget={composerDraftTarget} environmentId={environmentId} + mentionScope={ + // The thread itself carries the project id even + // while the project entity is still loading โ€” + // degrading to env-only there would briefly + // accept cross-project drops. + activeThread + ? `${activeThread.environmentId}:${activeThread.projectId}` + : activeProject + ? `${activeProject.environmentId}:${activeProject.id}` + : environmentId + } routeKind={routeKind} routeThreadRef={routeThreadRef} draftId={draftId} @@ -7608,7 +7734,14 @@ function ChatViewContent(props: ChatViewProps) { ) : null} {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( - + { 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 +557,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 ( @@ -603,7 +615,12 @@ function CommandPaletteDialog(props: { data-palette-mode={props.mode} data-testid="command-palette" finalFocus={() => { - composerHandleRef?.current?.focusAtEnd(); + // The app-root composer belongs to the primary pane; while the split + // is open with the secondary pane active (including a pick that just + // activated it), focus must return there instead. + if (!focusActiveThreadPane()) { + composerHandleRef?.current?.focusAtEnd(); + } return false; }} onBackdropPointerDown={() => { @@ -979,26 +996,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 +1246,8 @@ function OpenCommandPaletteDialog(props: { () => buildThreadActionItems({ threads, - ...(activeThreadId ? { activeThreadId } : {}), - projectTitleById, + ...(activeThreadKey ? { activeThreadKey } : {}), + projectTitleByKey, sortOrder: clientSettings.sidebarThreadSortOrder, icon: , renderLeadingContent: (thread) => , @@ -1222,12 +1262,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 +1661,62 @@ function OpenCommandPaletteDialog(props: { pushPaletteView, ]); + useLayoutEffect(() => { + if (openIntent?.kind !== "open-in-split") { + return; + } + if (allThreadItems.length === 0) { + // Thread shells may still be hydrating (cold load, environments still + // connecting) โ€” hold the intent like the new-thread-in guard above and + // re-run when the list lands. + return; + } + if (openInSplitItems.length === 0) { + // Threads exist but every one is already on screen โ€” routine for + // one-thread setups, so 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), + }, + ], + }); + }, [ + allThreadItems.length, + 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 +1900,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 +2053,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({ @@ -2369,8 +2520,12 @@ function OpenCommandPaletteDialog(props: { const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const isSavedPromptsView = currentView?.groups[0]?.value === SAVED_PROMPTS_GROUP_VALUE; // Ref read at render: fine for a footer hint โ€” the composer mounts long - // before the palette opens, and re-opening re-renders this component. - const canInsertSavedPrompt = isSavedPromptsView && composerHandleRef?.current != null; + // before the palette opens, and re-opening re-renders this component. The + // active split pane's composer takes precedence over the app-root one โ€” + // same resolution as the insert below, so the hint never advertises an + // insert the action would refuse (composer-less secondary notice state). + const canInsertSavedPrompt = + isSavedPromptsView && (activeThreadPaneComposerHandle() ?? composerHandleRef)?.current != null; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; const canSubmitBrowsePath = isBrowsing && @@ -2480,7 +2635,7 @@ function OpenCommandPaletteDialog(props: { const prompt = savedPrompts.find( (candidate) => savedPromptItemValue(candidate) === highlightedItem?.value, ); - const composer = composerHandleRef?.current; + const composer = (activeThreadPaneComposerHandle() ?? composerHandleRef)?.current; // Close only on a successful insert โ€” a busy composer (approval, // pending input) refuses it, and silently dismissing the palette would // make that failure look like success. 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/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index ebc4aa0a698f..c545898dff2a 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -7,6 +7,7 @@ export function RightPanelSheet(props: { children: ReactNode; open: boolean; onClose: () => void; + backdropClassName?: string | undefined; }) { return ( {props.children} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5cc421db3542..9007d78f200a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -41,6 +41,8 @@ import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanel import { FaviconImage } from "./preview/PreviewFaviconIcon"; import { previewBridge } from "./preview/previewBridge"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { useThreadPaneId } from "./thread-split/threadPaneContext"; +import { isThreadPaneActive } from "./thread-split/threadSplitStore"; interface RightPanelTabsProps { mode: PreviewPanelMode; @@ -337,11 +339,14 @@ function RightPanelEmptyState(props: { // Capture phase so app-level key handlers cannot swallow the event first; // typing contexts and already-handled events are left alone. const shortcutActionsRef = useRef(availableActions); + const threadPaneId = useThreadPaneId(); useEffect(() => { 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..548a67a3b08a 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, @@ -614,6 +616,8 @@ export interface ChatComposerSendOptions { export interface ChatComposerProps { composerDraftTarget: ScopedThreadRef | DraftId; environmentId: EnvironmentId; + /** Drag-mention scope (`env` or `env:projectId`); defaults to the environment alone. */ + mentionScope?: string | undefined; routeKind: "server" | "draft"; routeThreadRef: ScopedThreadRef; draftId: DraftId | null; @@ -729,9 +733,11 @@ export interface ChatComposerProps { export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { const providerUsageSettingsHydrated = useClientSettingsHydrated(); + const threadPaneId = useThreadPaneId(); const { composerDraftTarget, environmentId, + mentionScope, routeKind, routeThreadRef, draftId, @@ -3088,6 +3094,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 +3132,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectSelectionRequired, stashCurrentPrompt, terminalOpen, + threadPaneId, ]); // ------------------------------------------------------------------ @@ -3261,6 +3271,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: mentionScope ?? environmentId, insertMentionAtEnd: (text) => insertComposerTextAtEnd(text, { ensureLeadingBoundary: true }), setDragActive: setIsDragOverComposer, onInsertRejected: () => { @@ -3270,6 +3281,14 @@ 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 project or 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..2a9f042e7647 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,17 @@ 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 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(); @@ -41,7 +55,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; @@ -52,6 +66,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ role="dialog" aria-modal="true" aria-label="Expanded image preview" + data-expanded-image-dialog > + + + + ); +} 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..1f613e21e131 --- /dev/null +++ b/apps/web/src/components/thread-split/SplitThreadLayout.tsx @@ -0,0 +1,299 @@ +import { useParams } from "@tanstack/react-router"; +import { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { ComposerHandleContext, useComposerHandleContext } from "../../composerHandleContext"; +import type { ChatComposerHandle } from "../chat/ChatComposer"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { resolveThreadRouteRef } from "../../threadRoutes"; +import { ServerThreadPaneHost } from "./ServerThreadPaneHost"; +import { ThreadPaneContext } from "./threadPaneContext"; +import { + cancelPendingThreadPaneFocus, + clampSplitRatio, + noteThreadPaneFocus, + reclaimThreadPaneFocus, + redirectStrayThreadPaneFocus, + 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)"; + +// Module-level so route changes made while the chat layout is unmounted +// (e.g. picking another thread from Settings) still register as a change on +// remount โ€” a component ref would re-initialize and miss them. +const UNOBSERVED_ROUTE_THREAD_KEY = Symbol("unobserved"); +const lastSeenRouteThreadKey: { value: string | null | typeof UNOBSERVED_ROUTE_THREAD_KEY } = { + value: UNOBSERVED_ROUTE_THREAD_KEY, +}; + +/** + * 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 + * instance. (Its right panel still remounts on toggle: the split forces the + * sheet presentation, which lives in a different tree position than the + * inline one.) No diff worker pool is hoisted here on purpose: the pool is a + * module singleton, so both panes' ChatView-level providers already share it. + */ +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]); + + // Sidebar and palette navigation happen outside both pane roots, yet they + // retarget the primary pane โ€” attention (and shortcut ownership, e.g. + // mod+shift+E archive) must follow, or the secondary pane would keep + // swallowing thread-targeted shortcuts after the user switched threads. + const routeThreadKey = routeThreadRef === null ? null : scopedThreadKey(routeThreadRef); + useEffect(() => { + if (lastSeenRouteThreadKey.value === routeThreadKey) return; + const isFirstObservation = lastSeenRouteThreadKey.value === UNOBSERVED_ROUTE_THREAD_KEY; + lastSeenRouteThreadKey.value = routeThreadKey; + if (isFirstObservation) return; + useThreadSplitStore.getState().setActivePane("primary"); + }, [routeThreadKey]); + + 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. + // Layout effect on purpose: a passive effect would leave a stale-true + // window after the secondary pane is removed from the tree, during which + // the only visible pane still rejects shortcuts. + useLayoutEffect(() => { + 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 ( +
{ + // A real click is user intent โ€” it beats any queued pane-focus grant. + cancelPendingThreadPaneFocus(); + setActivePane(paneId); + reclaimThreadPaneFocus(paneId); + }} + onFocusCapture={(event) => { + if (redirectStrayThreadPaneFocus(paneId)) return; + 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; + // Captured once at pointerdown: reading getBoundingClientRect per + // pointermove would force a synchronous layout of both panes on every + // move, defeating the rAF write coalescing below. + boundsLeft: number; + boundsWidth: 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; + const container = containerRef.current; + if (!container) return; + const bounds = container.getBoundingClientRect(); + if (bounds.width <= 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + event.currentTarget.dataset.dragging = "true"; + dragStateRef.current = { + pointerId: event.pointerId, + ratio: useThreadSplitStore.getState().splitRatio, + boundsLeft: bounds.left, + boundsWidth: bounds.width, + }; + }} + onPointerMove={(event) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + dragState.ratio = clampSplitRatio( + (event.clientX - dragState.boundsLeft) / dragState.boundsWidth, + ); + if (frameRef.current === null) { + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + const current = dragStateRef.current; + if (current) applyRatio(current.ratio); + }); + } + }} + onPointerUp={(event) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + dragStateRef.current = null; + delete event.currentTarget.dataset.dragging; + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + setSplitRatio(dragState.ratio); + }} + onPointerCancel={(event) => { + dragStateRef.current = null; + delete event.currentTarget.dataset.dragging; + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + // 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..4881f269b1cc --- /dev/null +++ b/apps/web/src/components/thread-split/threadSplitStore.ts @@ -0,0 +1,266 @@ +/** + * 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); + // Number("") is 0, which would clamp to the minimum instead of the default. + if (raw === null || raw.trim().length === 0) { + 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) => { + // The pick usually comes from the command palette, whose close restores + // focus to the trigger in the primary pane a beat later โ€” the intent + // makes that restore bounce into the pane the user just opened. + requestThreadPaneFocus("secondary"); + 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" }); + // The close control usually lives in the secondary pane, whose unmount + // would drop DOM focus to ; hand it to the surviving pane instead. + focusThreadPane("primary"); + }, + + 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; + } +} + +/** + * One-shot intent that survives an overlay's close-time focus restore. A + * palette pick activates a pane, but the closing dialog then restores focus + * to its trigger in the other pane, which would silently re-activate it. + * While the intent is fresh, that stray focus gets bounced to the intended + * pane instead; a real pointerdown anywhere cancels it as user intent. + */ +let pendingPaneFocus: { paneId: ThreadPaneId; expiresAt: number } | null = null; +const PENDING_PANE_FOCUS_TTL_MS = 1_500; + +export function requestThreadPaneFocus(paneId: ThreadPaneId): void { + pendingPaneFocus = { paneId, expiresAt: Date.now() + PENDING_PANE_FOCUS_TTL_MS }; +} + +export function cancelPendingThreadPaneFocus(): void { + pendingPaneFocus = null; +} + +/** + * Called from a pane's focus-capture. Returns true when the focus event was + * a stray restore into the wrong pane and has been re-routed โ€” the caller + * must then leave the active pane unchanged. + */ +export function redirectStrayThreadPaneFocus(focusedPaneId: ThreadPaneId): boolean { + const pending = pendingPaneFocus; + if (pending === null) { + return false; + } + pendingPaneFocus = null; + if (Date.now() > pending.expiresAt || pending.paneId === focusedPaneId) { + return false; + } + focusThreadPane(pending.paneId); + return true; +} + +function focusThreadPane(paneId: ThreadPaneId): void { + const runtime = paneRuntimes[paneId]; + const lastFocused = runtime.lastFocused; + if (lastFocused && lastFocused.isConnected) { + lastFocused.focus(); + // A connected element can still refuse focus (disabled, hidden, inert); + // fall through to the composer or root instead of leaving focus behind. + if (document.activeElement === lastFocused) { + return; + } + } + if (runtime.composer?.current) { + runtime.composer.current.focusAtEnd(); + return; + } + runtime.root?.focus(); +} + +/** + * The composer that overlay actions (palette inserts, close-time focus) + * should target: the secondary pane's while it is rendered and active, null + * otherwise โ€” callers fall back to the app-root (primary) handle. + */ +export function activeThreadPaneComposerHandle(): ComposerHandleRef | null { + const state = useThreadSplitStore.getState(); + if (!state.splitMounted || state.activePaneId !== "secondary") { + return null; + } + return paneRuntimes.secondary.composer; +} + +/** + * Overlay close hook (command palette): return focus to the active pane. + * Falls through to the pane root when it shows a composer-less notice, so + * dismissing an overlay never silently hands ownership to the other pane. + * Returns false when the primary/app-root composer is the right target + * anyway, so callers can keep their default. + */ +export function focusActiveThreadPane(): boolean { + const state = useThreadSplitStore.getState(); + if (!state.splitMounted || state.activePaneId !== "secondary") { + return false; + } + focusThreadPane("secondary"); + return true; +} + +/** + * A pointerdown that prevents default (toolbar buttons, drag affordances) + * keeps DOM focus where it was โ€” possibly in the other pane. Active pane and + * real focus must not diverge: global focus probes (getTerminalFocusOwner) + * would describe the other pane's terminal while this pane's handlers act on + * it. Deferred a tick so it runs after the event cascade's default focusing. + */ +export function reclaimThreadPaneFocus(paneId: ThreadPaneId): void { + window.setTimeout(() => { + const state = useThreadSplitStore.getState(); + if (!state.splitMounted || state.activePaneId !== paneId) return; + const otherRoot = paneRuntimes[paneId === "primary" ? "secondary" : "primary"].root; + const activeElement = document.activeElement; + if (otherRoot && activeElement instanceof HTMLElement && otherRoot.contains(activeElement)) { + focusThreadPane(paneId); + } + }, 0); +} + +/** 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/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 9d0436b661da..e680ee56848e 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -64,16 +64,18 @@ function SheetPopup({ keepMounted = false, side = "right", variant = "default", + backdropClassName, ...props }: SheetPrimitive.Popup.Props & { showCloseButton?: boolean; keepMounted?: boolean; side?: "right" | "left" | "top" | "bottom"; variant?: "default" | "inset"; + backdropClassName?: string | undefined; }) { return ( - + (); 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" },