From dd739c880d6c912130cac005ea773ae386560460 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:40:00 -0700 Subject: [PATCH] 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 --- apps/desktop/package.json | 1 + .../scripts/build-preview-annotation-css.mjs | 40 + .../src/app/DesktopEnvironment.test.ts | 2 + apps/desktop/src/app/DesktopEnvironment.ts | 2 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 + apps/desktop/src/ipc/channels.ts | 4 + apps/desktop/src/ipc/methods/preview.ts | 64 ++ apps/desktop/src/preload.ts | 16 + .../preview-annotation-styles.generated.ts | 3 + apps/desktop/src/preview-annotation.css | 68 ++ apps/desktop/src/preview-pick-preload.ts | 413 ++++++---- apps/desktop/src/preview-view-manager.test.ts | 191 ++++- apps/desktop/src/preview-view-manager.ts | 232 +++++- apps/desktop/vite.config.ts | 7 +- apps/server/src/keybindings.test.ts | 2 + .../src/mcp/Layers/McpHttpServer.test.ts | 28 +- apps/server/src/mcp/Layers/McpHttpServer.ts | 7 +- .../src/preview/Layers/PortScanner.test.ts | 75 +- apps/server/src/preview/Layers/PortScanner.ts | 155 +++- .../src/preview/Services/PortScanner.ts | 26 +- apps/server/src/server.test.ts | 6 +- apps/server/src/server.ts | 12 +- .../src/terminal/Layers/Manager.test.ts | 14 +- apps/server/src/terminal/Layers/Manager.ts | 112 ++- apps/server/src/ws.ts | 10 +- apps/web/src/browser/ElectronBrowserHost.tsx | 47 +- apps/web/src/browser/annotationTheme.ts | 28 + .../src/browser/browserPointerStore.test.ts | 68 ++ apps/web/src/browser/browserPointerStore.ts | 25 + apps/web/src/components/ChatView.browser.tsx | 143 +++- apps/web/src/components/ChatView.tsx | 746 ++++++++++++++---- apps/web/src/components/RightPanelTabs.tsx | 111 ++- apps/web/src/components/Sidebar.tsx | 38 + .../src/components/ThreadTerminalDrawer.tsx | 156 +++- apps/web/src/components/chat/ChatHeader.tsx | 6 +- .../components/preview/AgentBrowserCursor.tsx | 52 ++ .../preview/PreviewChromeRow.browser.tsx | 41 + .../src/components/preview/PreviewView.tsx | 232 +++++- .../preview/agentBrowserCursorLogic.test.ts | 19 + .../preview/agentBrowserCursorLogic.ts | 6 + .../preview/fileExplorerLabel.test.ts | 13 + .../components/preview/fileExplorerLabel.ts | 6 + .../components/preview/openDiscoveredPort.ts | 24 + .../components/preview/openPreviewSession.ts | 7 +- .../preview/previewEmptyStateLogic.test.ts | 42 + .../preview/previewEmptyStateLogic.ts | 11 + .../preview/useDiscoveredLocalServers.test.ts | 1 + .../preview/useDiscoveredLocalServers.ts | 23 +- .../components/preview/usePreviewBridge.ts | 20 +- apps/web/src/components/ui/toast.tsx | 22 +- apps/web/src/environments/runtime/service.ts | 18 + apps/web/src/keybindings.test.ts | 35 +- apps/web/src/keybindings.ts | 8 + apps/web/src/lib/terminalFocus.test.ts | 22 +- apps/web/src/lib/terminalFocus.ts | 19 +- apps/web/src/portDiscoveryState.ts | 86 ++ apps/web/src/previewStateStore.test.ts | 36 + apps/web/src/previewStateStore.ts | 54 +- apps/web/src/rightPanelStore.test.ts | 165 +++- apps/web/src/rightPanelStore.ts | 229 +++++- apps/web/src/rpc/requestLatencyState.test.ts | 8 + apps/web/src/rpc/requestLatencyState.ts | 4 +- apps/web/src/terminalUiStateStore.test.ts | 18 + apps/web/src/terminalUiStateStore.ts | 19 +- apps/web/src/types.ts | 1 + packages/contracts/src/ipc.ts | 33 + packages/contracts/src/keybindings.test.ts | 9 +- packages/contracts/src/keybindings.ts | 2 + packages/contracts/src/preview.test.ts | 4 + packages/contracts/src/preview.ts | 6 + packages/shared/src/keybindings.ts | 2 + pnpm-lock.yaml | 3 + 72 files changed, 3644 insertions(+), 518 deletions(-) create mode 100644 apps/desktop/scripts/build-preview-annotation-css.mjs create mode 100644 apps/desktop/src/preview-annotation-styles.generated.ts create mode 100644 apps/desktop/src/preview-annotation.css create mode 100644 apps/web/src/browser/annotationTheme.ts create mode 100644 apps/web/src/browser/browserPointerStore.test.ts create mode 100644 apps/web/src/browser/browserPointerStore.ts create mode 100644 apps/web/src/components/preview/AgentBrowserCursor.tsx create mode 100644 apps/web/src/components/preview/PreviewChromeRow.browser.tsx create mode 100644 apps/web/src/components/preview/agentBrowserCursorLogic.test.ts create mode 100644 apps/web/src/components/preview/agentBrowserCursorLogic.ts create mode 100644 apps/web/src/components/preview/fileExplorerLabel.test.ts create mode 100644 apps/web/src/components/preview/fileExplorerLabel.ts create mode 100644 apps/web/src/components/preview/openDiscoveredPort.ts create mode 100644 apps/web/src/components/preview/previewEmptyStateLogic.test.ts create mode 100644 apps/web/src/components/preview/previewEmptyStateLogic.ts create mode 100644 apps/web/src/portDiscoveryState.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0d18b4c71f521222f55d6916709f09c63b0c3319..bba35c8de8b42baee1257507cdf70025ff53f75f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,6 +29,7 @@ "@types/node": "catalog:", "cross-env": "^10.1.0", "electron-builder": "26.8.1", + "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, "productName": "T3 Code (Alpha)" diff --git a/apps/desktop/scripts/build-preview-annotation-css.mjs b/apps/desktop/scripts/build-preview-annotation-css.mjs new file mode 100644 index 0000000000000000000000000000000000000000..e9dcff227d7ecc14172d168f436f0098ae61303f --- /dev/null +++ b/apps/desktop/scripts/build-preview-annotation-css.mjs @@ -0,0 +1,40 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { compile } from "tailwindcss"; + +const directory = dirname(fileURLToPath(import.meta.url)); +const appRoot = join(directory, ".."); +const sourcePath = join(appRoot, "src", "preview-annotation.css"); +const preloadPath = join(appRoot, "src", "preview-pick-preload.ts"); +const outputPath = join(appRoot, "src", "preview-annotation-styles.generated.ts"); +const require = createRequire(import.meta.url); +const tailwindRoot = dirname(require.resolve("tailwindcss/package.json")); + +const [annotationSource, preloadSource, themeSource, preflightSource] = await Promise.all([ + readFile(sourcePath, "utf8"), + readFile(preloadPath, "utf8"), + readFile(join(tailwindRoot, "theme.css"), "utf8"), + readFile(join(tailwindRoot, "preflight.css"), "utf8"), +]); + +const candidates = new Set( + Array.from(preloadSource.matchAll(/!?-?[A-Za-z0-9_:@/.[\]()%,-]+/g), (match) => match[0]), +); +const compilerInput = [ + themeSource, + preflightSource, + annotationSource.replace('@import "tailwindcss";', "@tailwind utilities;"), +].join("\n"); +const compiler = await compile(compilerInput, { base: appRoot }); +const css = compiler.build([...candidates]); +const encodedCss = `'${css + .replaceAll("\\", "\\\\") + .replaceAll("'", "\\'") + .replaceAll("\r", "\\r") + .replaceAll("\n", "\\n")}'`; +const moduleSource = `// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.\nexport const previewAnnotationStyles =\n ${encodedCss};\n`; + +await writeFile(outputPath, moduleSource); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index ee732bf830cba6cc1b7d91fe8f230bb0e5e5d907..92da3f887ac393ad1301c99ef40fb8528d2ece01 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -59,6 +59,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); assert.equal(environment.logDir, "/tmp/t3/dev/logs"); + assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts"); assert.equal(environment.rootDir, "/repo"); assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); @@ -89,6 +90,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.isDevelopment, false); assert.equal(environment.stateDir, "/tmp/t3/userdata"); assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); + assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 431e0d34d817a26629ed9d3664c63be2bc50f118..5a6be92ac11ca23d855b90190130f11dc3ffe6f0 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -49,6 +49,7 @@ export interface DesktopEnvironmentShape { readonly savedEnvironmentRegistryPath: string; readonly serverSettingsPath: string; readonly logDir: string; + readonly browserArtifactsDir: string; readonly rootDir: string; readonly appRoot: string; readonly backendEntryPath: string; @@ -183,6 +184,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), serverSettingsPath: path.join(stateDir, "settings.json"), logDir: path.join(stateDir, "logs"), + browserArtifactsDir: path.join(stateDir, "browser-artifacts"), rootDir, appRoot, backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 55b7b60e2dae8f401aaa1ec10a7153cb6e6dba99..0cb8976ed3b007c140586b2aadd4739d257d1cd3 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,5 +1,7 @@ import * as Effect from "effect/Effect"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { previewViewManager } from "../preview-view-manager.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; import { clearCloudAuthToken, @@ -52,6 +54,8 @@ import { previewMethods } from "./methods/preview.ts"; export const installDesktopIpcHandlers = Effect.gen(function* () { const ipc = yield* DesktopIpc.DesktopIpc; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + previewViewManager.configureArtifactDirectory(environment.browserArtifactsDir); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getLocalEnvironmentBootstrap); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index fe7b5b6f839c80ee8b6fbfa7918e6fa7a143ebaa..c5dabe0930f2652dfd5a78dd262715eb07967581 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -54,9 +54,12 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; +export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; +export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact"; +export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; @@ -70,3 +73,4 @@ export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; +export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 8683cd9d0b68010e07ccbc6eed661f8d51e0557a..c9b8c58fcbddfda04697bca64b3613a49cb1d919 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; import { BrowserWindow } from "electron"; import { pathToFileURL } from "node:url"; @@ -25,6 +26,14 @@ previewViewManager.onRecordingFrame((frame) => { } }); +previewViewManager.onPointerEvent((event) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); + } + } +}); + const tabIdFrom = (raw: unknown): string => { if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { throw new Error("preview tab id is required"); @@ -43,6 +52,44 @@ const inputFrom = (raw: unknown): unknown => { return raw.input; }; +const annotationThemeFrom = (raw: unknown): DesktopPreviewAnnotationTheme => { + if (typeof raw !== "object" || raw === null || !("theme" in raw)) { + throw new Error("preview annotation theme is required"); + } + const theme = raw.theme; + if (typeof theme !== "object" || theme === null) { + throw new Error("preview annotation theme must be an object"); + } + const record = theme as Record; + const stringKeys = [ + "radius", + "background", + "foreground", + "popover", + "popoverForeground", + "primary", + "primaryForeground", + "muted", + "mutedForeground", + "accent", + "accentForeground", + "border", + "input", + "ring", + "fontSans", + "fontMono", + ] as const; + for (const key of stringKeys) { + if (typeof record[key] !== "string" || record[key].length === 0) { + throw new Error(`preview annotation theme ${key} must be a non-empty string`); + } + } + if (record["colorScheme"] !== "light" && record["colorScheme"] !== "dark") { + throw new Error("preview annotation theme colorScheme must be light or dark"); + } + return record as unknown as DesktopPreviewAnnotationTheme; +}; + class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ readonly cause: unknown; }> {} @@ -117,6 +164,9 @@ export const previewMethods = [ preloadUrl: pathToFileURL(preloadPath).href, }; }), + method(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, (raw) => + previewViewManager.setAnnotationTheme(annotationThemeFrom(raw)), + ), method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => previewViewManager.pickElement(tabIdFrom(raw)), ), @@ -126,6 +176,20 @@ export const previewMethods = [ method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => previewViewManager.captureScreenshot(tabIdFrom(raw)), ), + method(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, (raw) => { + const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; + if (typeof path !== "string" || path.trim().length === 0) { + throw new Error("preview artifact path is required"); + } + return previewViewManager.revealArtifact(path); + }), + method(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, (raw) => { + const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; + if (typeof path !== "string" || path.trim().length === 0) { + throw new Error("preview artifact path is required"); + } + return previewViewManager.copyArtifactToClipboard(path); + }), method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => previewViewManager.automationStatus(tabIdFrom(raw)), ), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 8326673a862872caef7183945f2ae4d07180e13f..ce12f19bf72b1d8cd3c2c41641e2457b971d778b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import type { DesktopBridge, + DesktopPreviewPointerEvent, DesktopPreviewRecordingFrame, DesktopPreviewTabState, } from "@t3tools/contracts"; @@ -165,11 +166,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), getPreviewConfig: (environmentId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), + setAnnotationTheme: (theme) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), cancelPickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), captureScreenshot: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }), + revealArtifact: (path) => + ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), + copyArtifactToClipboard: (path) => + ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }), recording: { startScreencast: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), @@ -222,5 +229,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); }, + onPointerEvent: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, pointerEvent: unknown) => { + if (typeof pointerEvent !== "object" || pointerEvent === null) return; + listener(pointerEvent as DesktopPreviewPointerEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); + }, }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview-annotation-styles.generated.ts b/apps/desktop/src/preview-annotation-styles.generated.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b6b73c8ba7823822b484271ff16a69556825567 --- /dev/null +++ b/apps/desktop/src/preview-annotation-styles.generated.ts @@ -0,0 +1,3 @@ +// Generated by scripts/build-preview-annotation-css.mjs. Do not edit. +export const previewAnnotationStyles = + '/*! 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: "";\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: "";\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: "";\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: "";\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'; diff --git a/apps/desktop/src/preview-annotation.css b/apps/desktop/src/preview-annotation.css new file mode 100644 index 0000000000000000000000000000000000000000..89676a22d585b21417f195fbc09e864a1e0c102a --- /dev/null +++ b/apps/desktop/src/preview-annotation.css @@ -0,0 +1,68 @@ +@import "tailwindcss"; + +@theme inline { + --font-sans: var(--t3-font-sans); + --font-mono: var(--t3-font-mono); + --color-background: var(--t3-background); + --color-foreground: var(--t3-foreground); + --color-popover: var(--t3-popover); + --color-popover-foreground: var(--t3-popover-foreground); + --color-primary: var(--t3-primary); + --color-primary-foreground: var(--t3-primary-foreground); + --color-muted: var(--t3-muted); + --color-muted-foreground: var(--t3-muted-foreground); + --color-accent: var(--t3-accent); + --color-accent-foreground: var(--t3-accent-foreground); + --color-border: var(--t3-border); + --color-input: var(--t3-input); + --color-ring: var(--t3-ring); + --radius-sm: calc(var(--t3-radius) - 4px); + --radius-md: calc(var(--t3-radius) - 2px); + --radius-lg: var(--t3-radius); + --radius-xl: calc(var(--t3-radius) + 4px); + --radius-2xl: calc(var(--t3-radius) + 8px); +} + +:host { + --t3-font-sans: + "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; + --t3-font-mono: + "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; + --t3-radius: 0.625rem; + --t3-background: white; + --t3-foreground: oklch(0.269 0 0); + --t3-popover: white; + --t3-popover-foreground: oklch(0.269 0 0); + --t3-primary: oklch(0.488 0.217 264); + --t3-primary-foreground: white; + --t3-muted: rgb(0 0 0 / 4%); + --t3-muted-foreground: oklch(0.556 0 0); + --t3-accent: rgb(0 0 0 / 4%); + --t3-accent-foreground: oklch(0.269 0 0); + --t3-border: rgb(0 0 0 / 8%); + --t3-input: rgb(0 0 0 / 10%); + --t3-ring: oklch(0.488 0.217 264); + color: var(--t3-foreground); + font-family: var(--t3-font-sans); +} + +* { + box-sizing: border-box; + border-color: var(--t3-border); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); + outline-offset: 1px; +} diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index 7ca1da41b59603165dc56d62d71d895d2fd5e013..2fda1abd72009be89fed32eaf7dec1ddfb603b96 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -2,6 +2,7 @@ import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; import type { + DesktopPreviewAnnotationTheme, PickedElementPayload, PickedElementStackFrame, PreviewAnnotationPayload, @@ -12,16 +13,18 @@ import type { PreviewAnnotationStyleChange, } from "@t3tools/contracts"; +import { previewAnnotationStyles } from "./preview-annotation-styles.generated.ts"; + const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; const HUMAN_INPUT_CHANNEL = "preview:human-input"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const Z_INDEX_OVERLAY = 2147483646; -const ACCENT = "#7c3aed"; -const ACCENT_FILL = "rgba(124,58,237,0.12)"; -const BLUE = "#2563eb"; +const PRIMARY = "var(--t3-primary)"; +const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; const MAX_MARQUEE_ELEMENTS = 20; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; @@ -38,17 +41,63 @@ interface SelectedElement { interface AnnotationSession { teardown: (notifyMain: boolean) => void; + applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; } let activeSession: AnnotationSession | null = null; let idSequence = 0; +let annotationTheme: DesktopPreviewAnnotationTheme | null = null; -const reportHumanInput = (event: Event): void => { - if (event.isTrusted) ipcRenderer.send(HUMAN_INPUT_CHANNEL); +const applyAnnotationTheme = ( + host: HTMLElement, + theme: DesktopPreviewAnnotationTheme | null, +): void => { + if (!theme) return; + host.style.colorScheme = theme.colorScheme; + const variables = { + "--t3-radius": theme.radius, + "--t3-background": theme.background, + "--t3-foreground": theme.foreground, + "--t3-popover": theme.popover, + "--t3-popover-foreground": theme.popoverForeground, + "--t3-primary": theme.primary, + "--t3-primary-foreground": theme.primaryForeground, + "--t3-muted": theme.muted, + "--t3-muted-foreground": theme.mutedForeground, + "--t3-accent": theme.accent, + "--t3-accent-foreground": theme.accentForeground, + "--t3-border": theme.border, + "--t3-input": theme.input, + "--t3-ring": theme.ring, + "--t3-font-sans": theme.fontSans, + "--t3-font-mono": theme.fontMono, + }; + for (const [name, value] of Object.entries(variables)) { + host.style.setProperty(name, value); + } }; -window.addEventListener("pointerdown", reportHumanInput, true); -window.addEventListener("keydown", reportHumanInput, true); +const reportHumanPointerInput = (event: PointerEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "pointer", + x: event.clientX, + y: event.clientY, + button: event.button, + }); +}; + +const reportHumanKeyInput = (event: KeyboardEvent): void => { + if (!event.isTrusted) return; + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { + kind: "key", + key: event.key, + code: event.code, + }); +}; + +window.addEventListener("pointerdown", reportHumanPointerInput, true); +window.addEventListener("keydown", reportHumanKeyInput, true); const nextId = (prefix: string): string => { idSequence += 1; @@ -76,13 +125,15 @@ const normalizeRect = ( const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; -function unionRects(rects: ReadonlyArray): PreviewAnnotationRect | null { +function unionRects( + rects: ReadonlyArray, + padding = 20, +): PreviewAnnotationRect | null { if (rects.length === 0) return null; const left = Math.min(...rects.map((rect) => rect.x)); const top = Math.min(...rects.map((rect) => rect.y)); const right = Math.max(...rects.map((rect) => rect.x + rect.width)); const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); - const padding = 20; const x = Math.max(0, left - padding); const y = Math.max(0, top - padding); const maxWidth = Math.max(1, window.innerWidth - x); @@ -151,19 +202,13 @@ function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { function createLabel(): HTMLDivElement { const label = document.createElement("div"); label.setAttribute(OVERLAY_ATTRIBUTE, ""); + label.className = + "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"; label.style.cssText = [ "position:fixed", "pointer-events:none", - `background:${ACCENT}`, - "color:white", - "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", - "padding:2px 6px", - "border-radius:4px", "white-space:nowrap", - "max-width:280px", - "overflow:hidden", "text-overflow:ellipsis", - "box-shadow:0 1px 3px rgba(0,0,0,.3)", `z-index:${CONTENT_LAYER_Z_INDEX}`, ].join(";"); return label; @@ -222,22 +267,15 @@ function createButton(label: string, title: string): HTMLButtonElement { button.type = "button"; button.textContent = label; button.title = title; - button.style.cssText = [ - "border:0", - "border-radius:7px", - "padding:6px 9px", - "font:600 12px/1 ui-sans-serif,system-ui,-apple-system,sans-serif", - "background:transparent", - "color:#27272a", - "cursor:pointer", - ].join(";"); + button.className = + "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"; return button; } function styleControl(input: HTMLInputElement | HTMLSelectElement): void { input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); - input.style.cssText += - ";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"; + input.className = + "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"; } function createUnitControl(input: HTMLInputElement): HTMLElement { @@ -245,8 +283,8 @@ function createUnitControl(input: HTMLInputElement): HTMLElement { wrapper.style.cssText = "position:relative;min-width:0"; const unit = document.createElement("span"); unit.textContent = input.dataset.unit ?? ""; - unit.style.cssText = - "position:absolute;right:8px;top:50%;transform:translateY(-50%);pointer-events:none;color:#a1a1aa;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace"; + unit.className = + "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; wrapper.append(input, unit); return wrapper; } @@ -256,8 +294,8 @@ function createField( input: HTMLInputElement | HTMLSelectElement, ): HTMLLabelElement { const label = document.createElement("label"); - label.style.cssText = - "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"; + label.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; const text = document.createElement("span"); text.textContent = labelText; styleControl(input); @@ -270,7 +308,7 @@ function createField( function createStyleSection(): HTMLElement { const section = document.createElement("section"); - section.style.cssText = "display:grid;gap:3px;padding:7px 0;border-top:1px solid rgba(0,0,0,.07)"; + section.className = "grid gap-1 border-t border-border py-2"; return section; } @@ -314,16 +352,27 @@ function strokeBounds( function startAnnotation(): void { activeSession?.teardown(false); let finished = false; + const host = document.createElement("div"); + host.setAttribute(OVERLAY_ATTRIBUTE, ""); + host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; + applyAnnotationTheme(host, annotationTheme); + const shadowRoot = host.attachShadow({ mode: "closed" }); + const themeStyle = document.createElement("style"); + themeStyle.textContent = previewAnnotationStyles; + shadowRoot.appendChild(themeStyle); + const root = document.createElement("div"); root.setAttribute(OVERLAY_ATTRIBUTE, ""); - 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`; + root.className = "fixed inset-0 font-sans text-foreground"; + root.style.cssText = "pointer-events:none"; const cursorStyle = document.createElement("style"); cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); 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; }`; - root.appendChild(cursorStyle); + document.documentElement.appendChild(cursorStyle); + shadowRoot.appendChild(root); - const hoverOutline = createBox(BLUE, "rgba(37,99,235,.10)"); - const marqueeBox = createBox(ACCENT, ACCENT_FILL); + const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); + const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); root.append(hoverOutline, marqueeBox); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -337,58 +386,56 @@ function startAnnotation(): void { const toolbar = document.createElement("div"); toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); - 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)`; + toolbar.className = + "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"; + toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); root.appendChild(toolbar); const editor = document.createElement("div"); editor.setAttribute(OVERLAY_ATTRIBUTE, ""); - 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)`; + editor.className = + "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"; + editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); root.appendChild(editor); - const editorHeader = document.createElement("div"); - editorHeader.style.cssText = - "display:flex;align-items:center;gap:7px;padding:7px 8px;border-bottom:1px solid rgba(0,0,0,.06);cursor:grab;user-select:none"; - const adjustIcon = document.createElement("div"); - adjustIcon.innerHTML = - ''; - adjustIcon.style.cssText = - "display:grid;place-items:center;width:24px;height:24px;border-radius:999px;background:#f4f4f5;color:#52525b;font:700 14px/1 ui-sans-serif"; - editorHeader.appendChild(adjustIcon); + const composerRow = document.createElement("div"); + composerRow.className = "flex items-start gap-2 p-2"; - const status = document.createElement("div"); - status.style.cssText = - "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"; - editorHeader.appendChild(status); - const dragHandle = document.createElement("div"); - dragHandle.textContent = "⠿"; - dragHandle.title = "Drag annotation editor"; - dragHandle.style.cssText = "color:#a1a1aa;font:700 18px/1 ui-sans-serif"; - editorHeader.appendChild(dragHandle); - editor.appendChild(editorHeader); + const adjust = createButton("", "Expand annotation editor"); + adjust.setAttribute("aria-label", "Expand annotation editor"); + adjust.setAttribute("aria-expanded", "false"); + adjust.className += + " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; + adjust.innerHTML = + ''; + composerRow.appendChild(adjust); const comment = document.createElement("textarea"); comment.placeholder = "Describe the change…"; - comment.rows = 2; - comment.style.cssText = - "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"; - editor.appendChild(comment); + comment.rows = 1; + comment.className = + "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"; + composerRow.appendChild(comment); + + const dragHandle = document.createElement("button"); + dragHandle.type = "button"; + dragHandle.textContent = "⠿"; + dragHandle.title = "Drag annotation editor"; + dragHandle.className = + "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"; + composerRow.appendChild(dragHandle); + + const submit = createButton("Attach", "Attach annotation and screenshot"); + submit.className += + " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; + composerRow.appendChild(submit); + editor.appendChild(composerRow); const stylePanel = document.createElement("div"); - stylePanel.style.cssText = - "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)"; + stylePanel.className = + "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; editor.appendChild(stylePanel); - const footer = document.createElement("div"); - footer.style.cssText = - "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)"; - const adjust = createButton("Adjust", "Show style controls for selected elements"); - adjust.style.cssText += - ";display:none;padding:5px 7px;color:#52525b;background:#f4f4f5;font-size:11px"; - const submit = createButton("Attach", "Attach annotation and screenshot"); - submit.style.cssText += `;background:${ACCENT};color:white;padding:6px 9px;font-size:11px`; - footer.append(adjust, submit); - editor.appendChild(footer); - const selected = new Map(); const regions: PreviewAnnotationRegionTarget[] = []; const strokes: PreviewAnnotationStrokeTarget[] = []; @@ -398,28 +445,30 @@ function startAnnotation(): void { let dragStart: PreviewAnnotationPoint | null = null; let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; let pendingCapture = false; - let stylesExpanded = false; + let editorExpanded = false; let editorWasShown = false; let editorPosition: { left: number; top: number } | null = null; let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; + let editorLayoutFrame: number | null = null; + + const resizeComment = (): void => { + const maxHeight = 96; + comment.style.height = "auto"; + const nextHeight = Math.min(comment.scrollHeight, maxHeight); + comment.style.height = `${nextHeight}px`; + comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; + queueEditorLayout(); + }; + comment.addEventListener("input", resizeComment); const updateStatus = (): void => { - const parts = [ - selected.size > 0 ? `${selected.size} element${selected.size === 1 ? "" : "s"}` : "", - regions.length > 0 ? `${regions.length} region${regions.length === 1 ? "" : "s"}` : "", - strokes.length > 0 ? `${strokes.length} drawing${strokes.length === 1 ? "" : "s"}` : "", - ].filter(Boolean); - const hasTargets = parts.length > 0; - status.textContent = parts.join(" · "); + const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; editor.style.display = hasTargets ? "flex" : "none"; submit.disabled = !hasTargets; submit.style.opacity = hasTargets ? "1" : "0.45"; - adjust.style.display = selected.size > 0 ? "block" : "none"; - if (selected.size === 0 && stylesExpanded) { - stylesExpanded = false; - stylePanel.style.display = "none"; - adjust.textContent = "Adjust"; - } + adjust.disabled = !hasTargets; + stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; + queueEditorLayout(); if (hasTargets && !editorWasShown) { editorWasShown = true; window.setTimeout(() => comment.focus({ preventScroll: true }), 0); @@ -429,8 +478,9 @@ function startAnnotation(): void { const refreshToolButtons = (): void => { for (const [candidate, button] of toolButtons) { const active = candidate === tool; - button.style.background = active ? ACCENT_FILL : "transparent"; - button.style.color = active ? ACCENT : "#27272a"; + button.classList.toggle("bg-primary/10", active); + button.classList.toggle("text-primary", active); + button.classList.toggle("text-foreground", !active); } if (tool !== "select") hoverOutline.style.display = "none"; if (tool !== "marquee") marqueeBox.style.display = "none"; @@ -458,7 +508,7 @@ function startAnnotation(): void { const target: SelectedElement = { id: nextId("element"), element, - outline: createBox(ACCENT, ACCENT_FILL), + outline: createBox(PRIMARY, PRIMARY_FILL), label: createLabel(), baselineStyles: new Map(), }; @@ -466,7 +516,10 @@ function startAnnotation(): void { root.append(target.outline, target.label); updateSelectedVisual(target); updateStatus(); - if (stylesExpanded) syncStyleControls(); + if (editorExpanded) { + stylePanel.style.display = "grid"; + syncStyleControls(); + } }; const toggleSelected = (element: Element, additive: boolean): void => { @@ -552,13 +605,13 @@ function startAnnotation(): void { section: HTMLElement, ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { const row = document.createElement("label"); - row.style.cssText = - "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"; + row.className = + "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; const label = document.createElement("span"); label.textContent = labelText; const control = document.createElement("div"); - control.style.cssText = - "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)"; + control.className = + "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; const color = document.createElement("input"); color.type = "color"; color.setAttribute("aria-label", labelText); @@ -567,8 +620,8 @@ function startAnnotation(): void { const text = document.createElement("input"); text.type = "text"; text.setAttribute("aria-label", `${labelText} value`); - text.style.cssText = - "min-width:0;width:100%;border:0;background:transparent;color:#52525b;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none"; + text.className = + "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; color.addEventListener("input", () => { text.value = color.value; setStyleForSelected(property, color.value); @@ -594,7 +647,7 @@ function startAnnotation(): void { opacity.max = "1"; opacity.step = "0.05"; opacity.value = "1"; - opacity.style.accentColor = ACCENT; + opacity.style.accentColor = PRIMARY; opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); colorsSection.appendChild(createField("Opacity", opacity)); @@ -623,8 +676,7 @@ function startAnnotation(): void { dimensions.style.cssText = "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; const dimensionLabel = document.createElement("div"); - dimensionLabel.style.cssText = - "display:grid;gap:9px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; + dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; dimensionLabel.innerHTML = "WidthHeight"; const dimensionControls = document.createElement("div"); dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; @@ -635,7 +687,8 @@ function startAnnotation(): void { const aspectLock = createButton("", "Lock aspect ratio"); aspectLock.setAttribute("aria-pressed", "true"); aspectLock.style.cssText += - ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0;border-radius:6px;background:#ede9fe;color:#6d28d9"; + ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; + aspectLock.className += " bg-primary/10 text-primary"; dimensionControls.append( createUnitControl(widthInput), createUnitControl(heightInput), @@ -651,8 +704,10 @@ function startAnnotation(): void { ? '' : ''; aspectLock.setAttribute("aria-pressed", String(aspectLocked)); - aspectLock.style.background = aspectLocked ? "#ede9fe" : "#f4f4f5"; - aspectLock.style.color = aspectLocked ? "#6d28d9" : "#71717a"; + aspectLock.classList.toggle("bg-primary/10", aspectLocked); + aspectLock.classList.toggle("text-primary", aspectLocked); + aspectLock.classList.toggle("bg-muted", !aspectLocked); + aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); }; aspectLock.addEventListener("click", () => { aspectLocked = !aspectLocked; @@ -733,7 +788,7 @@ function startAnnotation(): void { ]; for (const [candidate, label, title] of tools) { const button = createButton(label, title); - button.style.cssText += ";padding:5px 7px;font-size:11px;border-radius:6px"; + button.className += " h-8 px-2.5 text-sm"; button.addEventListener("click", () => { tool = candidate; refreshToolButtons(); @@ -742,14 +797,6 @@ function startAnnotation(): void { toolbar.appendChild(button); } - adjust.addEventListener("click", () => { - if (selected.size === 0) return; - stylesExpanded = !stylesExpanded; - stylePanel.style.display = stylesExpanded ? "grid" : "none"; - adjust.textContent = stylesExpanded ? "Hide styles" : "Adjust"; - if (stylesExpanded) syncStyleControls(); - }); - const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { const margin = 8; const rect = editor.getBoundingClientRect(); @@ -766,23 +813,98 @@ function startAnnotation(): void { }; const applyEditorPosition = (position: { left: number; top: number }): void => { - editorPosition = clampEditorPosition(position.left, position.top); - editor.style.left = `${editorPosition.left}px`; - editor.style.top = `${editorPosition.top}px`; + const clamped = clampEditorPosition(position.left, position.top); + editor.style.left = `${clamped.left}px`; + editor.style.top = `${clamped.top}px`; editor.style.right = "auto"; editor.style.bottom = "auto"; + if (editorExpanded) editorPosition = clamped; }; + const getAnnotationBounds = (): PreviewAnnotationRect | null => + unionRects( + [ + ...Array.from(selected.values(), (target) => + rectFromDomRect(target.element.getBoundingClientRect()), + ), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ], + 0, + ); + + const positionCompactEditor = (): void => { + const bounds = getAnnotationBounds(); + if (!bounds) return; + const editorRect = editor.getBoundingClientRect(); + const gap = 8; + const candidates = [ + { left: bounds.x + bounds.width + gap, top: bounds.y }, + { left: bounds.x - editorRect.width - gap, top: bounds.y }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y + bounds.height + gap, + }, + { + left: bounds.x + bounds.width - editorRect.width, + top: bounds.y - editorRect.height - gap, + }, + ]; + const overflow = (position: { left: number; top: number }): number => + Math.max(0, -position.left) + + Math.max(0, -position.top) + + Math.max(0, position.left + editorRect.width - window.innerWidth) + + Math.max(0, position.top + editorRect.height - window.innerHeight); + const best = candidates.reduce((current, candidate) => + overflow(candidate) < overflow(current) ? candidate : current, + ); + applyEditorPosition(best); + }; + + function queueEditorLayout(): void { + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); + editorLayoutFrame = window.requestAnimationFrame(() => { + editorLayoutFrame = null; + if (editor.style.display === "none") return; + if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); + else positionCompactEditor(); + }); + } + + adjust.addEventListener("click", () => { + if (selected.size === 0) return; + if (!editorExpanded) { + const rect = editor.getBoundingClientRect(); + editorExpanded = true; + editorPosition = { left: rect.left, top: rect.top }; + stylePanel.style.display = selected.size > 0 ? "grid" : "none"; + dragHandle.style.display = "block"; + adjust.setAttribute("aria-expanded", "true"); + adjust.title = "Collapse annotation editor"; + adjust.setAttribute("aria-label", "Collapse annotation editor"); + if (selected.size > 0) syncStyleControls(); + } else { + editorExpanded = false; + editorPosition = null; + stylePanel.style.display = "none"; + dragHandle.style.display = "none"; + adjust.setAttribute("aria-expanded", "false"); + adjust.title = "Expand annotation editor"; + adjust.setAttribute("aria-label", "Expand annotation editor"); + } + queueEditorLayout(); + }); + const onEditorPointerDown = (event: PointerEvent): void => { - if (event.button !== 0) return; + if (event.button !== 0 || !editorExpanded) return; const rect = editor.getBoundingClientRect(); editorDrag = { pointerId: event.pointerId, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top, }; - editorHeader.setPointerCapture(event.pointerId); - editorHeader.style.cursor = "grabbing"; + dragHandle.setPointerCapture(event.pointerId); + dragHandle.style.cursor = "grabbing"; event.preventDefault(); event.stopPropagation(); }; @@ -800,20 +922,20 @@ function startAnnotation(): void { const onEditorPointerUp = (event: PointerEvent): void => { if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; editorDrag = null; - editorHeader.style.cursor = "grab"; - if (editorHeader.hasPointerCapture(event.pointerId)) - editorHeader.releasePointerCapture(event.pointerId); + dragHandle.style.cursor = "grab"; + if (dragHandle.hasPointerCapture(event.pointerId)) + dragHandle.releasePointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); }; - editorHeader.addEventListener("pointerdown", onEditorPointerDown); - editorHeader.addEventListener("pointermove", onEditorPointerMove); - editorHeader.addEventListener("pointerup", onEditorPointerUp); - editorHeader.addEventListener("pointercancel", onEditorPointerUp); + dragHandle.addEventListener("pointerdown", onEditorPointerDown); + dragHandle.addEventListener("pointermove", onEditorPointerMove); + dragHandle.addEventListener("pointerup", onEditorPointerUp); + dragHandle.addEventListener("pointercancel", onEditorPointerUp); const repaint = (): void => { for (const target of selected.values()) updateSelectedVisual(target); - if (editorPosition) applyEditorPosition(editorPosition); + queueEditorLayout(); }; const removeTargetAtPoint = (x: number, y: number): boolean => { @@ -941,7 +1063,7 @@ function startAnnotation(): void { if (tool === "draw") { const stroke: PreviewAnnotationStrokeTarget = { id: nextId("stroke"), - color: ACCENT, + color: annotationTheme?.primary ?? "#2563eb", width: 4, points: [dragStart], bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, @@ -971,7 +1093,10 @@ function startAnnotation(): void { if (found === 0) { const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; regions.push(region); - const regionBox = createBox(ACCENT, "rgba(124,58,237,.06)"); + const regionBox = createBox( + PRIMARY, + "color-mix(in srgb, var(--t3-primary) 6%, transparent)", + ); regionBox.setAttribute("data-region-id", region.id); positionBox(regionBox, rect); root.appendChild(regionBox); @@ -1024,14 +1149,16 @@ function startAnnotation(): void { window.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("scroll", repaint, true); window.removeEventListener("resize", repaint); - editorHeader.removeEventListener("pointerdown", onEditorPointerDown); - editorHeader.removeEventListener("pointermove", onEditorPointerMove); - editorHeader.removeEventListener("pointerup", onEditorPointerUp); - editorHeader.removeEventListener("pointercancel", onEditorPointerUp); + dragHandle.removeEventListener("pointerdown", onEditorPointerDown); + dragHandle.removeEventListener("pointermove", onEditorPointerMove); + dragHandle.removeEventListener("pointerup", onEditorPointerUp); + dragHandle.removeEventListener("pointercancel", onEditorPointerUp); + if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); document.documentElement.removeAttribute("data-t3code-annotation-tool"); - root.remove(); + cursorStyle.remove(); + host.remove(); activeSession = null; if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); }; @@ -1115,11 +1242,21 @@ function startAnnotation(): void { window.addEventListener("resize", repaint, { passive: true }); ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); - document.documentElement.appendChild(root); + document.documentElement.appendChild(host); refreshToolButtons(); updateStatus(); - activeSession = { teardown }; + activeSession = { + teardown, + applyTheme: (theme) => applyAnnotationTheme(host, theme), + }; } -ipcRenderer.on(START_PICK_CHANNEL, () => startAnnotation()); +ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { + if (theme) annotationTheme = theme; + startAnnotation(); +}); +ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { + annotationTheme = theme; + activeSession?.applyTheme(theme); +}); ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/preview-view-manager.test.ts b/apps/desktop/src/preview-view-manager.test.ts index c704ae6630c8343ca6504c1b0434b8a65b203675..a7aeabc0e0d6b63bcb31bd6ac7fe4d845ae78b16 100644 --- a/apps/desktop/src/preview-view-manager.test.ts +++ b/apps/desktop/src/preview-view-manager.test.ts @@ -3,16 +3,26 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const fromId = vi.fn(() => null); const mkdir = vi.fn(async () => undefined); const writeFile = vi.fn(async () => undefined); +const showItemInFolder = vi.fn(); +const writeImage = vi.fn(); +const createFromPath = vi.fn(() => ({ isEmpty: () => false })); +const webviewSend = vi.fn(); vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); vi.mock("electron", () => ({ - app: { - getPath: vi.fn(() => "/tmp/t3-code-test"), + clipboard: { + writeImage, + }, + nativeImage: { + createFromPath, }, session: { fromPartition: vi.fn(), }, + shell: { + showItemInFolder, + }, webContents: { fromId, }, @@ -23,6 +33,10 @@ describe("PreviewViewManager automation status", () => { fromId.mockClear(); mkdir.mockClear(); writeFile.mockClear(); + showItemInFolder.mockClear(); + writeImage.mockClear(); + createFromPath.mockClear(); + webviewSend.mockClear(); }); it("reports an unregistered webview as temporarily unavailable", async () => { @@ -59,7 +73,7 @@ describe("PreviewViewManager automation status", () => { id: 42, isDestroyed: () => false, getType: () => "webview", - getURL: () => "https://example.com/", + getURL: () => "https://example.com:8443/path?query=value", getTitle: () => "Example", isLoading: () => false, getZoomFactor: () => 1, @@ -69,6 +83,7 @@ describe("PreviewViewManager automation status", () => { }), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, navigationHistory: { canGoBack: () => false, canGoForward: () => false }, setWindowOpenHandler: vi.fn(), debugger: { @@ -82,13 +97,22 @@ describe("PreviewViewManager automation status", () => { } as never); const { PreviewViewManager } = await import("./preview-view-manager.ts"); const manager = new PreviewViewManager(); + manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); manager.createTab("tab_1"); manager.registerWebview("tab_1", 42); + expect(webviewSend).toHaveBeenCalledWith( + "preview:annotation-theme", + expect.objectContaining({ + colorScheme: "light", + primary: "oklch(0.488 0.217 264)", + }), + ); + const artifact = await manager.captureScreenshot("tab_1"); expect(capturePage).toHaveBeenCalledOnce(); - expect(mkdir).toHaveBeenCalledWith("/tmp/t3-code-test/browser-artifacts", { + expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", { recursive: true, }); expect(writeFile).toHaveBeenCalledWith(artifact.path, png); @@ -97,6 +121,163 @@ describe("PreviewViewManager automation status", () => { mimeType: "image/png", sizeBytes: png.byteLength, }); - expect(artifact.path).toMatch(/\/browser-artifacts\/browser-screenshot-[^.]+\.png$/); + expect(artifact.path).toMatch( + /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, + ); + }); + + it("reveals only files inside the configured browser artifact directory", async () => { + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); + + manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); + + expect(showItemInFolder).toHaveBeenCalledWith( + "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", + ); + expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow( + "outside the configured artifact directory", + ); + }); + + it("copies screenshot artifacts to the system clipboard", async () => { + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); + const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; + + manager.copyArtifactToClipboard(artifactPath); + + expect(createFromPath).toHaveBeenCalledWith(artifactPath); + expect(writeImage).toHaveBeenCalledOnce(); + expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow( + "outside the configured artifact directory", + ); + }); + + it("emits the resolved pointer target before dispatching an automation click", async () => { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const activity: string[] = []; + const sendCommand = vi.fn(async (method: string, params?: Record) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { + activity.push("mousePressed"); + humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + manager.onPointerEvent((event) => activity.push(event.phase)); + manager.createTab("tab_1"); + manager.registerWebview("tab_1", 42); + + await manager.automationClick("tab_1", { x: 120, y: 80 }); + + expect(activity).toEqual(["move", "click", "mousePressed"]); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mousePressed", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: 120, + y: 80, + button: "left", + clickCount: 1, + }); + }); + + it("still interrupts agent control for a different human pointer event", async () => { + let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; + const sendCommand = vi.fn(async (method: string) => { + if (method === "Runtime.evaluate") { + return { + result: { + value: { width: 800, height: 600 }, + }, + }; + } + if (method === "Input.dispatchMouseEvent") { + humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); + } + return undefined; + }); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof humanInput) => { + if (channel === "preview:human-input") humanInput = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const { PreviewViewManager } = await import("./preview-view-manager.ts"); + const manager = new PreviewViewManager(); + manager.createTab("tab_1"); + manager.registerWebview("tab_1", 42); + + await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({ + name: "PreviewAutomationControlInterruptedError", + }); }); }); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts index d740334207c460ee04dd6b3c23f484fab29de689..5406ae731b05425211fd977181ab76cfa76083d1 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview-view-manager.ts @@ -8,6 +8,8 @@ * here). Single layer-scoped browser session partition. */ import type { + DesktopPreviewAnnotationTheme, + DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, DesktopPreviewRecordingArtifact, @@ -26,9 +28,17 @@ import type { PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; -import { app, type BrowserWindow, type Session, session, webContents } from "electron"; +import { + type BrowserWindow, + type Session, + clipboard, + nativeImage, + session, + shell, + webContents, +} from "electron"; import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { createHash } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; @@ -40,6 +50,7 @@ const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; const HUMAN_INPUT_CHANNEL = "preview:human-input"; // Re-export the guest webview security posture from its dedicated module so @@ -83,6 +94,43 @@ const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_SCREENSHOT_WIDTH = 1280; const DIAGNOSTIC_BUFFER_LIMIT = 200; +const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; +const AGENT_CURSOR_MOVE_MS = 160; +const AGENT_CURSOR_CLICK_LEAD_MS = 40; +const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { + colorScheme: "light", + radius: "0.625rem", + background: "white", + foreground: "oklch(0.269 0 0)", + popover: "white", + popoverForeground: "oklch(0.269 0 0)", + primary: "oklch(0.488 0.217 264)", + primaryForeground: "white", + muted: "rgb(0 0 0 / 4%)", + mutedForeground: "oklch(0.556 0 0)", + accent: "rgb(0 0 0 / 4%)", + accentForeground: "oklch(0.269 0 0)", + border: "rgb(0 0 0 / 8%)", + input: "rgb(0 0 0 / 10%)", + ring: "oklch(0.488 0.217 264)", + fontSans: "system-ui, sans-serif", + fontMono: "ui-monospace, monospace", +}; + +const artifactSiteSlug = (rawUrl: string): string => { + try { + const url = new URL(rawUrl); + const slug = url.hostname + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH) + .replace(/-+$/g, ""); + return slug || "site"; + } catch { + return "site"; + } +}; interface CdpEvaluationResult { readonly result?: { @@ -182,10 +230,14 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { type Listener = (tabId: string, state: PreviewTabState) => void; type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; +type PreviewInputSignal = + | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } + | { readonly kind: "key"; readonly key: string; readonly code: string }; + interface ManagedListeners { navigate: () => void; failed: (event: Event, code: number, description: string) => void; - humanInput: () => void; + humanInput: (_event: unknown, signal?: unknown) => void; } interface PickSession { @@ -205,6 +257,13 @@ interface BrowserDiagnostics { readonly requests: Map; } +type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; + +interface ExpectedAgentInput { + readonly signal: PreviewInputSignal; + readonly expiresAt: number; +} + const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ key: string; meta: boolean; @@ -221,23 +280,116 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ { key: "w", meta: true, shift: false, control: false }, ]); +const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { + if (typeof value !== "object" || value === null || !("kind" in value)) return false; + if (value.kind === "pointer") { + return ( + "x" in value && + typeof value.x === "number" && + "y" in value && + typeof value.y === "number" && + "button" in value && + typeof value.button === "number" + ); + } + return ( + value.kind === "key" && + "key" in value && + typeof value.key === "string" && + "code" in value && + typeof value.code === "string" + ); +}; + +const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => { + if (left.kind !== right.kind) return false; + if (left.kind === "pointer" && right.kind === "pointer") { + return ( + Math.abs(left.x - right.x) <= 1 && + Math.abs(left.y - right.y) <= 1 && + left.button === right.button + ); + } + return ( + left.kind === "key" && + right.kind === "key" && + left.key === right.key && + left.code === right.code + ); +}; + export class PreviewViewManager { + private annotationTheme = DEFAULT_ANNOTATION_THEME; + private artifactDirectory: string | null = null; private mainWindow: BrowserWindow | null = null; private readonly tabs = new Map(); private readonly attached = new Map(); private readonly browserSessions = new Map(); private readonly listeners = new Set(); + private readonly pointerEventListeners = new Set(); private readonly recordingFrameListeners = new Set(); /** In-flight preview annotation sessions, keyed by tabId. */ private readonly pickSessions = new Map(); /** One long-lived CDP attachment and serialized command queue per guest. */ private readonly controlSessions = new Map(); private readonly diagnostics = new Map(); + private readonly expectedAgentInputs = new Map(); private readonly controlEpoch = new Map(); private readonly actionTimeline = new Map(); private actionSequence = 0; + private pointerSequence = 0; private recordingTabId: string | null = null; + configureArtifactDirectory(directory: string): void { + this.artifactDirectory = resolve(directory); + } + + private requireArtifactDirectory(): string { + if (!this.artifactDirectory) { + throw new Error("Preview artifact directory is not configured."); + } + return this.artifactDirectory; + } + + private resolveArtifactPath(path: string): string { + const directory = this.requireArtifactDirectory(); + const resolvedPath = resolve(path); + const relativePath = relative(directory, resolvedPath); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + throw new Error("Preview artifact path is outside the configured artifact directory."); + } + return resolvedPath; + } + + revealArtifact(path: string): void { + const resolvedPath = this.resolveArtifactPath(path); + shell.showItemInFolder(resolvedPath); + } + + copyArtifactToClipboard(path: string): void { + const resolvedPath = this.resolveArtifactPath(path); + const image = nativeImage.createFromPath(resolvedPath); + if (image.isEmpty()) { + throw new Error("Preview artifact could not be loaded as an image."); + } + clipboard.writeImage(image); + } + + setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void { + this.annotationTheme = theme; + for (const tab of this.tabs.values()) { + if (tab.webContentsId == null) continue; + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) continue; + wc.send(ANNOTATION_THEME_CHANNEL, theme); + } + } + setMainWindow(window: BrowserWindow): void { this.mainWindow = window; } @@ -338,6 +490,7 @@ export class PreviewViewManager { throw new PreviewWebContentsNotFoundError(tabId, webContentsId); } if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) { + wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); return; } if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { @@ -366,6 +519,7 @@ export class PreviewViewManager { canGoForward: wc.navigationHistory.canGoForward(), zoomFactor: tab.zoomFactor, }); + wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); } async navigate(tabId: string, rawUrl: string): Promise { @@ -513,7 +667,7 @@ export class PreviewViewManager { // wc may be torn down; the next try/catch settles. } try { - wc.send(START_PICK_CHANNEL); + wc.send(START_PICK_CHANNEL, this.annotationTheme); } catch { settle(null); } @@ -561,8 +715,8 @@ export class PreviewViewManager { async captureScreenshot(tabId: string): Promise { const wc = this.requireWebContents(tabId); const createdAt = new Date().toISOString(); - const id = `browser-screenshot-${Date.now().toString(36)}`; - const directory = join(app.getPath("userData"), "browser-artifacts"); + const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`; + const directory = this.requireArtifactDirectory(); const path = join(directory, `${id}.png`); const data = (await wc.capturePage()).toPNG(); await mkdir(directory, { recursive: true }); @@ -587,7 +741,7 @@ export class PreviewViewManager { const createdAt = new Date().toISOString(); const id = `browser-recording-${Date.now().toString(36)}`; const extension = mimeType.includes("mp4") ? "mp4" : "webm"; - const directory = join(app.getPath("userData"), "browser-artifacts"); + const directory = this.requireArtifactDirectory(); const path = join(directory, `${id}.${extension}`); await mkdir(directory, { recursive: true }); await writeFile(path, data); @@ -793,6 +947,25 @@ export class PreviewViewManager { `Click coordinates (${x}, ${y}) are outside the preview viewport.`, ); } + this.emitPointerEvent({ + tabId, + phase: "move", + x, + y, + sequence: this.pointerSequence++, + createdAt: new Date().toISOString(), + }); + await sleep(AGENT_CURSOR_MOVE_MS); + this.emitPointerEvent({ + tabId, + phase: "click", + x, + y, + sequence: this.pointerSequence++, + createdAt: new Date().toISOString(), + }); + await sleep(AGENT_CURSOR_CLICK_LEAD_MS); + this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 }); await send("Input.dispatchMouseEvent", { type: "mousePressed", x, @@ -886,6 +1059,7 @@ export class PreviewViewManager { modifiers, ...(text ? { text, unmodifiedText: text } : {}), }; + this.expectAgentInput(tabId, { kind: "key", key, code: params.code }); await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); }); @@ -1345,11 +1519,50 @@ export class PreviewViewManager { }; } + onPointerEvent(listener: PointerEventListener): () => void { + this.pointerEventListeners.add(listener); + return () => { + this.pointerEventListeners.delete(listener); + }; + } + + private emitPointerEvent(event: DesktopPreviewPointerEvent): void { + for (const listener of this.pointerEventListeners) listener(event); + } + + private expectAgentInput(tabId: string, signal: PreviewInputSignal): void { + const now = Date.now(); + const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + pending.push({ signal, expiresAt: now + 1_000 }); + this.expectedAgentInputs.set(tabId, pending); + } + + private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean { + const now = Date.now(); + const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( + (expected) => expected.expiresAt > now, + ); + const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); + if (index < 0) { + if (pending.length === 0) this.expectedAgentInputs.delete(tabId); + else this.expectedAgentInputs.set(tabId, pending); + return false; + } + pending.splice(index, 1); + if (pending.length === 0) this.expectedAgentInputs.delete(tabId); + else this.expectedAgentInputs.set(tabId, pending); + return true; + } + destroy(): void { for (const tabId of Array.from(this.tabs.keys())) { this.closeTab(tabId); } this.listeners.clear(); + this.expectedAgentInputs.clear(); + this.pointerEventListeners.clear(); this.recordingFrameListeners.clear(); } @@ -1375,7 +1588,10 @@ export class PreviewViewManager { }, }); }; - const humanInput = (): void => { + const humanInput = (_event: unknown, rawSignal?: unknown): void => { + if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) { + return; + } this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); this.update(tabId, { controller: "human" }); void sleep(750).then(() => { diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 4c1e9f8c1530715adbda5488c0bb6dae9a497eea..dceefc14e9e74b32beecf566e27b6a9aece3e05f 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -14,17 +14,18 @@ export default defineConfig({ run: { tasks: { build: { - command: "vp pack", + command: "node scripts/build-preview-annotation-css.mjs && vp pack", dependsOn: ["t3#build"], cache: false, }, dev: { - command: "cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", + command: + "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", dependsOn: ["t3#build"], cache: false, }, "dev:bundle": { - command: "vp pack --watch", + command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch", cache: false, }, "dev:electron": { diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 188a8d32d18c2f1d2626f04edf939abc292476c7..1bfd042d07825681b007278b56f956699c7e1b85 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -205,6 +205,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); + assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); }), diff --git a/apps/server/src/mcp/Layers/McpHttpServer.test.ts b/apps/server/src/mcp/Layers/McpHttpServer.test.ts index 908436341300d45062eafce8379d2436141f9183..374d147d8029c8667930297a159d03e67f1af052 100644 --- a/apps/server/src/mcp/Layers/McpHttpServer.test.ts +++ b/apps/server/src/mcp/Layers/McpHttpServer.test.ts @@ -75,14 +75,16 @@ it.effect("registers annotated tools and preserves authenticated request context height: 5, }, } - : { - available: true, - visible: true, - tabId, - url: "http://example.test/", - title: "Example", - loading: false, - }, + : request.operation === "press" + ? undefined + : { + available: true, + visible: true, + tabId, + url: "http://example.test/", + title: "Example", + loading: false, + }, }), ).pipe(Effect.forkScoped); yield* Effect.yieldNow; @@ -146,6 +148,16 @@ it.effect("registers annotated tools and preserves authenticated request context expect(snapshot.structuredContent).toMatchObject({ screenshot: { mimeType: "image/png", width: 10, height: 5 }, }); + + const press = yield* server + .callTool({ name: "preview_press", arguments: { key: "Enter" } }) + .pipe( + Effect.provideService(McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(press.isError).toBe(false); + expect(press.structuredContent).toBeUndefined(); + expect(press.content).toEqual([{ type: "text", text: "null" }]); }), ).pipe(Effect.provide(TestLayer)), ); diff --git a/apps/server/src/mcp/Layers/McpHttpServer.ts b/apps/server/src/mcp/Layers/McpHttpServer.ts index 645a8fdc3c8456fcf4f75145204b799be45c5e4b..356e2eabbef3bee71d766e7073c8ab3b0187460b 100644 --- a/apps/server/src/mcp/Layers/McpHttpServer.ts +++ b/apps/server/src/mcp/Layers/McpHttpServer.ts @@ -143,11 +143,14 @@ export const PreviewToolkitRegistrationLive = Layer.effectDiscard( ], }); } + const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; return new McpSchema.CallToolResult({ isError: false, structuredContent: - typeof result.encodedResult === "object" ? result.encodedResult : undefined, - content: [{ type: "text", text: JSON.stringify(result.encodedResult) }], + result.encodedResult !== null && typeof result.encodedResult === "object" + ? result.encodedResult + : undefined, + content: [{ type: "text", text: encodedResultText }], }); }, }), diff --git a/apps/server/src/preview/Layers/PortScanner.test.ts b/apps/server/src/preview/Layers/PortScanner.test.ts index e0bec8cd4d3a4e53da29de5580e0f19eaf24c921..f0e78aeedbc7f281bf570f7feb15442ef451c3b6 100644 --- a/apps/server/src/preview/Layers/PortScanner.test.ts +++ b/apps/server/src/preview/Layers/PortScanner.test.ts @@ -1,18 +1,20 @@ import * as net from "node:net"; import { it as effectIt } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; import { Effect, Layer } from "effect"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { COMMON_DEV_PORTS, PreviewPortScanner } from "../Services/PortScanner.ts"; +import { COMMON_DEV_PORTS, PortDiscovery } from "../Services/PortScanner.ts"; import { ProcessRunner } from "../../processRunner.ts"; -import { __testing, PreviewPortScannerLive } from "./PortScanner.ts"; +import { __testing, PortDiscoveryLive } from "./PortScanner.ts"; -const { parseLsofOutput, parsePortFromLsofName, serversEqual } = __testing; +const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = + __testing; const TestProcessRunner = Layer.succeed(ProcessRunner, { run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), }); -const TestPreviewPortScannerLive = PreviewPortScannerLive.pipe(Layer.provide(TestProcessRunner)); +const TestPortDiscoveryLive = PortDiscoveryLive.pipe(Layer.provide(TestProcessRunner)); describe("parsePortFromLsofName", () => { it("parses *:port", () => { @@ -70,6 +72,7 @@ describe("parseLsofOutput", () => { url: "http://localhost:3000", processName: "next-server", pid: 67890, + terminal: null, }, { host: "localhost", @@ -77,6 +80,7 @@ describe("parseLsofOutput", () => { url: "http://localhost:5173", processName: "node", pid: 12345, + terminal: null, }, { host: "localhost", @@ -84,6 +88,7 @@ describe("parseLsofOutput", () => { url: "http://localhost:9229", processName: "next-server", pid: 67890, + terminal: null, }, ]); }); @@ -98,6 +103,26 @@ describe("parseLsofOutput", () => { expect(servers).toHaveLength(1); expect(servers[0]?.port).toBe(5173); }); + + it("attributes listeners to a registered terminal process", () => { + const servers = parseLsofOutput( + ["p12345", "cnode", "n*:5173"].join("\n"), + new Map([ + [ + 12345, + { + threadId: ThreadId.make("thread-1"), + terminalId: "terminal-1", + }, + ], + ]), + ); + + expect(servers[0]?.terminal).toEqual({ + threadId: "thread-1", + terminalId: "terminal-1", + }); + }); }); describe("serversEqual", () => { @@ -107,6 +132,7 @@ describe("serversEqual", () => { url: "http://localhost:5173", processName: "node", pid: 1, + terminal: null, }; it("returns true for identical lists", () => { expect(serversEqual([a], [{ ...a }])).toBe(true); @@ -119,12 +145,43 @@ describe("serversEqual", () => { }); }); +describe("parseWindowsListenerOutput", () => { + it("parses and attributes PowerShell listener records", () => { + const servers = parseWindowsListenerOutput( + "0.0.0.0|5173|12345|node", + new Map([ + [ + 12345, + { + threadId: ThreadId.make("thread-1"), + terminalId: "terminal-1", + }, + ], + ]), + ); + + expect(servers).toEqual([ + { + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 12345, + terminal: { + threadId: "thread-1", + terminalId: "terminal-1", + }, + }, + ]); + }); +}); + /** * Integration tests against a real TCP listener. We force the Windows code * path (TCP-probe fallback) by monkey-patching `process.platform` for the * duration of the test so we don't depend on `lsof` being installed. */ -describe("PreviewPortScanner integration (TCP probe path)", () => { +describe("PortDiscovery integration (TCP probe fallback)", () => { let originalPlatform: NodeJS.Platform; let server: net.Server; let port: number; @@ -158,18 +215,18 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { effectIt.effect("scan() returns a server we just opened on a curated dev port", () => Effect.gen(function* () { - const scanner = yield* PreviewPortScanner; + const scanner = yield* PortDiscovery; const result = yield* scanner.scan(); const found = result.find((server) => server.port === port); expect(found).toBeDefined(); expect(found?.host).toBe("localhost"); - }).pipe(Effect.provide(TestPreviewPortScannerLive)), + }).pipe(Effect.provide(TestPortDiscoveryLive)), ); effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { const received: number[] = []; return Effect.gen(function* () { - const scanner = yield* PreviewPortScanner; + const scanner = yield* PortDiscovery; const unsubscribe = yield* scanner.subscribe((servers) => Effect.sync(() => { for (const server of servers) received.push(server.port); @@ -179,6 +236,6 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { unsubscribe(); release(); expect(received).toContain(port); - }).pipe(Effect.provide(TestPreviewPortScannerLive)); + }).pipe(Effect.provide(TestPortDiscoveryLive)); }); }); diff --git a/apps/server/src/preview/Layers/PortScanner.ts b/apps/server/src/preview/Layers/PortScanner.ts index fa0bd2b05201349913f9bbcbb21f13d9299798f2..4e4d3b758a313c1b63e0284fef4bbf2292507615 100644 --- a/apps/server/src/preview/Layers/PortScanner.ts +++ b/apps/server/src/preview/Layers/PortScanner.ts @@ -13,32 +13,42 @@ */ import * as net from "node:net"; -import type { DiscoveredLocalServer } from "@t3tools/contracts"; +import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; -import { Cause, Data, Duration, Effect, Layer, Ref, Schedule } from "effect"; +import { Cause, Duration, Effect, Layer, Ref, Schedule } from "effect"; -import { ProcessRunner, type ProcessRunnerShape } from "../../processRunner.ts"; +import { ProcessRunner } from "../../processRunner.ts"; import { COMMON_DEV_PORTS, - PreviewPortScanner, - type PreviewPortScannerShape, + PortDiscovery, + type PortDiscoveryShape, } from "../Services/PortScanner.ts"; const POLL_INTERVAL = Duration.seconds(3); const TCP_PROBE_TIMEOUT_MS = 200; const LSOF_TIMEOUT_MS = 5_000; +const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; type Listener = (servers: ReadonlyArray) => Effect.Effect; -class LsofProbeError extends Data.TaggedError("LsofProbeError")<{ - readonly cause: unknown; -}> {} - interface ScannerState { readonly lastSnapshot: ReadonlyArray; } -const parseLsofOutput = (raw: string): ReadonlyArray => { +interface TerminalProcessOwner { + readonly threadId: ThreadId; + readonly terminalId: string; +} + +const terminalOwnerKey = (owner: { + readonly threadId: string; + readonly terminalId: string; +}): string => `${owner.threadId}\u0000${owner.terminalId}`; + +const parseLsofOutput = ( + raw: string, + terminalByProcessId: ReadonlyMap = new Map(), +): ReadonlyArray => { const seen = new Map(); let pid: number | null = null; let processName: string | null = null; @@ -69,6 +79,7 @@ const parseLsofOutput = (raw: string): ReadonlyArray => { url, processName, pid, + terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), }); } } @@ -91,22 +102,31 @@ const parsePortFromLsofName = (name: string): number | null => { return port; }; -const probeLsof = ( - processRunner: ProcessRunnerShape, -): Effect.Effect | null> => - processRunner - .run({ - command: "lsof", - args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], - timeout: Duration.millis(LSOF_TIMEOUT_MS), - maxOutputBytes: 1024 * 1024, - outputMode: "truncate", - }) - .pipe( - Effect.mapError((cause) => new LsofProbeError({ cause })), - Effect.map((result) => parseLsofOutput(result.stdout)), - Effect.orElseSucceed(() => null), - ); +const parseWindowsListenerOutput = ( + raw: string, + terminalByProcessId: ReadonlyMap = new Map(), +): ReadonlyArray => { + const seen = new Map(); + for (const line of raw.split(/\r?\n/g)) { + const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); + const host = hostRaw?.trim() ?? ""; + if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; + const port = Number(portRaw); + const pid = Number(pidRaw); + if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; + const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; + if (seen.has(port)) continue; + seen.set(port, { + host: "localhost", + port, + url: `http://localhost:${port}`, + processName: processNameRaw?.trim() || null, + pid: normalizedPid, + terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), + }); + } + return [...seen.values()].toSorted((left, right) => left.port - right.port); +}; const probeTcpPort = (port: number): Promise => new Promise((resolve) => { @@ -138,6 +158,7 @@ const probeCommonPorts = (): Effect.Effect> url: `http://localhost:${r.port}`, processName: null, pid: null, + terminal: null, })); }); @@ -155,7 +176,9 @@ const serversEqual = ( a.port !== b.port || a.url !== b.url || a.processName !== b.processName || - a.pid !== b.pid + a.pid !== b.pid || + a.terminal?.threadId !== b.terminal?.threadId || + a.terminal?.terminalId !== b.terminal?.terminalId ) { return false; } @@ -163,12 +186,19 @@ const serversEqual = ( return true; }; -export const makePreviewPortScanner = Effect.gen(function* () { +export const makePortDiscovery = Effect.gen(function* () { const processRunner = yield* ProcessRunner; const stateRef = yield* Ref.make({ lastSnapshot: [], }); const listeners = new Set(); + const terminalProcesses = new Map< + string, + { + readonly owner: TerminalProcessOwner; + readonly processIds: ReadonlySet; + } + >(); // Plain integer because the release callback returned by `retain()` runs // outside any Effect context (the WS subscriber's release path) and must // be a synchronous side-effect-only function. @@ -176,11 +206,43 @@ export const makePreviewPortScanner = Effect.gen(function* () { const scanOnce = (): Effect.Effect> => Effect.gen(function* () { + const terminalByProcessId = new Map(); + for (const registration of terminalProcesses.values()) { + for (const processId of registration.processIds) { + terminalByProcessId.set(processId, registration.owner); + } + } if (process.platform === "win32") { + const command = + 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; + const listeners = yield* processRunner + .run({ + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (listeners !== null) return listeners; return yield* probeCommonPorts(); } - const lsof = yield* probeLsof(processRunner); - if (lsof !== null) return lsof; + const lsofResult = yield* processRunner + .run({ + command: "lsof", + args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], + timeout: Duration.millis(LSOF_TIMEOUT_MS), + maxOutputBytes: 1024 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (lsofResult !== null) return lsofResult; return yield* probeCommonPorts(); }); @@ -204,7 +266,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { // currently retained, so the cost is one Ref.get every POLL_INTERVAL. yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); - const retain: PreviewPortScannerShape["retain"] = () => + const retain: PortDiscoveryShape["retain"] = () => Effect.gen(function* () { const wasIdle = retainCount === 0; retainCount += 1; @@ -221,7 +283,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { }; }); - const subscribe: PreviewPortScannerShape["subscribe"] = (listener) => + const subscribe: PortDiscoveryShape["subscribe"] = (listener) => Effect.sync(() => { listeners.add(listener); return () => { @@ -229,18 +291,43 @@ export const makePreviewPortScanner = Effect.gen(function* () { }; }); + const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = (input) => + Effect.sync(() => { + const owner = { + threadId: ThreadId.make(input.threadId), + terminalId: input.terminalId, + }; + const processIds = new Set( + input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), + ); + const key = terminalOwnerKey(owner); + if (processIds.size === 0) { + terminalProcesses.delete(key); + return; + } + terminalProcesses.set(key, { owner, processIds }); + }); + + const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = (input) => + Effect.sync(() => { + terminalProcesses.delete(terminalOwnerKey(input)); + }); + return { scan: scanOnce, subscribe, retain, - } satisfies PreviewPortScannerShape; + registerTerminalProcesses, + unregisterTerminal, + } satisfies PortDiscoveryShape; }); -export const PreviewPortScannerLive = Layer.effect(PreviewPortScanner, makePreviewPortScanner); +export const PortDiscoveryLive = Layer.effect(PortDiscovery, makePortDiscovery); /** Exposed for tests. */ export const __testing = { parseLsofOutput, parsePortFromLsofName, + parseWindowsListenerOutput, serversEqual, }; diff --git a/apps/server/src/preview/Services/PortScanner.ts b/apps/server/src/preview/Services/PortScanner.ts index 4e8751bb6699b4a71b249738449d2b9c01800f45..af69596ba5a752b7c6983aa8e945f56941daf7ae 100644 --- a/apps/server/src/preview/Services/PortScanner.ts +++ b/apps/server/src/preview/Services/PortScanner.ts @@ -1,17 +1,17 @@ /** - * PreviewPortScanner - Discovers listening localhost ports for the preview - * empty-state recommendations. + * PortDiscovery - Discovers listening localhost ports and attributes them to + * registered terminal process families. * * Reference-counted polling: the scanner only runs when at least one client * has called `retain()`. This keeps idle desktops from running `lsof` every * 3 seconds for nothing. * - * @module PreviewPortScanner + * @module PortDiscovery */ import type { DiscoveredLocalServer } from "@t3tools/contracts"; import { Context, type Effect } from "effect"; -export interface PreviewPortScannerShape { +export interface PortDiscoveryShape { /** One-shot snapshot of currently listening localhost ports. */ readonly scan: () => Effect.Effect>; /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */ @@ -23,12 +23,22 @@ export interface PreviewPortScannerShape { * release fn. When release count hits 0, polling stops. */ readonly retain: () => Effect.Effect<() => void>; + /** Associate a terminal with its current process family for port attribution. */ + readonly registerTerminalProcesses: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray; + }) => Effect.Effect; + /** Remove process attribution for a terminal that stopped or closed. */ + readonly unregisterTerminal: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; } -export class PreviewPortScanner extends Context.Service< - PreviewPortScanner, - PreviewPortScannerShape ->()("t3/preview/Services/PortScanner/PreviewPortScanner") {} +export class PortDiscovery extends Context.Service()( + "t3/preview/Services/PortScanner/PortDiscovery", +) {} /** Curated list of common dev-server ports for the Windows TCP-probe fallback. */ export const COMMON_DEV_PORTS: ReadonlyArray = Object.freeze([ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c69771181f1020d8311a45178323f53577617ca7..e6d0f35cea7e8bd369cc25024f83e8e704c745d5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -100,7 +100,7 @@ import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRu import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; import { PreviewManager } from "./preview/Services/Manager.ts"; -import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; +import { PortDiscovery } from "./preview/Services/PortScanner.ts"; import { BrowserTraceCollector, type BrowserTraceCollectorShape, @@ -681,10 +681,12 @@ const buildAppUnderTest = (options?: { PubSub.subscribe(pubsub), ), }), - Layer.mock(PreviewPortScanner)({ + Layer.mock(PortDiscovery)({ scan: () => Effect.succeed([]), subscribe: () => Effect.succeed(() => {}), retain: () => Effect.succeed(() => {}), + registerTerminalProcesses: () => Effect.void, + unregisterTerminal: () => Effect.void, }), ), ), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e14a08b7952042a0149bbb71a950879aa41b5b3f..c1a1298a1aece658d6bbfa75eb39908a3738274f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -36,7 +36,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; -import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts"; +import { PortDiscoveryLive } from "./preview/Layers/PortScanner.ts"; import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; import * as ProcessRunner from "./processRunner.ts"; @@ -236,13 +236,15 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const PortScannerLayerLive = PortDiscoveryLive.pipe(Layer.provide(ProcessRunner.layer)); -const PreviewLayerLive = Layer.mergeAll( - PreviewManagerLive, - PreviewPortScannerLive.pipe(Layer.provide(ProcessRunner.layer)), +const TerminalLayerLive = TerminalManagerLive.pipe( + Layer.provide(PtyAdapterLive), + Layer.provide(PortScannerLayerLive), ); +const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PortScannerLayerLive); + const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(VcsDriverRegistryLayerLive), diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 2ebf8481957e67afc2b17596079b5d79776b88c3..30515ca2c47c4bc72957ec3cecf0c2a357bf3c34 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -204,6 +204,7 @@ interface CreateManagerOptions { subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; + readonly processIds: ReadonlyArray; }>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -745,7 +746,8 @@ it.layer( let inspect: { readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; - } = { hasRunningSubprocess: false, childCommand: null }; + readonly processIds: ReadonlyArray; + } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; const { manager, getEvents } = yield* createManager(5, { subprocessInspector: () => Effect.succeed(inspect), subprocessPollIntervalMs: 20, @@ -754,7 +756,7 @@ it.layer( yield* manager.open(openInput()); expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); - inspect = { hasRunningSubprocess: true, childCommand: "vim" }; + inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; yield* waitFor( Effect.map(getEvents, (events) => events.some( @@ -767,7 +769,7 @@ it.layer( "1200 millis", ); - inspect = { hasRunningSubprocess: false, childCommand: null }; + inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; yield* waitFor( Effect.map(getEvents, (events) => events.some( @@ -788,7 +790,11 @@ it.layer( const { manager } = yield* createManager(5, { subprocessInspector: () => { checks += 1; - return Effect.succeed({ hasRunningSubprocess: false, childCommand: null }); + return Effect.succeed({ + hasRunningSubprocess: false, + childCommand: null, + processIds: [], + }); }, subprocessPollIntervalMs: 20, }); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index cd490de1e3f3e439d0e742e8b51aae0975329c8e..acde17fc85fc40750e897369fb9ef196bafde053 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -33,6 +33,7 @@ import { terminalSessionsTotal, } from "../../observability/Metrics.ts"; import * as ProcessRunner from "../../processRunner.ts"; +import { PortDiscovery } from "../../preview/Services/PortScanner.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -82,6 +83,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass; } interface TerminalSubprocessInspector { @@ -505,12 +507,8 @@ function windowsInspectSubprocess( TerminalSubprocessCheckError, ProcessRunner.ProcessRunner > { - const command = [ - `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`, - "if ($null -eq $c) { exit 1 }", - "Write-Output $c.Name", - "exit 0", - ].join("; "); + const command = + 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; return Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; return yield* processRunner.run({ @@ -524,16 +522,41 @@ function windowsInspectSubprocess( }).pipe( Effect.map((result) => { if (result.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null } as const; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; } - const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? ""; - if (name.length === 0) { - return { hasRunningSubprocess: true, childCommand: null } as const; + const processNameById = new Map(); + const childrenByParent = new Map(); + for (const line of result.stdout.split(/\r?\n/g)) { + const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); + const pid = Number(pidRaw); + const parentPid = Number(parentPidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; + processNameById.set(pid, nameRaw?.trim() ?? ""); + const children = childrenByParent.get(parentPid) ?? []; + children.push(pid); + childrenByParent.set(parentPid, children); } - const normalized = normalizeChildCommandName(name, platform); + const directChildren = childrenByParent.get(terminalPid) ?? []; + const childPid = directChildren[0]; + if (childPid === undefined) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; + } + const processIds = new Set([terminalPid]); + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const pid of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(pid)) continue; + processIds.add(pid); + pending.push(pid); + } + } + const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], } as const; }), Effect.mapError( @@ -606,14 +629,14 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func if (pgrepResult.value.code === 0) { childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); } else if (pgrepResult.value.code === 1) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } } if (childPid === null) { const psResult = yield* Effect.exit(runPs); if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } for (const line of psResult.value.stdout.split(/\r?\n/g)) { const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); @@ -628,7 +651,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func } if (childPid === null) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } const runComm = processRunner.run({ @@ -663,16 +686,43 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func } const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; + const processIds = new Set([terminalPid]); + const psResult = yield* Effect.exit(runPs); + if (psResult._tag === "Success" && psResult.value.code === 0) { + const childrenByParent = new Map(); + for (const line of psResult.value.stdout.split(/\r?\n/g)) { + const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + const children = childrenByParent.get(ppid) ?? []; + children.push(pid); + childrenByParent.set(ppid, children); + } + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const child of childrenByParent.get(parentPid) ?? []) { + if (processIds.has(child)) continue; + processIds.add(child); + pending.push(child); + } + } + } else { + processIds.add(childPid); + } return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], }; }); function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return { hasRunningSubprocess: false, childCommand: null }; + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } if (platform === "win32") { return yield* windowsInspectSubprocess(terminalPid, platform); @@ -932,14 +982,26 @@ interface TerminalManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; + registerTerminalProcesses?: (input: { + readonly threadId: string; + readonly terminalId: string; + readonly processIds: ReadonlyArray; + }) => Effect.Effect; + unregisterTerminal?: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; } const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { const { terminalLogsDir } = yield* ServerConfig; const ptyAdapter = yield* PtyAdapter; + const portDiscovery = yield* PortDiscovery; return yield* makeTerminalManagerWithOptions({ logsDir: terminalLogsDir, ptyAdapter, + registerTerminalProcesses: portDiscovery.registerTerminalProcesses, + unregisterTerminal: portDiscovery.unregisterTerminal, }); }); @@ -967,6 +1029,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; const maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; + const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); + const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); @@ -1495,6 +1559,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } yield* clearKillFiber(action.process); + yield* unregisterTerminal({ + threadId: action.threadId, + terminalId: action.terminalId, + }); yield* publishEvent({ type: "exited", threadId: action.threadId, @@ -1531,6 +1599,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); yield* clearKillFiber(process); + yield* unregisterTerminal({ + threadId: session.threadId, + terminalId: session.terminalId, + }); yield* startKillEscalation(process, session.threadId, session.terminalId); yield* evictInactiveSessionsIfNeeded(); }); @@ -1700,6 +1772,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith advanceEventSequence(session); return [undefined, state] as const; }); + yield* unregisterTerminal({ + threadId: session.threadId, + terminalId: session.terminalId, + }); yield* evictInactiveSessionsIfNeeded(); @@ -1731,6 +1807,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith if (Option.isSome(session)) { yield* stopProcess(session.value); + yield* unregisterTerminal({ threadId, terminalId }); yield* persistHistory(threadId, terminalId, session.value.history); } @@ -1791,6 +1868,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } const next = inspectResult.value; + yield* registerTerminalProcesses({ + threadId: session.threadId, + terminalId: session.terminalId, + processIds: next.processIds, + }); const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; const event = yield* modifyManagerState((state) => { const liveSession: Option.Option = Option.fromNullishOr( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 900e80dfa15a99c77719e5bd875c0f9dff2a8b67..04ac8f5a62b29aa1a7542ab2e811969ca887fbcd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -72,7 +72,7 @@ import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { PreviewManager } from "./preview/Services/Manager.ts"; -import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; +import { PortDiscovery } from "./preview/Services/PortScanner.ts"; import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; @@ -257,7 +257,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; const previewManager = yield* PreviewManager; - const previewPortScanner = yield* PreviewPortScanner; + const portDiscovery = yield* PortDiscovery; const providerRegistry = yield* ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; @@ -1426,14 +1426,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Stream.callback((queue) => Effect.acquireRelease( Effect.gen(function* () { - const release = yield* previewPortScanner.retain(); - const initial = yield* previewPortScanner.scan(); + const release = yield* portDiscovery.retain(); + const initial = yield* portDiscovery.scan(); const initialScannedAt = DateTime.formatIso(yield* DateTime.now); yield* Queue.offer(queue, { servers: initial, scannedAt: initialScannedAt, }); - const unsubscribe = yield* previewPortScanner.subscribe((servers) => + const unsubscribe = yield* portDiscovery.subscribe((servers) => Effect.gen(function* () { const scannedAt = DateTime.formatIso(yield* DateTime.now); yield* Queue.offer(queue, { servers, scannedAt }); diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index cc611a22993efc6ec0d6bfe50b7e3a968fa26f0a..feac8ed0f2260bb4d7b8fb764d69d9964afa3c8a 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,14 +1,18 @@ "use client"; import { parseScopedThreadKey } from "@t3tools/client-runtime"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { isElectron } from "~/env"; +import { useTheme } from "~/hooks/useTheme"; import { usePreviewStateStore } from "~/previewStateStore"; +import { readPreviewAnnotationTheme } from "./annotationTheme"; +import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; export function ElectronBrowserHost() { + const { resolvedTheme } = useTheme(); const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); const sessions = useMemo( () => @@ -25,6 +29,47 @@ export function ElectronBrowserHost() { [previewByThreadKey], ); + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview) return; + + let lastSerializedTheme = ""; + const syncTheme = () => { + const theme = readPreviewAnnotationTheme(); + const serializedTheme = JSON.stringify(theme); + if (serializedTheme === lastSerializedTheme) return; + lastSerializedTheme = serializedTheme; + void preview.setAnnotationTheme(theme).catch(() => { + lastSerializedTheme = ""; + }); + }; + const frameId = window.requestAnimationFrame(syncTheme); + const observer = new MutationObserver(syncTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + const headObserver = new MutationObserver(syncTheme); + headObserver.observe(document.head, { + childList: true, + subtree: true, + characterData: true, + }); + return () => { + window.cancelAnimationFrame(frameId); + observer.disconnect(); + headObserver.disconnect(); + }; + }, [resolvedTheme]); + + useEffect(() => { + const preview = window.desktopBridge?.preview; + if (!preview) return; + return preview.onPointerEvent((event) => { + useBrowserPointerStore.getState().apply(event); + }); + }, []); + if (!isElectron) return null; return (
diff --git a/apps/web/src/browser/annotationTheme.ts b/apps/web/src/browser/annotationTheme.ts new file mode 100644 index 0000000000000000000000000000000000000000..e12c667d23d705e48773cee68d521bf8f759d6d8 --- /dev/null +++ b/apps/web/src/browser/annotationTheme.ts @@ -0,0 +1,28 @@ +import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; + +const readVariable = (styles: CSSStyleDeclaration, name: string, fallback: string): string => + styles.getPropertyValue(name).trim() || fallback; + +export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme { + const root = document.documentElement; + const styles = getComputedStyle(root); + return { + colorScheme: root.classList.contains("dark") ? "dark" : "light", + radius: readVariable(styles, "--radius", "0.625rem"), + background: readVariable(styles, "--background", "white"), + foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"), + popover: readVariable(styles, "--popover", "white"), + popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"), + primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"), + primaryForeground: readVariable(styles, "--primary-foreground", "white"), + muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"), + mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"), + accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"), + accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"), + border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"), + input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"), + ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"), + fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"), + fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"), + }; +} diff --git a/apps/web/src/browser/browserPointerStore.test.ts b/apps/web/src/browser/browserPointerStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..de9c173dc2da761d0f7168f87bc8ccb781fe8859 --- /dev/null +++ b/apps/web/src/browser/browserPointerStore.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { useBrowserPointerStore } from "./browserPointerStore"; + +beforeEach(() => { + useBrowserPointerStore.setState({ byTabId: {} }); +}); + +describe("browserPointerStore", () => { + it("tracks the latest pointer target independently for each tab", () => { + const store = useBrowserPointerStore.getState(); + store.apply({ + tabId: "tab_a", + phase: "move", + x: 20, + y: 30, + sequence: 0, + createdAt: "2026-06-12T00:00:00.000Z", + }); + store.apply({ + tabId: "tab_b", + phase: "move", + x: 40, + y: 50, + sequence: 1, + createdAt: "2026-06-12T00:00:01.000Z", + }); + store.apply({ + tabId: "tab_a", + phase: "click", + x: 60, + y: 70, + sequence: 2, + createdAt: "2026-06-12T00:00:02.000Z", + }); + + expect(useBrowserPointerStore.getState().byTabId).toMatchObject({ + tab_a: { phase: "click", x: 60, y: 70, sequence: 2 }, + tab_b: { phase: "move", x: 40, y: 50, sequence: 1 }, + }); + }); + + it("clears one tab without affecting the others", () => { + const store = useBrowserPointerStore.getState(); + store.apply({ + tabId: "tab_a", + phase: "move", + x: 20, + y: 30, + sequence: 0, + createdAt: "2026-06-12T00:00:00.000Z", + }); + store.apply({ + tabId: "tab_b", + phase: "move", + x: 40, + y: 50, + sequence: 1, + createdAt: "2026-06-12T00:00:01.000Z", + }); + + store.clear("tab_a"); + + expect(useBrowserPointerStore.getState().byTabId).toEqual({ + tab_b: expect.objectContaining({ x: 40, y: 50 }), + }); + }); +}); diff --git a/apps/web/src/browser/browserPointerStore.ts b/apps/web/src/browser/browserPointerStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9f905ddc8f840b8a227beadafee48056c81e8f7 --- /dev/null +++ b/apps/web/src/browser/browserPointerStore.ts @@ -0,0 +1,25 @@ +import type { DesktopPreviewPointerEvent } from "@t3tools/contracts"; +import { create } from "zustand"; + +interface BrowserPointerStoreState { + readonly byTabId: Record; + readonly apply: (event: DesktopPreviewPointerEvent) => void; + readonly clear: (tabId: string) => void; +} + +export const useBrowserPointerStore = create()((set) => ({ + byTabId: {}, + apply: (event) => + set((state) => ({ + byTabId: { + ...state.byTabId, + [event.tabId]: event, + }, + })), + clear: (tabId) => + set((state) => { + if (!(tabId in state.byTabId)) return state; + const { [tabId]: _removed, ...byTabId } = state.byTabId; + return { byTabId }; + }), +})); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e1f43bda46f10255c3eca735b086e9842f2b4d76..249434a60d78b40d25556150bbf809f53bb48b50 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -65,6 +65,7 @@ import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; +import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { terminalSessionManager } from "../terminalSessionState"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -1801,6 +1802,8 @@ describe("ChatView timeline estimator parity (full app)", () => { useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, }); + useRightPanelStore.persist.clearStorage(); + useRightPanelStore.setState({ byThreadKey: {} }); }); afterEach(() => { @@ -2054,12 +2057,15 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const toggle = await waitForElement( - () => - document.querySelector('button[aria-label="Toggle terminal drawer"]'), - "Unable to find terminal drawer toggle.", + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "j", + metaKey: isMacPlatform(navigator.platform), + ctrlKey: !isMacPlatform(navigator.platform), + bubbles: true, + cancelable: true, + }), ); - toggle.click(); await vi.waitFor( () => { @@ -2072,6 +2078,10 @@ describe("ChatView timeline estimator parity (full app)", () => { terminalId: DEFAULT_TERMINAL_ID, cwd: "/repo/project", }); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) + .isOpen, + ).toBe(false); }, { timeout: 8_000, interval: 16 }, ); @@ -2080,6 +2090,129 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps multiple terminal panel surfaces separate from the bottom drawer", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId, + targetText: "open inline terminal panel", + }), + }); + + try { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "b", + altKey: true, + metaKey: isMacPlatform(navigator.platform), + ctrlKey: !isMacPlatform(navigator.platform), + bubbles: true, + cancelable: true, + }), + ); + + const addSurface = await waitForElement( + () => document.querySelector('button[aria-label="Add panel surface"]'), + "Unable to find add panel surface button.", + ); + expect(document.body.textContent).toContain("Open a surface"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + expect(wsRequests.some((request) => request._tag === WS_METHODS.terminalOpen)).toBe(false); + + addSurface.click(); + + const terminalItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent?.trim() === "Terminal", + ) ?? null, + "Unable to find Terminal panel menu item.", + ); + terminalItem.click(); + + await vi.waitFor(() => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) + .surfaces.filter((surface) => surface.kind === "terminal") + .map((surface) => surface.resourceId), + ).toEqual(["term-1"]); + }); + + addSurface.click(); + const secondTerminalItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent?.trim() === "Terminal", + ) ?? null, + "Unable to find Terminal panel menu item.", + ); + secondTerminalItem.click(); + + await vi.waitFor( + () => { + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) + .surfaces.filter((surface) => surface.kind === "terminal") + .map((surface) => surface.resourceId), + ).toEqual(["term-1", "term-2"]); + expect( + document.querySelector('[data-preview-panel-mode="inline"] .thread-terminal-drawer'), + ).not.toBeNull(); + expect( + wsRequests + .filter((request) => request._tag === WS_METHODS.terminalOpen) + .map((request) => ("terminalId" in request ? request.terminalId : null)), + ).toEqual(expect.arrayContaining(["term-1", "term-2"])); + const attachRequest = wsRequests.find( + (request) => + request._tag === WS_METHODS.terminalAttach && + "terminalId" in request && + request.terminalId === "term-2", + ); + expect(attachRequest).toMatchObject({ + _tag: WS_METHODS.terminalAttach, + threadId: THREAD_ID, + terminalId: "term-2", + cwd: "/repo/project", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + + const drawerToggle = await waitForElement( + () => + document.querySelector('button[aria-label="Toggle terminal drawer"]'), + "Unable to find terminal drawer toggle.", + ); + drawerToggle.click(); + + await vi.waitFor(() => { + expect( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey[THREAD_KEY], + ).toMatchObject({ + terminalOpen: true, + terminalIds: ["term-3"], + }); + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.terminalAttach && + "terminalId" in request && + request.terminalId === "term-3", + ), + ).toBe(true); + }); + } finally { + await mounted.cleanup(); + } + }); + it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { setDraftThreadWithoutWorktree(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0bda0fb7eddb35ec195b4244cd15a6a9c6bca3e7..b2686f59c70e92f7b5a593170e7f2e32beb9eda6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -103,6 +103,7 @@ import { usePreviewStateStore, } from "../previewStateStore"; import { subscribePreviewAction } from "./preview/previewActionBus"; +import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; // Lazy: keeps the entire preview component graph (webview host, favicon // helper, Chromium error icon) out of the web bundle until first open. const PreviewPanel = lazy(() => @@ -129,7 +130,7 @@ import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; -import { isTerminalFocused } from "../lib/terminalFocus"; +import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -539,6 +540,7 @@ interface PersistentThreadTerminalDrawerProps { launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; + splitVerticalShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; @@ -553,6 +555,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra launchContext, focusRequestId, splitShortcutLabel, + splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, keybindings, @@ -573,16 +576,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra environmentId: threadRef.environmentId, threadId, }); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + const panelTerminalIds = useMemo( + () => + new Set( + panelSurfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [panelSurfaces], + ); + const drawerTerminalSessions = useMemo( + () => + knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), + [knownTerminalSessions, panelTerminalIds], + ); const terminalLabelsById = useMemo(() => { const next = new Map(); - for (const session of knownTerminalSessions) { + for (const session of drawerTerminalSessions) { next.set( session.target.terminalId, resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } return next; - }, [knownTerminalSessions]); + }, [drawerTerminalSessions]); const terminalLaunchLocationsById = useMemo(() => { const next = new Map< string, @@ -596,7 +616,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra return next; } - for (const session of knownTerminalSessions) { + for (const session of drawerTerminalSessions) { const summary = session.state.summary; if (!summary) { continue; @@ -614,13 +634,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra } return next; - }, [knownTerminalSessions, launchContext, project]); + }, [drawerTerminalSessions, launchContext, project]); const serverOrderedTerminalIds = useMemo( - () => knownTerminalSessions.map((session) => session.target.terminalId), - [knownTerminalSessions], + () => drawerTerminalSessions.map((session) => session.target.terminalId), + [drawerTerminalSessions], ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore( + (state) => state.splitTerminalVertical, + ); const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); @@ -712,6 +735,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra threadId, threadRef, ]); + const splitTerminalVertical = useCallback(() => { + const api = readEnvironmentApi(threadRef.environmentId); + if (!api || !cwd) { + return; + } + const terminalId = nextTerminalId(serverOrderedTerminalIds); + storeSplitTerminalVertical(threadRef, terminalId); + bumpFocusRequestId(); + void api.terminal + .open({ + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }) + .catch(() => undefined); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + runtimeEnv, + serverOrderedTerminalIds, + storeSplitTerminalVertical, + threadId, + threadRef, + ]); const createNewTerminal = useCallback(() => { const api = readEnvironmentApi(threadRef.environmentId); @@ -814,8 +864,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra activeTerminalGroupId={terminalUiState.activeTerminalGroupId} focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} onSplitTerminal={splitTerminal} + onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} splitShortcutLabel={visible ? splitShortcutLabel : undefined} + splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} closeShortcutLabel={visible ? closeShortcutLabel : undefined} keybindings={keybindings} @@ -830,6 +882,177 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); }); +interface PersistentThreadTerminalPanelProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; + surface: Extract; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; + onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; + splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; +} + +const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ + threadRef, + surface, + launchContext, + focusRequestId, + keybindings, + onAddTerminalContext, + onSplitTerminal, + onSplitTerminalVertical, + onNewTerminal, + onActiveTerminalChange, + onCloseTerminal, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, +}: PersistentThreadTerminalPanelProps) { + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const terminalSummary = + knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) + ?.state.summary ?? null; + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const worktreePath = + launchContext?.worktreePath ?? terminalSummary?.worktreePath ?? threadWorktreePath; + const cwd = useMemo( + () => + launchContext?.cwd ?? + terminalSummary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.cwd }, + worktreePath, + }) + : null), + [launchContext?.cwd, project, terminalSummary?.cwd, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + const terminalLabelsById = useMemo(() => { + const labels = new Map(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); + } + return labels; + }, [knownTerminalSessions, surface.terminalIds]); + const terminalLaunchLocationsById = useMemo(() => { + const locations = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + const terminalWorktreePath = + launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; + const terminalCwd = + launchContext?.cwd ?? + summary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.cwd }, + worktreePath: terminalWorktreePath, + }) + : null); + if (!terminalCwd || !project) continue; + locations.set(terminalId, { + cwd: terminalCwd, + worktreePath: terminalWorktreePath, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath: terminalWorktreePath, + }), + }); + } + return locations; + }, [ + knownTerminalSessions, + launchContext?.cwd, + launchContext?.worktreePath, + project, + surface.terminalIds, + threadWorktreePath, + ]); + + if (!project || !cwd) { + return null; + } + + return ( + undefined} + onAddTerminalContext={onAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} + keybindings={keybindings} + /> + ); +}); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -967,6 +1190,7 @@ export default function ChatView(props: ChatViewProps) { ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); @@ -1029,34 +1253,22 @@ export default function ChatView(props: ChatViewProps) { () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], [activeServerOrderedTerminalIds, terminalUiState.terminalIds], ); + const activeTerminalLabelsById = useMemo(() => { + const next = new Map(); + for (const session of activeThreadKnownSessions) { + next.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return next; + }, [activeThreadKnownSessions]); const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; - useEffect(() => { - if (!activeThreadRef) { - return; - } - if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) { - return; - } - if ( - serverTerminalIdsStrictSubsetOfClient( - activeServerOrderedTerminalIds, - terminalUiState.terminalIds, - ) - ) { - return; - } - reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds); - }, [ - activeThreadRef, - activeServerOrderedTerminalIds, - reconcileTerminalIds, - terminalUiState.terminalIds, - ]); const activeRightPanelKind = useRightPanelStore((store) => selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), ); @@ -1069,10 +1281,45 @@ export default function ChatView(props: ChatViewProps) { const activePreviewState = usePreviewStateStore((state) => selectThreadPreviewState(state.byThreadKey, activeThreadRef), ); + const panelTerminalIds = useMemo( + () => + new Set( + rightPanelState.surfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [rightPanelState.surfaces], + ); + const drawerServerOrderedTerminalIds = useMemo( + () => activeServerOrderedTerminalIds.filter((terminalId) => !panelTerminalIds.has(terminalId)), + [activeServerOrderedTerminalIds, panelTerminalIds], + ); + useEffect(() => { + if (!activeThreadRef) { + return; + } + if (terminalIdListsEqual(drawerServerOrderedTerminalIds, terminalUiState.terminalIds)) { + return; + } + if ( + serverTerminalIdsStrictSubsetOfClient( + drawerServerOrderedTerminalIds, + terminalUiState.terminalIds, + ) + ) { + return; + } + reconcileTerminalIds(activeThreadRef, drawerServerOrderedTerminalIds); + }, [ + activeThreadRef, + drawerServerOrderedTerminalIds, + reconcileTerminalIds, + terminalUiState.terminalIds, + ]); const planSidebarOpen = activeRightPanelKind === "plan"; const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); - const terminalPanelOpen = activeRightPanelKind === "terminal"; - const rightPanelOpen = activeRightPanelSurface !== null; + const rightPanelOpen = rightPanelState.isOpen; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; useEffect(() => { if (!activeThreadRef) return; @@ -1085,15 +1332,6 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !diffOpen) return; useRightPanelStore.getState().open(activeThreadRef, "diff"); }, [activeThreadRef, diffOpen]); - useEffect(() => { - if (!activeThreadRef || !terminalUiState.terminalOpen) return; - const state = selectThreadRightPanelState( - useRightPanelStore.getState().byThreadKey, - activeThreadRef, - ); - if (state.surfaces.some((surface) => surface.kind === "terminal")) return; - useRightPanelStore.getState().open(activeThreadRef, "terminal"); - }, [activeThreadRef, terminalUiState.terminalOpen]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -1115,6 +1353,10 @@ export default function ChatView(props: ChatViewProps) { const activeProject = useStore( useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); + const configuredPreviewUrls = useMemo( + () => getConfiguredPreviewUrls(activeProject?.scripts), + [activeProject?.scripts], + ); useEffect(() => { if (routeKind !== "server") { @@ -1906,6 +2148,11 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); + const splitTerminalVerticalShortcutLabel = useMemo( + () => + shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], + ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], @@ -1988,7 +2235,8 @@ export default function ChatView(props: ChatViewProps) { if (!isServerThread) { return; } - if (!diffOpen) { + const diffPanelOpen = activeRightPanelKind === "diff"; + if (!diffPanelOpen) { onDiffPanelOpen?.(); if (activeThreadRef) { useRightPanelStore.getState().open(activeThreadRef, "diff"); @@ -2005,12 +2253,12 @@ export default function ChatView(props: ChatViewProps) { replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; + return diffPanelOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); }, [ + activeRightPanelKind, activeThreadRef, - diffOpen, environmentId, isServerThread, navigate, @@ -2105,61 +2353,184 @@ export default function ChatView(props: ChatViewProps) { const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; + if (open && terminalUiState.terminalIds.length === 0) { + storeNewTerminal( + activeThreadRef, + nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]), + ); + return; + } storeSetTerminalOpen(activeThreadRef, open); }, - [activeThreadRef, storeSetTerminalOpen], + [ + activeKnownTerminalIds, + activeThreadRef, + panelTerminalIds, + storeNewTerminal, + storeSetTerminalOpen, + terminalUiState.terminalIds.length, + ], + ); + const addTerminalSurface = useCallback(() => { + if (!activeThreadRef || !activeThreadId || !activeProject) return; + const api = readEnvironmentApi(activeThreadRef.environmentId); + const cwd = gitCwd ?? activeProject.cwd; + if (!api || !cwd) return; + const panelIds = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ).surfaces.flatMap((surface) => (surface.kind === "terminal" ? surface.terminalIds : [])); + const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelIds]); + useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); + setTerminalFocusRequestId((value) => value + 1); + void api.terminal + .open({ + threadId: activeThreadId, + terminalId, + cwd, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }) + .catch(() => undefined); + }, [ + activeKnownTerminalIds, + activeProject, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + gitCwd, + ]); + const splitPanelTerminal = useCallback( + (direction: "horizontal" | "vertical" = "horizontal") => { + if ( + !activeThreadRef || + !activeThreadId || + !activeProject || + activeRightPanelSurface?.kind !== "terminal" || + activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP + ) { + return; + } + const api = readEnvironmentApi(activeThreadRef.environmentId); + const cwd = gitCwd ?? activeProject.cwd; + if (!api || !cwd) return; + const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); + useRightPanelStore + .getState() + .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); + setTerminalFocusRequestId((value) => value + 1); + void api.terminal + .open({ + threadId: activeThreadId, + terminalId, + cwd, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }) + .catch(() => undefined); + }, + [ + activeKnownTerminalIds, + activeProject, + activeRightPanelSurface, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + gitCwd, + panelTerminalIds, + ], + ); + const splitPanelTerminalVertical = useCallback(() => { + splitPanelTerminal("vertical"); + }, [splitPanelTerminal]); + const activatePanelTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + useRightPanelStore + .getState() + .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeRightPanelSurface, activeThreadRef], + ); + const closePanelTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + const api = readEnvironmentApi(activeThreadRef.environmentId); + void api?.terminal + .close({ + threadId: activeThreadRef.threadId, + terminalId, + deleteHistory: true, + }) + .catch(() => undefined); + useRightPanelStore + .getState() + .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeRightPanelSurface, activeThreadRef], ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; - if (terminalPanelOpen) { - useRightPanelStore.getState().close(activeThreadRef); - return; - } - setTerminalOpen(true); - useRightPanelStore.getState().open(activeThreadRef, "terminal"); - }, [activeThreadRef, setTerminalOpen, terminalPanelOpen]); - const splitTerminal = useCallback(() => { - if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { - return; - } - const cwdForOpen = gitCwd ?? activeProject.cwd; - if (!cwdForOpen) { - return; - } - const api = readEnvironmentApi(environmentId); - if (!api) { - return; - } - const terminalId = nextTerminalId(activeKnownTerminalIds); - storeSplitTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - void (async () => { - try { - await api.terminal.open({ - threadId: activeThreadId, - terminalId, - cwd: cwdForOpen, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.cwd }, - worktreePath: activeThreadWorktreePath, - }), - }); - } catch { - // Opening failed; the tab is already in the store — user can retry or close it. + setTerminalOpen(!terminalUiState.terminalOpen); + }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); + const splitTerminal = useCallback( + (direction: "horizontal" | "vertical" = "horizontal") => { + if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { + return; } - })(); - }, [ - activeProject, - activeKnownTerminalIds, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - environmentId, - gitCwd, - hasReachedSplitLimit, - storeSplitTerminal, - ]); + const cwdForOpen = gitCwd ?? activeProject.cwd; + if (!cwdForOpen) { + return; + } + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + const terminalId = nextTerminalId(activeKnownTerminalIds); + if (direction === "vertical") { + storeSplitTerminalVertical(activeThreadRef, terminalId); + } else { + storeSplitTerminal(activeThreadRef, terminalId); + } + setTerminalFocusRequestId((value) => value + 1); + void (async () => { + try { + await api.terminal.open({ + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }); + } catch { + // Opening failed; the tab is already in the store — user can retry or close it. + } + })(); + }, + [ + activeProject, + activeKnownTerminalIds, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + environmentId, + gitCwd, + hasReachedSplitLimit, + storeSplitTerminal, + storeSplitTerminalVertical, + ], + ); const createNewTerminal = useCallback(() => { if (!activeThreadRef || !activeThreadId || !activeProject) { return; @@ -2552,15 +2923,7 @@ export default function ChatView(props: ChatViewProps) { const closePreviewPanel = useCallback(() => { if (!activeThreadRef) return; useRightPanelStore.getState().close(activeThreadRef); - if (diffOpen) { - void navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId }, - replace: true, - search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), - }); - } - }, [activeThreadRef, diffOpen, environmentId, navigate, threadId]); + }, [activeThreadRef]); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; @@ -2569,7 +2932,7 @@ export default function ChatView(props: ChatViewProps) { usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); } if (surface.kind === "terminal") { - setTerminalOpen(true); + setTerminalFocusRequestId((value) => value + 1); } if (surface.kind === "diff" && !diffOpen) { onDiffPanelOpen?.(); @@ -2588,48 +2951,41 @@ export default function ChatView(props: ChatViewProps) { }); } }, - [ - activeThreadRef, - diffOpen, - environmentId, - navigate, - onDiffPanelOpen, - setTerminalOpen, - threadId, - ], + [activeThreadRef, diffOpen, environmentId, navigate, onDiffPanelOpen, threadId], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; - if (rightPanelOpen) { - closePreviewPanel(); - return; - } - const surface = rightPanelState.surfaces.at(-1) ?? null; - if (surface) { - activateRightPanelSurface(surface); - return; - } - createBrowserSurface(); - }, [ - activeThreadRef, - activateRightPanelSurface, - closePreviewPanel, - createBrowserSurface, - rightPanelOpen, - rightPanelState.surfaces, - ]); + useRightPanelStore.getState().toggleVisibility(activeThreadRef); + }, [activeThreadRef]); const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { + usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId); const api = readEnvironmentApi(activeThreadRef.environmentId); void api?.preview .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) .catch(() => undefined); } + useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + const nextActiveSurface = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ); + if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { + usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId); + } if (surface.kind === "terminal") { - setTerminalOpen(false); + const api = readEnvironmentApi(activeThreadRef.environmentId); + for (const terminalId of surface.terminalIds) { + void api?.terminal + .close({ + threadId: activeThreadRef.threadId, + terminalId, + deleteHistory: true, + }) + .catch(() => undefined); + } } if (surface.kind === "diff" && diffOpen) { void navigate({ @@ -2640,7 +2996,7 @@ export default function ChatView(props: ChatViewProps) { }); } }, - [activeThreadRef, diffOpen, environmentId, navigate, setTerminalOpen, threadId], + [activeThreadRef, diffOpen, environmentId, navigate, threadId], ); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -2920,8 +3276,9 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { return; } + const terminalFocusOwner = getTerminalFocusOwner(); const shortcutContext = { - terminalFocus: isTerminalFocused(), + terminalFocus: terminalFocusOwner !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; @@ -2950,9 +3307,20 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "rightPanel.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleRightPanel(); + return; + } + if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); + if (terminalFocusOwner === "right-panel") { + splitPanelTerminal(); + return; + } if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } @@ -2960,9 +3328,27 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "terminal.splitVertical") { + event.preventDefault(); + event.stopPropagation(); + if (terminalFocusOwner === "right-panel") { + splitPanelTerminal("vertical"); + return; + } + if (!terminalUiState.terminalOpen) { + setTerminalOpen(true); + } + splitTerminal("vertical"); + return; + } + if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); + if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { + closePanelTerminal(activeRightPanelSurface.activeTerminalId); + return; + } if (!terminalUiState.terminalOpen) return; closeTerminal(terminalUiState.activeTerminalId); return; @@ -2971,6 +3357,10 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); + if (terminalFocusOwner === "right-panel") { + addTerminalSurface(); + return; + } if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } @@ -3003,17 +3393,22 @@ export default function ChatView(props: ChatViewProps) { window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); }, [ + activeRightPanelSurface, activeProject, + addTerminalSurface, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, + closePanelTerminal, closeTerminal, createNewTerminal, setTerminalOpen, runProjectScript, splitTerminal, + splitPanelTerminal, keybindings, onToggleDiff, + toggleRightPanel, toggleTerminalVisibility, composerRef, ]); @@ -4026,6 +4421,7 @@ export default function ChatView(props: ChatViewProps) { ? cn( "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]", reserveTitleBarControlInset && + !inlineRightPanelOwnsTitleBar && "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", ) : "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) { } keybindings={keybindings} availableEditors={availableEditors} - rightPanelAvailable={isPreviewSupportedInRuntime() || isGitRepo} + rightPanelAvailable={Boolean(activeProject)} rightPanelOpen={rightPanelOpen} gitCwd={gitCwd} onRunProjectScript={runProjectScript} @@ -4238,33 +4634,67 @@ export default function ChatView(props: ChatViewProps) { {/* end chat column */}
{/* end horizontal flex container */} + {activeThreadRef ? ( + + ) : null} - {!shouldUsePlanSidebarSheet && - rightPanelOpen && - activeThreadRef && - activeRightPanelSurface ? ( + {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - {activeRightPanelSurface.kind === "preview" ? ( + {activeRightPanelSurface?.kind === "preview" ? ( - ) : activeRightPanelSurface.kind === "diff" ? ( + ) : activeRightPanelSurface?.kind === "terminal" ? ( + + ) : activeRightPanelSurface?.kind === "diff" ? ( @@ -4274,49 +4704,57 @@ export default function ChatView(props: ChatViewProps) { ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef && activeRightPanelSurface ? ( + {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - {activeRightPanelSurface.kind === "preview" ? ( + {activeRightPanelSurface?.kind === "preview" ? ( - ) : activeRightPanelSurface.kind === "terminal" ? ( - - ) : activeRightPanelSurface.kind === "diff" ? ( + ) : activeRightPanelSurface?.kind === "diff" ? ( - ) : ( + ) : activeRightPanelSurface?.kind === "plan" ? ( - )} + ) : null} ) : null} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index af2d118e8680cfaa5b9e1a6e6367e13d9b847cd5..7c07b61d931a4f25533fbdd4c0e016b7c3b5bd32 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,7 +1,9 @@ import type { PreviewSessionSnapshot } from "@t3tools/contracts"; +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; import { type ReactNode, useState } from "react"; +import { isElectron } from "~/env"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; @@ -12,25 +14,97 @@ import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanel interface RightPanelTabsProps { mode: PreviewPanelMode; surfaces: readonly RightPanelSurface[]; - activeSurfaceId: string; + activeSurfaceId: string | null; previewSessions: Readonly>; + terminalLabelsById: ReadonlyMap; onActivate: (surface: RightPanelSurface) => void; onCloseSurface: (surface: RightPanelSurface) => void; onAddBrowser: () => void; + onAddTerminal: () => void; onAddDiff: () => void; + browserAvailable: boolean; diffAvailable: boolean; children: ReactNode; } +function RightPanelEmptyState(props: { + onAddBrowser: () => void; + onAddTerminal: () => void; + onAddDiff: () => void; + browserAvailable: boolean; + diffAvailable: boolean; +}) { + const actions = [ + { + label: "Browser", + description: "Open a local app or URL.", + icon: Globe2, + available: props.browserAvailable, + onClick: props.onAddBrowser, + }, + { + label: "Terminal", + description: "Start a shell in this workspace.", + icon: TerminalSquare, + available: true, + onClick: props.onAddTerminal, + }, + { + label: "Diff", + description: "Review changes in this thread.", + icon: FileDiff, + available: props.diffAvailable, + onClick: props.onAddDiff, + }, + ] as const; + + return ( +
+
+
+

Open a surface

+

+ Choose what to show in the right panel. +

+
+
+ {actions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} +
+
+
+ ); +} + function surfaceTitle( surface: RightPanelSurface, sessions: Readonly>, + terminalLabelsById: ReadonlyMap, ): string { switch (surface.kind) { case "diff": return "Diff"; case "terminal": - return "Terminal"; + return ( + terminalLabelsById.get(surface.activeTerminalId) ?? + getTerminalLabel(surface.activeTerminalId) + ); case "plan": return "Plan"; case "preview": { @@ -85,13 +159,22 @@ function SurfaceIcon({ } export function RightPanelTabs(props: RightPanelTabsProps) { + const ownsDesktopTitleBar = isElectron && props.mode === "inline"; + return ( -
+
{props.surfaces.map((surface) => { const active = surface.id === props.activeSurfaceId; - const title = surfaceTitle(surface, props.previewSessions); + const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); return (
- + Browser + + + Terminal + Diff @@ -142,7 +229,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
-
{props.children}
+
+ {props.activeSurfaceId === null ? ( + + ) : ( + props.children + )} +
); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc5acaaadc71761db5f39d72d02c2394fb9e5593..67b575e4b46ecb925726c5775fed5d89ec527250 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronRightIcon, CloudIcon, FolderPlusIcon, + Globe2Icon, SearchIcon, SettingsIcon, SquarePenIcon, @@ -75,6 +76,7 @@ import { } from "../store"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, @@ -198,6 +200,7 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -349,6 +352,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); + const discoveredPorts = useThreadDiscoveredPorts({ + environmentId: thread.environmentId, + threadId: thread.id, + }); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; @@ -419,6 +426,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [handleThreadClick, orderedProjectThreadKeys, threadRef], ); + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void openDiscoveredPort({ threadRef, port }); + }, + [discoveredPorts, navigateToThread, threadRef], + ); const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; @@ -609,6 +627,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )}
+ {discoveredPorts.length > 0 && ( + + + } + > + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + )} {terminalStatus && ( undefined); }, 30); attachTerminal(); + let resizeFrame = 0; + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => { + if (resizeFrame !== 0) return; + resizeFrame = window.requestAnimationFrame(() => { + resizeFrame = 0; + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + const wasAtBottom = + activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; + fitTerminalSafely(activeFitAddon); + if (wasAtBottom) { + activeTerminal.scrollToBottom(); + } + void api.terminal + .resize({ + threadId, + terminalId, + cols: activeTerminal.cols, + rows: activeTerminal.rows, + }) + .catch(() => undefined); + }); + }); + resizeObserver?.observe(mount); return () => { disposed = true; unsubscribeAttach?.(); unsubscribeAttach = null; window.clearTimeout(fitTimer); + if (resizeFrame !== 0) { + window.cancelAnimationFrame(resizeFrame); + } + resizeObserver?.disconnect(); inputDisposable.dispose(); selectionDisposable.dispose(); terminalLinksDisposable.dispose(); @@ -806,8 +850,10 @@ interface ThreadTerminalDrawerProps { activeTerminalGroupId: string; focusRequestId: number; onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; onNewTerminal: () => void; splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; @@ -865,8 +911,10 @@ export default function ThreadTerminalDrawer({ activeTerminalGroupId, focusRequestId, onSplitTerminal, + onSplitTerminalVertical, onNewTerminal, splitShortcutLabel, + splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, onActiveTerminalChange, @@ -957,6 +1005,9 @@ export default function ThreadTerminalDrawer({ nextGroups.push({ id: assignUniqueGroupId(baseGroupId), terminalIds: nextTerminalIds, + ...(terminalGroup.splitDirection === "vertical" + ? { splitDirection: "vertical" as const } + : {}), }); } @@ -994,6 +1045,8 @@ export default function ThreadTerminalDrawer({ const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); + const splitDirection = + resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; const hasTerminalSidebar = normalizedTerminalIds.length > 1; const isSplitView = visibleTerminalIds.length > 1; const showGroupHeaders = @@ -1007,6 +1060,17 @@ export default function ThreadTerminalDrawer({ } return next; }, [normalizedTerminalIds, terminalLabelsById]); + const discoveredPorts = useDiscoveredPorts(threadRef.environmentId); + const discoveredPortByTerminalId = useMemo(() => { + const next = new Map(); + for (const port of discoveredPorts) { + if (port.terminal?.threadId !== threadId) continue; + if (!next.has(port.terminal.terminalId)) { + next.set(port.terminal.terminalId, port); + } + } + return next; + }, [discoveredPorts, threadId]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( @@ -1020,10 +1084,15 @@ export default function ThreadTerminalDrawer({ [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); const splitTerminalActionLabel = hasReachedSplitLimit - ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` + ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel - ? `Split Terminal (${splitShortcutLabel})` - : "Split Terminal"; + ? `Split Terminal Horizontally (${splitShortcutLabel})` + : "Split Terminal Horizontally"; + const splitTerminalVerticalActionLabel = hasReachedSplitLimit + ? `Split Terminal Vertically (max ${MAX_TERMINALS_PER_GROUP} per group)` + : splitVerticalShortcutLabel + ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` + : "Split Terminal Vertically"; const newTerminalActionLabel = newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"; @@ -1034,6 +1103,10 @@ export default function ThreadTerminalDrawer({ if (hasReachedSplitLimit) return; onSplitTerminal(); }, [hasReachedSplitLimit, onSplitTerminal]); + const onSplitTerminalVerticalAction = useCallback(() => { + if (hasReachedSplitLimit) return; + onSplitTerminalVertical(); + }, [hasReachedSplitLimit, onSplitTerminalVertical]); const onNewTerminalAction = useCallback(() => { onNewTerminal(); }, [onNewTerminal]); @@ -1143,6 +1216,7 @@ export default function ThreadTerminalDrawer({ if (normalizedTerminalIds.length === 0) { return (