diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f26f2cd1ae4e8c05fec81a63ebfc650444ffd173..0d18b4c71f521222f55d6916709f09c63b0c3319 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,7 @@ "effect": "catalog:", "electron": "41.5.0", "electron-updater": "^6.6.2", + "playwright-core": "1.60.0", "react-grab": "^0.1.32" }, "devDependencies": { diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 11832664d88c32080902b024042dd1349ea35710..fe7b5b6f839c80ee8b6fbfa7918e6fa7a143ebaa 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -56,6 +56,7 @@ export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; +export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; @@ -64,4 +65,8 @@ export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-pres export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; +export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; +export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; +export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; +export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 904cef30b41216d7245da1073f4f81a761bd14f7..8683cd9d0b68010e07ccbc6eed661f8d51e0557a 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -17,6 +17,14 @@ previewViewManager.onStateChange((tabId, state) => { } }); +previewViewManager.onRecordingFrame((frame) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); + } + } +}); + const tabIdFrom = (raw: unknown): string => { if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { throw new Error("preview tab id is required"); @@ -95,10 +103,16 @@ export const previewMethods = [ ), method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()), - method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, () => { + method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, (raw) => { + const environmentId = + typeof raw === "object" && raw !== null && "environmentId" in raw ? raw.environmentId : null; + if (typeof environmentId !== "string" || environmentId.length === 0) { + throw new Error("preview environment id is required"); + } + previewViewManager.getBrowserSession(environmentId); const preloadPath = `${__dirname}/preview-pick-preload.cjs`; return { - partition: previewViewManager.getBrowserPartition(), + partition: previewViewManager.getBrowserPartition(environmentId), webPreferences: PREVIEW_WEBVIEW_PREFERENCES, preloadUrl: pathToFileURL(preloadPath).href, }; @@ -109,6 +123,9 @@ export const previewMethods = [ method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => previewViewManager.cancelPickElement(tabIdFrom(raw)), ), + method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => + previewViewManager.captureScreenshot(tabIdFrom(raw)), + ), method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => previewViewManager.automationStatus(tabIdFrom(raw)), ), @@ -151,4 +168,20 @@ export const previewMethods = [ inputFrom(raw) as Parameters[1], ), ), + method(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, (raw) => + previewViewManager.startRecording(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, (raw) => + previewViewManager.stopRecording(tabIdFrom(raw)), + ), + method(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, (raw) => { + const tabId = tabIdFrom(raw); + if (typeof raw !== "object" || raw === null) throw new Error("recording payload is required"); + const mimeType = "mimeType" in raw ? raw.mimeType : null; + const data = "data" in raw ? raw.data : null; + if (typeof mimeType !== "string" || !(data instanceof Uint8Array)) { + throw new Error("recording mimeType and bytes are required"); + } + return previewViewManager.saveRecording(tabId, mimeType, data); + }), ] as const; diff --git a/apps/desktop/src/playwright-injected-runtime.test.ts b/apps/desktop/src/playwright-injected-runtime.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..20692063672b4e535ccaa236733577f9eedc763b --- /dev/null +++ b/apps/desktop/src/playwright-injected-runtime.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + playwrightInjectedRuntimeInstallExpression, + playwrightInjectedRuntimeSource, +} from "./playwright-injected-runtime.ts"; + +describe("playwright injected runtime", () => { + it("extracts the pinned runtime from playwright-core", async () => { + const source = await playwrightInjectedRuntimeSource(); + expect(source.length).toBeGreaterThan(100_000); + expect(source).toContain("InjectedScript"); + }); + + it("builds an idempotent install expression", async () => { + const expression = await playwrightInjectedRuntimeInstallExpression(); + expect(expression).toContain("__t3PlaywrightInjected"); + expect(expression).toContain('testIdAttributeName":"data-testid'); + }); +}); diff --git a/apps/desktop/src/playwright-injected-runtime.ts b/apps/desktop/src/playwright-injected-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4d7c6bd9402227e77763d866ba5cfa347bd8544 --- /dev/null +++ b/apps/desktop/src/playwright-injected-runtime.ts @@ -0,0 +1,49 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { readFile } from "node:fs/promises"; +import { runInNewContext } from "node:vm"; + +const require = createRequire(import.meta.url); +let sourcePromise: Promise | null = null; + +export const playwrightInjectedRuntimeSource = (): Promise => { + sourcePromise ??= (async () => { + const packageJsonPath = require.resolve("playwright-core/package.json"); + const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"); + const marker = "source3 = "; + const start = coreBundle.indexOf(marker); + if (start < 0) throw new Error("Playwright injected runtime marker was not found."); + const literalStart = start + marker.length; + const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); + if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found."); + const literal = coreBundle.slice(literalStart, literalEnd); + const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 }); + if (typeof source !== "string" || source.length < 100_000) { + throw new Error("Playwright injected runtime extraction returned invalid source."); + } + return source; + })(); + return sourcePromise; +}; + +export const playwrightInjectedRuntimeInstallExpression = async (): Promise => { + const source = await playwrightInjectedRuntimeSource(); + const options = { + isUnderTest: false, + sdkLanguage: "javascript", + testIdAttributeName: "data-testid", + stableRafCount: 1, + browserName: "chromium", + shouldPrependErrorPrefix: false, + isUtilityWorld: false, + customEngines: [], + }; + return `(() => { + if (globalThis.__t3PlaywrightInjected) return true; + const module = { exports: {} }; + ${source} + globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)}); + return true; + })()`; +}; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 8f890c27e781c4529686959b2e90edd07c8956a7..8326673a862872caef7183945f2ae4d07180e13f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,4 +1,8 @@ -import type { DesktopBridge, DesktopPreviewTabState } from "@t3tools/contracts"; +import type { + DesktopBridge, + DesktopPreviewRecordingFrame, + DesktopPreviewTabState, +} from "@t3tools/contracts"; import { contextBridge, ipcRenderer } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -159,10 +163,34 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), - getPreviewConfig: () => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL), + getPreviewConfig: (environmentId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), cancelPickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), + captureScreenshot: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }), + recording: { + startScreencast: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), + stopScreencast: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, { tabId }), + save: (tabId, mimeType, data) => + ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, { + tabId, + mimeType, + data, + }), + onFrame: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { + if (typeof frame !== "object" || frame === null) return; + listener(frame as DesktopPreviewRecordingFrame); + }; + ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); + }, + }, automation: { status: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index be4df208ed891fb8233584a11d555617a63ae856..7ca1da41b59603165dc56d62d71d895d2fd5e013 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -16,6 +16,7 @@ const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +const HUMAN_INPUT_CHANNEL = "preview:human-input"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const Z_INDEX_OVERLAY = 2147483646; const ACCENT = "#7c3aed"; @@ -42,6 +43,13 @@ interface AnnotationSession { let activeSession: AnnotationSession | null = null; let idSequence = 0; +const reportHumanInput = (event: Event): void => { + if (event.isTrusted) ipcRenderer.send(HUMAN_INPUT_CHANNEL); +}; + +window.addEventListener("pointerdown", reportHumanInput, true); +window.addEventListener("keydown", reportHumanInput, true); + const nextId = (prefix: string): string => { idSequence += 1; return `${prefix}_${idSequence.toString(36)}`; diff --git a/apps/desktop/src/preview-view-manager.test.ts b/apps/desktop/src/preview-view-manager.test.ts index 54d33a8569986adebeb7d0f73b8bda64687e830e..c704ae6630c8343ca6504c1b0434b8a65b203675 100644 --- a/apps/desktop/src/preview-view-manager.test.ts +++ b/apps/desktop/src/preview-view-manager.test.ts @@ -1,8 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const fromId = vi.fn(() => null); +const mkdir = vi.fn(async () => undefined); +const writeFile = vi.fn(async () => undefined); + +vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp/t3-code-test"), + }, session: { fromPartition: vi.fn(), }, @@ -14,6 +21,8 @@ vi.mock("electron", () => ({ describe("PreviewViewManager automation status", () => { beforeEach(() => { fromId.mockClear(); + mkdir.mockClear(); + writeFile.mockClear(); }); it("reports an unregistered webview as temporarily unavailable", async () => { @@ -41,4 +50,53 @@ describe("PreviewViewManager automation status", () => { }); expect(fromId).not.toHaveBeenCalled(); }); + + it("captures a PNG screenshot into browser artifacts", async () => { + const png = Buffer.from("preview-png"); + const capturePage = vi.fn(async () => ({ toPNG: () => png })); + const listeners = new Map void>(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com/", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + } as never); + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + manager.createTab("tab_1"); + manager.registerWebview("tab_1", 42); + + const artifact = await manager.captureScreenshot("tab_1"); + + expect(capturePage).toHaveBeenCalledOnce(); + expect(mkdir).toHaveBeenCalledWith("/tmp/t3-code-test/browser-artifacts", { + recursive: true, + }); + expect(writeFile).toHaveBeenCalledWith(artifact.path, png); + expect(artifact).toMatchObject({ + tabId: "tab_1", + mimeType: "image/png", + sizeBytes: png.byteLength, + }); + expect(artifact.path).toMatch(/\/browser-artifacts\/browser-screenshot-[^.]+\.png$/); + }); }); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts index ce8f9964529470ec191fb6630a79f27cf5203fd0..d740334207c460ee04dd6b3c23f484fab29de689 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview-view-manager.ts @@ -1,4 +1,5 @@ // @effect-diagnostics globalDate:off +// @effect-diagnostics nodeBuiltinImport:off /** * PreviewViewManager — desktop side of the in-app browser preview. * @@ -9,9 +10,15 @@ import type { PreviewAnnotationPayload, PreviewAnnotationRect, + DesktopPreviewRecordingArtifact, + DesktopPreviewRecordingFrame, + DesktopPreviewScreenshotArtifact, PreviewAutomationClickInput, + PreviewAutomationActionEvent, + PreviewAutomationConsoleEntry, PreviewAutomationEvaluateInput, PreviewAutomationPressInput, + PreviewAutomationNetworkEntry, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationStatus, @@ -19,16 +26,21 @@ import type { PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; -import { type BrowserWindow, type Session, session, webContents } from "electron"; +import { app, type BrowserWindow, type Session, session, webContents } from "electron"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; +import { playwrightInjectedRuntimeInstallExpression } from "./playwright-injected-runtime.ts"; -const PREVIEW_PARTITION = "persist:t3code-preview"; +const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +const HUMAN_INPUT_CHANNEL = "preview:human-input"; // Re-export the guest webview security posture from its dedicated module so // the constant is unit-testable in isolation. See @@ -55,6 +67,7 @@ export interface PreviewTabState { canGoBack: boolean; canGoForward: boolean; zoomFactor: number; + controller: "human" | "agent" | "none"; updatedAt: string; } @@ -69,6 +82,7 @@ const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_SCREENSHOT_WIDTH = 1280; +const DIAGNOSTIC_BUFFER_LIMIT = 200; interface CdpEvaluationResult { readonly result?: { @@ -86,7 +100,8 @@ const automationError = ( | "PreviewAutomationExecutionError" | "PreviewAutomationInvalidSelectorError" | "PreviewAutomationResultTooLargeError" - | "PreviewAutomationTimeoutError", + | "PreviewAutomationTimeoutError" + | "PreviewAutomationControlInterruptedError", message: string, detail?: unknown, ): Error & { detail?: unknown } => { @@ -165,10 +180,12 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { }; type Listener = (tabId: string, state: PreviewTabState) => void; +type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; interface ManagedListeners { navigate: () => void; failed: (event: Event, code: number, description: string) => void; + humanInput: () => void; } interface PickSession { @@ -176,6 +193,18 @@ interface PickSession { readonly cleanup: () => void; } +interface BrowserControlSession { + readonly webContentsId: number; + tail: Promise; + initialized: Promise; +} + +interface BrowserDiagnostics { + readonly consoleEntries: PreviewAutomationConsoleEntry[]; + readonly networkEntries: PreviewAutomationNetworkEntry[]; + readonly requests: Map; +} + const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ key: string; meta: boolean; @@ -196,17 +225,30 @@ export class PreviewViewManager { private mainWindow: BrowserWindow | null = null; private readonly tabs = new Map(); private readonly attached = new Map(); - private browserSession: Session | null = null; + private readonly browserSessions = new Map(); private readonly listeners = new Set(); + private readonly recordingFrameListeners = new Set(); /** In-flight preview annotation sessions, keyed by tabId. */ private readonly pickSessions = new Map(); + /** One long-lived CDP attachment and serialized command queue per guest. */ + private readonly controlSessions = new Map(); + private readonly diagnostics = new Map(); + private readonly controlEpoch = new Map(); + private readonly actionTimeline = new Map(); + private actionSequence = 0; + private recordingTabId: string | null = null; setMainWindow(window: BrowserWindow): void { this.mainWindow = window; } - getBrowserPartition(): string { - return PREVIEW_PARTITION; + getBrowserPartition(scope = "shared"): string { + const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20); + return `${PREVIEW_PARTITION_PREFIX}${digest}`; + } + + isBrowserPartition(partition: string): boolean { + return partition.startsWith(PREVIEW_PARTITION_PREFIX); } /** @@ -219,9 +261,11 @@ export class PreviewViewManager { return PREVIEW_WEBVIEW_PREFERENCES; } - getBrowserSession(): Session { - if (this.browserSession) return this.browserSession; - const sess = session.fromPartition(PREVIEW_PARTITION); + getBrowserSession(scope = "shared"): Session { + const partition = this.getBrowserPartition(scope); + const existing = this.browserSessions.get(partition); + if (existing) return existing; + const sess = session.fromPartition(partition); const ua = sess .getUserAgent() .replace(/Electron\/[\d.]+ /, "") @@ -231,7 +275,7 @@ export class PreviewViewManager { const allow = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; callback(allow.includes(perm)); }); - this.browserSession = sess; + this.browserSessions.set(partition, sess); return sess; } @@ -245,6 +289,7 @@ export class PreviewViewManager { canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", updatedAt: new Date().toISOString(), }; this.tabs.set(tabId, initial); @@ -257,6 +302,7 @@ export class PreviewViewManager { if (!tab) return; this.cancelPickElement(tabId); if (tab.webContentsId != null) { + this.detachControlSession(tab.webContentsId); this.detachListeners(tab.webContentsId); } const closed: PreviewTabState = { @@ -266,6 +312,7 @@ export class PreviewViewManager { canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, + controller: "none", updatedAt: new Date().toISOString(), }; this.tabs.delete(tabId); @@ -294,6 +341,7 @@ export class PreviewViewManager { return; } if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { + this.detachControlSession(tab.webContentsId); this.detachListeners(tab.webContentsId); // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. // Cancel it so the toggle button doesn't get stuck pressed waiting @@ -301,6 +349,7 @@ export class PreviewViewManager { this.cancelPickElement(tabId); } this.attachListeners(tabId, wc); + void this.ensureControlSession(wc).catch(() => undefined); // Restore the persisted zoom factor onto the freshly-attached WebContents // so a thread-switch + remount lands the user back where they were. if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { @@ -364,6 +413,10 @@ export class PreviewViewManager { wc.devToolsWebContents?.focus(); return; } + this.detachControlSession(wc.id); + wc.once("devtools-closed", () => { + if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined); + }); wc.openDevTools({ mode: "detach" }); } @@ -372,16 +425,18 @@ export class PreviewViewManager { * preview tab since they all share `persist:t3code-preview`. */ async clearCookies(): Promise { - const sess = this.getBrowserSession(); - await sess.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], - }); + await Promise.all( + [...this.browserSessions.values()].map((sess) => + sess.clearStorageData({ + storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + }), + ), + ); } /** Drop the HTTP cache for the preview partition. */ async clearCache(): Promise { - const sess = this.getBrowserSession(); - await sess.clearCache(); + await Promise.all([...this.browserSessions.values()].map((sess) => sess.clearCache())); } /** @@ -485,6 +540,65 @@ export class PreviewViewManager { session.resolve(null); } + async startRecording(tabId: string): Promise { + if (this.recordingTabId && this.recordingTabId !== tabId) { + throw new Error("Only one browser recording can be active per window."); + } + const wc = this.requireWebContents(tabId); + await this.withControlSession(tabId, wc, "recording.start", async (send) => { + await send("Page.enable"); + await send("Page.startScreencast", { + format: "jpeg", + quality: 80, + maxWidth: 1600, + maxHeight: 1200, + everyNthFrame: 1, + }); + }); + this.recordingTabId = tabId; + } + + async captureScreenshot(tabId: string): Promise { + const wc = this.requireWebContents(tabId); + const createdAt = new Date().toISOString(); + const id = `browser-screenshot-${Date.now().toString(36)}`; + const directory = join(app.getPath("userData"), "browser-artifacts"); + const path = join(directory, `${id}.png`); + const data = (await wc.capturePage()).toPNG(); + await mkdir(directory, { recursive: true }); + await writeFile(path, data); + return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt }; + } + + async stopRecording(tabId: string): Promise { + if (this.recordingTabId !== tabId) return; + const wc = this.requireWebContents(tabId); + await this.withControlSession(tabId, wc, "recording.stop", async (send) => { + await send("Page.stopScreencast"); + }); + this.recordingTabId = null; + } + + async saveRecording( + tabId: string, + mimeType: string, + data: Uint8Array, + ): Promise { + const createdAt = new Date().toISOString(); + const id = `browser-recording-${Date.now().toString(36)}`; + const extension = mimeType.includes("mp4") ? "mp4" : "webm"; + const directory = join(app.getPath("userData"), "browser-artifacts"); + const path = join(directory, `${id}.${extension}`); + await mkdir(directory, { recursive: true }); + await writeFile(path, data); + return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt }; + } + + onRecordingFrame(listener: RecordingFrameListener): () => void { + this.recordingFrameListeners.add(listener); + return () => this.recordingFrameListeners.delete(listener); + } + zoomIn(tabId: string): void { this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); } @@ -533,7 +647,7 @@ export class PreviewViewManager { async automationSnapshot(tabId: string): Promise { const wc = this.requireWebContents(tabId); - return this.withDebugger(wc, async (send) => { + return this.withControlSession(tabId, wc, "snapshot", async (send) => { await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); const page = await this.evaluateWithDebugger<{ url: string; @@ -604,6 +718,9 @@ export class PreviewViewManager { return { ...page, accessibilityTree: accessibility, + consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])], + networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])], + actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])], screenshot: { mimeType: "image/png", data: image.toPNG().toString("base64"), @@ -616,22 +733,29 @@ export class PreviewViewManager { async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise { const wc = this.requireWebContents(tabId); - await this.withDebugger(wc, async (send) => { + await this.withControlSession(tabId, wc, "click", async (send) => { await Promise.all([ send("Runtime.enable"), send("Input.setIgnoreInputEvents", { ignore: false }), ]); let x: number; let y: number; - if ("selector" in input) { + if ("selector" in input || "locator" in input) { + await this.ensurePlaywrightInjected(send); + const locator = this.automationLocator(input); const point = await this.evaluateWithDebugger< { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } >( send, `(() => { try { - const element = document.querySelector(${JSON.stringify(input.selector)}); + const injected = globalThis.__t3PlaywrightInjected; + const parsed = injected.parseSelector(${JSON.stringify(locator)}); + const element = injected.querySelector(parsed, document, true); if (!element) return { notFound: true }; + const visible = injected.elementState(element, "visible"); + const enabled = injected.elementState(element, "enabled"); + if (!visible.matches || !enabled.matches) return { notFound: true }; element.scrollIntoView({ block: "center", inline: "center" }); const rect = element.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; @@ -643,20 +767,20 @@ export class PreviewViewManager { ); if ("invalidSelector" in point) { throw automationError("PreviewAutomationInvalidSelectorError", point.message, { - selector: input.selector, + selector: locator, }); } if ("notFound" in point) { throw automationError( "PreviewAutomationExecutionError", - `No element matches selector ${input.selector}.`, + `No element matches locator ${locator}.`, ); } x = point.x; y = point.y; } else { - x = input.x; - y = input.y; + x = input.x!; + y = input.y!; } const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( send, @@ -688,19 +812,17 @@ export class PreviewViewManager { async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise { const wc = this.requireWebContents(tabId); - await this.withDebugger(wc, async (send) => { + await this.withControlSession(tabId, wc, "type", async (send) => { await send("Runtime.enable"); + const locator = this.automationLocator(input); + if (locator) await this.ensurePlaywrightInjected(send); const focusResult = await this.evaluateWithDebugger< { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( send, `(() => { try { - const element = ${ - input.selector - ? `document.querySelector(${JSON.stringify(input.selector)})` - : "document.activeElement" - }; + const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"}; if (!element) return { notFound: true }; element.focus(); if (${input.clear ?? false}) { @@ -723,8 +845,8 @@ export class PreviewViewManager { if ("notFound" in focusResult) { throw automationError( "PreviewAutomationExecutionError", - input.selector - ? `No element matches selector ${input.selector}.` + locator + ? `No element matches locator ${locator}.` : "No element is focused in the preview.", ); } @@ -743,7 +865,7 @@ export class PreviewViewManager { async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise { const wc = this.requireWebContents(tabId); - await this.withDebugger(wc, async (send) => { + await this.withControlSession(tabId, wc, "press", async (send) => { const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { switch (modifier) { case "Alt": @@ -771,19 +893,17 @@ export class PreviewViewManager { async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise { const wc = this.requireWebContents(tabId); - await this.withDebugger(wc, async (send) => { + await this.withControlSession(tabId, wc, "scroll", async (send) => { await send("Runtime.enable"); + const locator = this.automationLocator(input); + if (locator) await this.ensurePlaywrightInjected(send); const result = await this.evaluateWithDebugger< { ok: true } | { invalidSelector: true; message: string } | { notFound: true } >( send, `(() => { try { - const target = ${ - input.selector - ? `document.querySelector(${JSON.stringify(input.selector)})` - : "window" - }; + const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"}; if (!target) return { notFound: true }; target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); return { ok: true }; @@ -801,7 +921,7 @@ export class PreviewViewManager { if ("notFound" in result) { throw automationError( "PreviewAutomationExecutionError", - `No element matches selector ${input.selector}.`, + `No element matches locator ${locator}.`, ); } }); @@ -809,7 +929,7 @@ export class PreviewViewManager { async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise { const wc = this.requireWebContents(tabId); - return this.withDebugger(wc, async (send) => { + return this.withControlSession(tabId, wc, "evaluate", async (send) => { await send("Runtime.enable"); const value = await this.evaluateWithDebugger( send, @@ -835,8 +955,10 @@ export class PreviewViewManager { async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise { const wc = this.requireWebContents(tabId); const timeoutMs = input.timeoutMs ?? 15_000; - await this.withDebugger(wc, async (send) => { + await this.withControlSession(tabId, wc, "waitFor", async (send) => { await send("Runtime.enable"); + const locator = this.automationLocator(input); + if (locator) await this.ensurePlaywrightInjected(send); const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { const result = await this.evaluateWithDebugger< @@ -845,11 +967,7 @@ export class PreviewViewManager { send, `(() => { try { - const selectorMatched = ${ - input.selector - ? `document.querySelector(${JSON.stringify(input.selector)}) !== null` - : "true" - }; + const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"}; const textMatched = ${ input.text ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` @@ -896,36 +1014,282 @@ export class PreviewViewManager { this.update(tabId, { zoomFactor: next }); } - private async withDebugger( + private async withControlSession( + tabId: string, wc: Electron.WebContents, + action: string, use: ( send: (method: string, commandParams?: Record) => Promise, ) => Promise, ): Promise { - if (wc.debugger.isAttached()) { - throw automationError( - "PreviewAutomationExecutionError", - "Preview automation is unavailable while another debugger is attached.", - ); - } - wc.debugger.attach("1.3"); + const actionEvent: PreviewAutomationActionEvent = { + id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`, + action, + status: "running", + startedAt: new Date().toISOString(), + }; + this.pushAction(tabId, actionEvent); + const epoch = this.controlEpoch.get(tabId) ?? 0; + const control = await this.ensureControlSession(wc); + let resolveTail: () => void = () => undefined; + const previous = control.tail; + control.tail = new Promise((resolve) => { + resolveTail = resolve; + }); + await previous; + this.update(tabId, { controller: "agent" }); try { - return await use((method, commandParams) => wc.debugger.sendCommand(method, commandParams)); + const send = async (method: string, commandParams?: Record) => { + if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { + throw automationError( + "PreviewAutomationControlInterruptedError", + "Browser control was interrupted by human input.", + ); + } + const result = await wc.debugger.sendCommand(method, commandParams); + if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { + throw automationError( + "PreviewAutomationControlInterruptedError", + "Browser control was interrupted by human input.", + ); + } + return result; + }; + const result = await use(send); + this.replaceAction(tabId, { + ...actionEvent, + status: "succeeded", + completedAt: new Date().toISOString(), + }); + return result; } catch (cause) { + const interrupted = + cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError"; + this.replaceAction(tabId, { + ...actionEvent, + status: interrupted ? "interrupted" : "failed", + completedAt: new Date().toISOString(), + error: cause instanceof Error ? cause.message : String(cause), + }); if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; throw automationError( "PreviewAutomationExecutionError", cause instanceof Error ? cause.message : String(cause), - cause, + { tabId, cause }, ); } finally { - if (wc.debugger.isAttached()) { - try { - wc.debugger.detach(); - } catch { - // The target can disappear while an operation is completing. + if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" }); + resolveTail(); + } + } + + private pushAction(tabId: string, event: PreviewAutomationActionEvent): void { + const timeline = this.actionTimeline.get(tabId) ?? []; + timeline.push(event); + if (timeline.length > 200) timeline.splice(0, timeline.length - 200); + this.actionTimeline.set(tabId, timeline); + } + + private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void { + const timeline = this.actionTimeline.get(tabId); + if (!timeline) return; + const index = timeline.findIndex((candidate) => candidate.id === event.id); + if (index >= 0) timeline[index] = event; + } + + private async ensureControlSession(wc: Electron.WebContents): Promise { + const existing = this.controlSessions.get(wc.id); + if (existing) { + await existing.initialized; + return existing; + } + if (wc.isDevToolsOpened()) { + throw automationError( + "PreviewAutomationExecutionError", + "Close preview DevTools before using agent browser control.", + ); + } + if (wc.debugger.isAttached()) { + throw automationError( + "PreviewAutomationExecutionError", + "Preview control cannot attach because another debugger owns this page.", + ); + } + const control: BrowserControlSession = { + webContentsId: wc.id, + tail: Promise.resolve(), + initialized: Promise.resolve(), + }; + const diagnostics: BrowserDiagnostics = { + consoleEntries: [], + networkEntries: [], + requests: new Map(), + }; + this.diagnostics.set(wc.id, diagnostics); + wc.debugger.on("message", (_event, method, params) => { + if (method === "Page.screencastFrame") { + const frame = params as Record; + const sessionId = frame["sessionId"]; + if (typeof sessionId === "number") { + void wc.debugger + .sendCommand("Page.screencastFrameAck", { sessionId }) + .catch(() => undefined); } + const tabId = this.tabIdForWebContents(wc.id); + const metadata = + typeof frame["metadata"] === "object" && frame["metadata"] !== null + ? (frame["metadata"] as Record) + : {}; + if (tabId && typeof frame["data"] === "string") { + const payload: DesktopPreviewRecordingFrame = { + tabId, + data: frame["data"], + width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, + height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, + receivedAt: new Date().toISOString(), + }; + for (const listener of this.recordingFrameListeners) listener(payload); + } + } + this.captureDiagnosticMessage(diagnostics, method, params as Record); + }); + control.initialized = (async () => { + wc.debugger.attach("1.3"); + await Promise.all([ + wc.debugger.sendCommand("Runtime.enable"), + wc.debugger.sendCommand("Accessibility.enable"), + wc.debugger.sendCommand("Network.enable"), + wc.debugger.sendCommand("Log.enable"), + ]); + })(); + this.controlSessions.set(wc.id, control); + try { + await control.initialized; + return control; + } catch (cause) { + this.controlSessions.delete(wc.id); + throw cause; + } + } + + private detachControlSession(webContentsId: number): void { + this.controlSessions.delete(webContentsId); + this.diagnostics.delete(webContentsId); + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed() || !wc.debugger.isAttached()) return; + try { + wc.debugger.detach(); + } catch { + // Target teardown can race detachment. + } + } + + private tabIdForWebContents(webContentsId: number): string | null { + for (const [tabId, tab] of this.tabs) { + if (tab.webContentsId === webContentsId) return tabId; + } + return null; + } + + private captureDiagnosticMessage( + diagnostics: BrowserDiagnostics, + method: string, + params: Record, + ): void { + const timestamp = new Date().toISOString(); + if (method === "Runtime.consoleAPICalled") { + const args = Array.isArray(params["args"]) ? params["args"] : []; + const text = args + .map((arg) => { + if (typeof arg !== "object" || arg === null) return String(arg); + const value = arg as Record; + return String(value["value"] ?? value["description"] ?? ""); + }) + .join(" "); + this.pushBounded(diagnostics.consoleEntries, { + level: typeof params["type"] === "string" ? params["type"] : "log", + text, + timestamp, + source: "console", + }); + return; + } + if (method === "Runtime.exceptionThrown") { + const details = + typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null + ? (params["exceptionDetails"] as Record) + : {}; + this.pushBounded(diagnostics.consoleEntries, { + level: "error", + text: String(details["text"] ?? "Uncaught exception"), + timestamp, + source: "exception", + }); + return; + } + if (method === "Log.entryAdded") { + const entry = + typeof params["entry"] === "object" && params["entry"] !== null + ? (params["entry"] as Record) + : {}; + this.pushBounded(diagnostics.consoleEntries, { + level: typeof entry["level"] === "string" ? entry["level"] : "info", + text: String(entry["text"] ?? ""), + timestamp, + source: typeof entry["source"] === "string" ? entry["source"] : "log", + }); + return; + } + const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; + if (method === "Network.requestWillBeSent" && requestId) { + const request = + typeof params["request"] === "object" && params["request"] !== null + ? (params["request"] as Record) + : {}; + diagnostics.requests.set(requestId, { + url: String(request["url"] ?? ""), + method: String(request["method"] ?? "GET"), + }); + return; + } + if (method === "Network.responseReceived" && requestId) { + const request = diagnostics.requests.get(requestId); + const response = + typeof params["response"] === "object" && params["response"] !== null + ? (params["response"] as Record) + : {}; + const status = typeof response["status"] === "number" ? response["status"] : null; + if (request && status !== null && status >= 400) { + this.pushBounded(diagnostics.networkEntries, { + ...request, + status, + failed: true, + timestamp, + }); + } + return; + } + if (method === "Network.loadingFailed" && requestId) { + const request = diagnostics.requests.get(requestId); + if (request) { + this.pushBounded(diagnostics.networkEntries, { + ...request, + status: null, + failed: true, + errorText: String(params["errorText"] ?? "Network request failed"), + timestamp, + }); } + diagnostics.requests.delete(requestId); + return; + } + if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId); + } + + private pushBounded(buffer: A[], entry: A): void { + buffer.push(entry); + if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) { + buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT); } } @@ -952,6 +1316,28 @@ export class PreviewViewManager { return response.result?.value as A; } + private automationLocator(input: { + readonly selector?: string | undefined; + readonly locator?: string | undefined; + }): string | null { + if (input.locator) return input.locator; + if (input.selector) return `css=${input.selector}`; + return null; + } + + private async ensurePlaywrightInjected( + send: (method: string, commandParams?: Record) => Promise, + ): Promise { + const installed = await this.evaluateWithDebugger( + send, + "Boolean(globalThis.__t3PlaywrightInjected)", + true, + ); + if (installed) return; + const expression = await playwrightInjectedRuntimeInstallExpression(); + await this.evaluateWithDebugger(send, expression, true); + } + onStateChange(listener: Listener): () => void { this.listeners.add(listener); return () => { @@ -964,6 +1350,7 @@ export class PreviewViewManager { this.closeTab(tabId); } this.listeners.clear(); + this.recordingFrameListeners.clear(); } private attachListeners(tabId: string, wc: Electron.WebContents): void { @@ -988,6 +1375,15 @@ export class PreviewViewManager { }, }); }; + const humanInput = (): void => { + this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); + this.update(tabId, { controller: "human" }); + void sleep(750).then(() => { + if (this.tabs.get(tabId)?.controller === "human") { + this.update(tabId, { controller: "none" }); + } + }); + }; wc.on("did-navigate", sync); wc.on("did-navigate-in-page", sync); @@ -995,6 +1391,7 @@ export class PreviewViewManager { wc.on("did-start-loading", sync); wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); + wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); // Keep external links inside the same view (matches ami's policy). wc.setWindowOpenHandler(({ url }) => { @@ -1020,7 +1417,7 @@ export class PreviewViewManager { } }); - this.attached.set(wc.id, { navigate: sync, failed }); + this.attached.set(wc.id, { navigate: sync, failed, humanInput }); } private detachListeners(webContentsId: number): void { @@ -1035,6 +1432,7 @@ export class PreviewViewManager { wc.off("did-start-loading", handlers.navigate); wc.off("did-stop-loading", handlers.navigate); wc.off("did-fail-load", handlers.failed as never); + wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); } private isAppShortcut(input: Electron.Input): boolean { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 0450b413c131fde2153313d30e5ff1dd5c2f3060..953c7a3adb2e6c41f872b2907304a35346c949fc 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -200,7 +200,10 @@ const make = Effect.gen(function* () { previewViewManager.setMainWindow(window); window.webContents.on("will-attach-webview", (event, webPreferences, params) => { - if (params.partition !== previewViewManager.getBrowserPartition()) { + if ( + typeof params.partition !== "string" || + !previewViewManager.isBrowserPartition(params.partition) + ) { event.preventDefault(); return; } diff --git a/apps/server/src/mcp/Layers/McpHttpServer.test.ts b/apps/server/src/mcp/Layers/McpHttpServer.test.ts index 8d01277ce3e7852460f302d4c2ec53060ce3ab6b..908436341300d45062eafce8379d2436141f9183 100644 --- a/apps/server/src/mcp/Layers/McpHttpServer.test.ts +++ b/apps/server/src/mcp/Layers/McpHttpServer.test.ts @@ -65,6 +65,9 @@ it.effect("registers annotated tools and preserves authenticated request context visibleText: "Example", interactiveElements: [], accessibilityTree: {}, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], screenshot: { mimeType: "image/png", data: Buffer.from("png").toString("base64"), @@ -96,6 +99,21 @@ it.effect("registers annotated tools and preserves authenticated request context const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); + expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); + + const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); + expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true); + expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true); + expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); + + const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); + expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); + expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); + expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); + + const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); + expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); + expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); const status = yield* server .callTool({ name: "preview_status", arguments: {} }) diff --git a/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts index d3e43e4c53cade415c3aa51e865b49c82ac58538..7c97da39f2e40fdfe128481cc7eaa918a4e8d6f2 100644 --- a/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts @@ -63,3 +63,27 @@ it.effect("rejects calls when no focused owner exists", () => expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); }), ); + +it.effect("routes interactive commands to a hidden durable browser host", () => + Effect.scoped( + Effect.gen(function* () { + const broker = makePreviewAutomationBroker(); + const requests = yield* broker.connect("client-hidden"); + yield* Stream.runForEach(requests, (request) => + broker.respond({ requestId: request.requestId, ok: true }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner({ + clientId: "client-hidden", + environmentId: scope.environmentId, + threadId: scope.threadId, + tabId: "tab-hidden", + visible: false, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); + }), + ), +); diff --git a/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts b/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts index 67d3a6ed5758e869a3650ea6772a5bf617f5e11f..d32523375b1ecbbcaefc6220b8c4f1e0ac84df4b 100644 --- a/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/Layers/PreviewAutomationBroker.ts @@ -1,4 +1,5 @@ import { + PreviewAutomationControlInterruptedError, PreviewAutomationExecutionError, PreviewAutomationInvalidSelectorError, PreviewAutomationNoFocusedOwnerError, @@ -56,6 +57,8 @@ const makeResponseError = ( return new PreviewAutomationTabNotFoundError({ message: error.message }); case "PreviewAutomationTimeoutError": return new PreviewAutomationTimeoutError({ message: error.message }); + case "PreviewAutomationControlInterruptedError": + return new PreviewAutomationControlInterruptedError({ message: error.message }); case "PreviewAutomationInvalidSelectorError": { const detail = typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; @@ -193,20 +196,19 @@ export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { (owner) => owner.environmentId === input.scope.environmentId && owner.threadId === input.scope.threadId && - owner.supportsAutomation && - (input.operation === "open" || input.operation === "status" || owner.visible), + owner.supportsAutomation, ) .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); const owner = candidates[0]; if (!owner) { return yield* new PreviewAutomationNoFocusedOwnerError({ - message: "No focused desktop preview owner is available for this thread.", + message: "No desktop browser host is available for this thread.", }); } const connection = current.clients.get(owner.clientId); if (!connection) { return yield* new PreviewAutomationUnavailableError({ - message: "The focused preview owner is not connected.", + message: "The browser host is not connected.", }); } if ( @@ -216,7 +218,7 @@ export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { !input.tabId ) { return yield* new PreviewAutomationTabNotFoundError({ - message: "The focused preview owner does not have an active tab.", + message: "The browser host does not have an active tab.", }); } const requestId = `preview-${requestSequence++}`; diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index f5e19bfcf3e372dccd0532c332005944bca384eb..62f9142134357e71b5de3c174e22462f72477c22 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect"; import type { PreviewAutomationOperation, + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, PreviewAutomationSnapshot, PreviewAutomationStatus, } from "@t3tools/contracts"; @@ -44,4 +46,6 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ preview_scroll: (input) => invoke("scroll", input), preview_evaluate: (input) => invoke("evaluate", input), preview_wait_for: (input) => invoke("waitFor", input, input.timeoutMs), + preview_recording_start: () => invoke("recordingStart", {}), + preview_recording_stop: () => invoke("recordingStop", {}), }); diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1347e0db0ec420fdf3adee0cd3f4785d73ab21b8 --- /dev/null +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "@effect/vitest"; +import { Tool } from "effect/unstable/ai"; + +import { PreviewToolkit } from "./tools.ts"; + +const schemaHasDescription = (schema: unknown): boolean => { + if (!schema || typeof schema !== "object") return false; + const record = schema as Record; + if (typeof record.description === "string" && record.description.length > 0) return true; + return [record.anyOf, record.oneOf, record.allOf] + .filter(Array.isArray) + .some((members) => members.some(schemaHasDescription)); +}; + +it("exports provider-compatible object schemas with described parameters", () => { + for (const tool of Object.values(PreviewToolkit.tools)) { + const schema = Tool.getJsonSchema(tool) as { + readonly type?: unknown; + readonly properties?: Readonly>; + readonly anyOf?: unknown; + readonly oneOf?: unknown; + }; + expect( + tool.description?.length ?? 0, + `${tool.name} should have a useful description`, + ).toBeGreaterThan(40); + expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); + expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); + expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); + for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) { + expect( + schemaHasDescription(fieldSchema), + `${tool.name}.${field} should explain what data the agent must pass`, + ).toBe(true); + } + } +}); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 32baf050f2f4c0a9309a2b67a3b58cea62e55843..effd87fb7e434f2beedf9d9474f7c169cd746065 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -5,6 +5,8 @@ import { PreviewAutomationNavigateInput, PreviewAutomationOpenInput, PreviewAutomationPressInput, + PreviewAutomationRecordingArtifact, + PreviewAutomationRecordingStatus, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationStatus, @@ -17,7 +19,13 @@ import { Tool, Toolkit } from "effect/unstable/ai"; import { McpInvocationContext } from "../../Services/McpInvocationContext.ts"; const browserTool = (tool: T): T => - tool.annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, true) as T; + tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; + +const safeBrowserTool = (tool: T): T => + browserTool(tool).annotate(Tool.Destructive, false) as T; + +const readonlyBrowserTool = (tool: T): T => + safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; export const PreviewStatusTool = Tool.make("preview_status", { description: @@ -39,13 +47,15 @@ export const PreviewOpenTool = browserTool( success: PreviewAutomationStatus, failure: PreviewAutomationError, dependencies: [McpInvocationContext], - }).annotate(Tool.Title, "Open browser preview"), + }) + .annotate(Tool.Title, "Open browser preview") + .annotate(Tool.Destructive, false), ); -export const PreviewNavigateTool = browserTool( +export const PreviewNavigateTool = safeBrowserTool( Tool.make("preview_navigate", { description: - "Navigate the scoped thread's active preview tab to a URL and wait for the requested readiness condition.", + "Navigate the active collaborative browser tab. Pass {url:'https://t3.chat'} for a website, or {target:{kind:'environment-port',port:5173}} for a dev server in the current environment. Exactly one of url or target is required. Defaults to waiting for page loading to stop.", parameters: PreviewAutomationNavigateInput, success: PreviewAutomationStatus, failure: PreviewAutomationError, @@ -53,21 +63,20 @@ export const PreviewNavigateTool = browserTool( }).annotate(Tool.Title, "Navigate browser preview"), ); -export const PreviewSnapshotTool = Tool.make("preview_snapshot", { - description: - "Capture bounded page metadata, visible text, interactive elements, accessibility data, and a PNG screenshot from the scoped preview tab.", - success: PreviewAutomationSnapshot, - failure: PreviewAutomationError, - dependencies: [McpInvocationContext], -}) - .annotate(Tool.Title, "Capture preview snapshot") - .annotate(Tool.Readonly, true) - .annotate(Tool.Destructive, false); +export const PreviewSnapshotTool = readonlyBrowserTool( + Tool.make("preview_snapshot", { + description: + "Inspect the current page before interacting. Returns URL/title/loading state, visible text, semantic interactive elements with reusable selectors and coordinates, accessibility data, recent console/network failures, action history, and a PNG screenshot.", + success: PreviewAutomationSnapshot, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Inspect browser page"), +); export const PreviewClickTool = browserTool( Tool.make("preview_click", { description: - "Click an element selected by CSS selector or click viewport coordinates in the scoped preview tab.", + "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", parameters: PreviewAutomationClickInput, failure: PreviewAutomationError, dependencies: [McpInvocationContext], @@ -77,7 +86,7 @@ export const PreviewClickTool = browserTool( export const PreviewTypeTool = browserTool( Tool.make("preview_type", { description: - "Type text into the focused element or a CSS-selected element, optionally clearing its existing value first.", + "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", parameters: PreviewAutomationTypeInput, failure: PreviewAutomationError, dependencies: [McpInvocationContext], @@ -86,17 +95,18 @@ export const PreviewTypeTool = browserTool( export const PreviewPressTool = browserTool( Tool.make("preview_press", { - description: "Dispatch a keyboard key with optional modifiers to the scoped preview tab.", + description: + "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", parameters: PreviewAutomationPressInput, failure: PreviewAutomationError, dependencies: [McpInvocationContext], }).annotate(Tool.Title, "Press key in preview page"), ); -export const PreviewScrollTool = browserTool( +export const PreviewScrollTool = safeBrowserTool( Tool.make("preview_scroll", { description: - "Scroll the preview viewport or a CSS-selected scroll container by the requested deltas.", + "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", parameters: PreviewAutomationScrollInput, failure: PreviewAutomationError, dependencies: [McpInvocationContext], @@ -106,7 +116,7 @@ export const PreviewScrollTool = browserTool( export const PreviewEvaluateTool = browserTool( Tool.make("preview_evaluate", { description: - "Evaluate bounded JavaScript in the scoped preview tab and return a serializable result of at most 64 KB.", + "Evaluate a JavaScript expression in the page's main frame and return a serializable result up to 64 KB. Prefer preview_snapshot and semantic click/type/wait tools; use this for inspection or interactions those tools cannot express. The expression may mutate page state.", parameters: PreviewAutomationEvaluateInput, success: Schema.Unknown, failure: PreviewAutomationError, @@ -114,16 +124,35 @@ export const PreviewEvaluateTool = browserTool( }).annotate(Tool.Title, "Evaluate JavaScript in preview"), ); -export const PreviewWaitForTool = browserTool( +export const PreviewWaitForTool = readonlyBrowserTool( Tool.make("preview_wait_for", { description: - "Wait until a CSS selector, visible-text substring, or URL substring appears in the scoped preview tab.", + "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", parameters: PreviewAutomationWaitForInput, failure: PreviewAutomationError, dependencies: [McpInvocationContext], }).annotate(Tool.Title, "Wait for preview page condition"), ); +export const PreviewRecordingStartTool = safeBrowserTool( + Tool.make("preview_recording_start", { + description: + "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", + success: PreviewAutomationRecordingStatus, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Start browser recording"), +); + +export const PreviewRecordingStopTool = safeBrowserTool( + Tool.make("preview_recording_stop", { + description: "Stop the active browser recording and save it as a local evidence artifact.", + success: PreviewAutomationRecordingArtifact, + failure: PreviewAutomationError, + dependencies: [McpInvocationContext], + }).annotate(Tool.Title, "Stop browser recording"), +); + export const PreviewToolkit = Toolkit.make( PreviewStatusTool, PreviewOpenTool, @@ -135,4 +164,6 @@ export const PreviewToolkit = Toolkit.make( PreviewScrollTool, PreviewEvaluateTool, PreviewWaitForTool, + PreviewRecordingStartTool, + PreviewRecordingStopTool, ); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 76055f8b8be7ec19a857ab00cff5af53f2d754e4..b46a4ce1ba3c430052586e1b5b26c428bf562b76 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,3 +1,14 @@ +const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` + +## T3 Code collaborative browser + +You are running inside T3 Code. The \`t3-code\` MCP server is the product-native collaborative browser shared with the user. When it exposes \`preview_*\` tools, prefer those tools for browser navigation, inspection, interaction, screenshots, and recordings. + +For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates. + +Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. +`; + export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -118,6 +129,7 @@ plan content should be human and agent digestible. The final plan must be plan-o Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. +${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} `; export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default @@ -131,4 +143,5 @@ Your active mode changes only when new developer instructions with a different \ The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error. In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. +${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} `; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 04ef44d54e80571b1a56ef81bf17da99f7a5e443..7fef85c42e0e2273a5d4be20b6ede6bca8fc49ef 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -491,6 +491,64 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("labels MCP lifecycle entries with server and tool names", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId("evt-mcp-complete"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("mcp_1"), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "mcp_1", + server: "t3-code", + tool: "preview_status", + arguments: {}, + durationMs: 12, + error: null, + result: { content: [{ type: "text", text: "attached" }] }, + status: "completed", + }, + }, + }); + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + assert.equal(firstEvent.value.payload.itemType, "mcp_tool_call"); + assert.equal(firstEvent.value.payload.title, "t3-code · preview_status"); + assert.deepStrictEqual(firstEvent.value.payload.data, { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "mcpToolCall", + id: "mcp_1", + server: "t3-code", + tool: "preview_status", + arguments: {}, + durationMs: 12, + error: null, + result: { content: [{ type: "text", text: "attached" }] }, + status: "completed", + }, + }); + }), + ); + it.effect("maps completed plan items to canonical proposed-plan completion events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index ea2d898730afebcaf5913a8301ebf651b6989338..edb2bccdbcf6f954772b75ad7a559d1ab3933f27 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -235,7 +235,10 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType return "unknown"; } -function itemTitle(itemType: CanonicalItemType): string | undefined { +function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): string | undefined { + if (itemType === "mcp_tool_call" && item?.type === "mcpToolCall") { + return `${item.server} · ${item.tool}`; + } switch (itemType) { case "assistant_message": return "Assistant message"; @@ -476,7 +479,7 @@ function mapItemLifecycle( payload: { itemType, ...(status ? { status } : {}), - ...(itemTitle(itemType) ? { title: itemTitle(itemType) } : {}), + ...(itemTitle(itemType, item) ? { title: itemTitle(itemType, item) } : {}), ...(detail ? { detail } : {}), ...(event.payload !== undefined ? { data: event.payload } : {}), }, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index d2e51139b9f2983306450cfc8bcb0d2cb8605d6d..2d30303985635c85cf4e59548aafd8b38749ae00 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -13,6 +13,7 @@ import { } from "../CodexDeveloperInstructions.ts"; import { buildTurnStartParams, + hasConfiguredMcpServer, isRecoverableThreadResumeError, openCodexThread, } from "./CodexSessionRuntime.ts"; @@ -149,6 +150,31 @@ describe("buildTurnStartParams", () => { }); }); +describe("T3 browser developer instructions", () => { + it("prefers the product-native preview tools in both collaboration modes", () => { + for (const instructions of [ + CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + ]) { + assert.match(instructions, /t3-code/); + assert.match(instructions, /preview_status/); + assert.match(instructions, /preview_open/); + assert.match(instructions, /Do not switch to global browser skills/); + } + }); +}); + +describe("hasConfiguredMcpServer", () => { + it("detects inline Codex MCP configuration arguments", () => { + assert.equal(hasConfiguredMcpServer(undefined), false); + assert.equal(hasConfiguredMcpServer(["--model", "gpt-5.4"]), false); + assert.equal( + hasConfiguredMcpServer(["-c", 'mcp_servers.t3-code.url="http://127.0.0.1/mcp"']), + true, + ); + }); +}); + describe("isRecoverableThreadResumeError", () => { it("matches missing thread errors", () => { assert.equal( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index fc31ca3aef72559d1861adde39cd21790972aef8..e3e20106d72e7950731ffd3fe61b1c17cd6dc516 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -62,6 +62,10 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "does not exist", ]; +export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray | undefined): boolean { + return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; +} + export const CodexResumeCursorSchema = Schema.Struct({ threadId: Schema.String, }); @@ -1256,6 +1260,15 @@ export const makeCodexSessionRuntime = ( sendTurn: (input) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; + if (hasConfiguredMcpServer(options.appServerArgs)) { + yield* client.request("config/mcpServer/reload", undefined).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { + cause, + }), + ), + ); + } const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx new file mode 100644 index 0000000000000000000000000000000000000000..90769f8fb69e89c7d18c6a57562cc653b1a90774 --- /dev/null +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; + +export function BrowserSurfaceSlot(props: { + readonly tabId: string; + readonly visible: boolean; + readonly className?: string; +}) { + const { tabId, visible, className } = props; + const elementRef = useRef(null); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + const update = () => { + const rect = element.getBoundingClientRect(); + useBrowserSurfaceStore.getState().present( + tabId, + { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.max(1, Math.round(rect.width)), + height: Math.max(1, Math.round(rect.height)), + }, + visible && rect.width > 0 && rect.height > 0, + ); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + window.addEventListener("resize", update); + window.addEventListener("scroll", update, true); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + window.removeEventListener("scroll", update, true); + useBrowserSurfaceStore.getState().hide(tabId); + }; + }, [tabId, visible]); + + return
; +} diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cc611a22993efc6ec0d6bfe50b7e3a968fa26f0a --- /dev/null +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { parseScopedThreadKey } from "@t3tools/client-runtime"; +import { useMemo } from "react"; + +import { isElectron } from "~/env"; +import { usePreviewStateStore } from "~/previewStateStore"; + +import { HostedBrowserWebview } from "./HostedBrowserWebview"; + +export function ElectronBrowserHost() { + const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); + const sessions = useMemo( + () => + Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef + ? Object.values(previewState.sessions).map((snapshot) => ({ + threadRef, + snapshot, + active: previewState.activeTabId === snapshot.tabId, + })) + : []; + }), + [previewByThreadKey], + ); + + if (!isElectron) return null; + return ( +
+ {sessions.map(({ threadRef, snapshot }) => { + const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e158d7ebad6c3fba206858614eb13e92487b6176 --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -0,0 +1,116 @@ +"use client"; + +import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts"; +import { useShallow } from "zustand/react/shallow"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { previewBridge } from "~/components/preview/previewBridge"; +import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; + +import { useBrowserRecordingStore } from "./browserRecording"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { acquireDesktopTab } from "./desktopTabLifetime"; + +interface ElectronWebview extends HTMLElement { + src: string; + partition: string; + preload?: string; + webpreferences?: string; + getWebContentsId: () => number; +} + +declare global { + interface HTMLElementTagNameMap { + webview: ElectronWebview; + } +} + +export function HostedBrowserWebview(props: { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly initialUrl: string | null; +}) { + const { threadRef, tabId, initialUrl } = props; + const [config, setConfig] = useState(null); + const initialSrcRef = useRef(initialUrl ?? "about:blank"); + const webviewRef = useRef(null); + const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); + const recording = useBrowserRecordingStore((state) => state.activeTabId === tabId); + + usePreviewBridge({ threadRef, tabId }); + + useEffect(() => acquireDesktopTab(tabId), [tabId]); + + useEffect(() => { + let cancelled = false; + void previewBridge + ?.getPreviewConfig(threadRef.environmentId) + .then((next) => { + if (!cancelled) setConfig(next); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [threadRef.environmentId]); + + const setWebviewRef = useCallback((node: HTMLElement | null) => { + webviewRef.current = node as ElectronWebview | null; + if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); + }, []); + + useEffect(() => { + const webview = webviewRef.current; + const bridge = previewBridge; + if (!webview || !config || !bridge) return; + const register = () => { + try { + const webContentsId = webview.getWebContentsId(); + if (Number.isInteger(webContentsId) && webContentsId > 0) { + void bridge.registerWebview(tabId, webContentsId); + } + } catch { + // A later dom-ready will retry registration. + } + }; + webview.addEventListener("dom-ready", register); + register(); + return () => webview.removeEventListener("dom-ready", register); + }, [config, tabId]); + + if (!config) return null; + const active = presentation?.visible === true && presentation.rect !== null; + const lastRect = presentation?.rect; + const style = + active && lastRect + ? { + left: lastRect.x, + top: lastRect.y, + width: lastRect.width, + height: lastRect.height, + zIndex: 30, + pointerEvents: "auto" as const, + } + : { + left: 0, + top: 0, + width: lastRect?.width ?? 1280, + height: lastRect?.height ?? 800, + zIndex: recording ? 0 : -1, + pointerEvents: "none" as const, + }; + + return ( + + ); +} diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a1c6f413279539f478e0a801195d65dc8024bc2 --- /dev/null +++ b/apps/web/src/browser/browserRecording.ts @@ -0,0 +1,115 @@ +import type { + DesktopPreviewRecordingArtifact, + DesktopPreviewRecordingFrame, +} from "@t3tools/contracts"; +import { create } from "zustand"; + +import { previewBridge } from "~/components/preview/previewBridge"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; + +interface ActiveRecording { + readonly tabId: string; + readonly canvas: HTMLCanvasElement; + readonly context: CanvasRenderingContext2D; + readonly recorder: MediaRecorder; + readonly chunks: Blob[]; + readonly mimeType: string; + readonly startedAt: string; +} + +interface BrowserRecordingState { + activeTabId: string | null; + startedAt: string | null; + lastArtifact: DesktopPreviewRecordingArtifact | null; + setActive: (tabId: string | null, startedAt: string | null) => void; + setArtifact: (artifact: DesktopPreviewRecordingArtifact) => void; +} + +export const useBrowserRecordingStore = create()((set) => ({ + activeTabId: null, + startedAt: null, + lastArtifact: null, + setActive: (activeTabId, startedAt) => set({ activeTabId, startedAt }), + setArtifact: (lastArtifact) => set({ lastArtifact }), +})); + +let active: ActiveRecording | null = null; +let unsubscribeFrames: (() => void) | null = null; + +const preferredMimeType = (): string => { + const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; + return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; +}; + +const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { + const recording = active; + if (!recording || recording.tabId !== frame.tabId) return; + const image = new Image(); + image.addEventListener( + "load", + () => { + if (active !== recording) return; + recording.context.drawImage(image, 0, 0, recording.canvas.width, recording.canvas.height); + }, + { once: true }, + ); + image.src = `data:image/jpeg;base64,${frame.data}`; +}; + +export async function startBrowserRecording(tabId: string): Promise { + const bridge = previewBridge; + if (!bridge || active) return; + const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, rect?.width ?? 1280); + canvas.height = Math.max(1, rect?.height ?? 800); + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("Browser recording canvas is unavailable."); + const mimeType = preferredMimeType(); + const recorder = new MediaRecorder(canvas.captureStream(12), { + mimeType, + videoBitsPerSecond: 4_000_000, + }); + const startedAt = new Date().toISOString(); + const chunks: Blob[] = []; + recorder.addEventListener("dataavailable", (event) => { + if (event.data.size > 0) chunks.push(event.data); + }); + active = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; + unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); + recorder.start(1_000); + try { + await bridge.recording.startScreencast(tabId); + useBrowserRecordingStore.getState().setActive(tabId, startedAt); + } catch (error) { + active = null; + recorder.stop(); + throw error; + } +} + +export async function stopBrowserRecording( + tabId: string, +): Promise { + const bridge = previewBridge; + const recording = active; + if (!bridge || !recording || recording.tabId !== tabId) return null; + await bridge.recording.stopScreencast(tabId); + const stopped = new Promise((resolve) => + recording.recorder.addEventListener("stop", () => resolve(), { once: true }), + ); + recording.recorder.stop(); + await stopped; + const blob = new Blob(recording.chunks, { type: recording.mimeType }); + const artifact = await bridge.recording.save( + tabId, + recording.mimeType, + new Uint8Array(await blob.arrayBuffer()), + ); + active = null; + unsubscribeFrames?.(); + unsubscribeFrames = null; + useBrowserRecordingStore.getState().setActive(null, null); + useBrowserRecordingStore.getState().setArtifact(artifact); + return artifact; +} diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..64fd8e2df2b131a0de55c4e9f7c19b96d652e18e --- /dev/null +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -0,0 +1,53 @@ +import { create } from "zustand"; + +export interface BrowserSurfaceRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface BrowserSurfacePresentation { + readonly rect: BrowserSurfaceRect | null; + readonly visible: boolean; + readonly updatedAt: number; +} + +interface BrowserSurfaceStoreState { + readonly byTabId: Record; + readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; + readonly hide: (tabId: string) => void; +} + +const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => + left !== null && + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height; + +export const useBrowserSurfaceStore = create()((set) => ({ + byTabId: {}, + present: (tabId, rect, visible) => + set((state) => { + const current = state.byTabId[tabId]; + if (current && current.visible === visible && rectEquals(current.rect, rect)) return state; + return { + byTabId: { + ...state.byTabId, + [tabId]: { rect, visible, updatedAt: Date.now() }, + }, + }; + }), + hide: (tabId) => + set((state) => { + const current = state.byTabId[tabId]; + if (!current || !current.visible) return state; + return { + byTabId: { + ...state.byTabId, + [tabId]: { ...current, visible: false, updatedAt: Date.now() }, + }, + }; + }), +})); diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a50275eb8c0705bffef4da2d5c09a1a247dc6480 --- /dev/null +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -0,0 +1,81 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const readEnvironmentConnection = vi.fn(); + +vi.mock("~/environments/runtime", () => ({ readEnvironmentConnection })); + +describe("browser target resolver", () => { + beforeEach(() => readEnvironmentConnection.mockReset()); + + it("maps environment ports onto a private network host", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "http://192.168.1.25:3773" } }, + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + path: "/dashboard", + }), + ).toEqual({ + requestedUrl: "http://localhost:5173/dashboard", + resolvedUrl: "http://192.168.1.25:5173/dashboard", + resolutionKind: "direct-private-network", + environmentId: "environment-1", + }); + }); + + it("refuses public relay hosts until the authenticated gateway exists", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "https://relay.example.com" } }, + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect(() => + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }), + ).toThrow(/authenticated preview gateway/); + }); + + it("normalizes schemeless localhost server-picker values", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "http://localhost:3773" } }, + }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173")).toBe( + "http://localhost:5173/", + ); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "0.0.0.0:3000/app"), + ).toBe("http://localhost:3000/app"); + }); + + it("normalizes public URLs without treating them as environment ports", async () => { + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( + "https://example.com/app", + ); + }); + + it("supports private IPv6 environment hosts", async () => { + readEnvironmentConnection.mockReturnValue({ + knownEnvironment: { target: { httpBaseUrl: "http://[::1]:3773" } }, + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + path: "/app?mode=test", + }).resolvedUrl, + ).toBe("http://[::1]:5173/app?mode=test"); + }); + + it("leaves malformed input for the normal navigation error path", async () => { + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); + }); +}); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..12276673002031fdd29e9fcb8f6627effc548cca --- /dev/null +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -0,0 +1,81 @@ +import type { + BrowserNavigationTarget, + EnvironmentId, + PreviewUrlResolution, +} from "@t3tools/contracts"; +import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; + +import { readEnvironmentConnection } from "~/environments/runtime"; + +const isPrivateNetworkHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { + return true; + } + if (normalized.endsWith(".ts.net")) return true; + const parts = normalized.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + return ( + parts[0] === 10 || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + parts[0] === 127 || + (parts[0] === 169 && parts[1] === 254) + ); +}; + +export function resolveBrowserNavigationTarget( + environmentId: EnvironmentId, + target: BrowserNavigationTarget, +): PreviewUrlResolution { + if (target.kind === "url") { + return { + requestedUrl: target.url, + resolvedUrl: target.url, + resolutionKind: "direct", + environmentId, + }; + } + const connection = readEnvironmentConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + const environmentUrl = new URL(connection.knownEnvironment.target.httpBaseUrl); + if (!isPrivateNetworkHost(environmentUrl.hostname)) { + throw new Error( + "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", + ); + } + const protocol = target.protocol ?? "http"; + const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; + const requestedUrl = `${protocol}://localhost:${target.port}${path}`; + const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); + const resolvedHost = normalizedEnvironmentHost.includes(":") + ? `[${normalizedEnvironmentHost}]` + : normalizedEnvironmentHost; + const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + return { + requestedUrl, + resolvedUrl: resolved.toString(), + resolutionKind: + normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" + ? "direct" + : "direct-private-network", + environmentId, + }; +} + +export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { + try { + const normalizedUrl = normalizePreviewUrl(rawUrl); + const parsed = new URL(normalizedUrl); + if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); + return resolveBrowserNavigationTarget(environmentId, { + kind: "environment-port", + port, + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }).resolvedUrl; + } catch { + return rawUrl; + } +} diff --git a/apps/web/src/browser/desktopTabLifetime.ts b/apps/web/src/browser/desktopTabLifetime.ts new file mode 100644 index 0000000000000000000000000000000000000000..4254c7e6afc0cd121c490a9b694e541e6e792601 --- /dev/null +++ b/apps/web/src/browser/desktopTabLifetime.ts @@ -0,0 +1,30 @@ +import { previewBridge } from "~/components/preview/previewBridge"; + +interface DesktopTabLease { + references: number; + closeTimer: number | null; +} + +const leases = new Map(); + +export function acquireDesktopTab(tabId: string): () => void { + const current = leases.get(tabId) ?? { references: 0, closeTimer: null }; + if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); + current.references += 1; + current.closeTimer = null; + leases.set(tabId, current); + if (current.references === 1) void previewBridge?.createTab(tabId); + + return () => { + const lease = leases.get(tabId); + if (!lease) return; + lease.references = Math.max(0, lease.references - 1); + if (lease.references > 0) return; + lease.closeTimer = window.setTimeout(() => { + const latest = leases.get(tabId); + if (!latest || latest.references > 0) return; + leases.delete(tabId); + void previewBridge?.closeTab(tabId); + }, 0); + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4dd6d9003dd975bebb64cdc3145422fc01f78367..0bda0fb7eddb35ec195b4244cd15a6a9c6bca3e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,12 +21,7 @@ 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, createModelSelection, @@ -43,6 +38,7 @@ import { useVcsStatus } from "~/lib/vcsStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; +import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -70,11 +66,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 { @@ -98,20 +90,32 @@ import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; -import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore"; -import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; -import { isPreviewSupportedInRuntime, usePreviewStateStore } from "../previewStateStore"; +import { + selectActiveRightPanelKindWithUrl, + selectActiveRightPanelSurface, + selectThreadRightPanelState, + type RightPanelSurface, + useRightPanelStore, +} from "../rightPanelStore"; +import { + isPreviewSupportedInRuntime, + selectThreadPreviewState, + usePreviewStateStore, +} from "../previewStateStore"; import { subscribePreviewAction } from "./preview/previewActionBus"; // Lazy: keeps the entire preview component graph (webview host, favicon // helper, Chromium error icon) out of the web bundle until first open. const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), ); +const DiffPanel = lazy(() => import("./DiffPanel")); import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { RightPanelTabs } from "./RightPanelTabs"; +import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -167,8 +171,6 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { - MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, @@ -183,8 +185,6 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, - reconcileMountedTerminalThreadIds, - reconcileRetainedMountedThreadIds, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -535,6 +535,7 @@ interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; + mode?: "drawer" | "panel"; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; @@ -548,6 +549,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadRef, threadId, visible, + mode = "drawer", launchContext, focusRequestId, splitShortcutLabel, @@ -795,8 +797,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra } return ( -
+
selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); - const openTerminalThreadKeys = useTerminalUiStateStore( - useShallow((state) => - Object.entries(state.terminalUiStateByThreadKey).flatMap( - ([nextThreadKey, nextTerminalUiState]) => - nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); - const serverThreadKeys = useStore( - useShallow((state) => - selectThreadsAcrossEnvironments(state).map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ), - ), - ); - 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 previewSessionThreadKeys = usePreviewStateStore( - useShallow((state) => - Object.entries(state.byThreadKey).flatMap(([nextThreadKey, nextPreviewState]) => - nextPreviewState.snapshot ? [nextThreadKey] : [], - ), - ), - ); - const [mountedPreviewThreadKeys, setMountedPreviewThreadKeys] = useState([]); - const mountedPreviewThreadRefs = useMemo( - () => - mountedPreviewThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }), - [mountedPreviewThreadKeys], - ); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -1105,18 +1060,40 @@ export default function ChatView(props: ChatViewProps) { const activeRightPanelKind = useRightPanelStore((store) => selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), ); + const rightPanelState = useRightPanelStore((store) => + selectThreadRightPanelState(store.byThreadKey, activeThreadRef), + ); + const activeRightPanelSurface = useRightPanelStore((store) => + selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), + ); + const activePreviewState = usePreviewStateStore((state) => + selectThreadPreviewState(state.byThreadKey, activeThreadRef), + ); const planSidebarOpen = activeRightPanelKind === "plan"; const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); - const existingOpenTerminalThreadKeys = useMemo(() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); - const existingPreviewSessionThreadKeys = useMemo(() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return previewSessionThreadKeys.filter((nextThreadKey) => - existingThreadKeys.has(nextThreadKey), + const terminalPanelOpen = activeRightPanelKind === "terminal"; + const rightPanelOpen = activeRightPanelSurface !== null; + + useEffect(() => { + if (!activeThreadRef) return; + useRightPanelStore + .getState() + .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); + }, [activePreviewState.sessions, activeThreadRef]); + + useEffect(() => { + if (!activeThreadRef || !diffOpen) return; + useRightPanelStore.getState().open(activeThreadRef, "diff"); + }, [activeThreadRef, diffOpen]); + useEffect(() => { + if (!activeThreadRef || !terminalUiState.terminalOpen) return; + const state = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, ); - }, [draftThreadKeys, previewSessionThreadKeys, serverThreadKeys]); + if (state.surfaces.some((surface) => surface.kind === "terminal")) return; + useRightPanelStore.getState().open(activeThreadRef, "terminal"); + }, [activeThreadRef, terminalUiState.terminalOpen]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -1131,42 +1108,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 && terminalUiState.terminalOpen), - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - return currentThreadIds.length === nextThreadIds.length && - currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) - ? currentThreadIds - : nextThreadIds; - }); - }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); - useEffect(() => { - setMountedPreviewThreadKeys((currentThreadIds) => { - const nextThreadIds = reconcileRetainedMountedThreadIds({ - currentThreadIds, - openThreadIds: existingPreviewSessionThreadKeys, - activeThreadId: activeThreadKey, - activeThreadOpen: Boolean(activeThreadKey && !shouldUsePlanSidebarSheet), - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, - retainInactiveActiveThread: true, - }); - return currentThreadIds.length === nextThreadIds.length && - currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) - ? currentThreadIds - : nextThreadIds; - }); - }, [ - activeThreadKey, - existingPreviewSessionThreadKeys, - previewPanelOpen, - shouldUsePlanSidebarSheet, - ]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -1961,19 +1902,6 @@ export default function ChatView(props: ChatViewProps) { }), [terminalUiState.terminalOpen], ); - const nonTerminalShortcutLabelOptions = useMemo( - () => ({ - context: { - terminalFocus: false, - terminalOpen: Boolean(terminalUiState.terminalOpen), - }, - }), - [terminalUiState.terminalOpen], - ); - const terminalToggleShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.toggle"), - [keybindings], - ); const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], @@ -1986,14 +1914,37 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); - const diffPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), - [keybindings, nonTerminalShortcutLabelOptions], - ); - const previewPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "preview.toggle", nonTerminalShortcutLabelOptions), - [keybindings, nonTerminalShortcutLabelOptions], - ); + const createBrowserSurface = useCallback(() => { + if (!activeThreadRef) return; + const api = readEnvironmentApi(activeThreadRef.environmentId); + if (!api) return; + void api.preview + .open({ threadId: activeThreadRef.threadId }) + .then((snapshot) => { + usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot); + useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId); + }) + .catch(() => undefined); + }, [activeThreadRef]); + const addDiffSurface = useCallback(() => { + if (!activeThreadRef || !isServerThread || !isGitRepo) return; + useRightPanelStore.getState().open(activeThreadRef, "diff"); + onDiffPanelOpen?.(); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), + }); + }, [ + activeThreadRef, + environmentId, + isGitRepo, + isServerThread, + navigate, + onDiffPanelOpen, + threadId, + ]); // Right-panel arbitration: // - The diff panel's openness is mirrored by the `?diff=1` URL search // param so it deep-links cleanly. The store still records preview/plan @@ -2017,20 +1968,33 @@ export default function ChatView(props: ChatViewProps) { search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), }); } - useRightPanelStore.getState().open(activeThreadRef, "preview"); - }, [activeThreadRef, diffOpen, environmentId, navigate, previewPanelOpen, threadId]); + const activeTabId = activePreviewState.activeTabId; + if (!activeTabId) { + createBrowserSurface(); + return; + } + useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); + }, [ + activePreviewState.activeTabId, + activeThreadRef, + createBrowserSurface, + diffOpen, + environmentId, + navigate, + previewPanelOpen, + threadId, + ]); const onToggleDiff = useCallback(() => { if (!isServerThread) { return; } if (!diffOpen) { onDiffPanelOpen?.(); - // Switching to diff: drop whatever the store had (e.g. preview) so - // that when the user later closes diff, an old store entry doesn't - // unexpectedly resurface. if (activeThreadRef) { - useRightPanelStore.getState().close(activeThreadRef); + useRightPanelStore.getState().open(activeThreadRef, "diff"); } + } else if (activeThreadRef) { + useRightPanelStore.getState().closeSurface(activeThreadRef, "diff"); } void navigate({ to: "/$environmentId/$threadId", @@ -2147,8 +2111,13 @@ export default function ChatView(props: ChatViewProps) { ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; - setTerminalOpen(!terminalUiState.terminalOpen); - }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); + if (terminalPanelOpen) { + useRightPanelStore.getState().close(activeThreadRef); + return; + } + setTerminalOpen(true); + useRightPanelStore.getState().open(activeThreadRef, "terminal"); + }, [activeThreadRef, setTerminalOpen, terminalPanelOpen]); const splitTerminal = useCallback(() => { if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { return; @@ -2348,8 +2317,12 @@ export default function ChatView(props: ChatViewProps) { activeThreadRef ) { try { - await api.preview.open({ threadId: activeThreadId, url: script.previewUrl }); - useRightPanelStore.getState().open(activeThreadRef, "preview"); + const snapshot = await api.preview.open({ + threadId: activeThreadId, + url: script.previewUrl, + }); + usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot); + useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId); } catch { // Preview open failures are surfaced via the panel itself. } @@ -2579,8 +2552,96 @@ export default function ChatView(props: ChatViewProps) { const closePreviewPanel = useCallback(() => { if (!activeThreadRef) return; useRightPanelStore.getState().close(activeThreadRef); - }, [activeThreadRef]); - + if (diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + }, [activeThreadRef, diffOpen, environmentId, navigate, threadId]); + const activateRightPanelSurface = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); + if (surface.kind === "preview" && surface.resourceId) { + usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); + } + if (surface.kind === "terminal") { + setTerminalOpen(true); + } + if (surface.kind === "diff" && !diffOpen) { + onDiffPanelOpen?.(); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), + }); + } else if (surface.kind !== "diff" && diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + }, + [ + activeThreadRef, + diffOpen, + environmentId, + navigate, + onDiffPanelOpen, + setTerminalOpen, + threadId, + ], + ); + const toggleRightPanel = useCallback(() => { + if (!activeThreadRef) return; + if (rightPanelOpen) { + closePreviewPanel(); + return; + } + const surface = rightPanelState.surfaces.at(-1) ?? null; + if (surface) { + activateRightPanelSurface(surface); + return; + } + createBrowserSurface(); + }, [ + activeThreadRef, + activateRightPanelSurface, + closePreviewPanel, + createBrowserSurface, + rightPanelOpen, + rightPanelState.surfaces, + ]); + const closeRightPanelSurface = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + if (surface.kind === "preview" && surface.resourceId) { + const api = readEnvironmentApi(activeThreadRef.environmentId); + void api?.preview + .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) + .catch(() => undefined); + } + if (surface.kind === "terminal") { + setTerminalOpen(false); + } + if (surface.kind === "diff" && diffOpen) { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId }, + replace: true, + search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), + }); + } + }, + [activeThreadRef, diffOpen, environmentId, navigate, setTerminalOpen, threadId], + ); const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; @@ -3952,314 +4013,323 @@ export default function ChatView(props: ChatViewProps) { } return ( -
- {/* Top bar */} -
- + {isElectron && activeThreadRef ? ( + + ) : null} +
+ {/* Top bar */} +
+ +
+ + {/* Error banner */} + + setThreadError(activeThread.id, null)} /> -
+ {/* Main content area with optional plan sidebar */} +
+ {/* Chat column */} +
+ {/* Messages Wrapper */} +
+ {/* Messages — LegendList handles virtualization and scrolling internally */} + - {/* Error banner */} - - setThreadError(activeThread.id, null)} - /> - {/* Main content area with optional plan sidebar */} -
- {/* Chat column */} -
- {/* Messages Wrapper */} -
- {/* Messages — LegendList handles virtualization and scrolling internally */} - + {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} + {showScrollToBottom && ( +
+ +
+ )} +
- {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} - {showScrollToBottom && ( -
- + {/* Input bar */} +
+
+ +
+ +
- )} -
- - {/* Input bar */} -
-
- -
- -
+ )}
- {isGitRepo && ( - { + if (!open) { + closePullRequestDialog(); + } + }} + onPrepared={handlePreparedPullRequestThread} /> - )} + ) : null}
- - {pullRequestDialogState ? ( - { - if (!open) { - closePullRequestDialog(); - } - }} - onPrepared={handlePreparedPullRequestThread} - /> - ) : null} + {/* end chat column */}
- {/* end chat column */} - - {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( - - ) : null} - {!shouldUsePlanSidebarSheet - ? mountedPreviewThreadRefs.map( - ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => { - const visible = previewPanelOpen && mountedThreadKey === activeThreadKey; - return ( -
- - - -
- ); - }, - ) - : null} + {/* end horizontal flex container */}
- {/* end horizontal flex container */} - {activeThreadRef ? ( - + {!shouldUsePlanSidebarSheet && + rightPanelOpen && + activeThreadRef && + activeRightPanelSurface ? ( + + {activeRightPanelSurface.kind === "preview" ? ( + + + + ) : activeRightPanelSurface.kind === "diff" ? ( + + + + + + ) : null} + ) : null} - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} - {shouldUsePlanSidebarSheet && previewPanelOpen && activeThreadRef ? ( + {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef && activeRightPanelSurface ? ( - - - - - ) : null} - {shouldUsePlanSidebarSheet ? ( - - + surfaces={rightPanelState.surfaces} + activeSurfaceId={activeRightPanelSurface.id} + previewSessions={activePreviewState.sessions} + onActivate={activateRightPanelSurface} + onCloseSurface={closeRightPanelSurface} + onAddBrowser={createBrowserSurface} + onAddDiff={addDiffSurface} + diffAvailable={isServerThread && isGitRepo} + > + {activeRightPanelSurface.kind === "preview" ? ( + + + + ) : activeRightPanelSurface.kind === "terminal" ? ( + + ) : activeRightPanelSurface.kind === "diff" ? ( + + + + + + ) : ( + + )} + ) : null} diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 829ed4159d4ffe95414b2828728e8a05038da443..6a2219d610ff68204fb09026f549f80da10fad17 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -5,10 +5,10 @@ import { cn } from "~/lib/utils"; import { Skeleton } from "./ui/skeleton"; -export type DiffPanelMode = "inline" | "sheet" | "sidebar"; +export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet"; + const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded"; return cn( "flex items-center justify-between gap-2 px-4", shouldUseDragRegion @@ -22,7 +22,7 @@ export function DiffPanelShell(props: { header: ReactNode; children: ReactNode; }) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet"; + const shouldUseDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; return (
void; } diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..af2d118e8680cfaa5b9e1a6e6367e13d9b847cd5 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -0,0 +1,148 @@ +import type { PreviewSessionSnapshot } from "@t3tools/contracts"; +import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import type { RightPanelSurface } from "~/rightPanelStore"; +import { cn } from "~/lib/utils"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { faviconUrlForOrigin } from "~/lib/favicon"; + +import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; + +interface RightPanelTabsProps { + mode: PreviewPanelMode; + surfaces: readonly RightPanelSurface[]; + activeSurfaceId: string; + previewSessions: Readonly>; + onActivate: (surface: RightPanelSurface) => void; + onCloseSurface: (surface: RightPanelSurface) => void; + onAddBrowser: () => void; + onAddDiff: () => void; + diffAvailable: boolean; + children: ReactNode; +} + +function surfaceTitle( + surface: RightPanelSurface, + sessions: Readonly>, +): string { + switch (surface.kind) { + case "diff": + return "Diff"; + case "terminal": + return "Terminal"; + case "plan": + return "Plan"; + case "preview": { + const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; + if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; + if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title; + try { + return new URL(snapshot.navStatus.url).host || "Browser"; + } catch { + return "Browser"; + } + } + } +} + +function PreviewFavicon({ url }: { url: string | null }) { + const faviconUrl = faviconUrlForOrigin(url, 32); + const [failedUrl, setFailedUrl] = useState(null); + if (!faviconUrl || failedUrl === faviconUrl) return ; + return ( + setFailedUrl(faviconUrl)} + /> + ); +} + +function SurfaceIcon({ + surface, + sessions, +}: { + surface: RightPanelSurface; + sessions: Readonly>; +}) { + switch (surface.kind) { + case "preview": { + const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; + const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + return ; + } + case "diff": + return ; + case "terminal": + return ; + case "plan": + return ; + } +} + +export function RightPanelTabs(props: RightPanelTabsProps) { + return ( + +
+
+ {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const title = surfaceTitle(surface, props.previewSessions); + return ( +
+ + +
+ ); + })} +
+ + + + + + + + Browser + + + + Diff + + + +
+
{props.children}
+
+ ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 0213aa0b08b482151773be028dba68f183dd1f71..c4757cb345505f27910cd7d6a04fd359b78551b9 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -20,6 +20,7 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { openInPreferredEditor } from "../editorPreferences"; import { @@ -791,6 +792,7 @@ export function TerminalViewport({ } interface ThreadTerminalDrawerProps { + mode?: "drawer" | "panel"; threadRef: ScopedThreadRef; threadId: ThreadId; cwd: string; @@ -849,6 +851,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA } export default function ThreadTerminalDrawer({ + mode = "drawer", threadRef, threadId, cwd, @@ -874,6 +877,7 @@ export default function ThreadTerminalDrawer({ terminalLabelsById, terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { + const isPanel = mode === "panel"; const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); const drawerHeightRef = useRef(drawerHeight); @@ -1139,16 +1143,21 @@ export default function ThreadTerminalDrawer({ if (normalizedTerminalIds.length === 0) { return (