authorgravatar for 51714798+juliusmarminge@users.noreply.github.comJulius Marminge <51714798+juliusmarminge@users.noreply.github.com> 2026-06-12 16:40:00-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
logdd739c880d6c912130cac005ea773ae386560460
tree5f148327850a26504ac02dce6790fdc124d42c9a
parent6c6740e381aa1787492a8366f9020304ba322700

Port browser preview annotations to desktop

- Add IPC and runtime plumbing for preview annotation theming - Generate and ship annotation CSS for the desktop overlay - Add pointer and artifact handling for browser preview interactions

72 files changed, 3633 insertions(+), 507 deletions(-)

apps/desktop/package.json+1
...@@ -29,6 +29,7 @@...@@ -29,6 +29,7 @@
29 "@types/node": "catalog:",29 "@types/node": "catalog:",
30 "cross-env": "^10.1.0",30 "cross-env": "^10.1.0",
31 "electron-builder": "26.8.1",31 "electron-builder": "26.8.1",
32 "tailwindcss": "^4.0.0",
32 "vite-plus": "catalog:"33 "vite-plus": "catalog:"
33 },34 },
34 "productName": "T3 Code (Alpha)"35 "productName": "T3 Code (Alpha)"
apps/desktop/scripts/build-preview-annotation-css.mjs created+40
...@@ -0,0 +1,40 @@
1import { readFile, writeFile } from "node:fs/promises";
2import { createRequire } from "node:module";
3import { dirname, join } from "node:path";
4import { fileURLToPath } from "node:url";
5
6import { compile } from "tailwindcss";
7
8const directory = dirname(fileURLToPath(import.meta.url));
9const appRoot = join(directory, "..");
10const sourcePath = join(appRoot, "src", "preview-annotation.css");
11const preloadPath = join(appRoot, "src", "preview-pick-preload.ts");
12const outputPath = join(appRoot, "src", "preview-annotation-styles.generated.ts");
13const require = createRequire(import.meta.url);
14const tailwindRoot = dirname(require.resolve("tailwindcss/package.json"));
15
16const [annotationSource, preloadSource, themeSource, preflightSource] = await Promise.all([
17 readFile(sourcePath, "utf8"),
18 readFile(preloadPath, "utf8"),
19 readFile(join(tailwindRoot, "theme.css"), "utf8"),
20 readFile(join(tailwindRoot, "preflight.css"), "utf8"),
21]);
22
23const candidates = new Set(
24 Array.from(preloadSource.matchAll(/!?-?[A-Za-z0-9_:@/.[\]()%,-]+/g), (match) => match[0]),
25);
26const compilerInput = [
27 themeSource,
28 preflightSource,
29 annotationSource.replace('@import "tailwindcss";', "@tailwind utilities;"),
30].join("\n");
31const compiler = await compile(compilerInput, { base: appRoot });
32const css = compiler.build([...candidates]);
33const encodedCss = `'${css
34 .replaceAll("\\", "\\\\")
35 .replaceAll("'", "\\'")
36 .replaceAll("\r", "\\r")
37 .replaceAll("\n", "\\n")}'`;
38const moduleSource = `// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.\nexport const previewAnnotationStyles =\n ${encodedCss};\n`;
39
40await writeFile(outputPath, moduleSource);
apps/desktop/src/app/DesktopEnvironment.test.ts+2
...@@ -59,6 +59,7 @@ describe("DesktopEnvironment", () => {...@@ -59,6 +59,7 @@ describe("DesktopEnvironment", () => {
59 assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json");59 assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json");
60 assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json");60 assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json");
61 assert.equal(environment.logDir, "/tmp/t3/dev/logs");61 assert.equal(environment.logDir, "/tmp/t3/dev/logs");
62 assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts");
62 assert.equal(environment.rootDir, "/repo");63 assert.equal(environment.rootDir, "/repo");
63 assert.equal(environment.appRoot, "/repo");64 assert.equal(environment.appRoot, "/repo");
64 assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");65 assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");
...@@ -89,6 +90,7 @@ describe("DesktopEnvironment", () => {...@@ -89,6 +90,7 @@ describe("DesktopEnvironment", () => {
89 assert.equal(environment.isDevelopment, false);90 assert.equal(environment.isDevelopment, false);
90 assert.equal(environment.stateDir, "/tmp/t3/userdata");91 assert.equal(environment.stateDir, "/tmp/t3/userdata");
91 assert.equal(environment.logDir, "/tmp/t3/userdata/logs");92 assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
93 assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
92 assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");94 assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
93 }),95 }),
94 );96 );
apps/desktop/src/app/DesktopEnvironment.ts+2
...@@ -49,6 +49,7 @@ export interface DesktopEnvironmentShape {...@@ -49,6 +49,7 @@ export interface DesktopEnvironmentShape {
49 readonly savedEnvironmentRegistryPath: string;49 readonly savedEnvironmentRegistryPath: string;
50 readonly serverSettingsPath: string;50 readonly serverSettingsPath: string;
51 readonly logDir: string;51 readonly logDir: string;
52 readonly browserArtifactsDir: string;
52 readonly rootDir: string;53 readonly rootDir: string;
53 readonly appRoot: string;54 readonly appRoot: string;
54 readonly backendEntryPath: string;55 readonly backendEntryPath: string;
...@@ -183,6 +184,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (...@@ -183,6 +184,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
183 savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"),184 savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"),
184 serverSettingsPath: path.join(stateDir, "settings.json"),185 serverSettingsPath: path.join(stateDir, "settings.json"),
185 logDir: path.join(stateDir, "logs"),186 logDir: path.join(stateDir, "logs"),
187 browserArtifactsDir: path.join(stateDir, "browser-artifacts"),
186 rootDir,188 rootDir,
187 appRoot,189 appRoot,
188 backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"),190 backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"),
apps/desktop/src/ipc/DesktopIpcHandlers.ts+4
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
22
3import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
4import { previewViewManager } from "../preview-view-manager.ts";
3import * as DesktopIpc from "./DesktopIpc.ts";5import * as DesktopIpc from "./DesktopIpc.ts";
4import {6import {
5 clearCloudAuthToken,7 clearCloudAuthToken,
...@@ -52,6 +54,8 @@ import { previewMethods } from "./methods/preview.ts";...@@ -52,6 +54,8 @@ import { previewMethods } from "./methods/preview.ts";
5254
53export const installDesktopIpcHandlers = Effect.gen(function* () {55export const installDesktopIpcHandlers = Effect.gen(function* () {
54 const ipc = yield* DesktopIpc.DesktopIpc;56 const ipc = yield* DesktopIpc.DesktopIpc;
57 const environment = yield* DesktopEnvironment.DesktopEnvironment;
58 previewViewManager.configureArtifactDirectory(environment.browserArtifactsDir);
5559
56 yield* ipc.handleSync(getAppBranding);60 yield* ipc.handleSync(getAppBranding);
57 yield* ipc.handleSync(getLocalEnvironmentBootstrap);61 yield* ipc.handleSync(getLocalEnvironmentBootstrap);
apps/desktop/src/ipc/channels.ts+4
...@@ -54,9 +54,12 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";...@@ -54,9 +54,12 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
54export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";54export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
55export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";55export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
56export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";56export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
57export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme";
57export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";58export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
58export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";59export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
59export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot";60export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot";
61export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact";
62export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact";
60export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status";63export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status";
61export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot";64export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot";
62export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click";65export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click";
...@@ -70,3 +73,4 @@ export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop";...@@ -70,3 +73,4 @@ export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop";
70export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";73export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
71export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";74export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
72export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";75export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
76export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";
apps/desktop/src/ipc/methods/preview.ts+64
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off
2import * as Data from "effect/Data";2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts";
4import { BrowserWindow } from "electron";5import { BrowserWindow } from "electron";
5import { pathToFileURL } from "node:url";6import { pathToFileURL } from "node:url";
67
...@@ -25,6 +26,14 @@ previewViewManager.onRecordingFrame((frame) => {...@@ -25,6 +26,14 @@ previewViewManager.onRecordingFrame((frame) => {
25 }26 }
26});27});
2728
29previewViewManager.onPointerEvent((event) => {
30 for (const window of BrowserWindow.getAllWindows()) {
31 if (!window.isDestroyed()) {
32 window.webContents.send(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event);
33 }
34 }
35});
36
28const tabIdFrom = (raw: unknown): string => {37const tabIdFrom = (raw: unknown): string => {
29 if (typeof raw !== "object" || raw === null || !("tabId" in raw)) {38 if (typeof raw !== "object" || raw === null || !("tabId" in raw)) {
30 throw new Error("preview tab id is required");39 throw new Error("preview tab id is required");
...@@ -43,6 +52,44 @@ const inputFrom = (raw: unknown): unknown => {...@@ -43,6 +52,44 @@ const inputFrom = (raw: unknown): unknown => {
43 return raw.input;52 return raw.input;
44};53};
4554
55const annotationThemeFrom = (raw: unknown): DesktopPreviewAnnotationTheme => {
56 if (typeof raw !== "object" || raw === null || !("theme" in raw)) {
57 throw new Error("preview annotation theme is required");
58 }
59 const theme = raw.theme;
60 if (typeof theme !== "object" || theme === null) {
61 throw new Error("preview annotation theme must be an object");
62 }
63 const record = theme as Record<string, unknown>;
64 const stringKeys = [
65 "radius",
66 "background",
67 "foreground",
68 "popover",
69 "popoverForeground",
70 "primary",
71 "primaryForeground",
72 "muted",
73 "mutedForeground",
74 "accent",
75 "accentForeground",
76 "border",
77 "input",
78 "ring",
79 "fontSans",
80 "fontMono",
81 ] as const;
82 for (const key of stringKeys) {
83 if (typeof record[key] !== "string" || record[key].length === 0) {
84 throw new Error(`preview annotation theme ${key} must be a non-empty string`);
85 }
86 }
87 if (record["colorScheme"] !== "light" && record["colorScheme"] !== "dark") {
88 throw new Error("preview annotation theme colorScheme must be light or dark");
89 }
90 return record as unknown as DesktopPreviewAnnotationTheme;
91};
92
46class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{93class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{
47 readonly cause: unknown;94 readonly cause: unknown;
48}> {}95}> {}
...@@ -117,6 +164,9 @@ export const previewMethods = [...@@ -117,6 +164,9 @@ export const previewMethods = [
117 preloadUrl: pathToFileURL(preloadPath).href,164 preloadUrl: pathToFileURL(preloadPath).href,
118 };165 };
119 }),166 }),
167 method(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, (raw) =>
168 previewViewManager.setAnnotationTheme(annotationThemeFrom(raw)),
169 ),
120 method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) =>170 method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) =>
121 previewViewManager.pickElement(tabIdFrom(raw)),171 previewViewManager.pickElement(tabIdFrom(raw)),
122 ),172 ),
...@@ -126,6 +176,20 @@ export const previewMethods = [...@@ -126,6 +176,20 @@ export const previewMethods = [
126 method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) =>176 method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) =>
127 previewViewManager.captureScreenshot(tabIdFrom(raw)),177 previewViewManager.captureScreenshot(tabIdFrom(raw)),
128 ),178 ),
179 method(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, (raw) => {
180 const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null;
181 if (typeof path !== "string" || path.trim().length === 0) {
182 throw new Error("preview artifact path is required");
183 }
184 return previewViewManager.revealArtifact(path);
185 }),
186 method(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, (raw) => {
187 const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null;
188 if (typeof path !== "string" || path.trim().length === 0) {
189 throw new Error("preview artifact path is required");
190 }
191 return previewViewManager.copyArtifactToClipboard(path);
192 }),
129 method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) =>193 method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) =>
130 previewViewManager.automationStatus(tabIdFrom(raw)),194 previewViewManager.automationStatus(tabIdFrom(raw)),
131 ),195 ),
apps/desktop/src/preload.ts+16
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type {1import type {
2 DesktopBridge,2 DesktopBridge,
3 DesktopPreviewPointerEvent,
3 DesktopPreviewRecordingFrame,4 DesktopPreviewRecordingFrame,
4 DesktopPreviewTabState,5 DesktopPreviewTabState,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
...@@ -165,11 +166,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -165,11 +166,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
165 clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),166 clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),
166 getPreviewConfig: (environmentId) =>167 getPreviewConfig: (environmentId) =>
167 ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }),168 ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }),
169 setAnnotationTheme: (theme) =>
170 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }),
168 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),171 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
169 cancelPickElement: (tabId) =>172 cancelPickElement: (tabId) =>
170 ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }),173 ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }),
171 captureScreenshot: (tabId) =>174 captureScreenshot: (tabId) =>
172 ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }),175 ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }),
176 revealArtifact: (path) =>
177 ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }),
178 copyArtifactToClipboard: (path) =>
179 ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }),
173 recording: {180 recording: {
174 startScreencast: (tabId) =>181 startScreencast: (tabId) =>
175 ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }),182 ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }),
...@@ -222,5 +229,14 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -222,5 +229,14 @@ contextBridge.exposeInMainWorld("desktopBridge", {
222 return () =>229 return () =>
223 ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener);230 ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener);
224 },231 },
232 onPointerEvent: (listener) => {
233 const wrappedListener = (_event: Electron.IpcRendererEvent, pointerEvent: unknown) => {
234 if (typeof pointerEvent !== "object" || pointerEvent === null) return;
235 listener(pointerEvent as DesktopPreviewPointerEvent);
236 };
237 ipcRenderer.on(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener);
238 return () =>
239 ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener);
240 },
225 },241 },
226} satisfies DesktopBridge);242} satisfies DesktopBridge);
apps/desktop/src/preview-annotation-styles.generated.ts created+3
...@@ -0,0 +1,3 @@
1// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.
2export const previewAnnotationStyles =
3 '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "<length>";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n';
apps/desktop/src/preview-annotation.css created+68
...@@ -0,0 +1,68 @@
1@import "tailwindcss";
2
3@theme inline {
4 --font-sans: var(--t3-font-sans);
5 --font-mono: var(--t3-font-mono);
6 --color-background: var(--t3-background);
7 --color-foreground: var(--t3-foreground);
8 --color-popover: var(--t3-popover);
9 --color-popover-foreground: var(--t3-popover-foreground);
10 --color-primary: var(--t3-primary);
11 --color-primary-foreground: var(--t3-primary-foreground);
12 --color-muted: var(--t3-muted);
13 --color-muted-foreground: var(--t3-muted-foreground);
14 --color-accent: var(--t3-accent);
15 --color-accent-foreground: var(--t3-accent-foreground);
16 --color-border: var(--t3-border);
17 --color-input: var(--t3-input);
18 --color-ring: var(--t3-ring);
19 --radius-sm: calc(var(--t3-radius) - 4px);
20 --radius-md: calc(var(--t3-radius) - 2px);
21 --radius-lg: var(--t3-radius);
22 --radius-xl: calc(var(--t3-radius) + 4px);
23 --radius-2xl: calc(var(--t3-radius) + 8px);
24}
25
26:host {
27 --t3-font-sans:
28 "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
29 sans-serif;
30 --t3-font-mono:
31 "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;
32 --t3-radius: 0.625rem;
33 --t3-background: white;
34 --t3-foreground: oklch(0.269 0 0);
35 --t3-popover: white;
36 --t3-popover-foreground: oklch(0.269 0 0);
37 --t3-primary: oklch(0.488 0.217 264);
38 --t3-primary-foreground: white;
39 --t3-muted: rgb(0 0 0 / 4%);
40 --t3-muted-foreground: oklch(0.556 0 0);
41 --t3-accent: rgb(0 0 0 / 4%);
42 --t3-accent-foreground: oklch(0.269 0 0);
43 --t3-border: rgb(0 0 0 / 8%);
44 --t3-input: rgb(0 0 0 / 10%);
45 --t3-ring: oklch(0.488 0.217 264);
46 color: var(--t3-foreground);
47 font-family: var(--t3-font-sans);
48}
49
50* {
51 box-sizing: border-box;
52 border-color: var(--t3-border);
53}
54
55button,
56input,
57select,
58textarea {
59 font: inherit;
60}
61
62button:focus-visible,
63input:focus-visible,
64select:focus-visible,
65textarea:focus-visible {
66 outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);
67 outline-offset: 1px;
68}
apps/desktop/src/preview-pick-preload.ts+276-139
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
2import { ipcRenderer } from "electron";2import { ipcRenderer } from "electron";
3import { getElementContext } from "react-grab/primitives";3import { getElementContext } from "react-grab/primitives";
4import type {4import type {
5 DesktopPreviewAnnotationTheme,
5 PickedElementPayload,6 PickedElementPayload,
6 PickedElementStackFrame,7 PickedElementStackFrame,
7 PreviewAnnotationPayload,8 PreviewAnnotationPayload,
...@@ -12,16 +13,18 @@ import type {...@@ -12,16 +13,18 @@ import type {
12 PreviewAnnotationStyleChange,13 PreviewAnnotationStyleChange,
13} from "@t3tools/contracts";14} from "@t3tools/contracts";
1415
16import { previewAnnotationStyles } from "./preview-annotation-styles.generated.ts";
17
15const START_PICK_CHANNEL = "preview:start-pick";18const START_PICK_CHANNEL = "preview:start-pick";
16const CANCEL_PICK_CHANNEL = "preview:cancel-pick";19const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
17const ELEMENT_PICKED_CHANNEL = "preview:element-picked";20const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
18const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";21const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
22const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
19const HUMAN_INPUT_CHANNEL = "preview:human-input";23const HUMAN_INPUT_CHANNEL = "preview:human-input";
20const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";24const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
21const Z_INDEX_OVERLAY = 2147483646;25const Z_INDEX_OVERLAY = 2147483646;
22const ACCENT = "#7c3aed";26const PRIMARY = "var(--t3-primary)";
23const ACCENT_FILL = "rgba(124,58,237,0.12)";27const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)";
24const BLUE = "#2563eb";
25const MAX_MARQUEE_ELEMENTS = 20;28const MAX_MARQUEE_ELEMENTS = 20;
26const CONTENT_LAYER_Z_INDEX = 1;29const CONTENT_LAYER_Z_INDEX = 1;
27const CHROME_LAYER_Z_INDEX = 10;30const CHROME_LAYER_Z_INDEX = 10;
...@@ -38,17 +41,63 @@ interface SelectedElement {...@@ -38,17 +41,63 @@ interface SelectedElement {
3841
39interface AnnotationSession {42interface AnnotationSession {
40 teardown: (notifyMain: boolean) => void;43 teardown: (notifyMain: boolean) => void;
44 applyTheme: (theme: DesktopPreviewAnnotationTheme) => void;
41}45}
4246
43let activeSession: AnnotationSession | null = null;47let activeSession: AnnotationSession | null = null;
44let idSequence = 0;48let idSequence = 0;
49let annotationTheme: DesktopPreviewAnnotationTheme | null = null;
50
51const applyAnnotationTheme = (
52 host: HTMLElement,
53 theme: DesktopPreviewAnnotationTheme | null,
54): void => {
55 if (!theme) return;
56 host.style.colorScheme = theme.colorScheme;
57 const variables = {
58 "--t3-radius": theme.radius,
59 "--t3-background": theme.background,
60 "--t3-foreground": theme.foreground,
61 "--t3-popover": theme.popover,
62 "--t3-popover-foreground": theme.popoverForeground,
63 "--t3-primary": theme.primary,
64 "--t3-primary-foreground": theme.primaryForeground,
65 "--t3-muted": theme.muted,
66 "--t3-muted-foreground": theme.mutedForeground,
67 "--t3-accent": theme.accent,
68 "--t3-accent-foreground": theme.accentForeground,
69 "--t3-border": theme.border,
70 "--t3-input": theme.input,
71 "--t3-ring": theme.ring,
72 "--t3-font-sans": theme.fontSans,
73 "--t3-font-mono": theme.fontMono,
74 };
75 for (const [name, value] of Object.entries(variables)) {
76 host.style.setProperty(name, value);
77 }
78};
79
80const reportHumanPointerInput = (event: PointerEvent): void => {
81 if (!event.isTrusted) return;
82 ipcRenderer.send(HUMAN_INPUT_CHANNEL, {
83 kind: "pointer",
84 x: event.clientX,
85 y: event.clientY,
86 button: event.button,
87 });
88};
4589
46const reportHumanInput = (event: Event): void => {90const reportHumanKeyInput = (event: KeyboardEvent): void => {
47 if (event.isTrusted) ipcRenderer.send(HUMAN_INPUT_CHANNEL);91 if (!event.isTrusted) return;
92 ipcRenderer.send(HUMAN_INPUT_CHANNEL, {
93 kind: "key",
94 key: event.key,
95 code: event.code,
96 });
48};97};
4998
50window.addEventListener("pointerdown", reportHumanInput, true);99window.addEventListener("pointerdown", reportHumanPointerInput, true);
51window.addEventListener("keydown", reportHumanInput, true);100window.addEventListener("keydown", reportHumanKeyInput, true);
52101
53const nextId = (prefix: string): string => {102const nextId = (prefix: string): string => {
54 idSequence += 1;103 idSequence += 1;
...@@ -76,13 +125,15 @@ const normalizeRect = (...@@ -76,13 +125,15 @@ const normalizeRect = (
76125
77const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3;126const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3;
78127
79function unionRects(rects: ReadonlyArray<PreviewAnnotationRect>): PreviewAnnotationRect | null {128function unionRects(
129 rects: ReadonlyArray<PreviewAnnotationRect>,
130 padding = 20,
131): PreviewAnnotationRect | null {
80 if (rects.length === 0) return null;132 if (rects.length === 0) return null;
81 const left = Math.min(...rects.map((rect) => rect.x));133 const left = Math.min(...rects.map((rect) => rect.x));
82 const top = Math.min(...rects.map((rect) => rect.y));134 const top = Math.min(...rects.map((rect) => rect.y));
83 const right = Math.max(...rects.map((rect) => rect.x + rect.width));135 const right = Math.max(...rects.map((rect) => rect.x + rect.width));
84 const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));136 const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
85 const padding = 20;
86 const x = Math.max(0, left - padding);137 const x = Math.max(0, left - padding);
87 const y = Math.max(0, top - padding);138 const y = Math.max(0, top - padding);
88 const maxWidth = Math.max(1, window.innerWidth - x);139 const maxWidth = Math.max(1, window.innerWidth - x);
...@@ -151,19 +202,13 @@ function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void {...@@ -151,19 +202,13 @@ function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void {
151function createLabel(): HTMLDivElement {202function createLabel(): HTMLDivElement {
152 const label = document.createElement("div");203 const label = document.createElement("div");
153 label.setAttribute(OVERLAY_ATTRIBUTE, "");204 label.setAttribute(OVERLAY_ATTRIBUTE, "");
205 label.className =
206 "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md";
154 label.style.cssText = [207 label.style.cssText = [
155 "position:fixed",208 "position:fixed",
156 "pointer-events:none",209 "pointer-events:none",
157 `background:${ACCENT}`,
158 "color:white",
159 "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif",
160 "padding:2px 6px",
161 "border-radius:4px",
162 "white-space:nowrap",210 "white-space:nowrap",
163 "max-width:280px",
164 "overflow:hidden",
165 "text-overflow:ellipsis",211 "text-overflow:ellipsis",
166 "box-shadow:0 1px 3px rgba(0,0,0,.3)",
167 `z-index:${CONTENT_LAYER_Z_INDEX}`,212 `z-index:${CONTENT_LAYER_Z_INDEX}`,
168 ].join(";");213 ].join(";");
169 return label;214 return label;
...@@ -222,22 +267,15 @@ function createButton(label: string, title: string): HTMLButtonElement {...@@ -222,22 +267,15 @@ function createButton(label: string, title: string): HTMLButtonElement {
222 button.type = "button";267 button.type = "button";
223 button.textContent = label;268 button.textContent = label;
224 button.title = title;269 button.title = title;
225 button.style.cssText = [270 button.className =
226 "border:0",271 "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60";
227 "border-radius:7px",
228 "padding:6px 9px",
229 "font:600 12px/1 ui-sans-serif,system-ui,-apple-system,sans-serif",
230 "background:transparent",
231 "color:#27272a",
232 "cursor:pointer",
233 ].join(";");
234 return button;272 return button;
235}273}
236274
237function styleControl(input: HTMLInputElement | HTMLSelectElement): void {275function styleControl(input: HTMLInputElement | HTMLSelectElement): void {
238 input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value");276 input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value");
239 input.style.cssText +=277 input.className =
240 ";min-width:0;width:100%;height:26px;border:0;border-radius:7px;background:rgba(255,255,255,.78);color:#27272a;padding:3px 8px;box-sizing:border-box;font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none;box-shadow:inset 0 0 0 1px rgba(0,0,0,.09);appearance:none";278 "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none";
241}279}
242280
243function createUnitControl(input: HTMLInputElement): HTMLElement {281function createUnitControl(input: HTMLInputElement): HTMLElement {
...@@ -245,8 +283,8 @@ function createUnitControl(input: HTMLInputElement): HTMLElement {...@@ -245,8 +283,8 @@ function createUnitControl(input: HTMLInputElement): HTMLElement {
245 wrapper.style.cssText = "position:relative;min-width:0";283 wrapper.style.cssText = "position:relative;min-width:0";
246 const unit = document.createElement("span");284 const unit = document.createElement("span");
247 unit.textContent = input.dataset.unit ?? "";285 unit.textContent = input.dataset.unit ?? "";
248 unit.style.cssText =286 unit.className =
249 "position:absolute;right:8px;top:50%;transform:translateY(-50%);pointer-events:none;color:#a1a1aa;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace";287 "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground";
250 wrapper.append(input, unit);288 wrapper.append(input, unit);
251 return wrapper;289 return wrapper;
252}290}
...@@ -256,8 +294,8 @@ function createField(...@@ -256,8 +294,8 @@ function createField(
256 input: HTMLInputElement | HTMLSelectElement,294 input: HTMLInputElement | HTMLSelectElement,
257): HTMLLabelElement {295): HTMLLabelElement {
258 const label = document.createElement("label");296 const label = document.createElement("label");
259 label.style.cssText =297 label.className =
260 "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b";298 "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground";
261 const text = document.createElement("span");299 const text = document.createElement("span");
262 text.textContent = labelText;300 text.textContent = labelText;
263 styleControl(input);301 styleControl(input);
...@@ -270,7 +308,7 @@ function createField(...@@ -270,7 +308,7 @@ function createField(
270308
271function createStyleSection(): HTMLElement {309function createStyleSection(): HTMLElement {
272 const section = document.createElement("section");310 const section = document.createElement("section");
273 section.style.cssText = "display:grid;gap:3px;padding:7px 0;border-top:1px solid rgba(0,0,0,.07)";311 section.className = "grid gap-1 border-t border-border py-2";
274 return section;312 return section;
275}313}
276314
...@@ -314,16 +352,27 @@ function strokeBounds(...@@ -314,16 +352,27 @@ function strokeBounds(
314function startAnnotation(): void {352function startAnnotation(): void {
315 activeSession?.teardown(false);353 activeSession?.teardown(false);
316 let finished = false;354 let finished = false;
355 const host = document.createElement("div");
356 host.setAttribute(OVERLAY_ATTRIBUTE, "");
357 host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`;
358 applyAnnotationTheme(host, annotationTheme);
359 const shadowRoot = host.attachShadow({ mode: "closed" });
360 const themeStyle = document.createElement("style");
361 themeStyle.textContent = previewAnnotationStyles;
362 shadowRoot.appendChild(themeStyle);
363
317 const root = document.createElement("div");364 const root = document.createElement("div");
318 root.setAttribute(OVERLAY_ATTRIBUTE, "");365 root.setAttribute(OVERLAY_ATTRIBUTE, "");
319 root.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif`;366 root.className = "fixed inset-0 font-sans text-foreground";
367 root.style.cssText = "pointer-events:none";
320 const cursorStyle = document.createElement("style");368 const cursorStyle = document.createElement("style");
321 cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, "");369 cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, "");
322 cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`;370 cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`;
323 root.appendChild(cursorStyle);371 document.documentElement.appendChild(cursorStyle);
372 shadowRoot.appendChild(root);
324373
325 const hoverOutline = createBox(BLUE, "rgba(37,99,235,.10)");374 const hoverOutline = createBox(PRIMARY, PRIMARY_FILL);
326 const marqueeBox = createBox(ACCENT, ACCENT_FILL);375 const marqueeBox = createBox(PRIMARY, PRIMARY_FILL);
327 root.append(hoverOutline, marqueeBox);376 root.append(hoverOutline, marqueeBox);
328377
329 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");378 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
...@@ -337,57 +386,55 @@ function startAnnotation(): void {...@@ -337,57 +386,55 @@ function startAnnotation(): void {
337386
338 const toolbar = document.createElement("div");387 const toolbar = document.createElement("div");
339 toolbar.setAttribute(OVERLAY_ATTRIBUTE, "");388 toolbar.setAttribute(OVERLAY_ATTRIBUTE, "");
340 toolbar.style.cssText = `position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:${CHROME_LAYER_Z_INDEX};pointer-events:auto;display:flex;gap:2px;padding:3px;border:1px solid rgba(0,0,0,.10);border-radius:9px;background:rgba(255,255,255,.94);box-shadow:0 5px 16px rgba(0,0,0,.12);backdrop-filter:blur(12px)`;389 toolbar.className =
390 "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl";
391 toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX);
341 root.appendChild(toolbar);392 root.appendChild(toolbar);
342393
343 const editor = document.createElement("div");394 const editor = document.createElement("div");
344 editor.setAttribute(OVERLAY_ATTRIBUTE, "");395 editor.setAttribute(OVERLAY_ATTRIBUTE, "");
345 editor.style.cssText = `position:fixed;right:12px;bottom:12px;z-index:${CHROME_LAYER_Z_INDEX};width:min(306px,calc(100vw - 24px));max-height:calc(100vh - 32px);overflow:hidden;pointer-events:auto;display:none;flex-direction:column;border:1px solid rgba(0,0,0,.09);border-radius:14px;background:rgba(255,255,255,.96);box-shadow:0 14px 36px rgba(0,0,0,.18);color:#18181b;box-sizing:border-box;backdrop-filter:blur(18px)`;396 editor.className =
397 "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl";
398 editor.style.zIndex = String(CHROME_LAYER_Z_INDEX);
346 root.appendChild(editor);399 root.appendChild(editor);
347400
348 const editorHeader = document.createElement("div");401 const composerRow = document.createElement("div");
349 editorHeader.style.cssText =402 composerRow.className = "flex items-start gap-2 p-2";
350 "display:flex;align-items:center;gap:7px;padding:7px 8px;border-bottom:1px solid rgba(0,0,0,.06);cursor:grab;user-select:none";403
351 const adjustIcon = document.createElement("div");404 const adjust = createButton("", "Expand annotation editor");
352 adjustIcon.innerHTML =405 adjust.setAttribute("aria-label", "Expand annotation editor");
353 '<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M3 6h8M15 6h2M3 14h2M9 14h8M11 3v6M7 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><circle cx="13" cy="6" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="14" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/></svg>';406 adjust.setAttribute("aria-expanded", "false");
354 adjustIcon.style.cssText =407 adjust.className +=
355 "display:grid;place-items:center;width:24px;height:24px;border-radius:999px;background:#f4f4f5;color:#52525b;font:700 14px/1 ui-sans-serif";408 " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground";
356 editorHeader.appendChild(adjustIcon);409 adjust.innerHTML =
357410 '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12M7 3v4M13 8v4M9 13v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
358 const status = document.createElement("div");411 composerRow.appendChild(adjust);
359 status.style.cssText =
360 "min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 11px/1.2 ui-sans-serif,system-ui;color:#71717a";
361 editorHeader.appendChild(status);
362 const dragHandle = document.createElement("div");
363 dragHandle.textContent = "â ¿";
364 dragHandle.title = "Drag annotation editor";
365 dragHandle.style.cssText = "color:#a1a1aa;font:700 18px/1 ui-sans-serif";
366 editorHeader.appendChild(dragHandle);
367 editor.appendChild(editorHeader);
368412
369 const comment = document.createElement("textarea");413 const comment = document.createElement("textarea");
370 comment.placeholder = "Describe the change…";414 comment.placeholder = "Describe the change…";
371 comment.rows = 2;415 comment.rows = 1;
372 comment.style.cssText =416 comment.className =
373 "width:100%;resize:none;min-height:48px;border:0;padding:10px 12px;font:500 12px/1.35 ui-sans-serif,system-ui;color:#18181b;box-sizing:border-box;outline:none;background:transparent";417 "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0";
374 editor.appendChild(comment);418 composerRow.appendChild(comment);
375419
376 const stylePanel = document.createElement("div");420 const dragHandle = document.createElement("button");
377 stylePanel.style.cssText =421 dragHandle.type = "button";
378 "display:none;max-height:min(380px,calc(100vh - 180px));overflow:auto;padding:0 10px;background:rgba(250,250,250,.62);border-top:1px solid rgba(0,0,0,.06)";422 dragHandle.textContent = "â ¿";
379 editor.appendChild(stylePanel);423 dragHandle.title = "Drag annotation editor";
424 dragHandle.className =
425 "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground";
426 composerRow.appendChild(dragHandle);
380427
381 const footer = document.createElement("div");
382 footer.style.cssText =
383 "display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border-top:1px solid rgba(0,0,0,.06);background:rgba(255,255,255,.92)";
384 const adjust = createButton("Adjust", "Show style controls for selected elements");
385 adjust.style.cssText +=
386 ";display:none;padding:5px 7px;color:#52525b;background:#f4f4f5;font-size:11px";
387 const submit = createButton("Attach", "Attach annotation and screenshot");428 const submit = createButton("Attach", "Attach annotation and screenshot");
388 submit.style.cssText += `;background:${ACCENT};color:white;padding:6px 9px;font-size:11px`;429 submit.className +=
389 footer.append(adjust, submit);430 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";
390 editor.appendChild(footer);431 composerRow.appendChild(submit);
432 editor.appendChild(composerRow);
433
434 const stylePanel = document.createElement("div");
435 stylePanel.className =
436 "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3";
437 editor.appendChild(stylePanel);
391438
392 const selected = new Map<Element, SelectedElement>();439 const selected = new Map<Element, SelectedElement>();
393 const regions: PreviewAnnotationRegionTarget[] = [];440 const regions: PreviewAnnotationRegionTarget[] = [];
...@@ -398,28 +445,30 @@ function startAnnotation(): void {...@@ -398,28 +445,30 @@ function startAnnotation(): void {
398 let dragStart: PreviewAnnotationPoint | null = null;445 let dragStart: PreviewAnnotationPoint | null = null;
399 let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null;446 let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null;
400 let pendingCapture = false;447 let pendingCapture = false;
401 let stylesExpanded = false;448 let editorExpanded = false;
402 let editorWasShown = false;449 let editorWasShown = false;
403 let editorPosition: { left: number; top: number } | null = null;450 let editorPosition: { left: number; top: number } | null = null;
404 let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null;451 let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null;
452 let editorLayoutFrame: number | null = null;
453
454 const resizeComment = (): void => {
455 const maxHeight = 96;
456 comment.style.height = "auto";
457 const nextHeight = Math.min(comment.scrollHeight, maxHeight);
458 comment.style.height = `${nextHeight}px`;
459 comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden";
460 queueEditorLayout();
461 };
462 comment.addEventListener("input", resizeComment);
405463
406 const updateStatus = (): void => {464 const updateStatus = (): void => {
407 const parts = [465 const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0;
408 selected.size > 0 ? `${selected.size} element${selected.size === 1 ? "" : "s"}` : "",
409 regions.length > 0 ? `${regions.length} region${regions.length === 1 ? "" : "s"}` : "",
410 strokes.length > 0 ? `${strokes.length} drawing${strokes.length === 1 ? "" : "s"}` : "",
411 ].filter(Boolean);
412 const hasTargets = parts.length > 0;
413 status.textContent = parts.join(" · ");
414 editor.style.display = hasTargets ? "flex" : "none";466 editor.style.display = hasTargets ? "flex" : "none";
415 submit.disabled = !hasTargets;467 submit.disabled = !hasTargets;
416 submit.style.opacity = hasTargets ? "1" : "0.45";468 submit.style.opacity = hasTargets ? "1" : "0.45";
417 adjust.style.display = selected.size > 0 ? "block" : "none";469 adjust.disabled = !hasTargets;
418 if (selected.size === 0 && stylesExpanded) {470 stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none";
419 stylesExpanded = false;471 queueEditorLayout();
420 stylePanel.style.display = "none";
421 adjust.textContent = "Adjust";
422 }
423 if (hasTargets && !editorWasShown) {472 if (hasTargets && !editorWasShown) {
424 editorWasShown = true;473 editorWasShown = true;
425 window.setTimeout(() => comment.focus({ preventScroll: true }), 0);474 window.setTimeout(() => comment.focus({ preventScroll: true }), 0);
...@@ -429,8 +478,9 @@ function startAnnotation(): void {...@@ -429,8 +478,9 @@ function startAnnotation(): void {
429 const refreshToolButtons = (): void => {478 const refreshToolButtons = (): void => {
430 for (const [candidate, button] of toolButtons) {479 for (const [candidate, button] of toolButtons) {
431 const active = candidate === tool;480 const active = candidate === tool;
432 button.style.background = active ? ACCENT_FILL : "transparent";481 button.classList.toggle("bg-primary/10", active);
433 button.style.color = active ? ACCENT : "#27272a";482 button.classList.toggle("text-primary", active);
483 button.classList.toggle("text-foreground", !active);
434 }484 }
435 if (tool !== "select") hoverOutline.style.display = "none";485 if (tool !== "select") hoverOutline.style.display = "none";
436 if (tool !== "marquee") marqueeBox.style.display = "none";486 if (tool !== "marquee") marqueeBox.style.display = "none";
...@@ -458,7 +508,7 @@ function startAnnotation(): void {...@@ -458,7 +508,7 @@ function startAnnotation(): void {
458 const target: SelectedElement = {508 const target: SelectedElement = {
459 id: nextId("element"),509 id: nextId("element"),
460 element,510 element,
461 outline: createBox(ACCENT, ACCENT_FILL),511 outline: createBox(PRIMARY, PRIMARY_FILL),
462 label: createLabel(),512 label: createLabel(),
463 baselineStyles: new Map(),513 baselineStyles: new Map(),
464 };514 };
...@@ -466,7 +516,10 @@ function startAnnotation(): void {...@@ -466,7 +516,10 @@ function startAnnotation(): void {
466 root.append(target.outline, target.label);516 root.append(target.outline, target.label);
467 updateSelectedVisual(target);517 updateSelectedVisual(target);
468 updateStatus();518 updateStatus();
469 if (stylesExpanded) syncStyleControls();519 if (editorExpanded) {
520 stylePanel.style.display = "grid";
521 syncStyleControls();
522 }
470 };523 };
471524
472 const toggleSelected = (element: Element, additive: boolean): void => {525 const toggleSelected = (element: Element, additive: boolean): void => {
...@@ -552,13 +605,13 @@ function startAnnotation(): void {...@@ -552,13 +605,13 @@ function startAnnotation(): void {
552 section: HTMLElement,605 section: HTMLElement,
553 ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => {606 ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => {
554 const row = document.createElement("label");607 const row = document.createElement("label");
555 row.style.cssText =608 row.className =
556 "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b";609 "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground";
557 const label = document.createElement("span");610 const label = document.createElement("span");
558 label.textContent = labelText;611 label.textContent = labelText;
559 const control = document.createElement("div");612 const control = document.createElement("div");
560 control.style.cssText =613 control.className =
561 "display:grid;grid-template-columns:22px minmax(0,1fr);align-items:center;gap:5px;height:26px;padding:2px 5px;border-radius:7px;background:rgba(255,255,255,.78);box-shadow:inset 0 0 0 1px rgba(0,0,0,.09)";614 "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs";
562 const color = document.createElement("input");615 const color = document.createElement("input");
563 color.type = "color";616 color.type = "color";
564 color.setAttribute("aria-label", labelText);617 color.setAttribute("aria-label", labelText);
...@@ -567,8 +620,8 @@ function startAnnotation(): void {...@@ -567,8 +620,8 @@ function startAnnotation(): void {
567 const text = document.createElement("input");620 const text = document.createElement("input");
568 text.type = "text";621 text.type = "text";
569 text.setAttribute("aria-label", `${labelText} value`);622 text.setAttribute("aria-label", `${labelText} value`);
570 text.style.cssText =623 text.className =
571 "min-width:0;width:100%;border:0;background:transparent;color:#52525b;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none";624 "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none";
572 color.addEventListener("input", () => {625 color.addEventListener("input", () => {
573 text.value = color.value;626 text.value = color.value;
574 setStyleForSelected(property, color.value);627 setStyleForSelected(property, color.value);
...@@ -594,7 +647,7 @@ function startAnnotation(): void {...@@ -594,7 +647,7 @@ function startAnnotation(): void {
594 opacity.max = "1";647 opacity.max = "1";
595 opacity.step = "0.05";648 opacity.step = "0.05";
596 opacity.value = "1";649 opacity.value = "1";
597 opacity.style.accentColor = ACCENT;650 opacity.style.accentColor = PRIMARY;
598 opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value));651 opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value));
599 colorsSection.appendChild(createField("Opacity", opacity));652 colorsSection.appendChild(createField("Opacity", opacity));
600653
...@@ -623,8 +676,7 @@ function startAnnotation(): void {...@@ -623,8 +676,7 @@ function startAnnotation(): void {
623 dimensions.style.cssText =676 dimensions.style.cssText =
624 "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center";677 "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center";
625 const dimensionLabel = document.createElement("div");678 const dimensionLabel = document.createElement("div");
626 dimensionLabel.style.cssText =679 dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground";
627 "display:grid;gap:9px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b";
628 dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>";680 dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>";
629 const dimensionControls = document.createElement("div");681 const dimensionControls = document.createElement("div");
630 dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px";682 dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px";
...@@ -635,7 +687,8 @@ function startAnnotation(): void {...@@ -635,7 +687,8 @@ function startAnnotation(): void {
635 const aspectLock = createButton("", "Lock aspect ratio");687 const aspectLock = createButton("", "Lock aspect ratio");
636 aspectLock.setAttribute("aria-pressed", "true");688 aspectLock.setAttribute("aria-pressed", "true");
637 aspectLock.style.cssText +=689 aspectLock.style.cssText +=
638 ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0;border-radius:6px;background:#ede9fe;color:#6d28d9";690 ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0";
691 aspectLock.className += " bg-primary/10 text-primary";
639 dimensionControls.append(692 dimensionControls.append(
640 createUnitControl(widthInput),693 createUnitControl(widthInput),
641 createUnitControl(heightInput),694 createUnitControl(heightInput),
...@@ -651,8 +704,10 @@ function startAnnotation(): void {...@@ -651,8 +704,10 @@ function startAnnotation(): void {
651 ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'704 ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'
652 : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';705 : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
653 aspectLock.setAttribute("aria-pressed", String(aspectLocked));706 aspectLock.setAttribute("aria-pressed", String(aspectLocked));
654 aspectLock.style.background = aspectLocked ? "#ede9fe" : "#f4f4f5";707 aspectLock.classList.toggle("bg-primary/10", aspectLocked);
655 aspectLock.style.color = aspectLocked ? "#6d28d9" : "#71717a";708 aspectLock.classList.toggle("text-primary", aspectLocked);
709 aspectLock.classList.toggle("bg-muted", !aspectLocked);
710 aspectLock.classList.toggle("text-muted-foreground", !aspectLocked);
656 };711 };
657 aspectLock.addEventListener("click", () => {712 aspectLock.addEventListener("click", () => {
658 aspectLocked = !aspectLocked;713 aspectLocked = !aspectLocked;
...@@ -733,7 +788,7 @@ function startAnnotation(): void {...@@ -733,7 +788,7 @@ function startAnnotation(): void {
733 ];788 ];
734 for (const [candidate, label, title] of tools) {789 for (const [candidate, label, title] of tools) {
735 const button = createButton(label, title);790 const button = createButton(label, title);
736 button.style.cssText += ";padding:5px 7px;font-size:11px;border-radius:6px";791 button.className += " h-8 px-2.5 text-sm";
737 button.addEventListener("click", () => {792 button.addEventListener("click", () => {
738 tool = candidate;793 tool = candidate;
739 refreshToolButtons();794 refreshToolButtons();
...@@ -742,14 +797,6 @@ function startAnnotation(): void {...@@ -742,14 +797,6 @@ function startAnnotation(): void {
742 toolbar.appendChild(button);797 toolbar.appendChild(button);
743 }798 }
744799
745 adjust.addEventListener("click", () => {
746 if (selected.size === 0) return;
747 stylesExpanded = !stylesExpanded;
748 stylePanel.style.display = stylesExpanded ? "grid" : "none";
749 adjust.textContent = stylesExpanded ? "Hide styles" : "Adjust";
750 if (stylesExpanded) syncStyleControls();
751 });
752
753 const clampEditorPosition = (left: number, top: number): { left: number; top: number } => {800 const clampEditorPosition = (left: number, top: number): { left: number; top: number } => {
754 const margin = 8;801 const margin = 8;
755 const rect = editor.getBoundingClientRect();802 const rect = editor.getBoundingClientRect();
...@@ -766,23 +813,98 @@ function startAnnotation(): void {...@@ -766,23 +813,98 @@ function startAnnotation(): void {
766 };813 };
767814
768 const applyEditorPosition = (position: { left: number; top: number }): void => {815 const applyEditorPosition = (position: { left: number; top: number }): void => {
769 editorPosition = clampEditorPosition(position.left, position.top);816 const clamped = clampEditorPosition(position.left, position.top);
770 editor.style.left = `${editorPosition.left}px`;817 editor.style.left = `${clamped.left}px`;
771 editor.style.top = `${editorPosition.top}px`;818 editor.style.top = `${clamped.top}px`;
772 editor.style.right = "auto";819 editor.style.right = "auto";
773 editor.style.bottom = "auto";820 editor.style.bottom = "auto";
821 if (editorExpanded) editorPosition = clamped;
822 };
823
824 const getAnnotationBounds = (): PreviewAnnotationRect | null =>
825 unionRects(
826 [
827 ...Array.from(selected.values(), (target) =>
828 rectFromDomRect(target.element.getBoundingClientRect()),
829 ),
830 ...regions.map((region) => region.rect),
831 ...strokes.map((stroke) => stroke.bounds),
832 ],
833 0,
834 );
835
836 const positionCompactEditor = (): void => {
837 const bounds = getAnnotationBounds();
838 if (!bounds) return;
839 const editorRect = editor.getBoundingClientRect();
840 const gap = 8;
841 const candidates = [
842 { left: bounds.x + bounds.width + gap, top: bounds.y },
843 { left: bounds.x - editorRect.width - gap, top: bounds.y },
844 {
845 left: bounds.x + bounds.width - editorRect.width,
846 top: bounds.y + bounds.height + gap,
847 },
848 {
849 left: bounds.x + bounds.width - editorRect.width,
850 top: bounds.y - editorRect.height - gap,
851 },
852 ];
853 const overflow = (position: { left: number; top: number }): number =>
854 Math.max(0, -position.left) +
855 Math.max(0, -position.top) +
856 Math.max(0, position.left + editorRect.width - window.innerWidth) +
857 Math.max(0, position.top + editorRect.height - window.innerHeight);
858 const best = candidates.reduce((current, candidate) =>
859 overflow(candidate) < overflow(current) ? candidate : current,
860 );
861 applyEditorPosition(best);
774 };862 };
775863
864 function queueEditorLayout(): void {
865 if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame);
866 editorLayoutFrame = window.requestAnimationFrame(() => {
867 editorLayoutFrame = null;
868 if (editor.style.display === "none") return;
869 if (editorExpanded && editorPosition) applyEditorPosition(editorPosition);
870 else positionCompactEditor();
871 });
872 }
873
874 adjust.addEventListener("click", () => {
875 if (selected.size === 0) return;
876 if (!editorExpanded) {
877 const rect = editor.getBoundingClientRect();
878 editorExpanded = true;
879 editorPosition = { left: rect.left, top: rect.top };
880 stylePanel.style.display = selected.size > 0 ? "grid" : "none";
881 dragHandle.style.display = "block";
882 adjust.setAttribute("aria-expanded", "true");
883 adjust.title = "Collapse annotation editor";
884 adjust.setAttribute("aria-label", "Collapse annotation editor");
885 if (selected.size > 0) syncStyleControls();
886 } else {
887 editorExpanded = false;
888 editorPosition = null;
889 stylePanel.style.display = "none";
890 dragHandle.style.display = "none";
891 adjust.setAttribute("aria-expanded", "false");
892 adjust.title = "Expand annotation editor";
893 adjust.setAttribute("aria-label", "Expand annotation editor");
894 }
895 queueEditorLayout();
896 });
897
776 const onEditorPointerDown = (event: PointerEvent): void => {898 const onEditorPointerDown = (event: PointerEvent): void => {
777 if (event.button !== 0) return;899 if (event.button !== 0 || !editorExpanded) return;
778 const rect = editor.getBoundingClientRect();900 const rect = editor.getBoundingClientRect();
779 editorDrag = {901 editorDrag = {
780 pointerId: event.pointerId,902 pointerId: event.pointerId,
781 offsetX: event.clientX - rect.left,903 offsetX: event.clientX - rect.left,
782 offsetY: event.clientY - rect.top,904 offsetY: event.clientY - rect.top,
783 };905 };
784 editorHeader.setPointerCapture(event.pointerId);906 dragHandle.setPointerCapture(event.pointerId);
785 editorHeader.style.cursor = "grabbing";907 dragHandle.style.cursor = "grabbing";
786 event.preventDefault();908 event.preventDefault();
787 event.stopPropagation();909 event.stopPropagation();
788 };910 };
...@@ -800,20 +922,20 @@ function startAnnotation(): void {...@@ -800,20 +922,20 @@ function startAnnotation(): void {
800 const onEditorPointerUp = (event: PointerEvent): void => {922 const onEditorPointerUp = (event: PointerEvent): void => {
801 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;923 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;
802 editorDrag = null;924 editorDrag = null;
803 editorHeader.style.cursor = "grab";925 dragHandle.style.cursor = "grab";
804 if (editorHeader.hasPointerCapture(event.pointerId))926 if (dragHandle.hasPointerCapture(event.pointerId))
805 editorHeader.releasePointerCapture(event.pointerId);927 dragHandle.releasePointerCapture(event.pointerId);
806 event.preventDefault();928 event.preventDefault();
807 event.stopPropagation();929 event.stopPropagation();
808 };930 };
809 editorHeader.addEventListener("pointerdown", onEditorPointerDown);931 dragHandle.addEventListener("pointerdown", onEditorPointerDown);
810 editorHeader.addEventListener("pointermove", onEditorPointerMove);932 dragHandle.addEventListener("pointermove", onEditorPointerMove);
811 editorHeader.addEventListener("pointerup", onEditorPointerUp);933 dragHandle.addEventListener("pointerup", onEditorPointerUp);
812 editorHeader.addEventListener("pointercancel", onEditorPointerUp);934 dragHandle.addEventListener("pointercancel", onEditorPointerUp);
813935
814 const repaint = (): void => {936 const repaint = (): void => {
815 for (const target of selected.values()) updateSelectedVisual(target);937 for (const target of selected.values()) updateSelectedVisual(target);
816 if (editorPosition) applyEditorPosition(editorPosition);938 queueEditorLayout();
817 };939 };
818940
819 const removeTargetAtPoint = (x: number, y: number): boolean => {941 const removeTargetAtPoint = (x: number, y: number): boolean => {
...@@ -941,7 +1063,7 @@ function startAnnotation(): void {...@@ -941,7 +1063,7 @@ function startAnnotation(): void {
941 if (tool === "draw") {1063 if (tool === "draw") {
942 const stroke: PreviewAnnotationStrokeTarget = {1064 const stroke: PreviewAnnotationStrokeTarget = {
943 id: nextId("stroke"),1065 id: nextId("stroke"),
944 color: ACCENT,1066 color: annotationTheme?.primary ?? "#2563eb",
945 width: 4,1067 width: 4,
946 points: [dragStart],1068 points: [dragStart],
947 bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 },1069 bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 },
...@@ -971,7 +1093,10 @@ function startAnnotation(): void {...@@ -971,7 +1093,10 @@ function startAnnotation(): void {
971 if (found === 0) {1093 if (found === 0) {
972 const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect };1094 const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect };
973 regions.push(region);1095 regions.push(region);
974 const regionBox = createBox(ACCENT, "rgba(124,58,237,.06)");1096 const regionBox = createBox(
1097 PRIMARY,
1098 "color-mix(in srgb, var(--t3-primary) 6%, transparent)",
1099 );
975 regionBox.setAttribute("data-region-id", region.id);1100 regionBox.setAttribute("data-region-id", region.id);
976 positionBox(regionBox, rect);1101 positionBox(regionBox, rect);
977 root.appendChild(regionBox);1102 root.appendChild(regionBox);
...@@ -1024,14 +1149,16 @@ function startAnnotation(): void {...@@ -1024,14 +1149,16 @@ function startAnnotation(): void {
1024 window.removeEventListener("keydown", onKeyDown, true);1149 window.removeEventListener("keydown", onKeyDown, true);
1025 window.removeEventListener("scroll", repaint, true);1150 window.removeEventListener("scroll", repaint, true);
1026 window.removeEventListener("resize", repaint);1151 window.removeEventListener("resize", repaint);
1027 editorHeader.removeEventListener("pointerdown", onEditorPointerDown);1152 dragHandle.removeEventListener("pointerdown", onEditorPointerDown);
1028 editorHeader.removeEventListener("pointermove", onEditorPointerMove);1153 dragHandle.removeEventListener("pointermove", onEditorPointerMove);
1029 editorHeader.removeEventListener("pointerup", onEditorPointerUp);1154 dragHandle.removeEventListener("pointerup", onEditorPointerUp);
1030 editorHeader.removeEventListener("pointercancel", onEditorPointerUp);1155 dragHandle.removeEventListener("pointercancel", onEditorPointerUp);
1156 if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame);
1031 ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel);1157 ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel);
1032 ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured);1158 ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1033 document.documentElement.removeAttribute("data-t3code-annotation-tool");1159 document.documentElement.removeAttribute("data-t3code-annotation-tool");
1034 root.remove();1160 cursorStyle.remove();
1161 host.remove();
1035 activeSession = null;1162 activeSession = null;
1036 if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null);1163 if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null);
1037 };1164 };
...@@ -1115,11 +1242,21 @@ function startAnnotation(): void {...@@ -1115,11 +1242,21 @@ function startAnnotation(): void {
1115 window.addEventListener("resize", repaint, { passive: true });1242 window.addEventListener("resize", repaint, { passive: true });
1116 ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel);1243 ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel);
1117 ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured);1244 ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1118 document.documentElement.appendChild(root);1245 document.documentElement.appendChild(host);
1119 refreshToolButtons();1246 refreshToolButtons();
1120 updateStatus();1247 updateStatus();
1121 activeSession = { teardown };1248 activeSession = {
1249 teardown,
1250 applyTheme: (theme) => applyAnnotationTheme(host, theme),
1251 };
1122}1252}
11231253
1124ipcRenderer.on(START_PICK_CHANNEL, () => startAnnotation());1254ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => {
1255 if (theme) annotationTheme = theme;
1256 startAnnotation();
1257});
1258ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => {
1259 annotationTheme = theme;
1260 activeSession?.applyTheme(theme);
1261});
1125ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false));1262ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false));
apps/desktop/src/preview-view-manager.test.ts+186-5
...@@ -3,16 +3,26 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";...@@ -3,16 +3,26 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3const fromId = vi.fn(() => null);3const fromId = vi.fn(() => null);
4const mkdir = vi.fn(async () => undefined);4const mkdir = vi.fn(async () => undefined);
5const writeFile = vi.fn(async () => undefined);5const writeFile = vi.fn(async () => undefined);
6const showItemInFolder = vi.fn();
7const writeImage = vi.fn();
8const createFromPath = vi.fn(() => ({ isEmpty: () => false }));
9const webviewSend = vi.fn();
610
7vi.mock("node:fs/promises", () => ({ mkdir, writeFile }));11vi.mock("node:fs/promises", () => ({ mkdir, writeFile }));
812
9vi.mock("electron", () => ({13vi.mock("electron", () => ({
10 app: {14 clipboard: {
11 getPath: vi.fn(() => "/tmp/t3-code-test"),15 writeImage,
16 },
17 nativeImage: {
18 createFromPath,
12 },19 },
13 session: {20 session: {
14 fromPartition: vi.fn(),21 fromPartition: vi.fn(),
15 },22 },
23 shell: {
24 showItemInFolder,
25 },
16 webContents: {26 webContents: {
17 fromId,27 fromId,
18 },28 },
...@@ -23,6 +33,10 @@ describe("PreviewViewManager automation status", () => {...@@ -23,6 +33,10 @@ describe("PreviewViewManager automation status", () => {
23 fromId.mockClear();33 fromId.mockClear();
24 mkdir.mockClear();34 mkdir.mockClear();
25 writeFile.mockClear();35 writeFile.mockClear();
36 showItemInFolder.mockClear();
37 writeImage.mockClear();
38 createFromPath.mockClear();
39 webviewSend.mockClear();
26 });40 });
2741
28 it("reports an unregistered webview as temporarily unavailable", async () => {42 it("reports an unregistered webview as temporarily unavailable", async () => {
...@@ -59,7 +73,7 @@ describe("PreviewViewManager automation status", () => {...@@ -59,7 +73,7 @@ describe("PreviewViewManager automation status", () => {
59 id: 42,73 id: 42,
60 isDestroyed: () => false,74 isDestroyed: () => false,
61 getType: () => "webview",75 getType: () => "webview",
62 getURL: () => "https://example.com/",76 getURL: () => "https://example.com:8443/path?query=value",
63 getTitle: () => "Example",77 getTitle: () => "Example",
64 isLoading: () => false,78 isLoading: () => false,
65 getZoomFactor: () => 1,79 getZoomFactor: () => 1,
...@@ -69,6 +83,7 @@ describe("PreviewViewManager automation status", () => {...@@ -69,6 +83,7 @@ describe("PreviewViewManager automation status", () => {
69 }),83 }),
70 off: vi.fn(),84 off: vi.fn(),
71 ipc: { on: vi.fn(), off: vi.fn() },85 ipc: { on: vi.fn(), off: vi.fn() },
86 send: webviewSend,
72 navigationHistory: { canGoBack: () => false, canGoForward: () => false },87 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
73 setWindowOpenHandler: vi.fn(),88 setWindowOpenHandler: vi.fn(),
74 debugger: {89 debugger: {
...@@ -82,13 +97,22 @@ describe("PreviewViewManager automation status", () => {...@@ -82,13 +97,22 @@ describe("PreviewViewManager automation status", () => {
82 } as never);97 } as never);
83 const { PreviewViewManager } = await import("./preview-view-manager.ts");98 const { PreviewViewManager } = await import("./preview-view-manager.ts");
84 const manager = new PreviewViewManager();99 const manager = new PreviewViewManager();
100 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
85 manager.createTab("tab_1");101 manager.createTab("tab_1");
86 manager.registerWebview("tab_1", 42);102 manager.registerWebview("tab_1", 42);
87103
104 expect(webviewSend).toHaveBeenCalledWith(
105 "preview:annotation-theme",
106 expect.objectContaining({
107 colorScheme: "light",
108 primary: "oklch(0.488 0.217 264)",
109 }),
110 );
111
88 const artifact = await manager.captureScreenshot("tab_1");112 const artifact = await manager.captureScreenshot("tab_1");
89113
90 expect(capturePage).toHaveBeenCalledOnce();114 expect(capturePage).toHaveBeenCalledOnce();
91 expect(mkdir).toHaveBeenCalledWith("/tmp/t3-code-test/browser-artifacts", {115 expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", {
92 recursive: true,116 recursive: true,
93 });117 });
94 expect(writeFile).toHaveBeenCalledWith(artifact.path, png);118 expect(writeFile).toHaveBeenCalledWith(artifact.path, png);
...@@ -97,6 +121,163 @@ describe("PreviewViewManager automation status", () => {...@@ -97,6 +121,163 @@ describe("PreviewViewManager automation status", () => {
97 mimeType: "image/png",121 mimeType: "image/png",
98 sizeBytes: png.byteLength,122 sizeBytes: png.byteLength,
99 });123 });
100 expect(artifact.path).toMatch(/\/browser-artifacts\/browser-screenshot-[^.]+\.png$/);124 expect(artifact.path).toMatch(
125 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
126 );
127 });
128
129 it("reveals only files inside the configured browser artifact directory", async () => {
130 const { PreviewViewManager } = await import("./preview-view-manager.ts");
131 const manager = new PreviewViewManager();
132 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
133
134 manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png");
135
136 expect(showItemInFolder).toHaveBeenCalledWith(
137 "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png",
138 );
139 expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow(
140 "outside the configured artifact directory",
141 );
142 });
143
144 it("copies screenshot artifacts to the system clipboard", async () => {
145 const { PreviewViewManager } = await import("./preview-view-manager.ts");
146 const manager = new PreviewViewManager();
147 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
148 const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png";
149
150 manager.copyArtifactToClipboard(artifactPath);
151
152 expect(createFromPath).toHaveBeenCalledWith(artifactPath);
153 expect(writeImage).toHaveBeenCalledOnce();
154 expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow(
155 "outside the configured artifact directory",
156 );
157 });
158
159 it("emits the resolved pointer target before dispatching an automation click", async () => {
160 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
161 const activity: string[] = [];
162 const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
163 if (method === "Runtime.evaluate") {
164 return {
165 result: {
166 value: { width: 800, height: 600 },
167 },
168 };
169 }
170 if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") {
171 activity.push("mousePressed");
172 humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 });
173 }
174 return undefined;
175 });
176 fromId.mockReturnValue({
177 id: 42,
178 isDestroyed: () => false,
179 getType: () => "webview",
180 getURL: () => "https://example.com",
181 getTitle: () => "Example",
182 isLoading: () => false,
183 isDevToolsOpened: () => false,
184 getZoomFactor: () => 1,
185 setZoomFactor: vi.fn(),
186 on: vi.fn(),
187 off: vi.fn(),
188 ipc: {
189 on: vi.fn((channel: string, listener: typeof humanInput) => {
190 if (channel === "preview:human-input") humanInput = listener;
191 }),
192 off: vi.fn(),
193 },
194 send: webviewSend,
195 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
196 setWindowOpenHandler: vi.fn(),
197 debugger: {
198 isAttached: () => false,
199 attach: vi.fn(),
200 sendCommand,
201 on: vi.fn(),
202 off: vi.fn(),
203 },
204 } as never);
205 const { PreviewViewManager } = await import("./preview-view-manager.ts");
206 const manager = new PreviewViewManager();
207 manager.onPointerEvent((event) => activity.push(event.phase));
208 manager.createTab("tab_1");
209 manager.registerWebview("tab_1", 42);
210
211 await manager.automationClick("tab_1", { x: 120, y: 80 });
212
213 expect(activity).toEqual(["move", "click", "mousePressed"]);
214 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
215 type: "mousePressed",
216 x: 120,
217 y: 80,
218 button: "left",
219 clickCount: 1,
220 });
221 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
222 type: "mouseReleased",
223 x: 120,
224 y: 80,
225 button: "left",
226 clickCount: 1,
227 });
228 });
229
230 it("still interrupts agent control for a different human pointer event", async () => {
231 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
232 const sendCommand = vi.fn(async (method: string) => {
233 if (method === "Runtime.evaluate") {
234 return {
235 result: {
236 value: { width: 800, height: 600 },
237 },
238 };
239 }
240 if (method === "Input.dispatchMouseEvent") {
241 humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 });
242 }
243 return undefined;
244 });
245 fromId.mockReturnValue({
246 id: 42,
247 isDestroyed: () => false,
248 getType: () => "webview",
249 getURL: () => "https://example.com",
250 getTitle: () => "Example",
251 isLoading: () => false,
252 isDevToolsOpened: () => false,
253 getZoomFactor: () => 1,
254 setZoomFactor: vi.fn(),
255 on: vi.fn(),
256 off: vi.fn(),
257 ipc: {
258 on: vi.fn((channel: string, listener: typeof humanInput) => {
259 if (channel === "preview:human-input") humanInput = listener;
260 }),
261 off: vi.fn(),
262 },
263 send: webviewSend,
264 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
265 setWindowOpenHandler: vi.fn(),
266 debugger: {
267 isAttached: () => false,
268 attach: vi.fn(),
269 sendCommand,
270 on: vi.fn(),
271 off: vi.fn(),
272 },
273 } as never);
274 const { PreviewViewManager } = await import("./preview-view-manager.ts");
275 const manager = new PreviewViewManager();
276 manager.createTab("tab_1");
277 manager.registerWebview("tab_1", 42);
278
279 await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({
280 name: "PreviewAutomationControlInterruptedError",
281 });
101 });282 });
102});283});
apps/desktop/src/preview-view-manager.ts+224-8
...@@ -8,6 +8,8 @@...@@ -8,6 +8,8 @@
8 * here). Single layer-scoped browser session partition.8 * here). Single layer-scoped browser session partition.
9 */9 */
10import type {10import type {
11 DesktopPreviewAnnotationTheme,
12 DesktopPreviewPointerEvent,
11 PreviewAnnotationPayload,13 PreviewAnnotationPayload,
12 PreviewAnnotationRect,14 PreviewAnnotationRect,
13 DesktopPreviewRecordingArtifact,15 DesktopPreviewRecordingArtifact,
...@@ -26,9 +28,17 @@ import type {...@@ -26,9 +28,17 @@ import type {
26 PreviewAutomationWaitForInput,28 PreviewAutomationWaitForInput,
27} from "@t3tools/contracts";29} from "@t3tools/contracts";
28import { normalizePreviewUrl } from "@t3tools/shared/preview";30import { normalizePreviewUrl } from "@t3tools/shared/preview";
29import { app, type BrowserWindow, type Session, session, webContents } from "electron";31import {
32 type BrowserWindow,
33 type Session,
34 clipboard,
35 nativeImage,
36 session,
37 shell,
38 webContents,
39} from "electron";
30import { mkdir, writeFile } from "node:fs/promises";40import { mkdir, writeFile } from "node:fs/promises";
31import { join } from "node:path";41import { isAbsolute, join, relative, resolve, sep } from "node:path";
32import { createHash } from "node:crypto";42import { createHash } from "node:crypto";
33import { setTimeout as sleep } from "node:timers/promises";43import { setTimeout as sleep } from "node:timers/promises";
3444
...@@ -40,6 +50,7 @@ const START_PICK_CHANNEL = "preview:start-pick";...@@ -40,6 +50,7 @@ const START_PICK_CHANNEL = "preview:start-pick";
40const CANCEL_PICK_CHANNEL = "preview:cancel-pick";50const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
41const ELEMENT_PICKED_CHANNEL = "preview:element-picked";51const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
42const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";52const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
53const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
43const HUMAN_INPUT_CHANNEL = "preview:human-input";54const HUMAN_INPUT_CHANNEL = "preview:human-input";
4455
45// Re-export the guest webview security posture from its dedicated module so56// Re-export the guest webview security posture from its dedicated module so
...@@ -83,6 +94,43 @@ const MAX_VISIBLE_TEXT_LENGTH = 20_000;...@@ -83,6 +94,43 @@ const MAX_VISIBLE_TEXT_LENGTH = 20_000;
83const MAX_INTERACTIVE_ELEMENTS = 200;94const MAX_INTERACTIVE_ELEMENTS = 200;
84const MAX_SCREENSHOT_WIDTH = 1280;95const MAX_SCREENSHOT_WIDTH = 1280;
85const DIAGNOSTIC_BUFFER_LIMIT = 200;96const DIAGNOSTIC_BUFFER_LIMIT = 200;
97const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
98const AGENT_CURSOR_MOVE_MS = 160;
99const AGENT_CURSOR_CLICK_LEAD_MS = 40;
100const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {
101 colorScheme: "light",
102 radius: "0.625rem",
103 background: "white",
104 foreground: "oklch(0.269 0 0)",
105 popover: "white",
106 popoverForeground: "oklch(0.269 0 0)",
107 primary: "oklch(0.488 0.217 264)",
108 primaryForeground: "white",
109 muted: "rgb(0 0 0 / 4%)",
110 mutedForeground: "oklch(0.556 0 0)",
111 accent: "rgb(0 0 0 / 4%)",
112 accentForeground: "oklch(0.269 0 0)",
113 border: "rgb(0 0 0 / 8%)",
114 input: "rgb(0 0 0 / 10%)",
115 ring: "oklch(0.488 0.217 264)",
116 fontSans: "system-ui, sans-serif",
117 fontMono: "ui-monospace, monospace",
118};
119
120const artifactSiteSlug = (rawUrl: string): string => {
121 try {
122 const url = new URL(rawUrl);
123 const slug = url.hostname
124 .toLowerCase()
125 .replace(/[^a-z0-9]+/g, "-")
126 .replace(/^-+|-+$/g, "")
127 .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH)
128 .replace(/-+$/g, "");
129 return slug || "site";
130 } catch {
131 return "site";
132 }
133};
86134
87interface CdpEvaluationResult {135interface CdpEvaluationResult {
88 readonly result?: {136 readonly result?: {
...@@ -182,10 +230,14 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => {...@@ -182,10 +230,14 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
182type Listener = (tabId: string, state: PreviewTabState) => void;230type Listener = (tabId: string, state: PreviewTabState) => void;
183type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void;231type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void;
184232
233type PreviewInputSignal =
234 | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number }
235 | { readonly kind: "key"; readonly key: string; readonly code: string };
236
185interface ManagedListeners {237interface ManagedListeners {
186 navigate: () => void;238 navigate: () => void;
187 failed: (event: Event, code: number, description: string) => void;239 failed: (event: Event, code: number, description: string) => void;
188 humanInput: () => void;240 humanInput: (_event: unknown, signal?: unknown) => void;
189}241}
190242
191interface PickSession {243interface PickSession {
...@@ -205,6 +257,13 @@ interface BrowserDiagnostics {...@@ -205,6 +257,13 @@ interface BrowserDiagnostics {
205 readonly requests: Map<string, { url: string; method: string }>;257 readonly requests: Map<string, { url: string; method: string }>;
206}258}
207259
260type PointerEventListener = (event: DesktopPreviewPointerEvent) => void;
261
262interface ExpectedAgentInput {
263 readonly signal: PreviewInputSignal;
264 readonly expiresAt: number;
265}
266
208const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{267const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
209 key: string;268 key: string;
210 meta: boolean;269 meta: boolean;
...@@ -221,23 +280,116 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{...@@ -221,23 +280,116 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
221 { key: "w", meta: true, shift: false, control: false },280 { key: "w", meta: true, shift: false, control: false },
222]);281]);
223282
283const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
284 if (typeof value !== "object" || value === null || !("kind" in value)) return false;
285 if (value.kind === "pointer") {
286 return (
287 "x" in value &&
288 typeof value.x === "number" &&
289 "y" in value &&
290 typeof value.y === "number" &&
291 "button" in value &&
292 typeof value.button === "number"
293 );
294 }
295 return (
296 value.kind === "key" &&
297 "key" in value &&
298 typeof value.key === "string" &&
299 "code" in value &&
300 typeof value.code === "string"
301 );
302};
303
304const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => {
305 if (left.kind !== right.kind) return false;
306 if (left.kind === "pointer" && right.kind === "pointer") {
307 return (
308 Math.abs(left.x - right.x) <= 1 &&
309 Math.abs(left.y - right.y) <= 1 &&
310 left.button === right.button
311 );
312 }
313 return (
314 left.kind === "key" &&
315 right.kind === "key" &&
316 left.key === right.key &&
317 left.code === right.code
318 );
319};
320
224export class PreviewViewManager {321export class PreviewViewManager {
322 private annotationTheme = DEFAULT_ANNOTATION_THEME;
323 private artifactDirectory: string | null = null;
225 private mainWindow: BrowserWindow | null = null;324 private mainWindow: BrowserWindow | null = null;
226 private readonly tabs = new Map<string, PreviewTabState>();325 private readonly tabs = new Map<string, PreviewTabState>();
227 private readonly attached = new Map<number, ManagedListeners>();326 private readonly attached = new Map<number, ManagedListeners>();
228 private readonly browserSessions = new Map<string, Session>();327 private readonly browserSessions = new Map<string, Session>();
229 private readonly listeners = new Set<Listener>();328 private readonly listeners = new Set<Listener>();
329 private readonly pointerEventListeners = new Set<PointerEventListener>();
230 private readonly recordingFrameListeners = new Set<RecordingFrameListener>();330 private readonly recordingFrameListeners = new Set<RecordingFrameListener>();
231 /** In-flight preview annotation sessions, keyed by tabId. */331 /** In-flight preview annotation sessions, keyed by tabId. */
232 private readonly pickSessions = new Map<string, PickSession>();332 private readonly pickSessions = new Map<string, PickSession>();
233 /** One long-lived CDP attachment and serialized command queue per guest. */333 /** One long-lived CDP attachment and serialized command queue per guest. */
234 private readonly controlSessions = new Map<number, BrowserControlSession>();334 private readonly controlSessions = new Map<number, BrowserControlSession>();
235 private readonly diagnostics = new Map<number, BrowserDiagnostics>();335 private readonly diagnostics = new Map<number, BrowserDiagnostics>();
336 private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>();
236 private readonly controlEpoch = new Map<string, number>();337 private readonly controlEpoch = new Map<string, number>();
237 private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>();338 private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>();
238 private actionSequence = 0;339 private actionSequence = 0;
340 private pointerSequence = 0;
239 private recordingTabId: string | null = null;341 private recordingTabId: string | null = null;
240342
343 configureArtifactDirectory(directory: string): void {
344 this.artifactDirectory = resolve(directory);
345 }
346
347 private requireArtifactDirectory(): string {
348 if (!this.artifactDirectory) {
349 throw new Error("Preview artifact directory is not configured.");
350 }
351 return this.artifactDirectory;
352 }
353
354 private resolveArtifactPath(path: string): string {
355 const directory = this.requireArtifactDirectory();
356 const resolvedPath = resolve(path);
357 const relativePath = relative(directory, resolvedPath);
358 if (
359 relativePath.length === 0 ||
360 relativePath === ".." ||
361 relativePath.startsWith(`..${sep}`) ||
362 isAbsolute(relativePath)
363 ) {
364 throw new Error("Preview artifact path is outside the configured artifact directory.");
365 }
366 return resolvedPath;
367 }
368
369 revealArtifact(path: string): void {
370 const resolvedPath = this.resolveArtifactPath(path);
371 shell.showItemInFolder(resolvedPath);
372 }
373
374 copyArtifactToClipboard(path: string): void {
375 const resolvedPath = this.resolveArtifactPath(path);
376 const image = nativeImage.createFromPath(resolvedPath);
377 if (image.isEmpty()) {
378 throw new Error("Preview artifact could not be loaded as an image.");
379 }
380 clipboard.writeImage(image);
381 }
382
383 setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void {
384 this.annotationTheme = theme;
385 for (const tab of this.tabs.values()) {
386 if (tab.webContentsId == null) continue;
387 const wc = webContents.fromId(tab.webContentsId);
388 if (!wc || wc.isDestroyed()) continue;
389 wc.send(ANNOTATION_THEME_CHANNEL, theme);
390 }
391 }
392
241 setMainWindow(window: BrowserWindow): void {393 setMainWindow(window: BrowserWindow): void {
242 this.mainWindow = window;394 this.mainWindow = window;
243 }395 }
...@@ -338,6 +490,7 @@ export class PreviewViewManager {...@@ -338,6 +490,7 @@ export class PreviewViewManager {
338 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);490 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
339 }491 }
340 if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) {492 if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) {
493 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);
341 return;494 return;
342 }495 }
343 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {496 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {
...@@ -366,6 +519,7 @@ export class PreviewViewManager {...@@ -366,6 +519,7 @@ export class PreviewViewManager {
366 canGoForward: wc.navigationHistory.canGoForward(),519 canGoForward: wc.navigationHistory.canGoForward(),
367 zoomFactor: tab.zoomFactor,520 zoomFactor: tab.zoomFactor,
368 });521 });
522 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);
369 }523 }
370524
371 async navigate(tabId: string, rawUrl: string): Promise<void> {525 async navigate(tabId: string, rawUrl: string): Promise<void> {
...@@ -513,7 +667,7 @@ export class PreviewViewManager {...@@ -513,7 +667,7 @@ export class PreviewViewManager {
513 // wc may be torn down; the next try/catch settles.667 // wc may be torn down; the next try/catch settles.
514 }668 }
515 try {669 try {
516 wc.send(START_PICK_CHANNEL);670 wc.send(START_PICK_CHANNEL, this.annotationTheme);
517 } catch {671 } catch {
518 settle(null);672 settle(null);
519 }673 }
...@@ -561,8 +715,8 @@ export class PreviewViewManager {...@@ -561,8 +715,8 @@ export class PreviewViewManager {
561 async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> {715 async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> {
562 const wc = this.requireWebContents(tabId);716 const wc = this.requireWebContents(tabId);
563 const createdAt = new Date().toISOString();717 const createdAt = new Date().toISOString();
564 const id = `browser-screenshot-${Date.now().toString(36)}`;718 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`;
565 const directory = join(app.getPath("userData"), "browser-artifacts");719 const directory = this.requireArtifactDirectory();
566 const path = join(directory, `${id}.png`);720 const path = join(directory, `${id}.png`);
567 const data = (await wc.capturePage()).toPNG();721 const data = (await wc.capturePage()).toPNG();
568 await mkdir(directory, { recursive: true });722 await mkdir(directory, { recursive: true });
...@@ -587,7 +741,7 @@ export class PreviewViewManager {...@@ -587,7 +741,7 @@ export class PreviewViewManager {
587 const createdAt = new Date().toISOString();741 const createdAt = new Date().toISOString();
588 const id = `browser-recording-${Date.now().toString(36)}`;742 const id = `browser-recording-${Date.now().toString(36)}`;
589 const extension = mimeType.includes("mp4") ? "mp4" : "webm";743 const extension = mimeType.includes("mp4") ? "mp4" : "webm";
590 const directory = join(app.getPath("userData"), "browser-artifacts");744 const directory = this.requireArtifactDirectory();
591 const path = join(directory, `${id}.${extension}`);745 const path = join(directory, `${id}.${extension}`);
592 await mkdir(directory, { recursive: true });746 await mkdir(directory, { recursive: true });
593 await writeFile(path, data);747 await writeFile(path, data);
...@@ -793,6 +947,25 @@ export class PreviewViewManager {...@@ -793,6 +947,25 @@ export class PreviewViewManager {
793 `Click coordinates (${x}, ${y}) are outside the preview viewport.`,947 `Click coordinates (${x}, ${y}) are outside the preview viewport.`,
794 );948 );
795 }949 }
950 this.emitPointerEvent({
951 tabId,
952 phase: "move",
953 x,
954 y,
955 sequence: this.pointerSequence++,
956 createdAt: new Date().toISOString(),
957 });
958 await sleep(AGENT_CURSOR_MOVE_MS);
959 this.emitPointerEvent({
960 tabId,
961 phase: "click",
962 x,
963 y,
964 sequence: this.pointerSequence++,
965 createdAt: new Date().toISOString(),
966 });
967 await sleep(AGENT_CURSOR_CLICK_LEAD_MS);
968 this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 });
796 await send("Input.dispatchMouseEvent", {969 await send("Input.dispatchMouseEvent", {
797 type: "mousePressed",970 type: "mousePressed",
798 x,971 x,
...@@ -886,6 +1059,7 @@ export class PreviewViewManager {...@@ -886,6 +1059,7 @@ export class PreviewViewManager {
886 modifiers,1059 modifiers,
887 ...(text ? { text, unmodifiedText: text } : {}),1060 ...(text ? { text, unmodifiedText: text } : {}),
888 };1061 };
1062 this.expectAgentInput(tabId, { kind: "key", key, code: params.code });
889 await send("Input.dispatchKeyEvent", { type: "keyDown", ...params });1063 await send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
890 await send("Input.dispatchKeyEvent", { type: "keyUp", ...params });1064 await send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
891 });1065 });
...@@ -1345,11 +1519,50 @@ export class PreviewViewManager {...@@ -1345,11 +1519,50 @@ export class PreviewViewManager {
1345 };1519 };
1346 }1520 }
13471521
1522 onPointerEvent(listener: PointerEventListener): () => void {
1523 this.pointerEventListeners.add(listener);
1524 return () => {
1525 this.pointerEventListeners.delete(listener);
1526 };
1527 }
1528
1529 private emitPointerEvent(event: DesktopPreviewPointerEvent): void {
1530 for (const listener of this.pointerEventListeners) listener(event);
1531 }
1532
1533 private expectAgentInput(tabId: string, signal: PreviewInputSignal): void {
1534 const now = Date.now();
1535 const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter(
1536 (expected) => expected.expiresAt > now,
1537 );
1538 pending.push({ signal, expiresAt: now + 1_000 });
1539 this.expectedAgentInputs.set(tabId, pending);
1540 }
1541
1542 private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean {
1543 const now = Date.now();
1544 const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter(
1545 (expected) => expected.expiresAt > now,
1546 );
1547 const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal));
1548 if (index < 0) {
1549 if (pending.length === 0) this.expectedAgentInputs.delete(tabId);
1550 else this.expectedAgentInputs.set(tabId, pending);
1551 return false;
1552 }
1553 pending.splice(index, 1);
1554 if (pending.length === 0) this.expectedAgentInputs.delete(tabId);
1555 else this.expectedAgentInputs.set(tabId, pending);
1556 return true;
1557 }
1558
1348 destroy(): void {1559 destroy(): void {
1349 for (const tabId of Array.from(this.tabs.keys())) {1560 for (const tabId of Array.from(this.tabs.keys())) {
1350 this.closeTab(tabId);1561 this.closeTab(tabId);
1351 }1562 }
1352 this.listeners.clear();1563 this.listeners.clear();
1564 this.expectedAgentInputs.clear();
1565 this.pointerEventListeners.clear();
1353 this.recordingFrameListeners.clear();1566 this.recordingFrameListeners.clear();
1354 }1567 }
13551568
...@@ -1375,7 +1588,10 @@ export class PreviewViewManager {...@@ -1375,7 +1588,10 @@ export class PreviewViewManager {
1375 },1588 },
1376 });1589 });
1377 };1590 };
1378 const humanInput = (): void => {1591 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
1592 if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) {
1593 return;
1594 }
1379 this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1);1595 this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1);
1380 this.update(tabId, { controller: "human" });1596 this.update(tabId, { controller: "human" });
1381 void sleep(750).then(() => {1597 void sleep(750).then(() => {
apps/desktop/vite.config.ts+4-3
...@@ -14,17 +14,18 @@ export default defineConfig({...@@ -14,17 +14,18 @@ export default defineConfig({
14 run: {14 run: {
15 tasks: {15 tasks: {
16 build: {16 build: {
17 command: "vp pack",17 command: "node scripts/build-preview-annotation-css.mjs && vp pack",
18 dependsOn: ["t3#build"],18 dependsOn: ["t3#build"],
19 cache: false,19 cache: false,
20 },20 },
21 dev: {21 dev: {
22 command: "cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch",22 command:
23 "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch",
23 dependsOn: ["t3#build"],24 dependsOn: ["t3#build"],
24 cache: false,25 cache: false,
25 },26 },
26 "dev:bundle": {27 "dev:bundle": {
27 command: "vp pack --watch",28 command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch",
28 cache: false,29 cache: false,
29 },30 },
30 "dev:electron": {31 "dev:electron": {
apps/server/src/keybindings.test.ts+2
...@@ -205,6 +205,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -205,6 +205,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
205 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");205 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
206 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");206 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
207 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");207 assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
208 assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
209 assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
208 assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");210 assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");
209 assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9");211 assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9");
210 }),212 }),
apps/server/src/mcp/Layers/McpHttpServer.test.ts+20-8
...@@ -75,14 +75,16 @@ it.effect("registers annotated tools and preserves authenticated request context...@@ -75,14 +75,16 @@ it.effect("registers annotated tools and preserves authenticated request context
75 height: 5,75 height: 5,
76 },76 },
77 }77 }
78 : {78 : request.operation === "press"
79 available: true,79 ? undefined
80 visible: true,80 : {
81 tabId,81 available: true,
82 url: "http://example.test/",82 visible: true,
83 title: "Example",83 tabId,
84 loading: false,84 url: "http://example.test/",
85 },85 title: "Example",
86 loading: false,
87 },
86 }),88 }),
87 ).pipe(Effect.forkScoped);89 ).pipe(Effect.forkScoped);
88 yield* Effect.yieldNow;90 yield* Effect.yieldNow;
...@@ -146,6 +148,16 @@ it.effect("registers annotated tools and preserves authenticated request context...@@ -146,6 +148,16 @@ it.effect("registers annotated tools and preserves authenticated request context
146 expect(snapshot.structuredContent).toMatchObject({148 expect(snapshot.structuredContent).toMatchObject({
147 screenshot: { mimeType: "image/png", width: 10, height: 5 },149 screenshot: { mimeType: "image/png", width: 10, height: 5 },
148 });150 });
151
152 const press = yield* server
153 .callTool({ name: "preview_press", arguments: { key: "Enter" } })
154 .pipe(
155 Effect.provideService(McpInvocationContext, invocation),
156 Effect.provideService(McpSchema.McpServerClient, client),
157 );
158 expect(press.isError).toBe(false);
159 expect(press.structuredContent).toBeUndefined();
160 expect(press.content).toEqual([{ type: "text", text: "null" }]);
149 }),161 }),
150 ).pipe(Effect.provide(TestLayer)),162 ).pipe(Effect.provide(TestLayer)),
151);163);
apps/server/src/mcp/Layers/McpHttpServer.ts+5-2
...@@ -143,11 +143,14 @@ export const PreviewToolkitRegistrationLive = Layer.effectDiscard(...@@ -143,11 +143,14 @@ export const PreviewToolkitRegistrationLive = Layer.effectDiscard(
143 ],143 ],
144 });144 });
145 }145 }
146 const encodedResultText = JSON.stringify(result.encodedResult) ?? "null";
146 return new McpSchema.CallToolResult({147 return new McpSchema.CallToolResult({
147 isError: false,148 isError: false,
148 structuredContent:149 structuredContent:
149 typeof result.encodedResult === "object" ? result.encodedResult : undefined,150 result.encodedResult !== null && typeof result.encodedResult === "object"
150 content: [{ type: "text", text: JSON.stringify(result.encodedResult) }],151 ? result.encodedResult
152 : undefined,
153 content: [{ type: "text", text: encodedResultText }],
151 });154 });
152 },155 },
153 }),156 }),
apps/server/src/preview/Layers/PortScanner.test.ts+66-9
...@@ -1,18 +1,20 @@...@@ -1,18 +1,20 @@
1import * as net from "node:net";1import * as net from "node:net";
22
3import { it as effectIt } from "@effect/vitest";3import { it as effectIt } from "@effect/vitest";
4import { ThreadId } from "@t3tools/contracts";
4import { Effect, Layer } from "effect";5import { Effect, Layer } from "effect";
5import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";6import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
67
7import { COMMON_DEV_PORTS, PreviewPortScanner } from "../Services/PortScanner.ts";8import { COMMON_DEV_PORTS, PortDiscovery } from "../Services/PortScanner.ts";
8import { ProcessRunner } from "../../processRunner.ts";9import { ProcessRunner } from "../../processRunner.ts";
9import { __testing, PreviewPortScannerLive } from "./PortScanner.ts";10import { __testing, PortDiscoveryLive } from "./PortScanner.ts";
1011
11const { parseLsofOutput, parsePortFromLsofName, serversEqual } = __testing;12const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } =
13 __testing;
12const TestProcessRunner = Layer.succeed(ProcessRunner, {14const TestProcessRunner = Layer.succeed(ProcessRunner, {
13 run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),15 run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),
14});16});
15const TestPreviewPortScannerLive = PreviewPortScannerLive.pipe(Layer.provide(TestProcessRunner));17const TestPortDiscoveryLive = PortDiscoveryLive.pipe(Layer.provide(TestProcessRunner));
1618
17describe("parsePortFromLsofName", () => {19describe("parsePortFromLsofName", () => {
18 it("parses *:port", () => {20 it("parses *:port", () => {
...@@ -70,6 +72,7 @@ describe("parseLsofOutput", () => {...@@ -70,6 +72,7 @@ describe("parseLsofOutput", () => {
70 url: "http://localhost:3000",72 url: "http://localhost:3000",
71 processName: "next-server",73 processName: "next-server",
72 pid: 67890,74 pid: 67890,
75 terminal: null,
73 },76 },
74 {77 {
75 host: "localhost",78 host: "localhost",
...@@ -77,6 +80,7 @@ describe("parseLsofOutput", () => {...@@ -77,6 +80,7 @@ describe("parseLsofOutput", () => {
77 url: "http://localhost:5173",80 url: "http://localhost:5173",
78 processName: "node",81 processName: "node",
79 pid: 12345,82 pid: 12345,
83 terminal: null,
80 },84 },
81 {85 {
82 host: "localhost",86 host: "localhost",
...@@ -84,6 +88,7 @@ describe("parseLsofOutput", () => {...@@ -84,6 +88,7 @@ describe("parseLsofOutput", () => {
84 url: "http://localhost:9229",88 url: "http://localhost:9229",
85 processName: "next-server",89 processName: "next-server",
86 pid: 67890,90 pid: 67890,
91 terminal: null,
87 },92 },
88 ]);93 ]);
89 });94 });
...@@ -98,6 +103,26 @@ describe("parseLsofOutput", () => {...@@ -98,6 +103,26 @@ describe("parseLsofOutput", () => {
98 expect(servers).toHaveLength(1);103 expect(servers).toHaveLength(1);
99 expect(servers[0]?.port).toBe(5173);104 expect(servers[0]?.port).toBe(5173);
100 });105 });
106
107 it("attributes listeners to a registered terminal process", () => {
108 const servers = parseLsofOutput(
109 ["p12345", "cnode", "n*:5173"].join("\n"),
110 new Map([
111 [
112 12345,
113 {
114 threadId: ThreadId.make("thread-1"),
115 terminalId: "terminal-1",
116 },
117 ],
118 ]),
119 );
120
121 expect(servers[0]?.terminal).toEqual({
122 threadId: "thread-1",
123 terminalId: "terminal-1",
124 });
125 });
101});126});
102127
103describe("serversEqual", () => {128describe("serversEqual", () => {
...@@ -107,6 +132,7 @@ describe("serversEqual", () => {...@@ -107,6 +132,7 @@ describe("serversEqual", () => {
107 url: "http://localhost:5173",132 url: "http://localhost:5173",
108 processName: "node",133 processName: "node",
109 pid: 1,134 pid: 1,
135 terminal: null,
110 };136 };
111 it("returns true for identical lists", () => {137 it("returns true for identical lists", () => {
112 expect(serversEqual([a], [{ ...a }])).toBe(true);138 expect(serversEqual([a], [{ ...a }])).toBe(true);
...@@ -119,12 +145,43 @@ describe("serversEqual", () => {...@@ -119,12 +145,43 @@ describe("serversEqual", () => {
119 });145 });
120});146});
121147
148describe("parseWindowsListenerOutput", () => {
149 it("parses and attributes PowerShell listener records", () => {
150 const servers = parseWindowsListenerOutput(
151 "0.0.0.0|5173|12345|node",
152 new Map([
153 [
154 12345,
155 {
156 threadId: ThreadId.make("thread-1"),
157 terminalId: "terminal-1",
158 },
159 ],
160 ]),
161 );
162
163 expect(servers).toEqual([
164 {
165 host: "localhost",
166 port: 5173,
167 url: "http://localhost:5173",
168 processName: "node",
169 pid: 12345,
170 terminal: {
171 threadId: "thread-1",
172 terminalId: "terminal-1",
173 },
174 },
175 ]);
176 });
177});
178
122/**179/**
123 * Integration tests against a real TCP listener. We force the Windows code180 * Integration tests against a real TCP listener. We force the Windows code
124 * path (TCP-probe fallback) by monkey-patching `process.platform` for the181 * path (TCP-probe fallback) by monkey-patching `process.platform` for the
125 * duration of the test so we don't depend on `lsof` being installed.182 * duration of the test so we don't depend on `lsof` being installed.
126 */183 */
127describe("PreviewPortScanner integration (TCP probe path)", () => {184describe("PortDiscovery integration (TCP probe fallback)", () => {
128 let originalPlatform: NodeJS.Platform;185 let originalPlatform: NodeJS.Platform;
129 let server: net.Server;186 let server: net.Server;
130 let port: number;187 let port: number;
...@@ -158,18 +215,18 @@ describe("PreviewPortScanner integration (TCP probe path)", () => {...@@ -158,18 +215,18 @@ describe("PreviewPortScanner integration (TCP probe path)", () => {
158215
159 effectIt.effect("scan() returns a server we just opened on a curated dev port", () =>216 effectIt.effect("scan() returns a server we just opened on a curated dev port", () =>
160 Effect.gen(function* () {217 Effect.gen(function* () {
161 const scanner = yield* PreviewPortScanner;218 const scanner = yield* PortDiscovery;
162 const result = yield* scanner.scan();219 const result = yield* scanner.scan();
163 const found = result.find((server) => server.port === port);220 const found = result.find((server) => server.port === port);
164 expect(found).toBeDefined();221 expect(found).toBeDefined();
165 expect(found?.host).toBe("localhost");222 expect(found?.host).toBe("localhost");
166 }).pipe(Effect.provide(TestPreviewPortScannerLive)),223 }).pipe(Effect.provide(TestPortDiscoveryLive)),
167 );224 );
168225
169 effectIt.effect("retain() drives an immediate broadcast to subscribers", () => {226 effectIt.effect("retain() drives an immediate broadcast to subscribers", () => {
170 const received: number[] = [];227 const received: number[] = [];
171 return Effect.gen(function* () {228 return Effect.gen(function* () {
172 const scanner = yield* PreviewPortScanner;229 const scanner = yield* PortDiscovery;
173 const unsubscribe = yield* scanner.subscribe((servers) =>230 const unsubscribe = yield* scanner.subscribe((servers) =>
174 Effect.sync(() => {231 Effect.sync(() => {
175 for (const server of servers) received.push(server.port);232 for (const server of servers) received.push(server.port);
...@@ -179,6 +236,6 @@ describe("PreviewPortScanner integration (TCP probe path)", () => {...@@ -179,6 +236,6 @@ describe("PreviewPortScanner integration (TCP probe path)", () => {
179 unsubscribe();236 unsubscribe();
180 release();237 release();
181 expect(received).toContain(port);238 expect(received).toContain(port);
182 }).pipe(Effect.provide(TestPreviewPortScannerLive));239 }).pipe(Effect.provide(TestPortDiscoveryLive));
183 });240 });
184});241});
apps/server/src/preview/Layers/PortScanner.ts+121-34
...@@ -13,32 +13,42 @@...@@ -13,32 +13,42 @@
13 */13 */
14import * as net from "node:net";14import * as net from "node:net";
1515
16import type { DiscoveredLocalServer } from "@t3tools/contracts";16import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";
17import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";17import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
18import { Cause, Data, Duration, Effect, Layer, Ref, Schedule } from "effect";18import { Cause, Duration, Effect, Layer, Ref, Schedule } from "effect";
1919
20import { ProcessRunner, type ProcessRunnerShape } from "../../processRunner.ts";20import { ProcessRunner } from "../../processRunner.ts";
21import {21import {
22 COMMON_DEV_PORTS,22 COMMON_DEV_PORTS,
23 PreviewPortScanner,23 PortDiscovery,
24 type PreviewPortScannerShape,24 type PortDiscoveryShape,
25} from "../Services/PortScanner.ts";25} from "../Services/PortScanner.ts";
2626
27const POLL_INTERVAL = Duration.seconds(3);27const POLL_INTERVAL = Duration.seconds(3);
28const TCP_PROBE_TIMEOUT_MS = 200;28const TCP_PROBE_TIMEOUT_MS = 200;
29const LSOF_TIMEOUT_MS = 5_000;29const LSOF_TIMEOUT_MS = 5_000;
30const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;
3031
31type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>;32type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>;
3233
33class LsofProbeError extends Data.TaggedError("LsofProbeError")<{
34 readonly cause: unknown;
35}> {}
36
37interface ScannerState {34interface ScannerState {
38 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;35 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;
39}36}
4037
41const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => {38interface TerminalProcessOwner {
39 readonly threadId: ThreadId;
40 readonly terminalId: string;
41}
42
43const terminalOwnerKey = (owner: {
44 readonly threadId: string;
45 readonly terminalId: string;
46}): string => `${owner.threadId}\u0000${owner.terminalId}`;
47
48const parseLsofOutput = (
49 raw: string,
50 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(),
51): ReadonlyArray<DiscoveredLocalServer> => {
42 const seen = new Map<string, DiscoveredLocalServer>();52 const seen = new Map<string, DiscoveredLocalServer>();
43 let pid: number | null = null;53 let pid: number | null = null;
44 let processName: string | null = null;54 let processName: string | null = null;
...@@ -69,6 +79,7 @@ const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => {...@@ -69,6 +79,7 @@ const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => {
69 url,79 url,
70 processName,80 processName,
71 pid,81 pid,
82 terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null),
72 });83 });
73 }84 }
74 }85 }
...@@ -91,22 +102,31 @@ const parsePortFromLsofName = (name: string): number | null => {...@@ -91,22 +102,31 @@ const parsePortFromLsofName = (name: string): number | null => {
91 return port;102 return port;
92};103};
93104
94const probeLsof = (105const parseWindowsListenerOutput = (
95 processRunner: ProcessRunnerShape,106 raw: string,
96): Effect.Effect<ReadonlyArray<DiscoveredLocalServer> | null> =>107 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(),
97 processRunner108): ReadonlyArray<DiscoveredLocalServer> => {
98 .run({109 const seen = new Map<number, DiscoveredLocalServer>();
99 command: "lsof",110 for (const line of raw.split(/\r?\n/g)) {
100 args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"],111 const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4);
101 timeout: Duration.millis(LSOF_TIMEOUT_MS),112 const host = hostRaw?.trim() ?? "";
102 maxOutputBytes: 1024 * 1024,113 if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue;
103 outputMode: "truncate",114 const port = Number(portRaw);
104 })115 const pid = Number(pidRaw);
105 .pipe(116 if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue;
106 Effect.mapError((cause) => new LsofProbeError({ cause })),117 const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null;
107 Effect.map((result) => parseLsofOutput(result.stdout)),118 if (seen.has(port)) continue;
108 Effect.orElseSucceed(() => null),119 seen.set(port, {
109 );120 host: "localhost",
121 port,
122 url: `http://localhost:${port}`,
123 processName: processNameRaw?.trim() || null,
124 pid: normalizedPid,
125 terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null),
126 });
127 }
128 return [...seen.values()].toSorted((left, right) => left.port - right.port);
129};
110130
111const probeTcpPort = (port: number): Promise<boolean> =>131const probeTcpPort = (port: number): Promise<boolean> =>
112 new Promise((resolve) => {132 new Promise((resolve) => {
...@@ -138,6 +158,7 @@ const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>...@@ -138,6 +158,7 @@ const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>
138 url: `http://localhost:${r.port}`,158 url: `http://localhost:${r.port}`,
139 processName: null,159 processName: null,
140 pid: null,160 pid: null,
161 terminal: null,
141 }));162 }));
142 });163 });
143164
...@@ -155,7 +176,9 @@ const serversEqual = (...@@ -155,7 +176,9 @@ const serversEqual = (
155 a.port !== b.port ||176 a.port !== b.port ||
156 a.url !== b.url ||177 a.url !== b.url ||
157 a.processName !== b.processName ||178 a.processName !== b.processName ||
158 a.pid !== b.pid179 a.pid !== b.pid ||
180 a.terminal?.threadId !== b.terminal?.threadId ||
181 a.terminal?.terminalId !== b.terminal?.terminalId
159 ) {182 ) {
160 return false;183 return false;
161 }184 }
...@@ -163,12 +186,19 @@ const serversEqual = (...@@ -163,12 +186,19 @@ const serversEqual = (
163 return true;186 return true;
164};187};
165188
166export const makePreviewPortScanner = Effect.gen(function* () {189export const makePortDiscovery = Effect.gen(function* () {
167 const processRunner = yield* ProcessRunner;190 const processRunner = yield* ProcessRunner;
168 const stateRef = yield* Ref.make<ScannerState>({191 const stateRef = yield* Ref.make<ScannerState>({
169 lastSnapshot: [],192 lastSnapshot: [],
170 });193 });
171 const listeners = new Set<Listener>();194 const listeners = new Set<Listener>();
195 const terminalProcesses = new Map<
196 string,
197 {
198 readonly owner: TerminalProcessOwner;
199 readonly processIds: ReadonlySet<number>;
200 }
201 >();
172 // Plain integer because the release callback returned by `retain()` runs202 // Plain integer because the release callback returned by `retain()` runs
173 // outside any Effect context (the WS subscriber's release path) and must203 // outside any Effect context (the WS subscriber's release path) and must
174 // be a synchronous side-effect-only function.204 // be a synchronous side-effect-only function.
...@@ -176,11 +206,43 @@ export const makePreviewPortScanner = Effect.gen(function* () {...@@ -176,11 +206,43 @@ export const makePreviewPortScanner = Effect.gen(function* () {
176206
177 const scanOnce = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> =>207 const scanOnce = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> =>
178 Effect.gen(function* () {208 Effect.gen(function* () {
209 const terminalByProcessId = new Map<number, TerminalProcessOwner>();
210 for (const registration of terminalProcesses.values()) {
211 for (const processId of registration.processIds) {
212 terminalByProcessId.set(processId, registration.owner);
213 }
214 }
179 if (process.platform === "win32") {215 if (process.platform === "win32") {
216 const command =
217 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }';
218 const listeners = yield* processRunner
219 .run({
220 command: "powershell.exe",
221 args: ["-NoProfile", "-NonInteractive", "-Command", command],
222 timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS),
223 maxOutputBytes: 1024 * 1024,
224 outputMode: "truncate",
225 })
226 .pipe(
227 Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)),
228 Effect.catchCause(() => Effect.succeed(null)),
229 );
230 if (listeners !== null) return listeners;
180 return yield* probeCommonPorts();231 return yield* probeCommonPorts();
181 }232 }
182 const lsof = yield* probeLsof(processRunner);233 const lsofResult = yield* processRunner
183 if (lsof !== null) return lsof;234 .run({
235 command: "lsof",
236 args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"],
237 timeout: Duration.millis(LSOF_TIMEOUT_MS),
238 maxOutputBytes: 1024 * 1024,
239 outputMode: "truncate",
240 })
241 .pipe(
242 Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)),
243 Effect.catchCause(() => Effect.succeed(null)),
244 );
245 if (lsofResult !== null) return lsofResult;
184 return yield* probeCommonPorts();246 return yield* probeCommonPorts();
185 });247 });
186248
...@@ -204,7 +266,7 @@ export const makePreviewPortScanner = Effect.gen(function* () {...@@ -204,7 +266,7 @@ export const makePreviewPortScanner = Effect.gen(function* () {
204 // currently retained, so the cost is one Ref.get every POLL_INTERVAL.266 // currently retained, so the cost is one Ref.get every POLL_INTERVAL.
205 yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));267 yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
206268
207 const retain: PreviewPortScannerShape["retain"] = () =>269 const retain: PortDiscoveryShape["retain"] = () =>
208 Effect.gen(function* () {270 Effect.gen(function* () {
209 const wasIdle = retainCount === 0;271 const wasIdle = retainCount === 0;
210 retainCount += 1;272 retainCount += 1;
...@@ -221,7 +283,7 @@ export const makePreviewPortScanner = Effect.gen(function* () {...@@ -221,7 +283,7 @@ export const makePreviewPortScanner = Effect.gen(function* () {
221 };283 };
222 });284 });
223285
224 const subscribe: PreviewPortScannerShape["subscribe"] = (listener) =>286 const subscribe: PortDiscoveryShape["subscribe"] = (listener) =>
225 Effect.sync(() => {287 Effect.sync(() => {
226 listeners.add(listener);288 listeners.add(listener);
227 return () => {289 return () => {
...@@ -229,18 +291,43 @@ export const makePreviewPortScanner = Effect.gen(function* () {...@@ -229,18 +291,43 @@ export const makePreviewPortScanner = Effect.gen(function* () {
229 };291 };
230 });292 });
231293
294 const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = (input) =>
295 Effect.sync(() => {
296 const owner = {
297 threadId: ThreadId.make(input.threadId),
298 terminalId: input.terminalId,
299 };
300 const processIds = new Set(
301 input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0),
302 );
303 const key = terminalOwnerKey(owner);
304 if (processIds.size === 0) {
305 terminalProcesses.delete(key);
306 return;
307 }
308 terminalProcesses.set(key, { owner, processIds });
309 });
310
311 const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = (input) =>
312 Effect.sync(() => {
313 terminalProcesses.delete(terminalOwnerKey(input));
314 });
315
232 return {316 return {
233 scan: scanOnce,317 scan: scanOnce,
234 subscribe,318 subscribe,
235 retain,319 retain,
236 } satisfies PreviewPortScannerShape;320 registerTerminalProcesses,
321 unregisterTerminal,
322 } satisfies PortDiscoveryShape;
237});323});
238324
239export const PreviewPortScannerLive = Layer.effect(PreviewPortScanner, makePreviewPortScanner);325export const PortDiscoveryLive = Layer.effect(PortDiscovery, makePortDiscovery);
240326
241/** Exposed for tests. */327/** Exposed for tests. */
242export const __testing = {328export const __testing = {
243 parseLsofOutput,329 parseLsofOutput,
244 parsePortFromLsofName,330 parsePortFromLsofName,
331 parseWindowsListenerOutput,
245 serversEqual,332 serversEqual,
246};333};
apps/server/src/preview/Services/PortScanner.ts+18-8
...@@ -1,17 +1,17 @@...@@ -1,17 +1,17 @@
1/**1/**
2 * PreviewPortScanner - Discovers listening localhost ports for the preview2 * PortDiscovery - Discovers listening localhost ports and attributes them to
3 * empty-state recommendations.3 * registered terminal process families.
4 *4 *
5 * Reference-counted polling: the scanner only runs when at least one client5 * Reference-counted polling: the scanner only runs when at least one client
6 * has called `retain()`. This keeps idle desktops from running `lsof` every6 * has called `retain()`. This keeps idle desktops from running `lsof` every
7 * 3 seconds for nothing.7 * 3 seconds for nothing.
8 *8 *
9 * @module PreviewPortScanner9 * @module PortDiscovery
10 */10 */
11import type { DiscoveredLocalServer } from "@t3tools/contracts";11import type { DiscoveredLocalServer } from "@t3tools/contracts";
12import { Context, type Effect } from "effect";12import { Context, type Effect } from "effect";
1313
14export interface PreviewPortScannerShape {14export interface PortDiscoveryShape {
15 /** One-shot snapshot of currently listening localhost ports. */15 /** One-shot snapshot of currently listening localhost ports. */
16 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;16 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;
17 /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */17 /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */
...@@ -23,12 +23,22 @@ export interface PreviewPortScannerShape {...@@ -23,12 +23,22 @@ export interface PreviewPortScannerShape {
23 * release fn. When release count hits 0, polling stops.23 * release fn. When release count hits 0, polling stops.
24 */24 */
25 readonly retain: () => Effect.Effect<() => void>;25 readonly retain: () => Effect.Effect<() => void>;
26 /** Associate a terminal with its current process family for port attribution. */
27 readonly registerTerminalProcesses: (input: {
28 readonly threadId: string;
29 readonly terminalId: string;
30 readonly processIds: ReadonlyArray<number>;
31 }) => Effect.Effect<void>;
32 /** Remove process attribution for a terminal that stopped or closed. */
33 readonly unregisterTerminal: (input: {
34 readonly threadId: string;
35 readonly terminalId: string;
36 }) => Effect.Effect<void>;
26}37}
2738
28export class PreviewPortScanner extends Context.Service<39export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()(
29 PreviewPortScanner,40 "t3/preview/Services/PortScanner/PortDiscovery",
30 PreviewPortScannerShape41) {}
31>()("t3/preview/Services/PortScanner/PreviewPortScanner") {}
3242
33/** Curated list of common dev-server ports for the Windows TCP-probe fallback. */43/** Curated list of common dev-server ports for the Windows TCP-probe fallback. */
34export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([44export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([
apps/server/src/server.test.ts+4-2
...@@ -100,7 +100,7 @@ import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRu...@@ -100,7 +100,7 @@ import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRu
100import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";100import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
101import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";101import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";
102import { PreviewManager } from "./preview/Services/Manager.ts";102import { PreviewManager } from "./preview/Services/Manager.ts";
103import { PreviewPortScanner } from "./preview/Services/PortScanner.ts";103import { PortDiscovery } from "./preview/Services/PortScanner.ts";
104import {104import {
105 BrowserTraceCollector,105 BrowserTraceCollector,
106 type BrowserTraceCollectorShape,106 type BrowserTraceCollectorShape,
...@@ -681,10 +681,12 @@ const buildAppUnderTest = (options?: {...@@ -681,10 +681,12 @@ const buildAppUnderTest = (options?: {
681 PubSub.subscribe(pubsub),681 PubSub.subscribe(pubsub),
682 ),682 ),
683 }),683 }),
684 Layer.mock(PreviewPortScanner)({684 Layer.mock(PortDiscovery)({
685 scan: () => Effect.succeed([]),685 scan: () => Effect.succeed([]),
686 subscribe: () => Effect.succeed(() => {}),686 subscribe: () => Effect.succeed(() => {}),
687 retain: () => Effect.succeed(() => {}),687 retain: () => Effect.succeed(() => {}),
688 registerTerminalProcesses: () => Effect.void,
689 unregisterTerminal: () => Effect.void,
688 }),690 }),
689 ),691 ),
690 ),692 ),
apps/server/src/server.ts+7-5
...@@ -36,7 +36,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts";...@@ -36,7 +36,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts";
36import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";36import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
37import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";37import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
38import { PreviewManagerLive } from "./preview/Layers/Manager.ts";38import { PreviewManagerLive } from "./preview/Layers/Manager.ts";
39import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts";39import { PortDiscoveryLive } from "./preview/Layers/PortScanner.ts";
40import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts";40import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts";
41import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts";41import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts";
42import * as ProcessRunner from "./processRunner.ts";42import * as ProcessRunner from "./processRunner.ts";
...@@ -236,13 +236,15 @@ const CheckpointingLayerLive = Layer.empty.pipe(...@@ -236,13 +236,15 @@ const CheckpointingLayerLive = Layer.empty.pipe(
236 Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))),236 Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))),
237);237);
238238
239const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive));239const PortScannerLayerLive = PortDiscoveryLive.pipe(Layer.provide(ProcessRunner.layer));
240240
241const PreviewLayerLive = Layer.mergeAll(241const TerminalLayerLive = TerminalManagerLive.pipe(
242 PreviewManagerLive,242 Layer.provide(PtyAdapterLive),
243 PreviewPortScannerLive.pipe(Layer.provide(ProcessRunner.layer)),243 Layer.provide(PortScannerLayerLive),
244);244);
245245
246const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PortScannerLayerLive);
247
246const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe(248const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe(
247 Layer.provide(WorkspacePathsLive),249 Layer.provide(WorkspacePathsLive),
248 Layer.provideMerge(VcsDriverRegistryLayerLive),250 Layer.provideMerge(VcsDriverRegistryLayerLive),
apps/server/src/terminal/Layers/Manager.test.ts+10-4
...@@ -204,6 +204,7 @@ interface CreateManagerOptions {...@@ -204,6 +204,7 @@ interface CreateManagerOptions {
204 subprocessInspector?: (terminalPid: number) => Effect.Effect<{204 subprocessInspector?: (terminalPid: number) => Effect.Effect<{
205 readonly hasRunningSubprocess: boolean;205 readonly hasRunningSubprocess: boolean;
206 readonly childCommand: string | null;206 readonly childCommand: string | null;
207 readonly processIds: ReadonlyArray<number>;
207 }>;208 }>;
208 subprocessPollIntervalMs?: number;209 subprocessPollIntervalMs?: number;
209 processKillGraceMs?: number;210 processKillGraceMs?: number;
...@@ -745,7 +746,8 @@ it.layer(...@@ -745,7 +746,8 @@ it.layer(
745 let inspect: {746 let inspect: {
746 readonly hasRunningSubprocess: boolean;747 readonly hasRunningSubprocess: boolean;
747 readonly childCommand: string | null;748 readonly childCommand: string | null;
748 } = { hasRunningSubprocess: false, childCommand: null };749 readonly processIds: ReadonlyArray<number>;
750 } = { hasRunningSubprocess: false, childCommand: null, processIds: [] };
749 const { manager, getEvents } = yield* createManager(5, {751 const { manager, getEvents } = yield* createManager(5, {
750 subprocessInspector: () => Effect.succeed(inspect),752 subprocessInspector: () => Effect.succeed(inspect),
751 subprocessPollIntervalMs: 20,753 subprocessPollIntervalMs: 20,
...@@ -754,7 +756,7 @@ it.layer(...@@ -754,7 +756,7 @@ it.layer(
754 yield* manager.open(openInput());756 yield* manager.open(openInput());
755 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);757 expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false);
756758
757 inspect = { hasRunningSubprocess: true, childCommand: "vim" };759 inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] };
758 yield* waitFor(760 yield* waitFor(
759 Effect.map(getEvents, (events) =>761 Effect.map(getEvents, (events) =>
760 events.some(762 events.some(
...@@ -767,7 +769,7 @@ it.layer(...@@ -767,7 +769,7 @@ it.layer(
767 "1200 millis",769 "1200 millis",
768 );770 );
769771
770 inspect = { hasRunningSubprocess: false, childCommand: null };772 inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] };
771 yield* waitFor(773 yield* waitFor(
772 Effect.map(getEvents, (events) =>774 Effect.map(getEvents, (events) =>
773 events.some(775 events.some(
...@@ -788,7 +790,11 @@ it.layer(...@@ -788,7 +790,11 @@ it.layer(
788 const { manager } = yield* createManager(5, {790 const { manager } = yield* createManager(5, {
789 subprocessInspector: () => {791 subprocessInspector: () => {
790 checks += 1;792 checks += 1;
791 return Effect.succeed({ hasRunningSubprocess: false, childCommand: null });793 return Effect.succeed({
794 hasRunningSubprocess: false,
795 childCommand: null,
796 processIds: [],
797 });
792 },798 },
793 subprocessPollIntervalMs: 20,799 subprocessPollIntervalMs: 20,
794 });800 });
apps/server/src/terminal/Layers/Manager.ts+97-15
...@@ -33,6 +33,7 @@ import {...@@ -33,6 +33,7 @@ import {
33 terminalSessionsTotal,33 terminalSessionsTotal,
34} from "../../observability/Metrics.ts";34} from "../../observability/Metrics.ts";
35import * as ProcessRunner from "../../processRunner.ts";35import * as ProcessRunner from "../../processRunner.ts";
36import { PortDiscovery } from "../../preview/Services/PortScanner.ts";
36import {37import {
37 TerminalCwdError,38 TerminalCwdError,
38 TerminalHistoryError,39 TerminalHistoryError,
...@@ -82,6 +83,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess...@@ -82,6 +83,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess
82interface TerminalSubprocessInspectResult {83interface TerminalSubprocessInspectResult {
83 readonly hasRunningSubprocess: boolean;84 readonly hasRunningSubprocess: boolean;
84 readonly childCommand: string | null;85 readonly childCommand: string | null;
86 readonly processIds: ReadonlyArray<number>;
85}87}
8688
87interface TerminalSubprocessInspector {89interface TerminalSubprocessInspector {
...@@ -505,12 +507,8 @@ function windowsInspectSubprocess(...@@ -505,12 +507,8 @@ function windowsInspectSubprocess(
505 TerminalSubprocessCheckError,507 TerminalSubprocessCheckError,
506 ProcessRunner.ProcessRunner508 ProcessRunner.ProcessRunner
507> {509> {
508 const command = [510 const command =
509 `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`,511 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }';
510 "if ($null -eq $c) { exit 1 }",
511 "Write-Output $c.Name",
512 "exit 0",
513 ].join("; ");
514 return Effect.gen(function* () {512 return Effect.gen(function* () {
515 const processRunner = yield* ProcessRunner.ProcessRunner;513 const processRunner = yield* ProcessRunner.ProcessRunner;
516 return yield* processRunner.run({514 return yield* processRunner.run({
...@@ -524,16 +522,41 @@ function windowsInspectSubprocess(...@@ -524,16 +522,41 @@ function windowsInspectSubprocess(
524 }).pipe(522 }).pipe(
525 Effect.map((result) => {523 Effect.map((result) => {
526 if (result.code !== 0) {524 if (result.code !== 0) {
527 return { hasRunningSubprocess: false, childCommand: null } as const;525 return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
528 }526 }
529 const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? "";527 const processNameById = new Map<number, string>();
530 if (name.length === 0) {528 const childrenByParent = new Map<number, number[]>();
531 return { hasRunningSubprocess: true, childCommand: null } as const;529 for (const line of result.stdout.split(/\r?\n/g)) {
530 const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3);
531 const pid = Number(pidRaw);
532 const parentPid = Number(parentPidRaw);
533 if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue;
534 processNameById.set(pid, nameRaw?.trim() ?? "");
535 const children = childrenByParent.get(parentPid) ?? [];
536 children.push(pid);
537 childrenByParent.set(parentPid, children);
532 }538 }
533 const normalized = normalizeChildCommandName(name, platform);539 const directChildren = childrenByParent.get(terminalPid) ?? [];
540 const childPid = directChildren[0];
541 if (childPid === undefined) {
542 return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
543 }
544 const processIds = new Set<number>([terminalPid]);
545 const pending = [terminalPid];
546 while (pending.length > 0) {
547 const parentPid = pending.pop();
548 if (parentPid === undefined) continue;
549 for (const pid of childrenByParent.get(parentPid) ?? []) {
550 if (processIds.has(pid)) continue;
551 processIds.add(pid);
552 pending.push(pid);
553 }
554 }
555 const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform);
534 return {556 return {
535 hasRunningSubprocess: true,557 hasRunningSubprocess: true,
536 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,558 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
559 processIds: [...processIds],
537 } as const;560 } as const;
538 }),561 }),
539 Effect.mapError(562 Effect.mapError(
...@@ -606,14 +629,14 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func...@@ -606,14 +629,14 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func
606 if (pgrepResult.value.code === 0) {629 if (pgrepResult.value.code === 0) {
607 childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout);630 childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout);
608 } else if (pgrepResult.value.code === 1) {631 } else if (pgrepResult.value.code === 1) {
609 return { hasRunningSubprocess: false, childCommand: null };632 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
610 }633 }
611 }634 }
612635
613 if (childPid === null) {636 if (childPid === null) {
614 const psResult = yield* Effect.exit(runPs);637 const psResult = yield* Effect.exit(runPs);
615 if (psResult._tag === "Failure" || psResult.value.code !== 0) {638 if (psResult._tag === "Failure" || psResult.value.code !== 0) {
616 return { hasRunningSubprocess: false, childCommand: null };639 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
617 }640 }
618 for (const line of psResult.value.stdout.split(/\r?\n/g)) {641 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
619 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);642 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
...@@ -628,7 +651,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func...@@ -628,7 +651,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func
628 }651 }
629652
630 if (childPid === null) {653 if (childPid === null) {
631 return { hasRunningSubprocess: false, childCommand: null };654 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
632 }655 }
633656
634 const runComm = processRunner.run({657 const runComm = processRunner.run({
...@@ -663,16 +686,43 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func...@@ -663,16 +686,43 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func
663 }686 }
664687
665 const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null;688 const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null;
689 const processIds = new Set<number>([terminalPid]);
690 const psResult = yield* Effect.exit(runPs);
691 if (psResult._tag === "Success" && psResult.value.code === 0) {
692 const childrenByParent = new Map<number, number[]>();
693 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
694 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
695 const pid = Number(pidRaw);
696 const ppid = Number(ppidRaw);
697 if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;
698 const children = childrenByParent.get(ppid) ?? [];
699 children.push(pid);
700 childrenByParent.set(ppid, children);
701 }
702 const pending = [terminalPid];
703 while (pending.length > 0) {
704 const parentPid = pending.pop();
705 if (parentPid === undefined) continue;
706 for (const child of childrenByParent.get(parentPid) ?? []) {
707 if (processIds.has(child)) continue;
708 processIds.add(child);
709 pending.push(child);
710 }
711 }
712 } else {
713 processIds.add(childPid);
714 }
666 return {715 return {
667 hasRunningSubprocess: true,716 hasRunningSubprocess: true,
668 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,717 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
718 processIds: [...processIds],
669 };719 };
670});720});
671721
672function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) {722function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) {
673 return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) {723 return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) {
674 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {724 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
675 return { hasRunningSubprocess: false, childCommand: null };725 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
676 }726 }
677 if (platform === "win32") {727 if (platform === "win32") {
678 return yield* windowsInspectSubprocess(terminalPid, platform);728 return yield* windowsInspectSubprocess(terminalPid, platform);
...@@ -932,14 +982,26 @@ interface TerminalManagerOptions {...@@ -932,14 +982,26 @@ interface TerminalManagerOptions {
932 subprocessPollIntervalMs?: number;982 subprocessPollIntervalMs?: number;
933 processKillGraceMs?: number;983 processKillGraceMs?: number;
934 maxRetainedInactiveSessions?: number;984 maxRetainedInactiveSessions?: number;
985 registerTerminalProcesses?: (input: {
986 readonly threadId: string;
987 readonly terminalId: string;
988 readonly processIds: ReadonlyArray<number>;
989 }) => Effect.Effect<void>;
990 unregisterTerminal?: (input: {
991 readonly threadId: string;
992 readonly terminalId: string;
993 }) => Effect.Effect<void>;
935}994}
936995
937const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () {996const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () {
938 const { terminalLogsDir } = yield* ServerConfig;997 const { terminalLogsDir } = yield* ServerConfig;
939 const ptyAdapter = yield* PtyAdapter;998 const ptyAdapter = yield* PtyAdapter;
999 const portDiscovery = yield* PortDiscovery;
940 return yield* makeTerminalManagerWithOptions({1000 return yield* makeTerminalManagerWithOptions({
941 logsDir: terminalLogsDir,1001 logsDir: terminalLogsDir,
942 ptyAdapter,1002 ptyAdapter,
1003 registerTerminalProcesses: portDiscovery.registerTerminalProcesses,
1004 unregisterTerminal: portDiscovery.unregisterTerminal,
943 });1005 });
944});1006});
9451007
...@@ -967,6 +1029,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -967,6 +1029,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
967 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;1029 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
968 const maxRetainedInactiveSessions =1030 const maxRetainedInactiveSessions =
969 options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS;1031 options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS;
1032 const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void);
1033 const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void);
9701034
971 yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie);1035 yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie);
9721036
...@@ -1495,6 +1559,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1495,6 +1559,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1495 }1559 }
14961560
1497 yield* clearKillFiber(action.process);1561 yield* clearKillFiber(action.process);
1562 yield* unregisterTerminal({
1563 threadId: action.threadId,
1564 terminalId: action.terminalId,
1565 });
1498 yield* publishEvent({1566 yield* publishEvent({
1499 type: "exited",1567 type: "exited",
1500 threadId: action.threadId,1568 threadId: action.threadId,
...@@ -1531,6 +1599,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1531,6 +1599,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1531 });1599 });
15321600
1533 yield* clearKillFiber(process);1601 yield* clearKillFiber(process);
1602 yield* unregisterTerminal({
1603 threadId: session.threadId,
1604 terminalId: session.terminalId,
1605 });
1534 yield* startKillEscalation(process, session.threadId, session.terminalId);1606 yield* startKillEscalation(process, session.threadId, session.terminalId);
1535 yield* evictInactiveSessionsIfNeeded();1607 yield* evictInactiveSessionsIfNeeded();
1536 });1608 });
...@@ -1700,6 +1772,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1700,6 +1772,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1700 advanceEventSequence(session);1772 advanceEventSequence(session);
1701 return [undefined, state] as const;1773 return [undefined, state] as const;
1702 });1774 });
1775 yield* unregisterTerminal({
1776 threadId: session.threadId,
1777 terminalId: session.terminalId,
1778 });
17031779
1704 yield* evictInactiveSessionsIfNeeded();1780 yield* evictInactiveSessionsIfNeeded();
17051781
...@@ -1731,6 +1807,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1731,6 +1807,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
17311807
1732 if (Option.isSome(session)) {1808 if (Option.isSome(session)) {
1733 yield* stopProcess(session.value);1809 yield* stopProcess(session.value);
1810 yield* unregisterTerminal({ threadId, terminalId });
1734 yield* persistHistory(threadId, terminalId, session.value.history);1811 yield* persistHistory(threadId, terminalId, session.value.history);
1735 }1812 }
17361813
...@@ -1791,6 +1868,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1791,6 +1868,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1791 }1868 }
17921869
1793 const next = inspectResult.value;1870 const next = inspectResult.value;
1871 yield* registerTerminalProcesses({
1872 threadId: session.threadId,
1873 terminalId: session.terminalId,
1874 processIds: next.processIds,
1875 });
1794 const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null;1876 const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null;
1795 const event = yield* modifyManagerState((state) => {1877 const event = yield* modifyManagerState((state) => {
1796 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(1878 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
apps/server/src/ws.ts+5-5
...@@ -72,7 +72,7 @@ import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";...@@ -72,7 +72,7 @@ import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
72import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";72import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
73import { TerminalManager } from "./terminal/Services/Manager.ts";73import { TerminalManager } from "./terminal/Services/Manager.ts";
74import { PreviewManager } from "./preview/Services/Manager.ts";74import { PreviewManager } from "./preview/Services/Manager.ts";
75import { PreviewPortScanner } from "./preview/Services/PortScanner.ts";75import { PortDiscovery } from "./preview/Services/PortScanner.ts";
76import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts";76import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts";
77import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";77import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
78import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";78import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
...@@ -257,7 +257,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -257,7 +257,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
257 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;257 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
258 const terminalManager = yield* TerminalManager;258 const terminalManager = yield* TerminalManager;
259 const previewManager = yield* PreviewManager;259 const previewManager = yield* PreviewManager;
260 const previewPortScanner = yield* PreviewPortScanner;260 const portDiscovery = yield* PortDiscovery;
261 const providerRegistry = yield* ProviderRegistry;261 const providerRegistry = yield* ProviderRegistry;
262 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;262 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
263 const config = yield* ServerConfig;263 const config = yield* ServerConfig;
...@@ -1426,14 +1426,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -1426,14 +1426,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
1426 Stream.callback<DiscoveredLocalServerList>((queue) =>1426 Stream.callback<DiscoveredLocalServerList>((queue) =>
1427 Effect.acquireRelease(1427 Effect.acquireRelease(
1428 Effect.gen(function* () {1428 Effect.gen(function* () {
1429 const release = yield* previewPortScanner.retain();1429 const release = yield* portDiscovery.retain();
1430 const initial = yield* previewPortScanner.scan();1430 const initial = yield* portDiscovery.scan();
1431 const initialScannedAt = DateTime.formatIso(yield* DateTime.now);1431 const initialScannedAt = DateTime.formatIso(yield* DateTime.now);
1432 yield* Queue.offer(queue, {1432 yield* Queue.offer(queue, {
1433 servers: initial,1433 servers: initial,
1434 scannedAt: initialScannedAt,1434 scannedAt: initialScannedAt,
1435 });1435 });
1436 const unsubscribe = yield* previewPortScanner.subscribe((servers) =>1436 const unsubscribe = yield* portDiscovery.subscribe((servers) =>
1437 Effect.gen(function* () {1437 Effect.gen(function* () {
1438 const scannedAt = DateTime.formatIso(yield* DateTime.now);1438 const scannedAt = DateTime.formatIso(yield* DateTime.now);
1439 yield* Queue.offer(queue, { servers, scannedAt });1439 yield* Queue.offer(queue, { servers, scannedAt });
apps/web/src/browser/ElectronBrowserHost.tsx+46-1
...@@ -1,14 +1,18 @@...@@ -1,14 +1,18 @@
1"use client";1"use client";
22
3import { parseScopedThreadKey } from "@t3tools/client-runtime";3import { parseScopedThreadKey } from "@t3tools/client-runtime";
4import { useMemo } from "react";4import { useEffect, useMemo } from "react";
55
6import { isElectron } from "~/env";6import { isElectron } from "~/env";
7import { useTheme } from "~/hooks/useTheme";
7import { usePreviewStateStore } from "~/previewStateStore";8import { usePreviewStateStore } from "~/previewStateStore";
89
10import { readPreviewAnnotationTheme } from "./annotationTheme";
11import { useBrowserPointerStore } from "./browserPointerStore";
9import { HostedBrowserWebview } from "./HostedBrowserWebview";12import { HostedBrowserWebview } from "./HostedBrowserWebview";
1013
11export function ElectronBrowserHost() {14export function ElectronBrowserHost() {
15 const { resolvedTheme } = useTheme();
12 const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey);16 const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey);
13 const sessions = useMemo(17 const sessions = useMemo(
14 () =>18 () =>
...@@ -25,6 +29,47 @@ export function ElectronBrowserHost() {...@@ -25,6 +29,47 @@ export function ElectronBrowserHost() {
25 [previewByThreadKey],29 [previewByThreadKey],
26 );30 );
2731
32 useEffect(() => {
33 const preview = window.desktopBridge?.preview;
34 if (!preview) return;
35
36 let lastSerializedTheme = "";
37 const syncTheme = () => {
38 const theme = readPreviewAnnotationTheme();
39 const serializedTheme = JSON.stringify(theme);
40 if (serializedTheme === lastSerializedTheme) return;
41 lastSerializedTheme = serializedTheme;
42 void preview.setAnnotationTheme(theme).catch(() => {
43 lastSerializedTheme = "";
44 });
45 };
46 const frameId = window.requestAnimationFrame(syncTheme);
47 const observer = new MutationObserver(syncTheme);
48 observer.observe(document.documentElement, {
49 attributes: true,
50 attributeFilter: ["class", "style"],
51 });
52 const headObserver = new MutationObserver(syncTheme);
53 headObserver.observe(document.head, {
54 childList: true,
55 subtree: true,
56 characterData: true,
57 });
58 return () => {
59 window.cancelAnimationFrame(frameId);
60 observer.disconnect();
61 headObserver.disconnect();
62 };
63 }, [resolvedTheme]);
64
65 useEffect(() => {
66 const preview = window.desktopBridge?.preview;
67 if (!preview) return;
68 return preview.onPointerEvent((event) => {
69 useBrowserPointerStore.getState().apply(event);
70 });
71 }, []);
72
28 if (!isElectron) return null;73 if (!isElectron) return null;
29 return (74 return (
30 <div className="contents" data-electron-browser-host>75 <div className="contents" data-electron-browser-host>
apps/web/src/browser/annotationTheme.ts created+28
...@@ -0,0 +1,28 @@
1import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts";
2
3const readVariable = (styles: CSSStyleDeclaration, name: string, fallback: string): string =>
4 styles.getPropertyValue(name).trim() || fallback;
5
6export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme {
7 const root = document.documentElement;
8 const styles = getComputedStyle(root);
9 return {
10 colorScheme: root.classList.contains("dark") ? "dark" : "light",
11 radius: readVariable(styles, "--radius", "0.625rem"),
12 background: readVariable(styles, "--background", "white"),
13 foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"),
14 popover: readVariable(styles, "--popover", "white"),
15 popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"),
16 primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"),
17 primaryForeground: readVariable(styles, "--primary-foreground", "white"),
18 muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"),
19 mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"),
20 accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"),
21 accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"),
22 border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"),
23 input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"),
24 ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"),
25 fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"),
26 fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"),
27 };
28}
apps/web/src/browser/browserPointerStore.test.ts created+68
...@@ -0,0 +1,68 @@
1import { beforeEach, describe, expect, it } from "vite-plus/test";
2
3import { useBrowserPointerStore } from "./browserPointerStore";
4
5beforeEach(() => {
6 useBrowserPointerStore.setState({ byTabId: {} });
7});
8
9describe("browserPointerStore", () => {
10 it("tracks the latest pointer target independently for each tab", () => {
11 const store = useBrowserPointerStore.getState();
12 store.apply({
13 tabId: "tab_a",
14 phase: "move",
15 x: 20,
16 y: 30,
17 sequence: 0,
18 createdAt: "2026-06-12T00:00:00.000Z",
19 });
20 store.apply({
21 tabId: "tab_b",
22 phase: "move",
23 x: 40,
24 y: 50,
25 sequence: 1,
26 createdAt: "2026-06-12T00:00:01.000Z",
27 });
28 store.apply({
29 tabId: "tab_a",
30 phase: "click",
31 x: 60,
32 y: 70,
33 sequence: 2,
34 createdAt: "2026-06-12T00:00:02.000Z",
35 });
36
37 expect(useBrowserPointerStore.getState().byTabId).toMatchObject({
38 tab_a: { phase: "click", x: 60, y: 70, sequence: 2 },
39 tab_b: { phase: "move", x: 40, y: 50, sequence: 1 },
40 });
41 });
42
43 it("clears one tab without affecting the others", () => {
44 const store = useBrowserPointerStore.getState();
45 store.apply({
46 tabId: "tab_a",
47 phase: "move",
48 x: 20,
49 y: 30,
50 sequence: 0,
51 createdAt: "2026-06-12T00:00:00.000Z",
52 });
53 store.apply({
54 tabId: "tab_b",
55 phase: "move",
56 x: 40,
57 y: 50,
58 sequence: 1,
59 createdAt: "2026-06-12T00:00:01.000Z",
60 });
61
62 store.clear("tab_a");
63
64 expect(useBrowserPointerStore.getState().byTabId).toEqual({
65 tab_b: expect.objectContaining({ x: 40, y: 50 }),
66 });
67 });
68});
apps/web/src/browser/browserPointerStore.ts created+25
...@@ -0,0 +1,25 @@
1import type { DesktopPreviewPointerEvent } from "@t3tools/contracts";
2import { create } from "zustand";
3
4interface BrowserPointerStoreState {
5 readonly byTabId: Record<string, DesktopPreviewPointerEvent>;
6 readonly apply: (event: DesktopPreviewPointerEvent) => void;
7 readonly clear: (tabId: string) => void;
8}
9
10export const useBrowserPointerStore = create<BrowserPointerStoreState>()((set) => ({
11 byTabId: {},
12 apply: (event) =>
13 set((state) => ({
14 byTabId: {
15 ...state.byTabId,
16 [event.tabId]: event,
17 },
18 })),
19 clear: (tabId) =>
20 set((state) => {
21 if (!(tabId in state.byTabId)) return state;
22 const { [tabId]: _removed, ...byTabId } = state.byTabId;
23 return { byTabId };
24 }),
25}));
apps/web/src/components/ChatView.browser.tsx+138-5
...@@ -65,6 +65,7 @@ import { AppAtomRegistryProvider } from "../rpc/atomRegistry";...@@ -65,6 +65,7 @@ import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
65import { getServerConfig } from "../rpc/serverState";65import { getServerConfig } from "../rpc/serverState";
66import { getRouter } from "../router";66import { getRouter } from "../router";
67import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";67import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
68import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
68import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";69import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
69import { terminalSessionManager } from "../terminalSessionState";70import { terminalSessionManager } from "../terminalSessionState";
70import { useTerminalUiStateStore } from "../terminalUiStateStore";71import { useTerminalUiStateStore } from "../terminalUiStateStore";
...@@ -1801,6 +1802,8 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1801,6 +1802,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
1801 useTerminalUiStateStore.setState({1802 useTerminalUiStateStore.setState({
1802 terminalUiStateByThreadKey: {},1803 terminalUiStateByThreadKey: {},
1803 });1804 });
1805 useRightPanelStore.persist.clearStorage();
1806 useRightPanelStore.setState({ byThreadKey: {} });
1804 });1807 });
18051808
1806 afterEach(() => {1809 afterEach(() => {
...@@ -2054,12 +2057,15 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2054,12 +2057,15 @@ describe("ChatView timeline estimator parity (full app)", () => {
2054 });2057 });
20552058
2056 try {2059 try {
2057 const toggle = await waitForElement(2060 window.dispatchEvent(
2058 () =>2061 new KeyboardEvent("keydown", {
2059 document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'),2062 key: "j",
2060 "Unable to find terminal drawer toggle.",2063 metaKey: isMacPlatform(navigator.platform),
2064 ctrlKey: !isMacPlatform(navigator.platform),
2065 bubbles: true,
2066 cancelable: true,
2067 }),
2061 );2068 );
2062 toggle.click();
20632069
2064 await vi.waitFor(2070 await vi.waitFor(
2065 () => {2071 () => {
...@@ -2072,9 +2078,136 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2072,9 +2078,136 @@ describe("ChatView timeline estimator parity (full app)", () => {
2072 terminalId: DEFAULT_TERMINAL_ID,2078 terminalId: DEFAULT_TERMINAL_ID,
2073 cwd: "/repo/project",2079 cwd: "/repo/project",
2074 });2080 });
2081 expect(
2082 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF)
2083 .isOpen,
2084 ).toBe(false);
2085 },
2086 { timeout: 8_000, interval: 16 },
2087 );
2088 } finally {
2089 await mounted.cleanup();
2090 }
2091 });
2092
2093 it("keeps multiple terminal panel surfaces separate from the bottom drawer", async () => {
2094 const mounted = await mountChatView({
2095 viewport: WIDE_FOOTER_VIEWPORT,
2096 snapshot: createSnapshotForTargetUser({
2097 targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId,
2098 targetText: "open inline terminal panel",
2099 }),
2100 });
2101
2102 try {
2103 window.dispatchEvent(
2104 new KeyboardEvent("keydown", {
2105 key: "b",
2106 altKey: true,
2107 metaKey: isMacPlatform(navigator.platform),
2108 ctrlKey: !isMacPlatform(navigator.platform),
2109 bubbles: true,
2110 cancelable: true,
2111 }),
2112 );
2113
2114 const addSurface = await waitForElement(
2115 () => document.querySelector<HTMLButtonElement>('button[aria-label="Add panel surface"]'),
2116 "Unable to find add panel surface button.",
2117 );
2118 expect(document.body.textContent).toContain("Open a surface");
2119 expect(
2120 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF),
2121 ).toEqual({
2122 isOpen: true,
2123 activeSurfaceId: null,
2124 surfaces: [],
2125 });
2126 expect(wsRequests.some((request) => request._tag === WS_METHODS.terminalOpen)).toBe(false);
2127
2128 addSurface.click();
2129
2130 const terminalItem = await waitForElement(
2131 () =>
2132 Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
2133 (item) => item.textContent?.trim() === "Terminal",
2134 ) ?? null,
2135 "Unable to find Terminal panel menu item.",
2136 );
2137 terminalItem.click();
2138
2139 await vi.waitFor(() => {
2140 expect(
2141 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF)
2142 .surfaces.filter((surface) => surface.kind === "terminal")
2143 .map((surface) => surface.resourceId),
2144 ).toEqual(["term-1"]);
2145 });
2146
2147 addSurface.click();
2148 const secondTerminalItem = await waitForElement(
2149 () =>
2150 Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
2151 (item) => item.textContent?.trim() === "Terminal",
2152 ) ?? null,
2153 "Unable to find Terminal panel menu item.",
2154 );
2155 secondTerminalItem.click();
2156
2157 await vi.waitFor(
2158 () => {
2159 expect(
2160 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF)
2161 .surfaces.filter((surface) => surface.kind === "terminal")
2162 .map((surface) => surface.resourceId),
2163 ).toEqual(["term-1", "term-2"]);
2164 expect(
2165 document.querySelector('[data-preview-panel-mode="inline"] .thread-terminal-drawer'),
2166 ).not.toBeNull();
2167 expect(
2168 wsRequests
2169 .filter((request) => request._tag === WS_METHODS.terminalOpen)
2170 .map((request) => ("terminalId" in request ? request.terminalId : null)),
2171 ).toEqual(expect.arrayContaining(["term-1", "term-2"]));
2172 const attachRequest = wsRequests.find(
2173 (request) =>
2174 request._tag === WS_METHODS.terminalAttach &&
2175 "terminalId" in request &&
2176 request.terminalId === "term-2",
2177 );
2178 expect(attachRequest).toMatchObject({
2179 _tag: WS_METHODS.terminalAttach,
2180 threadId: THREAD_ID,
2181 terminalId: "term-2",
2182 cwd: "/repo/project",
2183 });
2075 },2184 },
2076 { timeout: 8_000, interval: 16 },2185 { timeout: 8_000, interval: 16 },
2077 );2186 );
2187
2188 const drawerToggle = await waitForElement(
2189 () =>
2190 document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'),
2191 "Unable to find terminal drawer toggle.",
2192 );
2193 drawerToggle.click();
2194
2195 await vi.waitFor(() => {
2196 expect(
2197 useTerminalUiStateStore.getState().terminalUiStateByThreadKey[THREAD_KEY],
2198 ).toMatchObject({
2199 terminalOpen: true,
2200 terminalIds: ["term-3"],
2201 });
2202 expect(
2203 wsRequests.some(
2204 (request) =>
2205 request._tag === WS_METHODS.terminalAttach &&
2206 "terminalId" in request &&
2207 request.terminalId === "term-3",
2208 ),
2209 ).toBe(true);
2210 });
2078 } finally {2211 } finally {
2079 await mounted.cleanup();2212 await mounted.cleanup();
2080 }2213 }
apps/web/src/components/ChatView.tsx+580-142
...@@ -103,6 +103,7 @@ import {...@@ -103,6 +103,7 @@ import {
103 usePreviewStateStore,103 usePreviewStateStore,
104} from "../previewStateStore";104} from "../previewStateStore";
105import { subscribePreviewAction } from "./preview/previewActionBus";105import { subscribePreviewAction } from "./preview/previewActionBus";
106import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
106// Lazy: keeps the entire preview component graph (webview host, favicon107// Lazy: keeps the entire preview component graph (webview host, favicon
107// helper, Chromium error icon) out of the web bundle until first open.108// helper, Chromium error icon) out of the web bundle until first open.
108const PreviewPanel = lazy(() =>109const PreviewPanel = lazy(() =>
...@@ -129,7 +130,7 @@ import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils...@@ -129,7 +130,7 @@ import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils
129import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";130import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
130import { useSettings } from "../hooks/useSettings";131import { useSettings } from "../hooks/useSettings";
131import { resolveAppModelSelectionForInstance } from "../modelSelection";132import { resolveAppModelSelectionForInstance } from "../modelSelection";
132import { isTerminalFocused } from "../lib/terminalFocus";133import { getTerminalFocusOwner } from "../lib/terminalFocus";
133import {134import {
134 deriveLogicalProjectKeyFromSettings,135 deriveLogicalProjectKeyFromSettings,
135 selectProjectGroupingSettings,136 selectProjectGroupingSettings,
...@@ -539,6 +540,7 @@ interface PersistentThreadTerminalDrawerProps {...@@ -539,6 +540,7 @@ interface PersistentThreadTerminalDrawerProps {
539 launchContext: PersistentTerminalLaunchContext | null;540 launchContext: PersistentTerminalLaunchContext | null;
540 focusRequestId: number;541 focusRequestId: number;
541 splitShortcutLabel: string | undefined;542 splitShortcutLabel: string | undefined;
543 splitVerticalShortcutLabel: string | undefined;
542 newShortcutLabel: string | undefined;544 newShortcutLabel: string | undefined;
543 closeShortcutLabel: string | undefined;545 closeShortcutLabel: string | undefined;
544 keybindings: ResolvedKeybindingsConfig;546 keybindings: ResolvedKeybindingsConfig;
...@@ -553,6 +555,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -553,6 +555,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
553 launchContext,555 launchContext,
554 focusRequestId,556 focusRequestId,
555 splitShortcutLabel,557 splitShortcutLabel,
558 splitVerticalShortcutLabel,
556 newShortcutLabel,559 newShortcutLabel,
557 closeShortcutLabel,560 closeShortcutLabel,
558 keybindings,561 keybindings,
...@@ -573,16 +576,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -573,16 +576,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
573 environmentId: threadRef.environmentId,576 environmentId: threadRef.environmentId,
574 threadId,577 threadId,
575 });578 });
579 const panelSurfaces = useRightPanelStore(
580 (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces,
581 );
582 const panelTerminalIds = useMemo(
583 () =>
584 new Set(
585 panelSurfaces.flatMap((surface) =>
586 surface.kind === "terminal" ? surface.terminalIds : [],
587 ),
588 ),
589 [panelSurfaces],
590 );
591 const drawerTerminalSessions = useMemo(
592 () =>
593 knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)),
594 [knownTerminalSessions, panelTerminalIds],
595 );
576 const terminalLabelsById = useMemo(() => {596 const terminalLabelsById = useMemo(() => {
577 const next = new Map<string, string>();597 const next = new Map<string, string>();
578 for (const session of knownTerminalSessions) {598 for (const session of drawerTerminalSessions) {
579 next.set(599 next.set(
580 session.target.terminalId,600 session.target.terminalId,
581 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),601 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
582 );602 );
583 }603 }
584 return next;604 return next;
585 }, [knownTerminalSessions]);605 }, [drawerTerminalSessions]);
586 const terminalLaunchLocationsById = useMemo(() => {606 const terminalLaunchLocationsById = useMemo(() => {
587 const next = new Map<607 const next = new Map<
588 string,608 string,
...@@ -596,7 +616,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -596,7 +616,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
596 return next;616 return next;
597 }617 }
598618
599 for (const session of knownTerminalSessions) {619 for (const session of drawerTerminalSessions) {
600 const summary = session.state.summary;620 const summary = session.state.summary;
601 if (!summary) {621 if (!summary) {
602 continue;622 continue;
...@@ -614,13 +634,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -614,13 +634,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
614 }634 }
615635
616 return next;636 return next;
617 }, [knownTerminalSessions, launchContext, project]);637 }, [drawerTerminalSessions, launchContext, project]);
618 const serverOrderedTerminalIds = useMemo(638 const serverOrderedTerminalIds = useMemo(
619 () => knownTerminalSessions.map((session) => session.target.terminalId),639 () => drawerTerminalSessions.map((session) => session.target.terminalId),
620 [knownTerminalSessions],640 [drawerTerminalSessions],
621 );641 );
622 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);642 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);
623 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);643 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);
644 const storeSplitTerminalVertical = useTerminalUiStateStore(
645 (state) => state.splitTerminalVertical,
646 );
624 const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal);647 const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal);
625 const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal);648 const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal);
626 const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal);649 const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal);
...@@ -712,6 +735,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -712,6 +735,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
712 threadId,735 threadId,
713 threadRef,736 threadRef,
714 ]);737 ]);
738 const splitTerminalVertical = useCallback(() => {
739 const api = readEnvironmentApi(threadRef.environmentId);
740 if (!api || !cwd) {
741 return;
742 }
743 const terminalId = nextTerminalId(serverOrderedTerminalIds);
744 storeSplitTerminalVertical(threadRef, terminalId);
745 bumpFocusRequestId();
746 void api.terminal
747 .open({
748 threadId,
749 terminalId,
750 cwd,
751 ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
752 env: runtimeEnv,
753 })
754 .catch(() => undefined);
755 }, [
756 bumpFocusRequestId,
757 cwd,
758 effectiveWorktreePath,
759 runtimeEnv,
760 serverOrderedTerminalIds,
761 storeSplitTerminalVertical,
762 threadId,
763 threadRef,
764 ]);
715765
716 const createNewTerminal = useCallback(() => {766 const createNewTerminal = useCallback(() => {
717 const api = readEnvironmentApi(threadRef.environmentId);767 const api = readEnvironmentApi(threadRef.environmentId);
...@@ -814,8 +864,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -814,8 +864,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
814 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}864 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}
815 focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}865 focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
816 onSplitTerminal={splitTerminal}866 onSplitTerminal={splitTerminal}
867 onSplitTerminalVertical={splitTerminalVertical}
817 onNewTerminal={createNewTerminal}868 onNewTerminal={createNewTerminal}
818 splitShortcutLabel={visible ? splitShortcutLabel : undefined}869 splitShortcutLabel={visible ? splitShortcutLabel : undefined}
870 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
819 newShortcutLabel={visible ? newShortcutLabel : undefined}871 newShortcutLabel={visible ? newShortcutLabel : undefined}
820 closeShortcutLabel={visible ? closeShortcutLabel : undefined}872 closeShortcutLabel={visible ? closeShortcutLabel : undefined}
821 keybindings={keybindings}873 keybindings={keybindings}
...@@ -830,6 +882,177 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -830,6 +882,177 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
830 );882 );
831});883});
832884
885interface PersistentThreadTerminalPanelProps {
886 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
887 surface: Extract<RightPanelSurface, { kind: "terminal" }>;
888 launchContext: PersistentTerminalLaunchContext | null;
889 focusRequestId: number;
890 keybindings: ResolvedKeybindingsConfig;
891 onAddTerminalContext: (selection: TerminalContextSelection) => void;
892 onSplitTerminal: () => void;
893 onSplitTerminalVertical: () => void;
894 onNewTerminal: () => void;
895 onActiveTerminalChange: (terminalId: string) => void;
896 onCloseTerminal: (terminalId: string) => void;
897 splitShortcutLabel?: string | undefined;
898 splitVerticalShortcutLabel?: string | undefined;
899 newShortcutLabel?: string | undefined;
900 closeShortcutLabel?: string | undefined;
901}
902
903const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({
904 threadRef,
905 surface,
906 launchContext,
907 focusRequestId,
908 keybindings,
909 onAddTerminalContext,
910 onSplitTerminal,
911 onSplitTerminalVertical,
912 onNewTerminal,
913 onActiveTerminalChange,
914 onCloseTerminal,
915 splitShortcutLabel,
916 splitVerticalShortcutLabel,
917 newShortcutLabel,
918 closeShortcutLabel,
919}: PersistentThreadTerminalPanelProps) {
920 const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]));
921 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
922 const projectRef = serverThread
923 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
924 : draftThread
925 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
926 : null;
927 const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef]));
928 const knownTerminalSessions = useKnownTerminalSessions({
929 environmentId: threadRef.environmentId,
930 threadId: threadRef.threadId,
931 });
932 const terminalSummary =
933 knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId)
934 ?.state.summary ?? null;
935 const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
936 const worktreePath =
937 launchContext?.worktreePath ?? terminalSummary?.worktreePath ?? threadWorktreePath;
938 const cwd = useMemo(
939 () =>
940 launchContext?.cwd ??
941 terminalSummary?.cwd ??
942 (project
943 ? projectScriptCwd({
944 project: { cwd: project.cwd },
945 worktreePath,
946 })
947 : null),
948 [launchContext?.cwd, project, terminalSummary?.cwd, worktreePath],
949 );
950 const runtimeEnv = useMemo(
951 () =>
952 project
953 ? projectScriptRuntimeEnv({
954 project: { cwd: project.cwd },
955 worktreePath,
956 })
957 : {},
958 [project, worktreePath],
959 );
960 const terminalLabelsById = useMemo(() => {
961 const labels = new Map<string, string>();
962 for (const terminalId of surface.terminalIds) {
963 const summary =
964 knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state
965 .summary ?? null;
966 labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary));
967 }
968 return labels;
969 }, [knownTerminalSessions, surface.terminalIds]);
970 const terminalLaunchLocationsById = useMemo(() => {
971 const locations = new Map<
972 string,
973 {
974 readonly cwd: string;
975 readonly worktreePath: string | null;
976 readonly runtimeEnv: Record<string, string>;
977 }
978 >();
979 for (const terminalId of surface.terminalIds) {
980 const summary =
981 knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state
982 .summary ?? null;
983 const terminalWorktreePath =
984 launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath;
985 const terminalCwd =
986 launchContext?.cwd ??
987 summary?.cwd ??
988 (project
989 ? projectScriptCwd({
990 project: { cwd: project.cwd },
991 worktreePath: terminalWorktreePath,
992 })
993 : null);
994 if (!terminalCwd || !project) continue;
995 locations.set(terminalId, {
996 cwd: terminalCwd,
997 worktreePath: terminalWorktreePath,
998 runtimeEnv: projectScriptRuntimeEnv({
999 project: { cwd: project.cwd },
1000 worktreePath: terminalWorktreePath,
1001 }),
1002 });
1003 }
1004 return locations;
1005 }, [
1006 knownTerminalSessions,
1007 launchContext?.cwd,
1008 launchContext?.worktreePath,
1009 project,
1010 surface.terminalIds,
1011 threadWorktreePath,
1012 ]);
1013
1014 if (!project || !cwd) {
1015 return null;
1016 }
1017
1018 return (
1019 <ThreadTerminalDrawer
1020 mode="panel"
1021 threadRef={threadRef}
1022 threadId={threadRef.threadId}
1023 cwd={cwd}
1024 worktreePath={worktreePath}
1025 runtimeEnv={runtimeEnv}
1026 height={0}
1027 terminalIds={surface.terminalIds}
1028 activeTerminalId={surface.activeTerminalId}
1029 terminalGroups={[
1030 {
1031 id: surface.id,
1032 terminalIds: surface.terminalIds,
1033 ...(surface.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}),
1034 },
1035 ]}
1036 activeTerminalGroupId={surface.id}
1037 focusRequestId={focusRequestId}
1038 onSplitTerminal={onSplitTerminal}
1039 onSplitTerminalVertical={onSplitTerminalVertical}
1040 onNewTerminal={onNewTerminal}
1041 splitShortcutLabel={splitShortcutLabel}
1042 splitVerticalShortcutLabel={splitVerticalShortcutLabel}
1043 newShortcutLabel={newShortcutLabel}
1044 closeShortcutLabel={closeShortcutLabel}
1045 onActiveTerminalChange={onActiveTerminalChange}
1046 onCloseTerminal={onCloseTerminal}
1047 onHeightChange={() => undefined}
1048 onAddTerminalContext={onAddTerminalContext}
1049 terminalLabelsById={terminalLabelsById}
1050 terminalLaunchLocationsById={terminalLaunchLocationsById}
1051 keybindings={keybindings}
1052 />
1053 );
1054});
1055
833export default function ChatView(props: ChatViewProps) {1056export default function ChatView(props: ChatViewProps) {
834 const {1057 const {
835 environmentId,1058 environmentId,
...@@ -967,6 +1190,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -967,6 +1190,7 @@ export default function ChatView(props: ChatViewProps) {
967 );1190 );
968 const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen);1191 const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen);
969 const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal);1192 const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal);
1193 const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical);
970 const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal);1194 const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal);
971 const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal);1195 const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal);
972 const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal);1196 const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal);
...@@ -1029,50 +1253,73 @@ export default function ChatView(props: ChatViewProps) {...@@ -1029,50 +1253,73 @@ export default function ChatView(props: ChatViewProps) {
1029 () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])],1253 () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])],
1030 [activeServerOrderedTerminalIds, terminalUiState.terminalIds],1254 [activeServerOrderedTerminalIds, terminalUiState.terminalIds],
1031 );1255 );
1256 const activeTerminalLabelsById = useMemo(() => {
1257 const next = new Map<string, string>();
1258 for (const session of activeThreadKnownSessions) {
1259 next.set(
1260 session.target.terminalId,
1261 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
1262 );
1263 }
1264 return next;
1265 }, [activeThreadKnownSessions]);
1032 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);1266 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);
1033 const activeThreadRef = useMemo(1267 const activeThreadRef = useMemo(
1034 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),1268 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
1035 [activeThread],1269 [activeThread],
1036 );1270 );
1037 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;1271 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1272 const activeRightPanelKind = useRightPanelStore((store) =>
1273 selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen),
1274 );
1275 const rightPanelState = useRightPanelStore((store) =>
1276 selectThreadRightPanelState(store.byThreadKey, activeThreadRef),
1277 );
1278 const activeRightPanelSurface = useRightPanelStore((store) =>
1279 selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef),
1280 );
1281 const activePreviewState = usePreviewStateStore((state) =>
1282 selectThreadPreviewState(state.byThreadKey, activeThreadRef),
1283 );
1284 const panelTerminalIds = useMemo(
1285 () =>
1286 new Set(
1287 rightPanelState.surfaces.flatMap((surface) =>
1288 surface.kind === "terminal" ? surface.terminalIds : [],
1289 ),
1290 ),
1291 [rightPanelState.surfaces],
1292 );
1293 const drawerServerOrderedTerminalIds = useMemo(
1294 () => activeServerOrderedTerminalIds.filter((terminalId) => !panelTerminalIds.has(terminalId)),
1295 [activeServerOrderedTerminalIds, panelTerminalIds],
1296 );
1038 useEffect(() => {1297 useEffect(() => {
1039 if (!activeThreadRef) {1298 if (!activeThreadRef) {
1040 return;1299 return;
1041 }1300 }
1042 if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) {1301 if (terminalIdListsEqual(drawerServerOrderedTerminalIds, terminalUiState.terminalIds)) {
1043 return;1302 return;
1044 }1303 }
1045 if (1304 if (
1046 serverTerminalIdsStrictSubsetOfClient(1305 serverTerminalIdsStrictSubsetOfClient(
1047 activeServerOrderedTerminalIds,1306 drawerServerOrderedTerminalIds,
1048 terminalUiState.terminalIds,1307 terminalUiState.terminalIds,
1049 )1308 )
1050 ) {1309 ) {
1051 return;1310 return;
1052 }1311 }
1053 reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds);1312 reconcileTerminalIds(activeThreadRef, drawerServerOrderedTerminalIds);
1054 }, [1313 }, [
1055 activeThreadRef,1314 activeThreadRef,
1056 activeServerOrderedTerminalIds,1315 drawerServerOrderedTerminalIds,
1057 reconcileTerminalIds,1316 reconcileTerminalIds,
1058 terminalUiState.terminalIds,1317 terminalUiState.terminalIds,
1059 ]);1318 ]);
1060 const activeRightPanelKind = useRightPanelStore((store) =>
1061 selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen),
1062 );
1063 const rightPanelState = useRightPanelStore((store) =>
1064 selectThreadRightPanelState(store.byThreadKey, activeThreadRef),
1065 );
1066 const activeRightPanelSurface = useRightPanelStore((store) =>
1067 selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef),
1068 );
1069 const activePreviewState = usePreviewStateStore((state) =>
1070 selectThreadPreviewState(state.byThreadKey, activeThreadRef),
1071 );
1072 const planSidebarOpen = activeRightPanelKind === "plan";1319 const planSidebarOpen = activeRightPanelKind === "plan";
1073 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();1320 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
1074 const terminalPanelOpen = activeRightPanelKind === "terminal";1321 const rightPanelOpen = rightPanelState.isOpen;
1075 const rightPanelOpen = activeRightPanelSurface !== null;1322 const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;
10761323
1077 useEffect(() => {1324 useEffect(() => {
1078 if (!activeThreadRef) return;1325 if (!activeThreadRef) return;
...@@ -1085,15 +1332,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -1085,15 +1332,6 @@ export default function ChatView(props: ChatViewProps) {
1085 if (!activeThreadRef || !diffOpen) return;1332 if (!activeThreadRef || !diffOpen) return;
1086 useRightPanelStore.getState().open(activeThreadRef, "diff");1333 useRightPanelStore.getState().open(activeThreadRef, "diff");
1087 }, [activeThreadRef, diffOpen]);1334 }, [activeThreadRef, diffOpen]);
1088 useEffect(() => {
1089 if (!activeThreadRef || !terminalUiState.terminalOpen) return;
1090 const state = selectThreadRightPanelState(
1091 useRightPanelStore.getState().byThreadKey,
1092 activeThreadRef,
1093 );
1094 if (state.surfaces.some((surface) => surface.kind === "terminal")) return;
1095 useRightPanelStore.getState().open(activeThreadRef, "terminal");
1096 }, [activeThreadRef, terminalUiState.terminalOpen]);
1097 const activeLatestTurn = activeThread?.latestTurn ?? null;1335 const activeLatestTurn = activeThread?.latestTurn ?? null;
1098 const threadPlanCatalog = useThreadPlanCatalog(1336 const threadPlanCatalog = useThreadPlanCatalog(
1099 useMemo(() => {1337 useMemo(() => {
...@@ -1115,6 +1353,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -1115,6 +1353,10 @@ export default function ChatView(props: ChatViewProps) {
1115 const activeProject = useStore(1353 const activeProject = useStore(
1116 useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]),1354 useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]),
1117 );1355 );
1356 const configuredPreviewUrls = useMemo(
1357 () => getConfiguredPreviewUrls(activeProject?.scripts),
1358 [activeProject?.scripts],
1359 );
11181360
1119 useEffect(() => {1361 useEffect(() => {
1120 if (routeKind !== "server") {1362 if (routeKind !== "server") {
...@@ -1906,6 +2148,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -1906,6 +2148,11 @@ export default function ChatView(props: ChatViewProps) {
1906 () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions),2148 () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions),
1907 [keybindings, terminalShortcutLabelOptions],2149 [keybindings, terminalShortcutLabelOptions],
1908 );2150 );
2151 const splitTerminalVerticalShortcutLabel = useMemo(
2152 () =>
2153 shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions),
2154 [keybindings, terminalShortcutLabelOptions],
2155 );
1909 const newTerminalShortcutLabel = useMemo(2156 const newTerminalShortcutLabel = useMemo(
1910 () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions),2157 () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions),
1911 [keybindings, terminalShortcutLabelOptions],2158 [keybindings, terminalShortcutLabelOptions],
...@@ -1988,7 +2235,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -1988,7 +2235,8 @@ export default function ChatView(props: ChatViewProps) {
1988 if (!isServerThread) {2235 if (!isServerThread) {
1989 return;2236 return;
1990 }2237 }
1991 if (!diffOpen) {2238 const diffPanelOpen = activeRightPanelKind === "diff";
2239 if (!diffPanelOpen) {
1992 onDiffPanelOpen?.();2240 onDiffPanelOpen?.();
1993 if (activeThreadRef) {2241 if (activeThreadRef) {
1994 useRightPanelStore.getState().open(activeThreadRef, "diff");2242 useRightPanelStore.getState().open(activeThreadRef, "diff");
...@@ -2005,12 +2253,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -2005,12 +2253,12 @@ export default function ChatView(props: ChatViewProps) {
2005 replace: true,2253 replace: true,
2006 search: (previous) => {2254 search: (previous) => {
2007 const rest = stripDiffSearchParams(previous);2255 const rest = stripDiffSearchParams(previous);
2008 return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };2256 return diffPanelOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
2009 },2257 },
2010 });2258 });
2011 }, [2259 }, [
2260 activeRightPanelKind,
2012 activeThreadRef,2261 activeThreadRef,
2013 diffOpen,
2014 environmentId,2262 environmentId,
2015 isServerThread,2263 isServerThread,
2016 navigate,2264 navigate,
...@@ -2105,61 +2353,184 @@ export default function ChatView(props: ChatViewProps) {...@@ -2105,61 +2353,184 @@ export default function ChatView(props: ChatViewProps) {
2105 const setTerminalOpen = useCallback(2353 const setTerminalOpen = useCallback(
2106 (open: boolean) => {2354 (open: boolean) => {
2107 if (!activeThreadRef) return;2355 if (!activeThreadRef) return;
2356 if (open && terminalUiState.terminalIds.length === 0) {
2357 storeNewTerminal(
2358 activeThreadRef,
2359 nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]),
2360 );
2361 return;
2362 }
2108 storeSetTerminalOpen(activeThreadRef, open);2363 storeSetTerminalOpen(activeThreadRef, open);
2109 },2364 },
2110 [activeThreadRef, storeSetTerminalOpen],2365 [
2366 activeKnownTerminalIds,
2367 activeThreadRef,
2368 panelTerminalIds,
2369 storeNewTerminal,
2370 storeSetTerminalOpen,
2371 terminalUiState.terminalIds.length,
2372 ],
2111 );2373 );
2112 const toggleTerminalVisibility = useCallback(() => {2374 const addTerminalSurface = useCallback(() => {
2113 if (!activeThreadRef) return;2375 if (!activeThreadRef || !activeThreadId || !activeProject) return;
2114 if (terminalPanelOpen) {2376 const api = readEnvironmentApi(activeThreadRef.environmentId);
2115 useRightPanelStore.getState().close(activeThreadRef);2377 const cwd = gitCwd ?? activeProject.cwd;
2116 return;2378 if (!api || !cwd) return;
2117 }2379 const panelIds = selectThreadRightPanelState(
2118 setTerminalOpen(true);2380 useRightPanelStore.getState().byThreadKey,
2119 useRightPanelStore.getState().open(activeThreadRef, "terminal");2381 activeThreadRef,
2120 }, [activeThreadRef, setTerminalOpen, terminalPanelOpen]);2382 ).surfaces.flatMap((surface) => (surface.kind === "terminal" ? surface.terminalIds : []));
2121 const splitTerminal = useCallback(() => {2383 const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelIds]);
2122 if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) {2384 useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId);
2123 return;
2124 }
2125 const cwdForOpen = gitCwd ?? activeProject.cwd;
2126 if (!cwdForOpen) {
2127 return;
2128 }
2129 const api = readEnvironmentApi(environmentId);
2130 if (!api) {
2131 return;
2132 }
2133 const terminalId = nextTerminalId(activeKnownTerminalIds);
2134 storeSplitTerminal(activeThreadRef, terminalId);
2135 setTerminalFocusRequestId((value) => value + 1);2385 setTerminalFocusRequestId((value) => value + 1);
2136 void (async () => {2386 void api.terminal
2137 try {2387 .open({
2138 await api.terminal.open({2388 threadId: activeThreadId,
2389 terminalId,
2390 cwd,
2391 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
2392 env: projectScriptRuntimeEnv({
2393 project: { cwd: activeProject.cwd },
2394 worktreePath: activeThreadWorktreePath,
2395 }),
2396 })
2397 .catch(() => undefined);
2398 }, [
2399 activeKnownTerminalIds,
2400 activeProject,
2401 activeThreadId,
2402 activeThreadRef,
2403 activeThreadWorktreePath,
2404 gitCwd,
2405 ]);
2406 const splitPanelTerminal = useCallback(
2407 (direction: "horizontal" | "vertical" = "horizontal") => {
2408 if (
2409 !activeThreadRef ||
2410 !activeThreadId ||
2411 !activeProject ||
2412 activeRightPanelSurface?.kind !== "terminal" ||
2413 activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP
2414 ) {
2415 return;
2416 }
2417 const api = readEnvironmentApi(activeThreadRef.environmentId);
2418 const cwd = gitCwd ?? activeProject.cwd;
2419 if (!api || !cwd) return;
2420 const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]);
2421 useRightPanelStore
2422 .getState()
2423 .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction);
2424 setTerminalFocusRequestId((value) => value + 1);
2425 void api.terminal
2426 .open({
2139 threadId: activeThreadId,2427 threadId: activeThreadId,
2140 terminalId,2428 terminalId,
2141 cwd: cwdForOpen,2429 cwd,
2142 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),2430 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
2143 env: projectScriptRuntimeEnv({2431 env: projectScriptRuntimeEnv({
2144 project: { cwd: activeProject.cwd },2432 project: { cwd: activeProject.cwd },
2145 worktreePath: activeThreadWorktreePath,2433 worktreePath: activeThreadWorktreePath,
2146 }),2434 }),
2147 });2435 })
2148 } catch {2436 .catch(() => undefined);
2149 // Opening failed; the tab is already in the store — user can retry or close it.2437 },
2438 [
2439 activeKnownTerminalIds,
2440 activeProject,
2441 activeRightPanelSurface,
2442 activeThreadId,
2443 activeThreadRef,
2444 activeThreadWorktreePath,
2445 gitCwd,
2446 panelTerminalIds,
2447 ],
2448 );
2449 const splitPanelTerminalVertical = useCallback(() => {
2450 splitPanelTerminal("vertical");
2451 }, [splitPanelTerminal]);
2452 const activatePanelTerminal = useCallback(
2453 (terminalId: string) => {
2454 if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return;
2455 useRightPanelStore
2456 .getState()
2457 .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
2458 setTerminalFocusRequestId((value) => value + 1);
2459 },
2460 [activeRightPanelSurface, activeThreadRef],
2461 );
2462 const closePanelTerminal = useCallback(
2463 (terminalId: string) => {
2464 if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return;
2465 const api = readEnvironmentApi(activeThreadRef.environmentId);
2466 void api?.terminal
2467 .close({
2468 threadId: activeThreadRef.threadId,
2469 terminalId,
2470 deleteHistory: true,
2471 })
2472 .catch(() => undefined);
2473 useRightPanelStore
2474 .getState()
2475 .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
2476 setTerminalFocusRequestId((value) => value + 1);
2477 },
2478 [activeRightPanelSurface, activeThreadRef],
2479 );
2480 const toggleTerminalVisibility = useCallback(() => {
2481 if (!activeThreadRef) return;
2482 setTerminalOpen(!terminalUiState.terminalOpen);
2483 }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]);
2484 const splitTerminal = useCallback(
2485 (direction: "horizontal" | "vertical" = "horizontal") => {
2486 if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) {
2487 return;
2150 }2488 }
2151 })();2489 const cwdForOpen = gitCwd ?? activeProject.cwd;
2152 }, [2490 if (!cwdForOpen) {
2153 activeProject,2491 return;
2154 activeKnownTerminalIds,2492 }
2155 activeThreadId,2493 const api = readEnvironmentApi(environmentId);
2156 activeThreadRef,2494 if (!api) {
2157 activeThreadWorktreePath,2495 return;
2158 environmentId,2496 }
2159 gitCwd,2497 const terminalId = nextTerminalId(activeKnownTerminalIds);
2160 hasReachedSplitLimit,2498 if (direction === "vertical") {
2161 storeSplitTerminal,2499 storeSplitTerminalVertical(activeThreadRef, terminalId);
2162 ]);2500 } else {
2501 storeSplitTerminal(activeThreadRef, terminalId);
2502 }
2503 setTerminalFocusRequestId((value) => value + 1);
2504 void (async () => {
2505 try {
2506 await api.terminal.open({
2507 threadId: activeThreadId,
2508 terminalId,
2509 cwd: cwdForOpen,
2510 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
2511 env: projectScriptRuntimeEnv({
2512 project: { cwd: activeProject.cwd },
2513 worktreePath: activeThreadWorktreePath,
2514 }),
2515 });
2516 } catch {
2517 // Opening failed; the tab is already in the store — user can retry or close it.
2518 }
2519 })();
2520 },
2521 [
2522 activeProject,
2523 activeKnownTerminalIds,
2524 activeThreadId,
2525 activeThreadRef,
2526 activeThreadWorktreePath,
2527 environmentId,
2528 gitCwd,
2529 hasReachedSplitLimit,
2530 storeSplitTerminal,
2531 storeSplitTerminalVertical,
2532 ],
2533 );
2163 const createNewTerminal = useCallback(() => {2534 const createNewTerminal = useCallback(() => {
2164 if (!activeThreadRef || !activeThreadId || !activeProject) {2535 if (!activeThreadRef || !activeThreadId || !activeProject) {
2165 return;2536 return;
...@@ -2552,15 +2923,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2552,15 +2923,7 @@ export default function ChatView(props: ChatViewProps) {
2552 const closePreviewPanel = useCallback(() => {2923 const closePreviewPanel = useCallback(() => {
2553 if (!activeThreadRef) return;2924 if (!activeThreadRef) return;
2554 useRightPanelStore.getState().close(activeThreadRef);2925 useRightPanelStore.getState().close(activeThreadRef);
2555 if (diffOpen) {2926 }, [activeThreadRef]);
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(2927 const activateRightPanelSurface = useCallback(
2565 (surface: RightPanelSurface) => {2928 (surface: RightPanelSurface) => {
2566 if (!activeThreadRef) return;2929 if (!activeThreadRef) return;
...@@ -2569,7 +2932,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2569,7 +2932,7 @@ export default function ChatView(props: ChatViewProps) {
2569 usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId);2932 usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId);
2570 }2933 }
2571 if (surface.kind === "terminal") {2934 if (surface.kind === "terminal") {
2572 setTerminalOpen(true);2935 setTerminalFocusRequestId((value) => value + 1);
2573 }2936 }
2574 if (surface.kind === "diff" && !diffOpen) {2937 if (surface.kind === "diff" && !diffOpen) {
2575 onDiffPanelOpen?.();2938 onDiffPanelOpen?.();
...@@ -2588,48 +2951,41 @@ export default function ChatView(props: ChatViewProps) {...@@ -2588,48 +2951,41 @@ export default function ChatView(props: ChatViewProps) {
2588 });2951 });
2589 }2952 }
2590 },2953 },
2591 [2954 [activeThreadRef, diffOpen, environmentId, navigate, onDiffPanelOpen, threadId],
2592 activeThreadRef,
2593 diffOpen,
2594 environmentId,
2595 navigate,
2596 onDiffPanelOpen,
2597 setTerminalOpen,
2598 threadId,
2599 ],
2600 );2955 );
2601 const toggleRightPanel = useCallback(() => {2956 const toggleRightPanel = useCallback(() => {
2602 if (!activeThreadRef) return;2957 if (!activeThreadRef) return;
2603 if (rightPanelOpen) {2958 useRightPanelStore.getState().toggleVisibility(activeThreadRef);
2604 closePreviewPanel();2959 }, [activeThreadRef]);
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(2960 const closeRightPanelSurface = useCallback(
2622 (surface: RightPanelSurface) => {2961 (surface: RightPanelSurface) => {
2623 if (!activeThreadRef) return;2962 if (!activeThreadRef) return;
2624 useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);
2625 if (surface.kind === "preview" && surface.resourceId) {2963 if (surface.kind === "preview" && surface.resourceId) {
2964 usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId);
2626 const api = readEnvironmentApi(activeThreadRef.environmentId);2965 const api = readEnvironmentApi(activeThreadRef.environmentId);
2627 void api?.preview2966 void api?.preview
2628 .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId })2967 .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId })
2629 .catch(() => undefined);2968 .catch(() => undefined);
2630 }2969 }
2970 useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);
2971 const nextActiveSurface = selectActiveRightPanelSurface(
2972 useRightPanelStore.getState().byThreadKey,
2973 activeThreadRef,
2974 );
2975 if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) {
2976 usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId);
2977 }
2631 if (surface.kind === "terminal") {2978 if (surface.kind === "terminal") {
2632 setTerminalOpen(false);2979 const api = readEnvironmentApi(activeThreadRef.environmentId);
2980 for (const terminalId of surface.terminalIds) {
2981 void api?.terminal
2982 .close({
2983 threadId: activeThreadRef.threadId,
2984 terminalId,
2985 deleteHistory: true,
2986 })
2987 .catch(() => undefined);
2988 }
2633 }2989 }
2634 if (surface.kind === "diff" && diffOpen) {2990 if (surface.kind === "diff" && diffOpen) {
2635 void navigate({2991 void navigate({
...@@ -2640,7 +2996,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2640,7 +2996,7 @@ export default function ChatView(props: ChatViewProps) {
2640 });2996 });
2641 }2997 }
2642 },2998 },
2643 [activeThreadRef, diffOpen, environmentId, navigate, setTerminalOpen, threadId],2999 [activeThreadRef, diffOpen, environmentId, navigate, threadId],
2644 );3000 );
2645 const persistThreadSettingsForNextTurn = useCallback(3001 const persistThreadSettingsForNextTurn = useCallback(
2646 async (input: {3002 async (input: {
...@@ -2920,8 +3276,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -2920,8 +3276,9 @@ export default function ChatView(props: ChatViewProps) {
2920 if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) {3276 if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) {
2921 return;3277 return;
2922 }3278 }
3279 const terminalFocusOwner = getTerminalFocusOwner();
2923 const shortcutContext = {3280 const shortcutContext = {
2924 terminalFocus: isTerminalFocused(),3281 terminalFocus: terminalFocusOwner !== null,
2925 terminalOpen: Boolean(terminalUiState.terminalOpen),3282 terminalOpen: Boolean(terminalUiState.terminalOpen),
2926 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,3283 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
2927 };3284 };
...@@ -2950,9 +3307,20 @@ export default function ChatView(props: ChatViewProps) {...@@ -2950,9 +3307,20 @@ export default function ChatView(props: ChatViewProps) {
2950 return;3307 return;
2951 }3308 }
29523309
3310 if (command === "rightPanel.toggle") {
3311 event.preventDefault();
3312 event.stopPropagation();
3313 toggleRightPanel();
3314 return;
3315 }
3316
2953 if (command === "terminal.split") {3317 if (command === "terminal.split") {
2954 event.preventDefault();3318 event.preventDefault();
2955 event.stopPropagation();3319 event.stopPropagation();
3320 if (terminalFocusOwner === "right-panel") {
3321 splitPanelTerminal();
3322 return;
3323 }
2956 if (!terminalUiState.terminalOpen) {3324 if (!terminalUiState.terminalOpen) {
2957 setTerminalOpen(true);3325 setTerminalOpen(true);
2958 }3326 }
...@@ -2960,9 +3328,27 @@ export default function ChatView(props: ChatViewProps) {...@@ -2960,9 +3328,27 @@ export default function ChatView(props: ChatViewProps) {
2960 return;3328 return;
2961 }3329 }
29623330
3331 if (command === "terminal.splitVertical") {
3332 event.preventDefault();
3333 event.stopPropagation();
3334 if (terminalFocusOwner === "right-panel") {
3335 splitPanelTerminal("vertical");
3336 return;
3337 }
3338 if (!terminalUiState.terminalOpen) {
3339 setTerminalOpen(true);
3340 }
3341 splitTerminal("vertical");
3342 return;
3343 }
3344
2963 if (command === "terminal.close") {3345 if (command === "terminal.close") {
2964 event.preventDefault();3346 event.preventDefault();
2965 event.stopPropagation();3347 event.stopPropagation();
3348 if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
3349 closePanelTerminal(activeRightPanelSurface.activeTerminalId);
3350 return;
3351 }
2966 if (!terminalUiState.terminalOpen) return;3352 if (!terminalUiState.terminalOpen) return;
2967 closeTerminal(terminalUiState.activeTerminalId);3353 closeTerminal(terminalUiState.activeTerminalId);
2968 return;3354 return;
...@@ -2971,6 +3357,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -2971,6 +3357,10 @@ export default function ChatView(props: ChatViewProps) {
2971 if (command === "terminal.new") {3357 if (command === "terminal.new") {
2972 event.preventDefault();3358 event.preventDefault();
2973 event.stopPropagation();3359 event.stopPropagation();
3360 if (terminalFocusOwner === "right-panel") {
3361 addTerminalSurface();
3362 return;
3363 }
2974 if (!terminalUiState.terminalOpen) {3364 if (!terminalUiState.terminalOpen) {
2975 setTerminalOpen(true);3365 setTerminalOpen(true);
2976 }3366 }
...@@ -3003,17 +3393,22 @@ export default function ChatView(props: ChatViewProps) {...@@ -3003,17 +3393,22 @@ export default function ChatView(props: ChatViewProps) {
3003 window.addEventListener("keydown", handler, true);3393 window.addEventListener("keydown", handler, true);
3004 return () => window.removeEventListener("keydown", handler, true);3394 return () => window.removeEventListener("keydown", handler, true);
3005 }, [3395 }, [
3396 activeRightPanelSurface,
3006 activeProject,3397 activeProject,
3398 addTerminalSurface,
3007 terminalUiState.terminalOpen,3399 terminalUiState.terminalOpen,
3008 terminalUiState.activeTerminalId,3400 terminalUiState.activeTerminalId,
3009 activeThreadId,3401 activeThreadId,
3402 closePanelTerminal,
3010 closeTerminal,3403 closeTerminal,
3011 createNewTerminal,3404 createNewTerminal,
3012 setTerminalOpen,3405 setTerminalOpen,
3013 runProjectScript,3406 runProjectScript,
3014 splitTerminal,3407 splitTerminal,
3408 splitPanelTerminal,
3015 keybindings,3409 keybindings,
3016 onToggleDiff,3410 onToggleDiff,
3411 toggleRightPanel,
3017 toggleTerminalVisibility,3412 toggleTerminalVisibility,
3018 composerRef,3413 composerRef,
3019 ]);3414 ]);
...@@ -4026,6 +4421,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4026,6 +4421,7 @@ export default function ChatView(props: ChatViewProps) {
4026 ? cn(4421 ? cn(
4027 "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]",4422 "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]",
4028 reserveTitleBarControlInset &&4423 reserveTitleBarControlInset &&
4424 !inlineRightPanelOwnsTitleBar &&
4029 "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",4425 "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
4030 )4426 )
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",4427 : "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",
...@@ -4044,7 +4440,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4044,7 +4440,7 @@ export default function ChatView(props: ChatViewProps) {
4044 }4440 }
4045 keybindings={keybindings}4441 keybindings={keybindings}
4046 availableEditors={availableEditors}4442 availableEditors={availableEditors}
4047 rightPanelAvailable={isPreviewSupportedInRuntime() || isGitRepo}4443 rightPanelAvailable={Boolean(activeProject)}
4048 rightPanelOpen={rightPanelOpen}4444 rightPanelOpen={rightPanelOpen}
4049 gitCwd={gitCwd}4445 gitCwd={gitCwd}
4050 onRunProjectScript={runProjectScript}4446 onRunProjectScript={runProjectScript}
...@@ -4238,33 +4634,67 @@ export default function ChatView(props: ChatViewProps) {...@@ -4238,33 +4634,67 @@ export default function ChatView(props: ChatViewProps) {
4238 {/* end chat column */}4634 {/* end chat column */}
4239 </div>4635 </div>
4240 {/* end horizontal flex container */}4636 {/* end horizontal flex container */}
4637 {activeThreadRef ? (
4638 <PersistentThreadTerminalDrawer
4639 threadRef={activeThreadRef}
4640 threadId={activeThreadRef.threadId}
4641 visible={terminalUiState.terminalOpen}
4642 launchContext={activeTerminalLaunchContext ?? null}
4643 focusRequestId={terminalFocusRequestId}
4644 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4645 splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
4646 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4647 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4648 keybindings={keybindings}
4649 onAddTerminalContext={addTerminalContextToDraft}
4650 />
4651 ) : null}
4241 </div>4652 </div>
42424653
4243 {!shouldUsePlanSidebarSheet &&4654 {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? (
4244 rightPanelOpen &&
4245 activeThreadRef &&
4246 activeRightPanelSurface ? (
4247 <RightPanelTabs4655 <RightPanelTabs
4248 mode="inline"4656 mode="inline"
4249 surfaces={rightPanelState.surfaces}4657 surfaces={rightPanelState.surfaces}
4250 activeSurfaceId={activeRightPanelSurface.id}4658 activeSurfaceId={activeRightPanelSurface?.id ?? null}
4251 previewSessions={activePreviewState.sessions}4659 previewSessions={activePreviewState.sessions}
4660 terminalLabelsById={activeTerminalLabelsById}
4252 onActivate={activateRightPanelSurface}4661 onActivate={activateRightPanelSurface}
4253 onCloseSurface={closeRightPanelSurface}4662 onCloseSurface={closeRightPanelSurface}
4254 onAddBrowser={createBrowserSurface}4663 onAddBrowser={createBrowserSurface}
4664 onAddTerminal={addTerminalSurface}
4255 onAddDiff={addDiffSurface}4665 onAddDiff={addDiffSurface}
4666 browserAvailable={isPreviewSupportedInRuntime()}
4256 diffAvailable={isServerThread && isGitRepo}4667 diffAvailable={isServerThread && isGitRepo}
4257 >4668 >
4258 {activeRightPanelSurface.kind === "preview" ? (4669 {activeRightPanelSurface?.kind === "preview" ? (
4259 <Suspense fallback={null}>4670 <Suspense fallback={null}>
4260 <PreviewPanel4671 <PreviewPanel
4261 mode="embedded"4672 mode="embedded"
4262 threadRef={activeThreadRef}4673 threadRef={activeThreadRef}
4263 tabId={activeRightPanelSurface.resourceId}4674 tabId={activeRightPanelSurface.resourceId}
4675 configuredUrls={configuredPreviewUrls}
4264 visible4676 visible
4265 />4677 />
4266 </Suspense>4678 </Suspense>
4267 ) : activeRightPanelSurface.kind === "diff" ? (4679 ) : activeRightPanelSurface?.kind === "terminal" ? (
4680 <PersistentThreadTerminalPanel
4681 threadRef={activeThreadRef}
4682 surface={activeRightPanelSurface}
4683 launchContext={activeTerminalLaunchContext ?? null}
4684 focusRequestId={terminalFocusRequestId}
4685 keybindings={keybindings}
4686 onAddTerminalContext={addTerminalContextToDraft}
4687 onSplitTerminal={splitPanelTerminal}
4688 onSplitTerminalVertical={splitPanelTerminalVertical}
4689 onNewTerminal={addTerminalSurface}
4690 onActiveTerminalChange={activatePanelTerminal}
4691 onCloseTerminal={closePanelTerminal}
4692 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4693 splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
4694 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4695 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4696 />
4697 ) : activeRightPanelSurface?.kind === "diff" ? (
4268 <DiffWorkerPoolProvider>4698 <DiffWorkerPoolProvider>
4269 <Suspense fallback={null}>4699 <Suspense fallback={null}>
4270 <DiffPanel mode="embedded" />4700 <DiffPanel mode="embedded" />
...@@ -4274,49 +4704,57 @@ export default function ChatView(props: ChatViewProps) {...@@ -4274,49 +4704,57 @@ export default function ChatView(props: ChatViewProps) {
4274 </RightPanelTabs>4704 </RightPanelTabs>
4275 ) : null}4705 ) : null}
42764706
4277 {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef && activeRightPanelSurface ? (4707 {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? (
4278 <RightPanelSheet open onClose={closePreviewPanel}>4708 <RightPanelSheet open onClose={closePreviewPanel}>
4279 <RightPanelTabs4709 <RightPanelTabs
4280 mode="sheet"4710 mode="sheet"
4281 surfaces={rightPanelState.surfaces}4711 surfaces={rightPanelState.surfaces}
4282 activeSurfaceId={activeRightPanelSurface.id}4712 activeSurfaceId={activeRightPanelSurface?.id ?? null}
4283 previewSessions={activePreviewState.sessions}4713 previewSessions={activePreviewState.sessions}
4714 terminalLabelsById={activeTerminalLabelsById}
4284 onActivate={activateRightPanelSurface}4715 onActivate={activateRightPanelSurface}
4285 onCloseSurface={closeRightPanelSurface}4716 onCloseSurface={closeRightPanelSurface}
4286 onAddBrowser={createBrowserSurface}4717 onAddBrowser={createBrowserSurface}
4718 onAddTerminal={addTerminalSurface}
4287 onAddDiff={addDiffSurface}4719 onAddDiff={addDiffSurface}
4720 browserAvailable={isPreviewSupportedInRuntime()}
4288 diffAvailable={isServerThread && isGitRepo}4721 diffAvailable={isServerThread && isGitRepo}
4289 >4722 >
4290 {activeRightPanelSurface.kind === "preview" ? (4723 {activeRightPanelSurface?.kind === "preview" ? (
4291 <Suspense fallback={null}>4724 <Suspense fallback={null}>
4292 <PreviewPanel4725 <PreviewPanel
4293 mode="embedded"4726 mode="embedded"
4294 threadRef={activeThreadRef}4727 threadRef={activeThreadRef}
4295 tabId={activeRightPanelSurface.resourceId}4728 tabId={activeRightPanelSurface.resourceId}
4729 configuredUrls={configuredPreviewUrls}
4296 visible4730 visible
4297 />4731 />
4298 </Suspense>4732 </Suspense>
4299 ) : activeRightPanelSurface.kind === "terminal" ? (4733 ) : activeRightPanelSurface?.kind === "terminal" ? (
4300 <PersistentThreadTerminalDrawer4734 <PersistentThreadTerminalPanel
4301 threadRef={activeThreadRef}4735 threadRef={activeThreadRef}
4302 threadId={activeThreadRef.threadId}4736 surface={activeRightPanelSurface}
4303 visible
4304 mode="panel"
4305 launchContext={activeTerminalLaunchContext ?? null}4737 launchContext={activeTerminalLaunchContext ?? null}
4306 focusRequestId={terminalFocusRequestId}4738 focusRequestId={terminalFocusRequestId}
4739 keybindings={keybindings}
4740 onAddTerminalContext={addTerminalContextToDraft}
4741 onSplitTerminal={splitPanelTerminal}
4742 onSplitTerminalVertical={splitPanelTerminalVertical}
4743 onNewTerminal={addTerminalSurface}
4744 onActiveTerminalChange={activatePanelTerminal}
4745 onCloseTerminal={closePanelTerminal}
4307 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}4746 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4747 splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
4308 newShortcutLabel={newTerminalShortcutLabel ?? undefined}4748 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4309 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}4749 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4310 keybindings={keybindings}
4311 onAddTerminalContext={addTerminalContextToDraft}
4312 />4750 />
4313 ) : activeRightPanelSurface.kind === "diff" ? (4751 ) : activeRightPanelSurface?.kind === "diff" ? (
4314 <DiffWorkerPoolProvider>4752 <DiffWorkerPoolProvider>
4315 <Suspense fallback={null}>4753 <Suspense fallback={null}>
4316 <DiffPanel mode="embedded" />4754 <DiffPanel mode="embedded" />
4317 </Suspense>4755 </Suspense>
4318 </DiffWorkerPoolProvider>4756 </DiffWorkerPoolProvider>
4319 ) : (4757 ) : activeRightPanelSurface?.kind === "plan" ? (
4320 <PlanSidebar4758 <PlanSidebar
4321 activePlan={activePlan}4759 activePlan={activePlan}
4322 activeProposedPlan={sidebarProposedPlan}4760 activeProposedPlan={sidebarProposedPlan}
...@@ -4328,7 +4766,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4328,7 +4766,7 @@ export default function ChatView(props: ChatViewProps) {
4328 mode="embedded"4766 mode="embedded"
4329 onClose={closePlanSidebar}4767 onClose={closePlanSidebar}
4330 />4768 />
4331 )}4769 ) : null}
4332 </RightPanelTabs>4770 </RightPanelTabs>
4333 </RightPanelSheet>4771 </RightPanelSheet>
4334 ) : null}4772 ) : null}
apps/web/src/components/RightPanelTabs.tsx+105-6
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import type { PreviewSessionSnapshot } from "@t3tools/contracts";1import type { PreviewSessionSnapshot } from "@t3tools/contracts";
2import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
2import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react";3import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react";
3import { type ReactNode, useState } from "react";4import { type ReactNode, useState } from "react";
45
6import { isElectron } from "~/env";
5import type { RightPanelSurface } from "~/rightPanelStore";7import type { RightPanelSurface } from "~/rightPanelStore";
6import { cn } from "~/lib/utils";8import { cn } from "~/lib/utils";
7import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";9import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
...@@ -12,25 +14,97 @@ import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanel...@@ -12,25 +14,97 @@ import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanel
12interface RightPanelTabsProps {14interface RightPanelTabsProps {
13 mode: PreviewPanelMode;15 mode: PreviewPanelMode;
14 surfaces: readonly RightPanelSurface[];16 surfaces: readonly RightPanelSurface[];
15 activeSurfaceId: string;17 activeSurfaceId: string | null;
16 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;18 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;
19 terminalLabelsById: ReadonlyMap<string, string>;
17 onActivate: (surface: RightPanelSurface) => void;20 onActivate: (surface: RightPanelSurface) => void;
18 onCloseSurface: (surface: RightPanelSurface) => void;21 onCloseSurface: (surface: RightPanelSurface) => void;
19 onAddBrowser: () => void;22 onAddBrowser: () => void;
23 onAddTerminal: () => void;
20 onAddDiff: () => void;24 onAddDiff: () => void;
25 browserAvailable: boolean;
21 diffAvailable: boolean;26 diffAvailable: boolean;
22 children: ReactNode;27 children: ReactNode;
23}28}
2429
30function RightPanelEmptyState(props: {
31 onAddBrowser: () => void;
32 onAddTerminal: () => void;
33 onAddDiff: () => void;
34 browserAvailable: boolean;
35 diffAvailable: boolean;
36}) {
37 const actions = [
38 {
39 label: "Browser",
40 description: "Open a local app or URL.",
41 icon: Globe2,
42 available: props.browserAvailable,
43 onClick: props.onAddBrowser,
44 },
45 {
46 label: "Terminal",
47 description: "Start a shell in this workspace.",
48 icon: TerminalSquare,
49 available: true,
50 onClick: props.onAddTerminal,
51 },
52 {
53 label: "Diff",
54 description: "Review changes in this thread.",
55 icon: FileDiff,
56 available: props.diffAvailable,
57 onClick: props.onAddDiff,
58 },
59 ] as const;
60
61 return (
62 <div className="flex min-h-0 flex-1 items-center justify-center p-6">
63 <div className="w-full max-w-xl">
64 <div className="mb-5 text-center">
65 <h3 className="text-sm font-medium text-foreground">Open a surface</h3>
66 <p className="mt-1 text-xs text-muted-foreground">
67 Choose what to show in the right panel.
68 </p>
69 </div>
70 <div className="grid gap-2 sm:grid-cols-3">
71 {actions.map((action) => {
72 const Icon = action.icon;
73 return (
74 <button
75 key={action.label}
76 type="button"
77 disabled={!action.available}
78 onClick={action.onClick}
79 className="flex min-h-28 flex-col items-start rounded-lg border border-border/80 bg-card/40 p-4 text-left transition hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-40"
80 >
81 <Icon className="mb-3 size-5" />
82 <span className="text-sm font-medium">{action.label}</span>
83 <span className="mt-1 text-xs leading-relaxed text-muted-foreground">
84 {action.description}
85 </span>
86 </button>
87 );
88 })}
89 </div>
90 </div>
91 </div>
92 );
93}
94
25function surfaceTitle(95function surfaceTitle(
26 surface: RightPanelSurface,96 surface: RightPanelSurface,
27 sessions: Readonly<Record<string, PreviewSessionSnapshot>>,97 sessions: Readonly<Record<string, PreviewSessionSnapshot>>,
98 terminalLabelsById: ReadonlyMap<string, string>,
28): string {99): string {
29 switch (surface.kind) {100 switch (surface.kind) {
30 case "diff":101 case "diff":
31 return "Diff";102 return "Diff";
32 case "terminal":103 case "terminal":
33 return "Terminal";104 return (
105 terminalLabelsById.get(surface.activeTerminalId) ??
106 getTerminalLabel(surface.activeTerminalId)
107 );
34 case "plan":108 case "plan":
35 return "Plan";109 return "Plan";
36 case "preview": {110 case "preview": {
...@@ -85,13 +159,22 @@ function SurfaceIcon({...@@ -85,13 +159,22 @@ function SurfaceIcon({
85}159}
86160
87export function RightPanelTabs(props: RightPanelTabsProps) {161export function RightPanelTabs(props: RightPanelTabsProps) {
162 const ownsDesktopTitleBar = isElectron && props.mode === "inline";
163
88 return (164 return (
89 <PreviewPanelShell mode={props.mode}>165 <PreviewPanelShell mode={props.mode}>
90 <div className="flex h-10 shrink-0 items-center px-2">166 <div
167 className={cn(
168 "flex shrink-0 items-center px-2",
169 ownsDesktopTitleBar
170 ? "drag-region h-[52px] wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]"
171 : "h-10",
172 )}
173 >
91 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">174 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
92 {props.surfaces.map((surface) => {175 {props.surfaces.map((surface) => {
93 const active = surface.id === props.activeSurfaceId;176 const active = surface.id === props.activeSurfaceId;
94 const title = surfaceTitle(surface, props.previewSessions);177 const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById);
95 return (178 return (
96 <div179 <div
97 key={surface.id}180 key={surface.id}
...@@ -131,10 +214,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -131,10 +214,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
131 <Plus className="size-4" />214 <Plus className="size-4" />
132 </MenuTrigger>215 </MenuTrigger>
133 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">216 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">
134 <MenuItem onClick={props.onAddBrowser}>217 <MenuItem onClick={props.onAddBrowser} disabled={!props.browserAvailable}>
135 <Globe2 />218 <Globe2 />
136 Browser219 Browser
137 </MenuItem>220 </MenuItem>
221 <MenuItem onClick={props.onAddTerminal}>
222 <TerminalSquare />
223 Terminal
224 </MenuItem>
138 <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}>225 <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}>
139 <FileDiff />226 <FileDiff />
140 Diff227 Diff
...@@ -142,7 +229,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -142,7 +229,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
142 </MenuPopup>229 </MenuPopup>
143 </Menu>230 </Menu>
144 </div>231 </div>
145 <div className="flex min-h-0 flex-1 flex-col">{props.children}</div>232 <div className="flex min-h-0 flex-1 flex-col">
233 {props.activeSurfaceId === null ? (
234 <RightPanelEmptyState
235 onAddBrowser={props.onAddBrowser}
236 onAddTerminal={props.onAddTerminal}
237 onAddDiff={props.onAddDiff}
238 browserAvailable={props.browserAvailable}
239 diffAvailable={props.diffAvailable}
240 />
241 ) : (
242 props.children
243 )}
244 </div>
146 </PreviewPanelShell>245 </PreviewPanelShell>
147 );246 );
148}247}
apps/web/src/components/Sidebar.tsx+38
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 ChevronRightIcon,4 ChevronRightIcon,
5 CloudIcon,5 CloudIcon,
6 FolderPlusIcon,6 FolderPlusIcon,
7 Globe2Icon,
7 SearchIcon,8 SearchIcon,
8 SettingsIcon,9 SettingsIcon,
9 SquarePenIcon,10 SquarePenIcon,
...@@ -75,6 +76,7 @@ import {...@@ -75,6 +76,7 @@ import {
75} from "../store";76} from "../store";
76import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";77import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
77import { useThreadRunningTerminalIds } from "../terminalSessionState";78import { useThreadRunningTerminalIds } from "../terminalSessionState";
79import { useThreadDiscoveredPorts } from "../portDiscoveryState";
78import { useUiStateStore } from "../uiStateStore";80import { useUiStateStore } from "../uiStateStore";
79import {81import {
80 resolveShortcutCommand,82 resolveShortcutCommand,
...@@ -198,6 +200,7 @@ import {...@@ -198,6 +200,7 @@ import {
198 type SidebarProjectSnapshot,200 type SidebarProjectSnapshot,
199} from "../sidebarProjectGrouping";201} from "../sidebarProjectGrouping";
200import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";202import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";
203import { openDiscoveredPort } from "./preview/openDiscoveredPort";
201const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {204const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
202 updated_at: "Last user message",205 updated_at: "Last user message",
203 created_at: "Created at",206 created_at: "Created at",
...@@ -349,6 +352,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -349,6 +352,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
349 environmentId: thread.environmentId,352 environmentId: thread.environmentId,
350 threadId: thread.id,353 threadId: thread.id,
351 });354 });
355 const discoveredPorts = useThreadDiscoveredPorts({
356 environmentId: thread.environmentId,
357 threadId: thread.id,
358 });
352 const primaryEnvironmentId = usePrimaryEnvironmentId();359 const primaryEnvironmentId = usePrimaryEnvironmentId();
353 const isRemoteThread =360 const isRemoteThread =
354 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;361 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
...@@ -419,6 +426,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -419,6 +426,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
419 },426 },
420 [handleThreadClick, orderedProjectThreadKeys, threadRef],427 [handleThreadClick, orderedProjectThreadKeys, threadRef],
421 );428 );
429 const handleOpenDiscoveredPort = useCallback(
430 (event: React.MouseEvent<HTMLButtonElement>) => {
431 const port = discoveredPorts[0];
432 if (!port) return;
433 event.preventDefault();
434 event.stopPropagation();
435 navigateToThread(threadRef);
436 void openDiscoveredPort({ threadRef, port });
437 },
438 [discoveredPorts, navigateToThread, threadRef],
439 );
422 const handleRowKeyDown = useCallback(440 const handleRowKeyDown = useCallback(
423 (event: React.KeyboardEvent) => {441 (event: React.KeyboardEvent) => {
424 if (event.key !== "Enter" && event.key !== " ") return;442 if (event.key !== "Enter" && event.key !== " ") return;
...@@ -609,6 +627,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -609,6 +627,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
609 )}627 )}
610 </div>628 </div>
611 <div className="ml-auto flex shrink-0 items-center gap-1.5">629 <div className="ml-auto flex shrink-0 items-center gap-1.5">
630 {discoveredPorts.length > 0 && (
631 <Tooltip>
632 <TooltipTrigger
633 render={
634 <button
635 type="button"
636 aria-label={`Open localhost:${discoveredPorts[0]?.port ?? ""}`}
637 className="inline-flex cursor-pointer items-center justify-center text-emerald-600 outline-hidden focus-visible:ring-1 focus-visible:ring-ring dark:text-emerald-400"
638 onClick={handleOpenDiscoveredPort}
639 />
640 }
641 >
642 <Globe2Icon className="size-3" />
643 </TooltipTrigger>
644 <TooltipPopup side="top">
645 Open localhost:{discoveredPorts[0]?.port}
646 {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""}
647 </TooltipPopup>
648 </Tooltip>
649 )}
612 {terminalStatus && (650 {terminalStatus && (
613 <Tooltip>651 <Tooltip>
614 <TooltipTrigger652 <TooltipTrigger
apps/web/src/components/ThreadTerminalDrawer.tsx+148-8
...@@ -1,5 +1,13 @@...@@ -1,5 +1,13 @@
1import { FitAddon } from "@xterm/addon-fit";1import { FitAddon } from "@xterm/addon-fit";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";2import {
3 Globe2,
4 Plus,
5 SquareSplitHorizontal,
6 SquareSplitVertical,
7 TerminalSquare,
8 Trash2,
9 XIcon,
10} from "lucide-react";
3import {11import {
4 type ResolvedKeybindingsConfig,12 type ResolvedKeybindingsConfig,
5 type ScopedThreadRef,13 type ScopedThreadRef,
...@@ -37,6 +45,7 @@ import {...@@ -37,6 +45,7 @@ import {
37 isTerminalCloseShortcut,45 isTerminalCloseShortcut,
38 isTerminalNewShortcut,46 isTerminalNewShortcut,
39 isTerminalSplitShortcut,47 isTerminalSplitShortcut,
48 isTerminalSplitVerticalShortcut,
40 isTerminalToggleShortcut,49 isTerminalToggleShortcut,
41 terminalDeleteShortcutData,50 terminalDeleteShortcutData,
42 terminalNavigationShortcutData,51 terminalNavigationShortcutData,
...@@ -50,6 +59,8 @@ import { readEnvironmentApi } from "~/environmentApi";...@@ -50,6 +59,8 @@ import { readEnvironmentApi } from "~/environmentApi";
50import { readLocalApi } from "~/localApi";59import { readLocalApi } from "~/localApi";
51import { attachTerminalSession } from "../terminalSessionState";60import { attachTerminalSession } from "../terminalSessionState";
52import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";61import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
62import { useDiscoveredPorts } from "../portDiscoveryState";
63import { openDiscoveredPort } from "./preview/openDiscoveredPort";
5364
54const MIN_DRAWER_HEIGHT = 180;65const MIN_DRAWER_HEIGHT = 180;
55const MAX_DRAWER_HEIGHT_RATIO = 0.75;66const MAX_DRAWER_HEIGHT_RATIO = 0.75;
...@@ -437,6 +448,7 @@ export function TerminalViewport({...@@ -437,6 +448,7 @@ export function TerminalViewport({
437 if (448 if (
438 isTerminalToggleShortcut(event, currentKeybindings, options) ||449 isTerminalToggleShortcut(event, currentKeybindings, options) ||
439 isTerminalSplitShortcut(event, currentKeybindings, options) ||450 isTerminalSplitShortcut(event, currentKeybindings, options) ||
451 isTerminalSplitVerticalShortcut(event, currentKeybindings, options) ||
440 isTerminalNewShortcut(event, currentKeybindings, options) ||452 isTerminalNewShortcut(event, currentKeybindings, options) ||
441 isTerminalCloseShortcut(event, currentKeybindings, options) ||453 isTerminalCloseShortcut(event, currentKeybindings, options) ||
442 isDiffToggleShortcut(event, currentKeybindings, options)454 isDiffToggleShortcut(event, currentKeybindings, options)
...@@ -723,12 +735,44 @@ export function TerminalViewport({...@@ -723,12 +735,44 @@ export function TerminalViewport({
723 .catch(() => undefined);735 .catch(() => undefined);
724 }, 30);736 }, 30);
725 attachTerminal();737 attachTerminal();
738 let resizeFrame = 0;
739 const resizeObserver =
740 typeof ResizeObserver === "undefined"
741 ? null
742 : new ResizeObserver(() => {
743 if (resizeFrame !== 0) return;
744 resizeFrame = window.requestAnimationFrame(() => {
745 resizeFrame = 0;
746 const activeTerminal = terminalRef.current;
747 const activeFitAddon = fitAddonRef.current;
748 if (!activeTerminal || !activeFitAddon) return;
749 const wasAtBottom =
750 activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;
751 fitTerminalSafely(activeFitAddon);
752 if (wasAtBottom) {
753 activeTerminal.scrollToBottom();
754 }
755 void api.terminal
756 .resize({
757 threadId,
758 terminalId,
759 cols: activeTerminal.cols,
760 rows: activeTerminal.rows,
761 })
762 .catch(() => undefined);
763 });
764 });
765 resizeObserver?.observe(mount);
726766
727 return () => {767 return () => {
728 disposed = true;768 disposed = true;
729 unsubscribeAttach?.();769 unsubscribeAttach?.();
730 unsubscribeAttach = null;770 unsubscribeAttach = null;
731 window.clearTimeout(fitTimer);771 window.clearTimeout(fitTimer);
772 if (resizeFrame !== 0) {
773 window.cancelAnimationFrame(resizeFrame);
774 }
775 resizeObserver?.disconnect();
732 inputDisposable.dispose();776 inputDisposable.dispose();
733 selectionDisposable.dispose();777 selectionDisposable.dispose();
734 terminalLinksDisposable.dispose();778 terminalLinksDisposable.dispose();
...@@ -806,8 +850,10 @@ interface ThreadTerminalDrawerProps {...@@ -806,8 +850,10 @@ interface ThreadTerminalDrawerProps {
806 activeTerminalGroupId: string;850 activeTerminalGroupId: string;
807 focusRequestId: number;851 focusRequestId: number;
808 onSplitTerminal: () => void;852 onSplitTerminal: () => void;
853 onSplitTerminalVertical: () => void;
809 onNewTerminal: () => void;854 onNewTerminal: () => void;
810 splitShortcutLabel?: string | undefined;855 splitShortcutLabel?: string | undefined;
856 splitVerticalShortcutLabel?: string | undefined;
811 newShortcutLabel?: string | undefined;857 newShortcutLabel?: string | undefined;
812 closeShortcutLabel?: string | undefined;858 closeShortcutLabel?: string | undefined;
813 onActiveTerminalChange: (terminalId: string) => void;859 onActiveTerminalChange: (terminalId: string) => void;
...@@ -865,8 +911,10 @@ export default function ThreadTerminalDrawer({...@@ -865,8 +911,10 @@ export default function ThreadTerminalDrawer({
865 activeTerminalGroupId,911 activeTerminalGroupId,
866 focusRequestId,912 focusRequestId,
867 onSplitTerminal,913 onSplitTerminal,
914 onSplitTerminalVertical,
868 onNewTerminal,915 onNewTerminal,
869 splitShortcutLabel,916 splitShortcutLabel,
917 splitVerticalShortcutLabel,
870 newShortcutLabel,918 newShortcutLabel,
871 closeShortcutLabel,919 closeShortcutLabel,
872 onActiveTerminalChange,920 onActiveTerminalChange,
...@@ -957,6 +1005,9 @@ export default function ThreadTerminalDrawer({...@@ -957,6 +1005,9 @@ export default function ThreadTerminalDrawer({
957 nextGroups.push({1005 nextGroups.push({
958 id: assignUniqueGroupId(baseGroupId),1006 id: assignUniqueGroupId(baseGroupId),
959 terminalIds: nextTerminalIds,1007 terminalIds: nextTerminalIds,
1008 ...(terminalGroup.splitDirection === "vertical"
1009 ? { splitDirection: "vertical" as const }
1010 : {}),
960 });1011 });
961 }1012 }
9621013
...@@ -994,6 +1045,8 @@ export default function ThreadTerminalDrawer({...@@ -994,6 +1045,8 @@ export default function ThreadTerminalDrawer({
994 const visibleTerminalIds =1045 const visibleTerminalIds =
995 resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ??1046 resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ??
996 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);1047 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);
1048 const splitDirection =
1049 resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal";
997 const hasTerminalSidebar = normalizedTerminalIds.length > 1;1050 const hasTerminalSidebar = normalizedTerminalIds.length > 1;
998 const isSplitView = visibleTerminalIds.length > 1;1051 const isSplitView = visibleTerminalIds.length > 1;
999 const showGroupHeaders =1052 const showGroupHeaders =
...@@ -1007,6 +1060,17 @@ export default function ThreadTerminalDrawer({...@@ -1007,6 +1060,17 @@ export default function ThreadTerminalDrawer({
1007 }1060 }
1008 return next;1061 return next;
1009 }, [normalizedTerminalIds, terminalLabelsById]);1062 }, [normalizedTerminalIds, terminalLabelsById]);
1063 const discoveredPorts = useDiscoveredPorts(threadRef.environmentId);
1064 const discoveredPortByTerminalId = useMemo(() => {
1065 const next = new Map<string, (typeof discoveredPorts)[number]>();
1066 for (const port of discoveredPorts) {
1067 if (port.terminal?.threadId !== threadId) continue;
1068 if (!next.has(port.terminal.terminalId)) {
1069 next.set(port.terminal.terminalId, port);
1070 }
1071 }
1072 return next;
1073 }, [discoveredPorts, threadId]);
1010 const resolveTerminalLaunchLocation = useCallback(1074 const resolveTerminalLaunchLocation = useCallback(
1011 (terminalId: string): TerminalLaunchLocation => {1075 (terminalId: string): TerminalLaunchLocation => {
1012 return (1076 return (
...@@ -1020,10 +1084,15 @@ export default function ThreadTerminalDrawer({...@@ -1020,10 +1084,15 @@ export default function ThreadTerminalDrawer({
1020 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],1084 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],
1021 );1085 );
1022 const splitTerminalActionLabel = hasReachedSplitLimit1086 const splitTerminalActionLabel = hasReachedSplitLimit
1023 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`1087 ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)`
1024 : splitShortcutLabel1088 : splitShortcutLabel
1025 ? `Split Terminal (${splitShortcutLabel})`1089 ? `Split Terminal Horizontally (${splitShortcutLabel})`
1026 : "Split Terminal";1090 : "Split Terminal Horizontally";
1091 const splitTerminalVerticalActionLabel = hasReachedSplitLimit
1092 ? `Split Terminal Vertically (max ${MAX_TERMINALS_PER_GROUP} per group)`
1093 : splitVerticalShortcutLabel
1094 ? `Split Terminal Vertically (${splitVerticalShortcutLabel})`
1095 : "Split Terminal Vertically";
1027 const newTerminalActionLabel = newShortcutLabel1096 const newTerminalActionLabel = newShortcutLabel
1028 ? `New Terminal (${newShortcutLabel})`1097 ? `New Terminal (${newShortcutLabel})`
1029 : "New Terminal";1098 : "New Terminal";
...@@ -1034,6 +1103,10 @@ export default function ThreadTerminalDrawer({...@@ -1034,6 +1103,10 @@ export default function ThreadTerminalDrawer({
1034 if (hasReachedSplitLimit) return;1103 if (hasReachedSplitLimit) return;
1035 onSplitTerminal();1104 onSplitTerminal();
1036 }, [hasReachedSplitLimit, onSplitTerminal]);1105 }, [hasReachedSplitLimit, onSplitTerminal]);
1106 const onSplitTerminalVerticalAction = useCallback(() => {
1107 if (hasReachedSplitLimit) return;
1108 onSplitTerminalVertical();
1109 }, [hasReachedSplitLimit, onSplitTerminalVertical]);
1037 const onNewTerminalAction = useCallback(() => {1110 const onNewTerminalAction = useCallback(() => {
1038 onNewTerminal();1111 onNewTerminal();
1039 }, [onNewTerminal]);1112 }, [onNewTerminal]);
...@@ -1143,6 +1216,7 @@ export default function ThreadTerminalDrawer({...@@ -1143,6 +1216,7 @@ export default function ThreadTerminalDrawer({
1143 if (normalizedTerminalIds.length === 0) {1216 if (normalizedTerminalIds.length === 0) {
1144 return (1217 return (
1145 <aside1218 <aside
1219 data-terminal-owner={isPanel ? "right-panel" : "drawer"}
1146 className={cn(1220 className={cn(
1147 "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background",1221 "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",1222 isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80",
...@@ -1176,6 +1250,7 @@ export default function ThreadTerminalDrawer({...@@ -1176,6 +1250,7 @@ export default function ThreadTerminalDrawer({
11761250
1177 return (1251 return (
1178 <aside1252 <aside
1253 data-terminal-owner={isPanel ? "right-panel" : "drawer"}
1179 className={cn(1254 className={cn(
1180 "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background",1255 "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",1256 isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80",
...@@ -1207,6 +1282,18 @@ export default function ThreadTerminalDrawer({...@@ -1207,6 +1282,18 @@ export default function ThreadTerminalDrawer({
1207 <SquareSplitHorizontal className="size-3.25" />1282 <SquareSplitHorizontal className="size-3.25" />
1208 </TerminalActionButton>1283 </TerminalActionButton>
1209 <div className="h-4 w-px bg-border/80" />1284 <div className="h-4 w-px bg-border/80" />
1285 <TerminalActionButton
1286 className={`p-1 text-foreground/90 transition-colors ${
1287 hasReachedSplitLimit
1288 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
1289 : "hover:bg-accent"
1290 }`}
1291 onClick={onSplitTerminalVerticalAction}
1292 label={splitTerminalVerticalActionLabel}
1293 >
1294 <SquareSplitVertical className="size-3.25" />
1295 </TerminalActionButton>
1296 <div className="h-4 w-px bg-border/80" />
1210 <TerminalActionButton1297 <TerminalActionButton
1211 className="p-1 text-foreground/90 transition-colors hover:bg-accent"1298 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1212 onClick={onNewTerminalAction}1299 onClick={onNewTerminalAction}
...@@ -1232,16 +1319,26 @@ export default function ThreadTerminalDrawer({...@@ -1232,16 +1319,26 @@ export default function ThreadTerminalDrawer({
1232 {isSplitView ? (1319 {isSplitView ? (
1233 <div1320 <div
1234 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"1321 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"
1235 style={{1322 style={
1236 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,1323 splitDirection === "vertical"
1237 }}1324 ? {
1325 gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1326 }
1327 : {
1328 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1329 }
1330 }
1238 >1331 >
1239 {visibleTerminalIds.map((terminalId) => {1332 {visibleTerminalIds.map((terminalId) => {
1240 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);1333 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);
1241 return (1334 return (
1242 <div1335 <div
1243 key={terminalId}1336 key={terminalId}
1244 className={`min-h-0 min-w-0 border-l first:border-l-0 ${1337 className={`min-h-0 min-w-0 ${
1338 splitDirection === "vertical"
1339 ? "border-t first:border-t-0"
1340 : "border-l first:border-l-0"
1341 } ${
1245 terminalId === resolvedActiveTerminalId1342 terminalId === resolvedActiveTerminalId
1246 ? "border-border"1343 ? "border-border"
1247 : "border-border/70"1344 : "border-border/70"
...@@ -1320,6 +1417,17 @@ export default function ThreadTerminalDrawer({...@@ -1320,6 +1417,17 @@ export default function ThreadTerminalDrawer({
1320 >1417 >
1321 <SquareSplitHorizontal className="size-3.25" />1418 <SquareSplitHorizontal className="size-3.25" />
1322 </TerminalActionButton>1419 </TerminalActionButton>
1420 <TerminalActionButton
1421 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
1422 hasReachedSplitLimit
1423 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
1424 : "hover:bg-accent/70"
1425 }`}
1426 onClick={onSplitTerminalVerticalAction}
1427 label={splitTerminalVerticalActionLabel}
1428 >
1429 <SquareSplitVertical className="size-3.25" />
1430 </TerminalActionButton>
1323 <TerminalActionButton1431 <TerminalActionButton
1324 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"1432 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
1325 onClick={onNewTerminalAction}1433 onClick={onNewTerminalAction}
...@@ -1366,6 +1474,7 @@ export default function ThreadTerminalDrawer({...@@ -1366,6 +1474,7 @@ export default function ThreadTerminalDrawer({
1366 >1474 >
1367 {terminalGroup.terminalIds.map((terminalId) => {1475 {terminalGroup.terminalIds.map((terminalId) => {
1368 const isActive = terminalId === resolvedActiveTerminalId;1476 const isActive = terminalId === resolvedActiveTerminalId;
1477 const discoveredPort = discoveredPortByTerminalId.get(terminalId);
1369 const closeTerminalLabel = `Close ${1478 const closeTerminalLabel = `Close ${
1370 terminalLabelById.get(terminalId) ?? "terminal"1479 terminalLabelById.get(terminalId) ?? "terminal"
1371 }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`;1480 }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`;
...@@ -1391,6 +1500,37 @@ export default function ThreadTerminalDrawer({...@@ -1391,6 +1500,37 @@ export default function ThreadTerminalDrawer({
1391 {terminalLabelById.get(terminalId) ?? "Terminal"}1500 {terminalLabelById.get(terminalId) ?? "Terminal"}
1392 </span>1501 </span>
1393 </button>1502 </button>
1503 {discoveredPort && (
1504 <Popover>
1505 <PopoverTrigger
1506 openOnHover
1507 render={
1508 <button
1509 type="button"
1510 className="inline-flex size-4 items-center justify-center rounded text-emerald-600 transition hover:bg-accent dark:text-emerald-400"
1511 onClick={() =>
1512 void openDiscoveredPort({
1513 threadRef,
1514 port: discoveredPort,
1515 })
1516 }
1517 aria-label={`Open localhost:${discoveredPort.port}`}
1518 />
1519 }
1520 >
1521 <Globe2 className="size-3" />
1522 </PopoverTrigger>
1523 <PopoverPopup
1524 tooltipStyle
1525 side="bottom"
1526 sideOffset={6}
1527 align="center"
1528 className="pointer-events-none select-none"
1529 >
1530 Open localhost:{discoveredPort.port}
1531 </PopoverPopup>
1532 </Popover>
1533 )}
1394 {normalizedTerminalIds.length > 1 && (1534 {normalizedTerminalIds.length > 1 && (
1395 <Popover>1535 <Popover>
1396 <PopoverTrigger1536 <PopoverTrigger
apps/web/src/components/chat/ChatHeader.tsx+5-1
...@@ -16,6 +16,7 @@ import { Toggle } from "../ui/toggle";...@@ -16,6 +16,7 @@ import { Toggle } from "../ui/toggle";
16import { SidebarTrigger } from "../ui/sidebar";16import { SidebarTrigger } from "../ui/sidebar";
17import { OpenInPicker } from "./OpenInPicker";17import { OpenInPicker } from "./OpenInPicker";
18import { usePrimaryEnvironmentId } from "../../environments/primary";18import { usePrimaryEnvironmentId } from "../../environments/primary";
19import { shortcutLabelForCommand } from "../../keybindings";
1920
20interface ChatHeaderProps {21interface ChatHeaderProps {
21 activeThreadEnvironmentId: EnvironmentId;22 activeThreadEnvironmentId: EnvironmentId;
...@@ -76,6 +77,7 @@ export const ChatHeader = memo(function ChatHeader({...@@ -76,6 +77,7 @@ export const ChatHeader = memo(function ChatHeader({
76 activeThreadEnvironmentId,77 activeThreadEnvironmentId,
77 primaryEnvironmentId,78 primaryEnvironmentId,
78 });79 });
80 const rightPanelShortcutLabel = shortcutLabelForCommand(keybindings, "rightPanel.toggle");
7981
80 return (82 return (
81 <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">83 <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
...@@ -138,7 +140,9 @@ export const ChatHeader = memo(function ChatHeader({...@@ -138,7 +140,9 @@ export const ChatHeader = memo(function ChatHeader({
138 }140 }
139 />141 />
140 <TooltipPopup side="bottom">142 <TooltipPopup side="bottom">
141 {rightPanelAvailable ? "Toggle right panel" : "Right panel is unavailable"}143 {rightPanelAvailable
144 ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}`
145 : "Right panel is unavailable"}
142 </TooltipPopup>146 </TooltipPopup>
143 </Tooltip>147 </Tooltip>
144 </div>148 </div>
apps/web/src/components/preview/AgentBrowserCursor.tsx created+52
...@@ -0,0 +1,52 @@
1"use client";
2
3import { MousePointer2 } from "lucide-react";
4import { useEffect, useState } from "react";
5
6import { useBrowserPointerStore } from "~/browser/browserPointerStore";
7
8import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic";
9
10const CURSOR_ACTIVE_MS = 700;
11
12export function AgentBrowserCursor(props: {
13 readonly tabId: string;
14 readonly zoomFactor: number;
15 readonly controller: BrowserController;
16}) {
17 const { tabId, zoomFactor, controller } = props;
18 const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null);
19 const [active, setActive] = useState(false);
20
21 useEffect(() => {
22 if (!event) return;
23 setActive(true);
24 const timeout = window.setTimeout(() => setActive(false), CURSOR_ACTIVE_MS);
25 return () => window.clearTimeout(timeout);
26 }, [event]);
27
28 if (!event) return null;
29
30 return (
31 <div
32 className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none"
33 style={{
34 opacity: agentBrowserCursorOpacity(active, controller),
35 transform: `translate3d(${event.x * zoomFactor}px, ${event.y * zoomFactor}px, 0)`,
36 }}
37 aria-hidden="true"
38 data-agent-browser-cursor
39 >
40 {event.phase === "click" ? (
41 <span
42 key={event.sequence}
43 className="absolute left-0.5 top-0.5 size-4 animate-ping rounded-full bg-primary/25 motion-reduce:animate-none"
44 />
45 ) : null}
46 <MousePointer2
47 className="relative size-5 -translate-x-0.5 -translate-y-0.5 fill-background text-primary drop-shadow-sm"
48 strokeWidth={2}
49 />
50 </div>
51 );
52}
apps/web/src/components/preview/PreviewChromeRow.browser.tsx created+41
...@@ -0,0 +1,41 @@
1import "../../index.css";
2
3import { page } from "vite-plus/test/browser";
4import { describe, expect, it, vi } from "vite-plus/test";
5import { render } from "vitest-browser-react";
6
7import { PreviewChromeRow } from "./PreviewChromeRow";
8
9const defaultProps = {
10 url: "https://example.com/",
11 loading: false,
12 loadProgress: 0,
13 canGoBack: false,
14 canGoForward: false,
15 refreshDisabled: false,
16 onBack: vi.fn(),
17 onForward: vi.fn(),
18 onRefresh: vi.fn(),
19 onSubmit: vi.fn(),
20};
21
22describe("PreviewChromeRow", () => {
23 it("only focuses the URL input after an explicit focus request", async () => {
24 const previouslyFocused = document.createElement("button");
25 document.body.append(previouslyFocused);
26 previouslyFocused.focus();
27
28 const screen = await render(<PreviewChromeRow {...defaultProps} focusUrlNonce={undefined} />);
29 const input = page.getByRole("textbox").element() as HTMLInputElement;
30
31 expect(document.activeElement).toBe(previouslyFocused);
32
33 await screen.rerender(<PreviewChromeRow {...defaultProps} focusUrlNonce={1} />);
34
35 expect(document.activeElement).toBe(input);
36 expect(input.selectionStart).toBe(0);
37 expect(input.selectionEnd).toBe(input.value.length);
38
39 previouslyFocused.remove();
40 });
41});
apps/web/src/components/preview/PreviewView.tsx+215-17
...@@ -22,16 +22,19 @@ import { PreviewChromeRow } from "./PreviewChromeRow";...@@ -22,16 +22,19 @@ import { PreviewChromeRow } from "./PreviewChromeRow";
22import { PreviewEmptyState } from "./PreviewEmptyState";22import { PreviewEmptyState } from "./PreviewEmptyState";
23import { PreviewMoreMenu } from "./PreviewMoreMenu";23import { PreviewMoreMenu } from "./PreviewMoreMenu";
24import { PreviewUnreachable } from "./PreviewUnreachable";24import { PreviewUnreachable } from "./PreviewUnreachable";
25import { revealInFileExplorerLabel } from "./fileExplorerLabel";
26import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
25import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";27import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
26import { useLoadingProgress } from "./useLoadingProgress";28import { useLoadingProgress } from "./useLoadingProgress";
27import { usePreviewSession } from "./usePreviewSession";29import { usePreviewSession } from "./usePreviewSession";
28import { ZoomIndicator } from "./ZoomIndicator";30import { ZoomIndicator } from "./ZoomIndicator";
31import { AgentBrowserCursor } from "./AgentBrowserCursor";
29import {32import {
30 startBrowserRecording,33 startBrowserRecording,
31 stopBrowserRecording,34 stopBrowserRecording,
32 useBrowserRecordingStore,35 useBrowserRecordingStore,
33} from "~/browser/browserRecording";36} from "~/browser/browserRecording";
34import { toastManager } from "~/components/ui/toast";37import { stackedThreadToast, toastManager } from "~/components/ui/toast";
3538
36interface Props {39interface Props {
37 threadRef: ScopedThreadRef;40 threadRef: ScopedThreadRef;
...@@ -47,7 +50,7 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();...@@ -47,7 +50,7 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();
47 * state when no session exists for the thread.50 * state when no session exists for the thread.
48 */51 */
49export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) {52export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) {
50 const [focusUrlNonce, setFocusUrlNonce] = useState(0);53 const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);
51 const [pickActive, setPickActive] = useState(false);54 const [pickActive, setPickActive] = useState(false);
52 const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId);55 const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId);
53 const pickActiveRef = useRef(false);56 const pickActiveRef = useRef(false);
...@@ -81,6 +84,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -81,6 +84,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
81 const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false;84 const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false;
82 const refreshDisabled = navStatus._tag === "Idle";85 const refreshDisabled = navStatus._tag === "Idle";
83 const isUnreachable = navStatus._tag === "LoadFailed";86 const isUnreachable = navStatus._tag === "LoadFailed";
87 const showEmptyState = shouldShowPreviewEmptyState(snapshot);
84 const controller = desktopOverlay?.controller ?? "none";88 const controller = desktopOverlay?.controller ?? "none";
85 const loadProgress = useLoadingProgress(loading);89 const loadProgress = useLoadingProgress(loading);
8690
...@@ -142,16 +146,89 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -142,16 +146,89 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
142 const handleCapture = useCallback(146 const handleCapture = useCallback(
143 (record: boolean) => {147 (record: boolean) => {
144 if (!previewBridge || !tabId) return;148 if (!previewBridge || !tabId) return;
149 const bridge = previewBridge;
145 const recordingThisTab = activeRecordingTabId === tabId;150 const recordingThisTab = activeRecordingTabId === tabId;
146 if (recordingThisTab) {151 if (recordingThisTab) {
147 void stopBrowserRecording(tabId).then(152 void stopBrowserRecording(tabId).then(
148 (artifact) => {153 (artifact) => {
149 if (!artifact) return;154 if (!artifact) return;
150 toastManager.add({155 let pathCopied = false;
151 type: "success",156 let toastId: ReturnType<typeof toastManager.add>;
152 title: "Recording saved",157
153 description: artifact.path,158 const copyPath = () => {
154 });159 if (!navigator.clipboard?.writeText) {
160 toastManager.update(
161 toastId,
162 stackedThreadToast({
163 type: "error",
164 title: "Unable to copy recording path",
165 description: "Clipboard API unavailable.",
166 actionProps: revealAction,
167 }),
168 );
169 return;
170 }
171
172 void navigator.clipboard.writeText(artifact.path).then(
173 () => {
174 pathCopied = true;
175 updateRecordingToast();
176 window.setTimeout(() => {
177 pathCopied = false;
178 updateRecordingToast();
179 }, 2_000);
180 },
181 (error) => {
182 toastManager.update(
183 toastId,
184 stackedThreadToast({
185 type: "error",
186 title: "Unable to copy recording path",
187 description: error instanceof Error ? error.message : "An error occurred.",
188 actionProps: revealAction,
189 }),
190 );
191 },
192 );
193 };
194
195 const revealAction = {
196 children: revealInFileExplorerLabel(navigator.platform),
197 onClick: () => void bridge.revealArtifact(artifact.path),
198 };
199 const updateRecordingToast = () => {
200 toastManager.update(
201 toastId,
202 stackedThreadToast({
203 type: "success",
204 title: "Recording saved",
205 actionProps: revealAction,
206 data: {
207 secondaryActionProps: {
208 children: pathCopied ? "Copied!" : "Copy path",
209 disabled: pathCopied,
210 onClick: copyPath,
211 },
212 secondaryActionVariant: "outline",
213 },
214 }),
215 );
216 };
217
218 toastId = toastManager.add(
219 stackedThreadToast({
220 type: "success",
221 title: "Recording saved",
222 actionProps: revealAction,
223 data: {
224 secondaryActionProps: {
225 children: "Copy path",
226 onClick: copyPath,
227 },
228 secondaryActionVariant: "outline",
229 },
230 }),
231 );
155 },232 },
156 (error) => {233 (error) => {
157 toastManager.add({234 toastManager.add({
...@@ -181,13 +258,126 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -181,13 +258,126 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
181 });258 });
182 return;259 return;
183 }260 }
184 void previewBridge.captureScreenshot(tabId).then(261 void bridge.captureScreenshot(tabId).then(
185 (artifact) => {262 (artifact) => {
186 toastManager.add({263 const revealAction = {
187 type: "success",264 children: revealInFileExplorerLabel(navigator.platform),
188 title: "Screenshot saved",265 onClick: () => void bridge.revealArtifact(artifact.path),
189 description: artifact.path,266 };
190 });267 let pathCopied = false;
268 let imageCopied = false;
269 let toastId: ReturnType<typeof toastManager.add>;
270
271 const updateScreenshotToast = (
272 type: "success" | "error" = "success",
273 title = "Screenshot saved",
274 description?: string,
275 ) => {
276 toastManager.update(
277 toastId,
278 stackedThreadToast({
279 type,
280 title,
281 description,
282 actionProps: {
283 children: imageCopied ? "Copied!" : "Copy image",
284 disabled: imageCopied,
285 onClick: copyImage,
286 },
287 data: {
288 additionalActions: [
289 {
290 id: "copy-path",
291 props: {
292 children: pathCopied ? "Copied!" : "Copy path",
293 disabled: pathCopied,
294 onClick: copyPath,
295 },
296 },
297 ],
298 secondaryActionProps: {
299 ...revealAction,
300 },
301 secondaryActionVariant: "outline",
302 },
303 }),
304 );
305 };
306
307 const copyPath = () => {
308 if (!navigator.clipboard?.writeText) {
309 updateScreenshotToast(
310 "error",
311 "Unable to copy screenshot path",
312 "Clipboard API unavailable.",
313 );
314 return;
315 }
316
317 void navigator.clipboard.writeText(artifact.path).then(
318 () => {
319 pathCopied = true;
320 updateScreenshotToast();
321 window.setTimeout(() => {
322 pathCopied = false;
323 updateScreenshotToast();
324 }, 2_000);
325 },
326 (error) => {
327 updateScreenshotToast(
328 "error",
329 "Unable to copy screenshot path",
330 error instanceof Error ? error.message : "An error occurred.",
331 );
332 },
333 );
334 };
335
336 const copyImage = () => {
337 void bridge.copyArtifactToClipboard(artifact.path).then(
338 () => {
339 imageCopied = true;
340 updateScreenshotToast();
341 window.setTimeout(() => {
342 imageCopied = false;
343 updateScreenshotToast();
344 }, 2_000);
345 },
346 (error) => {
347 updateScreenshotToast(
348 "error",
349 "Unable to copy screenshot",
350 error instanceof Error ? error.message : "An error occurred.",
351 );
352 },
353 );
354 };
355
356 toastId = toastManager.add(
357 stackedThreadToast({
358 type: "success",
359 title: "Screenshot saved",
360 actionProps: {
361 children: "Copy image",
362 onClick: copyImage,
363 },
364 data: {
365 additionalActions: [
366 {
367 id: "copy-path",
368 props: {
369 children: "Copy path",
370 onClick: copyPath,
371 },
372 },
373 ],
374 secondaryActionProps: {
375 ...revealAction,
376 },
377 secondaryActionVariant: "outline",
378 },
379 }),
380 );
191 },381 },
192 (error) => {382 (error) => {
193 toastManager.add({383 toastManager.add({
...@@ -287,7 +477,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -287,7 +477,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
287 handleRefresh();477 handleRefresh();
288 return;478 return;
289 case "focus-url":479 case "focus-url":
290 setFocusUrlNonce((value) => value + 1);480 setFocusUrlNonce((value) => (value ?? 0) + 1);
291 return;481 return;
292 case "zoom-in":482 case "zoom-in":
293 handleZoomIn();483 handleZoomIn();
...@@ -346,24 +536,32 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -346,24 +536,32 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
346 />536 />
347537
348 <div className="relative min-h-0 flex-1 overflow-hidden">538 <div className="relative min-h-0 flex-1 overflow-hidden">
349 {tabId && snapshot ? (539 {tabId && snapshot && !showEmptyState ? (
350 <BrowserSurfaceSlot540 <BrowserSurfaceSlot
351 key={tabId}541 key={tabId}
352 tabId={tabId}542 tabId={tabId}
353 visible={visible && !isUnreachable}543 visible={visible && !isUnreachable}
354 className="absolute inset-0 h-full w-full"544 className="absolute inset-0 h-full w-full"
355 />545 />
356 ) : (546 ) : null}
547 {showEmptyState ? (
357 <PreviewEmptyState548 <PreviewEmptyState
358 environmentId={threadRef.environmentId}549 environmentId={threadRef.environmentId}
359 configuredUrls={configuredUrls}550 configuredUrls={configuredUrls}
360 recentlySeenUrls={previewState.recentlySeenUrls}551 recentlySeenUrls={previewState.recentlySeenUrls}
361 onOpenUrl={(next) => void handleSubmitUrl(next)}552 onOpenUrl={(next) => void handleSubmitUrl(next)}
362 />553 />
363 )}554 ) : null}
364 {snapshot && desktopOverlay ? (555 {snapshot && desktopOverlay ? (
365 <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} />556 <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} />
366 ) : null}557 ) : null}
558 {tabId && desktopOverlay && !showEmptyState && !isUnreachable ? (
559 <AgentBrowserCursor
560 tabId={tabId}
561 zoomFactor={desktopOverlay.zoomFactor}
562 controller={controller}
563 />
564 ) : null}
367 {controller !== "none" ? (565 {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">566 <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"}567 {controller === "agent" ? "Agent controlling browser" : "Human control"}
apps/web/src/components/preview/agentBrowserCursorLogic.test.ts created+19
...@@ -0,0 +1,19 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { agentBrowserCursorOpacity } from "./agentBrowserCursorLogic";
4
5describe("agentBrowserCursorOpacity", () => {
6 it("keeps active movement fully visible", () => {
7 expect(agentBrowserCursorOpacity(true, "agent")).toBe(1);
8 expect(agentBrowserCursorOpacity(true, "human")).toBe(1);
9 });
10
11 it("settles to a visible idle state", () => {
12 expect(agentBrowserCursorOpacity(false, "none")).toBe(0.35);
13 expect(agentBrowserCursorOpacity(false, "agent")).toBe(0.35);
14 });
15
16 it("dims further while the human controls the page", () => {
17 expect(agentBrowserCursorOpacity(false, "human")).toBe(0.18);
18 });
19});
apps/web/src/components/preview/agentBrowserCursorLogic.ts created+6
...@@ -0,0 +1,6 @@
1export type BrowserController = "human" | "agent" | "none";
2
3export function agentBrowserCursorOpacity(active: boolean, controller: BrowserController): number {
4 if (active) return 1;
5 return controller === "human" ? 0.18 : 0.35;
6}
apps/web/src/components/preview/fileExplorerLabel.test.ts created+13
...@@ -0,0 +1,13 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { revealInFileExplorerLabel } from "./fileExplorerLabel";
4
5describe("revealInFileExplorerLabel", () => {
6 it.each([
7 ["MacIntel", "Reveal in Finder"],
8 ["Win32", "Reveal in File Explorer"],
9 ["Linux x86_64", "Reveal in Files"],
10 ])("maps %s to %s", (platform, expected) => {
11 expect(revealInFileExplorerLabel(platform)).toBe(expected);
12 });
13});
apps/web/src/components/preview/fileExplorerLabel.ts created+6
...@@ -0,0 +1,6 @@
1export function revealInFileExplorerLabel(platform: string): string {
2 const normalized = platform.toLowerCase();
3 if (normalized.includes("mac")) return "Reveal in Finder";
4 if (normalized.includes("win")) return "Reveal in File Explorer";
5 return "Reveal in Files";
6}
apps/web/src/components/preview/openDiscoveredPort.ts created+24
...@@ -0,0 +1,24 @@
1import type { DiscoveredLocalServer, ScopedThreadRef } from "@t3tools/contracts";
2
3import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
4import { ensureEnvironmentApi } from "~/environmentApi";
5import { usePreviewStateStore } from "~/previewStateStore";
6import { useRightPanelStore } from "~/rightPanelStore";
7import { openPreviewSession } from "./openPreviewSession";
8
9export async function openDiscoveredPort(input: {
10 readonly threadRef: ScopedThreadRef;
11 readonly port: DiscoveredLocalServer;
12}): Promise<void> {
13 const api = ensureEnvironmentApi(input.threadRef.environmentId);
14 const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url);
15 const previewState = usePreviewStateStore.getState();
16 const snapshot = await openPreviewSession({
17 previewApi: api.preview,
18 threadRef: input.threadRef,
19 url: resolvedUrl,
20 applyServerSnapshot: previewState.applyServerSnapshot,
21 rememberUrl: previewState.rememberUrl,
22 });
23 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);
24}
apps/web/src/components/preview/openPreviewSession.ts+5-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { EnvironmentApi, ScopedThreadRef } from "@t3tools/contracts";1import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
22
3import type { PreviewStateStoreState } from "~/previewStateStore";3import type { PreviewStateStoreState } from "~/previewStateStore";
44
...@@ -10,7 +10,9 @@ interface OpenPreviewSessionInput {...@@ -10,7 +10,9 @@ interface OpenPreviewSessionInput {
10 rememberUrl: PreviewStateStoreState["rememberUrl"];10 rememberUrl: PreviewStateStoreState["rememberUrl"];
11}11}
1212
13export async function openPreviewSession(input: OpenPreviewSessionInput): Promise<void> {13export async function openPreviewSession(
14 input: OpenPreviewSessionInput,
15): Promise<PreviewSessionSnapshot> {
14 const snapshot = await input.previewApi.open({16 const snapshot = await input.previewApi.open({
15 threadId: input.threadRef.threadId,17 threadId: input.threadRef.threadId,
16 url: input.url,18 url: input.url,
...@@ -20,4 +22,5 @@ export async function openPreviewSession(input: OpenPreviewSessionInput): Promis...@@ -20,4 +22,5 @@ export async function openPreviewSession(input: OpenPreviewSessionInput): Promis
20 input.threadRef,22 input.threadRef,
21 snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url,23 snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url,
22 );24 );
25 return snapshot;
23}26}
apps/web/src/components/preview/previewEmptyStateLogic.test.ts created+42
...@@ -0,0 +1,42 @@
1import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { getConfiguredPreviewUrls, shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
5
6const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({
7 threadId: "thread-1",
8 tabId: "tab-1",
9 navStatus,
10 canGoBack: false,
11 canGoForward: false,
12 updatedAt: "2026-06-12T20:00:00.000Z",
13});
14
15describe("shouldShowPreviewEmptyState", () => {
16 it("shows quick-open options for a new idle browser tab", () => {
17 expect(shouldShowPreviewEmptyState(snapshot({ _tag: "Idle" }))).toBe(true);
18 });
19
20 it("shows browser content once navigation starts", () => {
21 expect(
22 shouldShowPreviewEmptyState(
23 snapshot({ _tag: "Loading", url: "http://localhost:5173", title: "" }),
24 ),
25 ).toBe(false);
26 });
27});
28
29describe("getConfiguredPreviewUrls", () => {
30 it("collects configured preview URLs from project scripts", () => {
31 const scripts = [
32 { previewUrl: "http://localhost:5173" },
33 {},
34 { previewUrl: "http://localhost:3000" },
35 ] as ProjectScript[];
36
37 expect(getConfiguredPreviewUrls(scripts)).toEqual([
38 "http://localhost:5173",
39 "http://localhost:3000",
40 ]);
41 });
42});
apps/web/src/components/preview/previewEmptyStateLogic.ts created+11
...@@ -0,0 +1,11 @@
1import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts";
2
3export function shouldShowPreviewEmptyState(snapshot: PreviewSessionSnapshot | null): boolean {
4 return snapshot === null || snapshot.navStatus._tag === "Idle";
5}
6
7export function getConfiguredPreviewUrls(
8 scripts: ReadonlyArray<ProjectScript> | undefined,
9): ReadonlyArray<string> {
10 return scripts?.flatMap((script) => (script.previewUrl ? [script.previewUrl] : [])) ?? [];
11}
apps/web/src/components/preview/useDiscoveredLocalServers.test.ts+1
...@@ -9,6 +9,7 @@ const scannerServer = (overrides: Partial<DiscoveredLocalServer>): DiscoveredLoc...@@ -9,6 +9,7 @@ const scannerServer = (overrides: Partial<DiscoveredLocalServer>): DiscoveredLoc
9 url: "http://localhost:5173",9 url: "http://localhost:5173",
10 processName: "vite",10 processName: "vite",
11 pid: 1234,11 pid: 1234,
12 terminal: null,
12 ...overrides,13 ...overrides,
13});14});
1415
apps/web/src/components/preview/useDiscoveredLocalServers.ts+8-15
...@@ -1,10 +1,10 @@...@@ -1,10 +1,10 @@
1import type { DiscoveredLocalServer } from "@t3tools/contracts";1import type { DiscoveredLocalServer } from "@t3tools/contracts";
2import { isLoopbackHost } from "@t3tools/shared/preview";2import { isLoopbackHost } from "@t3tools/shared/preview";
3import { useEffect, useMemo, useState } from "react";3import { useMemo } from "react";
44
5import { ensureEnvironmentApi } from "~/environmentApi";
6import type { EnvironmentId } from "@t3tools/contracts";5import type { EnvironmentId } from "@t3tools/contracts";
7import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";6import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
7import { useDiscoveredPorts } from "~/portDiscoveryState";
88
9export interface PreviewableServer extends DiscoveredLocalServer {9export interface PreviewableServer extends DiscoveredLocalServer {
10 source: "scanner" | "configured" | "recent";10 source: "scanner" | "configured" | "recent";
...@@ -22,23 +22,13 @@ interface UseDiscoveredLocalServersInput {...@@ -22,23 +22,13 @@ interface UseDiscoveredLocalServersInput {
22}22}
2323
24/**24/**
25 * Subscribe to live localhost port scans, merge in configured /25 * Merge the environment-level port snapshot with configured / recently-seen
26 * recently-seen URLs, and return a stable sorted list. Retains the scanner26 * URLs and return a stable sorted list.
27 * while mounted.
28 */27 */
29export function useDiscoveredLocalServers(28export function useDiscoveredLocalServers(
30 input: UseDiscoveredLocalServersInput,29 input: UseDiscoveredLocalServersInput,
31): ReadonlyArray<PreviewableServer> {30): ReadonlyArray<PreviewableServer> {
32 const [scannerSnapshot, setScannerSnapshot] = useState<ReadonlyArray<DiscoveredLocalServer>>([]);31 const scannerSnapshot = useDiscoveredPorts(input.environmentId);
33
34 useEffect(() => {
35 const api = ensureEnvironmentApi(input.environmentId);
36 setScannerSnapshot([]);
37 const unsubscribe = api.preview.subscribePorts((next) => {
38 setScannerSnapshot(next.servers);
39 });
40 return unsubscribe;
41 }, [input.environmentId]);
4232
43 return useMemo(33 return useMemo(
44 () =>34 () =>
...@@ -72,6 +62,7 @@ export function mergeServers(input: {...@@ -72,6 +62,7 @@ export function mergeServers(input: {
72 url: parsed.url,62 url: parsed.url,
73 processName: null,63 processName: null,
74 pid: null,64 pid: null,
65 terminal: null,
75 source: "configured",66 source: "configured",
76 listening: false,67 listening: false,
77 });68 });
...@@ -87,6 +78,7 @@ export function mergeServers(input: {...@@ -87,6 +78,7 @@ export function mergeServers(input: {
87 ...existing,78 ...existing,
88 processName: server.processName ?? existing.processName,79 processName: server.processName ?? existing.processName,
89 pid: server.pid ?? existing.pid,80 pid: server.pid ?? existing.pid,
81 terminal: server.terminal ?? existing.terminal,
90 listening: true,82 listening: true,
91 });83 });
92 continue;84 continue;
...@@ -105,6 +97,7 @@ export function mergeServers(input: {...@@ -105,6 +97,7 @@ export function mergeServers(input: {
105 url: parsed.url,97 url: parsed.url,
106 processName: null,98 processName: null,
107 pid: null,99 pid: null,
100 terminal: null,
108 source: "recent",101 source: "recent",
109 listening: false,102 listening: false,
110 });103 });
apps/web/src/components/preview/usePreviewBridge.ts+19-1
...@@ -8,6 +8,7 @@ import type {...@@ -8,6 +8,7 @@ import type {
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { useEffect, useRef } from "react";9import { useEffect, useRef } from "react";
1010
11import { useBrowserPointerStore } from "~/browser/browserPointerStore";
11import { ensureEnvironmentApi } from "~/environmentApi";12import { ensureEnvironmentApi } from "~/environmentApi";
12import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore";13import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore";
1314
...@@ -20,19 +21,26 @@ import { previewBridge } from "./previewBridge";...@@ -20,19 +21,26 @@ import { previewBridge } from "./previewBridge";
20export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void {21export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void {
21 const { threadRef, tabId } = input;22 const { threadRef, tabId } = input;
22 const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState);23 const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState);
24 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);
23 const bridge = previewBridge;25 const bridge = previewBridge;
2426
25 // One bridge subscription does both jobs (mirror state + forward to27 // One bridge subscription does both jobs (mirror state + forward to
26 // server) so the desktop bridge keeps a single listener entry per tab.28 // server) so the desktop bridge keeps a single listener entry per tab.
27 const lastReportedUrl = useRef<string | null>(null);29 const lastReportedUrl = useRef<string | null>(null);
28 const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null);30 const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null);
31 const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null);
29 useEffect(() => {32 useEffect(() => {
30 if (!bridge || typeof window === "undefined") return;33 if (!bridge || typeof window === "undefined") return;
31 const api = ensureEnvironmentApi(threadRef.environmentId);34 const api = ensureEnvironmentApi(threadRef.environmentId);
32 lastReportedUrl.current = null;35 lastReportedUrl.current = null;
33 lastReportedKind.current = null;36 lastReportedKind.current = null;
37 lastDesktopNavStatus.current = null;
34 const unsubscribe = bridge.onStateChange((changedTabId, state) => {38 const unsubscribe = bridge.onStateChange((changedTabId, state) => {
35 if (changedTabId !== tabId) return;39 if (changedTabId !== tabId) return;
40 if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) {
41 clearBrowserPointer(tabId);
42 }
43 lastDesktopNavStatus.current = state.navStatus;
36 applyDesktopState(threadRef, tabId, projectDesktopState(state));44 applyDesktopState(threadRef, tabId, projectDesktopState(state));
37 const reported = buildReportInput({45 const reported = buildReportInput({
38 threadId: threadRef.threadId,46 threadId: threadRef.threadId,
...@@ -47,7 +55,17 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str...@@ -47,7 +55,17 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str
47 void api.preview.reportStatus(reported.input).catch(() => undefined);55 void api.preview.reportStatus(reported.input).catch(() => undefined);
48 });56 });
49 return unsubscribe;57 return unsubscribe;
50 }, [applyDesktopState, bridge, tabId, threadRef]);58 }, [applyDesktopState, bridge, clearBrowserPointer, tabId, threadRef]);
59}
60
61function shouldClearBrowserPointer(
62 previous: DesktopPreviewTabState["navStatus"] | null,
63 current: DesktopPreviewTabState["navStatus"],
64): boolean {
65 if (!previous) return false;
66 if (current.kind === "Loading" && previous.kind !== "Loading") return true;
67 if (current.kind === "Idle" || previous.kind === "Idle") return false;
68 return current.url !== previous.url;
51}69}
5270
53function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay {71function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay {
apps/web/src/components/ui/toast.tsx+21-1
...@@ -45,6 +45,10 @@ export type ThreadToastData = {...@@ -45,6 +45,10 @@ export type ThreadToastData = {
45 onClose?: (() => void) | undefined;45 onClose?: (() => void) | undefined;
46 dismissAfterVisibleMs?: number;46 dismissAfterVisibleMs?: number;
47 hideCopyButton?: boolean;47 hideCopyButton?: boolean;
48 additionalActions?: ReadonlyArray<{
49 id: string;
50 props: ComponentPropsWithoutRef<"button">;
51 }>;
48 secondaryActionProps?: ComponentPropsWithoutRef<"button">;52 secondaryActionProps?: ComponentPropsWithoutRef<"button">;
49 secondaryActionVariant?:53 secondaryActionVariant?:
50 | "default"54 | "default"
...@@ -292,9 +296,13 @@ function deriveToastBodyDescriptor(toast: {...@@ -292,9 +296,13 @@ function deriveToastBodyDescriptor(toast: {
292 toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton296 toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton
293 ? toast.description297 ? toast.description
294 : null;298 : null;
299 const hasAdditionalActions = (toast.data?.additionalActions?.length ?? 0) > 0;
295 const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined;300 const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined;
296 const hasTrailingControls =301 const hasTrailingControls =
297 copyErrorText !== null || toast.actionProps !== undefined || hasSecondaryAction;302 copyErrorText !== null ||
303 toast.actionProps !== undefined ||
304 hasAdditionalActions ||
305 hasSecondaryAction;
298 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";306 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";
299 return {307 return {
300 Icon,308 Icon,
...@@ -326,6 +334,7 @@ function ToastBodyContent({...@@ -326,6 +334,7 @@ function ToastBodyContent({
326 toastDescription,334 toastDescription,
327 toastType,335 toastType,
328}: ToastBodyContentProps) {336}: ToastBodyContentProps) {
337 const additionalActions = toastData?.additionalActions ?? [];
329 const secondaryActionProps = toastData?.secondaryActionProps;338 const secondaryActionProps = toastData?.secondaryActionProps;
330 const leadingIcon = toastData?.leadingIcon;339 const leadingIcon = toastData?.leadingIcon;
331 const { className: secondaryActionClassName, ...secondaryActionRest } =340 const { className: secondaryActionClassName, ...secondaryActionRest } =
...@@ -371,6 +380,17 @@ function ToastBodyContent({...@@ -371,6 +380,17 @@ function ToastBodyContent({
371 )}380 )}
372 >381 >
373 {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null}382 {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null}
383 {additionalActions.map(({ id, props: { className, ...props } }) => (
384 <button
385 {...props}
386 className={cn(
387 buttonVariants({ size: "xs", variant: secondaryActionVariant }),
388 className,
389 )}
390 key={id}
391 type="button"
392 />
393 ))}
374 {secondaryActionProps ? (394 {secondaryActionProps ? (
375 <button395 <button
376 {...secondaryActionRest}396 {...secondaryActionRest}
apps/web/src/environments/runtime/service.ts+18
...@@ -90,6 +90,7 @@ import {...@@ -90,6 +90,7 @@ import {
90const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope));90const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope));
91import { getClientSettings } from "~/hooks/useSettings";91import { getClientSettings } from "~/hooks/useSettings";
92import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState";92import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState";
93import { subscribePortDiscovery, usePortDiscoveryStore } from "../../portDiscoveryState";
93import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState";94import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState";
94import { resolveRemotePairingTarget } from "@t3tools/shared/remote";95import { resolveRemotePairingTarget } from "@t3tools/shared/remote";
9596
...@@ -140,6 +141,7 @@ const lastAppliedProjectionVersionByEnvironment = new Map<...@@ -140,6 +141,7 @@ const lastAppliedProjectionVersionByEnvironment = new Map<
140 }141 }
141>();142>();
142const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>();143const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>();
144const portDiscoverySubscriptions = new Map<EnvironmentId, () => void>();
143145
144let activeService: EnvironmentServiceState | null = null;146let activeService: EnvironmentServiceState | null = null;
145let needsProviderInvalidation = false;147let needsProviderInvalidation = false;
...@@ -1397,6 +1399,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne...@@ -1397,6 +1399,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne
1397 client: connection.client,1399 client: connection.client,
1398 }),1400 }),
1399 );1401 );
1402 portDiscoverySubscriptions.get(connection.environmentId)?.();
1403 portDiscoverySubscriptions.set(
1404 connection.environmentId,
1405 subscribePortDiscovery({
1406 environmentId: connection.environmentId,
1407 previewApi: connection.client.preview,
1408 }),
1409 );
1400 attachThreadDetailSubscriptionsForEnvironment(connection.environmentId);1410 attachThreadDetailSubscriptionsForEnvironment(connection.environmentId);
1401 emitEnvironmentConnectionRegistryChange();1411 emitEnvironmentConnectionRegistryChange();
1402 return connection;1412 return connection;
...@@ -1412,6 +1422,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>...@@ -1412,6 +1422,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>
1412 environmentConnections.delete(environmentId);1422 environmentConnections.delete(environmentId);
1413 terminalMetadataSubscriptions.get(environmentId)?.();1423 terminalMetadataSubscriptions.get(environmentId)?.();
1414 terminalMetadataSubscriptions.delete(environmentId);1424 terminalMetadataSubscriptions.delete(environmentId);
1425 portDiscoverySubscriptions.get(environmentId)?.();
1426 portDiscoverySubscriptions.delete(environmentId);
1427 usePortDiscoveryStore.getState().clearEnvironment(environmentId);
1415 terminalSessionManager.invalidateEnvironment(environmentId);1428 terminalSessionManager.invalidateEnvironment(environmentId);
1416 emitEnvironmentConnectionRegistryChange();1429 emitEnvironmentConnectionRegistryChange();
1417 detachThreadDetailSubscriptionsForEnvironment(environmentId);1430 detachThreadDetailSubscriptionsForEnvironment(environmentId);
...@@ -2048,6 +2061,11 @@ export async function resetEnvironmentServiceForTests(): Promise<void> {...@@ -2048,6 +2061,11 @@ export async function resetEnvironmentServiceForTests(): Promise<void> {
2048 unsubscribe();2061 unsubscribe();
2049 }2062 }
2050 terminalMetadataSubscriptions.clear();2063 terminalMetadataSubscriptions.clear();
2064 for (const unsubscribe of portDiscoverySubscriptions.values()) {
2065 unsubscribe();
2066 }
2067 portDiscoverySubscriptions.clear();
2068 usePortDiscoveryStore.getState().reset();
2051 terminalSessionManager.reset();2069 terminalSessionManager.reset();
2052 await Promise.all(2070 await Promise.all(
2053 [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)),2071 [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)),
apps/web/src/keybindings.test.ts+33-2
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 isTerminalCloseShortcut,18 isTerminalCloseShortcut,
19 isTerminalNewShortcut,19 isTerminalNewShortcut,
20 isTerminalSplitShortcut,20 isTerminalSplitShortcut,
21 isTerminalSplitVerticalShortcut,
21 isTerminalToggleShortcut,22 isTerminalToggleShortcut,
22 resolveShortcutCommand,23 resolveShortcutCommand,
23 shouldShowModelPickerJumpHints,24 shouldShowModelPickerJumpHints,
...@@ -85,6 +86,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {...@@ -85,6 +86,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {
8586
86const DEFAULT_BINDINGS = compile([87const DEFAULT_BINDINGS = compile([
87 { shortcut: modShortcut("j"), command: "terminal.toggle" },88 { shortcut: modShortcut("j"), command: "terminal.toggle" },
89 { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" },
88 {90 {
89 shortcut: modShortcut("d"),91 shortcut: modShortcut("d"),
90 command: "terminal.split",92 command: "terminal.split",
...@@ -92,6 +94,11 @@ const DEFAULT_BINDINGS = compile([...@@ -92,6 +94,11 @@ const DEFAULT_BINDINGS = compile([
92 },94 },
93 {95 {
94 shortcut: modShortcut("d", { shiftKey: true }),96 shortcut: modShortcut("d", { shiftKey: true }),
97 command: "terminal.splitVertical",
98 whenAst: whenIdentifier("terminalFocus"),
99 },
100 {
101 shortcut: modShortcut("n"),
95 command: "terminal.new",102 command: "terminal.new",
96 whenAst: whenIdentifier("terminalFocus"),103 whenAst: whenIdentifier("terminalFocus"),
97 },104 },
...@@ -174,7 +181,17 @@ describe("split/new/close terminal shortcuts", () => {...@@ -174,7 +181,17 @@ describe("split/new/close terminal shortcuts", () => {
174 }),181 }),
175 );182 );
176 assert.isFalse(183 assert.isFalse(
177 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {184 isTerminalSplitVerticalShortcut(
185 event({ key: "d", metaKey: true, shiftKey: true }),
186 DEFAULT_BINDINGS,
187 {
188 platform: "MacIntel",
189 context: { terminalFocus: false },
190 },
191 ),
192 );
193 assert.isFalse(
194 isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, {
178 platform: "Linux",195 platform: "Linux",
179 context: { terminalFocus: false },196 context: { terminalFocus: false },
180 }),197 }),
...@@ -195,7 +212,17 @@ describe("split/new/close terminal shortcuts", () => {...@@ -195,7 +212,17 @@ describe("split/new/close terminal shortcuts", () => {
195 }),212 }),
196 );213 );
197 assert.isTrue(214 assert.isTrue(
198 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {215 isTerminalSplitVerticalShortcut(
216 event({ key: "d", metaKey: true, shiftKey: true }),
217 DEFAULT_BINDINGS,
218 {
219 platform: "MacIntel",
220 context: { terminalFocus: true },
221 },
222 ),
223 );
224 assert.isTrue(
225 isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, {
199 platform: "Linux",226 platform: "Linux",
200 context: { terminalFocus: true },227 context: { terminalFocus: true },
201 }),228 }),
...@@ -287,6 +314,10 @@ describe("shortcutLabelForCommand", () => {...@@ -287,6 +314,10 @@ describe("shortcutLabelForCommand", () => {
287 it("returns effective labels for non-terminal commands", () => {314 it("returns effective labels for non-terminal commands", () => {
288 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");315 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
289 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");316 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
317 assert.strictEqual(
318 shortcutLabelForCommand(DEFAULT_BINDINGS, "rightPanel.toggle", "MacIntel"),
319 "⌥⌘B",
320 );
290 assert.strictEqual(321 assert.strictEqual(
291 shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"),322 shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"),
292 "⌘K",323 "⌘K",
apps/web/src/keybindings.ts+8
...@@ -359,6 +359,14 @@ export function isTerminalSplitShortcut(...@@ -359,6 +359,14 @@ export function isTerminalSplitShortcut(
359 return matchesCommandShortcut(event, keybindings, "terminal.split", options);359 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
360}360}
361361
362export function isTerminalSplitVerticalShortcut(
363 event: ShortcutEventLike,
364 keybindings: ResolvedKeybindingsConfig,
365 options?: ShortcutMatchOptions,
366): boolean {
367 return matchesCommandShortcut(event, keybindings, "terminal.splitVertical", options);
368}
369
362export function isTerminalNewShortcut(370export function isTerminalNewShortcut(
363 event: ShortcutEventLike,371 event: ShortcutEventLike,
364 keybindings: ResolvedKeybindingsConfig,372 keybindings: ResolvedKeybindingsConfig,
apps/web/src/lib/terminalFocus.test.ts+15-7
...@@ -1,10 +1,12 @@...@@ -1,10 +1,12 @@
1import { afterEach, describe, expect, it } from "vite-plus/test";1import { afterEach, describe, expect, it } from "vite-plus/test";
22
3import { isTerminalFocused } from "./terminalFocus";3import { getTerminalFocusOwner, isTerminalFocused } from "./terminalFocus";
44
5class MockHTMLElement {5class MockHTMLElement {
6 isConnected = false;6 isConnected = false;
7 className = "";7 className = "";
8 terminalOwner: string | null = null;
9 readonly dataset: { terminalOwner?: string } = {};
810
9 readonly classList = {11 readonly classList = {
10 contains: (value: string) => this.className.split(/\s+/).includes(value),12 contains: (value: string) => this.className.split(/\s+/).includes(value),
...@@ -14,7 +16,7 @@ class MockHTMLElement {...@@ -14,7 +16,7 @@ class MockHTMLElement {
14 if (!this.isConnected) {16 if (!this.isConnected) {
15 return null;17 return null;
16 }18 }
17 if (selector === ".thread-terminal-drawer .xterm" || selector === ".thread-terminal-drawer") {19 if (selector === "[data-terminal-owner]" && this.terminalOwner !== null) {
18 return this;20 return this;
19 }21 }
20 return null;22 return null;
...@@ -44,30 +46,36 @@ describe("isTerminalFocused", () => {...@@ -44,30 +46,36 @@ describe("isTerminalFocused", () => {
44 detached.className = "xterm-helper-textarea";46 detached.className = "xterm-helper-textarea";
4547
46 globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;48 globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
47 globalThis.document = { activeElement: detached } as Document;49 globalThis.document = { activeElement: detached } as unknown as Document;
4850
49 expect(isTerminalFocused()).toBe(false);51 expect(isTerminalFocused()).toBe(false);
50 });52 });
5153
52 it("returns true for connected xterm helper textareas", () => {54 it("returns the drawer owner for connected xterm helper textareas", () => {
53 const attached = new MockHTMLElement();55 const attached = new MockHTMLElement();
54 attached.className = "xterm-helper-textarea";56 attached.className = "xterm-helper-textarea";
55 attached.isConnected = true;57 attached.isConnected = true;
58 attached.terminalOwner = "drawer";
59 attached.dataset.terminalOwner = "drawer";
5660
57 globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;61 globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
58 globalThis.document = { activeElement: attached } as Document;62 globalThis.document = { activeElement: attached } as unknown as Document;
5963
64 expect(getTerminalFocusOwner()).toBe("drawer");
60 expect(isTerminalFocused()).toBe(true);65 expect(isTerminalFocused()).toBe(true);
61 });66 });
6267
63 it("returns true for focus inside the terminal drawer (e.g. sidebar)", () => {68 it("returns the right panel owner for focus inside its terminal UI", () => {
64 const sidebarButton = new MockHTMLElement();69 const sidebarButton = new MockHTMLElement();
65 sidebarButton.className = "terminal-sidebar-button";70 sidebarButton.className = "terminal-sidebar-button";
66 sidebarButton.isConnected = true;71 sidebarButton.isConnected = true;
72 sidebarButton.terminalOwner = "right-panel";
73 sidebarButton.dataset.terminalOwner = "right-panel";
6774
68 globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;75 globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement;
69 globalThis.document = { activeElement: sidebarButton } as Document;76 globalThis.document = { activeElement: sidebarButton } as unknown as Document;
7077
78 expect(getTerminalFocusOwner()).toBe("right-panel");
71 expect(isTerminalFocused()).toBe(true);79 expect(isTerminalFocused()).toBe(true);
72 });80 });
73});81});
apps/web/src/lib/terminalFocus.ts+12-7
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1export function isTerminalFocused(): boolean {1export type TerminalFocusOwner = "drawer" | "right-panel";
2
3export function getTerminalFocusOwner(): TerminalFocusOwner | null {
2 const activeElement = document.activeElement;4 const activeElement = document.activeElement;
3 if (!(activeElement instanceof HTMLElement)) return false;5 if (!(activeElement instanceof HTMLElement)) return null;
4 if (!activeElement.isConnected) return false;6 if (!activeElement.isConnected) return null;
5 if (activeElement.classList.contains("xterm-helper-textarea")) return true;7 const owner = activeElement.closest<HTMLElement>("[data-terminal-owner]")?.dataset.terminalOwner;
6 if (activeElement.closest(".thread-terminal-drawer .xterm") !== null) return true;8 if (owner === "drawer" || owner === "right-panel") return owner;
7 // Sidebar / toolbar / resize affordances: still "terminal UI" for split vs diff.toggle (⌘D).9 return null;
8 return activeElement.closest(".thread-terminal-drawer") !== null;10}
11
12export function isTerminalFocused(): boolean {
13 return getTerminalFocusOwner() !== null;
9}14}
apps/web/src/portDiscoveryState.ts created+86
...@@ -0,0 +1,86 @@
1import type {
2 DiscoveredLocalServer,
3 EnvironmentApi,
4 EnvironmentId,
5 ThreadId,
6} from "@t3tools/contracts";
7import { useMemo } from "react";
8import { create } from "zustand";
9
10const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]);
11
12interface PortDiscoveryState {
13 readonly byEnvironment: Record<string, ReadonlyArray<DiscoveredLocalServer>>;
14 setPorts: (environmentId: EnvironmentId, ports: ReadonlyArray<DiscoveredLocalServer>) => void;
15 clearEnvironment: (environmentId: EnvironmentId) => void;
16 reset: () => void;
17}
18
19export const usePortDiscoveryStore = create<PortDiscoveryState>((set) => ({
20 byEnvironment: {},
21 setPorts: (environmentId, ports) =>
22 set((state) => ({
23 byEnvironment: {
24 ...state.byEnvironment,
25 [environmentId]: ports,
26 },
27 })),
28 clearEnvironment: (environmentId) =>
29 set((state) => {
30 if (!(environmentId in state.byEnvironment)) return state;
31 const { [environmentId]: _removed, ...byEnvironment } = state.byEnvironment;
32 return { byEnvironment };
33 }),
34 reset: () => set({ byEnvironment: {} }),
35}));
36
37export function subscribePortDiscovery(input: {
38 readonly environmentId: EnvironmentId;
39 readonly previewApi: Pick<EnvironmentApi["preview"], "subscribePorts">;
40}): () => void {
41 usePortDiscoveryStore.getState().clearEnvironment(input.environmentId);
42 return input.previewApi.subscribePorts((snapshot) => {
43 usePortDiscoveryStore.getState().setPorts(input.environmentId, snapshot.servers);
44 });
45}
46
47export function useDiscoveredPorts(
48 environmentId: EnvironmentId | null,
49): ReadonlyArray<DiscoveredLocalServer> {
50 return usePortDiscoveryStore(
51 (state) => (environmentId ? state.byEnvironment[environmentId] : undefined) ?? EMPTY_PORTS,
52 );
53}
54
55export function useThreadDiscoveredPorts(input: {
56 readonly environmentId: EnvironmentId | null;
57 readonly threadId: ThreadId | null;
58}): ReadonlyArray<DiscoveredLocalServer> {
59 const ports = useDiscoveredPorts(input.environmentId);
60 return useMemo(
61 () =>
62 input.threadId
63 ? ports.filter((port) => port.terminal?.threadId === input.threadId)
64 : EMPTY_PORTS,
65 [input.threadId, ports],
66 );
67}
68
69export function useTerminalDiscoveredPorts(input: {
70 readonly environmentId: EnvironmentId | null;
71 readonly threadId: ThreadId | null;
72 readonly terminalId: string | null;
73}): ReadonlyArray<DiscoveredLocalServer> {
74 const ports = useDiscoveredPorts(input.environmentId);
75 return useMemo(
76 () =>
77 input.threadId && input.terminalId
78 ? ports.filter(
79 (port) =>
80 port.terminal?.threadId === input.threadId &&
81 port.terminal.terminalId === input.terminalId,
82 )
83 : EMPTY_PORTS,
84 [input.terminalId, input.threadId, ports],
85 );
86}
apps/web/src/previewStateStore.test.ts+36
...@@ -154,6 +154,42 @@ describe("previewStateStore (single-tab)", () => {...@@ -154,6 +154,42 @@ describe("previewStateStore (single-tab)", () => {
154 expect(state.recentlySeenUrls).toContain("http://localhost:5173/");154 expect(state.recentlySeenUrls).toContain("http://localhost:5173/");
155 });155 });
156156
157 it("optimistically removes a session before the server close event arrives", () => {
158 const first = makeSnapshot({ tabId: "tab_a" });
159 const second = makeSnapshot({
160 tabId: "tab_b",
161 updatedAt: "2026-01-01T00:00:01.000Z",
162 });
163 const store = usePreviewStateStore.getState();
164 store.applyServerSnapshot(ref, first);
165 store.applyServerSnapshot(ref, second);
166
167 store.removeSession(ref, second.tabId);
168
169 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
170 expect(Object.keys(state.sessions)).toEqual([first.tabId]);
171 expect(state.activeTabId).toBe(first.tabId);
172 expect(state.snapshot?.tabId).toBe(first.tabId);
173 });
174
175 it("treats a late server close event after optimistic removal as a no-op", () => {
176 const snapshot = makeSnapshot();
177 const store = usePreviewStateStore.getState();
178 store.applyServerSnapshot(ref, snapshot);
179 store.removeSession(ref, snapshot.tabId);
180
181 store.applyServerEvent(ref, {
182 type: "closed",
183 threadId: "thread-1",
184 tabId: snapshot.tabId,
185 createdAt: "2026-01-01T00:00:01.000Z",
186 });
187
188 const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref);
189 expect(state.sessions).toEqual({});
190 expect(state.snapshot).toBeNull();
191 });
192
157 it("closed event for a different tab is a no-op", () => {193 it("closed event for a different tab is a no-op", () => {
158 const snapshot = makeSnapshot({ tabId: "tab_a" });194 const snapshot = makeSnapshot({ tabId: "tab_a" });
159 const store = usePreviewStateStore.getState();195 const store = usePreviewStateStore.getState();
apps/web/src/previewStateStore.ts+32-22
...@@ -58,6 +58,7 @@ export interface PreviewStateStoreState {...@@ -58,6 +58,7 @@ export interface PreviewStateStoreState {
58 tabId: string,58 tabId: string,
59 overlay: DesktopPreviewOverlay | null,59 overlay: DesktopPreviewOverlay | null,
60 ) => void;60 ) => void;
61 removeSession: (ref: ScopedThreadRef, tabId: string) => void;
61 setActiveTab: (ref: ScopedThreadRef, tabId: string) => void;62 setActiveTab: (ref: ScopedThreadRef, tabId: string) => void;
62 rememberUrl: (ref: ScopedThreadRef, url: string) => void;63 rememberUrl: (ref: ScopedThreadRef, url: string) => void;
63 removeThread: (ref: ScopedThreadRef) => void;64 removeThread: (ref: ScopedThreadRef) => void;
...@@ -93,6 +94,27 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => {...@@ -93,6 +94,27 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => {
93 return next.slice(0, PREVIEW_RECENT_URL_LIMIT);94 return next.slice(0, PREVIEW_RECENT_URL_LIMIT);
94};95};
9596
97const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => {
98 if (!current.sessions[tabId]) return current;
99 const { [tabId]: _closed, ...sessions } = current.sessions;
100 const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;
101 const nextSnapshot =
102 Object.values(sessions)
103 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))
104 .at(-1) ?? null;
105 const activeTabId =
106 current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId;
107 const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot;
108 return {
109 ...current,
110 sessions,
111 desktopByTabId,
112 activeTabId: snapshot?.tabId ?? null,
113 snapshot,
114 desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null,
115 };
116};
117
96export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({118export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
97 byThreadKey: {},119 byThreadKey: {},
98 applyServerEvent: (ref, event) =>120 applyServerEvent: (ref, event) =>
...@@ -145,28 +167,9 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({...@@ -145,28 +167,9 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
145 });167 });
146 break;168 break;
147 case "closed":169 case "closed":
148 nextByThread = updateThread(state, threadKey, (current) => {170 nextByThread = updateThread(state, threadKey, (current) =>
149 if (!current.sessions[event.tabId]) return current;171 removeSession(current, event.tabId),
150 const { [event.tabId]: _closed, ...sessions } = current.sessions;172 );
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 };
169 });
170 break;173 break;
171 }174 }
172 return { byThreadKey: nextByThread };175 return { byThreadKey: nextByThread };
...@@ -216,6 +219,13 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({...@@ -216,6 +219,13 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
216 });219 });
217 return { byThreadKey: nextByThread };220 return { byThreadKey: nextByThread };
218 }),221 }),
222 removeSession: (ref, tabId) =>
223 set((state) => {
224 const threadKey = scopedThreadKey(ref);
225 return {
226 byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)),
227 };
228 }),
219 setActiveTab: (ref, tabId) =>229 setActiveTab: (ref, tabId) =>
220 set((state) => {230 set((state) => {
221 const threadKey = scopedThreadKey(ref);231 const threadKey = scopedThreadKey(ref);
apps/web/src/rightPanelStore.test.ts+160-5
...@@ -3,6 +3,7 @@ import { type EnvironmentId, ThreadId } from "@t3tools/contracts";...@@ -3,6 +3,7 @@ import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { beforeEach, describe, expect, it } from "vite-plus/test";3import { beforeEach, describe, expect, it } from "vite-plus/test";
44
5import {5import {
6 migratePersistedRightPanelState,
6 selectActiveRightPanel,7 selectActiveRightPanel,
7 selectActiveRightPanelSurface,8 selectActiveRightPanelSurface,
8 selectActiveRightPanelKindWithUrl,9 selectActiveRightPanelKindWithUrl,
...@@ -18,6 +19,60 @@ beforeEach(() => {...@@ -18,6 +19,60 @@ beforeEach(() => {
18});19});
1920
20describe("rightPanelStore", () => {21describe("rightPanelStore", () => {
22 it("drops the legacy singleton terminal surface during migration", () => {
23 expect(
24 migratePersistedRightPanelState({
25 byThreadKey: {
26 "env-1:thread-A": {
27 activeSurfaceId: "terminal",
28 surfaces: [
29 { id: "browser:tab-a", kind: "preview", resourceId: "tab-a" },
30 { id: "terminal", kind: "terminal" },
31 ],
32 },
33 },
34 }),
35 ).toEqual({
36 byThreadKey: {
37 "env-1:thread-A": {
38 isOpen: false,
39 activeSurfaceId: null,
40 surfaces: [{ id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }],
41 },
42 },
43 });
44 });
45
46 it("upgrades saved single-session terminal surfaces to split-capable surfaces", () => {
47 expect(
48 migratePersistedRightPanelState({
49 byThreadKey: {
50 "env-1:thread-A": {
51 isOpen: true,
52 activeSurfaceId: "terminal:term-1",
53 surfaces: [{ id: "terminal:term-1", kind: "terminal", resourceId: "term-1" }],
54 },
55 },
56 }),
57 ).toEqual({
58 byThreadKey: {
59 "env-1:thread-A": {
60 isOpen: true,
61 activeSurfaceId: "terminal:term-1",
62 surfaces: [
63 {
64 id: "terminal:term-1",
65 kind: "terminal",
66 resourceId: "term-1",
67 terminalIds: ["term-1"],
68 activeTerminalId: "term-1",
69 },
70 ],
71 },
72 },
73 });
74 });
75
21 it("open sets the active panel for a thread", () => {76 it("open sets the active panel for a thread", () => {
22 useRightPanelStore.getState().open(refA, "preview");77 useRightPanelStore.getState().open(refA, "preview");
23 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");78 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
...@@ -33,10 +88,27 @@ describe("rightPanelStore", () => {...@@ -33,10 +88,27 @@ describe("rightPanelStore", () => {
33 ).toHaveLength(2);88 ).toHaveLength(2);
34 });89 });
3590
36 it("close clears the active panel", () => {91 it("close hides the panel without clearing its selected surface", () => {
37 useRightPanelStore.getState().open(refA, "plan");92 useRightPanelStore.getState().open(refA, "plan");
38 useRightPanelStore.getState().close(refA);93 useRightPanelStore.getState().close(refA);
39 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();94 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
95 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
96 isOpen: false,
97 activeSurfaceId: "plan",
98 surfaces: [{ id: "plan", kind: "plan" }],
99 });
100 });
101
102 it("toggles empty panel visibility without creating a surface", () => {
103 useRightPanelStore.getState().toggleVisibility(refA);
104 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
105 isOpen: true,
106 activeSurfaceId: null,
107 surfaces: [],
108 });
109
110 useRightPanelStore.getState().toggleVisibility(refA);
111 expect(useRightPanelStore.getState().byThreadKey).toEqual({});
40 });112 });
41113
42 it("toggle opens then closes the same kind", () => {114 it("toggle opens then closes the same kind", () => {
...@@ -86,18 +158,101 @@ describe("rightPanelStore", () => {...@@ -86,18 +158,101 @@ describe("rightPanelStore", () => {
86 });158 });
87 });159 });
88160
161 it("tracks one surface per terminal session", () => {
162 useRightPanelStore.getState().openTerminal(refA, "term-1");
163 useRightPanelStore.getState().openTerminal(refA, "term-2");
164
165 const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
166 expect(state.surfaces).toEqual([
167 {
168 id: "terminal:term-1",
169 kind: "terminal",
170 resourceId: "term-1",
171 terminalIds: ["term-1"],
172 activeTerminalId: "term-1",
173 },
174 {
175 id: "terminal:term-2",
176 kind: "terminal",
177 resourceId: "term-2",
178 terminalIds: ["term-2"],
179 activeTerminalId: "term-2",
180 },
181 ]);
182 expect(state.activeSurfaceId).toBe("terminal:term-2");
183 });
184
185 it("tracks split panes and the active pane within a terminal surface", () => {
186 useRightPanelStore.getState().openTerminal(refA, "term-1");
187 useRightPanelStore.getState().splitTerminal(refA, "terminal:term-1", "term-2");
188
189 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
190 id: "terminal:term-1",
191 kind: "terminal",
192 resourceId: "term-1",
193 terminalIds: ["term-1", "term-2"],
194 activeTerminalId: "term-2",
195 });
196
197 useRightPanelStore.getState().activateTerminal(refA, "terminal:term-1", "term-1");
198 useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1");
199 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
200 id: "terminal:term-1",
201 kind: "terminal",
202 resourceId: "term-1",
203 terminalIds: ["term-2"],
204 activeTerminalId: "term-2",
205 });
206 });
207
208 it("tracks vertical layout for a terminal surface", () => {
209 useRightPanelStore.getState().openTerminal(refA, "term-1");
210 useRightPanelStore.getState().splitTerminal(refA, "terminal:term-1", "term-2", "vertical");
211
212 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
213 id: "terminal:term-1",
214 kind: "terminal",
215 resourceId: "term-1",
216 terminalIds: ["term-1", "term-2"],
217 activeTerminalId: "term-2",
218 splitDirection: "vertical",
219 });
220 });
221
222 it("closing the final terminal pane removes its surface but keeps the panel open", () => {
223 useRightPanelStore.getState().openTerminal(refA, "term-1");
224 useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1");
225
226 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
227 isOpen: true,
228 activeSurfaceId: null,
229 surfaces: [],
230 });
231 });
232
89 it("closing the active surface activates a neighboring surface", () => {233 it("closing the active surface activates a neighboring surface", () => {
90 useRightPanelStore.getState().openBrowser(refA, "tab-a");234 useRightPanelStore.getState().openBrowser(refA, "tab-a");
91 useRightPanelStore.getState().open(refA, "terminal");235 useRightPanelStore.getState().openTerminal(refA, "term-1");
92 useRightPanelStore.getState().closeSurface(refA, "terminal");236 useRightPanelStore.getState().closeSurface(refA, "terminal:term-1");
93237
94 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe(238 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe(
95 "browser:tab-a",239 "browser:tab-a",
96 );240 );
97 });241 });
98242
243 it("closing the final surface leaves the panel open and empty", () => {
244 useRightPanelStore.getState().openTerminal(refA, "term-1");
245 useRightPanelStore.getState().closeSurface(refA, "terminal:term-1");
246
247 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
248 isOpen: true,
249 activeSurfaceId: null,
250 surfaces: [],
251 });
252 });
253
99 it("reconciles browser surfaces without deleting other surface kinds", () => {254 it("reconciles browser surfaces without deleting other surface kinds", () => {
100 useRightPanelStore.getState().open(refA, "terminal");255 useRightPanelStore.getState().openTerminal(refA, "term-1");
101 useRightPanelStore.getState().openBrowser(refA, "tab-a");256 useRightPanelStore.getState().openBrowser(refA, "tab-a");
102 useRightPanelStore.getState().openBrowser(refA, "tab-b");257 useRightPanelStore.getState().openBrowser(refA, "tab-b");
103 useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-b", "tab-c"]);258 useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-b", "tab-c"]);
...@@ -106,6 +261,6 @@ describe("rightPanelStore", () => {...@@ -106,6 +261,6 @@ describe("rightPanelStore", () => {
106 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map(261 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map(
107 (surface) => surface.id,262 (surface) => surface.id,
108 ),263 ),
109 ).toEqual(["terminal", "browser:tab-b", "browser:tab-c"]);264 ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]);
110 });265 });
111});266});
apps/web/src/rightPanelStore.ts+214-15
...@@ -3,8 +3,9 @@...@@ -3,8 +3,9 @@
3 *3 *
4 * This is intentionally a shallow workspace model: it owns an ordered set of4 * This is intentionally a shallow workspace model: it owns an ordered set of
5 * surface descriptors and the active surface, while each feature continues to5 * surface descriptors and the active surface, while each feature continues to
6 * own its durable resource state. Browser surfaces point at preview tab ids;6 * own its durable resource state. Browser surfaces point at preview tab ids,
7 * singleton surfaces bridge the existing terminal, diff, and plan features.7 * terminal surfaces point at terminal session ids, and diff/plan remain
8 * singleton surfaces.
8 */9 */
9import { scopedThreadKey } from "@t3tools/client-runtime";10import { scopedThreadKey } from "@t3tools/client-runtime";
10import type { ScopedThreadRef } from "@t3tools/contracts";11import type { ScopedThreadRef } from "@t3tools/contracts";
...@@ -19,35 +20,59 @@ export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];...@@ -19,35 +20,59 @@ export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
19export type RightPanelSurface =20export type RightPanelSurface =
20 | { id: `browser:${string}`; kind: "preview"; resourceId: string }21 | { id: `browser:${string}`; kind: "preview"; resourceId: string }
21 | { id: "browser:new"; kind: "preview"; resourceId: null }22 | { id: "browser:new"; kind: "preview"; resourceId: null }
22 | { id: "terminal"; kind: "terminal" }23 | {
24 id: `terminal:${string}`;
25 kind: "terminal";
26 resourceId: string;
27 terminalIds: string[];
28 activeTerminalId: string;
29 splitDirection?: "horizontal" | "vertical";
30 }
23 | { id: "diff"; kind: "diff" }31 | { id: "diff"; kind: "diff" }
24 | { id: "plan"; kind: "plan" };32 | { id: "plan"; kind: "plan" };
2533
26const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";34const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
35const RIGHT_PANEL_STORAGE_VERSION = 5;
2736
28export interface ThreadRightPanelState {37export interface ThreadRightPanelState {
38 isOpen: boolean;
29 activeSurfaceId: string | null;39 activeSurfaceId: string | null;
30 surfaces: RightPanelSurface[];40 surfaces: RightPanelSurface[];
31}41}
3242
33interface RightPanelStoreState {43interface RightPanelStoreState {
34 byThreadKey: Record<string, ThreadRightPanelState>;44 byThreadKey: Record<string, ThreadRightPanelState>;
35 open: (ref: ScopedThreadRef, kind: RightPanelKind) => void;45 open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void;
36 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;46 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
47 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;
48 splitTerminal: (
49 ref: ScopedThreadRef,
50 surfaceId: string,
51 terminalId: string,
52 direction?: "horizontal" | "vertical",
53 ) => void;
54 activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;
55 closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;
37 activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void;56 activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
38 closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void;57 closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
39 reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void;58 reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void;
59 show: (ref: ScopedThreadRef) => void;
40 close: (ref: ScopedThreadRef) => void;60 close: (ref: ScopedThreadRef) => void;
41 toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void;61 toggleVisibility: (ref: ScopedThreadRef) => void;
62 toggle: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void;
42 removeThread: (ref: ScopedThreadRef) => void;63 removeThread: (ref: ScopedThreadRef) => void;
43}64}
4465
45const EMPTY_THREAD_STATE: ThreadRightPanelState = { activeSurfaceId: null, surfaces: [] };66const EMPTY_THREAD_STATE: ThreadRightPanelState = {
67 isOpen: false,
68 activeSurfaceId: null,
69 surfaces: [],
70};
4671
47const singletonSurface = (kind: Exclude<RightPanelKind, "preview">): RightPanelSurface => {72const singletonSurface = (
73 kind: Exclude<RightPanelKind, "preview" | "terminal">,
74): RightPanelSurface => {
48 switch (kind) {75 switch (kind) {
49 case "terminal":
50 return { id: "terminal", kind };
51 case "diff":76 case "diff":
52 return { id: "diff", kind };77 return { id: "diff", kind };
53 case "plan":78 case "plan":
...@@ -60,11 +85,20 @@ const browserSurface = (tabId: string | null): RightPanelSurface =>...@@ -60,11 +85,20 @@ const browserSurface = (tabId: string | null): RightPanelSurface =>
60 ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId }85 ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId }
61 : { id: "browser:new", kind: "preview", resourceId: null };86 : { id: "browser:new", kind: "preview", resourceId: null };
6287
88const terminalSurface = (terminalId: string): RightPanelSurface => ({
89 id: `terminal:${terminalId}`,
90 kind: "terminal",
91 resourceId: terminalId,
92 terminalIds: [terminalId],
93 activeTerminalId: terminalId,
94});
95
63const upsertSurface = (96const upsertSurface = (
64 current: ThreadRightPanelState,97 current: ThreadRightPanelState,
65 surface: RightPanelSurface,98 surface: RightPanelSurface,
66 activate = true,99 activate = true,
67): ThreadRightPanelState => ({100): ThreadRightPanelState => ({
101 isOpen: true,
68 surfaces: current.surfaces.some((entry) => entry.id === surface.id)102 surfaces: current.surfaces.some((entry) => entry.id === surface.id)
69 ? current.surfaces103 ? current.surfaces
70 : [...current.surfaces, surface],104 : [...current.surfaces, surface],
...@@ -78,7 +112,7 @@ const updateThread = (...@@ -78,7 +112,7 @@ const updateThread = (
78): Record<string, ThreadRightPanelState> => {112): Record<string, ThreadRightPanelState> => {
79 const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;113 const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
80 const next = updater(current);114 const next = updater(current);
81 if (next.activeSurfaceId === null && next.surfaces.length === 0) {115 if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) {
82 if (!(threadKey in byThreadKey)) return byThreadKey;116 if (!(threadKey in byThreadKey)) return byThreadKey;
83 const { [threadKey]: _removed, ...rest } = byThreadKey;117 const { [threadKey]: _removed, ...rest } = byThreadKey;
84 return rest;118 return rest;
...@@ -87,6 +121,74 @@ const updateThread = (...@@ -87,6 +121,74 @@ const updateThread = (
87 return { ...byThreadKey, [threadKey]: next };121 return { ...byThreadKey, [threadKey]: next };
88};122};
89123
124export function migratePersistedRightPanelState(persistedState: unknown): {
125 byThreadKey: Record<string, ThreadRightPanelState>;
126} {
127 if (!persistedState || typeof persistedState !== "object") {
128 return { byThreadKey: {} };
129 }
130 const byThreadKey =
131 "byThreadKey" in persistedState &&
132 persistedState.byThreadKey &&
133 typeof persistedState.byThreadKey === "object"
134 ? Object.fromEntries(
135 Object.entries(persistedState.byThreadKey as Record<string, ThreadRightPanelState>).map(
136 ([threadKey, threadState]) => {
137 const validThreadState =
138 threadState && typeof threadState === "object" ? threadState : null;
139 const surfaces = Array.isArray(validThreadState?.surfaces)
140 ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => {
141 if (surface.kind !== "terminal") return [surface];
142 if (
143 !("resourceId" in surface) ||
144 typeof surface.resourceId !== "string" ||
145 surface.id !== `terminal:${surface.resourceId}`
146 ) {
147 return [];
148 }
149 const terminalIds =
150 "terminalIds" in surface && Array.isArray(surface.terminalIds)
151 ? [
152 ...new Set(
153 surface.terminalIds.filter(
154 (terminalId): terminalId is string =>
155 typeof terminalId === "string",
156 ),
157 ),
158 ]
159 : [surface.resourceId];
160 const activeTerminalId =
161 "activeTerminalId" in surface &&
162 typeof surface.activeTerminalId === "string" &&
163 terminalIds.includes(surface.activeTerminalId)
164 ? surface.activeTerminalId
165 : (terminalIds[0] ?? surface.resourceId);
166 return [
167 {
168 ...surface,
169 terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId],
170 activeTerminalId,
171 },
172 ];
173 })
174 : [];
175 const activeSurfaceId = surfaces.some(
176 (surface) => surface.id === validThreadState?.activeSurfaceId,
177 )
178 ? (validThreadState?.activeSurfaceId ?? null)
179 : null;
180 const isOpen =
181 typeof validThreadState?.isOpen === "boolean"
182 ? validThreadState.isOpen
183 : activeSurfaceId !== null;
184 return [threadKey, { isOpen, surfaces, activeSurfaceId }];
185 },
186 ),
187 )
188 : {};
189 return { byThreadKey };
190}
191
90export const useRightPanelStore = create<RightPanelStoreState>()(192export const useRightPanelStore = create<RightPanelStoreState>()(
91 persist(193 persist(
92 (set) => ({194 (set) => ({
...@@ -111,11 +213,89 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -111,11 +213,89 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
111 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);213 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);
112 }),214 }),
113 })),215 })),
216 openTerminal: (ref, terminalId) =>
217 set((state) => ({
218 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
219 upsertSurface(current, terminalSurface(terminalId)),
220 ),
221 })),
222 splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") =>
223 set((state) => ({
224 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({
225 ...current,
226 isOpen: true,
227 activeSurfaceId: surfaceId,
228 surfaces: current.surfaces.map((surface) => {
229 if (surface.id !== surfaceId || surface.kind !== "terminal") return surface;
230 const { splitDirection: _splitDirection, ...baseSurface } = surface;
231 return {
232 ...baseSurface,
233 terminalIds: surface.terminalIds.includes(terminalId)
234 ? surface.terminalIds
235 : [...surface.terminalIds, terminalId],
236 activeTerminalId: terminalId,
237 ...(direction === "vertical" ? { splitDirection: "vertical" as const } : {}),
238 };
239 }),
240 })),
241 })),
242 activateTerminal: (ref, surfaceId, terminalId) =>
243 set((state) => ({
244 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({
245 ...current,
246 activeSurfaceId: surfaceId,
247 surfaces: current.surfaces.map((surface) =>
248 surface.id === surfaceId &&
249 surface.kind === "terminal" &&
250 surface.terminalIds.includes(terminalId)
251 ? { ...surface, activeTerminalId: terminalId }
252 : surface,
253 ),
254 })),
255 })),
256 closeTerminal: (ref, surfaceId, terminalId) =>
257 set((state) => ({
258 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
259 const surface = current.surfaces.find(
260 (entry) => entry.id === surfaceId && entry.kind === "terminal",
261 );
262 if (!surface || surface.kind !== "terminal") return current;
263 const terminalIds = surface.terminalIds.filter((id) => id !== terminalId);
264 if (terminalIds.length === 0) {
265 const index = current.surfaces.findIndex((entry) => entry.id === surfaceId);
266 const surfaces = current.surfaces.filter((entry) => entry.id !== surfaceId);
267 const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;
268 return {
269 ...current,
270 surfaces,
271 activeSurfaceId:
272 current.activeSurfaceId === surfaceId
273 ? (fallback?.id ?? null)
274 : current.activeSurfaceId,
275 };
276 }
277 return {
278 ...current,
279 surfaces: current.surfaces.map((entry) =>
280 entry.id === surfaceId && entry.kind === "terminal"
281 ? {
282 ...entry,
283 terminalIds,
284 activeTerminalId:
285 entry.activeTerminalId === terminalId
286 ? (terminalIds.at(-1) ?? terminalIds[0]!)
287 : entry.activeTerminalId,
288 }
289 : entry,
290 ),
291 };
292 }),
293 })),
114 activateSurface: (ref, surfaceId) =>294 activateSurface: (ref, surfaceId) =>
115 set((state) => ({295 set((state) => ({
116 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>296 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
117 current.surfaces.some((surface) => surface.id === surfaceId)297 current.surfaces.some((surface) => surface.id === surfaceId)
118 ? { ...current, activeSurfaceId: surfaceId }298 ? { ...current, isOpen: true, activeSurfaceId: surfaceId }
119 : current,299 : current,
120 ),300 ),
121 })),301 })),
...@@ -127,7 +307,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -127,7 +307,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
127 const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);307 const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);
128 if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces };308 if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces };
129 const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;309 const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;
130 return { surfaces, activeSurfaceId: fallback?.id ?? null };310 return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null };
131 }),311 }),
132 })),312 })),
133 reconcileBrowserSurfaces: (ref, tabIds) =>313 reconcileBrowserSurfaces: (ref, tabIds) =>
...@@ -151,6 +331,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -151,6 +331,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
151 );331 );
152 const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview");332 const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview");
153 return {333 return {
334 ...current,
154 surfaces,335 surfaces,
155 activeSurfaceId: activeStillExists336 activeSurfaceId: activeStillExists
156 ? current.activeSurfaceId337 ? current.activeSurfaceId
...@@ -158,11 +339,23 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -158,11 +339,23 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
158 };339 };
159 }),340 }),
160 })),341 })),
342 show: (ref) =>
343 set((state) => ({
344 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
345 current.isOpen ? current : { ...current, isOpen: true },
346 ),
347 })),
161 close: (ref) =>348 close: (ref) =>
349 set((state) => ({
350 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
351 current.isOpen ? { ...current, isOpen: false } : current,
352 ),
353 })),
354 toggleVisibility: (ref) =>
162 set((state) => ({355 set((state) => ({
163 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({356 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({
164 ...current,357 ...current,
165 activeSurfaceId: null,358 isOpen: !current.isOpen,
166 })),359 })),
167 })),360 })),
168 toggle: (ref, kind) =>361 toggle: (ref, kind) =>
...@@ -171,7 +364,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -171,7 +364,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
171 const active = current.surfaces.find(364 const active = current.surfaces.find(
172 (surface) => surface.id === current.activeSurfaceId,365 (surface) => surface.id === current.activeSurfaceId,
173 );366 );
174 if (active?.kind === kind) return { ...current, activeSurfaceId: null };367 if (current.isOpen && active?.kind === kind) {
368 return { ...current, isOpen: false };
369 }
175 if (kind === "preview") {370 if (kind === "preview") {
176 const existing = current.surfaces.find((surface) => surface.kind === "preview");371 const existing = current.surfaces.find((surface) => surface.kind === "preview");
177 return upsertSurface(current, existing ?? browserSurface(null));372 return upsertSurface(current, existing ?? browserSurface(null));
...@@ -189,11 +384,12 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -189,11 +384,12 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
189 }),384 }),
190 {385 {
191 name: RIGHT_PANEL_STORAGE_KEY,386 name: RIGHT_PANEL_STORAGE_KEY,
192 version: 2,387 version: RIGHT_PANEL_STORAGE_VERSION,
193 storage: createJSONStorage(() =>388 storage: createJSONStorage(() =>
194 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),389 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
195 ),390 ),
196 partialize: (state) => ({ byThreadKey: state.byThreadKey }),391 partialize: (state) => ({ byThreadKey: state.byThreadKey }),
392 migrate: migratePersistedRightPanelState,
197 },393 },
198 ),394 ),
199);395);
...@@ -211,6 +407,7 @@ export function selectActiveRightPanel(...@@ -211,6 +407,7 @@ export function selectActiveRightPanel(
211 ref: ScopedThreadRef | null | undefined,407 ref: ScopedThreadRef | null | undefined,
212): RightPanelKind | null {408): RightPanelKind | null {
213 const state = selectThreadRightPanelState(byThreadKey, ref);409 const state = selectThreadRightPanelState(byThreadKey, ref);
410 if (!state.isOpen) return null;
214 return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null;411 return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null;
215}412}
216413
...@@ -219,6 +416,7 @@ export function selectActiveRightPanelSurface(...@@ -219,6 +416,7 @@ export function selectActiveRightPanelSurface(
219 ref: ScopedThreadRef | null | undefined,416 ref: ScopedThreadRef | null | undefined,
220): RightPanelSurface | null {417): RightPanelSurface | null {
221 const state = selectThreadRightPanelState(byThreadKey, ref);418 const state = selectThreadRightPanelState(byThreadKey, ref);
419 if (!state.isOpen) return null;
222 return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null;420 return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null;
223}421}
224422
...@@ -227,6 +425,7 @@ export function selectActiveRightPanelKindWithUrl(...@@ -227,6 +425,7 @@ export function selectActiveRightPanelKindWithUrl(
227 ref: ScopedThreadRef | null | undefined,425 ref: ScopedThreadRef | null | undefined,
228 diffSearchActive: boolean,426 diffSearchActive: boolean,
229): RightPanelKind | null {427): RightPanelKind | null {
428 if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null;
230 if (diffSearchActive) return "diff";429 if (diffSearchActive) return "diff";
231 return selectActiveRightPanel(byThreadKey, ref);430 return selectActiveRightPanel(byThreadKey, ref);
232}431}
apps/web/src/rpc/requestLatencyState.test.ts+8
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { WS_METHODS } from "@t3tools/contracts";
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
23
3import {4import {
...@@ -50,6 +51,13 @@ describe("requestLatencyState", () => {...@@ -50,6 +51,13 @@ describe("requestLatencyState", () => {
50 expect(getSlowRpcAckRequests()).toEqual([]);51 expect(getSlowRpcAckRequests()).toEqual([]);
51 });52 });
5253
54 it("ignores the long-lived preview automation connection", () => {
55 trackRpcRequestSent("1", WS_METHODS.previewAutomationConnect);
56 vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);
57
58 expect(getSlowRpcAckRequests()).toEqual([]);
59 });
60
53 it("evicts the oldest pending requests once the tracker reaches capacity", () => {61 it("evicts the oldest pending requests once the tracker reaches capacity", () => {
54 for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) {62 for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) {
55 trackRpcRequestSent(String(index), "server.getConfig");63 trackRpcRequestSent(String(index), "server.getConfig");
apps/web/src/rpc/requestLatencyState.ts+3-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";3import { Atom } from "effect/unstable/reactivity";
34
4import { appAtomRegistry } from "./atomRegistry";5import { appAtomRegistry } from "./atomRegistry";
...@@ -21,6 +22,7 @@ interface PendingRpcAckRequest {...@@ -21,6 +22,7 @@ interface PendingRpcAckRequest {
21}22}
2223
23const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>();24const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>();
25const untrackedRpcAckTags = new Set<string>([WS_METHODS.previewAutomationConnect]);
2426
25const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe(27const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe(
26 Atom.keepAlive,28 Atom.keepAlive,
...@@ -36,7 +38,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> {...@@ -36,7 +38,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> {
36}38}
3739
38function shouldTrackRpcAck(tag: string): boolean {40function shouldTrackRpcAck(tag: string): boolean {
39 return !tag.includes("subscribe");41 return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag);
40}42}
4143
42export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> {44export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> {
apps/web/src/terminalUiStateStore.test.ts+18
...@@ -56,6 +56,24 @@ describe("terminalUiStateStore actions", () => {...@@ -56,6 +56,24 @@ describe("terminalUiStateStore actions", () => {
56 ]);56 ]);
57 });57 });
5858
59 it("stacks vertically split terminals in the active group", () => {
60 const store = useTerminalUiStateStore.getState();
61 store.setTerminalOpen(THREAD_REF, true);
62 store.splitTerminalVertical(THREAD_REF, "terminal-2");
63
64 const terminalUiState = selectThreadTerminalUiState(
65 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
66 THREAD_REF,
67 );
68 expect(terminalUiState.terminalGroups).toEqual([
69 {
70 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
71 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "terminal-2"],
72 splitDirection: "vertical",
73 },
74 ]);
75 });
76
59 it("materializes the default terminal when opening an empty drawer", () => {77 it("materializes the default terminal when opening an empty drawer", () => {
60 useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true);78 useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true);
6179
apps/web/src/terminalUiStateStore.ts+18-1
...@@ -126,6 +126,7 @@ function normalizeTerminalGroups(...@@ -126,6 +126,7 @@ function normalizeTerminalGroups(
126 nextGroups.push({126 nextGroups.push({
127 id: assignUniqueGroupId(baseGroupId, usedGroupIds),127 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
128 terminalIds: groupTerminalIds,128 terminalIds: groupTerminalIds,
129 ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}),
129 });130 });
130 }131 }
131132
...@@ -155,6 +156,11 @@ function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalG...@@ -155,6 +156,11 @@ function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalG
155 const rightGroup = right[index];156 const rightGroup = right[index];
156 if (!leftGroup || !rightGroup) return false;157 if (!leftGroup || !rightGroup) return false;
157 if (leftGroup.id !== rightGroup.id) return false;158 if (leftGroup.id !== rightGroup.id) return false;
159 if (
160 (leftGroup.splitDirection ?? "horizontal") !== (rightGroup.splitDirection ?? "horizontal")
161 ) {
162 return false;
163 }
158 if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false;164 if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false;
159 }165 }
160 return true;166 return true;
...@@ -241,6 +247,7 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[...@@ -241,6 +247,7 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[
241 return groups.map((group) => ({247 return groups.map((group) => ({
242 id: group.id,248 id: group.id,
243 terminalIds: [...group.terminalIds],249 terminalIds: [...group.terminalIds],
250 ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}),
244 }));251 }));
245}252}
246253
...@@ -248,6 +255,7 @@ function upsertTerminalIntoGroups(...@@ -248,6 +255,7 @@ function upsertTerminalIntoGroups(
248 state: ThreadTerminalUiState,255 state: ThreadTerminalUiState,
249 terminalId: string,256 terminalId: string,
250 mode: "split" | "new",257 mode: "split" | "new",
258 splitDirection: "horizontal" | "vertical" = "horizontal",
251): ThreadTerminalUiState {259): ThreadTerminalUiState {
252 const normalized = normalizeThreadTerminalUiState(state);260 const normalized = normalizeThreadTerminalUiState(state);
253 const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode;261 const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode;
...@@ -323,6 +331,11 @@ function upsertTerminalIntoGroups(...@@ -323,6 +331,11 @@ function upsertTerminalIntoGroups(
323 destinationGroup.terminalIds.push(terminalId);331 destinationGroup.terminalIds.push(terminalId);
324 }332 }
325 }333 }
334 if (splitDirection === "vertical") {
335 destinationGroup.splitDirection = "vertical";
336 } else {
337 delete destinationGroup.splitDirection;
338 }
326339
327 return normalizeThreadTerminalUiState({340 return normalizeThreadTerminalUiState({
328 ...normalized,341 ...normalized,
...@@ -357,8 +370,9 @@ function setThreadTerminalHeight(...@@ -357,8 +370,9 @@ function setThreadTerminalHeight(
357function splitThreadTerminal(370function splitThreadTerminal(
358 state: ThreadTerminalUiState,371 state: ThreadTerminalUiState,
359 terminalId: string,372 terminalId: string,
373 direction: "horizontal" | "vertical" = "horizontal",
360): ThreadTerminalUiState {374): ThreadTerminalUiState {
361 return upsertTerminalIntoGroups(state, terminalId, "split");375 return upsertTerminalIntoGroups(state, terminalId, "split", direction);
362}376}
363377
364function newThreadTerminal(378function newThreadTerminal(
...@@ -510,6 +524,7 @@ interface TerminalUiStateStoreState {...@@ -510,6 +524,7 @@ interface TerminalUiStateStoreState {
510 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;524 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
511 setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void;525 setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void;
512 splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;526 splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
527 splitTerminalVertical: (threadRef: ScopedThreadRef, terminalId: string) => void;
513 newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;528 newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
514 ensureTerminal: (529 ensureTerminal: (
515 threadRef: ScopedThreadRef,530 threadRef: ScopedThreadRef,
...@@ -554,6 +569,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(...@@ -554,6 +569,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
554 updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)),569 updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)),
555 splitTerminal: (threadRef, terminalId) =>570 splitTerminal: (threadRef, terminalId) =>
556 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)),571 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)),
572 splitTerminalVertical: (threadRef, terminalId) =>
573 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical")),
557 newTerminal: (threadRef, terminalId) =>574 newTerminal: (threadRef, terminalId) =>
558 updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)),575 updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)),
559 ensureTerminal: (threadRef, terminalId, options) =>576 ensureTerminal: (threadRef, terminalId, options) =>
apps/web/src/types.ts+1
...@@ -30,6 +30,7 @@ export type ProjectScript = ContractProjectScript;...@@ -30,6 +30,7 @@ export type ProjectScript = ContractProjectScript;
30export interface ThreadTerminalGroup {30export interface ThreadTerminalGroup {
31 id: string;31 id: string;
32 terminalIds: string[];32 terminalIds: string[];
33 splitDirection?: "horizontal" | "vertical";
33}34}
3435
35export interface ChatImageAttachment {36export interface ChatImageAttachment {
packages/contracts/src/ipc.ts+33
...@@ -455,6 +455,15 @@ export interface DesktopPreviewTabState {...@@ -455,6 +455,15 @@ export interface DesktopPreviewTabState {
455 updatedAt: string;455 updatedAt: string;
456}456}
457457
458export interface DesktopPreviewPointerEvent {
459 tabId: string;
460 phase: "move" | "click";
461 x: number;
462 y: number;
463 sequence: number;
464 createdAt: string;
465}
466
458/**467/**
459 * Static config a renderer needs to mount a preview `<webview>`. Returned468 * Static config a renderer needs to mount a preview `<webview>`. Returned
460 * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer469 * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer
...@@ -478,6 +487,26 @@ export interface DesktopPreviewWebviewConfig {...@@ -478,6 +487,26 @@ export interface DesktopPreviewWebviewConfig {
478 preloadUrl: string | null;487 preloadUrl: string | null;
479}488}
480489
490export interface DesktopPreviewAnnotationTheme {
491 colorScheme: "light" | "dark";
492 radius: string;
493 background: string;
494 foreground: string;
495 popover: string;
496 popoverForeground: string;
497 primary: string;
498 primaryForeground: string;
499 muted: string;
500 mutedForeground: string;
501 accent: string;
502 accentForeground: string;
503 border: string;
504 input: string;
505 ring: string;
506 fontSans: string;
507 fontMono: string;
508}
509
481export interface DesktopPreviewRecordingFrame {510export interface DesktopPreviewRecordingFrame {
482 tabId: string;511 tabId: string;
483 data: string;512 data: string;
...@@ -700,6 +729,7 @@ export interface DesktopPreviewBridge {...@@ -700,6 +729,7 @@ export interface DesktopPreviewBridge {
700 * the contract + main, not the renderer's mount logic.729 * the contract + main, not the renderer's mount logic.
701 */730 */
702 getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>;731 getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>;
732 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;
703 /**733 /**
704 * Activate the in-page element picker for the given tab. Resolves with734 * Activate the in-page element picker for the given tab. Resolves with
705 * the picked payload, or `null` when the user cancels (Escape / nav). The735 * the picked payload, or `null` when the user cancels (Escape / nav). The
...@@ -709,6 +739,8 @@ export interface DesktopPreviewBridge {...@@ -709,6 +739,8 @@ export interface DesktopPreviewBridge {
709 /** Cancel an in-flight preview annotation session. */739 /** Cancel an in-flight preview annotation session. */
710 cancelPickElement: (tabId: string) => Promise<void>;740 cancelPickElement: (tabId: string) => Promise<void>;
711 captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>;741 captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>;
742 revealArtifact: (path: string) => Promise<void>;
743 copyArtifactToClipboard: (path: string) => Promise<void>;
712 recording: {744 recording: {
713 startScreencast: (tabId: string) => Promise<void>;745 startScreencast: (tabId: string) => Promise<void>;
714 stopScreencast: (tabId: string) => Promise<void>;746 stopScreencast: (tabId: string) => Promise<void>;
...@@ -730,6 +762,7 @@ export interface DesktopPreviewBridge {...@@ -730,6 +762,7 @@ export interface DesktopPreviewBridge {
730 waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>;762 waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>;
731 };763 };
732 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;764 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;
765 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;
733}766}
734767
735/**768/**
packages/contracts/src/keybindings.test.ts+8-1
...@@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () =>...@@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () =>
29 });29 });
30 assert.strictEqual(parsed.command, "terminal.toggle");30 assert.strictEqual(parsed.command, "terminal.toggle");
3131
32 const parsedRightPanelToggle = yield* decode(KeybindingRule, {
33 key: "mod+alt+b",
34 command: "rightPanel.toggle",
35 });
36 assert.strictEqual(parsedRightPanelToggle.command, "rightPanel.toggle");
37
32 const parsedClose = yield* decode(KeybindingRule, {38 const parsedClose = yield* decode(KeybindingRule, {
33 key: "mod+w",39 key: "mod+w",
34 command: "terminal.close",40 command: "terminal.close",
...@@ -100,8 +106,9 @@ it.effect("parses keybindings array payload", () =>...@@ -100,8 +106,9 @@ it.effect("parses keybindings array payload", () =>
100 const parsed = yield* decode(KeybindingsConfig, [106 const parsed = yield* decode(KeybindingsConfig, [
101 { key: "mod+j", command: "terminal.toggle" },107 { key: "mod+j", command: "terminal.toggle" },
102 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },108 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
109 { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" },
103 ]);110 ]);
104 assert.lengthOf(parsed, 2);111 assert.lengthOf(parsed, 3);
105 }),112 }),
106);113);
107114
packages/contracts/src/keybindings.ts+2
...@@ -50,8 +50,10 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA...@@ -50,8 +50,10 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA
50const STATIC_KEYBINDING_COMMANDS = [50const STATIC_KEYBINDING_COMMANDS = [
51 "terminal.toggle",51 "terminal.toggle",
52 "terminal.split",52 "terminal.split",
53 "terminal.splitVertical",
53 "terminal.new",54 "terminal.new",
54 "terminal.close",55 "terminal.close",
56 "rightPanel.toggle",
55 "diff.toggle",57 "diff.toggle",
56 "preview.toggle",58 "preview.toggle",
57 "preview.refresh",59 "preview.refresh",
packages/contracts/src/preview.test.ts+4
...@@ -123,6 +123,7 @@ describe("DiscoveredLocalServer", () => {...@@ -123,6 +123,7 @@ describe("DiscoveredLocalServer", () => {
123 url: "http://localhost:5173",123 url: "http://localhost:5173",
124 processName: "node",124 processName: "node",
125 pid: 12345,125 pid: 12345,
126 terminal: null,
126 });127 });
127 expect(server.port).toBe(5173);128 expect(server.port).toBe(5173);
128 expect(server.processName).toBe("node");129 expect(server.processName).toBe("node");
...@@ -135,6 +136,7 @@ describe("DiscoveredLocalServer", () => {...@@ -135,6 +136,7 @@ describe("DiscoveredLocalServer", () => {
135 url: "http://localhost:3000",136 url: "http://localhost:3000",
136 processName: null,137 processName: null,
137 pid: null,138 pid: null,
139 terminal: null,
138 });140 });
139 expect(server.processName).toBeNull();141 expect(server.processName).toBeNull();
140 });142 });
...@@ -147,6 +149,7 @@ describe("DiscoveredLocalServer", () => {...@@ -147,6 +149,7 @@ describe("DiscoveredLocalServer", () => {
147 url: "http://localhost:0",149 url: "http://localhost:0",
148 processName: null,150 processName: null,
149 pid: null,151 pid: null,
152 terminal: null,
150 }),153 }),
151 ).toThrow();154 ).toThrow();
152 expect(() =>155 expect(() =>
...@@ -156,6 +159,7 @@ describe("DiscoveredLocalServer", () => {...@@ -156,6 +159,7 @@ describe("DiscoveredLocalServer", () => {
156 url: "http://localhost:70000",159 url: "http://localhost:70000",
157 processName: null,160 processName: null,
158 pid: null,161 pid: null,
162 terminal: null,
159 }),163 }),
160 ).toThrow();164 ).toThrow();
161 });165 });
packages/contracts/src/preview.ts+6
...@@ -142,6 +142,12 @@ export const DiscoveredLocalServer = Schema.Struct({...@@ -142,6 +142,12 @@ export const DiscoveredLocalServer = Schema.Struct({
142 url: Url,142 url: Url,
143 processName: Schema.NullOr(TrimmedNonEmptyString),143 processName: Schema.NullOr(TrimmedNonEmptyString),
144 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),144 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
145 terminal: Schema.NullOr(
146 Schema.Struct({
147 threadId: ThreadId,
148 terminalId: TrimmedNonEmptyString,
149 }),
150 ),
145});151});
146export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type;152export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type;
147153
packages/shared/src/keybindings.ts+2
...@@ -20,7 +20,9 @@ type WhenToken =...@@ -20,7 +20,9 @@ type WhenToken =
2020
21export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [21export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
22 { key: "mod+j", command: "terminal.toggle" },22 { key: "mod+j", command: "terminal.toggle" },
23 { key: "mod+alt+b", command: "rightPanel.toggle" },
23 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },24 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
25 { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" },
24 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },26 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
25 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },27 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
26 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },28 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
pnpm-lock.yaml+3
...@@ -147,6 +147,9 @@ importers:...@@ -147,6 +147,9 @@ importers:
147 electron-builder:147 electron-builder:
148 specifier: 26.8.1148 specifier: 26.8.1
149 version: 26.8.1(electron-builder-squirrel-windows@26.8.1)149 version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
150 tailwindcss:
151 specifier: ^4.0.0
152 version: 4.3.0
150 vite-plus:153 vite-plus:
151 specifier: 'catalog:'154 specifier: 'catalog:'
152 version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)155 version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)