authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-12 13:29:48-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log6c6740e381aa1787492a8366f9020304ba322700
tree057b0b8f2f71e0fa5710d077a52662d3c8fa89f1
parent29150b57367062e4382de7fb3ff0c952b7c38c4e

Refine collaborative browser preview

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";
56export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";56export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
57export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";57export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
58export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";58export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
59export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot";
59export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status";60export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status";
60export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot";61export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot";
61export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click";62export 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
64export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll";65export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll";
65export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate";66export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate";
66export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for";67export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for";
68export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start";
69export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop";
70export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
71export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
67export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";72export 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});
1919
20previewViewManager.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
20const tabIdFrom = (raw: unknown): string => {28const 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 playwrightInjectedRuntimeInstallExpression,
5 playwrightInjectedRuntimeSource,
6} from "./playwright-injected-runtime.ts";
7
8describe("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
2import { createRequire } from "node:module";
3import { dirname, join } from "node:path";
4import { readFile } from "node:fs/promises";
5import { runInNewContext } from "node:vm";
6
7const require = createRequire(import.meta.url);
8let sourcePromise: Promise<string> | null = null;
9
10export 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
30export 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 @@
1import type { DesktopBridge, DesktopPreviewTabState } from "@t3tools/contracts";1import type {
2 DesktopBridge,
3 DesktopPreviewRecordingFrame,
4 DesktopPreviewTabState,
5} from "@t3tools/contracts";
2import { contextBridge, ipcRenderer } from "electron";6import { contextBridge, ipcRenderer } from "electron";
37
4import * as IpcChannels from "./ipc/channels.ts";8import * 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";
16const CANCEL_PICK_CHANNEL = "preview:cancel-pick";16const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
17const ELEMENT_PICKED_CHANNEL = "preview:element-picked";17const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
18const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";18const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
19const HUMAN_INPUT_CHANNEL = "preview:human-input";
19const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";20const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
20const Z_INDEX_OVERLAY = 2147483646;21const Z_INDEX_OVERLAY = 2147483646;
21const ACCENT = "#7c3aed";22const ACCENT = "#7c3aed";
...@@ -42,6 +43,13 @@ interface AnnotationSession {...@@ -42,6 +43,13 @@ interface AnnotationSession {
42let activeSession: AnnotationSession | null = null;43let activeSession: AnnotationSession | null = null;
43let idSequence = 0;44let idSequence = 0;
4445
46const reportHumanInput = (event: Event): void => {
47 if (event.isTrusted) ipcRenderer.send(HUMAN_INPUT_CHANNEL);
48};
49
50window.addEventListener("pointerdown", reportHumanInput, true);
51window.addEventListener("keydown", reportHumanInput, true);
52
45const nextId = (prefix: string): string => {53const 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 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
3const fromId = vi.fn(() => null);3const fromId = vi.fn(() => null);
4const mkdir = vi.fn(async () => undefined);
5const writeFile = vi.fn(async () => undefined);
6
7vi.mock("node:fs/promises", () => ({ mkdir, writeFile }));
48
5vi.mock("electron", () => ({9vi.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", () => ({
14describe("PreviewViewManager automation status", () => {21describe("PreviewViewManager automation status", () => {
15 beforeEach(() => {22 beforeEach(() => {
16 fromId.mockClear();23 fromId.mockClear();
24 mkdir.mockClear();
25 writeFile.mockClear();
17 });26 });
1827
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:off1// @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 @@
9import type {10import 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";
21import { normalizePreviewUrl } from "@t3tools/shared/preview";28import { normalizePreviewUrl } from "@t3tools/shared/preview";
22import { type BrowserWindow, type Session, session, webContents } from "electron";29import { app, type BrowserWindow, type Session, session, webContents } from "electron";
30import { mkdir, writeFile } from "node:fs/promises";
31import { join } from "node:path";
32import { createHash } from "node:crypto";
23import { setTimeout as sleep } from "node:timers/promises";33import { setTimeout as sleep } from "node:timers/promises";
2434
25import { isPreviewAnnotationPayload } from "./picked-element-payload.ts";35import { isPreviewAnnotationPayload } from "./picked-element-payload.ts";
36import { playwrightInjectedRuntimeInstallExpression } from "./playwright-injected-runtime.ts";
2637
27const PREVIEW_PARTITION = "persist:t3code-preview";38const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
28const START_PICK_CHANNEL = "preview:start-pick";39const START_PICK_CHANNEL = "preview:start-pick";
29const CANCEL_PICK_CHANNEL = "preview:cancel-pick";40const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
30const ELEMENT_PICKED_CHANNEL = "preview:element-picked";41const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
31const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";42const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
43const HUMAN_INPUT_CHANNEL = "preview:human-input";
3244
33// Re-export the guest webview security posture from its dedicated module so45// Re-export the guest webview security posture from its dedicated module so
34// the constant is unit-testable in isolation. See46// 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}
6073
...@@ -69,6 +82,7 @@ const MAX_EVALUATION_BYTES = 64_000;...@@ -69,6 +82,7 @@ const MAX_EVALUATION_BYTES = 64_000;
69const MAX_VISIBLE_TEXT_LENGTH = 20_000;82const MAX_VISIBLE_TEXT_LENGTH = 20_000;
70const MAX_INTERACTIVE_ELEMENTS = 200;83const MAX_INTERACTIVE_ELEMENTS = 200;
71const MAX_SCREENSHOT_WIDTH = 1280;84const MAX_SCREENSHOT_WIDTH = 1280;
85const DIAGNOSTIC_BUFFER_LIMIT = 200;
7286
73interface CdpEvaluationResult {87interface 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};
166181
167type Listener = (tabId: string, state: PreviewTabState) => void;182type Listener = (tabId: string, state: PreviewTabState) => void;
183type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void;
168184
169interface ManagedListeners {185interface 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}
173190
174interface PickSession {191interface PickSession {
...@@ -176,6 +193,18 @@ interface PickSession {...@@ -176,6 +193,18 @@ interface PickSession {
176 readonly cleanup: () => void;193 readonly cleanup: () => void;
177}194}
178195
196interface BrowserControlSession {
197 readonly webContentsId: number;
198 tail: Promise<void>;
199 initialized: Promise<void>;
200}
201
202interface BrowserDiagnostics {
203 readonly consoleEntries: PreviewAutomationConsoleEntry[];
204 readonly networkEntries: PreviewAutomationNetworkEntry[];
205 readonly requests: Map<string, { url: string; method: string }>;
206}
207
179const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{208const 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;
203240
204 setMainWindow(window: BrowserWindow): void {241 setMainWindow(window: BrowserWindow): void {
205 this.mainWindow = window;242 this.mainWindow = window;
206 }243 }
207244
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 }
211253
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 }
221263
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 = sess269 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 }
237281
...@@ -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 waiting347 // 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 WebContents353 // 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 }
369422
...@@ -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 }
380436
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 }
386441
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 }
487542
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 {
533647
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 {
616733
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 {
688812
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.selector848 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 {
743865
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 {
771893
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 {
809929
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.text972 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 }
8981016
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 }
9311295
...@@ -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 }
9541318
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 }
9681355
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 };
9911387
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);
9981395
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 });
10221419
1023 this.attached.set(wc.id, { navigate: sync, failed });1420 this.attached.set(wc.id, { navigate: sync, failed, humanInput });
1024 }1421 }
10251422
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 }
10391437
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* () {
200200
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);
99117
100 const status = yield* server118 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
67it.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 @@
1import {1import {
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.tabId218 !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 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import type {2import 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 @@
1import { expect, it } from "@effect/vitest";
2import { Tool } from "effect/unstable/ai";
3
4import { PreviewToolkit } from "./tools.ts";
5
6const 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
15it("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";
17import { McpInvocationContext } from "../../Services/McpInvocationContext.ts";19import { McpInvocationContext } from "../../Services/McpInvocationContext.ts";
1820
19const browserTool = <T extends Tool.Any>(tool: T): T =>21const 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
24const safeBrowserTool = <T extends Tool.Any>(tool: T): T =>
25 browserTool(tool).annotate(Tool.Destructive, false) as T;
26
27const readonlyBrowserTool = <T extends Tool.Any>(tool: T): T =>
28 safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T;
2129
22export const PreviewStatusTool = Tool.make("preview_status", {30export 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);
4454
45export const PreviewNavigateTool = browserTool(55export 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);
5565
56export const PreviewSnapshotTool = Tool.make("preview_snapshot", {66export 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);
6675
67export const PreviewClickTool = browserTool(76export 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(
77export const PreviewTypeTool = browserTool(86export 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(
8695
87export const PreviewPressTool = browserTool(96export 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);
95105
96export const PreviewScrollTool = browserTool(106export 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(
106export const PreviewEvaluateTool = browserTool(116export 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);
116126
117export const PreviewWaitForTool = browserTool(127export 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);
126136
137export 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
147export 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
127export const PreviewToolkit = Toolkit.make(156export 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 @@
1const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `
2
3## T3 Code collaborative browser
4
5You 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
7For 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
9Do 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
1export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)12export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)
213
3You 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.14You 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
118Do 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.129Do 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.
119130
120Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec.131Only 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>`;
122134
123export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Collaboration Mode: Default135export 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 \
131The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error.143The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error.
132144
133In 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.145In 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 );
493493
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}
237237
238function itemTitle(itemType: CanonicalItemType): string | undefined {238function 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";
14import {14import {
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});
151152
153describe("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
167describe("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
152describe("isRecoverableThreadResumeError", () => {178describe("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];
6464
65export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray<string> | undefined): boolean {
66 return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true;
67}
68
65export const CodexResumeCursorSchema = Schema.Struct({69export 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
3import { useEffect, useRef } from "react";
4
5import { useBrowserSurfaceStore } from "./browserSurfaceStore";
6
7export 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
3import { parseScopedThreadKey } from "@t3tools/client-runtime";
4import { useMemo } from "react";
5
6import { isElectron } from "~/env";
7import { usePreviewStateStore } from "~/previewStateStore";
8
9import { HostedBrowserWebview } from "./HostedBrowserWebview";
10
11export 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
3import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts";
4import { useShallow } from "zustand/react/shallow";
5import { useCallback, useEffect, useRef, useState } from "react";
6
7import { previewBridge } from "~/components/preview/previewBridge";
8import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
9
10import { useBrowserRecordingStore } from "./browserRecording";
11import { useBrowserSurfaceStore } from "./browserSurfaceStore";
12import { acquireDesktopTab } from "./desktopTabLifetime";
13
14interface ElectronWebview extends HTMLElement {
15 src: string;
16 partition: string;
17 preload?: string;
18 webpreferences?: string;
19 getWebContentsId: () => number;
20}
21
22declare global {
23 interface HTMLElementTagNameMap {
24 webview: ElectronWebview;
25 }
26}
27
28export 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 @@
1import type {
2 DesktopPreviewRecordingArtifact,
3 DesktopPreviewRecordingFrame,
4} from "@t3tools/contracts";
5import { create } from "zustand";
6
7import { previewBridge } from "~/components/preview/previewBridge";
8import { useBrowserSurfaceStore } from "./browserSurfaceStore";
9
10interface 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
20interface 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
28export 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
36let active: ActiveRecording | null = null;
37let unsubscribeFrames: (() => void) | null = null;
38
39const 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
44const 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
59export 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
91export 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 @@
1import { create } from "zustand";
2
3export interface BrowserSurfaceRect {
4 readonly x: number;
5 readonly y: number;
6 readonly width: number;
7 readonly height: number;
8}
9
10export interface BrowserSurfacePresentation {
11 readonly rect: BrowserSurfaceRect | null;
12 readonly visible: boolean;
13 readonly updatedAt: number;
14}
15
16interface 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
22const 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
29export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const readEnvironmentConnection = vi.fn();
5
6vi.mock("~/environments/runtime", () => ({ readEnvironmentConnection }));
7
8describe("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 @@
1import type {
2 BrowserNavigationTarget,
3 EnvironmentId,
4 PreviewUrlResolution,
5} from "@t3tools/contracts";
6import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
7
8import { readEnvironmentConnection } from "~/environments/runtime";
9
10const 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
27export 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
66export 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 @@
1import { previewBridge } from "~/components/preview/previewBridge";
2
3interface DesktopTabLease {
4 references: number;
5 closeTimer: number | null;
6}
7
8const leases = new Map<string, DesktopTabLease>();
9
10export 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";
24import {24import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
25 parseScopedThreadKey,
26 scopedThreadKey,
27 scopeProjectRef,
28 scopeThreadRef,
29} from "@t3tools/client-runtime";
30import {25import {
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";
43import { usePrimaryEnvironmentId } from "../environments/primary";38import { usePrimaryEnvironmentId } from "../environments/primary";
44import { readEnvironmentApi } from "../environmentApi";39import { readEnvironmentApi } from "../environmentApi";
45import { isElectron } from "../env";40import { isElectron } from "../env";
41import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
46import { readLocalApi } from "../localApi";42import { readLocalApi } from "../localApi";
47import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";43import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
48import {44import {
...@@ -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";
73import {69import { selectProjectsAcrossEnvironments, useStore } from "../store";
74 selectProjectsAcrossEnvironments,
75 selectThreadsAcrossEnvironments,
76 useStore,
77} from "../store";
78import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";70import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
79import { useUiStateStore } from "../uiStateStore";71import { useUiStateStore } from "../uiStateStore";
80import {72import {
...@@ -98,20 +90,32 @@ import { useCommandPaletteStore } from "../commandPaletteStore";...@@ -98,20 +90,32 @@ import { useCommandPaletteStore } from "../commandPaletteStore";
98import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";90import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
99import { useMediaQuery } from "../hooks/useMediaQuery";91import { useMediaQuery } from "../hooks/useMediaQuery";
100import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";92import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
101import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore";93import {
102import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";94 selectActiveRightPanelKindWithUrl,
103import { isPreviewSupportedInRuntime, usePreviewStateStore } from "../previewStateStore";95 selectActiveRightPanelSurface,
96 selectThreadRightPanelState,
97 type RightPanelSurface,
98 useRightPanelStore,
99} from "../rightPanelStore";
100import {
101 isPreviewSupportedInRuntime,
102 selectThreadPreviewState,
103 usePreviewStateStore,
104} from "../previewStateStore";
104import { subscribePreviewAction } from "./preview/previewActionBus";105import { subscribePreviewAction } from "./preview/previewActionBus";
105// Lazy: keeps the entire preview component graph (webview host, favicon106// 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.
107const PreviewPanel = lazy(() =>108const PreviewPanel = lazy(() =>
108 import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })),109 import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })),
109);110);
111const DiffPanel = lazy(() => import("./DiffPanel"));
110import { BranchToolbar } from "./BranchToolbar";112import { BranchToolbar } from "./BranchToolbar";
111import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";113import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
112import PlanSidebar from "./PlanSidebar";114import PlanSidebar from "./PlanSidebar";
113import ThreadTerminalDrawer from "./ThreadTerminalDrawer";115import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
114import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";116import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
117import { RightPanelTabs } from "./RightPanelTabs";
118import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
115import { cn, randomHex } from "~/lib/utils";119import { cn, randomHex } from "~/lib/utils";
116import { stackedThreadToast, toastManager } from "./ui/toast";120import { stackedThreadToast, toastManager } from "./ui/toast";
117import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";121import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
...@@ -167,8 +171,6 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";...@@ -167,8 +171,6 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
167import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";171import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
168import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";172import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
169import {173import {
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 }
796798
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 <ThreadTerminalDrawer801 <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 );
1018973
1019 const fallbackDraftProjectRef = draftThread974 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 = activeThread1112 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 search1949 // - 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/plan1950 // 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 activeThreadRef2317 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) {
25832556 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 }
39534014
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 <header4018 <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 isElectron4021 {/* 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 <ChatHeader4030 )
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) : null4040 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 />
40014094
4002 {/* Error banner */}4095 {/* scroll to bottom pill — shown when user has scrolled away from the bottom */}
4003 <ProviderStatusBanner status={activeProviderStatus} />4096 {showScrollToBottom && (
4004 <ThreadErrorBanner4097 <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 <MessagesTimeline4108 </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>
40534109
4054 {/* Input bar */}4110 {/* Input bar */}
4055 <div4111 <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 isGitRepo4114 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 <ChatComposer4122 <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 onPreviousActivePendingUserInputQuestion4179 onPreviousActivePendingUserInputQuestion
4124 }4180 }
4125 onChangeActivePendingUserInputCustomAnswer={4181 onChangeActivePendingUserInputCustomAnswer={
4126 onChangeActivePendingUserInputCustomAnswer4182 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 <BranchToolbar4221 {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 {...(canOverrideServerThreadEnvMode4230 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>
41644238 {/* 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 */}
42214242
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}
42254276
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}
42654335
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";
55
6import { Skeleton } from "./ui/skeleton";6import { Skeleton } from "./ui/skeleton";
77
8export type DiffPanelMode = "inline" | "sheet" | "sidebar";8export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded";
99
10function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {10function 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 shouldUseDragRegion14 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";
2626
27 return (27 return (
28 <div28 <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}
6565
apps/web/src/components/RightPanelTabs.tsx created+148
...@@ -0,0 +1,148 @@
1import type { PreviewSessionSnapshot } from "@t3tools/contracts";
2import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react";
3import { type ReactNode, useState } from "react";
4
5import type { RightPanelSurface } from "~/rightPanelStore";
6import { cn } from "~/lib/utils";
7import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
8import { faviconUrlForOrigin } from "~/lib/favicon";
9
10import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";
11
12interface 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
25function 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
49function 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
65function 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
87export 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";
22import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";22import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
23import { cn } from "~/lib/utils";
23import { type TerminalContextSelection } from "~/lib/terminalContext";24import { type TerminalContextSelection } from "~/lib/terminalContext";
24import { openInPreferredEditor } from "../editorPreferences";25import { openInPreferredEditor } from "../editorPreferences";
25import {26import {
...@@ -791,6 +792,7 @@ export function TerminalViewport({...@@ -791,6 +792,7 @@ export function TerminalViewport({
791}792}
792793
793interface ThreadTerminalDrawerProps {794interface 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}
850852
851export default function ThreadTerminalDrawer({853export 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 <aside1145 <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 <div1152 {!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 <button1163 <button
...@@ -1167,16 +1176,21 @@ export default function ThreadTerminalDrawer({...@@ -1167,16 +1176,21 @@ export default function ThreadTerminalDrawer({
11671176
1168 return (1177 return (
1169 <aside1178 <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 <div1185 {!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}
11801194
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";
9import { memo } from "react";9import { memo } from "react";
10import GitActionsControl from "../GitActionsControl";10import GitActionsControl from "../GitActionsControl";
11import { type DraftId } from "~/composerDraftStore";11import { type DraftId } from "~/composerDraftStore";
12import { DiffIcon, Globe, TerminalSquareIcon } from "lucide-react";12import { PanelRightIcon } from "lucide-react";
13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
14import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";14import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";
15import { Toggle } from "../ui/toggle";15import { 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}
5040
51export function shouldShowOpenInPicker(input: {41export 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 <Toggle127 <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 {!terminalAvailable141 {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 changedFiles1395 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}
13981403
1399function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {1404function 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";
13import { useCallback, useEffect, useRef } from "react";13import { useCallback, useEffect, useRef, useState } from "react";
1414
15import { ensureEnvironmentApi } from "~/environmentApi";15import { ensureEnvironmentApi } from "~/environmentApi";
16import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";16import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
17import { useRightPanelStore } from "~/rightPanelStore";17import { useRightPanelStore } from "~/rightPanelStore";
18import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
19import {
20 startBrowserRecording,
21 stopBrowserRecording,
22 useBrowserRecordingStore,
23} from "~/browser/browserRecording";
1824
19import { previewBridge } from "./previewBridge";25import { previewBridge } from "./previewBridge";
2026
21const automationClientId =27const 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]);
120129
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]);
212242
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]);
253283
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]);
285315
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 @@
1import {1import {
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 of44 * 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 };
101109
102 return (110 return (
103 <div className="relative">111 <div className="relative">
104 <form112 <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>
161169
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-input189 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>
185213
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 <TooltipTrigger242 <TooltipTrigger
215 render={243 render={
216 <Button244 <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 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import { Globe } from "lucide-react";2import { Globe, RadioTower } from "lucide-react";
33
4import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty";4import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty";
55
...@@ -41,18 +41,24 @@ export function PreviewEmptyState({...@@ -41,18 +41,24 @@ export function PreviewEmptyState({
41 }41 }
4242
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 Local46 <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 <PreviewLocalServerCard50 <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 @@
1import { cn } from "~/lib/utils";
2
3import { BrowserMockup } from "./BrowserMockup";1import { BrowserMockup } from "./BrowserMockup";
4import type { PreviewableServer } from "./useDiscoveredLocalServers";2import type { PreviewableServer } from "./useDiscoveredLocalServers";
53
...@@ -14,18 +12,14 @@ export function PreviewLocalServerCard({ server, onOpen }: Props) {...@@ -14,18 +12,14 @@ export function PreviewLocalServerCard({ server, onOpen }: Props) {
14 <button12 <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 };
3737
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 DevTools60 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";
10interface Props {10interface 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}
1617
17export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props) {18export 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
2930
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";
66
7import { RightPanelResizeHandle } from "./RightPanelResizeHandle";7import { RightPanelResizeHandle } from "./RightPanelResizeHandle";
88
9export type PreviewPanelMode = "inline" | "sheet" | "sidebar";9export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded";
1010
11const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width";11const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width";
12const PREVIEW_PANEL_MIN_WIDTH = 360;12const 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 */
24export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: ReactNode }) {24export 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 <div37 <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";
14import { ensureLocalApi } from "~/localApi";14import { ensureLocalApi } from "~/localApi";
15import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";15import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
16import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
1617
17import { previewBridge } from "./previewBridge";18import { previewBridge } from "./previewBridge";
18import { subscribePreviewAction } from "./previewActionBus";19import { subscribePreviewAction } from "./previewActionBus";
...@@ -21,13 +22,20 @@ import { PreviewChromeRow } from "./PreviewChromeRow";...@@ -21,13 +22,20 @@ import { PreviewChromeRow } from "./PreviewChromeRow";
21import { PreviewEmptyState } from "./PreviewEmptyState";22import { PreviewEmptyState } from "./PreviewEmptyState";
22import { PreviewMoreMenu } from "./PreviewMoreMenu";23import { PreviewMoreMenu } from "./PreviewMoreMenu";
23import { PreviewUnreachable } from "./PreviewUnreachable";24import { PreviewUnreachable } from "./PreviewUnreachable";
24import { PreviewWebview } from "./PreviewWebview";25import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
25import { useLoadingProgress } from "./useLoadingProgress";26import { useLoadingProgress } from "./useLoadingProgress";
26import { usePreviewSession } from "./usePreviewSession";27import { usePreviewSession } from "./usePreviewSession";
27import { ZoomIndicator } from "./ZoomIndicator";28import { ZoomIndicator } from "./ZoomIndicator";
29import {
30 startBrowserRecording,
31 stopBrowserRecording,
32 useBrowserRecordingStore,
33} from "~/browser/browserRecording";
34import { toastManager } from "~/components/ui/toast";
2835
29interface Props {36interface 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, empty46 * 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 */
41export function PreviewView({ threadRef, configuredUrls, visible }: Props) {49export 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 }, []);
6473
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);
7586
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 the93 // 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]);
129141
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) {
232306
233 return (307 return (
234 <div308 <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 <PreviewChromeRow312 <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 page330 // 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 />
270347
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 <PreviewWebview350 <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 <PreviewUnreachable374 <PreviewUnreachable
apps/web/src/components/preview/PreviewWebview.tsx deleted-142
...@@ -1,142 +0,0 @@
1"use client";
2
3import type { DesktopPreviewWebviewConfig, ScopedThreadRef } from "@t3tools/contracts";
4import { useCallback, useEffect, useRef, useState } from "react";
5
6import { isElectron } from "~/env";
7
8import { previewBridge } from "./previewBridge";
9import { usePreviewBridge } from "./usePreviewBridge";
10
11interface 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
25interface 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
39declare 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 */
52export 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";
44
5import { ensureEnvironmentApi } from "~/environmentApi";5import { ensureEnvironmentApi } from "~/environmentApi";
6import type { EnvironmentId } from "@t3tools/contracts";6import type { EnvironmentId } from "@t3tools/contracts";
7import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
78
8export interface PreviewableServer extends DiscoveredLocalServer {9export 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}
5256
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
14import { previewBridge } from "./previewBridge";14import { previewBridge } from "./previewBridge";
1515
16/**16/**
17 * Owns the desktop tab lifecycle, mirrors low-latency button state into the17 * 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 */
24export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void {20export 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;
2824
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 to25 // 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}
7362
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 renderer39 // 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";
17import { getRouter } from "./router";17import { getRouter } from "./router";
18import { APP_DISPLAY_NAME } from "./branding";18import { APP_DISPLAY_NAME } from "./branding";
19import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";19import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
20import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
2021
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.
22const history = isElectron ? createHashHistory() : createBrowserHistory();23const history = isElectron ? createHashHistory() : createBrowserHistory();
...@@ -31,7 +32,12 @@ document.title = APP_DISPLAY_NAME;...@@ -31,7 +32,12 @@ document.title = APP_DISPLAY_NAME;
3132
32const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;33const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
3334
34const app = <RouterProvider router={router} />;35const app = (
36 <>
37 <RouterProvider router={router} />
38 <ElectronBrowserHost />
39 </>
40);
3541
36ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(42ReactDOM.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 });
197198
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}
3031
31export interface ThreadPreviewState {32export 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}
3842
39const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({43const 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});
4451
...@@ -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.recentlySeenUrls109 ? 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; the149 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";
44
5import {5import {
6 selectActiveRightPanel,6 selectActiveRightPanel,
7 selectActiveRightPanelSurface,
7 selectActiveRightPanelKindWithUrl,8 selectActiveRightPanelKindWithUrl,
9 selectThreadRightPanelState,
8 useRightPanelStore,10 useRightPanelStore,
9} from "./rightPanelStore";11} from "./rightPanelStore";
1012
...@@ -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 });
2426
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 });
3035
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, and4 * 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 is5 * 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 */
12import { scopedThreadKey } from "@t3tools/client-runtime";9import { scopedThreadKey } from "@t3tools/client-runtime";
13import type { ScopedThreadRef } from "@t3tools/contracts";10import 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";
1613
17import { resolveStorage } from "./lib/storage";14import { resolveStorage } from "./lib/storage";
1815
19export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview"] as const;16export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview", "terminal"] as const;
20export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];17export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
2118
22const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v1";19export 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
26const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
2327
24interface ThreadRightPanelState {28export interface ThreadRightPanelState {
25 active: RightPanelKind | null;29 activeSurfaceId: string | null;
30 surfaces: RightPanelSurface[];
26}31}
2732
28interface RightPanelStoreState {33interface 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}
3544
45const EMPTY_THREAD_STATE: ThreadRightPanelState = { activeSurfaceId: null, surfaces: [] };
46
47const 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
58const browserSurface = (tabId: string | null): RightPanelSurface =>
59 tabId
60 ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId }
61 : { id: "browser:new", kind: "preview", resourceId: null };
62
63const 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
36const updateThread = (74const 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};
5089
...@@ -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);
90200
201export 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
91export function selectActiveRightPanel(209export 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
217export 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}
98224
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 */
107export function selectActiveRightPanelKindWithUrl(225export 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 @@
1import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";1import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
2import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";2import { useEffect, useMemo } from "react";
33
4import ChatView from "../components/ChatView";4import ChatView from "../components/ChatView";
5import { threadHasStarted } from "../components/ChatView.logic";5import { threadHasStarted } from "../components/ChatView.logic";
6import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
7import {
8 DiffPanelHeaderSkeleton,
9 DiffPanelLoadingState,
10 DiffPanelShell,
11 type DiffPanelMode,
12} from "../components/DiffPanelShell";
13import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
14import {7import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch";
15 type DiffRouteSearch,
16 parseDiffRouteSearch,
17 stripDiffSearchParams,
18} from "../diffRouteSearch";
19import { useMediaQuery } from "../hooks/useMediaQuery";
20import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
21import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";8import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";
22import { createThreadSelectorByRef } from "../storeSelectors";9import { createThreadSelectorByRef } from "../storeSelectors";
23import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";10import { resolveThreadRouteRef } from "../threadRoutes";
24import { RightPanelSheet } from "../components/RightPanelSheet";11import { SidebarInset } from "~/components/ui/sidebar";
25import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
26
27const DiffPanel = lazy(() => import("../components/DiffPanel"));
28const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";
29const DIFF_INLINE_DEFAULT_WIDTH = "clamp(24rem,34vw,36rem)";
30const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 22 * 16;
31const DIFF_INLINE_SIDEBAR_MAX_WIDTH = 256 * 16;
32const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208;
33
34const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {
35 return (
36 <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}>
37 <DiffPanelLoadingState label="Loading diff viewer..." />
38 </DiffPanelShell>
39 );
40};
41
42const 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
52const 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};
14012
141function ChatThreadRouteView() {13function 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]);
21639
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]);
22646
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]);
23351
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 }
26153
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 <ChatView57 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}
27864
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 });
936936
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}
829838
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}
456457
...@@ -477,6 +478,32 @@ export interface DesktopPreviewWebviewConfig {...@@ -477,6 +478,32 @@ export interface DesktopPreviewWebviewConfig {
477 preloadUrl: string | null;478 preloadUrl: string | null;
478}479}
479480
481export interface DesktopPreviewRecordingFrame {
482 tabId: string;
483 data: string;
484 width: number;
485 height: number;
486 receivedAt: string;
487}
488
489export interface DesktopPreviewRecordingArtifact {
490 id: string;
491 tabId: string;
492 path: string;
493 mimeType: string;
494 sizeBytes: number;
495 createdAt: string;
496}
497
498export 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 surface508 * 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 that509 * 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 touching699 * `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 with704 * Activate the in-page element picker for the given tab. Resolves with
678 * the picked payload, or `null` when the user cancels (Escape / nav). The705 * 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";
3import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";3import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { PreviewTabId } from "./preview.ts";4import { PreviewTabId } from "./preview.ts";
55
6const BoundedUrl = TrimmedNonEmptyString.check(Schema.isMaxLength(2048));6const 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));
7const OptionalTimeoutMs = Schema.optional(14const 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." });
1019
11export const PreviewAutomationOperation = Schema.Literals([20export 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]);
23export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;34export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;
2435
...@@ -33,66 +44,294 @@ export const PreviewAutomationStatus = Schema.Struct({...@@ -33,66 +44,294 @@ export const PreviewAutomationStatus = Schema.Struct({
33export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;44export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;
3445
35export const PreviewAutomationOpenInput = Schema.Struct({46export 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});
40export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type;66export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type;
4167
68export 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]);
96export type BrowserNavigationTarget = typeof BrowserNavigationTarget.Type;
97
42export const PreviewAutomationNavigateInput = Schema.Struct({98export 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 });
47export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type;134export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type;
48135
49export const PreviewAutomationClickInput = Schema.Union([136const 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
141const LegacySelector = TrimmedNonEmptyString.annotate({
142 description:
143 "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.",
144});
145
146export 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 });
60export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type;182export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type;
61183
62export const PreviewAutomationTypeInput = Schema.Struct({184export 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 });
68export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type;211export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type;
69212
70export const PreviewAutomationPressInput = Schema.Struct({213export 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." });
74export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type;231export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type;
75232
76export const PreviewAutomationScrollInput = Schema.Struct({233export 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 });
81export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type;265export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type;
82266
83export const PreviewAutomationEvaluateInput = Schema.Struct({267export 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});
88export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type;293export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type;
89294
90export const PreviewAutomationWaitForInput = Schema.Struct({295export 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 });
96export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type;335export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type;
97336
98export const PreviewAutomationElement = Schema.Struct({337export const PreviewAutomationElement = Schema.Struct({
...@@ -107,6 +346,34 @@ export const PreviewAutomationElement = Schema.Struct({...@@ -107,6 +346,34 @@ export const PreviewAutomationElement = Schema.Struct({
107});346});
108export type PreviewAutomationElement = typeof PreviewAutomationElement.Type;347export type PreviewAutomationElement = typeof PreviewAutomationElement.Type;
109348
349export const PreviewAutomationConsoleEntry = Schema.Struct({
350 level: Schema.String,
351 text: Schema.String,
352 timestamp: Schema.String,
353 source: Schema.optional(Schema.String),
354});
355export type PreviewAutomationConsoleEntry = typeof PreviewAutomationConsoleEntry.Type;
356
357export 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});
365export type PreviewAutomationNetworkEntry = typeof PreviewAutomationNetworkEntry.Type;
366
367export 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});
375export type PreviewAutomationActionEvent = typeof PreviewAutomationActionEvent.Type;
376
110export const PreviewAutomationSnapshot = Schema.Struct({377export 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});
124export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type;394export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type;
125395
396export const PreviewAutomationRecordingStatus = Schema.Struct({
397 tabId: PreviewTabId,
398 recording: Schema.Boolean,
399 startedAt: Schema.NullOr(Schema.String),
400});
401export type PreviewAutomationRecordingStatus = typeof PreviewAutomationRecordingStatus.Type;
402
403export 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});
411export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type;
412
126export const PreviewAutomationOwner = Schema.Struct({413export 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) {}
185472
473export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass<PreviewAutomationControlInterruptedError>()(
474 "PreviewAutomationControlInterruptedError",
475 { message: Schema.String },
476) {}
477
186export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()(478export 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;
213export const PreviewUrlResolution = Schema.Struct({506export 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});
219export type PreviewUrlResolution = typeof PreviewUrlResolution.Type;512export 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.2129 specifier: ^6.6.2
130 version: 6.8.3130 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.32135 specifier: ^0.1.32
133 version: 0.1.44(react@19.2.6)136 version: 0.1.44(react@19.2.6)