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 @@
2929 "@types/node": "catalog:",
3030 "cross-env": "^10.1.0",
3131 "electron-builder": "26.8.1",
32 "tailwindcss": "^4.0.0",
3233 "vite-plus": "catalog:"
3334 },
3435 "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", () => {
5959 assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json");
6060 assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json");
6161 assert.equal(environment.logDir, "/tmp/t3/dev/logs");
62 assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts");
6263 assert.equal(environment.rootDir, "/repo");
6364 assert.equal(environment.appRoot, "/repo");
6465 assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");
......@@ -89,6 +90,7 @@ describe("DesktopEnvironment", () => {
8990 assert.equal(environment.isDevelopment, false);
9091 assert.equal(environment.stateDir, "/tmp/t3/userdata");
9192 assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
93 assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts");
9294 assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
9395 }),
9496 );
apps/desktop/src/app/DesktopEnvironment.ts+2
......@@ -49,6 +49,7 @@ export interface DesktopEnvironmentShape {
4949 readonly savedEnvironmentRegistryPath: string;
5050 readonly serverSettingsPath: string;
5151 readonly logDir: string;
52 readonly browserArtifactsDir: string;
5253 readonly rootDir: string;
5354 readonly appRoot: string;
5455 readonly backendEntryPath: string;
......@@ -183,6 +184,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
183184 savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"),
184185 serverSettingsPath: path.join(stateDir, "settings.json"),
185186 logDir: path.join(stateDir, "logs"),
187 browserArtifactsDir: path.join(stateDir, "browser-artifacts"),
186188 rootDir,
187189 appRoot,
188190 backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"),
apps/desktop/src/ipc/DesktopIpcHandlers.ts+4
......@@ -1,5 +1,7 @@
11import * as Effect from "effect/Effect";
22
3import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
4import { previewViewManager } from "../preview-view-manager.ts";
35import * as DesktopIpc from "./DesktopIpc.ts";
46import {
57 clearCloudAuthToken,
......@@ -52,6 +54,8 @@ import { previewMethods } from "./methods/preview.ts";
5254
5355export const installDesktopIpcHandlers = Effect.gen(function* () {
5456 const ipc = yield* DesktopIpc.DesktopIpc;
57 const environment = yield* DesktopEnvironment.DesktopEnvironment;
58 previewViewManager.configureArtifactDirectory(environment.browserArtifactsDir);
5559
5660 yield* ipc.handleSync(getAppBranding);
5761 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";
5454export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
5555export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
5656export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
57export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme";
5758export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
5859export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
5960export 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";
6063export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status";
6164export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot";
6265export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click";
......@@ -70,3 +73,4 @@ export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop";
7073export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
7174export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
7275export 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 @@
11// @effect-diagnostics nodeBuiltinImport:off
22import * as Data from "effect/Data";
33import * as Effect from "effect/Effect";
4import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts";
45import { BrowserWindow } from "electron";
56import { pathToFileURL } from "node:url";
67
......@@ -25,6 +26,14 @@ previewViewManager.onRecordingFrame((frame) => {
2526 }
2627});
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
2837const tabIdFrom = (raw: unknown): string => {
2938 if (typeof raw !== "object" || raw === null || !("tabId" in raw)) {
3039 throw new Error("preview tab id is required");
......@@ -43,6 +52,44 @@ const inputFrom = (raw: unknown): unknown => {
4352 return raw.input;
4453};
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
4693class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{
4794 readonly cause: unknown;
4895}> {}
......@@ -117,6 +164,9 @@ export const previewMethods = [
117164 preloadUrl: pathToFileURL(preloadPath).href,
118165 };
119166 }),
167 method(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, (raw) =>
168 previewViewManager.setAnnotationTheme(annotationThemeFrom(raw)),
169 ),
120170 method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) =>
121171 previewViewManager.pickElement(tabIdFrom(raw)),
122172 ),
......@@ -126,6 +176,20 @@ export const previewMethods = [
126176 method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) =>
127177 previewViewManager.captureScreenshot(tabIdFrom(raw)),
128178 ),
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 }),
129193 method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) =>
130194 previewViewManager.automationStatus(tabIdFrom(raw)),
131195 ),
apps/desktop/src/preload.ts+16
......@@ -1,5 +1,6 @@
11import type {
22 DesktopBridge,
3 DesktopPreviewPointerEvent,
34 DesktopPreviewRecordingFrame,
45 DesktopPreviewTabState,
56} from "@t3tools/contracts";
......@@ -165,11 +166,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
165166 clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),
166167 getPreviewConfig: (environmentId) =>
167168 ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }),
169 setAnnotationTheme: (theme) =>
170 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }),
168171 pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
169172 cancelPickElement: (tabId) =>
170173 ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }),
171174 captureScreenshot: (tabId) =>
172175 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 }),
173180 recording: {
174181 startScreencast: (tabId) =>
175182 ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }),
......@@ -222,5 +229,14 @@ contextBridge.exposeInMainWorld("desktopBridge", {
222229 return () =>
223230 ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener);
224231 },
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 },
225241 },
226242} 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 @@
22import { ipcRenderer } from "electron";
33import { getElementContext } from "react-grab/primitives";
44import type {
5 DesktopPreviewAnnotationTheme,
56 PickedElementPayload,
67 PickedElementStackFrame,
78 PreviewAnnotationPayload,
......@@ -12,16 +13,18 @@ import type {
1213 PreviewAnnotationStyleChange,
1314} from "@t3tools/contracts";
1415
16import { previewAnnotationStyles } from "./preview-annotation-styles.generated.ts";
17
1518const START_PICK_CHANNEL = "preview:start-pick";
1619const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
1720const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
1821const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
22const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
1923const HUMAN_INPUT_CHANNEL = "preview:human-input";
2024const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
2125const Z_INDEX_OVERLAY = 2147483646;
22const ACCENT = "#7c3aed";
23const ACCENT_FILL = "rgba(124,58,237,0.12)";
24const BLUE = "#2563eb";
26const PRIMARY = "var(--t3-primary)";
27const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)";
2528const MAX_MARQUEE_ELEMENTS = 20;
2629const CONTENT_LAYER_Z_INDEX = 1;
2730const CHROME_LAYER_Z_INDEX = 10;
......@@ -38,17 +41,63 @@ interface SelectedElement {
3841
3942interface AnnotationSession {
4043 teardown: (notifyMain: boolean) => void;
44 applyTheme: (theme: DesktopPreviewAnnotationTheme) => void;
4145}
4246
4347let activeSession: AnnotationSession | null = null;
4448let 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 => {
47 if (event.isTrusted) ipcRenderer.send(HUMAN_INPUT_CHANNEL);
90const reportHumanKeyInput = (event: KeyboardEvent): void => {
91 if (!event.isTrusted) return;
92 ipcRenderer.send(HUMAN_INPUT_CHANNEL, {
93 kind: "key",
94 key: event.key,
95 code: event.code,
96 });
4897};
4998
50window.addEventListener("pointerdown", reportHumanInput, true);
51window.addEventListener("keydown", reportHumanInput, true);
99window.addEventListener("pointerdown", reportHumanPointerInput, true);
100window.addEventListener("keydown", reportHumanKeyInput, true);
52101
53102const nextId = (prefix: string): string => {
54103 idSequence += 1;
......@@ -76,13 +125,15 @@ const normalizeRect = (
76125
77126const 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 {
80132 if (rects.length === 0) return null;
81133 const left = Math.min(...rects.map((rect) => rect.x));
82134 const top = Math.min(...rects.map((rect) => rect.y));
83135 const right = Math.max(...rects.map((rect) => rect.x + rect.width));
84136 const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
85 const padding = 20;
86137 const x = Math.max(0, left - padding);
87138 const y = Math.max(0, top - padding);
88139 const maxWidth = Math.max(1, window.innerWidth - x);
......@@ -151,19 +202,13 @@ function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void {
151202function createLabel(): HTMLDivElement {
152203 const label = document.createElement("div");
153204 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";
154207 label.style.cssText = [
155208 "position:fixed",
156209 "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",
162210 "white-space:nowrap",
163 "max-width:280px",
164 "overflow:hidden",
165211 "text-overflow:ellipsis",
166 "box-shadow:0 1px 3px rgba(0,0,0,.3)",
167212 `z-index:${CONTENT_LAYER_Z_INDEX}`,
168213 ].join(";");
169214 return label;
......@@ -222,22 +267,15 @@ function createButton(label: string, title: string): HTMLButtonElement {
222267 button.type = "button";
223268 button.textContent = label;
224269 button.title = title;
225 button.style.cssText = [
226 "border:0",
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(";");
270 button.className =
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";
234272 return button;
235273}
236274
237275function styleControl(input: HTMLInputElement | HTMLSelectElement): void {
238276 input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value");
239 input.style.cssText +=
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";
277 input.className =
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";
241279}
242280
243281function createUnitControl(input: HTMLInputElement): HTMLElement {
......@@ -245,8 +283,8 @@ function createUnitControl(input: HTMLInputElement): HTMLElement {
245283 wrapper.style.cssText = "position:relative;min-width:0";
246284 const unit = document.createElement("span");
247285 unit.textContent = input.dataset.unit ?? "";
248 unit.style.cssText =
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";
286 unit.className =
287 "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground";
250288 wrapper.append(input, unit);
251289 return wrapper;
252290}
......@@ -256,8 +294,8 @@ function createField(
256294 input: HTMLInputElement | HTMLSelectElement,
257295): HTMLLabelElement {
258296 const label = document.createElement("label");
259 label.style.cssText =
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";
297 label.className =
298 "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground";
261299 const text = document.createElement("span");
262300 text.textContent = labelText;
263301 styleControl(input);
......@@ -270,7 +308,7 @@ function createField(
270308
271309function createStyleSection(): HTMLElement {
272310 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";
274312 return section;
275313}
276314
......@@ -314,16 +352,27 @@ function strokeBounds(
314352function startAnnotation(): void {
315353 activeSession?.teardown(false);
316354 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
317364 const root = document.createElement("div");
318365 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";
320368 const cursorStyle = document.createElement("style");
321369 cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, "");
322370 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)");
326 const marqueeBox = createBox(ACCENT, ACCENT_FILL);
374 const hoverOutline = createBox(PRIMARY, PRIMARY_FILL);
375 const marqueeBox = createBox(PRIMARY, PRIMARY_FILL);
327376 root.append(hoverOutline, marqueeBox);
328377
329378 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
......@@ -337,57 +386,55 @@ function startAnnotation(): void {
337386
338387 const toolbar = document.createElement("div");
339388 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);
341392 root.appendChild(toolbar);
342393
343394 const editor = document.createElement("div");
344395 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);
346399 root.appendChild(editor);
347400
348 const editorHeader = document.createElement("div");
349 editorHeader.style.cssText =
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";
351 const adjustIcon = document.createElement("div");
352 adjustIcon.innerHTML =
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>';
354 adjustIcon.style.cssText =
355 "display:grid;place-items:center;width:24px;height:24px;border-radius:999px;background:#f4f4f5;color:#52525b;font:700 14px/1 ui-sans-serif";
356 editorHeader.appendChild(adjustIcon);
357
358 const status = document.createElement("div");
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);
401 const composerRow = document.createElement("div");
402 composerRow.className = "flex items-start gap-2 p-2";
403
404 const adjust = createButton("", "Expand annotation editor");
405 adjust.setAttribute("aria-label", "Expand annotation editor");
406 adjust.setAttribute("aria-expanded", "false");
407 adjust.className +=
408 " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground";
409 adjust.innerHTML =
410 '<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>';
411 composerRow.appendChild(adjust);
368412
369413 const comment = document.createElement("textarea");
370414 comment.placeholder = "Describe the change…";
371 comment.rows = 2;
372 comment.style.cssText =
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";
374 editor.appendChild(comment);
415 comment.rows = 1;
416 comment.className =
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";
418 composerRow.appendChild(comment);
375419
376 const stylePanel = document.createElement("div");
377 stylePanel.style.cssText =
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)";
379 editor.appendChild(stylePanel);
420 const dragHandle = document.createElement("button");
421 dragHandle.type = "button";
422 dragHandle.textContent = "â ¿";
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";
387428 const submit = createButton("Attach", "Attach annotation and screenshot");
388 submit.style.cssText += `;background:${ACCENT};color:white;padding:6px 9px;font-size:11px`;
389 footer.append(adjust, submit);
390 editor.appendChild(footer);
429 submit.className +=
430 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";
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
392439 const selected = new Map<Element, SelectedElement>();
393440 const regions: PreviewAnnotationRegionTarget[] = [];
......@@ -398,28 +445,30 @@ function startAnnotation(): void {
398445 let dragStart: PreviewAnnotationPoint | null = null;
399446 let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null;
400447 let pendingCapture = false;
401 let stylesExpanded = false;
448 let editorExpanded = false;
402449 let editorWasShown = false;
403450 let editorPosition: { left: number; top: number } | null = null;
404451 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
406464 const updateStatus = (): void => {
407 const parts = [
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(" · ");
465 const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0;
414466 editor.style.display = hasTargets ? "flex" : "none";
415467 submit.disabled = !hasTargets;
416468 submit.style.opacity = hasTargets ? "1" : "0.45";
417 adjust.style.display = selected.size > 0 ? "block" : "none";
418 if (selected.size === 0 && stylesExpanded) {
419 stylesExpanded = false;
420 stylePanel.style.display = "none";
421 adjust.textContent = "Adjust";
422 }
469 adjust.disabled = !hasTargets;
470 stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none";
471 queueEditorLayout();
423472 if (hasTargets && !editorWasShown) {
424473 editorWasShown = true;
425474 window.setTimeout(() => comment.focus({ preventScroll: true }), 0);
......@@ -429,8 +478,9 @@ function startAnnotation(): void {
429478 const refreshToolButtons = (): void => {
430479 for (const [candidate, button] of toolButtons) {
431480 const active = candidate === tool;
432 button.style.background = active ? ACCENT_FILL : "transparent";
433 button.style.color = active ? ACCENT : "#27272a";
481 button.classList.toggle("bg-primary/10", active);
482 button.classList.toggle("text-primary", active);
483 button.classList.toggle("text-foreground", !active);
434484 }
435485 if (tool !== "select") hoverOutline.style.display = "none";
436486 if (tool !== "marquee") marqueeBox.style.display = "none";
......@@ -458,7 +508,7 @@ function startAnnotation(): void {
458508 const target: SelectedElement = {
459509 id: nextId("element"),
460510 element,
461 outline: createBox(ACCENT, ACCENT_FILL),
511 outline: createBox(PRIMARY, PRIMARY_FILL),
462512 label: createLabel(),
463513 baselineStyles: new Map(),
464514 };
......@@ -466,7 +516,10 @@ function startAnnotation(): void {
466516 root.append(target.outline, target.label);
467517 updateSelectedVisual(target);
468518 updateStatus();
469 if (stylesExpanded) syncStyleControls();
519 if (editorExpanded) {
520 stylePanel.style.display = "grid";
521 syncStyleControls();
522 }
470523 };
471524
472525 const toggleSelected = (element: Element, additive: boolean): void => {
......@@ -552,13 +605,13 @@ function startAnnotation(): void {
552605 section: HTMLElement,
553606 ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => {
554607 const row = document.createElement("label");
555 row.style.cssText =
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";
608 row.className =
609 "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground";
557610 const label = document.createElement("span");
558611 label.textContent = labelText;
559612 const control = document.createElement("div");
560 control.style.cssText =
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)";
613 control.className =
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";
562615 const color = document.createElement("input");
563616 color.type = "color";
564617 color.setAttribute("aria-label", labelText);
......@@ -567,8 +620,8 @@ function startAnnotation(): void {
567620 const text = document.createElement("input");
568621 text.type = "text";
569622 text.setAttribute("aria-label", `${labelText} value`);
570 text.style.cssText =
571 "min-width:0;width:100%;border:0;background:transparent;color:#52525b;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none";
623 text.className =
624 "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none";
572625 color.addEventListener("input", () => {
573626 text.value = color.value;
574627 setStyleForSelected(property, color.value);
......@@ -594,7 +647,7 @@ function startAnnotation(): void {
594647 opacity.max = "1";
595648 opacity.step = "0.05";
596649 opacity.value = "1";
597 opacity.style.accentColor = ACCENT;
650 opacity.style.accentColor = PRIMARY;
598651 opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value));
599652 colorsSection.appendChild(createField("Opacity", opacity));
600653
......@@ -623,8 +676,7 @@ function startAnnotation(): void {
623676 dimensions.style.cssText =
624677 "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center";
625678 const dimensionLabel = document.createElement("div");
626 dimensionLabel.style.cssText =
627 "display:grid;gap:9px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b";
679 dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground";
628680 dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>";
629681 const dimensionControls = document.createElement("div");
630682 dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px";
......@@ -635,7 +687,8 @@ function startAnnotation(): void {
635687 const aspectLock = createButton("", "Lock aspect ratio");
636688 aspectLock.setAttribute("aria-pressed", "true");
637689 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";
639692 dimensionControls.append(
640693 createUnitControl(widthInput),
641694 createUnitControl(heightInput),
......@@ -651,8 +704,10 @@ function startAnnotation(): void {
651704 ? '<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>'
652705 : '<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>';
653706 aspectLock.setAttribute("aria-pressed", String(aspectLocked));
654 aspectLock.style.background = aspectLocked ? "#ede9fe" : "#f4f4f5";
655 aspectLock.style.color = aspectLocked ? "#6d28d9" : "#71717a";
707 aspectLock.classList.toggle("bg-primary/10", aspectLocked);
708 aspectLock.classList.toggle("text-primary", aspectLocked);
709 aspectLock.classList.toggle("bg-muted", !aspectLocked);
710 aspectLock.classList.toggle("text-muted-foreground", !aspectLocked);
656711 };
657712 aspectLock.addEventListener("click", () => {
658713 aspectLocked = !aspectLocked;
......@@ -733,7 +788,7 @@ function startAnnotation(): void {
733788 ];
734789 for (const [candidate, label, title] of tools) {
735790 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";
737792 button.addEventListener("click", () => {
738793 tool = candidate;
739794 refreshToolButtons();
......@@ -742,14 +797,6 @@ function startAnnotation(): void {
742797 toolbar.appendChild(button);
743798 }
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
753800 const clampEditorPosition = (left: number, top: number): { left: number; top: number } => {
754801 const margin = 8;
755802 const rect = editor.getBoundingClientRect();
......@@ -766,23 +813,98 @@ function startAnnotation(): void {
766813 };
767814
768815 const applyEditorPosition = (position: { left: number; top: number }): void => {
769 editorPosition = clampEditorPosition(position.left, position.top);
770 editor.style.left = `${editorPosition.left}px`;
771 editor.style.top = `${editorPosition.top}px`;
816 const clamped = clampEditorPosition(position.left, position.top);
817 editor.style.left = `${clamped.left}px`;
818 editor.style.top = `${clamped.top}px`;
772819 editor.style.right = "auto";
773820 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);
774862 };
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
776898 const onEditorPointerDown = (event: PointerEvent): void => {
777 if (event.button !== 0) return;
899 if (event.button !== 0 || !editorExpanded) return;
778900 const rect = editor.getBoundingClientRect();
779901 editorDrag = {
780902 pointerId: event.pointerId,
781903 offsetX: event.clientX - rect.left,
782904 offsetY: event.clientY - rect.top,
783905 };
784 editorHeader.setPointerCapture(event.pointerId);
785 editorHeader.style.cursor = "grabbing";
906 dragHandle.setPointerCapture(event.pointerId);
907 dragHandle.style.cursor = "grabbing";
786908 event.preventDefault();
787909 event.stopPropagation();
788910 };
......@@ -800,20 +922,20 @@ function startAnnotation(): void {
800922 const onEditorPointerUp = (event: PointerEvent): void => {
801923 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;
802924 editorDrag = null;
803 editorHeader.style.cursor = "grab";
804 if (editorHeader.hasPointerCapture(event.pointerId))
805 editorHeader.releasePointerCapture(event.pointerId);
925 dragHandle.style.cursor = "grab";
926 if (dragHandle.hasPointerCapture(event.pointerId))
927 dragHandle.releasePointerCapture(event.pointerId);
806928 event.preventDefault();
807929 event.stopPropagation();
808930 };
809 editorHeader.addEventListener("pointerdown", onEditorPointerDown);
810 editorHeader.addEventListener("pointermove", onEditorPointerMove);
811 editorHeader.addEventListener("pointerup", onEditorPointerUp);
812 editorHeader.addEventListener("pointercancel", onEditorPointerUp);
931 dragHandle.addEventListener("pointerdown", onEditorPointerDown);
932 dragHandle.addEventListener("pointermove", onEditorPointerMove);
933 dragHandle.addEventListener("pointerup", onEditorPointerUp);
934 dragHandle.addEventListener("pointercancel", onEditorPointerUp);
813935
814936 const repaint = (): void => {
815937 for (const target of selected.values()) updateSelectedVisual(target);
816 if (editorPosition) applyEditorPosition(editorPosition);
938 queueEditorLayout();
817939 };
818940
819941 const removeTargetAtPoint = (x: number, y: number): boolean => {
......@@ -941,7 +1063,7 @@ function startAnnotation(): void {
9411063 if (tool === "draw") {
9421064 const stroke: PreviewAnnotationStrokeTarget = {
9431065 id: nextId("stroke"),
944 color: ACCENT,
1066 color: annotationTheme?.primary ?? "#2563eb",
9451067 width: 4,
9461068 points: [dragStart],
9471069 bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 },
......@@ -971,7 +1093,10 @@ function startAnnotation(): void {
9711093 if (found === 0) {
9721094 const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect };
9731095 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 );
9751100 regionBox.setAttribute("data-region-id", region.id);
9761101 positionBox(regionBox, rect);
9771102 root.appendChild(regionBox);
......@@ -1024,14 +1149,16 @@ function startAnnotation(): void {
10241149 window.removeEventListener("keydown", onKeyDown, true);
10251150 window.removeEventListener("scroll", repaint, true);
10261151 window.removeEventListener("resize", repaint);
1027 editorHeader.removeEventListener("pointerdown", onEditorPointerDown);
1028 editorHeader.removeEventListener("pointermove", onEditorPointerMove);
1029 editorHeader.removeEventListener("pointerup", onEditorPointerUp);
1030 editorHeader.removeEventListener("pointercancel", onEditorPointerUp);
1152 dragHandle.removeEventListener("pointerdown", onEditorPointerDown);
1153 dragHandle.removeEventListener("pointermove", onEditorPointerMove);
1154 dragHandle.removeEventListener("pointerup", onEditorPointerUp);
1155 dragHandle.removeEventListener("pointercancel", onEditorPointerUp);
1156 if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame);
10311157 ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel);
10321158 ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
10331159 document.documentElement.removeAttribute("data-t3code-annotation-tool");
1034 root.remove();
1160 cursorStyle.remove();
1161 host.remove();
10351162 activeSession = null;
10361163 if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null);
10371164 };
......@@ -1115,11 +1242,21 @@ function startAnnotation(): void {
11151242 window.addEventListener("resize", repaint, { passive: true });
11161243 ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel);
11171244 ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1118 document.documentElement.appendChild(root);
1245 document.documentElement.appendChild(host);
11191246 refreshToolButtons();
11201247 updateStatus();
1121 activeSession = { teardown };
1248 activeSession = {
1249 teardown,
1250 applyTheme: (theme) => applyAnnotationTheme(host, theme),
1251 };
11221252}
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});
11251262ipcRenderer.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";
33const fromId = vi.fn(() => null);
44const mkdir = vi.fn(async () => undefined);
55const writeFile = vi.fn(async () => undefined);
6const showItemInFolder = vi.fn();
7const writeImage = vi.fn();
8const createFromPath = vi.fn(() => ({ isEmpty: () => false }));
9const webviewSend = vi.fn();
610
711vi.mock("node:fs/promises", () => ({ mkdir, writeFile }));
812
913vi.mock("electron", () => ({
10 app: {
11 getPath: vi.fn(() => "/tmp/t3-code-test"),
14 clipboard: {
15 writeImage,
16 },
17 nativeImage: {
18 createFromPath,
1219 },
1320 session: {
1421 fromPartition: vi.fn(),
1522 },
23 shell: {
24 showItemInFolder,
25 },
1626 webContents: {
1727 fromId,
1828 },
......@@ -23,6 +33,10 @@ describe("PreviewViewManager automation status", () => {
2333 fromId.mockClear();
2434 mkdir.mockClear();
2535 writeFile.mockClear();
36 showItemInFolder.mockClear();
37 writeImage.mockClear();
38 createFromPath.mockClear();
39 webviewSend.mockClear();
2640 });
2741
2842 it("reports an unregistered webview as temporarily unavailable", async () => {
......@@ -59,7 +73,7 @@ describe("PreviewViewManager automation status", () => {
5973 id: 42,
6074 isDestroyed: () => false,
6175 getType: () => "webview",
62 getURL: () => "https://example.com/",
76 getURL: () => "https://example.com:8443/path?query=value",
6377 getTitle: () => "Example",
6478 isLoading: () => false,
6579 getZoomFactor: () => 1,
......@@ -69,6 +83,7 @@ describe("PreviewViewManager automation status", () => {
6983 }),
7084 off: vi.fn(),
7185 ipc: { on: vi.fn(), off: vi.fn() },
86 send: webviewSend,
7287 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
7388 setWindowOpenHandler: vi.fn(),
7489 debugger: {
......@@ -82,13 +97,22 @@ describe("PreviewViewManager automation status", () => {
8297 } as never);
8398 const { PreviewViewManager } = await import("./preview-view-manager.ts");
8499 const manager = new PreviewViewManager();
100 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
85101 manager.createTab("tab_1");
86102 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
88112 const artifact = await manager.captureScreenshot("tab_1");
89113
90114 expect(capturePage).toHaveBeenCalledOnce();
91 expect(mkdir).toHaveBeenCalledWith("/tmp/t3-code-test/browser-artifacts", {
115 expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", {
92116 recursive: true,
93117 });
94118 expect(writeFile).toHaveBeenCalledWith(artifact.path, png);
......@@ -97,6 +121,163 @@ describe("PreviewViewManager automation status", () => {
97121 mimeType: "image/png",
98122 sizeBytes: png.byteLength,
99123 });
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 });
101282 });
102283});
apps/desktop/src/preview-view-manager.ts+224-8
......@@ -8,6 +8,8 @@
88 * here). Single layer-scoped browser session partition.
99 */
1010import type {
11 DesktopPreviewAnnotationTheme,
12 DesktopPreviewPointerEvent,
1113 PreviewAnnotationPayload,
1214 PreviewAnnotationRect,
1315 DesktopPreviewRecordingArtifact,
......@@ -26,9 +28,17 @@ import type {
2628 PreviewAutomationWaitForInput,
2729} from "@t3tools/contracts";
2830import { 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";
3040import { mkdir, writeFile } from "node:fs/promises";
31import { join } from "node:path";
41import { isAbsolute, join, relative, resolve, sep } from "node:path";
3242import { createHash } from "node:crypto";
3343import { setTimeout as sleep } from "node:timers/promises";
3444
......@@ -40,6 +50,7 @@ const START_PICK_CHANNEL = "preview:start-pick";
4050const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
4151const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
4252const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
53const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
4354const HUMAN_INPUT_CHANNEL = "preview:human-input";
4455
4556// Re-export the guest webview security posture from its dedicated module so
......@@ -83,6 +94,43 @@ const MAX_VISIBLE_TEXT_LENGTH = 20_000;
8394const MAX_INTERACTIVE_ELEMENTS = 200;
8495const MAX_SCREENSHOT_WIDTH = 1280;
8596const 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
87135interface CdpEvaluationResult {
88136 readonly result?: {
......@@ -182,10 +230,14 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
182230type Listener = (tabId: string, state: PreviewTabState) => void;
183231type 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
185237interface ManagedListeners {
186238 navigate: () => void;
187239 failed: (event: Event, code: number, description: string) => void;
188 humanInput: () => void;
240 humanInput: (_event: unknown, signal?: unknown) => void;
189241}
190242
191243interface PickSession {
......@@ -205,6 +257,13 @@ interface BrowserDiagnostics {
205257 readonly requests: Map<string, { url: string; method: string }>;
206258}
207259
260type PointerEventListener = (event: DesktopPreviewPointerEvent) => void;
261
262interface ExpectedAgentInput {
263 readonly signal: PreviewInputSignal;
264 readonly expiresAt: number;
265}
266
208267const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
209268 key: string;
210269 meta: boolean;
......@@ -221,23 +280,116 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
221280 { key: "w", meta: true, shift: false, control: false },
222281]);
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
224321export class PreviewViewManager {
322 private annotationTheme = DEFAULT_ANNOTATION_THEME;
323 private artifactDirectory: string | null = null;
225324 private mainWindow: BrowserWindow | null = null;
226325 private readonly tabs = new Map<string, PreviewTabState>();
227326 private readonly attached = new Map<number, ManagedListeners>();
228327 private readonly browserSessions = new Map<string, Session>();
229328 private readonly listeners = new Set<Listener>();
329 private readonly pointerEventListeners = new Set<PointerEventListener>();
230330 private readonly recordingFrameListeners = new Set<RecordingFrameListener>();
231331 /** In-flight preview annotation sessions, keyed by tabId. */
232332 private readonly pickSessions = new Map<string, PickSession>();
233333 /** One long-lived CDP attachment and serialized command queue per guest. */
234334 private readonly controlSessions = new Map<number, BrowserControlSession>();
235335 private readonly diagnostics = new Map<number, BrowserDiagnostics>();
336 private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>();
236337 private readonly controlEpoch = new Map<string, number>();
237338 private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>();
238339 private actionSequence = 0;
340 private pointerSequence = 0;
239341 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
241393 setMainWindow(window: BrowserWindow): void {
242394 this.mainWindow = window;
243395 }
......@@ -338,6 +490,7 @@ export class PreviewViewManager {
338490 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
339491 }
340492 if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) {
493 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);
341494 return;
342495 }
343496 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {
......@@ -366,6 +519,7 @@ export class PreviewViewManager {
366519 canGoForward: wc.navigationHistory.canGoForward(),
367520 zoomFactor: tab.zoomFactor,
368521 });
522 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);
369523 }
370524
371525 async navigate(tabId: string, rawUrl: string): Promise<void> {
......@@ -513,7 +667,7 @@ export class PreviewViewManager {
513667 // wc may be torn down; the next try/catch settles.
514668 }
515669 try {
516 wc.send(START_PICK_CHANNEL);
670 wc.send(START_PICK_CHANNEL, this.annotationTheme);
517671 } catch {
518672 settle(null);
519673 }
......@@ -561,8 +715,8 @@ export class PreviewViewManager {
561715 async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> {
562716 const wc = this.requireWebContents(tabId);
563717 const createdAt = new Date().toISOString();
564 const id = `browser-screenshot-${Date.now().toString(36)}`;
565 const directory = join(app.getPath("userData"), "browser-artifacts");
718 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`;
719 const directory = this.requireArtifactDirectory();
566720 const path = join(directory, `${id}.png`);
567721 const data = (await wc.capturePage()).toPNG();
568722 await mkdir(directory, { recursive: true });
......@@ -587,7 +741,7 @@ export class PreviewViewManager {
587741 const createdAt = new Date().toISOString();
588742 const id = `browser-recording-${Date.now().toString(36)}`;
589743 const extension = mimeType.includes("mp4") ? "mp4" : "webm";
590 const directory = join(app.getPath("userData"), "browser-artifacts");
744 const directory = this.requireArtifactDirectory();
591745 const path = join(directory, `${id}.${extension}`);
592746 await mkdir(directory, { recursive: true });
593747 await writeFile(path, data);
......@@ -793,6 +947,25 @@ export class PreviewViewManager {
793947 `Click coordinates (${x}, ${y}) are outside the preview viewport.`,
794948 );
795949 }
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 });
796969 await send("Input.dispatchMouseEvent", {
797970 type: "mousePressed",
798971 x,
......@@ -886,6 +1059,7 @@ export class PreviewViewManager {
8861059 modifiers,
8871060 ...(text ? { text, unmodifiedText: text } : {}),
8881061 };
1062 this.expectAgentInput(tabId, { kind: "key", key, code: params.code });
8891063 await send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
8901064 await send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
8911065 });
......@@ -1345,11 +1519,50 @@ export class PreviewViewManager {
13451519 };
13461520 }
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
13481559 destroy(): void {
13491560 for (const tabId of Array.from(this.tabs.keys())) {
13501561 this.closeTab(tabId);
13511562 }
13521563 this.listeners.clear();
1564 this.expectedAgentInputs.clear();
1565 this.pointerEventListeners.clear();
13531566 this.recordingFrameListeners.clear();
13541567 }
13551568
......@@ -1375,7 +1588,10 @@ export class PreviewViewManager {
13751588 },
13761589 });
13771590 };
1378 const humanInput = (): void => {
1591 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
1592 if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) {
1593 return;
1594 }
13791595 this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1);
13801596 this.update(tabId, { controller: "human" });
13811597 void sleep(750).then(() => {
apps/desktop/vite.config.ts+4-3
......@@ -14,17 +14,18 @@ export default defineConfig({
1414 run: {
1515 tasks: {
1616 build: {
17 command: "vp pack",
17 command: "node scripts/build-preview-annotation-css.mjs && vp pack",
1818 dependsOn: ["t3#build"],
1919 cache: false,
2020 },
2121 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",
2324 dependsOn: ["t3#build"],
2425 cache: false,
2526 },
2627 "dev:bundle": {
27 command: "vp pack --watch",
28 command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch",
2829 cache: false,
2930 },
3031 "dev:electron": {
apps/server/src/keybindings.test.ts+2
......@@ -205,6 +205,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
205205 assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1");
206206 assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
207207 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");
208210 assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");
209211 assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9");
210212 }),
apps/server/src/mcp/Layers/McpHttpServer.test.ts+20-8
......@@ -75,14 +75,16 @@ it.effect("registers annotated tools and preserves authenticated request context
7575 height: 5,
7676 },
7777 }
78 : {
79 available: true,
80 visible: true,
81 tabId,
82 url: "http://example.test/",
83 title: "Example",
84 loading: false,
85 },
78 : request.operation === "press"
79 ? undefined
80 : {
81 available: true,
82 visible: true,
83 tabId,
84 url: "http://example.test/",
85 title: "Example",
86 loading: false,
87 },
8688 }),
8789 ).pipe(Effect.forkScoped);
8890 yield* Effect.yieldNow;
......@@ -146,6 +148,16 @@ it.effect("registers annotated tools and preserves authenticated request context
146148 expect(snapshot.structuredContent).toMatchObject({
147149 screenshot: { mimeType: "image/png", width: 10, height: 5 },
148150 });
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" }]);
149161 }),
150162 ).pipe(Effect.provide(TestLayer)),
151163);
apps/server/src/mcp/Layers/McpHttpServer.ts+5-2
......@@ -143,11 +143,14 @@ export const PreviewToolkitRegistrationLive = Layer.effectDiscard(
143143 ],
144144 });
145145 }
146 const encodedResultText = JSON.stringify(result.encodedResult) ?? "null";
146147 return new McpSchema.CallToolResult({
147148 isError: false,
148149 structuredContent:
149 typeof result.encodedResult === "object" ? result.encodedResult : undefined,
150 content: [{ type: "text", text: JSON.stringify(result.encodedResult) }],
150 result.encodedResult !== null && typeof result.encodedResult === "object"
151 ? result.encodedResult
152 : undefined,
153 content: [{ type: "text", text: encodedResultText }],
151154 });
152155 },
153156 }),
apps/server/src/preview/Layers/PortScanner.test.ts+66-9
......@@ -1,18 +1,20 @@
11import * as net from "node:net";
22
33import { it as effectIt } from "@effect/vitest";
4import { ThreadId } from "@t3tools/contracts";
45import { Effect, Layer } from "effect";
56import { 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";
89import { 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;
1214const TestProcessRunner = Layer.succeed(ProcessRunner, {
1315 run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),
1416});
15const TestPreviewPortScannerLive = PreviewPortScannerLive.pipe(Layer.provide(TestProcessRunner));
17const TestPortDiscoveryLive = PortDiscoveryLive.pipe(Layer.provide(TestProcessRunner));
1618
1719describe("parsePortFromLsofName", () => {
1820 it("parses *:port", () => {
......@@ -70,6 +72,7 @@ describe("parseLsofOutput", () => {
7072 url: "http://localhost:3000",
7173 processName: "next-server",
7274 pid: 67890,
75 terminal: null,
7376 },
7477 {
7578 host: "localhost",
......@@ -77,6 +80,7 @@ describe("parseLsofOutput", () => {
7780 url: "http://localhost:5173",
7881 processName: "node",
7982 pid: 12345,
83 terminal: null,
8084 },
8185 {
8286 host: "localhost",
......@@ -84,6 +88,7 @@ describe("parseLsofOutput", () => {
8488 url: "http://localhost:9229",
8589 processName: "next-server",
8690 pid: 67890,
91 terminal: null,
8792 },
8893 ]);
8994 });
......@@ -98,6 +103,26 @@ describe("parseLsofOutput", () => {
98103 expect(servers).toHaveLength(1);
99104 expect(servers[0]?.port).toBe(5173);
100105 });
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 });
101126});
102127
103128describe("serversEqual", () => {
......@@ -107,6 +132,7 @@ describe("serversEqual", () => {
107132 url: "http://localhost:5173",
108133 processName: "node",
109134 pid: 1,
135 terminal: null,
110136 };
111137 it("returns true for identical lists", () => {
112138 expect(serversEqual([a], [{ ...a }])).toBe(true);
......@@ -119,12 +145,43 @@ describe("serversEqual", () => {
119145 });
120146});
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
122179/**
123180 * Integration tests against a real TCP listener. We force the Windows code
124181 * path (TCP-probe fallback) by monkey-patching `process.platform` for the
125182 * duration of the test so we don't depend on `lsof` being installed.
126183 */
127describe("PreviewPortScanner integration (TCP probe path)", () => {
184describe("PortDiscovery integration (TCP probe fallback)", () => {
128185 let originalPlatform: NodeJS.Platform;
129186 let server: net.Server;
130187 let port: number;
......@@ -158,18 +215,18 @@ describe("PreviewPortScanner integration (TCP probe path)", () => {
158215
159216 effectIt.effect("scan() returns a server we just opened on a curated dev port", () =>
160217 Effect.gen(function* () {
161 const scanner = yield* PreviewPortScanner;
218 const scanner = yield* PortDiscovery;
162219 const result = yield* scanner.scan();
163220 const found = result.find((server) => server.port === port);
164221 expect(found).toBeDefined();
165222 expect(found?.host).toBe("localhost");
166 }).pipe(Effect.provide(TestPreviewPortScannerLive)),
223 }).pipe(Effect.provide(TestPortDiscoveryLive)),
167224 );
168225
169226 effectIt.effect("retain() drives an immediate broadcast to subscribers", () => {
170227 const received: number[] = [];
171228 return Effect.gen(function* () {
172 const scanner = yield* PreviewPortScanner;
229 const scanner = yield* PortDiscovery;
173230 const unsubscribe = yield* scanner.subscribe((servers) =>
174231 Effect.sync(() => {
175232 for (const server of servers) received.push(server.port);
......@@ -179,6 +236,6 @@ describe("PreviewPortScanner integration (TCP probe path)", () => {
179236 unsubscribe();
180237 release();
181238 expect(received).toContain(port);
182 }).pipe(Effect.provide(TestPreviewPortScannerLive));
239 }).pipe(Effect.provide(TestPortDiscoveryLive));
183240 });
184241});
apps/server/src/preview/Layers/PortScanner.ts+121-34
......@@ -13,32 +13,42 @@
1313 */
1414import * as net from "node:net";
1515
16import type { DiscoveredLocalServer } from "@t3tools/contracts";
16import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";
1717import { 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";
2121import {
2222 COMMON_DEV_PORTS,
23 PreviewPortScanner,
24 type PreviewPortScannerShape,
23 PortDiscovery,
24 type PortDiscoveryShape,
2525} from "../Services/PortScanner.ts";
2626
2727const POLL_INTERVAL = Duration.seconds(3);
2828const TCP_PROBE_TIMEOUT_MS = 200;
2929const LSOF_TIMEOUT_MS = 5_000;
30const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;
3031
3132type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>;
3233
33class LsofProbeError extends Data.TaggedError("LsofProbeError")<{
34 readonly cause: unknown;
35}> {}
36
3734interface ScannerState {
3835 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;
3936}
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> => {
4252 const seen = new Map<string, DiscoveredLocalServer>();
4353 let pid: number | null = null;
4454 let processName: string | null = null;
......@@ -69,6 +79,7 @@ const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => {
6979 url,
7080 processName,
7181 pid,
82 terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null),
7283 });
7384 }
7485 }
......@@ -91,22 +102,31 @@ const parsePortFromLsofName = (name: string): number | null => {
91102 return port;
92103};
93104
94const probeLsof = (
95 processRunner: ProcessRunnerShape,
96): Effect.Effect<ReadonlyArray<DiscoveredLocalServer> | null> =>
97 processRunner
98 .run({
99 command: "lsof",
100 args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"],
101 timeout: Duration.millis(LSOF_TIMEOUT_MS),
102 maxOutputBytes: 1024 * 1024,
103 outputMode: "truncate",
104 })
105 .pipe(
106 Effect.mapError((cause) => new LsofProbeError({ cause })),
107 Effect.map((result) => parseLsofOutput(result.stdout)),
108 Effect.orElseSucceed(() => null),
109 );
105const parseWindowsListenerOutput = (
106 raw: string,
107 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(),
108): ReadonlyArray<DiscoveredLocalServer> => {
109 const seen = new Map<number, DiscoveredLocalServer>();
110 for (const line of raw.split(/\r?\n/g)) {
111 const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4);
112 const host = hostRaw?.trim() ?? "";
113 if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue;
114 const port = Number(portRaw);
115 const pid = Number(pidRaw);
116 if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue;
117 const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null;
118 if (seen.has(port)) continue;
119 seen.set(port, {
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
111131const probeTcpPort = (port: number): Promise<boolean> =>
112132 new Promise((resolve) => {
......@@ -138,6 +158,7 @@ const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>
138158 url: `http://localhost:${r.port}`,
139159 processName: null,
140160 pid: null,
161 terminal: null,
141162 }));
142163 });
143164
......@@ -155,7 +176,9 @@ const serversEqual = (
155176 a.port !== b.port ||
156177 a.url !== b.url ||
157178 a.processName !== b.processName ||
158 a.pid !== b.pid
179 a.pid !== b.pid ||
180 a.terminal?.threadId !== b.terminal?.threadId ||
181 a.terminal?.terminalId !== b.terminal?.terminalId
159182 ) {
160183 return false;
161184 }
......@@ -163,12 +186,19 @@ const serversEqual = (
163186 return true;
164187};
165188
166export const makePreviewPortScanner = Effect.gen(function* () {
189export const makePortDiscovery = Effect.gen(function* () {
167190 const processRunner = yield* ProcessRunner;
168191 const stateRef = yield* Ref.make<ScannerState>({
169192 lastSnapshot: [],
170193 });
171194 const listeners = new Set<Listener>();
195 const terminalProcesses = new Map<
196 string,
197 {
198 readonly owner: TerminalProcessOwner;
199 readonly processIds: ReadonlySet<number>;
200 }
201 >();
172202 // Plain integer because the release callback returned by `retain()` runs
173203 // outside any Effect context (the WS subscriber's release path) and must
174204 // be a synchronous side-effect-only function.
......@@ -176,11 +206,43 @@ export const makePreviewPortScanner = Effect.gen(function* () {
176206
177207 const scanOnce = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> =>
178208 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 }
179215 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;
180231 return yield* probeCommonPorts();
181232 }
182 const lsof = yield* probeLsof(processRunner);
183 if (lsof !== null) return lsof;
233 const lsofResult = yield* processRunner
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;
184246 return yield* probeCommonPorts();
185247 });
186248
......@@ -204,7 +266,7 @@ export const makePreviewPortScanner = Effect.gen(function* () {
204266 // currently retained, so the cost is one Ref.get every POLL_INTERVAL.
205267 yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
206268
207 const retain: PreviewPortScannerShape["retain"] = () =>
269 const retain: PortDiscoveryShape["retain"] = () =>
208270 Effect.gen(function* () {
209271 const wasIdle = retainCount === 0;
210272 retainCount += 1;
......@@ -221,7 +283,7 @@ export const makePreviewPortScanner = Effect.gen(function* () {
221283 };
222284 });
223285
224 const subscribe: PreviewPortScannerShape["subscribe"] = (listener) =>
286 const subscribe: PortDiscoveryShape["subscribe"] = (listener) =>
225287 Effect.sync(() => {
226288 listeners.add(listener);
227289 return () => {
......@@ -229,18 +291,43 @@ export const makePreviewPortScanner = Effect.gen(function* () {
229291 };
230292 });
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
232316 return {
233317 scan: scanOnce,
234318 subscribe,
235319 retain,
236 } satisfies PreviewPortScannerShape;
320 registerTerminalProcesses,
321 unregisterTerminal,
322 } satisfies PortDiscoveryShape;
237323});
238324
239export const PreviewPortScannerLive = Layer.effect(PreviewPortScanner, makePreviewPortScanner);
325export const PortDiscoveryLive = Layer.effect(PortDiscovery, makePortDiscovery);
240326
241327/** Exposed for tests. */
242328export const __testing = {
243329 parseLsofOutput,
244330 parsePortFromLsofName,
331 parseWindowsListenerOutput,
245332 serversEqual,
246333};
apps/server/src/preview/Services/PortScanner.ts+18-8
......@@ -1,17 +1,17 @@
11/**
2 * PreviewPortScanner - Discovers listening localhost ports for the preview
3 * empty-state recommendations.
2 * PortDiscovery - Discovers listening localhost ports and attributes them to
3 * registered terminal process families.
44 *
55 * Reference-counted polling: the scanner only runs when at least one client
66 * has called `retain()`. This keeps idle desktops from running `lsof` every
77 * 3 seconds for nothing.
88 *
9 * @module PreviewPortScanner
9 * @module PortDiscovery
1010 */
1111import type { DiscoveredLocalServer } from "@t3tools/contracts";
1212import { Context, type Effect } from "effect";
1313
14export interface PreviewPortScannerShape {
14export interface PortDiscoveryShape {
1515 /** One-shot snapshot of currently listening localhost ports. */
1616 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;
1717 /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */
......@@ -23,12 +23,22 @@ export interface PreviewPortScannerShape {
2323 * release fn. When release count hits 0, polling stops.
2424 */
2525 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>;
2637}
2738
28export class PreviewPortScanner extends Context.Service<
29 PreviewPortScanner,
30 PreviewPortScannerShape
31>()("t3/preview/Services/PortScanner/PreviewPortScanner") {}
39export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()(
40 "t3/preview/Services/PortScanner/PortDiscovery",
41) {}
3242
3343/** Curated list of common dev-server ports for the Windows TCP-probe fallback. */
3444export 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
100100import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
101101import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";
102102import { PreviewManager } from "./preview/Services/Manager.ts";
103import { PreviewPortScanner } from "./preview/Services/PortScanner.ts";
103import { PortDiscovery } from "./preview/Services/PortScanner.ts";
104104import {
105105 BrowserTraceCollector,
106106 type BrowserTraceCollectorShape,
......@@ -681,10 +681,12 @@ const buildAppUnderTest = (options?: {
681681 PubSub.subscribe(pubsub),
682682 ),
683683 }),
684 Layer.mock(PreviewPortScanner)({
684 Layer.mock(PortDiscovery)({
685685 scan: () => Effect.succeed([]),
686686 subscribe: () => Effect.succeed(() => {}),
687687 retain: () => Effect.succeed(() => {}),
688 registerTerminalProcesses: () => Effect.void,
689 unregisterTerminal: () => Effect.void,
688690 }),
689691 ),
690692 ),
apps/server/src/server.ts+7-5
......@@ -36,7 +36,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts";
3636import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
3737import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
3838import { PreviewManagerLive } from "./preview/Layers/Manager.ts";
39import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts";
39import { PortDiscoveryLive } from "./preview/Layers/PortScanner.ts";
4040import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts";
4141import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts";
4242import * as ProcessRunner from "./processRunner.ts";
......@@ -236,13 +236,15 @@ const CheckpointingLayerLive = Layer.empty.pipe(
236236 Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))),
237237);
238238
239const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive));
239const PortScannerLayerLive = PortDiscoveryLive.pipe(Layer.provide(ProcessRunner.layer));
240240
241const PreviewLayerLive = Layer.mergeAll(
242 PreviewManagerLive,
243 PreviewPortScannerLive.pipe(Layer.provide(ProcessRunner.layer)),
241const TerminalLayerLive = TerminalManagerLive.pipe(
242 Layer.provide(PtyAdapterLive),
243 Layer.provide(PortScannerLayerLive),
244244);
245245
246const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PortScannerLayerLive);
247
246248const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe(
247249 Layer.provide(WorkspacePathsLive),
248250 Layer.provideMerge(VcsDriverRegistryLayerLive),
apps/server/src/terminal/Layers/Manager.test.ts+10-4
......@@ -204,6 +204,7 @@ interface CreateManagerOptions {
204204 subprocessInspector?: (terminalPid: number) => Effect.Effect<{
205205 readonly hasRunningSubprocess: boolean;
206206 readonly childCommand: string | null;
207 readonly processIds: ReadonlyArray<number>;
207208 }>;
208209 subprocessPollIntervalMs?: number;
209210 processKillGraceMs?: number;
......@@ -745,7 +746,8 @@ it.layer(
745746 let inspect: {
746747 readonly hasRunningSubprocess: boolean;
747748 readonly childCommand: string | null;
748 } = { hasRunningSubprocess: false, childCommand: null };
749 readonly processIds: ReadonlyArray<number>;
750 } = { hasRunningSubprocess: false, childCommand: null, processIds: [] };
749751 const { manager, getEvents } = yield* createManager(5, {
750752 subprocessInspector: () => Effect.succeed(inspect),
751753 subprocessPollIntervalMs: 20,
......@@ -754,7 +756,7 @@ it.layer(
754756 yield* manager.open(openInput());
755757 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] };
758760 yield* waitFor(
759761 Effect.map(getEvents, (events) =>
760762 events.some(
......@@ -767,7 +769,7 @@ it.layer(
767769 "1200 millis",
768770 );
769771
770 inspect = { hasRunningSubprocess: false, childCommand: null };
772 inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] };
771773 yield* waitFor(
772774 Effect.map(getEvents, (events) =>
773775 events.some(
......@@ -788,7 +790,11 @@ it.layer(
788790 const { manager } = yield* createManager(5, {
789791 subprocessInspector: () => {
790792 checks += 1;
791 return Effect.succeed({ hasRunningSubprocess: false, childCommand: null });
793 return Effect.succeed({
794 hasRunningSubprocess: false,
795 childCommand: null,
796 processIds: [],
797 });
792798 },
793799 subprocessPollIntervalMs: 20,
794800 });
apps/server/src/terminal/Layers/Manager.ts+97-15
......@@ -33,6 +33,7 @@ import {
3333 terminalSessionsTotal,
3434} from "../../observability/Metrics.ts";
3535import * as ProcessRunner from "../../processRunner.ts";
36import { PortDiscovery } from "../../preview/Services/PortScanner.ts";
3637import {
3738 TerminalCwdError,
3839 TerminalHistoryError,
......@@ -82,6 +83,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess
8283interface TerminalSubprocessInspectResult {
8384 readonly hasRunningSubprocess: boolean;
8485 readonly childCommand: string | null;
86 readonly processIds: ReadonlyArray<number>;
8587}
8688
8789interface TerminalSubprocessInspector {
......@@ -505,12 +507,8 @@ function windowsInspectSubprocess(
505507 TerminalSubprocessCheckError,
506508 ProcessRunner.ProcessRunner
507509> {
508 const command = [
509 `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`,
510 "if ($null -eq $c) { exit 1 }",
511 "Write-Output $c.Name",
512 "exit 0",
513 ].join("; ");
510 const command =
511 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }';
514512 return Effect.gen(function* () {
515513 const processRunner = yield* ProcessRunner.ProcessRunner;
516514 return yield* processRunner.run({
......@@ -524,16 +522,41 @@ function windowsInspectSubprocess(
524522 }).pipe(
525523 Effect.map((result) => {
526524 if (result.code !== 0) {
527 return { hasRunningSubprocess: false, childCommand: null } as const;
525 return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const;
528526 }
529 const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? "";
530 if (name.length === 0) {
531 return { hasRunningSubprocess: true, childCommand: null } as const;
527 const processNameById = new Map<number, string>();
528 const childrenByParent = new Map<number, number[]>();
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);
532538 }
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);
534556 return {
535557 hasRunningSubprocess: true,
536558 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
559 processIds: [...processIds],
537560 } as const;
538561 }),
539562 Effect.mapError(
......@@ -606,14 +629,14 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func
606629 if (pgrepResult.value.code === 0) {
607630 childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout);
608631 } else if (pgrepResult.value.code === 1) {
609 return { hasRunningSubprocess: false, childCommand: null };
632 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
610633 }
611634 }
612635
613636 if (childPid === null) {
614637 const psResult = yield* Effect.exit(runPs);
615638 if (psResult._tag === "Failure" || psResult.value.code !== 0) {
616 return { hasRunningSubprocess: false, childCommand: null };
639 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
617640 }
618641 for (const line of psResult.value.stdout.split(/\r?\n/g)) {
619642 const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);
......@@ -628,7 +651,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func
628651 }
629652
630653 if (childPid === null) {
631 return { hasRunningSubprocess: false, childCommand: null };
654 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
632655 }
633656
634657 const runComm = processRunner.run({
......@@ -663,16 +686,43 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func
663686 }
664687
665688 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 }
666715 return {
667716 hasRunningSubprocess: true,
668717 childCommand: normalized ? truncateTerminalWireLabel(normalized) : null,
718 processIds: [...processIds],
669719 };
670720});
671721
672722function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) {
673723 return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) {
674724 if (!Number.isInteger(terminalPid) || terminalPid <= 0) {
675 return { hasRunningSubprocess: false, childCommand: null };
725 return { hasRunningSubprocess: false, childCommand: null, processIds: [] };
676726 }
677727 if (platform === "win32") {
678728 return yield* windowsInspectSubprocess(terminalPid, platform);
......@@ -932,14 +982,26 @@ interface TerminalManagerOptions {
932982 subprocessPollIntervalMs?: number;
933983 processKillGraceMs?: number;
934984 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>;
935994}
936995
937996const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () {
938997 const { terminalLogsDir } = yield* ServerConfig;
939998 const ptyAdapter = yield* PtyAdapter;
999 const portDiscovery = yield* PortDiscovery;
9401000 return yield* makeTerminalManagerWithOptions({
9411001 logsDir: terminalLogsDir,
9421002 ptyAdapter,
1003 registerTerminalProcesses: portDiscovery.registerTerminalProcesses,
1004 unregisterTerminal: portDiscovery.unregisterTerminal,
9431005 });
9441006});
9451007
......@@ -967,6 +1029,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
9671029 const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS;
9681030 const maxRetainedInactiveSessions =
9691031 options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS;
1032 const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void);
1033 const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void);
9701034
9711035 yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie);
9721036
......@@ -1495,6 +1559,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
14951559 }
14961560
14971561 yield* clearKillFiber(action.process);
1562 yield* unregisterTerminal({
1563 threadId: action.threadId,
1564 terminalId: action.terminalId,
1565 });
14981566 yield* publishEvent({
14991567 type: "exited",
15001568 threadId: action.threadId,
......@@ -1531,6 +1599,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
15311599 });
15321600
15331601 yield* clearKillFiber(process);
1602 yield* unregisterTerminal({
1603 threadId: session.threadId,
1604 terminalId: session.terminalId,
1605 });
15341606 yield* startKillEscalation(process, session.threadId, session.terminalId);
15351607 yield* evictInactiveSessionsIfNeeded();
15361608 });
......@@ -1700,6 +1772,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
17001772 advanceEventSequence(session);
17011773 return [undefined, state] as const;
17021774 });
1775 yield* unregisterTerminal({
1776 threadId: session.threadId,
1777 terminalId: session.terminalId,
1778 });
17031779
17041780 yield* evictInactiveSessionsIfNeeded();
17051781
......@@ -1731,6 +1807,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
17311807
17321808 if (Option.isSome(session)) {
17331809 yield* stopProcess(session.value);
1810 yield* unregisterTerminal({ threadId, terminalId });
17341811 yield* persistHistory(threadId, terminalId, session.value.history);
17351812 }
17361813
......@@ -1791,6 +1868,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
17911868 }
17921869
17931870 const next = inspectResult.value;
1871 yield* registerTerminalProcesses({
1872 threadId: session.threadId,
1873 terminalId: session.terminalId,
1874 processIds: next.processIds,
1875 });
17941876 const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null;
17951877 const event = yield* modifyManagerState((state) => {
17961878 const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr(
apps/server/src/ws.ts+5-5
......@@ -72,7 +72,7 @@ import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
7272import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
7373import { TerminalManager } from "./terminal/Services/Manager.ts";
7474import { PreviewManager } from "./preview/Services/Manager.ts";
75import { PreviewPortScanner } from "./preview/Services/PortScanner.ts";
75import { PortDiscovery } from "./preview/Services/PortScanner.ts";
7676import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts";
7777import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
7878import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
......@@ -257,7 +257,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
257257 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
258258 const terminalManager = yield* TerminalManager;
259259 const previewManager = yield* PreviewManager;
260 const previewPortScanner = yield* PreviewPortScanner;
260 const portDiscovery = yield* PortDiscovery;
261261 const providerRegistry = yield* ProviderRegistry;
262262 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
263263 const config = yield* ServerConfig;
......@@ -1426,14 +1426,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
14261426 Stream.callback<DiscoveredLocalServerList>((queue) =>
14271427 Effect.acquireRelease(
14281428 Effect.gen(function* () {
1429 const release = yield* previewPortScanner.retain();
1430 const initial = yield* previewPortScanner.scan();
1429 const release = yield* portDiscovery.retain();
1430 const initial = yield* portDiscovery.scan();
14311431 const initialScannedAt = DateTime.formatIso(yield* DateTime.now);
14321432 yield* Queue.offer(queue, {
14331433 servers: initial,
14341434 scannedAt: initialScannedAt,
14351435 });
1436 const unsubscribe = yield* previewPortScanner.subscribe((servers) =>
1436 const unsubscribe = yield* portDiscovery.subscribe((servers) =>
14371437 Effect.gen(function* () {
14381438 const scannedAt = DateTime.formatIso(yield* DateTime.now);
14391439 yield* Queue.offer(queue, { servers, scannedAt });
apps/web/src/browser/ElectronBrowserHost.tsx+46-1
......@@ -1,14 +1,18 @@
11"use client";
22
33import { parseScopedThreadKey } from "@t3tools/client-runtime";
4import { useMemo } from "react";
4import { useEffect, useMemo } from "react";
55
66import { isElectron } from "~/env";
7import { useTheme } from "~/hooks/useTheme";
78import { usePreviewStateStore } from "~/previewStateStore";
89
10import { readPreviewAnnotationTheme } from "./annotationTheme";
11import { useBrowserPointerStore } from "./browserPointerStore";
912import { HostedBrowserWebview } from "./HostedBrowserWebview";
1013
1114export function ElectronBrowserHost() {
15 const { resolvedTheme } = useTheme();
1216 const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey);
1317 const sessions = useMemo(
1418 () =>
......@@ -25,6 +29,47 @@ export function ElectronBrowserHost() {
2529 [previewByThreadKey],
2630 );
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
2873 if (!isElectron) return null;
2974 return (
3075 <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";
6565import { getServerConfig } from "../rpc/serverState";
6666import { getRouter } from "../router";
6767import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
68import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
6869import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
6970import { terminalSessionManager } from "../terminalSessionState";
7071import { useTerminalUiStateStore } from "../terminalUiStateStore";
......@@ -1801,6 +1802,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
18011802 useTerminalUiStateStore.setState({
18021803 terminalUiStateByThreadKey: {},
18031804 });
1805 useRightPanelStore.persist.clearStorage();
1806 useRightPanelStore.setState({ byThreadKey: {} });
18041807 });
18051808
18061809 afterEach(() => {
......@@ -2054,12 +2057,15 @@ describe("ChatView timeline estimator parity (full app)", () => {
20542057 });
20552058
20562059 try {
2057 const toggle = await waitForElement(
2058 () =>
2059 document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'),
2060 "Unable to find terminal drawer toggle.",
2060 window.dispatchEvent(
2061 new KeyboardEvent("keydown", {
2062 key: "j",
2063 metaKey: isMacPlatform(navigator.platform),
2064 ctrlKey: !isMacPlatform(navigator.platform),
2065 bubbles: true,
2066 cancelable: true,
2067 }),
20612068 );
2062 toggle.click();
20632069
20642070 await vi.waitFor(
20652071 () => {
......@@ -2072,9 +2078,136 @@ describe("ChatView timeline estimator parity (full app)", () => {
20722078 terminalId: DEFAULT_TERMINAL_ID,
20732079 cwd: "/repo/project",
20742080 });
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 });
20752184 },
20762185 { timeout: 8_000, interval: 16 },
20772186 );
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 });
20782211 } finally {
20792212 await mounted.cleanup();
20802213 }
apps/web/src/components/ChatView.tsx+580-142
......@@ -103,6 +103,7 @@ import {
103103 usePreviewStateStore,
104104} from "../previewStateStore";
105105import { subscribePreviewAction } from "./preview/previewActionBus";
106import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
106107// Lazy: keeps the entire preview component graph (webview host, favicon
107108// helper, Chromium error icon) out of the web bundle until first open.
108109const PreviewPanel = lazy(() =>
......@@ -129,7 +130,7 @@ import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils
129130import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
130131import { useSettings } from "../hooks/useSettings";
131132import { resolveAppModelSelectionForInstance } from "../modelSelection";
132import { isTerminalFocused } from "../lib/terminalFocus";
133import { getTerminalFocusOwner } from "../lib/terminalFocus";
133134import {
134135 deriveLogicalProjectKeyFromSettings,
135136 selectProjectGroupingSettings,
......@@ -539,6 +540,7 @@ interface PersistentThreadTerminalDrawerProps {
539540 launchContext: PersistentTerminalLaunchContext | null;
540541 focusRequestId: number;
541542 splitShortcutLabel: string | undefined;
543 splitVerticalShortcutLabel: string | undefined;
542544 newShortcutLabel: string | undefined;
543545 closeShortcutLabel: string | undefined;
544546 keybindings: ResolvedKeybindingsConfig;
......@@ -553,6 +555,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
553555 launchContext,
554556 focusRequestId,
555557 splitShortcutLabel,
558 splitVerticalShortcutLabel,
556559 newShortcutLabel,
557560 closeShortcutLabel,
558561 keybindings,
......@@ -573,16 +576,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
573576 environmentId: threadRef.environmentId,
574577 threadId,
575578 });
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 );
576596 const terminalLabelsById = useMemo(() => {
577597 const next = new Map<string, string>();
578 for (const session of knownTerminalSessions) {
598 for (const session of drawerTerminalSessions) {
579599 next.set(
580600 session.target.terminalId,
581601 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
582602 );
583603 }
584604 return next;
585 }, [knownTerminalSessions]);
605 }, [drawerTerminalSessions]);
586606 const terminalLaunchLocationsById = useMemo(() => {
587607 const next = new Map<
588608 string,
......@@ -596,7 +616,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
596616 return next;
597617 }
598618
599 for (const session of knownTerminalSessions) {
619 for (const session of drawerTerminalSessions) {
600620 const summary = session.state.summary;
601621 if (!summary) {
602622 continue;
......@@ -614,13 +634,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
614634 }
615635
616636 return next;
617 }, [knownTerminalSessions, launchContext, project]);
637 }, [drawerTerminalSessions, launchContext, project]);
618638 const serverOrderedTerminalIds = useMemo(
619 () => knownTerminalSessions.map((session) => session.target.terminalId),
620 [knownTerminalSessions],
639 () => drawerTerminalSessions.map((session) => session.target.terminalId),
640 [drawerTerminalSessions],
621641 );
622642 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);
623643 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);
644 const storeSplitTerminalVertical = useTerminalUiStateStore(
645 (state) => state.splitTerminalVertical,
646 );
624647 const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal);
625648 const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal);
626649 const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal);
......@@ -712,6 +735,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
712735 threadId,
713736 threadRef,
714737 ]);
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
716766 const createNewTerminal = useCallback(() => {
717767 const api = readEnvironmentApi(threadRef.environmentId);
......@@ -814,8 +864,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
814864 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}
815865 focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
816866 onSplitTerminal={splitTerminal}
867 onSplitTerminalVertical={splitTerminalVertical}
817868 onNewTerminal={createNewTerminal}
818869 splitShortcutLabel={visible ? splitShortcutLabel : undefined}
870 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
819871 newShortcutLabel={visible ? newShortcutLabel : undefined}
820872 closeShortcutLabel={visible ? closeShortcutLabel : undefined}
821873 keybindings={keybindings}
......@@ -830,6 +882,177 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
830882 );
831883});
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
8331056export default function ChatView(props: ChatViewProps) {
8341057 const {
8351058 environmentId,
......@@ -967,6 +1190,7 @@ export default function ChatView(props: ChatViewProps) {
9671190 );
9681191 const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen);
9691192 const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal);
1193 const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical);
9701194 const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal);
9711195 const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal);
9721196 const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal);
......@@ -1029,50 +1253,73 @@ export default function ChatView(props: ChatViewProps) {
10291253 () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])],
10301254 [activeServerOrderedTerminalIds, terminalUiState.terminalIds],
10311255 );
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]);
10321266 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);
10331267 const activeThreadRef = useMemo(
10341268 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
10351269 [activeThread],
10361270 );
10371271 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 );
10381297 useEffect(() => {
10391298 if (!activeThreadRef) {
10401299 return;
10411300 }
1042 if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) {
1301 if (terminalIdListsEqual(drawerServerOrderedTerminalIds, terminalUiState.terminalIds)) {
10431302 return;
10441303 }
10451304 if (
10461305 serverTerminalIdsStrictSubsetOfClient(
1047 activeServerOrderedTerminalIds,
1306 drawerServerOrderedTerminalIds,
10481307 terminalUiState.terminalIds,
10491308 )
10501309 ) {
10511310 return;
10521311 }
1053 reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds);
1312 reconcileTerminalIds(activeThreadRef, drawerServerOrderedTerminalIds);
10541313 }, [
10551314 activeThreadRef,
1056 activeServerOrderedTerminalIds,
1315 drawerServerOrderedTerminalIds,
10571316 reconcileTerminalIds,
10581317 terminalUiState.terminalIds,
10591318 ]);
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 );
10721319 const planSidebarOpen = activeRightPanelKind === "plan";
10731320 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
1074 const terminalPanelOpen = activeRightPanelKind === "terminal";
1075 const rightPanelOpen = activeRightPanelSurface !== null;
1321 const rightPanelOpen = rightPanelState.isOpen;
1322 const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;
10761323
10771324 useEffect(() => {
10781325 if (!activeThreadRef) return;
......@@ -1085,15 +1332,6 @@ export default function ChatView(props: ChatViewProps) {
10851332 if (!activeThreadRef || !diffOpen) return;
10861333 useRightPanelStore.getState().open(activeThreadRef, "diff");
10871334 }, [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]);
10971335 const activeLatestTurn = activeThread?.latestTurn ?? null;
10981336 const threadPlanCatalog = useThreadPlanCatalog(
10991337 useMemo(() => {
......@@ -1115,6 +1353,10 @@ export default function ChatView(props: ChatViewProps) {
11151353 const activeProject = useStore(
11161354 useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]),
11171355 );
1356 const configuredPreviewUrls = useMemo(
1357 () => getConfiguredPreviewUrls(activeProject?.scripts),
1358 [activeProject?.scripts],
1359 );
11181360
11191361 useEffect(() => {
11201362 if (routeKind !== "server") {
......@@ -1906,6 +2148,11 @@ export default function ChatView(props: ChatViewProps) {
19062148 () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions),
19072149 [keybindings, terminalShortcutLabelOptions],
19082150 );
2151 const splitTerminalVerticalShortcutLabel = useMemo(
2152 () =>
2153 shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions),
2154 [keybindings, terminalShortcutLabelOptions],
2155 );
19092156 const newTerminalShortcutLabel = useMemo(
19102157 () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions),
19112158 [keybindings, terminalShortcutLabelOptions],
......@@ -1988,7 +2235,8 @@ export default function ChatView(props: ChatViewProps) {
19882235 if (!isServerThread) {
19892236 return;
19902237 }
1991 if (!diffOpen) {
2238 const diffPanelOpen = activeRightPanelKind === "diff";
2239 if (!diffPanelOpen) {
19922240 onDiffPanelOpen?.();
19932241 if (activeThreadRef) {
19942242 useRightPanelStore.getState().open(activeThreadRef, "diff");
......@@ -2005,12 +2253,12 @@ export default function ChatView(props: ChatViewProps) {
20052253 replace: true,
20062254 search: (previous) => {
20072255 const rest = stripDiffSearchParams(previous);
2008 return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
2256 return diffPanelOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" };
20092257 },
20102258 });
20112259 }, [
2260 activeRightPanelKind,
20122261 activeThreadRef,
2013 diffOpen,
20142262 environmentId,
20152263 isServerThread,
20162264 navigate,
......@@ -2105,61 +2353,184 @@ export default function ChatView(props: ChatViewProps) {
21052353 const setTerminalOpen = useCallback(
21062354 (open: boolean) => {
21072355 if (!activeThreadRef) return;
2356 if (open && terminalUiState.terminalIds.length === 0) {
2357 storeNewTerminal(
2358 activeThreadRef,
2359 nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]),
2360 );
2361 return;
2362 }
21082363 storeSetTerminalOpen(activeThreadRef, open);
21092364 },
2110 [activeThreadRef, storeSetTerminalOpen],
2365 [
2366 activeKnownTerminalIds,
2367 activeThreadRef,
2368 panelTerminalIds,
2369 storeNewTerminal,
2370 storeSetTerminalOpen,
2371 terminalUiState.terminalIds.length,
2372 ],
21112373 );
2112 const toggleTerminalVisibility = useCallback(() => {
2113 if (!activeThreadRef) return;
2114 if (terminalPanelOpen) {
2115 useRightPanelStore.getState().close(activeThreadRef);
2116 return;
2117 }
2118 setTerminalOpen(true);
2119 useRightPanelStore.getState().open(activeThreadRef, "terminal");
2120 }, [activeThreadRef, setTerminalOpen, terminalPanelOpen]);
2121 const splitTerminal = useCallback(() => {
2122 if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) {
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);
2374 const addTerminalSurface = useCallback(() => {
2375 if (!activeThreadRef || !activeThreadId || !activeProject) return;
2376 const api = readEnvironmentApi(activeThreadRef.environmentId);
2377 const cwd = gitCwd ?? activeProject.cwd;
2378 if (!api || !cwd) return;
2379 const panelIds = selectThreadRightPanelState(
2380 useRightPanelStore.getState().byThreadKey,
2381 activeThreadRef,
2382 ).surfaces.flatMap((surface) => (surface.kind === "terminal" ? surface.terminalIds : []));
2383 const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelIds]);
2384 useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId);
21352385 setTerminalFocusRequestId((value) => value + 1);
2136 void (async () => {
2137 try {
2138 await api.terminal.open({
2386 void api.terminal
2387 .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({
21392427 threadId: activeThreadId,
21402428 terminalId,
2141 cwd: cwdForOpen,
2429 cwd,
21422430 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
21432431 env: projectScriptRuntimeEnv({
21442432 project: { cwd: activeProject.cwd },
21452433 worktreePath: activeThreadWorktreePath,
21462434 }),
2147 });
2148 } catch {
2149 // Opening failed; the tab is already in the store — user can retry or close it.
2435 })
2436 .catch(() => undefined);
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;
21502488 }
2151 })();
2152 }, [
2153 activeProject,
2154 activeKnownTerminalIds,
2155 activeThreadId,
2156 activeThreadRef,
2157 activeThreadWorktreePath,
2158 environmentId,
2159 gitCwd,
2160 hasReachedSplitLimit,
2161 storeSplitTerminal,
2162 ]);
2489 const cwdForOpen = gitCwd ?? activeProject.cwd;
2490 if (!cwdForOpen) {
2491 return;
2492 }
2493 const api = readEnvironmentApi(environmentId);
2494 if (!api) {
2495 return;
2496 }
2497 const terminalId = nextTerminalId(activeKnownTerminalIds);
2498 if (direction === "vertical") {
2499 storeSplitTerminalVertical(activeThreadRef, terminalId);
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 );
21632534 const createNewTerminal = useCallback(() => {
21642535 if (!activeThreadRef || !activeThreadId || !activeProject) {
21652536 return;
......@@ -2552,15 +2923,7 @@ export default function ChatView(props: ChatViewProps) {
25522923 const closePreviewPanel = useCallback(() => {
25532924 if (!activeThreadRef) return;
25542925 useRightPanelStore.getState().close(activeThreadRef);
2555 if (diffOpen) {
2556 void navigate({
2557 to: "/$environmentId/$threadId",
2558 params: { environmentId, threadId },
2559 replace: true,
2560 search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }),
2561 });
2562 }
2563 }, [activeThreadRef, diffOpen, environmentId, navigate, threadId]);
2926 }, [activeThreadRef]);
25642927 const activateRightPanelSurface = useCallback(
25652928 (surface: RightPanelSurface) => {
25662929 if (!activeThreadRef) return;
......@@ -2569,7 +2932,7 @@ export default function ChatView(props: ChatViewProps) {
25692932 usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId);
25702933 }
25712934 if (surface.kind === "terminal") {
2572 setTerminalOpen(true);
2935 setTerminalFocusRequestId((value) => value + 1);
25732936 }
25742937 if (surface.kind === "diff" && !diffOpen) {
25752938 onDiffPanelOpen?.();
......@@ -2588,48 +2951,41 @@ export default function ChatView(props: ChatViewProps) {
25882951 });
25892952 }
25902953 },
2591 [
2592 activeThreadRef,
2593 diffOpen,
2594 environmentId,
2595 navigate,
2596 onDiffPanelOpen,
2597 setTerminalOpen,
2598 threadId,
2599 ],
2954 [activeThreadRef, diffOpen, environmentId, navigate, onDiffPanelOpen, threadId],
26002955 );
26012956 const toggleRightPanel = useCallback(() => {
26022957 if (!activeThreadRef) return;
2603 if (rightPanelOpen) {
2604 closePreviewPanel();
2605 return;
2606 }
2607 const surface = rightPanelState.surfaces.at(-1) ?? null;
2608 if (surface) {
2609 activateRightPanelSurface(surface);
2610 return;
2611 }
2612 createBrowserSurface();
2613 }, [
2614 activeThreadRef,
2615 activateRightPanelSurface,
2616 closePreviewPanel,
2617 createBrowserSurface,
2618 rightPanelOpen,
2619 rightPanelState.surfaces,
2620 ]);
2958 useRightPanelStore.getState().toggleVisibility(activeThreadRef);
2959 }, [activeThreadRef]);
26212960 const closeRightPanelSurface = useCallback(
26222961 (surface: RightPanelSurface) => {
26232962 if (!activeThreadRef) return;
2624 useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);
26252963 if (surface.kind === "preview" && surface.resourceId) {
2964 usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId);
26262965 const api = readEnvironmentApi(activeThreadRef.environmentId);
26272966 void api?.preview
26282967 .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId })
26292968 .catch(() => undefined);
26302969 }
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 }
26312978 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 }
26332989 }
26342990 if (surface.kind === "diff" && diffOpen) {
26352991 void navigate({
......@@ -2640,7 +2996,7 @@ export default function ChatView(props: ChatViewProps) {
26402996 });
26412997 }
26422998 },
2643 [activeThreadRef, diffOpen, environmentId, navigate, setTerminalOpen, threadId],
2999 [activeThreadRef, diffOpen, environmentId, navigate, threadId],
26443000 );
26453001 const persistThreadSettingsForNextTurn = useCallback(
26463002 async (input: {
......@@ -2920,8 +3276,9 @@ export default function ChatView(props: ChatViewProps) {
29203276 if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) {
29213277 return;
29223278 }
3279 const terminalFocusOwner = getTerminalFocusOwner();
29233280 const shortcutContext = {
2924 terminalFocus: isTerminalFocused(),
3281 terminalFocus: terminalFocusOwner !== null,
29253282 terminalOpen: Boolean(terminalUiState.terminalOpen),
29263283 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
29273284 };
......@@ -2950,9 +3307,20 @@ export default function ChatView(props: ChatViewProps) {
29503307 return;
29513308 }
29523309
3310 if (command === "rightPanel.toggle") {
3311 event.preventDefault();
3312 event.stopPropagation();
3313 toggleRightPanel();
3314 return;
3315 }
3316
29533317 if (command === "terminal.split") {
29543318 event.preventDefault();
29553319 event.stopPropagation();
3320 if (terminalFocusOwner === "right-panel") {
3321 splitPanelTerminal();
3322 return;
3323 }
29563324 if (!terminalUiState.terminalOpen) {
29573325 setTerminalOpen(true);
29583326 }
......@@ -2960,9 +3328,27 @@ export default function ChatView(props: ChatViewProps) {
29603328 return;
29613329 }
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
29633345 if (command === "terminal.close") {
29643346 event.preventDefault();
29653347 event.stopPropagation();
3348 if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
3349 closePanelTerminal(activeRightPanelSurface.activeTerminalId);
3350 return;
3351 }
29663352 if (!terminalUiState.terminalOpen) return;
29673353 closeTerminal(terminalUiState.activeTerminalId);
29683354 return;
......@@ -2971,6 +3357,10 @@ export default function ChatView(props: ChatViewProps) {
29713357 if (command === "terminal.new") {
29723358 event.preventDefault();
29733359 event.stopPropagation();
3360 if (terminalFocusOwner === "right-panel") {
3361 addTerminalSurface();
3362 return;
3363 }
29743364 if (!terminalUiState.terminalOpen) {
29753365 setTerminalOpen(true);
29763366 }
......@@ -3003,17 +3393,22 @@ export default function ChatView(props: ChatViewProps) {
30033393 window.addEventListener("keydown", handler, true);
30043394 return () => window.removeEventListener("keydown", handler, true);
30053395 }, [
3396 activeRightPanelSurface,
30063397 activeProject,
3398 addTerminalSurface,
30073399 terminalUiState.terminalOpen,
30083400 terminalUiState.activeTerminalId,
30093401 activeThreadId,
3402 closePanelTerminal,
30103403 closeTerminal,
30113404 createNewTerminal,
30123405 setTerminalOpen,
30133406 runProjectScript,
30143407 splitTerminal,
3408 splitPanelTerminal,
30153409 keybindings,
30163410 onToggleDiff,
3411 toggleRightPanel,
30173412 toggleTerminalVisibility,
30183413 composerRef,
30193414 ]);
......@@ -4026,6 +4421,7 @@ export default function ChatView(props: ChatViewProps) {
40264421 ? cn(
40274422 "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]",
40284423 reserveTitleBarControlInset &&
4424 !inlineRightPanelOwnsTitleBar &&
40294425 "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
40304426 )
40314427 : "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) {
40444440 }
40454441 keybindings={keybindings}
40464442 availableEditors={availableEditors}
4047 rightPanelAvailable={isPreviewSupportedInRuntime() || isGitRepo}
4443 rightPanelAvailable={Boolean(activeProject)}
40484444 rightPanelOpen={rightPanelOpen}
40494445 gitCwd={gitCwd}
40504446 onRunProjectScript={runProjectScript}
......@@ -4238,33 +4634,67 @@ export default function ChatView(props: ChatViewProps) {
42384634 {/* end chat column */}
42394635 </div>
42404636 {/* 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}
42414652 </div>
42424653
4243 {!shouldUsePlanSidebarSheet &&
4244 rightPanelOpen &&
4245 activeThreadRef &&
4246 activeRightPanelSurface ? (
4654 {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? (
42474655 <RightPanelTabs
42484656 mode="inline"
42494657 surfaces={rightPanelState.surfaces}
4250 activeSurfaceId={activeRightPanelSurface.id}
4658 activeSurfaceId={activeRightPanelSurface?.id ?? null}
42514659 previewSessions={activePreviewState.sessions}
4660 terminalLabelsById={activeTerminalLabelsById}
42524661 onActivate={activateRightPanelSurface}
42534662 onCloseSurface={closeRightPanelSurface}
42544663 onAddBrowser={createBrowserSurface}
4664 onAddTerminal={addTerminalSurface}
42554665 onAddDiff={addDiffSurface}
4666 browserAvailable={isPreviewSupportedInRuntime()}
42564667 diffAvailable={isServerThread && isGitRepo}
42574668 >
4258 {activeRightPanelSurface.kind === "preview" ? (
4669 {activeRightPanelSurface?.kind === "preview" ? (
42594670 <Suspense fallback={null}>
42604671 <PreviewPanel
42614672 mode="embedded"
42624673 threadRef={activeThreadRef}
42634674 tabId={activeRightPanelSurface.resourceId}
4675 configuredUrls={configuredPreviewUrls}
42644676 visible
42654677 />
42664678 </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" ? (
42684698 <DiffWorkerPoolProvider>
42694699 <Suspense fallback={null}>
42704700 <DiffPanel mode="embedded" />
......@@ -4274,49 +4704,57 @@ export default function ChatView(props: ChatViewProps) {
42744704 </RightPanelTabs>
42754705 ) : null}
42764706
4277 {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef && activeRightPanelSurface ? (
4707 {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? (
42784708 <RightPanelSheet open onClose={closePreviewPanel}>
42794709 <RightPanelTabs
42804710 mode="sheet"
42814711 surfaces={rightPanelState.surfaces}
4282 activeSurfaceId={activeRightPanelSurface.id}
4712 activeSurfaceId={activeRightPanelSurface?.id ?? null}
42834713 previewSessions={activePreviewState.sessions}
4714 terminalLabelsById={activeTerminalLabelsById}
42844715 onActivate={activateRightPanelSurface}
42854716 onCloseSurface={closeRightPanelSurface}
42864717 onAddBrowser={createBrowserSurface}
4718 onAddTerminal={addTerminalSurface}
42874719 onAddDiff={addDiffSurface}
4720 browserAvailable={isPreviewSupportedInRuntime()}
42884721 diffAvailable={isServerThread && isGitRepo}
42894722 >
4290 {activeRightPanelSurface.kind === "preview" ? (
4723 {activeRightPanelSurface?.kind === "preview" ? (
42914724 <Suspense fallback={null}>
42924725 <PreviewPanel
42934726 mode="embedded"
42944727 threadRef={activeThreadRef}
42954728 tabId={activeRightPanelSurface.resourceId}
4729 configuredUrls={configuredPreviewUrls}
42964730 visible
42974731 />
42984732 </Suspense>
4299 ) : activeRightPanelSurface.kind === "terminal" ? (
4300 <PersistentThreadTerminalDrawer
4733 ) : activeRightPanelSurface?.kind === "terminal" ? (
4734 <PersistentThreadTerminalPanel
43014735 threadRef={activeThreadRef}
4302 threadId={activeThreadRef.threadId}
4303 visible
4304 mode="panel"
4736 surface={activeRightPanelSurface}
43054737 launchContext={activeTerminalLaunchContext ?? null}
43064738 focusRequestId={terminalFocusRequestId}
4739 keybindings={keybindings}
4740 onAddTerminalContext={addTerminalContextToDraft}
4741 onSplitTerminal={splitPanelTerminal}
4742 onSplitTerminalVertical={splitPanelTerminalVertical}
4743 onNewTerminal={addTerminalSurface}
4744 onActiveTerminalChange={activatePanelTerminal}
4745 onCloseTerminal={closePanelTerminal}
43074746 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4747 splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
43084748 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
43094749 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4310 keybindings={keybindings}
4311 onAddTerminalContext={addTerminalContextToDraft}
43124750 />
4313 ) : activeRightPanelSurface.kind === "diff" ? (
4751 ) : activeRightPanelSurface?.kind === "diff" ? (
43144752 <DiffWorkerPoolProvider>
43154753 <Suspense fallback={null}>
43164754 <DiffPanel mode="embedded" />
43174755 </Suspense>
43184756 </DiffWorkerPoolProvider>
4319 ) : (
4757 ) : activeRightPanelSurface?.kind === "plan" ? (
43204758 <PlanSidebar
43214759 activePlan={activePlan}
43224760 activeProposedPlan={sidebarProposedPlan}
......@@ -4328,7 +4766,7 @@ export default function ChatView(props: ChatViewProps) {
43284766 mode="embedded"
43294767 onClose={closePlanSidebar}
43304768 />
4331 )}
4769 ) : null}
43324770 </RightPanelTabs>
43334771 </RightPanelSheet>
43344772 ) : null}
apps/web/src/components/RightPanelTabs.tsx+105-6
......@@ -1,7 +1,9 @@
11import type { PreviewSessionSnapshot } from "@t3tools/contracts";
2import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
23import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react";
34import { type ReactNode, useState } from "react";
45
6import { isElectron } from "~/env";
57import type { RightPanelSurface } from "~/rightPanelStore";
68import { cn } from "~/lib/utils";
79import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
......@@ -12,25 +14,97 @@ import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanel
1214interface RightPanelTabsProps {
1315 mode: PreviewPanelMode;
1416 surfaces: readonly RightPanelSurface[];
15 activeSurfaceId: string;
17 activeSurfaceId: string | null;
1618 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;
19 terminalLabelsById: ReadonlyMap<string, string>;
1720 onActivate: (surface: RightPanelSurface) => void;
1821 onCloseSurface: (surface: RightPanelSurface) => void;
1922 onAddBrowser: () => void;
23 onAddTerminal: () => void;
2024 onAddDiff: () => void;
25 browserAvailable: boolean;
2126 diffAvailable: boolean;
2227 children: ReactNode;
2328}
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
2595function surfaceTitle(
2696 surface: RightPanelSurface,
2797 sessions: Readonly<Record<string, PreviewSessionSnapshot>>,
98 terminalLabelsById: ReadonlyMap<string, string>,
2899): string {
29100 switch (surface.kind) {
30101 case "diff":
31102 return "Diff";
32103 case "terminal":
33 return "Terminal";
104 return (
105 terminalLabelsById.get(surface.activeTerminalId) ??
106 getTerminalLabel(surface.activeTerminalId)
107 );
34108 case "plan":
35109 return "Plan";
36110 case "preview": {
......@@ -85,13 +159,22 @@ function SurfaceIcon({
85159}
86160
87161export function RightPanelTabs(props: RightPanelTabsProps) {
162 const ownsDesktopTitleBar = isElectron && props.mode === "inline";
163
88164 return (
89165 <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 >
91174 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
92175 {props.surfaces.map((surface) => {
93176 const active = surface.id === props.activeSurfaceId;
94 const title = surfaceTitle(surface, props.previewSessions);
177 const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById);
95178 return (
96179 <div
97180 key={surface.id}
......@@ -131,10 +214,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
131214 <Plus className="size-4" />
132215 </MenuTrigger>
133216 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">
134 <MenuItem onClick={props.onAddBrowser}>
217 <MenuItem onClick={props.onAddBrowser} disabled={!props.browserAvailable}>
135218 <Globe2 />
136219 Browser
137220 </MenuItem>
221 <MenuItem onClick={props.onAddTerminal}>
222 <TerminalSquare />
223 Terminal
224 </MenuItem>
138225 <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}>
139226 <FileDiff />
140227 Diff
......@@ -142,7 +229,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
142229 </MenuPopup>
143230 </Menu>
144231 </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>
146245 </PreviewPanelShell>
147246 );
148247}
apps/web/src/components/Sidebar.tsx+38
......@@ -4,6 +4,7 @@ import {
44 ChevronRightIcon,
55 CloudIcon,
66 FolderPlusIcon,
7 Globe2Icon,
78 SearchIcon,
89 SettingsIcon,
910 SquarePenIcon,
......@@ -75,6 +76,7 @@ import {
7576} from "../store";
7677import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
7778import { useThreadRunningTerminalIds } from "../terminalSessionState";
79import { useThreadDiscoveredPorts } from "../portDiscoveryState";
7880import { useUiStateStore } from "../uiStateStore";
7981import {
8082 resolveShortcutCommand,
......@@ -198,6 +200,7 @@ import {
198200 type SidebarProjectSnapshot,
199201} from "../sidebarProjectGrouping";
200202import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";
203import { openDiscoveredPort } from "./preview/openDiscoveredPort";
201204const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
202205 updated_at: "Last user message",
203206 created_at: "Created at",
......@@ -349,6 +352,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
349352 environmentId: thread.environmentId,
350353 threadId: thread.id,
351354 });
355 const discoveredPorts = useThreadDiscoveredPorts({
356 environmentId: thread.environmentId,
357 threadId: thread.id,
358 });
352359 const primaryEnvironmentId = usePrimaryEnvironmentId();
353360 const isRemoteThread =
354361 primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId;
......@@ -419,6 +426,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
419426 },
420427 [handleThreadClick, orderedProjectThreadKeys, threadRef],
421428 );
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 );
422440 const handleRowKeyDown = useCallback(
423441 (event: React.KeyboardEvent) => {
424442 if (event.key !== "Enter" && event.key !== " ") return;
......@@ -609,6 +627,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
609627 )}
610628 </div>
611629 <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 )}
612650 {terminalStatus && (
613651 <Tooltip>
614652 <TooltipTrigger
apps/web/src/components/ThreadTerminalDrawer.tsx+148-8
......@@ -1,5 +1,13 @@
11import { 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";
311import {
412 type ResolvedKeybindingsConfig,
513 type ScopedThreadRef,
......@@ -37,6 +45,7 @@ import {
3745 isTerminalCloseShortcut,
3846 isTerminalNewShortcut,
3947 isTerminalSplitShortcut,
48 isTerminalSplitVerticalShortcut,
4049 isTerminalToggleShortcut,
4150 terminalDeleteShortcutData,
4251 terminalNavigationShortcutData,
......@@ -50,6 +59,8 @@ import { readEnvironmentApi } from "~/environmentApi";
5059import { readLocalApi } from "~/localApi";
5160import { attachTerminalSession } from "../terminalSessionState";
5261import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
62import { useDiscoveredPorts } from "../portDiscoveryState";
63import { openDiscoveredPort } from "./preview/openDiscoveredPort";
5364
5465const MIN_DRAWER_HEIGHT = 180;
5566const MAX_DRAWER_HEIGHT_RATIO = 0.75;
......@@ -437,6 +448,7 @@ export function TerminalViewport({
437448 if (
438449 isTerminalToggleShortcut(event, currentKeybindings, options) ||
439450 isTerminalSplitShortcut(event, currentKeybindings, options) ||
451 isTerminalSplitVerticalShortcut(event, currentKeybindings, options) ||
440452 isTerminalNewShortcut(event, currentKeybindings, options) ||
441453 isTerminalCloseShortcut(event, currentKeybindings, options) ||
442454 isDiffToggleShortcut(event, currentKeybindings, options)
......@@ -723,12 +735,44 @@ export function TerminalViewport({
723735 .catch(() => undefined);
724736 }, 30);
725737 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
727767 return () => {
728768 disposed = true;
729769 unsubscribeAttach?.();
730770 unsubscribeAttach = null;
731771 window.clearTimeout(fitTimer);
772 if (resizeFrame !== 0) {
773 window.cancelAnimationFrame(resizeFrame);
774 }
775 resizeObserver?.disconnect();
732776 inputDisposable.dispose();
733777 selectionDisposable.dispose();
734778 terminalLinksDisposable.dispose();
......@@ -806,8 +850,10 @@ interface ThreadTerminalDrawerProps {
806850 activeTerminalGroupId: string;
807851 focusRequestId: number;
808852 onSplitTerminal: () => void;
853 onSplitTerminalVertical: () => void;
809854 onNewTerminal: () => void;
810855 splitShortcutLabel?: string | undefined;
856 splitVerticalShortcutLabel?: string | undefined;
811857 newShortcutLabel?: string | undefined;
812858 closeShortcutLabel?: string | undefined;
813859 onActiveTerminalChange: (terminalId: string) => void;
......@@ -865,8 +911,10 @@ export default function ThreadTerminalDrawer({
865911 activeTerminalGroupId,
866912 focusRequestId,
867913 onSplitTerminal,
914 onSplitTerminalVertical,
868915 onNewTerminal,
869916 splitShortcutLabel,
917 splitVerticalShortcutLabel,
870918 newShortcutLabel,
871919 closeShortcutLabel,
872920 onActiveTerminalChange,
......@@ -957,6 +1005,9 @@ export default function ThreadTerminalDrawer({
9571005 nextGroups.push({
9581006 id: assignUniqueGroupId(baseGroupId),
9591007 terminalIds: nextTerminalIds,
1008 ...(terminalGroup.splitDirection === "vertical"
1009 ? { splitDirection: "vertical" as const }
1010 : {}),
9601011 });
9611012 }
9621013
......@@ -994,6 +1045,8 @@ export default function ThreadTerminalDrawer({
9941045 const visibleTerminalIds =
9951046 resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ??
9961047 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);
1048 const splitDirection =
1049 resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal";
9971050 const hasTerminalSidebar = normalizedTerminalIds.length > 1;
9981051 const isSplitView = visibleTerminalIds.length > 1;
9991052 const showGroupHeaders =
......@@ -1007,6 +1060,17 @@ export default function ThreadTerminalDrawer({
10071060 }
10081061 return next;
10091062 }, [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]);
10101074 const resolveTerminalLaunchLocation = useCallback(
10111075 (terminalId: string): TerminalLaunchLocation => {
10121076 return (
......@@ -1020,10 +1084,15 @@ export default function ThreadTerminalDrawer({
10201084 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],
10211085 );
10221086 const splitTerminalActionLabel = hasReachedSplitLimit
1023 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`
1087 ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)`
10241088 : splitShortcutLabel
1025 ? `Split Terminal (${splitShortcutLabel})`
1026 : "Split Terminal";
1089 ? `Split Terminal Horizontally (${splitShortcutLabel})`
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";
10271096 const newTerminalActionLabel = newShortcutLabel
10281097 ? `New Terminal (${newShortcutLabel})`
10291098 : "New Terminal";
......@@ -1034,6 +1103,10 @@ export default function ThreadTerminalDrawer({
10341103 if (hasReachedSplitLimit) return;
10351104 onSplitTerminal();
10361105 }, [hasReachedSplitLimit, onSplitTerminal]);
1106 const onSplitTerminalVerticalAction = useCallback(() => {
1107 if (hasReachedSplitLimit) return;
1108 onSplitTerminalVertical();
1109 }, [hasReachedSplitLimit, onSplitTerminalVertical]);
10371110 const onNewTerminalAction = useCallback(() => {
10381111 onNewTerminal();
10391112 }, [onNewTerminal]);
......@@ -1143,6 +1216,7 @@ export default function ThreadTerminalDrawer({
11431216 if (normalizedTerminalIds.length === 0) {
11441217 return (
11451218 <aside
1219 data-terminal-owner={isPanel ? "right-panel" : "drawer"}
11461220 className={cn(
11471221 "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background",
11481222 isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80",
......@@ -1176,6 +1250,7 @@ export default function ThreadTerminalDrawer({
11761250
11771251 return (
11781252 <aside
1253 data-terminal-owner={isPanel ? "right-panel" : "drawer"}
11791254 className={cn(
11801255 "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background",
11811256 isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80",
......@@ -1207,6 +1282,18 @@ export default function ThreadTerminalDrawer({
12071282 <SquareSplitHorizontal className="size-3.25" />
12081283 </TerminalActionButton>
12091284 <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" />
12101297 <TerminalActionButton
12111298 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
12121299 onClick={onNewTerminalAction}
......@@ -1232,16 +1319,26 @@ export default function ThreadTerminalDrawer({
12321319 {isSplitView ? (
12331320 <div
12341321 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"
1235 style={{
1236 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1237 }}
1322 style={
1323 splitDirection === "vertical"
1324 ? {
1325 gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1326 }
1327 : {
1328 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1329 }
1330 }
12381331 >
12391332 {visibleTerminalIds.map((terminalId) => {
12401333 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);
12411334 return (
12421335 <div
12431336 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 } ${
12451342 terminalId === resolvedActiveTerminalId
12461343 ? "border-border"
12471344 : "border-border/70"
......@@ -1320,6 +1417,17 @@ export default function ThreadTerminalDrawer({
13201417 >
13211418 <SquareSplitHorizontal className="size-3.25" />
13221419 </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>
13231431 <TerminalActionButton
13241432 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
13251433 onClick={onNewTerminalAction}
......@@ -1366,6 +1474,7 @@ export default function ThreadTerminalDrawer({
13661474 >
13671475 {terminalGroup.terminalIds.map((terminalId) => {
13681476 const isActive = terminalId === resolvedActiveTerminalId;
1477 const discoveredPort = discoveredPortByTerminalId.get(terminalId);
13691478 const closeTerminalLabel = `Close ${
13701479 terminalLabelById.get(terminalId) ?? "terminal"
13711480 }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`;
......@@ -1391,6 +1500,37 @@ export default function ThreadTerminalDrawer({
13911500 {terminalLabelById.get(terminalId) ?? "Terminal"}
13921501 </span>
13931502 </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 )}
13941534 {normalizedTerminalIds.length > 1 && (
13951535 <Popover>
13961536 <PopoverTrigger
apps/web/src/components/chat/ChatHeader.tsx+5-1
......@@ -16,6 +16,7 @@ import { Toggle } from "../ui/toggle";
1616import { SidebarTrigger } from "../ui/sidebar";
1717import { OpenInPicker } from "./OpenInPicker";
1818import { usePrimaryEnvironmentId } from "../../environments/primary";
19import { shortcutLabelForCommand } from "../../keybindings";
1920
2021interface ChatHeaderProps {
2122 activeThreadEnvironmentId: EnvironmentId;
......@@ -76,6 +77,7 @@ export const ChatHeader = memo(function ChatHeader({
7677 activeThreadEnvironmentId,
7778 primaryEnvironmentId,
7879 });
80 const rightPanelShortcutLabel = shortcutLabelForCommand(keybindings, "rightPanel.toggle");
7981
8082 return (
8183 <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({
138140 }
139141 />
140142 <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"}
142146 </TooltipPopup>
143147 </Tooltip>
144148 </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";
2222import { PreviewEmptyState } from "./PreviewEmptyState";
2323import { PreviewMoreMenu } from "./PreviewMoreMenu";
2424import { PreviewUnreachable } from "./PreviewUnreachable";
25import { revealInFileExplorerLabel } from "./fileExplorerLabel";
26import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
2527import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
2628import { useLoadingProgress } from "./useLoadingProgress";
2729import { usePreviewSession } from "./usePreviewSession";
2830import { ZoomIndicator } from "./ZoomIndicator";
31import { AgentBrowserCursor } from "./AgentBrowserCursor";
2932import {
3033 startBrowserRecording,
3134 stopBrowserRecording,
3235 useBrowserRecordingStore,
3336} from "~/browser/browserRecording";
34import { toastManager } from "~/components/ui/toast";
37import { stackedThreadToast, toastManager } from "~/components/ui/toast";
3538
3639interface Props {
3740 threadRef: ScopedThreadRef;
......@@ -47,7 +50,7 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi();
4750 * state when no session exists for the thread.
4851 */
4952export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) {
50 const [focusUrlNonce, setFocusUrlNonce] = useState(0);
53 const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined);
5154 const [pickActive, setPickActive] = useState(false);
5255 const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId);
5356 const pickActiveRef = useRef(false);
......@@ -81,6 +84,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
8184 const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false;
8285 const refreshDisabled = navStatus._tag === "Idle";
8386 const isUnreachable = navStatus._tag === "LoadFailed";
87 const showEmptyState = shouldShowPreviewEmptyState(snapshot);
8488 const controller = desktopOverlay?.controller ?? "none";
8589 const loadProgress = useLoadingProgress(loading);
8690
......@@ -142,16 +146,89 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
142146 const handleCapture = useCallback(
143147 (record: boolean) => {
144148 if (!previewBridge || !tabId) return;
149 const bridge = previewBridge;
145150 const recordingThisTab = activeRecordingTabId === tabId;
146151 if (recordingThisTab) {
147152 void stopBrowserRecording(tabId).then(
148153 (artifact) => {
149154 if (!artifact) return;
150 toastManager.add({
151 type: "success",
152 title: "Recording saved",
153 description: artifact.path,
154 });
155 let pathCopied = false;
156 let toastId: ReturnType<typeof toastManager.add>;
157
158 const copyPath = () => {
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 );
155232 },
156233 (error) => {
157234 toastManager.add({
......@@ -181,13 +258,126 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
181258 });
182259 return;
183260 }
184 void previewBridge.captureScreenshot(tabId).then(
261 void bridge.captureScreenshot(tabId).then(
185262 (artifact) => {
186 toastManager.add({
187 type: "success",
188 title: "Screenshot saved",
189 description: artifact.path,
190 });
263 const revealAction = {
264 children: revealInFileExplorerLabel(navigator.platform),
265 onClick: () => void bridge.revealArtifact(artifact.path),
266 };
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 );
191381 },
192382 (error) => {
193383 toastManager.add({
......@@ -287,7 +477,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
287477 handleRefresh();
288478 return;
289479 case "focus-url":
290 setFocusUrlNonce((value) => value + 1);
480 setFocusUrlNonce((value) => (value ?? 0) + 1);
291481 return;
292482 case "zoom-in":
293483 handleZoomIn();
......@@ -346,24 +536,32 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
346536 />
347537
348538 <div className="relative min-h-0 flex-1 overflow-hidden">
349 {tabId && snapshot ? (
539 {tabId && snapshot && !showEmptyState ? (
350540 <BrowserSurfaceSlot
351541 key={tabId}
352542 tabId={tabId}
353543 visible={visible && !isUnreachable}
354544 className="absolute inset-0 h-full w-full"
355545 />
356 ) : (
546 ) : null}
547 {showEmptyState ? (
357548 <PreviewEmptyState
358549 environmentId={threadRef.environmentId}
359550 configuredUrls={configuredUrls}
360551 recentlySeenUrls={previewState.recentlySeenUrls}
361552 onOpenUrl={(next) => void handleSubmitUrl(next)}
362553 />
363 )}
554 ) : null}
364555 {snapshot && desktopOverlay ? (
365556 <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} />
366557 ) : null}
558 {tabId && desktopOverlay && !showEmptyState && !isUnreachable ? (
559 <AgentBrowserCursor
560 tabId={tabId}
561 zoomFactor={desktopOverlay.zoomFactor}
562 controller={controller}
563 />
564 ) : null}
367565 {controller !== "none" ? (
368566 <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">
369567 {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 @@
1import type { EnvironmentApi, ScopedThreadRef } from "@t3tools/contracts";
1import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts";
22
33import type { PreviewStateStoreState } from "~/previewStateStore";
44
......@@ -10,7 +10,9 @@ interface OpenPreviewSessionInput {
1010 rememberUrl: PreviewStateStoreState["rememberUrl"];
1111}
1212
13export async function openPreviewSession(input: OpenPreviewSessionInput): Promise<void> {
13export async function openPreviewSession(
14 input: OpenPreviewSessionInput,
15): Promise<PreviewSessionSnapshot> {
1416 const snapshot = await input.previewApi.open({
1517 threadId: input.threadRef.threadId,
1618 url: input.url,
......@@ -20,4 +22,5 @@ export async function openPreviewSession(input: OpenPreviewSessionInput): Promis
2022 input.threadRef,
2123 snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url,
2224 );
25 return snapshot;
2326}
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
99 url: "http://localhost:5173",
1010 processName: "vite",
1111 pid: 1234,
12 terminal: null,
1213 ...overrides,
1314});
1415
apps/web/src/components/preview/useDiscoveredLocalServers.ts+8-15
......@@ -1,10 +1,10 @@
11import type { DiscoveredLocalServer } from "@t3tools/contracts";
22import { isLoopbackHost } from "@t3tools/shared/preview";
3import { useEffect, useMemo, useState } from "react";
3import { useMemo } from "react";
44
5import { ensureEnvironmentApi } from "~/environmentApi";
65import type { EnvironmentId } from "@t3tools/contracts";
76import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
7import { useDiscoveredPorts } from "~/portDiscoveryState";
88
99export interface PreviewableServer extends DiscoveredLocalServer {
1010 source: "scanner" | "configured" | "recent";
......@@ -22,23 +22,13 @@ interface UseDiscoveredLocalServersInput {
2222}
2323
2424/**
25 * Subscribe to live localhost port scans, merge in configured /
26 * recently-seen URLs, and return a stable sorted list. Retains the scanner
27 * while mounted.
25 * Merge the environment-level port snapshot with configured / recently-seen
26 * URLs and return a stable sorted list.
2827 */
2928export function useDiscoveredLocalServers(
3029 input: UseDiscoveredLocalServersInput,
3130): ReadonlyArray<PreviewableServer> {
32 const [scannerSnapshot, setScannerSnapshot] = useState<ReadonlyArray<DiscoveredLocalServer>>([]);
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]);
31 const scannerSnapshot = useDiscoveredPorts(input.environmentId);
4232
4333 return useMemo(
4434 () =>
......@@ -72,6 +62,7 @@ export function mergeServers(input: {
7262 url: parsed.url,
7363 processName: null,
7464 pid: null,
65 terminal: null,
7566 source: "configured",
7667 listening: false,
7768 });
......@@ -87,6 +78,7 @@ export function mergeServers(input: {
8778 ...existing,
8879 processName: server.processName ?? existing.processName,
8980 pid: server.pid ?? existing.pid,
81 terminal: server.terminal ?? existing.terminal,
9082 listening: true,
9183 });
9284 continue;
......@@ -105,6 +97,7 @@ export function mergeServers(input: {
10597 url: parsed.url,
10698 processName: null,
10799 pid: null,
100 terminal: null,
108101 source: "recent",
109102 listening: false,
110103 });
apps/web/src/components/preview/usePreviewBridge.ts+19-1
......@@ -8,6 +8,7 @@ import type {
88} from "@t3tools/contracts";
99import { useEffect, useRef } from "react";
1010
11import { useBrowserPointerStore } from "~/browser/browserPointerStore";
1112import { ensureEnvironmentApi } from "~/environmentApi";
1213import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore";
1314
......@@ -20,19 +21,26 @@ import { previewBridge } from "./previewBridge";
2021export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void {
2122 const { threadRef, tabId } = input;
2223 const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState);
24 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);
2325 const bridge = previewBridge;
2426
2527 // One bridge subscription does both jobs (mirror state + forward to
2628 // server) so the desktop bridge keeps a single listener entry per tab.
2729 const lastReportedUrl = useRef<string | null>(null);
2830 const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null);
31 const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null);
2932 useEffect(() => {
3033 if (!bridge || typeof window === "undefined") return;
3134 const api = ensureEnvironmentApi(threadRef.environmentId);
3235 lastReportedUrl.current = null;
3336 lastReportedKind.current = null;
37 lastDesktopNavStatus.current = null;
3438 const unsubscribe = bridge.onStateChange((changedTabId, state) => {
3539 if (changedTabId !== tabId) return;
40 if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) {
41 clearBrowserPointer(tabId);
42 }
43 lastDesktopNavStatus.current = state.navStatus;
3644 applyDesktopState(threadRef, tabId, projectDesktopState(state));
3745 const reported = buildReportInput({
3846 threadId: threadRef.threadId,
......@@ -47,7 +55,17 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str
4755 void api.preview.reportStatus(reported.input).catch(() => undefined);
4856 });
4957 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;
5169}
5270
5371function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay {
apps/web/src/components/ui/toast.tsx+21-1
......@@ -45,6 +45,10 @@ export type ThreadToastData = {
4545 onClose?: (() => void) | undefined;
4646 dismissAfterVisibleMs?: number;
4747 hideCopyButton?: boolean;
48 additionalActions?: ReadonlyArray<{
49 id: string;
50 props: ComponentPropsWithoutRef<"button">;
51 }>;
4852 secondaryActionProps?: ComponentPropsWithoutRef<"button">;
4953 secondaryActionVariant?:
5054 | "default"
......@@ -292,9 +296,13 @@ function deriveToastBodyDescriptor(toast: {
292296 toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton
293297 ? toast.description
294298 : null;
299 const hasAdditionalActions = (toast.data?.additionalActions?.length ?? 0) > 0;
295300 const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined;
296301 const hasTrailingControls =
297 copyErrorText !== null || toast.actionProps !== undefined || hasSecondaryAction;
302 copyErrorText !== null ||
303 toast.actionProps !== undefined ||
304 hasAdditionalActions ||
305 hasSecondaryAction;
298306 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";
299307 return {
300308 Icon,
......@@ -326,6 +334,7 @@ function ToastBodyContent({
326334 toastDescription,
327335 toastType,
328336}: ToastBodyContentProps) {
337 const additionalActions = toastData?.additionalActions ?? [];
329338 const secondaryActionProps = toastData?.secondaryActionProps;
330339 const leadingIcon = toastData?.leadingIcon;
331340 const { className: secondaryActionClassName, ...secondaryActionRest } =
......@@ -371,6 +380,17 @@ function ToastBodyContent({
371380 )}
372381 >
373382 {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 ))}
374394 {secondaryActionProps ? (
375395 <button
376396 {...secondaryActionRest}
apps/web/src/environments/runtime/service.ts+18
......@@ -90,6 +90,7 @@ import {
9090const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope));
9191import { getClientSettings } from "~/hooks/useSettings";
9292import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState";
93import { subscribePortDiscovery, usePortDiscoveryStore } from "../../portDiscoveryState";
9394import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState";
9495import { resolveRemotePairingTarget } from "@t3tools/shared/remote";
9596
......@@ -140,6 +141,7 @@ const lastAppliedProjectionVersionByEnvironment = new Map<
140141 }
141142>();
142143const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>();
144const portDiscoverySubscriptions = new Map<EnvironmentId, () => void>();
143145
144146let activeService: EnvironmentServiceState | null = null;
145147let needsProviderInvalidation = false;
......@@ -1397,6 +1399,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne
13971399 client: connection.client,
13981400 }),
13991401 );
1402 portDiscoverySubscriptions.get(connection.environmentId)?.();
1403 portDiscoverySubscriptions.set(
1404 connection.environmentId,
1405 subscribePortDiscovery({
1406 environmentId: connection.environmentId,
1407 previewApi: connection.client.preview,
1408 }),
1409 );
14001410 attachThreadDetailSubscriptionsForEnvironment(connection.environmentId);
14011411 emitEnvironmentConnectionRegistryChange();
14021412 return connection;
......@@ -1412,6 +1422,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean>
14121422 environmentConnections.delete(environmentId);
14131423 terminalMetadataSubscriptions.get(environmentId)?.();
14141424 terminalMetadataSubscriptions.delete(environmentId);
1425 portDiscoverySubscriptions.get(environmentId)?.();
1426 portDiscoverySubscriptions.delete(environmentId);
1427 usePortDiscoveryStore.getState().clearEnvironment(environmentId);
14151428 terminalSessionManager.invalidateEnvironment(environmentId);
14161429 emitEnvironmentConnectionRegistryChange();
14171430 detachThreadDetailSubscriptionsForEnvironment(environmentId);
......@@ -2048,6 +2061,11 @@ export async function resetEnvironmentServiceForTests(): Promise<void> {
20482061 unsubscribe();
20492062 }
20502063 terminalMetadataSubscriptions.clear();
2064 for (const unsubscribe of portDiscoverySubscriptions.values()) {
2065 unsubscribe();
2066 }
2067 portDiscoverySubscriptions.clear();
2068 usePortDiscoveryStore.getState().reset();
20512069 terminalSessionManager.reset();
20522070 await Promise.all(
20532071 [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)),
apps/web/src/keybindings.test.ts+33-2
......@@ -18,6 +18,7 @@ import {
1818 isTerminalCloseShortcut,
1919 isTerminalNewShortcut,
2020 isTerminalSplitShortcut,
21 isTerminalSplitVerticalShortcut,
2122 isTerminalToggleShortcut,
2223 resolveShortcutCommand,
2324 shouldShowModelPickerJumpHints,
......@@ -85,6 +86,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {
8586
8687const DEFAULT_BINDINGS = compile([
8788 { shortcut: modShortcut("j"), command: "terminal.toggle" },
89 { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" },
8890 {
8991 shortcut: modShortcut("d"),
9092 command: "terminal.split",
......@@ -92,6 +94,11 @@ const DEFAULT_BINDINGS = compile([
9294 },
9395 {
9496 shortcut: modShortcut("d", { shiftKey: true }),
97 command: "terminal.splitVertical",
98 whenAst: whenIdentifier("terminalFocus"),
99 },
100 {
101 shortcut: modShortcut("n"),
95102 command: "terminal.new",
96103 whenAst: whenIdentifier("terminalFocus"),
97104 },
......@@ -174,7 +181,17 @@ describe("split/new/close terminal shortcuts", () => {
174181 }),
175182 );
176183 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, {
178195 platform: "Linux",
179196 context: { terminalFocus: false },
180197 }),
......@@ -195,7 +212,17 @@ describe("split/new/close terminal shortcuts", () => {
195212 }),
196213 );
197214 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, {
199226 platform: "Linux",
200227 context: { terminalFocus: true },
201228 }),
......@@ -287,6 +314,10 @@ describe("shortcutLabelForCommand", () => {
287314 it("returns effective labels for non-terminal commands", () => {
288315 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
289316 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
317 assert.strictEqual(
318 shortcutLabelForCommand(DEFAULT_BINDINGS, "rightPanel.toggle", "MacIntel"),
319 "⌥⌘B",
320 );
290321 assert.strictEqual(
291322 shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"),
292323 "⌘K",
apps/web/src/keybindings.ts+8
......@@ -359,6 +359,14 @@ export function isTerminalSplitShortcut(
359359 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
360360}
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
362370export function isTerminalNewShortcut(
363371 event: ShortcutEventLike,
364372 keybindings: ResolvedKeybindingsConfig,
apps/web/src/lib/terminalFocus.test.ts+15-7
......@@ -1,10 +1,12 @@
11import { afterEach, describe, expect, it } from "vite-plus/test";
22
3import { isTerminalFocused } from "./terminalFocus";
3import { getTerminalFocusOwner, isTerminalFocused } from "./terminalFocus";
44
55class MockHTMLElement {
66 isConnected = false;
77 className = "";
8 terminalOwner: string | null = null;
9 readonly dataset: { terminalOwner?: string } = {};
810
911 readonly classList = {
1012 contains: (value: string) => this.className.split(/\s+/).includes(value),
......@@ -14,7 +16,7 @@ class MockHTMLElement {
1416 if (!this.isConnected) {
1517 return null;
1618 }
17 if (selector === ".thread-terminal-drawer .xterm" || selector === ".thread-terminal-drawer") {
19 if (selector === "[data-terminal-owner]" && this.terminalOwner !== null) {
1820 return this;
1921 }
2022 return null;
......@@ -44,30 +46,36 @@ describe("isTerminalFocused", () => {
4446 detached.className = "xterm-helper-textarea";
4547
4648 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
4951 expect(isTerminalFocused()).toBe(false);
5052 });
5153
52 it("returns true for connected xterm helper textareas", () => {
54 it("returns the drawer owner for connected xterm helper textareas", () => {
5355 const attached = new MockHTMLElement();
5456 attached.className = "xterm-helper-textarea";
5557 attached.isConnected = true;
58 attached.terminalOwner = "drawer";
59 attached.dataset.terminalOwner = "drawer";
5660
5761 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");
6065 expect(isTerminalFocused()).toBe(true);
6166 });
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", () => {
6469 const sidebarButton = new MockHTMLElement();
6570 sidebarButton.className = "terminal-sidebar-button";
6671 sidebarButton.isConnected = true;
72 sidebarButton.terminalOwner = "right-panel";
73 sidebarButton.dataset.terminalOwner = "right-panel";
6774
6875 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");
7179 expect(isTerminalFocused()).toBe(true);
7280 });
7381});
apps/web/src/lib/terminalFocus.ts+12-7
......@@ -1,9 +1,14 @@
1export function isTerminalFocused(): boolean {
1export type TerminalFocusOwner = "drawer" | "right-panel";
2
3export function getTerminalFocusOwner(): TerminalFocusOwner | null {
24 const activeElement = document.activeElement;
3 if (!(activeElement instanceof HTMLElement)) return false;
4 if (!activeElement.isConnected) return false;
5 if (activeElement.classList.contains("xterm-helper-textarea")) return true;
6 if (activeElement.closest(".thread-terminal-drawer .xterm") !== null) return true;
7 // Sidebar / toolbar / resize affordances: still "terminal UI" for split vs diff.toggle (⌘D).
8 return activeElement.closest(".thread-terminal-drawer") !== null;
5 if (!(activeElement instanceof HTMLElement)) return null;
6 if (!activeElement.isConnected) return null;
7 const owner = activeElement.closest<HTMLElement>("[data-terminal-owner]")?.dataset.terminalOwner;
8 if (owner === "drawer" || owner === "right-panel") return owner;
9 return null;
10}
11
12export function isTerminalFocused(): boolean {
13 return getTerminalFocusOwner() !== null;
914}
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)", () => {
154154 expect(state.recentlySeenUrls).toContain("http://localhost:5173/");
155155 });
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
157193 it("closed event for a different tab is a no-op", () => {
158194 const snapshot = makeSnapshot({ tabId: "tab_a" });
159195 const store = usePreviewStateStore.getState();
apps/web/src/previewStateStore.ts+32-22
......@@ -58,6 +58,7 @@ export interface PreviewStateStoreState {
5858 tabId: string,
5959 overlay: DesktopPreviewOverlay | null,
6060 ) => void;
61 removeSession: (ref: ScopedThreadRef, tabId: string) => void;
6162 setActiveTab: (ref: ScopedThreadRef, tabId: string) => void;
6263 rememberUrl: (ref: ScopedThreadRef, url: string) => void;
6364 removeThread: (ref: ScopedThreadRef) => void;
......@@ -93,6 +94,27 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => {
9394 return next.slice(0, PREVIEW_RECENT_URL_LIMIT);
9495};
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
96118export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
97119 byThreadKey: {},
98120 applyServerEvent: (ref, event) =>
......@@ -145,28 +167,9 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
145167 });
146168 break;
147169 case "closed":
148 nextByThread = updateThread(state, threadKey, (current) => {
149 if (!current.sessions[event.tabId]) return current;
150 const { [event.tabId]: _closed, ...sessions } = current.sessions;
151 const { [event.tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;
152 const nextSnapshot =
153 Object.values(sessions)
154 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))
155 .at(-1) ?? null;
156 const activeTabId =
157 current.activeTabId === event.tabId
158 ? (nextSnapshot?.tabId ?? null)
159 : current.activeTabId;
160 const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot;
161 return {
162 ...current,
163 sessions,
164 desktopByTabId,
165 activeTabId: snapshot?.tabId ?? null,
166 snapshot,
167 desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null,
168 };
169 });
170 nextByThread = updateThread(state, threadKey, (current) =>
171 removeSession(current, event.tabId),
172 );
170173 break;
171174 }
172175 return { byThreadKey: nextByThread };
......@@ -216,6 +219,13 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({
216219 });
217220 return { byThreadKey: nextByThread };
218221 }),
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 }),
219229 setActiveTab: (ref, tabId) =>
220230 set((state) => {
221231 const threadKey = scopedThreadKey(ref);
apps/web/src/rightPanelStore.test.ts+160-5
......@@ -3,6 +3,7 @@ import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
33import { beforeEach, describe, expect, it } from "vite-plus/test";
44
55import {
6 migratePersistedRightPanelState,
67 selectActiveRightPanel,
78 selectActiveRightPanelSurface,
89 selectActiveRightPanelKindWithUrl,
......@@ -18,6 +19,60 @@ beforeEach(() => {
1819});
1920
2021describe("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
2176 it("open sets the active panel for a thread", () => {
2277 useRightPanelStore.getState().open(refA, "preview");
2378 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
......@@ -33,10 +88,27 @@ describe("rightPanelStore", () => {
3388 ).toHaveLength(2);
3489 });
3590
36 it("close clears the active panel", () => {
91 it("close hides the panel without clearing its selected surface", () => {
3792 useRightPanelStore.getState().open(refA, "plan");
3893 useRightPanelStore.getState().close(refA);
3994 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({});
40112 });
41113
42114 it("toggle opens then closes the same kind", () => {
......@@ -86,18 +158,101 @@ describe("rightPanelStore", () => {
86158 });
87159 });
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
89233 it("closing the active surface activates a neighboring surface", () => {
90234 useRightPanelStore.getState().openBrowser(refA, "tab-a");
91 useRightPanelStore.getState().open(refA, "terminal");
92 useRightPanelStore.getState().closeSurface(refA, "terminal");
235 useRightPanelStore.getState().openTerminal(refA, "term-1");
236 useRightPanelStore.getState().closeSurface(refA, "terminal:term-1");
93237
94238 expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe(
95239 "browser:tab-a",
96240 );
97241 });
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
99254 it("reconciles browser surfaces without deleting other surface kinds", () => {
100 useRightPanelStore.getState().open(refA, "terminal");
255 useRightPanelStore.getState().openTerminal(refA, "term-1");
101256 useRightPanelStore.getState().openBrowser(refA, "tab-a");
102257 useRightPanelStore.getState().openBrowser(refA, "tab-b");
103258 useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-b", "tab-c"]);
......@@ -106,6 +261,6 @@ describe("rightPanelStore", () => {
106261 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map(
107262 (surface) => surface.id,
108263 ),
109 ).toEqual(["terminal", "browser:tab-b", "browser:tab-c"]);
264 ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]);
110265 });
111266});
apps/web/src/rightPanelStore.ts+214-15
......@@ -3,8 +3,9 @@
33 *
44 * This is intentionally a shallow workspace model: it owns an ordered set of
55 * surface descriptors and the active surface, while each feature continues to
6 * own its durable resource state. Browser surfaces point at preview tab ids;
7 * singleton surfaces bridge the existing terminal, diff, and plan features.
6 * own its durable resource state. Browser surfaces point at preview tab ids,
7 * terminal surfaces point at terminal session ids, and diff/plan remain
8 * singleton surfaces.
89 */
910import { scopedThreadKey } from "@t3tools/client-runtime";
1011import type { ScopedThreadRef } from "@t3tools/contracts";
......@@ -19,35 +20,59 @@ export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
1920export type RightPanelSurface =
2021 | { id: `browser:${string}`; kind: "preview"; resourceId: string }
2122 | { 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 }
2331 | { id: "diff"; kind: "diff" }
2432 | { id: "plan"; kind: "plan" };
2533
2634const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
35const RIGHT_PANEL_STORAGE_VERSION = 5;
2736
2837export interface ThreadRightPanelState {
38 isOpen: boolean;
2939 activeSurfaceId: string | null;
3040 surfaces: RightPanelSurface[];
3141}
3242
3343interface RightPanelStoreState {
3444 byThreadKey: Record<string, ThreadRightPanelState>;
35 open: (ref: ScopedThreadRef, kind: RightPanelKind) => void;
45 open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void;
3646 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;
3756 activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
3857 closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
3958 reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void;
59 show: (ref: ScopedThreadRef) => void;
4060 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;
4263 removeThread: (ref: ScopedThreadRef) => void;
4364}
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 => {
4875 switch (kind) {
49 case "terminal":
50 return { id: "terminal", kind };
5176 case "diff":
5277 return { id: "diff", kind };
5378 case "plan":
......@@ -60,11 +85,20 @@ const browserSurface = (tabId: string | null): RightPanelSurface =>
6085 ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId }
6186 : { 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
6396const upsertSurface = (
6497 current: ThreadRightPanelState,
6598 surface: RightPanelSurface,
6699 activate = true,
67100): ThreadRightPanelState => ({
101 isOpen: true,
68102 surfaces: current.surfaces.some((entry) => entry.id === surface.id)
69103 ? current.surfaces
70104 : [...current.surfaces, surface],
......@@ -78,7 +112,7 @@ const updateThread = (
78112): Record<string, ThreadRightPanelState> => {
79113 const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
80114 const next = updater(current);
81 if (next.activeSurfaceId === null && next.surfaces.length === 0) {
115 if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) {
82116 if (!(threadKey in byThreadKey)) return byThreadKey;
83117 const { [threadKey]: _removed, ...rest } = byThreadKey;
84118 return rest;
......@@ -87,6 +121,74 @@ const updateThread = (
87121 return { ...byThreadKey, [threadKey]: next };
88122};
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
90192export const useRightPanelStore = create<RightPanelStoreState>()(
91193 persist(
92194 (set) => ({
......@@ -111,11 +213,89 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
111213 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);
112214 }),
113215 })),
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 })),
114294 activateSurface: (ref, surfaceId) =>
115295 set((state) => ({
116296 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
117297 current.surfaces.some((surface) => surface.id === surfaceId)
118 ? { ...current, activeSurfaceId: surfaceId }
298 ? { ...current, isOpen: true, activeSurfaceId: surfaceId }
119299 : current,
120300 ),
121301 })),
......@@ -127,7 +307,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
127307 const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);
128308 if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces };
129309 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 };
131311 }),
132312 })),
133313 reconcileBrowserSurfaces: (ref, tabIds) =>
......@@ -151,6 +331,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
151331 );
152332 const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview");
153333 return {
334 ...current,
154335 surfaces,
155336 activeSurfaceId: activeStillExists
156337 ? current.activeSurfaceId
......@@ -158,11 +339,23 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
158339 };
159340 }),
160341 })),
342 show: (ref) =>
343 set((state) => ({
344 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
345 current.isOpen ? current : { ...current, isOpen: true },
346 ),
347 })),
161348 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) =>
162355 set((state) => ({
163356 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({
164357 ...current,
165 activeSurfaceId: null,
358 isOpen: !current.isOpen,
166359 })),
167360 })),
168361 toggle: (ref, kind) =>
......@@ -171,7 +364,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
171364 const active = current.surfaces.find(
172365 (surface) => surface.id === current.activeSurfaceId,
173366 );
174 if (active?.kind === kind) return { ...current, activeSurfaceId: null };
367 if (current.isOpen && active?.kind === kind) {
368 return { ...current, isOpen: false };
369 }
175370 if (kind === "preview") {
176371 const existing = current.surfaces.find((surface) => surface.kind === "preview");
177372 return upsertSurface(current, existing ?? browserSurface(null));
......@@ -189,11 +384,12 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
189384 }),
190385 {
191386 name: RIGHT_PANEL_STORAGE_KEY,
192 version: 2,
387 version: RIGHT_PANEL_STORAGE_VERSION,
193388 storage: createJSONStorage(() =>
194389 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
195390 ),
196391 partialize: (state) => ({ byThreadKey: state.byThreadKey }),
392 migrate: migratePersistedRightPanelState,
197393 },
198394 ),
199395);
......@@ -211,6 +407,7 @@ export function selectActiveRightPanel(
211407 ref: ScopedThreadRef | null | undefined,
212408): RightPanelKind | null {
213409 const state = selectThreadRightPanelState(byThreadKey, ref);
410 if (!state.isOpen) return null;
214411 return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null;
215412}
216413
......@@ -219,6 +416,7 @@ export function selectActiveRightPanelSurface(
219416 ref: ScopedThreadRef | null | undefined,
220417): RightPanelSurface | null {
221418 const state = selectThreadRightPanelState(byThreadKey, ref);
419 if (!state.isOpen) return null;
222420 return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null;
223421}
224422
......@@ -227,6 +425,7 @@ export function selectActiveRightPanelKindWithUrl(
227425 ref: ScopedThreadRef | null | undefined,
228426 diffSearchActive: boolean,
229427): RightPanelKind | null {
428 if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null;
230429 if (diffSearchActive) return "diff";
231430 return selectActiveRightPanel(byThreadKey, ref);
232431}
apps/web/src/rpc/requestLatencyState.test.ts+8
......@@ -1,3 +1,4 @@
1import { WS_METHODS } from "@t3tools/contracts";
12import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
23
34import {
......@@ -50,6 +51,13 @@ describe("requestLatencyState", () => {
5051 expect(getSlowRpcAckRequests()).toEqual([]);
5152 });
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
5361 it("evicts the oldest pending requests once the tracker reaches capacity", () => {
5462 for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) {
5563 trackRpcRequestSent(String(index), "server.getConfig");
apps/web/src/rpc/requestLatencyState.ts+3-1
......@@ -1,4 +1,5 @@
11import { useAtomValue } from "@effect/atom-react";
2import { WS_METHODS } from "@t3tools/contracts";
23import { Atom } from "effect/unstable/reactivity";
34
45import { appAtomRegistry } from "./atomRegistry";
......@@ -21,6 +22,7 @@ interface PendingRpcAckRequest {
2122}
2223
2324const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>();
25const untrackedRpcAckTags = new Set<string>([WS_METHODS.previewAutomationConnect]);
2426
2527const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe(
2628 Atom.keepAlive,
......@@ -36,7 +38,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> {
3638}
3739
3840function shouldTrackRpcAck(tag: string): boolean {
39 return !tag.includes("subscribe");
41 return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag);
4042}
4143
4244export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> {
apps/web/src/terminalUiStateStore.test.ts+18
......@@ -56,6 +56,24 @@ describe("terminalUiStateStore actions", () => {
5656 ]);
5757 });
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
5977 it("materializes the default terminal when opening an empty drawer", () => {
6078 useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true);
6179
apps/web/src/terminalUiStateStore.ts+18-1
......@@ -126,6 +126,7 @@ function normalizeTerminalGroups(
126126 nextGroups.push({
127127 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
128128 terminalIds: groupTerminalIds,
129 ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}),
129130 });
130131 }
131132
......@@ -155,6 +156,11 @@ function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalG
155156 const rightGroup = right[index];
156157 if (!leftGroup || !rightGroup) return false;
157158 if (leftGroup.id !== rightGroup.id) return false;
159 if (
160 (leftGroup.splitDirection ?? "horizontal") !== (rightGroup.splitDirection ?? "horizontal")
161 ) {
162 return false;
163 }
158164 if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false;
159165 }
160166 return true;
......@@ -241,6 +247,7 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[
241247 return groups.map((group) => ({
242248 id: group.id,
243249 terminalIds: [...group.terminalIds],
250 ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}),
244251 }));
245252}
246253
......@@ -248,6 +255,7 @@ function upsertTerminalIntoGroups(
248255 state: ThreadTerminalUiState,
249256 terminalId: string,
250257 mode: "split" | "new",
258 splitDirection: "horizontal" | "vertical" = "horizontal",
251259): ThreadTerminalUiState {
252260 const normalized = normalizeThreadTerminalUiState(state);
253261 const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode;
......@@ -323,6 +331,11 @@ function upsertTerminalIntoGroups(
323331 destinationGroup.terminalIds.push(terminalId);
324332 }
325333 }
334 if (splitDirection === "vertical") {
335 destinationGroup.splitDirection = "vertical";
336 } else {
337 delete destinationGroup.splitDirection;
338 }
326339
327340 return normalizeThreadTerminalUiState({
328341 ...normalized,
......@@ -357,8 +370,9 @@ function setThreadTerminalHeight(
357370function splitThreadTerminal(
358371 state: ThreadTerminalUiState,
359372 terminalId: string,
373 direction: "horizontal" | "vertical" = "horizontal",
360374): ThreadTerminalUiState {
361 return upsertTerminalIntoGroups(state, terminalId, "split");
375 return upsertTerminalIntoGroups(state, terminalId, "split", direction);
362376}
363377
364378function newThreadTerminal(
......@@ -510,6 +524,7 @@ interface TerminalUiStateStoreState {
510524 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
511525 setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void;
512526 splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
527 splitTerminalVertical: (threadRef: ScopedThreadRef, terminalId: string) => void;
513528 newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
514529 ensureTerminal: (
515530 threadRef: ScopedThreadRef,
......@@ -554,6 +569,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
554569 updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)),
555570 splitTerminal: (threadRef, terminalId) =>
556571 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)),
572 splitTerminalVertical: (threadRef, terminalId) =>
573 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical")),
557574 newTerminal: (threadRef, terminalId) =>
558575 updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)),
559576 ensureTerminal: (threadRef, terminalId, options) =>
apps/web/src/types.ts+1
......@@ -30,6 +30,7 @@ export type ProjectScript = ContractProjectScript;
3030export interface ThreadTerminalGroup {
3131 id: string;
3232 terminalIds: string[];
33 splitDirection?: "horizontal" | "vertical";
3334}
3435
3536export interface ChatImageAttachment {
packages/contracts/src/ipc.ts+33
......@@ -455,6 +455,15 @@ export interface DesktopPreviewTabState {
455455 updatedAt: string;
456456}
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
458467/**
459468 * Static config a renderer needs to mount a preview `<webview>`. Returned
460469 * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer
......@@ -478,6 +487,26 @@ export interface DesktopPreviewWebviewConfig {
478487 preloadUrl: string | null;
479488}
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
481510export interface DesktopPreviewRecordingFrame {
482511 tabId: string;
483512 data: string;
......@@ -700,6 +729,7 @@ export interface DesktopPreviewBridge {
700729 * the contract + main, not the renderer's mount logic.
701730 */
702731 getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>;
732 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;
703733 /**
704734 * Activate the in-page element picker for the given tab. Resolves with
705735 * the picked payload, or `null` when the user cancels (Escape / nav). The
......@@ -709,6 +739,8 @@ export interface DesktopPreviewBridge {
709739 /** Cancel an in-flight preview annotation session. */
710740 cancelPickElement: (tabId: string) => Promise<void>;
711741 captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>;
742 revealArtifact: (path: string) => Promise<void>;
743 copyArtifactToClipboard: (path: string) => Promise<void>;
712744 recording: {
713745 startScreencast: (tabId: string) => Promise<void>;
714746 stopScreencast: (tabId: string) => Promise<void>;
......@@ -730,6 +762,7 @@ export interface DesktopPreviewBridge {
730762 waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>;
731763 };
732764 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;
765 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;
733766}
734767
735768/**
packages/contracts/src/keybindings.test.ts+8-1
......@@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () =>
2929 });
3030 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
3238 const parsedClose = yield* decode(KeybindingRule, {
3339 key: "mod+w",
3440 command: "terminal.close",
......@@ -100,8 +106,9 @@ it.effect("parses keybindings array payload", () =>
100106 const parsed = yield* decode(KeybindingsConfig, [
101107 { key: "mod+j", command: "terminal.toggle" },
102108 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
109 { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" },
103110 ]);
104 assert.lengthOf(parsed, 2);
111 assert.lengthOf(parsed, 3);
105112 }),
106113);
107114
packages/contracts/src/keybindings.ts+2
......@@ -50,8 +50,10 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA
5050const STATIC_KEYBINDING_COMMANDS = [
5151 "terminal.toggle",
5252 "terminal.split",
53 "terminal.splitVertical",
5354 "terminal.new",
5455 "terminal.close",
56 "rightPanel.toggle",
5557 "diff.toggle",
5658 "preview.toggle",
5759 "preview.refresh",
packages/contracts/src/preview.test.ts+4
......@@ -123,6 +123,7 @@ describe("DiscoveredLocalServer", () => {
123123 url: "http://localhost:5173",
124124 processName: "node",
125125 pid: 12345,
126 terminal: null,
126127 });
127128 expect(server.port).toBe(5173);
128129 expect(server.processName).toBe("node");
......@@ -135,6 +136,7 @@ describe("DiscoveredLocalServer", () => {
135136 url: "http://localhost:3000",
136137 processName: null,
137138 pid: null,
139 terminal: null,
138140 });
139141 expect(server.processName).toBeNull();
140142 });
......@@ -147,6 +149,7 @@ describe("DiscoveredLocalServer", () => {
147149 url: "http://localhost:0",
148150 processName: null,
149151 pid: null,
152 terminal: null,
150153 }),
151154 ).toThrow();
152155 expect(() =>
......@@ -156,6 +159,7 @@ describe("DiscoveredLocalServer", () => {
156159 url: "http://localhost:70000",
157160 processName: null,
158161 pid: null,
162 terminal: null,
159163 }),
160164 ).toThrow();
161165 });
packages/contracts/src/preview.ts+6
......@@ -142,6 +142,12 @@ export const DiscoveredLocalServer = Schema.Struct({
142142 url: Url,
143143 processName: Schema.NullOr(TrimmedNonEmptyString),
144144 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
145 terminal: Schema.NullOr(
146 Schema.Struct({
147 threadId: ThreadId,
148 terminalId: TrimmedNonEmptyString,
149 }),
150 ),
145151});
146152export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type;
147153
packages/shared/src/keybindings.ts+2
......@@ -20,7 +20,9 @@ type WhenToken =
2020
2121export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
2222 { key: "mod+j", command: "terminal.toggle" },
23 { key: "mod+alt+b", command: "rightPanel.toggle" },
2324 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
25 { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" },
2426 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
2527 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
2628 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
pnpm-lock.yaml+3
......@@ -147,6 +147,9 @@ importers:
147147 electron-builder:
148148 specifier: 26.8.1
149149 version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
150 tailwindcss:
151 specifier: ^4.0.0
152 version: 4.3.0
150153 vite-plus:
151154 specifier: 'catalog:'
152155 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)