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 @@
2121 "effect": "catalog:",
2222 "electron": "41.5.0",
2323 "electron-updater": "^6.6.2",
24 "playwright-core": "1.60.0",
2425 "react-grab": "^0.1.32"
2526 },
2627 "devDependencies": {
apps/desktop/src/ipc/channels.ts+5
......@@ -56,6 +56,7 @@ export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
5656export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
5757export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
5858export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
59export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot";
5960export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status";
6061export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot";
6162export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click";
......@@ -64,4 +65,8 @@ export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-pres
6465export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll";
6566export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate";
6667export 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";
6772export 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) => {
1717 }
1818});
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
2028const tabIdFrom = (raw: unknown): string => {
2129 if (typeof raw !== "object" || raw === null || !("tabId" in raw)) {
2230 throw new Error("preview tab id is required");
......@@ -95,10 +103,16 @@ export const previewMethods = [
95103 ),
96104 method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()),
97105 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);
99113 const preloadPath = `${__dirname}/preview-pick-preload.cjs`;
100114 return {
101 partition: previewViewManager.getBrowserPartition(),
115 partition: previewViewManager.getBrowserPartition(environmentId),
102116 webPreferences: PREVIEW_WEBVIEW_PREFERENCES,
103117 preloadUrl: pathToFileURL(preloadPath).href,
104118 };
......@@ -109,6 +123,9 @@ export const previewMethods = [
109123 method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) =>
110124 previewViewManager.cancelPickElement(tabIdFrom(raw)),
111125 ),
126 method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) =>
127 previewViewManager.captureScreenshot(tabIdFrom(raw)),
128 ),
112129 method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) =>
113130 previewViewManager.automationStatus(tabIdFrom(raw)),
114131 ),
......@@ -151,4 +168,20 @@ export const previewMethods = [
151168 inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1],
152169 ),
153170 ),
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 }),
154187] 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 @@
1import type { DesktopBridge, DesktopPreviewTabState } from "@t3tools/contracts";
1import type {
2 DesktopBridge,
3 DesktopPreviewRecordingFrame,
4 DesktopPreviewTabState,
5} from "@t3tools/contracts";
26import { contextBridge, ipcRenderer } from "electron";
37
48import * as IpcChannels from "./ipc/channels.ts";
......@@ -159,10 +163,34 @@ contextBridge.exposeInMainWorld("desktopBridge", {
159163 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
160164 clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
161165 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 }),
163168 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
164169 cancelPickElement: (tabId) =>
165170 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 },
166194 automation: {
167195 status: (tabId) =>
168196 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";
1616const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
1717const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
1818const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
19const HUMAN_INPUT_CHANNEL = "preview:human-input";
1920const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
2021const Z_INDEX_OVERLAY = 2147483646;
2122const ACCENT = "#7c3aed";
......@@ -42,6 +43,13 @@ interface AnnotationSession {
4243let activeSession: AnnotationSession | null = null;
4344let 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
4553const nextId = (prefix: string): string => {
4654 idSequence += 1;
4755 return `${prefix}_${idSequence.toString(36)}`;
apps/desktop/src/preview-view-manager.test.ts+58
......@@ -1,8 +1,15 @@
11import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
33const 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
59vi.mock("electron", () => ({
10 app: {
11 getPath: vi.fn(() => "/tmp/t3-code-test"),
12 },
613 session: {
714 fromPartition: vi.fn(),
815 },
......@@ -14,6 +21,8 @@ vi.mock("electron", () => ({
1421describe("PreviewViewManager automation status", () => {
1522 beforeEach(() => {
1623 fromId.mockClear();
24 mkdir.mockClear();
25 writeFile.mockClear();
1726 });
1827
1928 it("reports an unregistered webview as temporarily unavailable", async () => {
......@@ -41,4 +50,53 @@ describe("PreviewViewManager automation status", () => {
4150 });
4251 expect(fromId).not.toHaveBeenCalled();
4352 });
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 });
44102});
apps/desktop/src/preview-view-manager.ts+461-63
......@@ -1,4 +1,5 @@
11// @effect-diagnostics globalDate:off
2// @effect-diagnostics nodeBuiltinImport:off
23/**
34 * PreviewViewManager — desktop side of the in-app browser preview.
45 *
......@@ -9,9 +10,15 @@
910import type {
1011 PreviewAnnotationPayload,
1112 PreviewAnnotationRect,
13 DesktopPreviewRecordingArtifact,
14 DesktopPreviewRecordingFrame,
15 DesktopPreviewScreenshotArtifact,
1216 PreviewAutomationClickInput,
17 PreviewAutomationActionEvent,
18 PreviewAutomationConsoleEntry,
1319 PreviewAutomationEvaluateInput,
1420 PreviewAutomationPressInput,
21 PreviewAutomationNetworkEntry,
1522 PreviewAutomationScrollInput,
1623 PreviewAutomationSnapshot,
1724 PreviewAutomationStatus,
......@@ -19,16 +26,21 @@ import type {
1926 PreviewAutomationWaitForInput,
2027} from "@t3tools/contracts";
2128import { 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";
2333import { setTimeout as sleep } from "node:timers/promises";
2434
2535import { 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-";
2839const START_PICK_CHANNEL = "preview:start-pick";
2940const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
3041const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
3142const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
43const HUMAN_INPUT_CHANNEL = "preview:human-input";
3244
3345// Re-export the guest webview security posture from its dedicated module so
3446// the constant is unit-testable in isolation. See
......@@ -55,6 +67,7 @@ export interface PreviewTabState {
5567 canGoBack: boolean;
5668 canGoForward: boolean;
5769 zoomFactor: number;
70 controller: "human" | "agent" | "none";
5871 updatedAt: string;
5972}
6073
......@@ -69,6 +82,7 @@ const MAX_EVALUATION_BYTES = 64_000;
6982const MAX_VISIBLE_TEXT_LENGTH = 20_000;
7083const MAX_INTERACTIVE_ELEMENTS = 200;
7184const MAX_SCREENSHOT_WIDTH = 1280;
85const DIAGNOSTIC_BUFFER_LIMIT = 200;
7286
7387interface CdpEvaluationResult {
7488 readonly result?: {
......@@ -86,7 +100,8 @@ const automationError = (
86100 | "PreviewAutomationExecutionError"
87101 | "PreviewAutomationInvalidSelectorError"
88102 | "PreviewAutomationResultTooLargeError"
89 | "PreviewAutomationTimeoutError",
103 | "PreviewAutomationTimeoutError"
104 | "PreviewAutomationControlInterruptedError",
90105 message: string,
91106 detail?: unknown,
92107): Error & { detail?: unknown } => {
......@@ -165,10 +180,12 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
165180};
166181
167182type Listener = (tabId: string, state: PreviewTabState) => void;
183type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void;
168184
169185interface ManagedListeners {
170186 navigate: () => void;
171187 failed: (event: Event, code: number, description: string) => void;
188 humanInput: () => void;
172189}
173190
174191interface PickSession {
......@@ -176,6 +193,18 @@ interface PickSession {
176193 readonly cleanup: () => void;
177194}
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
179208const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
180209 key: string;
181210 meta: boolean;
......@@ -196,17 +225,30 @@ export class PreviewViewManager {
196225 private mainWindow: BrowserWindow | null = null;
197226 private readonly tabs = new Map<string, PreviewTabState>();
198227 private readonly attached = new Map<number, ManagedListeners>();
199 private browserSession: Session | null = null;
228 private readonly browserSessions = new Map<string, Session>();
200229 private readonly listeners = new Set<Listener>();
230 private readonly recordingFrameListeners = new Set<RecordingFrameListener>();
201231 /** In-flight preview annotation sessions, keyed by tabId. */
202232 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
204241 setMainWindow(window: BrowserWindow): void {
205242 this.mainWindow = window;
206243 }
207244
208 getBrowserPartition(): string {
209 return PREVIEW_PARTITION;
245 getBrowserPartition(scope = "shared"): string {
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);
210252 }
211253
212254 /**
......@@ -219,9 +261,11 @@ export class PreviewViewManager {
219261 return PREVIEW_WEBVIEW_PREFERENCES;
220262 }
221263
222 getBrowserSession(): Session {
223 if (this.browserSession) return this.browserSession;
224 const sess = session.fromPartition(PREVIEW_PARTITION);
264 getBrowserSession(scope = "shared"): Session {
265 const partition = this.getBrowserPartition(scope);
266 const existing = this.browserSessions.get(partition);
267 if (existing) return existing;
268 const sess = session.fromPartition(partition);
225269 const ua = sess
226270 .getUserAgent()
227271 .replace(/Electron\/[\d.]+ /, "")
......@@ -231,7 +275,7 @@ export class PreviewViewManager {
231275 const allow = ["clipboard-read", "clipboard-write", "notifications", "geolocation"];
232276 callback(allow.includes(perm));
233277 });
234 this.browserSession = sess;
278 this.browserSessions.set(partition, sess);
235279 return sess;
236280 }
237281
......@@ -245,6 +289,7 @@ export class PreviewViewManager {
245289 canGoBack: false,
246290 canGoForward: false,
247291 zoomFactor: DEFAULT_ZOOM_FACTOR,
292 controller: "none",
248293 updatedAt: new Date().toISOString(),
249294 };
250295 this.tabs.set(tabId, initial);
......@@ -257,6 +302,7 @@ export class PreviewViewManager {
257302 if (!tab) return;
258303 this.cancelPickElement(tabId);
259304 if (tab.webContentsId != null) {
305 this.detachControlSession(tab.webContentsId);
260306 this.detachListeners(tab.webContentsId);
261307 }
262308 const closed: PreviewTabState = {
......@@ -266,6 +312,7 @@ export class PreviewViewManager {
266312 canGoBack: false,
267313 canGoForward: false,
268314 zoomFactor: DEFAULT_ZOOM_FACTOR,
315 controller: "none",
269316 updatedAt: new Date().toISOString(),
270317 };
271318 this.tabs.delete(tabId);
......@@ -294,6 +341,7 @@ export class PreviewViewManager {
294341 return;
295342 }
296343 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {
344 this.detachControlSession(tab.webContentsId);
297345 this.detachListeners(tab.webContentsId);
298346 // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`.
299347 // Cancel it so the toggle button doesn't get stuck pressed waiting
......@@ -301,6 +349,7 @@ export class PreviewViewManager {
301349 this.cancelPickElement(tabId);
302350 }
303351 this.attachListeners(tabId, wc);
352 void this.ensureControlSession(wc).catch(() => undefined);
304353 // Restore the persisted zoom factor onto the freshly-attached WebContents
305354 // so a thread-switch + remount lands the user back where they were.
306355 if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
......@@ -364,6 +413,10 @@ export class PreviewViewManager {
364413 wc.devToolsWebContents?.focus();
365414 return;
366415 }
416 this.detachControlSession(wc.id);
417 wc.once("devtools-closed", () => {
418 if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined);
419 });
367420 wc.openDevTools({ mode: "detach" });
368421 }
369422
......@@ -372,16 +425,18 @@ export class PreviewViewManager {
372425 * preview tab since they all share `persist:t3code-preview`.
373426 */
374427 async clearCookies(): Promise<void> {
375 const sess = this.getBrowserSession();
376 await sess.clearStorageData({
377 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],
378 });
428 await Promise.all(
429 [...this.browserSessions.values()].map((sess) =>
430 sess.clearStorageData({
431 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],
432 }),
433 ),
434 );
379435 }
380436
381437 /** Drop the HTTP cache for the preview partition. */
382438 async clearCache(): Promise<void> {
383 const sess = this.getBrowserSession();
384 await sess.clearCache();
439 await Promise.all([...this.browserSessions.values()].map((sess) => sess.clearCache()));
385440 }
386441
387442 /**
......@@ -485,6 +540,65 @@ export class PreviewViewManager {
485540 session.resolve(null);
486541 }
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
488602 zoomIn(tabId: string): void {
489603 this.applyZoom(tabId, (current) => nextZoomLevel(current, "in"));
490604 }
......@@ -533,7 +647,7 @@ export class PreviewViewManager {
533647
534648 async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> {
535649 const wc = this.requireWebContents(tabId);
536 return this.withDebugger(wc, async (send) => {
650 return this.withControlSession(tabId, wc, "snapshot", async (send) => {
537651 await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]);
538652 const page = await this.evaluateWithDebugger<{
539653 url: string;
......@@ -604,6 +718,9 @@ export class PreviewViewManager {
604718 return {
605719 ...page,
606720 accessibilityTree: accessibility,
721 consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])],
722 networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])],
723 actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])],
607724 screenshot: {
608725 mimeType: "image/png",
609726 data: image.toPNG().toString("base64"),
......@@ -616,22 +733,29 @@ export class PreviewViewManager {
616733
617734 async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> {
618735 const wc = this.requireWebContents(tabId);
619 await this.withDebugger(wc, async (send) => {
736 await this.withControlSession(tabId, wc, "click", async (send) => {
620737 await Promise.all([
621738 send("Runtime.enable"),
622739 send("Input.setIgnoreInputEvents", { ignore: false }),
623740 ]);
624741 let x: number;
625742 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);
627746 const point = await this.evaluateWithDebugger<
628747 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }
629748 >(
630749 send,
631750 `(() => {
632751 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);
634755 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 };
635759 element.scrollIntoView({ block: "center", inline: "center" });
636760 const rect = element.getBoundingClientRect();
637761 return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
......@@ -643,20 +767,20 @@ export class PreviewViewManager {
643767 );
644768 if ("invalidSelector" in point) {
645769 throw automationError("PreviewAutomationInvalidSelectorError", point.message, {
646 selector: input.selector,
770 selector: locator,
647771 });
648772 }
649773 if ("notFound" in point) {
650774 throw automationError(
651775 "PreviewAutomationExecutionError",
652 `No element matches selector ${input.selector}.`,
776 `No element matches locator ${locator}.`,
653777 );
654778 }
655779 x = point.x;
656780 y = point.y;
657781 } else {
658 x = input.x;
659 y = input.y;
782 x = input.x!;
783 y = input.y!;
660784 }
661785 const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>(
662786 send,
......@@ -688,19 +812,17 @@ export class PreviewViewManager {
688812
689813 async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> {
690814 const wc = this.requireWebContents(tabId);
691 await this.withDebugger(wc, async (send) => {
815 await this.withControlSession(tabId, wc, "type", async (send) => {
692816 await send("Runtime.enable");
817 const locator = this.automationLocator(input);
818 if (locator) await this.ensurePlaywrightInjected(send);
693819 const focusResult = await this.evaluateWithDebugger<
694820 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
695821 >(
696822 send,
697823 `(() => {
698824 try {
699 const element = ${
700 input.selector
701 ? `document.querySelector(${JSON.stringify(input.selector)})`
702 : "document.activeElement"
703 };
825 const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"};
704826 if (!element) return { notFound: true };
705827 element.focus();
706828 if (${input.clear ?? false}) {
......@@ -723,8 +845,8 @@ export class PreviewViewManager {
723845 if ("notFound" in focusResult) {
724846 throw automationError(
725847 "PreviewAutomationExecutionError",
726 input.selector
727 ? `No element matches selector ${input.selector}.`
848 locator
849 ? `No element matches locator ${locator}.`
728850 : "No element is focused in the preview.",
729851 );
730852 }
......@@ -743,7 +865,7 @@ export class PreviewViewManager {
743865
744866 async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> {
745867 const wc = this.requireWebContents(tabId);
746 await this.withDebugger(wc, async (send) => {
868 await this.withControlSession(tabId, wc, "press", async (send) => {
747869 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {
748870 switch (modifier) {
749871 case "Alt":
......@@ -771,19 +893,17 @@ export class PreviewViewManager {
771893
772894 async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> {
773895 const wc = this.requireWebContents(tabId);
774 await this.withDebugger(wc, async (send) => {
896 await this.withControlSession(tabId, wc, "scroll", async (send) => {
775897 await send("Runtime.enable");
898 const locator = this.automationLocator(input);
899 if (locator) await this.ensurePlaywrightInjected(send);
776900 const result = await this.evaluateWithDebugger<
777901 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
778902 >(
779903 send,
780904 `(() => {
781905 try {
782 const target = ${
783 input.selector
784 ? `document.querySelector(${JSON.stringify(input.selector)})`
785 : "window"
786 };
906 const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"};
787907 if (!target) return { notFound: true };
788908 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });
789909 return { ok: true };
......@@ -801,7 +921,7 @@ export class PreviewViewManager {
801921 if ("notFound" in result) {
802922 throw automationError(
803923 "PreviewAutomationExecutionError",
804 `No element matches selector ${input.selector}.`,
924 `No element matches locator ${locator}.`,
805925 );
806926 }
807927 });
......@@ -809,7 +929,7 @@ export class PreviewViewManager {
809929
810930 async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> {
811931 const wc = this.requireWebContents(tabId);
812 return this.withDebugger(wc, async (send) => {
932 return this.withControlSession(tabId, wc, "evaluate", async (send) => {
813933 await send("Runtime.enable");
814934 const value = await this.evaluateWithDebugger(
815935 send,
......@@ -835,8 +955,10 @@ export class PreviewViewManager {
835955 async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> {
836956 const wc = this.requireWebContents(tabId);
837957 const timeoutMs = input.timeoutMs ?? 15_000;
838 await this.withDebugger(wc, async (send) => {
958 await this.withControlSession(tabId, wc, "waitFor", async (send) => {
839959 await send("Runtime.enable");
960 const locator = this.automationLocator(input);
961 if (locator) await this.ensurePlaywrightInjected(send);
840962 const deadline = Date.now() + timeoutMs;
841963 while (Date.now() <= deadline) {
842964 const result = await this.evaluateWithDebugger<
......@@ -845,11 +967,7 @@ export class PreviewViewManager {
845967 send,
846968 `(() => {
847969 try {
848 const selectorMatched = ${
849 input.selector
850 ? `document.querySelector(${JSON.stringify(input.selector)}) !== null`
851 : "true"
852 };
970 const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"};
853971 const textMatched = ${
854972 input.text
855973 ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})`
......@@ -896,36 +1014,282 @@ export class PreviewViewManager {
8961014 this.update(tabId, { zoomFactor: next });
8971015 }
8981016
899 private async withDebugger<A>(
1017 private async withControlSession<A>(
1018 tabId: string,
9001019 wc: Electron.WebContents,
1020 action: string,
9011021 use: (
9021022 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
9031023 ) => Promise<A>,
9041024 ): Promise<A> {
905 if (wc.debugger.isAttached()) {
906 throw automationError(
907 "PreviewAutomationExecutionError",
908 "Preview automation is unavailable while another debugger is attached.",
909 );
910 }
911 wc.debugger.attach("1.3");
1025 const actionEvent: PreviewAutomationActionEvent = {
1026 id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`,
1027 action,
1028 status: "running",
1029 startedAt: new Date().toISOString(),
1030 };
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" });
9121041 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;
9141065 } 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 });
9151074 if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause;
9161075 throw automationError(
9171076 "PreviewAutomationExecutionError",
9181077 cause instanceof Error ? cause.message : String(cause),
919 cause,
1078 { tabId, cause },
9201079 );
9211080 } finally {
922 if (wc.debugger.isAttached()) {
923 try {
924 wc.debugger.detach();
925 } catch {
926 // The target can disappear while an operation is completing.
1081 if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" });
1082 resolveTail();
1083 }
1084 }
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);
9271152 }
9281153 }
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);
9291293 }
9301294 }
9311295
......@@ -952,6 +1316,28 @@ export class PreviewViewManager {
9521316 return response.result?.value as A;
9531317 }
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
9551341 onStateChange(listener: Listener): () => void {
9561342 this.listeners.add(listener);
9571343 return () => {
......@@ -964,6 +1350,7 @@ export class PreviewViewManager {
9641350 this.closeTab(tabId);
9651351 }
9661352 this.listeners.clear();
1353 this.recordingFrameListeners.clear();
9671354 }
9681355
9691356 private attachListeners(tabId: string, wc: Electron.WebContents): void {
......@@ -988,6 +1375,15 @@ export class PreviewViewManager {
9881375 },
9891376 });
9901377 };
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
9921388 wc.on("did-navigate", sync);
9931389 wc.on("did-navigate-in-page", sync);
......@@ -995,6 +1391,7 @@ export class PreviewViewManager {
9951391 wc.on("did-start-loading", sync);
9961392 wc.on("did-stop-loading", sync);
9971393 wc.on("did-fail-load", failed as never);
1394 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
9981395
9991396 // Keep external links inside the same view (matches ami's policy).
10001397 wc.setWindowOpenHandler(({ url }) => {
......@@ -1020,7 +1417,7 @@ export class PreviewViewManager {
10201417 }
10211418 });
10221419
1023 this.attached.set(wc.id, { navigate: sync, failed });
1420 this.attached.set(wc.id, { navigate: sync, failed, humanInput });
10241421 }
10251422
10261423 private detachListeners(webContentsId: number): void {
......@@ -1035,6 +1432,7 @@ export class PreviewViewManager {
10351432 wc.off("did-start-loading", handlers.navigate);
10361433 wc.off("did-stop-loading", handlers.navigate);
10371434 wc.off("did-fail-load", handlers.failed as never);
1435 wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput);
10381436 }
10391437
10401438 private isAppShortcut(input: Electron.Input): boolean {
apps/desktop/src/window/DesktopWindow.ts+4-1
......@@ -200,7 +200,10 @@ const make = Effect.gen(function* () {
200200
201201 previewViewManager.setMainWindow(window);
202202 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 ) {
204207 event.preventDefault();
205208 return;
206209 }
apps/server/src/mcp/Layers/McpHttpServer.test.ts+18
......@@ -65,6 +65,9 @@ it.effect("registers annotated tools and preserves authenticated request context
6565 visibleText: "Example",
6666 interactiveElements: [],
6767 accessibilityTree: {},
68 consoleEntries: [],
69 networkEntries: [],
70 actionTimeline: [],
6871 screenshot: {
6972 mimeType: "image/png",
7073 data: Buffer.from("png").toString("base64"),
......@@ -96,6 +99,21 @@ it.effect("registers annotated tools and preserves authenticated request context
9699 const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status");
97100 expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true);
98101 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
100118 const status = yield* server
101119 .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", () =>
6363 expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError);
6464 }),
6565);
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 @@
11import {
2 PreviewAutomationControlInterruptedError,
23 PreviewAutomationExecutionError,
34 PreviewAutomationInvalidSelectorError,
45 PreviewAutomationNoFocusedOwnerError,
......@@ -56,6 +57,8 @@ const makeResponseError = (
5657 return new PreviewAutomationTabNotFoundError({ message: error.message });
5758 case "PreviewAutomationTimeoutError":
5859 return new PreviewAutomationTimeoutError({ message: error.message });
60 case "PreviewAutomationControlInterruptedError":
61 return new PreviewAutomationControlInterruptedError({ message: error.message });
5962 case "PreviewAutomationInvalidSelectorError": {
6063 const detail =
6164 typeof error.detail === "object" && error.detail !== null ? error.detail : undefined;
......@@ -193,20 +196,19 @@ export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => {
193196 (owner) =>
194197 owner.environmentId === input.scope.environmentId &&
195198 owner.threadId === input.scope.threadId &&
196 owner.supportsAutomation &&
197 (input.operation === "open" || input.operation === "status" || owner.visible),
199 owner.supportsAutomation,
198200 )
199201 .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt));
200202 const owner = candidates[0];
201203 if (!owner) {
202204 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.",
204206 });
205207 }
206208 const connection = current.clients.get(owner.clientId);
207209 if (!connection) {
208210 return yield* new PreviewAutomationUnavailableError({
209 message: "The focused preview owner is not connected.",
211 message: "The browser host is not connected.",
210212 });
211213 }
212214 if (
......@@ -216,7 +218,7 @@ export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => {
216218 !input.tabId
217219 ) {
218220 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.",
220222 });
221223 }
222224 const requestId = `preview-${requestSequence++}`;
apps/server/src/mcp/toolkits/preview/handlers.ts+4
......@@ -1,6 +1,8 @@
11import * as Effect from "effect/Effect";
22import type {
33 PreviewAutomationOperation,
4 PreviewAutomationRecordingArtifact,
5 PreviewAutomationRecordingStatus,
46 PreviewAutomationSnapshot,
57 PreviewAutomationStatus,
68} from "@t3tools/contracts";
......@@ -44,4 +46,6 @@ export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({
4446 preview_scroll: (input) => invoke<void>("scroll", input),
4547 preview_evaluate: (input) => invoke<unknown>("evaluate", input),
4648 preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs),
49 preview_recording_start: () => invoke<PreviewAutomationRecordingStatus>("recordingStart", {}),
50 preview_recording_stop: () => invoke<PreviewAutomationRecordingArtifact>("recordingStop", {}),
4751});
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 {
55 PreviewAutomationNavigateInput,
66 PreviewAutomationOpenInput,
77 PreviewAutomationPressInput,
8 PreviewAutomationRecordingArtifact,
9 PreviewAutomationRecordingStatus,
810 PreviewAutomationScrollInput,
911 PreviewAutomationSnapshot,
1012 PreviewAutomationStatus,
......@@ -17,7 +19,13 @@ import { Tool, Toolkit } from "effect/unstable/ai";
1719import { McpInvocationContext } from "../../Services/McpInvocationContext.ts";
1820
1921const 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
2230export const PreviewStatusTool = Tool.make("preview_status", {
2331 description:
......@@ -39,13 +47,15 @@ export const PreviewOpenTool = browserTool(
3947 success: PreviewAutomationStatus,
4048 failure: PreviewAutomationError,
4149 dependencies: [McpInvocationContext],
42 }).annotate(Tool.Title, "Open browser preview"),
50 })
51 .annotate(Tool.Title, "Open browser preview")
52 .annotate(Tool.Destructive, false),
4353);
4454
45export const PreviewNavigateTool = browserTool(
55export const PreviewNavigateTool = safeBrowserTool(
4656 Tool.make("preview_navigate", {
4757 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.",
4959 parameters: PreviewAutomationNavigateInput,
5060 success: PreviewAutomationStatus,
5161 failure: PreviewAutomationError,
......@@ -53,21 +63,20 @@ export const PreviewNavigateTool = browserTool(
5363 }).annotate(Tool.Title, "Navigate browser preview"),
5464);
5565
56export const PreviewSnapshotTool = Tool.make("preview_snapshot", {
57 description:
58 "Capture bounded page metadata, visible text, interactive elements, accessibility data, and a PNG screenshot from the scoped preview tab.",
59 success: PreviewAutomationSnapshot,
60 failure: PreviewAutomationError,
61 dependencies: [McpInvocationContext],
62})
63 .annotate(Tool.Title, "Capture preview snapshot")
64 .annotate(Tool.Readonly, true)
65 .annotate(Tool.Destructive, false);
66export const PreviewSnapshotTool = readonlyBrowserTool(
67 Tool.make("preview_snapshot", {
68 description:
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.",
70 success: PreviewAutomationSnapshot,
71 failure: PreviewAutomationError,
72 dependencies: [McpInvocationContext],
73 }).annotate(Tool.Title, "Inspect browser page"),
74);
6675
6776export const PreviewClickTool = browserTool(
6877 Tool.make("preview_click", {
6978 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.",
7180 parameters: PreviewAutomationClickInput,
7281 failure: PreviewAutomationError,
7382 dependencies: [McpInvocationContext],
......@@ -77,7 +86,7 @@ export const PreviewClickTool = browserTool(
7786export const PreviewTypeTool = browserTool(
7887 Tool.make("preview_type", {
7988 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.",
8190 parameters: PreviewAutomationTypeInput,
8291 failure: PreviewAutomationError,
8392 dependencies: [McpInvocationContext],
......@@ -86,17 +95,18 @@ export const PreviewTypeTool = browserTool(
8695
8796export const PreviewPressTool = browserTool(
8897 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.",
90100 parameters: PreviewAutomationPressInput,
91101 failure: PreviewAutomationError,
92102 dependencies: [McpInvocationContext],
93103 }).annotate(Tool.Title, "Press key in preview page"),
94104);
95105
96export const PreviewScrollTool = browserTool(
106export const PreviewScrollTool = safeBrowserTool(
97107 Tool.make("preview_scroll", {
98108 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.",
100110 parameters: PreviewAutomationScrollInput,
101111 failure: PreviewAutomationError,
102112 dependencies: [McpInvocationContext],
......@@ -106,7 +116,7 @@ export const PreviewScrollTool = browserTool(
106116export const PreviewEvaluateTool = browserTool(
107117 Tool.make("preview_evaluate", {
108118 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.",
110120 parameters: PreviewAutomationEvaluateInput,
111121 success: Schema.Unknown,
112122 failure: PreviewAutomationError,
......@@ -114,16 +124,35 @@ export const PreviewEvaluateTool = browserTool(
114124 }).annotate(Tool.Title, "Evaluate JavaScript in preview"),
115125);
116126
117export const PreviewWaitForTool = browserTool(
127export const PreviewWaitForTool = readonlyBrowserTool(
118128 Tool.make("preview_wait_for", {
119129 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.",
121131 parameters: PreviewAutomationWaitForInput,
122132 failure: PreviewAutomationError,
123133 dependencies: [McpInvocationContext],
124134 }).annotate(Tool.Title, "Wait for preview page condition"),
125135);
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
127156export const PreviewToolkit = Toolkit.make(
128157 PreviewStatusTool,
129158 PreviewOpenTool,
......@@ -135,4 +164,6 @@ export const PreviewToolkit = Toolkit.make(
135164 PreviewScrollTool,
136165 PreviewEvaluateTool,
137166 PreviewWaitForTool,
167 PreviewRecordingStartTool,
168 PreviewRecordingStopTool,
138169);
apps/server/src/provider/CodexDeveloperInstructions.ts+13
......@@ -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
112export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)
213
314You 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
118129Do 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
120131Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec.
132${T3_CODE_BROWSER_TOOL_INSTRUCTIONS}
121133</collaboration_mode>`;
122134
123135export 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 \
131143The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error.
132144
133145In 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}
134147</collaboration_mode>`;
apps/server/src/provider/Layers/CodexAdapter.test.ts+58
......@@ -491,6 +491,64 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
491491 }),
492492 );
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
494552 it.effect("maps completed plan items to canonical proposed-plan completion events", () =>
495553 Effect.gen(function* () {
496554 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
235235 return "unknown";
236236}
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 }
239242 switch (itemType) {
240243 case "assistant_message":
241244 return "Assistant message";
......@@ -476,7 +479,7 @@ function mapItemLifecycle(
476479 payload: {
477480 itemType,
478481 ...(status ? { status } : {}),
479 ...(itemTitle(itemType) ? { title: itemTitle(itemType) } : {}),
482 ...(itemTitle(itemType, item) ? { title: itemTitle(itemType, item) } : {}),
480483 ...(detail ? { detail } : {}),
481484 ...(event.payload !== undefined ? { data: event.payload } : {}),
482485 },
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+26
......@@ -13,6 +13,7 @@ import {
1313} from "../CodexDeveloperInstructions.ts";
1414import {
1515 buildTurnStartParams,
16 hasConfiguredMcpServer,
1617 isRecoverableThreadResumeError,
1718 openCodexThread,
1819} from "./CodexSessionRuntime.ts";
......@@ -149,6 +150,31 @@ describe("buildTurnStartParams", () => {
149150 });
150151});
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
152178describe("isRecoverableThreadResumeError", () => {
153179 it("matches missing thread errors", () => {
154180 assert.equal(
apps/server/src/provider/Layers/CodexSessionRuntime.ts+13
......@@ -62,6 +62,10 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
6262 "does not exist",
6363];
6464
65export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray<string> | undefined): boolean {
66 return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true;
67}
68
6569export const CodexResumeCursorSchema = Schema.Struct({
6670 threadId: Schema.String,
6771});
......@@ -1256,6 +1260,15 @@ export const makeCodexSessionRuntime = (
12561260 sendTurn: (input) =>
12571261 Effect.gen(function* () {
12581262 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 }
12591272 const normalizedModel = normalizeCodexModelSlug(
12601273 input.model ?? (yield* Ref.get(sessionRef)).model,
12611274 );
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 {
2121 RuntimeMode,
2222 TerminalOpenInput,
2323} from "@t3tools/contracts";
24import {
25 parseScopedThreadKey,
26 scopedThreadKey,
27 scopeProjectRef,
28 scopeThreadRef,
29} from "@t3tools/client-runtime";
24import { scopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
3025import {
3126 applyClaudePromptEffortPrefix,
3227 createModelSelection,
......@@ -43,6 +38,7 @@ import { useVcsStatus } from "~/lib/vcsStatusState";
4338import { usePrimaryEnvironmentId } from "../environments/primary";
4439import { readEnvironmentApi } from "../environmentApi";
4540import { isElectron } from "../env";
41import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
4642import { readLocalApi } from "../localApi";
4743import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
4844import {
......@@ -70,11 +66,7 @@ import {
7066 togglePendingUserInputOptionSelection,
7167 type PendingUserInputDraftAnswer,
7268} from "../pendingUserInput";
73import {
74 selectProjectsAcrossEnvironments,
75 selectThreadsAcrossEnvironments,
76 useStore,
77} from "../store";
69import { selectProjectsAcrossEnvironments, useStore } from "../store";
7870import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
7971import { useUiStateStore } from "../uiStateStore";
8072import {
......@@ -98,20 +90,32 @@ import { useCommandPaletteStore } from "../commandPaletteStore";
9890import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
9991import { useMediaQuery } from "../hooks/useMediaQuery";
10092import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
101import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore";
102import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
103import { isPreviewSupportedInRuntime, usePreviewStateStore } from "../previewStateStore";
93import {
94 selectActiveRightPanelKindWithUrl,
95 selectActiveRightPanelSurface,
96 selectThreadRightPanelState,
97 type RightPanelSurface,
98 useRightPanelStore,
99} from "../rightPanelStore";
100import {
101 isPreviewSupportedInRuntime,
102 selectThreadPreviewState,
103 usePreviewStateStore,
104} from "../previewStateStore";
104105import { subscribePreviewAction } from "./preview/previewActionBus";
105106// Lazy: keeps the entire preview component graph (webview host, favicon
106107// helper, Chromium error icon) out of the web bundle until first open.
107108const PreviewPanel = lazy(() =>
108109 import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })),
109110);
111const DiffPanel = lazy(() => import("./DiffPanel"));
110112import { BranchToolbar } from "./BranchToolbar";
111113import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
112114import PlanSidebar from "./PlanSidebar";
113115import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
114116import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react";
117import { RightPanelTabs } from "./RightPanelTabs";
118import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
115119import { cn, randomHex } from "~/lib/utils";
116120import { stackedThreadToast, toastManager } from "./ui/toast";
117121import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
......@@ -167,8 +171,6 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
167171import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
168172import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
169173import {
170 MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
171 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
172174 buildExpiredTerminalContextToastCopy,
173175 buildLocalDraftThread,
174176 collectUserMessageBlobPreviewUrls,
......@@ -183,8 +185,6 @@ import {
183185 cloneComposerImageForRetry,
184186 deriveLockedProvider,
185187 readFileAsDataUrl,
186 reconcileMountedTerminalThreadIds,
187 reconcileRetainedMountedThreadIds,
188188 resolveSendEnvMode,
189189 revokeBlobPreviewUrl,
190190 revokeUserMessagePreviewUrls,
......@@ -535,6 +535,7 @@ interface PersistentThreadTerminalDrawerProps {
535535 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
536536 threadId: ThreadId;
537537 visible: boolean;
538 mode?: "drawer" | "panel";
538539 launchContext: PersistentTerminalLaunchContext | null;
539540 focusRequestId: number;
540541 splitShortcutLabel: string | undefined;
......@@ -548,6 +549,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
548549 threadRef,
549550 threadId,
550551 visible,
552 mode = "drawer",
551553 launchContext,
552554 focusRequestId,
553555 splitShortcutLabel,
......@@ -795,8 +797,9 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
795797 }
796798
797799 return (
798 <div className={visible ? undefined : "hidden"}>
800 <div className={cn(visible ? undefined : "hidden", mode === "panel" && "h-full min-h-0")}>
799801 <ThreadTerminalDrawer
802 mode={mode}
800803 threadRef={threadRef}
801804 threadId={threadId}
802805 cwd={cwd}
......@@ -962,59 +965,11 @@ export default function ChatView(props: ChatViewProps) {
962965 const terminalUiState = useTerminalUiStateStore((state) =>
963966 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef),
964967 );
965 const openTerminalThreadKeys = useTerminalUiStateStore(
966 useShallow((state) =>
967 Object.entries(state.terminalUiStateByThreadKey).flatMap(
968 ([nextThreadKey, nextTerminalUiState]) =>
969 nextTerminalUiState.terminalOpen ? [nextThreadKey] : [],
970 ),
971 ),
972 );
973968 const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen);
974969 const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal);
975970 const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal);
976971 const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal);
977972 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
1019974 const fallbackDraftProjectRef = draftThread
1020975 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
......@@ -1105,18 +1060,40 @@ export default function ChatView(props: ChatViewProps) {
11051060 const activeRightPanelKind = useRightPanelStore((store) =>
11061061 selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen),
11071062 );
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 );
11081072 const planSidebarOpen = activeRightPanelKind === "plan";
11091073 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
1110 const existingOpenTerminalThreadKeys = useMemo(() => {
1111 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
1112 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
1113 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
1114 const existingPreviewSessionThreadKeys = useMemo(() => {
1115 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
1116 return previewSessionThreadKeys.filter((nextThreadKey) =>
1117 existingThreadKeys.has(nextThreadKey),
1074 const terminalPanelOpen = activeRightPanelKind === "terminal";
1075 const rightPanelOpen = activeRightPanelSurface !== null;
1076
1077 useEffect(() => {
1078 if (!activeThreadRef) return;
1079 useRightPanelStore
1080 .getState()
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,
11181093 );
1119 }, [draftThreadKeys, previewSessionThreadKeys, serverThreadKeys]);
1094 if (state.surfaces.some((surface) => surface.kind === "terminal")) return;
1095 useRightPanelStore.getState().open(activeThreadRef, "terminal");
1096 }, [activeThreadRef, terminalUiState.terminalOpen]);
11201097 const activeLatestTurn = activeThread?.latestTurn ?? null;
11211098 const threadPlanCatalog = useThreadPlanCatalog(
11221099 useMemo(() => {
......@@ -1131,42 +1108,6 @@ export default function ChatView(props: ChatViewProps) {
11311108 return threadIds;
11321109 }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]),
11331110 );
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 ]);
11701111 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
11711112 const activeProjectRef = activeThread
11721113 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)
......@@ -1961,19 +1902,6 @@ export default function ChatView(props: ChatViewProps) {
19611902 }),
19621903 [terminalUiState.terminalOpen],
19631904 );
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 );
19771905 const splitTerminalShortcutLabel = useMemo(
19781906 () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions),
19791907 [keybindings, terminalShortcutLabelOptions],
......@@ -1986,14 +1914,37 @@ export default function ChatView(props: ChatViewProps) {
19861914 () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions),
19871915 [keybindings, terminalShortcutLabelOptions],
19881916 );
1989 const diffPanelShortcutLabel = useMemo(
1990 () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions),
1991 [keybindings, nonTerminalShortcutLabelOptions],
1992 );
1993 const previewPanelShortcutLabel = useMemo(
1994 () => shortcutLabelForCommand(keybindings, "preview.toggle", nonTerminalShortcutLabelOptions),
1995 [keybindings, nonTerminalShortcutLabelOptions],
1996 );
1917 const createBrowserSurface = useCallback(() => {
1918 if (!activeThreadRef) return;
1919 const api = readEnvironmentApi(activeThreadRef.environmentId);
1920 if (!api) return;
1921 void api.preview
1922 .open({ threadId: activeThreadRef.threadId })
1923 .then((snapshot) => {
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 ]);
19971948 // Right-panel arbitration:
19981949 // - The diff panel's openness is mirrored by the `?diff=1` URL search
19991950 // param so it deep-links cleanly. The store still records preview/plan
......@@ -2017,20 +1968,33 @@ export default function ChatView(props: ChatViewProps) {
20171968 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
20181969 });
20191970 }
2020 useRightPanelStore.getState().open(activeThreadRef, "preview");
2021 }, [activeThreadRef, diffOpen, environmentId, navigate, previewPanelOpen, threadId]);
1971 const activeTabId = activePreviewState.activeTabId;
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 ]);
20221987 const onToggleDiff = useCallback(() => {
20231988 if (!isServerThread) {
20241989 return;
20251990 }
20261991 if (!diffOpen) {
20271992 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.
20311993 if (activeThreadRef) {
2032 useRightPanelStore.getState().close(activeThreadRef);
1994 useRightPanelStore.getState().open(activeThreadRef, "diff");
20331995 }
1996 } else if (activeThreadRef) {
1997 useRightPanelStore.getState().closeSurface(activeThreadRef, "diff");
20341998 }
20351999 void navigate({
20362000 to: "/$environmentId/$threadId",
......@@ -2147,8 +2111,13 @@ export default function ChatView(props: ChatViewProps) {
21472111 );
21482112 const toggleTerminalVisibility = useCallback(() => {
21492113 if (!activeThreadRef) return;
2150 setTerminalOpen(!terminalUiState.terminalOpen);
2151 }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]);
2114 if (terminalPanelOpen) {
2115 useRightPanelStore.getState().close(activeThreadRef);
2116 return;
2117 }
2118 setTerminalOpen(true);
2119 useRightPanelStore.getState().open(activeThreadRef, "terminal");
2120 }, [activeThreadRef, setTerminalOpen, terminalPanelOpen]);
21522121 const splitTerminal = useCallback(() => {
21532122 if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) {
21542123 return;
......@@ -2348,8 +2317,12 @@ export default function ChatView(props: ChatViewProps) {
23482317 activeThreadRef
23492318 ) {
23502319 try {
2351 await api.preview.open({ threadId: activeThreadId, url: script.previewUrl });
2352 useRightPanelStore.getState().open(activeThreadRef, "preview");
2320 const snapshot = await api.preview.open({
2321 threadId: activeThreadId,
2322 url: script.previewUrl,
2323 });
2324 usePreviewStateStore.getState().applyServerSnapshot(activeThreadRef, snapshot);
2325 useRightPanelStore.getState().openBrowser(activeThreadRef, snapshot.tabId);
23532326 } catch {
23542327 // Preview open failures are surfaced via the panel itself.
23552328 }
......@@ -2579,8 +2552,96 @@ export default function ChatView(props: ChatViewProps) {
25792552 const closePreviewPanel = useCallback(() => {
25802553 if (!activeThreadRef) return;
25812554 useRightPanelStore.getState().close(activeThreadRef);
2582 }, [activeThreadRef]);
2583
2555 if (diffOpen) {
2556 void navigate({
2557 to: "/$environmentId/$threadId",
2558 params: { environmentId, threadId },
2559 replace: true,
2560 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
2561 });
2562 }
2563 }, [activeThreadRef, diffOpen, environmentId, navigate, threadId]);
2564 const activateRightPanelSurface = useCallback(
2565 (surface: RightPanelSurface) => {
2566 if (!activeThreadRef) return;
2567 useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);
2568 if (surface.kind === "preview" && surface.resourceId) {
2569 usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId);
2570 }
2571 if (surface.kind === "terminal") {
2572 setTerminalOpen(true);
2573 }
2574 if (surface.kind === "diff" && !diffOpen) {
2575 onDiffPanelOpen?.();
2576 void navigate({
2577 to: "/$environmentId/$threadId",
2578 params: { environmentId, threadId },
2579 replace: true,
2580 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: "1" }),
2581 });
2582 } else if (surface.kind !== "diff" && diffOpen) {
2583 void navigate({
2584 to: "/$environmentId/$threadId",
2585 params: { environmentId, threadId },
2586 replace: true,
2587 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
2588 });
2589 }
2590 },
2591 [
2592 activeThreadRef,
2593 diffOpen,
2594 environmentId,
2595 navigate,
2596 onDiffPanelOpen,
2597 setTerminalOpen,
2598 threadId,
2599 ],
2600 );
2601 const toggleRightPanel = useCallback(() => {
2602 if (!activeThreadRef) return;
2603 if (rightPanelOpen) {
2604 closePreviewPanel();
2605 return;
2606 }
2607 const surface = rightPanelState.surfaces.at(-1) ?? null;
2608 if (surface) {
2609 activateRightPanelSurface(surface);
2610 return;
2611 }
2612 createBrowserSurface();
2613 }, [
2614 activeThreadRef,
2615 activateRightPanelSurface,
2616 closePreviewPanel,
2617 createBrowserSurface,
2618 rightPanelOpen,
2619 rightPanelState.surfaces,
2620 ]);
2621 const closeRightPanelSurface = useCallback(
2622 (surface: RightPanelSurface) => {
2623 if (!activeThreadRef) return;
2624 useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);
2625 if (surface.kind === "preview" && surface.resourceId) {
2626 const api = readEnvironmentApi(activeThreadRef.environmentId);
2627 void api?.preview
2628 .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId })
2629 .catch(() => undefined);
2630 }
2631 if (surface.kind === "terminal") {
2632 setTerminalOpen(false);
2633 }
2634 if (surface.kind === "diff" && diffOpen) {
2635 void navigate({
2636 to: "/$environmentId/$threadId",
2637 params: { environmentId, threadId },
2638 replace: true,
2639 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
2640 });
2641 }
2642 },
2643 [activeThreadRef, diffOpen, environmentId, navigate, setTerminalOpen, threadId],
2644 );
25842645 const persistThreadSettingsForNextTurn = useCallback(
25852646 async (input: {
25862647 threadId: ThreadId;
......@@ -3952,314 +4013,323 @@ export default function ChatView(props: ChatViewProps) {
39524013 }
39534014
39544015 return (
3955 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
3956 {/* Top bar */}
3957 <header
3958 className={cn(
3959 "border-b border-border",
3960 isElectron
3961 ? cn(
3962 "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]",
3963 reserveTitleBarControlInset &&
3964 "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
3965 )
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",
3967 )}
3968 >
3969 <ChatHeader
3970 activeThreadEnvironmentId={activeThread.environmentId}
3971 activeThreadId={activeThread.id}
3972 {...(routeKind === "draft" && draftId ? { draftId } : {})}
3973 activeThreadTitle={activeThread.title}
3974 activeProjectName={activeProject?.name}
3975 isGitRepo={isGitRepo}
3976 openInCwd={gitCwd}
3977 activeProjectScripts={activeProject?.scripts}
3978 preferredScriptId={
3979 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
3980 }
3981 keybindings={keybindings}
3982 availableEditors={availableEditors}
3983 terminalAvailable={activeProject !== undefined}
3984 terminalOpen={terminalUiState.terminalOpen}
3985 terminalToggleShortcutLabel={terminalToggleShortcutLabel}
3986 diffToggleShortcutLabel={diffPanelShortcutLabel}
3987 previewAvailable={isPreviewSupportedInRuntime()}
3988 previewOpen={previewPanelOpen}
3989 previewToggleShortcutLabel={previewPanelShortcutLabel}
3990 gitCwd={gitCwd}
3991 diffOpen={diffOpen}
3992 onRunProjectScript={runProjectScript}
3993 onAddProjectScript={saveProjectScript}
3994 onUpdateProjectScript={updateProjectScript}
3995 onDeleteProjectScript={deleteProjectScript}
3996 onToggleTerminal={toggleTerminalVisibility}
3997 onTogglePreview={onTogglePreview}
3998 onToggleDiff={onToggleDiff}
4016 <div className="flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
4017 {isElectron && activeThreadRef ? (
4018 <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} />
4019 ) : null}
4020 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden">
4021 {/* Top bar */}
4022 <header
4023 className={cn(
4024 "border-b border-border",
4025 isElectron
4026 ? cn(
4027 "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]",
4028 reserveTitleBarControlInset &&
4029 "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
4030 )
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",
4032 )}
4033 >
4034 <ChatHeader
4035 activeThreadEnvironmentId={activeThread.environmentId}
4036 activeThreadId={activeThread.id}
4037 {...(routeKind === "draft" && draftId ? { draftId } : {})}
4038 activeThreadTitle={activeThread.title}
4039 activeProjectName={activeProject?.name}
4040 openInCwd={gitCwd}
4041 activeProjectScripts={activeProject?.scripts}
4042 preferredScriptId={
4043 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
4044 }
4045 keybindings={keybindings}
4046 availableEditors={availableEditors}
4047 rightPanelAvailable={isPreviewSupportedInRuntime() || isGitRepo}
4048 rightPanelOpen={rightPanelOpen}
4049 gitCwd={gitCwd}
4050 onRunProjectScript={runProjectScript}
4051 onAddProjectScript={saveProjectScript}
4052 onUpdateProjectScript={updateProjectScript}
4053 onDeleteProjectScript={deleteProjectScript}
4054 onToggleRightPanel={toggleRightPanel}
4055 />
4056 </header>
4057
4058 {/* Error banner */}
4059 <ProviderStatusBanner status={activeProviderStatus} />
4060 <ThreadErrorBanner
4061 error={activeThread.error}
4062 onDismiss={() => setThreadError(activeThread.id, null)}
39994063 />
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 */}
4003 <ProviderStatusBanner status={activeProviderStatus} />
4004 <ThreadErrorBanner
4005 error={activeThread.error}
4006 onDismiss={() => setThreadError(activeThread.id, null)}
4007 />
4008 {/* Main content area with optional plan sidebar */}
4009 <div className="flex min-h-0 min-w-0 flex-1">
4010 {/* Chat column */}
4011 <div className="flex min-h-0 min-w-0 flex-1 flex-col">
4012 {/* Messages Wrapper */}
4013 <div className="relative flex min-h-0 flex-1 flex-col">
4014 {/* Messages — LegendList handles virtualization and scrolling internally */}
4015 <MessagesTimeline
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>
4095 {/* scroll to bottom pill — shown when user has scrolled away from the bottom */}
4096 {showScrollToBottom && (
4097 <div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5">
4098 <button
4099 type="button"
4100 onClick={() => scrollToEnd(true)}
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"
4102 >
4103 <ChevronDownIcon className="size-3.5" />
4104 Scroll to bottom
4105 </button>
4106 </div>
4107 )}
4108 </div>
40534109
4054 {/* Input bar */}
4055 <div
4056 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",
4058 isGitRepo
4059 ? "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)]",
4061 )}
4062 >
4063 <div className="relative isolate">
4064 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
4065 <div className="relative z-10">
4066 <ChatComposer
4067 composerRef={composerRef}
4068 composerDraftTarget={composerDraftTarget}
4069 environmentId={environmentId}
4070 routeKind={routeKind}
4071 routeThreadRef={routeThreadRef}
4072 draftId={draftId}
4073 activeThreadId={activeThreadId}
4074 activeThreadEnvironmentId={activeThread?.environmentId}
4075 activeThread={activeThread}
4076 isServerThread={isServerThread}
4077 isLocalDraftThread={isLocalDraftThread}
4078 phase={phase}
4079 isConnecting={isConnecting}
4080 isSendBusy={isSendBusy}
4081 isPreparingWorktree={isPreparingWorktree}
4082 environmentUnavailable={activeEnvironmentUnavailableState}
4083 activePendingApproval={activePendingApproval}
4084 pendingApprovals={pendingApprovals}
4085 pendingUserInputs={pendingUserInputs}
4086 activePendingProgress={activePendingProgress}
4087 activePendingResolvedAnswers={activePendingResolvedAnswers}
4088 activePendingIsResponding={activePendingIsResponding}
4089 activePendingDraftAnswers={activePendingDraftAnswers}
4090 activePendingQuestionIndex={activePendingQuestionIndex}
4091 respondingRequestIds={respondingRequestIds}
4092 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
4093 activeProposedPlan={activeProposedPlan}
4094 activePlan={activePlan as { turnId?: TurnId } | null}
4095 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
4096 planSidebarLabel={planSidebarLabel}
4097 planSidebarOpen={planSidebarOpen}
4098 runtimeMode={runtimeMode}
4099 interactionMode={interactionMode}
4100 lockedProvider={lockedProvider}
4101 providerStatuses={providerStatuses as ServerProvider[]}
4102 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
4103 activeThreadModelSelection={activeThread?.modelSelection}
4104 activeThreadActivities={activeThread?.activities}
4105 resolvedTheme={resolvedTheme}
4106 settings={settings}
4107 keybindings={keybindings}
4108 terminalOpen={Boolean(terminalUiState.terminalOpen)}
4109 gitCwd={gitCwd}
4110 promptRef={promptRef}
4111 composerImagesRef={composerImagesRef}
4112 composerTerminalContextsRef={composerTerminalContextsRef}
4113 composerElementContextsRef={composerElementContextsRef}
4114 shouldAutoScrollRef={isAtEndRef}
4115 scheduleStickToBottom={scrollToEnd}
4116 onSend={onSend}
4117 onInterrupt={onInterrupt}
4118 onImplementPlanInNewThread={onImplementPlanInNewThread}
4119 onRespondToApproval={onRespondToApproval}
4120 onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
4121 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
4122 onPreviousActivePendingUserInputQuestion={
4123 onPreviousActivePendingUserInputQuestion
4124 }
4125 onChangeActivePendingUserInputCustomAnswer={
4126 onChangeActivePendingUserInputCustomAnswer
4127 }
4128 onProviderModelSelect={onProviderModelSelect}
4129 getModelDisabledReason={getModelDisabledReason}
4130 toggleInteractionMode={toggleInteractionMode}
4131 handleRuntimeModeChange={handleRuntimeModeChange}
4132 handleInteractionModeChange={handleInteractionModeChange}
4133 togglePlanSidebar={togglePlanSidebar}
4134 focusComposer={focusComposer}
4135 scheduleComposerFocus={scheduleComposerFocus}
4136 setThreadError={setThreadError}
4137 onExpandImage={onExpandTimelineImage}
4138 />
4110 {/* Input bar */}
4111 <div
4112 className={cn(
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",
4114 isGitRepo
4115 ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]"
4116 : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]",
4117 )}
4118 >
4119 <div className="relative isolate">
4120 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
4121 <div className="relative z-10">
4122 <ChatComposer
4123 composerRef={composerRef}
4124 composerDraftTarget={composerDraftTarget}
4125 environmentId={environmentId}
4126 routeKind={routeKind}
4127 routeThreadRef={routeThreadRef}
4128 draftId={draftId}
4129 activeThreadId={activeThreadId}
4130 activeThreadEnvironmentId={activeThread?.environmentId}
4131 activeThread={activeThread}
4132 isServerThread={isServerThread}
4133 isLocalDraftThread={isLocalDraftThread}
4134 phase={phase}
4135 isConnecting={isConnecting}
4136 isSendBusy={isSendBusy}
4137 isPreparingWorktree={isPreparingWorktree}
4138 environmentUnavailable={activeEnvironmentUnavailableState}
4139 activePendingApproval={activePendingApproval}
4140 pendingApprovals={pendingApprovals}
4141 pendingUserInputs={pendingUserInputs}
4142 activePendingProgress={activePendingProgress}
4143 activePendingResolvedAnswers={activePendingResolvedAnswers}
4144 activePendingIsResponding={activePendingIsResponding}
4145 activePendingDraftAnswers={activePendingDraftAnswers}
4146 activePendingQuestionIndex={activePendingQuestionIndex}
4147 respondingRequestIds={respondingRequestIds}
4148 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
4149 activeProposedPlan={activeProposedPlan}
4150 activePlan={activePlan as { turnId?: TurnId } | null}
4151 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
4152 planSidebarLabel={planSidebarLabel}
4153 planSidebarOpen={planSidebarOpen}
4154 runtimeMode={runtimeMode}
4155 interactionMode={interactionMode}
4156 lockedProvider={lockedProvider}
4157 providerStatuses={providerStatuses as ServerProvider[]}
4158 activeProjectDefaultModelSelection={activeProject?.defaultModelSelection}
4159 activeThreadModelSelection={activeThread?.modelSelection}
4160 activeThreadActivities={activeThread?.activities}
4161 resolvedTheme={resolvedTheme}
4162 settings={settings}
4163 keybindings={keybindings}
4164 terminalOpen={Boolean(terminalUiState.terminalOpen)}
4165 gitCwd={gitCwd}
4166 promptRef={promptRef}
4167 composerImagesRef={composerImagesRef}
4168 composerTerminalContextsRef={composerTerminalContextsRef}
4169 composerElementContextsRef={composerElementContextsRef}
4170 shouldAutoScrollRef={isAtEndRef}
4171 scheduleStickToBottom={scrollToEnd}
4172 onSend={onSend}
4173 onInterrupt={onInterrupt}
4174 onImplementPlanInNewThread={onImplementPlanInNewThread}
4175 onRespondToApproval={onRespondToApproval}
4176 onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
4177 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
4178 onPreviousActivePendingUserInputQuestion={
4179 onPreviousActivePendingUserInputQuestion
4180 }
4181 onChangeActivePendingUserInputCustomAnswer={
4182 onChangeActivePendingUserInputCustomAnswer
4183 }
4184 onProviderModelSelect={onProviderModelSelect}
4185 getModelDisabledReason={getModelDisabledReason}
4186 toggleInteractionMode={toggleInteractionMode}
4187 handleRuntimeModeChange={handleRuntimeModeChange}
4188 handleInteractionModeChange={handleInteractionModeChange}
4189 togglePlanSidebar={togglePlanSidebar}
4190 focusComposer={focusComposer}
4191 scheduleComposerFocus={scheduleComposerFocus}
4192 setThreadError={setThreadError}
4193 onExpandImage={onExpandTimelineImage}
4194 />
4195 </div>
41394196 </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 )}
41404219 </div>
4141 {isGitRepo && (
4142 <BranchToolbar
4220
4221 {pullRequestDialogState ? (
4222 <PullRequestThreadDialog
4223 key={pullRequestDialogState.key}
4224 open
41434225 environmentId={activeThread.environmentId}
41444226 threadId={activeThread.id}
4145 {...(routeKind === "draft" && draftId ? { draftId } : {})}
4146 onEnvModeChange={onEnvModeChange}
4147 {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})}
4148 {...(canOverrideServerThreadEnvMode
4149 ? {
4150 activeThreadBranchOverride: activeThreadBranch,
4151 onActiveThreadBranchOverrideChange: setPendingServerThreadBranch,
4152 }
4153 : {})}
4154 envLocked={envLocked}
4155 onComposerFocusRequest={scheduleComposerFocus}
4156 {...(canCheckoutPullRequestIntoThread
4157 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
4158 : {})}
4159 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
4160 availableEnvironments={logicalProjectEnvironments}
4227 cwd={activeProject?.cwd ?? null}
4228 initialReference={pullRequestDialogState.initialReference}
4229 onOpenChange={(open) => {
4230 if (!open) {
4231 closePullRequestDialog();
4232 }
4233 }}
4234 onPrepared={handlePreparedPullRequestThread}
41614235 />
4162 )}
4236 ) : null}
41634237 </div>
4164
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}
4238 {/* end chat column */}
41814239 </div>
4182 {/* end chat column */}
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}
4240 {/* end horizontal flex container */}
42194241 </div>
4220 {/* end horizontal flex container */}
42214242
4222 {activeThreadRef ? (
4223 <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} />
4243 {!shouldUsePlanSidebarSheet &&
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>
42244275 ) : null}
42254276
4226 {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => (
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 ? (
4277 {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef && activeRightPanelSurface ? (
42444278 <RightPanelSheet open onClose={closePreviewPanel}>
4245 <Suspense fallback={null}>
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}
4279 <RightPanelTabs
42604280 mode="sheet"
4261 onClose={closePlanSidebar}
4262 />
4281 surfaces={rightPanelState.surfaces}
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>
42634333 </RightPanelSheet>
42644334 ) : null}
42654335
apps/web/src/components/DiffPanelShell.tsx+3-3
......@@ -5,10 +5,10 @@ import { cn } from "~/lib/utils";
55
66import { Skeleton } from "./ui/skeleton";
77
8export type DiffPanelMode = "inline" | "sheet" | "sidebar";
8export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded";
99
1010function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
11 const shouldUseDragRegion = isElectron && mode !== "sheet";
11 const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded";
1212 return cn(
1313 "flex items-center justify-between gap-2 px-4",
1414 shouldUseDragRegion
......@@ -22,7 +22,7 @@ export function DiffPanelShell(props: {
2222 header: ReactNode;
2323 children: ReactNode;
2424}) {
25 const shouldUseDragRegion = isElectron && props.mode !== "sheet";
25 const shouldUseDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded";
2626
2727 return (
2828 <div
apps/web/src/components/PlanSidebar.tsx+1-1
......@@ -59,7 +59,7 @@ interface PlanSidebarProps {
5959 markdownCwd: string | undefined;
6060 workspaceRoot: string | undefined;
6161 timestampFormat: TimestampFormat;
62 mode?: "sheet" | "sidebar";
62 mode?: "sheet" | "sidebar" | "embedded";
6363 onClose: () => void;
6464}
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 {
2020 useState,
2121} from "react";
2222import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
23import { cn } from "~/lib/utils";
2324import { type TerminalContextSelection } from "~/lib/terminalContext";
2425import { openInPreferredEditor } from "../editorPreferences";
2526import {
......@@ -791,6 +792,7 @@ export function TerminalViewport({
791792}
792793
793794interface ThreadTerminalDrawerProps {
795 mode?: "drawer" | "panel";
794796 threadRef: ScopedThreadRef;
795797 threadId: ThreadId;
796798 cwd: string;
......@@ -849,6 +851,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA
849851}
850852
851853export default function ThreadTerminalDrawer({
854 mode = "drawer",
852855 threadRef,
853856 threadId,
854857 cwd,
......@@ -874,6 +877,7 @@ export default function ThreadTerminalDrawer({
874877 terminalLabelsById,
875878 terminalLaunchLocationsById,
876879}: ThreadTerminalDrawerProps) {
880 const isPanel = mode === "panel";
877881 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
878882 const [resizeEpoch, setResizeEpoch] = useState(0);
879883 const drawerHeightRef = useRef(drawerHeight);
......@@ -1139,16 +1143,21 @@ export default function ThreadTerminalDrawer({
11391143 if (normalizedTerminalIds.length === 0) {
11401144 return (
11411145 <aside
1142 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
1143 style={{ height: `${drawerHeight}px` }}
1146 className={cn(
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` }}
11441151 >
1145 <div
1146 className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
1147 onPointerDown={handleResizePointerDown}
1148 onPointerMove={handleResizePointerMove}
1149 onPointerUp={handleResizePointerEnd}
1150 onPointerCancel={handleResizePointerEnd}
1151 />
1152 {!isPanel ? (
1153 <div
1154 className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
1155 onPointerDown={handleResizePointerDown}
1156 onPointerMove={handleResizePointerMove}
1157 onPointerUp={handleResizePointerEnd}
1158 onPointerCancel={handleResizePointerEnd}
1159 />
1160 ) : null}
11521161 <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">
11531162 <p>No terminal sessions for this thread yet.</p>
11541163 <button
......@@ -1167,16 +1176,21 @@ export default function ThreadTerminalDrawer({
11671176
11681177 return (
11691178 <aside
1170 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
1171 style={{ height: `${drawerHeight}px` }}
1179 className={cn(
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` }}
11721184 >
1173 <div
1174 className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
1175 onPointerDown={handleResizePointerDown}
1176 onPointerMove={handleResizePointerMove}
1177 onPointerUp={handleResizePointerEnd}
1178 onPointerCancel={handleResizePointerEnd}
1179 />
1185 {!isPanel ? (
1186 <div
1187 className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
1188 onPointerDown={handleResizePointerDown}
1189 onPointerMove={handleResizePointerMove}
1190 onPointerUp={handleResizePointerEnd}
1191 onPointerCancel={handleResizePointerEnd}
1192 />
1193 ) : null}
11801194
11811195 {!hasTerminalSidebar && (
11821196 <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";
99import { memo } from "react";
1010import GitActionsControl from "../GitActionsControl";
1111import { type DraftId } from "~/composerDraftStore";
12import { DiffIcon, Globe, TerminalSquareIcon } from "lucide-react";
12import { PanelRightIcon } from "lucide-react";
1313import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1414import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl";
1515import { Toggle } from "../ui/toggle";
......@@ -23,29 +23,19 @@ interface ChatHeaderProps {
2323 draftId?: DraftId;
2424 activeThreadTitle: string;
2525 activeProjectName: string | undefined;
26 isGitRepo: boolean;
2726 openInCwd: string | null;
2827 activeProjectScripts: ProjectScript[] | undefined;
2928 preferredScriptId: string | null;
3029 keybindings: ResolvedKeybindingsConfig;
3130 availableEditors: ReadonlyArray<EditorId>;
32 terminalAvailable: boolean;
33 terminalOpen: 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;
31 rightPanelAvailable: boolean;
32 rightPanelOpen: boolean;
4033 gitCwd: string | null;
41 diffOpen: boolean;
4234 onRunProjectScript: (script: ProjectScript) => void;
4335 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
4436 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
4537 onDeleteProjectScript: (scriptId: string) => Promise<void>;
46 onToggleTerminal: () => void;
47 onTogglePreview: () => void;
48 onToggleDiff: () => void;
38 onToggleRightPanel: () => void;
4939}
5040
5141export function shouldShowOpenInPicker(input: {
......@@ -66,28 +56,19 @@ export const ChatHeader = memo(function ChatHeader({
6656 draftId,
6757 activeThreadTitle,
6858 activeProjectName,
69 isGitRepo,
7059 openInCwd,
7160 activeProjectScripts,
7261 preferredScriptId,
7362 keybindings,
7463 availableEditors,
75 terminalAvailable,
76 terminalOpen,
77 terminalToggleShortcutLabel,
78 diffToggleShortcutLabel,
79 previewAvailable,
80 previewOpen,
81 previewToggleShortcutLabel,
64 rightPanelAvailable,
65 rightPanelOpen,
8266 gitCwd,
83 diffOpen,
8467 onRunProjectScript,
8568 onAddProjectScript,
8669 onUpdateProjectScript,
8770 onDeleteProjectScript,
88 onToggleTerminal,
89 onTogglePreview,
90 onToggleDiff,
71 onToggleRightPanel,
9172}: ChatHeaderProps) {
9273 const primaryEnvironmentId = usePrimaryEnvironmentId();
9374 const showOpenInPicker = shouldShowOpenInPicker({
......@@ -145,70 +126,19 @@ export const ChatHeader = memo(function ChatHeader({
145126 render={
146127 <Toggle
147128 className="shrink-0"
148 pressed={terminalOpen}
149 onPressedChange={onToggleTerminal}
150 aria-label="Toggle terminal drawer"
129 pressed={rightPanelOpen}
130 onPressedChange={onToggleRightPanel}
131 aria-label="Toggle right panel"
151132 variant="ghost"
152133 size="xs"
153 disabled={!terminalAvailable}
134 disabled={!rightPanelAvailable}
154135 >
155 <TerminalSquareIcon className="size-3" />
136 <PanelRightIcon className="size-3.5" />
156137 </Toggle>
157138 }
158139 />
159140 <TooltipPopup side="bottom">
160 {!terminalAvailable
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"}
141 {rightPanelAvailable ? "Toggle right panel" : "Right panel is unavailable"}
212142 </TooltipPopup>
213143 </Tooltip>
214144 </div>
apps/web/src/components/chat/MessagesTimeline.tsx+14-9
......@@ -1377,23 +1377,28 @@ function buildToolCallExpandedBody(
13771377 workEntry: TimelineWorkEntry,
13781378 workspaceRoot: string | undefined,
13791379): 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 }
13801384 const raw = workEntryRawCommand(workEntry);
13811385 if (raw?.trim()) {
1382 return raw.trim();
1383 }
1384 if (workEntry.command?.trim()) {
1385 return workEntry.command.trim();
1386 blocks.push(raw.trim());
1387 } else if (workEntry.command?.trim()) {
1388 blocks.push(workEntry.command.trim());
13861389 }
13871390 if (workEntry.detail?.trim()) {
1388 return workEntry.detail.trim();
1391 blocks.push(workEntry.detail.trim());
13891392 }
13901393 const changedFiles = workEntry.changedFiles ?? [];
13911394 if (changedFiles.length > 0) {
1392 return changedFiles
1393 .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot))
1394 .join("\n");
1395 blocks.push(
1396 changedFiles
1397 .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot))
1398 .join("\n"),
1399 );
13951400 }
1396 return null;
1401 return blocks.length > 0 ? blocks.join("\n\n") : null;
13971402}
13981403
13991404function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
apps/web/src/components/preview/PreviewAutomationOwner.tsx+40-10
......@@ -10,15 +10,21 @@ import type {
1010 PreviewAutomationStatus,
1111 ScopedThreadRef,
1212} from "@t3tools/contracts";
13import { useCallback, useEffect, useRef } from "react";
13import { useCallback, useEffect, useRef, useState } from "react";
1414
1515import { ensureEnvironmentApi } from "~/environmentApi";
1616import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
1717import { useRightPanelStore } from "~/rightPanelStore";
18import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver";
19import {
20 startBrowserRecording,
21 stopBrowserRecording,
22 useBrowserRecordingStore,
23} from "~/browser/browserRecording";
1824
1925import { previewBridge } from "./previewBridge";
2026
21const automationClientId =
27const newAutomationClientId = (): string =>
2228 typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
2329 ? crypto.randomUUID()
2430 : `preview-${Math.random().toString(36).slice(2)}`;
......@@ -112,11 +118,14 @@ export function PreviewAutomationOwner(props: {
112118 readonly visible: boolean;
113119}) {
114120 const { threadRef, visible } = props;
121 const [automationClientId] = useState(newAutomationClientId);
115122 const ownerStateRef = useRef({ threadRef, visible });
116123 const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>(
117124 async () => undefined,
118125 );
119 ownerStateRef.current = { threadRef, visible };
126 useEffect(() => {
127 ownerStateRef.current = { threadRef, visible };
128 }, [threadRef, visible]);
120129
121130 const handleRequest = useCallback(
122131 async (request: PreviewAutomationRequest): Promise<unknown> => {
......@@ -136,9 +145,6 @@ export function PreviewAutomationOwner(props: {
136145 return currentStatus(threadRef, visible);
137146 case "open": {
138147 const input = request.input as PreviewAutomationOpenInput;
139 if (input.show ?? true) {
140 useRightPanelStore.getState().open(threadRef, "preview");
141 }
142148 let activeTabId =
143149 (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null;
144150 if (!activeTabId) {
......@@ -151,13 +157,20 @@ export function PreviewAutomationOwner(props: {
151157 } else if (input.url && previewBridge) {
152158 await previewBridge.navigate(activeTabId, input.url);
153159 }
160 if (input.show ?? true) {
161 useRightPanelStore.getState().openBrowser(threadRef, activeTabId);
162 }
154163 await waitForDesktopOverlay(threadRef, request.timeoutMs);
155164 return currentStatus(threadRef, input.show ?? true);
156165 }
157166 case "navigate": {
158167 if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized.");
159168 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);
161174 await waitForNavigationReadiness(
162175 tabId,
163176 input.readiness ?? "load",
......@@ -204,11 +217,28 @@ export function PreviewAutomationOwner(props: {
204217 tabId,
205218 request.input as Parameters<typeof previewBridge.automation.waitFor>[1],
206219 );
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 }
207235 }
208236 },
209237 [threadRef, visible],
210238 );
211 handlerRef.current = handleRequest;
239 useEffect(() => {
240 handlerRef.current = handleRequest;
241 }, [handleRequest]);
212242
213243 useEffect(() => {
214244 const api = ensureEnvironmentApi(threadRef.environmentId);
......@@ -249,7 +279,7 @@ export function PreviewAutomationOwner(props: {
249279 },
250280 },
251281 );
252 }, [threadRef.environmentId]);
282 }, [automationClientId, threadRef.environmentId]);
253283
254284 useEffect(() => {
255285 const api = ensureEnvironmentApi(threadRef.environmentId);
......@@ -281,7 +311,7 @@ export function PreviewAutomationOwner(props: {
281311 unsubscribe();
282312 void api.preview.automation.clearOwner({ clientId: automationClientId });
283313 };
284 }, [threadRef, visible]);
314 }, [automationClientId, threadRef, visible]);
285315
286316 return null;
287317}
apps/web/src/components/preview/PreviewChromeRow.tsx+43-8
......@@ -1,6 +1,7 @@
11import {
22 ArrowLeft,
33 ArrowRight,
4 Camera,
45 ExternalLink,
56 Globe,
67 MousePointerClick,
......@@ -36,6 +37,9 @@ interface Props {
3637 onSubmit: (url: string) => void;
3738 /** When provided, renders an "Open in browser" affordance to the right. */
3839 onOpenInBrowser?: (() => void) | undefined;
40 onCapture?: ((record: boolean) => void) | undefined;
41 captureDisabled?: boolean | undefined;
42 recording?: boolean | undefined;
3943 /**
4044 * When provided, renders an annotation-mode toggle button to the right of
4145 * the URL input. Pressed while annotation mode is active (button shows in `pressed`
......@@ -69,6 +73,9 @@ export function PreviewChromeRow({
6973 onRefresh,
7074 onSubmit,
7175 onOpenInBrowser,
76 onCapture,
77 captureDisabled,
78 recording,
7279 onPickElement,
7380 pickActive,
7481 pickDisabled,
......@@ -97,13 +104,14 @@ export function PreviewChromeRow({
97104 const next = draft.trim();
98105 if (next.length === 0) return;
99106 onSubmit(next);
107 inputRef.current?.blur();
100108 };
101109
102110 return (
103111 <div className="relative">
104112 <form
105113 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"
107115 >
108116 <div className="flex items-center gap-0.5" role="group" aria-label="Navigation">
109117 <Tooltip>
......@@ -159,7 +167,7 @@ export function PreviewChromeRow({
159167 </Tooltip>
160168 </div>
161169
162 <InputGroup className="flex-1">
170 <InputGroup className="h-7 flex-1 rounded-md">
163171 <InputGroupAddon align="inline-start">
164172 <Globe className="size-3.5 text-muted-foreground" aria-hidden />
165173 </InputGroupAddon>
......@@ -181,6 +189,26 @@ export function PreviewChromeRow({
181189 data-preview-url-input
182190 size="sm"
183191 />
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}
184212 </InputGroup>
185213
186214 {onPickElement ? (
......@@ -209,22 +237,29 @@ export function PreviewChromeRow({
209237 </TooltipPopup>
210238 </Tooltip>
211239 ) : null}
212 {onOpenInBrowser ? (
240 {onCapture ? (
213241 <Tooltip>
214242 <TooltipTrigger
215243 render={
216244 <Button
217 variant="ghost"
245 variant={recording ? "secondary" : "ghost"}
218246 size="icon-xs"
219 onClick={onOpenInBrowser}
220 aria-label="Open in system browser"
247 onClick={(event) => onCapture(event.shiftKey)}
248 aria-label={recording ? "Stop recording" : "Capture screenshot"}
221249 type="button"
250 className="relative"
251 disabled={captureDisabled}
222252 />
223253 }
224254 >
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}
226259 </TooltipTrigger>
227 <TooltipPopup>Open in system browser</TooltipPopup>
260 <TooltipPopup>
261 {recording ? "Stop recording" : "Screenshot · Shift-click to record"}
262 </TooltipPopup>
228263 </Tooltip>
229264 ) : null}
230265 {trailingActions}
apps/web/src/components/preview/PreviewEmptyState.tsx+19-13
......@@ -1,5 +1,5 @@
11import type { EnvironmentId } from "@t3tools/contracts";
2import { Globe } from "lucide-react";
2import { Globe, RadioTower } from "lucide-react";
33
44import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty";
55
......@@ -41,18 +41,24 @@ export function PreviewEmptyState({
4141 }
4242
4343 return (
44 <div className="flex h-full min-h-0 flex-col overflow-y-auto">
45 <h2 className="px-4 pt-4 pb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80">
46 Local
47 </h2>
48 <div className="flex flex-col gap-1.5 px-3 pb-4">
49 {servers.map((server) => (
50 <PreviewLocalServerCard
51 key={`${server.host}:${server.port}`}
52 server={server}
53 onOpen={() => onOpenUrl(server.url)}
54 />
55 ))}
44 <div className="flex h-full min-h-0 overflow-y-auto px-5 py-8">
45 <div className="m-auto flex w-full max-w-xl flex-col gap-3">
46 <div className="flex items-center gap-2 text-sm text-muted-foreground">
47 <RadioTower className="size-4 shrink-0" />
48 <h2 className="font-medium">Local servers</h2>
49 </div>
50 <div className="flex flex-col divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70 bg-background">
51 {servers.map((server) => (
52 <PreviewLocalServerCard
53 key={`${server.host}:${server.port}`}
54 server={server}
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>
5662 </div>
5763 </div>
5864 );
apps/web/src/components/preview/PreviewLocalServerCard.tsx+3-9
......@@ -1,5 +1,3 @@
1import { cn } from "~/lib/utils";
2
31import { BrowserMockup } from "./BrowserMockup";
42import type { PreviewableServer } from "./useDiscoveredLocalServers";
53
......@@ -14,18 +12,14 @@ export function PreviewLocalServerCard({ server, onOpen }: Props) {
1412 <button
1513 type="button"
1614 onClick={onOpen}
17 className={cn(
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 )}
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"
2216 >
2317 <BrowserMockup className="size-7 shrink-0" />
2418 <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">
2621 {server.host}:{server.port}
2722 </span>
28 <span className="truncate text-xs text-muted-foreground">{subtitle}</span>
2923 </div>
3024 {server.listening ? <PulsingDot /> : <DimDot />}
3125 </button>
apps/web/src/components/preview/PreviewMoreMenu.tsx-2
......@@ -36,7 +36,6 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) {
3636 };
3737
3838 const zoomLabel = `${Math.round(zoomFactor * 100)}%`;
39
4039 return (
4140 <Menu>
4241 <Tooltip>
......@@ -60,7 +59,6 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) {
6059 <MenuItem onClick={callTab(bridge.openDevTools)} disabled={tabDisabled}>
6160 Open DevTools
6261 </MenuItem>
63 <MenuSeparator />
6462 {/*
6563 Zoom row: label + inline control cluster. `closeOnClick=false`
6664 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";
1010interface Props {
1111 mode: PreviewPanelMode;
1212 threadRef: ScopedThreadRef;
13 tabId?: string | null;
1314 configuredUrls?: ReadonlyArray<string> | undefined;
1415 visible: boolean;
1516}
1617
17export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props) {
18export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible }: Props) {
1819 if (!isPreviewSupportedInRuntime()) {
1920 return (
2021 <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">
2223 <p className="max-w-sm text-sm text-muted-foreground">
2324 Preview is only available in the T3 Code desktop app.
2425 </p>
......@@ -29,7 +30,12 @@ export function PreviewPanel({ mode, threadRef, configuredUrls, visible }: Props
2930
3031 return (
3132 <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 />
3339 </PreviewPanelShell>
3440 );
3541}
apps/web/src/components/preview/PreviewPanelShell.tsx+3-3
......@@ -6,7 +6,7 @@ import { cn } from "~/lib/utils";
66
77import { RightPanelResizeHandle } from "./RightPanelResizeHandle";
88
9export type PreviewPanelMode = "inline" | "sheet" | "sidebar";
9export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded";
1010
1111const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width";
1212const PREVIEW_PANEL_MIN_WIDTH = 360;
......@@ -22,7 +22,7 @@ const PREVIEW_PANEL_DEFAULT_WIDTH = 540;
2222 * sheet/sidebar modes the parent owns the size.
2323 */
2424export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: ReactNode }) {
25 const useDragRegion = isElectron && props.mode !== "sheet";
25 const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded";
2626 const isInline = props.mode === "inline";
2727 const maxWidth = useViewportClampedMaxWidth();
2828 const { width, handlers } = useResizableWidth({
......@@ -36,7 +36,7 @@ export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: Rea
3636 return (
3737 <div
3838 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",
4040 isInline ? "shrink-0 border-l border-border" : "w-full",
4141 )}
4242 style={isInline ? { width: `${width}px` } : undefined}
apps/web/src/components/preview/PreviewView.tsx+93-12
......@@ -13,6 +13,7 @@ import {
1313} from "~/lib/previewAnnotation";
1414import { ensureLocalApi } from "~/localApi";
1515import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
16import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
1617
1718import { previewBridge } from "./previewBridge";
1819import { subscribePreviewAction } from "./previewActionBus";
......@@ -21,13 +22,20 @@ import { PreviewChromeRow } from "./PreviewChromeRow";
2122import { PreviewEmptyState } from "./PreviewEmptyState";
2223import { PreviewMoreMenu } from "./PreviewMoreMenu";
2324import { PreviewUnreachable } from "./PreviewUnreachable";
24import { PreviewWebview } from "./PreviewWebview";
25import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
2526import { useLoadingProgress } from "./useLoadingProgress";
2627import { usePreviewSession } from "./usePreviewSession";
2728import { ZoomIndicator } from "./ZoomIndicator";
29import {
30 startBrowserRecording,
31 stopBrowserRecording,
32 useBrowserRecordingStore,
33} from "~/browser/browserRecording";
34import { toastManager } from "~/components/ui/toast";
2835
2936interface Props {
3037 threadRef: ScopedThreadRef;
38 tabId?: string | null;
3139 configuredUrls?: ReadonlyArray<string> | undefined;
3240 visible: boolean;
3341}
......@@ -38,9 +46,10 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();
3846 * Single-tab preview surface: chrome row on top, one webview below, empty
3947 * state when no session exists for the thread.
4048 */
41export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
49export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) {
4250 const [focusUrlNonce, setFocusUrlNonce] = useState(0);
4351 const [pickActive, setPickActive] = useState(false);
52 const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId);
4453 const pickActiveRef = useRef(false);
4554 const isMountedRef = useRef(true);
4655 const previewState = usePreviewStateStore((state) =>
......@@ -62,8 +71,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
6271 };
6372 }, []);
6473
65 const { snapshot, desktopOverlay } = previewState;
66 const tabId = snapshot?.tabId ?? null;
74 const tabId = requestedTabId ?? previewState.activeTabId;
75 const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null;
76 const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null;
6777 const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const };
6878 const url = navStatus._tag === "Idle" ? "" : navStatus.url;
6979 const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading";
......@@ -71,22 +81,24 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
7181 const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false;
7282 const refreshDisabled = navStatus._tag === "Idle";
7383 const isUnreachable = navStatus._tag === "LoadFailed";
84 const controller = desktopOverlay?.controller ?? "none";
7485 const loadProgress = useLoadingProgress(loading);
7586
7687 const handleSubmitUrl = useCallback(
7788 async (next: string) => {
7889 const api = ensureEnvironmentApi(threadRef.environmentId);
7990 try {
91 const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next);
8092 if (tabId && previewBridge) {
8193 // Drive the webview imperatively; `usePreviewBridge` mirrors the
8294 // resolved URL back to the server so other clients stay in sync.
83 await previewBridge.navigate(tabId, next);
84 rememberUrl(threadRef, next);
95 await previewBridge.navigate(tabId, resolvedUrl);
96 rememberUrl(threadRef, resolvedUrl);
8597 } else {
8698 await openPreviewSession({
8799 previewApi: api.preview,
88100 threadRef,
89 url: next,
101 url: resolvedUrl,
90102 applyServerSnapshot,
91103 rememberUrl,
92104 });
......@@ -127,6 +139,68 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
127139 void localApi.shell.openExternal(url).catch(() => undefined);
128140 }, [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
130204 const handlePickElement = useCallback(() => {
131205 if (!previewBridge || !tabId) return;
132206 if (pickActiveRef.current) {
......@@ -232,7 +306,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
232306
233307 return (
234308 <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"
236310 data-thread-key={scopedThreadKey(threadRef)}
237311 >
238312 <PreviewChromeRow
......@@ -248,6 +322,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
248322 onRefresh={handleRefresh}
249323 onSubmit={(next) => void handleSubmitUrl(next)}
250324 onOpenInBrowser={tabId ? handleOpenInBrowser : undefined}
325 onCapture={previewBridge && tabId ? handleCapture : undefined}
326 captureDisabled={!desktopOverlay || isUnreachable}
327 recording={tabId !== null && activeRecordingTabId === tabId}
251328 onPickElement={previewBridge && tabId ? handlePickElement : undefined}
252329 pickActive={pickActive}
253330 // Disable when there's no tab (nothing to pick on) OR the page
......@@ -268,13 +345,12 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
268345 }
269346 />
270347
271 <div className="relative flex-1 overflow-hidden">
348 <div className="relative min-h-0 flex-1 overflow-hidden">
272349 {tabId && snapshot ? (
273 <PreviewWebview
350 <BrowserSurfaceSlot
274351 key={tabId}
275 threadRef={threadRef}
276352 tabId={tabId}
277 initialUrl={url || null}
353 visible={visible && !isUnreachable}
278354 className="absolute inset-0 h-full w-full"
279355 />
280356 ) : (
......@@ -288,6 +364,11 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
288364 {snapshot && desktopOverlay ? (
289365 <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} />
290366 ) : 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}
291372 {navStatus._tag === "LoadFailed" ? (
292373 <div className="absolute inset-0 z-10 bg-background">
293374 <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";
44
55import { ensureEnvironmentApi } from "~/environmentApi";
66import type { EnvironmentId } from "@t3tools/contracts";
7import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
78
89export interface PreviewableServer extends DiscoveredLocalServer {
910 source: "scanner" | "configured" | "recent";
......@@ -42,11 +43,14 @@ export function useDiscoveredLocalServers(
4243 return useMemo(
4344 () =>
4445 mergeServers({
45 scanner: scannerSnapshot,
46 scanner: scannerSnapshot.map((server) => ({
47 ...server,
48 url: resolveDiscoveredServerUrl(input.environmentId, server.url),
49 })),
4650 configuredUrls: input.configuredUrls ?? [],
4751 recentlySeenUrls: input.recentlySeenUrls ?? [],
4852 }),
49 [scannerSnapshot, input.configuredUrls, input.recentlySeenUrls],
53 [input.environmentId, scannerSnapshot, input.configuredUrls, input.recentlySeenUrls],
5054 );
5155}
5256
apps/web/src/components/preview/usePreviewBridge.ts+4-15
......@@ -14,26 +14,14 @@ import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewState
1414import { previewBridge } from "./previewBridge";
1515
1616/**
17 * Owns the desktop tab lifecycle, mirrors low-latency button state into the
18 * store, and reflects bridge navigation events back to the server.
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.
17 * Mirrors low-latency desktop state into the store and reflects navigation
18 * events back to the server. Webview lifetime is owned by ElectronBrowserHost.
2319 */
2420export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void {
2521 const { threadRef, tabId } = input;
2622 const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState);
2723 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
3725 // One bridge subscription does both jobs (mirror state + forward to
3826 // server) so the desktop bridge keeps a single listener entry per tab.
3927 const lastReportedUrl = useRef<string | null>(null);
......@@ -45,7 +33,7 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str
4533 lastReportedKind.current = null;
4634 const unsubscribe = bridge.onStateChange((changedTabId, state) => {
4735 if (changedTabId !== tabId) return;
48 applyDesktopState(threadRef, projectDesktopState(state));
36 applyDesktopState(threadRef, tabId, projectDesktopState(state));
4937 const reported = buildReportInput({
5038 threadId: threadRef.threadId,
5139 tabId,
......@@ -68,6 +56,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl
6856 canGoForward: state.canGoForward,
6957 loading: state.navStatus.kind === "Loading",
7058 zoomFactor: state.zoomFactor,
59 controller: state.controller,
7160 };
7261}
7362
apps/web/src/components/preview/usePreviewSession.ts+1-1
......@@ -33,7 +33,7 @@ export function usePreviewSession(threadRef: ScopedThreadRef): void {
3333 // `updatedAt` ascending, so the last one is freshest.
3434 const serverSnapshot = result.sessions.at(-1) ?? null;
3535 if (serverSnapshot) {
36 applyServerSnapshot(threadRef, serverSnapshot);
36 for (const snapshot of result.sessions) applyServerSnapshot(threadRef, snapshot);
3737 return;
3838 }
3939 // 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";
1717import { getRouter } from "./router";
1818import { APP_DISPLAY_NAME } from "./branding";
1919import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
20import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
2021
2122// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
2223const history = isElectron ? createHashHistory() : createBrowserHistory();
......@@ -31,7 +32,12 @@ document.title = APP_DISPLAY_NAME;
3132
3233const 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
3642ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
3743 <React.StrictMode>
apps/web/src/previewStateStore.test.ts+23-1
......@@ -184,17 +184,39 @@ describe("previewStateStore (single-tab)", () => {
184184 createdAt: snapshot.updatedAt,
185185 snapshot,
186186 });
187 store.applyDesktopState(ref, {
187 store.applyDesktopState(ref, snapshot.tabId, {
188188 canGoBack: true,
189189 canGoForward: false,
190190 loading: false,
191191 zoomFactor: 1,
192 controller: "none",
192193 });
193194 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
194195 expect(state.desktopOverlay?.canGoBack).toBe(true);
195196 expect(state.snapshot?.canGoBack).toBe(false);
196197 });
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
198220 it("applyServerSnapshot null clears snapshot for a thread that had one", () => {
199221 const snapshot = makeSnapshot();
200222 const store = usePreviewStateStore.getState();
apps/web/src/previewStateStore.ts+104-24
......@@ -26,19 +26,26 @@ export interface DesktopPreviewOverlay {
2626 canGoForward: boolean;
2727 loading: boolean;
2828 zoomFactor: number;
29 controller: "human" | "agent" | "none";
2930}
3031
3132export interface ThreadPreviewState {
3233 snapshot: PreviewSessionSnapshot | null;
34 sessions: Record<string, PreviewSessionSnapshot>;
35 activeTabId: string | null;
3336 /** Bridge state takes precedence over `snapshot` for nav button enablement. */
3437 desktopOverlay: DesktopPreviewOverlay | null;
38 desktopByTabId: Record<string, DesktopPreviewOverlay>;
3539 /** Recently-visited URLs surfaced in the empty state. */
3640 recentlySeenUrls: string[];
3741}
3842
3943const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
4044 snapshot: null,
45 sessions: {},
46 activeTabId: null,
4147 desktopOverlay: null,
48 desktopByTabId: {},
4249 recentlySeenUrls: [] as string[],
4350});
4451
......@@ -46,7 +53,12 @@ export interface PreviewStateStoreState {
4653 byThreadKey: Record<string, ThreadPreviewState>;
4754 applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void;
4855 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;
5062 rememberUrl: (ref: ScopedThreadRef, url: string) => void;
5163 removeThread: (ref: ScopedThreadRef) => void;
5264}
......@@ -96,34 +108,64 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
96108 snapshot.navStatus._tag === "Idle"
97109 ? current.recentlySeenUrls
98110 : 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 };
100122 });
101123 break;
102124 case "failed":
103125 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 };
105140 return {
106141 ...current,
107 snapshot: {
108 ...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 },
142 sessions,
143 snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot,
118144 };
119145 });
120146 break;
121147 case "closed":
122148 nextByThread = updateThread(state, threadKey, (current) => {
123 // Only clear if the closed tab is the one we were tracking; the
124 // server may have multiple tabs per thread but we only render one.
125 if (current.snapshot && current.snapshot.tabId !== event.tabId) return current;
126 return { ...current, snapshot: null, desktopOverlay: null };
149 if (!current.sessions[event.tabId]) return current;
150 const { [event.tabId]: _closed, ...sessions } = current.sessions;
151 const { [event.tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;
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 };
127169 });
128170 break;
129171 }
......@@ -134,21 +176,59 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
134176 const threadKey = scopedThreadKey(ref);
135177 const nextByThread = updateThread(state, threadKey, (current) => {
136178 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 }
137189 const recentlySeenUrls =
138190 snapshot && snapshot.navStatus._tag !== "Idle"
139191 ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
140192 : 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 };
142201 });
143202 return { byThreadKey: nextByThread };
144203 }),
145 applyDesktopState: (ref, overlay) =>
204 applyDesktopState: (ref, tabId, overlay) =>
146205 set((state) => {
147206 const threadKey = scopedThreadKey(ref);
148 const nextByThread = updateThread(state, threadKey, (current) => ({
149 ...current,
150 desktopOverlay: overlay,
151 }));
207 const nextByThread = updateThread(state, threadKey, (current) => {
208 const desktopByTabId = { ...current.desktopByTabId };
209 if (overlay) desktopByTabId[tabId] = overlay;
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 });
152232 return { byThreadKey: nextByThread };
153233 }),
154234 rememberUrl: (ref, url) =>
apps/web/src/rightPanelStore.test.ts+42-1
......@@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";
44
55import {
66 selectActiveRightPanel,
7 selectActiveRightPanelSurface,
78 selectActiveRightPanelKindWithUrl,
9 selectThreadRightPanelState,
810 useRightPanelStore,
911} from "./rightPanelStore";
1012
......@@ -22,10 +24,13 @@ describe("rightPanelStore", () => {
2224 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBeNull();
2325 });
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", () => {
2628 useRightPanelStore.getState().open(refA, "plan");
2729 useRightPanelStore.getState().open(refA, "preview");
2830 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
31 expect(
32 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
33 ).toHaveLength(2);
2934 });
3035
3136 it("close clears the active panel", () => {
......@@ -67,4 +72,40 @@ describe("rightPanelStore", () => {
6772 useRightPanelStore.getState().close(refA);
6873 expect(useRightPanelStore.getState().byThreadKey).toEqual({});
6974 });
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 });
70111});
apps/web/src/rightPanelStore.ts+156-38
......@@ -1,13 +1,10 @@
11/**
2 * Per-thread arbiter for the right-side panel.
2 * Thread-scoped right-panel surface state.
33 *
4 * Three tenants share the same slot: the plan sidebar, the diff panel, and
5 * the preview panel. Only one is open at a time per thread; the choice is
6 * remembered across thread switches.
7 *
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.
4 * This is intentionally a shallow workspace model: it owns an ordered set of
5 * surface descriptors and the active surface, while each feature continues to
6 * own its durable resource state. Browser surfaces point at preview tab ids;
7 * singleton surfaces bridge the existing terminal, diff, and plan features.
118 */
129import { scopedThreadKey } from "@t3tools/client-runtime";
1310import type { ScopedThreadRef } from "@t3tools/contracts";
......@@ -16,35 +13,77 @@ import { createJSONStorage, persist } from "zustand/middleware";
1613
1714import { 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;
2017export 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 {
25 active: RightPanelKind | null;
28export interface ThreadRightPanelState {
29 activeSurfaceId: string | null;
30 surfaces: RightPanelSurface[];
2631}
2732
2833interface RightPanelStoreState {
2934 byThreadKey: Record<string, ThreadRightPanelState>;
3035 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;
3140 close: (ref: ScopedThreadRef) => void;
3241 toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void;
3342 removeThread: (ref: ScopedThreadRef) => void;
3443}
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
3674const updateThread = (
3775 byThreadKey: Record<string, ThreadRightPanelState>,
3876 threadKey: string,
39 next: ThreadRightPanelState,
77 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
4078): Record<string, ThreadRightPanelState> => {
41 const current = byThreadKey[threadKey];
42 if (current && current.active === next.active) return byThreadKey;
43 if (next.active === null) {
44 if (!current) return byThreadKey;
79 const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
80 const next = updater(current);
81 if (next.activeSurfaceId === null && next.surfaces.length === 0) {
82 if (!(threadKey in byThreadKey)) return byThreadKey;
4583 const { [threadKey]: _removed, ...rest } = byThreadKey;
4684 return rest;
4785 }
86 if (next === current) return byThreadKey;
4887 return { ...byThreadKey, [threadKey]: next };
4988};
5089
......@@ -54,21 +93,92 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
5493 byThreadKey: {},
5594 open: (ref, kind) =>
5695 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 }),
58160 })),
59161 close: (ref) =>
60162 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 })),
62167 })),
63168 toggle: (ref, kind) =>
64 set((state) => {
65 const threadKey = scopedThreadKey(ref);
66 const current = state.byThreadKey[threadKey]?.active ?? null;
67 const next: RightPanelKind | null = current === kind ? null : kind;
68 return {
69 byThreadKey: updateThread(state.byThreadKey, threadKey, { active: next }),
70 };
71 }),
169 set((state) => ({
170 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
171 const active = current.surfaces.find(
172 (surface) => surface.id === current.activeSurfaceId,
173 );
174 if (active?.kind === kind) return { ...current, activeSurfaceId: null };
175 if (kind === "preview") {
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 })),
72182 removeThread: (ref) =>
73183 set((state) => {
74184 const threadKey = scopedThreadKey(ref);
......@@ -79,7 +189,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
79189 }),
80190 {
81191 name: RIGHT_PANEL_STORAGE_KEY,
82 version: 1,
192 version: 2,
83193 storage: createJSONStorage(() =>
84194 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
85195 ),
......@@ -88,22 +198,30 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
88198 ),
89199);
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
91209export function selectActiveRightPanel(
92210 byThreadKey: Record<string, ThreadRightPanelState>,
93211 ref: ScopedThreadRef | null | undefined,
94212): RightPanelKind | null {
95 if (!ref) return null;
96 return byThreadKey[scopedThreadKey(ref)]?.active ?? null;
213 const state = selectThreadRightPanelState(byThreadKey, ref);
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;
97223}
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 */
107225export function selectActiveRightPanelKindWithUrl(
108226 byThreadKey: Record<string, ThreadRightPanelState>,
109227 ref: ScopedThreadRef | null | undefined,
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+15-229
......@@ -1,149 +1,20 @@
11import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
2import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
2import { useEffect, useMemo } from "react";
33
44import ChatView from "../components/ChatView";
55import { threadHasStarted } from "../components/ChatView.logic";
6import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
7import {
8 DiffPanelHeaderSkeleton,
9 DiffPanelLoadingState,
10 DiffPanelShell,
11 type DiffPanelMode,
12} from "../components/DiffPanelShell";
136import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
14import {
15 type DiffRouteSearch,
16 parseDiffRouteSearch,
17 stripDiffSearchParams,
18} from "../diffRouteSearch";
19import { useMediaQuery } from "../hooks/useMediaQuery";
20import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
7import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch";
218import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";
229import { createThreadSelectorByRef } from "../storeSelectors";
23import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
24import { RightPanelSheet } from "../components/RightPanelSheet";
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};
10import { resolveThreadRouteRef } from "../threadRoutes";
11import { SidebarInset } from "~/components/ui/sidebar";
14012
14113function ChatThreadRouteView() {
14214 const navigate = useNavigate();
14315 const threadRef = Route.useParams({
14416 select: (params) => resolveThreadRouteRef(params),
14517 });
146 const search = Route.useSearch();
14718 const bootstrapComplete = useStore(
14819 (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete,
14920 );
......@@ -159,120 +30,35 @@ function ChatThreadRouteView() {
15930 threadRef ? store.getDraftThreadByRef(threadRef) : null,
16031 );
16132 const environmentHasDraftThreads = useComposerDraftStore((store) => {
162 if (!threadRef) {
163 return false;
164 }
33 if (!threadRef) return false;
16534 return store.hasDraftThreadsInEnvironment(threadRef.environmentId);
16635 });
16736 const routeThreadExists = threadExists || draftThreadExists;
16837 const serverThreadStarted = threadHasStarted(serverThread);
16938 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
21740 useEffect(() => {
218 if (!threadRef || !bootstrapComplete) {
219 return;
220 }
221
41 if (!threadRef || !bootstrapComplete) return;
22242 if (!routeThreadExists && environmentHasAnyThreads) {
22343 void navigate({ to: "/", replace: true });
22444 }
22545 }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]);
22646
22747 useEffect(() => {
228 if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) {
229 return;
230 }
48 if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) return;
23149 finalizePromotedDraftThreadByRef(threadRef);
23250 }, [draftThread?.promotedTo, serverThreadStarted, threadRef]);
23351
234 if (!threadRef || !bootstrapComplete || !routeThreadExists) {
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 }
52 if (!threadRef || !bootstrapComplete || !routeThreadExists) return null;
26153
26254 return (
263 <>
264 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
265 <ChatView
266 environmentId={threadRef.environmentId}
267 threadId={threadRef.threadId}
268 onDiffPanelOpen={markDiffOpened}
269 routeKind="server"
270 />
271 </SidebarInset>
272 <RightPanelSheet open={diffOpen} onClose={closeDiff}>
273 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}
274 </RightPanelSheet>
275 </>
55 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground md:h-dvh">
56 <ChatView
57 environmentId={threadRef.environmentId}
58 threadId={threadRef.threadId}
59 routeKind="server"
60 />
61 </SidebarInset>
27662 );
27763}
27864
apps/web/src/session-logic.test.ts+61
......@@ -934,6 +934,67 @@ describe("deriveWorkLogEntries", () => {
934934 expect(entry?.toolLifecycleStatus).toBe("completed");
935935 });
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
937998 it("unwraps PowerShell command wrappers for displayed command text", () => {
938999 const activities: OrchestrationThreadActivity[] = [
9391000 makeActivity({
apps/web/src/session-logic.ts+9
......@@ -71,6 +71,7 @@ export interface WorkLogEntry {
7171 changedFiles?: ReadonlyArray<string>;
7272 tone: "thinking" | "tool" | "info" | "error";
7373 toolTitle?: string;
74 toolData?: unknown;
7475 itemType?: ToolLifecycleItemType;
7576 requestKind?: PendingApproval["requestKind"];
7677 /** From runtime item / task payload `status` when present (e.g. tool.updated). */
......@@ -734,6 +735,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
734735 if (title) {
735736 entry.toolTitle = title;
736737 }
738 if (itemType === "mcp_tool_call") {
739 const data = asRecord(payload?.data);
740 if (data?.item !== undefined) {
741 entry.toolData = data.item;
742 }
743 }
737744 if (itemType) {
738745 entry.itemType = itemType;
739746 }
......@@ -811,6 +818,7 @@ function mergeDerivedWorkLogEntries(
811818 const collapseKey = next.collapseKey ?? previous.collapseKey;
812819 const toolCallId = next.toolCallId ?? previous.toolCallId;
813820 const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
821 const toolData = next.toolData ?? previous.toolData;
814822 return {
815823 ...previous,
816824 ...next,
......@@ -824,6 +832,7 @@ function mergeDerivedWorkLogEntries(
824832 ...(collapseKey ? { collapseKey } : {}),
825833 ...(toolCallId ? { toolCallId } : {}),
826834 ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}),
835 ...(toolData !== undefined ? { toolData } : {}),
827836 };
828837}
829838
packages/contracts/src/ipc.ts+39-1
......@@ -451,6 +451,7 @@ export interface DesktopPreviewTabState {
451451 canGoForward: boolean;
452452 /** Current zoom factor (1.0 = 100%). */
453453 zoomFactor: number;
454 controller: "human" | "agent" | "none";
454455 updatedAt: string;
455456}
456457
......@@ -477,6 +478,32 @@ export interface DesktopPreviewWebviewConfig {
477478 preloadUrl: string | null;
478479}
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
480507/**
481508 * Single stack frame captured by react-grab's `getElementContext`. We surface
482509 * the source file/line so coding agents can jump straight to the JSX that
......@@ -672,7 +699,7 @@ export interface DesktopPreviewBridge {
672699 * `getPickPreloadPath`) so adding a new field here only requires touching
673700 * the contract + main, not the renderer's mount logic.
674701 */
675 getPreviewConfig: () => Promise<DesktopPreviewWebviewConfig>;
702 getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>;
676703 /**
677704 * Activate the in-page element picker for the given tab. Resolves with
678705 * the picked payload, or `null` when the user cancels (Escape / nav). The
......@@ -681,6 +708,17 @@ export interface DesktopPreviewBridge {
681708 pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>;
682709 /** Cancel an in-flight preview annotation session. */
683710 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 };
684722 automation: {
685723 status: (tabId: string) => Promise<PreviewAutomationStatus>;
686724 snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>;
packages/contracts/src/previewAutomation.ts+330-37
......@@ -3,10 +3,19 @@ import { Schema } from "effect";
33import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
44import { 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));
714const OptionalTimeoutMs = Schema.optional(
8 Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThanOrEqualTo(60_000)),
9);
15 Schema.Int.check(Schema.isGreaterThan(0))
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
1120export const PreviewAutomationOperation = Schema.Literals([
1221 "status",
......@@ -19,6 +28,8 @@ export const PreviewAutomationOperation = Schema.Literals([
1928 "scroll",
2029 "evaluate",
2130 "waitFor",
31 "recordingStart",
32 "recordingStop",
2233]);
2334export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type;
2435
......@@ -33,66 +44,294 @@ export const PreviewAutomationStatus = Schema.Struct({
3344export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type;
3445
3546export const PreviewAutomationOpenInput = Schema.Struct({
36 url: Schema.optional(BoundedUrl),
37 show: Schema.optional(Schema.Boolean),
38 reuseExistingTab: Schema.optional(Schema.Boolean),
47 url: Schema.optional(BoundedUrl).annotate({
48 description:
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.",
3965});
4066export 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
4298export const PreviewAutomationNavigateInput = Schema.Struct({
43 url: BoundedUrl,
44 readiness: Schema.optional(Schema.Literals(["load", "domContentLoaded", "none"])),
99 url: Schema.optional(BoundedUrl).annotate({
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 }),
45121 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 });
47134export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type;
48135
49export const PreviewAutomationClickInput = Schema.Union([
50 Schema.Struct({
51 selector: TrimmedNonEmptyString,
52 timeoutMs: OptionalTimeoutMs,
136const Locator = TrimmedNonEmptyString.annotate({
137 description:
138 "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",
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.",
53150 }),
54 Schema.Struct({
55 x: Schema.Number,
56 y: Schema.Number,
57 timeoutMs: OptionalTimeoutMs,
151 locator: Schema.optional(Locator).annotate({
152 description:
153 "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",
58154 }),
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 });
60182export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type;
61183
62184export const PreviewAutomationTypeInput = Schema.Struct({
63 text: Schema.String,
64 selector: Schema.optional(TrimmedNonEmptyString),
65 clear: Schema.optional(Schema.Boolean),
185 text: Schema.String.annotate({ description: "Literal text to insert." }),
186 selector: Schema.optional(LegacySelector).annotate({
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 ),
66198 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 });
68211export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type;
69212
70213export const PreviewAutomationPressInput = Schema.Struct({
71 key: TrimmedNonEmptyString,
72 modifiers: Schema.optional(Schema.Array(Schema.Literals(["Alt", "Control", "Meta", "Shift"]))),
73});
214 key: Schema.String.check(Schema.isTrimmed())
215 .check(
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." });
74231export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type;
75232
76233export const PreviewAutomationScrollInput = Schema.Struct({
77 deltaX: Schema.optional(Schema.Number),
78 deltaY: Schema.optional(Schema.Number),
79 selector: Schema.optional(TrimmedNonEmptyString),
80});
234 deltaX: Schema.optional(
235 Schema.Finite.annotate({
236 description: "Horizontal scroll delta in CSS pixels. Positive scrolls right. Defaults to 0.",
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 });
81265export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type;
82266
83267export const PreviewAutomationEvaluateInput = Schema.Struct({
84 expression: TrimmedNonEmptyString.check(Schema.isMaxLength(64_000)),
85 awaitPromise: Schema.optional(Schema.Boolean),
86 returnByValue: Schema.optional(Schema.Boolean),
268 expression: Schema.String.check(Schema.isTrimmed())
269 .check(
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.",
87292});
88293export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type;
89294
90295export const PreviewAutomationWaitForInput = Schema.Struct({
91 selector: Schema.optional(TrimmedNonEmptyString),
92 text: Schema.optional(TrimmedNonEmptyString),
93 urlIncludes: Schema.optional(TrimmedNonEmptyString),
296 selector: Schema.optional(LegacySelector).annotate({
297 description: "Legacy CSS selector that must match an element. Prefer locator.",
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." }),
94315 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 });
96335export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type;
97336
98337export const PreviewAutomationElement = Schema.Struct({
......@@ -107,6 +346,34 @@ export const PreviewAutomationElement = Schema.Struct({
107346});
108347export 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
110377export const PreviewAutomationSnapshot = Schema.Struct({
111378 url: Schema.String,
112379 title: Schema.String,
......@@ -114,6 +381,9 @@ export const PreviewAutomationSnapshot = Schema.Struct({
114381 visibleText: Schema.String,
115382 interactiveElements: Schema.Array(PreviewAutomationElement),
116383 accessibilityTree: Schema.Unknown,
384 consoleEntries: Schema.Array(PreviewAutomationConsoleEntry),
385 networkEntries: Schema.Array(PreviewAutomationNetworkEntry),
386 actionTimeline: Schema.Array(PreviewAutomationActionEvent),
117387 screenshot: Schema.Struct({
118388 mimeType: Schema.Literal("image/png"),
119389 data: Schema.String,
......@@ -123,6 +393,23 @@ export const PreviewAutomationSnapshot = Schema.Struct({
123393});
124394export 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
126413export const PreviewAutomationOwner = Schema.Struct({
127414 clientId: TrimmedNonEmptyString,
128415 environmentId: EnvironmentId,
......@@ -183,6 +470,11 @@ export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<Previ
183470 { message: Schema.String },
184471) {}
185472
473export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass<PreviewAutomationControlInterruptedError>()(
474 "PreviewAutomationControlInterruptedError",
475 { message: Schema.String },
476) {}
477
186478export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()(
187479 "PreviewAutomationExecutionError",
188480 { message: Schema.String, detail: Schema.optional(Schema.Unknown) },
......@@ -204,6 +496,7 @@ export const PreviewAutomationError = Schema.Union([
204496 PreviewAutomationUnsupportedClientError,
205497 PreviewAutomationTabNotFoundError,
206498 PreviewAutomationTimeoutError,
499 PreviewAutomationControlInterruptedError,
207500 PreviewAutomationExecutionError,
208501 PreviewAutomationInvalidSelectorError,
209502 PreviewAutomationResultTooLargeError,
......@@ -213,7 +506,7 @@ export type PreviewAutomationError = typeof PreviewAutomationError.Type;
213506export const PreviewUrlResolution = Schema.Struct({
214507 requestedUrl: Schema.String,
215508 resolvedUrl: Schema.String,
216 resolutionKind: Schema.Literal("direct"),
509 resolutionKind: Schema.Literals(["direct", "direct-private-network"]),
217510 environmentId: EnvironmentId,
218511});
219512export type PreviewUrlResolution = typeof PreviewUrlResolution.Type;
pnpm-lock.yaml+3
......@@ -128,6 +128,9 @@ importers:
128128 electron-updater:
129129 specifier: ^6.6.2
130130 version: 6.8.3
131 playwright-core:
132 specifier: 1.60.0
133 version: 1.60.0
131134 react-grab:
132135 specifier: ^0.1.32
133136 version: 0.1.44(react@19.2.6)