diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index e3f190ff06..ef7bc3856d 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -188,6 +188,21 @@ it.layer(NodeServices.layer)("keybindings", (it) => { DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), ); + assert.equal(defaultsByCommand.get("workspace.pane.splitRight"), "mod+d"); + assert.equal(defaultsByCommand.get("workspace.pane.splitDown"), "mod+shift+d"); + assert.equal(defaultsByCommand.get("workspace.pane.close"), "mod+w"); + assert.equal(defaultsByCommand.get("workspace.focus.previous"), "mod+["); + assert.equal(defaultsByCommand.get("workspace.focus.next"), "mod+]"); + assert.equal(defaultsByCommand.get("workspace.focus.left"), "mod+alt+arrowleft"); + assert.equal(defaultsByCommand.get("workspace.focus.right"), "mod+alt+arrowright"); + assert.equal(defaultsByCommand.get("workspace.focus.up"), "mod+alt+arrowup"); + assert.equal(defaultsByCommand.get("workspace.focus.down"), "mod+alt+arrowdown"); + assert.equal(defaultsByCommand.get("workspace.pane.toggleZoom"), "mod+shift+enter"); + assert.equal(defaultsByCommand.get("workspace.pane.resizeLeft"), "mod+ctrl+arrowleft"); + assert.equal(defaultsByCommand.get("workspace.pane.resizeRight"), "mod+ctrl+arrowright"); + assert.equal(defaultsByCommand.get("workspace.pane.resizeUp"), "mod+ctrl+arrowup"); + assert.equal(defaultsByCommand.get("workspace.pane.resizeDown"), "mod+ctrl+arrowdown"); + assert.equal(defaultsByCommand.get("workspace.pane.equalize"), "mod+ctrl+="); assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index b473f77ca1..bd41ec6380 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -60,7 +60,22 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, - { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+d", command: "workspace.pane.splitRight", when: "!terminalFocus" }, + { key: "mod+shift+d", command: "workspace.pane.splitDown", when: "!terminalFocus" }, + { key: "mod+w", command: "workspace.pane.close", when: "!terminalFocus" }, + { key: "mod+[", command: "workspace.focus.previous" }, + { key: "mod+]", command: "workspace.focus.next" }, + { key: "mod+alt+arrowup", command: "workspace.focus.up" }, + { key: "mod+alt+arrowdown", command: "workspace.focus.down" }, + { key: "mod+alt+arrowleft", command: "workspace.focus.left" }, + { key: "mod+alt+arrowright", command: "workspace.focus.right" }, + { key: "mod+shift+enter", command: "workspace.pane.toggleZoom" }, + { key: "mod+ctrl+arrowup", command: "workspace.pane.resizeUp" }, + { key: "mod+ctrl+arrowdown", command: "workspace.pane.resizeDown" }, + { key: "mod+ctrl+arrowleft", command: "workspace.pane.resizeLeft" }, + { key: "mod+ctrl+arrowright", command: "workspace.pane.resizeRight" }, + { key: "mod+ctrl+=", command: "workspace.pane.equalize" }, + { key: "mod+alt+d", command: "diff.toggle", when: "!terminalFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index a74ce18ac3..ec3935acdd 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -77,4 +77,53 @@ describe("clientPersistenceStorage", () => { ], }); }); + + it("reads and writes workspace documents as JSON blobs", async () => { + const testWindow = getTestWindow(); + const { + WORKSPACE_DOCUMENT_STORAGE_KEY, + readBrowserWorkspaceDocument, + writeBrowserWorkspaceDocument, + } = await import("./clientPersistenceStorage"); + const workspaceDocument = { + version: 1 as const, + layoutEngine: "split" as const, + rootNodeId: "node-1", + nodesById: { + "node-1": { + id: "node-1", + kind: "window" as const, + windowId: "window-1", + }, + }, + windowsById: { + "window-1": { + id: "window-1", + surfaceId: "surface-1", + }, + }, + surfacesById: { + "surface-1": { + id: "surface-1", + kind: "thread" as const, + input: { + scope: "server" as const, + threadRef: { + environmentId: testEnvironmentId, + threadId: "thread-1", + }, + }, + }, + }, + focusedWindowId: "window-1", + mobileActiveWindowId: "window-1", + }; + + writeBrowserWorkspaceDocument(workspaceDocument); + + expect(readBrowserWorkspaceDocument()).toEqual(workspaceDocument); + expect(JSON.parse(testWindow.localStorage.getItem(WORKSPACE_DOCUMENT_STORAGE_KEY)!)).toEqual( + workspaceDocument, + ); + }); }); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 70f51d5c30..ce9609c0c1 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -11,6 +11,7 @@ import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorag export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; +export const WORKSPACE_DOCUMENT_STORAGE_KEY = "t3code:workspace-document:v1"; const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ environmentId: EnvironmentId, @@ -34,6 +35,34 @@ function hasWindow(): boolean { return typeof window !== "undefined"; } +function readBrowserJsonDocument(storageKey: string): T | null { + if (!hasWindow()) { + return null; + } + + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return null; + } + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function writeBrowserJsonDocument(storageKey: string, document: T): void { + if (!hasWindow()) { + return; + } + + try { + window.localStorage.setItem(storageKey, JSON.stringify(document)); + } catch { + // Ignore quota/storage errors to avoid breaking the app. + } +} + function toPersistedSavedEnvironmentRecord( record: PersistedSavedEnvironmentRecord, ): PersistedSavedEnvironmentRecord { @@ -192,3 +221,11 @@ export function removeBrowserSavedEnvironmentSecret(environmentId: EnvironmentId }), }); } + +export function readBrowserWorkspaceDocument(): T | null { + return readBrowserJsonDocument(WORKSPACE_DOCUMENT_STORAGE_KEY); +} + +export function writeBrowserWorkspaceDocument(document: T): void { + writeBrowserJsonDocument(WORKSPACE_DOCUMENT_STORAGE_KEY, document); +} diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts index 4f291d5a48..9066e12213 100644 --- a/apps/web/src/commandPaletteStore.ts +++ b/apps/web/src/commandPaletteStore.ts @@ -1,13 +1,27 @@ import { create } from "zustand"; +export interface CommandPaletteWorkspaceTarget { + disposition: "split-right" | "split-down"; +} + interface CommandPaletteStore { open: boolean; + workspaceTarget: CommandPaletteWorkspaceTarget | null; setOpen: (open: boolean) => void; toggleOpen: () => void; + openWorkspaceTarget: (target: CommandPaletteWorkspaceTarget) => void; + clearWorkspaceTarget: () => void; } export const useCommandPaletteStore = create((set) => ({ open: false, - setOpen: (open) => set({ open }), - toggleOpen: () => set((state) => ({ open: !state.open })), + workspaceTarget: null, + setOpen: (open) => set({ open, ...(open ? {} : { workspaceTarget: null }) }), + toggleOpen: () => + set((state) => ({ + open: !state.open, + ...(!state.open ? {} : { workspaceTarget: null }), + })), + openWorkspaceTarget: (target) => set({ open: true, workspaceTarget: target }), + clearWorkspaceTarget: () => set({ workspaceTarget: null }), })); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ba2ac54190..e597e76dcc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -19,17 +19,12 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; -import { - parseScopedThreadKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; @@ -66,11 +61,7 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { - selectProjectsAcrossEnvironments, - selectThreadsAcrossEnvironments, - useStore, -} from "../store"; +import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { @@ -89,13 +80,16 @@ import { type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; +import { + isWorkspaceCommandId, + useWorkspaceCommandExecutor, +} from "../hooks/useWorkspaceCommandExecutor"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; import { toastManager } from "./ui/toast"; @@ -126,8 +120,8 @@ import { import { appendTerminalContextsToPrompt, formatTerminalContextLabel, - type TerminalContextDraft, type TerminalContextSelection, + type TerminalContextDraft, } from "../lib/terminalContext"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; @@ -140,8 +134,8 @@ import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, @@ -155,7 +149,6 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, - reconcileMountedTerminalThreadIds, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -312,6 +305,8 @@ const SCRIPT_TERMINAL_ROWS = 30; type ChatViewProps = | { + activationFocusRequestId?: number; + bindSharedComposerHandle?: boolean; environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; @@ -320,6 +315,8 @@ type ChatViewProps = draftId?: never; } | { + activationFocusRequestId?: number; + bindSharedComposerHandle?: boolean; environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; @@ -328,14 +325,6 @@ type ChatViewProps = draftId: DraftId; }; -interface TerminalLaunchContext { - threadId: ThreadId; - cwd: string; - worktreePath: string | null; -} - -type PersistentTerminalLaunchContext = Pick; - function useLocalDispatchState(input: { activeThread: Thread | undefined; activeLatestTurn: Thread["latestTurn"] | null; @@ -403,181 +392,10 @@ function useLocalDispatchState(input: { }; } -interface PersistentThreadTerminalDrawerProps { - threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; - threadId: ThreadId; - visible: boolean; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - splitShortcutLabel: string | undefined; - newShortcutLabel: string | undefined; - closeShortcutLabel: string | undefined; - onAddTerminalContext: (selection: TerminalContextSelection) => void; -} - -const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ - threadRef, - threadId, - visible, - launchContext, - focusRequestId, - splitShortcutLabel, - newShortcutLabel, - closeShortcutLabel, - onAddTerminalContext, -}: PersistentThreadTerminalDrawerProps) { - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), - ); - const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); - const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); - const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal); - const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); - const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); - const [localFocusRequestId, setLocalFocusRequestId] = useState(0); - const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const effectiveWorktreePath = useMemo(() => { - if (launchContext !== null) { - return launchContext.worktreePath; - } - return worktreePath; - }, [launchContext, worktreePath]); - const cwd = useMemo( - () => - launchContext?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.cwd }, - worktreePath: effectiveWorktreePath, - }) - : null), - [effectiveWorktreePath, launchContext?.cwd, project], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, - worktreePath: effectiveWorktreePath, - }) - : {}, - [effectiveWorktreePath, project], - ); - - const bumpFocusRequestId = useCallback(() => { - if (!visible) { - return; - } - setLocalFocusRequestId((value) => value + 1); - }, [visible]); - - const setTerminalHeight = useCallback( - (height: number) => { - storeSetTerminalHeight(threadRef, height); - }, - [storeSetTerminalHeight, threadRef], - ); - - const splitTerminal = useCallback(() => { - storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); - bumpFocusRequestId(); - }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); - - const createNewTerminal = useCallback(() => { - storeNewTerminal(threadRef, `terminal-${randomUUID()}`); - bumpFocusRequestId(); - }, [bumpFocusRequestId, storeNewTerminal, threadRef]); - - const activateTerminal = useCallback( - (terminalId: string) => { - storeSetActiveTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeSetActiveTerminal, threadRef], - ); - - const closeTerminal = useCallback( - (terminalId: string) => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) return; - const isFinalTerminal = terminalState.terminalIds.length <= 1; - const fallbackExitWrite = () => - api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); - - if ("close" in api.terminal && typeof api.terminal.close === "function") { - void (async () => { - if (isFinalTerminal) { - await api.terminal.clear({ threadId, terminalId }).catch(() => undefined); - } - await api.terminal.close({ - threadId, - terminalId, - deleteHistory: true, - }); - })().catch(() => fallbackExitWrite()); - } else { - void fallbackExitWrite(); - } - - storeCloseTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], - ); - - const handleAddTerminalContext = useCallback( - (selection: TerminalContextSelection) => { - if (!visible) { - return; - } - onAddTerminalContext(selection); - }, - [onAddTerminalContext, visible], - ); - - if (!project || !terminalState.terminalOpen || !cwd) { - return null; - } - - return ( -
- -
- ); -}); - export default function ChatView(props: ChatViewProps) { const { + activationFocusRequestId, + bindSharedComposerHandle = true, environmentId, threadId, routeKind, @@ -654,7 +472,8 @@ export default function ChatView(props: ChatViewProps) { const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const localComposerRef = useRef(null); - const composerRef = useComposerHandleContext() ?? localComposerRef; + const sharedComposerRef = useComposerHandleContext(); + const composerRef = localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -683,9 +502,6 @@ export default function ChatView(props: ChatViewProps) { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [terminalLaunchContext, setTerminalLaunchContext] = useState( - null, - ); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); @@ -704,51 +520,44 @@ export default function ChatView(props: ChatViewProps) { const sendInFlightRef = useRef(false); const terminalOpenByThreadRef = useRef>({}); + useLayoutEffect(() => { + if (!sharedComposerRef) { + return; + } + + const localComposerHandle = localComposerRef.current; + if (bindSharedComposerHandle) { + sharedComposerRef.current = localComposerHandle; + return () => { + if (sharedComposerRef.current === localComposerHandle) { + sharedComposerRef.current = null; + } + }; + } + + if (sharedComposerRef.current === localComposerHandle) { + sharedComposerRef.current = null; + } + }); + const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), ); - const openTerminalThreadKeys = useTerminalStateStore( - useShallow((state) => - Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => - nextTerminalState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); + const storeEnsureTerminal = useTerminalStateStore((s) => s.ensureTerminal); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); + const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); - const serverThreadKeys = useStore( - useShallow((state) => - selectThreadsAcrossEnvironments(state).map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ), - ); + const storeSetTerminalLaunchContext = useTerminalStateStore((s) => s.setTerminalLaunchContext); const storeServerTerminalLaunchContext = useTerminalStateStore( (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, ); const storeClearTerminalLaunchContext = useTerminalStateStore( (s) => s.clearTerminalLaunchContext, ); - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftThreadKeys = useMemo( - () => - Object.values(draftThreadsByThreadKey).map((draftThread) => - scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), - ), - [draftThreadsByThreadKey], - ); - const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); - const mountedTerminalThreadRefs = useMemo( - () => - mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }), - [mountedTerminalThreadKeys], - ); + const addTerminalContext = useComposerDraftStore((state) => state.addTerminalContext); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -789,10 +598,6 @@ export default function ChatView(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; - const existingOpenTerminalThreadKeys = useMemo(() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -807,21 +612,6 @@ export default function ChatView(props: ChatViewProps) { return threadIds; }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), ); - useEffect(() => { - setMountedTerminalThreadKeys((currentThreadIds) => { - const nextThreadIds = reconcileMountedTerminalThreadIds({ - currentThreadIds, - openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - return currentThreadIds.length === nextThreadIds.length && - currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) - ? currentThreadIds - : nextThreadIds; - }); - }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -1403,6 +1193,7 @@ export default function ChatView(props: ChatViewProps) { const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); + const { executeWorkspaceCommand } = useWorkspaceCommandExecutor(); const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], @@ -1410,26 +1201,13 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; - const activeTerminalLaunchContext = - terminalLaunchContext?.threadId === activeThreadId - ? terminalLaunchContext - : (storeServerTerminalLaunchContext ?? null); // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const terminalShortcutLabelOptions = useMemo( - () => ({ - context: { - terminalFocus: true, - terminalOpen: Boolean(terminalState.terminalOpen), - }, - }), - [terminalState.terminalOpen], - ); const nonTerminalShortcutLabelOptions = useMemo( () => ({ context: { terminalFocus: false, - terminalOpen: Boolean(terminalState.terminalOpen), + terminalOpen: terminalState.terminalOpen, }, }), [terminalState.terminalOpen], @@ -1438,18 +1216,6 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "terminal.toggle"), [keybindings], ); - const splitTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), - [keybindings, terminalShortcutLabelOptions], - ); - const newTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), - [keybindings, terminalShortcutLabelOptions], - ); - const closeTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), - [keybindings, terminalShortcutLabelOptions], - ); const diffPanelShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), [keybindings, nonTerminalShortcutLabelOptions], @@ -1543,31 +1309,39 @@ export default function ChatView(props: ChatViewProps) { focusComposer(); }); }, [focusComposer]); - const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }, []); - const setTerminalOpen = useCallback( - (open: boolean) => { - if (!activeThreadRef) return; - storeSetTerminalOpen(activeThreadRef, open); - }, - [activeThreadRef, storeSetTerminalOpen], - ); + const terminalDrawerOpen = terminalState.terminalOpen; const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; - setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); + const nextOpen = !terminalDrawerOpen; + if (nextOpen) { + const terminalId = + terminalState.activeTerminalId || + terminalState.terminalIds[0] || + DEFAULT_THREAD_TERMINAL_ID; + storeEnsureTerminal(activeThreadRef, terminalId, { open: true, active: true }); + setTerminalFocusRequestId((current) => current + 1); + return; + } + storeSetTerminalOpen(activeThreadRef, false); + }, [ + activeThreadRef, + storeEnsureTerminal, + storeSetTerminalOpen, + terminalDrawerOpen, + terminalState.activeTerminalId, + terminalState.terminalIds, + ]); const splitTerminal = useCallback(() => { if (!activeThreadRef || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; storeSplitTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); + setTerminalFocusRequestId((current) => current + 1); }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); const createNewTerminal = useCallback(() => { if (!activeThreadRef) return; const terminalId = `terminal-${randomUUID()}`; storeNewTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); + setTerminalFocusRequestId((current) => current + 1); }, [activeThreadRef, storeNewTerminal]); const closeTerminal = useCallback( (terminalId: string) => { @@ -1597,7 +1371,6 @@ export default function ChatView(props: ChatViewProps) { if (activeThreadRef) { storeCloseTerminal(activeThreadRef, terminalId); } - setTerminalFocusRequestId((value) => value + 1); }, [ activeThreadId, @@ -1607,6 +1380,17 @@ export default function ChatView(props: ChatViewProps) { terminalState.terminalIds.length, ], ); + const handleAddTerminalContext = useCallback( + (selection: TerminalContextSelection) => { + addTerminalContext(routeThreadRef, { + ...selection, + id: randomUUID(), + threadId: routeThreadRef.threadId, + createdAt: new Date().toISOString(), + }); + }, + [addTerminalContext, routeThreadRef], + ); const runProjectScript = useCallback( async ( script: ProjectScript, @@ -1639,21 +1423,20 @@ export default function ChatView(props: ChatViewProps) { : baseTerminalId; const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; - setTerminalLaunchContext({ - threadId: activeThreadId, - cwd: targetCwd, - worktreePath: targetWorktreePath, - }); - setTerminalOpen(true); if (!activeThreadRef) { return; } + storeSetTerminalLaunchContext(activeThreadRef, { + cwd: targetCwd, + worktreePath: targetWorktreePath, + }); if (shouldCreateNewTerminal) { storeNewTerminal(activeThreadRef, targetTerminalId); } else { - storeSetActiveTerminal(activeThreadRef, targetTerminalId); + storeEnsureTerminal(activeThreadRef, targetTerminalId, { open: true, active: true }); } - setTerminalFocusRequestId((value) => value + 1); + storeSetTerminalOpen(activeThreadRef, true); + setTerminalFocusRequestId((current) => current + 1); const runtimeEnv = projectScriptRuntimeEnv({ project: { @@ -1700,10 +1483,11 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, activeThreadRef, gitCwd, - setTerminalOpen, setThreadError, + storeEnsureTerminal, storeNewTerminal, - storeSetActiveTerminal, + storeSetTerminalLaunchContext, + storeSetTerminalOpen, setLastInvokedScriptByProjectId, environmentId, terminalState.activeTerminalId, @@ -1711,6 +1495,26 @@ export default function ChatView(props: ChatViewProps) { terminalState.terminalIds, ], ); + const terminalDrawerWorktreePath = + storeServerTerminalLaunchContext?.worktreePath ?? activeThread?.worktreePath ?? null; + const terminalDrawerCwd = + storeServerTerminalLaunchContext?.cwd ?? + (activeProject + ? projectScriptCwd({ + project: { cwd: activeProject.cwd }, + worktreePath: terminalDrawerWorktreePath, + }) + : null); + const terminalDrawerRuntimeEnv = useMemo( + () => + activeProject + ? projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: terminalDrawerWorktreePath, + }) + : {}, + [activeProject, terminalDrawerWorktreePath], + ); const persistProjectScripts = useCallback( async (input: { @@ -1994,14 +1798,21 @@ export default function ChatView(props: ChatViewProps) { }, [activeThread?.id]); useEffect(() => { - if (!activeThread?.id || terminalState.terminalOpen) return; + if (!activeThread?.id || terminalDrawerOpen) return; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); + }, [activeThread?.id, focusComposer, terminalDrawerOpen]); + + useLayoutEffect(() => { + if (activationFocusRequestId === undefined) { + return; + } + focusComposer(); + }, [activationFocusRequestId, focusComposer]); useEffect(() => { if (!activeThread?.id) return; @@ -2086,48 +1897,11 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (!activeThreadId) { - setTerminalLaunchContext(null); storeClearTerminalLaunchContext(routeThreadRef); return; } - setTerminalLaunchContext((current) => { - if (!current) return current; - if (current.threadId === activeThreadId) return current; - return null; - }); }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); - useEffect(() => { - if (!activeThreadId || !activeProjectCwd) { - return; - } - setTerminalLaunchContext((current) => { - if (!current || current.threadId !== activeThreadId) { - return current; - } - const settledCwd = projectScriptCwd({ - project: { cwd: activeProjectCwd }, - worktreePath: activeThreadWorktreePath, - }); - if ( - settledCwd === current.cwd && - (activeThreadWorktreePath ?? null) === current.worktreePath - ) { - if (activeThreadRef) { - storeClearTerminalLaunchContext(activeThreadRef); - } - return null; - } - return current; - }); - }, [ - activeProjectCwd, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - storeClearTerminalLaunchContext, - ]); - useEffect(() => { if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { return; @@ -2154,28 +1928,21 @@ export default function ChatView(props: ChatViewProps) { ]); useEffect(() => { - if (terminalState.terminalOpen) { + if (terminalDrawerOpen) { return; } if (activeThreadRef) { storeClearTerminalLaunchContext(activeThreadRef); } - setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); - }, [ - activeThreadId, - activeThreadRef, - storeClearTerminalLaunchContext, - terminalState.terminalOpen, - ]); + }, [activeThreadRef, storeClearTerminalLaunchContext, terminalDrawerOpen]); useEffect(() => { if (!activeThreadKey) return; const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; - const current = Boolean(terminalState.terminalOpen); + const current = terminalDrawerOpen; if (!previous && current) { terminalOpenByThreadRef.current[activeThreadKey] = current; - setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { terminalOpenByThreadRef.current[activeThreadKey] = current; @@ -2188,7 +1955,7 @@ export default function ChatView(props: ChatViewProps) { } terminalOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); + }, [activeThreadKey, focusComposer, terminalDrawerOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -2197,7 +1964,7 @@ export default function ChatView(props: ChatViewProps) { } const shortcutContext = { terminalFocus: isTerminalFocused(), - terminalOpen: Boolean(terminalState.terminalOpen), + terminalOpen: terminalDrawerOpen, }; const command = resolveShortcutCommand(event, keybindings, { @@ -2205,6 +1972,13 @@ export default function ChatView(props: ChatViewProps) { }); if (!command) return; + if (isWorkspaceCommandId(command)) { + event.preventDefault(); + event.stopPropagation(); + void executeWorkspaceCommand(command); + return; + } + if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); @@ -2215,8 +1989,8 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); + if (!terminalDrawerOpen && activeThreadRef) { + storeSetTerminalOpen(activeThreadRef, true); } splitTerminal(); return; @@ -2225,7 +1999,7 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); - if (!terminalState.terminalOpen) return; + if (!terminalDrawerOpen) return; closeTerminal(terminalState.activeTerminalId); return; } @@ -2233,8 +2007,8 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); + if (!terminalDrawerOpen && activeThreadRef) { + storeSetTerminalOpen(activeThreadRef, true); } createNewTerminal(); return; @@ -2259,16 +2033,18 @@ export default function ChatView(props: ChatViewProps) { return () => window.removeEventListener("keydown", handler); }, [ activeProject, - terminalState.terminalOpen, terminalState.activeTerminalId, + activeThreadRef, activeThreadId, closeTerminal, createNewTerminal, - setTerminalOpen, + executeWorkspaceCommand, runProjectScript, splitTerminal, keybindings, onToggleDiff, + storeSetTerminalOpen, + terminalDrawerOpen, toggleTerminalVisibility, ]); @@ -3171,7 +2947,7 @@ export default function ChatView(props: ChatViewProps) { } return ( -
+
{/* Top bar */}
+ {terminalDrawerOpen && activeThreadId && activeProject && terminalDrawerCwd ? ( +
+ { + storeSetActiveTerminal(routeThreadRef, terminalId); + setTerminalFocusRequestId((current) => current + 1); + }} + onCloseTerminal={closeTerminal} + onHeightChange={(height) => { + storeSetTerminalHeight(routeThreadRef, height); + }} + onAddTerminalContext={handleAddTerminalContext} + /> +
+ ) : null} + {isGitRepo && ( {/* end horizontal flex container */} - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} - {expandedImage && ( )} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4028043f19..6330714d21 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -6,10 +6,15 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { ArrowDownIcon, ArrowLeftIcon, + ArrowRightIcon, ArrowUpIcon, + Columns2Icon, MessageSquareIcon, + Rows2Icon, SettingsIcon, SquarePenIcon, + TerminalSquareIcon, + XIcon, } from "lucide-react"; import { useDeferredValue, @@ -23,6 +28,10 @@ import { import { useShallow } from "zustand/react/shallow"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { + WORKSPACE_COMMAND_METADATA, + useWorkspaceCommandExecutor, +} from "../hooks/useWorkspaceCommandExecutor"; import { useSettings } from "../hooks/useSettings"; import { startNewThreadInProjectFromContext, @@ -36,8 +45,10 @@ import { selectSidebarThreadsAcrossEnvironments, useStore, } from "../store"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useThreadTerminalOpen } from "../terminalStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { useWorkspaceStore } from "../workspace/store"; +import { serverThreadSurfaceInput } from "../workspace/types"; import { ADDON_ICON_CLASS, buildProjectActionItems, @@ -80,11 +91,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { select: (params) => resolveThreadRouteTarget(params), }); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; - const terminalOpen = useTerminalStateStore((state) => - routeThreadRef - ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); + const terminalOpen = useThreadTerminalOpen(routeThreadRef); useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { @@ -108,8 +115,8 @@ export function CommandPalette({ children }: { children: ReactNode }) { return ( + {children} - {children} @@ -136,6 +143,8 @@ function CommandPaletteDialog() { function OpenCommandPaletteDialog() { const navigate = useNavigate(); const setOpen = useCommandPaletteStore((store) => store.setOpen); + const workspaceTarget = useCommandPaletteStore((store) => store.workspaceTarget); + const clearWorkspaceTarget = useCommandPaletteStore((store) => store.clearWorkspaceTarget); const composerHandleRef = useComposerHandleContext(); const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); @@ -149,6 +158,13 @@ function OpenCommandPaletteDialog() { const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const paletteMode = getCommandPaletteMode({ currentView }); + const { canOpenTerminalSurface, canSplitFocusedPane, executeWorkspaceCommand } = + useWorkspaceCommandExecutor(); + const openThreadSurface = useWorkspaceStore((state) => state.openThreadSurface); + const workspaceWindowCount = useWorkspaceStore( + (state) => Object.keys(state.document.windowsById).length, + ); + const canUseSpatialWorkspaceCommands = workspaceWindowCount > 1; const projectTitleById = useMemo( () => new Map(projects.map((project) => [project.id, project.name])), @@ -258,6 +274,40 @@ function OpenCommandPaletteDialog() { [activeThreadId, navigate, projectTitleById, settings.sidebarThreadSortOrder, threads], ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); + const workspaceTargetThreadItems = useMemo(() => { + if (!workspaceTarget) { + return []; + } + + return buildThreadActionItems({ + threads, + ...(activeThreadId ? { activeThreadId } : {}), + projectTitleById, + sortOrder: settings.sidebarThreadSortOrder, + icon: , + runThread: async (thread) => { + openThreadSurface( + serverThreadSurfaceInput(scopeThreadRef(thread.environmentId, thread.id)), + workspaceTarget.disposition, + ); + }, + }); + }, [ + activeThreadId, + openThreadSurface, + projectTitleById, + settings.sidebarThreadSortOrder, + threads, + workspaceTarget, + ]); + + useEffect(() => { + if (!workspaceTarget) { + return; + } + setViewStack([]); + setQuery(""); + }, [workspaceTarget]); function pushView(item: CommandPaletteSubmenuItem): void { setViewStack((previousViews) => [ @@ -276,6 +326,15 @@ function OpenCommandPaletteDialog() { setQuery(""); } + function leaveSubmenu(): void { + if (currentView) { + popView(); + return; + } + clearWorkspaceTarget(); + setQuery(""); + } + function handleQueryChange(nextQuery: string): void { setQuery(nextQuery); if (nextQuery === "" && currentView?.initialQuery) { @@ -325,6 +384,246 @@ function OpenCommandPaletteDialog() { }); } + if (canOpenTerminalSurface) { + actionItems.push({ + kind: "action", + value: "action:workspace-terminal-split-right", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.terminal.splitRight"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.terminal.splitRight"].title, + icon: , + shortcutCommand: "workspace.terminal.splitRight", + run: async () => { + await executeWorkspaceCommand("workspace.terminal.splitRight"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-terminal-split-down", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.terminal.splitDown"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.terminal.splitDown"].title, + icon: , + shortcutCommand: "workspace.terminal.splitDown", + run: async () => { + await executeWorkspaceCommand("workspace.terminal.splitDown"); + }, + }); + } + + if (canSplitFocusedPane) { + actionItems.push({ + kind: "action", + value: "action:workspace-pane-split-right", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.splitRight"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.splitRight"].title, + icon: , + shortcutCommand: "workspace.pane.splitRight", + run: async () => { + await executeWorkspaceCommand("workspace.pane.splitRight"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-split-down", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.splitDown"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.splitDown"].title, + icon: , + shortcutCommand: "workspace.pane.splitDown", + run: async () => { + await executeWorkspaceCommand("workspace.pane.splitDown"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-close", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.close"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.close"].title, + icon: , + shortcutCommand: "workspace.pane.close", + run: async () => { + await executeWorkspaceCommand("workspace.pane.close"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-toggle-zoom", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.toggleZoom"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.toggleZoom"].title, + icon: , + shortcutCommand: "workspace.pane.toggleZoom", + run: async () => { + await executeWorkspaceCommand("workspace.pane.toggleZoom"); + }, + }); + } + + if (canUseSpatialWorkspaceCommands) { + actionItems.push({ + kind: "action", + value: "action:workspace-focus-previous", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.focus.previous"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.focus.previous"].title, + icon: , + shortcutCommand: "workspace.focus.previous", + run: async () => { + await executeWorkspaceCommand("workspace.focus.previous"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-focus-next", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.focus.next"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.focus.next"].title, + icon: , + shortcutCommand: "workspace.focus.next", + run: async () => { + await executeWorkspaceCommand("workspace.focus.next"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-focus-left", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.focus.left"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.focus.left"].title, + icon: , + shortcutCommand: "workspace.focus.left", + run: async () => { + await executeWorkspaceCommand("workspace.focus.left"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-focus-right", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.focus.right"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.focus.right"].title, + icon: , + shortcutCommand: "workspace.focus.right", + run: async () => { + await executeWorkspaceCommand("workspace.focus.right"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-focus-up", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.focus.up"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.focus.up"].title, + icon: , + shortcutCommand: "workspace.focus.up", + run: async () => { + await executeWorkspaceCommand("workspace.focus.up"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-focus-down", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.focus.down"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.focus.down"].title, + icon: , + shortcutCommand: "workspace.focus.down", + run: async () => { + await executeWorkspaceCommand("workspace.focus.down"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-resize-left", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeLeft"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeLeft"].title, + icon: , + shortcutCommand: "workspace.pane.resizeLeft", + run: async () => { + await executeWorkspaceCommand("workspace.pane.resizeLeft"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-resize-right", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeRight"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeRight"].title, + icon: , + shortcutCommand: "workspace.pane.resizeRight", + run: async () => { + await executeWorkspaceCommand("workspace.pane.resizeRight"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-resize-up", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeUp"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeUp"].title, + icon: , + shortcutCommand: "workspace.pane.resizeUp", + run: async () => { + await executeWorkspaceCommand("workspace.pane.resizeUp"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-resize-down", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeDown"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.resizeDown"].title, + icon: , + shortcutCommand: "workspace.pane.resizeDown", + run: async () => { + await executeWorkspaceCommand("workspace.pane.resizeDown"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-equalize", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.equalize"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.equalize"].title, + icon: , + shortcutCommand: "workspace.pane.equalize", + run: async () => { + await executeWorkspaceCommand("workspace.pane.equalize"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-move-left", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.moveLeft"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.moveLeft"].title, + icon: , + shortcutCommand: "workspace.pane.moveLeft", + run: async () => { + await executeWorkspaceCommand("workspace.pane.moveLeft"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-move-right", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.moveRight"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.moveRight"].title, + icon: , + shortcutCommand: "workspace.pane.moveRight", + run: async () => { + await executeWorkspaceCommand("workspace.pane.moveRight"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-move-up", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.moveUp"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.moveUp"].title, + icon: , + shortcutCommand: "workspace.pane.moveUp", + run: async () => { + await executeWorkspaceCommand("workspace.pane.moveUp"); + }, + }); + actionItems.push({ + kind: "action", + value: "action:workspace-pane-move-down", + searchTerms: WORKSPACE_COMMAND_METADATA["workspace.pane.moveDown"].searchTerms, + title: WORKSPACE_COMMAND_METADATA["workspace.pane.moveDown"].title, + icon: , + shortcutCommand: "workspace.pane.moveDown", + run: async () => { + await executeWorkspaceCommand("workspace.pane.moveDown"); + }, + }); + } + actionItems.push({ kind: "action", value: "action:settings", @@ -337,23 +636,65 @@ function OpenCommandPaletteDialog() { }); const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); - const activeGroups = currentView ? currentView.groups : rootGroups; + const workspaceTargetGroups = useMemo(() => { + if (!workspaceTarget) { + return []; + } + + const items: CommandPaletteActionItem[] = []; + if (canOpenTerminalSurface) { + const targetCommand = + workspaceTarget.disposition === "split-right" + ? "workspace.terminal.splitRight" + : "workspace.terminal.splitDown"; + items.push({ + kind: "action", + value: `action:workspace-target:${targetCommand}`, + searchTerms: WORKSPACE_COMMAND_METADATA[targetCommand].searchTerms, + title: WORKSPACE_COMMAND_METADATA[targetCommand].title, + icon: , + shortcutCommand: targetCommand, + run: async () => { + await executeWorkspaceCommand(targetCommand); + }, + }); + } + + return [ + ...(items.length > 0 ? [{ value: "actions", label: "Actions", items }] : []), + ...(workspaceTargetThreadItems.length > 0 + ? [{ value: "threads", label: "Threads", items: workspaceTargetThreadItems }] + : []), + ]; + }, [ + canOpenTerminalSurface, + executeWorkspaceCommand, + workspaceTarget, + workspaceTargetThreadItems, + ]); + const activeGroups = currentView + ? currentView.groups + : workspaceTarget + ? workspaceTargetGroups + : rootGroups; const displayedGroups = filterCommandPaletteGroups({ activeGroups, query: deferredQuery, - isInSubmenu: currentView !== null, + isInSubmenu: currentView !== null || workspaceTarget !== null, projectSearchItems: projectSearchItems, threadSearchItems: allThreadItems, }); - const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); - const isSubmenu = paletteMode === "submenu"; + const isSubmenu = paletteMode === "submenu" || workspaceTarget !== null; + const inputPlaceholder = workspaceTarget + ? "Open in split..." + : getCommandPaletteInputPlaceholder(paletteMode); function handleKeyDown(event: KeyboardEvent): void { if (event.key === "Backspace" && query === "" && isSubmenu) { event.preventDefault(); - popView(); + leaveSubmenu(); } } @@ -387,7 +728,7 @@ function OpenCommandPaletteDialog() { }} > diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 95157d787f..6a96a1dd4e 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -890,6 +890,7 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; + isFocused: () => boolean; readSnapshot: () => { value: string; cursor: number; @@ -1383,7 +1384,6 @@ function ComposerSurroundSelectionPlugin(props: { const onCompositionEnd = () => { tryApplyDeadKeyBacktickSurround({ finalAttempt: true }); }; - let activeRootElement: HTMLElement | null = null; const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { prevRootElement?.removeEventListener("keydown", onKeyDown); @@ -1493,7 +1493,7 @@ function ComposerPromptEditorInner({ if (shouldRewriteEditorState) { $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); } - if (shouldRewriteEditorState || isFocused) { + if (isFocused) { $setSelectionAtComposerOffset(normalizedCursor); } }); @@ -1508,22 +1508,19 @@ function ComposerPromptEditorInner({ if (!rootElement) return; const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); rootElement.focus(); + isApplyingControlledUpdateRef.current = true; editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); }); + queueMicrotask(() => { + isApplyingControlledUpdateRef.current = false; + }); snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, }; - onChangeRef.current( - snapshotRef.current.value, - boundedCursor, - snapshotRef.current.expandedCursor, - false, - snapshotRef.current.terminalContextIds, - ); }, [editor], ); @@ -1577,9 +1574,13 @@ function ComposerPromptEditorInner({ ), ); }, + isFocused: () => { + const rootElement = editor.getRootElement(); + return Boolean(rootElement && document.activeElement === rootElement); + }, readSnapshot, }), - [focusAt, readSnapshot], + [editor, focusAt, readSnapshot], ); const handleEditorChange = useCallback((editorState: EditorState) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc2da70e20..42a42cf69a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -68,7 +68,11 @@ import { selectThreadByRef, useStore, } from "../store"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { + selectThreadTerminalState, + useTerminalStateStore, + useThreadTerminalOpen, +} from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, @@ -141,15 +145,18 @@ import { import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { deriveLogicalProjectKey } from "../logicalProject"; +import { useCommandPaletteStore } from "../commandPaletteStore"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; +import { serverThreadSurfaceInput, type WorkspaceLayoutEngine } from "../workspace/types"; +import { useWorkspaceDragStore } from "../workspace/dragStore"; +import { useWorkspaceLayoutEngine, useWorkspaceStore } from "../workspace/store"; import type { Project, SidebarThreadSummary } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { @@ -570,6 +577,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [], ); + const handleDragStart = useCallback( + (event: React.DragEvent) => { + if (renamingThreadKey === threadKey) { + event.preventDefault(); + return; + } + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", thread.id); + useWorkspaceDragStore.getState().setItem({ + kind: "thread", + input: serverThreadSurfaceInput(threadRef), + }); + }, + [renamingThreadKey, thread.id, threadKey, threadRef], + ); + const handleDragEnd = useCallback(() => { + useWorkspaceDragStore.getState().clearItem(); + }, []); const handleConfirmArchiveClick = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -616,7 +641,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isActive, isSelected, })} relative isolate`} + draggable={renamingThreadKey !== threadKey} onClick={handleRowClick} + onDragEnd={handleDragEnd} + onDragStart={handleDragStart} onKeyDown={handleRowKeyDown} onContextMenu={handleRowContextMenu} > @@ -1008,6 +1036,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const removeFromSelection = useThreadSelectionStore((state) => state.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((state) => state.setAnchor); const selectedThreadCount = useThreadSelectionStore((state) => state.selectedThreadKeys.size); + const openThreadInSplit = useWorkspaceStore((state) => state.openThreadInSplit); const clearComposerDraftForThread = useComposerDraftStore((state) => state.clearDraftThread); const getDraftThreadByProjectRef = useComposerDraftStore( (state) => state.getDraftThreadByProjectRef, @@ -1636,6 +1665,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null; const clicked = await api.contextMenu.show( [ + { id: "open-split-right", label: "Open in split right" }, + { id: "open-split-down", label: "Open in split down" }, { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, @@ -1645,6 +1676,24 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec position, ); + if (clicked === "open-split-right") { + openThreadInSplit(serverThreadSurfaceInput(threadRef), "x"); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + return; + } + + if (clicked === "open-split-down") { + openThreadInSplit(serverThreadSurfaceInput(threadRef), "y"); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + return; + } + if (clicked === "rename") { setRenamingThreadKey(threadKey); setRenamingTitle(thread.title); @@ -1692,7 +1741,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard, deleteThread, markThreadUnread, + openThreadInSplit, project.cwd, + router, ], ); @@ -2001,6 +2052,8 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); + const layoutEngine = useWorkspaceLayoutEngine(); + const setLayoutEngine = useWorkspaceStore((state) => state.setLayoutEngine); const handleSettingsClick = useCallback(() => { void navigate({ to: "/settings" }); }, [navigate]); @@ -2020,10 +2073,41 @@ const SidebarChromeFooter = memo(function SidebarChromeFooter() { +
+ Layout + setLayoutEngine(engine)} + /> +
); }); +const WorkspaceLayoutToggle = memo(function WorkspaceLayoutToggle(props: { + layoutEngine: WorkspaceLayoutEngine; + onSelect: (engine: WorkspaceLayoutEngine) => void; +}) { + return ( +
+ {(["split", "paper"] as const).map((engine) => ( + + ))} +
+ ); +}); + interface SidebarProjectsContentProps { showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; @@ -2062,6 +2146,7 @@ interface SidebarProjectsContentProps { routeThreadKey: string | null; newThreadShortcutLabel: string | null; commandPaletteShortcutLabel: string | null; + onOpenCommandPalette: () => void; threadJumpLabelByKey: ReadonlyMap; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; expandThreadListForProject: (projectKey: string) => void; @@ -2114,6 +2199,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( routeThreadKey, newThreadShortcutLabel, commandPaletteShortcutLabel, + onOpenCommandPalette, threadJumpLabelByKey, attachThreadListAutoAnimateRef, expandThreadListForProject, @@ -2163,14 +2249,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( - - } + Search @@ -2179,7 +2263,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( {commandPaletteShortcutLabel} ) : null} - + @@ -2390,7 +2474,9 @@ export default function Sidebar() { select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeThreadTerminalOpen = useThreadTerminalOpen(routeThreadRef); const keybindings = useServerKeybindings(); + const setCommandPaletteOpen = useCommandPaletteStore((state) => state.setOpen); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); @@ -2552,14 +2638,9 @@ export default function Sidebar() { const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), - terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, - routeThreadRef, - ).terminalOpen - : false, + terminalOpen: routeThreadTerminalOpen, }), - [routeThreadRef], + [routeThreadTerminalOpen], ); const newThreadShortcutLabelOptions = useMemo( () => ({ @@ -3241,6 +3322,7 @@ export default function Sidebar() { routeThreadKey={routeThreadKey} newThreadShortcutLabel={newThreadShortcutLabel} commandPaletteShortcutLabel={commandPaletteShortcutLabel} + onOpenCommandPalette={() => setCommandPaletteOpen(true)} threadJumpLabelByKey={visibleThreadJumpLabelByKey} attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} expandThreadListForProject={expandThreadListForProject} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 91f4dbc1b3..d7296801a8 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -783,6 +783,7 @@ interface ThreadTerminalDrawerProps { onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; + showResizeHandle?: boolean; } interface TerminalActionButtonProps { @@ -836,6 +837,7 @@ export default function ThreadTerminalDrawer({ onCloseTerminal, onHeightChange, onAddTerminalContext, + showResizeHandle = true, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); @@ -1072,16 +1074,20 @@ export default function ThreadTerminalDrawer({ return (