diff --git a/apps/desktop/src/browserRuntime.test.ts b/apps/desktop/src/browserRuntime.test.ts index 138e86c4968..b2e0fc2e8dd 100644 --- a/apps/desktop/src/browserRuntime.test.ts +++ b/apps/desktop/src/browserRuntime.test.ts @@ -171,7 +171,10 @@ interface TestProjectRuntime { activeTabId: string | null; } -function getProjectRuntime(registry: BrowserRuntimeRegistry, projectId: ProjectId): TestProjectRuntime { +function getProjectRuntime( + registry: BrowserRuntimeRegistry, + projectId: ProjectId, +): TestProjectRuntime { const runtime = ((registry as any).runtimes as Map).get(projectId); expect(runtime).toBeDefined(); return runtime!; @@ -181,7 +184,10 @@ function hasProjectRuntime(registry: BrowserRuntimeRegistry, projectId: ProjectI return ((registry as any).runtimes as Map).has(projectId); } -function getActiveTabRuntime(registry: BrowserRuntimeRegistry, projectId: ProjectId): TestTabRuntime { +function getActiveTabRuntime( + registry: BrowserRuntimeRegistry, + projectId: ProjectId, +): TestTabRuntime { const projectRuntime = getProjectRuntime(registry, projectId); expect(projectRuntime.activeTabId).toBeTruthy(); const tab = projectRuntime.activeTabId @@ -234,7 +240,9 @@ describe("BrowserRuntimeRegistry", () => { throw new Error("ERR_ABORTED"); }; - await expect(registry.navigate(projectId, "https://example.com")).rejects.toThrow("ERR_ABORTED"); + await expect(registry.navigate(projectId, "https://example.com")).rejects.toThrow( + "ERR_ABORTED", + ); }); it("uses the shared persistent browser partition", async () => { @@ -565,7 +573,12 @@ describe("BrowserRuntimeRegistry", () => { const staleOpen = registry.open(projectId, { x: 540, y: 35, width: 200, height: 360 }); await Promise.resolve(); - const latestSnapshot = await registry.open(projectId, { x: 420, y: 35, width: 260, height: 360 }); + const latestSnapshot = await registry.open(projectId, { + x: 420, + y: 35, + width: 260, + height: 360, + }); resolveFirstOpen(); await staleOpen; vi.runAllTimers(); @@ -750,7 +763,7 @@ describe("BrowserRuntimeRegistry", () => { expect(closed.activeTabId).not.toBe(firstTabId); }); - it("recreates a default-start replacement tab when closing the last tab", async () => { + it("leaves an empty browser state when closing the last tab", async () => { const registry = new BrowserRuntimeRegistry({ browserPreloadPath: "test-preload.js" }); const projectId = ProjectId.makeUnsafe("project-tabs-2"); @@ -762,10 +775,9 @@ describe("BrowserRuntimeRegistry", () => { } const afterClose = await registry.closeTab(projectId, initialTabId); - expect(afterClose.tabs).toHaveLength(1); - expect(afterClose.activeTabId).toBeTruthy(); - expect(afterClose.activeTabId).not.toBe(initialTabId); - expect(afterClose.session?.navigation.url).toBe("https://www.google.com"); + expect(afterClose.tabs).toHaveLength(0); + expect(afterClose.activeTabId).toBeNull(); + expect(afterClose.session).toBeNull(); }); it("injects a visible agent cursor for browser interactions", async () => { diff --git a/apps/desktop/src/browserRuntime.ts b/apps/desktop/src/browserRuntime.ts index bd942045f92..92923b0c185 100644 --- a/apps/desktop/src/browserRuntime.ts +++ b/apps/desktop/src/browserRuntime.ts @@ -621,12 +621,11 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ projectRuntime.tabOrder = projectRuntime.tabOrder.filter((entry) => entry !== tabId); this.closeTabWebContents(tab); - if (projectRuntime.tabOrder.length === 0) { - await this.createTab(projectRuntime, { url: DEFAULT_NEW_TAB_URL, activate: true }); - } else if ( - projectRuntime.activeTabId === tabId || - !projectRuntime.activeTabId || - !projectRuntime.tabs.has(projectRuntime.activeTabId) + if ( + projectRuntime.tabOrder.length > 0 && + (projectRuntime.activeTabId === tabId || + !projectRuntime.activeTabId || + !projectRuntime.tabs.has(projectRuntime.activeTabId)) ) { const nextIndex = Math.min( Math.max(closedIndex, 0), @@ -634,6 +633,8 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ ); projectRuntime.activeTabId = projectRuntime.tabOrder[nextIndex] ?? projectRuntime.tabOrder[0] ?? null; + } else if (projectRuntime.tabOrder.length === 0) { + projectRuntime.activeTabId = null; } if (this.window && this.paneOpen && this.paneProjectId === projectId && this.paneBounds) { @@ -771,7 +772,10 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ CAPTURE_SELECTION_SCRIPT, true, )) as - | (Omit & { + | (Omit< + BrowserInspectCapture, + "sessionId" | "projectId" | "screenshotDataUrl" | "capturedAt" + > & { boundingBox: BrowserInspectCapture["boundingBox"]; }) | null; @@ -839,7 +843,10 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ }); } - async waitFor(projectId: ProjectId, input: { selector?: string; text?: string; timeoutMs?: number }) { + async waitFor( + projectId: ProjectId, + input: { selector?: string; text?: string; timeoutMs?: number }, + ) { const tab = await this.ensureActiveTab(projectId); const timeoutMs = Math.max(100, Math.min(input.timeoutMs ?? 10_000, 60_000)); const startedAt = Date.now(); @@ -871,7 +878,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ await tab.view.webContents.executeJavaScript( selectorInteractionScript( selector, - ` + ` window.__t3BrowserAgentCursor?.moveTo(x, y, "click"); element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, clientX: x, clientY: y })); element.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y })); @@ -889,7 +896,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ await tab.view.webContents.executeJavaScript( selectorInteractionScript( selector, - ` + ` element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, clientX: x, clientY: y })); element.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y })); `, @@ -903,7 +910,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ await tab.view.webContents.executeJavaScript( selectorInteractionScript( input.selector, - ` + ` if (!("value" in element)) { throw new Error("Target element does not support value assignment."); } @@ -923,7 +930,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ await tab.view.webContents.executeJavaScript( selectorInteractionScript( input.selector, - ` + ` if (!("value" in element)) { throw new Error("Target element does not support text input."); } @@ -1171,10 +1178,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ } else if (nudgedBounds.height > 1) { nudgedBounds.height -= 1; } - if ( - nudgedBounds.width !== nextBounds.width || - nudgedBounds.height !== nextBounds.height - ) { + if (nudgedBounds.width !== nextBounds.width || nudgedBounds.height !== nextBounds.height) { tab.view.setBounds(nudgedBounds); } } @@ -1189,19 +1193,25 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ .catch(() => undefined); } - private attachActiveTab(window: BrowserWindow, projectId: ProjectId, bounds: BrowserPaneBounds): void { + private attachActiveTab( + window: BrowserWindow, + projectId: ProjectId, + bounds: BrowserPaneBounds, + ): void { const projectRuntime = this.runtimes.get(projectId); const activeTab = this.getActiveTab(projectRuntime); if (!activeTab) { return; } - const contentView = (window as BrowserWindow & { - contentView: { - addChildView: (view: Electron.WebContentsView) => void; - removeChildView: (view: Electron.WebContentsView) => void; - }; - }).contentView; + const contentView = ( + window as BrowserWindow & { + contentView: { + addChildView: (view: Electron.WebContentsView) => void; + removeChildView: (view: Electron.WebContentsView) => void; + }; + } + ).contentView; const sameAttachment = this.attachedProjectId === projectId && this.attachedTabId === activeTab.tabId; @@ -1264,9 +1274,11 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ this.attachedViews.delete(view); return; } - const contentView = (window as BrowserWindow & { - contentView: { removeChildView: (view: Electron.WebContentsView) => void }; - }).contentView; + const contentView = ( + window as BrowserWindow & { + contentView: { removeChildView: (view: Electron.WebContentsView) => void }; + } + ).contentView; hideView(view); contentView.removeChildView(view); this.attachedViews.delete(view); @@ -1353,9 +1365,7 @@ export class BrowserRuntimeRegistry extends EventEmitter<{ } } - private findTabByWebContentsId( - webContentsId: number, - ): { tab: BrowserTabRuntimeRecord } | null { + private findTabByWebContentsId(webContentsId: number): { tab: BrowserTabRuntimeRecord } | null { for (const projectRuntime of this.runtimes.values()) { for (const tab of projectRuntime.tabs.values()) { if (tab.view.webContents.id === webContentsId) { diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index ec6f3f7e654..d553f5f78d9 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -69,7 +69,9 @@ interface ExternalServerDescriptor { createdAt: string | null; } -type ExternalServerDiscoverer = (filter: ExternalServerFilter) => Promise; +type ExternalServerDiscoverer = ( + filter: ExternalServerFilter, +) => Promise; type ExternalProcessKiller = (pid: number) => Promise; function defaultShellResolver(): string { @@ -303,8 +305,7 @@ function samePath(left: string, right: string): boolean { function pathContains(parent: string, child: string): boolean { const relativePath = path.relative(path.resolve(parent), path.resolve(child)); return ( - relativePath.length === 0 || - (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + relativePath.length === 0 || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) ); } @@ -331,25 +332,7 @@ function escapeRegExp(value: string): string { } function normalizeProjectRootForMatch(value: string): string { - return value - .trim() - .replaceAll("\\", "/") - .replace(/\/+/g, "/") - .replace(/\/+$/g, "") - .toLowerCase(); -} - -function encodePowershellCommand(script: string): string { - return Buffer.from(script, "utf16le").toString("base64"); -} - -function normalizeProjectRootForMatch(value: string): string { - return value - .trim() - .replaceAll("\\", "/") - .replace(/\/+/g, "/") - .replace(/\/+$/g, "") - .toLowerCase(); + return value.trim().replaceAll("\\", "/").replace(/\/+/g, "/").replace(/\/+$/g, "").toLowerCase(); } function parseJsonArrayOrObject(value: string): T[] { @@ -386,10 +369,7 @@ function commandMatchesProjectRoot( const normalizedCommandLine = normalizeProjectRootForMatch(commandLine).replaceAll('"', ""); return projectRoots.some((root) => { const normalizedRoot = normalizeProjectRootForMatch(root); - const boundaryPattern = new RegExp( - `(^|\\s)${escapeRegExp(normalizedRoot)}(?:/|\\s|$)`, - "i", - ); + const boundaryPattern = new RegExp(`(^|\\s)${escapeRegExp(normalizedRoot)}(?:/|\\s|$)`, "i"); return ( normalizedCommandLine === normalizedRoot || normalizedCommandLine.includes(`${normalizedRoot}/`) || @@ -400,7 +380,9 @@ function commandMatchesProjectRoot( function extractRecentOutputPorts(value: string): Set { const ports = new Set(); - for (const match of value.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{2,5})/gi)) { + for (const match of value.matchAll( + /(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{2,5})/gi, + )) { const port = Number(match[1]); if (Number.isInteger(port) && port > 0) { ports.add(port); @@ -520,16 +502,12 @@ async function discoverPosixExternalServers( let commandLine = ""; let parentPid: number | null = null; try { - const psResult = await runProcess( - "ps", - ["-p", String(currentPid), "-o", "ppid=,command="], - { - timeoutMs: 1_500, - allowNonZeroExit: true, - maxBufferBytes: 65_536, - outputMode: "truncate", - }, - ); + const psResult = await runProcess("ps", ["-p", String(currentPid), "-o", "ppid=,command="], { + timeoutMs: 1_500, + allowNonZeroExit: true, + maxBufferBytes: 65_536, + outputMode: "truncate", + }); const raw = psResult.stdout.trim(); const firstSpace = raw.search(/\s/); if (firstSpace > 0) { @@ -553,7 +531,9 @@ async function discoverPosixExternalServers( }); } - return descriptors.filter((server) => commandMatchesProjectRoot(server.commandLine, projectRoots)); + return descriptors.filter((server) => + commandMatchesProjectRoot(server.commandLine, projectRoots), + ); } async function defaultExternalServerDiscoverer( @@ -891,7 +871,10 @@ export class TerminalManagerRuntime extends EventEmitter .filter((session) => includeInactive || session.status === "running") .filter((session) => !input.threadId || session.threadId === input.threadId) .filter((session) => !input.cwd || samePath(session.cwd, input.cwd)) - .filter((session) => !input.projectRoot || this.sessionMatchesProjectRoot(session, input.projectRoot)) + .filter( + (session) => + !input.projectRoot || this.sessionMatchesProjectRoot(session, input.projectRoot), + ) .map((session) => this.summary(session)) .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)); @@ -1324,7 +1307,8 @@ export class TerminalManagerRuntime extends EventEmitter } catch (migrationError) { this.logger.warn("failed to rename legacy terminal history", { threadId, - error: migrationError instanceof Error ? migrationError.message : String(migrationError), + error: + migrationError instanceof Error ? migrationError.message : String(migrationError), }); } } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d407416da44..b5ddb1890ac 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1526,7 +1526,8 @@ function createDesktopBrowserBridge( closePane: async () => undefined, newTab: async () => { const tabId = `tab-${tabs.length + 1}`; - const base = buildSnapshot().tabs?.[0]; + const base = + buildSnapshot().tabs?.[0] ?? createDesktopBrowserSnapshot(projectId, paneBounds).tabs?.[0]; if (!base) { return buildSnapshot(); } @@ -1554,12 +1555,6 @@ function createDesktopBrowserBridge( }, closeTab: async (input) => { tabs = tabs.filter((tab) => tab.tabId !== input.tabId); - if (tabs.length === 0) { - const fallback = createDesktopBrowserSnapshot(projectId, paneBounds).tabs?.[0]; - if (fallback) { - tabs = [fallback]; - } - } if (!tabs.some((tab) => tab.tabId === activeTabId)) { activeTabId = tabs[0]?.tabId ?? null; } @@ -2322,7 +2317,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await expect.element(page.getByLabelText("Reload")).toBeVisible(); await expect.element(page.getByLabelText("Browser URL")).toBeVisible(); await expect.element(page.getByLabelText("Inspect element")).toBeVisible(); - await expect.element(page.getByLabelText("Collapse browser")).toBeVisible(); + await expect.element(page.getByLabelText("Collapse browser")).not.toBeInTheDocument(); await expect .poll(() => elementHeightByTestId("integrated-browser-top-header")) .toBeGreaterThanOrEqual(40); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 289d5dc8353..d3b6dcaef89 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -188,7 +188,7 @@ import { KanbanSquareIcon, HammerIcon, MessageSquareIcon, - PanelLeftIcon, + PanelRightOpenIcon, BoxIcon, GitBranchIcon, GitCommitIcon, @@ -198,13 +198,11 @@ import { ListChecksIcon, MousePointer2Icon, PlusIcon, - Maximize2Icon, ArrowLeftRight, PlayIcon, ServerIcon, SettingsIcon, SquareIcon, - TerminalIcon, Undo2Icon, Trash2Icon, WrenchIcon, @@ -366,8 +364,7 @@ const DESKTOP_APP_RESOLUTION_TIMEOUT_MS = 2_500; const POINTER_SCROLL_INTENT_THRESHOLD_PX = 6; function isTerminalUserInputSubmitFailure(error: unknown): boolean { - const message = - error instanceof Error ? error.message : typeof error === "string" ? error : ""; + const message = error instanceof Error ? error.message : typeof error === "string" ? error : ""; return ( message.includes("Unknown pending") || message.includes('Expected a string starting with "que"') || @@ -461,10 +458,7 @@ const THREAD_CONTEXT_ARTIFACT_PAGE_SIZE = 5; const THREAD_CONTEXT_MARKDOWN_ARTIFACT_EXTENSIONS = new Set(["md", "mdown", "mdx", "mkd"]); const LOCAL_SERVER_REFRESH_MS = 2_000; const LOCAL_SERVER_COPY_LINE_LIMIT = 160; -const ANSI_ESCAPE_PATTERN = new RegExp( - `${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, - "g", -); +const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g"); const THREAD_CONTEXT_LOW_VALUE_ARTIFACT_FILENAMES = new Set([ "agents.md", "authors.md", @@ -564,8 +558,7 @@ function localServerPathMatches(root: string | null | undefined, candidate: stri return false; } return ( - normalizedCandidate === normalizedRoot || - normalizedCandidate.startsWith(`${normalizedRoot}/`) + normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) ); } @@ -691,9 +684,7 @@ function buildLocalServerSessionView( title: resolveLocalServerSessionTitle(session, scripts), detail: latestArtifact?.label ?? - (session.metadata?.command - ? friendlyCommandSummary(session.metadata.command) - : session.cwd), + (session.metadata?.command ? friendlyCommandSummary(session.metadata.command) : session.cwd), }; } @@ -2081,13 +2072,18 @@ interface ChatViewProps { surfaceMode?: "single" | "split"; isFocusedPane?: boolean; panelState?: { - panel: "browser" | "diff" | null; + panel: "picker" | "browser" | "diff" | "files" | "terminal" | "side-chat" | null; filesOpen: boolean; diffTurnId: TurnId | null; diffFilePath: string | null; hasOpenedPanel: boolean; - lastOpenPanel: "browser" | "diff"; + lastOpenPanel: "browser" | "diff" | "files" | "terminal" | "side-chat" | null; + lastPanelClosedAt?: number | null; }; + rightPanelOpen?: boolean; + onToggleRightPanel?: () => void; + terminalPanelOpen?: boolean; + onOpenTerminalPanel?: () => void; onToggleDiffPanel?: () => void; onToggleBrowserPanel?: () => void; onToggleFilesPanel?: () => void; @@ -2104,9 +2100,13 @@ export default function ChatView({ surfaceMode = "single", isFocusedPane = true, panelState, + rightPanelOpen = false, + onToggleRightPanel, + terminalPanelOpen = false, + onOpenTerminalPanel, onToggleDiffPanel, onToggleBrowserPanel, - onToggleFilesPanel, + onToggleFilesPanel: _onToggleFilesPanel, onOpenFileViewerPanel, onOpenTurnDiffPanel: _onOpenTurnDiffPanel, floatingComposer = false, @@ -3476,7 +3476,8 @@ export default function ChatView({ return; } const api = readNativeApi(); - const hasIntegratedBrowser = typeof window !== "undefined" && Boolean(window.desktopBridge?.browser); + const hasIntegratedBrowser = + typeof window !== "undefined" && Boolean(window.desktopBridge?.browser); if (!api?.browser || !hasIntegratedBrowser) { window.open(url, "_blank", "noopener,noreferrer"); return; @@ -3499,7 +3500,8 @@ export default function ChatView({ const api = readNativeApi(); const projectId = activeProject?.id; const url = pathToBrowserFileUrl(path, options?.cwd ?? threadWorkspaceCwd ?? undefined); - const hasIntegratedBrowser = typeof window !== "undefined" && Boolean(window.desktopBridge?.browser); + const hasIntegratedBrowser = + typeof window !== "undefined" && Boolean(window.desktopBridge?.browser); if (!api?.browser || !projectId || !hasIntegratedBrowser) { window.open(url, "_blank", "noopener,noreferrer"); return; @@ -3517,11 +3519,6 @@ export default function ChatView({ }, [activeProject?.id, onToggleBrowser, resolvedBrowserPaneOpen, threadWorkspaceCwd], ); - const onToggleFiles = useCallback(() => { - if (onToggleFilesPanel) { - onToggleFilesPanel(); - } - }, [onToggleFilesPanel]); const onOpenFilePath = useCallback( (path: string, options?: { cwd?: string | undefined; displayName?: string | undefined }) => { if (!activeThreadId) { @@ -3602,8 +3599,19 @@ export default function ChatView({ ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; + if (onOpenTerminalPanel) { + onOpenTerminalPanel(); + return; + } setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); + }, [activeThreadId, onOpenTerminalPanel, setTerminalOpen, terminalState.terminalOpen]); + const openTerminalSurface = useCallback(() => { + if (onOpenTerminalPanel) { + onOpenTerminalPanel(); + return; + } + setTerminalOpen(true); + }, [onOpenTerminalPanel, setTerminalOpen]); const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; const terminalId = `terminal-${crypto.randomUUID()}`; @@ -3684,7 +3692,7 @@ export default function ChatView({ ? `terminal-${crypto.randomUUID()}` : baseTerminalId; - setTerminalOpen(true); + openTerminalSurface(); if (shouldCreateNewTerminal) { storeNewTerminal(activeThreadId, targetTerminalId); } else { @@ -3744,7 +3752,7 @@ export default function ChatView({ activeThreadId, gitCwd, isServerThread, - setTerminalOpen, + openTerminalSurface, setThreadError, storeNewTerminal, storeSetActiveTerminal, @@ -4005,7 +4013,10 @@ export default function ChatView({ const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; - const bottomScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight); + const bottomScrollTop = Math.max( + 0, + scrollContainer.scrollHeight - scrollContainer.clientHeight, + ); scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); lastKnownScrollTopRef.current = bottomScrollTop; shouldAutoScrollRef.current = true; @@ -4462,8 +4473,8 @@ export default function ChatView({ if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); + if (!terminalState.terminalOpen && !terminalPanelOpen) { + openTerminalSurface(); } splitTerminal(); return; @@ -4472,7 +4483,7 @@ export default function ChatView({ if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); - if (!terminalState.terminalOpen) return; + if (!terminalState.terminalOpen && !terminalPanelOpen) return; closeTerminal(terminalState.activeTerminalId); return; } @@ -4480,8 +4491,8 @@ export default function ChatView({ if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); - if (!terminalState.terminalOpen) { - setTerminalOpen(true); + if (!terminalState.terminalOpen && !terminalPanelOpen) { + openTerminalSurface(); } createNewTerminal(); return; @@ -4508,10 +4519,11 @@ export default function ChatView({ activeProject, terminalState.terminalOpen, terminalState.activeTerminalId, + terminalPanelOpen, activeThreadId, closeTerminal, createNewTerminal, - setTerminalOpen, + openTerminalSurface, runProjectScript, splitTerminal, keybindings, @@ -6775,10 +6787,9 @@ export default function ChatView({ handoffTargetProviderCount={handoffTargetProviders.length} handoffBadgeSourceProvider={handoffBadgeSourceProvider} handoffBadgeTargetProvider={handoffBadgeTargetProvider} - terminalOpen={terminalState.terminalOpen} - filesRailOpen={filesRailOpen} - browserPaneOpen={resolvedBrowserPaneOpen} - diffOpen={resolvedDiffOpen} + rightPanelOpen={ + rightPanelOpen || resolvedDiffOpen || resolvedBrowserPaneOpen || filesRailOpen + } surfaceMode={surfaceMode} isFocusedPane={isFocusedPane} {...(onSplitSurface ? { onSplitSurface } : {})} @@ -6801,10 +6812,7 @@ export default function ChatView({ } : null } - onToggleTerminal={toggleTerminalVisibility} - onToggleFiles={onToggleFiles} - onToggleDiff={onToggleDiff} - onToggleBrowser={onToggleBrowser} + onToggleRightPanel={onToggleRightPanel ?? onToggleDiff} onCreateHandoff={onCreateProviderHandoffThread} onCycleHandoffTargetProvider={onCycleHandoffTargetProvider} /> @@ -7857,7 +7865,7 @@ export default function ChatView({ {(() => { - if (!terminalState.terminalOpen || !activeProject) { + if (!terminalState.terminalOpen || !activeProject || onOpenTerminalPanel) { return null; } return ( @@ -8035,9 +8043,15 @@ function LocalServersPopoverControl({ const terminalSessionsSnapshot = terminalSessionsData ?? cachedTerminalSessions; const hasLoadedTerminalSessions = terminalSessionsSnapshot !== null; const isInitialServerCheckPending = - popoverOpen && terminalSessionsLoading && terminalSessionsData === undefined && !hasLoadedTerminalSessions; + popoverOpen && + terminalSessionsLoading && + terminalSessionsData === undefined && + !hasLoadedTerminalSessions; const hasServerCheckError = - popoverOpen && terminalSessionsError && terminalSessionsData === undefined && !hasLoadedTerminalSessions; + popoverOpen && + terminalSessionsError && + terminalSessionsData === undefined && + !hasLoadedTerminalSessions; const localServerSessions = useMemo(() => { const sessions = terminalSessionsSnapshot?.sessions ?? []; @@ -8180,11 +8194,7 @@ function LocalServersPopoverControl({ }); useTerminalStateStore .getState() - .setTerminalActivity( - view.session.threadId as ThreadId, - view.session.terminalId, - false, - ); + .setTerminalActivity(view.session.threadId as ThreadId, view.session.terminalId, false); await refetchTerminalSessions(); toastManager.add({ type: "success", @@ -8254,11 +8264,7 @@ function LocalServersPopoverControl({ )} - +

@@ -8425,9 +8431,7 @@ function LocalServersPopoverControl({ {projectScriptIconNode(script.icon)} - - {script.name} - + {script.name}

diff --git a/apps/web/src/components/DesktopBrowserController.tsx b/apps/web/src/components/DesktopBrowserController.tsx index 52ceb5d2c7b..8c3d963d184 100644 --- a/apps/web/src/components/DesktopBrowserController.tsx +++ b/apps/web/src/components/DesktopBrowserController.tsx @@ -141,6 +141,25 @@ export function DesktopBrowserController() { }); }, [navigate]); + useEffect(() => { + let closeRequested = false; + const closeNativeBrowserPane = () => { + if (closeRequested) { + return; + } + closeRequested = true; + const api = readNativeApi(); + void api?.browser?.closePane().catch(() => undefined); + }; + + window.addEventListener("pagehide", closeNativeBrowserPane); + window.addEventListener("beforeunload", closeNativeBrowserPane); + return () => { + window.removeEventListener("pagehide", closeNativeBrowserPane); + window.removeEventListener("beforeunload", closeNativeBrowserPane); + }; + }, []); + useEffect(() => { if (browserRouteThreadIdFromPathname(pathname) !== null) { return; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ab6a225a29e..0fad25cea50 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -72,6 +72,7 @@ import { toastManager } from "./ui/toast"; interface DiffPanelProps { mode?: "inline" | "sheet" | "sidebar"; + hideReviewTabHeader?: boolean; } export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; @@ -166,8 +167,9 @@ function createPdfAnnotationImageAttachment(dataUrl: string): ComposerImageAttac }; } -export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { +export default function DiffPanel({ mode = "inline", hideReviewTabHeader = false }: DiffPanelProps) { const usesDesktopAppChrome = isElectronRuntime(); + const reviewOnly = hideReviewTabHeader; const { resolvedTheme } = useTheme(); const routeThreadId = useParams({ strict: false, @@ -232,7 +234,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, [activeThreadId, diffSearch.diffFilePath, openFile]); const activeFilePath = - filePanelState.activeTab.kind === "file" ? filePanelState.activeTab.path : null; + !reviewOnly && filePanelState.activeTab.kind === "file" ? filePanelState.activeTab.path : null; const activeFileCwd = activeFilePath !== null ? (filePanelState.cwdByFilePath[activeFilePath] ?? activeCwd) @@ -336,6 +338,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ? "h-[var(--app-desktop-content-header-height)]" : "h-11", ); + const showViewerTabHeader = + !reviewOnly && + (!hideReviewTabHeader || + filePanelState.activeTab.kind !== "review" || + filePanelState.openFiles.length > 0); + const showReviewSurface = reviewOnly || filePanelState.activeTab.kind === "review"; return (
-
-
-
-
- { - if (activeThreadId) { - selectReview(activeThreadId); - } - }} - > - Review - - {filePanelState.openFiles.map((filePath) => ( - { - if (activeThreadId) { - openFile(activeThreadId, filePath); - } - }} - onClose={() => { - if (activeThreadId) { - closeFile(activeThreadId, filePath); - } - }} - /> - ))} -
-
-
- {filePanelState.activeTab.kind === "file" && showFileSubheader ? ( -
- {activeFilePath ? ( -
-
- {activeFileDisplayName} -
-
- {breadcrumbs.slice(0, -1).map((segment, index) => ( - - {index > 0 ? ( - - ) : null} - {segment} - - ))} -
-
- ) : ( - File - )} - {activeFilePath ? ( - - - } - > - - - - - - Open in editor - - {isMarkdownFile(activeFilePath) ? ( - { + {showViewerTabHeader || (filePanelState.activeTab.kind === "file" && showFileSubheader) ? ( +
+ {showViewerTabHeader ? ( +
+
+
+ { + if (activeThreadId) { + selectReview(activeThreadId); + } + }} + > + Review + + {filePanelState.openFiles.map((filePath) => ( + { if (activeThreadId) { - toggleMarkdownRichView(activeThreadId, activeFilePath); + openFile(activeThreadId, filePath); } }} - > - {"{}"} - {markdownRichViewEnabled ? "Disable rich view" : "Enable rich view"} - - ) : ( - { + onClose={() => { if (activeThreadId) { - toggleCodeWordWrap(activeThreadId, activeFilePath); + closeFile(activeThreadId, filePath); } }} - > - {"↩"} - {codeWordWrapEnabled ? "Disable word wrap" : "Enable word wrap"} + /> + ))} +
+
+
+ ) : null} + {filePanelState.activeTab.kind === "file" && showFileSubheader ? ( +
+ {activeFilePath ? ( +
+
+ {activeFileDisplayName} +
+
+ {breadcrumbs.slice(0, -1).map((segment, index) => ( + + {index > 0 ? ( + + ) : null} + {segment} + + ))} +
+
+ ) : ( + File + )} + {activeFilePath ? ( + + + } + > + + + + + + Open in editor - )} - - - ) : null} -
- ) : null} -
+ {isMarkdownFile(activeFilePath) ? ( + { + if (activeThreadId) { + toggleMarkdownRichView(activeThreadId, activeFilePath); + } + }} + > + {"{}"} + {markdownRichViewEnabled ? "Disable rich view" : "Enable rich view"} + + ) : ( + { + if (activeThreadId) { + toggleCodeWordWrap(activeThreadId, activeFilePath); + } + }} + > + {"↩"} + {codeWordWrapEnabled ? "Disable word wrap" : "Enable word wrap"} + + )} +
+
+ ) : null} +
+ ) : null} +
+ ) : null}
{!activeThread ? ( - ) : filePanelState.activeTab.kind === "review" ? ( + ) : showReviewSurface ? ( void; children +
+
+ ); +} + export default function IntegratedBrowserPane(props: BrowserPaneProps) { - const { - activeProjectId, - activeThreadId, - expanded = false, - open, - layout = "aside", - onToggleExpanded, - onRequestClose, - } = props; + const { activeProjectId, activeThreadId, open, layout = "aside" } = props; const usesAsideLayout = layout === "aside"; const { settings } = useAppSettings(); const width = useBrowserPaneStore((state) => state.width); @@ -228,6 +251,7 @@ export default function IntegratedBrowserPane(props: BrowserPaneProps) { const viewportRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [urlInput, setUrlInput] = useState(""); + const [nativeOverlayBlocked, setNativeOverlayBlocked] = useState(false); const [containerWidth, setContainerWidth] = useState(() => typeof window === "undefined" ? 0 : window.innerWidth, ); @@ -277,6 +301,18 @@ export default function IntegratedBrowserPane(props: BrowserPaneProps) { [handleBrowserError], ); + useEffect(() => { + const handleNativeOverlayBlock = (event: Event) => { + const blocked = Boolean((event as CustomEvent<{ blocked?: boolean }>).detail?.blocked); + setNativeOverlayBlocked(blocked); + }; + + window.addEventListener(BROWSER_NATIVE_OVERLAY_BLOCK_EVENT, handleNativeOverlayBlock); + return () => { + window.removeEventListener(BROWSER_NATIVE_OVERLAY_BLOCK_EVENT, handleNativeOverlayBlock); + }; + }, []); + useEffect(() => { activeProjectIdRef.current = activeProjectId; activeThreadIdRef.current = activeThreadId; @@ -447,6 +483,19 @@ export default function IntegratedBrowserPane(props: BrowserPaneProps) { return; } + if (nativeOverlayBlocked) { + latestBoundsRequestSeqRef.current += 1; + latestBoundsResponseSeqRef.current = latestBoundsRequestSeqRef.current; + lastDispatchedBoundsRef.current = null; + void api.browser + .open({ + projectId: activeProjectId, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + }) + .catch(() => undefined); + return; + } + let cancelled = false; let animationFrameId: number | null = null; const readBounds = (): BrowserPaneBounds | null => { @@ -528,7 +577,7 @@ export default function IntegratedBrowserPane(props: BrowserPaneProps) { window.removeEventListener("resize", requestBoundsSyncOnNextFrame); window.removeEventListener("scroll", requestBoundsSyncOnNextFrame, true); }; - }, [activeProjectId, api, effectivePaneWidth, open, runBrowserAction]); + }, [activeProjectId, api, effectivePaneWidth, nativeOverlayBlocked, open, runBrowserAction]); const onResizeStart = (event: ReactPointerEvent) => { if (!paneRef.current) { @@ -608,14 +657,6 @@ export default function IntegratedBrowserPane(props: BrowserPaneProps) { }); }; - const requestClose = useCallback(() => { - lastDispatchedBoundsRef.current = null; - latestBoundsRequestSeqRef.current += 1; - latestBoundsResponseSeqRef.current = latestBoundsRequestSeqRef.current; - void api?.browser?.closePane().catch(() => undefined); - onRequestClose(); - }, [api, onRequestClose]); - const browserOpen = open && isDesktopBrowserAvailable && activeProjectId !== null; const controlsDisabled = !browserOpen || !activeProjectId || !api?.browser; @@ -623,175 +664,150 @@ export default function IntegratedBrowserPane(props: BrowserPaneProps) { return null; } - if (!usesAsideLayout) { - return ( -
-
-
- + const browserContent = ( +
+
+
+ + +
+
+
-
-
-
- - - - { - void navigate(nextValue); - }} - disabled={controlsDisabled} - className="h-8 min-w-[120px] flex-1 basis-0 rounded-md border-border bg-muted/40 text-xs" - ariaLabel="Browser URL" - /> -
-
- {onToggleExpanded ? ( - - ) : null} - { - if (!activeProjectId) { - return; + + + -
+ }); + }} + > + +
-
-
-
{ + void navigate(nextValue); + }} + disabled={controlsDisabled || !activeTab} + className="h-8 min-w-[120px] flex-1 basis-0 rounded-lg border-border/70 bg-muted/35 px-3 font-mono text-xs shadow-inner shadow-black/[0.025] focus-visible:bg-background" + ariaLabel="Browser URL" /> - {!activeTab && ( -
- Loading browser... -
- )} +
+ { + if (!activeProjectId) { + return; + } + void runBrowserAction("toggle inspect mode", () => + api.browser.setInspectMode({ projectId: activeProjectId, enabled: next }), + ).then((nextSnapshot) => { + if (nextSnapshot) { + setSnapshot(nextSnapshot); + } + }); + }} + variant="outline" + size="sm" + aria-label="Inspect element" + disabled={controlsDisabled || !activeTab} + className="h-8 rounded-lg border-border/65 bg-background px-2" + > + + +
- ); +
+
+ {!activeTab && } +
+
+ ); + + if (!usesAsideLayout) { + return
{browserContent}
; } return ( @@ -810,172 +826,7 @@ export default function IntegratedBrowserPane(props: BrowserPaneProps) { className="absolute inset-y-0 left-0 z-20 w-1 cursor-col-resize" onPointerDown={onResizeStart} /> -
-
-
- - -
-
-
- - - - { - void navigate(nextValue); - }} - disabled={controlsDisabled} - className="h-8 min-w-[120px] flex-1 basis-0 rounded-md border-border bg-muted/40 text-xs" - ariaLabel="Browser URL" - /> -
-
- {onToggleExpanded ? ( - - ) : null} - { - if (!activeProjectId) { - return; - } - void runBrowserAction("toggle inspect mode", () => - api.browser.setInspectMode({ projectId: activeProjectId, enabled: next }), - ).then((nextSnapshot) => { - if (nextSnapshot) { - setSnapshot(nextSnapshot); - } - }); - }} - variant="outline" - size="sm" - aria-label="Inspect element" - disabled={controlsDisabled} - > - - - -
-
-
-
-
- {!activeTab && ( -
- Loading browser... -
- )} -
-
+ {browserContent} ); } diff --git a/apps/web/src/components/PdfCanvasPreview.tsx b/apps/web/src/components/PdfCanvasPreview.tsx index 4273052ed49..790893349b4 100644 --- a/apps/web/src/components/PdfCanvasPreview.tsx +++ b/apps/web/src/components/PdfCanvasPreview.tsx @@ -8,6 +8,7 @@ import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import { Maximize2Icon, MessageCircleIcon, MinusIcon, PlusIcon } from "lucide-react"; import { type PointerEvent, + type RefObject, type TouchEvent, useCallback, useEffect, @@ -54,6 +55,11 @@ const EMBEDDED_PADDING_X_PX = 24; const WHEEL_ZOOM_SENSITIVITY = 0.002; const RENDER_SCALE_SETTLE_MS = 140; const RENDER_SCALE_QUALITY_GAP = 0.18; +const PDF_RENDER_ROOT_MARGIN = "240px 0px"; +const ESTIMATED_PAGE_SIZE = { + height: 792, + width: 612, +}; function pdfJsMapGetOrInsertComputed( this: Map, @@ -155,6 +161,7 @@ export function PdfCanvasPreview(props: { const [pageSizesByPage, setPageSizesByPage] = useState< Record >({}); + const [renderablePages, setRenderablePages] = useState>(() => new Set([1])); useEffect(() => { const element = viewportRef.current; @@ -179,6 +186,7 @@ export function PdfCanvasPreview(props: { let cancelled = false; setPdfDocument(null); setPageSizesByPage({}); + setRenderablePages(new Set([1])); setError(null); const loadingTask = getDocument({ data: props.bytes.slice(), @@ -208,7 +216,10 @@ export function PdfCanvasPreview(props: { }; }, [props.bytes]); - const pageSizes = useMemo(() => Object.values(pageSizesByPage), [pageSizesByPage]); + const pageSizes = useMemo(() => { + const measuredSizes = Object.values(pageSizesByPage); + return measuredSizes.length > 0 ? measuredSizes : [ESTIMATED_PAGE_SIZE]; + }, [pageSizesByPage]); const fitScales = useMemo(() => { const maxPageWidth = Math.max(...pageSizes.map((size) => size.width), 0); const maxPageHeight = Math.max(...pageSizes.map((size) => size.height), 0); @@ -440,6 +451,17 @@ export function PdfCanvasPreview(props: { }); }, []); + const markPageRenderable = useCallback((pageNumber: number) => { + setRenderablePages((current) => { + if (current.has(pageNumber)) { + return current; + } + const next = new Set(current); + next.add(pageNumber); + return next; + }); + }, []); + if (error) { return ; } @@ -572,12 +594,15 @@ export function PdfCanvasPreview(props: { document={pdfDocument} pageNumber={pageNumber} renderScale={renderScale} + scrollRootRef={scrollAreaRef} + shouldRender={renderablePages.has(pageNumber)} visualScale={effectiveScale} annotationMode={annotationMode} annotationMarkers={annotationMarkers.filter( (marker) => marker.pageNumber === pageNumber, )} filePath={props.filePath} + onPageNearViewport={markPageRenderable} onPageSize={recordPageSize} onAnnotationCapture={canAnnotate ? handleAnnotationCapture : undefined} /> @@ -596,8 +621,11 @@ function PdfPage(props: { filePath: string; pageNumber: number; renderScale: number; + scrollRootRef: RefObject; + shouldRender: boolean; visualScale: number; onAnnotationCapture?: ((capture: PdfAnnotationCapture) => void) | undefined; + onPageNearViewport: (pageNumber: number) => void; onPageSize: (pageNumber: number, size: { width: number; height: number }) => void; }) { const { @@ -606,16 +634,21 @@ function PdfPage(props: { document, filePath, onAnnotationCapture, + onPageNearViewport, onPageSize, pageNumber, renderScale, + scrollRootRef, + shouldRender, visualScale, } = props; const canvasRef = useRef(null); const pageRootRef = useRef(null); const selectionPointerIdRef = useRef(null); const hasRenderedPageRef = useRef(false); - const [renderState, setRenderState] = useState<"loading" | "rendered" | "error">("loading"); + const [renderState, setRenderState] = useState<"queued" | "loading" | "rendered" | "error">( + shouldRender ? "loading" : "queued", + ); const [errorMessage, setErrorMessage] = useState(null); const [basePageSize, setBasePageSize] = useState<{ height: number; width: number } | null>(null); const [draftSelection, setDraftSelection] = useState<{ @@ -626,6 +659,41 @@ function PdfPage(props: { } | null>(null); useEffect(() => { + const root = pageRootRef.current; + const scrollRoot = scrollRootRef.current; + if (!root) { + return; + } + if (pageNumber === 1 || typeof IntersectionObserver === "undefined" || !scrollRoot) { + onPageNearViewport(pageNumber); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + onPageNearViewport(pageNumber); + observer.disconnect(); + } + }, + { + root: scrollRoot, + rootMargin: PDF_RENDER_ROOT_MARGIN, + threshold: 0, + }, + ); + observer.observe(root); + return () => observer.disconnect(); + }, [onPageNearViewport, pageNumber, scrollRootRef]); + + useEffect(() => { + if (!shouldRender) { + if (!hasRenderedPageRef.current) { + setRenderState("queued"); + } + return; + } + let cancelled = false; const canvas = canvasRef.current; if (!canvas) { @@ -704,29 +772,27 @@ function PdfPage(props: { cancelled = true; cleanupRender?.(); }; - }, [document, onPageSize, pageNumber, renderScale]); + }, [document, onPageSize, pageNumber, renderScale, shouldRender]); - const visualPageSize = basePageSize - ? { - height: Math.ceil(basePageSize.height * visualScale), - width: Math.ceil(basePageSize.width * visualScale), - } + const effectivePageSize = basePageSize ?? ESTIMATED_PAGE_SIZE; + const visualPageSize = { + height: Math.ceil(effectivePageSize.height * visualScale), + width: Math.ceil(effectivePageSize.width * visualScale), + }; + const draftBoundingBox = draftSelection + ? normalizeCssRectToBoundingBox({ + x1: draftSelection.startX, + y1: draftSelection.startY, + x2: draftSelection.currentX, + y2: draftSelection.currentY, + width: visualPageSize.width, + height: visualPageSize.height, + }) : null; - const draftBoundingBox = - draftSelection && visualPageSize - ? normalizeCssRectToBoundingBox({ - x1: draftSelection.startX, - y1: draftSelection.startY, - x2: draftSelection.currentX, - y2: draftSelection.currentY, - width: visualPageSize.width, - height: visualPageSize.height, - }) - : null; const handleAnnotationPointerDown = useCallback( (event: PointerEvent) => { - if (!annotationMode || !onAnnotationCapture || event.button !== 0) { + if (!annotationMode || renderState !== "rendered" || !onAnnotationCapture || event.button !== 0) { return; } @@ -749,7 +815,7 @@ function PdfPage(props: { currentY: startY, }); }, - [annotationMode, onAnnotationCapture], + [annotationMode, onAnnotationCapture, renderState], ); const handleAnnotationPointerMove = useCallback( @@ -829,8 +895,8 @@ function PdfPage(props: { ref={pageRootRef} className="relative max-w-full rounded-md border border-border/55 bg-white shadow-sm transition-[width] duration-150 ease-out" style={{ - aspectRatio: basePageSize ? `${basePageSize.width} / ${basePageSize.height}` : undefined, - width: visualPageSize ? `${visualPageSize.width}px` : "min(100%, 820px)", + aspectRatio: `${effectivePageSize.width} / ${effectivePageSize.height}`, + width: `${visualPageSize.width}px`, }} onPointerCancel={finishAnnotationSelection} onPointerDown={handleAnnotationPointerDown} @@ -849,7 +915,9 @@ function PdfPage(props: {
{renderState === "error" ? (errorMessage ?? "Unable to render this page.") - : "Rendering page..."} + : renderState === "queued" + ? "Page preview queued" + : "Rendering page..."}
) : null} {annotationMarkers.map((marker, markerIndex) => ( diff --git a/apps/web/src/components/RightSidebarWorkspace.tsx b/apps/web/src/components/RightSidebarWorkspace.tsx new file mode 100644 index 00000000000..024fe462207 --- /dev/null +++ b/apps/web/src/components/RightSidebarWorkspace.tsx @@ -0,0 +1,329 @@ +import { + useEffect, + useMemo, + useState, + type ComponentType, + type DragEvent, + type ReactNode, +} from "react"; +import { Maximize2Icon, Minimize2Icon, PanelRightCloseIcon, PlusIcon, XIcon } from "lucide-react"; + +import type { ChatRightPanel } from "../diffRouteSearch"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; + +export type RightSidebarWorkspaceTabId = Exclude; +const BROWSER_NATIVE_OVERLAY_BLOCK_EVENT = "t3code:browser-native-overlay-block"; + +export interface RightSidebarWorkspaceTab { + id: RightSidebarWorkspaceTabId; + label: string; + Icon: ComponentType<{ className?: string }>; + keepMounted?: boolean; + render: () => ReactNode; +} + +interface RightSidebarWorkspaceProps { + activeTab: ChatRightPanel; + tabs: ReadonlyArray; + onSelectTab: (tab: RightSidebarWorkspaceTabId) => void; + onOpenPicker: () => void; + onClose: () => void; + expanded?: boolean; + onToggleExpanded?: (() => void) | undefined; + className?: string; +} + +export default function RightSidebarWorkspace(props: RightSidebarWorkspaceProps) { + const tabsById = useMemo(() => new Map(props.tabs.map((tab) => [tab.id, tab])), [props.tabs]); + const [openTabIds, setOpenTabIds] = useState(() => + isWorkspaceTabId(props.activeTab) ? [props.activeTab] : [], + ); + const [draggedTabId, setDraggedTabId] = useState(null); + const [addTabMenuOpen, setAddTabMenuOpen] = useState(false); + const activeTab = tabsById.get(props.activeTab as RightSidebarWorkspaceTabId) ?? null; + const showingPicker = props.activeTab === "picker"; + const openTabs = openTabIds.map((id) => tabsById.get(id)).filter(isDefined); + const hiddenTabs = props.tabs.filter((tab) => !openTabIds.includes(tab.id)); + const mountedTabs = openTabs.filter( + (tab) => tab.keepMounted !== false || (!showingPicker && props.activeTab === tab.id), + ); + const renderedTabs = + !showingPicker && activeTab && !mountedTabs.some((tab) => tab.id === activeTab.id) + ? [...mountedTabs, activeTab] + : mountedTabs; + + useEffect(() => { + setOpenTabIds((currentIds) => { + const validIds = currentIds.filter((id) => tabsById.has(id)); + if (!isWorkspaceTabId(props.activeTab) || validIds.includes(props.activeTab)) { + return arraysEqual(validIds, currentIds) ? currentIds : validIds; + } + return [...validIds, props.activeTab]; + }); + }, [props.activeTab, tabsById]); + + useEffect(() => { + const blocked = addTabMenuOpen && props.activeTab === "browser"; + window.dispatchEvent( + new CustomEvent(BROWSER_NATIVE_OVERLAY_BLOCK_EVENT, { detail: { blocked } }), + ); + return () => { + if (blocked) { + window.dispatchEvent( + new CustomEvent(BROWSER_NATIVE_OVERLAY_BLOCK_EVENT, { detail: { blocked: false } }), + ); + } + }; + }, [addTabMenuOpen, props.activeTab]); + + const addTab = (tabId: RightSidebarWorkspaceTabId) => { + setOpenTabIds((currentIds) => + currentIds.includes(tabId) ? currentIds : [...currentIds, tabId], + ); + props.onSelectTab(tabId); + }; + + const closeTab = (tabId: RightSidebarWorkspaceTabId) => { + const closedIndex = openTabIds.indexOf(tabId); + const nextOpenTabIds = openTabIds.filter((id) => id !== tabId); + setOpenTabIds(nextOpenTabIds); + + if (props.activeTab !== tabId) { + return; + } + + const nextActiveTab = nextOpenTabIds[Math.min(closedIndex, nextOpenTabIds.length - 1)] ?? null; + if (nextActiveTab) { + props.onSelectTab(nextActiveTab); + return; + } + props.onOpenPicker(); + }; + + const moveTab = (sourceId: RightSidebarWorkspaceTabId, targetId: RightSidebarWorkspaceTabId) => { + if (sourceId === targetId) return; + setOpenTabIds((currentIds) => { + const sourceIndex = currentIds.indexOf(sourceId); + const targetIndex = currentIds.indexOf(targetId); + if (sourceIndex < 0 || targetIndex < 0) return currentIds; + const nextIds = currentIds.filter((id) => id !== sourceId); + nextIds.splice(targetIndex, 0, sourceId); + return nextIds; + }); + }; + + const handleTabDrop = ( + event: DragEvent, + targetId: RightSidebarWorkspaceTabId, + ) => { + event.preventDefault(); + const sourceId = parseWorkspaceTabId(event.dataTransfer.getData("text/plain")) ?? draggedTabId; + if (sourceId) { + moveTab(sourceId, targetId); + } + setDraggedTabId(null); + }; + + return ( +
+
+
+ {openTabs.map(({ id, label, Icon }) => ( +
setDraggedTabId(null)} + onDragOver={(event) => event.preventDefault()} + onDragStart={(event) => { + setDraggedTabId(id); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", id); + }} + onDrop={(event) => handleTabDrop(event, id)} + > + + +
+ ))} + + + + + } + /> + + {hiddenTabs.length > 0 ? ( + hiddenTabs.map(({ id, label, Icon }) => ( + addTab(id)}> + + {label} + + )) + ) : ( + All tabs are open + )} + + +
+
+ {props.onToggleExpanded ? ( + + ) : null} + +
+
+
+ {showingPicker ? ( + 0 ? hiddenTabs : props.tabs} + onSelectTab={addTab} + /> + ) : activeTab === null ? ( +
+ Choose a workspace tab. +
+ ) : null} + {renderedTabs.map((tab) => ( +
+ {tab.render()} +
+ ))} +
+
+ ); +} + +function isWorkspaceTabId(value: ChatRightPanel): value is RightSidebarWorkspaceTabId { + return value !== "picker"; +} + +function parseWorkspaceTabId(value: string): RightSidebarWorkspaceTabId | null { + if ( + value === "diff" || + value === "terminal" || + value === "browser" || + value === "files" || + value === "side-chat" + ) { + return value; + } + return null; +} + +function isDefined(value: T | undefined): value is T { + return value !== undefined; +} + +function arraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function RightSidebarWorkspacePicker(props: { + tabs: ReadonlyArray; + onSelectTab: (tab: RightSidebarWorkspaceTabId) => void; +}) { + return ( +
+
+ {props.tabs.map(({ id, label, Icon }) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.browser.tsx b/apps/web/src/components/Sidebar.browser.tsx index d1689f19d22..9f52cd699c3 100644 --- a/apps/web/src/components/Sidebar.browser.tsx +++ b/apps/web/src/components/Sidebar.browser.tsx @@ -762,7 +762,8 @@ function createDesktopBrowserBridge(projectId: ProjectId): DesktopBridge["browse closePane: async () => undefined, newTab: async () => { const tabId = `tab-${tabs.length + 1}`; - const base = buildSnapshot().tabs?.[0]; + const base = + buildSnapshot().tabs?.[0] ?? createDesktopBrowserSnapshot(projectId, paneBounds).tabs?.[0]; if (!base) { return buildSnapshot(); } @@ -790,12 +791,6 @@ function createDesktopBrowserBridge(projectId: ProjectId): DesktopBridge["browse }, closeTab: async (input) => { tabs = tabs.filter((tab) => tab.tabId !== input.tabId); - if (tabs.length === 0) { - const fallback = createDesktopBrowserSnapshot(projectId, paneBounds).tabs?.[0]; - if (fallback) { - tabs = [fallback]; - } - } if (!tabs.some((tab) => tab.tabId === activeTabId)) { activeTabId = tabs[0]?.tabId ?? null; } diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index cce2ed13382..1633b89b1ee 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -437,6 +437,7 @@ interface ThreadTerminalDrawerProps { threadId: ThreadId; cwd: string; runtimeEnv?: Record; + layout?: "drawer" | "panel"; height: number; terminalIds: string[]; activeTerminalId: string; @@ -486,6 +487,7 @@ export default function ThreadTerminalDrawer({ threadId, cwd, runtimeEnv, + layout = "drawer", height, terminalIds, activeTerminalId, @@ -726,18 +728,24 @@ export default function ThreadTerminalDrawer({ }; }, [syncHeight]); + const isPanelLayout = layout === "panel"; + return (