| author | |
| committer | |
| log | 6c6740e381aa1787492a8366f9020304ba322700 |
| tree | 057b0b8f2f71e0fa5710d077a52662d3c8fa89f1 |
| parent | 29150b57367062e4382de7fb3ff0c952b7c38c4e |
Co-authored-by: codex <codex@users.noreply.github.com>59 files changed, 3222 insertions(+), 1200 deletions(-)
apps/desktop/package.json+1| ... | @@ -21,6 +21,7 @@ | ... | @@ -21,6 +21,7 @@ |
| 21 | "effect": "catalog:", | 21 | "effect": "catalog:", |
| 22 | "electron": "41.5.0", | 22 | "electron": "41.5.0", |
| 23 | "electron-updater": "^6.6.2", | 23 | "electron-updater": "^6.6.2", |
| 24 | "playwright-core": "1.60.0", | ||
| 24 | "react-grab": "^0.1.32" | 25 | "react-grab": "^0.1.32" |
| 25 | }, | 26 | }, |
| 26 | "devDependencies": { | 27 | "devDependencies": { |
apps/desktop/src/ipc/channels.ts+5| ... | @@ -56,6 +56,7 @@ export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; | ... | @@ -56,6 +56,7 @@ export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; |
| 56 | export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; | 56 | export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; |
| 57 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; | 57 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; |
| 58 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; | 58 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; |
| 59 | export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; | ||
| 59 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; | 60 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; |
| 60 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; | 61 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; |
| 61 | export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; | 62 | export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; |
| ... | @@ -64,4 +65,8 @@ export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-pres | ... | @@ -64,4 +65,8 @@ export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-pres |
| 64 | export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; | 65 | export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; |
| 65 | export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; | 66 | export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; |
| 66 | export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; | 67 | export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; |
| 68 | export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; | ||
| 69 | export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; | ||
| 70 | export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; | ||
| 71 | export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; | ||
| 67 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; | 72 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; |
apps/desktop/src/ipc/methods/preview.ts+35-2| ... | @@ -17,6 +17,14 @@ previewViewManager.onStateChange((tabId, state) => { | ... | @@ -17,6 +17,14 @@ previewViewManager.onStateChange((tabId, state) => { |
| 17 | } | 17 | } |
| 18 | }); | 18 | }); |
| 19 | 19 | ||
| 20 | previewViewManager.onRecordingFrame((frame) => { | ||
| 21 | for (const window of BrowserWindow.getAllWindows()) { | ||
| 22 | if (!window.isDestroyed()) { | ||
| 23 | window.webContents.send(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); | ||
| 24 | } | ||
| 25 | } | ||
| 26 | }); | ||
| 27 | |||
| 20 | const tabIdFrom = (raw: unknown): string => { | 28 | const tabIdFrom = (raw: unknown): string => { |
| 21 | if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { | 29 | if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { |
| 22 | throw new Error("preview tab id is required"); | 30 | throw new Error("preview tab id is required"); |
| ... | @@ -95,10 +103,16 @@ export const previewMethods = [ | ... | @@ -95,10 +103,16 @@ export const previewMethods = [ |
| 95 | ), | 103 | ), |
| 96 | method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), | 104 | method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()), |
| 97 | method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()), | 105 | method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()), |
| 98 | method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, () => { | 106 | method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, (raw) => { |
| 107 | const environmentId = | ||
| 108 | typeof raw === "object" && raw !== null && "environmentId" in raw ? raw.environmentId : null; | ||
| 109 | if (typeof environmentId !== "string" || environmentId.length === 0) { | ||
| 110 | throw new Error("preview environment id is required"); | ||
| 111 | } | ||
| 112 | previewViewManager.getBrowserSession(environmentId); | ||
| 99 | const preloadPath = `${__dirname}/preview-pick-preload.cjs`; | 113 | const preloadPath = `${__dirname}/preview-pick-preload.cjs`; |
| 100 | return { | 114 | return { |
| 101 | partition: previewViewManager.getBrowserPartition(), | 115 | partition: previewViewManager.getBrowserPartition(environmentId), |
| 102 | webPreferences: PREVIEW_WEBVIEW_PREFERENCES, | 116 | webPreferences: PREVIEW_WEBVIEW_PREFERENCES, |
| 103 | preloadUrl: pathToFileURL(preloadPath).href, | 117 | preloadUrl: pathToFileURL(preloadPath).href, |
| 104 | }; | 118 | }; |
| ... | @@ -109,6 +123,9 @@ export const previewMethods = [ | ... | @@ -109,6 +123,9 @@ export const previewMethods = [ |
| 109 | method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => | 123 | method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => |
| 110 | previewViewManager.cancelPickElement(tabIdFrom(raw)), | 124 | previewViewManager.cancelPickElement(tabIdFrom(raw)), |
| 111 | ), | 125 | ), |
| 126 | method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => | ||
| 127 | previewViewManager.captureScreenshot(tabIdFrom(raw)), | ||
| 128 | ), | ||
| 112 | method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => | 129 | method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => |
| 113 | previewViewManager.automationStatus(tabIdFrom(raw)), | 130 | previewViewManager.automationStatus(tabIdFrom(raw)), |
| 114 | ), | 131 | ), |
| ... | @@ -151,4 +168,20 @@ export const previewMethods = [ | ... | @@ -151,4 +168,20 @@ export const previewMethods = [ |
| 151 | inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1], | 168 | inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1], |
| 152 | ), | 169 | ), |
| 153 | ), | 170 | ), |
| 171 | method(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, (raw) => | ||
| 172 | previewViewManager.startRecording(tabIdFrom(raw)), | ||
| 173 | ), | ||
| 174 | method(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, (raw) => | ||
| 175 | previewViewManager.stopRecording(tabIdFrom(raw)), | ||
| 176 | ), | ||
| 177 | method(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, (raw) => { | ||
| 178 | const tabId = tabIdFrom(raw); | ||
| 179 | if (typeof raw !== "object" || raw === null) throw new Error("recording payload is required"); | ||
| 180 | const mimeType = "mimeType" in raw ? raw.mimeType : null; | ||
| 181 | const data = "data" in raw ? raw.data : null; | ||
| 182 | if (typeof mimeType !== "string" || !(data instanceof Uint8Array)) { | ||
| 183 | throw new Error("recording mimeType and bytes are required"); | ||
| 184 | } | ||
| 185 | return previewViewManager.saveRecording(tabId, mimeType, data); | ||
| 186 | }), | ||
| 154 | ] as const; | 187 | ] as const; |
apps/desktop/src/playwright-injected-runtime.test.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | playwrightInjectedRuntimeInstallExpression, | ||
| 5 | playwrightInjectedRuntimeSource, | ||
| 6 | } from "./playwright-injected-runtime.ts"; | ||
| 7 | |||
| 8 | describe("playwright injected runtime", () => { | ||
| 9 | it("extracts the pinned runtime from playwright-core", async () => { | ||
| 10 | const source = await playwrightInjectedRuntimeSource(); | ||
| 11 | expect(source.length).toBeGreaterThan(100_000); | ||
| 12 | expect(source).toContain("InjectedScript"); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("builds an idempotent install expression", async () => { | ||
| 16 | const expression = await playwrightInjectedRuntimeInstallExpression(); | ||
| 17 | expect(expression).toContain("__t3PlaywrightInjected"); | ||
| 18 | expect(expression).toContain('testIdAttributeName":"data-testid'); | ||
| 19 | }); | ||
| 20 | }); | ||
apps/desktop/src/playwright-injected-runtime.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | import { createRequire } from "node:module"; | ||
| 3 | import { dirname, join } from "node:path"; | ||
| 4 | import { readFile } from "node:fs/promises"; | ||
| 5 | import { runInNewContext } from "node:vm"; | ||
| 6 | |||
| 7 | const require = createRequire(import.meta.url); | ||
| 8 | let sourcePromise: Promise<string> | null = null; | ||
| 9 | |||
| 10 | export const playwrightInjectedRuntimeSource = (): Promise<string> => { | ||
| 11 | sourcePromise ??= (async () => { | ||
| 12 | const packageJsonPath = require.resolve("playwright-core/package.json"); | ||
| 13 | const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8"); | ||
| 14 | const marker = "source3 = "; | ||
| 15 | const start = coreBundle.indexOf(marker); | ||
| 16 | if (start < 0) throw new Error("Playwright injected runtime marker was not found."); | ||
| 17 | const literalStart = start + marker.length; | ||
| 18 | const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart); | ||
| 19 | if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found."); | ||
| 20 | const literal = coreBundle.slice(literalStart, literalEnd); | ||
| 21 | const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 }); | ||
| 22 | if (typeof source !== "string" || source.length < 100_000) { | ||
| 23 | throw new Error("Playwright injected runtime extraction returned invalid source."); | ||
| 24 | } | ||
| 25 | return source; | ||
| 26 | })(); | ||
| 27 | return sourcePromise; | ||
| 28 | }; | ||
| 29 | |||
| 30 | export const playwrightInjectedRuntimeInstallExpression = async (): Promise<string> => { | ||
| 31 | const source = await playwrightInjectedRuntimeSource(); | ||
| 32 | const options = { | ||
| 33 | isUnderTest: false, | ||
| 34 | sdkLanguage: "javascript", | ||
| 35 | testIdAttributeName: "data-testid", | ||
| 36 | stableRafCount: 1, | ||
| 37 | browserName: "chromium", | ||
| 38 | shouldPrependErrorPrefix: false, | ||
| 39 | isUtilityWorld: false, | ||
| 40 | customEngines: [], | ||
| 41 | }; | ||
| 42 | return `(() => { | ||
| 43 | if (globalThis.__t3PlaywrightInjected) return true; | ||
| 44 | const module = { exports: {} }; | ||
| 45 | ${source} | ||
| 46 | globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)}); | ||
| 47 | return true; | ||
| 48 | })()`; | ||
| 49 | }; | ||
apps/desktop/src/preload.ts+30-2| ... | @@ -1,4 +1,8 @@ | ... | @@ -1,4 +1,8 @@ |
| 1 | import type { DesktopBridge, DesktopPreviewTabState } from "@t3tools/contracts"; | 1 | import type { |
| 2 | DesktopBridge, | ||
| 3 | DesktopPreviewRecordingFrame, | ||
| 4 | DesktopPreviewTabState, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { contextBridge, ipcRenderer } from "electron"; | 6 | import { contextBridge, ipcRenderer } from "electron"; |
| 3 | 7 | ||
| 4 | import * as IpcChannels from "./ipc/channels.ts"; | 8 | import * as IpcChannels from "./ipc/channels.ts"; |
| ... | @@ -159,10 +163,34 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -159,10 +163,34 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 159 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), | 163 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), |
| 160 | clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), | 164 | clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), |
| 161 | clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), | 165 | clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), |
| 162 | getPreviewConfig: () => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL), | 166 | getPreviewConfig: (environmentId) => |
| 167 | ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), | ||
| 163 | pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), | 168 | pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), |
| 164 | cancelPickElement: (tabId) => | 169 | cancelPickElement: (tabId) => |
| 165 | ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), | 170 | ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), |
| 171 | captureScreenshot: (tabId) => | ||
| 172 | ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }), | ||
| 173 | recording: { | ||
| 174 | startScreencast: (tabId) => | ||
| 175 | ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), | ||
| 176 | stopScreencast: (tabId) => | ||
| 177 | ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, { tabId }), | ||
| 178 | save: (tabId, mimeType, data) => | ||
| 179 | ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, { | ||
| 180 | tabId, | ||
| 181 | mimeType, | ||
| 182 | data, | ||
| 183 | }), | ||
| 184 | onFrame: (listener) => { | ||
| 185 | const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { | ||
| 186 | if (typeof frame !== "object" || frame === null) return; | ||
| 187 | listener(frame as DesktopPreviewRecordingFrame); | ||
| 188 | }; | ||
| 189 | ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); | ||
| 190 | return () => | ||
| 191 | ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); | ||
| 192 | }, | ||
| 193 | }, | ||
| 166 | automation: { | 194 | automation: { |
| 167 | status: (tabId) => | 195 | status: (tabId) => |
| 168 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), | 196 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), |
apps/desktop/src/preview-pick-preload.ts+8| ... | @@ -16,6 +16,7 @@ const START_PICK_CHANNEL = "preview:start-pick"; | ... | @@ -16,6 +16,7 @@ const START_PICK_CHANNEL = "preview:start-pick"; |
| 16 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; | 16 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; |
| 17 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | 17 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; |
| 18 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; | 18 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; |
| 19 | const HUMAN_INPUT_CHANNEL = "preview:human-input"; | ||
| 19 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; | 20 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; |
| 20 | const Z_INDEX_OVERLAY = 2147483646; | 21 | const Z_INDEX_OVERLAY = 2147483646; |
| 21 | const ACCENT = "#7c3aed"; | 22 | const ACCENT = "#7c3aed"; |
| ... | @@ -42,6 +43,13 @@ interface AnnotationSession { | ... | @@ -42,6 +43,13 @@ interface AnnotationSession { |
| 42 | let activeSession: AnnotationSession | null = null; | 43 | let activeSession: AnnotationSession | null = null; |
| 43 | let idSequence = 0; | 44 | let idSequence = 0; |
| 44 | 45 | ||
| 46 | const reportHumanInput = (event: Event): void => { | ||
| 47 | if (event.isTrusted) ipcRenderer.send(HUMAN_INPUT_CHANNEL); | ||
| 48 | }; | ||
| 49 | |||
| 50 | window.addEventListener("pointerdown", reportHumanInput, true); | ||
| 51 | window.addEventListener("keydown", reportHumanInput, true); | ||
| 52 | |||
| 45 | const nextId = (prefix: string): string => { | 53 | const nextId = (prefix: string): string => { |
| 46 | idSequence += 1; | 54 | idSequence += 1; |
| 47 | return `${prefix}_${idSequence.toString(36)}`; | 55 | return `${prefix}_${idSequence.toString(36)}`; |
apps/desktop/src/preview-view-manager.test.ts+58| ... | @@ -1,8 +1,15 @@ | ... | @@ -1,8 +1,15 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | const fromId = vi.fn(() => null); | 3 | const fromId = vi.fn(() => null); |
| 4 | const mkdir = vi.fn(async () => undefined); | ||
| 5 | const writeFile = vi.fn(async () => undefined); | ||
| 6 | |||
| 7 | vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); | ||
| 4 | 8 | ||
| 5 | vi.mock("electron", () => ({ | 9 | vi.mock("electron", () => ({ |
| 10 | app: { | ||
| 11 | getPath: vi.fn(() => "/tmp/t3-code-test"), | ||
| 12 | }, | ||
| 6 | session: { | 13 | session: { |
| 7 | fromPartition: vi.fn(), | 14 | fromPartition: vi.fn(), |
| 8 | }, | 15 | }, |
| ... | @@ -14,6 +21,8 @@ vi.mock("electron", () => ({ | ... | @@ -14,6 +21,8 @@ vi.mock("electron", () => ({ |
| 14 | describe("PreviewViewManager automation status", () => { | 21 | describe("PreviewViewManager automation status", () => { |
| 15 | beforeEach(() => { | 22 | beforeEach(() => { |
| 16 | fromId.mockClear(); | 23 | fromId.mockClear(); |
| 24 | mkdir.mockClear(); | ||
| 25 | writeFile.mockClear(); | ||
| 17 | }); | 26 | }); |
| 18 | 27 | ||
| 19 | it("reports an unregistered webview as temporarily unavailable", async () => { | 28 | it("reports an unregistered webview as temporarily unavailable", async () => { |
| ... | @@ -41,4 +50,53 @@ describe("PreviewViewManager automation status", () => { | ... | @@ -41,4 +50,53 @@ describe("PreviewViewManager automation status", () => { |
| 41 | }); | 50 | }); |
| 42 | expect(fromId).not.toHaveBeenCalled(); | 51 | expect(fromId).not.toHaveBeenCalled(); |
| 43 | }); | 52 | }); |
| 53 | |||
| 54 | it("captures a PNG screenshot into browser artifacts", async () => { | ||
| 55 | const png = Buffer.from("preview-png"); | ||
| 56 | const capturePage = vi.fn(async () => ({ toPNG: () => png })); | ||
| 57 | const listeners = new Map<string, (...args: never[]) => void>(); | ||
| 58 | fromId.mockReturnValue({ | ||
| 59 | id: 42, | ||
| 60 | isDestroyed: () => false, | ||
| 61 | getType: () => "webview", | ||
| 62 | getURL: () => "https://example.com/", | ||
| 63 | getTitle: () => "Example", | ||
| 64 | isLoading: () => false, | ||
| 65 | getZoomFactor: () => 1, | ||
| 66 | setZoomFactor: vi.fn(), | ||
| 67 | on: vi.fn((event: string, listener: (...args: never[]) => void) => { | ||
| 68 | listeners.set(event, listener); | ||
| 69 | }), | ||
| 70 | off: vi.fn(), | ||
| 71 | ipc: { on: vi.fn(), off: vi.fn() }, | ||
| 72 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 73 | setWindowOpenHandler: vi.fn(), | ||
| 74 | debugger: { | ||
| 75 | isAttached: () => false, | ||
| 76 | attach: vi.fn(), | ||
| 77 | sendCommand: vi.fn(async () => undefined), | ||
| 78 | on: vi.fn(), | ||
| 79 | off: vi.fn(), | ||
| 80 | }, | ||
| 81 | capturePage, | ||
| 82 | } as never); | ||
| 83 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 84 | const manager = new PreviewViewManager(); | ||
| 85 | manager.createTab("tab_1"); | ||
| 86 | manager.registerWebview("tab_1", 42); | ||
| 87 | |||
| 88 | const artifact = await manager.captureScreenshot("tab_1"); | ||
| 89 | |||
| 90 | expect(capturePage).toHaveBeenCalledOnce(); | ||
| 91 | expect(mkdir).toHaveBeenCalledWith("/tmp/t3-code-test/browser-artifacts", { | ||
| 92 | recursive: true, | ||
| 93 | }); | ||
| 94 | expect(writeFile).toHaveBeenCalledWith(artifact.path, png); | ||
| 95 | expect(artifact).toMatchObject({ | ||
| 96 | tabId: "tab_1", | ||
| 97 | mimeType: "image/png", | ||
| 98 | sizeBytes: png.byteLength, | ||
| 99 | }); | ||
| 100 | expect(artifact.path).toMatch(/\/browser-artifacts\/browser-screenshot-[^.]+\.png$/); | ||
| 101 | }); | ||
| 44 | }); | 102 | }); |
apps/desktop/src/preview-view-manager.ts+461-63| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | // @effect-diagnostics globalDate:off | 1 | // @effect-diagnostics globalDate:off |
| 2 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 2 | /** | 3 | /** |
| 3 | * PreviewViewManager — desktop side of the in-app browser preview. | 4 | * PreviewViewManager — desktop side of the in-app browser preview. |
| 4 | * | 5 | * |
| ... | @@ -9,9 +10,15 @@ | ... | @@ -9,9 +10,15 @@ |
| 9 | import type { | 10 | import type { |
| 10 | PreviewAnnotationPayload, | 11 | PreviewAnnotationPayload, |
| 11 | PreviewAnnotationRect, | 12 | PreviewAnnotationRect, |
| 13 | DesktopPreviewRecordingArtifact, | ||
| 14 | DesktopPreviewRecordingFrame, | ||
| 15 | DesktopPreviewScreenshotArtifact, | ||
| 12 | PreviewAutomationClickInput, | 16 | PreviewAutomationClickInput, |
| 17 | PreviewAutomationActionEvent, | ||
| 18 | PreviewAutomationConsoleEntry, | ||
| 13 | PreviewAutomationEvaluateInput, | 19 | PreviewAutomationEvaluateInput, |
| 14 | PreviewAutomationPressInput, | 20 | PreviewAutomationPressInput, |
| 21 | PreviewAutomationNetworkEntry, | ||
| 15 | PreviewAutomationScrollInput, | 22 | PreviewAutomationScrollInput, |
| 16 | PreviewAutomationSnapshot, | 23 | PreviewAutomationSnapshot, |
| 17 | PreviewAutomationStatus, | 24 | PreviewAutomationStatus, |
| ... | @@ -19,16 +26,21 @@ import type { | ... | @@ -19,16 +26,21 @@ import type { |
| 19 | PreviewAutomationWaitForInput, | 26 | PreviewAutomationWaitForInput, |
| 20 | } from "@t3tools/contracts"; | 27 | } from "@t3tools/contracts"; |
| 21 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | 28 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 22 | import { type BrowserWindow, type Session, session, webContents } from "electron"; | 29 | import { app, type BrowserWindow, type Session, session, webContents } from "electron"; |
| 30 | import { mkdir, writeFile } from "node:fs/promises"; | ||
| 31 | import { join } from "node:path"; | ||
| 32 | import { createHash } from "node:crypto"; | ||
| 23 | import { setTimeout as sleep } from "node:timers/promises"; | 33 | import { setTimeout as sleep } from "node:timers/promises"; |
| 24 | 34 | ||
| 25 | import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; | 35 | import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; |
| 36 | import { playwrightInjectedRuntimeInstallExpression } from "./playwright-injected-runtime.ts"; | ||
| 26 | 37 | ||
| 27 | const PREVIEW_PARTITION = "persist:t3code-preview"; | 38 | const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; |
| 28 | const START_PICK_CHANNEL = "preview:start-pick"; | 39 | const START_PICK_CHANNEL = "preview:start-pick"; |
| 29 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; | 40 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; |
| 30 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | 41 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; |
| 31 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; | 42 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; |
| 43 | const HUMAN_INPUT_CHANNEL = "preview:human-input"; | ||
| 32 | 44 | ||
| 33 | // Re-export the guest webview security posture from its dedicated module so | 45 | // Re-export the guest webview security posture from its dedicated module so |
| 34 | // the constant is unit-testable in isolation. See | 46 | // the constant is unit-testable in isolation. See |
| ... | @@ -55,6 +67,7 @@ export interface PreviewTabState { | ... | @@ -55,6 +67,7 @@ export interface PreviewTabState { |
| 55 | canGoBack: boolean; | 67 | canGoBack: boolean; |
| 56 | canGoForward: boolean; | 68 | canGoForward: boolean; |
| 57 | zoomFactor: number; | 69 | zoomFactor: number; |
| 70 | controller: "human" | "agent" | "none"; | ||
| 58 | updatedAt: string; | 71 | updatedAt: string; |
| 59 | } | 72 | } |
| 60 | 73 | ||
| ... | @@ -69,6 +82,7 @@ const MAX_EVALUATION_BYTES = 64_000; | ... | @@ -69,6 +82,7 @@ const MAX_EVALUATION_BYTES = 64_000; |
| 69 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; | 82 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; |
| 70 | const MAX_INTERACTIVE_ELEMENTS = 200; | 83 | const MAX_INTERACTIVE_ELEMENTS = 200; |
| 71 | const MAX_SCREENSHOT_WIDTH = 1280; | 84 | const MAX_SCREENSHOT_WIDTH = 1280; |
| 85 | const DIAGNOSTIC_BUFFER_LIMIT = 200; | ||
| 72 | 86 | ||
| 73 | interface CdpEvaluationResult { | 87 | interface CdpEvaluationResult { |
| 74 | readonly result?: { | 88 | readonly result?: { |
| ... | @@ -86,7 +100,8 @@ const automationError = ( | ... | @@ -86,7 +100,8 @@ const automationError = ( |
| 86 | | "PreviewAutomationExecutionError" | 100 | | "PreviewAutomationExecutionError" |
| 87 | | "PreviewAutomationInvalidSelectorError" | 101 | | "PreviewAutomationInvalidSelectorError" |
| 88 | | "PreviewAutomationResultTooLargeError" | 102 | | "PreviewAutomationResultTooLargeError" |
| 89 | | "PreviewAutomationTimeoutError", | 103 | | "PreviewAutomationTimeoutError" |
| 104 | | "PreviewAutomationControlInterruptedError", | ||
| 90 | message: string, | 105 | message: string, |
| 91 | detail?: unknown, | 106 | detail?: unknown, |
| 92 | ): Error & { detail?: unknown } => { | 107 | ): Error & { detail?: unknown } => { |
| ... | @@ -165,10 +180,12 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { | ... | @@ -165,10 +180,12 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { |
| 165 | }; | 180 | }; |
| 166 | 181 | ||
| 167 | type Listener = (tabId: string, state: PreviewTabState) => void; | 182 | type Listener = (tabId: string, state: PreviewTabState) => void; |
| 183 | type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; | ||
| 168 | 184 | ||
| 169 | interface ManagedListeners { | 185 | interface ManagedListeners { |
| 170 | navigate: () => void; | 186 | navigate: () => void; |
| 171 | failed: (event: Event, code: number, description: string) => void; | 187 | failed: (event: Event, code: number, description: string) => void; |
| 188 | humanInput: () => void; | ||
| 172 | } | 189 | } |
| 173 | 190 | ||
| 174 | interface PickSession { | 191 | interface PickSession { |
| ... | @@ -176,6 +193,18 @@ interface PickSession { | ... | @@ -176,6 +193,18 @@ interface PickSession { |
| 176 | readonly cleanup: () => void; | 193 | readonly cleanup: () => void; |
| 177 | } | 194 | } |
| 178 | 195 | ||
| 196 | interface BrowserControlSession { | ||
| 197 | readonly webContentsId: number; | ||
| 198 | tail: Promise<void>; | ||
| 199 | initialized: Promise<void>; | ||
| 200 | } | ||
| 201 | |||
| 202 | interface BrowserDiagnostics { | ||
| 203 | readonly consoleEntries: PreviewAutomationConsoleEntry[]; | ||
| 204 | readonly networkEntries: PreviewAutomationNetworkEntry[]; | ||
| 205 | readonly requests: Map<string, { url: string; method: string }>; | ||
| 206 | } | ||
| 207 | |||
| 179 | const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ | 208 | const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ |
| 180 | key: string; | 209 | key: string; |
| 181 | meta: boolean; | 210 | meta: boolean; |
| ... | @@ -196,17 +225,30 @@ export class PreviewViewManager { | ... | @@ -196,17 +225,30 @@ export class PreviewViewManager { |
| 196 | private mainWindow: BrowserWindow | null = null; | 225 | private mainWindow: BrowserWindow | null = null; |
| 197 | private readonly tabs = new Map<string, PreviewTabState>(); | 226 | private readonly tabs = new Map<string, PreviewTabState>(); |
| 198 | private readonly attached = new Map<number, ManagedListeners>(); | 227 | private readonly attached = new Map<number, ManagedListeners>(); |
| 199 | private browserSession: Session | null = null; | 228 | private readonly browserSessions = new Map<string, Session>(); |
| 200 | private readonly listeners = new Set<Listener>(); | 229 | private readonly listeners = new Set<Listener>(); |
| 230 | private readonly recordingFrameListeners = new Set<RecordingFrameListener>(); | ||
| 201 | /** In-flight preview annotation sessions, keyed by tabId. */ | 231 | /** In-flight preview annotation sessions, keyed by tabId. */ |
| 202 | private readonly pickSessions = new Map<string, PickSession>(); | 232 | private readonly pickSessions = new Map<string, PickSession>(); |
| 233 | /** One long-lived CDP attachment and serialized command queue per guest. */ | ||
| 234 | private readonly controlSessions = new Map<number, BrowserControlSession>(); | ||
| 235 | private readonly diagnostics = new Map<number, BrowserDiagnostics>(); | ||
| 236 | private readonly controlEpoch = new Map<string, number>(); | ||
| 237 | private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>(); | ||
| 238 | private actionSequence = 0; | ||
| 239 | private recordingTabId: string | null = null; | ||
| 203 | 240 | ||
| 204 | setMainWindow(window: BrowserWindow): void { | 241 | setMainWindow(window: BrowserWindow): void { |
| 205 | this.mainWindow = window; | 242 | this.mainWindow = window; |
| 206 | } | 243 | } |
| 207 | 244 | ||
| 208 | getBrowserPartition(): string { | 245 | getBrowserPartition(scope = "shared"): string { |
| 209 | return PREVIEW_PARTITION; | 246 | const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20); |
| 247 | return `${PREVIEW_PARTITION_PREFIX}${digest}`; | ||
| 248 | } | ||
| 249 | |||
| 250 | isBrowserPartition(partition: string): boolean { | ||
| 251 | return partition.startsWith(PREVIEW_PARTITION_PREFIX); | ||
| 210 | } | 252 | } |
| 211 | 253 | ||
| 212 | /** | 254 | /** |
| ... | @@ -219,9 +261,11 @@ export class PreviewViewManager { | ... | @@ -219,9 +261,11 @@ export class PreviewViewManager { |
| 219 | return PREVIEW_WEBVIEW_PREFERENCES; | 261 | return PREVIEW_WEBVIEW_PREFERENCES; |
| 220 | } | 262 | } |
| 221 | 263 | ||
| 222 | getBrowserSession(): Session { | 264 | getBrowserSession(scope = "shared"): Session { |
| 223 | if (this.browserSession) return this.browserSession; | 265 | const partition = this.getBrowserPartition(scope); |
| 224 | const sess = session.fromPartition(PREVIEW_PARTITION); | 266 | const existing = this.browserSessions.get(partition); |
| 267 | if (existing) return existing; | ||
| 268 | const sess = session.fromPartition(partition); | ||
| 225 | const ua = sess | 269 | const ua = sess |
| 226 | .getUserAgent() | 270 | .getUserAgent() |
| 227 | .replace(/Electron\/[\d.]+ /, "") | 271 | .replace(/Electron\/[\d.]+ /, "") |
| ... | @@ -231,7 +275,7 @@ export class PreviewViewManager { | ... | @@ -231,7 +275,7 @@ export class PreviewViewManager { |
| 231 | const allow = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; | 275 | const allow = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; |
| 232 | callback(allow.includes(perm)); | 276 | callback(allow.includes(perm)); |
| 233 | }); | 277 | }); |
| 234 | this.browserSession = sess; | 278 | this.browserSessions.set(partition, sess); |
| 235 | return sess; | 279 | return sess; |
| 236 | } | 280 | } |
| 237 | 281 | ||
| ... | @@ -245,6 +289,7 @@ export class PreviewViewManager { | ... | @@ -245,6 +289,7 @@ export class PreviewViewManager { |
| 245 | canGoBack: false, | 289 | canGoBack: false, |
| 246 | canGoForward: false, | 290 | canGoForward: false, |
| 247 | zoomFactor: DEFAULT_ZOOM_FACTOR, | 291 | zoomFactor: DEFAULT_ZOOM_FACTOR, |
| 292 | controller: "none", | ||
| 248 | updatedAt: new Date().toISOString(), | 293 | updatedAt: new Date().toISOString(), |
| 249 | }; | 294 | }; |
| 250 | this.tabs.set(tabId, initial); | 295 | this.tabs.set(tabId, initial); |
| ... | @@ -257,6 +302,7 @@ export class PreviewViewManager { | ... | @@ -257,6 +302,7 @@ export class PreviewViewManager { |
| 257 | if (!tab) return; | 302 | if (!tab) return; |
| 258 | this.cancelPickElement(tabId); | 303 | this.cancelPickElement(tabId); |
| 259 | if (tab.webContentsId != null) { | 304 | if (tab.webContentsId != null) { |
| 305 | this.detachControlSession(tab.webContentsId); | ||
| 260 | this.detachListeners(tab.webContentsId); | 306 | this.detachListeners(tab.webContentsId); |
| 261 | } | 307 | } |
| 262 | const closed: PreviewTabState = { | 308 | const closed: PreviewTabState = { |
| ... | @@ -266,6 +312,7 @@ export class PreviewViewManager { | ... | @@ -266,6 +312,7 @@ export class PreviewViewManager { |
| 266 | canGoBack: false, | 312 | canGoBack: false, |
| 267 | canGoForward: false, | 313 | canGoForward: false, |
| 268 | zoomFactor: DEFAULT_ZOOM_FACTOR, | 314 | zoomFactor: DEFAULT_ZOOM_FACTOR, |
| 315 | controller: "none", | ||
| 269 | updatedAt: new Date().toISOString(), | 316 | updatedAt: new Date().toISOString(), |
| 270 | }; | 317 | }; |
| 271 | this.tabs.delete(tabId); | 318 | this.tabs.delete(tabId); |
| ... | @@ -294,6 +341,7 @@ export class PreviewViewManager { | ... | @@ -294,6 +341,7 @@ export class PreviewViewManager { |
| 294 | return; | 341 | return; |
| 295 | } | 342 | } |
| 296 | if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { | 343 | if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { |
| 344 | this.detachControlSession(tab.webContentsId); | ||
| 297 | this.detachListeners(tab.webContentsId); | 345 | this.detachListeners(tab.webContentsId); |
| 298 | // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. | 346 | // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`. |
| 299 | // Cancel it so the toggle button doesn't get stuck pressed waiting | 347 | // Cancel it so the toggle button doesn't get stuck pressed waiting |
| ... | @@ -301,6 +349,7 @@ export class PreviewViewManager { | ... | @@ -301,6 +349,7 @@ export class PreviewViewManager { |
| 301 | this.cancelPickElement(tabId); | 349 | this.cancelPickElement(tabId); |
| 302 | } | 350 | } |
| 303 | this.attachListeners(tabId, wc); | 351 | this.attachListeners(tabId, wc); |
| 352 | void this.ensureControlSession(wc).catch(() => undefined); | ||
| 304 | // Restore the persisted zoom factor onto the freshly-attached WebContents | 353 | // Restore the persisted zoom factor onto the freshly-attached WebContents |
| 305 | // so a thread-switch + remount lands the user back where they were. | 354 | // so a thread-switch + remount lands the user back where they were. |
| 306 | if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { | 355 | if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) { |
| ... | @@ -364,6 +413,10 @@ export class PreviewViewManager { | ... | @@ -364,6 +413,10 @@ export class PreviewViewManager { |
| 364 | wc.devToolsWebContents?.focus(); | 413 | wc.devToolsWebContents?.focus(); |
| 365 | return; | 414 | return; |
| 366 | } | 415 | } |
| 416 | this.detachControlSession(wc.id); | ||
| 417 | wc.once("devtools-closed", () => { | ||
| 418 | if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined); | ||
| 419 | }); | ||
| 367 | wc.openDevTools({ mode: "detach" }); | 420 | wc.openDevTools({ mode: "detach" }); |
| 368 | } | 421 | } |
| 369 | 422 | ||
| ... | @@ -372,16 +425,18 @@ export class PreviewViewManager { | ... | @@ -372,16 +425,18 @@ export class PreviewViewManager { |
| 372 | * preview tab since they all share `persist:t3code-preview`. | 425 | * preview tab since they all share `persist:t3code-preview`. |
| 373 | */ | 426 | */ |
| 374 | async clearCookies(): Promise<void> { | 427 | async clearCookies(): Promise<void> { |
| 375 | const sess = this.getBrowserSession(); | 428 | await Promise.all( |
| 376 | await sess.clearStorageData({ | 429 | [...this.browserSessions.values()].map((sess) => |
| 377 | storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], | 430 | sess.clearStorageData({ |
| 378 | }); | 431 | storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], |
| 432 | }), | ||
| 433 | ), | ||
| 434 | ); | ||
| 379 | } | 435 | } |
| 380 | 436 | ||
| 381 | /** Drop the HTTP cache for the preview partition. */ | 437 | /** Drop the HTTP cache for the preview partition. */ |
| 382 | async clearCache(): Promise<void> { | 438 | async clearCache(): Promise<void> { |
| 383 | const sess = this.getBrowserSession(); | 439 | await Promise.all([...this.browserSessions.values()].map((sess) => sess.clearCache())); |
| 384 | await sess.clearCache(); | ||
| 385 | } | 440 | } |
| 386 | 441 | ||
| 387 | /** | 442 | /** |
| ... | @@ -485,6 +540,65 @@ export class PreviewViewManager { | ... | @@ -485,6 +540,65 @@ export class PreviewViewManager { |
| 485 | session.resolve(null); | 540 | session.resolve(null); |
| 486 | } | 541 | } |
| 487 | 542 | ||
| 543 | async startRecording(tabId: string): Promise<void> { | ||
| 544 | if (this.recordingTabId && this.recordingTabId !== tabId) { | ||
| 545 | throw new Error("Only one browser recording can be active per window."); | ||
| 546 | } | ||
| 547 | const wc = this.requireWebContents(tabId); | ||
| 548 | await this.withControlSession(tabId, wc, "recording.start", async (send) => { | ||
| 549 | await send("Page.enable"); | ||
| 550 | await send("Page.startScreencast", { | ||
| 551 | format: "jpeg", | ||
| 552 | quality: 80, | ||
| 553 | maxWidth: 1600, | ||
| 554 | maxHeight: 1200, | ||
| 555 | everyNthFrame: 1, | ||
| 556 | }); | ||
| 557 | }); | ||
| 558 | this.recordingTabId = tabId; | ||
| 559 | } | ||
| 560 | |||
| 561 | async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> { | ||
| 562 | const wc = this.requireWebContents(tabId); | ||
| 563 | const createdAt = new Date().toISOString(); | ||
| 564 | const id = `browser-screenshot-${Date.now().toString(36)}`; | ||
| 565 | const directory = join(app.getPath("userData"), "browser-artifacts"); | ||
| 566 | const path = join(directory, `${id}.png`); | ||
| 567 | const data = (await wc.capturePage()).toPNG(); | ||
| 568 | await mkdir(directory, { recursive: true }); | ||
| 569 | await writeFile(path, data); | ||
| 570 | return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt }; | ||
| 571 | } | ||
| 572 | |||
| 573 | async stopRecording(tabId: string): Promise<void> { | ||
| 574 | if (this.recordingTabId !== tabId) return; | ||
| 575 | const wc = this.requireWebContents(tabId); | ||
| 576 | await this.withControlSession(tabId, wc, "recording.stop", async (send) => { | ||
| 577 | await send("Page.stopScreencast"); | ||
| 578 | }); | ||
| 579 | this.recordingTabId = null; | ||
| 580 | } | ||
| 581 | |||
| 582 | async saveRecording( | ||
| 583 | tabId: string, | ||
| 584 | mimeType: string, | ||
| 585 | data: Uint8Array, | ||
| 586 | ): Promise<DesktopPreviewRecordingArtifact> { | ||
| 587 | const createdAt = new Date().toISOString(); | ||
| 588 | const id = `browser-recording-${Date.now().toString(36)}`; | ||
| 589 | const extension = mimeType.includes("mp4") ? "mp4" : "webm"; | ||
| 590 | const directory = join(app.getPath("userData"), "browser-artifacts"); | ||
| 591 | const path = join(directory, `${id}.${extension}`); | ||
| 592 | await mkdir(directory, { recursive: true }); | ||
| 593 | await writeFile(path, data); | ||
| 594 | return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt }; | ||
| 595 | } | ||
| 596 | |||
| 597 | onRecordingFrame(listener: RecordingFrameListener): () => void { | ||
| 598 | this.recordingFrameListeners.add(listener); | ||
| 599 | return () => this.recordingFrameListeners.delete(listener); | ||
| 600 | } | ||
| 601 | |||
| 488 | zoomIn(tabId: string): void { | 602 | zoomIn(tabId: string): void { |
| 489 | this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); | 603 | this.applyZoom(tabId, (current) => nextZoomLevel(current, "in")); |
| 490 | } | 604 | } |
| ... | @@ -533,7 +647,7 @@ export class PreviewViewManager { | ... | @@ -533,7 +647,7 @@ export class PreviewViewManager { |
| 533 | 647 | ||
| 534 | async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> { | 648 | async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> { |
| 535 | const wc = this.requireWebContents(tabId); | 649 | const wc = this.requireWebContents(tabId); |
| 536 | return this.withDebugger(wc, async (send) => { | 650 | return this.withControlSession(tabId, wc, "snapshot", async (send) => { |
| 537 | await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); | 651 | await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); |
| 538 | const page = await this.evaluateWithDebugger<{ | 652 | const page = await this.evaluateWithDebugger<{ |
| 539 | url: string; | 653 | url: string; |
| ... | @@ -604,6 +718,9 @@ export class PreviewViewManager { | ... | @@ -604,6 +718,9 @@ export class PreviewViewManager { |
| 604 | return { | 718 | return { |
| 605 | ...page, | 719 | ...page, |
| 606 | accessibilityTree: accessibility, | 720 | accessibilityTree: accessibility, |
| 721 | consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])], | ||
| 722 | networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])], | ||
| 723 | actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])], | ||
| 607 | screenshot: { | 724 | screenshot: { |
| 608 | mimeType: "image/png", | 725 | mimeType: "image/png", |
| 609 | data: image.toPNG().toString("base64"), | 726 | data: image.toPNG().toString("base64"), |
| ... | @@ -616,22 +733,29 @@ export class PreviewViewManager { | ... | @@ -616,22 +733,29 @@ export class PreviewViewManager { |
| 616 | 733 | ||
| 617 | async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> { | 734 | async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> { |
| 618 | const wc = this.requireWebContents(tabId); | 735 | const wc = this.requireWebContents(tabId); |
| 619 | await this.withDebugger(wc, async (send) => { | 736 | await this.withControlSession(tabId, wc, "click", async (send) => { |
| 620 | await Promise.all([ | 737 | await Promise.all([ |
| 621 | send("Runtime.enable"), | 738 | send("Runtime.enable"), |
| 622 | send("Input.setIgnoreInputEvents", { ignore: false }), | 739 | send("Input.setIgnoreInputEvents", { ignore: false }), |
| 623 | ]); | 740 | ]); |
| 624 | let x: number; | 741 | let x: number; |
| 625 | let y: number; | 742 | let y: number; |
| 626 | if ("selector" in input) { | 743 | if ("selector" in input || "locator" in input) { |
| 744 | await this.ensurePlaywrightInjected(send); | ||
| 745 | const locator = this.automationLocator(input); | ||
| 627 | const point = await this.evaluateWithDebugger< | 746 | const point = await this.evaluateWithDebugger< |
| 628 | { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } | 747 | { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } |
| 629 | >( | 748 | >( |
| 630 | send, | 749 | send, |
| 631 | `(() => { | 750 | `(() => { |
| 632 | try { | 751 | try { |
| 633 | const element = document.querySelector(${JSON.stringify(input.selector)}); | 752 | const injected = globalThis.__t3PlaywrightInjected; |
| 753 | const parsed = injected.parseSelector(${JSON.stringify(locator)}); | ||
| 754 | const element = injected.querySelector(parsed, document, true); | ||
| 634 | if (!element) return { notFound: true }; | 755 | if (!element) return { notFound: true }; |
| 756 | const visible = injected.elementState(element, "visible"); | ||
| 757 | const enabled = injected.elementState(element, "enabled"); | ||
| 758 | if (!visible.matches || !enabled.matches) return { notFound: true }; | ||
| 635 | element.scrollIntoView({ block: "center", inline: "center" }); | 759 | element.scrollIntoView({ block: "center", inline: "center" }); |
| 636 | const rect = element.getBoundingClientRect(); | 760 | const rect = element.getBoundingClientRect(); |
| 637 | return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; | 761 | return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; |
| ... | @@ -643,20 +767,20 @@ export class PreviewViewManager { | ... | @@ -643,20 +767,20 @@ export class PreviewViewManager { |
| 643 | ); | 767 | ); |
| 644 | if ("invalidSelector" in point) { | 768 | if ("invalidSelector" in point) { |
| 645 | throw automationError("PreviewAutomationInvalidSelectorError", point.message, { | 769 | throw automationError("PreviewAutomationInvalidSelectorError", point.message, { |
| 646 | selector: input.selector, | 770 | selector: locator, |
| 647 | }); | 771 | }); |
| 648 | } | 772 | } |
| 649 | if ("notFound" in point) { | 773 | if ("notFound" in point) { |
| 650 | throw automationError( | 774 | throw automationError( |
| 651 | "PreviewAutomationExecutionError", | 775 | "PreviewAutomationExecutionError", |
| 652 | `No element matches selector ${input.selector}.`, | 776 | `No element matches locator ${locator}.`, |
| 653 | ); | 777 | ); |
| 654 | } | 778 | } |
| 655 | x = point.x; | 779 | x = point.x; |
| 656 | y = point.y; | 780 | y = point.y; |
| 657 | } else { | 781 | } else { |
| 658 | x = input.x; | 782 | x = input.x!; |
| 659 | y = input.y; | 783 | y = input.y!; |
| 660 | } | 784 | } |
| 661 | const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( | 785 | const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( |
| 662 | send, | 786 | send, |
| ... | @@ -688,19 +812,17 @@ export class PreviewViewManager { | ... | @@ -688,19 +812,17 @@ export class PreviewViewManager { |
| 688 | 812 | ||
| 689 | async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> { | 813 | async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> { |
| 690 | const wc = this.requireWebContents(tabId); | 814 | const wc = this.requireWebContents(tabId); |
| 691 | await this.withDebugger(wc, async (send) => { | 815 | await this.withControlSession(tabId, wc, "type", async (send) => { |
| 692 | await send("Runtime.enable"); | 816 | await send("Runtime.enable"); |
| 817 | const locator = this.automationLocator(input); | ||
| 818 | if (locator) await this.ensurePlaywrightInjected(send); | ||
| 693 | const focusResult = await this.evaluateWithDebugger< | 819 | const focusResult = await this.evaluateWithDebugger< |
| 694 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | 820 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } |
| 695 | >( | 821 | >( |
| 696 | send, | 822 | send, |
| 697 | `(() => { | 823 | `(() => { |
| 698 | try { | 824 | try { |
| 699 | const element = ${ | 825 | const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"}; |
| 700 | input.selector | ||
| 701 | ? `document.querySelector(${JSON.stringify(input.selector)})` | ||
| 702 | : "document.activeElement" | ||
| 703 | }; | ||
| 704 | if (!element) return { notFound: true }; | 826 | if (!element) return { notFound: true }; |
| 705 | element.focus(); | 827 | element.focus(); |
| 706 | if (${input.clear ?? false}) { | 828 | if (${input.clear ?? false}) { |
| ... | @@ -723,8 +845,8 @@ export class PreviewViewManager { | ... | @@ -723,8 +845,8 @@ export class PreviewViewManager { |
| 723 | if ("notFound" in focusResult) { | 845 | if ("notFound" in focusResult) { |
| 724 | throw automationError( | 846 | throw automationError( |
| 725 | "PreviewAutomationExecutionError", | 847 | "PreviewAutomationExecutionError", |
| 726 | input.selector | 848 | locator |
| 727 | ? `No element matches selector ${input.selector}.` | 849 | ? `No element matches locator ${locator}.` |
| 728 | : "No element is focused in the preview.", | 850 | : "No element is focused in the preview.", |
| 729 | ); | 851 | ); |
| 730 | } | 852 | } |
| ... | @@ -743,7 +865,7 @@ export class PreviewViewManager { | ... | @@ -743,7 +865,7 @@ export class PreviewViewManager { |
| 743 | 865 | ||
| 744 | async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> { | 866 | async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> { |
| 745 | const wc = this.requireWebContents(tabId); | 867 | const wc = this.requireWebContents(tabId); |
| 746 | await this.withDebugger(wc, async (send) => { | 868 | await this.withControlSession(tabId, wc, "press", async (send) => { |
| 747 | const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { | 869 | const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { |
| 748 | switch (modifier) { | 870 | switch (modifier) { |
| 749 | case "Alt": | 871 | case "Alt": |
| ... | @@ -771,19 +893,17 @@ export class PreviewViewManager { | ... | @@ -771,19 +893,17 @@ export class PreviewViewManager { |
| 771 | 893 | ||
| 772 | async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> { | 894 | async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> { |
| 773 | const wc = this.requireWebContents(tabId); | 895 | const wc = this.requireWebContents(tabId); |
| 774 | await this.withDebugger(wc, async (send) => { | 896 | await this.withControlSession(tabId, wc, "scroll", async (send) => { |
| 775 | await send("Runtime.enable"); | 897 | await send("Runtime.enable"); |
| 898 | const locator = this.automationLocator(input); | ||
| 899 | if (locator) await this.ensurePlaywrightInjected(send); | ||
| 776 | const result = await this.evaluateWithDebugger< | 900 | const result = await this.evaluateWithDebugger< |
| 777 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | 901 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } |
| 778 | >( | 902 | >( |
| 779 | send, | 903 | send, |
| 780 | `(() => { | 904 | `(() => { |
| 781 | try { | 905 | try { |
| 782 | const target = ${ | 906 | const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"}; |
| 783 | input.selector | ||
| 784 | ? `document.querySelector(${JSON.stringify(input.selector)})` | ||
| 785 | : "window" | ||
| 786 | }; | ||
| 787 | if (!target) return { notFound: true }; | 907 | if (!target) return { notFound: true }; |
| 788 | target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); | 908 | target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); |
| 789 | return { ok: true }; | 909 | return { ok: true }; |
| ... | @@ -801,7 +921,7 @@ export class PreviewViewManager { | ... | @@ -801,7 +921,7 @@ export class PreviewViewManager { |
| 801 | if ("notFound" in result) { | 921 | if ("notFound" in result) { |
| 802 | throw automationError( | 922 | throw automationError( |
| 803 | "PreviewAutomationExecutionError", | 923 | "PreviewAutomationExecutionError", |
| 804 | `No element matches selector ${input.selector}.`, | 924 | `No element matches locator ${locator}.`, |
| 805 | ); | 925 | ); |
| 806 | } | 926 | } |
| 807 | }); | 927 | }); |
| ... | @@ -809,7 +929,7 @@ export class PreviewViewManager { | ... | @@ -809,7 +929,7 @@ export class PreviewViewManager { |
| 809 | 929 | ||
| 810 | async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> { | 930 | async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> { |
| 811 | const wc = this.requireWebContents(tabId); | 931 | const wc = this.requireWebContents(tabId); |
| 812 | return this.withDebugger(wc, async (send) => { | 932 | return this.withControlSession(tabId, wc, "evaluate", async (send) => { |
| 813 | await send("Runtime.enable"); | 933 | await send("Runtime.enable"); |
| 814 | const value = await this.evaluateWithDebugger( | 934 | const value = await this.evaluateWithDebugger( |
| 815 | send, | 935 | send, |
| ... | @@ -835,8 +955,10 @@ export class PreviewViewManager { | ... | @@ -835,8 +955,10 @@ export class PreviewViewManager { |
| 835 | async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> { | 955 | async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> { |
| 836 | const wc = this.requireWebContents(tabId); | 956 | const wc = this.requireWebContents(tabId); |
| 837 | const timeoutMs = input.timeoutMs ?? 15_000; | 957 | const timeoutMs = input.timeoutMs ?? 15_000; |
| 838 | await this.withDebugger(wc, async (send) => { | 958 | await this.withControlSession(tabId, wc, "waitFor", async (send) => { |
| 839 | await send("Runtime.enable"); | 959 | await send("Runtime.enable"); |
| 960 | const locator = this.automationLocator(input); | ||
| 961 | if (locator) await this.ensurePlaywrightInjected(send); | ||
| 840 | const deadline = Date.now() + timeoutMs; | 962 | const deadline = Date.now() + timeoutMs; |
| 841 | while (Date.now() <= deadline) { | 963 | while (Date.now() <= deadline) { |
| 842 | const result = await this.evaluateWithDebugger< | 964 | const result = await this.evaluateWithDebugger< |
| ... | @@ -845,11 +967,7 @@ export class PreviewViewManager { | ... | @@ -845,11 +967,7 @@ export class PreviewViewManager { |
| 845 | send, | 967 | send, |
| 846 | `(() => { | 968 | `(() => { |
| 847 | try { | 969 | try { |
| 848 | const selectorMatched = ${ | 970 | const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"}; |
| 849 | input.selector | ||
| 850 | ? `document.querySelector(${JSON.stringify(input.selector)}) !== null` | ||
| 851 | : "true" | ||
| 852 | }; | ||
| 853 | const textMatched = ${ | 971 | const textMatched = ${ |
| 854 | input.text | 972 | input.text |
| 855 | ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` | 973 | ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` |
| ... | @@ -896,36 +1014,282 @@ export class PreviewViewManager { | ... | @@ -896,36 +1014,282 @@ export class PreviewViewManager { |
| 896 | this.update(tabId, { zoomFactor: next }); | 1014 | this.update(tabId, { zoomFactor: next }); |
| 897 | } | 1015 | } |
| 898 | 1016 | ||
| 899 | private async withDebugger<A>( | 1017 | private async withControlSession<A>( |
| 1018 | tabId: string, | ||
| 900 | wc: Electron.WebContents, | 1019 | wc: Electron.WebContents, |
| 1020 | action: string, | ||
| 901 | use: ( | 1021 | use: ( |
| 902 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | 1022 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, |
| 903 | ) => Promise<A>, | 1023 | ) => Promise<A>, |
| 904 | ): Promise<A> { | 1024 | ): Promise<A> { |
| 905 | if (wc.debugger.isAttached()) { | 1025 | const actionEvent: PreviewAutomationActionEvent = { |
| 906 | throw automationError( | 1026 | id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`, |
| 907 | "PreviewAutomationExecutionError", | 1027 | action, |
| 908 | "Preview automation is unavailable while another debugger is attached.", | 1028 | status: "running", |
| 909 | ); | 1029 | startedAt: new Date().toISOString(), |
| 910 | } | 1030 | }; |
| 911 | wc.debugger.attach("1.3"); | 1031 | this.pushAction(tabId, actionEvent); |
| 1032 | const epoch = this.controlEpoch.get(tabId) ?? 0; | ||
| 1033 | const control = await this.ensureControlSession(wc); | ||
| 1034 | let resolveTail: () => void = () => undefined; | ||
| 1035 | const previous = control.tail; | ||
| 1036 | control.tail = new Promise<void>((resolve) => { | ||
| 1037 | resolveTail = resolve; | ||
| 1038 | }); | ||
| 1039 | await previous; | ||
| 1040 | this.update(tabId, { controller: "agent" }); | ||
| 912 | try { | 1041 | try { |
| 913 | return await use((method, commandParams) => wc.debugger.sendCommand(method, commandParams)); | 1042 | const send = async (method: string, commandParams?: Record<string, unknown>) => { |
| 1043 | if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { | ||
| 1044 | throw automationError( | ||
| 1045 | "PreviewAutomationControlInterruptedError", | ||
| 1046 | "Browser control was interrupted by human input.", | ||
| 1047 | ); | ||
| 1048 | } | ||
| 1049 | const result = await wc.debugger.sendCommand(method, commandParams); | ||
| 1050 | if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) { | ||
| 1051 | throw automationError( | ||
| 1052 | "PreviewAutomationControlInterruptedError", | ||
| 1053 | "Browser control was interrupted by human input.", | ||
| 1054 | ); | ||
| 1055 | } | ||
| 1056 | return result; | ||
| 1057 | }; | ||
| 1058 | const result = await use(send); | ||
| 1059 | this.replaceAction(tabId, { | ||
| 1060 | ...actionEvent, | ||
| 1061 | status: "succeeded", | ||
| 1062 | completedAt: new Date().toISOString(), | ||
| 1063 | }); | ||
| 1064 | return result; | ||
| 914 | } catch (cause) { | 1065 | } catch (cause) { |
| 1066 | const interrupted = | ||
| 1067 | cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError"; | ||
| 1068 | this.replaceAction(tabId, { | ||
| 1069 | ...actionEvent, | ||
| 1070 | status: interrupted ? "interrupted" : "failed", | ||
| 1071 | completedAt: new Date().toISOString(), | ||
| 1072 | error: cause instanceof Error ? cause.message : String(cause), | ||
| 1073 | }); | ||
| 915 | if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; | 1074 | if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; |
| 916 | throw automationError( | 1075 | throw automationError( |
| 917 | "PreviewAutomationExecutionError", | 1076 | "PreviewAutomationExecutionError", |
| 918 | cause instanceof Error ? cause.message : String(cause), | 1077 | cause instanceof Error ? cause.message : String(cause), |
| 919 | cause, | 1078 | { tabId, cause }, |
| 920 | ); | 1079 | ); |
| 921 | } finally { | 1080 | } finally { |
| 922 | if (wc.debugger.isAttached()) { | 1081 | if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" }); |
| 923 | try { | 1082 | resolveTail(); |
| 924 | wc.debugger.detach(); | 1083 | } |
| 925 | } catch { | 1084 | } |
| 926 | // The target can disappear while an operation is completing. | 1085 | |
| 1086 | private pushAction(tabId: string, event: PreviewAutomationActionEvent): void { | ||
| 1087 | const timeline = this.actionTimeline.get(tabId) ?? []; | ||
| 1088 | timeline.push(event); | ||
| 1089 | if (timeline.length > 200) timeline.splice(0, timeline.length - 200); | ||
| 1090 | this.actionTimeline.set(tabId, timeline); | ||
| 1091 | } | ||
| 1092 | |||
| 1093 | private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void { | ||
| 1094 | const timeline = this.actionTimeline.get(tabId); | ||
| 1095 | if (!timeline) return; | ||
| 1096 | const index = timeline.findIndex((candidate) => candidate.id === event.id); | ||
| 1097 | if (index >= 0) timeline[index] = event; | ||
| 1098 | } | ||
| 1099 | |||
| 1100 | private async ensureControlSession(wc: Electron.WebContents): Promise<BrowserControlSession> { | ||
| 1101 | const existing = this.controlSessions.get(wc.id); | ||
| 1102 | if (existing) { | ||
| 1103 | await existing.initialized; | ||
| 1104 | return existing; | ||
| 1105 | } | ||
| 1106 | if (wc.isDevToolsOpened()) { | ||
| 1107 | throw automationError( | ||
| 1108 | "PreviewAutomationExecutionError", | ||
| 1109 | "Close preview DevTools before using agent browser control.", | ||
| 1110 | ); | ||
| 1111 | } | ||
| 1112 | if (wc.debugger.isAttached()) { | ||
| 1113 | throw automationError( | ||
| 1114 | "PreviewAutomationExecutionError", | ||
| 1115 | "Preview control cannot attach because another debugger owns this page.", | ||
| 1116 | ); | ||
| 1117 | } | ||
| 1118 | const control: BrowserControlSession = { | ||
| 1119 | webContentsId: wc.id, | ||
| 1120 | tail: Promise.resolve(), | ||
| 1121 | initialized: Promise.resolve(), | ||
| 1122 | }; | ||
| 1123 | const diagnostics: BrowserDiagnostics = { | ||
| 1124 | consoleEntries: [], | ||
| 1125 | networkEntries: [], | ||
| 1126 | requests: new Map(), | ||
| 1127 | }; | ||
| 1128 | this.diagnostics.set(wc.id, diagnostics); | ||
| 1129 | wc.debugger.on("message", (_event, method, params) => { | ||
| 1130 | if (method === "Page.screencastFrame") { | ||
| 1131 | const frame = params as Record<string, unknown>; | ||
| 1132 | const sessionId = frame["sessionId"]; | ||
| 1133 | if (typeof sessionId === "number") { | ||
| 1134 | void wc.debugger | ||
| 1135 | .sendCommand("Page.screencastFrameAck", { sessionId }) | ||
| 1136 | .catch(() => undefined); | ||
| 1137 | } | ||
| 1138 | const tabId = this.tabIdForWebContents(wc.id); | ||
| 1139 | const metadata = | ||
| 1140 | typeof frame["metadata"] === "object" && frame["metadata"] !== null | ||
| 1141 | ? (frame["metadata"] as Record<string, unknown>) | ||
| 1142 | : {}; | ||
| 1143 | if (tabId && typeof frame["data"] === "string") { | ||
| 1144 | const payload: DesktopPreviewRecordingFrame = { | ||
| 1145 | tabId, | ||
| 1146 | data: frame["data"], | ||
| 1147 | width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, | ||
| 1148 | height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, | ||
| 1149 | receivedAt: new Date().toISOString(), | ||
| 1150 | }; | ||
| 1151 | for (const listener of this.recordingFrameListeners) listener(payload); | ||
| 927 | } | 1152 | } |
| 928 | } | 1153 | } |
| 1154 | this.captureDiagnosticMessage(diagnostics, method, params as Record<string, unknown>); | ||
| 1155 | }); | ||
| 1156 | control.initialized = (async () => { | ||
| 1157 | wc.debugger.attach("1.3"); | ||
| 1158 | await Promise.all([ | ||
| 1159 | wc.debugger.sendCommand("Runtime.enable"), | ||
| 1160 | wc.debugger.sendCommand("Accessibility.enable"), | ||
| 1161 | wc.debugger.sendCommand("Network.enable"), | ||
| 1162 | wc.debugger.sendCommand("Log.enable"), | ||
| 1163 | ]); | ||
| 1164 | })(); | ||
| 1165 | this.controlSessions.set(wc.id, control); | ||
| 1166 | try { | ||
| 1167 | await control.initialized; | ||
| 1168 | return control; | ||
| 1169 | } catch (cause) { | ||
| 1170 | this.controlSessions.delete(wc.id); | ||
| 1171 | throw cause; | ||
| 1172 | } | ||
| 1173 | } | ||
| 1174 | |||
| 1175 | private detachControlSession(webContentsId: number): void { | ||
| 1176 | this.controlSessions.delete(webContentsId); | ||
| 1177 | this.diagnostics.delete(webContentsId); | ||
| 1178 | const wc = webContents.fromId(webContentsId); | ||
| 1179 | if (!wc || wc.isDestroyed() || !wc.debugger.isAttached()) return; | ||
| 1180 | try { | ||
| 1181 | wc.debugger.detach(); | ||
| 1182 | } catch { | ||
| 1183 | // Target teardown can race detachment. | ||
| 1184 | } | ||
| 1185 | } | ||
| 1186 | |||
| 1187 | private tabIdForWebContents(webContentsId: number): string | null { | ||
| 1188 | for (const [tabId, tab] of this.tabs) { | ||
| 1189 | if (tab.webContentsId === webContentsId) return tabId; | ||
| 1190 | } | ||
| 1191 | return null; | ||
| 1192 | } | ||
| 1193 | |||
| 1194 | private captureDiagnosticMessage( | ||
| 1195 | diagnostics: BrowserDiagnostics, | ||
| 1196 | method: string, | ||
| 1197 | params: Record<string, unknown>, | ||
| 1198 | ): void { | ||
| 1199 | const timestamp = new Date().toISOString(); | ||
| 1200 | if (method === "Runtime.consoleAPICalled") { | ||
| 1201 | const args = Array.isArray(params["args"]) ? params["args"] : []; | ||
| 1202 | const text = args | ||
| 1203 | .map((arg) => { | ||
| 1204 | if (typeof arg !== "object" || arg === null) return String(arg); | ||
| 1205 | const value = arg as Record<string, unknown>; | ||
| 1206 | return String(value["value"] ?? value["description"] ?? ""); | ||
| 1207 | }) | ||
| 1208 | .join(" "); | ||
| 1209 | this.pushBounded(diagnostics.consoleEntries, { | ||
| 1210 | level: typeof params["type"] === "string" ? params["type"] : "log", | ||
| 1211 | text, | ||
| 1212 | timestamp, | ||
| 1213 | source: "console", | ||
| 1214 | }); | ||
| 1215 | return; | ||
| 1216 | } | ||
| 1217 | if (method === "Runtime.exceptionThrown") { | ||
| 1218 | const details = | ||
| 1219 | typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null | ||
| 1220 | ? (params["exceptionDetails"] as Record<string, unknown>) | ||
| 1221 | : {}; | ||
| 1222 | this.pushBounded(diagnostics.consoleEntries, { | ||
| 1223 | level: "error", | ||
| 1224 | text: String(details["text"] ?? "Uncaught exception"), | ||
| 1225 | timestamp, | ||
| 1226 | source: "exception", | ||
| 1227 | }); | ||
| 1228 | return; | ||
| 1229 | } | ||
| 1230 | if (method === "Log.entryAdded") { | ||
| 1231 | const entry = | ||
| 1232 | typeof params["entry"] === "object" && params["entry"] !== null | ||
| 1233 | ? (params["entry"] as Record<string, unknown>) | ||
| 1234 | : {}; | ||
| 1235 | this.pushBounded(diagnostics.consoleEntries, { | ||
| 1236 | level: typeof entry["level"] === "string" ? entry["level"] : "info", | ||
| 1237 | text: String(entry["text"] ?? ""), | ||
| 1238 | timestamp, | ||
| 1239 | source: typeof entry["source"] === "string" ? entry["source"] : "log", | ||
| 1240 | }); | ||
| 1241 | return; | ||
| 1242 | } | ||
| 1243 | const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null; | ||
| 1244 | if (method === "Network.requestWillBeSent" && requestId) { | ||
| 1245 | const request = | ||
| 1246 | typeof params["request"] === "object" && params["request"] !== null | ||
| 1247 | ? (params["request"] as Record<string, unknown>) | ||
| 1248 | : {}; | ||
| 1249 | diagnostics.requests.set(requestId, { | ||
| 1250 | url: String(request["url"] ?? ""), | ||
| 1251 | method: String(request["method"] ?? "GET"), | ||
| 1252 | }); | ||
| 1253 | return; | ||
| 1254 | } | ||
| 1255 | if (method === "Network.responseReceived" && requestId) { | ||
| 1256 | const request = diagnostics.requests.get(requestId); | ||
| 1257 | const response = | ||
| 1258 | typeof params["response"] === "object" && params["response"] !== null | ||
| 1259 | ? (params["response"] as Record<string, unknown>) | ||
| 1260 | : {}; | ||
| 1261 | const status = typeof response["status"] === "number" ? response["status"] : null; | ||
| 1262 | if (request && status !== null && status >= 400) { | ||
| 1263 | this.pushBounded(diagnostics.networkEntries, { | ||
| 1264 | ...request, | ||
| 1265 | status, | ||
| 1266 | failed: true, | ||
| 1267 | timestamp, | ||
| 1268 | }); | ||
| 1269 | } | ||
| 1270 | return; | ||
| 1271 | } | ||
| 1272 | if (method === "Network.loadingFailed" && requestId) { | ||
| 1273 | const request = diagnostics.requests.get(requestId); | ||
| 1274 | if (request) { | ||
| 1275 | this.pushBounded(diagnostics.networkEntries, { | ||
| 1276 | ...request, | ||
| 1277 | status: null, | ||
| 1278 | failed: true, | ||
| 1279 | errorText: String(params["errorText"] ?? "Network request failed"), | ||
| 1280 | timestamp, | ||
| 1281 | }); | ||
| 1282 | } | ||
| 1283 | diagnostics.requests.delete(requestId); | ||
| 1284 | return; | ||
| 1285 | } | ||
| 1286 | if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId); | ||
| 1287 | } | ||
| 1288 | |||
| 1289 | private pushBounded<A>(buffer: A[], entry: A): void { | ||
| 1290 | buffer.push(entry); | ||
| 1291 | if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) { | ||
| 1292 | buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT); | ||
| 929 | } | 1293 | } |
| 930 | } | 1294 | } |
| 931 | 1295 | ||
| ... | @@ -952,6 +1316,28 @@ export class PreviewViewManager { | ... | @@ -952,6 +1316,28 @@ export class PreviewViewManager { |
| 952 | return response.result?.value as A; | 1316 | return response.result?.value as A; |
| 953 | } | 1317 | } |
| 954 | 1318 | ||
| 1319 | private automationLocator(input: { | ||
| 1320 | readonly selector?: string | undefined; | ||
| 1321 | readonly locator?: string | undefined; | ||
| 1322 | }): string | null { | ||
| 1323 | if (input.locator) return input.locator; | ||
| 1324 | if (input.selector) return `css=${input.selector}`; | ||
| 1325 | return null; | ||
| 1326 | } | ||
| 1327 | |||
| 1328 | private async ensurePlaywrightInjected( | ||
| 1329 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | ||
| 1330 | ): Promise<void> { | ||
| 1331 | const installed = await this.evaluateWithDebugger<boolean>( | ||
| 1332 | send, | ||
| 1333 | "Boolean(globalThis.__t3PlaywrightInjected)", | ||
| 1334 | true, | ||
| 1335 | ); | ||
| 1336 | if (installed) return; | ||
| 1337 | const expression = await playwrightInjectedRuntimeInstallExpression(); | ||
| 1338 | await this.evaluateWithDebugger(send, expression, true); | ||
| 1339 | } | ||
| 1340 | |||
| 955 | onStateChange(listener: Listener): () => void { | 1341 | onStateChange(listener: Listener): () => void { |
| 956 | this.listeners.add(listener); | 1342 | this.listeners.add(listener); |
| 957 | return () => { | 1343 | return () => { |
| ... | @@ -964,6 +1350,7 @@ export class PreviewViewManager { | ... | @@ -964,6 +1350,7 @@ export class PreviewViewManager { |
| 964 | this.closeTab(tabId); | 1350 | this.closeTab(tabId); |
| 965 | } | 1351 | } |
| 966 | this.listeners.clear(); | 1352 | this.listeners.clear(); |
| 1353 | this.recordingFrameListeners.clear(); | ||
| 967 | } | 1354 | } |
| 968 | 1355 | ||
| 969 | private attachListeners(tabId: string, wc: Electron.WebContents): void { | 1356 | private attachListeners(tabId: string, wc: Electron.WebContents): void { |
| ... | @@ -988,6 +1375,15 @@ export class PreviewViewManager { | ... | @@ -988,6 +1375,15 @@ export class PreviewViewManager { |
| 988 | }, | 1375 | }, |
| 989 | }); | 1376 | }); |
| 990 | }; | 1377 | }; |
| 1378 | const humanInput = (): void => { | ||
| 1379 | this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); | ||
| 1380 | this.update(tabId, { controller: "human" }); | ||
| 1381 | void sleep(750).then(() => { | ||
| 1382 | if (this.tabs.get(tabId)?.controller === "human") { | ||
| 1383 | this.update(tabId, { controller: "none" }); | ||
| 1384 | } | ||
| 1385 | }); | ||
| 1386 | }; | ||
| 991 | 1387 | ||
| 992 | wc.on("did-navigate", sync); | 1388 | wc.on("did-navigate", sync); |
| 993 | wc.on("did-navigate-in-page", sync); | 1389 | wc.on("did-navigate-in-page", sync); |
| ... | @@ -995,6 +1391,7 @@ export class PreviewViewManager { | ... | @@ -995,6 +1391,7 @@ export class PreviewViewManager { |
| 995 | wc.on("did-start-loading", sync); | 1391 | wc.on("did-start-loading", sync); |
| 996 | wc.on("did-stop-loading", sync); | 1392 | wc.on("did-stop-loading", sync); |
| 997 | wc.on("did-fail-load", failed as never); | 1393 | wc.on("did-fail-load", failed as never); |
| 1394 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); | ||
| 998 | 1395 | ||
| 999 | // Keep external links inside the same view (matches ami's policy). | 1396 | // Keep external links inside the same view (matches ami's policy). |
| 1000 | wc.setWindowOpenHandler(({ url }) => { | 1397 | wc.setWindowOpenHandler(({ url }) => { |
| ... | @@ -1020,7 +1417,7 @@ export class PreviewViewManager { | ... | @@ -1020,7 +1417,7 @@ export class PreviewViewManager { |
| 1020 | } | 1417 | } |
| 1021 | }); | 1418 | }); |
| 1022 | 1419 | ||
| 1023 | this.attached.set(wc.id, { navigate: sync, failed }); | 1420 | this.attached.set(wc.id, { navigate: sync, failed, humanInput }); |
| 1024 | } | 1421 | } |
| 1025 | 1422 | ||
| 1026 | private detachListeners(webContentsId: number): void { | 1423 | private detachListeners(webContentsId: number): void { |
| ... | @@ -1035,6 +1432,7 @@ export class PreviewViewManager { | ... | @@ -1035,6 +1432,7 @@ export class PreviewViewManager { |
| 1035 | wc.off("did-start-loading", handlers.navigate); | 1432 | wc.off("did-start-loading", handlers.navigate); |
| 1036 | wc.off("did-stop-loading", handlers.navigate); | 1433 | wc.off("did-stop-loading", handlers.navigate); |
| 1037 | wc.off("did-fail-load", handlers.failed as never); | 1434 | wc.off("did-fail-load", handlers.failed as never); |
| 1435 | wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput); | ||
| 1038 | } | 1436 | } |
| 1039 | 1437 | ||
| 1040 | private isAppShortcut(input: Electron.Input): boolean { | 1438 | private isAppShortcut(input: Electron.Input): boolean { |
apps/desktop/src/window/DesktopWindow.ts+4-1| ... | @@ -200,7 +200,10 @@ const make = Effect.gen(function* () { | ... | @@ -200,7 +200,10 @@ const make = Effect.gen(function* () { |
| 200 | 200 | ||
| 201 | previewViewManager.setMainWindow(window); | 201 | previewViewManager.setMainWindow(window); |
| 202 | window.webContents.on("will-attach-webview", (event, webPreferences, params) => { | 202 | window.webContents.on("will-attach-webview", (event, webPreferences, params) => { |
| 203 | if (params.partition !== previewViewManager.getBrowserPartition()) { | 203 | if ( |
| 204 | typeof params.partition !== "string" || | ||
| 205 | !previewViewManager.isBrowserPartition(params.partition) | ||
| 206 | ) { | ||
| 204 | event.preventDefault(); | 207 | event.preventDefault(); |
| 205 | return; | 208 | return; |
| 206 | } | 209 | } |
apps/server/src/mcp/Layers/McpHttpServer.test.ts+18| ... | @@ -65,6 +65,9 @@ it.effect("registers annotated tools and preserves authenticated request context | ... | @@ -65,6 +65,9 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 65 | visibleText: "Example", | 65 | visibleText: "Example", |
| 66 | interactiveElements: [], | 66 | interactiveElements: [], |
| 67 | accessibilityTree: {}, | 67 | accessibilityTree: {}, |
| 68 | consoleEntries: [], | ||
| 69 | networkEntries: [], | ||
| 70 | actionTimeline: [], | ||
| 68 | screenshot: { | 71 | screenshot: { |
| 69 | mimeType: "image/png", | 72 | mimeType: "image/png", |
| 70 | data: Buffer.from("png").toString("base64"), | 73 | data: Buffer.from("png").toString("base64"), |
| ... | @@ -96,6 +99,21 @@ it.effect("registers annotated tools and preserves authenticated request context | ... | @@ -96,6 +99,21 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 96 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); | 99 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); |
| 97 | expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); | 100 | expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); |
| 98 | expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); | 101 | expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); |
| 102 | expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); | ||
| 103 | |||
| 104 | const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); | ||
| 105 | expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true); | ||
| 106 | expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true); | ||
| 107 | expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); | ||
| 108 | |||
| 109 | const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); | ||
| 110 | expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); | ||
| 111 | expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); | ||
| 112 | expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); | ||
| 113 | |||
| 114 | const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); | ||
| 115 | expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); | ||
| 116 | expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); | ||
| 99 | 117 | ||
| 100 | const status = yield* server | 118 | const status = yield* server |
| 101 | .callTool({ name: "preview_status", arguments: {} }) | 119 | .callTool({ name: "preview_status", arguments: {} }) |
apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts+24| ... | @@ -63,3 +63,27 @@ it.effect("rejects calls when no focused owner exists", () => | ... | @@ -63,3 +63,27 @@ it.effect("rejects calls when no focused owner exists", () => |
| 63 | expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); | 63 | expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); |
| 64 | }), | 64 | }), |
| 65 | ); | 65 | ); |
| 66 | |||
| 67 | it.effect("routes interactive commands to a hidden durable browser host", () => | ||
| 68 | Effect.scoped( | ||
| 69 | Effect.gen(function* () { | ||
| 70 | const broker = makePreviewAutomationBroker(); | ||
| 71 | const requests = yield* broker.connect("client-hidden"); | ||
| 72 | yield* Stream.runForEach(requests, (request) => | ||
| 73 | broker.respond({ requestId: request.requestId, ok: true }), | ||
| 74 | ).pipe(Effect.forkScoped); | ||
| 75 | yield* Effect.yieldNow; | ||
| 76 | yield* broker.reportOwner({ | ||
| 77 | clientId: "client-hidden", | ||
| 78 | environmentId: scope.environmentId, | ||
| 79 | threadId: scope.threadId, | ||
| 80 | tabId: "tab-hidden", | ||
| 81 | visible: false, | ||
| 82 | supportsAutomation: true, | ||
| 83 | focusedAt: "2026-06-11T00:00:00.000Z", | ||
| 84 | }); | ||
| 85 | |||
| 86 | yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } }); | ||
| 87 | }), | ||
| 88 | ), | ||
| 89 | ); |
apps/server/src/mcp/Layers/PreviewAutomationBroker.ts+7-5| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | PreviewAutomationControlInterruptedError, | ||
| 2 | PreviewAutomationExecutionError, | 3 | PreviewAutomationExecutionError, |
| 3 | PreviewAutomationInvalidSelectorError, | 4 | PreviewAutomationInvalidSelectorError, |
| 4 | PreviewAutomationNoFocusedOwnerError, | 5 | PreviewAutomationNoFocusedOwnerError, |
| ... | @@ -56,6 +57,8 @@ const makeResponseError = ( | ... | @@ -56,6 +57,8 @@ const makeResponseError = ( |
| 56 | return new PreviewAutomationTabNotFoundError({ message: error.message }); | 57 | return new PreviewAutomationTabNotFoundError({ message: error.message }); |
| 57 | case "PreviewAutomationTimeoutError": | 58 | case "PreviewAutomationTimeoutError": |
| 58 | return new PreviewAutomationTimeoutError({ message: error.message }); | 59 | return new PreviewAutomationTimeoutError({ message: error.message }); |
| 60 | case "PreviewAutomationControlInterruptedError": | ||
| 61 | return new PreviewAutomationControlInterruptedError({ message: error.message }); | ||
| 59 | case "PreviewAutomationInvalidSelectorError": { | 62 | case "PreviewAutomationInvalidSelectorError": { |
| 60 | const detail = | 63 | const detail = |
| 61 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | 64 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; |
| ... | @@ -193,20 +196,19 @@ export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { | ... | @@ -193,20 +196,19 @@ export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { |
| 193 | (owner) => | 196 | (owner) => |
| 194 | owner.environmentId === input.scope.environmentId && | 197 | owner.environmentId === input.scope.environmentId && |
| 195 | owner.threadId === input.scope.threadId && | 198 | owner.threadId === input.scope.threadId && |
| 196 | owner.supportsAutomation && | 199 | owner.supportsAutomation, |
| 197 | (input.operation === "open" || input.operation === "status" || owner.visible), | ||
| 198 | ) | 200 | ) |
| 199 | .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); | 201 | .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); |
| 200 | const owner = candidates[0]; | 202 | const owner = candidates[0]; |
| 201 | if (!owner) { | 203 | if (!owner) { |
| 202 | return yield* new PreviewAutomationNoFocusedOwnerError({ | 204 | return yield* new PreviewAutomationNoFocusedOwnerError({ |
| 203 | message: "No focused desktop preview owner is available for this thread.", | 205 | message: "No desktop browser host is available for this thread.", |
| 204 | }); | 206 | }); |
| 205 | } | 207 | } |
| 206 | const connection = current.clients.get(owner.clientId); | 208 | const connection = current.clients.get(owner.clientId); |
| 207 | if (!connection) { | 209 | if (!connection) { |
| 208 | return yield* new PreviewAutomationUnavailableError({ | 210 | return yield* new PreviewAutomationUnavailableError({ |
| 209 | message: "The focused preview owner is not connected.", | 211 | message: "The browser host is not connected.", |
| 210 | }); | 212 | }); |
| 211 | } | 213 | } |
| 212 | if ( | 214 | if ( |
| ... | @@ -216,7 +218,7 @@ export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { | ... | @@ -216,7 +218,7 @@ export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { |
| 216 | !input.tabId | 218 | !input.tabId |
| 217 | ) { | 219 | ) { |
| 218 | return yield* new PreviewAutomationTabNotFoundError({ | 220 | return yield* new PreviewAutomationTabNotFoundError({ |
| 219 | message: "The focused preview owner does not have an active tab.", | 221 | message: "The browser host does not have an active tab.", |
| 220 | }); | 222 | }); |
| 221 | } | 223 | } |
| 222 | const requestId = `preview-${requestSequence++}`; | 224 | const requestId = `preview-${requestSequence++}`; |
apps/server/src/mcp/toolkits/preview/handlers.ts+4| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import type { | 2 | import type { |
| 3 | PreviewAutomationOperation, | 3 | PreviewAutomationOperation, |
| 4 | PreviewAutomationRecordingArtifact, | ||
| 5 | PreviewAutomationRecordingStatus, | ||
| 4 | PreviewAutomationSnapshot, | 6 | PreviewAutomationSnapshot, |
| 5 | PreviewAutomationStatus, | 7 | PreviewAutomationStatus, |
| 6 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| ... | @@ -44,4 +46,6 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ | ... | @@ -44,4 +46,6 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ |
| 44 | preview_scroll: (input) => invoke<void>("scroll", input), | 46 | preview_scroll: (input) => invoke<void>("scroll", input), |
| 45 | preview_evaluate: (input) => invoke<unknown>("evaluate", input), | 47 | preview_evaluate: (input) => invoke<unknown>("evaluate", input), |
| 46 | preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs), | 48 | preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs), |
| 49 | preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}), | ||
| 50 | preview_recording_stop: () => invoke<PreviewAutomationRecordingArtifact>("recordingStop", {}), | ||
| 47 | }); | 51 | }); |
apps/server/src/mcp/toolkits/preview/tools.test.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { Tool } from "effect/unstable/ai"; | ||
| 3 | |||
| 4 | import { PreviewToolkit } from "./tools.ts"; | ||
| 5 | |||
| 6 | const schemaHasDescription = (schema: unknown): boolean => { | ||
| 7 | if (!schema || typeof schema !== "object") return false; | ||
| 8 | const record = schema as Record<string, unknown>; | ||
| 9 | if (typeof record.description === "string" && record.description.length > 0) return true; | ||
| 10 | return [record.anyOf, record.oneOf, record.allOf] | ||
| 11 | .filter(Array.isArray) | ||
| 12 | .some((members) => members.some(schemaHasDescription)); | ||
| 13 | }; | ||
| 14 | |||
| 15 | it("exports provider-compatible object schemas with described parameters", () => { | ||
| 16 | for (const tool of Object.values(PreviewToolkit.tools)) { | ||
| 17 | const schema = Tool.getJsonSchema(tool) as { | ||
| 18 | readonly type?: unknown; | ||
| 19 | readonly properties?: Readonly<Record<string, unknown>>; | ||
| 20 | readonly anyOf?: unknown; | ||
| 21 | readonly oneOf?: unknown; | ||
| 22 | }; | ||
| 23 | expect( | ||
| 24 | tool.description?.length ?? 0, | ||
| 25 | `${tool.name} should have a useful description`, | ||
| 26 | ).toBeGreaterThan(40); | ||
| 27 | expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); | ||
| 28 | expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); | ||
| 29 | expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); | ||
| 30 | for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) { | ||
| 31 | expect( | ||
| 32 | schemaHasDescription(fieldSchema), | ||
| 33 | `${tool.name}.${field} should explain what data the agent must pass`, | ||
| 34 | ).toBe(true); | ||
| 35 | } | ||
| 36 | } | ||
| 37 | }); | ||
apps/server/src/mcp/toolkits/preview/tools.ts+53-22| ... | @@ -5,6 +5,8 @@ import { | ... | @@ -5,6 +5,8 @@ import { |
| 5 | PreviewAutomationNavigateInput, | 5 | PreviewAutomationNavigateInput, |
| 6 | PreviewAutomationOpenInput, | 6 | PreviewAutomationOpenInput, |
| 7 | PreviewAutomationPressInput, | 7 | PreviewAutomationPressInput, |
| 8 | PreviewAutomationRecordingArtifact, | ||
| 9 | PreviewAutomationRecordingStatus, | ||
| 8 | PreviewAutomationScrollInput, | 10 | PreviewAutomationScrollInput, |
| 9 | PreviewAutomationSnapshot, | 11 | PreviewAutomationSnapshot, |
| 10 | PreviewAutomationStatus, | 12 | PreviewAutomationStatus, |
| ... | @@ -17,7 +19,13 @@ import { Tool, Toolkit } from "effect/unstable/ai"; | ... | @@ -17,7 +19,13 @@ import { Tool, Toolkit } from "effect/unstable/ai"; |
| 17 | import { McpInvocationContext } from "../../Services/McpInvocationContext.ts"; | 19 | import { McpInvocationContext } from "../../Services/McpInvocationContext.ts"; |
| 18 | 20 | ||
| 19 | const browserTool = <T extends Tool.Any>(tool: T): T => | 21 | const browserTool = <T extends Tool.Any>(tool: T): T => |
| 20 | tool.annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, true) as T; | 22 | tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; |
| 23 | |||
| 24 | const safeBrowserTool = <T extends Tool.Any>(tool: T): T => | ||
| 25 | browserTool(tool).annotate(Tool.Destructive, false) as T; | ||
| 26 | |||
| 27 | const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T => | ||
| 28 | safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; | ||
| 21 | 29 | ||
| 22 | export const PreviewStatusTool = Tool.make("preview_status", { | 30 | export const PreviewStatusTool = Tool.make("preview_status", { |
| 23 | description: | 31 | description: |
| ... | @@ -39,13 +47,15 @@ export const PreviewOpenTool = browserTool( | ... | @@ -39,13 +47,15 @@ export const PreviewOpenTool = browserTool( |
| 39 | success: PreviewAutomationStatus, | 47 | success: PreviewAutomationStatus, |
| 40 | failure: PreviewAutomationError, | 48 | failure: PreviewAutomationError, |
| 41 | dependencies: [McpInvocationContext], | 49 | dependencies: [McpInvocationContext], |
| 42 | }).annotate(Tool.Title, "Open browser preview"), | 50 | }) |
| 51 | .annotate(Tool.Title, "Open browser preview") | ||
| 52 | .annotate(Tool.Destructive, false), | ||
| 43 | ); | 53 | ); |
| 44 | 54 | ||
| 45 | export const PreviewNavigateTool = browserTool( | 55 | export const PreviewNavigateTool = safeBrowserTool( |
| 46 | Tool.make("preview_navigate", { | 56 | Tool.make("preview_navigate", { |
| 47 | description: | 57 | description: |
| 48 | "Navigate the scoped thread's active preview tab to a URL and wait for the requested readiness condition.", | 58 | "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.", |
| 49 | parameters: PreviewAutomationNavigateInput, | 59 | parameters: PreviewAutomationNavigateInput, |
| 50 | success: PreviewAutomationStatus, | 60 | success: PreviewAutomationStatus, |
| 51 | failure: PreviewAutomationError, | 61 | failure: PreviewAutomationError, |
| ... | @@ -53,21 +63,20 @@ export const PreviewNavigateTool = browserTool( | ... | @@ -53,21 +63,20 @@ export const PreviewNavigateTool = browserTool( |
| 53 | }).annotate(Tool.Title, "Navigate browser preview"), | 63 | }).annotate(Tool.Title, "Navigate browser preview"), |
| 54 | ); | 64 | ); |
| 55 | 65 | ||
| 56 | export const PreviewSnapshotTool = Tool.make("preview_snapshot", { | 66 | export const PreviewSnapshotTool = readonlyBrowserTool( |
| 57 | description: | 67 | Tool.make("preview_snapshot", { |
| 58 | "Capture bounded page metadata, visible text, interactive elements, accessibility data, and a PNG screenshot from the scoped preview tab.", | 68 | description: |
| 59 | success: PreviewAutomationSnapshot, | 69 | "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.", |
| 60 | failure: PreviewAutomationError, | 70 | success: PreviewAutomationSnapshot, |
| 61 | dependencies: [McpInvocationContext], | 71 | failure: PreviewAutomationError, |
| 62 | }) | 72 | dependencies: [McpInvocationContext], |
| 63 | .annotate(Tool.Title, "Capture preview snapshot") | 73 | }).annotate(Tool.Title, "Inspect browser page"), |
| 64 | .annotate(Tool.Readonly, true) | 74 | ); |
| 65 | .annotate(Tool.Destructive, false); | ||
| 66 | 75 | ||
| 67 | export const PreviewClickTool = browserTool( | 76 | export const PreviewClickTool = browserTool( |
| 68 | Tool.make("preview_click", { | 77 | Tool.make("preview_click", { |
| 69 | description: | 78 | description: |
| 70 | "Click an element selected by CSS selector or click viewport coordinates in the scoped preview tab.", | 79 | "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.", |
| 71 | parameters: PreviewAutomationClickInput, | 80 | parameters: PreviewAutomationClickInput, |
| 72 | failure: PreviewAutomationError, | 81 | failure: PreviewAutomationError, |
| 73 | dependencies: [McpInvocationContext], | 82 | dependencies: [McpInvocationContext], |
| ... | @@ -77,7 +86,7 @@ export const PreviewClickTool = browserTool( | ... | @@ -77,7 +86,7 @@ export const PreviewClickTool = browserTool( |
| 77 | export const PreviewTypeTool = browserTool( | 86 | export const PreviewTypeTool = browserTool( |
| 78 | Tool.make("preview_type", { | 87 | Tool.make("preview_type", { |
| 79 | description: | 88 | description: |
| 80 | "Type text into the focused element or a CSS-selected element, optionally clearing its existing value first.", | 89 | "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.", |
| 81 | parameters: PreviewAutomationTypeInput, | 90 | parameters: PreviewAutomationTypeInput, |
| 82 | failure: PreviewAutomationError, | 91 | failure: PreviewAutomationError, |
| 83 | dependencies: [McpInvocationContext], | 92 | dependencies: [McpInvocationContext], |
| ... | @@ -86,17 +95,18 @@ export const PreviewTypeTool = browserTool( | ... | @@ -86,17 +95,18 @@ export const PreviewTypeTool = browserTool( |
| 86 | 95 | ||
| 87 | export const PreviewPressTool = browserTool( | 96 | export const PreviewPressTool = browserTool( |
| 88 | Tool.make("preview_press", { | 97 | Tool.make("preview_press", { |
| 89 | description: "Dispatch a keyboard key with optional modifiers to the scoped preview tab.", | 98 | description: |
| 99 | "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.", | ||
| 90 | parameters: PreviewAutomationPressInput, | 100 | parameters: PreviewAutomationPressInput, |
| 91 | failure: PreviewAutomationError, | 101 | failure: PreviewAutomationError, |
| 92 | dependencies: [McpInvocationContext], | 102 | dependencies: [McpInvocationContext], |
| 93 | }).annotate(Tool.Title, "Press key in preview page"), | 103 | }).annotate(Tool.Title, "Press key in preview page"), |
| 94 | ); | 104 | ); |
| 95 | 105 | ||
| 96 | export const PreviewScrollTool = browserTool( | 106 | export const PreviewScrollTool = safeBrowserTool( |
| 97 | Tool.make("preview_scroll", { | 107 | Tool.make("preview_scroll", { |
| 98 | description: | 108 | description: |
| 99 | "Scroll the preview viewport or a CSS-selected scroll container by the requested deltas.", | 109 | "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.", |
| 100 | parameters: PreviewAutomationScrollInput, | 110 | parameters: PreviewAutomationScrollInput, |
| 101 | failure: PreviewAutomationError, | 111 | failure: PreviewAutomationError, |
| 102 | dependencies: [McpInvocationContext], | 112 | dependencies: [McpInvocationContext], |
| ... | @@ -106,7 +116,7 @@ export const PreviewScrollTool = browserTool( | ... | @@ -106,7 +116,7 @@ export const PreviewScrollTool = browserTool( |
| 106 | export const PreviewEvaluateTool = browserTool( | 116 | export const PreviewEvaluateTool = browserTool( |
| 107 | Tool.make("preview_evaluate", { | 117 | Tool.make("preview_evaluate", { |
| 108 | description: | 118 | description: |
| 109 | "Evaluate bounded JavaScript in the scoped preview tab and return a serializable result of at most 64 KB.", | 119 | "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.", |
| 110 | parameters: PreviewAutomationEvaluateInput, | 120 | parameters: PreviewAutomationEvaluateInput, |
| 111 | success: Schema.Unknown, | 121 | success: Schema.Unknown, |
| 112 | failure: PreviewAutomationError, | 122 | failure: PreviewAutomationError, |
| ... | @@ -114,16 +124,35 @@ export const PreviewEvaluateTool = browserTool( | ... | @@ -114,16 +124,35 @@ export const PreviewEvaluateTool = browserTool( |
| 114 | }).annotate(Tool.Title, "Evaluate JavaScript in preview"), | 124 | }).annotate(Tool.Title, "Evaluate JavaScript in preview"), |
| 115 | ); | 125 | ); |
| 116 | 126 | ||
| 117 | export const PreviewWaitForTool = browserTool( | 127 | export const PreviewWaitForTool = readonlyBrowserTool( |
| 118 | Tool.make("preview_wait_for", { | 128 | Tool.make("preview_wait_for", { |
| 119 | description: | 129 | description: |
| 120 | "Wait until a CSS selector, visible-text substring, or URL substring appears in the scoped preview tab.", | 130 | "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.", |
| 121 | parameters: PreviewAutomationWaitForInput, | 131 | parameters: PreviewAutomationWaitForInput, |
| 122 | failure: PreviewAutomationError, | 132 | failure: PreviewAutomationError, |
| 123 | dependencies: [McpInvocationContext], | 133 | dependencies: [McpInvocationContext], |
| 124 | }).annotate(Tool.Title, "Wait for preview page condition"), | 134 | }).annotate(Tool.Title, "Wait for preview page condition"), |
| 125 | ); | 135 | ); |
| 126 | 136 | ||
| 137 | export const PreviewRecordingStartTool = safeBrowserTool( | ||
| 138 | Tool.make("preview_recording_start", { | ||
| 139 | description: | ||
| 140 | "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", | ||
| 141 | success: PreviewAutomationRecordingStatus, | ||
| 142 | failure: PreviewAutomationError, | ||
| 143 | dependencies: [McpInvocationContext], | ||
| 144 | }).annotate(Tool.Title, "Start browser recording"), | ||
| 145 | ); | ||
| 146 | |||
| 147 | export const PreviewRecordingStopTool = safeBrowserTool( | ||
| 148 | Tool.make("preview_recording_stop", { | ||
| 149 | description: "Stop the active browser recording and save it as a local evidence artifact.", | ||
| 150 | success: PreviewAutomationRecordingArtifact, | ||
| 151 | failure: PreviewAutomationError, | ||
| 152 | dependencies: [McpInvocationContext], | ||
| 153 | }).annotate(Tool.Title, "Stop browser recording"), | ||
| 154 | ); | ||
| 155 | |||
| 127 | export const PreviewToolkit = Toolkit.make( | 156 | export const PreviewToolkit = Toolkit.make( |
| 128 | PreviewStatusTool, | 157 | PreviewStatusTool, |
| 129 | PreviewOpenTool, | 158 | PreviewOpenTool, |
| ... | @@ -135,4 +164,6 @@ export const PreviewToolkit = Toolkit.make( | ... | @@ -135,4 +164,6 @@ export const PreviewToolkit = Toolkit.make( |
| 135 | PreviewScrollTool, | 164 | PreviewScrollTool, |
| 136 | PreviewEvaluateTool, | 165 | PreviewEvaluateTool, |
| 137 | PreviewWaitForTool, | 166 | PreviewWaitForTool, |
| 167 | PreviewRecordingStartTool, | ||
| 168 | PreviewRecordingStopTool, | ||
| 138 | ); | 169 | ); |
apps/server/src/provider/CodexDeveloperInstructions.ts+13| ... | @@ -1,3 +1,14 @@ | ... | @@ -1,3 +1,14 @@ |
| 1 | const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` | ||
| 2 | |||
| 3 | ## T3 Code collaborative browser | ||
| 4 | |||
| 5 | 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. | ||
| 6 | |||
| 7 | 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. | ||
| 8 | |||
| 9 | 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. | ||
| 10 | `; | ||
| 11 | |||
| 1 | export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational) | 12 | export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational) |
| 2 | 13 | ||
| 3 | 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. | 14 | 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 | ... | @@ -118,6 +129,7 @@ plan content should be human and agent digestible. The final plan must be plan-o |
| 118 | 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 \`<proposed_plan>\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. | 129 | 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 \`<proposed_plan>\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. |
| 119 | 130 | ||
| 120 | Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec. | 131 | Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec. |
| 132 | ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} | ||
| 121 | </collaboration_mode>`; | 133 | </collaboration_mode>`; |
| 122 | 134 | ||
| 123 | export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Collaboration Mode: Default | 135 | export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Collaboration Mode: Default |
| ... | @@ -131,4 +143,5 @@ Your active mode changes only when new developer instructions with a different \ | ... | @@ -131,4 +143,5 @@ Your active mode changes only when new developer instructions with a different \ |
| 131 | The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error. | 143 | The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error. |
| 132 | 144 | ||
| 133 | 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. | 145 | 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. |
| 146 | ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} | ||
| 134 | </collaboration_mode>`; | 147 | </collaboration_mode>`; |
apps/server/src/provider/Layers/CodexAdapter.test.ts+58| ... | @@ -491,6 +491,64 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -491,6 +491,64 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 491 | }), | 491 | }), |
| 492 | ); | 492 | ); |
| 493 | 493 | ||
| 494 | it.effect("labels MCP lifecycle entries with server and tool names", () => | ||
| 495 | Effect.gen(function* () { | ||
| 496 | const { adapter, runtime } = yield* startLifecycleRuntime(); | ||
| 497 | const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); | ||
| 498 | |||
| 499 | yield* runtime.emit({ | ||
| 500 | id: asEventId("evt-mcp-complete"), | ||
| 501 | kind: "notification", | ||
| 502 | provider: ProviderDriverKind.make("codex"), | ||
| 503 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 504 | method: "item/completed", | ||
| 505 | threadId: asThreadId("thread-1"), | ||
| 506 | turnId: asTurnId("turn-1"), | ||
| 507 | itemId: asItemId("mcp_1"), | ||
| 508 | payload: { | ||
| 509 | completedAtMs: 1_778_000_000_000, | ||
| 510 | threadId: "thread-1", | ||
| 511 | turnId: "turn-1", | ||
| 512 | item: { | ||
| 513 | type: "mcpToolCall", | ||
| 514 | id: "mcp_1", | ||
| 515 | server: "t3-code", | ||
| 516 | tool: "preview_status", | ||
| 517 | arguments: {}, | ||
| 518 | durationMs: 12, | ||
| 519 | error: null, | ||
| 520 | result: { content: [{ type: "text", text: "attached" }] }, | ||
| 521 | status: "completed", | ||
| 522 | }, | ||
| 523 | }, | ||
| 524 | }); | ||
| 525 | const firstEvent = yield* Fiber.join(firstEventFiber); | ||
| 526 | |||
| 527 | assert.equal(firstEvent._tag, "Some"); | ||
| 528 | if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { | ||
| 529 | return; | ||
| 530 | } | ||
| 531 | assert.equal(firstEvent.value.payload.itemType, "mcp_tool_call"); | ||
| 532 | assert.equal(firstEvent.value.payload.title, "t3-code · preview_status"); | ||
| 533 | assert.deepStrictEqual(firstEvent.value.payload.data, { | ||
| 534 | completedAtMs: 1_778_000_000_000, | ||
| 535 | threadId: "thread-1", | ||
| 536 | turnId: "turn-1", | ||
| 537 | item: { | ||
| 538 | type: "mcpToolCall", | ||
| 539 | id: "mcp_1", | ||
| 540 | server: "t3-code", | ||
| 541 | tool: "preview_status", | ||
| 542 | arguments: {}, | ||
| 543 | durationMs: 12, | ||
| 544 | error: null, | ||
| 545 | result: { content: [{ type: "text", text: "attached" }] }, | ||
| 546 | status: "completed", | ||
| 547 | }, | ||
| 548 | }); | ||
| 549 | }), | ||
| 550 | ); | ||
| 551 | |||
| 494 | it.effect("maps completed plan items to canonical proposed-plan completion events", () => | 552 | it.effect("maps completed plan items to canonical proposed-plan completion events", () => |
| 495 | Effect.gen(function* () { | 553 | Effect.gen(function* () { |
| 496 | const { adapter, runtime } = yield* startLifecycleRuntime(); | 554 | const { adapter, runtime } = yield* startLifecycleRuntime(); |
apps/server/src/provider/Layers/CodexAdapter.ts+5-2| ... | @@ -235,7 +235,10 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType | ... | @@ -235,7 +235,10 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType |
| 235 | return "unknown"; | 235 | return "unknown"; |
| 236 | } | 236 | } |
| 237 | 237 | ||
| 238 | function itemTitle(itemType: CanonicalItemType): string | undefined { | 238 | function itemTitle(itemType: CanonicalItemType, item?: CodexLifecycleItem): string | undefined { |
| 239 | if (itemType === "mcp_tool_call" && item?.type === "mcpToolCall") { | ||
| 240 | return `${item.server} · ${item.tool}`; | ||
| 241 | } | ||
| 239 | switch (itemType) { | 242 | switch (itemType) { |
| 240 | case "assistant_message": | 243 | case "assistant_message": |
| 241 | return "Assistant message"; | 244 | return "Assistant message"; |
| ... | @@ -476,7 +479,7 @@ function mapItemLifecycle( | ... | @@ -476,7 +479,7 @@ function mapItemLifecycle( |
| 476 | payload: { | 479 | payload: { |
| 477 | itemType, | 480 | itemType, |
| 478 | ...(status ? { status } : {}), | 481 | ...(status ? { status } : {}), |
| 479 | ...(itemTitle(itemType) ? { title: itemTitle(itemType) } : {}), | 482 | ...(itemTitle(itemType, item) ? { title: itemTitle(itemType, item) } : {}), |
| 480 | ...(detail ? { detail } : {}), | 483 | ...(detail ? { detail } : {}), |
| 481 | ...(event.payload !== undefined ? { data: event.payload } : {}), | 484 | ...(event.payload !== undefined ? { data: event.payload } : {}), |
| 482 | }, | 485 | }, |
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+26| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | } from "../CodexDeveloperInstructions.ts"; | 13 | } from "../CodexDeveloperInstructions.ts"; |
| 14 | import { | 14 | import { |
| 15 | buildTurnStartParams, | 15 | buildTurnStartParams, |
| 16 | hasConfiguredMcpServer, | ||
| 16 | isRecoverableThreadResumeError, | 17 | isRecoverableThreadResumeError, |
| 17 | openCodexThread, | 18 | openCodexThread, |
| 18 | } from "./CodexSessionRuntime.ts"; | 19 | } from "./CodexSessionRuntime.ts"; |
| ... | @@ -149,6 +150,31 @@ describe("buildTurnStartParams", () => { | ... | @@ -149,6 +150,31 @@ describe("buildTurnStartParams", () => { |
| 149 | }); | 150 | }); |
| 150 | }); | 151 | }); |
| 151 | 152 | ||
| 153 | describe("T3 browser developer instructions", () => { | ||
| 154 | it("prefers the product-native preview tools in both collaboration modes", () => { | ||
| 155 | for (const instructions of [ | ||
| 156 | CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, | ||
| 157 | CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, | ||
| 158 | ]) { | ||
| 159 | assert.match(instructions, /t3-code/); | ||
| 160 | assert.match(instructions, /preview_status/); | ||
| 161 | assert.match(instructions, /preview_open/); | ||
| 162 | assert.match(instructions, /Do not switch to global browser skills/); | ||
| 163 | } | ||
| 164 | }); | ||
| 165 | }); | ||
| 166 | |||
| 167 | describe("hasConfiguredMcpServer", () => { | ||
| 168 | it("detects inline Codex MCP configuration arguments", () => { | ||
| 169 | assert.equal(hasConfiguredMcpServer(undefined), false); | ||
| 170 | assert.equal(hasConfiguredMcpServer(["--model", "gpt-5.4"]), false); | ||
| 171 | assert.equal( | ||
| 172 | hasConfiguredMcpServer(["-c", 'mcp_servers.t3-code.url="http://127.0.0.1/mcp"']), | ||
| 173 | true, | ||
| 174 | ); | ||
| 175 | }); | ||
| 176 | }); | ||
| 177 | |||
| 152 | describe("isRecoverableThreadResumeError", () => { | 178 | describe("isRecoverableThreadResumeError", () => { |
| 153 | it("matches missing thread errors", () => { | 179 | it("matches missing thread errors", () => { |
| 154 | assert.equal( | 180 | assert.equal( |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+13| ... | @@ -62,6 +62,10 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ | ... | @@ -62,6 +62,10 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ |
| 62 | "does not exist", | 62 | "does not exist", |
| 63 | ]; | 63 | ]; |
| 64 | 64 | ||
| 65 | export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray<string> | undefined): boolean { | ||
| 66 | return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; | ||
| 67 | } | ||
| 68 | |||
| 65 | export const CodexResumeCursorSchema = Schema.Struct({ | 69 | export const CodexResumeCursorSchema = Schema.Struct({ |
| 66 | threadId: Schema.String, | 70 | threadId: Schema.String, |
| 67 | }); | 71 | }); |
| ... | @@ -1256,6 +1260,15 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1256,6 +1260,15 @@ export const makeCodexSessionRuntime = ( |
| 1256 | sendTurn: (input) => | 1260 | sendTurn: (input) => |
| 1257 | Effect.gen(function* () { | 1261 | Effect.gen(function* () { |
| 1258 | const providerThreadId = yield* readProviderThreadId; | 1262 | const providerThreadId = yield* readProviderThreadId; |
| 1263 | if (hasConfiguredMcpServer(options.appServerArgs)) { | ||
| 1264 | yield* client.request("config/mcpServer/reload", undefined).pipe( | ||
| 1265 | Effect.catch((cause) => | ||
| 1266 | Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { | ||
| 1267 | cause, | ||
| 1268 | }), | ||
| 1269 | ), | ||
| 1270 | ); | ||
| 1271 | } | ||
| 1259 | const normalizedModel = normalizeCodexModelSlug( | 1272 | const normalizedModel = normalizeCodexModelSlug( |
| 1260 | input.model ?? (yield* Ref.get(sessionRef)).model, | 1273 | input.model ?? (yield* Ref.get(sessionRef)).model, |
| 1261 | ); | 1274 | ); |
apps/web/src/browser/BrowserSurfaceSlot.tsx created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useEffect, useRef } from "react"; | ||
| 4 | |||
| 5 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; | ||
| 6 | |||
| 7 | export function BrowserSurfaceSlot(props: { | ||
| 8 | readonly tabId: string; | ||
| 9 | readonly visible: boolean; | ||
| 10 | readonly className?: string; | ||
| 11 | }) { | ||
| 12 | const { tabId, visible, className } = props; | ||
| 13 | const elementRef = useRef<HTMLDivElement | null>(null); | ||
| 14 | |||
| 15 | useEffect(() => { | ||
| 16 | const element = elementRef.current; | ||
| 17 | if (!element) return; | ||
| 18 | const update = () => { | ||
| 19 | const rect = element.getBoundingClientRect(); | ||
| 20 | useBrowserSurfaceStore.getState().present( | ||
| 21 | tabId, | ||
| 22 | { | ||
| 23 | x: Math.round(rect.x), | ||
| 24 | y: Math.round(rect.y), | ||
| 25 | width: Math.max(1, Math.round(rect.width)), | ||
| 26 | height: Math.max(1, Math.round(rect.height)), | ||
| 27 | }, | ||
| 28 | visible && rect.width > 0 && rect.height > 0, | ||
| 29 | ); | ||
| 30 | }; | ||
| 31 | update(); | ||
| 32 | const observer = new ResizeObserver(update); | ||
| 33 | observer.observe(element); | ||
| 34 | window.addEventListener("resize", update); | ||
| 35 | window.addEventListener("scroll", update, true); | ||
| 36 | return () => { | ||
| 37 | observer.disconnect(); | ||
| 38 | window.removeEventListener("resize", update); | ||
| 39 | window.removeEventListener("scroll", update, true); | ||
| 40 | useBrowserSurfaceStore.getState().hide(tabId); | ||
| 41 | }; | ||
| 42 | }, [tabId, visible]); | ||
| 43 | |||
| 44 | return <div ref={elementRef} className={className} data-browser-surface-slot={tabId} />; | ||
| 45 | } | ||
apps/web/src/browser/ElectronBrowserHost.tsx created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime"; | ||
| 4 | import { useMemo } from "react"; | ||
| 5 | |||
| 6 | import { isElectron } from "~/env"; | ||
| 7 | import { usePreviewStateStore } from "~/previewStateStore"; | ||
| 8 | |||
| 9 | import { HostedBrowserWebview } from "./HostedBrowserWebview"; | ||
| 10 | |||
| 11 | export function ElectronBrowserHost() { | ||
| 12 | const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); | ||
| 13 | const sessions = useMemo( | ||
| 14 | () => | ||
| 15 | Object.entries(previewByThreadKey).flatMap(([threadKey, previewState]) => { | ||
| 16 | const threadRef = parseScopedThreadKey(threadKey); | ||
| 17 | return threadRef | ||
| 18 | ? Object.values(previewState.sessions).map((snapshot) => ({ | ||
| 19 | threadRef, | ||
| 20 | snapshot, | ||
| 21 | active: previewState.activeTabId === snapshot.tabId, | ||
| 22 | })) | ||
| 23 | : []; | ||
| 24 | }), | ||
| 25 | [previewByThreadKey], | ||
| 26 | ); | ||
| 27 | |||
| 28 | if (!isElectron) return null; | ||
| 29 | return ( | ||
| 30 | <div className="contents" data-electron-browser-host> | ||
| 31 | {sessions.map(({ threadRef, snapshot }) => { | ||
| 32 | const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; | ||
| 33 | return ( | ||
| 34 | <HostedBrowserWebview | ||
| 35 | key={snapshot.tabId} | ||
| 36 | threadRef={threadRef} | ||
| 37 | tabId={snapshot.tabId} | ||
| 38 | initialUrl={url} | ||
| 39 | /> | ||
| 40 | ); | ||
| 41 | })} | ||
| 42 | </div> | ||
| 43 | ); | ||
| 44 | } | ||
apps/web/src/browser/HostedBrowserWebview.tsx created+116| ... | @@ -0,0 +1,116 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts"; | ||
| 4 | import { useShallow } from "zustand/react/shallow"; | ||
| 5 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 6 | |||
| 7 | import { previewBridge } from "~/components/preview/previewBridge"; | ||
| 8 | import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; | ||
| 9 | |||
| 10 | import { useBrowserRecordingStore } from "./browserRecording"; | ||
| 11 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; | ||
| 12 | import { acquireDesktopTab } from "./desktopTabLifetime"; | ||
| 13 | |||
| 14 | interface ElectronWebview extends HTMLElement { | ||
| 15 | src: string; | ||
| 16 | partition: string; | ||
| 17 | preload?: string; | ||
| 18 | webpreferences?: string; | ||
| 19 | getWebContentsId: () => number; | ||
| 20 | } | ||
| 21 | |||
| 22 | declare global { | ||
| 23 | interface HTMLElementTagNameMap { | ||
| 24 | webview: ElectronWebview; | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | export function HostedBrowserWebview(props: { | ||
| 29 | readonly threadRef: ScopedThreadRef; | ||
| 30 | readonly tabId: string; | ||
| 31 | readonly initialUrl: string | null; | ||
| 32 | }) { | ||
| 33 | const { threadRef, tabId, initialUrl } = props; | ||
| 34 | const [config, setConfig] = useState<DesktopPreviewWebviewConfig | null>(null); | ||
| 35 | const initialSrcRef = useRef(initialUrl ?? "about:blank"); | ||
| 36 | const webviewRef = useRef<ElectronWebview | null>(null); | ||
| 37 | const presentation = useBrowserSurfaceStore(useShallow((state) => state.byTabId[tabId] ?? null)); | ||
| 38 | const recording = useBrowserRecordingStore((state) => state.activeTabId === tabId); | ||
| 39 | |||
| 40 | usePreviewBridge({ threadRef, tabId }); | ||
| 41 | |||
| 42 | useEffect(() => acquireDesktopTab(tabId), [tabId]); | ||
| 43 | |||
| 44 | useEffect(() => { | ||
| 45 | let cancelled = false; | ||
| 46 | void previewBridge | ||
| 47 | ?.getPreviewConfig(threadRef.environmentId) | ||
| 48 | .then((next) => { | ||
| 49 | if (!cancelled) setConfig(next); | ||
| 50 | }) | ||
| 51 | .catch(() => undefined); | ||
| 52 | return () => { | ||
| 53 | cancelled = true; | ||
| 54 | }; | ||
| 55 | }, [threadRef.environmentId]); | ||
| 56 | |||
| 57 | const setWebviewRef = useCallback((node: HTMLElement | null) => { | ||
| 58 | webviewRef.current = node as ElectronWebview | null; | ||
| 59 | if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); | ||
| 60 | }, []); | ||
| 61 | |||
| 62 | useEffect(() => { | ||
| 63 | const webview = webviewRef.current; | ||
| 64 | const bridge = previewBridge; | ||
| 65 | if (!webview || !config || !bridge) return; | ||
| 66 | const register = () => { | ||
| 67 | try { | ||
| 68 | const webContentsId = webview.getWebContentsId(); | ||
| 69 | if (Number.isInteger(webContentsId) && webContentsId > 0) { | ||
| 70 | void bridge.registerWebview(tabId, webContentsId); | ||
| 71 | } | ||
| 72 | } catch { | ||
| 73 | // A later dom-ready will retry registration. | ||
| 74 | } | ||
| 75 | }; | ||
| 76 | webview.addEventListener("dom-ready", register); | ||
| 77 | register(); | ||
| 78 | return () => webview.removeEventListener("dom-ready", register); | ||
| 79 | }, [config, tabId]); | ||
| 80 | |||
| 81 | if (!config) return null; | ||
| 82 | const active = presentation?.visible === true && presentation.rect !== null; | ||
| 83 | const lastRect = presentation?.rect; | ||
| 84 | const style = | ||
| 85 | active && lastRect | ||
| 86 | ? { | ||
| 87 | left: lastRect.x, | ||
| 88 | top: lastRect.y, | ||
| 89 | width: lastRect.width, | ||
| 90 | height: lastRect.height, | ||
| 91 | zIndex: 30, | ||
| 92 | pointerEvents: "auto" as const, | ||
| 93 | } | ||
| 94 | : { | ||
| 95 | left: 0, | ||
| 96 | top: 0, | ||
| 97 | width: lastRect?.width ?? 1280, | ||
| 98 | height: lastRect?.height ?? 800, | ||
| 99 | zIndex: recording ? 0 : -1, | ||
| 100 | pointerEvents: "none" as const, | ||
| 101 | }; | ||
| 102 | |||
| 103 | return ( | ||
| 104 | <webview | ||
| 105 | ref={setWebviewRef} | ||
| 106 | src={initialSrcRef.current} | ||
| 107 | partition={config.partition} | ||
| 108 | webpreferences={config.webPreferences} | ||
| 109 | {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} | ||
| 110 | data-preview-tab={tabId} | ||
| 111 | aria-hidden={active ? undefined : true} | ||
| 112 | className="fixed flex overflow-hidden bg-background" | ||
| 113 | style={style} | ||
| 114 | /> | ||
| 115 | ); | ||
| 116 | } | ||
apps/web/src/browser/browserRecording.ts created+115| ... | @@ -0,0 +1,115 @@ | ||
| 1 | import type { | ||
| 2 | DesktopPreviewRecordingArtifact, | ||
| 3 | DesktopPreviewRecordingFrame, | ||
| 4 | } from "@t3tools/contracts"; | ||
| 5 | import { create } from "zustand"; | ||
| 6 | |||
| 7 | import { previewBridge } from "~/components/preview/previewBridge"; | ||
| 8 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; | ||
| 9 | |||
| 10 | interface ActiveRecording { | ||
| 11 | readonly tabId: string; | ||
| 12 | readonly canvas: HTMLCanvasElement; | ||
| 13 | readonly context: CanvasRenderingContext2D; | ||
| 14 | readonly recorder: MediaRecorder; | ||
| 15 | readonly chunks: Blob[]; | ||
| 16 | readonly mimeType: string; | ||
| 17 | readonly startedAt: string; | ||
| 18 | } | ||
| 19 | |||
| 20 | interface BrowserRecordingState { | ||
| 21 | activeTabId: string | null; | ||
| 22 | startedAt: string | null; | ||
| 23 | lastArtifact: DesktopPreviewRecordingArtifact | null; | ||
| 24 | setActive: (tabId: string | null, startedAt: string | null) => void; | ||
| 25 | setArtifact: (artifact: DesktopPreviewRecordingArtifact) => void; | ||
| 26 | } | ||
| 27 | |||
| 28 | export const useBrowserRecordingStore = create<BrowserRecordingState>()((set) => ({ | ||
| 29 | activeTabId: null, | ||
| 30 | startedAt: null, | ||
| 31 | lastArtifact: null, | ||
| 32 | setActive: (activeTabId, startedAt) => set({ activeTabId, startedAt }), | ||
| 33 | setArtifact: (lastArtifact) => set({ lastArtifact }), | ||
| 34 | })); | ||
| 35 | |||
| 36 | let active: ActiveRecording | null = null; | ||
| 37 | let unsubscribeFrames: (() => void) | null = null; | ||
| 38 | |||
| 39 | const preferredMimeType = (): string => { | ||
| 40 | const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; | ||
| 41 | return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; | ||
| 42 | }; | ||
| 43 | |||
| 44 | const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { | ||
| 45 | const recording = active; | ||
| 46 | if (!recording || recording.tabId !== frame.tabId) return; | ||
| 47 | const image = new Image(); | ||
| 48 | image.addEventListener( | ||
| 49 | "load", | ||
| 50 | () => { | ||
| 51 | if (active !== recording) return; | ||
| 52 | recording.context.drawImage(image, 0, 0, recording.canvas.width, recording.canvas.height); | ||
| 53 | }, | ||
| 54 | { once: true }, | ||
| 55 | ); | ||
| 56 | image.src = `data:image/jpeg;base64,${frame.data}`; | ||
| 57 | }; | ||
| 58 | |||
| 59 | export async function startBrowserRecording(tabId: string): Promise<void> { | ||
| 60 | const bridge = previewBridge; | ||
| 61 | if (!bridge || active) return; | ||
| 62 | const rect = useBrowserSurfaceStore.getState().byTabId[tabId]?.rect; | ||
| 63 | const canvas = document.createElement("canvas"); | ||
| 64 | canvas.width = Math.max(1, rect?.width ?? 1280); | ||
| 65 | canvas.height = Math.max(1, rect?.height ?? 800); | ||
| 66 | const context = canvas.getContext("2d", { alpha: false }); | ||
| 67 | if (!context) throw new Error("Browser recording canvas is unavailable."); | ||
| 68 | const mimeType = preferredMimeType(); | ||
| 69 | const recorder = new MediaRecorder(canvas.captureStream(12), { | ||
| 70 | mimeType, | ||
| 71 | videoBitsPerSecond: 4_000_000, | ||
| 72 | }); | ||
| 73 | const startedAt = new Date().toISOString(); | ||
| 74 | const chunks: Blob[] = []; | ||
| 75 | recorder.addEventListener("dataavailable", (event) => { | ||
| 76 | if (event.data.size > 0) chunks.push(event.data); | ||
| 77 | }); | ||
| 78 | active = { tabId, canvas, context, recorder, chunks, mimeType, startedAt }; | ||
| 79 | unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); | ||
| 80 | recorder.start(1_000); | ||
| 81 | try { | ||
| 82 | await bridge.recording.startScreencast(tabId); | ||
| 83 | useBrowserRecordingStore.getState().setActive(tabId, startedAt); | ||
| 84 | } catch (error) { | ||
| 85 | active = null; | ||
| 86 | recorder.stop(); | ||
| 87 | throw error; | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | export async function stopBrowserRecording( | ||
| 92 | tabId: string, | ||
| 93 | ): Promise<DesktopPreviewRecordingArtifact | null> { | ||
| 94 | const bridge = previewBridge; | ||
| 95 | const recording = active; | ||
| 96 | if (!bridge || !recording || recording.tabId !== tabId) return null; | ||
| 97 | await bridge.recording.stopScreencast(tabId); | ||
| 98 | const stopped = new Promise<void>((resolve) => | ||
| 99 | recording.recorder.addEventListener("stop", () => resolve(), { once: true }), | ||
| 100 | ); | ||
| 101 | recording.recorder.stop(); | ||
| 102 | await stopped; | ||
| 103 | const blob = new Blob(recording.chunks, { type: recording.mimeType }); | ||
| 104 | const artifact = await bridge.recording.save( | ||
| 105 | tabId, | ||
| 106 | recording.mimeType, | ||
| 107 | new Uint8Array(await blob.arrayBuffer()), | ||
| 108 | ); | ||
| 109 | active = null; | ||
| 110 | unsubscribeFrames?.(); | ||
| 111 | unsubscribeFrames = null; | ||
| 112 | useBrowserRecordingStore.getState().setActive(null, null); | ||
| 113 | useBrowserRecordingStore.getState().setArtifact(artifact); | ||
| 114 | return artifact; | ||
| 115 | } | ||
apps/web/src/browser/browserSurfaceStore.ts created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | import { create } from "zustand"; | ||
| 2 | |||
| 3 | export interface BrowserSurfaceRect { | ||
| 4 | readonly x: number; | ||
| 5 | readonly y: number; | ||
| 6 | readonly width: number; | ||
| 7 | readonly height: number; | ||
| 8 | } | ||
| 9 | |||
| 10 | export interface BrowserSurfacePresentation { | ||
| 11 | readonly rect: BrowserSurfaceRect | null; | ||
| 12 | readonly visible: boolean; | ||
| 13 | readonly updatedAt: number; | ||
| 14 | } | ||
| 15 | |||
| 16 | interface BrowserSurfaceStoreState { | ||
| 17 | readonly byTabId: Record<string, BrowserSurfacePresentation>; | ||
| 18 | readonly present: (tabId: string, rect: BrowserSurfaceRect, visible: boolean) => void; | ||
| 19 | readonly hide: (tabId: string) => void; | ||
| 20 | } | ||
| 21 | |||
| 22 | const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): boolean => | ||
| 23 | left !== null && | ||
| 24 | left.x === right.x && | ||
| 25 | left.y === right.y && | ||
| 26 | left.width === right.width && | ||
| 27 | left.height === right.height; | ||
| 28 | |||
| 29 | export const useBrowserSurfaceStore = create<BrowserSurfaceStoreState>()((set) => ({ | ||
| 30 | byTabId: {}, | ||
| 31 | present: (tabId, rect, visible) => | ||
| 32 | set((state) => { | ||
| 33 | const current = state.byTabId[tabId]; | ||
| 34 | if (current && current.visible === visible && rectEquals(current.rect, rect)) return state; | ||
| 35 | return { | ||
| 36 | byTabId: { | ||
| 37 | ...state.byTabId, | ||
| 38 | [tabId]: { rect, visible, updatedAt: Date.now() }, | ||
| 39 | }, | ||
| 40 | }; | ||
| 41 | }), | ||
| 42 | hide: (tabId) => | ||
| 43 | set((state) => { | ||
| 44 | const current = state.byTabId[tabId]; | ||
| 45 | if (!current || !current.visible) return state; | ||
| 46 | return { | ||
| 47 | byTabId: { | ||
| 48 | ...state.byTabId, | ||
| 49 | [tabId]: { ...current, visible: false, updatedAt: Date.now() }, | ||
| 50 | }, | ||
| 51 | }; | ||
| 52 | }), | ||
| 53 | })); | ||
apps/web/src/browser/browserTargetResolver.test.ts created+81| ... | @@ -0,0 +1,81 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | |||
| 4 | const readEnvironmentConnection = vi.fn(); | ||
| 5 | |||
| 6 | vi.mock("~/environments/runtime", () => ({ readEnvironmentConnection })); | ||
| 7 | |||
| 8 | describe("browser target resolver", () => { | ||
| 9 | beforeEach(() => readEnvironmentConnection.mockReset()); | ||
| 10 | |||
| 11 | it("maps environment ports onto a private network host", async () => { | ||
| 12 | readEnvironmentConnection.mockReturnValue({ | ||
| 13 | knownEnvironment: { target: { httpBaseUrl: "http://192.168.1.25:3773" } }, | ||
| 14 | }); | ||
| 15 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | ||
| 16 | expect( | ||
| 17 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 18 | kind: "environment-port", | ||
| 19 | port: 5173, | ||
| 20 | path: "/dashboard", | ||
| 21 | }), | ||
| 22 | ).toEqual({ | ||
| 23 | requestedUrl: "http://localhost:5173/dashboard", | ||
| 24 | resolvedUrl: "http://192.168.1.25:5173/dashboard", | ||
| 25 | resolutionKind: "direct-private-network", | ||
| 26 | environmentId: "environment-1", | ||
| 27 | }); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("refuses public relay hosts until the authenticated gateway exists", async () => { | ||
| 31 | readEnvironmentConnection.mockReturnValue({ | ||
| 32 | knownEnvironment: { target: { httpBaseUrl: "https://relay.example.com" } }, | ||
| 33 | }); | ||
| 34 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | ||
| 35 | expect(() => | ||
| 36 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 37 | kind: "environment-port", | ||
| 38 | port: 5173, | ||
| 39 | }), | ||
| 40 | ).toThrow(/authenticated preview gateway/); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("normalizes schemeless localhost server-picker values", async () => { | ||
| 44 | readEnvironmentConnection.mockReturnValue({ | ||
| 45 | knownEnvironment: { target: { httpBaseUrl: "http://localhost:3773" } }, | ||
| 46 | }); | ||
| 47 | const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); | ||
| 48 | expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173")).toBe( | ||
| 49 | "http://localhost:5173/", | ||
| 50 | ); | ||
| 51 | expect( | ||
| 52 | resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "0.0.0.0:3000/app"), | ||
| 53 | ).toBe("http://localhost:3000/app"); | ||
| 54 | }); | ||
| 55 | |||
| 56 | it("normalizes public URLs without treating them as environment ports", async () => { | ||
| 57 | const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); | ||
| 58 | expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( | ||
| 59 | "https://example.com/app", | ||
| 60 | ); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("supports private IPv6 environment hosts", async () => { | ||
| 64 | readEnvironmentConnection.mockReturnValue({ | ||
| 65 | knownEnvironment: { target: { httpBaseUrl: "http://[::1]:3773" } }, | ||
| 66 | }); | ||
| 67 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | ||
| 68 | expect( | ||
| 69 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | ||
| 70 | kind: "environment-port", | ||
| 71 | port: 5173, | ||
| 72 | path: "/app?mode=test", | ||
| 73 | }).resolvedUrl, | ||
| 74 | ).toBe("http://[::1]:5173/app?mode=test"); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it("leaves malformed input for the normal navigation error path", async () => { | ||
| 78 | const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); | ||
| 79 | expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); | ||
| 80 | }); | ||
| 81 | }); | ||
apps/web/src/browser/browserTargetResolver.ts created+81| ... | @@ -0,0 +1,81 @@ | ||
| 1 | import type { | ||
| 2 | BrowserNavigationTarget, | ||
| 3 | EnvironmentId, | ||
| 4 | PreviewUrlResolution, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; | ||
| 7 | |||
| 8 | import { readEnvironmentConnection } from "~/environments/runtime"; | ||
| 9 | |||
| 10 | const isPrivateNetworkHost = (host: string): boolean => { | ||
| 11 | const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); | ||
| 12 | if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { | ||
| 13 | return true; | ||
| 14 | } | ||
| 15 | if (normalized.endsWith(".ts.net")) return true; | ||
| 16 | const parts = normalized.split(".").map(Number); | ||
| 17 | if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; | ||
| 18 | return ( | ||
| 19 | parts[0] === 10 || | ||
| 20 | (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || | ||
| 21 | (parts[0] === 192 && parts[1] === 168) || | ||
| 22 | parts[0] === 127 || | ||
| 23 | (parts[0] === 169 && parts[1] === 254) | ||
| 24 | ); | ||
| 25 | }; | ||
| 26 | |||
| 27 | export function resolveBrowserNavigationTarget( | ||
| 28 | environmentId: EnvironmentId, | ||
| 29 | target: BrowserNavigationTarget, | ||
| 30 | ): PreviewUrlResolution { | ||
| 31 | if (target.kind === "url") { | ||
| 32 | return { | ||
| 33 | requestedUrl: target.url, | ||
| 34 | resolvedUrl: target.url, | ||
| 35 | resolutionKind: "direct", | ||
| 36 | environmentId, | ||
| 37 | }; | ||
| 38 | } | ||
| 39 | const connection = readEnvironmentConnection(environmentId); | ||
| 40 | if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); | ||
| 41 | const environmentUrl = new URL(connection.knownEnvironment.target.httpBaseUrl); | ||
| 42 | if (!isPrivateNetworkHost(environmentUrl.hostname)) { | ||
| 43 | throw new Error( | ||
| 44 | "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | const protocol = target.protocol ?? "http"; | ||
| 48 | const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; | ||
| 49 | const requestedUrl = `${protocol}://localhost:${target.port}${path}`; | ||
| 50 | const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); | ||
| 51 | const resolvedHost = normalizedEnvironmentHost.includes(":") | ||
| 52 | ? `[${normalizedEnvironmentHost}]` | ||
| 53 | : normalizedEnvironmentHost; | ||
| 54 | const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); | ||
| 55 | return { | ||
| 56 | requestedUrl, | ||
| 57 | resolvedUrl: resolved.toString(), | ||
| 58 | resolutionKind: | ||
| 59 | normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" | ||
| 60 | ? "direct" | ||
| 61 | : "direct-private-network", | ||
| 62 | environmentId, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { | ||
| 67 | try { | ||
| 68 | const normalizedUrl = normalizePreviewUrl(rawUrl); | ||
| 69 | const parsed = new URL(normalizedUrl); | ||
| 70 | if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; | ||
| 71 | const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); | ||
| 72 | return resolveBrowserNavigationTarget(environmentId, { | ||
| 73 | kind: "environment-port", | ||
| 74 | port, | ||
| 75 | protocol: parsed.protocol === "https:" ? "https" : "http", | ||
| 76 | path: `${parsed.pathname}${parsed.search}${parsed.hash}`, | ||
| 77 | }).resolvedUrl; | ||
| 78 | } catch { | ||
| 79 | return rawUrl; | ||
| 80 | } | ||
| 81 | } | ||
apps/web/src/browser/desktopTabLifetime.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import { previewBridge } from "~/components/preview/previewBridge"; | ||
| 2 | |||
| 3 | interface DesktopTabLease { | ||
| 4 | references: number; | ||
| 5 | closeTimer: number | null; | ||
| 6 | } | ||
| 7 | |||
| 8 | const leases = new Map<string, DesktopTabLease>(); | ||
| 9 | |||
| 10 | export function acquireDesktopTab(tabId: string): () => void { | ||
| 11 | const current = leases.get(tabId) ?? { references: 0, closeTimer: null }; | ||
| 12 | if (current.closeTimer !== null) window.clearTimeout(current.closeTimer); | ||
| 13 | current.references += 1; | ||
| 14 | current.closeTimer = null; | ||
| 15 | leases.set(tabId, current); | ||
| 16 | if (current.references === 1) void previewBridge?.createTab(tabId); | ||
| 17 | |||
| 18 | return () => { | ||
| 19 | const lease = leases.get(tabId); | ||
| 20 | if (!lease) return; | ||
| 21 | lease.references = Math.max(0, lease.references - 1); | ||
| 22 | if (lease.references > 0) return; | ||
| 23 | lease.closeTimer = window.setTimeout(() => { | ||
| 24 | const latest = leases.get(tabId); | ||
| 25 | if (!latest || latest.references > 0) return; | ||
| 26 | leases.delete(tabId); | ||
| 27 | void previewBridge?.closeTab(tabId); | ||
| 28 | }, 0); | ||
| 29 | }; | ||
| 30 | } | ||
apps/web/src/components/ChatView.tsx+507-437| ... | @@ -21,12 +21,7 @@ import { | ... | @@ -21,12 +21,7 @@ import { |
| 21 | RuntimeMode, | 21 | RuntimeMode, |
| 22 | TerminalOpenInput, | 22 | TerminalOpenInput, |
| 23 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 24 | import { | 24 | import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 25 | parseScopedThreadKey, | ||
| 26 | scopedThreadKey, | ||
| 27 | scopeProjectRef, | ||
| 28 | scopeThreadRef, | ||
| 29 | } from "@t3tools/client-runtime"; | ||
| 30 | import { | 25 | import { |
| 31 | applyClaudePromptEffortPrefix, | 26 | applyClaudePromptEffortPrefix, |
| 32 | createModelSelection, | 27 | createModelSelection, |
| ... | @@ -43,6 +38,7 @@ import { useVcsStatus } from "~/lib/vcsStatusState"; | ... | @@ -43,6 +38,7 @@ import { useVcsStatus } from "~/lib/vcsStatusState"; |
| 43 | import { usePrimaryEnvironmentId } from "../environments/primary"; | 38 | import { usePrimaryEnvironmentId } from "../environments/primary"; |
| 44 | import { readEnvironmentApi } from "../environmentApi"; | 39 | import { readEnvironmentApi } from "../environmentApi"; |
| 45 | import { isElectron } from "../env"; | 40 | import { isElectron } from "../env"; |
| 41 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; | ||
| 46 | import { readLocalApi } from "../localApi"; | 42 | import { readLocalApi } from "../localApi"; |
| 47 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 43 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 48 | import { | 44 | import { |
| ... | @@ -70,11 +66,7 @@ import { | ... | @@ -70,11 +66,7 @@ import { |
| 70 | togglePendingUserInputOptionSelection, | 66 | togglePendingUserInputOptionSelection, |
| 71 | type PendingUserInputDraftAnswer, | 67 | type PendingUserInputDraftAnswer, |
| 72 | } from "../pendingUserInput"; | 68 | } from "../pendingUserInput"; |
| 73 | import { | 69 | import { selectProjectsAcrossEnvironments, useStore } from "../store"; |
| 74 | selectProjectsAcrossEnvironments, | ||
| 75 | selectThreadsAcrossEnvironments, | ||
| 76 | useStore, | ||
| 77 | } from "../store"; | ||
| 78 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | 70 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; |
| 79 | import { useUiStateStore } from "../uiStateStore"; | 71 | import { useUiStateStore } from "../uiStateStore"; |
| 80 | import { | 72 | import { |
| ... | @@ -98,20 +90,32 @@ import { useCommandPaletteStore } from "../commandPaletteStore"; | ... | @@ -98,20 +90,32 @@ import { useCommandPaletteStore } from "../commandPaletteStore"; |
| 98 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; | 90 | import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 99 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 91 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 100 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; | 92 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; |
| 101 | import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore"; | 93 | import { |
| 102 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; | 94 | selectActiveRightPanelKindWithUrl, |
| 103 | import { isPreviewSupportedInRuntime, usePreviewStateStore } from "../previewStateStore"; | 95 | selectActiveRightPanelSurface, |
| 96 | selectThreadRightPanelState, | ||
| 97 | type RightPanelSurface, | ||
| 98 | useRightPanelStore, | ||
| 99 | } from "../rightPanelStore"; | ||
| 100 | import { | ||
| 101 | isPreviewSupportedInRuntime, | ||
| 102 | selectThreadPreviewState, | ||
| 103 | usePreviewStateStore, | ||
| 104 | } from "../previewStateStore"; | ||
| 104 | import { subscribePreviewAction } from "./preview/previewActionBus"; | 105 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 105 | // Lazy: keeps the entire preview component graph (webview host, favicon | 106 | // Lazy: keeps the entire preview component graph (webview host, favicon |
| 106 | // helper, Chromium error icon) out of the web bundle until first open. | 107 | // helper, Chromium error icon) out of the web bundle until first open. |
| 107 | const PreviewPanel = lazy(() => | 108 | const PreviewPanel = lazy(() => |
| 108 | import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), | 109 | import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), |
| 109 | ); | 110 | ); |
| 111 | const DiffPanel = lazy(() => import("./DiffPanel")); | ||
| 110 | import { BranchToolbar } from "./BranchToolbar"; | 112 | import { BranchToolbar } from "./BranchToolbar"; |
| 111 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 113 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 112 | import PlanSidebar from "./PlanSidebar"; | 114 | import PlanSidebar from "./PlanSidebar"; |
| 113 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 115 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 114 | import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; | 116 | import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; |
| 117 | import { RightPanelTabs } from "./RightPanelTabs"; | ||
| 118 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | ||
| 115 | import { cn, randomHex } from "~/lib/utils"; | 119 | import { cn, randomHex } from "~/lib/utils"; |
| 116 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 120 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 117 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | 121 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| ... | @@ -167,8 +171,6 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; | ... | @@ -167,8 +171,6 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| 167 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; | 171 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 168 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | 172 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 169 | import { | 173 | import { |
| 170 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | ||
| 171 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | ||
| 172 | buildExpiredTerminalContextToastCopy, | 174 | buildExpiredTerminalContextToastCopy, |
| 173 | buildLocalDraftThread, | 175 | buildLocalDraftThread, |
| 174 | collectUserMessageBlobPreviewUrls, | 176 | collectUserMessageBlobPreviewUrls, |
| ... | @@ -183,8 +185,6 @@ import { | ... | @@ -183,8 +185,6 @@ import { |
| 183 | cloneComposerImageForRetry, | 185 | cloneComposerImageForRetry, |
| 184 | deriveLockedProvider, | 186 | deriveLockedProvider, |
| 185 | readFileAsDataUrl, | 187 | readFileAsDataUrl, |
| 186 | reconcileMountedTerminalThreadIds, | ||
| 187 | reconcileRetainedMountedThreadIds, | ||
| 188 | resolveSendEnvMode, | 188 | resolveSendEnvMode, |
| 189 | revokeBlobPreviewUrl, | 189 | revokeBlobPreviewUrl, |
| 190 | revokeUserMessagePreviewUrls, | 190 | revokeUserMessagePreviewUrls, |
| ... | @@ -535,6 +535,7 @@ interface PersistentThreadTerminalDrawerProps { | ... | @@ -535,6 +535,7 @@ interface PersistentThreadTerminalDrawerProps { |
| 535 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; | 535 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; |
| 536 | threadId: ThreadId; | 536 | threadId: ThreadId; |
| 537 | visible: boolean; | 537 | visible: boolean; |
| 538 | mode?: "drawer" | "panel"; | ||
| 538 | launchContext: PersistentTerminalLaunchContext | null; | 539 | launchContext: PersistentTerminalLaunchContext | null; |
| 539 | focusRequestId: number; | 540 | focusRequestId: number; |
| 540 | splitShortcutLabel: string | undefined; | 541 | splitShortcutLabel: string | undefined; |
| ... | @@ -548,6 +549,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -548,6 +549,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 548 | threadRef, | 549 | threadRef, |
| 549 | threadId, | 550 | threadId, |
| 550 | visible, | 551 | visible, |
| 552 | mode = "drawer", | ||
| 551 | launchContext, | 553 | launchContext, |
| 552 | focusRequestId, | 554 | focusRequestId, |
| 553 | splitShortcutLabel, | 555 | splitShortcutLabel, |
| ... | @@ -795,8 +797,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -795,8 +797,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 795 | } | 797 | } |
| 796 | 798 | ||
| 797 | return ( | 799 | return ( |
| 798 | <div className={visible ? undefined : "hidden"}> | 800 | <div className={cn(visible ? undefined : "hidden", mode === "panel" && "h-full min-h-0")}> |
| 799 | <ThreadTerminalDrawer | 801 | <ThreadTerminalDrawer |
| 802 | mode={mode} | ||
| 800 | threadRef={threadRef} | 803 | threadRef={threadRef} |
| 801 | threadId={threadId} | 804 | threadId={threadId} |
| 802 | cwd={cwd} | 805 | cwd={cwd} |
| ... | @@ -962,59 +965,11 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -962,59 +965,11 @@ export default function ChatView(props: ChatViewProps) { |
| 962 | const terminalUiState = useTerminalUiStateStore((state) => | 965 | const terminalUiState = useTerminalUiStateStore((state) => |
| 963 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), | 966 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), |
| 964 | ); | 967 | ); |
| 965 | const openTerminalThreadKeys = useTerminalUiStateStore( | ||
| 966 | useShallow((state) => | ||
| 967 | Object.entries(state.terminalUiStateByThreadKey).flatMap( | ||
| 968 | ([nextThreadKey, nextTerminalUiState]) => | ||
| 969 | nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], | ||
| 970 | ), | ||
| 971 | ), | ||
| 972 | ); | ||
| 973 | const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); | 968 | const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); |
| 974 | const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); | 969 | const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); |
| 975 | const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); | 970 | const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); |
| 976 | const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); | 971 | const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); |
| 977 | const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); | 972 | const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); |
| 978 | const serverThreadKeys = useStore( | ||
| 979 | useShallow((state) => | ||
| 980 | selectThreadsAcrossEnvironments(state).map((thread) => | ||
| 981 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 982 | ), | ||
| 983 | ), | ||
| 984 | ); | ||
| 985 | const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); | ||
| 986 | const draftThreadKeys = useMemo( | ||
| 987 | () => | ||
| 988 | Object.values(draftThreadsByThreadKey).map((draftThread) => | ||
| 989 | scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), | ||
| 990 | ), | ||
| 991 | [draftThreadsByThreadKey], | ||
| 992 | ); | ||
| 993 | const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState<string[]>([]); | ||
| 994 | const mountedTerminalThreadRefs = useMemo( | ||
| 995 | () => | ||
| 996 | mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { | ||
| 997 | const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); | ||
| 998 | return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; | ||
| 999 | }), | ||
| 1000 | [mountedTerminalThreadKeys], | ||
| 1001 | ); | ||
| 1002 | const previewSessionThreadKeys = usePreviewStateStore( | ||
| 1003 | useShallow((state) => | ||
| 1004 | Object.entries(state.byThreadKey).flatMap(([nextThreadKey, nextPreviewState]) => | ||
| 1005 | nextPreviewState.snapshot ? [nextThreadKey] : [], | ||
| 1006 | ), | ||
| 1007 | ), | ||
| 1008 | ); | ||
| 1009 | const [mountedPreviewThreadKeys, setMountedPreviewThreadKeys] = useState<string[]>([]); | ||
| 1010 | const mountedPreviewThreadRefs = useMemo( | ||
| 1011 | () => | ||
| 1012 | mountedPreviewThreadKeys.flatMap((mountedThreadKey) => { | ||
| 1013 | const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); | ||
| 1014 | return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; | ||
| 1015 | }), | ||
| 1016 | [mountedPreviewThreadKeys], | ||
| 1017 | ); | ||
| 1018 | 973 | ||
| 1019 | const fallbackDraftProjectRef = draftThread | 974 | const fallbackDraftProjectRef = draftThread |
| 1020 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | 975 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| ... | @@ -1105,18 +1060,40 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1105,18 +1060,40 @@ export default function ChatView(props: ChatViewProps) { |
| 1105 | const activeRightPanelKind = useRightPanelStore((store) => | 1060 | const activeRightPanelKind = useRightPanelStore((store) => |
| 1106 | selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), | 1061 | selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), |
| 1107 | ); | 1062 | ); |
| 1063 | const rightPanelState = useRightPanelStore((store) => | ||
| 1064 | selectThreadRightPanelState(store.byThreadKey, activeThreadRef), | ||
| 1065 | ); | ||
| 1066 | const activeRightPanelSurface = useRightPanelStore((store) => | ||
| 1067 | selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), | ||
| 1068 | ); | ||
| 1069 | const activePreviewState = usePreviewStateStore((state) => | ||
| 1070 | selectThreadPreviewState(state.byThreadKey, activeThreadRef), | ||
| 1071 | ); | ||
| 1108 | const planSidebarOpen = activeRightPanelKind === "plan"; | 1072 | const planSidebarOpen = activeRightPanelKind === "plan"; |
| 1109 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); | 1073 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); |
| 1110 | const existingOpenTerminalThreadKeys = useMemo(() => { | 1074 | const terminalPanelOpen = activeRightPanelKind === "terminal"; |
| 1111 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | 1075 | const rightPanelOpen = activeRightPanelSurface !== null; |
| 1112 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); | 1076 | |
| 1113 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); | 1077 | useEffect(() => { |
| 1114 | const existingPreviewSessionThreadKeys = useMemo(() => { | 1078 | if (!activeThreadRef) return; |
| 1115 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | 1079 | useRightPanelStore |
| 1116 | return previewSessionThreadKeys.filter((nextThreadKey) => | 1080 | .getState() |
| 1117 | existingThreadKeys.has(nextThreadKey), | 1081 | .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); |
| 1082 | }, [activePreviewState.sessions, activeThreadRef]); | ||
| 1083 | |||
| 1084 | useEffect(() => { | ||
| 1085 | if (!activeThreadRef || !diffOpen) return; | ||
| 1086 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | ||
| 1087 | }, [activeThreadRef, diffOpen]); | ||
| 1088 | useEffect(() => { | ||
| 1089 | if (!activeThreadRef || !terminalUiState.terminalOpen) return; | ||
| 1090 | const state = selectThreadRightPanelState( | ||
| 1091 | useRightPanelStore.getState().byThreadKey, | ||
| 1092 | activeThreadRef, | ||
| 1118 | ); | 1093 | ); |
| 1119 | }, [draftThreadKeys, previewSessionThreadKeys, serverThreadKeys]); | 1094 | if (state.surfaces.some((surface) => surface.kind === "terminal")) return; |
| 1095 | useRightPanelStore.getState().open(activeThreadRef, "terminal"); | ||
| 1096 | }, [activeThreadRef, terminalUiState.terminalOpen]); | ||
| 1120 | const activeLatestTurn = activeThread?.latestTurn ?? null; | 1097 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 1121 | const threadPlanCatalog = useThreadPlanCatalog( | 1098 | const threadPlanCatalog = useThreadPlanCatalog( |
| 1122 | useMemo(() => { | 1099 | useMemo(() => { |
| ... | @@ -1131,42 +1108,6 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1131,42 +1108,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1131 | return threadIds; | 1108 | return threadIds; |
| 1132 | }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), | 1109 | }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), |
| 1133 | ); | 1110 | ); |
| 1134 | useEffect(() => { | ||
| 1135 | setMountedTerminalThreadKeys((currentThreadIds) => { | ||
| 1136 | const nextThreadIds = reconcileMountedTerminalThreadIds({ | ||
| 1137 | currentThreadIds, | ||
| 1138 | openThreadIds: existingOpenTerminalThreadKeys, | ||
| 1139 | activeThreadId: activeThreadKey, | ||
| 1140 | activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), | ||
| 1141 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | ||
| 1142 | }); | ||
| 1143 | return currentThreadIds.length === nextThreadIds.length && | ||
| 1144 | currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) | ||
| 1145 | ? currentThreadIds | ||
| 1146 | : nextThreadIds; | ||
| 1147 | }); | ||
| 1148 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); | ||
| 1149 | useEffect(() => { | ||
| 1150 | setMountedPreviewThreadKeys((currentThreadIds) => { | ||
| 1151 | const nextThreadIds = reconcileRetainedMountedThreadIds({ | ||
| 1152 | currentThreadIds, | ||
| 1153 | openThreadIds: existingPreviewSessionThreadKeys, | ||
| 1154 | activeThreadId: activeThreadKey, | ||
| 1155 | activeThreadOpen: Boolean(activeThreadKey && !shouldUsePlanSidebarSheet), | ||
| 1156 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | ||
| 1157 | retainInactiveActiveThread: true, | ||
| 1158 | }); | ||
| 1159 | return currentThreadIds.length === nextThreadIds.length && | ||
| 1160 | currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) | ||
| 1161 | ? currentThreadIds | ||
| 1162 | : nextThreadIds; | ||
| 1163 | }); | ||
| 1164 | }, [ | ||
| 1165 | activeThreadKey, | ||
| 1166 | existingPreviewSessionThreadKeys, | ||
| 1167 | previewPanelOpen, | ||
| 1168 | shouldUsePlanSidebarSheet, | ||
| 1169 | ]); | ||
| 1170 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 1111 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 1171 | const activeProjectRef = activeThread | 1112 | const activeProjectRef = activeThread |
| 1172 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) | 1113 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) |
| ... | @@ -1961,19 +1902,6 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1961,19 +1902,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1961 | }), | 1902 | }), |
| 1962 | [terminalUiState.terminalOpen], | 1903 | [terminalUiState.terminalOpen], |
| 1963 | ); | 1904 | ); |
| 1964 | const nonTerminalShortcutLabelOptions = useMemo( | ||
| 1965 | () => ({ | ||
| 1966 | context: { | ||
| 1967 | terminalFocus: false, | ||
| 1968 | terminalOpen: Boolean(terminalUiState.terminalOpen), | ||
| 1969 | }, | ||
| 1970 | }), | ||
| 1971 | [terminalUiState.terminalOpen], | ||
| 1972 | ); | ||
| 1973 | const terminalToggleShortcutLabel = useMemo( | ||
| 1974 | () => shortcutLabelForCommand(keybindings, "terminal.toggle"), | ||
| 1975 | [keybindings], | ||
| 1976 | ); | ||
| 1977 | const splitTerminalShortcutLabel = useMemo( | 1905 | const splitTerminalShortcutLabel = useMemo( |
| 1978 | () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), | 1906 | () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), |
| 1979 | [keybindings, terminalShortcutLabelOptions], | 1907 | [keybindings, terminalShortcutLabelOptions], |
| ... | @@ -1986,14 +1914,37 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1986,14 +1914,37 @@ export default function ChatView(props: ChatViewProps) { |
| 1986 | () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), | 1914 | () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), |
| 1987 | [keybindings, terminalShortcutLabelOptions], | 1915 | [keybindings, terminalShortcutLabelOptions], |
| 1988 | ); | 1916 | ); |
| 1989 | const diffPanelShortcutLabel = useMemo( | 1917 | const createBrowserSurface = useCallback(() => { |
| 1990 | () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), | 1918 | if (!activeThreadRef) return; |
| 1991 | [keybindings, nonTerminalShortcutLabelOptions], | 1919 | const api = readEnvironmentApi(activeThreadRef.environmentId); |
| 1992 | ); | 1920 | if (!api) return; |
| 1993 | const previewPanelShortcutLabel = useMemo( | 1921 | void api.preview |
| 1994 | () => shortcutLabelForCommand(keybindings, "preview.toggle", nonTerminalShortcutLabelOptions), | 1922 | .open({ threadId: activeThreadRef.threadId }) |
| 1995 | [keybindings, nonTerminalShortcutLabelOptions], | 1923 | .then((snapshot) => { |
| 1996 | ); | 1924 | usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot); |
| 1925 | useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId); | ||
| 1926 | }) | ||
| 1927 | .catch(() => undefined); | ||
| 1928 | }, [activeThreadRef]); | ||
| 1929 | const addDiffSurface = useCallback(() => { | ||
| 1930 | if (!activeThreadRef || !isServerThread || !isGitRepo) return; | ||
| 1931 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | ||
| 1932 | onDiffPanelOpen?.(); | ||
| 1933 | void navigate({ | ||
| 1934 | to: "/$environmentId/$threadId", | ||
| 1935 | params: { environmentId, threadId }, | ||
| 1936 | replace: true, | ||
| 1937 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), | ||
| 1938 | }); | ||
| 1939 | }, [ | ||
| 1940 | activeThreadRef, | ||
| 1941 | environmentId, | ||
| 1942 | isGitRepo, | ||
| 1943 | isServerThread, | ||
| 1944 | navigate, | ||
| 1945 | onDiffPanelOpen, | ||
| 1946 | threadId, | ||
| 1947 | ]); | ||
| 1997 | // Right-panel arbitration: | 1948 | // Right-panel arbitration: |
| 1998 | // - The diff panel's openness is mirrored by the `?diff=1` URL search | 1949 | // - The diff panel's openness is mirrored by the `?diff=1` URL search |
| 1999 | // param so it deep-links cleanly. The store still records preview/plan | 1950 | // param so it deep-links cleanly. The store still records preview/plan |
| ... | @@ -2017,20 +1968,33 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2017,20 +1968,33 @@ export default function ChatView(props: ChatViewProps) { |
| 2017 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), | 1968 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), |
| 2018 | }); | 1969 | }); |
| 2019 | } | 1970 | } |
| 2020 | useRightPanelStore.getState().open(activeThreadRef, "preview"); | 1971 | const activeTabId = activePreviewState.activeTabId; |
| 2021 | }, [activeThreadRef, diffOpen, environmentId, navigate, previewPanelOpen, threadId]); | 1972 | if (!activeTabId) { |
| 1973 | createBrowserSurface(); | ||
| 1974 | return; | ||
| 1975 | } | ||
| 1976 | useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); | ||
| 1977 | }, [ | ||
| 1978 | activePreviewState.activeTabId, | ||
| 1979 | activeThreadRef, | ||
| 1980 | createBrowserSurface, | ||
| 1981 | diffOpen, | ||
| 1982 | environmentId, | ||
| 1983 | navigate, | ||
| 1984 | previewPanelOpen, | ||
| 1985 | threadId, | ||
| 1986 | ]); | ||
| 2022 | const onToggleDiff = useCallback(() => { | 1987 | const onToggleDiff = useCallback(() => { |
| 2023 | if (!isServerThread) { | 1988 | if (!isServerThread) { |
| 2024 | return; | 1989 | return; |
| 2025 | } | 1990 | } |
| 2026 | if (!diffOpen) { | 1991 | if (!diffOpen) { |
| 2027 | onDiffPanelOpen?.(); | 1992 | onDiffPanelOpen?.(); |
| 2028 | // Switching to diff: drop whatever the store had (e.g. preview) so | ||
| 2029 | // that when the user later closes diff, an old store entry doesn't | ||
| 2030 | // unexpectedly resurface. | ||
| 2031 | if (activeThreadRef) { | 1993 | if (activeThreadRef) { |
| 2032 | useRightPanelStore.getState().close(activeThreadRef); | 1994 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| 2033 | } | 1995 | } |
| 1996 | } else if (activeThreadRef) { | ||
| 1997 | useRightPanelStore.getState().closeSurface(activeThreadRef, "diff"); | ||
| 2034 | } | 1998 | } |
| 2035 | void navigate({ | 1999 | void navigate({ |
| 2036 | to: "/$environmentId/$threadId", | 2000 | to: "/$environmentId/$threadId", |
| ... | @@ -2147,8 +2111,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2147,8 +2111,13 @@ export default function ChatView(props: ChatViewProps) { |
| 2147 | ); | 2111 | ); |
| 2148 | const toggleTerminalVisibility = useCallback(() => { | 2112 | const toggleTerminalVisibility = useCallback(() => { |
| 2149 | if (!activeThreadRef) return; | 2113 | if (!activeThreadRef) return; |
| 2150 | setTerminalOpen(!terminalUiState.terminalOpen); | 2114 | if (terminalPanelOpen) { |
| 2151 | }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); | 2115 | useRightPanelStore.getState().close(activeThreadRef); |
| 2116 | return; | ||
| 2117 | } | ||
| 2118 | setTerminalOpen(true); | ||
| 2119 | useRightPanelStore.getState().open(activeThreadRef, "terminal"); | ||
| 2120 | }, [activeThreadRef, setTerminalOpen, terminalPanelOpen]); | ||
| 2152 | const splitTerminal = useCallback(() => { | 2121 | const splitTerminal = useCallback(() => { |
| 2153 | if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { | 2122 | if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { |
| 2154 | return; | 2123 | return; |
| ... | @@ -2348,8 +2317,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2348,8 +2317,12 @@ export default function ChatView(props: ChatViewProps) { |
| 2348 | activeThreadRef | 2317 | activeThreadRef |
| 2349 | ) { | 2318 | ) { |
| 2350 | try { | 2319 | try { |
| 2351 | await api.preview.open({ threadId: activeThreadId, url: script.previewUrl }); | 2320 | const snapshot = await api.preview.open({ |
| 2352 | useRightPanelStore.getState().open(activeThreadRef, "preview"); | 2321 | threadId: activeThreadId, |
| 2322 | url: script.previewUrl, | ||
| 2323 | }); | ||
| 2324 | usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot); | ||
| 2325 | useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId); | ||
| 2353 | } catch { | 2326 | } catch { |
| 2354 | // Preview open failures are surfaced via the panel itself. | 2327 | // Preview open failures are surfaced via the panel itself. |
| 2355 | } | 2328 | } |
| ... | @@ -2579,8 +2552,96 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2579,8 +2552,96 @@ export default function ChatView(props: ChatViewProps) { |
| 2579 | const closePreviewPanel = useCallback(() => { | 2552 | const closePreviewPanel = useCallback(() => { |
| 2580 | if (!activeThreadRef) return; | 2553 | if (!activeThreadRef) return; |
| 2581 | useRightPanelStore.getState().close(activeThreadRef); | 2554 | useRightPanelStore.getState().close(activeThreadRef); |
| 2582 | }, [activeThreadRef]); | 2555 | if (diffOpen) { |
| 2583 | 2556 | void navigate({ | |
| 2557 | to: "/$environmentId/$threadId", | ||
| 2558 | params: { environmentId, threadId }, | ||
| 2559 | replace: true, | ||
| 2560 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), | ||
| 2561 | }); | ||
| 2562 | } | ||
| 2563 | }, [activeThreadRef, diffOpen, environmentId, navigate, threadId]); | ||
| 2564 | const activateRightPanelSurface = useCallback( | ||
| 2565 | (surface: RightPanelSurface) => { | ||
| 2566 | if (!activeThreadRef) return; | ||
| 2567 | useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); | ||
| 2568 | if (surface.kind === "preview" && surface.resourceId) { | ||
| 2569 | usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); | ||
| 2570 | } | ||
| 2571 | if (surface.kind === "terminal") { | ||
| 2572 | setTerminalOpen(true); | ||
| 2573 | } | ||
| 2574 | if (surface.kind === "diff" && !diffOpen) { | ||
| 2575 | onDiffPanelOpen?.(); | ||
| 2576 | void navigate({ | ||
| 2577 | to: "/$environmentId/$threadId", | ||
| 2578 | params: { environmentId, threadId }, | ||
| 2579 | replace: true, | ||
| 2580 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }), | ||
| 2581 | }); | ||
| 2582 | } else if (surface.kind !== "diff" && diffOpen) { | ||
| 2583 | void navigate({ | ||
| 2584 | to: "/$environmentId/$threadId", | ||
| 2585 | params: { environmentId, threadId }, | ||
| 2586 | replace: true, | ||
| 2587 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), | ||
| 2588 | }); | ||
| 2589 | } | ||
| 2590 | }, | ||
| 2591 | [ | ||
| 2592 | activeThreadRef, | ||
| 2593 | diffOpen, | ||
| 2594 | environmentId, | ||
| 2595 | navigate, | ||
| 2596 | onDiffPanelOpen, | ||
| 2597 | setTerminalOpen, | ||
| 2598 | threadId, | ||
| 2599 | ], | ||
| 2600 | ); | ||
| 2601 | const toggleRightPanel = useCallback(() => { | ||
| 2602 | if (!activeThreadRef) return; | ||
| 2603 | if (rightPanelOpen) { | ||
| 2604 | closePreviewPanel(); | ||
| 2605 | return; | ||
| 2606 | } | ||
| 2607 | const surface = rightPanelState.surfaces.at(-1) ?? null; | ||
| 2608 | if (surface) { | ||
| 2609 | activateRightPanelSurface(surface); | ||
| 2610 | return; | ||
| 2611 | } | ||
| 2612 | createBrowserSurface(); | ||
| 2613 | }, [ | ||
| 2614 | activeThreadRef, | ||
| 2615 | activateRightPanelSurface, | ||
| 2616 | closePreviewPanel, | ||
| 2617 | createBrowserSurface, | ||
| 2618 | rightPanelOpen, | ||
| 2619 | rightPanelState.surfaces, | ||
| 2620 | ]); | ||
| 2621 | const closeRightPanelSurface = useCallback( | ||
| 2622 | (surface: RightPanelSurface) => { | ||
| 2623 | if (!activeThreadRef) return; | ||
| 2624 | useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); | ||
| 2625 | if (surface.kind === "preview" && surface.resourceId) { | ||
| 2626 | const api = readEnvironmentApi(activeThreadRef.environmentId); | ||
| 2627 | void api?.preview | ||
| 2628 | .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) | ||
| 2629 | .catch(() => undefined); | ||
| 2630 | } | ||
| 2631 | if (surface.kind === "terminal") { | ||
| 2632 | setTerminalOpen(false); | ||
| 2633 | } | ||
| 2634 | if (surface.kind === "diff" && diffOpen) { | ||
| 2635 | void navigate({ | ||
| 2636 | to: "/$environmentId/$threadId", | ||
| 2637 | params: { environmentId, threadId }, | ||
| 2638 | replace: true, | ||
| 2639 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), | ||
| 2640 | }); | ||
| 2641 | } | ||
| 2642 | }, | ||
| 2643 | [activeThreadRef, diffOpen, environmentId, navigate, setTerminalOpen, threadId], | ||
| 2644 | ); | ||
| 2584 | const persistThreadSettingsForNextTurn = useCallback( | 2645 | const persistThreadSettingsForNextTurn = useCallback( |
| 2585 | async (input: { | 2646 | async (input: { |
| 2586 | threadId: ThreadId; | 2647 | threadId: ThreadId; |
| ... | @@ -3952,314 +4013,323 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3952,314 +4013,323 @@ export default function ChatView(props: ChatViewProps) { |
| 3952 | } | 4013 | } |
| 3953 | 4014 | ||
| 3954 | return ( | 4015 | return ( |
| 3955 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> | 4016 | <div className="flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"> |
| 3956 | {/* Top bar */} | 4017 | {isElectron && activeThreadRef ? ( |
| 3957 | <header | 4018 | <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} /> |
| 3958 | className={cn( | 4019 | ) : null} |
| 3959 | "border-b border-border", | 4020 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden"> |
| 3960 | isElectron | 4021 | {/* Top bar */} |
| 3961 | ? cn( | 4022 | <header |
| 3962 | "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]", | 4023 | className={cn( |
| 3963 | reserveTitleBarControlInset && | 4024 | "border-b border-border", |
| 3964 | "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", | 4025 | isElectron |
| 3965 | ) | 4026 | ? cn( |
| 3966 | : "pb-2 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-2 sm:pb-3 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-3", | 4027 | "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]", |
| 3967 | )} | 4028 | reserveTitleBarControlInset && |
| 3968 | > | 4029 | "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", |
| 3969 | <ChatHeader | 4030 | ) |
| 3970 | activeThreadEnvironmentId={activeThread.environmentId} | 4031 | : "pb-2 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-2 sm:pb-3 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-3", |
| 3971 | activeThreadId={activeThread.id} | 4032 | )} |
| 3972 | {...(routeKind === "draft" && draftId ? { draftId } : {})} | 4033 | > |
| 3973 | activeThreadTitle={activeThread.title} | 4034 | <ChatHeader |
| 3974 | activeProjectName={activeProject?.name} | 4035 | activeThreadEnvironmentId={activeThread.environmentId} |
| 3975 | isGitRepo={isGitRepo} | 4036 | activeThreadId={activeThread.id} |
| 3976 | openInCwd={gitCwd} | 4037 | {...(routeKind === "draft" && draftId ? { draftId } : {})} |
| 3977 | activeProjectScripts={activeProject?.scripts} | 4038 | activeThreadTitle={activeThread.title} |
| 3978 | preferredScriptId={ | 4039 | activeProjectName={activeProject?.name} |
| 3979 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null | 4040 | openInCwd={gitCwd} |
| 3980 | } | 4041 | activeProjectScripts={activeProject?.scripts} |
| 3981 | keybindings={keybindings} | 4042 | preferredScriptId={ |
| 3982 | availableEditors={availableEditors} | 4043 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null |
| 3983 | terminalAvailable={activeProject !== undefined} | 4044 | } |
| 3984 | terminalOpen={terminalUiState.terminalOpen} | 4045 | keybindings={keybindings} |
| 3985 | terminalToggleShortcutLabel={terminalToggleShortcutLabel} | 4046 | availableEditors={availableEditors} |
| 3986 | diffToggleShortcutLabel={diffPanelShortcutLabel} | 4047 | rightPanelAvailable={isPreviewSupportedInRuntime() || isGitRepo} |
| 3987 | previewAvailable={isPreviewSupportedInRuntime()} | 4048 | rightPanelOpen={rightPanelOpen} |
| 3988 | previewOpen={previewPanelOpen} | 4049 | gitCwd={gitCwd} |
| 3989 | previewToggleShortcutLabel={previewPanelShortcutLabel} | 4050 | onRunProjectScript={runProjectScript} |
| 3990 | gitCwd={gitCwd} | 4051 | onAddProjectScript={saveProjectScript} |
| 3991 | diffOpen={diffOpen} | 4052 | onUpdateProjectScript={updateProjectScript} |
| 3992 | onRunProjectScript={runProjectScript} | 4053 | onDeleteProjectScript={deleteProjectScript} |
| 3993 | onAddProjectScript={saveProjectScript} | 4054 | onToggleRightPanel={toggleRightPanel} |
| 3994 | onUpdateProjectScript={updateProjectScript} | 4055 | /> |
| 3995 | onDeleteProjectScript={deleteProjectScript} | 4056 | </header> |
| 3996 | onToggleTerminal={toggleTerminalVisibility} | 4057 | |
| 3997 | onTogglePreview={onTogglePreview} | 4058 | {/* Error banner */} |
| 3998 | onToggleDiff={onToggleDiff} | 4059 | <ProviderStatusBanner status={activeProviderStatus} /> |
| 4060 | <ThreadErrorBanner | ||
| 4061 | error={activeThread.error} | ||
| 4062 | onDismiss={() => setThreadError(activeThread.id, null)} | ||
| 3999 | /> | 4063 | /> |
| 4000 | </header> | 4064 | {/* Main content area with optional plan sidebar */} |
| 4065 | <div className="flex min-h-0 min-w-0 flex-1"> | ||
| 4066 | {/* Chat column */} | ||
| 4067 | <div className="flex min-h-0 min-w-0 flex-1 flex-col"> | ||
| 4068 | {/* Messages Wrapper */} | ||
| 4069 | <div className="relative flex min-h-0 flex-1 flex-col"> | ||
| 4070 | {/* Messages — LegendList handles virtualization and scrolling internally */} | ||
| 4071 | <MessagesTimeline | ||
| 4072 | key={activeThread.id} | ||
| 4073 | isWorking={isWorking} | ||
| 4074 | activeTurnInProgress={isWorking || !latestTurnSettled} | ||
| 4075 | activeTurnStartedAt={activeWorkStartedAt} | ||
| 4076 | listRef={legendListRef} | ||
| 4077 | timelineEntries={timelineEntries} | ||
| 4078 | latestTurn={activeLatestTurn} | ||
| 4079 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} | ||
| 4080 | activeThreadEnvironmentId={activeThread.environmentId} | ||
| 4081 | routeThreadKey={routeThreadKey} | ||
| 4082 | onOpenTurnDiff={onOpenTurnDiff} | ||
| 4083 | revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} | ||
| 4084 | onRevertUserMessage={onRevertUserMessage} | ||
| 4085 | isRevertingCheckpoint={isRevertingCheckpoint} | ||
| 4086 | onImageExpand={onExpandTimelineImage} | ||
| 4087 | markdownCwd={gitCwd ?? undefined} | ||
| 4088 | resolvedTheme={resolvedTheme} | ||
| 4089 | timestampFormat={timestampFormat} | ||
| 4090 | workspaceRoot={activeWorkspaceRoot} | ||
| 4091 | skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} | ||
| 4092 | onIsAtEndChange={onIsAtEndChange} | ||
| 4093 | /> | ||
| 4001 | 4094 | ||
| 4002 | {/* Error banner */} | 4095 | {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} |
| 4003 | <ProviderStatusBanner status={activeProviderStatus} /> | 4096 | {showScrollToBottom && ( |
| 4004 | <ThreadErrorBanner | 4097 | <div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5"> |
| 4005 | error={activeThread.error} | 4098 | <button |
| 4006 | onDismiss={() => setThreadError(activeThread.id, null)} | 4099 | type="button" |
| 4007 | /> | 4100 | onClick={() => scrollToEnd(true)} |
| 4008 | {/* Main content area with optional plan sidebar */} | 4101 | className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" |
| 4009 | <div className="flex min-h-0 min-w-0 flex-1"> | 4102 | > |
| 4010 | {/* Chat column */} | 4103 | <ChevronDownIcon className="size-3.5" /> |
| 4011 | <div className="flex min-h-0 min-w-0 flex-1 flex-col"> | 4104 | Scroll to bottom |
| 4012 | {/* Messages Wrapper */} | 4105 | </button> |
| 4013 | <div className="relative flex min-h-0 flex-1 flex-col"> | 4106 | </div> |
| 4014 | {/* Messages — LegendList handles virtualization and scrolling internally */} | 4107 | )} |
| 4015 | <MessagesTimeline | 4108 | </div> |
| 4016 | key={activeThread.id} | ||
| 4017 | isWorking={isWorking} | ||
| 4018 | activeTurnInProgress={isWorking || !latestTurnSettled} | ||
| 4019 | activeTurnStartedAt={activeWorkStartedAt} | ||
| 4020 | listRef={legendListRef} | ||
| 4021 | timelineEntries={timelineEntries} | ||
| 4022 | latestTurn={activeLatestTurn} | ||
| 4023 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} | ||
| 4024 | activeThreadEnvironmentId={activeThread.environmentId} | ||
| 4025 | routeThreadKey={routeThreadKey} | ||
| 4026 | onOpenTurnDiff={onOpenTurnDiff} | ||
| 4027 | revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} | ||
| 4028 | onRevertUserMessage={onRevertUserMessage} | ||
| 4029 | isRevertingCheckpoint={isRevertingCheckpoint} | ||
| 4030 | onImageExpand={onExpandTimelineImage} | ||
| 4031 | markdownCwd={gitCwd ?? undefined} | ||
| 4032 | resolvedTheme={resolvedTheme} | ||
| 4033 | timestampFormat={timestampFormat} | ||
| 4034 | workspaceRoot={activeWorkspaceRoot} | ||
| 4035 | skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} | ||
| 4036 | onIsAtEndChange={onIsAtEndChange} | ||
| 4037 | /> | ||
| 4038 | |||
| 4039 | {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} | ||
| 4040 | {showScrollToBottom && ( | ||
| 4041 | <div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5"> | ||
| 4042 | <button | ||
| 4043 | type="button" | ||
| 4044 | onClick={() => scrollToEnd(true)} | ||
| 4045 | className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" | ||
| 4046 | > | ||
| 4047 | <ChevronDownIcon className="size-3.5" /> | ||
| 4048 | Scroll to bottom | ||
| 4049 | </button> | ||
| 4050 | </div> | ||
| 4051 | )} | ||
| 4052 | </div> | ||
| 4053 | 4109 | ||
| 4054 | {/* Input bar */} | 4110 | {/* Input bar */} |
| 4055 | <div | 4111 | <div |
| 4056 | className={cn( | 4112 | className={cn( |
| 4057 | "pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-1.5 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-2", | 4113 | "pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-1.5 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-2", |
| 4058 | isGitRepo | 4114 | isGitRepo |
| 4059 | ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]" | 4115 | ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]" |
| 4060 | : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]", | 4116 | : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]", |
| 4061 | )} | 4117 | )} |
| 4062 | > | 4118 | > |
| 4063 | <div className="relative isolate"> | 4119 | <div className="relative isolate"> |
| 4064 | <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> | 4120 | <ComposerBannerStack className="relative z-0" items={composerBannerItems} /> |
| 4065 | <div className="relative z-10"> | 4121 | <div className="relative z-10"> |
| 4066 | <ChatComposer | 4122 | <ChatComposer |
| 4067 | composerRef={composerRef} | 4123 | composerRef={composerRef} |
| 4068 | composerDraftTarget={composerDraftTarget} | 4124 | composerDraftTarget={composerDraftTarget} |
| 4069 | environmentId={environmentId} | 4125 | environmentId={environmentId} |
| 4070 | routeKind={routeKind} | 4126 | routeKind={routeKind} |
| 4071 | routeThreadRef={routeThreadRef} | 4127 | routeThreadRef={routeThreadRef} |
| 4072 | draftId={draftId} | 4128 | draftId={draftId} |
| 4073 | activeThreadId={activeThreadId} | 4129 | activeThreadId={activeThreadId} |
| 4074 | activeThreadEnvironmentId={activeThread?.environmentId} | 4130 | activeThreadEnvironmentId={activeThread?.environmentId} |
| 4075 | activeThread={activeThread} | 4131 | activeThread={activeThread} |
| 4076 | isServerThread={isServerThread} | 4132 | isServerThread={isServerThread} |
| 4077 | isLocalDraftThread={isLocalDraftThread} | 4133 | isLocalDraftThread={isLocalDraftThread} |
| 4078 | phase={phase} | 4134 | phase={phase} |
| 4079 | isConnecting={isConnecting} | 4135 | isConnecting={isConnecting} |
| 4080 | isSendBusy={isSendBusy} | 4136 | isSendBusy={isSendBusy} |
| 4081 | isPreparingWorktree={isPreparingWorktree} | 4137 | isPreparingWorktree={isPreparingWorktree} |
| 4082 | environmentUnavailable={activeEnvironmentUnavailableState} | 4138 | environmentUnavailable={activeEnvironmentUnavailableState} |
| 4083 | activePendingApproval={activePendingApproval} | 4139 | activePendingApproval={activePendingApproval} |
| 4084 | pendingApprovals={pendingApprovals} | 4140 | pendingApprovals={pendingApprovals} |
| 4085 | pendingUserInputs={pendingUserInputs} | 4141 | pendingUserInputs={pendingUserInputs} |
| 4086 | activePendingProgress={activePendingProgress} | 4142 | activePendingProgress={activePendingProgress} |
| 4087 | activePendingResolvedAnswers={activePendingResolvedAnswers} | 4143 | activePendingResolvedAnswers={activePendingResolvedAnswers} |
| 4088 | activePendingIsResponding={activePendingIsResponding} | 4144 | activePendingIsResponding={activePendingIsResponding} |
| 4089 | activePendingDraftAnswers={activePendingDraftAnswers} | 4145 | activePendingDraftAnswers={activePendingDraftAnswers} |
| 4090 | activePendingQuestionIndex={activePendingQuestionIndex} | 4146 | activePendingQuestionIndex={activePendingQuestionIndex} |
| 4091 | respondingRequestIds={respondingRequestIds} | 4147 | respondingRequestIds={respondingRequestIds} |
| 4092 | showPlanFollowUpPrompt={showPlanFollowUpPrompt} | 4148 | showPlanFollowUpPrompt={showPlanFollowUpPrompt} |
| 4093 | activeProposedPlan={activeProposedPlan} | 4149 | activeProposedPlan={activeProposedPlan} |
| 4094 | activePlan={activePlan as { turnId?: TurnId } | null} | 4150 | activePlan={activePlan as { turnId?: TurnId } | null} |
| 4095 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} | 4151 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} |
| 4096 | planSidebarLabel={planSidebarLabel} | 4152 | planSidebarLabel={planSidebarLabel} |
| 4097 | planSidebarOpen={planSidebarOpen} | 4153 | planSidebarOpen={planSidebarOpen} |
| 4098 | runtimeMode={runtimeMode} | 4154 | runtimeMode={runtimeMode} |
| 4099 | interactionMode={interactionMode} | 4155 | interactionMode={interactionMode} |
| 4100 | lockedProvider={lockedProvider} | 4156 | lockedProvider={lockedProvider} |
| 4101 | providerStatuses={providerStatuses as ServerProvider[]} | 4157 | providerStatuses={providerStatuses as ServerProvider[]} |
| 4102 | activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} | 4158 | activeProjectDefaultModelSelection={activeProject?.defaultModelSelection} |
| 4103 | activeThreadModelSelection={activeThread?.modelSelection} | 4159 | activeThreadModelSelection={activeThread?.modelSelection} |
| 4104 | activeThreadActivities={activeThread?.activities} | 4160 | activeThreadActivities={activeThread?.activities} |
| 4105 | resolvedTheme={resolvedTheme} | 4161 | resolvedTheme={resolvedTheme} |
| 4106 | settings={settings} | 4162 | settings={settings} |
| 4107 | keybindings={keybindings} | 4163 | keybindings={keybindings} |
| 4108 | terminalOpen={Boolean(terminalUiState.terminalOpen)} | 4164 | terminalOpen={Boolean(terminalUiState.terminalOpen)} |
| 4109 | gitCwd={gitCwd} | 4165 | gitCwd={gitCwd} |
| 4110 | promptRef={promptRef} | 4166 | promptRef={promptRef} |
| 4111 | composerImagesRef={composerImagesRef} | 4167 | composerImagesRef={composerImagesRef} |
| 4112 | composerTerminalContextsRef={composerTerminalContextsRef} | 4168 | composerTerminalContextsRef={composerTerminalContextsRef} |
| 4113 | composerElementContextsRef={composerElementContextsRef} | 4169 | composerElementContextsRef={composerElementContextsRef} |
| 4114 | shouldAutoScrollRef={isAtEndRef} | 4170 | shouldAutoScrollRef={isAtEndRef} |
| 4115 | scheduleStickToBottom={scrollToEnd} | 4171 | scheduleStickToBottom={scrollToEnd} |
| 4116 | onSend={onSend} | 4172 | onSend={onSend} |
| 4117 | onInterrupt={onInterrupt} | 4173 | onInterrupt={onInterrupt} |
| 4118 | onImplementPlanInNewThread={onImplementPlanInNewThread} | 4174 | onImplementPlanInNewThread={onImplementPlanInNewThread} |
| 4119 | onRespondToApproval={onRespondToApproval} | 4175 | onRespondToApproval={onRespondToApproval} |
| 4120 | onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} | 4176 | onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} |
| 4121 | onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} | 4177 | onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} |
| 4122 | onPreviousActivePendingUserInputQuestion={ | 4178 | onPreviousActivePendingUserInputQuestion={ |
| 4123 | onPreviousActivePendingUserInputQuestion | 4179 | onPreviousActivePendingUserInputQuestion |
| 4124 | } | 4180 | } |
| 4125 | onChangeActivePendingUserInputCustomAnswer={ | 4181 | onChangeActivePendingUserInputCustomAnswer={ |
| 4126 | onChangeActivePendingUserInputCustomAnswer | 4182 | onChangeActivePendingUserInputCustomAnswer |
| 4127 | } | 4183 | } |
| 4128 | onProviderModelSelect={onProviderModelSelect} | 4184 | onProviderModelSelect={onProviderModelSelect} |
| 4129 | getModelDisabledReason={getModelDisabledReason} | 4185 | getModelDisabledReason={getModelDisabledReason} |
| 4130 | toggleInteractionMode={toggleInteractionMode} | 4186 | toggleInteractionMode={toggleInteractionMode} |
| 4131 | handleRuntimeModeChange={handleRuntimeModeChange} | 4187 | handleRuntimeModeChange={handleRuntimeModeChange} |
| 4132 | handleInteractionModeChange={handleInteractionModeChange} | 4188 | handleInteractionModeChange={handleInteractionModeChange} |
| 4133 | togglePlanSidebar={togglePlanSidebar} | 4189 | togglePlanSidebar={togglePlanSidebar} |
| 4134 | focusComposer={focusComposer} | 4190 | focusComposer={focusComposer} |
| 4135 | scheduleComposerFocus={scheduleComposerFocus} | 4191 | scheduleComposerFocus={scheduleComposerFocus} |
| 4136 | setThreadError={setThreadError} | 4192 | setThreadError={setThreadError} |
| 4137 | onExpandImage={onExpandTimelineImage} | 4193 | onExpandImage={onExpandTimelineImage} |
| 4138 | /> | 4194 | /> |
| 4195 | </div> | ||
| 4139 | </div> | 4196 | </div> |
| 4197 | {isGitRepo && ( | ||
| 4198 | <BranchToolbar | ||
| 4199 | environmentId={activeThread.environmentId} | ||
| 4200 | threadId={activeThread.id} | ||
| 4201 | {...(routeKind === "draft" && draftId ? { draftId } : {})} | ||
| 4202 | onEnvModeChange={onEnvModeChange} | ||
| 4203 | {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} | ||
| 4204 | {...(canOverrideServerThreadEnvMode | ||
| 4205 | ? { | ||
| 4206 | activeThreadBranchOverride: activeThreadBranch, | ||
| 4207 | onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, | ||
| 4208 | } | ||
| 4209 | : {})} | ||
| 4210 | envLocked={envLocked} | ||
| 4211 | onComposerFocusRequest={scheduleComposerFocus} | ||
| 4212 | {...(canCheckoutPullRequestIntoThread | ||
| 4213 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } | ||
| 4214 | : {})} | ||
| 4215 | {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} | ||
| 4216 | availableEnvironments={logicalProjectEnvironments} | ||
| 4217 | /> | ||
| 4218 | )} | ||
| 4140 | </div> | 4219 | </div> |
| 4141 | {isGitRepo && ( | 4220 | |
| 4142 | <BranchToolbar | 4221 | {pullRequestDialogState ? ( |
| 4222 | <PullRequestThreadDialog | ||
| 4223 | key={pullRequestDialogState.key} | ||
| 4224 | open | ||
| 4143 | environmentId={activeThread.environmentId} | 4225 | environmentId={activeThread.environmentId} |
| 4144 | threadId={activeThread.id} | 4226 | threadId={activeThread.id} |
| 4145 | {...(routeKind === "draft" && draftId ? { draftId } : {})} | 4227 | cwd={activeProject?.cwd ?? null} |
| 4146 | onEnvModeChange={onEnvModeChange} | 4228 | initialReference={pullRequestDialogState.initialReference} |
| 4147 | {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} | 4229 | onOpenChange={(open) => { |
| 4148 | {...(canOverrideServerThreadEnvMode | 4230 | if (!open) { |
| 4149 | ? { | 4231 | closePullRequestDialog(); |
| 4150 | activeThreadBranchOverride: activeThreadBranch, | 4232 | } |
| 4151 | onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, | 4233 | }} |
| 4152 | } | 4234 | onPrepared={handlePreparedPullRequestThread} |
| 4153 | : {})} | ||
| 4154 | envLocked={envLocked} | ||
| 4155 | onComposerFocusRequest={scheduleComposerFocus} | ||
| 4156 | {...(canCheckoutPullRequestIntoThread | ||
| 4157 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } | ||
| 4158 | : {})} | ||
| 4159 | {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} | ||
| 4160 | availableEnvironments={logicalProjectEnvironments} | ||
| 4161 | /> | 4235 | /> |
| 4162 | )} | 4236 | ) : null} |
| 4163 | </div> | 4237 | </div> |
| 4164 | 4238 | {/* end chat column */} | |
| 4165 | {pullRequestDialogState ? ( | ||
| 4166 | <PullRequestThreadDialog | ||
| 4167 | key={pullRequestDialogState.key} | ||
| 4168 | open | ||
| 4169 | environmentId={activeThread.environmentId} | ||
| 4170 | threadId={activeThread.id} | ||
| 4171 | cwd={activeProject?.cwd ?? null} | ||
| 4172 | initialReference={pullRequestDialogState.initialReference} | ||
| 4173 | onOpenChange={(open) => { | ||
| 4174 | if (!open) { | ||
| 4175 | closePullRequestDialog(); | ||
| 4176 | } | ||
| 4177 | }} | ||
| 4178 | onPrepared={handlePreparedPullRequestThread} | ||
| 4179 | /> | ||
| 4180 | ) : null} | ||
| 4181 | </div> | 4239 | </div> |
| 4182 | {/* end chat column */} | 4240 | {/* end horizontal flex container */} |
| 4183 | |||
| 4184 | {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( | ||
| 4185 | <PlanSidebar | ||
| 4186 | activePlan={activePlan} | ||
| 4187 | activeProposedPlan={sidebarProposedPlan} | ||
| 4188 | label={planSidebarLabel} | ||
| 4189 | environmentId={environmentId} | ||
| 4190 | markdownCwd={gitCwd ?? undefined} | ||
| 4191 | workspaceRoot={activeWorkspaceRoot} | ||
| 4192 | timestampFormat={timestampFormat} | ||
| 4193 | mode="sidebar" | ||
| 4194 | onClose={closePlanSidebar} | ||
| 4195 | /> | ||
| 4196 | ) : null} | ||
| 4197 | {!shouldUsePlanSidebarSheet | ||
| 4198 | ? mountedPreviewThreadRefs.map( | ||
| 4199 | ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => { | ||
| 4200 | const visible = previewPanelOpen && mountedThreadKey === activeThreadKey; | ||
| 4201 | return ( | ||
| 4202 | <div | ||
| 4203 | key={mountedThreadKey} | ||
| 4204 | className={cn( | ||
| 4205 | visible | ||
| 4206 | ? "contents" | ||
| 4207 | : "pointer-events-none fixed -left-[10000px] top-0 h-px w-px overflow-hidden opacity-0", | ||
| 4208 | )} | ||
| 4209 | aria-hidden={visible ? undefined : true} | ||
| 4210 | > | ||
| 4211 | <Suspense fallback={null}> | ||
| 4212 | <PreviewPanel mode="inline" threadRef={mountedThreadRef} visible={visible} /> | ||
| 4213 | </Suspense> | ||
| 4214 | </div> | ||
| 4215 | ); | ||
| 4216 | }, | ||
| 4217 | ) | ||
| 4218 | : null} | ||
| 4219 | </div> | 4241 | </div> |
| 4220 | {/* end horizontal flex container */} | ||
| 4221 | 4242 | ||
| 4222 | {activeThreadRef ? ( | 4243 | {!shouldUsePlanSidebarSheet && |
| 4223 | <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} /> | 4244 | rightPanelOpen && |
| 4245 | activeThreadRef && | ||
| 4246 | activeRightPanelSurface ? ( | ||
| 4247 | <RightPanelTabs | ||
| 4248 | mode="inline" | ||
| 4249 | surfaces={rightPanelState.surfaces} | ||
| 4250 | activeSurfaceId={activeRightPanelSurface.id} | ||
| 4251 | previewSessions={activePreviewState.sessions} | ||
| 4252 | onActivate={activateRightPanelSurface} | ||
| 4253 | onCloseSurface={closeRightPanelSurface} | ||
| 4254 | onAddBrowser={createBrowserSurface} | ||
| 4255 | onAddDiff={addDiffSurface} | ||
| 4256 | diffAvailable={isServerThread && isGitRepo} | ||
| 4257 | > | ||
| 4258 | {activeRightPanelSurface.kind === "preview" ? ( | ||
| 4259 | <Suspense fallback={null}> | ||
| 4260 | <PreviewPanel | ||
| 4261 | mode="embedded" | ||
| 4262 | threadRef={activeThreadRef} | ||
| 4263 | tabId={activeRightPanelSurface.resourceId} | ||
| 4264 | visible | ||
| 4265 | /> | ||
| 4266 | </Suspense> | ||
| 4267 | ) : activeRightPanelSurface.kind === "diff" ? ( | ||
| 4268 | <DiffWorkerPoolProvider> | ||
| 4269 | <Suspense fallback={null}> | ||
| 4270 | <DiffPanel mode="embedded" /> | ||
| 4271 | </Suspense> | ||
| 4272 | </DiffWorkerPoolProvider> | ||
| 4273 | ) : null} | ||
| 4274 | </RightPanelTabs> | ||
| 4224 | ) : null} | 4275 | ) : null} |
| 4225 | 4276 | ||
| 4226 | {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( | 4277 | {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef && activeRightPanelSurface ? ( |
| 4227 | <PersistentThreadTerminalDrawer | ||
| 4228 | key={mountedThreadKey} | ||
| 4229 | threadRef={mountedThreadRef} | ||
| 4230 | threadId={mountedThreadRef.threadId} | ||
| 4231 | visible={mountedThreadKey === activeThreadKey && terminalUiState.terminalOpen} | ||
| 4232 | launchContext={ | ||
| 4233 | mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null | ||
| 4234 | } | ||
| 4235 | focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0} | ||
| 4236 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | ||
| 4237 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | ||
| 4238 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | ||
| 4239 | keybindings={keybindings} | ||
| 4240 | onAddTerminalContext={addTerminalContextToDraft} | ||
| 4241 | /> | ||
| 4242 | ))} | ||
| 4243 | {shouldUsePlanSidebarSheet && previewPanelOpen && activeThreadRef ? ( | ||
| 4244 | <RightPanelSheet open onClose={closePreviewPanel}> | 4278 | <RightPanelSheet open onClose={closePreviewPanel}> |
| 4245 | <Suspense fallback={null}> | 4279 | <RightPanelTabs |
| 4246 | <PreviewPanel mode="sheet" threadRef={activeThreadRef} visible /> | ||
| 4247 | </Suspense> | ||
| 4248 | </RightPanelSheet> | ||
| 4249 | ) : null} | ||
| 4250 | {shouldUsePlanSidebarSheet ? ( | ||
| 4251 | <RightPanelSheet open={planSidebarOpen} onClose={closePlanSidebar}> | ||
| 4252 | <PlanSidebar | ||
| 4253 | activePlan={activePlan} | ||
| 4254 | activeProposedPlan={sidebarProposedPlan} | ||
| 4255 | label={planSidebarLabel} | ||
| 4256 | environmentId={environmentId} | ||
| 4257 | markdownCwd={gitCwd ?? undefined} | ||
| 4258 | workspaceRoot={activeWorkspaceRoot} | ||
| 4259 | timestampFormat={timestampFormat} | ||
| 4260 | mode="sheet" | 4280 | mode="sheet" |
| 4261 | onClose={closePlanSidebar} | 4281 | surfaces={rightPanelState.surfaces} |
| 4262 | /> | 4282 | activeSurfaceId={activeRightPanelSurface.id} |
| 4283 | previewSessions={activePreviewState.sessions} | ||
| 4284 | onActivate={activateRightPanelSurface} | ||
| 4285 | onCloseSurface={closeRightPanelSurface} | ||
| 4286 | onAddBrowser={createBrowserSurface} | ||
| 4287 | onAddDiff={addDiffSurface} | ||
| 4288 | diffAvailable={isServerThread && isGitRepo} | ||
| 4289 | > | ||
| 4290 | {activeRightPanelSurface.kind === "preview" ? ( | ||
| 4291 | <Suspense fallback={null}> | ||
| 4292 | <PreviewPanel | ||
| 4293 | mode="embedded" | ||
| 4294 | threadRef={activeThreadRef} | ||
| 4295 | tabId={activeRightPanelSurface.resourceId} | ||
| 4296 | visible | ||
| 4297 | /> | ||
| 4298 | </Suspense> | ||
| 4299 | ) : activeRightPanelSurface.kind === "terminal" ? ( | ||
| 4300 | <PersistentThreadTerminalDrawer | ||
| 4301 | threadRef={activeThreadRef} | ||
| 4302 | threadId={activeThreadRef.threadId} | ||
| 4303 | visible | ||
| 4304 | mode="panel" | ||
| 4305 | launchContext={activeTerminalLaunchContext ?? null} | ||
| 4306 | focusRequestId={terminalFocusRequestId} | ||
| 4307 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | ||
| 4308 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | ||
| 4309 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | ||
| 4310 | keybindings={keybindings} | ||
| 4311 | onAddTerminalContext={addTerminalContextToDraft} | ||
| 4312 | /> | ||
| 4313 | ) : activeRightPanelSurface.kind === "diff" ? ( | ||
| 4314 | <DiffWorkerPoolProvider> | ||
| 4315 | <Suspense fallback={null}> | ||
| 4316 | <DiffPanel mode="embedded" /> | ||
| 4317 | </Suspense> | ||
| 4318 | </DiffWorkerPoolProvider> | ||
| 4319 | ) : ( | ||
| 4320 | <PlanSidebar | ||
| 4321 | activePlan={activePlan} | ||
| 4322 | activeProposedPlan={sidebarProposedPlan} | ||
| 4323 | label={planSidebarLabel} | ||
| 4324 | environmentId={environmentId} | ||
| 4325 | markdownCwd={gitCwd ?? undefined} | ||
| 4326 | workspaceRoot={activeWorkspaceRoot} | ||
| 4327 | timestampFormat={timestampFormat} | ||
| 4328 | mode="embedded" | ||
| 4329 | onClose={closePlanSidebar} | ||
| 4330 | /> | ||
| 4331 | )} | ||
| 4332 | </RightPanelTabs> | ||
| 4263 | </RightPanelSheet> | 4333 | </RightPanelSheet> |
| 4264 | ) : null} | 4334 | ) : null} |
| 4265 | 4335 |
apps/web/src/components/DiffPanelShell.tsx+3-3| ... | @@ -5,10 +5,10 @@ import { cn } from "~/lib/utils"; | ... | @@ -5,10 +5,10 @@ import { cn } from "~/lib/utils"; |
| 5 | 5 | ||
| 6 | import { Skeleton } from "./ui/skeleton"; | 6 | import { Skeleton } from "./ui/skeleton"; |
| 7 | 7 | ||
| 8 | export type DiffPanelMode = "inline" | "sheet" | "sidebar"; | 8 | export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; |
| 9 | 9 | ||
| 10 | function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { | 10 | function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { |
| 11 | const shouldUseDragRegion = isElectron && mode !== "sheet"; | 11 | const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded"; |
| 12 | return cn( | 12 | return cn( |
| 13 | "flex items-center justify-between gap-2 px-4", | 13 | "flex items-center justify-between gap-2 px-4", |
| 14 | shouldUseDragRegion | 14 | shouldUseDragRegion |
| ... | @@ -22,7 +22,7 @@ export function DiffPanelShell(props: { | ... | @@ -22,7 +22,7 @@ export function DiffPanelShell(props: { |
| 22 | header: ReactNode; | 22 | header: ReactNode; |
| 23 | children: ReactNode; | 23 | children: ReactNode; |
| 24 | }) { | 24 | }) { |
| 25 | const shouldUseDragRegion = isElectron && props.mode !== "sheet"; | 25 | const shouldUseDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; |
| 26 | 26 | ||
| 27 | return ( | 27 | return ( |
| 28 | <div | 28 | <div |
apps/web/src/components/PlanSidebar.tsx+1-1| ... | @@ -59,7 +59,7 @@ interface PlanSidebarProps { | ... | @@ -59,7 +59,7 @@ interface PlanSidebarProps { |
| 59 | markdownCwd: string | undefined; | 59 | markdownCwd: string | undefined; |
| 60 | workspaceRoot: string | undefined; | 60 | workspaceRoot: string | undefined; |
| 61 | timestampFormat: TimestampFormat; | 61 | timestampFormat: TimestampFormat; |
| 62 | mode?: "sheet" | "sidebar"; | 62 | mode?: "sheet" | "sidebar" | "embedded"; |
| 63 | onClose: () => void; | 63 | onClose: () => void; |
| 64 | } | 64 | } |
| 65 | 65 |
apps/web/src/components/RightPanelTabs.tsx created+148| ... | @@ -0,0 +1,148 @@ | ||
| 1 | import type { PreviewSessionSnapshot } from "@t3tools/contracts"; | ||
| 2 | import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; | ||
| 3 | import { type ReactNode, useState } from "react"; | ||
| 4 | |||
| 5 | import type { RightPanelSurface } from "~/rightPanelStore"; | ||
| 6 | import { cn } from "~/lib/utils"; | ||
| 7 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; | ||
| 8 | import { faviconUrlForOrigin } from "~/lib/favicon"; | ||
| 9 | |||
| 10 | import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; | ||
| 11 | |||
| 12 | interface RightPanelTabsProps { | ||
| 13 | mode: PreviewPanelMode; | ||
| 14 | surfaces: readonly RightPanelSurface[]; | ||
| 15 | activeSurfaceId: string; | ||
| 16 | previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; | ||
| 17 | onActivate: (surface: RightPanelSurface) => void; | ||
| 18 | onCloseSurface: (surface: RightPanelSurface) => void; | ||
| 19 | onAddBrowser: () => void; | ||
| 20 | onAddDiff: () => void; | ||
| 21 | diffAvailable: boolean; | ||
| 22 | children: ReactNode; | ||
| 23 | } | ||
| 24 | |||
| 25 | function surfaceTitle( | ||
| 26 | surface: RightPanelSurface, | ||
| 27 | sessions: Readonly<Record<string, PreviewSessionSnapshot>>, | ||
| 28 | ): string { | ||
| 29 | switch (surface.kind) { | ||
| 30 | case "diff": | ||
| 31 | return "Diff"; | ||
| 32 | case "terminal": | ||
| 33 | return "Terminal"; | ||
| 34 | case "plan": | ||
| 35 | return "Plan"; | ||
| 36 | case "preview": { | ||
| 37 | const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; | ||
| 38 | if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; | ||
| 39 | if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title; | ||
| 40 | try { | ||
| 41 | return new URL(snapshot.navStatus.url).host || "Browser"; | ||
| 42 | } catch { | ||
| 43 | return "Browser"; | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | function PreviewFavicon({ url }: { url: string | null }) { | ||
| 50 | const faviconUrl = faviconUrlForOrigin(url, 32); | ||
| 51 | const [failedUrl, setFailedUrl] = useState<string | null>(null); | ||
| 52 | if (!faviconUrl || failedUrl === faviconUrl) return <Globe2 className="size-3.5 shrink-0" />; | ||
| 53 | return ( | ||
| 54 | <img | ||
| 55 | src={faviconUrl} | ||
| 56 | alt="" | ||
| 57 | aria-hidden | ||
| 58 | draggable={false} | ||
| 59 | className="size-3.5 shrink-0 rounded-sm" | ||
| 60 | onError={() => setFailedUrl(faviconUrl)} | ||
| 61 | /> | ||
| 62 | ); | ||
| 63 | } | ||
| 64 | |||
| 65 | function SurfaceIcon({ | ||
| 66 | surface, | ||
| 67 | sessions, | ||
| 68 | }: { | ||
| 69 | surface: RightPanelSurface; | ||
| 70 | sessions: Readonly<Record<string, PreviewSessionSnapshot>>; | ||
| 71 | }) { | ||
| 72 | switch (surface.kind) { | ||
| 73 | case "preview": { | ||
| 74 | const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; | ||
| 75 | const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; | ||
| 76 | return <PreviewFavicon url={url} />; | ||
| 77 | } | ||
| 78 | case "diff": | ||
| 79 | return <FileDiff className="size-3.5 shrink-0" />; | ||
| 80 | case "terminal": | ||
| 81 | return <TerminalSquare className="size-3.5 shrink-0" />; | ||
| 82 | case "plan": | ||
| 83 | return <ClipboardList className="size-3.5 shrink-0" />; | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | export function RightPanelTabs(props: RightPanelTabsProps) { | ||
| 88 | return ( | ||
| 89 | <PreviewPanelShell mode={props.mode}> | ||
| 90 | <div className="flex h-10 shrink-0 items-center px-2"> | ||
| 91 | <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"> | ||
| 92 | {props.surfaces.map((surface) => { | ||
| 93 | const active = surface.id === props.activeSurfaceId; | ||
| 94 | const title = surfaceTitle(surface, props.previewSessions); | ||
| 95 | return ( | ||
| 96 | <div | ||
| 97 | key={surface.id} | ||
| 98 | className={cn( | ||
| 99 | "group flex h-7 min-w-0 max-w-52 items-center gap-1.5 rounded-md px-2 text-sm", | ||
| 100 | active | ||
| 101 | ? "bg-accent text-foreground" | ||
| 102 | : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", | ||
| 103 | )} | ||
| 104 | > | ||
| 105 | <button | ||
| 106 | type="button" | ||
| 107 | className="flex min-w-0 flex-1 items-center gap-1.5" | ||
| 108 | onClick={() => props.onActivate(surface)} | ||
| 109 | title={title} | ||
| 110 | > | ||
| 111 | <SurfaceIcon surface={surface} sessions={props.previewSessions} /> | ||
| 112 | <span className="truncate">{title}</span> | ||
| 113 | </button> | ||
| 114 | <button | ||
| 115 | type="button" | ||
| 116 | className="rounded p-0.5 opacity-0 hover:bg-muted group-hover:opacity-100 focus:opacity-100" | ||
| 117 | aria-label={`Close ${title}`} | ||
| 118 | onClick={() => props.onCloseSurface(surface)} | ||
| 119 | > | ||
| 120 | <X className="size-3" /> | ||
| 121 | </button> | ||
| 122 | </div> | ||
| 123 | ); | ||
| 124 | })} | ||
| 125 | </div> | ||
| 126 | <Menu> | ||
| 127 | <MenuTrigger | ||
| 128 | className="relative ml-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" | ||
| 129 | aria-label="Add panel surface" | ||
| 130 | > | ||
| 131 | <Plus className="size-4" /> | ||
| 132 | </MenuTrigger> | ||
| 133 | <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44"> | ||
| 134 | <MenuItem onClick={props.onAddBrowser}> | ||
| 135 | <Globe2 /> | ||
| 136 | Browser | ||
| 137 | </MenuItem> | ||
| 138 | <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}> | ||
| 139 | <FileDiff /> | ||
| 140 | Diff | ||
| 141 | </MenuItem> | ||
| 142 | </MenuPopup> | ||
| 143 | </Menu> | ||
| 144 | </div> | ||
| 145 | <div className="flex min-h-0 flex-1 flex-col">{props.children}</div> | ||
| 146 | </PreviewPanelShell> | ||
| 147 | ); | ||
| 148 | } | ||
apps/web/src/components/ThreadTerminalDrawer.tsx+32-18| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | useState, | 20 | useState, |
| 21 | } from "react"; | 21 | } from "react"; |
| 22 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | 22 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 23 | import { cn } from "~/lib/utils"; | ||
| 23 | import { type TerminalContextSelection } from "~/lib/terminalContext"; | 24 | import { type TerminalContextSelection } from "~/lib/terminalContext"; |
| 24 | import { openInPreferredEditor } from "../editorPreferences"; | 25 | import { openInPreferredEditor } from "../editorPreferences"; |
| 25 | import { | 26 | import { |
| ... | @@ -791,6 +792,7 @@ export function TerminalViewport({ | ... | @@ -791,6 +792,7 @@ export function TerminalViewport({ |
| 791 | } | 792 | } |
| 792 | 793 | ||
| 793 | interface ThreadTerminalDrawerProps { | 794 | interface ThreadTerminalDrawerProps { |
| 795 | mode?: "drawer" | "panel"; | ||
| 794 | threadRef: ScopedThreadRef; | 796 | threadRef: ScopedThreadRef; |
| 795 | threadId: ThreadId; | 797 | threadId: ThreadId; |
| 796 | cwd: string; | 798 | cwd: string; |
| ... | @@ -849,6 +851,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA | ... | @@ -849,6 +851,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA |
| 849 | } | 851 | } |
| 850 | 852 | ||
| 851 | export default function ThreadTerminalDrawer({ | 853 | export default function ThreadTerminalDrawer({ |
| 854 | mode = "drawer", | ||
| 852 | threadRef, | 855 | threadRef, |
| 853 | threadId, | 856 | threadId, |
| 854 | cwd, | 857 | cwd, |
| ... | @@ -874,6 +877,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -874,6 +877,7 @@ export default function ThreadTerminalDrawer({ |
| 874 | terminalLabelsById, | 877 | terminalLabelsById, |
| 875 | terminalLaunchLocationsById, | 878 | terminalLaunchLocationsById, |
| 876 | }: ThreadTerminalDrawerProps) { | 879 | }: ThreadTerminalDrawerProps) { |
| 880 | const isPanel = mode === "panel"; | ||
| 877 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); | 881 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); |
| 878 | const [resizeEpoch, setResizeEpoch] = useState(0); | 882 | const [resizeEpoch, setResizeEpoch] = useState(0); |
| 879 | const drawerHeightRef = useRef(drawerHeight); | 883 | const drawerHeightRef = useRef(drawerHeight); |
| ... | @@ -1139,16 +1143,21 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1139,16 +1143,21 @@ export default function ThreadTerminalDrawer({ |
| 1139 | if (normalizedTerminalIds.length === 0) { | 1143 | if (normalizedTerminalIds.length === 0) { |
| 1140 | return ( | 1144 | return ( |
| 1141 | <aside | 1145 | <aside |
| 1142 | className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" | 1146 | className={cn( |
| 1143 | style={{ height: `${drawerHeight}px` }} | 1147 | "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", |
| 1148 | isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", | ||
| 1149 | )} | ||
| 1150 | style={isPanel ? undefined : { height: `${drawerHeight}px` }} | ||
| 1144 | > | 1151 | > |
| 1145 | <div | 1152 | {!isPanel ? ( |
| 1146 | className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" | 1153 | <div |
| 1147 | onPointerDown={handleResizePointerDown} | 1154 | className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" |
| 1148 | onPointerMove={handleResizePointerMove} | 1155 | onPointerDown={handleResizePointerDown} |
| 1149 | onPointerUp={handleResizePointerEnd} | 1156 | onPointerMove={handleResizePointerMove} |
| 1150 | onPointerCancel={handleResizePointerEnd} | 1157 | onPointerUp={handleResizePointerEnd} |
| 1151 | /> | 1158 | onPointerCancel={handleResizePointerEnd} |
| 1159 | /> | ||
| 1160 | ) : null} | ||
| 1152 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground"> | 1161 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground"> |
| 1153 | <p>No terminal sessions for this thread yet.</p> | 1162 | <p>No terminal sessions for this thread yet.</p> |
| 1154 | <button | 1163 | <button |
| ... | @@ -1167,16 +1176,21 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1167,16 +1176,21 @@ export default function ThreadTerminalDrawer({ |
| 1167 | 1176 | ||
| 1168 | return ( | 1177 | return ( |
| 1169 | <aside | 1178 | <aside |
| 1170 | className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" | 1179 | className={cn( |
| 1171 | style={{ height: `${drawerHeight}px` }} | 1180 | "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", |
| 1181 | isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", | ||
| 1182 | )} | ||
| 1183 | style={isPanel ? undefined : { height: `${drawerHeight}px` }} | ||
| 1172 | > | 1184 | > |
| 1173 | <div | 1185 | {!isPanel ? ( |
| 1174 | className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" | 1186 | <div |
| 1175 | onPointerDown={handleResizePointerDown} | 1187 | className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" |
| 1176 | onPointerMove={handleResizePointerMove} | 1188 | onPointerDown={handleResizePointerDown} |
| 1177 | onPointerUp={handleResizePointerEnd} | 1189 | onPointerMove={handleResizePointerMove} |
| 1178 | onPointerCancel={handleResizePointerEnd} | 1190 | onPointerUp={handleResizePointerEnd} |
| 1179 | /> | 1191 | onPointerCancel={handleResizePointerEnd} |
| 1192 | /> | ||
| 1193 | ) : null} | ||
| 1180 | 1194 | ||
| 1181 | {!hasTerminalSidebar && ( | 1195 | {!hasTerminalSidebar && ( |
| 1182 | <div className="pointer-events-none absolute right-2 top-2 z-20"> | 1196 | <div className="pointer-events-none absolute right-2 top-2 z-20"> |
apps/web/src/components/chat/ChatHeader.tsx+13-83| ... | @@ -9,7 +9,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; | ... | @@ -9,7 +9,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 9 | import { memo } from "react"; | 9 | import { memo } from "react"; |
| 10 | import GitActionsControl from "../GitActionsControl"; | 10 | import GitActionsControl from "../GitActionsControl"; |
| 11 | import { type DraftId } from "~/composerDraftStore"; | 11 | import { type DraftId } from "~/composerDraftStore"; |
| 12 | import { DiffIcon, Globe, TerminalSquareIcon } from "lucide-react"; | 12 | import { PanelRightIcon } from "lucide-react"; |
| 13 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 13 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 14 | import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; | 14 | import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; |
| 15 | import { Toggle } from "../ui/toggle"; | 15 | import { Toggle } from "../ui/toggle"; |
| ... | @@ -23,29 +23,19 @@ interface ChatHeaderProps { | ... | @@ -23,29 +23,19 @@ interface ChatHeaderProps { |
| 23 | draftId?: DraftId; | 23 | draftId?: DraftId; |
| 24 | activeThreadTitle: string; | 24 | activeThreadTitle: string; |
| 25 | activeProjectName: string | undefined; | 25 | activeProjectName: string | undefined; |
| 26 | isGitRepo: boolean; | ||
| 27 | openInCwd: string | null; | 26 | openInCwd: string | null; |
| 28 | activeProjectScripts: ProjectScript[] | undefined; | 27 | activeProjectScripts: ProjectScript[] | undefined; |
| 29 | preferredScriptId: string | null; | 28 | preferredScriptId: string | null; |
| 30 | keybindings: ResolvedKeybindingsConfig; | 29 | keybindings: ResolvedKeybindingsConfig; |
| 31 | availableEditors: ReadonlyArray<EditorId>; | 30 | availableEditors: ReadonlyArray<EditorId>; |
| 32 | terminalAvailable: boolean; | 31 | rightPanelAvailable: boolean; |
| 33 | terminalOpen: boolean; | 32 | rightPanelOpen: boolean; |
| 34 | terminalToggleShortcutLabel: string | null; | ||
| 35 | diffToggleShortcutLabel: string | null; | ||
| 36 | /** False on the web build; true only when the desktop preview bridge is present. */ | ||
| 37 | previewAvailable: boolean; | ||
| 38 | previewOpen: boolean; | ||
| 39 | previewToggleShortcutLabel: string | null; | ||
| 40 | gitCwd: string | null; | 33 | gitCwd: string | null; |
| 41 | diffOpen: boolean; | ||
| 42 | onRunProjectScript: (script: ProjectScript) => void; | 34 | onRunProjectScript: (script: ProjectScript) => void; |
| 43 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; | 35 | onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>; |
| 44 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; | 36 | onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>; |
| 45 | onDeleteProjectScript: (scriptId: string) => Promise<void>; | 37 | onDeleteProjectScript: (scriptId: string) => Promise<void>; |
| 46 | onToggleTerminal: () => void; | 38 | onToggleRightPanel: () => void; |
| 47 | onTogglePreview: () => void; | ||
| 48 | onToggleDiff: () => void; | ||
| 49 | } | 39 | } |
| 50 | 40 | ||
| 51 | export function shouldShowOpenInPicker(input: { | 41 | export function shouldShowOpenInPicker(input: { |
| ... | @@ -66,28 +56,19 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -66,28 +56,19 @@ export const ChatHeader = memo(function ChatHeader({ |
| 66 | draftId, | 56 | draftId, |
| 67 | activeThreadTitle, | 57 | activeThreadTitle, |
| 68 | activeProjectName, | 58 | activeProjectName, |
| 69 | isGitRepo, | ||
| 70 | openInCwd, | 59 | openInCwd, |
| 71 | activeProjectScripts, | 60 | activeProjectScripts, |
| 72 | preferredScriptId, | 61 | preferredScriptId, |
| 73 | keybindings, | 62 | keybindings, |
| 74 | availableEditors, | 63 | availableEditors, |
| 75 | terminalAvailable, | 64 | rightPanelAvailable, |
| 76 | terminalOpen, | 65 | rightPanelOpen, |
| 77 | terminalToggleShortcutLabel, | ||
| 78 | diffToggleShortcutLabel, | ||
| 79 | previewAvailable, | ||
| 80 | previewOpen, | ||
| 81 | previewToggleShortcutLabel, | ||
| 82 | gitCwd, | 66 | gitCwd, |
| 83 | diffOpen, | ||
| 84 | onRunProjectScript, | 67 | onRunProjectScript, |
| 85 | onAddProjectScript, | 68 | onAddProjectScript, |
| 86 | onUpdateProjectScript, | 69 | onUpdateProjectScript, |
| 87 | onDeleteProjectScript, | 70 | onDeleteProjectScript, |
| 88 | onToggleTerminal, | 71 | onToggleRightPanel, |
| 89 | onTogglePreview, | ||
| 90 | onToggleDiff, | ||
| 91 | }: ChatHeaderProps) { | 72 | }: ChatHeaderProps) { |
| 92 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 73 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 93 | const showOpenInPicker = shouldShowOpenInPicker({ | 74 | const showOpenInPicker = shouldShowOpenInPicker({ |
| ... | @@ -145,70 +126,19 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -145,70 +126,19 @@ export const ChatHeader = memo(function ChatHeader({ |
| 145 | render={ | 126 | render={ |
| 146 | <Toggle | 127 | <Toggle |
| 147 | className="shrink-0" | 128 | className="shrink-0" |
| 148 | pressed={terminalOpen} | 129 | pressed={rightPanelOpen} |
| 149 | onPressedChange={onToggleTerminal} | 130 | onPressedChange={onToggleRightPanel} |
| 150 | aria-label="Toggle terminal drawer" | 131 | aria-label="Toggle right panel" |
| 151 | variant="ghost" | 132 | variant="ghost" |
| 152 | size="xs" | 133 | size="xs" |
| 153 | disabled={!terminalAvailable} | 134 | disabled={!rightPanelAvailable} |
| 154 | > | 135 | > |
| 155 | <TerminalSquareIcon className="size-3" /> | 136 | <PanelRightIcon className="size-3.5" /> |
| 156 | </Toggle> | 137 | </Toggle> |
| 157 | } | 138 | } |
| 158 | /> | 139 | /> |
| 159 | <TooltipPopup side="bottom"> | 140 | <TooltipPopup side="bottom"> |
| 160 | {!terminalAvailable | 141 | {rightPanelAvailable ? "Toggle right panel" : "Right panel is unavailable"} |
| 161 | ? "Terminal is unavailable until this thread has an active project." | ||
| 162 | : terminalToggleShortcutLabel | ||
| 163 | ? `Toggle terminal drawer (${terminalToggleShortcutLabel})` | ||
| 164 | : "Toggle terminal drawer"} | ||
| 165 | </TooltipPopup> | ||
| 166 | </Tooltip> | ||
| 167 | {previewAvailable ? ( | ||
| 168 | <Tooltip> | ||
| 169 | <TooltipTrigger | ||
| 170 | render={ | ||
| 171 | <Toggle | ||
| 172 | className="shrink-0" | ||
| 173 | pressed={previewOpen} | ||
| 174 | onPressedChange={onTogglePreview} | ||
| 175 | aria-label="Toggle preview browser" | ||
| 176 | variant="outline" | ||
| 177 | size="xs" | ||
| 178 | > | ||
| 179 | <Globe className="size-3" /> | ||
| 180 | </Toggle> | ||
| 181 | } | ||
| 182 | /> | ||
| 183 | <TooltipPopup side="bottom"> | ||
| 184 | {previewToggleShortcutLabel | ||
| 185 | ? `Toggle preview browser (${previewToggleShortcutLabel})` | ||
| 186 | : "Toggle preview browser"} | ||
| 187 | </TooltipPopup> | ||
| 188 | </Tooltip> | ||
| 189 | ) : null} | ||
| 190 | <Tooltip> | ||
| 191 | <TooltipTrigger | ||
| 192 | render={ | ||
| 193 | <Toggle | ||
| 194 | className="shrink-0" | ||
| 195 | pressed={diffOpen} | ||
| 196 | onPressedChange={onToggleDiff} | ||
| 197 | aria-label="Toggle diff panel" | ||
| 198 | variant="ghost" | ||
| 199 | size="xs" | ||
| 200 | disabled={!isGitRepo && !diffOpen} | ||
| 201 | > | ||
| 202 | <DiffIcon className="size-3" /> | ||
| 203 | </Toggle> | ||
| 204 | } | ||
| 205 | /> | ||
| 206 | <TooltipPopup side="bottom"> | ||
| 207 | {!isGitRepo && !diffOpen | ||
| 208 | ? "Diff panel is unavailable because this project is not a git repository." | ||
| 209 | : diffToggleShortcutLabel | ||
| 210 | ? `Toggle diff panel (${diffToggleShortcutLabel})` | ||
| 211 | : "Toggle diff panel"} | ||
| 212 | </TooltipPopup> | 142 | </TooltipPopup> |
| 213 | </Tooltip> | 143 | </Tooltip> |
| 214 | </div> | 144 | </div> |
apps/web/src/components/chat/MessagesTimeline.tsx+14-9| ... | @@ -1377,23 +1377,28 @@ function buildToolCallExpandedBody( | ... | @@ -1377,23 +1377,28 @@ function buildToolCallExpandedBody( |
| 1377 | workEntry: TimelineWorkEntry, | 1377 | workEntry: TimelineWorkEntry, |
| 1378 | workspaceRoot: string | undefined, | 1378 | workspaceRoot: string | undefined, |
| 1379 | ): string | null { | 1379 | ): string | null { |
| 1380 | const blocks: string[] = []; | ||
| 1381 | if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { | ||
| 1382 | blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); | ||
| 1383 | } | ||
| 1380 | const raw = workEntryRawCommand(workEntry); | 1384 | const raw = workEntryRawCommand(workEntry); |
| 1381 | if (raw?.trim()) { | 1385 | if (raw?.trim()) { |
| 1382 | return raw.trim(); | 1386 | blocks.push(raw.trim()); |
| 1383 | } | 1387 | } else if (workEntry.command?.trim()) { |
| 1384 | if (workEntry.command?.trim()) { | 1388 | blocks.push(workEntry.command.trim()); |
| 1385 | return workEntry.command.trim(); | ||
| 1386 | } | 1389 | } |
| 1387 | if (workEntry.detail?.trim()) { | 1390 | if (workEntry.detail?.trim()) { |
| 1388 | return workEntry.detail.trim(); | 1391 | blocks.push(workEntry.detail.trim()); |
| 1389 | } | 1392 | } |
| 1390 | const changedFiles = workEntry.changedFiles ?? []; | 1393 | const changedFiles = workEntry.changedFiles ?? []; |
| 1391 | if (changedFiles.length > 0) { | 1394 | if (changedFiles.length > 0) { |
| 1392 | return changedFiles | 1395 | blocks.push( |
| 1393 | .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) | 1396 | changedFiles |
| 1394 | .join("\n"); | 1397 | .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) |
| 1398 | .join("\n"), | ||
| 1399 | ); | ||
| 1395 | } | 1400 | } |
| 1396 | return null; | 1401 | return blocks.length > 0 ? blocks.join("\n\n") : null; |
| 1397 | } | 1402 | } |
| 1398 | 1403 | ||
| 1399 | function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { | 1404 | function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
apps/web/src/components/preview/PreviewAutomationOwner.tsx+40-10| ... | @@ -10,15 +10,21 @@ import type { | ... | @@ -10,15 +10,21 @@ import type { |
| 10 | PreviewAutomationStatus, | 10 | PreviewAutomationStatus, |
| 11 | ScopedThreadRef, | 11 | ScopedThreadRef, |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { useCallback, useEffect, useRef } from "react"; | 13 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 14 | 14 | ||
| 15 | import { ensureEnvironmentApi } from "~/environmentApi"; | 15 | import { ensureEnvironmentApi } from "~/environmentApi"; |
| 16 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; | 16 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; |
| 17 | import { useRightPanelStore } from "~/rightPanelStore"; | 17 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 18 | import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; | ||
| 19 | import { | ||
| 20 | startBrowserRecording, | ||
| 21 | stopBrowserRecording, | ||
| 22 | useBrowserRecordingStore, | ||
| 23 | } from "~/browser/browserRecording"; | ||
| 18 | 24 | ||
| 19 | import { previewBridge } from "./previewBridge"; | 25 | import { previewBridge } from "./previewBridge"; |
| 20 | 26 | ||
| 21 | const automationClientId = | 27 | const newAutomationClientId = (): string => |
| 22 | typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" | 28 | typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" |
| 23 | ? crypto.randomUUID() | 29 | ? crypto.randomUUID() |
| 24 | : `preview-${Math.random().toString(36).slice(2)}`; | 30 | : `preview-${Math.random().toString(36).slice(2)}`; |
| ... | @@ -112,11 +118,14 @@ export function PreviewAutomationOwner(props: { | ... | @@ -112,11 +118,14 @@ export function PreviewAutomationOwner(props: { |
| 112 | readonly visible: boolean; | 118 | readonly visible: boolean; |
| 113 | }) { | 119 | }) { |
| 114 | const { threadRef, visible } = props; | 120 | const { threadRef, visible } = props; |
| 121 | const [automationClientId] = useState(newAutomationClientId); | ||
| 115 | const ownerStateRef = useRef({ threadRef, visible }); | 122 | const ownerStateRef = useRef({ threadRef, visible }); |
| 116 | const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>( | 123 | const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>( |
| 117 | async () => undefined, | 124 | async () => undefined, |
| 118 | ); | 125 | ); |
| 119 | ownerStateRef.current = { threadRef, visible }; | 126 | useEffect(() => { |
| 127 | ownerStateRef.current = { threadRef, visible }; | ||
| 128 | }, [threadRef, visible]); | ||
| 120 | 129 | ||
| 121 | const handleRequest = useCallback( | 130 | const handleRequest = useCallback( |
| 122 | async (request: PreviewAutomationRequest): Promise<unknown> => { | 131 | async (request: PreviewAutomationRequest): Promise<unknown> => { |
| ... | @@ -136,9 +145,6 @@ export function PreviewAutomationOwner(props: { | ... | @@ -136,9 +145,6 @@ export function PreviewAutomationOwner(props: { |
| 136 | return currentStatus(threadRef, visible); | 145 | return currentStatus(threadRef, visible); |
| 137 | case "open": { | 146 | case "open": { |
| 138 | const input = request.input as PreviewAutomationOpenInput; | 147 | const input = request.input as PreviewAutomationOpenInput; |
| 139 | if (input.show ?? true) { | ||
| 140 | useRightPanelStore.getState().open(threadRef, "preview"); | ||
| 141 | } | ||
| 142 | let activeTabId = | 148 | let activeTabId = |
| 143 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; | 149 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; |
| 144 | if (!activeTabId) { | 150 | if (!activeTabId) { |
| ... | @@ -151,13 +157,20 @@ export function PreviewAutomationOwner(props: { | ... | @@ -151,13 +157,20 @@ export function PreviewAutomationOwner(props: { |
| 151 | } else if (input.url && previewBridge) { | 157 | } else if (input.url && previewBridge) { |
| 152 | await previewBridge.navigate(activeTabId, input.url); | 158 | await previewBridge.navigate(activeTabId, input.url); |
| 153 | } | 159 | } |
| 160 | if (input.show ?? true) { | ||
| 161 | useRightPanelStore.getState().openBrowser(threadRef, activeTabId); | ||
| 162 | } | ||
| 154 | await waitForDesktopOverlay(threadRef, request.timeoutMs); | 163 | await waitForDesktopOverlay(threadRef, request.timeoutMs); |
| 155 | return currentStatus(threadRef, input.show ?? true); | 164 | return currentStatus(threadRef, input.show ?? true); |
| 156 | } | 165 | } |
| 157 | case "navigate": { | 166 | case "navigate": { |
| 158 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | 167 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); |
| 159 | const input = request.input as PreviewAutomationNavigateInput; | 168 | const input = request.input as PreviewAutomationNavigateInput; |
| 160 | await previewBridge.navigate(tabId, input.url); | 169 | const resolution = resolveBrowserNavigationTarget( |
| 170 | threadRef.environmentId, | ||
| 171 | input.target ?? { kind: "url", url: input.url! }, | ||
| 172 | ); | ||
| 173 | await previewBridge.navigate(tabId, resolution.resolvedUrl); | ||
| 161 | await waitForNavigationReadiness( | 174 | await waitForNavigationReadiness( |
| 162 | tabId, | 175 | tabId, |
| 163 | input.readiness ?? "load", | 176 | input.readiness ?? "load", |
| ... | @@ -204,11 +217,28 @@ export function PreviewAutomationOwner(props: { | ... | @@ -204,11 +217,28 @@ export function PreviewAutomationOwner(props: { |
| 204 | tabId, | 217 | tabId, |
| 205 | request.input as Parameters<typeof previewBridge.automation.waitFor>[1], | 218 | request.input as Parameters<typeof previewBridge.automation.waitFor>[1], |
| 206 | ); | 219 | ); |
| 220 | case "recordingStart": { | ||
| 221 | if (!tabId) throw new Error("Preview tab is not initialized."); | ||
| 222 | await startBrowserRecording(tabId); | ||
| 223 | return { | ||
| 224 | tabId, | ||
| 225 | recording: true, | ||
| 226 | startedAt: useBrowserRecordingStore.getState().startedAt, | ||
| 227 | }; | ||
| 228 | } | ||
| 229 | case "recordingStop": { | ||
| 230 | if (!tabId) throw new Error("Preview tab is not initialized."); | ||
| 231 | const artifact = await stopBrowserRecording(tabId); | ||
| 232 | if (!artifact) throw new Error("No active recording exists for this preview tab."); | ||
| 233 | return artifact; | ||
| 234 | } | ||
| 207 | } | 235 | } |
| 208 | }, | 236 | }, |
| 209 | [threadRef, visible], | 237 | [threadRef, visible], |
| 210 | ); | 238 | ); |
| 211 | handlerRef.current = handleRequest; | 239 | useEffect(() => { |
| 240 | handlerRef.current = handleRequest; | ||
| 241 | }, [handleRequest]); | ||
| 212 | 242 | ||
| 213 | useEffect(() => { | 243 | useEffect(() => { |
| 214 | const api = ensureEnvironmentApi(threadRef.environmentId); | 244 | const api = ensureEnvironmentApi(threadRef.environmentId); |
| ... | @@ -249,7 +279,7 @@ export function PreviewAutomationOwner(props: { | ... | @@ -249,7 +279,7 @@ export function PreviewAutomationOwner(props: { |
| 249 | }, | 279 | }, |
| 250 | }, | 280 | }, |
| 251 | ); | 281 | ); |
| 252 | }, [threadRef.environmentId]); | 282 | }, [automationClientId, threadRef.environmentId]); |
| 253 | 283 | ||
| 254 | useEffect(() => { | 284 | useEffect(() => { |
| 255 | const api = ensureEnvironmentApi(threadRef.environmentId); | 285 | const api = ensureEnvironmentApi(threadRef.environmentId); |
| ... | @@ -281,7 +311,7 @@ export function PreviewAutomationOwner(props: { | ... | @@ -281,7 +311,7 @@ export function PreviewAutomationOwner(props: { |
| 281 | unsubscribe(); | 311 | unsubscribe(); |
| 282 | void api.preview.automation.clearOwner({ clientId: automationClientId }); | 312 | void api.preview.automation.clearOwner({ clientId: automationClientId }); |
| 283 | }; | 313 | }; |
| 284 | }, [threadRef, visible]); | 314 | }, [automationClientId, threadRef, visible]); |
| 285 | 315 | ||
| 286 | return null; | 316 | return null; |
| 287 | } | 317 | } |
apps/web/src/components/preview/PreviewChromeRow.tsx+43-8| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | ArrowLeft, | 2 | ArrowLeft, |
| 3 | ArrowRight, | 3 | ArrowRight, |
| 4 | Camera, | ||
| 4 | ExternalLink, | 5 | ExternalLink, |
| 5 | Globe, | 6 | Globe, |
| 6 | MousePointerClick, | 7 | MousePointerClick, |
| ... | @@ -36,6 +37,9 @@ interface Props { | ... | @@ -36,6 +37,9 @@ interface Props { |
| 36 | onSubmit: (url: string) => void; | 37 | onSubmit: (url: string) => void; |
| 37 | /** When provided, renders an "Open in browser" affordance to the right. */ | 38 | /** When provided, renders an "Open in browser" affordance to the right. */ |
| 38 | onOpenInBrowser?: (() => void) | undefined; | 39 | onOpenInBrowser?: (() => void) | undefined; |
| 40 | onCapture?: ((record: boolean) => void) | undefined; | ||
| 41 | captureDisabled?: boolean | undefined; | ||
| 42 | recording?: boolean | undefined; | ||
| 39 | /** | 43 | /** |
| 40 | * When provided, renders an annotation-mode toggle button to the right of | 44 | * When provided, renders an annotation-mode toggle button to the right of |
| 41 | * the URL input. Pressed while annotation mode is active (button shows in `pressed` | 45 | * the URL input. Pressed while annotation mode is active (button shows in `pressed` |
| ... | @@ -69,6 +73,9 @@ export function PreviewChromeRow({ | ... | @@ -69,6 +73,9 @@ export function PreviewChromeRow({ |
| 69 | onRefresh, | 73 | onRefresh, |
| 70 | onSubmit, | 74 | onSubmit, |
| 71 | onOpenInBrowser, | 75 | onOpenInBrowser, |
| 76 | onCapture, | ||
| 77 | captureDisabled, | ||
| 78 | recording, | ||
| 72 | onPickElement, | 79 | onPickElement, |
| 73 | pickActive, | 80 | pickActive, |
| 74 | pickDisabled, | 81 | pickDisabled, |
| ... | @@ -97,13 +104,14 @@ export function PreviewChromeRow({ | ... | @@ -97,13 +104,14 @@ export function PreviewChromeRow({ |
| 97 | const next = draft.trim(); | 104 | const next = draft.trim(); |
| 98 | if (next.length === 0) return; | 105 | if (next.length === 0) return; |
| 99 | onSubmit(next); | 106 | onSubmit(next); |
| 107 | inputRef.current?.blur(); | ||
| 100 | }; | 108 | }; |
| 101 | 109 | ||
| 102 | return ( | 110 | return ( |
| 103 | <div className="relative"> | 111 | <div className="relative"> |
| 104 | <form | 112 | <form |
| 105 | onSubmit={submit} | 113 | onSubmit={submit} |
| 106 | className="flex items-center gap-2 border-b border-border bg-background px-2 py-1.5" | 114 | className="flex h-10 items-center gap-1 border-b border-border/70 bg-background px-2" |
| 107 | > | 115 | > |
| 108 | <div className="flex items-center gap-0.5" role="group" aria-label="Navigation"> | 116 | <div className="flex items-center gap-0.5" role="group" aria-label="Navigation"> |
| 109 | <Tooltip> | 117 | <Tooltip> |
| ... | @@ -159,7 +167,7 @@ export function PreviewChromeRow({ | ... | @@ -159,7 +167,7 @@ export function PreviewChromeRow({ |
| 159 | </Tooltip> | 167 | </Tooltip> |
| 160 | </div> | 168 | </div> |
| 161 | 169 | ||
| 162 | <InputGroup className="flex-1"> | 170 | <InputGroup className="h-7 flex-1 rounded-md"> |
| 163 | <InputGroupAddon align="inline-start"> | 171 | <InputGroupAddon align="inline-start"> |
| 164 | <Globe className="size-3.5 text-muted-foreground" aria-hidden /> | 172 | <Globe className="size-3.5 text-muted-foreground" aria-hidden /> |
| 165 | </InputGroupAddon> | 173 | </InputGroupAddon> |
| ... | @@ -181,6 +189,26 @@ export function PreviewChromeRow({ | ... | @@ -181,6 +189,26 @@ export function PreviewChromeRow({ |
| 181 | data-preview-url-input | 189 | data-preview-url-input |
| 182 | size="sm" | 190 | size="sm" |
| 183 | /> | 191 | /> |
| 192 | {onOpenInBrowser ? ( | ||
| 193 | <InputGroupAddon align="inline-end"> | ||
| 194 | <Tooltip> | ||
| 195 | <TooltipTrigger | ||
| 196 | render={ | ||
| 197 | <Button | ||
| 198 | variant="ghost" | ||
| 199 | size="icon-xs" | ||
| 200 | onClick={onOpenInBrowser} | ||
| 201 | aria-label="Open in system browser" | ||
| 202 | type="button" | ||
| 203 | /> | ||
| 204 | } | ||
| 205 | > | ||
| 206 | <ExternalLink /> | ||
| 207 | </TooltipTrigger> | ||
| 208 | <TooltipPopup>Open in system browser</TooltipPopup> | ||
| 209 | </Tooltip> | ||
| 210 | </InputGroupAddon> | ||
| 211 | ) : null} | ||
| 184 | </InputGroup> | 212 | </InputGroup> |
| 185 | 213 | ||
| 186 | {onPickElement ? ( | 214 | {onPickElement ? ( |
| ... | @@ -209,22 +237,29 @@ export function PreviewChromeRow({ | ... | @@ -209,22 +237,29 @@ export function PreviewChromeRow({ |
| 209 | </TooltipPopup> | 237 | </TooltipPopup> |
| 210 | </Tooltip> | 238 | </Tooltip> |
| 211 | ) : null} | 239 | ) : null} |
| 212 | {onOpenInBrowser ? ( | 240 | {onCapture ? ( |
| 213 | <Tooltip> | 241 | <Tooltip> |
| 214 | <TooltipTrigger | 242 | <TooltipTrigger |
| 215 | render={ | 243 | render={ |
| 216 | <Button | 244 | <Button |
| 217 | variant="ghost" | 245 | variant={recording ? "secondary" : "ghost"} |
| 218 | size="icon-xs" | 246 | size="icon-xs" |
| 219 | onClick={onOpenInBrowser} | 247 | onClick={(event) => onCapture(event.shiftKey)} |
| 220 | aria-label="Open in system browser" | 248 | aria-label={recording ? "Stop recording" : "Capture screenshot"} |
| 221 | type="button" | 249 | type="button" |
| 250 | className="relative" | ||
| 251 | disabled={captureDisabled} | ||
| 222 | /> | 252 | /> |
| 223 | } | 253 | } |
| 224 | > | 254 | > |
| 225 | <ExternalLink /> | 255 | <Camera className={cn(recording && "text-destructive")} /> |
| 256 | {recording ? ( | ||
| 257 | <span className="absolute right-0.5 top-0.5 size-1.5 animate-pulse rounded-full bg-destructive" /> | ||
| 258 | ) : null} | ||
| 226 | </TooltipTrigger> | 259 | </TooltipTrigger> |
| 227 | <TooltipPopup>Open in system browser</TooltipPopup> | 260 | <TooltipPopup> |
| 261 | {recording ? "Stop recording" : "Screenshot · Shift-click to record"} | ||
| 262 | </TooltipPopup> | ||
| 228 | </Tooltip> | 263 | </Tooltip> |
| 229 | ) : null} | 264 | ) : null} |
| 230 | {trailingActions} | 265 | {trailingActions} |
apps/web/src/components/preview/PreviewEmptyState.tsx+19-13| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { Globe } from "lucide-react"; | 2 | import { Globe, RadioTower } from "lucide-react"; |
| 3 | 3 | ||
| 4 | import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; | 4 | import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; |
| 5 | 5 | ||
| ... | @@ -41,18 +41,24 @@ export function PreviewEmptyState({ | ... | @@ -41,18 +41,24 @@ export function PreviewEmptyState({ |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | return ( | 43 | return ( |
| 44 | <div className="flex h-full min-h-0 flex-col overflow-y-auto"> | 44 | <div className="flex h-full min-h-0 overflow-y-auto px-5 py-8"> |
| 45 | <h2 className="px-4 pt-4 pb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80"> | 45 | <div className="m-auto flex w-full max-w-xl flex-col gap-3"> |
| 46 | Local | 46 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> |
| 47 | </h2> | 47 | <RadioTower className="size-4 shrink-0" /> |
| 48 | <div className="flex flex-col gap-1.5 px-3 pb-4"> | 48 | <h2 className="font-medium">Local servers</h2> |
| 49 | {servers.map((server) => ( | 49 | </div> |
| 50 | <PreviewLocalServerCard | 50 | <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background"> |
| 51 | key={`${server.host}:${server.port}`} | 51 | {servers.map((server) => ( |
| 52 | server={server} | 52 | <PreviewLocalServerCard |
| 53 | onOpen={() => onOpenUrl(server.url)} | 53 | key={`${server.host}:${server.port}`} |
| 54 | /> | 54 | server={server} |
| 55 | ))} | 55 | onOpen={() => onOpenUrl(server.url)} |
| 56 | /> | ||
| 57 | ))} | ||
| 58 | </div> | ||
| 59 | <p className="px-1 text-xs text-muted-foreground"> | ||
| 60 | Select a listening port to open it in this browser tab. | ||
| 61 | </p> | ||
| 56 | </div> | 62 | </div> |
| 57 | </div> | 63 | </div> |
| 58 | ); | 64 | ); |
apps/web/src/components/preview/PreviewLocalServerCard.tsx+3-9| ... | @@ -1,5 +1,3 @@ | ... | @@ -1,5 +1,3 @@ |
| 1 | import { cn } from "~/lib/utils"; | ||
| 2 | |||
| 3 | import { BrowserMockup } from "./BrowserMockup"; | 1 | import { BrowserMockup } from "./BrowserMockup"; |
| 4 | import type { PreviewableServer } from "./useDiscoveredLocalServers"; | 2 | import type { PreviewableServer } from "./useDiscoveredLocalServers"; |
| 5 | 3 | ||
| ... | @@ -14,18 +12,14 @@ export function PreviewLocalServerCard({ server, onOpen }: Props) { | ... | @@ -14,18 +12,14 @@ export function PreviewLocalServerCard({ server, onOpen }: Props) { |
| 14 | <button | 12 | <button |
| 15 | type="button" | 13 | type="button" |
| 16 | onClick={onOpen} | 14 | onClick={onOpen} |
| 17 | className={cn( | 15 | className="group flex w-full items-center gap-3 px-3 py-3 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" |
| 18 | "group flex w-full items-center gap-3 rounded-xl border border-border/70 bg-card px-3 py-2.5 text-left transition-colors", | ||
| 19 | "hover:border-border hover:bg-accent/40", | ||
| 20 | "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", | ||
| 21 | )} | ||
| 22 | > | 16 | > |
| 23 | <BrowserMockup className="size-7 shrink-0" /> | 17 | <BrowserMockup className="size-7 shrink-0" /> |
| 24 | <div className="flex min-w-0 flex-1 flex-col"> | 18 | <div className="flex min-w-0 flex-1 flex-col"> |
| 25 | <span className="truncate text-sm font-medium text-foreground"> | 19 | <span className="truncate text-sm font-medium text-foreground">{subtitle}</span> |
| 20 | <span className="truncate text-xs text-muted-foreground"> | ||
| 26 | {server.host}:{server.port} | 21 | {server.host}:{server.port} |
| 27 | </span> | 22 | </span> |
| 28 | <span className="truncate text-xs text-muted-foreground">{subtitle}</span> | ||
| 29 | </div> | 23 | </div> |
| 30 | {server.listening ? <PulsingDot /> : <DimDot />} | 24 | {server.listening ? <PulsingDot /> : <DimDot />} |
| 31 | </button> | 25 | </button> |
apps/web/src/components/preview/PreviewMoreMenu.tsx-2| ... | @@ -36,7 +36,6 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { | ... | @@ -36,7 +36,6 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { |
| 36 | }; | 36 | }; |
| 37 | 37 | ||
| 38 | const zoomLabel = `${Math.round(zoomFactor * 100)}%`; | 38 | const zoomLabel = `${Math.round(zoomFactor * 100)}%`; |
| 39 | |||
| 40 | return ( | 39 | return ( |
| 41 | <Menu> | 40 | <Menu> |
| 42 | <Tooltip> | 41 | <Tooltip> |
| ... | @@ -60,7 +59,6 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { | ... | @@ -60,7 +59,6 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { |
| 60 | <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}> | 59 | <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}> |
| 61 | Open DevTools | 60 | Open DevTools |
| 62 | </MenuItem> | 61 | </MenuItem> |
| 63 | <MenuSeparator /> | ||
| 64 | {/* | 62 | {/* |
| 65 | Zoom row: label + inline control cluster. `closeOnClick=false` | 63 | Zoom row: label + inline control cluster. `closeOnClick=false` |
| 66 | keeps the menu open while the user clicks the +/− buttons. | 64 | keeps the menu open while the user clicks the +/− buttons. |
apps/web/src/components/preview/PreviewPanel.tsx+9-3| ... | @@ -10,15 +10,16 @@ import { PreviewView } from "./PreviewView"; | ... | @@ -10,15 +10,16 @@ import { PreviewView } from "./PreviewView"; |
| 10 | interface Props { | 10 | interface Props { |
| 11 | mode: PreviewPanelMode; | 11 | mode: PreviewPanelMode; |
| 12 | threadRef: ScopedThreadRef; | 12 | threadRef: ScopedThreadRef; |
| 13 | tabId?: string | null; | ||
| 13 | configuredUrls?: ReadonlyArray<string> | undefined; | 14 | configuredUrls?: ReadonlyArray<string> | undefined; |
| 14 | visible: boolean; | 15 | visible: boolean; |
| 15 | } | 16 | } |
| 16 | 17 | ||
| 17 | export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props) { | 18 | export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible }: Props) { |
| 18 | if (!isPreviewSupportedInRuntime()) { | 19 | if (!isPreviewSupportedInRuntime()) { |
| 19 | return ( | 20 | return ( |
| 20 | <PreviewPanelShell mode={mode}> | 21 | <PreviewPanelShell mode={mode}> |
| 21 | <div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center"> | 22 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-8 text-center"> |
| 22 | <p className="max-w-sm text-sm text-muted-foreground"> | 23 | <p className="max-w-sm text-sm text-muted-foreground"> |
| 23 | Preview is only available in the T3 Code desktop app. | 24 | Preview is only available in the T3 Code desktop app. |
| 24 | </p> | 25 | </p> |
| ... | @@ -29,7 +30,12 @@ export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props | ... | @@ -29,7 +30,12 @@ export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props |
| 29 | 30 | ||
| 30 | return ( | 31 | return ( |
| 31 | <PreviewPanelShell mode={mode}> | 32 | <PreviewPanelShell mode={mode}> |
| 32 | <PreviewView threadRef={threadRef} configuredUrls={configuredUrls} visible={visible} /> | 33 | <PreviewView |
| 34 | threadRef={threadRef} | ||
| 35 | {...(tabId !== undefined ? { tabId } : {})} | ||
| 36 | configuredUrls={configuredUrls} | ||
| 37 | visible={visible} | ||
| 38 | /> | ||
| 33 | </PreviewPanelShell> | 39 | </PreviewPanelShell> |
| 34 | ); | 40 | ); |
| 35 | } | 41 | } |
apps/web/src/components/preview/PreviewPanelShell.tsx+3-3| ... | @@ -6,7 +6,7 @@ import { cn } from "~/lib/utils"; | ... | @@ -6,7 +6,7 @@ import { cn } from "~/lib/utils"; |
| 6 | 6 | ||
| 7 | import { RightPanelResizeHandle } from "./RightPanelResizeHandle"; | 7 | import { RightPanelResizeHandle } from "./RightPanelResizeHandle"; |
| 8 | 8 | ||
| 9 | export type PreviewPanelMode = "inline" | "sheet" | "sidebar"; | 9 | export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; |
| 10 | 10 | ||
| 11 | const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; | 11 | const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; |
| 12 | const PREVIEW_PANEL_MIN_WIDTH = 360; | 12 | const PREVIEW_PANEL_MIN_WIDTH = 360; |
| ... | @@ -22,7 +22,7 @@ const PREVIEW_PANEL_DEFAULT_WIDTH = 540; | ... | @@ -22,7 +22,7 @@ const PREVIEW_PANEL_DEFAULT_WIDTH = 540; |
| 22 | * sheet/sidebar modes the parent owns the size. | 22 | * sheet/sidebar modes the parent owns the size. |
| 23 | */ | 23 | */ |
| 24 | export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: ReactNode }) { | 24 | export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: ReactNode }) { |
| 25 | const useDragRegion = isElectron && props.mode !== "sheet"; | 25 | const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; |
| 26 | const isInline = props.mode === "inline"; | 26 | const isInline = props.mode === "inline"; |
| 27 | const maxWidth = useViewportClampedMaxWidth(); | 27 | const maxWidth = useViewportClampedMaxWidth(); |
| 28 | const { width, handlers } = useResizableWidth({ | 28 | const { width, handlers } = useResizableWidth({ |
| ... | @@ -36,7 +36,7 @@ export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: Rea | ... | @@ -36,7 +36,7 @@ export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: Rea |
| 36 | return ( | 36 | return ( |
| 37 | <div | 37 | <div |
| 38 | className={cn( | 38 | className={cn( |
| 39 | "relative flex h-full min-w-0 flex-col bg-background", | 39 | "relative flex h-full min-h-0 min-w-0 flex-col self-stretch bg-background", |
| 40 | isInline ? "shrink-0 border-l border-border" : "w-full", | 40 | isInline ? "shrink-0 border-l border-border" : "w-full", |
| 41 | )} | 41 | )} |
| 42 | style={isInline ? { width: `${width}px` } : undefined} | 42 | style={isInline ? { width: `${width}px` } : undefined} |
apps/web/src/components/preview/PreviewView.tsx+93-12| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | } from "~/lib/previewAnnotation"; | 13 | } from "~/lib/previewAnnotation"; |
| 14 | import { ensureLocalApi } from "~/localApi"; | 14 | import { ensureLocalApi } from "~/localApi"; |
| 15 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; | 15 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; |
| 16 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | ||
| 16 | 17 | ||
| 17 | import { previewBridge } from "./previewBridge"; | 18 | import { previewBridge } from "./previewBridge"; |
| 18 | import { subscribePreviewAction } from "./previewActionBus"; | 19 | import { subscribePreviewAction } from "./previewActionBus"; |
| ... | @@ -21,13 +22,20 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; | ... | @@ -21,13 +22,20 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 21 | import { PreviewEmptyState } from "./PreviewEmptyState"; | 22 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 22 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; | 23 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; |
| 23 | import { PreviewUnreachable } from "./PreviewUnreachable"; | 24 | import { PreviewUnreachable } from "./PreviewUnreachable"; |
| 24 | import { PreviewWebview } from "./PreviewWebview"; | 25 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 25 | import { useLoadingProgress } from "./useLoadingProgress"; | 26 | import { useLoadingProgress } from "./useLoadingProgress"; |
| 26 | import { usePreviewSession } from "./usePreviewSession"; | 27 | import { usePreviewSession } from "./usePreviewSession"; |
| 27 | import { ZoomIndicator } from "./ZoomIndicator"; | 28 | import { ZoomIndicator } from "./ZoomIndicator"; |
| 29 | import { | ||
| 30 | startBrowserRecording, | ||
| 31 | stopBrowserRecording, | ||
| 32 | useBrowserRecordingStore, | ||
| 33 | } from "~/browser/browserRecording"; | ||
| 34 | import { toastManager } from "~/components/ui/toast"; | ||
| 28 | 35 | ||
| 29 | interface Props { | 36 | interface Props { |
| 30 | threadRef: ScopedThreadRef; | 37 | threadRef: ScopedThreadRef; |
| 38 | tabId?: string | null; | ||
| 31 | configuredUrls?: ReadonlyArray<string> | undefined; | 39 | configuredUrls?: ReadonlyArray<string> | undefined; |
| 32 | visible: boolean; | 40 | visible: boolean; |
| 33 | } | 41 | } |
| ... | @@ -38,9 +46,10 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); | ... | @@ -38,9 +46,10 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 38 | * Single-tab preview surface: chrome row on top, one webview below, empty | 46 | * Single-tab preview surface: chrome row on top, one webview below, empty |
| 39 | * state when no session exists for the thread. | 47 | * state when no session exists for the thread. |
| 40 | */ | 48 | */ |
| 41 | export function PreviewView({ threadRef, configuredUrls, visible }: Props) { | 49 | export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { |
| 42 | const [focusUrlNonce, setFocusUrlNonce] = useState(0); | 50 | const [focusUrlNonce, setFocusUrlNonce] = useState(0); |
| 43 | const [pickActive, setPickActive] = useState(false); | 51 | const [pickActive, setPickActive] = useState(false); |
| 52 | const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId); | ||
| 44 | const pickActiveRef = useRef(false); | 53 | const pickActiveRef = useRef(false); |
| 45 | const isMountedRef = useRef(true); | 54 | const isMountedRef = useRef(true); |
| 46 | const previewState = usePreviewStateStore((state) => | 55 | const previewState = usePreviewStateStore((state) => |
| ... | @@ -62,8 +71,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { | ... | @@ -62,8 +71,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 62 | }; | 71 | }; |
| 63 | }, []); | 72 | }, []); |
| 64 | 73 | ||
| 65 | const { snapshot, desktopOverlay } = previewState; | 74 | const tabId = requestedTabId ?? previewState.activeTabId; |
| 66 | const tabId = snapshot?.tabId ?? null; | 75 | const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null; |
| 76 | const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null; | ||
| 67 | const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; | 77 | const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; |
| 68 | const url = navStatus._tag === "Idle" ? "" : navStatus.url; | 78 | const url = navStatus._tag === "Idle" ? "" : navStatus.url; |
| 69 | const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading"; | 79 | const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading"; |
| ... | @@ -71,22 +81,24 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { | ... | @@ -71,22 +81,24 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 71 | const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false; | 81 | const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false; |
| 72 | const refreshDisabled = navStatus._tag === "Idle"; | 82 | const refreshDisabled = navStatus._tag === "Idle"; |
| 73 | const isUnreachable = navStatus._tag === "LoadFailed"; | 83 | const isUnreachable = navStatus._tag === "LoadFailed"; |
| 84 | const controller = desktopOverlay?.controller ?? "none"; | ||
| 74 | const loadProgress = useLoadingProgress(loading); | 85 | const loadProgress = useLoadingProgress(loading); |
| 75 | 86 | ||
| 76 | const handleSubmitUrl = useCallback( | 87 | const handleSubmitUrl = useCallback( |
| 77 | async (next: string) => { | 88 | async (next: string) => { |
| 78 | const api = ensureEnvironmentApi(threadRef.environmentId); | 89 | const api = ensureEnvironmentApi(threadRef.environmentId); |
| 79 | try { | 90 | try { |
| 91 | const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next); | ||
| 80 | if (tabId && previewBridge) { | 92 | if (tabId && previewBridge) { |
| 81 | // Drive the webview imperatively; `usePreviewBridge` mirrors the | 93 | // Drive the webview imperatively; `usePreviewBridge` mirrors the |
| 82 | // resolved URL back to the server so other clients stay in sync. | 94 | // resolved URL back to the server so other clients stay in sync. |
| 83 | await previewBridge.navigate(tabId, next); | 95 | await previewBridge.navigate(tabId, resolvedUrl); |
| 84 | rememberUrl(threadRef, next); | 96 | rememberUrl(threadRef, resolvedUrl); |
| 85 | } else { | 97 | } else { |
| 86 | await openPreviewSession({ | 98 | await openPreviewSession({ |
| 87 | previewApi: api.preview, | 99 | previewApi: api.preview, |
| 88 | threadRef, | 100 | threadRef, |
| 89 | url: next, | 101 | url: resolvedUrl, |
| 90 | applyServerSnapshot, | 102 | applyServerSnapshot, |
| 91 | rememberUrl, | 103 | rememberUrl, |
| 92 | }); | 104 | }); |
| ... | @@ -127,6 +139,68 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { | ... | @@ -127,6 +139,68 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 127 | void localApi.shell.openExternal(url).catch(() => undefined); | 139 | void localApi.shell.openExternal(url).catch(() => undefined); |
| 128 | }, [url]); | 140 | }, [url]); |
| 129 | 141 | ||
| 142 | const handleCapture = useCallback( | ||
| 143 | (record: boolean) => { | ||
| 144 | if (!previewBridge || !tabId) return; | ||
| 145 | const recordingThisTab = activeRecordingTabId === tabId; | ||
| 146 | if (recordingThisTab) { | ||
| 147 | void stopBrowserRecording(tabId).then( | ||
| 148 | (artifact) => { | ||
| 149 | if (!artifact) return; | ||
| 150 | toastManager.add({ | ||
| 151 | type: "success", | ||
| 152 | title: "Recording saved", | ||
| 153 | description: artifact.path, | ||
| 154 | }); | ||
| 155 | }, | ||
| 156 | (error) => { | ||
| 157 | toastManager.add({ | ||
| 158 | type: "error", | ||
| 159 | title: "Unable to stop recording", | ||
| 160 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 161 | }); | ||
| 162 | }, | ||
| 163 | ); | ||
| 164 | return; | ||
| 165 | } | ||
| 166 | if (record) { | ||
| 167 | if (activeRecordingTabId !== null) { | ||
| 168 | toastManager.add({ | ||
| 169 | type: "warning", | ||
| 170 | title: "Another preview is recording", | ||
| 171 | description: "Stop the active recording before starting a new one.", | ||
| 172 | }); | ||
| 173 | return; | ||
| 174 | } | ||
| 175 | void startBrowserRecording(tabId).catch((error) => { | ||
| 176 | toastManager.add({ | ||
| 177 | type: "error", | ||
| 178 | title: "Unable to start recording", | ||
| 179 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 180 | }); | ||
| 181 | }); | ||
| 182 | return; | ||
| 183 | } | ||
| 184 | void previewBridge.captureScreenshot(tabId).then( | ||
| 185 | (artifact) => { | ||
| 186 | toastManager.add({ | ||
| 187 | type: "success", | ||
| 188 | title: "Screenshot saved", | ||
| 189 | description: artifact.path, | ||
| 190 | }); | ||
| 191 | }, | ||
| 192 | (error) => { | ||
| 193 | toastManager.add({ | ||
| 194 | type: "error", | ||
| 195 | title: "Unable to capture screenshot", | ||
| 196 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 197 | }); | ||
| 198 | }, | ||
| 199 | ); | ||
| 200 | }, | ||
| 201 | [activeRecordingTabId, tabId], | ||
| 202 | ); | ||
| 203 | |||
| 130 | const handlePickElement = useCallback(() => { | 204 | const handlePickElement = useCallback(() => { |
| 131 | if (!previewBridge || !tabId) return; | 205 | if (!previewBridge || !tabId) return; |
| 132 | if (pickActiveRef.current) { | 206 | if (pickActiveRef.current) { |
| ... | @@ -232,7 +306,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { | ... | @@ -232,7 +306,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 232 | 306 | ||
| 233 | return ( | 307 | return ( |
| 234 | <div | 308 | <div |
| 235 | className="flex h-full min-h-0 flex-col bg-background" | 309 | className="flex min-h-0 flex-1 flex-col bg-background" |
| 236 | data-thread-key={scopedThreadKey(threadRef)} | 310 | data-thread-key={scopedThreadKey(threadRef)} |
| 237 | > | 311 | > |
| 238 | <PreviewChromeRow | 312 | <PreviewChromeRow |
| ... | @@ -248,6 +322,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { | ... | @@ -248,6 +322,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 248 | onRefresh={handleRefresh} | 322 | onRefresh={handleRefresh} |
| 249 | onSubmit={(next) => void handleSubmitUrl(next)} | 323 | onSubmit={(next) => void handleSubmitUrl(next)} |
| 250 | onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} | 324 | onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} |
| 325 | onCapture={previewBridge && tabId ? handleCapture : undefined} | ||
| 326 | captureDisabled={!desktopOverlay || isUnreachable} | ||
| 327 | recording={tabId !== null && activeRecordingTabId === tabId} | ||
| 251 | onPickElement={previewBridge && tabId ? handlePickElement : undefined} | 328 | onPickElement={previewBridge && tabId ? handlePickElement : undefined} |
| 252 | pickActive={pickActive} | 329 | pickActive={pickActive} |
| 253 | // Disable when there's no tab (nothing to pick on) OR the page | 330 | // Disable when there's no tab (nothing to pick on) OR the page |
| ... | @@ -268,13 +345,12 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { | ... | @@ -268,13 +345,12 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 268 | } | 345 | } |
| 269 | /> | 346 | /> |
| 270 | 347 | ||
| 271 | <div className="relative flex-1 overflow-hidden"> | 348 | <div className="relative min-h-0 flex-1 overflow-hidden"> |
| 272 | {tabId && snapshot ? ( | 349 | {tabId && snapshot ? ( |
| 273 | <PreviewWebview | 350 | <BrowserSurfaceSlot |
| 274 | key={tabId} | 351 | key={tabId} |
| 275 | threadRef={threadRef} | ||
| 276 | tabId={tabId} | 352 | tabId={tabId} |
| 277 | initialUrl={url || null} | 353 | visible={visible && !isUnreachable} |
| 278 | className="absolute inset-0 h-full w-full" | 354 | className="absolute inset-0 h-full w-full" |
| 279 | /> | 355 | /> |
| 280 | ) : ( | 356 | ) : ( |
| ... | @@ -288,6 +364,11 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { | ... | @@ -288,6 +364,11 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { |
| 288 | {snapshot && desktopOverlay ? ( | 364 | {snapshot && desktopOverlay ? ( |
| 289 | <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} /> | 365 | <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} /> |
| 290 | ) : null} | 366 | ) : null} |
| 367 | {controller !== "none" ? ( | ||
| 368 | <div className="pointer-events-none absolute left-3 top-3 z-40 rounded-full border border-border/70 bg-background/90 px-2.5 py-1 text-[11px] font-medium shadow-sm backdrop-blur"> | ||
| 369 | {controller === "agent" ? "Agent controlling browser" : "Human control"} | ||
| 370 | </div> | ||
| 371 | ) : null} | ||
| 291 | {navStatus._tag === "LoadFailed" ? ( | 372 | {navStatus._tag === "LoadFailed" ? ( |
| 292 | <div className="absolute inset-0 z-10 bg-background"> | 373 | <div className="absolute inset-0 z-10 bg-background"> |
| 293 | <PreviewUnreachable | 374 | <PreviewUnreachable |
apps/web/src/components/preview/PreviewWebview.tsx deleted-142| ... | @@ -1,142 +0,0 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts"; | ||
| 4 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 5 | |||
| 6 | import { isElectron } from "~/env"; | ||
| 7 | |||
| 8 | import { previewBridge } from "./previewBridge"; | ||
| 9 | import { usePreviewBridge } from "./usePreviewBridge"; | ||
| 10 | |||
| 11 | interface Props { | ||
| 12 | threadRef: ScopedThreadRef; | ||
| 13 | tabId: string; | ||
| 14 | /** | ||
| 15 | * URL to load on first mount. Subsequent prop changes are ignored — once | ||
| 16 | * the webview is live, navigation flows exclusively through the bridge | ||
| 17 | * (`previewBridge.navigate`, `goBack`, `goForward`, `refresh`). Otherwise, | ||
| 18 | * a snapshot URL update from the server would re-set `<webview src>` and | ||
| 19 | * race with the `loadURL` we already issued via the bridge. | ||
| 20 | */ | ||
| 21 | initialUrl: string | null; | ||
| 22 | className?: string; | ||
| 23 | } | ||
| 24 | |||
| 25 | interface ElectronWebview extends HTMLElement { | ||
| 26 | src: string; | ||
| 27 | partition: string; | ||
| 28 | preload?: string; | ||
| 29 | /** | ||
| 30 | * Comma-separated `key=value` web-preferences override. We use it to drop | ||
| 31 | * `contextIsolation` so the picker preload can see the guest page's | ||
| 32 | * `__REACT_DEVTOOLS_GLOBAL_HOOK__` and resolve component names. | ||
| 33 | */ | ||
| 34 | webpreferences?: string; | ||
| 35 | reload: () => void; | ||
| 36 | getWebContentsId: () => number; | ||
| 37 | } | ||
| 38 | |||
| 39 | declare global { | ||
| 40 | interface HTMLElementTagNameMap { | ||
| 41 | webview: ElectronWebview; | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | /** | ||
| 46 | * Hosts the Electron `<webview>` for a single preview tab. Returns null on | ||
| 47 | * web builds. The two-step handshake (createTab → wait for `dom-ready` → | ||
| 48 | * registerWebview) is necessary because `getWebContentsId()` only returns a | ||
| 49 | * valid id once the embedded contents have parsed; calling it synchronously | ||
| 50 | * after mount throws. | ||
| 51 | */ | ||
| 52 | export function PreviewWebview({ threadRef, tabId, initialUrl, className }: Props) { | ||
| 53 | const [config, setConfig] = useState<DesktopPreviewWebviewConfig | null>(null); | ||
| 54 | const webviewRef = useRef<ElectronWebview | null>(null); | ||
| 55 | // Capture once at mount; never re-derived from `initialUrl` props later. | ||
| 56 | const initialSrcRef = useRef<string>(initialUrl ?? "about:blank"); | ||
| 57 | const bridge = previewBridge; | ||
| 58 | |||
| 59 | // Per-tab desktop lifecycle: createTab on mount, closeTab on unmount, | ||
| 60 | // mirror state into the store, and reflect navigation back to the server. | ||
| 61 | usePreviewBridge({ threadRef, tabId }); | ||
| 62 | |||
| 63 | useEffect(() => { | ||
| 64 | if (!bridge) return; | ||
| 65 | let cancelled = false; | ||
| 66 | bridge | ||
| 67 | .getPreviewConfig() | ||
| 68 | .then((next) => { | ||
| 69 | if (cancelled) return; | ||
| 70 | setConfig(next); | ||
| 71 | }) | ||
| 72 | .catch(() => undefined); | ||
| 73 | return () => { | ||
| 74 | cancelled = true; | ||
| 75 | }; | ||
| 76 | }, [bridge]); | ||
| 77 | |||
| 78 | // Stable callback ref so React doesn't re-invoke the closure on every | ||
| 79 | // commit (otherwise we'd be re-asserting `allowpopups` and re-storing the | ||
| 80 | // ref pointer per render, which is harmless but wasteful). | ||
| 81 | const setWebviewRef = useCallback((node: HTMLElement | null) => { | ||
| 82 | webviewRef.current = node as ElectronWebview | null; | ||
| 83 | if (node && !node.hasAttribute("allowpopups")) { | ||
| 84 | // React's @types declare `allowpopups` as `boolean`, but the runtime | ||
| 85 | // doesn't list <webview> in its boolean-attribute allowlist, so | ||
| 86 | // passing it via JSX triggers a "received true for a non-boolean | ||
| 87 | // attribute" warning. Setting it imperatively bypasses React's prop | ||
| 88 | // normalization entirely. | ||
| 89 | node.setAttribute("allowpopups", "true"); | ||
| 90 | } | ||
| 91 | }, []); | ||
| 92 | |||
| 93 | useEffect(() => { | ||
| 94 | if (!bridge || !config) return; | ||
| 95 | const webview = webviewRef.current; | ||
| 96 | if (!webview) return; | ||
| 97 | |||
| 98 | const onDomReady = () => { | ||
| 99 | try { | ||
| 100 | const id = webview.getWebContentsId(); | ||
| 101 | if (Number.isFinite(id)) { | ||
| 102 | void bridge.registerWebview(tabId, id); | ||
| 103 | } | ||
| 104 | } catch { | ||
| 105 | // The next dom-ready (e.g. cross-document navigation) will retry. | ||
| 106 | } | ||
| 107 | }; | ||
| 108 | webview.addEventListener("dom-ready", onDomReady as EventListener); | ||
| 109 | // Defensive: dom-ready may have fired between React commit and this | ||
| 110 | // effect running. Failures fall through to the listener above. | ||
| 111 | try { | ||
| 112 | const existing = webview.getWebContentsId(); | ||
| 113 | if (Number.isFinite(existing)) { | ||
| 114 | void bridge.registerWebview(tabId, existing); | ||
| 115 | } | ||
| 116 | } catch { | ||
| 117 | // covered by dom-ready listener | ||
| 118 | } | ||
| 119 | |||
| 120 | return () => { | ||
| 121 | webview.removeEventListener("dom-ready", onDomReady as EventListener); | ||
| 122 | }; | ||
| 123 | }, [bridge, config, tabId]); | ||
| 124 | |||
| 125 | if (!isElectron || !bridge || !config) return null; | ||
| 126 | |||
| 127 | // `preload` and `webpreferences` are HTML attributes Electron only reads | ||
| 128 | // at element-attach time — a state update later in the lifecycle would | ||
| 129 | // have no effect. The renderer-attached values are also enforced by the | ||
| 130 | // main-process `will-attach-webview` validator (defense in depth). | ||
| 131 | return ( | ||
| 132 | <webview | ||
| 133 | ref={setWebviewRef} | ||
| 134 | src={initialSrcRef.current} | ||
| 135 | partition={config.partition} | ||
| 136 | webpreferences={config.webPreferences} | ||
| 137 | {...(config.preloadUrl ? { preload: config.preloadUrl } : {})} | ||
| 138 | data-preview-tab={tabId} | ||
| 139 | className={className} | ||
| 140 | /> | ||
| 141 | ); | ||
| 142 | } | ||
apps/web/src/components/preview/useDiscoveredLocalServers.ts+6-2| ... | @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; | ... | @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; |
| 4 | 4 | ||
| 5 | import { ensureEnvironmentApi } from "~/environmentApi"; | 5 | import { ensureEnvironmentApi } from "~/environmentApi"; |
| 6 | import type { EnvironmentId } from "@t3tools/contracts"; | 6 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 7 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | ||
| 7 | 8 | ||
| 8 | export interface PreviewableServer extends DiscoveredLocalServer { | 9 | export interface PreviewableServer extends DiscoveredLocalServer { |
| 9 | source: "scanner" | "configured" | "recent"; | 10 | source: "scanner" | "configured" | "recent"; |
| ... | @@ -42,11 +43,14 @@ export function useDiscoveredLocalServers( | ... | @@ -42,11 +43,14 @@ export function useDiscoveredLocalServers( |
| 42 | return useMemo( | 43 | return useMemo( |
| 43 | () => | 44 | () => |
| 44 | mergeServers({ | 45 | mergeServers({ |
| 45 | scanner: scannerSnapshot, | 46 | scanner: scannerSnapshot.map((server) => ({ |
| 47 | ...server, | ||
| 48 | url: resolveDiscoveredServerUrl(input.environmentId, server.url), | ||
| 49 | })), | ||
| 46 | configuredUrls: input.configuredUrls ?? [], | 50 | configuredUrls: input.configuredUrls ?? [], |
| 47 | recentlySeenUrls: input.recentlySeenUrls ?? [], | 51 | recentlySeenUrls: input.recentlySeenUrls ?? [], |
| 48 | }), | 52 | }), |
| 49 | [scannerSnapshot, input.configuredUrls, input.recentlySeenUrls], | 53 | [input.environmentId, scannerSnapshot, input.configuredUrls, input.recentlySeenUrls], |
| 50 | ); | 54 | ); |
| 51 | } | 55 | } |
| 52 | 56 |
apps/web/src/components/preview/usePreviewBridge.ts+4-15| ... | @@ -14,26 +14,14 @@ import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewState | ... | @@ -14,26 +14,14 @@ import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewState |
| 14 | import { previewBridge } from "./previewBridge"; | 14 | import { previewBridge } from "./previewBridge"; |
| 15 | 15 | ||
| 16 | /** | 16 | /** |
| 17 | * Owns the desktop tab lifecycle, mirrors low-latency button state into the | 17 | * Mirrors low-latency desktop state into the store and reflects navigation |
| 18 | * store, and reflects bridge navigation events back to the server. | 18 | * events back to the server. Webview lifetime is owned by ElectronBrowserHost. |
| 19 | * | ||
| 20 | * Tab create/close is anchored to the panel mount, NOT to the snapshot, so | ||
| 21 | * snapshot transitions (`opened` → `closed` → `opened`) cannot tear down and | ||
| 22 | * recreate the tab in the wrong order relative to in-flight desktop IPCs. | ||
| 23 | */ | 19 | */ |
| 24 | export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { | 20 | export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { |
| 25 | const { threadRef, tabId } = input; | 21 | const { threadRef, tabId } = input; |
| 26 | const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); | 22 | const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); |
| 27 | const bridge = previewBridge; | 23 | const bridge = previewBridge; |
| 28 | 24 | ||
| 29 | useEffect(() => { | ||
| 30 | if (!bridge) return; | ||
| 31 | void bridge.createTab(tabId); | ||
| 32 | return () => { | ||
| 33 | void bridge.closeTab(tabId); | ||
| 34 | }; | ||
| 35 | }, [bridge, tabId]); | ||
| 36 | |||
| 37 | // One bridge subscription does both jobs (mirror state + forward to | 25 | // One bridge subscription does both jobs (mirror state + forward to |
| 38 | // server) so the desktop bridge keeps a single listener entry per tab. | 26 | // server) so the desktop bridge keeps a single listener entry per tab. |
| 39 | const lastReportedUrl = useRef<string | null>(null); | 27 | const lastReportedUrl = useRef<string | null>(null); |
| ... | @@ -45,7 +33,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str | ... | @@ -45,7 +33,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str |
| 45 | lastReportedKind.current = null; | 33 | lastReportedKind.current = null; |
| 46 | const unsubscribe = bridge.onStateChange((changedTabId, state) => { | 34 | const unsubscribe = bridge.onStateChange((changedTabId, state) => { |
| 47 | if (changedTabId !== tabId) return; | 35 | if (changedTabId !== tabId) return; |
| 48 | applyDesktopState(threadRef, projectDesktopState(state)); | 36 | applyDesktopState(threadRef, tabId, projectDesktopState(state)); |
| 49 | const reported = buildReportInput({ | 37 | const reported = buildReportInput({ |
| 50 | threadId: threadRef.threadId, | 38 | threadId: threadRef.threadId, |
| 51 | tabId, | 39 | tabId, |
| ... | @@ -68,6 +56,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl | ... | @@ -68,6 +56,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl |
| 68 | canGoForward: state.canGoForward, | 56 | canGoForward: state.canGoForward, |
| 69 | loading: state.navStatus.kind === "Loading", | 57 | loading: state.navStatus.kind === "Loading", |
| 70 | zoomFactor: state.zoomFactor, | 58 | zoomFactor: state.zoomFactor, |
| 59 | controller: state.controller, | ||
| 71 | }; | 60 | }; |
| 72 | } | 61 | } |
| 73 | 62 |
apps/web/src/components/preview/usePreviewSession.ts+1-1| ... | @@ -33,7 +33,7 @@ export function usePreviewSession(threadRef: ScopedThreadRef): void { | ... | @@ -33,7 +33,7 @@ export function usePreviewSession(threadRef: ScopedThreadRef): void { |
| 33 | // `updatedAt` ascending, so the last one is freshest. | 33 | // `updatedAt` ascending, so the last one is freshest. |
| 34 | const serverSnapshot = result.sessions.at(-1) ?? null; | 34 | const serverSnapshot = result.sessions.at(-1) ?? null; |
| 35 | if (serverSnapshot) { | 35 | if (serverSnapshot) { |
| 36 | applyServerSnapshot(threadRef, serverSnapshot); | 36 | for (const snapshot of result.sessions) applyServerSnapshot(threadRef, snapshot); |
| 37 | return; | 37 | return; |
| 38 | } | 38 | } |
| 39 | // Server has no sessions — try to recover what the renderer | 39 | // Server has no sessions — try to recover what the renderer |
apps/web/src/main.tsx+7-1| ... | @@ -17,6 +17,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig"; | ... | @@ -17,6 +17,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig"; |
| 17 | import { getRouter } from "./router"; | 17 | import { getRouter } from "./router"; |
| 18 | import { APP_DISPLAY_NAME } from "./branding"; | 18 | import { APP_DISPLAY_NAME } from "./branding"; |
| 19 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; | 19 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; |
| 20 | import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; | ||
| 20 | 21 | ||
| 21 | // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. | 22 | // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. |
| 22 | const history = isElectron ? createHashHistory() : createBrowserHistory(); | 23 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| ... | @@ -31,7 +32,12 @@ document.title = APP_DISPLAY_NAME; | ... | @@ -31,7 +32,12 @@ document.title = APP_DISPLAY_NAME; |
| 31 | 32 | ||
| 32 | const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; | 33 | const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; |
| 33 | 34 | ||
| 34 | const app = <RouterProvider router={router} />; | 35 | const app = ( |
| 36 | <> | ||
| 37 | <RouterProvider router={router} /> | ||
| 38 | <ElectronBrowserHost /> | ||
| 39 | </> | ||
| 40 | ); | ||
| 35 | 41 | ||
| 36 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | 42 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( |
| 37 | <React.StrictMode> | 43 | <React.StrictMode> |
apps/web/src/previewStateStore.test.ts+23-1| ... | @@ -184,17 +184,39 @@ describe("previewStateStore (single-tab)", () => { | ... | @@ -184,17 +184,39 @@ describe("previewStateStore (single-tab)", () => { |
| 184 | createdAt: snapshot.updatedAt, | 184 | createdAt: snapshot.updatedAt, |
| 185 | snapshot, | 185 | snapshot, |
| 186 | }); | 186 | }); |
| 187 | store.applyDesktopState(ref, { | 187 | store.applyDesktopState(ref, snapshot.tabId, { |
| 188 | canGoBack: true, | 188 | canGoBack: true, |
| 189 | canGoForward: false, | 189 | canGoForward: false, |
| 190 | loading: false, | 190 | loading: false, |
| 191 | zoomFactor: 1, | 191 | zoomFactor: 1, |
| 192 | controller: "none", | ||
| 192 | }); | 193 | }); |
| 193 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | 194 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); |
| 194 | expect(state.desktopOverlay?.canGoBack).toBe(true); | 195 | expect(state.desktopOverlay?.canGoBack).toBe(true); |
| 195 | expect(state.snapshot?.canGoBack).toBe(false); | 196 | expect(state.snapshot?.canGoBack).toBe(false); |
| 196 | }); | 197 | }); |
| 197 | 198 | ||
| 199 | it("retains multiple tabs and switches active desktop state", () => { | ||
| 200 | const first = makeSnapshot(); | ||
| 201 | const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" }; | ||
| 202 | const store = usePreviewStateStore.getState(); | ||
| 203 | store.applyServerSnapshot(ref, first); | ||
| 204 | store.applyServerSnapshot(ref, second); | ||
| 205 | store.applyDesktopState(ref, first.tabId, { | ||
| 206 | canGoBack: true, | ||
| 207 | canGoForward: false, | ||
| 208 | loading: false, | ||
| 209 | zoomFactor: 1, | ||
| 210 | controller: "none", | ||
| 211 | }); | ||
| 212 | store.setActiveTab(ref, first.tabId); | ||
| 213 | |||
| 214 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | ||
| 215 | expect(Object.keys(state.sessions)).toEqual([first.tabId, second.tabId]); | ||
| 216 | expect(state.snapshot?.tabId).toBe(first.tabId); | ||
| 217 | expect(state.desktopOverlay?.canGoBack).toBe(true); | ||
| 218 | }); | ||
| 219 | |||
| 198 | it("applyServerSnapshot null clears snapshot for a thread that had one", () => { | 220 | it("applyServerSnapshot null clears snapshot for a thread that had one", () => { |
| 199 | const snapshot = makeSnapshot(); | 221 | const snapshot = makeSnapshot(); |
| 200 | const store = usePreviewStateStore.getState(); | 222 | const store = usePreviewStateStore.getState(); |
apps/web/src/previewStateStore.ts+104-24| ... | @@ -26,19 +26,26 @@ export interface DesktopPreviewOverlay { | ... | @@ -26,19 +26,26 @@ export interface DesktopPreviewOverlay { |
| 26 | canGoForward: boolean; | 26 | canGoForward: boolean; |
| 27 | loading: boolean; | 27 | loading: boolean; |
| 28 | zoomFactor: number; | 28 | zoomFactor: number; |
| 29 | controller: "human" | "agent" | "none"; | ||
| 29 | } | 30 | } |
| 30 | 31 | ||
| 31 | export interface ThreadPreviewState { | 32 | export interface ThreadPreviewState { |
| 32 | snapshot: PreviewSessionSnapshot | null; | 33 | snapshot: PreviewSessionSnapshot | null; |
| 34 | sessions: Record<string, PreviewSessionSnapshot>; | ||
| 35 | activeTabId: string | null; | ||
| 33 | /** Bridge state takes precedence over `snapshot` for nav button enablement. */ | 36 | /** Bridge state takes precedence over `snapshot` for nav button enablement. */ |
| 34 | desktopOverlay: DesktopPreviewOverlay | null; | 37 | desktopOverlay: DesktopPreviewOverlay | null; |
| 38 | desktopByTabId: Record<string, DesktopPreviewOverlay>; | ||
| 35 | /** Recently-visited URLs surfaced in the empty state. */ | 39 | /** Recently-visited URLs surfaced in the empty state. */ |
| 36 | recentlySeenUrls: string[]; | 40 | recentlySeenUrls: string[]; |
| 37 | } | 41 | } |
| 38 | 42 | ||
| 39 | const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ | 43 | const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ |
| 40 | snapshot: null, | 44 | snapshot: null, |
| 45 | sessions: {}, | ||
| 46 | activeTabId: null, | ||
| 41 | desktopOverlay: null, | 47 | desktopOverlay: null, |
| 48 | desktopByTabId: {}, | ||
| 42 | recentlySeenUrls: [] as string[], | 49 | recentlySeenUrls: [] as string[], |
| 43 | }); | 50 | }); |
| 44 | 51 | ||
| ... | @@ -46,7 +53,12 @@ export interface PreviewStateStoreState { | ... | @@ -46,7 +53,12 @@ export interface PreviewStateStoreState { |
| 46 | byThreadKey: Record<string, ThreadPreviewState>; | 53 | byThreadKey: Record<string, ThreadPreviewState>; |
| 47 | applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; | 54 | applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; |
| 48 | applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void; | 55 | applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void; |
| 49 | applyDesktopState: (ref: ScopedThreadRef, overlay: DesktopPreviewOverlay | null) => void; | 56 | applyDesktopState: ( |
| 57 | ref: ScopedThreadRef, | ||
| 58 | tabId: string, | ||
| 59 | overlay: DesktopPreviewOverlay | null, | ||
| 60 | ) => void; | ||
| 61 | setActiveTab: (ref: ScopedThreadRef, tabId: string) => void; | ||
| 50 | rememberUrl: (ref: ScopedThreadRef, url: string) => void; | 62 | rememberUrl: (ref: ScopedThreadRef, url: string) => void; |
| 51 | removeThread: (ref: ScopedThreadRef) => void; | 63 | removeThread: (ref: ScopedThreadRef) => void; |
| 52 | } | 64 | } |
| ... | @@ -96,34 +108,64 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ | ... | @@ -96,34 +108,64 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ |
| 96 | snapshot.navStatus._tag === "Idle" | 108 | snapshot.navStatus._tag === "Idle" |
| 97 | ? current.recentlySeenUrls | 109 | ? current.recentlySeenUrls |
| 98 | : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); | 110 | : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); |
| 99 | return { ...current, snapshot, recentlySeenUrls }; | 111 | const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; |
| 112 | const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; | ||
| 113 | const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; | ||
| 114 | return { | ||
| 115 | ...current, | ||
| 116 | sessions, | ||
| 117 | activeTabId: activeTabId ?? snapshot.tabId, | ||
| 118 | snapshot: activeSnapshot, | ||
| 119 | desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, | ||
| 120 | recentlySeenUrls, | ||
| 121 | }; | ||
| 100 | }); | 122 | }); |
| 101 | break; | 123 | break; |
| 102 | case "failed": | 124 | case "failed": |
| 103 | nextByThread = updateThread(state, threadKey, (current) => { | 125 | nextByThread = updateThread(state, threadKey, (current) => { |
| 104 | if (!current.snapshot || current.snapshot.tabId !== event.tabId) return current; | 126 | const existing = current.sessions[event.tabId]; |
| 127 | if (!existing) return current; | ||
| 128 | const failedSnapshot = { | ||
| 129 | ...existing, | ||
| 130 | navStatus: { | ||
| 131 | _tag: "LoadFailed" as const, | ||
| 132 | url: event.url, | ||
| 133 | title: event.title, | ||
| 134 | code: event.code, | ||
| 135 | description: event.description, | ||
| 136 | }, | ||
| 137 | updatedAt: event.createdAt, | ||
| 138 | }; | ||
| 139 | const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; | ||
| 105 | return { | 140 | return { |
| 106 | ...current, | 141 | ...current, |
| 107 | snapshot: { | 142 | sessions, |
| 108 | ...current.snapshot, | 143 | snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, |
| 109 | navStatus: { | ||
| 110 | _tag: "LoadFailed", | ||
| 111 | url: event.url, | ||
| 112 | title: event.title, | ||
| 113 | code: event.code, | ||
| 114 | description: event.description, | ||
| 115 | }, | ||
| 116 | updatedAt: event.createdAt, | ||
| 117 | }, | ||
| 118 | }; | 144 | }; |
| 119 | }); | 145 | }); |
| 120 | break; | 146 | break; |
| 121 | case "closed": | 147 | case "closed": |
| 122 | nextByThread = updateThread(state, threadKey, (current) => { | 148 | nextByThread = updateThread(state, threadKey, (current) => { |
| 123 | // Only clear if the closed tab is the one we were tracking; the | 149 | if (!current.sessions[event.tabId]) return current; |
| 124 | // server may have multiple tabs per thread but we only render one. | 150 | const { [event.tabId]: _closed, ...sessions } = current.sessions; |
| 125 | if (current.snapshot && current.snapshot.tabId !== event.tabId) return current; | 151 | const { [event.tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; |
| 126 | return { ...current, snapshot: null, desktopOverlay: null }; | 152 | const nextSnapshot = |
| 153 | Object.values(sessions) | ||
| 154 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) | ||
| 155 | .at(-1) ?? null; | ||
| 156 | const activeTabId = | ||
| 157 | current.activeTabId === event.tabId | ||
| 158 | ? (nextSnapshot?.tabId ?? null) | ||
| 159 | : current.activeTabId; | ||
| 160 | const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; | ||
| 161 | return { | ||
| 162 | ...current, | ||
| 163 | sessions, | ||
| 164 | desktopByTabId, | ||
| 165 | activeTabId: snapshot?.tabId ?? null, | ||
| 166 | snapshot, | ||
| 167 | desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null, | ||
| 168 | }; | ||
| 127 | }); | 169 | }); |
| 128 | break; | 170 | break; |
| 129 | } | 171 | } |
| ... | @@ -134,21 +176,59 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ | ... | @@ -134,21 +176,59 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ |
| 134 | const threadKey = scopedThreadKey(ref); | 176 | const threadKey = scopedThreadKey(ref); |
| 135 | const nextByThread = updateThread(state, threadKey, (current) => { | 177 | const nextByThread = updateThread(state, threadKey, (current) => { |
| 136 | if (!snapshot && current.snapshot === null) return current; | 178 | if (!snapshot && current.snapshot === null) return current; |
| 179 | if (!snapshot) { | ||
| 180 | return { | ||
| 181 | ...current, | ||
| 182 | snapshot: null, | ||
| 183 | sessions: {}, | ||
| 184 | activeTabId: null, | ||
| 185 | desktopOverlay: null, | ||
| 186 | desktopByTabId: {}, | ||
| 187 | }; | ||
| 188 | } | ||
| 137 | const recentlySeenUrls = | 189 | const recentlySeenUrls = |
| 138 | snapshot && snapshot.navStatus._tag !== "Idle" | 190 | snapshot && snapshot.navStatus._tag !== "Idle" |
| 139 | ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) | 191 | ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) |
| 140 | : current.recentlySeenUrls; | 192 | : current.recentlySeenUrls; |
| 141 | return { ...current, snapshot, recentlySeenUrls }; | 193 | return { |
| 194 | ...current, | ||
| 195 | snapshot, | ||
| 196 | sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, | ||
| 197 | activeTabId: snapshot.tabId, | ||
| 198 | desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, | ||
| 199 | recentlySeenUrls, | ||
| 200 | }; | ||
| 142 | }); | 201 | }); |
| 143 | return { byThreadKey: nextByThread }; | 202 | return { byThreadKey: nextByThread }; |
| 144 | }), | 203 | }), |
| 145 | applyDesktopState: (ref, overlay) => | 204 | applyDesktopState: (ref, tabId, overlay) => |
| 146 | set((state) => { | 205 | set((state) => { |
| 147 | const threadKey = scopedThreadKey(ref); | 206 | const threadKey = scopedThreadKey(ref); |
| 148 | const nextByThread = updateThread(state, threadKey, (current) => ({ | 207 | const nextByThread = updateThread(state, threadKey, (current) => { |
| 149 | ...current, | 208 | const desktopByTabId = { ...current.desktopByTabId }; |
| 150 | desktopOverlay: overlay, | 209 | if (overlay) desktopByTabId[tabId] = overlay; |
| 151 | })); | 210 | else delete desktopByTabId[tabId]; |
| 211 | return { | ||
| 212 | ...current, | ||
| 213 | desktopByTabId, | ||
| 214 | desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay, | ||
| 215 | }; | ||
| 216 | }); | ||
| 217 | return { byThreadKey: nextByThread }; | ||
| 218 | }), | ||
| 219 | setActiveTab: (ref, tabId) => | ||
| 220 | set((state) => { | ||
| 221 | const threadKey = scopedThreadKey(ref); | ||
| 222 | const nextByThread = updateThread(state, threadKey, (current) => { | ||
| 223 | const snapshot = current.sessions[tabId]; | ||
| 224 | if (!snapshot || current.activeTabId === tabId) return current; | ||
| 225 | return { | ||
| 226 | ...current, | ||
| 227 | activeTabId: tabId, | ||
| 228 | snapshot, | ||
| 229 | desktopOverlay: current.desktopByTabId[tabId] ?? null, | ||
| 230 | }; | ||
| 231 | }); | ||
| 152 | return { byThreadKey: nextByThread }; | 232 | return { byThreadKey: nextByThread }; |
| 153 | }), | 233 | }), |
| 154 | rememberUrl: (ref, url) => | 234 | rememberUrl: (ref, url) => |
apps/web/src/rightPanelStore.test.ts+42-1| ... | @@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; | ... | @@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | selectActiveRightPanel, | 6 | selectActiveRightPanel, |
| 7 | selectActiveRightPanelSurface, | ||
| 7 | selectActiveRightPanelKindWithUrl, | 8 | selectActiveRightPanelKindWithUrl, |
| 9 | selectThreadRightPanelState, | ||
| 8 | useRightPanelStore, | 10 | useRightPanelStore, |
| 9 | } from "./rightPanelStore"; | 11 | } from "./rightPanelStore"; |
| 10 | 12 | ||
| ... | @@ -22,10 +24,13 @@ describe("rightPanelStore", () => { | ... | @@ -22,10 +24,13 @@ describe("rightPanelStore", () => { |
| 22 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBeNull(); | 24 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBeNull(); |
| 23 | }); | 25 | }); |
| 24 | 26 | ||
| 25 | it("opening a different kind replaces the previous one", () => { | 27 | it("opening a different kind keeps both surfaces and activates the new one", () => { |
| 26 | useRightPanelStore.getState().open(refA, "plan"); | 28 | useRightPanelStore.getState().open(refA, "plan"); |
| 27 | useRightPanelStore.getState().open(refA, "preview"); | 29 | useRightPanelStore.getState().open(refA, "preview"); |
| 28 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); | 30 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); |
| 31 | expect( | ||
| 32 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, | ||
| 33 | ).toHaveLength(2); | ||
| 29 | }); | 34 | }); |
| 30 | 35 | ||
| 31 | it("close clears the active panel", () => { | 36 | it("close clears the active panel", () => { |
| ... | @@ -67,4 +72,40 @@ describe("rightPanelStore", () => { | ... | @@ -67,4 +72,40 @@ describe("rightPanelStore", () => { |
| 67 | useRightPanelStore.getState().close(refA); | 72 | useRightPanelStore.getState().close(refA); |
| 68 | expect(useRightPanelStore.getState().byThreadKey).toEqual({}); | 73 | expect(useRightPanelStore.getState().byThreadKey).toEqual({}); |
| 69 | }); | 74 | }); |
| 75 | |||
| 76 | it("tracks one surface per browser session", () => { | ||
| 77 | useRightPanelStore.getState().openBrowser(refA, "tab-a"); | ||
| 78 | useRightPanelStore.getState().openBrowser(refA, "tab-b"); | ||
| 79 | |||
| 80 | const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); | ||
| 81 | expect(state.surfaces.map((surface) => surface.id)).toEqual(["browser:tab-a", "browser:tab-b"]); | ||
| 82 | expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | ||
| 83 | id: "browser:tab-b", | ||
| 84 | kind: "preview", | ||
| 85 | resourceId: "tab-b", | ||
| 86 | }); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("closing the active surface activates a neighboring surface", () => { | ||
| 90 | useRightPanelStore.getState().openBrowser(refA, "tab-a"); | ||
| 91 | useRightPanelStore.getState().open(refA, "terminal"); | ||
| 92 | useRightPanelStore.getState().closeSurface(refA, "terminal"); | ||
| 93 | |||
| 94 | expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe( | ||
| 95 | "browser:tab-a", | ||
| 96 | ); | ||
| 97 | }); | ||
| 98 | |||
| 99 | it("reconciles browser surfaces without deleting other surface kinds", () => { | ||
| 100 | useRightPanelStore.getState().open(refA, "terminal"); | ||
| 101 | useRightPanelStore.getState().openBrowser(refA, "tab-a"); | ||
| 102 | useRightPanelStore.getState().openBrowser(refA, "tab-b"); | ||
| 103 | useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-b", "tab-c"]); | ||
| 104 | |||
| 105 | expect( | ||
| 106 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( | ||
| 107 | (surface) => surface.id, | ||
| 108 | ), | ||
| 109 | ).toEqual(["terminal", "browser:tab-b", "browser:tab-c"]); | ||
| 110 | }); | ||
| 70 | }); | 111 | }); |
apps/web/src/rightPanelStore.ts+156-38| ... | @@ -1,13 +1,10 @@ | ... | @@ -1,13 +1,10 @@ |
| 1 | /** | 1 | /** |
| 2 | * Per-thread arbiter for the right-side panel. | 2 | * Thread-scoped right-panel surface state. |
| 3 | * | 3 | * |
| 4 | * Three tenants share the same slot: the plan sidebar, the diff panel, and | 4 | * This is intentionally a shallow workspace model: it owns an ordered set of |
| 5 | * the preview panel. Only one is open at a time per thread; the choice is | 5 | * surface descriptors and the active surface, while each feature continues to |
| 6 | * remembered across thread switches. | 6 | * own its durable resource state. Browser surfaces point at preview tab ids; |
| 7 | * | 7 | * singleton surfaces bridge the existing terminal, diff, and plan features. |
| 8 | * The diff panel still uses `?diff=1` as URL truth for deep-linking — when | ||
| 9 | * that param is present the diff tenant wins regardless of what's persisted | ||
| 10 | * here. See `selectActiveRightPanelKindWithUrl` for the resolution rule. | ||
| 11 | */ | 8 | */ |
| 12 | import { scopedThreadKey } from "@t3tools/client-runtime"; | 9 | import { scopedThreadKey } from "@t3tools/client-runtime"; |
| 13 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 10 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| ... | @@ -16,35 +13,77 @@ import { createJSONStorage, persist } from "zustand/middleware"; | ... | @@ -16,35 +13,77 @@ import { createJSONStorage, persist } from "zustand/middleware"; |
| 16 | 13 | ||
| 17 | import { resolveStorage } from "./lib/storage"; | 14 | import { resolveStorage } from "./lib/storage"; |
| 18 | 15 | ||
| 19 | export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview"] as const; | 16 | export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview", "terminal"] as const; |
| 20 | export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; | 17 | export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; |
| 21 | 18 | ||
| 22 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v1"; | 19 | export type RightPanelSurface = |
| 20 | | { id: `browser:${string}`; kind: "preview"; resourceId: string } | ||
| 21 | | { id: "browser:new"; kind: "preview"; resourceId: null } | ||
| 22 | | { id: "terminal"; kind: "terminal" } | ||
| 23 | | { id: "diff"; kind: "diff" } | ||
| 24 | | { id: "plan"; kind: "plan" }; | ||
| 25 | |||
| 26 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; | ||
| 23 | 27 | ||
| 24 | interface ThreadRightPanelState { | 28 | export interface ThreadRightPanelState { |
| 25 | active: RightPanelKind | null; | 29 | activeSurfaceId: string | null; |
| 30 | surfaces: RightPanelSurface[]; | ||
| 26 | } | 31 | } |
| 27 | 32 | ||
| 28 | interface RightPanelStoreState { | 33 | interface RightPanelStoreState { |
| 29 | byThreadKey: Record<string, ThreadRightPanelState>; | 34 | byThreadKey: Record<string, ThreadRightPanelState>; |
| 30 | open: (ref: ScopedThreadRef, kind: RightPanelKind) => void; | 35 | open: (ref: ScopedThreadRef, kind: RightPanelKind) => void; |
| 36 | openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; | ||
| 37 | activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; | ||
| 38 | closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; | ||
| 39 | reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; | ||
| 31 | close: (ref: ScopedThreadRef) => void; | 40 | close: (ref: ScopedThreadRef) => void; |
| 32 | toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void; | 41 | toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void; |
| 33 | removeThread: (ref: ScopedThreadRef) => void; | 42 | removeThread: (ref: ScopedThreadRef) => void; |
| 34 | } | 43 | } |
| 35 | 44 | ||
| 45 | const EMPTY_THREAD_STATE: ThreadRightPanelState = { activeSurfaceId: null, surfaces: [] }; | ||
| 46 | |||
| 47 | const singletonSurface = (kind: Exclude<RightPanelKind, "preview">): RightPanelSurface => { | ||
| 48 | switch (kind) { | ||
| 49 | case "terminal": | ||
| 50 | return { id: "terminal", kind }; | ||
| 51 | case "diff": | ||
| 52 | return { id: "diff", kind }; | ||
| 53 | case "plan": | ||
| 54 | return { id: "plan", kind }; | ||
| 55 | } | ||
| 56 | }; | ||
| 57 | |||
| 58 | const browserSurface = (tabId: string | null): RightPanelSurface => | ||
| 59 | tabId | ||
| 60 | ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } | ||
| 61 | : { id: "browser:new", kind: "preview", resourceId: null }; | ||
| 62 | |||
| 63 | const upsertSurface = ( | ||
| 64 | current: ThreadRightPanelState, | ||
| 65 | surface: RightPanelSurface, | ||
| 66 | activate = true, | ||
| 67 | ): ThreadRightPanelState => ({ | ||
| 68 | surfaces: current.surfaces.some((entry) => entry.id === surface.id) | ||
| 69 | ? current.surfaces | ||
| 70 | : [...current.surfaces, surface], | ||
| 71 | activeSurfaceId: activate ? surface.id : current.activeSurfaceId, | ||
| 72 | }); | ||
| 73 | |||
| 36 | const updateThread = ( | 74 | const updateThread = ( |
| 37 | byThreadKey: Record<string, ThreadRightPanelState>, | 75 | byThreadKey: Record<string, ThreadRightPanelState>, |
| 38 | threadKey: string, | 76 | threadKey: string, |
| 39 | next: ThreadRightPanelState, | 77 | updater: (current: ThreadRightPanelState) => ThreadRightPanelState, |
| 40 | ): Record<string, ThreadRightPanelState> => { | 78 | ): Record<string, ThreadRightPanelState> => { |
| 41 | const current = byThreadKey[threadKey]; | 79 | const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; |
| 42 | if (current && current.active === next.active) return byThreadKey; | 80 | const next = updater(current); |
| 43 | if (next.active === null) { | 81 | if (next.activeSurfaceId === null && next.surfaces.length === 0) { |
| 44 | if (!current) return byThreadKey; | 82 | if (!(threadKey in byThreadKey)) return byThreadKey; |
| 45 | const { [threadKey]: _removed, ...rest } = byThreadKey; | 83 | const { [threadKey]: _removed, ...rest } = byThreadKey; |
| 46 | return rest; | 84 | return rest; |
| 47 | } | 85 | } |
| 86 | if (next === current) return byThreadKey; | ||
| 48 | return { ...byThreadKey, [threadKey]: next }; | 87 | return { ...byThreadKey, [threadKey]: next }; |
| 49 | }; | 88 | }; |
| 50 | 89 | ||
| ... | @@ -54,21 +93,92 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -54,21 +93,92 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 54 | byThreadKey: {}, | 93 | byThreadKey: {}, |
| 55 | open: (ref, kind) => | 94 | open: (ref, kind) => |
| 56 | set((state) => ({ | 95 | set((state) => ({ |
| 57 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), { active: kind }), | 96 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { |
| 97 | if (kind === "preview") { | ||
| 98 | const existing = current.surfaces.find((surface) => surface.kind === "preview"); | ||
| 99 | return upsertSurface(current, existing ?? browserSurface(null)); | ||
| 100 | } | ||
| 101 | return upsertSurface(current, singletonSurface(kind)); | ||
| 102 | }), | ||
| 103 | })), | ||
| 104 | openBrowser: (ref, tabId) => | ||
| 105 | set((state) => ({ | ||
| 106 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { | ||
| 107 | const surface = browserSurface(tabId); | ||
| 108 | const withoutPlaceholder = tabId | ||
| 109 | ? current.surfaces.filter((entry) => entry.id !== "browser:new") | ||
| 110 | : current.surfaces; | ||
| 111 | return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); | ||
| 112 | }), | ||
| 113 | })), | ||
| 114 | activateSurface: (ref, surfaceId) => | ||
| 115 | set((state) => ({ | ||
| 116 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => | ||
| 117 | current.surfaces.some((surface) => surface.id === surfaceId) | ||
| 118 | ? { ...current, activeSurfaceId: surfaceId } | ||
| 119 | : current, | ||
| 120 | ), | ||
| 121 | })), | ||
| 122 | closeSurface: (ref, surfaceId) => | ||
| 123 | set((state) => ({ | ||
| 124 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { | ||
| 125 | const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); | ||
| 126 | if (index < 0) return current; | ||
| 127 | const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); | ||
| 128 | if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; | ||
| 129 | const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; | ||
| 130 | return { surfaces, activeSurfaceId: fallback?.id ?? null }; | ||
| 131 | }), | ||
| 132 | })), | ||
| 133 | reconcileBrowserSurfaces: (ref, tabIds) => | ||
| 134 | set((state) => ({ | ||
| 135 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { | ||
| 136 | const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); | ||
| 137 | const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); | ||
| 138 | const existingBrowser = current.surfaces.filter( | ||
| 139 | (surface): surface is Extract<RightPanelSurface, { kind: "preview" }> => | ||
| 140 | surface.kind === "preview" && | ||
| 141 | surface.id !== "browser:new" && | ||
| 142 | validIds.has(surface.id), | ||
| 143 | ); | ||
| 144 | const knownIds = new Set(existingBrowser.map((surface) => surface.id)); | ||
| 145 | const added = tabIds | ||
| 146 | .filter((tabId) => !knownIds.has(`browser:${tabId}`)) | ||
| 147 | .map((tabId) => browserSurface(tabId)); | ||
| 148 | const surfaces = [...nonBrowser, ...existingBrowser, ...added]; | ||
| 149 | const activeStillExists = surfaces.some( | ||
| 150 | (surface) => surface.id === current.activeSurfaceId, | ||
| 151 | ); | ||
| 152 | const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview"); | ||
| 153 | return { | ||
| 154 | surfaces, | ||
| 155 | activeSurfaceId: activeStillExists | ||
| 156 | ? current.activeSurfaceId | ||
| 157 | : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null), | ||
| 158 | }; | ||
| 159 | }), | ||
| 58 | })), | 160 | })), |
| 59 | close: (ref) => | 161 | close: (ref) => |
| 60 | set((state) => ({ | 162 | set((state) => ({ |
| 61 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), { active: null }), | 163 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ |
| 164 | ...current, | ||
| 165 | activeSurfaceId: null, | ||
| 166 | })), | ||
| 62 | })), | 167 | })), |
| 63 | toggle: (ref, kind) => | 168 | toggle: (ref, kind) => |
| 64 | set((state) => { | 169 | set((state) => ({ |
| 65 | const threadKey = scopedThreadKey(ref); | 170 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { |
| 66 | const current = state.byThreadKey[threadKey]?.active ?? null; | 171 | const active = current.surfaces.find( |
| 67 | const next: RightPanelKind | null = current === kind ? null : kind; | 172 | (surface) => surface.id === current.activeSurfaceId, |
| 68 | return { | 173 | ); |
| 69 | byThreadKey: updateThread(state.byThreadKey, threadKey, { active: next }), | 174 | if (active?.kind === kind) return { ...current, activeSurfaceId: null }; |
| 70 | }; | 175 | if (kind === "preview") { |
| 71 | }), | 176 | const existing = current.surfaces.find((surface) => surface.kind === "preview"); |
| 177 | return upsertSurface(current, existing ?? browserSurface(null)); | ||
| 178 | } | ||
| 179 | return upsertSurface(current, singletonSurface(kind)); | ||
| 180 | }), | ||
| 181 | })), | ||
| 72 | removeThread: (ref) => | 182 | removeThread: (ref) => |
| 73 | set((state) => { | 183 | set((state) => { |
| 74 | const threadKey = scopedThreadKey(ref); | 184 | const threadKey = scopedThreadKey(ref); |
| ... | @@ -79,7 +189,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -79,7 +189,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 79 | }), | 189 | }), |
| 80 | { | 190 | { |
| 81 | name: RIGHT_PANEL_STORAGE_KEY, | 191 | name: RIGHT_PANEL_STORAGE_KEY, |
| 82 | version: 1, | 192 | version: 2, |
| 83 | storage: createJSONStorage(() => | 193 | storage: createJSONStorage(() => |
| 84 | resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), | 194 | resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), |
| 85 | ), | 195 | ), |
| ... | @@ -88,22 +198,30 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -88,22 +198,30 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 88 | ), | 198 | ), |
| 89 | ); | 199 | ); |
| 90 | 200 | ||
| 201 | export function selectThreadRightPanelState( | ||
| 202 | byThreadKey: Record<string, ThreadRightPanelState>, | ||
| 203 | ref: ScopedThreadRef | null | undefined, | ||
| 204 | ): ThreadRightPanelState { | ||
| 205 | if (!ref) return EMPTY_THREAD_STATE; | ||
| 206 | return byThreadKey[scopedThreadKey(ref)] ?? EMPTY_THREAD_STATE; | ||
| 207 | } | ||
| 208 | |||
| 91 | export function selectActiveRightPanel( | 209 | export function selectActiveRightPanel( |
| 92 | byThreadKey: Record<string, ThreadRightPanelState>, | 210 | byThreadKey: Record<string, ThreadRightPanelState>, |
| 93 | ref: ScopedThreadRef | null | undefined, | 211 | ref: ScopedThreadRef | null | undefined, |
| 94 | ): RightPanelKind | null { | 212 | ): RightPanelKind | null { |
| 95 | if (!ref) return null; | 213 | const state = selectThreadRightPanelState(byThreadKey, ref); |
| 96 | return byThreadKey[scopedThreadKey(ref)]?.active ?? null; | 214 | return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null; |
| 215 | } | ||
| 216 | |||
| 217 | export function selectActiveRightPanelSurface( | ||
| 218 | byThreadKey: Record<string, ThreadRightPanelState>, | ||
| 219 | ref: ScopedThreadRef | null | undefined, | ||
| 220 | ): RightPanelSurface | null { | ||
| 221 | const state = selectThreadRightPanelState(byThreadKey, ref); | ||
| 222 | return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; | ||
| 97 | } | 223 | } |
| 98 | 224 | ||
| 99 | /** | ||
| 100 | * Resolves the active right panel taking the `?diff=1` URL truth into | ||
| 101 | * account. When `diff=1` the diff panel always wins. | ||
| 102 | * | ||
| 103 | * Prefer using `useSyncDiffSearchToRightPanel` (in ChatView) to mirror the | ||
| 104 | * URL into the store and consume `selectActiveRightPanel` directly — this | ||
| 105 | * helper exists for callers that don't want to install the sync effect. | ||
| 106 | */ | ||
| 107 | export function selectActiveRightPanelKindWithUrl( | 225 | export function selectActiveRightPanelKindWithUrl( |
| 108 | byThreadKey: Record<string, ThreadRightPanelState>, | 226 | byThreadKey: Record<string, ThreadRightPanelState>, |
| 109 | ref: ScopedThreadRef | null | undefined, | 227 | ref: ScopedThreadRef | null | undefined, |
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+15-229| ... | @@ -1,149 +1,20 @@ | ... | @@ -1,149 +1,20 @@ |
| 1 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; | 1 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; |
| 2 | import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react"; | 2 | import { useEffect, useMemo } from "react"; |
| 3 | 3 | ||
| 4 | import ChatView from "../components/ChatView"; | 4 | import ChatView from "../components/ChatView"; |
| 5 | import { threadHasStarted } from "../components/ChatView.logic"; | 5 | import { threadHasStarted } from "../components/ChatView.logic"; |
| 6 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | ||
| 7 | import { | ||
| 8 | DiffPanelHeaderSkeleton, | ||
| 9 | DiffPanelLoadingState, | ||
| 10 | DiffPanelShell, | ||
| 11 | type DiffPanelMode, | ||
| 12 | } from "../components/DiffPanelShell"; | ||
| 13 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; | 6 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; |
| 14 | import { | 7 | import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch"; |
| 15 | type DiffRouteSearch, | ||
| 16 | parseDiffRouteSearch, | ||
| 17 | stripDiffSearchParams, | ||
| 18 | } from "../diffRouteSearch"; | ||
| 19 | import { useMediaQuery } from "../hooks/useMediaQuery"; | ||
| 20 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; | ||
| 21 | import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; | 8 | import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; |
| 22 | import { createThreadSelectorByRef } from "../storeSelectors"; | 9 | import { createThreadSelectorByRef } from "../storeSelectors"; |
| 23 | import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; | 10 | import { resolveThreadRouteRef } from "../threadRoutes"; |
| 24 | import { RightPanelSheet } from "../components/RightPanelSheet"; | 11 | import { SidebarInset } from "~/components/ui/sidebar"; |
| 25 | import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; | ||
| 26 | |||
| 27 | const DiffPanel = lazy(() => import("../components/DiffPanel")); | ||
| 28 | const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; | ||
| 29 | const DIFF_INLINE_DEFAULT_WIDTH = "clamp(24rem,34vw,36rem)"; | ||
| 30 | const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 22 * 16; | ||
| 31 | const DIFF_INLINE_SIDEBAR_MAX_WIDTH = 256 * 16; | ||
| 32 | const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; | ||
| 33 | |||
| 34 | const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { | ||
| 35 | return ( | ||
| 36 | <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}> | ||
| 37 | <DiffPanelLoadingState label="Loading diff viewer..." /> | ||
| 38 | </DiffPanelShell> | ||
| 39 | ); | ||
| 40 | }; | ||
| 41 | |||
| 42 | const LazyDiffPanel = (props: { mode: DiffPanelMode }) => { | ||
| 43 | return ( | ||
| 44 | <DiffWorkerPoolProvider> | ||
| 45 | <Suspense fallback={<DiffLoadingFallback mode={props.mode} />}> | ||
| 46 | <DiffPanel mode={props.mode} /> | ||
| 47 | </Suspense> | ||
| 48 | </DiffWorkerPoolProvider> | ||
| 49 | ); | ||
| 50 | }; | ||
| 51 | |||
| 52 | const DiffPanelInlineSidebar = (props: { | ||
| 53 | diffOpen: boolean; | ||
| 54 | onCloseDiff: () => void; | ||
| 55 | onOpenDiff: () => void; | ||
| 56 | renderDiffContent: boolean; | ||
| 57 | }) => { | ||
| 58 | const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props; | ||
| 59 | const onOpenChange = useCallback( | ||
| 60 | (open: boolean) => { | ||
| 61 | if (open) { | ||
| 62 | onOpenDiff(); | ||
| 63 | return; | ||
| 64 | } | ||
| 65 | onCloseDiff(); | ||
| 66 | }, | ||
| 67 | [onCloseDiff, onOpenDiff], | ||
| 68 | ); | ||
| 69 | const shouldAcceptInlineSidebarWidth = useCallback( | ||
| 70 | ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { | ||
| 71 | const composerForm = document.querySelector<HTMLElement>("[data-chat-composer-form='true']"); | ||
| 72 | if (!composerForm) return true; | ||
| 73 | const composerViewport = composerForm.parentElement; | ||
| 74 | if (!composerViewport) return true; | ||
| 75 | const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width"); | ||
| 76 | wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); | ||
| 77 | |||
| 78 | const viewportStyle = window.getComputedStyle(composerViewport); | ||
| 79 | const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; | ||
| 80 | const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; | ||
| 81 | const viewportContentWidth = Math.max( | ||
| 82 | 0, | ||
| 83 | composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, | ||
| 84 | ); | ||
| 85 | const formRect = composerForm.getBoundingClientRect(); | ||
| 86 | const composerFooter = composerForm.querySelector<HTMLElement>( | ||
| 87 | "[data-chat-composer-footer='true']", | ||
| 88 | ); | ||
| 89 | const composerRightActions = composerForm.querySelector<HTMLElement>( | ||
| 90 | "[data-chat-composer-actions='right']", | ||
| 91 | ); | ||
| 92 | const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; | ||
| 93 | const composerFooterGap = composerFooter | ||
| 94 | ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || | ||
| 95 | Number.parseFloat(window.getComputedStyle(composerFooter).gap) || | ||
| 96 | 0 | ||
| 97 | : 0; | ||
| 98 | const minimumComposerWidth = | ||
| 99 | COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; | ||
| 100 | const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; | ||
| 101 | const overflowsViewport = formRect.width > viewportContentWidth + 0.5; | ||
| 102 | const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; | ||
| 103 | |||
| 104 | if (previousSidebarWidth.length > 0) { | ||
| 105 | wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); | ||
| 106 | } else { | ||
| 107 | wrapper.style.removeProperty("--sidebar-width"); | ||
| 108 | } | ||
| 109 | |||
| 110 | return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; | ||
| 111 | }, | ||
| 112 | [], | ||
| 113 | ); | ||
| 114 | |||
| 115 | return ( | ||
| 116 | <SidebarProvider | ||
| 117 | defaultOpen={false} | ||
| 118 | open={diffOpen} | ||
| 119 | onOpenChange={onOpenChange} | ||
| 120 | className="w-auto min-h-0 flex-none bg-transparent" | ||
| 121 | style={{ "--sidebar-width": DIFF_INLINE_DEFAULT_WIDTH } as React.CSSProperties} | ||
| 122 | > | ||
| 123 | <Sidebar | ||
| 124 | side="right" | ||
| 125 | collapsible="offcanvas" | ||
| 126 | className="border-l border-border bg-card text-foreground" | ||
| 127 | resizable={{ | ||
| 128 | maxWidth: DIFF_INLINE_SIDEBAR_MAX_WIDTH, | ||
| 129 | minWidth: DIFF_INLINE_SIDEBAR_MIN_WIDTH, | ||
| 130 | shouldAcceptWidth: shouldAcceptInlineSidebarWidth, | ||
| 131 | storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY, | ||
| 132 | }} | ||
| 133 | > | ||
| 134 | {renderDiffContent ? <LazyDiffPanel mode="sidebar" /> : null} | ||
| 135 | <SidebarRail /> | ||
| 136 | </Sidebar> | ||
| 137 | </SidebarProvider> | ||
| 138 | ); | ||
| 139 | }; | ||
| 140 | 12 | ||
| 141 | function ChatThreadRouteView() { | 13 | function ChatThreadRouteView() { |
| 142 | const navigate = useNavigate(); | 14 | const navigate = useNavigate(); |
| 143 | const threadRef = Route.useParams({ | 15 | const threadRef = Route.useParams({ |
| 144 | select: (params) => resolveThreadRouteRef(params), | 16 | select: (params) => resolveThreadRouteRef(params), |
| 145 | }); | 17 | }); |
| 146 | const search = Route.useSearch(); | ||
| 147 | const bootstrapComplete = useStore( | 18 | const bootstrapComplete = useStore( |
| 148 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, | 19 | (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, |
| 149 | ); | 20 | ); |
| ... | @@ -159,120 +30,35 @@ function ChatThreadRouteView() { | ... | @@ -159,120 +30,35 @@ function ChatThreadRouteView() { |
| 159 | threadRef ? store.getDraftThreadByRef(threadRef) : null, | 30 | threadRef ? store.getDraftThreadByRef(threadRef) : null, |
| 160 | ); | 31 | ); |
| 161 | const environmentHasDraftThreads = useComposerDraftStore((store) => { | 32 | const environmentHasDraftThreads = useComposerDraftStore((store) => { |
| 162 | if (!threadRef) { | 33 | if (!threadRef) return false; |
| 163 | return false; | ||
| 164 | } | ||
| 165 | return store.hasDraftThreadsInEnvironment(threadRef.environmentId); | 34 | return store.hasDraftThreadsInEnvironment(threadRef.environmentId); |
| 166 | }); | 35 | }); |
| 167 | const routeThreadExists = threadExists || draftThreadExists; | 36 | const routeThreadExists = threadExists || draftThreadExists; |
| 168 | const serverThreadStarted = threadHasStarted(serverThread); | 37 | const serverThreadStarted = threadHasStarted(serverThread); |
| 169 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; | 38 | const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; |
| 170 | const diffOpen = search.diff === "1"; | ||
| 171 | const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); | ||
| 172 | const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; | ||
| 173 | const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ | ||
| 174 | threadKey: currentThreadKey, | ||
| 175 | hasOpenedDiff: diffOpen, | ||
| 176 | })); | ||
| 177 | const hasOpenedDiff = | ||
| 178 | diffPanelMountState.threadKey === currentThreadKey | ||
| 179 | ? diffPanelMountState.hasOpenedDiff | ||
| 180 | : diffOpen; | ||
| 181 | const markDiffOpened = useCallback(() => { | ||
| 182 | setDiffPanelMountState((previous) => { | ||
| 183 | if (previous.threadKey === currentThreadKey && previous.hasOpenedDiff) { | ||
| 184 | return previous; | ||
| 185 | } | ||
| 186 | return { | ||
| 187 | threadKey: currentThreadKey, | ||
| 188 | hasOpenedDiff: true, | ||
| 189 | }; | ||
| 190 | }); | ||
| 191 | }, [currentThreadKey]); | ||
| 192 | const closeDiff = useCallback(() => { | ||
| 193 | if (!threadRef) { | ||
| 194 | return; | ||
| 195 | } | ||
| 196 | void navigate({ | ||
| 197 | to: "/$environmentId/$threadId", | ||
| 198 | params: buildThreadRouteParams(threadRef), | ||
| 199 | search: { diff: undefined }, | ||
| 200 | }); | ||
| 201 | }, [navigate, threadRef]); | ||
| 202 | const openDiff = useCallback(() => { | ||
| 203 | if (!threadRef) { | ||
| 204 | return; | ||
| 205 | } | ||
| 206 | markDiffOpened(); | ||
| 207 | void navigate({ | ||
| 208 | to: "/$environmentId/$threadId", | ||
| 209 | params: buildThreadRouteParams(threadRef), | ||
| 210 | search: (previous) => { | ||
| 211 | const rest = stripDiffSearchParams(previous); | ||
| 212 | return { ...rest, diff: "1" }; | ||
| 213 | }, | ||
| 214 | }); | ||
| 215 | }, [markDiffOpened, navigate, threadRef]); | ||
| 216 | 39 | ||
| 217 | useEffect(() => { | 40 | useEffect(() => { |
| 218 | if (!threadRef || !bootstrapComplete) { | 41 | if (!threadRef || !bootstrapComplete) return; |
| 219 | return; | ||
| 220 | } | ||
| 221 | |||
| 222 | if (!routeThreadExists && environmentHasAnyThreads) { | 42 | if (!routeThreadExists && environmentHasAnyThreads) { |
| 223 | void navigate({ to: "/", replace: true }); | 43 | void navigate({ to: "/", replace: true }); |
| 224 | } | 44 | } |
| 225 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); | 45 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); |
| 226 | 46 | ||
| 227 | useEffect(() => { | 47 | useEffect(() => { |
| 228 | if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) { | 48 | if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) return; |
| 229 | return; | ||
| 230 | } | ||
| 231 | finalizePromotedDraftThreadByRef(threadRef); | 49 | finalizePromotedDraftThreadByRef(threadRef); |
| 232 | }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); | 50 | }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); |
| 233 | 51 | ||
| 234 | if (!threadRef || !bootstrapComplete || !routeThreadExists) { | 52 | if (!threadRef || !bootstrapComplete || !routeThreadExists) return null; |
| 235 | return null; | ||
| 236 | } | ||
| 237 | |||
| 238 | const shouldRenderDiffContent = diffOpen || hasOpenedDiff; | ||
| 239 | |||
| 240 | if (!shouldUseDiffSheet) { | ||
| 241 | return ( | ||
| 242 | <> | ||
| 243 | <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh"> | ||
| 244 | <ChatView | ||
| 245 | environmentId={threadRef.environmentId} | ||
| 246 | threadId={threadRef.threadId} | ||
| 247 | onDiffPanelOpen={markDiffOpened} | ||
| 248 | reserveTitleBarControlInset={!diffOpen} | ||
| 249 | routeKind="server" | ||
| 250 | /> | ||
| 251 | </SidebarInset> | ||
| 252 | <DiffPanelInlineSidebar | ||
| 253 | diffOpen={diffOpen} | ||
| 254 | onCloseDiff={closeDiff} | ||
| 255 | onOpenDiff={openDiff} | ||
| 256 | renderDiffContent={shouldRenderDiffContent} | ||
| 257 | /> | ||
| 258 | </> | ||
| 259 | ); | ||
| 260 | } | ||
| 261 | 53 | ||
| 262 | return ( | 54 | return ( |
| 263 | <> | 55 | <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh"> |
| 264 | <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh"> | 56 | <ChatView |
| 265 | <ChatView | 57 | environmentId={threadRef.environmentId} |
| 266 | environmentId={threadRef.environmentId} | 58 | threadId={threadRef.threadId} |
| 267 | threadId={threadRef.threadId} | 59 | routeKind="server" |
| 268 | onDiffPanelOpen={markDiffOpened} | 60 | /> |
| 269 | routeKind="server" | 61 | </SidebarInset> |
| 270 | /> | ||
| 271 | </SidebarInset> | ||
| 272 | <RightPanelSheet open={diffOpen} onClose={closeDiff}> | ||
| 273 | {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null} | ||
| 274 | </RightPanelSheet> | ||
| 275 | </> | ||
| 276 | ); | 62 | ); |
| 277 | } | 63 | } |
| 278 | 64 |
apps/web/src/session-logic.test.ts+61| ... | @@ -934,6 +934,67 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -934,6 +934,67 @@ describe("deriveWorkLogEntries", () => { |
| 934 | expect(entry?.toolLifecycleStatus).toBe("completed"); | 934 | expect(entry?.toolLifecycleStatus).toBe("completed"); |
| 935 | }); | 935 | }); |
| 936 | 936 | ||
| 937 | it("preserves MCP server, tool, arguments, and results for expanded display", () => { | ||
| 938 | const item = { | ||
| 939 | type: "mcpToolCall", | ||
| 940 | server: "t3-code", | ||
| 941 | tool: "preview_status", | ||
| 942 | arguments: {}, | ||
| 943 | status: "completed", | ||
| 944 | result: { content: [{ type: "text", text: "attached" }] }, | ||
| 945 | }; | ||
| 946 | const activities: OrchestrationThreadActivity[] = [ | ||
| 947 | makeActivity({ | ||
| 948 | id: "mcp-tool-done", | ||
| 949 | kind: "tool.completed", | ||
| 950 | summary: "t3-code · preview_status", | ||
| 951 | payload: { | ||
| 952 | itemType: "mcp_tool_call", | ||
| 953 | title: "t3-code · preview_status", | ||
| 954 | data: { item }, | ||
| 955 | }, | ||
| 956 | }), | ||
| 957 | ]; | ||
| 958 | |||
| 959 | const [entry] = deriveWorkLogEntries(activities); | ||
| 960 | expect(entry?.toolTitle).toBe("t3-code · preview_status"); | ||
| 961 | expect(entry?.toolData).toEqual(item); | ||
| 962 | }); | ||
| 963 | |||
| 964 | it("keeps MCP payloads while collapsing lifecycle updates", () => { | ||
| 965 | const item = { | ||
| 966 | type: "mcpToolCall", | ||
| 967 | server: "t3-code", | ||
| 968 | tool: "preview_snapshot", | ||
| 969 | arguments: { interactiveOnly: true }, | ||
| 970 | status: "completed", | ||
| 971 | }; | ||
| 972 | const activities: OrchestrationThreadActivity[] = [ | ||
| 973 | makeActivity({ | ||
| 974 | id: "mcp-tool-progress", | ||
| 975 | kind: "tool.updated", | ||
| 976 | summary: "t3-code · preview_snapshot", | ||
| 977 | payload: { | ||
| 978 | itemType: "mcp_tool_call", | ||
| 979 | toolCallId: "call-1", | ||
| 980 | data: { item }, | ||
| 981 | }, | ||
| 982 | }), | ||
| 983 | makeActivity({ | ||
| 984 | id: "mcp-tool-complete", | ||
| 985 | kind: "tool.completed", | ||
| 986 | summary: "t3-code · preview_snapshot", | ||
| 987 | payload: { | ||
| 988 | itemType: "mcp_tool_call", | ||
| 989 | toolCallId: "call-1", | ||
| 990 | }, | ||
| 991 | }), | ||
| 992 | ]; | ||
| 993 | |||
| 994 | const [entry] = deriveWorkLogEntries(activities); | ||
| 995 | expect(entry?.toolData).toEqual(item); | ||
| 996 | }); | ||
| 997 | |||
| 937 | it("unwraps PowerShell command wrappers for displayed command text", () => { | 998 | it("unwraps PowerShell command wrappers for displayed command text", () => { |
| 938 | const activities: OrchestrationThreadActivity[] = [ | 999 | const activities: OrchestrationThreadActivity[] = [ |
| 939 | makeActivity({ | 1000 | makeActivity({ |
apps/web/src/session-logic.ts+9| ... | @@ -71,6 +71,7 @@ export interface WorkLogEntry { | ... | @@ -71,6 +71,7 @@ export interface WorkLogEntry { |
| 71 | changedFiles?: ReadonlyArray<string>; | 71 | changedFiles?: ReadonlyArray<string>; |
| 72 | tone: "thinking" | "tool" | "info" | "error"; | 72 | tone: "thinking" | "tool" | "info" | "error"; |
| 73 | toolTitle?: string; | 73 | toolTitle?: string; |
| 74 | toolData?: unknown; | ||
| 74 | itemType?: ToolLifecycleItemType; | 75 | itemType?: ToolLifecycleItemType; |
| 75 | requestKind?: PendingApproval["requestKind"]; | 76 | requestKind?: PendingApproval["requestKind"]; |
| 76 | /** From runtime item / task payload `status` when present (e.g. tool.updated). */ | 77 | /** From runtime item / task payload `status` when present (e.g. tool.updated). */ |
| ... | @@ -734,6 +735,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo | ... | @@ -734,6 +735,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 734 | if (title) { | 735 | if (title) { |
| 735 | entry.toolTitle = title; | 736 | entry.toolTitle = title; |
| 736 | } | 737 | } |
| 738 | if (itemType === "mcp_tool_call") { | ||
| 739 | const data = asRecord(payload?.data); | ||
| 740 | if (data?.item !== undefined) { | ||
| 741 | entry.toolData = data.item; | ||
| 742 | } | ||
| 743 | } | ||
| 737 | if (itemType) { | 744 | if (itemType) { |
| 738 | entry.itemType = itemType; | 745 | entry.itemType = itemType; |
| 739 | } | 746 | } |
| ... | @@ -811,6 +818,7 @@ function mergeDerivedWorkLogEntries( | ... | @@ -811,6 +818,7 @@ function mergeDerivedWorkLogEntries( |
| 811 | const collapseKey = next.collapseKey ?? previous.collapseKey; | 818 | const collapseKey = next.collapseKey ?? previous.collapseKey; |
| 812 | const toolCallId = next.toolCallId ?? previous.toolCallId; | 819 | const toolCallId = next.toolCallId ?? previous.toolCallId; |
| 813 | const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; | 820 | const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; |
| 821 | const toolData = next.toolData ?? previous.toolData; | ||
| 814 | return { | 822 | return { |
| 815 | ...previous, | 823 | ...previous, |
| 816 | ...next, | 824 | ...next, |
| ... | @@ -824,6 +832,7 @@ function mergeDerivedWorkLogEntries( | ... | @@ -824,6 +832,7 @@ function mergeDerivedWorkLogEntries( |
| 824 | ...(collapseKey ? { collapseKey } : {}), | 832 | ...(collapseKey ? { collapseKey } : {}), |
| 825 | ...(toolCallId ? { toolCallId } : {}), | 833 | ...(toolCallId ? { toolCallId } : {}), |
| 826 | ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}), | 834 | ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}), |
| 835 | ...(toolData !== undefined ? { toolData } : {}), | ||
| 827 | }; | 836 | }; |
| 828 | } | 837 | } |
| 829 | 838 |
packages/contracts/src/ipc.ts+39-1| ... | @@ -451,6 +451,7 @@ export interface DesktopPreviewTabState { | ... | @@ -451,6 +451,7 @@ export interface DesktopPreviewTabState { |
| 451 | canGoForward: boolean; | 451 | canGoForward: boolean; |
| 452 | /** Current zoom factor (1.0 = 100%). */ | 452 | /** Current zoom factor (1.0 = 100%). */ |
| 453 | zoomFactor: number; | 453 | zoomFactor: number; |
| 454 | controller: "human" | "agent" | "none"; | ||
| 454 | updatedAt: string; | 455 | updatedAt: string; |
| 455 | } | 456 | } |
| 456 | 457 | ||
| ... | @@ -477,6 +478,32 @@ export interface DesktopPreviewWebviewConfig { | ... | @@ -477,6 +478,32 @@ export interface DesktopPreviewWebviewConfig { |
| 477 | preloadUrl: string | null; | 478 | preloadUrl: string | null; |
| 478 | } | 479 | } |
| 479 | 480 | ||
| 481 | export interface DesktopPreviewRecordingFrame { | ||
| 482 | tabId: string; | ||
| 483 | data: string; | ||
| 484 | width: number; | ||
| 485 | height: number; | ||
| 486 | receivedAt: string; | ||
| 487 | } | ||
| 488 | |||
| 489 | export interface DesktopPreviewRecordingArtifact { | ||
| 490 | id: string; | ||
| 491 | tabId: string; | ||
| 492 | path: string; | ||
| 493 | mimeType: string; | ||
| 494 | sizeBytes: number; | ||
| 495 | createdAt: string; | ||
| 496 | } | ||
| 497 | |||
| 498 | export interface DesktopPreviewScreenshotArtifact { | ||
| 499 | id: string; | ||
| 500 | tabId: string; | ||
| 501 | path: string; | ||
| 502 | mimeType: "image/png"; | ||
| 503 | sizeBytes: number; | ||
| 504 | createdAt: string; | ||
| 505 | } | ||
| 506 | |||
| 480 | /** | 507 | /** |
| 481 | * Single stack frame captured by react-grab's `getElementContext`. We surface | 508 | * Single stack frame captured by react-grab's `getElementContext`. We surface |
| 482 | * the source file/line so coding agents can jump straight to the JSX that | 509 | * the source file/line so coding agents can jump straight to the JSX that |
| ... | @@ -672,7 +699,7 @@ export interface DesktopPreviewBridge { | ... | @@ -672,7 +699,7 @@ export interface DesktopPreviewBridge { |
| 672 | * `getPickPreloadPath`) so adding a new field here only requires touching | 699 | * `getPickPreloadPath`) so adding a new field here only requires touching |
| 673 | * the contract + main, not the renderer's mount logic. | 700 | * the contract + main, not the renderer's mount logic. |
| 674 | */ | 701 | */ |
| 675 | getPreviewConfig: () => Promise<DesktopPreviewWebviewConfig>; | 702 | getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>; |
| 676 | /** | 703 | /** |
| 677 | * Activate the in-page element picker for the given tab. Resolves with | 704 | * Activate the in-page element picker for the given tab. Resolves with |
| 678 | * the picked payload, or `null` when the user cancels (Escape / nav). The | 705 | * the picked payload, or `null` when the user cancels (Escape / nav). The |
| ... | @@ -681,6 +708,17 @@ export interface DesktopPreviewBridge { | ... | @@ -681,6 +708,17 @@ export interface DesktopPreviewBridge { |
| 681 | pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>; | 708 | pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>; |
| 682 | /** Cancel an in-flight preview annotation session. */ | 709 | /** Cancel an in-flight preview annotation session. */ |
| 683 | cancelPickElement: (tabId: string) => Promise<void>; | 710 | cancelPickElement: (tabId: string) => Promise<void>; |
| 711 | captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; | ||
| 712 | recording: { | ||
| 713 | startScreencast: (tabId: string) => Promise<void>; | ||
| 714 | stopScreencast: (tabId: string) => Promise<void>; | ||
| 715 | save: ( | ||
| 716 | tabId: string, | ||
| 717 | mimeType: string, | ||
| 718 | data: Uint8Array, | ||
| 719 | ) => Promise<DesktopPreviewRecordingArtifact>; | ||
| 720 | onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => () => void; | ||
| 721 | }; | ||
| 684 | automation: { | 722 | automation: { |
| 685 | status: (tabId: string) => Promise<PreviewAutomationStatus>; | 723 | status: (tabId: string) => Promise<PreviewAutomationStatus>; |
| 686 | snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>; | 724 | snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>; |
packages/contracts/src/previewAutomation.ts+330-37| ... | @@ -3,10 +3,19 @@ import { Schema } from "effect"; | ... | @@ -3,10 +3,19 @@ import { Schema } from "effect"; |
| 3 | import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 3 | import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | import { PreviewTabId } from "./preview.ts"; | 4 | import { PreviewTabId } from "./preview.ts"; |
| 5 | 5 | ||
| 6 | const BoundedUrl = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); | 6 | const BoundedUrl = Schema.String.check(Schema.isTrimmed()) |
| 7 | .check( | ||
| 8 | Schema.isNonEmpty({ | ||
| 9 | description: | ||
| 10 | "Absolute http(s) URL or a schemeless host such as t3.chat or localhost:5173. Schemeless public hosts use https; loopback hosts use http.", | ||
| 11 | }), | ||
| 12 | ) | ||
| 13 | .check(Schema.isMaxLength(2048)); | ||
| 7 | const OptionalTimeoutMs = Schema.optional( | 14 | const OptionalTimeoutMs = Schema.optional( |
| 8 | Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThanOrEqualTo(60_000)), | 15 | Schema.Int.check(Schema.isGreaterThan(0)) |
| 9 | ); | 16 | .check(Schema.isLessThanOrEqualTo(60_000)) |
| 17 | .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }), | ||
| 18 | ).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }); | ||
| 10 | 19 | ||
| 11 | export const PreviewAutomationOperation = Schema.Literals([ | 20 | export const PreviewAutomationOperation = Schema.Literals([ |
| 12 | "status", | 21 | "status", |
| ... | @@ -19,6 +28,8 @@ export const PreviewAutomationOperation = Schema.Literals([ | ... | @@ -19,6 +28,8 @@ export const PreviewAutomationOperation = Schema.Literals([ |
| 19 | "scroll", | 28 | "scroll", |
| 20 | "evaluate", | 29 | "evaluate", |
| 21 | "waitFor", | 30 | "waitFor", |
| 31 | "recordingStart", | ||
| 32 | "recordingStop", | ||
| 22 | ]); | 33 | ]); |
| 23 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; | 34 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; |
| 24 | 35 | ||
| ... | @@ -33,66 +44,294 @@ export const PreviewAutomationStatus = Schema.Struct({ | ... | @@ -33,66 +44,294 @@ export const PreviewAutomationStatus = Schema.Struct({ |
| 33 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; | 44 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; |
| 34 | 45 | ||
| 35 | export const PreviewAutomationOpenInput = Schema.Struct({ | 46 | export const PreviewAutomationOpenInput = Schema.Struct({ |
| 36 | url: Schema.optional(BoundedUrl), | 47 | url: Schema.optional(BoundedUrl).annotate({ |
| 37 | show: Schema.optional(Schema.Boolean), | 48 | description: |
| 38 | reuseExistingTab: Schema.optional(Schema.Boolean), | 49 | "Optional initial page URL, for example https://t3.chat or localhost:5173. Omit to open a blank tab.", |
| 50 | }), | ||
| 51 | show: Schema.optional( | ||
| 52 | Schema.Boolean.annotate({ | ||
| 53 | description: "Whether to reveal the preview panel to the human. Defaults to true.", | ||
| 54 | }), | ||
| 55 | ), | ||
| 56 | reuseExistingTab: Schema.optional( | ||
| 57 | Schema.Boolean.annotate({ | ||
| 58 | description: | ||
| 59 | "Reuse the thread's active browser tab when available. Defaults to true; set false to create a new tab.", | ||
| 60 | }), | ||
| 61 | ), | ||
| 62 | }).annotate({ | ||
| 63 | description: | ||
| 64 | "Opens the collaborative browser for the current thread. Use preview_navigate afterward when readiness waiting matters.", | ||
| 39 | }); | 65 | }); |
| 40 | export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; | 66 | export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; |
| 41 | 67 | ||
| 68 | export const BrowserNavigationTarget = Schema.Union([ | ||
| 69 | Schema.Struct({ | ||
| 70 | kind: Schema.Literal("url").annotate({ | ||
| 71 | description: "Selects direct URL navigation.", | ||
| 72 | }), | ||
| 73 | url: BoundedUrl.annotate({ | ||
| 74 | description: "Direct website URL.", | ||
| 75 | }), | ||
| 76 | }), | ||
| 77 | Schema.Struct({ | ||
| 78 | kind: Schema.Literal("environment-port").annotate({ | ||
| 79 | description: "Selects a dev-server port relative to the current execution environment.", | ||
| 80 | }), | ||
| 81 | port: Schema.Int.check(Schema.isGreaterThan(0)) | ||
| 82 | .check(Schema.isLessThan(65_536)) | ||
| 83 | .annotate({ description: "Dev-server TCP port inside the current environment." }), | ||
| 84 | protocol: Schema.optional( | ||
| 85 | Schema.Literals(["http", "https"]).annotate({ | ||
| 86 | description: "Dev-server protocol. Defaults to http.", | ||
| 87 | }), | ||
| 88 | ), | ||
| 89 | path: Schema.optional( | ||
| 90 | Schema.String.annotate({ | ||
| 91 | description: "Optional path, query, and fragment, for example /settings?tab=account.", | ||
| 92 | }), | ||
| 93 | ), | ||
| 94 | }), | ||
| 95 | ]); | ||
| 96 | export type BrowserNavigationTarget = typeof BrowserNavigationTarget.Type; | ||
| 97 | |||
| 42 | export const PreviewAutomationNavigateInput = Schema.Struct({ | 98 | export const PreviewAutomationNavigateInput = Schema.Struct({ |
| 43 | url: BoundedUrl, | 99 | url: Schema.optional(BoundedUrl).annotate({ |
| 44 | readiness: Schema.optional(Schema.Literals(["load", "domContentLoaded", "none"])), | 100 | description: |
| 101 | "Website URL, for example https://t3.chat. Use this for public pages and directly reachable URLs.", | ||
| 102 | }), | ||
| 103 | target: Schema.optional( | ||
| 104 | BrowserNavigationTarget.annotate({ | ||
| 105 | description: | ||
| 106 | "Environment-relative target. Prefer {kind:'environment-port',port:5173} for a dev server in the current environment.", | ||
| 107 | }), | ||
| 108 | ).annotate({ | ||
| 109 | description: | ||
| 110 | "Environment-relative target. Prefer {kind:'environment-port',port:5173} for a dev server in the current environment.", | ||
| 111 | }), | ||
| 112 | readiness: Schema.optional( | ||
| 113 | Schema.Literals(["load", "domContentLoaded", "none"]).annotate({ | ||
| 114 | description: | ||
| 115 | "Readiness milestone before returning. 'load' waits for loading to stop (default), 'domContentLoaded' waits for an interactive document, and 'none' returns immediately.", | ||
| 116 | }), | ||
| 117 | ).annotate({ | ||
| 118 | description: | ||
| 119 | "Readiness milestone before returning. 'load' is the default; use 'none' only when a later wait call will verify the page.", | ||
| 120 | }), | ||
| 45 | timeoutMs: OptionalTimeoutMs, | 121 | timeoutMs: OptionalTimeoutMs, |
| 46 | }); | 122 | }) |
| 123 | .check( | ||
| 124 | Schema.makeFilter( | ||
| 125 | (input) => | ||
| 126 | Number(input.url !== undefined) + Number(input.target !== undefined) === 1 || | ||
| 127 | "Provide exactly one of url or target.", | ||
| 128 | ), | ||
| 129 | ) | ||
| 130 | .annotate({ | ||
| 131 | description: | ||
| 132 | "Navigates the active browser tab. Provide exactly one of url or target; for most public pages use url.", | ||
| 133 | }); | ||
| 47 | export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; | 134 | export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; |
| 48 | 135 | ||
| 49 | export const PreviewAutomationClickInput = Schema.Union([ | 136 | const Locator = TrimmedNonEmptyString.annotate({ |
| 50 | Schema.Struct({ | 137 | description: |
| 51 | selector: TrimmedNonEmptyString, | 138 | "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", |
| 52 | timeoutMs: OptionalTimeoutMs, | 139 | }); |
| 140 | |||
| 141 | const LegacySelector = TrimmedNonEmptyString.annotate({ | ||
| 142 | description: | ||
| 143 | "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.", | ||
| 144 | }); | ||
| 145 | |||
| 146 | export const PreviewAutomationClickInput = Schema.Struct({ | ||
| 147 | selector: Schema.optional(LegacySelector).annotate({ | ||
| 148 | description: | ||
| 149 | "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.", | ||
| 53 | }), | 150 | }), |
| 54 | Schema.Struct({ | 151 | locator: Schema.optional(Locator).annotate({ |
| 55 | x: Schema.Number, | 152 | description: |
| 56 | y: Schema.Number, | 153 | "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", |
| 57 | timeoutMs: OptionalTimeoutMs, | ||
| 58 | }), | 154 | }), |
| 59 | ]); | 155 | x: Schema.optional( |
| 156 | Schema.Finite.annotate({ | ||
| 157 | description: "Viewport-relative X coordinate in CSS pixels. Must be paired with y.", | ||
| 158 | }), | ||
| 159 | ), | ||
| 160 | y: Schema.optional( | ||
| 161 | Schema.Finite.annotate({ | ||
| 162 | description: "Viewport-relative Y coordinate in CSS pixels. Must be paired with x.", | ||
| 163 | }), | ||
| 164 | ), | ||
| 165 | timeoutMs: OptionalTimeoutMs, | ||
| 166 | }) | ||
| 167 | .check( | ||
| 168 | Schema.makeFilter((input) => { | ||
| 169 | const selectorModes = | ||
| 170 | Number(input.selector !== undefined) + Number(input.locator !== undefined); | ||
| 171 | const hasX = input.x !== undefined; | ||
| 172 | const hasY = input.y !== undefined; | ||
| 173 | if (hasX !== hasY) return "Coordinates require both x and y."; | ||
| 174 | const coordinateModes = hasX && hasY ? 1 : 0; | ||
| 175 | return selectorModes + coordinateModes === 1 || "Provide exactly one click target."; | ||
| 176 | }), | ||
| 177 | ) | ||
| 178 | .annotate({ | ||
| 179 | description: | ||
| 180 | "Clicks one target. Provide exactly one of locator, selector, or the x/y coordinate pair.", | ||
| 181 | }); | ||
| 60 | export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; | 182 | export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; |
| 61 | 183 | ||
| 62 | export const PreviewAutomationTypeInput = Schema.Struct({ | 184 | export const PreviewAutomationTypeInput = Schema.Struct({ |
| 63 | text: Schema.String, | 185 | text: Schema.String.annotate({ description: "Literal text to insert." }), |
| 64 | selector: Schema.optional(TrimmedNonEmptyString), | 186 | selector: Schema.optional(LegacySelector).annotate({ |
| 65 | clear: Schema.optional(Schema.Boolean), | 187 | description: "Legacy CSS selector for the input. Prefer locator.", |
| 188 | }), | ||
| 189 | locator: Schema.optional(Locator).annotate({ | ||
| 190 | description: | ||
| 191 | "Playwright selector for the input, for example role=textbox[name='Message'] or textarea[placeholder*='Message'].", | ||
| 192 | }), | ||
| 193 | clear: Schema.optional( | ||
| 194 | Schema.Boolean.annotate({ | ||
| 195 | description: "Clear the existing input value before inserting text. Defaults to false.", | ||
| 196 | }), | ||
| 197 | ), | ||
| 66 | timeoutMs: OptionalTimeoutMs, | 198 | timeoutMs: OptionalTimeoutMs, |
| 67 | }); | 199 | }) |
| 200 | .check( | ||
| 201 | Schema.makeFilter( | ||
| 202 | (input) => | ||
| 203 | !(input.selector !== undefined && input.locator !== undefined) || | ||
| 204 | "Provide at most one of selector or locator.", | ||
| 205 | ), | ||
| 206 | ) | ||
| 207 | .annotate({ | ||
| 208 | description: | ||
| 209 | "Types into locator/selector, or into the currently focused element when neither target is provided.", | ||
| 210 | }); | ||
| 68 | export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; | 211 | export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; |
| 69 | 212 | ||
| 70 | export const PreviewAutomationPressInput = Schema.Struct({ | 213 | export const PreviewAutomationPressInput = Schema.Struct({ |
| 71 | key: TrimmedNonEmptyString, | 214 | key: Schema.String.check(Schema.isTrimmed()) |
| 72 | modifiers: Schema.optional(Schema.Array(Schema.Literals(["Alt", "Control", "Meta", "Shift"]))), | 215 | .check( |
| 73 | }); | 216 | Schema.isNonEmpty({ |
| 217 | description: | ||
| 218 | "Keyboard key name such as Enter, Escape, Tab, ArrowDown, Backspace, or a single character.", | ||
| 219 | }), | ||
| 220 | ) | ||
| 221 | .annotateKey({ | ||
| 222 | description: | ||
| 223 | "Keyboard key name such as Enter, Escape, Tab, ArrowDown, Backspace, or a single character.", | ||
| 224 | }), | ||
| 225 | modifiers: Schema.optional( | ||
| 226 | Schema.Array(Schema.Literals(["Alt", "Control", "Meta", "Shift"])).annotate({ | ||
| 227 | description: "Modifier keys held while pressing key.", | ||
| 228 | }), | ||
| 229 | ), | ||
| 230 | }).annotate({ description: "Presses one keyboard key in the active browser tab." }); | ||
| 74 | export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; | 231 | export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; |
| 75 | 232 | ||
| 76 | export const PreviewAutomationScrollInput = Schema.Struct({ | 233 | export const PreviewAutomationScrollInput = Schema.Struct({ |
| 77 | deltaX: Schema.optional(Schema.Number), | 234 | deltaX: Schema.optional( |
| 78 | deltaY: Schema.optional(Schema.Number), | 235 | Schema.Finite.annotate({ |
| 79 | selector: Schema.optional(TrimmedNonEmptyString), | 236 | description: "Horizontal scroll delta in CSS pixels. Positive scrolls right. Defaults to 0.", |
| 80 | }); | 237 | }), |
| 238 | ), | ||
| 239 | deltaY: Schema.optional( | ||
| 240 | Schema.Finite.annotate({ | ||
| 241 | description: "Vertical scroll delta in CSS pixels. Positive scrolls down. Defaults to 0.", | ||
| 242 | }), | ||
| 243 | ), | ||
| 244 | selector: Schema.optional(LegacySelector).annotate({ | ||
| 245 | description: "Legacy CSS selector for a scrollable container. Omit to scroll the viewport.", | ||
| 246 | }), | ||
| 247 | locator: Schema.optional(Locator).annotate({ | ||
| 248 | description: "Playwright selector for a scrollable container. Omit to scroll the viewport.", | ||
| 249 | }), | ||
| 250 | }) | ||
| 251 | .check( | ||
| 252 | Schema.makeFilter((input) => { | ||
| 253 | if (input.selector !== undefined && input.locator !== undefined) { | ||
| 254 | return "Provide at most one of selector or locator."; | ||
| 255 | } | ||
| 256 | return ( | ||
| 257 | input.deltaX !== undefined || input.deltaY !== undefined || "Provide deltaX or deltaY." | ||
| 258 | ); | ||
| 259 | }), | ||
| 260 | ) | ||
| 261 | .annotate({ | ||
| 262 | description: | ||
| 263 | "Scrolls the viewport, or a locator/selector container. Provide deltaX, deltaY, or both.", | ||
| 264 | }); | ||
| 81 | export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; | 265 | export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; |
| 82 | 266 | ||
| 83 | export const PreviewAutomationEvaluateInput = Schema.Struct({ | 267 | export const PreviewAutomationEvaluateInput = Schema.Struct({ |
| 84 | expression: TrimmedNonEmptyString.check(Schema.isMaxLength(64_000)), | 268 | expression: Schema.String.check(Schema.isTrimmed()) |
| 85 | awaitPromise: Schema.optional(Schema.Boolean), | 269 | .check( |
| 86 | returnByValue: Schema.optional(Schema.Boolean), | 270 | Schema.isNonEmpty({ |
| 271 | description: | ||
| 272 | "JavaScript expression evaluated in the page's main frame, for example document.title or (() => ({href: location.href}))().", | ||
| 273 | }), | ||
| 274 | ) | ||
| 275 | .check(Schema.isMaxLength(64_000)) | ||
| 276 | .annotateKey({ | ||
| 277 | description: | ||
| 278 | "JavaScript expression evaluated in the page's main frame, for example document.title or (() => ({href: location.href}))().", | ||
| 279 | }), | ||
| 280 | awaitPromise: Schema.optional( | ||
| 281 | Schema.Boolean.annotate({ description: "Await a returned Promise. Defaults to true." }), | ||
| 282 | ), | ||
| 283 | returnByValue: Schema.optional( | ||
| 284 | Schema.Boolean.annotate({ | ||
| 285 | description: | ||
| 286 | "Serialize and return the value instead of a remote object reference. Defaults to true.", | ||
| 287 | }), | ||
| 288 | ), | ||
| 289 | }).annotate({ | ||
| 290 | description: | ||
| 291 | "Evaluates JavaScript in the page. Prefer snapshot and semantic actions; use evaluate for inspection or unsupported interactions.", | ||
| 87 | }); | 292 | }); |
| 88 | export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; | 293 | export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; |
| 89 | 294 | ||
| 90 | export const PreviewAutomationWaitForInput = Schema.Struct({ | 295 | export const PreviewAutomationWaitForInput = Schema.Struct({ |
| 91 | selector: Schema.optional(TrimmedNonEmptyString), | 296 | selector: Schema.optional(LegacySelector).annotate({ |
| 92 | text: Schema.optional(TrimmedNonEmptyString), | 297 | description: "Legacy CSS selector that must match an element. Prefer locator.", |
| 93 | urlIncludes: Schema.optional(TrimmedNonEmptyString), | 298 | }), |
| 299 | locator: Schema.optional(Locator).annotate({ | ||
| 300 | description: | ||
| 301 | "Playwright selector that must match an element, for example role=button[name='Send'].", | ||
| 302 | }), | ||
| 303 | text: Schema.optional( | ||
| 304 | TrimmedNonEmptyString.annotate({ | ||
| 305 | description: "Case-sensitive substring that must appear in visible document text.", | ||
| 306 | }), | ||
| 307 | ).annotate({ | ||
| 308 | description: "Case-sensitive substring that must appear in visible document text.", | ||
| 309 | }), | ||
| 310 | urlIncludes: Schema.optional( | ||
| 311 | TrimmedNonEmptyString.annotate({ | ||
| 312 | description: "Substring that must appear in the current absolute URL.", | ||
| 313 | }), | ||
| 314 | ).annotate({ description: "Substring that must appear in the current absolute URL." }), | ||
| 94 | timeoutMs: OptionalTimeoutMs, | 315 | timeoutMs: OptionalTimeoutMs, |
| 95 | }); | 316 | }) |
| 317 | .check( | ||
| 318 | Schema.makeFilter((input) => { | ||
| 319 | if (input.selector !== undefined && input.locator !== undefined) { | ||
| 320 | return "Provide at most one of selector or locator."; | ||
| 321 | } | ||
| 322 | return ( | ||
| 323 | input.selector !== undefined || | ||
| 324 | input.locator !== undefined || | ||
| 325 | input.text !== undefined || | ||
| 326 | input.urlIncludes !== undefined || | ||
| 327 | "Provide at least one wait condition." | ||
| 328 | ); | ||
| 329 | }), | ||
| 330 | ) | ||
| 331 | .annotate({ | ||
| 332 | description: | ||
| 333 | "Waits until all provided conditions match. Use after click/type when the page changes asynchronously.", | ||
| 334 | }); | ||
| 96 | export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type; | 335 | export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type; |
| 97 | 336 | ||
| 98 | export const PreviewAutomationElement = Schema.Struct({ | 337 | export const PreviewAutomationElement = Schema.Struct({ |
| ... | @@ -107,6 +346,34 @@ export const PreviewAutomationElement = Schema.Struct({ | ... | @@ -107,6 +346,34 @@ export const PreviewAutomationElement = Schema.Struct({ |
| 107 | }); | 346 | }); |
| 108 | export type PreviewAutomationElement = typeof PreviewAutomationElement.Type; | 347 | export type PreviewAutomationElement = typeof PreviewAutomationElement.Type; |
| 109 | 348 | ||
| 349 | export const PreviewAutomationConsoleEntry = Schema.Struct({ | ||
| 350 | level: Schema.String, | ||
| 351 | text: Schema.String, | ||
| 352 | timestamp: Schema.String, | ||
| 353 | source: Schema.optional(Schema.String), | ||
| 354 | }); | ||
| 355 | export type PreviewAutomationConsoleEntry = typeof PreviewAutomationConsoleEntry.Type; | ||
| 356 | |||
| 357 | export const PreviewAutomationNetworkEntry = Schema.Struct({ | ||
| 358 | url: Schema.String, | ||
| 359 | method: Schema.String, | ||
| 360 | status: Schema.NullOr(Schema.Number), | ||
| 361 | failed: Schema.Boolean, | ||
| 362 | errorText: Schema.optional(Schema.String), | ||
| 363 | timestamp: Schema.String, | ||
| 364 | }); | ||
| 365 | export type PreviewAutomationNetworkEntry = typeof PreviewAutomationNetworkEntry.Type; | ||
| 366 | |||
| 367 | export const PreviewAutomationActionEvent = Schema.Struct({ | ||
| 368 | id: Schema.String, | ||
| 369 | action: Schema.String, | ||
| 370 | status: Schema.Literals(["running", "succeeded", "failed", "interrupted"]), | ||
| 371 | startedAt: Schema.String, | ||
| 372 | completedAt: Schema.optional(Schema.String), | ||
| 373 | error: Schema.optional(Schema.String), | ||
| 374 | }); | ||
| 375 | export type PreviewAutomationActionEvent = typeof PreviewAutomationActionEvent.Type; | ||
| 376 | |||
| 110 | export const PreviewAutomationSnapshot = Schema.Struct({ | 377 | export const PreviewAutomationSnapshot = Schema.Struct({ |
| 111 | url: Schema.String, | 378 | url: Schema.String, |
| 112 | title: Schema.String, | 379 | title: Schema.String, |
| ... | @@ -114,6 +381,9 @@ export const PreviewAutomationSnapshot = Schema.Struct({ | ... | @@ -114,6 +381,9 @@ export const PreviewAutomationSnapshot = Schema.Struct({ |
| 114 | visibleText: Schema.String, | 381 | visibleText: Schema.String, |
| 115 | interactiveElements: Schema.Array(PreviewAutomationElement), | 382 | interactiveElements: Schema.Array(PreviewAutomationElement), |
| 116 | accessibilityTree: Schema.Unknown, | 383 | accessibilityTree: Schema.Unknown, |
| 384 | consoleEntries: Schema.Array(PreviewAutomationConsoleEntry), | ||
| 385 | networkEntries: Schema.Array(PreviewAutomationNetworkEntry), | ||
| 386 | actionTimeline: Schema.Array(PreviewAutomationActionEvent), | ||
| 117 | screenshot: Schema.Struct({ | 387 | screenshot: Schema.Struct({ |
| 118 | mimeType: Schema.Literal("image/png"), | 388 | mimeType: Schema.Literal("image/png"), |
| 119 | data: Schema.String, | 389 | data: Schema.String, |
| ... | @@ -123,6 +393,23 @@ export const PreviewAutomationSnapshot = Schema.Struct({ | ... | @@ -123,6 +393,23 @@ export const PreviewAutomationSnapshot = Schema.Struct({ |
| 123 | }); | 393 | }); |
| 124 | export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type; | 394 | export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type; |
| 125 | 395 | ||
| 396 | export const PreviewAutomationRecordingStatus = Schema.Struct({ | ||
| 397 | tabId: PreviewTabId, | ||
| 398 | recording: Schema.Boolean, | ||
| 399 | startedAt: Schema.NullOr(Schema.String), | ||
| 400 | }); | ||
| 401 | export type PreviewAutomationRecordingStatus = typeof PreviewAutomationRecordingStatus.Type; | ||
| 402 | |||
| 403 | export const PreviewAutomationRecordingArtifact = Schema.Struct({ | ||
| 404 | id: Schema.String, | ||
| 405 | tabId: PreviewTabId, | ||
| 406 | path: Schema.String, | ||
| 407 | mimeType: Schema.String, | ||
| 408 | sizeBytes: Schema.Int, | ||
| 409 | createdAt: Schema.String, | ||
| 410 | }); | ||
| 411 | export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; | ||
| 412 | |||
| 126 | export const PreviewAutomationOwner = Schema.Struct({ | 413 | export const PreviewAutomationOwner = Schema.Struct({ |
| 127 | clientId: TrimmedNonEmptyString, | 414 | clientId: TrimmedNonEmptyString, |
| 128 | environmentId: EnvironmentId, | 415 | environmentId: EnvironmentId, |
| ... | @@ -183,6 +470,11 @@ export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<Previ | ... | @@ -183,6 +470,11 @@ export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<Previ |
| 183 | { message: Schema.String }, | 470 | { message: Schema.String }, |
| 184 | ) {} | 471 | ) {} |
| 185 | 472 | ||
| 473 | export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass<PreviewAutomationControlInterruptedError>()( | ||
| 474 | "PreviewAutomationControlInterruptedError", | ||
| 475 | { message: Schema.String }, | ||
| 476 | ) {} | ||
| 477 | |||
| 186 | export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()( | 478 | export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()( |
| 187 | "PreviewAutomationExecutionError", | 479 | "PreviewAutomationExecutionError", |
| 188 | { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, | 480 | { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, |
| ... | @@ -204,6 +496,7 @@ export const PreviewAutomationError = Schema.Union([ | ... | @@ -204,6 +496,7 @@ export const PreviewAutomationError = Schema.Union([ |
| 204 | PreviewAutomationUnsupportedClientError, | 496 | PreviewAutomationUnsupportedClientError, |
| 205 | PreviewAutomationTabNotFoundError, | 497 | PreviewAutomationTabNotFoundError, |
| 206 | PreviewAutomationTimeoutError, | 498 | PreviewAutomationTimeoutError, |
| 499 | PreviewAutomationControlInterruptedError, | ||
| 207 | PreviewAutomationExecutionError, | 500 | PreviewAutomationExecutionError, |
| 208 | PreviewAutomationInvalidSelectorError, | 501 | PreviewAutomationInvalidSelectorError, |
| 209 | PreviewAutomationResultTooLargeError, | 502 | PreviewAutomationResultTooLargeError, |
| ... | @@ -213,7 +506,7 @@ export type PreviewAutomationError = typeof PreviewAutomationError.Type; | ... | @@ -213,7 +506,7 @@ export type PreviewAutomationError = typeof PreviewAutomationError.Type; |
| 213 | export const PreviewUrlResolution = Schema.Struct({ | 506 | export const PreviewUrlResolution = Schema.Struct({ |
| 214 | requestedUrl: Schema.String, | 507 | requestedUrl: Schema.String, |
| 215 | resolvedUrl: Schema.String, | 508 | resolvedUrl: Schema.String, |
| 216 | resolutionKind: Schema.Literal("direct"), | 509 | resolutionKind: Schema.Literals(["direct", "direct-private-network"]), |
| 217 | environmentId: EnvironmentId, | 510 | environmentId: EnvironmentId, |
| 218 | }); | 511 | }); |
| 219 | export type PreviewUrlResolution = typeof PreviewUrlResolution.Type; | 512 | export type PreviewUrlResolution = typeof PreviewUrlResolution.Type; |
pnpm-lock.yaml+3| ... | @@ -128,6 +128,9 @@ importers: | ... | @@ -128,6 +128,9 @@ importers: |
| 128 | electron-updater: | 128 | electron-updater: |
| 129 | specifier: ^6.6.2 | 129 | specifier: ^6.6.2 |
| 130 | version: 6.8.3 | 130 | version: 6.8.3 |
| 131 | playwright-core: | ||
| 132 | specifier: 1.60.0 | ||
| 133 | version: 1.60.0 | ||
| 131 | react-grab: | 134 | react-grab: |
| 132 | specifier: ^0.1.32 | 135 | specifier: ^0.1.32 |
| 133 | version: 0.1.44(react@19.2.6) | 136 | version: 0.1.44(react@19.2.6) |