| author | |
| committer | |
| log | dd739c880d6c912130cac005ea773ae386560460 |
| tree | 5f148327850a26504ac02dce6790fdc124d42c9a |
| parent | 6c6740e381aa1787492a8366f9020304ba322700 |
- 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 interactions72 files changed, 3633 insertions(+), 507 deletions(-)
apps/desktop/package.json+1| ... | ... | @@ -29,6 +29,7 @@ |
| 29 | 29 | "@types/node": "catalog:", |
| 30 | 30 | "cross-env": "^10.1.0", |
| 31 | 31 | "electron-builder": "26.8.1", |
| 32 | "tailwindcss": "^4.0.0", | |
| 32 | 33 | "vite-plus": "catalog:" |
| 33 | 34 | }, |
| 34 | 35 | "productName": "T3 Code (Alpha)" |
apps/desktop/scripts/build-preview-annotation-css.mjs created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | import { readFile, writeFile } from "node:fs/promises"; | |
| 2 | import { createRequire } from "node:module"; | |
| 3 | import { dirname, join } from "node:path"; | |
| 4 | import { fileURLToPath } from "node:url"; | |
| 5 | ||
| 6 | import { compile } from "tailwindcss"; | |
| 7 | ||
| 8 | const directory = dirname(fileURLToPath(import.meta.url)); | |
| 9 | const appRoot = join(directory, ".."); | |
| 10 | const sourcePath = join(appRoot, "src", "preview-annotation.css"); | |
| 11 | const preloadPath = join(appRoot, "src", "preview-pick-preload.ts"); | |
| 12 | const outputPath = join(appRoot, "src", "preview-annotation-styles.generated.ts"); | |
| 13 | const require = createRequire(import.meta.url); | |
| 14 | const tailwindRoot = dirname(require.resolve("tailwindcss/package.json")); | |
| 15 | ||
| 16 | const [annotationSource, preloadSource, themeSource, preflightSource] = await Promise.all([ | |
| 17 | readFile(sourcePath, "utf8"), | |
| 18 | readFile(preloadPath, "utf8"), | |
| 19 | readFile(join(tailwindRoot, "theme.css"), "utf8"), | |
| 20 | readFile(join(tailwindRoot, "preflight.css"), "utf8"), | |
| 21 | ]); | |
| 22 | ||
| 23 | const candidates = new Set( | |
| 24 | Array.from(preloadSource.matchAll(/!?-?[A-Za-z0-9_:@/.[\]()%,-]+/g), (match) => match[0]), | |
| 25 | ); | |
| 26 | const compilerInput = [ | |
| 27 | themeSource, | |
| 28 | preflightSource, | |
| 29 | annotationSource.replace('@import "tailwindcss";', "@tailwind utilities;"), | |
| 30 | ].join("\n"); | |
| 31 | const compiler = await compile(compilerInput, { base: appRoot }); | |
| 32 | const css = compiler.build([...candidates]); | |
| 33 | const encodedCss = `'${css | |
| 34 | .replaceAll("\\", "\\\\") | |
| 35 | .replaceAll("'", "\\'") | |
| 36 | .replaceAll("\r", "\\r") | |
| 37 | .replaceAll("\n", "\\n")}'`; | |
| 38 | const moduleSource = `// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.\nexport const previewAnnotationStyles =\n ${encodedCss};\n`; | |
| 39 | ||
| 40 | await writeFile(outputPath, moduleSource); |
apps/desktop/src/app/DesktopEnvironment.test.ts+2| ... | ... | @@ -59,6 +59,7 @@ describe("DesktopEnvironment", () => { |
| 59 | 59 | assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); |
| 60 | 60 | assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); |
| 61 | 61 | assert.equal(environment.logDir, "/tmp/t3/dev/logs"); |
| 62 | assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts"); | |
| 62 | 63 | assert.equal(environment.rootDir, "/repo"); |
| 63 | 64 | assert.equal(environment.appRoot, "/repo"); |
| 64 | 65 | assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); |
| ... | ... | @@ -89,6 +90,7 @@ describe("DesktopEnvironment", () => { |
| 89 | 90 | assert.equal(environment.isDevelopment, false); |
| 90 | 91 | assert.equal(environment.stateDir, "/tmp/t3/userdata"); |
| 91 | 92 | assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); |
| 93 | assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); | |
| 92 | 94 | assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); |
| 93 | 95 | }), |
| 94 | 96 | ); |
apps/desktop/src/app/DesktopEnvironment.ts+2| ... | ... | @@ -49,6 +49,7 @@ export interface DesktopEnvironmentShape { |
| 49 | 49 | readonly savedEnvironmentRegistryPath: string; |
| 50 | 50 | readonly serverSettingsPath: string; |
| 51 | 51 | readonly logDir: string; |
| 52 | readonly browserArtifactsDir: string; | |
| 52 | 53 | readonly rootDir: string; |
| 53 | 54 | readonly appRoot: string; |
| 54 | 55 | readonly backendEntryPath: string; |
| ... | ... | @@ -183,6 +184,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( |
| 183 | 184 | savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), |
| 184 | 185 | serverSettingsPath: path.join(stateDir, "settings.json"), |
| 185 | 186 | logDir: path.join(stateDir, "logs"), |
| 187 | browserArtifactsDir: path.join(stateDir, "browser-artifacts"), | |
| 186 | 188 | rootDir, |
| 187 | 189 | appRoot, |
| 188 | 190 | backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+4| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | |
| 3 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 4 | import { previewViewManager } from "../preview-view-manager.ts"; | |
| 3 | 5 | import * as DesktopIpc from "./DesktopIpc.ts"; |
| 4 | 6 | import { |
| 5 | 7 | clearCloudAuthToken, |
| ... | ... | @@ -52,6 +54,8 @@ import { previewMethods } from "./methods/preview.ts"; |
| 52 | 54 | |
| 53 | 55 | export const installDesktopIpcHandlers = Effect.gen(function* () { |
| 54 | 56 | const ipc = yield* DesktopIpc.DesktopIpc; |
| 57 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 58 | previewViewManager.configureArtifactDirectory(environment.browserArtifactsDir); | |
| 55 | 59 | |
| 56 | 60 | yield* ipc.handleSync(getAppBranding); |
| 57 | 61 | yield* ipc.handleSync(getLocalEnvironmentBootstrap); |
apps/desktop/src/ipc/channels.ts+4| ... | ... | @@ -54,9 +54,12 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; |
| 54 | 54 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; |
| 55 | 55 | export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; |
| 56 | 56 | export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; |
| 57 | export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; | |
| 57 | 58 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; |
| 58 | 59 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; |
| 59 | 60 | export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; |
| 61 | export const PREVIEW_REVEAL_ARTIFACT_CHANNEL = "desktop:preview-reveal-artifact"; | |
| 62 | export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; | |
| 60 | 63 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; |
| 61 | 64 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; |
| 62 | 65 | export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; |
| ... | ... | @@ -70,3 +73,4 @@ export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; |
| 70 | 73 | export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; |
| 71 | 74 | export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; |
| 72 | 75 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; |
| 76 | export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; |
apps/desktop/src/ipc/methods/preview.ts+64| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | 2 | import * as Data from "effect/Data"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; | |
| 4 | 5 | import { BrowserWindow } from "electron"; |
| 5 | 6 | import { pathToFileURL } from "node:url"; |
| 6 | 7 | |
| ... | ... | @@ -25,6 +26,14 @@ previewViewManager.onRecordingFrame((frame) => { |
| 25 | 26 | } |
| 26 | 27 | }); |
| 27 | 28 | |
| 29 | previewViewManager.onPointerEvent((event) => { | |
| 30 | for (const window of BrowserWindow.getAllWindows()) { | |
| 31 | if (!window.isDestroyed()) { | |
| 32 | window.webContents.send(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); | |
| 33 | } | |
| 34 | } | |
| 35 | }); | |
| 36 | ||
| 28 | 37 | const tabIdFrom = (raw: unknown): string => { |
| 29 | 38 | if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { |
| 30 | 39 | throw new Error("preview tab id is required"); |
| ... | ... | @@ -43,6 +52,44 @@ const inputFrom = (raw: unknown): unknown => { |
| 43 | 52 | return raw.input; |
| 44 | 53 | }; |
| 45 | 54 | |
| 55 | const annotationThemeFrom = (raw: unknown): DesktopPreviewAnnotationTheme => { | |
| 56 | if (typeof raw !== "object" || raw === null || !("theme" in raw)) { | |
| 57 | throw new Error("preview annotation theme is required"); | |
| 58 | } | |
| 59 | const theme = raw.theme; | |
| 60 | if (typeof theme !== "object" || theme === null) { | |
| 61 | throw new Error("preview annotation theme must be an object"); | |
| 62 | } | |
| 63 | const record = theme as Record<string, unknown>; | |
| 64 | const stringKeys = [ | |
| 65 | "radius", | |
| 66 | "background", | |
| 67 | "foreground", | |
| 68 | "popover", | |
| 69 | "popoverForeground", | |
| 70 | "primary", | |
| 71 | "primaryForeground", | |
| 72 | "muted", | |
| 73 | "mutedForeground", | |
| 74 | "accent", | |
| 75 | "accentForeground", | |
| 76 | "border", | |
| 77 | "input", | |
| 78 | "ring", | |
| 79 | "fontSans", | |
| 80 | "fontMono", | |
| 81 | ] as const; | |
| 82 | for (const key of stringKeys) { | |
| 83 | if (typeof record[key] !== "string" || record[key].length === 0) { | |
| 84 | throw new Error(`preview annotation theme ${key} must be a non-empty string`); | |
| 85 | } | |
| 86 | } | |
| 87 | if (record["colorScheme"] !== "light" && record["colorScheme"] !== "dark") { | |
| 88 | throw new Error("preview annotation theme colorScheme must be light or dark"); | |
| 89 | } | |
| 90 | return record as unknown as DesktopPreviewAnnotationTheme; | |
| 91 | }; | |
| 92 | ||
| 46 | 93 | class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ |
| 47 | 94 | readonly cause: unknown; |
| 48 | 95 | }> {} |
| ... | ... | @@ -117,6 +164,9 @@ export const previewMethods = [ |
| 117 | 164 | preloadUrl: pathToFileURL(preloadPath).href, |
| 118 | 165 | }; |
| 119 | 166 | }), |
| 167 | method(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, (raw) => | |
| 168 | previewViewManager.setAnnotationTheme(annotationThemeFrom(raw)), | |
| 169 | ), | |
| 120 | 170 | method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => |
| 121 | 171 | previewViewManager.pickElement(tabIdFrom(raw)), |
| 122 | 172 | ), |
| ... | ... | @@ -126,6 +176,20 @@ export const previewMethods = [ |
| 126 | 176 | method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => |
| 127 | 177 | previewViewManager.captureScreenshot(tabIdFrom(raw)), |
| 128 | 178 | ), |
| 179 | method(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, (raw) => { | |
| 180 | const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; | |
| 181 | if (typeof path !== "string" || path.trim().length === 0) { | |
| 182 | throw new Error("preview artifact path is required"); | |
| 183 | } | |
| 184 | return previewViewManager.revealArtifact(path); | |
| 185 | }), | |
| 186 | method(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, (raw) => { | |
| 187 | const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; | |
| 188 | if (typeof path !== "string" || path.trim().length === 0) { | |
| 189 | throw new Error("preview artifact path is required"); | |
| 190 | } | |
| 191 | return previewViewManager.copyArtifactToClipboard(path); | |
| 192 | }), | |
| 129 | 193 | method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => |
| 130 | 194 | previewViewManager.automationStatus(tabIdFrom(raw)), |
| 131 | 195 | ), |
apps/desktop/src/preload.ts+16| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import type { |
| 2 | 2 | DesktopBridge, |
| 3 | DesktopPreviewPointerEvent, | |
| 3 | 4 | DesktopPreviewRecordingFrame, |
| 4 | 5 | DesktopPreviewTabState, |
| 5 | 6 | } from "@t3tools/contracts"; |
| ... | ... | @@ -165,11 +166,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 165 | 166 | clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), |
| 166 | 167 | getPreviewConfig: (environmentId) => |
| 167 | 168 | ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), |
| 169 | setAnnotationTheme: (theme) => | |
| 170 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), | |
| 168 | 171 | pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), |
| 169 | 172 | cancelPickElement: (tabId) => |
| 170 | 173 | ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), |
| 171 | 174 | captureScreenshot: (tabId) => |
| 172 | 175 | ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }), |
| 176 | revealArtifact: (path) => | |
| 177 | ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), | |
| 178 | copyArtifactToClipboard: (path) => | |
| 179 | ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }), | |
| 173 | 180 | recording: { |
| 174 | 181 | startScreencast: (tabId) => |
| 175 | 182 | ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), |
| ... | ... | @@ -222,5 +229,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 222 | 229 | return () => |
| 223 | 230 | ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); |
| 224 | 231 | }, |
| 232 | onPointerEvent: (listener) => { | |
| 233 | const wrappedListener = (_event: Electron.IpcRendererEvent, pointerEvent: unknown) => { | |
| 234 | if (typeof pointerEvent !== "object" || pointerEvent === null) return; | |
| 235 | listener(pointerEvent as DesktopPreviewPointerEvent); | |
| 236 | }; | |
| 237 | ipcRenderer.on(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); | |
| 238 | return () => | |
| 239 | ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); | |
| 240 | }, | |
| 225 | 241 | }, |
| 226 | 242 | } satisfies DesktopBridge); |
apps/desktop/src/preview-annotation-styles.generated.ts created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. | |
| 2 | export const previewAnnotationStyles = | |
| 3 | '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "<length>";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; |
apps/desktop/src/preview-annotation.css created+68| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | @import "tailwindcss"; | |
| 2 | ||
| 3 | @theme inline { | |
| 4 | --font-sans: var(--t3-font-sans); | |
| 5 | --font-mono: var(--t3-font-mono); | |
| 6 | --color-background: var(--t3-background); | |
| 7 | --color-foreground: var(--t3-foreground); | |
| 8 | --color-popover: var(--t3-popover); | |
| 9 | --color-popover-foreground: var(--t3-popover-foreground); | |
| 10 | --color-primary: var(--t3-primary); | |
| 11 | --color-primary-foreground: var(--t3-primary-foreground); | |
| 12 | --color-muted: var(--t3-muted); | |
| 13 | --color-muted-foreground: var(--t3-muted-foreground); | |
| 14 | --color-accent: var(--t3-accent); | |
| 15 | --color-accent-foreground: var(--t3-accent-foreground); | |
| 16 | --color-border: var(--t3-border); | |
| 17 | --color-input: var(--t3-input); | |
| 18 | --color-ring: var(--t3-ring); | |
| 19 | --radius-sm: calc(var(--t3-radius) - 4px); | |
| 20 | --radius-md: calc(var(--t3-radius) - 2px); | |
| 21 | --radius-lg: var(--t3-radius); | |
| 22 | --radius-xl: calc(var(--t3-radius) + 4px); | |
| 23 | --radius-2xl: calc(var(--t3-radius) + 8px); | |
| 24 | } | |
| 25 | ||
| 26 | :host { | |
| 27 | --t3-font-sans: | |
| 28 | "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, | |
| 29 | sans-serif; | |
| 30 | --t3-font-mono: | |
| 31 | "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace; | |
| 32 | --t3-radius: 0.625rem; | |
| 33 | --t3-background: white; | |
| 34 | --t3-foreground: oklch(0.269 0 0); | |
| 35 | --t3-popover: white; | |
| 36 | --t3-popover-foreground: oklch(0.269 0 0); | |
| 37 | --t3-primary: oklch(0.488 0.217 264); | |
| 38 | --t3-primary-foreground: white; | |
| 39 | --t3-muted: rgb(0 0 0 / 4%); | |
| 40 | --t3-muted-foreground: oklch(0.556 0 0); | |
| 41 | --t3-accent: rgb(0 0 0 / 4%); | |
| 42 | --t3-accent-foreground: oklch(0.269 0 0); | |
| 43 | --t3-border: rgb(0 0 0 / 8%); | |
| 44 | --t3-input: rgb(0 0 0 / 10%); | |
| 45 | --t3-ring: oklch(0.488 0.217 264); | |
| 46 | color: var(--t3-foreground); | |
| 47 | font-family: var(--t3-font-sans); | |
| 48 | } | |
| 49 | ||
| 50 | * { | |
| 51 | box-sizing: border-box; | |
| 52 | border-color: var(--t3-border); | |
| 53 | } | |
| 54 | ||
| 55 | button, | |
| 56 | input, | |
| 57 | select, | |
| 58 | textarea { | |
| 59 | font: inherit; | |
| 60 | } | |
| 61 | ||
| 62 | button:focus-visible, | |
| 63 | input:focus-visible, | |
| 64 | select:focus-visible, | |
| 65 | textarea:focus-visible { | |
| 66 | outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent); | |
| 67 | outline-offset: 1px; | |
| 68 | } |
apps/desktop/src/preview-pick-preload.ts+276-139| ... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | import { ipcRenderer } from "electron"; |
| 3 | 3 | import { getElementContext } from "react-grab/primitives"; |
| 4 | 4 | import type { |
| 5 | DesktopPreviewAnnotationTheme, | |
| 5 | 6 | PickedElementPayload, |
| 6 | 7 | PickedElementStackFrame, |
| 7 | 8 | PreviewAnnotationPayload, |
| ... | ... | @@ -12,16 +13,18 @@ import type { |
| 12 | 13 | PreviewAnnotationStyleChange, |
| 13 | 14 | } from "@t3tools/contracts"; |
| 14 | 15 | |
| 16 | import { previewAnnotationStyles } from "./preview-annotation-styles.generated.ts"; | |
| 17 | ||
| 15 | 18 | const START_PICK_CHANNEL = "preview:start-pick"; |
| 16 | 19 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; |
| 17 | 20 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; |
| 18 | 21 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; |
| 22 | const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; | |
| 19 | 23 | const HUMAN_INPUT_CHANNEL = "preview:human-input"; |
| 20 | 24 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; |
| 21 | 25 | const Z_INDEX_OVERLAY = 2147483646; |
| 22 | const ACCENT = "#7c3aed"; | |
| 23 | const ACCENT_FILL = "rgba(124,58,237,0.12)"; | |
| 24 | const BLUE = "#2563eb"; | |
| 26 | const PRIMARY = "var(--t3-primary)"; | |
| 27 | const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; | |
| 25 | 28 | const MAX_MARQUEE_ELEMENTS = 20; |
| 26 | 29 | const CONTENT_LAYER_Z_INDEX = 1; |
| 27 | 30 | const CHROME_LAYER_Z_INDEX = 10; |
| ... | ... | @@ -38,17 +41,63 @@ interface SelectedElement { |
| 38 | 41 | |
| 39 | 42 | interface AnnotationSession { |
| 40 | 43 | teardown: (notifyMain: boolean) => void; |
| 44 | applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; | |
| 41 | 45 | } |
| 42 | 46 | |
| 43 | 47 | let activeSession: AnnotationSession | null = null; |
| 44 | 48 | let idSequence = 0; |
| 49 | let annotationTheme: DesktopPreviewAnnotationTheme | null = null; | |
| 50 | ||
| 51 | const applyAnnotationTheme = ( | |
| 52 | host: HTMLElement, | |
| 53 | theme: DesktopPreviewAnnotationTheme | null, | |
| 54 | ): void => { | |
| 55 | if (!theme) return; | |
| 56 | host.style.colorScheme = theme.colorScheme; | |
| 57 | const variables = { | |
| 58 | "--t3-radius": theme.radius, | |
| 59 | "--t3-background": theme.background, | |
| 60 | "--t3-foreground": theme.foreground, | |
| 61 | "--t3-popover": theme.popover, | |
| 62 | "--t3-popover-foreground": theme.popoverForeground, | |
| 63 | "--t3-primary": theme.primary, | |
| 64 | "--t3-primary-foreground": theme.primaryForeground, | |
| 65 | "--t3-muted": theme.muted, | |
| 66 | "--t3-muted-foreground": theme.mutedForeground, | |
| 67 | "--t3-accent": theme.accent, | |
| 68 | "--t3-accent-foreground": theme.accentForeground, | |
| 69 | "--t3-border": theme.border, | |
| 70 | "--t3-input": theme.input, | |
| 71 | "--t3-ring": theme.ring, | |
| 72 | "--t3-font-sans": theme.fontSans, | |
| 73 | "--t3-font-mono": theme.fontMono, | |
| 74 | }; | |
| 75 | for (const [name, value] of Object.entries(variables)) { | |
| 76 | host.style.setProperty(name, value); | |
| 77 | } | |
| 78 | }; | |
| 79 | ||
| 80 | const reportHumanPointerInput = (event: PointerEvent): void => { | |
| 81 | if (!event.isTrusted) return; | |
| 82 | ipcRenderer.send(HUMAN_INPUT_CHANNEL, { | |
| 83 | kind: "pointer", | |
| 84 | x: event.clientX, | |
| 85 | y: event.clientY, | |
| 86 | button: event.button, | |
| 87 | }); | |
| 88 | }; | |
| 45 | 89 | |
| 46 | const reportHumanInput = (event: Event): void => { | |
| 47 | if (event.isTrusted) ipcRenderer.send(HUMAN_INPUT_CHANNEL); | |
| 90 | const reportHumanKeyInput = (event: KeyboardEvent): void => { | |
| 91 | if (!event.isTrusted) return; | |
| 92 | ipcRenderer.send(HUMAN_INPUT_CHANNEL, { | |
| 93 | kind: "key", | |
| 94 | key: event.key, | |
| 95 | code: event.code, | |
| 96 | }); | |
| 48 | 97 | }; |
| 49 | 98 | |
| 50 | window.addEventListener("pointerdown", reportHumanInput, true); | |
| 51 | window.addEventListener("keydown", reportHumanInput, true); | |
| 99 | window.addEventListener("pointerdown", reportHumanPointerInput, true); | |
| 100 | window.addEventListener("keydown", reportHumanKeyInput, true); | |
| 52 | 101 | |
| 53 | 102 | const nextId = (prefix: string): string => { |
| 54 | 103 | idSequence += 1; |
| ... | ... | @@ -76,13 +125,15 @@ const normalizeRect = ( |
| 76 | 125 | |
| 77 | 126 | const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; |
| 78 | 127 | |
| 79 | function unionRects(rects: ReadonlyArray<PreviewAnnotationRect>): PreviewAnnotationRect | null { | |
| 128 | function unionRects( | |
| 129 | rects: ReadonlyArray<PreviewAnnotationRect>, | |
| 130 | padding = 20, | |
| 131 | ): PreviewAnnotationRect | null { | |
| 80 | 132 | if (rects.length === 0) return null; |
| 81 | 133 | const left = Math.min(...rects.map((rect) => rect.x)); |
| 82 | 134 | const top = Math.min(...rects.map((rect) => rect.y)); |
| 83 | 135 | const right = Math.max(...rects.map((rect) => rect.x + rect.width)); |
| 84 | 136 | const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); |
| 85 | const padding = 20; | |
| 86 | 137 | const x = Math.max(0, left - padding); |
| 87 | 138 | const y = Math.max(0, top - padding); |
| 88 | 139 | const maxWidth = Math.max(1, window.innerWidth - x); |
| ... | ... | @@ -151,19 +202,13 @@ function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { |
| 151 | 202 | function createLabel(): HTMLDivElement { |
| 152 | 203 | const label = document.createElement("div"); |
| 153 | 204 | label.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 205 | label.className = | |
| 206 | "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md"; | |
| 154 | 207 | label.style.cssText = [ |
| 155 | 208 | "position:fixed", |
| 156 | 209 | "pointer-events:none", |
| 157 | `background:${ACCENT}`, | |
| 158 | "color:white", | |
| 159 | "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", | |
| 160 | "padding:2px 6px", | |
| 161 | "border-radius:4px", | |
| 162 | 210 | "white-space:nowrap", |
| 163 | "max-width:280px", | |
| 164 | "overflow:hidden", | |
| 165 | 211 | "text-overflow:ellipsis", |
| 166 | "box-shadow:0 1px 3px rgba(0,0,0,.3)", | |
| 167 | 212 | `z-index:${CONTENT_LAYER_Z_INDEX}`, |
| 168 | 213 | ].join(";"); |
| 169 | 214 | return label; |
| ... | ... | @@ -222,22 +267,15 @@ function createButton(label: string, title: string): HTMLButtonElement { |
| 222 | 267 | button.type = "button"; |
| 223 | 268 | button.textContent = label; |
| 224 | 269 | button.title = title; |
| 225 | button.style.cssText = [ | |
| 226 | "border:0", | |
| 227 | "border-radius:7px", | |
| 228 | "padding:6px 9px", | |
| 229 | "font:600 12px/1 ui-sans-serif,system-ui,-apple-system,sans-serif", | |
| 230 | "background:transparent", | |
| 231 | "color:#27272a", | |
| 232 | "cursor:pointer", | |
| 233 | ].join(";"); | |
| 270 | button.className = | |
| 271 | "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60"; | |
| 234 | 272 | return button; |
| 235 | 273 | } |
| 236 | 274 | |
| 237 | 275 | function styleControl(input: HTMLInputElement | HTMLSelectElement): void { |
| 238 | 276 | input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); |
| 239 | input.style.cssText += | |
| 240 | ";min-width:0;width:100%;height:26px;border:0;border-radius:7px;background:rgba(255,255,255,.78);color:#27272a;padding:3px 8px;box-sizing:border-box;font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none;box-shadow:inset 0 0 0 1px rgba(0,0,0,.09);appearance:none"; | |
| 277 | input.className = | |
| 278 | "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none"; | |
| 241 | 279 | } |
| 242 | 280 | |
| 243 | 281 | function createUnitControl(input: HTMLInputElement): HTMLElement { |
| ... | ... | @@ -245,8 +283,8 @@ function createUnitControl(input: HTMLInputElement): HTMLElement { |
| 245 | 283 | wrapper.style.cssText = "position:relative;min-width:0"; |
| 246 | 284 | const unit = document.createElement("span"); |
| 247 | 285 | unit.textContent = input.dataset.unit ?? ""; |
| 248 | unit.style.cssText = | |
| 249 | "position:absolute;right:8px;top:50%;transform:translateY(-50%);pointer-events:none;color:#a1a1aa;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace"; | |
| 286 | unit.className = | |
| 287 | "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; | |
| 250 | 288 | wrapper.append(input, unit); |
| 251 | 289 | return wrapper; |
| 252 | 290 | } |
| ... | ... | @@ -256,8 +294,8 @@ function createField( |
| 256 | 294 | input: HTMLInputElement | HTMLSelectElement, |
| 257 | 295 | ): HTMLLabelElement { |
| 258 | 296 | const label = document.createElement("label"); |
| 259 | label.style.cssText = | |
| 260 | "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; | |
| 297 | label.className = | |
| 298 | "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; | |
| 261 | 299 | const text = document.createElement("span"); |
| 262 | 300 | text.textContent = labelText; |
| 263 | 301 | styleControl(input); |
| ... | ... | @@ -270,7 +308,7 @@ function createField( |
| 270 | 308 | |
| 271 | 309 | function createStyleSection(): HTMLElement { |
| 272 | 310 | const section = document.createElement("section"); |
| 273 | section.style.cssText = "display:grid;gap:3px;padding:7px 0;border-top:1px solid rgba(0,0,0,.07)"; | |
| 311 | section.className = "grid gap-1 border-t border-border py-2"; | |
| 274 | 312 | return section; |
| 275 | 313 | } |
| 276 | 314 | |
| ... | ... | @@ -314,16 +352,27 @@ function strokeBounds( |
| 314 | 352 | function startAnnotation(): void { |
| 315 | 353 | activeSession?.teardown(false); |
| 316 | 354 | let finished = false; |
| 355 | const host = document.createElement("div"); | |
| 356 | host.setAttribute(OVERLAY_ATTRIBUTE, ""); | |
| 357 | host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; | |
| 358 | applyAnnotationTheme(host, annotationTheme); | |
| 359 | const shadowRoot = host.attachShadow({ mode: "closed" }); | |
| 360 | const themeStyle = document.createElement("style"); | |
| 361 | themeStyle.textContent = previewAnnotationStyles; | |
| 362 | shadowRoot.appendChild(themeStyle); | |
| 363 | ||
| 317 | 364 | const root = document.createElement("div"); |
| 318 | 365 | root.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 319 | root.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif`; | |
| 366 | root.className = "fixed inset-0 font-sans text-foreground"; | |
| 367 | root.style.cssText = "pointer-events:none"; | |
| 320 | 368 | const cursorStyle = document.createElement("style"); |
| 321 | 369 | cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 322 | 370 | cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; |
| 323 | root.appendChild(cursorStyle); | |
| 371 | document.documentElement.appendChild(cursorStyle); | |
| 372 | shadowRoot.appendChild(root); | |
| 324 | 373 | |
| 325 | const hoverOutline = createBox(BLUE, "rgba(37,99,235,.10)"); | |
| 326 | const marqueeBox = createBox(ACCENT, ACCENT_FILL); | |
| 374 | const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); | |
| 375 | const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); | |
| 327 | 376 | root.append(hoverOutline, marqueeBox); |
| 328 | 377 | |
| 329 | 378 | const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); |
| ... | ... | @@ -337,57 +386,55 @@ function startAnnotation(): void { |
| 337 | 386 | |
| 338 | 387 | const toolbar = document.createElement("div"); |
| 339 | 388 | toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 340 | toolbar.style.cssText = `position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:${CHROME_LAYER_Z_INDEX};pointer-events:auto;display:flex;gap:2px;padding:3px;border:1px solid rgba(0,0,0,.10);border-radius:9px;background:rgba(255,255,255,.94);box-shadow:0 5px 16px rgba(0,0,0,.12);backdrop-filter:blur(12px)`; | |
| 389 | toolbar.className = | |
| 390 | "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl"; | |
| 391 | toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); | |
| 341 | 392 | root.appendChild(toolbar); |
| 342 | 393 | |
| 343 | 394 | const editor = document.createElement("div"); |
| 344 | 395 | editor.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 345 | editor.style.cssText = `position:fixed;right:12px;bottom:12px;z-index:${CHROME_LAYER_Z_INDEX};width:min(306px,calc(100vw - 24px));max-height:calc(100vh - 32px);overflow:hidden;pointer-events:auto;display:none;flex-direction:column;border:1px solid rgba(0,0,0,.09);border-radius:14px;background:rgba(255,255,255,.96);box-shadow:0 14px 36px rgba(0,0,0,.18);color:#18181b;box-sizing:border-box;backdrop-filter:blur(18px)`; | |
| 396 | editor.className = | |
| 397 | "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl"; | |
| 398 | editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); | |
| 346 | 399 | root.appendChild(editor); |
| 347 | 400 | |
| 348 | const editorHeader = document.createElement("div"); | |
| 349 | editorHeader.style.cssText = | |
| 350 | "display:flex;align-items:center;gap:7px;padding:7px 8px;border-bottom:1px solid rgba(0,0,0,.06);cursor:grab;user-select:none"; | |
| 351 | const adjustIcon = document.createElement("div"); | |
| 352 | adjustIcon.innerHTML = | |
| 353 | '<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M3 6h8M15 6h2M3 14h2M9 14h8M11 3v6M7 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><circle cx="13" cy="6" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="14" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/></svg>'; | |
| 354 | adjustIcon.style.cssText = | |
| 355 | "display:grid;place-items:center;width:24px;height:24px;border-radius:999px;background:#f4f4f5;color:#52525b;font:700 14px/1 ui-sans-serif"; | |
| 356 | editorHeader.appendChild(adjustIcon); | |
| 357 | ||
| 358 | const status = document.createElement("div"); | |
| 359 | status.style.cssText = | |
| 360 | "min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 11px/1.2 ui-sans-serif,system-ui;color:#71717a"; | |
| 361 | editorHeader.appendChild(status); | |
| 362 | const dragHandle = document.createElement("div"); | |
| 363 | dragHandle.textContent = "â ¿"; | |
| 364 | dragHandle.title = "Drag annotation editor"; | |
| 365 | dragHandle.style.cssText = "color:#a1a1aa;font:700 18px/1 ui-sans-serif"; | |
| 366 | editorHeader.appendChild(dragHandle); | |
| 367 | editor.appendChild(editorHeader); | |
| 401 | const composerRow = document.createElement("div"); | |
| 402 | composerRow.className = "flex items-start gap-2 p-2"; | |
| 403 | ||
| 404 | const adjust = createButton("", "Expand annotation editor"); | |
| 405 | adjust.setAttribute("aria-label", "Expand annotation editor"); | |
| 406 | adjust.setAttribute("aria-expanded", "false"); | |
| 407 | adjust.className += | |
| 408 | " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; | |
| 409 | adjust.innerHTML = | |
| 410 | '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12M7 3v4M13 8v4M9 13v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>'; | |
| 411 | composerRow.appendChild(adjust); | |
| 368 | 412 | |
| 369 | 413 | const comment = document.createElement("textarea"); |
| 370 | 414 | comment.placeholder = "Describe the change…"; |
| 371 | comment.rows = 2; | |
| 372 | comment.style.cssText = | |
| 373 | "width:100%;resize:none;min-height:48px;border:0;padding:10px 12px;font:500 12px/1.35 ui-sans-serif,system-ui;color:#18181b;box-sizing:border-box;outline:none;background:transparent"; | |
| 374 | editor.appendChild(comment); | |
| 415 | comment.rows = 1; | |
| 416 | comment.className = | |
| 417 | "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; | |
| 418 | composerRow.appendChild(comment); | |
| 375 | 419 | |
| 376 | const stylePanel = document.createElement("div"); | |
| 377 | stylePanel.style.cssText = | |
| 378 | "display:none;max-height:min(380px,calc(100vh - 180px));overflow:auto;padding:0 10px;background:rgba(250,250,250,.62);border-top:1px solid rgba(0,0,0,.06)"; | |
| 379 | editor.appendChild(stylePanel); | |
| 420 | const dragHandle = document.createElement("button"); | |
| 421 | dragHandle.type = "button"; | |
| 422 | dragHandle.textContent = "â ¿"; | |
| 423 | dragHandle.title = "Drag annotation editor"; | |
| 424 | dragHandle.className = | |
| 425 | "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; | |
| 426 | composerRow.appendChild(dragHandle); | |
| 380 | 427 | |
| 381 | const footer = document.createElement("div"); | |
| 382 | footer.style.cssText = | |
| 383 | "display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border-top:1px solid rgba(0,0,0,.06);background:rgba(255,255,255,.92)"; | |
| 384 | const adjust = createButton("Adjust", "Show style controls for selected elements"); | |
| 385 | adjust.style.cssText += | |
| 386 | ";display:none;padding:5px 7px;color:#52525b;background:#f4f4f5;font-size:11px"; | |
| 387 | 428 | const submit = createButton("Attach", "Attach annotation and screenshot"); |
| 388 | submit.style.cssText += `;background:${ACCENT};color:white;padding:6px 9px;font-size:11px`; | |
| 389 | footer.append(adjust, submit); | |
| 390 | editor.appendChild(footer); | |
| 429 | submit.className += | |
| 430 | " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; | |
| 431 | composerRow.appendChild(submit); | |
| 432 | editor.appendChild(composerRow); | |
| 433 | ||
| 434 | const stylePanel = document.createElement("div"); | |
| 435 | stylePanel.className = | |
| 436 | "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; | |
| 437 | editor.appendChild(stylePanel); | |
| 391 | 438 | |
| 392 | 439 | const selected = new Map<Element, SelectedElement>(); |
| 393 | 440 | const regions: PreviewAnnotationRegionTarget[] = []; |
| ... | ... | @@ -398,28 +445,30 @@ function startAnnotation(): void { |
| 398 | 445 | let dragStart: PreviewAnnotationPoint | null = null; |
| 399 | 446 | let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; |
| 400 | 447 | let pendingCapture = false; |
| 401 | let stylesExpanded = false; | |
| 448 | let editorExpanded = false; | |
| 402 | 449 | let editorWasShown = false; |
| 403 | 450 | let editorPosition: { left: number; top: number } | null = null; |
| 404 | 451 | let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; |
| 452 | let editorLayoutFrame: number | null = null; | |
| 453 | ||
| 454 | const resizeComment = (): void => { | |
| 455 | const maxHeight = 96; | |
| 456 | comment.style.height = "auto"; | |
| 457 | const nextHeight = Math.min(comment.scrollHeight, maxHeight); | |
| 458 | comment.style.height = `${nextHeight}px`; | |
| 459 | comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; | |
| 460 | queueEditorLayout(); | |
| 461 | }; | |
| 462 | comment.addEventListener("input", resizeComment); | |
| 405 | 463 | |
| 406 | 464 | const updateStatus = (): void => { |
| 407 | const parts = [ | |
| 408 | selected.size > 0 ? `${selected.size} element${selected.size === 1 ? "" : "s"}` : "", | |
| 409 | regions.length > 0 ? `${regions.length} region${regions.length === 1 ? "" : "s"}` : "", | |
| 410 | strokes.length > 0 ? `${strokes.length} drawing${strokes.length === 1 ? "" : "s"}` : "", | |
| 411 | ].filter(Boolean); | |
| 412 | const hasTargets = parts.length > 0; | |
| 413 | status.textContent = parts.join(" · "); | |
| 465 | const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; | |
| 414 | 466 | editor.style.display = hasTargets ? "flex" : "none"; |
| 415 | 467 | submit.disabled = !hasTargets; |
| 416 | 468 | submit.style.opacity = hasTargets ? "1" : "0.45"; |
| 417 | adjust.style.display = selected.size > 0 ? "block" : "none"; | |
| 418 | if (selected.size === 0 && stylesExpanded) { | |
| 419 | stylesExpanded = false; | |
| 420 | stylePanel.style.display = "none"; | |
| 421 | adjust.textContent = "Adjust"; | |
| 422 | } | |
| 469 | adjust.disabled = !hasTargets; | |
| 470 | stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; | |
| 471 | queueEditorLayout(); | |
| 423 | 472 | if (hasTargets && !editorWasShown) { |
| 424 | 473 | editorWasShown = true; |
| 425 | 474 | window.setTimeout(() => comment.focus({ preventScroll: true }), 0); |
| ... | ... | @@ -429,8 +478,9 @@ function startAnnotation(): void { |
| 429 | 478 | const refreshToolButtons = (): void => { |
| 430 | 479 | for (const [candidate, button] of toolButtons) { |
| 431 | 480 | const active = candidate === tool; |
| 432 | button.style.background = active ? ACCENT_FILL : "transparent"; | |
| 433 | button.style.color = active ? ACCENT : "#27272a"; | |
| 481 | button.classList.toggle("bg-primary/10", active); | |
| 482 | button.classList.toggle("text-primary", active); | |
| 483 | button.classList.toggle("text-foreground", !active); | |
| 434 | 484 | } |
| 435 | 485 | if (tool !== "select") hoverOutline.style.display = "none"; |
| 436 | 486 | if (tool !== "marquee") marqueeBox.style.display = "none"; |
| ... | ... | @@ -458,7 +508,7 @@ function startAnnotation(): void { |
| 458 | 508 | const target: SelectedElement = { |
| 459 | 509 | id: nextId("element"), |
| 460 | 510 | element, |
| 461 | outline: createBox(ACCENT, ACCENT_FILL), | |
| 511 | outline: createBox(PRIMARY, PRIMARY_FILL), | |
| 462 | 512 | label: createLabel(), |
| 463 | 513 | baselineStyles: new Map(), |
| 464 | 514 | }; |
| ... | ... | @@ -466,7 +516,10 @@ function startAnnotation(): void { |
| 466 | 516 | root.append(target.outline, target.label); |
| 467 | 517 | updateSelectedVisual(target); |
| 468 | 518 | updateStatus(); |
| 469 | if (stylesExpanded) syncStyleControls(); | |
| 519 | if (editorExpanded) { | |
| 520 | stylePanel.style.display = "grid"; | |
| 521 | syncStyleControls(); | |
| 522 | } | |
| 470 | 523 | }; |
| 471 | 524 | |
| 472 | 525 | const toggleSelected = (element: Element, additive: boolean): void => { |
| ... | ... | @@ -552,13 +605,13 @@ function startAnnotation(): void { |
| 552 | 605 | section: HTMLElement, |
| 553 | 606 | ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { |
| 554 | 607 | const row = document.createElement("label"); |
| 555 | row.style.cssText = | |
| 556 | "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; | |
| 608 | row.className = | |
| 609 | "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; | |
| 557 | 610 | const label = document.createElement("span"); |
| 558 | 611 | label.textContent = labelText; |
| 559 | 612 | const control = document.createElement("div"); |
| 560 | control.style.cssText = | |
| 561 | "display:grid;grid-template-columns:22px minmax(0,1fr);align-items:center;gap:5px;height:26px;padding:2px 5px;border-radius:7px;background:rgba(255,255,255,.78);box-shadow:inset 0 0 0 1px rgba(0,0,0,.09)"; | |
| 613 | control.className = | |
| 614 | "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; | |
| 562 | 615 | const color = document.createElement("input"); |
| 563 | 616 | color.type = "color"; |
| 564 | 617 | color.setAttribute("aria-label", labelText); |
| ... | ... | @@ -567,8 +620,8 @@ function startAnnotation(): void { |
| 567 | 620 | const text = document.createElement("input"); |
| 568 | 621 | text.type = "text"; |
| 569 | 622 | text.setAttribute("aria-label", `${labelText} value`); |
| 570 | text.style.cssText = | |
| 571 | "min-width:0;width:100%;border:0;background:transparent;color:#52525b;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none"; | |
| 623 | text.className = | |
| 624 | "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; | |
| 572 | 625 | color.addEventListener("input", () => { |
| 573 | 626 | text.value = color.value; |
| 574 | 627 | setStyleForSelected(property, color.value); |
| ... | ... | @@ -594,7 +647,7 @@ function startAnnotation(): void { |
| 594 | 647 | opacity.max = "1"; |
| 595 | 648 | opacity.step = "0.05"; |
| 596 | 649 | opacity.value = "1"; |
| 597 | opacity.style.accentColor = ACCENT; | |
| 650 | opacity.style.accentColor = PRIMARY; | |
| 598 | 651 | opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); |
| 599 | 652 | colorsSection.appendChild(createField("Opacity", opacity)); |
| 600 | 653 | |
| ... | ... | @@ -623,8 +676,7 @@ function startAnnotation(): void { |
| 623 | 676 | dimensions.style.cssText = |
| 624 | 677 | "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; |
| 625 | 678 | const dimensionLabel = document.createElement("div"); |
| 626 | dimensionLabel.style.cssText = | |
| 627 | "display:grid;gap:9px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; | |
| 679 | dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; | |
| 628 | 680 | dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>"; |
| 629 | 681 | const dimensionControls = document.createElement("div"); |
| 630 | 682 | dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; |
| ... | ... | @@ -635,7 +687,8 @@ function startAnnotation(): void { |
| 635 | 687 | const aspectLock = createButton("", "Lock aspect ratio"); |
| 636 | 688 | aspectLock.setAttribute("aria-pressed", "true"); |
| 637 | 689 | aspectLock.style.cssText += |
| 638 | ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0;border-radius:6px;background:#ede9fe;color:#6d28d9"; | |
| 690 | ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; | |
| 691 | aspectLock.className += " bg-primary/10 text-primary"; | |
| 639 | 692 | dimensionControls.append( |
| 640 | 693 | createUnitControl(widthInput), |
| 641 | 694 | createUnitControl(heightInput), |
| ... | ... | @@ -651,8 +704,10 @@ function startAnnotation(): void { |
| 651 | 704 | ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>' |
| 652 | 705 | : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'; |
| 653 | 706 | aspectLock.setAttribute("aria-pressed", String(aspectLocked)); |
| 654 | aspectLock.style.background = aspectLocked ? "#ede9fe" : "#f4f4f5"; | |
| 655 | aspectLock.style.color = aspectLocked ? "#6d28d9" : "#71717a"; | |
| 707 | aspectLock.classList.toggle("bg-primary/10", aspectLocked); | |
| 708 | aspectLock.classList.toggle("text-primary", aspectLocked); | |
| 709 | aspectLock.classList.toggle("bg-muted", !aspectLocked); | |
| 710 | aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); | |
| 656 | 711 | }; |
| 657 | 712 | aspectLock.addEventListener("click", () => { |
| 658 | 713 | aspectLocked = !aspectLocked; |
| ... | ... | @@ -733,7 +788,7 @@ function startAnnotation(): void { |
| 733 | 788 | ]; |
| 734 | 789 | for (const [candidate, label, title] of tools) { |
| 735 | 790 | const button = createButton(label, title); |
| 736 | button.style.cssText += ";padding:5px 7px;font-size:11px;border-radius:6px"; | |
| 791 | button.className += " h-8 px-2.5 text-sm"; | |
| 737 | 792 | button.addEventListener("click", () => { |
| 738 | 793 | tool = candidate; |
| 739 | 794 | refreshToolButtons(); |
| ... | ... | @@ -742,14 +797,6 @@ function startAnnotation(): void { |
| 742 | 797 | toolbar.appendChild(button); |
| 743 | 798 | } |
| 744 | 799 | |
| 745 | adjust.addEventListener("click", () => { | |
| 746 | if (selected.size === 0) return; | |
| 747 | stylesExpanded = !stylesExpanded; | |
| 748 | stylePanel.style.display = stylesExpanded ? "grid" : "none"; | |
| 749 | adjust.textContent = stylesExpanded ? "Hide styles" : "Adjust"; | |
| 750 | if (stylesExpanded) syncStyleControls(); | |
| 751 | }); | |
| 752 | ||
| 753 | 800 | const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { |
| 754 | 801 | const margin = 8; |
| 755 | 802 | const rect = editor.getBoundingClientRect(); |
| ... | ... | @@ -766,23 +813,98 @@ function startAnnotation(): void { |
| 766 | 813 | }; |
| 767 | 814 | |
| 768 | 815 | const applyEditorPosition = (position: { left: number; top: number }): void => { |
| 769 | editorPosition = clampEditorPosition(position.left, position.top); | |
| 770 | editor.style.left = `${editorPosition.left}px`; | |
| 771 | editor.style.top = `${editorPosition.top}px`; | |
| 816 | const clamped = clampEditorPosition(position.left, position.top); | |
| 817 | editor.style.left = `${clamped.left}px`; | |
| 818 | editor.style.top = `${clamped.top}px`; | |
| 772 | 819 | editor.style.right = "auto"; |
| 773 | 820 | editor.style.bottom = "auto"; |
| 821 | if (editorExpanded) editorPosition = clamped; | |
| 822 | }; | |
| 823 | ||
| 824 | const getAnnotationBounds = (): PreviewAnnotationRect | null => | |
| 825 | unionRects( | |
| 826 | [ | |
| 827 | ...Array.from(selected.values(), (target) => | |
| 828 | rectFromDomRect(target.element.getBoundingClientRect()), | |
| 829 | ), | |
| 830 | ...regions.map((region) => region.rect), | |
| 831 | ...strokes.map((stroke) => stroke.bounds), | |
| 832 | ], | |
| 833 | 0, | |
| 834 | ); | |
| 835 | ||
| 836 | const positionCompactEditor = (): void => { | |
| 837 | const bounds = getAnnotationBounds(); | |
| 838 | if (!bounds) return; | |
| 839 | const editorRect = editor.getBoundingClientRect(); | |
| 840 | const gap = 8; | |
| 841 | const candidates = [ | |
| 842 | { left: bounds.x + bounds.width + gap, top: bounds.y }, | |
| 843 | { left: bounds.x - editorRect.width - gap, top: bounds.y }, | |
| 844 | { | |
| 845 | left: bounds.x + bounds.width - editorRect.width, | |
| 846 | top: bounds.y + bounds.height + gap, | |
| 847 | }, | |
| 848 | { | |
| 849 | left: bounds.x + bounds.width - editorRect.width, | |
| 850 | top: bounds.y - editorRect.height - gap, | |
| 851 | }, | |
| 852 | ]; | |
| 853 | const overflow = (position: { left: number; top: number }): number => | |
| 854 | Math.max(0, -position.left) + | |
| 855 | Math.max(0, -position.top) + | |
| 856 | Math.max(0, position.left + editorRect.width - window.innerWidth) + | |
| 857 | Math.max(0, position.top + editorRect.height - window.innerHeight); | |
| 858 | const best = candidates.reduce((current, candidate) => | |
| 859 | overflow(candidate) < overflow(current) ? candidate : current, | |
| 860 | ); | |
| 861 | applyEditorPosition(best); | |
| 774 | 862 | }; |
| 775 | 863 | |
| 864 | function queueEditorLayout(): void { | |
| 865 | if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); | |
| 866 | editorLayoutFrame = window.requestAnimationFrame(() => { | |
| 867 | editorLayoutFrame = null; | |
| 868 | if (editor.style.display === "none") return; | |
| 869 | if (editorExpanded && editorPosition) applyEditorPosition(editorPosition); | |
| 870 | else positionCompactEditor(); | |
| 871 | }); | |
| 872 | } | |
| 873 | ||
| 874 | adjust.addEventListener("click", () => { | |
| 875 | if (selected.size === 0) return; | |
| 876 | if (!editorExpanded) { | |
| 877 | const rect = editor.getBoundingClientRect(); | |
| 878 | editorExpanded = true; | |
| 879 | editorPosition = { left: rect.left, top: rect.top }; | |
| 880 | stylePanel.style.display = selected.size > 0 ? "grid" : "none"; | |
| 881 | dragHandle.style.display = "block"; | |
| 882 | adjust.setAttribute("aria-expanded", "true"); | |
| 883 | adjust.title = "Collapse annotation editor"; | |
| 884 | adjust.setAttribute("aria-label", "Collapse annotation editor"); | |
| 885 | if (selected.size > 0) syncStyleControls(); | |
| 886 | } else { | |
| 887 | editorExpanded = false; | |
| 888 | editorPosition = null; | |
| 889 | stylePanel.style.display = "none"; | |
| 890 | dragHandle.style.display = "none"; | |
| 891 | adjust.setAttribute("aria-expanded", "false"); | |
| 892 | adjust.title = "Expand annotation editor"; | |
| 893 | adjust.setAttribute("aria-label", "Expand annotation editor"); | |
| 894 | } | |
| 895 | queueEditorLayout(); | |
| 896 | }); | |
| 897 | ||
| 776 | 898 | const onEditorPointerDown = (event: PointerEvent): void => { |
| 777 | if (event.button !== 0) return; | |
| 899 | if (event.button !== 0 || !editorExpanded) return; | |
| 778 | 900 | const rect = editor.getBoundingClientRect(); |
| 779 | 901 | editorDrag = { |
| 780 | 902 | pointerId: event.pointerId, |
| 781 | 903 | offsetX: event.clientX - rect.left, |
| 782 | 904 | offsetY: event.clientY - rect.top, |
| 783 | 905 | }; |
| 784 | editorHeader.setPointerCapture(event.pointerId); | |
| 785 | editorHeader.style.cursor = "grabbing"; | |
| 906 | dragHandle.setPointerCapture(event.pointerId); | |
| 907 | dragHandle.style.cursor = "grabbing"; | |
| 786 | 908 | event.preventDefault(); |
| 787 | 909 | event.stopPropagation(); |
| 788 | 910 | }; |
| ... | ... | @@ -800,20 +922,20 @@ function startAnnotation(): void { |
| 800 | 922 | const onEditorPointerUp = (event: PointerEvent): void => { |
| 801 | 923 | if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; |
| 802 | 924 | editorDrag = null; |
| 803 | editorHeader.style.cursor = "grab"; | |
| 804 | if (editorHeader.hasPointerCapture(event.pointerId)) | |
| 805 | editorHeader.releasePointerCapture(event.pointerId); | |
| 925 | dragHandle.style.cursor = "grab"; | |
| 926 | if (dragHandle.hasPointerCapture(event.pointerId)) | |
| 927 | dragHandle.releasePointerCapture(event.pointerId); | |
| 806 | 928 | event.preventDefault(); |
| 807 | 929 | event.stopPropagation(); |
| 808 | 930 | }; |
| 809 | editorHeader.addEventListener("pointerdown", onEditorPointerDown); | |
| 810 | editorHeader.addEventListener("pointermove", onEditorPointerMove); | |
| 811 | editorHeader.addEventListener("pointerup", onEditorPointerUp); | |
| 812 | editorHeader.addEventListener("pointercancel", onEditorPointerUp); | |
| 931 | dragHandle.addEventListener("pointerdown", onEditorPointerDown); | |
| 932 | dragHandle.addEventListener("pointermove", onEditorPointerMove); | |
| 933 | dragHandle.addEventListener("pointerup", onEditorPointerUp); | |
| 934 | dragHandle.addEventListener("pointercancel", onEditorPointerUp); | |
| 813 | 935 | |
| 814 | 936 | const repaint = (): void => { |
| 815 | 937 | for (const target of selected.values()) updateSelectedVisual(target); |
| 816 | if (editorPosition) applyEditorPosition(editorPosition); | |
| 938 | queueEditorLayout(); | |
| 817 | 939 | }; |
| 818 | 940 | |
| 819 | 941 | const removeTargetAtPoint = (x: number, y: number): boolean => { |
| ... | ... | @@ -941,7 +1063,7 @@ function startAnnotation(): void { |
| 941 | 1063 | if (tool === "draw") { |
| 942 | 1064 | const stroke: PreviewAnnotationStrokeTarget = { |
| 943 | 1065 | id: nextId("stroke"), |
| 944 | color: ACCENT, | |
| 1066 | color: annotationTheme?.primary ?? "#2563eb", | |
| 945 | 1067 | width: 4, |
| 946 | 1068 | points: [dragStart], |
| 947 | 1069 | bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, |
| ... | ... | @@ -971,7 +1093,10 @@ function startAnnotation(): void { |
| 971 | 1093 | if (found === 0) { |
| 972 | 1094 | const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; |
| 973 | 1095 | regions.push(region); |
| 974 | const regionBox = createBox(ACCENT, "rgba(124,58,237,.06)"); | |
| 1096 | const regionBox = createBox( | |
| 1097 | PRIMARY, | |
| 1098 | "color-mix(in srgb, var(--t3-primary) 6%, transparent)", | |
| 1099 | ); | |
| 975 | 1100 | regionBox.setAttribute("data-region-id", region.id); |
| 976 | 1101 | positionBox(regionBox, rect); |
| 977 | 1102 | root.appendChild(regionBox); |
| ... | ... | @@ -1024,14 +1149,16 @@ function startAnnotation(): void { |
| 1024 | 1149 | window.removeEventListener("keydown", onKeyDown, true); |
| 1025 | 1150 | window.removeEventListener("scroll", repaint, true); |
| 1026 | 1151 | window.removeEventListener("resize", repaint); |
| 1027 | editorHeader.removeEventListener("pointerdown", onEditorPointerDown); | |
| 1028 | editorHeader.removeEventListener("pointermove", onEditorPointerMove); | |
| 1029 | editorHeader.removeEventListener("pointerup", onEditorPointerUp); | |
| 1030 | editorHeader.removeEventListener("pointercancel", onEditorPointerUp); | |
| 1152 | dragHandle.removeEventListener("pointerdown", onEditorPointerDown); | |
| 1153 | dragHandle.removeEventListener("pointermove", onEditorPointerMove); | |
| 1154 | dragHandle.removeEventListener("pointerup", onEditorPointerUp); | |
| 1155 | dragHandle.removeEventListener("pointercancel", onEditorPointerUp); | |
| 1156 | if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); | |
| 1031 | 1157 | ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); |
| 1032 | 1158 | ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); |
| 1033 | 1159 | document.documentElement.removeAttribute("data-t3code-annotation-tool"); |
| 1034 | root.remove(); | |
| 1160 | cursorStyle.remove(); | |
| 1161 | host.remove(); | |
| 1035 | 1162 | activeSession = null; |
| 1036 | 1163 | if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); |
| 1037 | 1164 | }; |
| ... | ... | @@ -1115,11 +1242,21 @@ function startAnnotation(): void { |
| 1115 | 1242 | window.addEventListener("resize", repaint, { passive: true }); |
| 1116 | 1243 | ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); |
| 1117 | 1244 | ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); |
| 1118 | document.documentElement.appendChild(root); | |
| 1245 | document.documentElement.appendChild(host); | |
| 1119 | 1246 | refreshToolButtons(); |
| 1120 | 1247 | updateStatus(); |
| 1121 | activeSession = { teardown }; | |
| 1248 | activeSession = { | |
| 1249 | teardown, | |
| 1250 | applyTheme: (theme) => applyAnnotationTheme(host, theme), | |
| 1251 | }; | |
| 1122 | 1252 | } |
| 1123 | 1253 | |
| 1124 | ipcRenderer.on(START_PICK_CHANNEL, () => startAnnotation()); | |
| 1254 | ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => { | |
| 1255 | if (theme) annotationTheme = theme; | |
| 1256 | startAnnotation(); | |
| 1257 | }); | |
| 1258 | ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { | |
| 1259 | annotationTheme = theme; | |
| 1260 | activeSession?.applyTheme(theme); | |
| 1261 | }); | |
| 1125 | 1262 | ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); |
apps/desktop/src/preview-view-manager.test.ts+186-5| ... | ... | @@ -3,16 +3,26 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | const fromId = vi.fn(() => null); |
| 4 | 4 | const mkdir = vi.fn(async () => undefined); |
| 5 | 5 | const writeFile = vi.fn(async () => undefined); |
| 6 | const showItemInFolder = vi.fn(); | |
| 7 | const writeImage = vi.fn(); | |
| 8 | const createFromPath = vi.fn(() => ({ isEmpty: () => false })); | |
| 9 | const webviewSend = vi.fn(); | |
| 6 | 10 | |
| 7 | 11 | vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); |
| 8 | 12 | |
| 9 | 13 | vi.mock("electron", () => ({ |
| 10 | app: { | |
| 11 | getPath: vi.fn(() => "/tmp/t3-code-test"), | |
| 14 | clipboard: { | |
| 15 | writeImage, | |
| 16 | }, | |
| 17 | nativeImage: { | |
| 18 | createFromPath, | |
| 12 | 19 | }, |
| 13 | 20 | session: { |
| 14 | 21 | fromPartition: vi.fn(), |
| 15 | 22 | }, |
| 23 | shell: { | |
| 24 | showItemInFolder, | |
| 25 | }, | |
| 16 | 26 | webContents: { |
| 17 | 27 | fromId, |
| 18 | 28 | }, |
| ... | ... | @@ -23,6 +33,10 @@ describe("PreviewViewManager automation status", () => { |
| 23 | 33 | fromId.mockClear(); |
| 24 | 34 | mkdir.mockClear(); |
| 25 | 35 | writeFile.mockClear(); |
| 36 | showItemInFolder.mockClear(); | |
| 37 | writeImage.mockClear(); | |
| 38 | createFromPath.mockClear(); | |
| 39 | webviewSend.mockClear(); | |
| 26 | 40 | }); |
| 27 | 41 | |
| 28 | 42 | it("reports an unregistered webview as temporarily unavailable", async () => { |
| ... | ... | @@ -59,7 +73,7 @@ describe("PreviewViewManager automation status", () => { |
| 59 | 73 | id: 42, |
| 60 | 74 | isDestroyed: () => false, |
| 61 | 75 | getType: () => "webview", |
| 62 | getURL: () => "https://example.com/", | |
| 76 | getURL: () => "https://example.com:8443/path?query=value", | |
| 63 | 77 | getTitle: () => "Example", |
| 64 | 78 | isLoading: () => false, |
| 65 | 79 | getZoomFactor: () => 1, |
| ... | ... | @@ -69,6 +83,7 @@ describe("PreviewViewManager automation status", () => { |
| 69 | 83 | }), |
| 70 | 84 | off: vi.fn(), |
| 71 | 85 | ipc: { on: vi.fn(), off: vi.fn() }, |
| 86 | send: webviewSend, | |
| 72 | 87 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, |
| 73 | 88 | setWindowOpenHandler: vi.fn(), |
| 74 | 89 | debugger: { |
| ... | ... | @@ -82,13 +97,22 @@ describe("PreviewViewManager automation status", () => { |
| 82 | 97 | } as never); |
| 83 | 98 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); |
| 84 | 99 | const manager = new PreviewViewManager(); |
| 100 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | |
| 85 | 101 | manager.createTab("tab_1"); |
| 86 | 102 | manager.registerWebview("tab_1", 42); |
| 87 | 103 | |
| 104 | expect(webviewSend).toHaveBeenCalledWith( | |
| 105 | "preview:annotation-theme", | |
| 106 | expect.objectContaining({ | |
| 107 | colorScheme: "light", | |
| 108 | primary: "oklch(0.488 0.217 264)", | |
| 109 | }), | |
| 110 | ); | |
| 111 | ||
| 88 | 112 | const artifact = await manager.captureScreenshot("tab_1"); |
| 89 | 113 | |
| 90 | 114 | expect(capturePage).toHaveBeenCalledOnce(); |
| 91 | expect(mkdir).toHaveBeenCalledWith("/tmp/t3-code-test/browser-artifacts", { | |
| 115 | expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", { | |
| 92 | 116 | recursive: true, |
| 93 | 117 | }); |
| 94 | 118 | expect(writeFile).toHaveBeenCalledWith(artifact.path, png); |
| ... | ... | @@ -97,6 +121,163 @@ describe("PreviewViewManager automation status", () => { |
| 97 | 121 | mimeType: "image/png", |
| 98 | 122 | sizeBytes: png.byteLength, |
| 99 | 123 | }); |
| 100 | expect(artifact.path).toMatch(/\/browser-artifacts\/browser-screenshot-[^.]+\.png$/); | |
| 124 | expect(artifact.path).toMatch( | |
| 125 | /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, | |
| 126 | ); | |
| 127 | }); | |
| 128 | ||
| 129 | it("reveals only files inside the configured browser artifact directory", async () => { | |
| 130 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | |
| 131 | const manager = new PreviewViewManager(); | |
| 132 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | |
| 133 | ||
| 134 | manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); | |
| 135 | ||
| 136 | expect(showItemInFolder).toHaveBeenCalledWith( | |
| 137 | "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", | |
| 138 | ); | |
| 139 | expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow( | |
| 140 | "outside the configured artifact directory", | |
| 141 | ); | |
| 142 | }); | |
| 143 | ||
| 144 | it("copies screenshot artifacts to the system clipboard", async () => { | |
| 145 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | |
| 146 | const manager = new PreviewViewManager(); | |
| 147 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | |
| 148 | const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; | |
| 149 | ||
| 150 | manager.copyArtifactToClipboard(artifactPath); | |
| 151 | ||
| 152 | expect(createFromPath).toHaveBeenCalledWith(artifactPath); | |
| 153 | expect(writeImage).toHaveBeenCalledOnce(); | |
| 154 | expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow( | |
| 155 | "outside the configured artifact directory", | |
| 156 | ); | |
| 157 | }); | |
| 158 | ||
| 159 | it("emits the resolved pointer target before dispatching an automation click", async () => { | |
| 160 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | |
| 161 | const activity: string[] = []; | |
| 162 | const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => { | |
| 163 | if (method === "Runtime.evaluate") { | |
| 164 | return { | |
| 165 | result: { | |
| 166 | value: { width: 800, height: 600 }, | |
| 167 | }, | |
| 168 | }; | |
| 169 | } | |
| 170 | if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { | |
| 171 | activity.push("mousePressed"); | |
| 172 | humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); | |
| 173 | } | |
| 174 | return undefined; | |
| 175 | }); | |
| 176 | fromId.mockReturnValue({ | |
| 177 | id: 42, | |
| 178 | isDestroyed: () => false, | |
| 179 | getType: () => "webview", | |
| 180 | getURL: () => "https://example.com", | |
| 181 | getTitle: () => "Example", | |
| 182 | isLoading: () => false, | |
| 183 | isDevToolsOpened: () => false, | |
| 184 | getZoomFactor: () => 1, | |
| 185 | setZoomFactor: vi.fn(), | |
| 186 | on: vi.fn(), | |
| 187 | off: vi.fn(), | |
| 188 | ipc: { | |
| 189 | on: vi.fn((channel: string, listener: typeof humanInput) => { | |
| 190 | if (channel === "preview:human-input") humanInput = listener; | |
| 191 | }), | |
| 192 | off: vi.fn(), | |
| 193 | }, | |
| 194 | send: webviewSend, | |
| 195 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 196 | setWindowOpenHandler: vi.fn(), | |
| 197 | debugger: { | |
| 198 | isAttached: () => false, | |
| 199 | attach: vi.fn(), | |
| 200 | sendCommand, | |
| 201 | on: vi.fn(), | |
| 202 | off: vi.fn(), | |
| 203 | }, | |
| 204 | } as never); | |
| 205 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | |
| 206 | const manager = new PreviewViewManager(); | |
| 207 | manager.onPointerEvent((event) => activity.push(event.phase)); | |
| 208 | manager.createTab("tab_1"); | |
| 209 | manager.registerWebview("tab_1", 42); | |
| 210 | ||
| 211 | await manager.automationClick("tab_1", { x: 120, y: 80 }); | |
| 212 | ||
| 213 | expect(activity).toEqual(["move", "click", "mousePressed"]); | |
| 214 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { | |
| 215 | type: "mousePressed", | |
| 216 | x: 120, | |
| 217 | y: 80, | |
| 218 | button: "left", | |
| 219 | clickCount: 1, | |
| 220 | }); | |
| 221 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { | |
| 222 | type: "mouseReleased", | |
| 223 | x: 120, | |
| 224 | y: 80, | |
| 225 | button: "left", | |
| 226 | clickCount: 1, | |
| 227 | }); | |
| 228 | }); | |
| 229 | ||
| 230 | it("still interrupts agent control for a different human pointer event", async () => { | |
| 231 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | |
| 232 | const sendCommand = vi.fn(async (method: string) => { | |
| 233 | if (method === "Runtime.evaluate") { | |
| 234 | return { | |
| 235 | result: { | |
| 236 | value: { width: 800, height: 600 }, | |
| 237 | }, | |
| 238 | }; | |
| 239 | } | |
| 240 | if (method === "Input.dispatchMouseEvent") { | |
| 241 | humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); | |
| 242 | } | |
| 243 | return undefined; | |
| 244 | }); | |
| 245 | fromId.mockReturnValue({ | |
| 246 | id: 42, | |
| 247 | isDestroyed: () => false, | |
| 248 | getType: () => "webview", | |
| 249 | getURL: () => "https://example.com", | |
| 250 | getTitle: () => "Example", | |
| 251 | isLoading: () => false, | |
| 252 | isDevToolsOpened: () => false, | |
| 253 | getZoomFactor: () => 1, | |
| 254 | setZoomFactor: vi.fn(), | |
| 255 | on: vi.fn(), | |
| 256 | off: vi.fn(), | |
| 257 | ipc: { | |
| 258 | on: vi.fn((channel: string, listener: typeof humanInput) => { | |
| 259 | if (channel === "preview:human-input") humanInput = listener; | |
| 260 | }), | |
| 261 | off: vi.fn(), | |
| 262 | }, | |
| 263 | send: webviewSend, | |
| 264 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | |
| 265 | setWindowOpenHandler: vi.fn(), | |
| 266 | debugger: { | |
| 267 | isAttached: () => false, | |
| 268 | attach: vi.fn(), | |
| 269 | sendCommand, | |
| 270 | on: vi.fn(), | |
| 271 | off: vi.fn(), | |
| 272 | }, | |
| 273 | } as never); | |
| 274 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | |
| 275 | const manager = new PreviewViewManager(); | |
| 276 | manager.createTab("tab_1"); | |
| 277 | manager.registerWebview("tab_1", 42); | |
| 278 | ||
| 279 | await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({ | |
| 280 | name: "PreviewAutomationControlInterruptedError", | |
| 281 | }); | |
| 101 | 282 | }); |
| 102 | 283 | }); |
apps/desktop/src/preview-view-manager.ts+224-8| ... | ... | @@ -8,6 +8,8 @@ |
| 8 | 8 | * here). Single layer-scoped browser session partition. |
| 9 | 9 | */ |
| 10 | 10 | import type { |
| 11 | DesktopPreviewAnnotationTheme, | |
| 12 | DesktopPreviewPointerEvent, | |
| 11 | 13 | PreviewAnnotationPayload, |
| 12 | 14 | PreviewAnnotationRect, |
| 13 | 15 | DesktopPreviewRecordingArtifact, |
| ... | ... | @@ -26,9 +28,17 @@ import type { |
| 26 | 28 | PreviewAutomationWaitForInput, |
| 27 | 29 | } from "@t3tools/contracts"; |
| 28 | 30 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 29 | import { app, type BrowserWindow, type Session, session, webContents } from "electron"; | |
| 31 | import { | |
| 32 | type BrowserWindow, | |
| 33 | type Session, | |
| 34 | clipboard, | |
| 35 | nativeImage, | |
| 36 | session, | |
| 37 | shell, | |
| 38 | webContents, | |
| 39 | } from "electron"; | |
| 30 | 40 | import { mkdir, writeFile } from "node:fs/promises"; |
| 31 | import { join } from "node:path"; | |
| 41 | import { isAbsolute, join, relative, resolve, sep } from "node:path"; | |
| 32 | 42 | import { createHash } from "node:crypto"; |
| 33 | 43 | import { setTimeout as sleep } from "node:timers/promises"; |
| 34 | 44 | |
| ... | ... | @@ -40,6 +50,7 @@ const START_PICK_CHANNEL = "preview:start-pick"; |
| 40 | 50 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; |
| 41 | 51 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; |
| 42 | 52 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; |
| 53 | const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; | |
| 43 | 54 | const HUMAN_INPUT_CHANNEL = "preview:human-input"; |
| 44 | 55 | |
| 45 | 56 | // Re-export the guest webview security posture from its dedicated module so |
| ... | ... | @@ -83,6 +94,43 @@ const MAX_VISIBLE_TEXT_LENGTH = 20_000; |
| 83 | 94 | const MAX_INTERACTIVE_ELEMENTS = 200; |
| 84 | 95 | const MAX_SCREENSHOT_WIDTH = 1280; |
| 85 | 96 | const DIAGNOSTIC_BUFFER_LIMIT = 200; |
| 97 | const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; | |
| 98 | const AGENT_CURSOR_MOVE_MS = 160; | |
| 99 | const AGENT_CURSOR_CLICK_LEAD_MS = 40; | |
| 100 | const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { | |
| 101 | colorScheme: "light", | |
| 102 | radius: "0.625rem", | |
| 103 | background: "white", | |
| 104 | foreground: "oklch(0.269 0 0)", | |
| 105 | popover: "white", | |
| 106 | popoverForeground: "oklch(0.269 0 0)", | |
| 107 | primary: "oklch(0.488 0.217 264)", | |
| 108 | primaryForeground: "white", | |
| 109 | muted: "rgb(0 0 0 / 4%)", | |
| 110 | mutedForeground: "oklch(0.556 0 0)", | |
| 111 | accent: "rgb(0 0 0 / 4%)", | |
| 112 | accentForeground: "oklch(0.269 0 0)", | |
| 113 | border: "rgb(0 0 0 / 8%)", | |
| 114 | input: "rgb(0 0 0 / 10%)", | |
| 115 | ring: "oklch(0.488 0.217 264)", | |
| 116 | fontSans: "system-ui, sans-serif", | |
| 117 | fontMono: "ui-monospace, monospace", | |
| 118 | }; | |
| 119 | ||
| 120 | const artifactSiteSlug = (rawUrl: string): string => { | |
| 121 | try { | |
| 122 | const url = new URL(rawUrl); | |
| 123 | const slug = url.hostname | |
| 124 | .toLowerCase() | |
| 125 | .replace(/[^a-z0-9]+/g, "-") | |
| 126 | .replace(/^-+|-+$/g, "") | |
| 127 | .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH) | |
| 128 | .replace(/-+$/g, ""); | |
| 129 | return slug || "site"; | |
| 130 | } catch { | |
| 131 | return "site"; | |
| 132 | } | |
| 133 | }; | |
| 86 | 134 | |
| 87 | 135 | interface CdpEvaluationResult { |
| 88 | 136 | readonly result?: { |
| ... | ... | @@ -182,10 +230,14 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { |
| 182 | 230 | type Listener = (tabId: string, state: PreviewTabState) => void; |
| 183 | 231 | type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; |
| 184 | 232 | |
| 233 | type PreviewInputSignal = | |
| 234 | | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } | |
| 235 | | { readonly kind: "key"; readonly key: string; readonly code: string }; | |
| 236 | ||
| 185 | 237 | interface ManagedListeners { |
| 186 | 238 | navigate: () => void; |
| 187 | 239 | failed: (event: Event, code: number, description: string) => void; |
| 188 | humanInput: () => void; | |
| 240 | humanInput: (_event: unknown, signal?: unknown) => void; | |
| 189 | 241 | } |
| 190 | 242 | |
| 191 | 243 | interface PickSession { |
| ... | ... | @@ -205,6 +257,13 @@ interface BrowserDiagnostics { |
| 205 | 257 | readonly requests: Map<string, { url: string; method: string }>; |
| 206 | 258 | } |
| 207 | 259 | |
| 260 | type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; | |
| 261 | ||
| 262 | interface ExpectedAgentInput { | |
| 263 | readonly signal: PreviewInputSignal; | |
| 264 | readonly expiresAt: number; | |
| 265 | } | |
| 266 | ||
| 208 | 267 | const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ |
| 209 | 268 | key: string; |
| 210 | 269 | meta: boolean; |
| ... | ... | @@ -221,23 +280,116 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ |
| 221 | 280 | { key: "w", meta: true, shift: false, control: false }, |
| 222 | 281 | ]); |
| 223 | 282 | |
| 283 | const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { | |
| 284 | if (typeof value !== "object" || value === null || !("kind" in value)) return false; | |
| 285 | if (value.kind === "pointer") { | |
| 286 | return ( | |
| 287 | "x" in value && | |
| 288 | typeof value.x === "number" && | |
| 289 | "y" in value && | |
| 290 | typeof value.y === "number" && | |
| 291 | "button" in value && | |
| 292 | typeof value.button === "number" | |
| 293 | ); | |
| 294 | } | |
| 295 | return ( | |
| 296 | value.kind === "key" && | |
| 297 | "key" in value && | |
| 298 | typeof value.key === "string" && | |
| 299 | "code" in value && | |
| 300 | typeof value.code === "string" | |
| 301 | ); | |
| 302 | }; | |
| 303 | ||
| 304 | const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => { | |
| 305 | if (left.kind !== right.kind) return false; | |
| 306 | if (left.kind === "pointer" && right.kind === "pointer") { | |
| 307 | return ( | |
| 308 | Math.abs(left.x - right.x) <= 1 && | |
| 309 | Math.abs(left.y - right.y) <= 1 && | |
| 310 | left.button === right.button | |
| 311 | ); | |
| 312 | } | |
| 313 | return ( | |
| 314 | left.kind === "key" && | |
| 315 | right.kind === "key" && | |
| 316 | left.key === right.key && | |
| 317 | left.code === right.code | |
| 318 | ); | |
| 319 | }; | |
| 320 | ||
| 224 | 321 | export class PreviewViewManager { |
| 322 | private annotationTheme = DEFAULT_ANNOTATION_THEME; | |
| 323 | private artifactDirectory: string | null = null; | |
| 225 | 324 | private mainWindow: BrowserWindow | null = null; |
| 226 | 325 | private readonly tabs = new Map<string, PreviewTabState>(); |
| 227 | 326 | private readonly attached = new Map<number, ManagedListeners>(); |
| 228 | 327 | private readonly browserSessions = new Map<string, Session>(); |
| 229 | 328 | private readonly listeners = new Set<Listener>(); |
| 329 | private readonly pointerEventListeners = new Set<PointerEventListener>(); | |
| 230 | 330 | private readonly recordingFrameListeners = new Set<RecordingFrameListener>(); |
| 231 | 331 | /** In-flight preview annotation sessions, keyed by tabId. */ |
| 232 | 332 | private readonly pickSessions = new Map<string, PickSession>(); |
| 233 | 333 | /** One long-lived CDP attachment and serialized command queue per guest. */ |
| 234 | 334 | private readonly controlSessions = new Map<number, BrowserControlSession>(); |
| 235 | 335 | private readonly diagnostics = new Map<number, BrowserDiagnostics>(); |
| 336 | private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>(); | |
| 236 | 337 | private readonly controlEpoch = new Map<string, number>(); |
| 237 | 338 | private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>(); |
| 238 | 339 | private actionSequence = 0; |
| 340 | private pointerSequence = 0; | |
| 239 | 341 | private recordingTabId: string | null = null; |
| 240 | 342 | |
| 343 | configureArtifactDirectory(directory: string): void { | |
| 344 | this.artifactDirectory = resolve(directory); | |
| 345 | } | |
| 346 | ||
| 347 | private requireArtifactDirectory(): string { | |
| 348 | if (!this.artifactDirectory) { | |
| 349 | throw new Error("Preview artifact directory is not configured."); | |
| 350 | } | |
| 351 | return this.artifactDirectory; | |
| 352 | } | |
| 353 | ||
| 354 | private resolveArtifactPath(path: string): string { | |
| 355 | const directory = this.requireArtifactDirectory(); | |
| 356 | const resolvedPath = resolve(path); | |
| 357 | const relativePath = relative(directory, resolvedPath); | |
| 358 | if ( | |
| 359 | relativePath.length === 0 || | |
| 360 | relativePath === ".." || | |
| 361 | relativePath.startsWith(`..${sep}`) || | |
| 362 | isAbsolute(relativePath) | |
| 363 | ) { | |
| 364 | throw new Error("Preview artifact path is outside the configured artifact directory."); | |
| 365 | } | |
| 366 | return resolvedPath; | |
| 367 | } | |
| 368 | ||
| 369 | revealArtifact(path: string): void { | |
| 370 | const resolvedPath = this.resolveArtifactPath(path); | |
| 371 | shell.showItemInFolder(resolvedPath); | |
| 372 | } | |
| 373 | ||
| 374 | copyArtifactToClipboard(path: string): void { | |
| 375 | const resolvedPath = this.resolveArtifactPath(path); | |
| 376 | const image = nativeImage.createFromPath(resolvedPath); | |
| 377 | if (image.isEmpty()) { | |
| 378 | throw new Error("Preview artifact could not be loaded as an image."); | |
| 379 | } | |
| 380 | clipboard.writeImage(image); | |
| 381 | } | |
| 382 | ||
| 383 | setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void { | |
| 384 | this.annotationTheme = theme; | |
| 385 | for (const tab of this.tabs.values()) { | |
| 386 | if (tab.webContentsId == null) continue; | |
| 387 | const wc = webContents.fromId(tab.webContentsId); | |
| 388 | if (!wc || wc.isDestroyed()) continue; | |
| 389 | wc.send(ANNOTATION_THEME_CHANNEL, theme); | |
| 390 | } | |
| 391 | } | |
| 392 | ||
| 241 | 393 | setMainWindow(window: BrowserWindow): void { |
| 242 | 394 | this.mainWindow = window; |
| 243 | 395 | } |
| ... | ... | @@ -338,6 +490,7 @@ export class PreviewViewManager { |
| 338 | 490 | throw new PreviewWebContentsNotFoundError(tabId, webContentsId); |
| 339 | 491 | } |
| 340 | 492 | if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) { |
| 493 | wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); | |
| 341 | 494 | return; |
| 342 | 495 | } |
| 343 | 496 | if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { |
| ... | ... | @@ -366,6 +519,7 @@ export class PreviewViewManager { |
| 366 | 519 | canGoForward: wc.navigationHistory.canGoForward(), |
| 367 | 520 | zoomFactor: tab.zoomFactor, |
| 368 | 521 | }); |
| 522 | wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); | |
| 369 | 523 | } |
| 370 | 524 | |
| 371 | 525 | async navigate(tabId: string, rawUrl: string): Promise<void> { |
| ... | ... | @@ -513,7 +667,7 @@ export class PreviewViewManager { |
| 513 | 667 | // wc may be torn down; the next try/catch settles. |
| 514 | 668 | } |
| 515 | 669 | try { |
| 516 | wc.send(START_PICK_CHANNEL); | |
| 670 | wc.send(START_PICK_CHANNEL, this.annotationTheme); | |
| 517 | 671 | } catch { |
| 518 | 672 | settle(null); |
| 519 | 673 | } |
| ... | ... | @@ -561,8 +715,8 @@ export class PreviewViewManager { |
| 561 | 715 | async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> { |
| 562 | 716 | const wc = this.requireWebContents(tabId); |
| 563 | 717 | const createdAt = new Date().toISOString(); |
| 564 | const id = `browser-screenshot-${Date.now().toString(36)}`; | |
| 565 | const directory = join(app.getPath("userData"), "browser-artifacts"); | |
| 718 | const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`; | |
| 719 | const directory = this.requireArtifactDirectory(); | |
| 566 | 720 | const path = join(directory, `${id}.png`); |
| 567 | 721 | const data = (await wc.capturePage()).toPNG(); |
| 568 | 722 | await mkdir(directory, { recursive: true }); |
| ... | ... | @@ -587,7 +741,7 @@ export class PreviewViewManager { |
| 587 | 741 | const createdAt = new Date().toISOString(); |
| 588 | 742 | const id = `browser-recording-${Date.now().toString(36)}`; |
| 589 | 743 | const extension = mimeType.includes("mp4") ? "mp4" : "webm"; |
| 590 | const directory = join(app.getPath("userData"), "browser-artifacts"); | |
| 744 | const directory = this.requireArtifactDirectory(); | |
| 591 | 745 | const path = join(directory, `${id}.${extension}`); |
| 592 | 746 | await mkdir(directory, { recursive: true }); |
| 593 | 747 | await writeFile(path, data); |
| ... | ... | @@ -793,6 +947,25 @@ export class PreviewViewManager { |
| 793 | 947 | `Click coordinates (${x}, ${y}) are outside the preview viewport.`, |
| 794 | 948 | ); |
| 795 | 949 | } |
| 950 | this.emitPointerEvent({ | |
| 951 | tabId, | |
| 952 | phase: "move", | |
| 953 | x, | |
| 954 | y, | |
| 955 | sequence: this.pointerSequence++, | |
| 956 | createdAt: new Date().toISOString(), | |
| 957 | }); | |
| 958 | await sleep(AGENT_CURSOR_MOVE_MS); | |
| 959 | this.emitPointerEvent({ | |
| 960 | tabId, | |
| 961 | phase: "click", | |
| 962 | x, | |
| 963 | y, | |
| 964 | sequence: this.pointerSequence++, | |
| 965 | createdAt: new Date().toISOString(), | |
| 966 | }); | |
| 967 | await sleep(AGENT_CURSOR_CLICK_LEAD_MS); | |
| 968 | this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 }); | |
| 796 | 969 | await send("Input.dispatchMouseEvent", { |
| 797 | 970 | type: "mousePressed", |
| 798 | 971 | x, |
| ... | ... | @@ -886,6 +1059,7 @@ export class PreviewViewManager { |
| 886 | 1059 | modifiers, |
| 887 | 1060 | ...(text ? { text, unmodifiedText: text } : {}), |
| 888 | 1061 | }; |
| 1062 | this.expectAgentInput(tabId, { kind: "key", key, code: params.code }); | |
| 889 | 1063 | await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); |
| 890 | 1064 | await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); |
| 891 | 1065 | }); |
| ... | ... | @@ -1345,11 +1519,50 @@ export class PreviewViewManager { |
| 1345 | 1519 | }; |
| 1346 | 1520 | } |
| 1347 | 1521 | |
| 1522 | onPointerEvent(listener: PointerEventListener): () => void { | |
| 1523 | this.pointerEventListeners.add(listener); | |
| 1524 | return () => { | |
| 1525 | this.pointerEventListeners.delete(listener); | |
| 1526 | }; | |
| 1527 | } | |
| 1528 | ||
| 1529 | private emitPointerEvent(event: DesktopPreviewPointerEvent): void { | |
| 1530 | for (const listener of this.pointerEventListeners) listener(event); | |
| 1531 | } | |
| 1532 | ||
| 1533 | private expectAgentInput(tabId: string, signal: PreviewInputSignal): void { | |
| 1534 | const now = Date.now(); | |
| 1535 | const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( | |
| 1536 | (expected) => expected.expiresAt > now, | |
| 1537 | ); | |
| 1538 | pending.push({ signal, expiresAt: now + 1_000 }); | |
| 1539 | this.expectedAgentInputs.set(tabId, pending); | |
| 1540 | } | |
| 1541 | ||
| 1542 | private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean { | |
| 1543 | const now = Date.now(); | |
| 1544 | const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter( | |
| 1545 | (expected) => expected.expiresAt > now, | |
| 1546 | ); | |
| 1547 | const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal)); | |
| 1548 | if (index < 0) { | |
| 1549 | if (pending.length === 0) this.expectedAgentInputs.delete(tabId); | |
| 1550 | else this.expectedAgentInputs.set(tabId, pending); | |
| 1551 | return false; | |
| 1552 | } | |
| 1553 | pending.splice(index, 1); | |
| 1554 | if (pending.length === 0) this.expectedAgentInputs.delete(tabId); | |
| 1555 | else this.expectedAgentInputs.set(tabId, pending); | |
| 1556 | return true; | |
| 1557 | } | |
| 1558 | ||
| 1348 | 1559 | destroy(): void { |
| 1349 | 1560 | for (const tabId of Array.from(this.tabs.keys())) { |
| 1350 | 1561 | this.closeTab(tabId); |
| 1351 | 1562 | } |
| 1352 | 1563 | this.listeners.clear(); |
| 1564 | this.expectedAgentInputs.clear(); | |
| 1565 | this.pointerEventListeners.clear(); | |
| 1353 | 1566 | this.recordingFrameListeners.clear(); |
| 1354 | 1567 | } |
| 1355 | 1568 | |
| ... | ... | @@ -1375,7 +1588,10 @@ export class PreviewViewManager { |
| 1375 | 1588 | }, |
| 1376 | 1589 | }); |
| 1377 | 1590 | }; |
| 1378 | const humanInput = (): void => { | |
| 1591 | const humanInput = (_event: unknown, rawSignal?: unknown): void => { | |
| 1592 | if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) { | |
| 1593 | return; | |
| 1594 | } | |
| 1379 | 1595 | this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); |
| 1380 | 1596 | this.update(tabId, { controller: "human" }); |
| 1381 | 1597 | void sleep(750).then(() => { |
apps/desktop/vite.config.ts+4-3| ... | ... | @@ -14,17 +14,18 @@ export default defineConfig({ |
| 14 | 14 | run: { |
| 15 | 15 | tasks: { |
| 16 | 16 | build: { |
| 17 | command: "vp pack", | |
| 17 | command: "node scripts/build-preview-annotation-css.mjs && vp pack", | |
| 18 | 18 | dependsOn: ["t3#build"], |
| 19 | 19 | cache: false, |
| 20 | 20 | }, |
| 21 | 21 | dev: { |
| 22 | command: "cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", | |
| 22 | command: | |
| 23 | "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", | |
| 23 | 24 | dependsOn: ["t3#build"], |
| 24 | 25 | cache: false, |
| 25 | 26 | }, |
| 26 | 27 | "dev:bundle": { |
| 27 | command: "vp pack --watch", | |
| 28 | command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch", | |
| 28 | 29 | cache: false, |
| 29 | 30 | }, |
| 30 | 31 | "dev:electron": { |
apps/server/src/keybindings.test.ts+2| ... | ... | @@ -205,6 +205,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 205 | 205 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); |
| 206 | 206 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); |
| 207 | 207 | assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); |
| 208 | assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); | |
| 209 | assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); | |
| 208 | 210 | assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); |
| 209 | 211 | assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); |
| 210 | 212 | }), |
apps/server/src/mcp/Layers/McpHttpServer.test.ts+20-8| ... | ... | @@ -75,14 +75,16 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 75 | 75 | height: 5, |
| 76 | 76 | }, |
| 77 | 77 | } |
| 78 | : { | |
| 79 | available: true, | |
| 80 | visible: true, | |
| 81 | tabId, | |
| 82 | url: "http://example.test/", | |
| 83 | title: "Example", | |
| 84 | loading: false, | |
| 85 | }, | |
| 78 | : request.operation === "press" | |
| 79 | ? undefined | |
| 80 | : { | |
| 81 | available: true, | |
| 82 | visible: true, | |
| 83 | tabId, | |
| 84 | url: "http://example.test/", | |
| 85 | title: "Example", | |
| 86 | loading: false, | |
| 87 | }, | |
| 86 | 88 | }), |
| 87 | 89 | ).pipe(Effect.forkScoped); |
| 88 | 90 | yield* Effect.yieldNow; |
| ... | ... | @@ -146,6 +148,16 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 146 | 148 | expect(snapshot.structuredContent).toMatchObject({ |
| 147 | 149 | screenshot: { mimeType: "image/png", width: 10, height: 5 }, |
| 148 | 150 | }); |
| 151 | ||
| 152 | const press = yield* server | |
| 153 | .callTool({ name: "preview_press", arguments: { key: "Enter" } }) | |
| 154 | .pipe( | |
| 155 | Effect.provideService(McpInvocationContext, invocation), | |
| 156 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 157 | ); | |
| 158 | expect(press.isError).toBe(false); | |
| 159 | expect(press.structuredContent).toBeUndefined(); | |
| 160 | expect(press.content).toEqual([{ type: "text", text: "null" }]); | |
| 149 | 161 | }), |
| 150 | 162 | ).pipe(Effect.provide(TestLayer)), |
| 151 | 163 | ); |
apps/server/src/mcp/Layers/McpHttpServer.ts+5-2| ... | ... | @@ -143,11 +143,14 @@ export const PreviewToolkitRegistrationLive = Layer.effectDiscard( |
| 143 | 143 | ], |
| 144 | 144 | }); |
| 145 | 145 | } |
| 146 | const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; | |
| 146 | 147 | return new McpSchema.CallToolResult({ |
| 147 | 148 | isError: false, |
| 148 | 149 | structuredContent: |
| 149 | typeof result.encodedResult === "object" ? result.encodedResult : undefined, | |
| 150 | content: [{ type: "text", text: JSON.stringify(result.encodedResult) }], | |
| 150 | result.encodedResult !== null && typeof result.encodedResult === "object" | |
| 151 | ? result.encodedResult | |
| 152 | : undefined, | |
| 153 | content: [{ type: "text", text: encodedResultText }], | |
| 151 | 154 | }); |
| 152 | 155 | }, |
| 153 | 156 | }), |
apps/server/src/preview/Layers/PortScanner.test.ts+66-9| ... | ... | @@ -1,18 +1,20 @@ |
| 1 | 1 | import * as net from "node:net"; |
| 2 | 2 | |
| 3 | 3 | import { it as effectIt } from "@effect/vitest"; |
| 4 | import { ThreadId } from "@t3tools/contracts"; | |
| 4 | 5 | import { Effect, Layer } from "effect"; |
| 5 | 6 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; |
| 6 | 7 | |
| 7 | import { COMMON_DEV_PORTS, PreviewPortScanner } from "../Services/PortScanner.ts"; | |
| 8 | import { COMMON_DEV_PORTS, PortDiscovery } from "../Services/PortScanner.ts"; | |
| 8 | 9 | import { ProcessRunner } from "../../processRunner.ts"; |
| 9 | import { __testing, PreviewPortScannerLive } from "./PortScanner.ts"; | |
| 10 | import { __testing, PortDiscoveryLive } from "./PortScanner.ts"; | |
| 10 | 11 | |
| 11 | const { parseLsofOutput, parsePortFromLsofName, serversEqual } = __testing; | |
| 12 | const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = | |
| 13 | __testing; | |
| 12 | 14 | const TestProcessRunner = Layer.succeed(ProcessRunner, { |
| 13 | 15 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), |
| 14 | 16 | }); |
| 15 | const TestPreviewPortScannerLive = PreviewPortScannerLive.pipe(Layer.provide(TestProcessRunner)); | |
| 17 | const TestPortDiscoveryLive = PortDiscoveryLive.pipe(Layer.provide(TestProcessRunner)); | |
| 16 | 18 | |
| 17 | 19 | describe("parsePortFromLsofName", () => { |
| 18 | 20 | it("parses *:port", () => { |
| ... | ... | @@ -70,6 +72,7 @@ describe("parseLsofOutput", () => { |
| 70 | 72 | url: "http://localhost:3000", |
| 71 | 73 | processName: "next-server", |
| 72 | 74 | pid: 67890, |
| 75 | terminal: null, | |
| 73 | 76 | }, |
| 74 | 77 | { |
| 75 | 78 | host: "localhost", |
| ... | ... | @@ -77,6 +80,7 @@ describe("parseLsofOutput", () => { |
| 77 | 80 | url: "http://localhost:5173", |
| 78 | 81 | processName: "node", |
| 79 | 82 | pid: 12345, |
| 83 | terminal: null, | |
| 80 | 84 | }, |
| 81 | 85 | { |
| 82 | 86 | host: "localhost", |
| ... | ... | @@ -84,6 +88,7 @@ describe("parseLsofOutput", () => { |
| 84 | 88 | url: "http://localhost:9229", |
| 85 | 89 | processName: "next-server", |
| 86 | 90 | pid: 67890, |
| 91 | terminal: null, | |
| 87 | 92 | }, |
| 88 | 93 | ]); |
| 89 | 94 | }); |
| ... | ... | @@ -98,6 +103,26 @@ describe("parseLsofOutput", () => { |
| 98 | 103 | expect(servers).toHaveLength(1); |
| 99 | 104 | expect(servers[0]?.port).toBe(5173); |
| 100 | 105 | }); |
| 106 | ||
| 107 | it("attributes listeners to a registered terminal process", () => { | |
| 108 | const servers = parseLsofOutput( | |
| 109 | ["p12345", "cnode", "n*:5173"].join("\n"), | |
| 110 | new Map([ | |
| 111 | [ | |
| 112 | 12345, | |
| 113 | { | |
| 114 | threadId: ThreadId.make("thread-1"), | |
| 115 | terminalId: "terminal-1", | |
| 116 | }, | |
| 117 | ], | |
| 118 | ]), | |
| 119 | ); | |
| 120 | ||
| 121 | expect(servers[0]?.terminal).toEqual({ | |
| 122 | threadId: "thread-1", | |
| 123 | terminalId: "terminal-1", | |
| 124 | }); | |
| 125 | }); | |
| 101 | 126 | }); |
| 102 | 127 | |
| 103 | 128 | describe("serversEqual", () => { |
| ... | ... | @@ -107,6 +132,7 @@ describe("serversEqual", () => { |
| 107 | 132 | url: "http://localhost:5173", |
| 108 | 133 | processName: "node", |
| 109 | 134 | pid: 1, |
| 135 | terminal: null, | |
| 110 | 136 | }; |
| 111 | 137 | it("returns true for identical lists", () => { |
| 112 | 138 | expect(serversEqual([a], [{ ...a }])).toBe(true); |
| ... | ... | @@ -119,12 +145,43 @@ describe("serversEqual", () => { |
| 119 | 145 | }); |
| 120 | 146 | }); |
| 121 | 147 | |
| 148 | describe("parseWindowsListenerOutput", () => { | |
| 149 | it("parses and attributes PowerShell listener records", () => { | |
| 150 | const servers = parseWindowsListenerOutput( | |
| 151 | "0.0.0.0|5173|12345|node", | |
| 152 | new Map([ | |
| 153 | [ | |
| 154 | 12345, | |
| 155 | { | |
| 156 | threadId: ThreadId.make("thread-1"), | |
| 157 | terminalId: "terminal-1", | |
| 158 | }, | |
| 159 | ], | |
| 160 | ]), | |
| 161 | ); | |
| 162 | ||
| 163 | expect(servers).toEqual([ | |
| 164 | { | |
| 165 | host: "localhost", | |
| 166 | port: 5173, | |
| 167 | url: "http://localhost:5173", | |
| 168 | processName: "node", | |
| 169 | pid: 12345, | |
| 170 | terminal: { | |
| 171 | threadId: "thread-1", | |
| 172 | terminalId: "terminal-1", | |
| 173 | }, | |
| 174 | }, | |
| 175 | ]); | |
| 176 | }); | |
| 177 | }); | |
| 178 | ||
| 122 | 179 | /** |
| 123 | 180 | * Integration tests against a real TCP listener. We force the Windows code |
| 124 | 181 | * path (TCP-probe fallback) by monkey-patching `process.platform` for the |
| 125 | 182 | * duration of the test so we don't depend on `lsof` being installed. |
| 126 | 183 | */ |
| 127 | describe("PreviewPortScanner integration (TCP probe path)", () => { | |
| 184 | describe("PortDiscovery integration (TCP probe fallback)", () => { | |
| 128 | 185 | let originalPlatform: NodeJS.Platform; |
| 129 | 186 | let server: net.Server; |
| 130 | 187 | let port: number; |
| ... | ... | @@ -158,18 +215,18 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { |
| 158 | 215 | |
| 159 | 216 | effectIt.effect("scan() returns a server we just opened on a curated dev port", () => |
| 160 | 217 | Effect.gen(function* () { |
| 161 | const scanner = yield* PreviewPortScanner; | |
| 218 | const scanner = yield* PortDiscovery; | |
| 162 | 219 | const result = yield* scanner.scan(); |
| 163 | 220 | const found = result.find((server) => server.port === port); |
| 164 | 221 | expect(found).toBeDefined(); |
| 165 | 222 | expect(found?.host).toBe("localhost"); |
| 166 | }).pipe(Effect.provide(TestPreviewPortScannerLive)), | |
| 223 | }).pipe(Effect.provide(TestPortDiscoveryLive)), | |
| 167 | 224 | ); |
| 168 | 225 | |
| 169 | 226 | effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { |
| 170 | 227 | const received: number[] = []; |
| 171 | 228 | return Effect.gen(function* () { |
| 172 | const scanner = yield* PreviewPortScanner; | |
| 229 | const scanner = yield* PortDiscovery; | |
| 173 | 230 | const unsubscribe = yield* scanner.subscribe((servers) => |
| 174 | 231 | Effect.sync(() => { |
| 175 | 232 | for (const server of servers) received.push(server.port); |
| ... | ... | @@ -179,6 +236,6 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { |
| 179 | 236 | unsubscribe(); |
| 180 | 237 | release(); |
| 181 | 238 | expect(received).toContain(port); |
| 182 | }).pipe(Effect.provide(TestPreviewPortScannerLive)); | |
| 239 | }).pipe(Effect.provide(TestPortDiscoveryLive)); | |
| 183 | 240 | }); |
| 184 | 241 | }); |
apps/server/src/preview/Layers/PortScanner.ts+121-34| ... | ... | @@ -13,32 +13,42 @@ |
| 13 | 13 | */ |
| 14 | 14 | import * as net from "node:net"; |
| 15 | 15 | |
| 16 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; | |
| 16 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; | |
| 17 | 17 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; |
| 18 | import { Cause, Data, Duration, Effect, Layer, Ref, Schedule } from "effect"; | |
| 18 | import { Cause, Duration, Effect, Layer, Ref, Schedule } from "effect"; | |
| 19 | 19 | |
| 20 | import { ProcessRunner, type ProcessRunnerShape } from "../../processRunner.ts"; | |
| 20 | import { ProcessRunner } from "../../processRunner.ts"; | |
| 21 | 21 | import { |
| 22 | 22 | COMMON_DEV_PORTS, |
| 23 | PreviewPortScanner, | |
| 24 | type PreviewPortScannerShape, | |
| 23 | PortDiscovery, | |
| 24 | type PortDiscoveryShape, | |
| 25 | 25 | } from "../Services/PortScanner.ts"; |
| 26 | 26 | |
| 27 | 27 | const POLL_INTERVAL = Duration.seconds(3); |
| 28 | 28 | const TCP_PROBE_TIMEOUT_MS = 200; |
| 29 | 29 | const LSOF_TIMEOUT_MS = 5_000; |
| 30 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; | |
| 30 | 31 | |
| 31 | 32 | type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>; |
| 32 | 33 | |
| 33 | class LsofProbeError extends Data.TaggedError("LsofProbeError")<{ | |
| 34 | readonly cause: unknown; | |
| 35 | }> {} | |
| 36 | ||
| 37 | 34 | interface ScannerState { |
| 38 | 35 | readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; |
| 39 | 36 | } |
| 40 | 37 | |
| 41 | const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => { | |
| 38 | interface TerminalProcessOwner { | |
| 39 | readonly threadId: ThreadId; | |
| 40 | readonly terminalId: string; | |
| 41 | } | |
| 42 | ||
| 43 | const terminalOwnerKey = (owner: { | |
| 44 | readonly threadId: string; | |
| 45 | readonly terminalId: string; | |
| 46 | }): string => `${owner.threadId}\u0000${owner.terminalId}`; | |
| 47 | ||
| 48 | const parseLsofOutput = ( | |
| 49 | raw: string, | |
| 50 | terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), | |
| 51 | ): ReadonlyArray<DiscoveredLocalServer> => { | |
| 42 | 52 | const seen = new Map<string, DiscoveredLocalServer>(); |
| 43 | 53 | let pid: number | null = null; |
| 44 | 54 | let processName: string | null = null; |
| ... | ... | @@ -69,6 +79,7 @@ const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => { |
| 69 | 79 | url, |
| 70 | 80 | processName, |
| 71 | 81 | pid, |
| 82 | terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), | |
| 72 | 83 | }); |
| 73 | 84 | } |
| 74 | 85 | } |
| ... | ... | @@ -91,22 +102,31 @@ const parsePortFromLsofName = (name: string): number | null => { |
| 91 | 102 | return port; |
| 92 | 103 | }; |
| 93 | 104 | |
| 94 | const probeLsof = ( | |
| 95 | processRunner: ProcessRunnerShape, | |
| 96 | ): Effect.Effect<ReadonlyArray<DiscoveredLocalServer> | null> => | |
| 97 | processRunner | |
| 98 | .run({ | |
| 99 | command: "lsof", | |
| 100 | args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], | |
| 101 | timeout: Duration.millis(LSOF_TIMEOUT_MS), | |
| 102 | maxOutputBytes: 1024 * 1024, | |
| 103 | outputMode: "truncate", | |
| 104 | }) | |
| 105 | .pipe( | |
| 106 | Effect.mapError((cause) => new LsofProbeError({ cause })), | |
| 107 | Effect.map((result) => parseLsofOutput(result.stdout)), | |
| 108 | Effect.orElseSucceed(() => null), | |
| 109 | ); | |
| 105 | const parseWindowsListenerOutput = ( | |
| 106 | raw: string, | |
| 107 | terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), | |
| 108 | ): ReadonlyArray<DiscoveredLocalServer> => { | |
| 109 | const seen = new Map<number, DiscoveredLocalServer>(); | |
| 110 | for (const line of raw.split(/\r?\n/g)) { | |
| 111 | const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); | |
| 112 | const host = hostRaw?.trim() ?? ""; | |
| 113 | if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; | |
| 114 | const port = Number(portRaw); | |
| 115 | const pid = Number(pidRaw); | |
| 116 | if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; | |
| 117 | const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; | |
| 118 | if (seen.has(port)) continue; | |
| 119 | seen.set(port, { | |
| 120 | host: "localhost", | |
| 121 | port, | |
| 122 | url: `http://localhost:${port}`, | |
| 123 | processName: processNameRaw?.trim() || null, | |
| 124 | pid: normalizedPid, | |
| 125 | terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), | |
| 126 | }); | |
| 127 | } | |
| 128 | return [...seen.values()].toSorted((left, right) => left.port - right.port); | |
| 129 | }; | |
| 110 | 130 | |
| 111 | 131 | const probeTcpPort = (port: number): Promise<boolean> => |
| 112 | 132 | new Promise((resolve) => { |
| ... | ... | @@ -138,6 +158,7 @@ const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> |
| 138 | 158 | url: `http://localhost:${r.port}`, |
| 139 | 159 | processName: null, |
| 140 | 160 | pid: null, |
| 161 | terminal: null, | |
| 141 | 162 | })); |
| 142 | 163 | }); |
| 143 | 164 | |
| ... | ... | @@ -155,7 +176,9 @@ const serversEqual = ( |
| 155 | 176 | a.port !== b.port || |
| 156 | 177 | a.url !== b.url || |
| 157 | 178 | a.processName !== b.processName || |
| 158 | a.pid !== b.pid | |
| 179 | a.pid !== b.pid || | |
| 180 | a.terminal?.threadId !== b.terminal?.threadId || | |
| 181 | a.terminal?.terminalId !== b.terminal?.terminalId | |
| 159 | 182 | ) { |
| 160 | 183 | return false; |
| 161 | 184 | } |
| ... | ... | @@ -163,12 +186,19 @@ const serversEqual = ( |
| 163 | 186 | return true; |
| 164 | 187 | }; |
| 165 | 188 | |
| 166 | export const makePreviewPortScanner = Effect.gen(function* () { | |
| 189 | export const makePortDiscovery = Effect.gen(function* () { | |
| 167 | 190 | const processRunner = yield* ProcessRunner; |
| 168 | 191 | const stateRef = yield* Ref.make<ScannerState>({ |
| 169 | 192 | lastSnapshot: [], |
| 170 | 193 | }); |
| 171 | 194 | const listeners = new Set<Listener>(); |
| 195 | const terminalProcesses = new Map< | |
| 196 | string, | |
| 197 | { | |
| 198 | readonly owner: TerminalProcessOwner; | |
| 199 | readonly processIds: ReadonlySet<number>; | |
| 200 | } | |
| 201 | >(); | |
| 172 | 202 | // Plain integer because the release callback returned by `retain()` runs |
| 173 | 203 | // outside any Effect context (the WS subscriber's release path) and must |
| 174 | 204 | // be a synchronous side-effect-only function. |
| ... | ... | @@ -176,11 +206,43 @@ export const makePreviewPortScanner = Effect.gen(function* () { |
| 176 | 206 | |
| 177 | 207 | const scanOnce = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> => |
| 178 | 208 | Effect.gen(function* () { |
| 209 | const terminalByProcessId = new Map<number, TerminalProcessOwner>(); | |
| 210 | for (const registration of terminalProcesses.values()) { | |
| 211 | for (const processId of registration.processIds) { | |
| 212 | terminalByProcessId.set(processId, registration.owner); | |
| 213 | } | |
| 214 | } | |
| 179 | 215 | if (process.platform === "win32") { |
| 216 | const command = | |
| 217 | 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; | |
| 218 | const listeners = yield* processRunner | |
| 219 | .run({ | |
| 220 | command: "powershell.exe", | |
| 221 | args: ["-NoProfile", "-NonInteractive", "-Command", command], | |
| 222 | timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), | |
| 223 | maxOutputBytes: 1024 * 1024, | |
| 224 | outputMode: "truncate", | |
| 225 | }) | |
| 226 | .pipe( | |
| 227 | Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), | |
| 228 | Effect.catchCause(() => Effect.succeed(null)), | |
| 229 | ); | |
| 230 | if (listeners !== null) return listeners; | |
| 180 | 231 | return yield* probeCommonPorts(); |
| 181 | 232 | } |
| 182 | const lsof = yield* probeLsof(processRunner); | |
| 183 | if (lsof !== null) return lsof; | |
| 233 | const lsofResult = yield* processRunner | |
| 234 | .run({ | |
| 235 | command: "lsof", | |
| 236 | args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], | |
| 237 | timeout: Duration.millis(LSOF_TIMEOUT_MS), | |
| 238 | maxOutputBytes: 1024 * 1024, | |
| 239 | outputMode: "truncate", | |
| 240 | }) | |
| 241 | .pipe( | |
| 242 | Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), | |
| 243 | Effect.catchCause(() => Effect.succeed(null)), | |
| 244 | ); | |
| 245 | if (lsofResult !== null) return lsofResult; | |
| 184 | 246 | return yield* probeCommonPorts(); |
| 185 | 247 | }); |
| 186 | 248 | |
| ... | ... | @@ -204,7 +266,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { |
| 204 | 266 | // currently retained, so the cost is one Ref.get every POLL_INTERVAL. |
| 205 | 267 | yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); |
| 206 | 268 | |
| 207 | const retain: PreviewPortScannerShape["retain"] = () => | |
| 269 | const retain: PortDiscoveryShape["retain"] = () => | |
| 208 | 270 | Effect.gen(function* () { |
| 209 | 271 | const wasIdle = retainCount === 0; |
| 210 | 272 | retainCount += 1; |
| ... | ... | @@ -221,7 +283,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { |
| 221 | 283 | }; |
| 222 | 284 | }); |
| 223 | 285 | |
| 224 | const subscribe: PreviewPortScannerShape["subscribe"] = (listener) => | |
| 286 | const subscribe: PortDiscoveryShape["subscribe"] = (listener) => | |
| 225 | 287 | Effect.sync(() => { |
| 226 | 288 | listeners.add(listener); |
| 227 | 289 | return () => { |
| ... | ... | @@ -229,18 +291,43 @@ export const makePreviewPortScanner = Effect.gen(function* () { |
| 229 | 291 | }; |
| 230 | 292 | }); |
| 231 | 293 | |
| 294 | const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = (input) => | |
| 295 | Effect.sync(() => { | |
| 296 | const owner = { | |
| 297 | threadId: ThreadId.make(input.threadId), | |
| 298 | terminalId: input.terminalId, | |
| 299 | }; | |
| 300 | const processIds = new Set( | |
| 301 | input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), | |
| 302 | ); | |
| 303 | const key = terminalOwnerKey(owner); | |
| 304 | if (processIds.size === 0) { | |
| 305 | terminalProcesses.delete(key); | |
| 306 | return; | |
| 307 | } | |
| 308 | terminalProcesses.set(key, { owner, processIds }); | |
| 309 | }); | |
| 310 | ||
| 311 | const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = (input) => | |
| 312 | Effect.sync(() => { | |
| 313 | terminalProcesses.delete(terminalOwnerKey(input)); | |
| 314 | }); | |
| 315 | ||
| 232 | 316 | return { |
| 233 | 317 | scan: scanOnce, |
| 234 | 318 | subscribe, |
| 235 | 319 | retain, |
| 236 | } satisfies PreviewPortScannerShape; | |
| 320 | registerTerminalProcesses, | |
| 321 | unregisterTerminal, | |
| 322 | } satisfies PortDiscoveryShape; | |
| 237 | 323 | }); |
| 238 | 324 | |
| 239 | export const PreviewPortScannerLive = Layer.effect(PreviewPortScanner, makePreviewPortScanner); | |
| 325 | export const PortDiscoveryLive = Layer.effect(PortDiscovery, makePortDiscovery); | |
| 240 | 326 | |
| 241 | 327 | /** Exposed for tests. */ |
| 242 | 328 | export const __testing = { |
| 243 | 329 | parseLsofOutput, |
| 244 | 330 | parsePortFromLsofName, |
| 331 | parseWindowsListenerOutput, | |
| 245 | 332 | serversEqual, |
| 246 | 333 | }; |
apps/server/src/preview/Services/PortScanner.ts+18-8| ... | ... | @@ -1,17 +1,17 @@ |
| 1 | 1 | /** |
| 2 | * PreviewPortScanner - Discovers listening localhost ports for the preview | |
| 3 | * empty-state recommendations. | |
| 2 | * PortDiscovery - Discovers listening localhost ports and attributes them to | |
| 3 | * registered terminal process families. | |
| 4 | 4 | * |
| 5 | 5 | * Reference-counted polling: the scanner only runs when at least one client |
| 6 | 6 | * has called `retain()`. This keeps idle desktops from running `lsof` every |
| 7 | 7 | * 3 seconds for nothing. |
| 8 | 8 | * |
| 9 | * @module PreviewPortScanner | |
| 9 | * @module PortDiscovery | |
| 10 | 10 | */ |
| 11 | 11 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; |
| 12 | 12 | import { Context, type Effect } from "effect"; |
| 13 | 13 | |
| 14 | export interface PreviewPortScannerShape { | |
| 14 | export interface PortDiscoveryShape { | |
| 15 | 15 | /** One-shot snapshot of currently listening localhost ports. */ |
| 16 | 16 | readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; |
| 17 | 17 | /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */ |
| ... | ... | @@ -23,12 +23,22 @@ export interface PreviewPortScannerShape { |
| 23 | 23 | * release fn. When release count hits 0, polling stops. |
| 24 | 24 | */ |
| 25 | 25 | readonly retain: () => Effect.Effect<() => void>; |
| 26 | /** Associate a terminal with its current process family for port attribution. */ | |
| 27 | readonly registerTerminalProcesses: (input: { | |
| 28 | readonly threadId: string; | |
| 29 | readonly terminalId: string; | |
| 30 | readonly processIds: ReadonlyArray<number>; | |
| 31 | }) => Effect.Effect<void>; | |
| 32 | /** Remove process attribution for a terminal that stopped or closed. */ | |
| 33 | readonly unregisterTerminal: (input: { | |
| 34 | readonly threadId: string; | |
| 35 | readonly terminalId: string; | |
| 36 | }) => Effect.Effect<void>; | |
| 26 | 37 | } |
| 27 | 38 | |
| 28 | export class PreviewPortScanner extends Context.Service< | |
| 29 | PreviewPortScanner, | |
| 30 | PreviewPortScannerShape | |
| 31 | >()("t3/preview/Services/PortScanner/PreviewPortScanner") {} | |
| 39 | export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()( | |
| 40 | "t3/preview/Services/PortScanner/PortDiscovery", | |
| 41 | ) {} | |
| 32 | 42 | |
| 33 | 43 | /** Curated list of common dev-server ports for the Windows TCP-probe fallback. */ |
| 34 | 44 | export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ |
apps/server/src/server.test.ts+4-2| ... | ... | @@ -100,7 +100,7 @@ import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRu |
| 100 | 100 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; |
| 101 | 101 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; |
| 102 | 102 | import { PreviewManager } from "./preview/Services/Manager.ts"; |
| 103 | import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; | |
| 103 | import { PortDiscovery } from "./preview/Services/PortScanner.ts"; | |
| 104 | 104 | import { |
| 105 | 105 | BrowserTraceCollector, |
| 106 | 106 | type BrowserTraceCollectorShape, |
| ... | ... | @@ -681,10 +681,12 @@ const buildAppUnderTest = (options?: { |
| 681 | 681 | PubSub.subscribe(pubsub), |
| 682 | 682 | ), |
| 683 | 683 | }), |
| 684 | Layer.mock(PreviewPortScanner)({ | |
| 684 | Layer.mock(PortDiscovery)({ | |
| 685 | 685 | scan: () => Effect.succeed([]), |
| 686 | 686 | subscribe: () => Effect.succeed(() => {}), |
| 687 | 687 | retain: () => Effect.succeed(() => {}), |
| 688 | registerTerminalProcesses: () => Effect.void, | |
| 689 | unregisterTerminal: () => Effect.void, | |
| 688 | 690 | }), |
| 689 | 691 | ), |
| 690 | 692 | ), |
apps/server/src/server.ts+7-5| ... | ... | @@ -36,7 +36,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 36 | 36 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; |
| 37 | 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; |
| 38 | 38 | import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; |
| 39 | import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts"; | |
| 39 | import { PortDiscoveryLive } from "./preview/Layers/PortScanner.ts"; | |
| 40 | 40 | import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; |
| 41 | 41 | import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; |
| 42 | 42 | import * as ProcessRunner from "./processRunner.ts"; |
| ... | ... | @@ -236,13 +236,15 @@ const CheckpointingLayerLive = Layer.empty.pipe( |
| 236 | 236 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), |
| 237 | 237 | ); |
| 238 | 238 | |
| 239 | const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); | |
| 239 | const PortScannerLayerLive = PortDiscoveryLive.pipe(Layer.provide(ProcessRunner.layer)); | |
| 240 | 240 | |
| 241 | const PreviewLayerLive = Layer.mergeAll( | |
| 242 | PreviewManagerLive, | |
| 243 | PreviewPortScannerLive.pipe(Layer.provide(ProcessRunner.layer)), | |
| 241 | const TerminalLayerLive = TerminalManagerLive.pipe( | |
| 242 | Layer.provide(PtyAdapterLive), | |
| 243 | Layer.provide(PortScannerLayerLive), | |
| 244 | 244 | ); |
| 245 | 245 | |
| 246 | const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PortScannerLayerLive); | |
| 247 | ||
| 246 | 248 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( |
| 247 | 249 | Layer.provide(WorkspacePathsLive), |
| 248 | 250 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
apps/server/src/terminal/Layers/Manager.test.ts+10-4| ... | ... | @@ -204,6 +204,7 @@ interface CreateManagerOptions { |
| 204 | 204 | subprocessInspector?: (terminalPid: number) => Effect.Effect<{ |
| 205 | 205 | readonly hasRunningSubprocess: boolean; |
| 206 | 206 | readonly childCommand: string | null; |
| 207 | readonly processIds: ReadonlyArray<number>; | |
| 207 | 208 | }>; |
| 208 | 209 | subprocessPollIntervalMs?: number; |
| 209 | 210 | processKillGraceMs?: number; |
| ... | ... | @@ -745,7 +746,8 @@ it.layer( |
| 745 | 746 | let inspect: { |
| 746 | 747 | readonly hasRunningSubprocess: boolean; |
| 747 | 748 | readonly childCommand: string | null; |
| 748 | } = { hasRunningSubprocess: false, childCommand: null }; | |
| 749 | readonly processIds: ReadonlyArray<number>; | |
| 750 | } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | |
| 749 | 751 | const { manager, getEvents } = yield* createManager(5, { |
| 750 | 752 | subprocessInspector: () => Effect.succeed(inspect), |
| 751 | 753 | subprocessPollIntervalMs: 20, |
| ... | ... | @@ -754,7 +756,7 @@ it.layer( |
| 754 | 756 | yield* manager.open(openInput()); |
| 755 | 757 | expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); |
| 756 | 758 | |
| 757 | inspect = { hasRunningSubprocess: true, childCommand: "vim" }; | |
| 759 | inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; | |
| 758 | 760 | yield* waitFor( |
| 759 | 761 | Effect.map(getEvents, (events) => |
| 760 | 762 | events.some( |
| ... | ... | @@ -767,7 +769,7 @@ it.layer( |
| 767 | 769 | "1200 millis", |
| 768 | 770 | ); |
| 769 | 771 | |
| 770 | inspect = { hasRunningSubprocess: false, childCommand: null }; | |
| 772 | inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | |
| 771 | 773 | yield* waitFor( |
| 772 | 774 | Effect.map(getEvents, (events) => |
| 773 | 775 | events.some( |
| ... | ... | @@ -788,7 +790,11 @@ it.layer( |
| 788 | 790 | const { manager } = yield* createManager(5, { |
| 789 | 791 | subprocessInspector: () => { |
| 790 | 792 | checks += 1; |
| 791 | return Effect.succeed({ hasRunningSubprocess: false, childCommand: null }); | |
| 793 | return Effect.succeed({ | |
| 794 | hasRunningSubprocess: false, | |
| 795 | childCommand: null, | |
| 796 | processIds: [], | |
| 797 | }); | |
| 792 | 798 | }, |
| 793 | 799 | subprocessPollIntervalMs: 20, |
| 794 | 800 | }); |
apps/server/src/terminal/Layers/Manager.ts+97-15| ... | ... | @@ -33,6 +33,7 @@ import { |
| 33 | 33 | terminalSessionsTotal, |
| 34 | 34 | } from "../../observability/Metrics.ts"; |
| 35 | 35 | import * as ProcessRunner from "../../processRunner.ts"; |
| 36 | import { PortDiscovery } from "../../preview/Services/PortScanner.ts"; | |
| 36 | 37 | import { |
| 37 | 38 | TerminalCwdError, |
| 38 | 39 | TerminalHistoryError, |
| ... | ... | @@ -82,6 +83,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess |
| 82 | 83 | interface TerminalSubprocessInspectResult { |
| 83 | 84 | readonly hasRunningSubprocess: boolean; |
| 84 | 85 | readonly childCommand: string | null; |
| 86 | readonly processIds: ReadonlyArray<number>; | |
| 85 | 87 | } |
| 86 | 88 | |
| 87 | 89 | interface TerminalSubprocessInspector { |
| ... | ... | @@ -505,12 +507,8 @@ function windowsInspectSubprocess( |
| 505 | 507 | TerminalSubprocessCheckError, |
| 506 | 508 | ProcessRunner.ProcessRunner |
| 507 | 509 | > { |
| 508 | const command = [ | |
| 509 | `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`, | |
| 510 | "if ($null -eq $c) { exit 1 }", | |
| 511 | "Write-Output $c.Name", | |
| 512 | "exit 0", | |
| 513 | ].join("; "); | |
| 510 | const command = | |
| 511 | 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; | |
| 514 | 512 | return Effect.gen(function* () { |
| 515 | 513 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 516 | 514 | return yield* processRunner.run({ |
| ... | ... | @@ -524,16 +522,41 @@ function windowsInspectSubprocess( |
| 524 | 522 | }).pipe( |
| 525 | 523 | Effect.map((result) => { |
| 526 | 524 | if (result.code !== 0) { |
| 527 | return { hasRunningSubprocess: false, childCommand: null } as const; | |
| 525 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; | |
| 528 | 526 | } |
| 529 | const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? ""; | |
| 530 | if (name.length === 0) { | |
| 531 | return { hasRunningSubprocess: true, childCommand: null } as const; | |
| 527 | const processNameById = new Map<number, string>(); | |
| 528 | const childrenByParent = new Map<number, number[]>(); | |
| 529 | for (const line of result.stdout.split(/\r?\n/g)) { | |
| 530 | const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); | |
| 531 | const pid = Number(pidRaw); | |
| 532 | const parentPid = Number(parentPidRaw); | |
| 533 | if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; | |
| 534 | processNameById.set(pid, nameRaw?.trim() ?? ""); | |
| 535 | const children = childrenByParent.get(parentPid) ?? []; | |
| 536 | children.push(pid); | |
| 537 | childrenByParent.set(parentPid, children); | |
| 532 | 538 | } |
| 533 | const normalized = normalizeChildCommandName(name, platform); | |
| 539 | const directChildren = childrenByParent.get(terminalPid) ?? []; | |
| 540 | const childPid = directChildren[0]; | |
| 541 | if (childPid === undefined) { | |
| 542 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; | |
| 543 | } | |
| 544 | const processIds = new Set<number>([terminalPid]); | |
| 545 | const pending = [terminalPid]; | |
| 546 | while (pending.length > 0) { | |
| 547 | const parentPid = pending.pop(); | |
| 548 | if (parentPid === undefined) continue; | |
| 549 | for (const pid of childrenByParent.get(parentPid) ?? []) { | |
| 550 | if (processIds.has(pid)) continue; | |
| 551 | processIds.add(pid); | |
| 552 | pending.push(pid); | |
| 553 | } | |
| 554 | } | |
| 555 | const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); | |
| 534 | 556 | return { |
| 535 | 557 | hasRunningSubprocess: true, |
| 536 | 558 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, |
| 559 | processIds: [...processIds], | |
| 537 | 560 | } as const; |
| 538 | 561 | }), |
| 539 | 562 | Effect.mapError( |
| ... | ... | @@ -606,14 +629,14 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func |
| 606 | 629 | if (pgrepResult.value.code === 0) { |
| 607 | 630 | childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); |
| 608 | 631 | } else if (pgrepResult.value.code === 1) { |
| 609 | return { hasRunningSubprocess: false, childCommand: null }; | |
| 632 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | |
| 610 | 633 | } |
| 611 | 634 | } |
| 612 | 635 | |
| 613 | 636 | if (childPid === null) { |
| 614 | 637 | const psResult = yield* Effect.exit(runPs); |
| 615 | 638 | if (psResult._tag === "Failure" || psResult.value.code !== 0) { |
| 616 | return { hasRunningSubprocess: false, childCommand: null }; | |
| 639 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | |
| 617 | 640 | } |
| 618 | 641 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { |
| 619 | 642 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); |
| ... | ... | @@ -628,7 +651,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func |
| 628 | 651 | } |
| 629 | 652 | |
| 630 | 653 | if (childPid === null) { |
| 631 | return { hasRunningSubprocess: false, childCommand: null }; | |
| 654 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | |
| 632 | 655 | } |
| 633 | 656 | |
| 634 | 657 | const runComm = processRunner.run({ |
| ... | ... | @@ -663,16 +686,43 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func |
| 663 | 686 | } |
| 664 | 687 | |
| 665 | 688 | const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; |
| 689 | const processIds = new Set<number>([terminalPid]); | |
| 690 | const psResult = yield* Effect.exit(runPs); | |
| 691 | if (psResult._tag === "Success" && psResult.value.code === 0) { | |
| 692 | const childrenByParent = new Map<number, number[]>(); | |
| 693 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | |
| 694 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | |
| 695 | const pid = Number(pidRaw); | |
| 696 | const ppid = Number(ppidRaw); | |
| 697 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | |
| 698 | const children = childrenByParent.get(ppid) ?? []; | |
| 699 | children.push(pid); | |
| 700 | childrenByParent.set(ppid, children); | |
| 701 | } | |
| 702 | const pending = [terminalPid]; | |
| 703 | while (pending.length > 0) { | |
| 704 | const parentPid = pending.pop(); | |
| 705 | if (parentPid === undefined) continue; | |
| 706 | for (const child of childrenByParent.get(parentPid) ?? []) { | |
| 707 | if (processIds.has(child)) continue; | |
| 708 | processIds.add(child); | |
| 709 | pending.push(child); | |
| 710 | } | |
| 711 | } | |
| 712 | } else { | |
| 713 | processIds.add(childPid); | |
| 714 | } | |
| 666 | 715 | return { |
| 667 | 716 | hasRunningSubprocess: true, |
| 668 | 717 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, |
| 718 | processIds: [...processIds], | |
| 669 | 719 | }; |
| 670 | 720 | }); |
| 671 | 721 | |
| 672 | 722 | function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { |
| 673 | 723 | return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { |
| 674 | 724 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { |
| 675 | return { hasRunningSubprocess: false, childCommand: null }; | |
| 725 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | |
| 676 | 726 | } |
| 677 | 727 | if (platform === "win32") { |
| 678 | 728 | return yield* windowsInspectSubprocess(terminalPid, platform); |
| ... | ... | @@ -932,14 +982,26 @@ interface TerminalManagerOptions { |
| 932 | 982 | subprocessPollIntervalMs?: number; |
| 933 | 983 | processKillGraceMs?: number; |
| 934 | 984 | maxRetainedInactiveSessions?: number; |
| 985 | registerTerminalProcesses?: (input: { | |
| 986 | readonly threadId: string; | |
| 987 | readonly terminalId: string; | |
| 988 | readonly processIds: ReadonlyArray<number>; | |
| 989 | }) => Effect.Effect<void>; | |
| 990 | unregisterTerminal?: (input: { | |
| 991 | readonly threadId: string; | |
| 992 | readonly terminalId: string; | |
| 993 | }) => Effect.Effect<void>; | |
| 935 | 994 | } |
| 936 | 995 | |
| 937 | 996 | const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { |
| 938 | 997 | const { terminalLogsDir } = yield* ServerConfig; |
| 939 | 998 | const ptyAdapter = yield* PtyAdapter; |
| 999 | const portDiscovery = yield* PortDiscovery; | |
| 940 | 1000 | return yield* makeTerminalManagerWithOptions({ |
| 941 | 1001 | logsDir: terminalLogsDir, |
| 942 | 1002 | ptyAdapter, |
| 1003 | registerTerminalProcesses: portDiscovery.registerTerminalProcesses, | |
| 1004 | unregisterTerminal: portDiscovery.unregisterTerminal, | |
| 943 | 1005 | }); |
| 944 | 1006 | }); |
| 945 | 1007 | |
| ... | ... | @@ -967,6 +1029,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 967 | 1029 | const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; |
| 968 | 1030 | const maxRetainedInactiveSessions = |
| 969 | 1031 | options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; |
| 1032 | const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); | |
| 1033 | const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); | |
| 970 | 1034 | |
| 971 | 1035 | yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); |
| 972 | 1036 | |
| ... | ... | @@ -1495,6 +1559,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1495 | 1559 | } |
| 1496 | 1560 | |
| 1497 | 1561 | yield* clearKillFiber(action.process); |
| 1562 | yield* unregisterTerminal({ | |
| 1563 | threadId: action.threadId, | |
| 1564 | terminalId: action.terminalId, | |
| 1565 | }); | |
| 1498 | 1566 | yield* publishEvent({ |
| 1499 | 1567 | type: "exited", |
| 1500 | 1568 | threadId: action.threadId, |
| ... | ... | @@ -1531,6 +1599,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1531 | 1599 | }); |
| 1532 | 1600 | |
| 1533 | 1601 | yield* clearKillFiber(process); |
| 1602 | yield* unregisterTerminal({ | |
| 1603 | threadId: session.threadId, | |
| 1604 | terminalId: session.terminalId, | |
| 1605 | }); | |
| 1534 | 1606 | yield* startKillEscalation(process, session.threadId, session.terminalId); |
| 1535 | 1607 | yield* evictInactiveSessionsIfNeeded(); |
| 1536 | 1608 | }); |
| ... | ... | @@ -1700,6 +1772,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1700 | 1772 | advanceEventSequence(session); |
| 1701 | 1773 | return [undefined, state] as const; |
| 1702 | 1774 | }); |
| 1775 | yield* unregisterTerminal({ | |
| 1776 | threadId: session.threadId, | |
| 1777 | terminalId: session.terminalId, | |
| 1778 | }); | |
| 1703 | 1779 | |
| 1704 | 1780 | yield* evictInactiveSessionsIfNeeded(); |
| 1705 | 1781 | |
| ... | ... | @@ -1731,6 +1807,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1731 | 1807 | |
| 1732 | 1808 | if (Option.isSome(session)) { |
| 1733 | 1809 | yield* stopProcess(session.value); |
| 1810 | yield* unregisterTerminal({ threadId, terminalId }); | |
| 1734 | 1811 | yield* persistHistory(threadId, terminalId, session.value.history); |
| 1735 | 1812 | } |
| 1736 | 1813 | |
| ... | ... | @@ -1791,6 +1868,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1791 | 1868 | } |
| 1792 | 1869 | |
| 1793 | 1870 | const next = inspectResult.value; |
| 1871 | yield* registerTerminalProcesses({ | |
| 1872 | threadId: session.threadId, | |
| 1873 | terminalId: session.terminalId, | |
| 1874 | processIds: next.processIds, | |
| 1875 | }); | |
| 1794 | 1876 | const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; |
| 1795 | 1877 | const event = yield* modifyManagerState((state) => { |
| 1796 | 1878 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( |
apps/server/src/ws.ts+5-5| ... | ... | @@ -72,7 +72,7 @@ import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; |
| 72 | 72 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; |
| 73 | 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 74 | 74 | import { PreviewManager } from "./preview/Services/Manager.ts"; |
| 75 | import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; | |
| 75 | import { PortDiscovery } from "./preview/Services/PortScanner.ts"; | |
| 76 | 76 | import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; |
| 77 | 77 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 78 | 78 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| ... | ... | @@ -257,7 +257,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 257 | 257 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 258 | 258 | const terminalManager = yield* TerminalManager; |
| 259 | 259 | const previewManager = yield* PreviewManager; |
| 260 | const previewPortScanner = yield* PreviewPortScanner; | |
| 260 | const portDiscovery = yield* PortDiscovery; | |
| 261 | 261 | const providerRegistry = yield* ProviderRegistry; |
| 262 | 262 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 263 | 263 | const config = yield* ServerConfig; |
| ... | ... | @@ -1426,14 +1426,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1426 | 1426 | Stream.callback<DiscoveredLocalServerList>((queue) => |
| 1427 | 1427 | Effect.acquireRelease( |
| 1428 | 1428 | Effect.gen(function* () { |
| 1429 | const release = yield* previewPortScanner.retain(); | |
| 1430 | const initial = yield* previewPortScanner.scan(); | |
| 1429 | const release = yield* portDiscovery.retain(); | |
| 1430 | const initial = yield* portDiscovery.scan(); | |
| 1431 | 1431 | const initialScannedAt = DateTime.formatIso(yield* DateTime.now); |
| 1432 | 1432 | yield* Queue.offer(queue, { |
| 1433 | 1433 | servers: initial, |
| 1434 | 1434 | scannedAt: initialScannedAt, |
| 1435 | 1435 | }); |
| 1436 | const unsubscribe = yield* previewPortScanner.subscribe((servers) => | |
| 1436 | const unsubscribe = yield* portDiscovery.subscribe((servers) => | |
| 1437 | 1437 | Effect.gen(function* () { |
| 1438 | 1438 | const scannedAt = DateTime.formatIso(yield* DateTime.now); |
| 1439 | 1439 | yield* Queue.offer(queue, { servers, scannedAt }); |
apps/web/src/browser/ElectronBrowserHost.tsx+46-1| ... | ... | @@ -1,14 +1,18 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime"; |
| 4 | import { useMemo } from "react"; | |
| 4 | import { useEffect, useMemo } from "react"; | |
| 5 | 5 | |
| 6 | 6 | import { isElectron } from "~/env"; |
| 7 | import { useTheme } from "~/hooks/useTheme"; | |
| 7 | 8 | import { usePreviewStateStore } from "~/previewStateStore"; |
| 8 | 9 | |
| 10 | import { readPreviewAnnotationTheme } from "./annotationTheme"; | |
| 11 | import { useBrowserPointerStore } from "./browserPointerStore"; | |
| 9 | 12 | import { HostedBrowserWebview } from "./HostedBrowserWebview"; |
| 10 | 13 | |
| 11 | 14 | export function ElectronBrowserHost() { |
| 15 | const { resolvedTheme } = useTheme(); | |
| 12 | 16 | const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); |
| 13 | 17 | const sessions = useMemo( |
| 14 | 18 | () => |
| ... | ... | @@ -25,6 +29,47 @@ export function ElectronBrowserHost() { |
| 25 | 29 | [previewByThreadKey], |
| 26 | 30 | ); |
| 27 | 31 | |
| 32 | useEffect(() => { | |
| 33 | const preview = window.desktopBridge?.preview; | |
| 34 | if (!preview) return; | |
| 35 | ||
| 36 | let lastSerializedTheme = ""; | |
| 37 | const syncTheme = () => { | |
| 38 | const theme = readPreviewAnnotationTheme(); | |
| 39 | const serializedTheme = JSON.stringify(theme); | |
| 40 | if (serializedTheme === lastSerializedTheme) return; | |
| 41 | lastSerializedTheme = serializedTheme; | |
| 42 | void preview.setAnnotationTheme(theme).catch(() => { | |
| 43 | lastSerializedTheme = ""; | |
| 44 | }); | |
| 45 | }; | |
| 46 | const frameId = window.requestAnimationFrame(syncTheme); | |
| 47 | const observer = new MutationObserver(syncTheme); | |
| 48 | observer.observe(document.documentElement, { | |
| 49 | attributes: true, | |
| 50 | attributeFilter: ["class", "style"], | |
| 51 | }); | |
| 52 | const headObserver = new MutationObserver(syncTheme); | |
| 53 | headObserver.observe(document.head, { | |
| 54 | childList: true, | |
| 55 | subtree: true, | |
| 56 | characterData: true, | |
| 57 | }); | |
| 58 | return () => { | |
| 59 | window.cancelAnimationFrame(frameId); | |
| 60 | observer.disconnect(); | |
| 61 | headObserver.disconnect(); | |
| 62 | }; | |
| 63 | }, [resolvedTheme]); | |
| 64 | ||
| 65 | useEffect(() => { | |
| 66 | const preview = window.desktopBridge?.preview; | |
| 67 | if (!preview) return; | |
| 68 | return preview.onPointerEvent((event) => { | |
| 69 | useBrowserPointerStore.getState().apply(event); | |
| 70 | }); | |
| 71 | }, []); | |
| 72 | ||
| 28 | 73 | if (!isElectron) return null; |
| 29 | 74 | return ( |
| 30 | 75 | <div className="contents" data-electron-browser-host> |
apps/web/src/browser/annotationTheme.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | const readVariable = (styles: CSSStyleDeclaration, name: string, fallback: string): string => | |
| 4 | styles.getPropertyValue(name).trim() || fallback; | |
| 5 | ||
| 6 | export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme { | |
| 7 | const root = document.documentElement; | |
| 8 | const styles = getComputedStyle(root); | |
| 9 | return { | |
| 10 | colorScheme: root.classList.contains("dark") ? "dark" : "light", | |
| 11 | radius: readVariable(styles, "--radius", "0.625rem"), | |
| 12 | background: readVariable(styles, "--background", "white"), | |
| 13 | foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"), | |
| 14 | popover: readVariable(styles, "--popover", "white"), | |
| 15 | popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"), | |
| 16 | primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"), | |
| 17 | primaryForeground: readVariable(styles, "--primary-foreground", "white"), | |
| 18 | muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"), | |
| 19 | mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"), | |
| 20 | accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"), | |
| 21 | accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"), | |
| 22 | border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"), | |
| 23 | input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"), | |
| 24 | ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"), | |
| 25 | fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"), | |
| 26 | fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"), | |
| 27 | }; | |
| 28 | } |
apps/web/src/browser/browserPointerStore.test.ts created+68| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { useBrowserPointerStore } from "./browserPointerStore"; | |
| 4 | ||
| 5 | beforeEach(() => { | |
| 6 | useBrowserPointerStore.setState({ byTabId: {} }); | |
| 7 | }); | |
| 8 | ||
| 9 | describe("browserPointerStore", () => { | |
| 10 | it("tracks the latest pointer target independently for each tab", () => { | |
| 11 | const store = useBrowserPointerStore.getState(); | |
| 12 | store.apply({ | |
| 13 | tabId: "tab_a", | |
| 14 | phase: "move", | |
| 15 | x: 20, | |
| 16 | y: 30, | |
| 17 | sequence: 0, | |
| 18 | createdAt: "2026-06-12T00:00:00.000Z", | |
| 19 | }); | |
| 20 | store.apply({ | |
| 21 | tabId: "tab_b", | |
| 22 | phase: "move", | |
| 23 | x: 40, | |
| 24 | y: 50, | |
| 25 | sequence: 1, | |
| 26 | createdAt: "2026-06-12T00:00:01.000Z", | |
| 27 | }); | |
| 28 | store.apply({ | |
| 29 | tabId: "tab_a", | |
| 30 | phase: "click", | |
| 31 | x: 60, | |
| 32 | y: 70, | |
| 33 | sequence: 2, | |
| 34 | createdAt: "2026-06-12T00:00:02.000Z", | |
| 35 | }); | |
| 36 | ||
| 37 | expect(useBrowserPointerStore.getState().byTabId).toMatchObject({ | |
| 38 | tab_a: { phase: "click", x: 60, y: 70, sequence: 2 }, | |
| 39 | tab_b: { phase: "move", x: 40, y: 50, sequence: 1 }, | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | it("clears one tab without affecting the others", () => { | |
| 44 | const store = useBrowserPointerStore.getState(); | |
| 45 | store.apply({ | |
| 46 | tabId: "tab_a", | |
| 47 | phase: "move", | |
| 48 | x: 20, | |
| 49 | y: 30, | |
| 50 | sequence: 0, | |
| 51 | createdAt: "2026-06-12T00:00:00.000Z", | |
| 52 | }); | |
| 53 | store.apply({ | |
| 54 | tabId: "tab_b", | |
| 55 | phase: "move", | |
| 56 | x: 40, | |
| 57 | y: 50, | |
| 58 | sequence: 1, | |
| 59 | createdAt: "2026-06-12T00:00:01.000Z", | |
| 60 | }); | |
| 61 | ||
| 62 | store.clear("tab_a"); | |
| 63 | ||
| 64 | expect(useBrowserPointerStore.getState().byTabId).toEqual({ | |
| 65 | tab_b: expect.objectContaining({ x: 40, y: 50 }), | |
| 66 | }); | |
| 67 | }); | |
| 68 | }); |
apps/web/src/browser/browserPointerStore.ts created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | import type { DesktopPreviewPointerEvent } from "@t3tools/contracts"; | |
| 2 | import { create } from "zustand"; | |
| 3 | ||
| 4 | interface BrowserPointerStoreState { | |
| 5 | readonly byTabId: Record<string, DesktopPreviewPointerEvent>; | |
| 6 | readonly apply: (event: DesktopPreviewPointerEvent) => void; | |
| 7 | readonly clear: (tabId: string) => void; | |
| 8 | } | |
| 9 | ||
| 10 | export const useBrowserPointerStore = create<BrowserPointerStoreState>()((set) => ({ | |
| 11 | byTabId: {}, | |
| 12 | apply: (event) => | |
| 13 | set((state) => ({ | |
| 14 | byTabId: { | |
| 15 | ...state.byTabId, | |
| 16 | [event.tabId]: event, | |
| 17 | }, | |
| 18 | })), | |
| 19 | clear: (tabId) => | |
| 20 | set((state) => { | |
| 21 | if (!(tabId in state.byTabId)) return state; | |
| 22 | const { [tabId]: _removed, ...byTabId } = state.byTabId; | |
| 23 | return { byTabId }; | |
| 24 | }), | |
| 25 | })); |
apps/web/src/components/ChatView.browser.tsx+138-5| ... | ... | @@ -65,6 +65,7 @@ import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; |
| 65 | 65 | import { getServerConfig } from "../rpc/serverState"; |
| 66 | 66 | import { getRouter } from "../router"; |
| 67 | 67 | import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; |
| 68 | import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; | |
| 68 | 69 | import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; |
| 69 | 70 | import { terminalSessionManager } from "../terminalSessionState"; |
| 70 | 71 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; |
| ... | ... | @@ -1801,6 +1802,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1801 | 1802 | useTerminalUiStateStore.setState({ |
| 1802 | 1803 | terminalUiStateByThreadKey: {}, |
| 1803 | 1804 | }); |
| 1805 | useRightPanelStore.persist.clearStorage(); | |
| 1806 | useRightPanelStore.setState({ byThreadKey: {} }); | |
| 1804 | 1807 | }); |
| 1805 | 1808 | |
| 1806 | 1809 | afterEach(() => { |
| ... | ... | @@ -2054,12 +2057,15 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2054 | 2057 | }); |
| 2055 | 2058 | |
| 2056 | 2059 | try { |
| 2057 | const toggle = await waitForElement( | |
| 2058 | () => | |
| 2059 | document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), | |
| 2060 | "Unable to find terminal drawer toggle.", | |
| 2060 | window.dispatchEvent( | |
| 2061 | new KeyboardEvent("keydown", { | |
| 2062 | key: "j", | |
| 2063 | metaKey: isMacPlatform(navigator.platform), | |
| 2064 | ctrlKey: !isMacPlatform(navigator.platform), | |
| 2065 | bubbles: true, | |
| 2066 | cancelable: true, | |
| 2067 | }), | |
| 2061 | 2068 | ); |
| 2062 | toggle.click(); | |
| 2063 | 2069 | |
| 2064 | 2070 | await vi.waitFor( |
| 2065 | 2071 | () => { |
| ... | ... | @@ -2072,9 +2078,136 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2072 | 2078 | terminalId: DEFAULT_TERMINAL_ID, |
| 2073 | 2079 | cwd: "/repo/project", |
| 2074 | 2080 | }); |
| 2081 | expect( | |
| 2082 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) | |
| 2083 | .isOpen, | |
| 2084 | ).toBe(false); | |
| 2085 | }, | |
| 2086 | { timeout: 8_000, interval: 16 }, | |
| 2087 | ); | |
| 2088 | } finally { | |
| 2089 | await mounted.cleanup(); | |
| 2090 | } | |
| 2091 | }); | |
| 2092 | ||
| 2093 | it("keeps multiple terminal panel surfaces separate from the bottom drawer", async () => { | |
| 2094 | const mounted = await mountChatView({ | |
| 2095 | viewport: WIDE_FOOTER_VIEWPORT, | |
| 2096 | snapshot: createSnapshotForTargetUser({ | |
| 2097 | targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId, | |
| 2098 | targetText: "open inline terminal panel", | |
| 2099 | }), | |
| 2100 | }); | |
| 2101 | ||
| 2102 | try { | |
| 2103 | window.dispatchEvent( | |
| 2104 | new KeyboardEvent("keydown", { | |
| 2105 | key: "b", | |
| 2106 | altKey: true, | |
| 2107 | metaKey: isMacPlatform(navigator.platform), | |
| 2108 | ctrlKey: !isMacPlatform(navigator.platform), | |
| 2109 | bubbles: true, | |
| 2110 | cancelable: true, | |
| 2111 | }), | |
| 2112 | ); | |
| 2113 | ||
| 2114 | const addSurface = await waitForElement( | |
| 2115 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Add panel surface"]'), | |
| 2116 | "Unable to find add panel surface button.", | |
| 2117 | ); | |
| 2118 | expect(document.body.textContent).toContain("Open a surface"); | |
| 2119 | expect( | |
| 2120 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), | |
| 2121 | ).toEqual({ | |
| 2122 | isOpen: true, | |
| 2123 | activeSurfaceId: null, | |
| 2124 | surfaces: [], | |
| 2125 | }); | |
| 2126 | expect(wsRequests.some((request) => request._tag === WS_METHODS.terminalOpen)).toBe(false); | |
| 2127 | ||
| 2128 | addSurface.click(); | |
| 2129 | ||
| 2130 | const terminalItem = await waitForElement( | |
| 2131 | () => | |
| 2132 | Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find( | |
| 2133 | (item) => item.textContent?.trim() === "Terminal", | |
| 2134 | ) ?? null, | |
| 2135 | "Unable to find Terminal panel menu item.", | |
| 2136 | ); | |
| 2137 | terminalItem.click(); | |
| 2138 | ||
| 2139 | await vi.waitFor(() => { | |
| 2140 | expect( | |
| 2141 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) | |
| 2142 | .surfaces.filter((surface) => surface.kind === "terminal") | |
| 2143 | .map((surface) => surface.resourceId), | |
| 2144 | ).toEqual(["term-1"]); | |
| 2145 | }); | |
| 2146 | ||
| 2147 | addSurface.click(); | |
| 2148 | const secondTerminalItem = await waitForElement( | |
| 2149 | () => | |
| 2150 | Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find( | |
| 2151 | (item) => item.textContent?.trim() === "Terminal", | |
| 2152 | ) ?? null, | |
| 2153 | "Unable to find Terminal panel menu item.", | |
| 2154 | ); | |
| 2155 | secondTerminalItem.click(); | |
| 2156 | ||
| 2157 | await vi.waitFor( | |
| 2158 | () => { | |
| 2159 | expect( | |
| 2160 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) | |
| 2161 | .surfaces.filter((surface) => surface.kind === "terminal") | |
| 2162 | .map((surface) => surface.resourceId), | |
| 2163 | ).toEqual(["term-1", "term-2"]); | |
| 2164 | expect( | |
| 2165 | document.querySelector('[data-preview-panel-mode="inline"] .thread-terminal-drawer'), | |
| 2166 | ).not.toBeNull(); | |
| 2167 | expect( | |
| 2168 | wsRequests | |
| 2169 | .filter((request) => request._tag === WS_METHODS.terminalOpen) | |
| 2170 | .map((request) => ("terminalId" in request ? request.terminalId : null)), | |
| 2171 | ).toEqual(expect.arrayContaining(["term-1", "term-2"])); | |
| 2172 | const attachRequest = wsRequests.find( | |
| 2173 | (request) => | |
| 2174 | request._tag === WS_METHODS.terminalAttach && | |
| 2175 | "terminalId" in request && | |
| 2176 | request.terminalId === "term-2", | |
| 2177 | ); | |
| 2178 | expect(attachRequest).toMatchObject({ | |
| 2179 | _tag: WS_METHODS.terminalAttach, | |
| 2180 | threadId: THREAD_ID, | |
| 2181 | terminalId: "term-2", | |
| 2182 | cwd: "/repo/project", | |
| 2183 | }); | |
| 2075 | 2184 | }, |
| 2076 | 2185 | { timeout: 8_000, interval: 16 }, |
| 2077 | 2186 | ); |
| 2187 | ||
| 2188 | const drawerToggle = await waitForElement( | |
| 2189 | () => | |
| 2190 | document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), | |
| 2191 | "Unable to find terminal drawer toggle.", | |
| 2192 | ); | |
| 2193 | drawerToggle.click(); | |
| 2194 | ||
| 2195 | await vi.waitFor(() => { | |
| 2196 | expect( | |
| 2197 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey[THREAD_KEY], | |
| 2198 | ).toMatchObject({ | |
| 2199 | terminalOpen: true, | |
| 2200 | terminalIds: ["term-3"], | |
| 2201 | }); | |
| 2202 | expect( | |
| 2203 | wsRequests.some( | |
| 2204 | (request) => | |
| 2205 | request._tag === WS_METHODS.terminalAttach && | |
| 2206 | "terminalId" in request && | |
| 2207 | request.terminalId === "term-3", | |
| 2208 | ), | |
| 2209 | ).toBe(true); | |
| 2210 | }); | |
| 2078 | 2211 | } finally { |
| 2079 | 2212 | await mounted.cleanup(); |
| 2080 | 2213 | } |
apps/web/src/components/ChatView.tsx+580-142| ... | ... | @@ -103,6 +103,7 @@ import { |
| 103 | 103 | usePreviewStateStore, |
| 104 | 104 | } from "../previewStateStore"; |
| 105 | 105 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 106 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; | |
| 106 | 107 | // Lazy: keeps the entire preview component graph (webview host, favicon |
| 107 | 108 | // helper, Chromium error icon) out of the web bundle until first open. |
| 108 | 109 | const PreviewPanel = lazy(() => |
| ... | ... | @@ -129,7 +130,7 @@ import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils |
| 129 | 130 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 130 | 131 | import { useSettings } from "../hooks/useSettings"; |
| 131 | 132 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 132 | import { isTerminalFocused } from "../lib/terminalFocus"; | |
| 133 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; | |
| 133 | 134 | import { |
| 134 | 135 | deriveLogicalProjectKeyFromSettings, |
| 135 | 136 | selectProjectGroupingSettings, |
| ... | ... | @@ -539,6 +540,7 @@ interface PersistentThreadTerminalDrawerProps { |
| 539 | 540 | launchContext: PersistentTerminalLaunchContext | null; |
| 540 | 541 | focusRequestId: number; |
| 541 | 542 | splitShortcutLabel: string | undefined; |
| 543 | splitVerticalShortcutLabel: string | undefined; | |
| 542 | 544 | newShortcutLabel: string | undefined; |
| 543 | 545 | closeShortcutLabel: string | undefined; |
| 544 | 546 | keybindings: ResolvedKeybindingsConfig; |
| ... | ... | @@ -553,6 +555,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 553 | 555 | launchContext, |
| 554 | 556 | focusRequestId, |
| 555 | 557 | splitShortcutLabel, |
| 558 | splitVerticalShortcutLabel, | |
| 556 | 559 | newShortcutLabel, |
| 557 | 560 | closeShortcutLabel, |
| 558 | 561 | keybindings, |
| ... | ... | @@ -573,16 +576,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 573 | 576 | environmentId: threadRef.environmentId, |
| 574 | 577 | threadId, |
| 575 | 578 | }); |
| 579 | const panelSurfaces = useRightPanelStore( | |
| 580 | (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, | |
| 581 | ); | |
| 582 | const panelTerminalIds = useMemo( | |
| 583 | () => | |
| 584 | new Set( | |
| 585 | panelSurfaces.flatMap((surface) => | |
| 586 | surface.kind === "terminal" ? surface.terminalIds : [], | |
| 587 | ), | |
| 588 | ), | |
| 589 | [panelSurfaces], | |
| 590 | ); | |
| 591 | const drawerTerminalSessions = useMemo( | |
| 592 | () => | |
| 593 | knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), | |
| 594 | [knownTerminalSessions, panelTerminalIds], | |
| 595 | ); | |
| 576 | 596 | const terminalLabelsById = useMemo(() => { |
| 577 | 597 | const next = new Map<string, string>(); |
| 578 | for (const session of knownTerminalSessions) { | |
| 598 | for (const session of drawerTerminalSessions) { | |
| 579 | 599 | next.set( |
| 580 | 600 | session.target.terminalId, |
| 581 | 601 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), |
| 582 | 602 | ); |
| 583 | 603 | } |
| 584 | 604 | return next; |
| 585 | }, [knownTerminalSessions]); | |
| 605 | }, [drawerTerminalSessions]); | |
| 586 | 606 | const terminalLaunchLocationsById = useMemo(() => { |
| 587 | 607 | const next = new Map< |
| 588 | 608 | string, |
| ... | ... | @@ -596,7 +616,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 596 | 616 | return next; |
| 597 | 617 | } |
| 598 | 618 | |
| 599 | for (const session of knownTerminalSessions) { | |
| 619 | for (const session of drawerTerminalSessions) { | |
| 600 | 620 | const summary = session.state.summary; |
| 601 | 621 | if (!summary) { |
| 602 | 622 | continue; |
| ... | ... | @@ -614,13 +634,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 614 | 634 | } |
| 615 | 635 | |
| 616 | 636 | return next; |
| 617 | }, [knownTerminalSessions, launchContext, project]); | |
| 637 | }, [drawerTerminalSessions, launchContext, project]); | |
| 618 | 638 | const serverOrderedTerminalIds = useMemo( |
| 619 | () => knownTerminalSessions.map((session) => session.target.terminalId), | |
| 620 | [knownTerminalSessions], | |
| 639 | () => drawerTerminalSessions.map((session) => session.target.terminalId), | |
| 640 | [drawerTerminalSessions], | |
| 621 | 641 | ); |
| 622 | 642 | const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); |
| 623 | 643 | const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); |
| 644 | const storeSplitTerminalVertical = useTerminalUiStateStore( | |
| 645 | (state) => state.splitTerminalVertical, | |
| 646 | ); | |
| 624 | 647 | const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); |
| 625 | 648 | const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); |
| 626 | 649 | const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); |
| ... | ... | @@ -712,6 +735,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 712 | 735 | threadId, |
| 713 | 736 | threadRef, |
| 714 | 737 | ]); |
| 738 | const splitTerminalVertical = useCallback(() => { | |
| 739 | const api = readEnvironmentApi(threadRef.environmentId); | |
| 740 | if (!api || !cwd) { | |
| 741 | return; | |
| 742 | } | |
| 743 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | |
| 744 | storeSplitTerminalVertical(threadRef, terminalId); | |
| 745 | bumpFocusRequestId(); | |
| 746 | void api.terminal | |
| 747 | .open({ | |
| 748 | threadId, | |
| 749 | terminalId, | |
| 750 | cwd, | |
| 751 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), | |
| 752 | env: runtimeEnv, | |
| 753 | }) | |
| 754 | .catch(() => undefined); | |
| 755 | }, [ | |
| 756 | bumpFocusRequestId, | |
| 757 | cwd, | |
| 758 | effectiveWorktreePath, | |
| 759 | runtimeEnv, | |
| 760 | serverOrderedTerminalIds, | |
| 761 | storeSplitTerminalVertical, | |
| 762 | threadId, | |
| 763 | threadRef, | |
| 764 | ]); | |
| 715 | 765 | |
| 716 | 766 | const createNewTerminal = useCallback(() => { |
| 717 | 767 | const api = readEnvironmentApi(threadRef.environmentId); |
| ... | ... | @@ -814,8 +864,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 814 | 864 | activeTerminalGroupId={terminalUiState.activeTerminalGroupId} |
| 815 | 865 | focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} |
| 816 | 866 | onSplitTerminal={splitTerminal} |
| 867 | onSplitTerminalVertical={splitTerminalVertical} | |
| 817 | 868 | onNewTerminal={createNewTerminal} |
| 818 | 869 | splitShortcutLabel={visible ? splitShortcutLabel : undefined} |
| 870 | splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} | |
| 819 | 871 | newShortcutLabel={visible ? newShortcutLabel : undefined} |
| 820 | 872 | closeShortcutLabel={visible ? closeShortcutLabel : undefined} |
| 821 | 873 | keybindings={keybindings} |
| ... | ... | @@ -830,6 +882,177 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 830 | 882 | ); |
| 831 | 883 | }); |
| 832 | 884 | |
| 885 | interface PersistentThreadTerminalPanelProps { | |
| 886 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; | |
| 887 | surface: Extract<RightPanelSurface, { kind: "terminal" }>; | |
| 888 | launchContext: PersistentTerminalLaunchContext | null; | |
| 889 | focusRequestId: number; | |
| 890 | keybindings: ResolvedKeybindingsConfig; | |
| 891 | onAddTerminalContext: (selection: TerminalContextSelection) => void; | |
| 892 | onSplitTerminal: () => void; | |
| 893 | onSplitTerminalVertical: () => void; | |
| 894 | onNewTerminal: () => void; | |
| 895 | onActiveTerminalChange: (terminalId: string) => void; | |
| 896 | onCloseTerminal: (terminalId: string) => void; | |
| 897 | splitShortcutLabel?: string | undefined; | |
| 898 | splitVerticalShortcutLabel?: string | undefined; | |
| 899 | newShortcutLabel?: string | undefined; | |
| 900 | closeShortcutLabel?: string | undefined; | |
| 901 | } | |
| 902 | ||
| 903 | const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ | |
| 904 | threadRef, | |
| 905 | surface, | |
| 906 | launchContext, | |
| 907 | focusRequestId, | |
| 908 | keybindings, | |
| 909 | onAddTerminalContext, | |
| 910 | onSplitTerminal, | |
| 911 | onSplitTerminalVertical, | |
| 912 | onNewTerminal, | |
| 913 | onActiveTerminalChange, | |
| 914 | onCloseTerminal, | |
| 915 | splitShortcutLabel, | |
| 916 | splitVerticalShortcutLabel, | |
| 917 | newShortcutLabel, | |
| 918 | closeShortcutLabel, | |
| 919 | }: PersistentThreadTerminalPanelProps) { | |
| 920 | const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); | |
| 921 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); | |
| 922 | const projectRef = serverThread | |
| 923 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) | |
| 924 | : draftThread | |
| 925 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | |
| 926 | : null; | |
| 927 | const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); | |
| 928 | const knownTerminalSessions = useKnownTerminalSessions({ | |
| 929 | environmentId: threadRef.environmentId, | |
| 930 | threadId: threadRef.threadId, | |
| 931 | }); | |
| 932 | const terminalSummary = | |
| 933 | knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) | |
| 934 | ?.state.summary ?? null; | |
| 935 | const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | |
| 936 | const worktreePath = | |
| 937 | launchContext?.worktreePath ?? terminalSummary?.worktreePath ?? threadWorktreePath; | |
| 938 | const cwd = useMemo( | |
| 939 | () => | |
| 940 | launchContext?.cwd ?? | |
| 941 | terminalSummary?.cwd ?? | |
| 942 | (project | |
| 943 | ? projectScriptCwd({ | |
| 944 | project: { cwd: project.cwd }, | |
| 945 | worktreePath, | |
| 946 | }) | |
| 947 | : null), | |
| 948 | [launchContext?.cwd, project, terminalSummary?.cwd, worktreePath], | |
| 949 | ); | |
| 950 | const runtimeEnv = useMemo( | |
| 951 | () => | |
| 952 | project | |
| 953 | ? projectScriptRuntimeEnv({ | |
| 954 | project: { cwd: project.cwd }, | |
| 955 | worktreePath, | |
| 956 | }) | |
| 957 | : {}, | |
| 958 | [project, worktreePath], | |
| 959 | ); | |
| 960 | const terminalLabelsById = useMemo(() => { | |
| 961 | const labels = new Map<string, string>(); | |
| 962 | for (const terminalId of surface.terminalIds) { | |
| 963 | const summary = | |
| 964 | knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state | |
| 965 | .summary ?? null; | |
| 966 | labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); | |
| 967 | } | |
| 968 | return labels; | |
| 969 | }, [knownTerminalSessions, surface.terminalIds]); | |
| 970 | const terminalLaunchLocationsById = useMemo(() => { | |
| 971 | const locations = new Map< | |
| 972 | string, | |
| 973 | { | |
| 974 | readonly cwd: string; | |
| 975 | readonly worktreePath: string | null; | |
| 976 | readonly runtimeEnv: Record<string, string>; | |
| 977 | } | |
| 978 | >(); | |
| 979 | for (const terminalId of surface.terminalIds) { | |
| 980 | const summary = | |
| 981 | knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state | |
| 982 | .summary ?? null; | |
| 983 | const terminalWorktreePath = | |
| 984 | launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; | |
| 985 | const terminalCwd = | |
| 986 | launchContext?.cwd ?? | |
| 987 | summary?.cwd ?? | |
| 988 | (project | |
| 989 | ? projectScriptCwd({ | |
| 990 | project: { cwd: project.cwd }, | |
| 991 | worktreePath: terminalWorktreePath, | |
| 992 | }) | |
| 993 | : null); | |
| 994 | if (!terminalCwd || !project) continue; | |
| 995 | locations.set(terminalId, { | |
| 996 | cwd: terminalCwd, | |
| 997 | worktreePath: terminalWorktreePath, | |
| 998 | runtimeEnv: projectScriptRuntimeEnv({ | |
| 999 | project: { cwd: project.cwd }, | |
| 1000 | worktreePath: terminalWorktreePath, | |
| 1001 | }), | |
| 1002 | }); | |
| 1003 | } | |
| 1004 | return locations; | |
| 1005 | }, [ | |
| 1006 | knownTerminalSessions, | |
| 1007 | launchContext?.cwd, | |
| 1008 | launchContext?.worktreePath, | |
| 1009 | project, | |
| 1010 | surface.terminalIds, | |
| 1011 | threadWorktreePath, | |
| 1012 | ]); | |
| 1013 | ||
| 1014 | if (!project || !cwd) { | |
| 1015 | return null; | |
| 1016 | } | |
| 1017 | ||
| 1018 | return ( | |
| 1019 | <ThreadTerminalDrawer | |
| 1020 | mode="panel" | |
| 1021 | threadRef={threadRef} | |
| 1022 | threadId={threadRef.threadId} | |
| 1023 | cwd={cwd} | |
| 1024 | worktreePath={worktreePath} | |
| 1025 | runtimeEnv={runtimeEnv} | |
| 1026 | height={0} | |
| 1027 | terminalIds={surface.terminalIds} | |
| 1028 | activeTerminalId={surface.activeTerminalId} | |
| 1029 | terminalGroups={[ | |
| 1030 | { | |
| 1031 | id: surface.id, | |
| 1032 | terminalIds: surface.terminalIds, | |
| 1033 | ...(surface.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), | |
| 1034 | }, | |
| 1035 | ]} | |
| 1036 | activeTerminalGroupId={surface.id} | |
| 1037 | focusRequestId={focusRequestId} | |
| 1038 | onSplitTerminal={onSplitTerminal} | |
| 1039 | onSplitTerminalVertical={onSplitTerminalVertical} | |
| 1040 | onNewTerminal={onNewTerminal} | |
| 1041 | splitShortcutLabel={splitShortcutLabel} | |
| 1042 | splitVerticalShortcutLabel={splitVerticalShortcutLabel} | |
| 1043 | newShortcutLabel={newShortcutLabel} | |
| 1044 | closeShortcutLabel={closeShortcutLabel} | |
| 1045 | onActiveTerminalChange={onActiveTerminalChange} | |
| 1046 | onCloseTerminal={onCloseTerminal} | |
| 1047 | onHeightChange={() => undefined} | |
| 1048 | onAddTerminalContext={onAddTerminalContext} | |
| 1049 | terminalLabelsById={terminalLabelsById} | |
| 1050 | terminalLaunchLocationsById={terminalLaunchLocationsById} | |
| 1051 | keybindings={keybindings} | |
| 1052 | /> | |
| 1053 | ); | |
| 1054 | }); | |
| 1055 | ||
| 833 | 1056 | export default function ChatView(props: ChatViewProps) { |
| 834 | 1057 | const { |
| 835 | 1058 | environmentId, |
| ... | ... | @@ -967,6 +1190,7 @@ export default function ChatView(props: ChatViewProps) { |
| 967 | 1190 | ); |
| 968 | 1191 | const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); |
| 969 | 1192 | const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); |
| 1193 | const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); | |
| 970 | 1194 | const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); |
| 971 | 1195 | const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); |
| 972 | 1196 | const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); |
| ... | ... | @@ -1029,50 +1253,73 @@ export default function ChatView(props: ChatViewProps) { |
| 1029 | 1253 | () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], |
| 1030 | 1254 | [activeServerOrderedTerminalIds, terminalUiState.terminalIds], |
| 1031 | 1255 | ); |
| 1256 | const activeTerminalLabelsById = useMemo(() => { | |
| 1257 | const next = new Map<string, string>(); | |
| 1258 | for (const session of activeThreadKnownSessions) { | |
| 1259 | next.set( | |
| 1260 | session.target.terminalId, | |
| 1261 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | |
| 1262 | ); | |
| 1263 | } | |
| 1264 | return next; | |
| 1265 | }, [activeThreadKnownSessions]); | |
| 1032 | 1266 | const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); |
| 1033 | 1267 | const activeThreadRef = useMemo( |
| 1034 | 1268 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), |
| 1035 | 1269 | [activeThread], |
| 1036 | 1270 | ); |
| 1037 | 1271 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1272 | const activeRightPanelKind = useRightPanelStore((store) => | |
| 1273 | selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), | |
| 1274 | ); | |
| 1275 | const rightPanelState = useRightPanelStore((store) => | |
| 1276 | selectThreadRightPanelState(store.byThreadKey, activeThreadRef), | |
| 1277 | ); | |
| 1278 | const activeRightPanelSurface = useRightPanelStore((store) => | |
| 1279 | selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), | |
| 1280 | ); | |
| 1281 | const activePreviewState = usePreviewStateStore((state) => | |
| 1282 | selectThreadPreviewState(state.byThreadKey, activeThreadRef), | |
| 1283 | ); | |
| 1284 | const panelTerminalIds = useMemo( | |
| 1285 | () => | |
| 1286 | new Set( | |
| 1287 | rightPanelState.surfaces.flatMap((surface) => | |
| 1288 | surface.kind === "terminal" ? surface.terminalIds : [], | |
| 1289 | ), | |
| 1290 | ), | |
| 1291 | [rightPanelState.surfaces], | |
| 1292 | ); | |
| 1293 | const drawerServerOrderedTerminalIds = useMemo( | |
| 1294 | () => activeServerOrderedTerminalIds.filter((terminalId) => !panelTerminalIds.has(terminalId)), | |
| 1295 | [activeServerOrderedTerminalIds, panelTerminalIds], | |
| 1296 | ); | |
| 1038 | 1297 | useEffect(() => { |
| 1039 | 1298 | if (!activeThreadRef) { |
| 1040 | 1299 | return; |
| 1041 | 1300 | } |
| 1042 | if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) { | |
| 1301 | if (terminalIdListsEqual(drawerServerOrderedTerminalIds, terminalUiState.terminalIds)) { | |
| 1043 | 1302 | return; |
| 1044 | 1303 | } |
| 1045 | 1304 | if ( |
| 1046 | 1305 | serverTerminalIdsStrictSubsetOfClient( |
| 1047 | activeServerOrderedTerminalIds, | |
| 1306 | drawerServerOrderedTerminalIds, | |
| 1048 | 1307 | terminalUiState.terminalIds, |
| 1049 | 1308 | ) |
| 1050 | 1309 | ) { |
| 1051 | 1310 | return; |
| 1052 | 1311 | } |
| 1053 | reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds); | |
| 1312 | reconcileTerminalIds(activeThreadRef, drawerServerOrderedTerminalIds); | |
| 1054 | 1313 | }, [ |
| 1055 | 1314 | activeThreadRef, |
| 1056 | activeServerOrderedTerminalIds, | |
| 1315 | drawerServerOrderedTerminalIds, | |
| 1057 | 1316 | reconcileTerminalIds, |
| 1058 | 1317 | terminalUiState.terminalIds, |
| 1059 | 1318 | ]); |
| 1060 | const activeRightPanelKind = useRightPanelStore((store) => | |
| 1061 | selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), | |
| 1062 | ); | |
| 1063 | const rightPanelState = useRightPanelStore((store) => | |
| 1064 | selectThreadRightPanelState(store.byThreadKey, activeThreadRef), | |
| 1065 | ); | |
| 1066 | const activeRightPanelSurface = useRightPanelStore((store) => | |
| 1067 | selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), | |
| 1068 | ); | |
| 1069 | const activePreviewState = usePreviewStateStore((state) => | |
| 1070 | selectThreadPreviewState(state.byThreadKey, activeThreadRef), | |
| 1071 | ); | |
| 1072 | 1319 | const planSidebarOpen = activeRightPanelKind === "plan"; |
| 1073 | 1320 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); |
| 1074 | const terminalPanelOpen = activeRightPanelKind === "terminal"; | |
| 1075 | const rightPanelOpen = activeRightPanelSurface !== null; | |
| 1321 | const rightPanelOpen = rightPanelState.isOpen; | |
| 1322 | const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; | |
| 1076 | 1323 | |
| 1077 | 1324 | useEffect(() => { |
| 1078 | 1325 | if (!activeThreadRef) return; |
| ... | ... | @@ -1085,15 +1332,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1085 | 1332 | if (!activeThreadRef || !diffOpen) return; |
| 1086 | 1333 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| 1087 | 1334 | }, [activeThreadRef, diffOpen]); |
| 1088 | useEffect(() => { | |
| 1089 | if (!activeThreadRef || !terminalUiState.terminalOpen) return; | |
| 1090 | const state = selectThreadRightPanelState( | |
| 1091 | useRightPanelStore.getState().byThreadKey, | |
| 1092 | activeThreadRef, | |
| 1093 | ); | |
| 1094 | if (state.surfaces.some((surface) => surface.kind === "terminal")) return; | |
| 1095 | useRightPanelStore.getState().open(activeThreadRef, "terminal"); | |
| 1096 | }, [activeThreadRef, terminalUiState.terminalOpen]); | |
| 1097 | 1335 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 1098 | 1336 | const threadPlanCatalog = useThreadPlanCatalog( |
| 1099 | 1337 | useMemo(() => { |
| ... | ... | @@ -1115,6 +1353,10 @@ export default function ChatView(props: ChatViewProps) { |
| 1115 | 1353 | const activeProject = useStore( |
| 1116 | 1354 | useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), |
| 1117 | 1355 | ); |
| 1356 | const configuredPreviewUrls = useMemo( | |
| 1357 | () => getConfiguredPreviewUrls(activeProject?.scripts), | |
| 1358 | [activeProject?.scripts], | |
| 1359 | ); | |
| 1118 | 1360 | |
| 1119 | 1361 | useEffect(() => { |
| 1120 | 1362 | if (routeKind !== "server") { |
| ... | ... | @@ -1906,6 +2148,11 @@ export default function ChatView(props: ChatViewProps) { |
| 1906 | 2148 | () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), |
| 1907 | 2149 | [keybindings, terminalShortcutLabelOptions], |
| 1908 | 2150 | ); |
| 2151 | const splitTerminalVerticalShortcutLabel = useMemo( | |
| 2152 | () => | |
| 2153 | shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions), | |
| 2154 | [keybindings, terminalShortcutLabelOptions], | |
| 2155 | ); | |
| 1909 | 2156 | const newTerminalShortcutLabel = useMemo( |
| 1910 | 2157 | () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), |
| 1911 | 2158 | [keybindings, terminalShortcutLabelOptions], |
| ... | ... | @@ -1988,7 +2235,8 @@ export default function ChatView(props: ChatViewProps) { |
| 1988 | 2235 | if (!isServerThread) { |
| 1989 | 2236 | return; |
| 1990 | 2237 | } |
| 1991 | if (!diffOpen) { | |
| 2238 | const diffPanelOpen = activeRightPanelKind === "diff"; | |
| 2239 | if (!diffPanelOpen) { | |
| 1992 | 2240 | onDiffPanelOpen?.(); |
| 1993 | 2241 | if (activeThreadRef) { |
| 1994 | 2242 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| ... | ... | @@ -2005,12 +2253,12 @@ export default function ChatView(props: ChatViewProps) { |
| 2005 | 2253 | replace: true, |
| 2006 | 2254 | search: (previous) => { |
| 2007 | 2255 | const rest = stripDiffSearchParams(previous); |
| 2008 | return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; | |
| 2256 | return diffPanelOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; | |
| 2009 | 2257 | }, |
| 2010 | 2258 | }); |
| 2011 | 2259 | }, [ |
| 2260 | activeRightPanelKind, | |
| 2012 | 2261 | activeThreadRef, |
| 2013 | diffOpen, | |
| 2014 | 2262 | environmentId, |
| 2015 | 2263 | isServerThread, |
| 2016 | 2264 | navigate, |
| ... | ... | @@ -2105,61 +2353,184 @@ export default function ChatView(props: ChatViewProps) { |
| 2105 | 2353 | const setTerminalOpen = useCallback( |
| 2106 | 2354 | (open: boolean) => { |
| 2107 | 2355 | if (!activeThreadRef) return; |
| 2356 | if (open && terminalUiState.terminalIds.length === 0) { | |
| 2357 | storeNewTerminal( | |
| 2358 | activeThreadRef, | |
| 2359 | nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]), | |
| 2360 | ); | |
| 2361 | return; | |
| 2362 | } | |
| 2108 | 2363 | storeSetTerminalOpen(activeThreadRef, open); |
| 2109 | 2364 | }, |
| 2110 | [activeThreadRef, storeSetTerminalOpen], | |
| 2365 | [ | |
| 2366 | activeKnownTerminalIds, | |
| 2367 | activeThreadRef, | |
| 2368 | panelTerminalIds, | |
| 2369 | storeNewTerminal, | |
| 2370 | storeSetTerminalOpen, | |
| 2371 | terminalUiState.terminalIds.length, | |
| 2372 | ], | |
| 2111 | 2373 | ); |
| 2112 | const toggleTerminalVisibility = useCallback(() => { | |
| 2113 | if (!activeThreadRef) return; | |
| 2114 | if (terminalPanelOpen) { | |
| 2115 | useRightPanelStore.getState().close(activeThreadRef); | |
| 2116 | return; | |
| 2117 | } | |
| 2118 | setTerminalOpen(true); | |
| 2119 | useRightPanelStore.getState().open(activeThreadRef, "terminal"); | |
| 2120 | }, [activeThreadRef, setTerminalOpen, terminalPanelOpen]); | |
| 2121 | const splitTerminal = useCallback(() => { | |
| 2122 | if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { | |
| 2123 | return; | |
| 2124 | } | |
| 2125 | const cwdForOpen = gitCwd ?? activeProject.cwd; | |
| 2126 | if (!cwdForOpen) { | |
| 2127 | return; | |
| 2128 | } | |
| 2129 | const api = readEnvironmentApi(environmentId); | |
| 2130 | if (!api) { | |
| 2131 | return; | |
| 2132 | } | |
| 2133 | const terminalId = nextTerminalId(activeKnownTerminalIds); | |
| 2134 | storeSplitTerminal(activeThreadRef, terminalId); | |
| 2374 | const addTerminalSurface = useCallback(() => { | |
| 2375 | if (!activeThreadRef || !activeThreadId || !activeProject) return; | |
| 2376 | const api = readEnvironmentApi(activeThreadRef.environmentId); | |
| 2377 | const cwd = gitCwd ?? activeProject.cwd; | |
| 2378 | if (!api || !cwd) return; | |
| 2379 | const panelIds = selectThreadRightPanelState( | |
| 2380 | useRightPanelStore.getState().byThreadKey, | |
| 2381 | activeThreadRef, | |
| 2382 | ).surfaces.flatMap((surface) => (surface.kind === "terminal" ? surface.terminalIds : [])); | |
| 2383 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelIds]); | |
| 2384 | useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); | |
| 2135 | 2385 | setTerminalFocusRequestId((value) => value + 1); |
| 2136 | void (async () => { | |
| 2137 | try { | |
| 2138 | await api.terminal.open({ | |
| 2386 | void api.terminal | |
| 2387 | .open({ | |
| 2388 | threadId: activeThreadId, | |
| 2389 | terminalId, | |
| 2390 | cwd, | |
| 2391 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | |
| 2392 | env: projectScriptRuntimeEnv({ | |
| 2393 | project: { cwd: activeProject.cwd }, | |
| 2394 | worktreePath: activeThreadWorktreePath, | |
| 2395 | }), | |
| 2396 | }) | |
| 2397 | .catch(() => undefined); | |
| 2398 | }, [ | |
| 2399 | activeKnownTerminalIds, | |
| 2400 | activeProject, | |
| 2401 | activeThreadId, | |
| 2402 | activeThreadRef, | |
| 2403 | activeThreadWorktreePath, | |
| 2404 | gitCwd, | |
| 2405 | ]); | |
| 2406 | const splitPanelTerminal = useCallback( | |
| 2407 | (direction: "horizontal" | "vertical" = "horizontal") => { | |
| 2408 | if ( | |
| 2409 | !activeThreadRef || | |
| 2410 | !activeThreadId || | |
| 2411 | !activeProject || | |
| 2412 | activeRightPanelSurface?.kind !== "terminal" || | |
| 2413 | activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP | |
| 2414 | ) { | |
| 2415 | return; | |
| 2416 | } | |
| 2417 | const api = readEnvironmentApi(activeThreadRef.environmentId); | |
| 2418 | const cwd = gitCwd ?? activeProject.cwd; | |
| 2419 | if (!api || !cwd) return; | |
| 2420 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | |
| 2421 | useRightPanelStore | |
| 2422 | .getState() | |
| 2423 | .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); | |
| 2424 | setTerminalFocusRequestId((value) => value + 1); | |
| 2425 | void api.terminal | |
| 2426 | .open({ | |
| 2139 | 2427 | threadId: activeThreadId, |
| 2140 | 2428 | terminalId, |
| 2141 | cwd: cwdForOpen, | |
| 2429 | cwd, | |
| 2142 | 2430 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), |
| 2143 | 2431 | env: projectScriptRuntimeEnv({ |
| 2144 | 2432 | project: { cwd: activeProject.cwd }, |
| 2145 | 2433 | worktreePath: activeThreadWorktreePath, |
| 2146 | 2434 | }), |
| 2147 | }); | |
| 2148 | } catch { | |
| 2149 | // Opening failed; the tab is already in the store — user can retry or close it. | |
| 2435 | }) | |
| 2436 | .catch(() => undefined); | |
| 2437 | }, | |
| 2438 | [ | |
| 2439 | activeKnownTerminalIds, | |
| 2440 | activeProject, | |
| 2441 | activeRightPanelSurface, | |
| 2442 | activeThreadId, | |
| 2443 | activeThreadRef, | |
| 2444 | activeThreadWorktreePath, | |
| 2445 | gitCwd, | |
| 2446 | panelTerminalIds, | |
| 2447 | ], | |
| 2448 | ); | |
| 2449 | const splitPanelTerminalVertical = useCallback(() => { | |
| 2450 | splitPanelTerminal("vertical"); | |
| 2451 | }, [splitPanelTerminal]); | |
| 2452 | const activatePanelTerminal = useCallback( | |
| 2453 | (terminalId: string) => { | |
| 2454 | if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; | |
| 2455 | useRightPanelStore | |
| 2456 | .getState() | |
| 2457 | .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); | |
| 2458 | setTerminalFocusRequestId((value) => value + 1); | |
| 2459 | }, | |
| 2460 | [activeRightPanelSurface, activeThreadRef], | |
| 2461 | ); | |
| 2462 | const closePanelTerminal = useCallback( | |
| 2463 | (terminalId: string) => { | |
| 2464 | if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; | |
| 2465 | const api = readEnvironmentApi(activeThreadRef.environmentId); | |
| 2466 | void api?.terminal | |
| 2467 | .close({ | |
| 2468 | threadId: activeThreadRef.threadId, | |
| 2469 | terminalId, | |
| 2470 | deleteHistory: true, | |
| 2471 | }) | |
| 2472 | .catch(() => undefined); | |
| 2473 | useRightPanelStore | |
| 2474 | .getState() | |
| 2475 | .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); | |
| 2476 | setTerminalFocusRequestId((value) => value + 1); | |
| 2477 | }, | |
| 2478 | [activeRightPanelSurface, activeThreadRef], | |
| 2479 | ); | |
| 2480 | const toggleTerminalVisibility = useCallback(() => { | |
| 2481 | if (!activeThreadRef) return; | |
| 2482 | setTerminalOpen(!terminalUiState.terminalOpen); | |
| 2483 | }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); | |
| 2484 | const splitTerminal = useCallback( | |
| 2485 | (direction: "horizontal" | "vertical" = "horizontal") => { | |
| 2486 | if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { | |
| 2487 | return; | |
| 2150 | 2488 | } |
| 2151 | })(); | |
| 2152 | }, [ | |
| 2153 | activeProject, | |
| 2154 | activeKnownTerminalIds, | |
| 2155 | activeThreadId, | |
| 2156 | activeThreadRef, | |
| 2157 | activeThreadWorktreePath, | |
| 2158 | environmentId, | |
| 2159 | gitCwd, | |
| 2160 | hasReachedSplitLimit, | |
| 2161 | storeSplitTerminal, | |
| 2162 | ]); | |
| 2489 | const cwdForOpen = gitCwd ?? activeProject.cwd; | |
| 2490 | if (!cwdForOpen) { | |
| 2491 | return; | |
| 2492 | } | |
| 2493 | const api = readEnvironmentApi(environmentId); | |
| 2494 | if (!api) { | |
| 2495 | return; | |
| 2496 | } | |
| 2497 | const terminalId = nextTerminalId(activeKnownTerminalIds); | |
| 2498 | if (direction === "vertical") { | |
| 2499 | storeSplitTerminalVertical(activeThreadRef, terminalId); | |
| 2500 | } else { | |
| 2501 | storeSplitTerminal(activeThreadRef, terminalId); | |
| 2502 | } | |
| 2503 | setTerminalFocusRequestId((value) => value + 1); | |
| 2504 | void (async () => { | |
| 2505 | try { | |
| 2506 | await api.terminal.open({ | |
| 2507 | threadId: activeThreadId, | |
| 2508 | terminalId, | |
| 2509 | cwd: cwdForOpen, | |
| 2510 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | |
| 2511 | env: projectScriptRuntimeEnv({ | |
| 2512 | project: { cwd: activeProject.cwd }, | |
| 2513 | worktreePath: activeThreadWorktreePath, | |
| 2514 | }), | |
| 2515 | }); | |
| 2516 | } catch { | |
| 2517 | // Opening failed; the tab is already in the store — user can retry or close it. | |
| 2518 | } | |
| 2519 | })(); | |
| 2520 | }, | |
| 2521 | [ | |
| 2522 | activeProject, | |
| 2523 | activeKnownTerminalIds, | |
| 2524 | activeThreadId, | |
| 2525 | activeThreadRef, | |
| 2526 | activeThreadWorktreePath, | |
| 2527 | environmentId, | |
| 2528 | gitCwd, | |
| 2529 | hasReachedSplitLimit, | |
| 2530 | storeSplitTerminal, | |
| 2531 | storeSplitTerminalVertical, | |
| 2532 | ], | |
| 2533 | ); | |
| 2163 | 2534 | const createNewTerminal = useCallback(() => { |
| 2164 | 2535 | if (!activeThreadRef || !activeThreadId || !activeProject) { |
| 2165 | 2536 | return; |
| ... | ... | @@ -2552,15 +2923,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2552 | 2923 | const closePreviewPanel = useCallback(() => { |
| 2553 | 2924 | if (!activeThreadRef) return; |
| 2554 | 2925 | useRightPanelStore.getState().close(activeThreadRef); |
| 2555 | if (diffOpen) { | |
| 2556 | void navigate({ | |
| 2557 | to: "/$environmentId/$threadId", | |
| 2558 | params: { environmentId, threadId }, | |
| 2559 | replace: true, | |
| 2560 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), | |
| 2561 | }); | |
| 2562 | } | |
| 2563 | }, [activeThreadRef, diffOpen, environmentId, navigate, threadId]); | |
| 2926 | }, [activeThreadRef]); | |
| 2564 | 2927 | const activateRightPanelSurface = useCallback( |
| 2565 | 2928 | (surface: RightPanelSurface) => { |
| 2566 | 2929 | if (!activeThreadRef) return; |
| ... | ... | @@ -2569,7 +2932,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2569 | 2932 | usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); |
| 2570 | 2933 | } |
| 2571 | 2934 | if (surface.kind === "terminal") { |
| 2572 | setTerminalOpen(true); | |
| 2935 | setTerminalFocusRequestId((value) => value + 1); | |
| 2573 | 2936 | } |
| 2574 | 2937 | if (surface.kind === "diff" && !diffOpen) { |
| 2575 | 2938 | onDiffPanelOpen?.(); |
| ... | ... | @@ -2588,48 +2951,41 @@ export default function ChatView(props: ChatViewProps) { |
| 2588 | 2951 | }); |
| 2589 | 2952 | } |
| 2590 | 2953 | }, |
| 2591 | [ | |
| 2592 | activeThreadRef, | |
| 2593 | diffOpen, | |
| 2594 | environmentId, | |
| 2595 | navigate, | |
| 2596 | onDiffPanelOpen, | |
| 2597 | setTerminalOpen, | |
| 2598 | threadId, | |
| 2599 | ], | |
| 2954 | [activeThreadRef, diffOpen, environmentId, navigate, onDiffPanelOpen, threadId], | |
| 2600 | 2955 | ); |
| 2601 | 2956 | const toggleRightPanel = useCallback(() => { |
| 2602 | 2957 | if (!activeThreadRef) return; |
| 2603 | if (rightPanelOpen) { | |
| 2604 | closePreviewPanel(); | |
| 2605 | return; | |
| 2606 | } | |
| 2607 | const surface = rightPanelState.surfaces.at(-1) ?? null; | |
| 2608 | if (surface) { | |
| 2609 | activateRightPanelSurface(surface); | |
| 2610 | return; | |
| 2611 | } | |
| 2612 | createBrowserSurface(); | |
| 2613 | }, [ | |
| 2614 | activeThreadRef, | |
| 2615 | activateRightPanelSurface, | |
| 2616 | closePreviewPanel, | |
| 2617 | createBrowserSurface, | |
| 2618 | rightPanelOpen, | |
| 2619 | rightPanelState.surfaces, | |
| 2620 | ]); | |
| 2958 | useRightPanelStore.getState().toggleVisibility(activeThreadRef); | |
| 2959 | }, [activeThreadRef]); | |
| 2621 | 2960 | const closeRightPanelSurface = useCallback( |
| 2622 | 2961 | (surface: RightPanelSurface) => { |
| 2623 | 2962 | if (!activeThreadRef) return; |
| 2624 | useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); | |
| 2625 | 2963 | if (surface.kind === "preview" && surface.resourceId) { |
| 2964 | usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId); | |
| 2626 | 2965 | const api = readEnvironmentApi(activeThreadRef.environmentId); |
| 2627 | 2966 | void api?.preview |
| 2628 | 2967 | .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) |
| 2629 | 2968 | .catch(() => undefined); |
| 2630 | 2969 | } |
| 2970 | useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); | |
| 2971 | const nextActiveSurface = selectActiveRightPanelSurface( | |
| 2972 | useRightPanelStore.getState().byThreadKey, | |
| 2973 | activeThreadRef, | |
| 2974 | ); | |
| 2975 | if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { | |
| 2976 | usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId); | |
| 2977 | } | |
| 2631 | 2978 | if (surface.kind === "terminal") { |
| 2632 | setTerminalOpen(false); | |
| 2979 | const api = readEnvironmentApi(activeThreadRef.environmentId); | |
| 2980 | for (const terminalId of surface.terminalIds) { | |
| 2981 | void api?.terminal | |
| 2982 | .close({ | |
| 2983 | threadId: activeThreadRef.threadId, | |
| 2984 | terminalId, | |
| 2985 | deleteHistory: true, | |
| 2986 | }) | |
| 2987 | .catch(() => undefined); | |
| 2988 | } | |
| 2633 | 2989 | } |
| 2634 | 2990 | if (surface.kind === "diff" && diffOpen) { |
| 2635 | 2991 | void navigate({ |
| ... | ... | @@ -2640,7 +2996,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2640 | 2996 | }); |
| 2641 | 2997 | } |
| 2642 | 2998 | }, |
| 2643 | [activeThreadRef, diffOpen, environmentId, navigate, setTerminalOpen, threadId], | |
| 2999 | [activeThreadRef, diffOpen, environmentId, navigate, threadId], | |
| 2644 | 3000 | ); |
| 2645 | 3001 | const persistThreadSettingsForNextTurn = useCallback( |
| 2646 | 3002 | async (input: { |
| ... | ... | @@ -2920,8 +3276,9 @@ export default function ChatView(props: ChatViewProps) { |
| 2920 | 3276 | if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { |
| 2921 | 3277 | return; |
| 2922 | 3278 | } |
| 3279 | const terminalFocusOwner = getTerminalFocusOwner(); | |
| 2923 | 3280 | const shortcutContext = { |
| 2924 | terminalFocus: isTerminalFocused(), | |
| 3281 | terminalFocus: terminalFocusOwner !== null, | |
| 2925 | 3282 | terminalOpen: Boolean(terminalUiState.terminalOpen), |
| 2926 | 3283 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, |
| 2927 | 3284 | }; |
| ... | ... | @@ -2950,9 +3307,20 @@ export default function ChatView(props: ChatViewProps) { |
| 2950 | 3307 | return; |
| 2951 | 3308 | } |
| 2952 | 3309 | |
| 3310 | if (command === "rightPanel.toggle") { | |
| 3311 | event.preventDefault(); | |
| 3312 | event.stopPropagation(); | |
| 3313 | toggleRightPanel(); | |
| 3314 | return; | |
| 3315 | } | |
| 3316 | ||
| 2953 | 3317 | if (command === "terminal.split") { |
| 2954 | 3318 | event.preventDefault(); |
| 2955 | 3319 | event.stopPropagation(); |
| 3320 | if (terminalFocusOwner === "right-panel") { | |
| 3321 | splitPanelTerminal(); | |
| 3322 | return; | |
| 3323 | } | |
| 2956 | 3324 | if (!terminalUiState.terminalOpen) { |
| 2957 | 3325 | setTerminalOpen(true); |
| 2958 | 3326 | } |
| ... | ... | @@ -2960,9 +3328,27 @@ export default function ChatView(props: ChatViewProps) { |
| 2960 | 3328 | return; |
| 2961 | 3329 | } |
| 2962 | 3330 | |
| 3331 | if (command === "terminal.splitVertical") { | |
| 3332 | event.preventDefault(); | |
| 3333 | event.stopPropagation(); | |
| 3334 | if (terminalFocusOwner === "right-panel") { | |
| 3335 | splitPanelTerminal("vertical"); | |
| 3336 | return; | |
| 3337 | } | |
| 3338 | if (!terminalUiState.terminalOpen) { | |
| 3339 | setTerminalOpen(true); | |
| 3340 | } | |
| 3341 | splitTerminal("vertical"); | |
| 3342 | return; | |
| 3343 | } | |
| 3344 | ||
| 2963 | 3345 | if (command === "terminal.close") { |
| 2964 | 3346 | event.preventDefault(); |
| 2965 | 3347 | event.stopPropagation(); |
| 3348 | if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { | |
| 3349 | closePanelTerminal(activeRightPanelSurface.activeTerminalId); | |
| 3350 | return; | |
| 3351 | } | |
| 2966 | 3352 | if (!terminalUiState.terminalOpen) return; |
| 2967 | 3353 | closeTerminal(terminalUiState.activeTerminalId); |
| 2968 | 3354 | return; |
| ... | ... | @@ -2971,6 +3357,10 @@ export default function ChatView(props: ChatViewProps) { |
| 2971 | 3357 | if (command === "terminal.new") { |
| 2972 | 3358 | event.preventDefault(); |
| 2973 | 3359 | event.stopPropagation(); |
| 3360 | if (terminalFocusOwner === "right-panel") { | |
| 3361 | addTerminalSurface(); | |
| 3362 | return; | |
| 3363 | } | |
| 2974 | 3364 | if (!terminalUiState.terminalOpen) { |
| 2975 | 3365 | setTerminalOpen(true); |
| 2976 | 3366 | } |
| ... | ... | @@ -3003,17 +3393,22 @@ export default function ChatView(props: ChatViewProps) { |
| 3003 | 3393 | window.addEventListener("keydown", handler, true); |
| 3004 | 3394 | return () => window.removeEventListener("keydown", handler, true); |
| 3005 | 3395 | }, [ |
| 3396 | activeRightPanelSurface, | |
| 3006 | 3397 | activeProject, |
| 3398 | addTerminalSurface, | |
| 3007 | 3399 | terminalUiState.terminalOpen, |
| 3008 | 3400 | terminalUiState.activeTerminalId, |
| 3009 | 3401 | activeThreadId, |
| 3402 | closePanelTerminal, | |
| 3010 | 3403 | closeTerminal, |
| 3011 | 3404 | createNewTerminal, |
| 3012 | 3405 | setTerminalOpen, |
| 3013 | 3406 | runProjectScript, |
| 3014 | 3407 | splitTerminal, |
| 3408 | splitPanelTerminal, | |
| 3015 | 3409 | keybindings, |
| 3016 | 3410 | onToggleDiff, |
| 3411 | toggleRightPanel, | |
| 3017 | 3412 | toggleTerminalVisibility, |
| 3018 | 3413 | composerRef, |
| 3019 | 3414 | ]); |
| ... | ... | @@ -4026,6 +4421,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4026 | 4421 | ? cn( |
| 4027 | 4422 | "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]", |
| 4028 | 4423 | reserveTitleBarControlInset && |
| 4424 | !inlineRightPanelOwnsTitleBar && | |
| 4029 | 4425 | "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", |
| 4030 | 4426 | ) |
| 4031 | 4427 | : "pb-2 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-2 sm:pb-3 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-3", |
| ... | ... | @@ -4044,7 +4440,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4044 | 4440 | } |
| 4045 | 4441 | keybindings={keybindings} |
| 4046 | 4442 | availableEditors={availableEditors} |
| 4047 | rightPanelAvailable={isPreviewSupportedInRuntime() || isGitRepo} | |
| 4443 | rightPanelAvailable={Boolean(activeProject)} | |
| 4048 | 4444 | rightPanelOpen={rightPanelOpen} |
| 4049 | 4445 | gitCwd={gitCwd} |
| 4050 | 4446 | onRunProjectScript={runProjectScript} |
| ... | ... | @@ -4238,33 +4634,67 @@ export default function ChatView(props: ChatViewProps) { |
| 4238 | 4634 | {/* end chat column */} |
| 4239 | 4635 | </div> |
| 4240 | 4636 | {/* end horizontal flex container */} |
| 4637 | {activeThreadRef ? ( | |
| 4638 | <PersistentThreadTerminalDrawer | |
| 4639 | threadRef={activeThreadRef} | |
| 4640 | threadId={activeThreadRef.threadId} | |
| 4641 | visible={terminalUiState.terminalOpen} | |
| 4642 | launchContext={activeTerminalLaunchContext ?? null} | |
| 4643 | focusRequestId={terminalFocusRequestId} | |
| 4644 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | |
| 4645 | splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} | |
| 4646 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | |
| 4647 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | |
| 4648 | keybindings={keybindings} | |
| 4649 | onAddTerminalContext={addTerminalContextToDraft} | |
| 4650 | /> | |
| 4651 | ) : null} | |
| 4241 | 4652 | </div> |
| 4242 | 4653 | |
| 4243 | {!shouldUsePlanSidebarSheet && | |
| 4244 | rightPanelOpen && | |
| 4245 | activeThreadRef && | |
| 4246 | activeRightPanelSurface ? ( | |
| 4654 | {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( | |
| 4247 | 4655 | <RightPanelTabs |
| 4248 | 4656 | mode="inline" |
| 4249 | 4657 | surfaces={rightPanelState.surfaces} |
| 4250 | activeSurfaceId={activeRightPanelSurface.id} | |
| 4658 | activeSurfaceId={activeRightPanelSurface?.id ?? null} | |
| 4251 | 4659 | previewSessions={activePreviewState.sessions} |
| 4660 | terminalLabelsById={activeTerminalLabelsById} | |
| 4252 | 4661 | onActivate={activateRightPanelSurface} |
| 4253 | 4662 | onCloseSurface={closeRightPanelSurface} |
| 4254 | 4663 | onAddBrowser={createBrowserSurface} |
| 4664 | onAddTerminal={addTerminalSurface} | |
| 4255 | 4665 | onAddDiff={addDiffSurface} |
| 4666 | browserAvailable={isPreviewSupportedInRuntime()} | |
| 4256 | 4667 | diffAvailable={isServerThread && isGitRepo} |
| 4257 | 4668 | > |
| 4258 | {activeRightPanelSurface.kind === "preview" ? ( | |
| 4669 | {activeRightPanelSurface?.kind === "preview" ? ( | |
| 4259 | 4670 | <Suspense fallback={null}> |
| 4260 | 4671 | <PreviewPanel |
| 4261 | 4672 | mode="embedded" |
| 4262 | 4673 | threadRef={activeThreadRef} |
| 4263 | 4674 | tabId={activeRightPanelSurface.resourceId} |
| 4675 | configuredUrls={configuredPreviewUrls} | |
| 4264 | 4676 | visible |
| 4265 | 4677 | /> |
| 4266 | 4678 | </Suspense> |
| 4267 | ) : activeRightPanelSurface.kind === "diff" ? ( | |
| 4679 | ) : activeRightPanelSurface?.kind === "terminal" ? ( | |
| 4680 | <PersistentThreadTerminalPanel | |
| 4681 | threadRef={activeThreadRef} | |
| 4682 | surface={activeRightPanelSurface} | |
| 4683 | launchContext={activeTerminalLaunchContext ?? null} | |
| 4684 | focusRequestId={terminalFocusRequestId} | |
| 4685 | keybindings={keybindings} | |
| 4686 | onAddTerminalContext={addTerminalContextToDraft} | |
| 4687 | onSplitTerminal={splitPanelTerminal} | |
| 4688 | onSplitTerminalVertical={splitPanelTerminalVertical} | |
| 4689 | onNewTerminal={addTerminalSurface} | |
| 4690 | onActiveTerminalChange={activatePanelTerminal} | |
| 4691 | onCloseTerminal={closePanelTerminal} | |
| 4692 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | |
| 4693 | splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} | |
| 4694 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | |
| 4695 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | |
| 4696 | /> | |
| 4697 | ) : activeRightPanelSurface?.kind === "diff" ? ( | |
| 4268 | 4698 | <DiffWorkerPoolProvider> |
| 4269 | 4699 | <Suspense fallback={null}> |
| 4270 | 4700 | <DiffPanel mode="embedded" /> |
| ... | ... | @@ -4274,49 +4704,57 @@ export default function ChatView(props: ChatViewProps) { |
| 4274 | 4704 | </RightPanelTabs> |
| 4275 | 4705 | ) : null} |
| 4276 | 4706 | |
| 4277 | {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef && activeRightPanelSurface ? ( | |
| 4707 | {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( | |
| 4278 | 4708 | <RightPanelSheet open onClose={closePreviewPanel}> |
| 4279 | 4709 | <RightPanelTabs |
| 4280 | 4710 | mode="sheet" |
| 4281 | 4711 | surfaces={rightPanelState.surfaces} |
| 4282 | activeSurfaceId={activeRightPanelSurface.id} | |
| 4712 | activeSurfaceId={activeRightPanelSurface?.id ?? null} | |
| 4283 | 4713 | previewSessions={activePreviewState.sessions} |
| 4714 | terminalLabelsById={activeTerminalLabelsById} | |
| 4284 | 4715 | onActivate={activateRightPanelSurface} |
| 4285 | 4716 | onCloseSurface={closeRightPanelSurface} |
| 4286 | 4717 | onAddBrowser={createBrowserSurface} |
| 4718 | onAddTerminal={addTerminalSurface} | |
| 4287 | 4719 | onAddDiff={addDiffSurface} |
| 4720 | browserAvailable={isPreviewSupportedInRuntime()} | |
| 4288 | 4721 | diffAvailable={isServerThread && isGitRepo} |
| 4289 | 4722 | > |
| 4290 | {activeRightPanelSurface.kind === "preview" ? ( | |
| 4723 | {activeRightPanelSurface?.kind === "preview" ? ( | |
| 4291 | 4724 | <Suspense fallback={null}> |
| 4292 | 4725 | <PreviewPanel |
| 4293 | 4726 | mode="embedded" |
| 4294 | 4727 | threadRef={activeThreadRef} |
| 4295 | 4728 | tabId={activeRightPanelSurface.resourceId} |
| 4729 | configuredUrls={configuredPreviewUrls} | |
| 4296 | 4730 | visible |
| 4297 | 4731 | /> |
| 4298 | 4732 | </Suspense> |
| 4299 | ) : activeRightPanelSurface.kind === "terminal" ? ( | |
| 4300 | <PersistentThreadTerminalDrawer | |
| 4733 | ) : activeRightPanelSurface?.kind === "terminal" ? ( | |
| 4734 | <PersistentThreadTerminalPanel | |
| 4301 | 4735 | threadRef={activeThreadRef} |
| 4302 | threadId={activeThreadRef.threadId} | |
| 4303 | visible | |
| 4304 | mode="panel" | |
| 4736 | surface={activeRightPanelSurface} | |
| 4305 | 4737 | launchContext={activeTerminalLaunchContext ?? null} |
| 4306 | 4738 | focusRequestId={terminalFocusRequestId} |
| 4739 | keybindings={keybindings} | |
| 4740 | onAddTerminalContext={addTerminalContextToDraft} | |
| 4741 | onSplitTerminal={splitPanelTerminal} | |
| 4742 | onSplitTerminalVertical={splitPanelTerminalVertical} | |
| 4743 | onNewTerminal={addTerminalSurface} | |
| 4744 | onActiveTerminalChange={activatePanelTerminal} | |
| 4745 | onCloseTerminal={closePanelTerminal} | |
| 4307 | 4746 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} |
| 4747 | splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} | |
| 4308 | 4748 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} |
| 4309 | 4749 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} |
| 4310 | keybindings={keybindings} | |
| 4311 | onAddTerminalContext={addTerminalContextToDraft} | |
| 4312 | 4750 | /> |
| 4313 | ) : activeRightPanelSurface.kind === "diff" ? ( | |
| 4751 | ) : activeRightPanelSurface?.kind === "diff" ? ( | |
| 4314 | 4752 | <DiffWorkerPoolProvider> |
| 4315 | 4753 | <Suspense fallback={null}> |
| 4316 | 4754 | <DiffPanel mode="embedded" /> |
| 4317 | 4755 | </Suspense> |
| 4318 | 4756 | </DiffWorkerPoolProvider> |
| 4319 | ) : ( | |
| 4757 | ) : activeRightPanelSurface?.kind === "plan" ? ( | |
| 4320 | 4758 | <PlanSidebar |
| 4321 | 4759 | activePlan={activePlan} |
| 4322 | 4760 | activeProposedPlan={sidebarProposedPlan} |
| ... | ... | @@ -4328,7 +4766,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4328 | 4766 | mode="embedded" |
| 4329 | 4767 | onClose={closePlanSidebar} |
| 4330 | 4768 | /> |
| 4331 | )} | |
| 4769 | ) : null} | |
| 4332 | 4770 | </RightPanelTabs> |
| 4333 | 4771 | </RightPanelSheet> |
| 4334 | 4772 | ) : null} |
apps/web/src/components/RightPanelTabs.tsx+105-6| ... | ... | @@ -1,7 +1,9 @@ |
| 1 | 1 | import type { PreviewSessionSnapshot } from "@t3tools/contracts"; |
| 2 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | |
| 2 | 3 | import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; |
| 3 | 4 | import { type ReactNode, useState } from "react"; |
| 4 | 5 | |
| 6 | import { isElectron } from "~/env"; | |
| 5 | 7 | import type { RightPanelSurface } from "~/rightPanelStore"; |
| 6 | 8 | import { cn } from "~/lib/utils"; |
| 7 | 9 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| ... | ... | @@ -12,25 +14,97 @@ import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanel |
| 12 | 14 | interface RightPanelTabsProps { |
| 13 | 15 | mode: PreviewPanelMode; |
| 14 | 16 | surfaces: readonly RightPanelSurface[]; |
| 15 | activeSurfaceId: string; | |
| 17 | activeSurfaceId: string | null; | |
| 16 | 18 | previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; |
| 19 | terminalLabelsById: ReadonlyMap<string, string>; | |
| 17 | 20 | onActivate: (surface: RightPanelSurface) => void; |
| 18 | 21 | onCloseSurface: (surface: RightPanelSurface) => void; |
| 19 | 22 | onAddBrowser: () => void; |
| 23 | onAddTerminal: () => void; | |
| 20 | 24 | onAddDiff: () => void; |
| 25 | browserAvailable: boolean; | |
| 21 | 26 | diffAvailable: boolean; |
| 22 | 27 | children: ReactNode; |
| 23 | 28 | } |
| 24 | 29 | |
| 30 | function RightPanelEmptyState(props: { | |
| 31 | onAddBrowser: () => void; | |
| 32 | onAddTerminal: () => void; | |
| 33 | onAddDiff: () => void; | |
| 34 | browserAvailable: boolean; | |
| 35 | diffAvailable: boolean; | |
| 36 | }) { | |
| 37 | const actions = [ | |
| 38 | { | |
| 39 | label: "Browser", | |
| 40 | description: "Open a local app or URL.", | |
| 41 | icon: Globe2, | |
| 42 | available: props.browserAvailable, | |
| 43 | onClick: props.onAddBrowser, | |
| 44 | }, | |
| 45 | { | |
| 46 | label: "Terminal", | |
| 47 | description: "Start a shell in this workspace.", | |
| 48 | icon: TerminalSquare, | |
| 49 | available: true, | |
| 50 | onClick: props.onAddTerminal, | |
| 51 | }, | |
| 52 | { | |
| 53 | label: "Diff", | |
| 54 | description: "Review changes in this thread.", | |
| 55 | icon: FileDiff, | |
| 56 | available: props.diffAvailable, | |
| 57 | onClick: props.onAddDiff, | |
| 58 | }, | |
| 59 | ] as const; | |
| 60 | ||
| 61 | return ( | |
| 62 | <div className="flex min-h-0 flex-1 items-center justify-center p-6"> | |
| 63 | <div className="w-full max-w-xl"> | |
| 64 | <div className="mb-5 text-center"> | |
| 65 | <h3 className="text-sm font-medium text-foreground">Open a surface</h3> | |
| 66 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 67 | Choose what to show in the right panel. | |
| 68 | </p> | |
| 69 | </div> | |
| 70 | <div className="grid gap-2 sm:grid-cols-3"> | |
| 71 | {actions.map((action) => { | |
| 72 | const Icon = action.icon; | |
| 73 | return ( | |
| 74 | <button | |
| 75 | key={action.label} | |
| 76 | type="button" | |
| 77 | disabled={!action.available} | |
| 78 | onClick={action.onClick} | |
| 79 | className="flex min-h-28 flex-col items-start rounded-lg border border-border/80 bg-card/40 p-4 text-left transition hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-40" | |
| 80 | > | |
| 81 | <Icon className="mb-3 size-5" /> | |
| 82 | <span className="text-sm font-medium">{action.label}</span> | |
| 83 | <span className="mt-1 text-xs leading-relaxed text-muted-foreground"> | |
| 84 | {action.description} | |
| 85 | </span> | |
| 86 | </button> | |
| 87 | ); | |
| 88 | })} | |
| 89 | </div> | |
| 90 | </div> | |
| 91 | </div> | |
| 92 | ); | |
| 93 | } | |
| 94 | ||
| 25 | 95 | function surfaceTitle( |
| 26 | 96 | surface: RightPanelSurface, |
| 27 | 97 | sessions: Readonly<Record<string, PreviewSessionSnapshot>>, |
| 98 | terminalLabelsById: ReadonlyMap<string, string>, | |
| 28 | 99 | ): string { |
| 29 | 100 | switch (surface.kind) { |
| 30 | 101 | case "diff": |
| 31 | 102 | return "Diff"; |
| 32 | 103 | case "terminal": |
| 33 | return "Terminal"; | |
| 104 | return ( | |
| 105 | terminalLabelsById.get(surface.activeTerminalId) ?? | |
| 106 | getTerminalLabel(surface.activeTerminalId) | |
| 107 | ); | |
| 34 | 108 | case "plan": |
| 35 | 109 | return "Plan"; |
| 36 | 110 | case "preview": { |
| ... | ... | @@ -85,13 +159,22 @@ function SurfaceIcon({ |
| 85 | 159 | } |
| 86 | 160 | |
| 87 | 161 | export function RightPanelTabs(props: RightPanelTabsProps) { |
| 162 | const ownsDesktopTitleBar = isElectron && props.mode === "inline"; | |
| 163 | ||
| 88 | 164 | return ( |
| 89 | 165 | <PreviewPanelShell mode={props.mode}> |
| 90 | <div className="flex h-10 shrink-0 items-center px-2"> | |
| 166 | <div | |
| 167 | className={cn( | |
| 168 | "flex shrink-0 items-center px-2", | |
| 169 | ownsDesktopTitleBar | |
| 170 | ? "drag-region h-[52px] wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" | |
| 171 | : "h-10", | |
| 172 | )} | |
| 173 | > | |
| 91 | 174 | <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"> |
| 92 | 175 | {props.surfaces.map((surface) => { |
| 93 | 176 | const active = surface.id === props.activeSurfaceId; |
| 94 | const title = surfaceTitle(surface, props.previewSessions); | |
| 177 | const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); | |
| 95 | 178 | return ( |
| 96 | 179 | <div |
| 97 | 180 | key={surface.id} |
| ... | ... | @@ -131,10 +214,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 131 | 214 | <Plus className="size-4" /> |
| 132 | 215 | </MenuTrigger> |
| 133 | 216 | <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44"> |
| 134 | <MenuItem onClick={props.onAddBrowser}> | |
| 217 | <MenuItem onClick={props.onAddBrowser} disabled={!props.browserAvailable}> | |
| 135 | 218 | <Globe2 /> |
| 136 | 219 | Browser |
| 137 | 220 | </MenuItem> |
| 221 | <MenuItem onClick={props.onAddTerminal}> | |
| 222 | <TerminalSquare /> | |
| 223 | Terminal | |
| 224 | </MenuItem> | |
| 138 | 225 | <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}> |
| 139 | 226 | <FileDiff /> |
| 140 | 227 | Diff |
| ... | ... | @@ -142,7 +229,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 142 | 229 | </MenuPopup> |
| 143 | 230 | </Menu> |
| 144 | 231 | </div> |
| 145 | <div className="flex min-h-0 flex-1 flex-col">{props.children}</div> | |
| 232 | <div className="flex min-h-0 flex-1 flex-col"> | |
| 233 | {props.activeSurfaceId === null ? ( | |
| 234 | <RightPanelEmptyState | |
| 235 | onAddBrowser={props.onAddBrowser} | |
| 236 | onAddTerminal={props.onAddTerminal} | |
| 237 | onAddDiff={props.onAddDiff} | |
| 238 | browserAvailable={props.browserAvailable} | |
| 239 | diffAvailable={props.diffAvailable} | |
| 240 | /> | |
| 241 | ) : ( | |
| 242 | props.children | |
| 243 | )} | |
| 244 | </div> | |
| 146 | 245 | </PreviewPanelShell> |
| 147 | 246 | ); |
| 148 | 247 | } |
apps/web/src/components/Sidebar.tsx+38| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | ChevronRightIcon, |
| 5 | 5 | CloudIcon, |
| 6 | 6 | FolderPlusIcon, |
| 7 | Globe2Icon, | |
| 7 | 8 | SearchIcon, |
| 8 | 9 | SettingsIcon, |
| 9 | 10 | SquarePenIcon, |
| ... | ... | @@ -75,6 +76,7 @@ import { |
| 75 | 76 | } from "../store"; |
| 76 | 77 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 77 | 78 | import { useThreadRunningTerminalIds } from "../terminalSessionState"; |
| 79 | import { useThreadDiscoveredPorts } from "../portDiscoveryState"; | |
| 78 | 80 | import { useUiStateStore } from "../uiStateStore"; |
| 79 | 81 | import { |
| 80 | 82 | resolveShortcutCommand, |
| ... | ... | @@ -198,6 +200,7 @@ import { |
| 198 | 200 | type SidebarProjectSnapshot, |
| 199 | 201 | } from "../sidebarProjectGrouping"; |
| 200 | 202 | import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; |
| 203 | import { openDiscoveredPort } from "./preview/openDiscoveredPort"; | |
| 201 | 204 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| 202 | 205 | updated_at: "Last user message", |
| 203 | 206 | created_at: "Created at", |
| ... | ... | @@ -349,6 +352,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 349 | 352 | environmentId: thread.environmentId, |
| 350 | 353 | threadId: thread.id, |
| 351 | 354 | }); |
| 355 | const discoveredPorts = useThreadDiscoveredPorts({ | |
| 356 | environmentId: thread.environmentId, | |
| 357 | threadId: thread.id, | |
| 358 | }); | |
| 352 | 359 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 353 | 360 | const isRemoteThread = |
| 354 | 361 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; |
| ... | ... | @@ -419,6 +426,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 419 | 426 | }, |
| 420 | 427 | [handleThreadClick, orderedProjectThreadKeys, threadRef], |
| 421 | 428 | ); |
| 429 | const handleOpenDiscoveredPort = useCallback( | |
| 430 | (event: React.MouseEvent<HTMLButtonElement>) => { | |
| 431 | const port = discoveredPorts[0]; | |
| 432 | if (!port) return; | |
| 433 | event.preventDefault(); | |
| 434 | event.stopPropagation(); | |
| 435 | navigateToThread(threadRef); | |
| 436 | void openDiscoveredPort({ threadRef, port }); | |
| 437 | }, | |
| 438 | [discoveredPorts, navigateToThread, threadRef], | |
| 439 | ); | |
| 422 | 440 | const handleRowKeyDown = useCallback( |
| 423 | 441 | (event: React.KeyboardEvent) => { |
| 424 | 442 | if (event.key !== "Enter" && event.key !== " ") return; |
| ... | ... | @@ -609,6 +627,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 609 | 627 | )} |
| 610 | 628 | </div> |
| 611 | 629 | <div className="ml-auto flex shrink-0 items-center gap-1.5"> |
| 630 | {discoveredPorts.length > 0 && ( | |
| 631 | <Tooltip> | |
| 632 | <TooltipTrigger | |
| 633 | render={ | |
| 634 | <button | |
| 635 | type="button" | |
| 636 | aria-label={`Open localhost:${discoveredPorts[0]?.port ?? ""}`} | |
| 637 | className="inline-flex cursor-pointer items-center justify-center text-emerald-600 outline-hidden focus-visible:ring-1 focus-visible:ring-ring dark:text-emerald-400" | |
| 638 | onClick={handleOpenDiscoveredPort} | |
| 639 | /> | |
| 640 | } | |
| 641 | > | |
| 642 | <Globe2Icon className="size-3" /> | |
| 643 | </TooltipTrigger> | |
| 644 | <TooltipPopup side="top"> | |
| 645 | Open localhost:{discoveredPorts[0]?.port} | |
| 646 | {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} | |
| 647 | </TooltipPopup> | |
| 648 | </Tooltip> | |
| 649 | )} | |
| 612 | 650 | {terminalStatus && ( |
| 613 | 651 | <Tooltip> |
| 614 | 652 | <TooltipTrigger |
apps/web/src/components/ThreadTerminalDrawer.tsx+148-8| ... | ... | @@ -1,5 +1,13 @@ |
| 1 | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; | |
| 2 | import { | |
| 3 | Globe2, | |
| 4 | Plus, | |
| 5 | SquareSplitHorizontal, | |
| 6 | SquareSplitVertical, | |
| 7 | TerminalSquare, | |
| 8 | Trash2, | |
| 9 | XIcon, | |
| 10 | } from "lucide-react"; | |
| 3 | 11 | import { |
| 4 | 12 | type ResolvedKeybindingsConfig, |
| 5 | 13 | type ScopedThreadRef, |
| ... | ... | @@ -37,6 +45,7 @@ import { |
| 37 | 45 | isTerminalCloseShortcut, |
| 38 | 46 | isTerminalNewShortcut, |
| 39 | 47 | isTerminalSplitShortcut, |
| 48 | isTerminalSplitVerticalShortcut, | |
| 40 | 49 | isTerminalToggleShortcut, |
| 41 | 50 | terminalDeleteShortcutData, |
| 42 | 51 | terminalNavigationShortcutData, |
| ... | ... | @@ -50,6 +59,8 @@ import { readEnvironmentApi } from "~/environmentApi"; |
| 50 | 59 | import { readLocalApi } from "~/localApi"; |
| 51 | 60 | import { attachTerminalSession } from "../terminalSessionState"; |
| 52 | 61 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; |
| 62 | import { useDiscoveredPorts } from "../portDiscoveryState"; | |
| 63 | import { openDiscoveredPort } from "./preview/openDiscoveredPort"; | |
| 53 | 64 | |
| 54 | 65 | const MIN_DRAWER_HEIGHT = 180; |
| 55 | 66 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | ... | @@ -437,6 +448,7 @@ export function TerminalViewport({ |
| 437 | 448 | if ( |
| 438 | 449 | isTerminalToggleShortcut(event, currentKeybindings, options) || |
| 439 | 450 | isTerminalSplitShortcut(event, currentKeybindings, options) || |
| 451 | isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || | |
| 440 | 452 | isTerminalNewShortcut(event, currentKeybindings, options) || |
| 441 | 453 | isTerminalCloseShortcut(event, currentKeybindings, options) || |
| 442 | 454 | isDiffToggleShortcut(event, currentKeybindings, options) |
| ... | ... | @@ -723,12 +735,44 @@ export function TerminalViewport({ |
| 723 | 735 | .catch(() => undefined); |
| 724 | 736 | }, 30); |
| 725 | 737 | attachTerminal(); |
| 738 | let resizeFrame = 0; | |
| 739 | const resizeObserver = | |
| 740 | typeof ResizeObserver === "undefined" | |
| 741 | ? null | |
| 742 | : new ResizeObserver(() => { | |
| 743 | if (resizeFrame !== 0) return; | |
| 744 | resizeFrame = window.requestAnimationFrame(() => { | |
| 745 | resizeFrame = 0; | |
| 746 | const activeTerminal = terminalRef.current; | |
| 747 | const activeFitAddon = fitAddonRef.current; | |
| 748 | if (!activeTerminal || !activeFitAddon) return; | |
| 749 | const wasAtBottom = | |
| 750 | activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; | |
| 751 | fitTerminalSafely(activeFitAddon); | |
| 752 | if (wasAtBottom) { | |
| 753 | activeTerminal.scrollToBottom(); | |
| 754 | } | |
| 755 | void api.terminal | |
| 756 | .resize({ | |
| 757 | threadId, | |
| 758 | terminalId, | |
| 759 | cols: activeTerminal.cols, | |
| 760 | rows: activeTerminal.rows, | |
| 761 | }) | |
| 762 | .catch(() => undefined); | |
| 763 | }); | |
| 764 | }); | |
| 765 | resizeObserver?.observe(mount); | |
| 726 | 766 | |
| 727 | 767 | return () => { |
| 728 | 768 | disposed = true; |
| 729 | 769 | unsubscribeAttach?.(); |
| 730 | 770 | unsubscribeAttach = null; |
| 731 | 771 | window.clearTimeout(fitTimer); |
| 772 | if (resizeFrame !== 0) { | |
| 773 | window.cancelAnimationFrame(resizeFrame); | |
| 774 | } | |
| 775 | resizeObserver?.disconnect(); | |
| 732 | 776 | inputDisposable.dispose(); |
| 733 | 777 | selectionDisposable.dispose(); |
| 734 | 778 | terminalLinksDisposable.dispose(); |
| ... | ... | @@ -806,8 +850,10 @@ interface ThreadTerminalDrawerProps { |
| 806 | 850 | activeTerminalGroupId: string; |
| 807 | 851 | focusRequestId: number; |
| 808 | 852 | onSplitTerminal: () => void; |
| 853 | onSplitTerminalVertical: () => void; | |
| 809 | 854 | onNewTerminal: () => void; |
| 810 | 855 | splitShortcutLabel?: string | undefined; |
| 856 | splitVerticalShortcutLabel?: string | undefined; | |
| 811 | 857 | newShortcutLabel?: string | undefined; |
| 812 | 858 | closeShortcutLabel?: string | undefined; |
| 813 | 859 | onActiveTerminalChange: (terminalId: string) => void; |
| ... | ... | @@ -865,8 +911,10 @@ export default function ThreadTerminalDrawer({ |
| 865 | 911 | activeTerminalGroupId, |
| 866 | 912 | focusRequestId, |
| 867 | 913 | onSplitTerminal, |
| 914 | onSplitTerminalVertical, | |
| 868 | 915 | onNewTerminal, |
| 869 | 916 | splitShortcutLabel, |
| 917 | splitVerticalShortcutLabel, | |
| 870 | 918 | newShortcutLabel, |
| 871 | 919 | closeShortcutLabel, |
| 872 | 920 | onActiveTerminalChange, |
| ... | ... | @@ -957,6 +1005,9 @@ export default function ThreadTerminalDrawer({ |
| 957 | 1005 | nextGroups.push({ |
| 958 | 1006 | id: assignUniqueGroupId(baseGroupId), |
| 959 | 1007 | terminalIds: nextTerminalIds, |
| 1008 | ...(terminalGroup.splitDirection === "vertical" | |
| 1009 | ? { splitDirection: "vertical" as const } | |
| 1010 | : {}), | |
| 960 | 1011 | }); |
| 961 | 1012 | } |
| 962 | 1013 | |
| ... | ... | @@ -994,6 +1045,8 @@ export default function ThreadTerminalDrawer({ |
| 994 | 1045 | const visibleTerminalIds = |
| 995 | 1046 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? |
| 996 | 1047 | (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); |
| 1048 | const splitDirection = | |
| 1049 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; | |
| 997 | 1050 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; |
| 998 | 1051 | const isSplitView = visibleTerminalIds.length > 1; |
| 999 | 1052 | const showGroupHeaders = |
| ... | ... | @@ -1007,6 +1060,17 @@ export default function ThreadTerminalDrawer({ |
| 1007 | 1060 | } |
| 1008 | 1061 | return next; |
| 1009 | 1062 | }, [normalizedTerminalIds, terminalLabelsById]); |
| 1063 | const discoveredPorts = useDiscoveredPorts(threadRef.environmentId); | |
| 1064 | const discoveredPortByTerminalId = useMemo(() => { | |
| 1065 | const next = new Map<string, (typeof discoveredPorts)[number]>(); | |
| 1066 | for (const port of discoveredPorts) { | |
| 1067 | if (port.terminal?.threadId !== threadId) continue; | |
| 1068 | if (!next.has(port.terminal.terminalId)) { | |
| 1069 | next.set(port.terminal.terminalId, port); | |
| 1070 | } | |
| 1071 | } | |
| 1072 | return next; | |
| 1073 | }, [discoveredPorts, threadId]); | |
| 1010 | 1074 | const resolveTerminalLaunchLocation = useCallback( |
| 1011 | 1075 | (terminalId: string): TerminalLaunchLocation => { |
| 1012 | 1076 | return ( |
| ... | ... | @@ -1020,10 +1084,15 @@ export default function ThreadTerminalDrawer({ |
| 1020 | 1084 | [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], |
| 1021 | 1085 | ); |
| 1022 | 1086 | const splitTerminalActionLabel = hasReachedSplitLimit |
| 1023 | ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` | |
| 1087 | ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` | |
| 1024 | 1088 | : splitShortcutLabel |
| 1025 | ? `Split Terminal (${splitShortcutLabel})` | |
| 1026 | : "Split Terminal"; | |
| 1089 | ? `Split Terminal Horizontally (${splitShortcutLabel})` | |
| 1090 | : "Split Terminal Horizontally"; | |
| 1091 | const splitTerminalVerticalActionLabel = hasReachedSplitLimit | |
| 1092 | ? `Split Terminal Vertically (max ${MAX_TERMINALS_PER_GROUP} per group)` | |
| 1093 | : splitVerticalShortcutLabel | |
| 1094 | ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` | |
| 1095 | : "Split Terminal Vertically"; | |
| 1027 | 1096 | const newTerminalActionLabel = newShortcutLabel |
| 1028 | 1097 | ? `New Terminal (${newShortcutLabel})` |
| 1029 | 1098 | : "New Terminal"; |
| ... | ... | @@ -1034,6 +1103,10 @@ export default function ThreadTerminalDrawer({ |
| 1034 | 1103 | if (hasReachedSplitLimit) return; |
| 1035 | 1104 | onSplitTerminal(); |
| 1036 | 1105 | }, [hasReachedSplitLimit, onSplitTerminal]); |
| 1106 | const onSplitTerminalVerticalAction = useCallback(() => { | |
| 1107 | if (hasReachedSplitLimit) return; | |
| 1108 | onSplitTerminalVertical(); | |
| 1109 | }, [hasReachedSplitLimit, onSplitTerminalVertical]); | |
| 1037 | 1110 | const onNewTerminalAction = useCallback(() => { |
| 1038 | 1111 | onNewTerminal(); |
| 1039 | 1112 | }, [onNewTerminal]); |
| ... | ... | @@ -1143,6 +1216,7 @@ export default function ThreadTerminalDrawer({ |
| 1143 | 1216 | if (normalizedTerminalIds.length === 0) { |
| 1144 | 1217 | return ( |
| 1145 | 1218 | <aside |
| 1219 | data-terminal-owner={isPanel ? "right-panel" : "drawer"} | |
| 1146 | 1220 | className={cn( |
| 1147 | 1221 | "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", |
| 1148 | 1222 | isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", |
| ... | ... | @@ -1176,6 +1250,7 @@ export default function ThreadTerminalDrawer({ |
| 1176 | 1250 | |
| 1177 | 1251 | return ( |
| 1178 | 1252 | <aside |
| 1253 | data-terminal-owner={isPanel ? "right-panel" : "drawer"} | |
| 1179 | 1254 | className={cn( |
| 1180 | 1255 | "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", |
| 1181 | 1256 | isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", |
| ... | ... | @@ -1207,6 +1282,18 @@ export default function ThreadTerminalDrawer({ |
| 1207 | 1282 | <SquareSplitHorizontal className="size-3.25" /> |
| 1208 | 1283 | </TerminalActionButton> |
| 1209 | 1284 | <div className="h-4 w-px bg-border/80" /> |
| 1285 | <TerminalActionButton | |
| 1286 | className={`p-1 text-foreground/90 transition-colors ${ | |
| 1287 | hasReachedSplitLimit | |
| 1288 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 1289 | : "hover:bg-accent" | |
| 1290 | }`} | |
| 1291 | onClick={onSplitTerminalVerticalAction} | |
| 1292 | label={splitTerminalVerticalActionLabel} | |
| 1293 | > | |
| 1294 | <SquareSplitVertical className="size-3.25" /> | |
| 1295 | </TerminalActionButton> | |
| 1296 | <div className="h-4 w-px bg-border/80" /> | |
| 1210 | 1297 | <TerminalActionButton |
| 1211 | 1298 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 1212 | 1299 | onClick={onNewTerminalAction} |
| ... | ... | @@ -1232,16 +1319,26 @@ export default function ThreadTerminalDrawer({ |
| 1232 | 1319 | {isSplitView ? ( |
| 1233 | 1320 | <div |
| 1234 | 1321 | className="grid h-full w-full min-w-0 gap-0 overflow-hidden" |
| 1235 | style={{ | |
| 1236 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | |
| 1237 | }} | |
| 1322 | style={ | |
| 1323 | splitDirection === "vertical" | |
| 1324 | ? { | |
| 1325 | gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | |
| 1326 | } | |
| 1327 | : { | |
| 1328 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | |
| 1329 | } | |
| 1330 | } | |
| 1238 | 1331 | > |
| 1239 | 1332 | {visibleTerminalIds.map((terminalId) => { |
| 1240 | 1333 | const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); |
| 1241 | 1334 | return ( |
| 1242 | 1335 | <div |
| 1243 | 1336 | key={terminalId} |
| 1244 | className={`min-h-0 min-w-0 border-l first:border-l-0 ${ | |
| 1337 | className={`min-h-0 min-w-0 ${ | |
| 1338 | splitDirection === "vertical" | |
| 1339 | ? "border-t first:border-t-0" | |
| 1340 | : "border-l first:border-l-0" | |
| 1341 | } ${ | |
| 1245 | 1342 | terminalId === resolvedActiveTerminalId |
| 1246 | 1343 | ? "border-border" |
| 1247 | 1344 | : "border-border/70" |
| ... | ... | @@ -1320,6 +1417,17 @@ export default function ThreadTerminalDrawer({ |
| 1320 | 1417 | > |
| 1321 | 1418 | <SquareSplitHorizontal className="size-3.25" /> |
| 1322 | 1419 | </TerminalActionButton> |
| 1420 | <TerminalActionButton | |
| 1421 | className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${ | |
| 1422 | hasReachedSplitLimit | |
| 1423 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | |
| 1424 | : "hover:bg-accent/70" | |
| 1425 | }`} | |
| 1426 | onClick={onSplitTerminalVerticalAction} | |
| 1427 | label={splitTerminalVerticalActionLabel} | |
| 1428 | > | |
| 1429 | <SquareSplitVertical className="size-3.25" /> | |
| 1430 | </TerminalActionButton> | |
| 1323 | 1431 | <TerminalActionButton |
| 1324 | 1432 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 1325 | 1433 | onClick={onNewTerminalAction} |
| ... | ... | @@ -1366,6 +1474,7 @@ export default function ThreadTerminalDrawer({ |
| 1366 | 1474 | > |
| 1367 | 1475 | {terminalGroup.terminalIds.map((terminalId) => { |
| 1368 | 1476 | const isActive = terminalId === resolvedActiveTerminalId; |
| 1477 | const discoveredPort = discoveredPortByTerminalId.get(terminalId); | |
| 1369 | 1478 | const closeTerminalLabel = `Close ${ |
| 1370 | 1479 | terminalLabelById.get(terminalId) ?? "terminal" |
| 1371 | 1480 | }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; |
| ... | ... | @@ -1391,6 +1500,37 @@ export default function ThreadTerminalDrawer({ |
| 1391 | 1500 | {terminalLabelById.get(terminalId) ?? "Terminal"} |
| 1392 | 1501 | </span> |
| 1393 | 1502 | </button> |
| 1503 | {discoveredPort && ( | |
| 1504 | <Popover> | |
| 1505 | <PopoverTrigger | |
| 1506 | openOnHover | |
| 1507 | render={ | |
| 1508 | <button | |
| 1509 | type="button" | |
| 1510 | className="inline-flex size-4 items-center justify-center rounded text-emerald-600 transition hover:bg-accent dark:text-emerald-400" | |
| 1511 | onClick={() => | |
| 1512 | void openDiscoveredPort({ | |
| 1513 | threadRef, | |
| 1514 | port: discoveredPort, | |
| 1515 | }) | |
| 1516 | } | |
| 1517 | aria-label={`Open localhost:${discoveredPort.port}`} | |
| 1518 | /> | |
| 1519 | } | |
| 1520 | > | |
| 1521 | <Globe2 className="size-3" /> | |
| 1522 | </PopoverTrigger> | |
| 1523 | <PopoverPopup | |
| 1524 | tooltipStyle | |
| 1525 | side="bottom" | |
| 1526 | sideOffset={6} | |
| 1527 | align="center" | |
| 1528 | className="pointer-events-none select-none" | |
| 1529 | > | |
| 1530 | Open localhost:{discoveredPort.port} | |
| 1531 | </PopoverPopup> | |
| 1532 | </Popover> | |
| 1533 | )} | |
| 1394 | 1534 | {normalizedTerminalIds.length > 1 && ( |
| 1395 | 1535 | <Popover> |
| 1396 | 1536 | <PopoverTrigger |
apps/web/src/components/chat/ChatHeader.tsx+5-1| ... | ... | @@ -16,6 +16,7 @@ import { Toggle } from "../ui/toggle"; |
| 16 | 16 | import { SidebarTrigger } from "../ui/sidebar"; |
| 17 | 17 | import { OpenInPicker } from "./OpenInPicker"; |
| 18 | 18 | import { usePrimaryEnvironmentId } from "../../environments/primary"; |
| 19 | import { shortcutLabelForCommand } from "../../keybindings"; | |
| 19 | 20 | |
| 20 | 21 | interface ChatHeaderProps { |
| 21 | 22 | activeThreadEnvironmentId: EnvironmentId; |
| ... | ... | @@ -76,6 +77,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 76 | 77 | activeThreadEnvironmentId, |
| 77 | 78 | primaryEnvironmentId, |
| 78 | 79 | }); |
| 80 | const rightPanelShortcutLabel = shortcutLabelForCommand(keybindings, "rightPanel.toggle"); | |
| 79 | 81 | |
| 80 | 82 | return ( |
| 81 | 83 | <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> |
| ... | ... | @@ -138,7 +140,9 @@ export const ChatHeader = memo(function ChatHeader({ |
| 138 | 140 | } |
| 139 | 141 | /> |
| 140 | 142 | <TooltipPopup side="bottom"> |
| 141 | {rightPanelAvailable ? "Toggle right panel" : "Right panel is unavailable"} | |
| 143 | {rightPanelAvailable | |
| 144 | ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}` | |
| 145 | : "Right panel is unavailable"} | |
| 142 | 146 | </TooltipPopup> |
| 143 | 147 | </Tooltip> |
| 144 | 148 | </div> |
apps/web/src/components/preview/AgentBrowserCursor.tsx created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | "use client"; | |
| 2 | ||
| 3 | import { MousePointer2 } from "lucide-react"; | |
| 4 | import { useEffect, useState } from "react"; | |
| 5 | ||
| 6 | import { useBrowserPointerStore } from "~/browser/browserPointerStore"; | |
| 7 | ||
| 8 | import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic"; | |
| 9 | ||
| 10 | const CURSOR_ACTIVE_MS = 700; | |
| 11 | ||
| 12 | export function AgentBrowserCursor(props: { | |
| 13 | readonly tabId: string; | |
| 14 | readonly zoomFactor: number; | |
| 15 | readonly controller: BrowserController; | |
| 16 | }) { | |
| 17 | const { tabId, zoomFactor, controller } = props; | |
| 18 | const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null); | |
| 19 | const [active, setActive] = useState(false); | |
| 20 | ||
| 21 | useEffect(() => { | |
| 22 | if (!event) return; | |
| 23 | setActive(true); | |
| 24 | const timeout = window.setTimeout(() => setActive(false), CURSOR_ACTIVE_MS); | |
| 25 | return () => window.clearTimeout(timeout); | |
| 26 | }, [event]); | |
| 27 | ||
| 28 | if (!event) return null; | |
| 29 | ||
| 30 | return ( | |
| 31 | <div | |
| 32 | className="pointer-events-none absolute left-0 top-0 z-40 transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none" | |
| 33 | style={{ | |
| 34 | opacity: agentBrowserCursorOpacity(active, controller), | |
| 35 | transform: `translate3d(${event.x * zoomFactor}px, ${event.y * zoomFactor}px, 0)`, | |
| 36 | }} | |
| 37 | aria-hidden="true" | |
| 38 | data-agent-browser-cursor | |
| 39 | > | |
| 40 | {event.phase === "click" ? ( | |
| 41 | <span | |
| 42 | key={event.sequence} | |
| 43 | className="absolute left-0.5 top-0.5 size-4 animate-ping rounded-full bg-primary/25 motion-reduce:animate-none" | |
| 44 | /> | |
| 45 | ) : null} | |
| 46 | <MousePointer2 | |
| 47 | className="relative size-5 -translate-x-0.5 -translate-y-0.5 fill-background text-primary drop-shadow-sm" | |
| 48 | strokeWidth={2} | |
| 49 | /> | |
| 50 | </div> | |
| 51 | ); | |
| 52 | } |
apps/web/src/components/preview/PreviewChromeRow.browser.tsx created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import "../../index.css"; | |
| 2 | ||
| 3 | import { page } from "vite-plus/test/browser"; | |
| 4 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 5 | import { render } from "vitest-browser-react"; | |
| 6 | ||
| 7 | import { PreviewChromeRow } from "./PreviewChromeRow"; | |
| 8 | ||
| 9 | const defaultProps = { | |
| 10 | url: "https://example.com/", | |
| 11 | loading: false, | |
| 12 | loadProgress: 0, | |
| 13 | canGoBack: false, | |
| 14 | canGoForward: false, | |
| 15 | refreshDisabled: false, | |
| 16 | onBack: vi.fn(), | |
| 17 | onForward: vi.fn(), | |
| 18 | onRefresh: vi.fn(), | |
| 19 | onSubmit: vi.fn(), | |
| 20 | }; | |
| 21 | ||
| 22 | describe("PreviewChromeRow", () => { | |
| 23 | it("only focuses the URL input after an explicit focus request", async () => { | |
| 24 | const previouslyFocused = document.createElement("button"); | |
| 25 | document.body.append(previouslyFocused); | |
| 26 | previouslyFocused.focus(); | |
| 27 | ||
| 28 | const screen = await render(<PreviewChromeRow {...defaultProps} focusUrlNonce={undefined} />); | |
| 29 | const input = page.getByRole("textbox").element() as HTMLInputElement; | |
| 30 | ||
| 31 | expect(document.activeElement).toBe(previouslyFocused); | |
| 32 | ||
| 33 | await screen.rerender(<PreviewChromeRow {...defaultProps} focusUrlNonce={1} />); | |
| 34 | ||
| 35 | expect(document.activeElement).toBe(input); | |
| 36 | expect(input.selectionStart).toBe(0); | |
| 37 | expect(input.selectionEnd).toBe(input.value.length); | |
| 38 | ||
| 39 | previouslyFocused.remove(); | |
| 40 | }); | |
| 41 | }); |
apps/web/src/components/preview/PreviewView.tsx+215-17| ... | ... | @@ -22,16 +22,19 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 22 | 22 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 23 | 23 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; |
| 24 | 24 | import { PreviewUnreachable } from "./PreviewUnreachable"; |
| 25 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; | |
| 26 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; | |
| 25 | 27 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 26 | 28 | import { useLoadingProgress } from "./useLoadingProgress"; |
| 27 | 29 | import { usePreviewSession } from "./usePreviewSession"; |
| 28 | 30 | import { ZoomIndicator } from "./ZoomIndicator"; |
| 31 | import { AgentBrowserCursor } from "./AgentBrowserCursor"; | |
| 29 | 32 | import { |
| 30 | 33 | startBrowserRecording, |
| 31 | 34 | stopBrowserRecording, |
| 32 | 35 | useBrowserRecordingStore, |
| 33 | 36 | } from "~/browser/browserRecording"; |
| 34 | import { toastManager } from "~/components/ui/toast"; | |
| 37 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | |
| 35 | 38 | |
| 36 | 39 | interface Props { |
| 37 | 40 | threadRef: ScopedThreadRef; |
| ... | ... | @@ -47,7 +50,7 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 47 | 50 | * state when no session exists for the thread. |
| 48 | 51 | */ |
| 49 | 52 | export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { |
| 50 | const [focusUrlNonce, setFocusUrlNonce] = useState(0); | |
| 53 | const [focusUrlNonce, setFocusUrlNonce] = useState<number | undefined>(undefined); | |
| 51 | 54 | const [pickActive, setPickActive] = useState(false); |
| 52 | 55 | const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId); |
| 53 | 56 | const pickActiveRef = useRef(false); |
| ... | ... | @@ -81,6 +84,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 81 | 84 | const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false; |
| 82 | 85 | const refreshDisabled = navStatus._tag === "Idle"; |
| 83 | 86 | const isUnreachable = navStatus._tag === "LoadFailed"; |
| 87 | const showEmptyState = shouldShowPreviewEmptyState(snapshot); | |
| 84 | 88 | const controller = desktopOverlay?.controller ?? "none"; |
| 85 | 89 | const loadProgress = useLoadingProgress(loading); |
| 86 | 90 | |
| ... | ... | @@ -142,16 +146,89 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 142 | 146 | const handleCapture = useCallback( |
| 143 | 147 | (record: boolean) => { |
| 144 | 148 | if (!previewBridge || !tabId) return; |
| 149 | const bridge = previewBridge; | |
| 145 | 150 | const recordingThisTab = activeRecordingTabId === tabId; |
| 146 | 151 | if (recordingThisTab) { |
| 147 | 152 | void stopBrowserRecording(tabId).then( |
| 148 | 153 | (artifact) => { |
| 149 | 154 | if (!artifact) return; |
| 150 | toastManager.add({ | |
| 151 | type: "success", | |
| 152 | title: "Recording saved", | |
| 153 | description: artifact.path, | |
| 154 | }); | |
| 155 | let pathCopied = false; | |
| 156 | let toastId: ReturnType<typeof toastManager.add>; | |
| 157 | ||
| 158 | const copyPath = () => { | |
| 159 | if (!navigator.clipboard?.writeText) { | |
| 160 | toastManager.update( | |
| 161 | toastId, | |
| 162 | stackedThreadToast({ | |
| 163 | type: "error", | |
| 164 | title: "Unable to copy recording path", | |
| 165 | description: "Clipboard API unavailable.", | |
| 166 | actionProps: revealAction, | |
| 167 | }), | |
| 168 | ); | |
| 169 | return; | |
| 170 | } | |
| 171 | ||
| 172 | void navigator.clipboard.writeText(artifact.path).then( | |
| 173 | () => { | |
| 174 | pathCopied = true; | |
| 175 | updateRecordingToast(); | |
| 176 | window.setTimeout(() => { | |
| 177 | pathCopied = false; | |
| 178 | updateRecordingToast(); | |
| 179 | }, 2_000); | |
| 180 | }, | |
| 181 | (error) => { | |
| 182 | toastManager.update( | |
| 183 | toastId, | |
| 184 | stackedThreadToast({ | |
| 185 | type: "error", | |
| 186 | title: "Unable to copy recording path", | |
| 187 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 188 | actionProps: revealAction, | |
| 189 | }), | |
| 190 | ); | |
| 191 | }, | |
| 192 | ); | |
| 193 | }; | |
| 194 | ||
| 195 | const revealAction = { | |
| 196 | children: revealInFileExplorerLabel(navigator.platform), | |
| 197 | onClick: () => void bridge.revealArtifact(artifact.path), | |
| 198 | }; | |
| 199 | const updateRecordingToast = () => { | |
| 200 | toastManager.update( | |
| 201 | toastId, | |
| 202 | stackedThreadToast({ | |
| 203 | type: "success", | |
| 204 | title: "Recording saved", | |
| 205 | actionProps: revealAction, | |
| 206 | data: { | |
| 207 | secondaryActionProps: { | |
| 208 | children: pathCopied ? "Copied!" : "Copy path", | |
| 209 | disabled: pathCopied, | |
| 210 | onClick: copyPath, | |
| 211 | }, | |
| 212 | secondaryActionVariant: "outline", | |
| 213 | }, | |
| 214 | }), | |
| 215 | ); | |
| 216 | }; | |
| 217 | ||
| 218 | toastId = toastManager.add( | |
| 219 | stackedThreadToast({ | |
| 220 | type: "success", | |
| 221 | title: "Recording saved", | |
| 222 | actionProps: revealAction, | |
| 223 | data: { | |
| 224 | secondaryActionProps: { | |
| 225 | children: "Copy path", | |
| 226 | onClick: copyPath, | |
| 227 | }, | |
| 228 | secondaryActionVariant: "outline", | |
| 229 | }, | |
| 230 | }), | |
| 231 | ); | |
| 155 | 232 | }, |
| 156 | 233 | (error) => { |
| 157 | 234 | toastManager.add({ |
| ... | ... | @@ -181,13 +258,126 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 181 | 258 | }); |
| 182 | 259 | return; |
| 183 | 260 | } |
| 184 | void previewBridge.captureScreenshot(tabId).then( | |
| 261 | void bridge.captureScreenshot(tabId).then( | |
| 185 | 262 | (artifact) => { |
| 186 | toastManager.add({ | |
| 187 | type: "success", | |
| 188 | title: "Screenshot saved", | |
| 189 | description: artifact.path, | |
| 190 | }); | |
| 263 | const revealAction = { | |
| 264 | children: revealInFileExplorerLabel(navigator.platform), | |
| 265 | onClick: () => void bridge.revealArtifact(artifact.path), | |
| 266 | }; | |
| 267 | let pathCopied = false; | |
| 268 | let imageCopied = false; | |
| 269 | let toastId: ReturnType<typeof toastManager.add>; | |
| 270 | ||
| 271 | const updateScreenshotToast = ( | |
| 272 | type: "success" | "error" = "success", | |
| 273 | title = "Screenshot saved", | |
| 274 | description?: string, | |
| 275 | ) => { | |
| 276 | toastManager.update( | |
| 277 | toastId, | |
| 278 | stackedThreadToast({ | |
| 279 | type, | |
| 280 | title, | |
| 281 | description, | |
| 282 | actionProps: { | |
| 283 | children: imageCopied ? "Copied!" : "Copy image", | |
| 284 | disabled: imageCopied, | |
| 285 | onClick: copyImage, | |
| 286 | }, | |
| 287 | data: { | |
| 288 | additionalActions: [ | |
| 289 | { | |
| 290 | id: "copy-path", | |
| 291 | props: { | |
| 292 | children: pathCopied ? "Copied!" : "Copy path", | |
| 293 | disabled: pathCopied, | |
| 294 | onClick: copyPath, | |
| 295 | }, | |
| 296 | }, | |
| 297 | ], | |
| 298 | secondaryActionProps: { | |
| 299 | ...revealAction, | |
| 300 | }, | |
| 301 | secondaryActionVariant: "outline", | |
| 302 | }, | |
| 303 | }), | |
| 304 | ); | |
| 305 | }; | |
| 306 | ||
| 307 | const copyPath = () => { | |
| 308 | if (!navigator.clipboard?.writeText) { | |
| 309 | updateScreenshotToast( | |
| 310 | "error", | |
| 311 | "Unable to copy screenshot path", | |
| 312 | "Clipboard API unavailable.", | |
| 313 | ); | |
| 314 | return; | |
| 315 | } | |
| 316 | ||
| 317 | void navigator.clipboard.writeText(artifact.path).then( | |
| 318 | () => { | |
| 319 | pathCopied = true; | |
| 320 | updateScreenshotToast(); | |
| 321 | window.setTimeout(() => { | |
| 322 | pathCopied = false; | |
| 323 | updateScreenshotToast(); | |
| 324 | }, 2_000); | |
| 325 | }, | |
| 326 | (error) => { | |
| 327 | updateScreenshotToast( | |
| 328 | "error", | |
| 329 | "Unable to copy screenshot path", | |
| 330 | error instanceof Error ? error.message : "An error occurred.", | |
| 331 | ); | |
| 332 | }, | |
| 333 | ); | |
| 334 | }; | |
| 335 | ||
| 336 | const copyImage = () => { | |
| 337 | void bridge.copyArtifactToClipboard(artifact.path).then( | |
| 338 | () => { | |
| 339 | imageCopied = true; | |
| 340 | updateScreenshotToast(); | |
| 341 | window.setTimeout(() => { | |
| 342 | imageCopied = false; | |
| 343 | updateScreenshotToast(); | |
| 344 | }, 2_000); | |
| 345 | }, | |
| 346 | (error) => { | |
| 347 | updateScreenshotToast( | |
| 348 | "error", | |
| 349 | "Unable to copy screenshot", | |
| 350 | error instanceof Error ? error.message : "An error occurred.", | |
| 351 | ); | |
| 352 | }, | |
| 353 | ); | |
| 354 | }; | |
| 355 | ||
| 356 | toastId = toastManager.add( | |
| 357 | stackedThreadToast({ | |
| 358 | type: "success", | |
| 359 | title: "Screenshot saved", | |
| 360 | actionProps: { | |
| 361 | children: "Copy image", | |
| 362 | onClick: copyImage, | |
| 363 | }, | |
| 364 | data: { | |
| 365 | additionalActions: [ | |
| 366 | { | |
| 367 | id: "copy-path", | |
| 368 | props: { | |
| 369 | children: "Copy path", | |
| 370 | onClick: copyPath, | |
| 371 | }, | |
| 372 | }, | |
| 373 | ], | |
| 374 | secondaryActionProps: { | |
| 375 | ...revealAction, | |
| 376 | }, | |
| 377 | secondaryActionVariant: "outline", | |
| 378 | }, | |
| 379 | }), | |
| 380 | ); | |
| 191 | 381 | }, |
| 192 | 382 | (error) => { |
| 193 | 383 | toastManager.add({ |
| ... | ... | @@ -287,7 +477,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 287 | 477 | handleRefresh(); |
| 288 | 478 | return; |
| 289 | 479 | case "focus-url": |
| 290 | setFocusUrlNonce((value) => value + 1); | |
| 480 | setFocusUrlNonce((value) => (value ?? 0) + 1); | |
| 291 | 481 | return; |
| 292 | 482 | case "zoom-in": |
| 293 | 483 | handleZoomIn(); |
| ... | ... | @@ -346,24 +536,32 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 346 | 536 | /> |
| 347 | 537 | |
| 348 | 538 | <div className="relative min-h-0 flex-1 overflow-hidden"> |
| 349 | {tabId && snapshot ? ( | |
| 539 | {tabId && snapshot && !showEmptyState ? ( | |
| 350 | 540 | <BrowserSurfaceSlot |
| 351 | 541 | key={tabId} |
| 352 | 542 | tabId={tabId} |
| 353 | 543 | visible={visible && !isUnreachable} |
| 354 | 544 | className="absolute inset-0 h-full w-full" |
| 355 | 545 | /> |
| 356 | ) : ( | |
| 546 | ) : null} | |
| 547 | {showEmptyState ? ( | |
| 357 | 548 | <PreviewEmptyState |
| 358 | 549 | environmentId={threadRef.environmentId} |
| 359 | 550 | configuredUrls={configuredUrls} |
| 360 | 551 | recentlySeenUrls={previewState.recentlySeenUrls} |
| 361 | 552 | onOpenUrl={(next) => void handleSubmitUrl(next)} |
| 362 | 553 | /> |
| 363 | )} | |
| 554 | ) : null} | |
| 364 | 555 | {snapshot && desktopOverlay ? ( |
| 365 | 556 | <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} /> |
| 366 | 557 | ) : null} |
| 558 | {tabId && desktopOverlay && !showEmptyState && !isUnreachable ? ( | |
| 559 | <AgentBrowserCursor | |
| 560 | tabId={tabId} | |
| 561 | zoomFactor={desktopOverlay.zoomFactor} | |
| 562 | controller={controller} | |
| 563 | /> | |
| 564 | ) : null} | |
| 367 | 565 | {controller !== "none" ? ( |
| 368 | 566 | <div className="pointer-events-none absolute left-3 top-3 z-40 rounded-full border border-border/70 bg-background/90 px-2.5 py-1 text-[11px] font-medium shadow-sm backdrop-blur"> |
| 369 | 567 | {controller === "agent" ? "Agent controlling browser" : "Human control"} |
apps/web/src/components/preview/agentBrowserCursorLogic.test.ts created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { agentBrowserCursorOpacity } from "./agentBrowserCursorLogic"; | |
| 4 | ||
| 5 | describe("agentBrowserCursorOpacity", () => { | |
| 6 | it("keeps active movement fully visible", () => { | |
| 7 | expect(agentBrowserCursorOpacity(true, "agent")).toBe(1); | |
| 8 | expect(agentBrowserCursorOpacity(true, "human")).toBe(1); | |
| 9 | }); | |
| 10 | ||
| 11 | it("settles to a visible idle state", () => { | |
| 12 | expect(agentBrowserCursorOpacity(false, "none")).toBe(0.35); | |
| 13 | expect(agentBrowserCursorOpacity(false, "agent")).toBe(0.35); | |
| 14 | }); | |
| 15 | ||
| 16 | it("dims further while the human controls the page", () => { | |
| 17 | expect(agentBrowserCursorOpacity(false, "human")).toBe(0.18); | |
| 18 | }); | |
| 19 | }); |
apps/web/src/components/preview/agentBrowserCursorLogic.ts created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | export type BrowserController = "human" | "agent" | "none"; | |
| 2 | ||
| 3 | export function agentBrowserCursorOpacity(active: boolean, controller: BrowserController): number { | |
| 4 | if (active) return 1; | |
| 5 | return controller === "human" ? 0.18 : 0.35; | |
| 6 | } |
apps/web/src/components/preview/fileExplorerLabel.test.ts created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; | |
| 4 | ||
| 5 | describe("revealInFileExplorerLabel", () => { | |
| 6 | it.each([ | |
| 7 | ["MacIntel", "Reveal in Finder"], | |
| 8 | ["Win32", "Reveal in File Explorer"], | |
| 9 | ["Linux x86_64", "Reveal in Files"], | |
| 10 | ])("maps %s to %s", (platform, expected) => { | |
| 11 | expect(revealInFileExplorerLabel(platform)).toBe(expected); | |
| 12 | }); | |
| 13 | }); |
apps/web/src/components/preview/fileExplorerLabel.ts created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | export function revealInFileExplorerLabel(platform: string): string { | |
| 2 | const normalized = platform.toLowerCase(); | |
| 3 | if (normalized.includes("mac")) return "Reveal in Finder"; | |
| 4 | if (normalized.includes("win")) return "Reveal in File Explorer"; | |
| 5 | return "Reveal in Files"; | |
| 6 | } |
apps/web/src/components/preview/openDiscoveredPort.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | import type { DiscoveredLocalServer, ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | |
| 4 | import { ensureEnvironmentApi } from "~/environmentApi"; | |
| 5 | import { usePreviewStateStore } from "~/previewStateStore"; | |
| 6 | import { useRightPanelStore } from "~/rightPanelStore"; | |
| 7 | import { openPreviewSession } from "./openPreviewSession"; | |
| 8 | ||
| 9 | export async function openDiscoveredPort(input: { | |
| 10 | readonly threadRef: ScopedThreadRef; | |
| 11 | readonly port: DiscoveredLocalServer; | |
| 12 | }): Promise<void> { | |
| 13 | const api = ensureEnvironmentApi(input.threadRef.environmentId); | |
| 14 | const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); | |
| 15 | const previewState = usePreviewStateStore.getState(); | |
| 16 | const snapshot = await openPreviewSession({ | |
| 17 | previewApi: api.preview, | |
| 18 | threadRef: input.threadRef, | |
| 19 | url: resolvedUrl, | |
| 20 | applyServerSnapshot: previewState.applyServerSnapshot, | |
| 21 | rememberUrl: previewState.rememberUrl, | |
| 22 | }); | |
| 23 | useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); | |
| 24 | } |
apps/web/src/components/preview/openPreviewSession.ts+5-2| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentApi, ScopedThreadRef } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | import type { PreviewStateStoreState } from "~/previewStateStore"; |
| 4 | 4 | |
| ... | ... | @@ -10,7 +10,9 @@ interface OpenPreviewSessionInput { |
| 10 | 10 | rememberUrl: PreviewStateStoreState["rememberUrl"]; |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | export async function openPreviewSession(input: OpenPreviewSessionInput): Promise<void> { | |
| 13 | export async function openPreviewSession( | |
| 14 | input: OpenPreviewSessionInput, | |
| 15 | ): Promise<PreviewSessionSnapshot> { | |
| 14 | 16 | const snapshot = await input.previewApi.open({ |
| 15 | 17 | threadId: input.threadRef.threadId, |
| 16 | 18 | url: input.url, |
| ... | ... | @@ -20,4 +22,5 @@ export async function openPreviewSession(input: OpenPreviewSessionInput): Promis |
| 20 | 22 | input.threadRef, |
| 21 | 23 | snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, |
| 22 | 24 | ); |
| 25 | return snapshot; | |
| 23 | 26 | } |
apps/web/src/components/preview/previewEmptyStateLogic.test.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { getConfiguredPreviewUrls, shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; | |
| 5 | ||
| 6 | const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ | |
| 7 | threadId: "thread-1", | |
| 8 | tabId: "tab-1", | |
| 9 | navStatus, | |
| 10 | canGoBack: false, | |
| 11 | canGoForward: false, | |
| 12 | updatedAt: "2026-06-12T20:00:00.000Z", | |
| 13 | }); | |
| 14 | ||
| 15 | describe("shouldShowPreviewEmptyState", () => { | |
| 16 | it("shows quick-open options for a new idle browser tab", () => { | |
| 17 | expect(shouldShowPreviewEmptyState(snapshot({ _tag: "Idle" }))).toBe(true); | |
| 18 | }); | |
| 19 | ||
| 20 | it("shows browser content once navigation starts", () => { | |
| 21 | expect( | |
| 22 | shouldShowPreviewEmptyState( | |
| 23 | snapshot({ _tag: "Loading", url: "http://localhost:5173", title: "" }), | |
| 24 | ), | |
| 25 | ).toBe(false); | |
| 26 | }); | |
| 27 | }); | |
| 28 | ||
| 29 | describe("getConfiguredPreviewUrls", () => { | |
| 30 | it("collects configured preview URLs from project scripts", () => { | |
| 31 | const scripts = [ | |
| 32 | { previewUrl: "http://localhost:5173" }, | |
| 33 | {}, | |
| 34 | { previewUrl: "http://localhost:3000" }, | |
| 35 | ] as ProjectScript[]; | |
| 36 | ||
| 37 | expect(getConfiguredPreviewUrls(scripts)).toEqual([ | |
| 38 | "http://localhost:5173", | |
| 39 | "http://localhost:3000", | |
| 40 | ]); | |
| 41 | }); | |
| 42 | }); |
apps/web/src/components/preview/previewEmptyStateLogic.ts created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export function shouldShowPreviewEmptyState(snapshot: PreviewSessionSnapshot | null): boolean { | |
| 4 | return snapshot === null || snapshot.navStatus._tag === "Idle"; | |
| 5 | } | |
| 6 | ||
| 7 | export function getConfiguredPreviewUrls( | |
| 8 | scripts: ReadonlyArray<ProjectScript> | undefined, | |
| 9 | ): ReadonlyArray<string> { | |
| 10 | return scripts?.flatMap((script) => (script.previewUrl ? [script.previewUrl] : [])) ?? []; | |
| 11 | } |
apps/web/src/components/preview/useDiscoveredLocalServers.test.ts+1| ... | ... | @@ -9,6 +9,7 @@ const scannerServer = (overrides: Partial<DiscoveredLocalServer>): DiscoveredLoc |
| 9 | 9 | url: "http://localhost:5173", |
| 10 | 10 | processName: "vite", |
| 11 | 11 | pid: 1234, |
| 12 | terminal: null, | |
| 12 | 13 | ...overrides, |
| 13 | 14 | }); |
| 14 | 15 |
apps/web/src/components/preview/useDiscoveredLocalServers.ts+8-15| ... | ... | @@ -1,10 +1,10 @@ |
| 1 | 1 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; |
| 2 | 2 | import { isLoopbackHost } from "@t3tools/shared/preview"; |
| 3 | import { useEffect, useMemo, useState } from "react"; | |
| 3 | import { useMemo } from "react"; | |
| 4 | 4 | |
| 5 | import { ensureEnvironmentApi } from "~/environmentApi"; | |
| 6 | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 7 | 6 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 7 | import { useDiscoveredPorts } from "~/portDiscoveryState"; | |
| 8 | 8 | |
| 9 | 9 | export interface PreviewableServer extends DiscoveredLocalServer { |
| 10 | 10 | source: "scanner" | "configured" | "recent"; |
| ... | ... | @@ -22,23 +22,13 @@ interface UseDiscoveredLocalServersInput { |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | 24 | /** |
| 25 | * Subscribe to live localhost port scans, merge in configured / | |
| 26 | * recently-seen URLs, and return a stable sorted list. Retains the scanner | |
| 27 | * while mounted. | |
| 25 | * Merge the environment-level port snapshot with configured / recently-seen | |
| 26 | * URLs and return a stable sorted list. | |
| 28 | 27 | */ |
| 29 | 28 | export function useDiscoveredLocalServers( |
| 30 | 29 | input: UseDiscoveredLocalServersInput, |
| 31 | 30 | ): ReadonlyArray<PreviewableServer> { |
| 32 | const [scannerSnapshot, setScannerSnapshot] = useState<ReadonlyArray<DiscoveredLocalServer>>([]); | |
| 33 | ||
| 34 | useEffect(() => { | |
| 35 | const api = ensureEnvironmentApi(input.environmentId); | |
| 36 | setScannerSnapshot([]); | |
| 37 | const unsubscribe = api.preview.subscribePorts((next) => { | |
| 38 | setScannerSnapshot(next.servers); | |
| 39 | }); | |
| 40 | return unsubscribe; | |
| 41 | }, [input.environmentId]); | |
| 31 | const scannerSnapshot = useDiscoveredPorts(input.environmentId); | |
| 42 | 32 | |
| 43 | 33 | return useMemo( |
| 44 | 34 | () => |
| ... | ... | @@ -72,6 +62,7 @@ export function mergeServers(input: { |
| 72 | 62 | url: parsed.url, |
| 73 | 63 | processName: null, |
| 74 | 64 | pid: null, |
| 65 | terminal: null, | |
| 75 | 66 | source: "configured", |
| 76 | 67 | listening: false, |
| 77 | 68 | }); |
| ... | ... | @@ -87,6 +78,7 @@ export function mergeServers(input: { |
| 87 | 78 | ...existing, |
| 88 | 79 | processName: server.processName ?? existing.processName, |
| 89 | 80 | pid: server.pid ?? existing.pid, |
| 81 | terminal: server.terminal ?? existing.terminal, | |
| 90 | 82 | listening: true, |
| 91 | 83 | }); |
| 92 | 84 | continue; |
| ... | ... | @@ -105,6 +97,7 @@ export function mergeServers(input: { |
| 105 | 97 | url: parsed.url, |
| 106 | 98 | processName: null, |
| 107 | 99 | pid: null, |
| 100 | terminal: null, | |
| 108 | 101 | source: "recent", |
| 109 | 102 | listening: false, |
| 110 | 103 | }); |
apps/web/src/components/preview/usePreviewBridge.ts+19-1| ... | ... | @@ -8,6 +8,7 @@ import type { |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { useEffect, useRef } from "react"; |
| 10 | 10 | |
| 11 | import { useBrowserPointerStore } from "~/browser/browserPointerStore"; | |
| 11 | 12 | import { ensureEnvironmentApi } from "~/environmentApi"; |
| 12 | 13 | import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore"; |
| 13 | 14 | |
| ... | ... | @@ -20,19 +21,26 @@ import { previewBridge } from "./previewBridge"; |
| 20 | 21 | export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { |
| 21 | 22 | const { threadRef, tabId } = input; |
| 22 | 23 | const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); |
| 24 | const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); | |
| 23 | 25 | const bridge = previewBridge; |
| 24 | 26 | |
| 25 | 27 | // One bridge subscription does both jobs (mirror state + forward to |
| 26 | 28 | // server) so the desktop bridge keeps a single listener entry per tab. |
| 27 | 29 | const lastReportedUrl = useRef<string | null>(null); |
| 28 | 30 | const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null); |
| 31 | const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null); | |
| 29 | 32 | useEffect(() => { |
| 30 | 33 | if (!bridge || typeof window === "undefined") return; |
| 31 | 34 | const api = ensureEnvironmentApi(threadRef.environmentId); |
| 32 | 35 | lastReportedUrl.current = null; |
| 33 | 36 | lastReportedKind.current = null; |
| 37 | lastDesktopNavStatus.current = null; | |
| 34 | 38 | const unsubscribe = bridge.onStateChange((changedTabId, state) => { |
| 35 | 39 | if (changedTabId !== tabId) return; |
| 40 | if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) { | |
| 41 | clearBrowserPointer(tabId); | |
| 42 | } | |
| 43 | lastDesktopNavStatus.current = state.navStatus; | |
| 36 | 44 | applyDesktopState(threadRef, tabId, projectDesktopState(state)); |
| 37 | 45 | const reported = buildReportInput({ |
| 38 | 46 | threadId: threadRef.threadId, |
| ... | ... | @@ -47,7 +55,17 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str |
| 47 | 55 | void api.preview.reportStatus(reported.input).catch(() => undefined); |
| 48 | 56 | }); |
| 49 | 57 | return unsubscribe; |
| 50 | }, [applyDesktopState, bridge, tabId, threadRef]); | |
| 58 | }, [applyDesktopState, bridge, clearBrowserPointer, tabId, threadRef]); | |
| 59 | } | |
| 60 | ||
| 61 | function shouldClearBrowserPointer( | |
| 62 | previous: DesktopPreviewTabState["navStatus"] | null, | |
| 63 | current: DesktopPreviewTabState["navStatus"], | |
| 64 | ): boolean { | |
| 65 | if (!previous) return false; | |
| 66 | if (current.kind === "Loading" && previous.kind !== "Loading") return true; | |
| 67 | if (current.kind === "Idle" || previous.kind === "Idle") return false; | |
| 68 | return current.url !== previous.url; | |
| 51 | 69 | } |
| 52 | 70 | |
| 53 | 71 | function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { |
apps/web/src/components/ui/toast.tsx+21-1| ... | ... | @@ -45,6 +45,10 @@ export type ThreadToastData = { |
| 45 | 45 | onClose?: (() => void) | undefined; |
| 46 | 46 | dismissAfterVisibleMs?: number; |
| 47 | 47 | hideCopyButton?: boolean; |
| 48 | additionalActions?: ReadonlyArray<{ | |
| 49 | id: string; | |
| 50 | props: ComponentPropsWithoutRef<"button">; | |
| 51 | }>; | |
| 48 | 52 | secondaryActionProps?: ComponentPropsWithoutRef<"button">; |
| 49 | 53 | secondaryActionVariant?: |
| 50 | 54 | | "default" |
| ... | ... | @@ -292,9 +296,13 @@ function deriveToastBodyDescriptor(toast: { |
| 292 | 296 | toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton |
| 293 | 297 | ? toast.description |
| 294 | 298 | : null; |
| 299 | const hasAdditionalActions = (toast.data?.additionalActions?.length ?? 0) > 0; | |
| 295 | 300 | const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; |
| 296 | 301 | const hasTrailingControls = |
| 297 | copyErrorText !== null || toast.actionProps !== undefined || hasSecondaryAction; | |
| 302 | copyErrorText !== null || | |
| 303 | toast.actionProps !== undefined || | |
| 304 | hasAdditionalActions || | |
| 305 | hasSecondaryAction; | |
| 298 | 306 | const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; |
| 299 | 307 | return { |
| 300 | 308 | Icon, |
| ... | ... | @@ -326,6 +334,7 @@ function ToastBodyContent({ |
| 326 | 334 | toastDescription, |
| 327 | 335 | toastType, |
| 328 | 336 | }: ToastBodyContentProps) { |
| 337 | const additionalActions = toastData?.additionalActions ?? []; | |
| 329 | 338 | const secondaryActionProps = toastData?.secondaryActionProps; |
| 330 | 339 | const leadingIcon = toastData?.leadingIcon; |
| 331 | 340 | const { className: secondaryActionClassName, ...secondaryActionRest } = |
| ... | ... | @@ -371,6 +380,17 @@ function ToastBodyContent({ |
| 371 | 380 | )} |
| 372 | 381 | > |
| 373 | 382 | {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null} |
| 383 | {additionalActions.map(({ id, props: { className, ...props } }) => ( | |
| 384 | <button | |
| 385 | {...props} | |
| 386 | className={cn( | |
| 387 | buttonVariants({ size: "xs", variant: secondaryActionVariant }), | |
| 388 | className, | |
| 389 | )} | |
| 390 | key={id} | |
| 391 | type="button" | |
| 392 | /> | |
| 393 | ))} | |
| 374 | 394 | {secondaryActionProps ? ( |
| 375 | 395 | <button |
| 376 | 396 | {...secondaryActionRest} |
apps/web/src/environments/runtime/service.ts+18| ... | ... | @@ -90,6 +90,7 @@ import { |
| 90 | 90 | const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope)); |
| 91 | 91 | import { getClientSettings } from "~/hooks/useSettings"; |
| 92 | 92 | import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; |
| 93 | import { subscribePortDiscovery, usePortDiscoveryStore } from "../../portDiscoveryState"; | |
| 93 | 94 | import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; |
| 94 | 95 | import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; |
| 95 | 96 | |
| ... | ... | @@ -140,6 +141,7 @@ const lastAppliedProjectionVersionByEnvironment = new Map< |
| 140 | 141 | } |
| 141 | 142 | >(); |
| 142 | 143 | const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>(); |
| 144 | const portDiscoverySubscriptions = new Map<EnvironmentId, () => void>(); | |
| 143 | 145 | |
| 144 | 146 | let activeService: EnvironmentServiceState | null = null; |
| 145 | 147 | let needsProviderInvalidation = false; |
| ... | ... | @@ -1397,6 +1399,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne |
| 1397 | 1399 | client: connection.client, |
| 1398 | 1400 | }), |
| 1399 | 1401 | ); |
| 1402 | portDiscoverySubscriptions.get(connection.environmentId)?.(); | |
| 1403 | portDiscoverySubscriptions.set( | |
| 1404 | connection.environmentId, | |
| 1405 | subscribePortDiscovery({ | |
| 1406 | environmentId: connection.environmentId, | |
| 1407 | previewApi: connection.client.preview, | |
| 1408 | }), | |
| 1409 | ); | |
| 1400 | 1410 | attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); |
| 1401 | 1411 | emitEnvironmentConnectionRegistryChange(); |
| 1402 | 1412 | return connection; |
| ... | ... | @@ -1412,6 +1422,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> |
| 1412 | 1422 | environmentConnections.delete(environmentId); |
| 1413 | 1423 | terminalMetadataSubscriptions.get(environmentId)?.(); |
| 1414 | 1424 | terminalMetadataSubscriptions.delete(environmentId); |
| 1425 | portDiscoverySubscriptions.get(environmentId)?.(); | |
| 1426 | portDiscoverySubscriptions.delete(environmentId); | |
| 1427 | usePortDiscoveryStore.getState().clearEnvironment(environmentId); | |
| 1415 | 1428 | terminalSessionManager.invalidateEnvironment(environmentId); |
| 1416 | 1429 | emitEnvironmentConnectionRegistryChange(); |
| 1417 | 1430 | detachThreadDetailSubscriptionsForEnvironment(environmentId); |
| ... | ... | @@ -2048,6 +2061,11 @@ export async function resetEnvironmentServiceForTests(): Promise<void> { |
| 2048 | 2061 | unsubscribe(); |
| 2049 | 2062 | } |
| 2050 | 2063 | terminalMetadataSubscriptions.clear(); |
| 2064 | for (const unsubscribe of portDiscoverySubscriptions.values()) { | |
| 2065 | unsubscribe(); | |
| 2066 | } | |
| 2067 | portDiscoverySubscriptions.clear(); | |
| 2068 | usePortDiscoveryStore.getState().reset(); | |
| 2051 | 2069 | terminalSessionManager.reset(); |
| 2052 | 2070 | await Promise.all( |
| 2053 | 2071 | [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), |
apps/web/src/keybindings.test.ts+33-2| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | isTerminalCloseShortcut, |
| 19 | 19 | isTerminalNewShortcut, |
| 20 | 20 | isTerminalSplitShortcut, |
| 21 | isTerminalSplitVerticalShortcut, | |
| 21 | 22 | isTerminalToggleShortcut, |
| 22 | 23 | resolveShortcutCommand, |
| 23 | 24 | shouldShowModelPickerJumpHints, |
| ... | ... | @@ -85,6 +86,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { |
| 85 | 86 | |
| 86 | 87 | const DEFAULT_BINDINGS = compile([ |
| 87 | 88 | { shortcut: modShortcut("j"), command: "terminal.toggle" }, |
| 89 | { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" }, | |
| 88 | 90 | { |
| 89 | 91 | shortcut: modShortcut("d"), |
| 90 | 92 | command: "terminal.split", |
| ... | ... | @@ -92,6 +94,11 @@ const DEFAULT_BINDINGS = compile([ |
| 92 | 94 | }, |
| 93 | 95 | { |
| 94 | 96 | shortcut: modShortcut("d", { shiftKey: true }), |
| 97 | command: "terminal.splitVertical", | |
| 98 | whenAst: whenIdentifier("terminalFocus"), | |
| 99 | }, | |
| 100 | { | |
| 101 | shortcut: modShortcut("n"), | |
| 95 | 102 | command: "terminal.new", |
| 96 | 103 | whenAst: whenIdentifier("terminalFocus"), |
| 97 | 104 | }, |
| ... | ... | @@ -174,7 +181,17 @@ describe("split/new/close terminal shortcuts", () => { |
| 174 | 181 | }), |
| 175 | 182 | ); |
| 176 | 183 | assert.isFalse( |
| 177 | isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 184 | isTerminalSplitVerticalShortcut( | |
| 185 | event({ key: "d", metaKey: true, shiftKey: true }), | |
| 186 | DEFAULT_BINDINGS, | |
| 187 | { | |
| 188 | platform: "MacIntel", | |
| 189 | context: { terminalFocus: false }, | |
| 190 | }, | |
| 191 | ), | |
| 192 | ); | |
| 193 | assert.isFalse( | |
| 194 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, { | |
| 178 | 195 | platform: "Linux", |
| 179 | 196 | context: { terminalFocus: false }, |
| 180 | 197 | }), |
| ... | ... | @@ -195,7 +212,17 @@ describe("split/new/close terminal shortcuts", () => { |
| 195 | 212 | }), |
| 196 | 213 | ); |
| 197 | 214 | assert.isTrue( |
| 198 | isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 215 | isTerminalSplitVerticalShortcut( | |
| 216 | event({ key: "d", metaKey: true, shiftKey: true }), | |
| 217 | DEFAULT_BINDINGS, | |
| 218 | { | |
| 219 | platform: "MacIntel", | |
| 220 | context: { terminalFocus: true }, | |
| 221 | }, | |
| 222 | ), | |
| 223 | ); | |
| 224 | assert.isTrue( | |
| 225 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, { | |
| 199 | 226 | platform: "Linux", |
| 200 | 227 | context: { terminalFocus: true }, |
| 201 | 228 | }), |
| ... | ... | @@ -287,6 +314,10 @@ describe("shortcutLabelForCommand", () => { |
| 287 | 314 | it("returns effective labels for non-terminal commands", () => { |
| 288 | 315 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); |
| 289 | 316 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); |
| 317 | assert.strictEqual( | |
| 318 | shortcutLabelForCommand(DEFAULT_BINDINGS, "rightPanel.toggle", "MacIntel"), | |
| 319 | "⌥⌘B", | |
| 320 | ); | |
| 290 | 321 | assert.strictEqual( |
| 291 | 322 | shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), |
| 292 | 323 | "⌘K", |
apps/web/src/keybindings.ts+8| ... | ... | @@ -359,6 +359,14 @@ export function isTerminalSplitShortcut( |
| 359 | 359 | return matchesCommandShortcut(event, keybindings, "terminal.split", options); |
| 360 | 360 | } |
| 361 | 361 | |
| 362 | export function isTerminalSplitVerticalShortcut( | |
| 363 | event: ShortcutEventLike, | |
| 364 | keybindings: ResolvedKeybindingsConfig, | |
| 365 | options?: ShortcutMatchOptions, | |
| 366 | ): boolean { | |
| 367 | return matchesCommandShortcut(event, keybindings, "terminal.splitVertical", options); | |
| 368 | } | |
| 369 | ||
| 362 | 370 | export function isTerminalNewShortcut( |
| 363 | 371 | event: ShortcutEventLike, |
| 364 | 372 | keybindings: ResolvedKeybindingsConfig, |
apps/web/src/lib/terminalFocus.test.ts+15-7| ... | ... | @@ -1,10 +1,12 @@ |
| 1 | 1 | import { afterEach, describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { isTerminalFocused } from "./terminalFocus"; | |
| 3 | import { getTerminalFocusOwner, isTerminalFocused } from "./terminalFocus"; | |
| 4 | 4 | |
| 5 | 5 | class MockHTMLElement { |
| 6 | 6 | isConnected = false; |
| 7 | 7 | className = ""; |
| 8 | terminalOwner: string | null = null; | |
| 9 | readonly dataset: { terminalOwner?: string } = {}; | |
| 8 | 10 | |
| 9 | 11 | readonly classList = { |
| 10 | 12 | contains: (value: string) => this.className.split(/\s+/).includes(value), |
| ... | ... | @@ -14,7 +16,7 @@ class MockHTMLElement { |
| 14 | 16 | if (!this.isConnected) { |
| 15 | 17 | return null; |
| 16 | 18 | } |
| 17 | if (selector === ".thread-terminal-drawer .xterm" || selector === ".thread-terminal-drawer") { | |
| 19 | if (selector === "[data-terminal-owner]" && this.terminalOwner !== null) { | |
| 18 | 20 | return this; |
| 19 | 21 | } |
| 20 | 22 | return null; |
| ... | ... | @@ -44,30 +46,36 @@ describe("isTerminalFocused", () => { |
| 44 | 46 | detached.className = "xterm-helper-textarea"; |
| 45 | 47 | |
| 46 | 48 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; |
| 47 | globalThis.document = { activeElement: detached } as Document; | |
| 49 | globalThis.document = { activeElement: detached } as unknown as Document; | |
| 48 | 50 | |
| 49 | 51 | expect(isTerminalFocused()).toBe(false); |
| 50 | 52 | }); |
| 51 | 53 | |
| 52 | it("returns true for connected xterm helper textareas", () => { | |
| 54 | it("returns the drawer owner for connected xterm helper textareas", () => { | |
| 53 | 55 | const attached = new MockHTMLElement(); |
| 54 | 56 | attached.className = "xterm-helper-textarea"; |
| 55 | 57 | attached.isConnected = true; |
| 58 | attached.terminalOwner = "drawer"; | |
| 59 | attached.dataset.terminalOwner = "drawer"; | |
| 56 | 60 | |
| 57 | 61 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; |
| 58 | globalThis.document = { activeElement: attached } as Document; | |
| 62 | globalThis.document = { activeElement: attached } as unknown as Document; | |
| 59 | 63 | |
| 64 | expect(getTerminalFocusOwner()).toBe("drawer"); | |
| 60 | 65 | expect(isTerminalFocused()).toBe(true); |
| 61 | 66 | }); |
| 62 | 67 | |
| 63 | it("returns true for focus inside the terminal drawer (e.g. sidebar)", () => { | |
| 68 | it("returns the right panel owner for focus inside its terminal UI", () => { | |
| 64 | 69 | const sidebarButton = new MockHTMLElement(); |
| 65 | 70 | sidebarButton.className = "terminal-sidebar-button"; |
| 66 | 71 | sidebarButton.isConnected = true; |
| 72 | sidebarButton.terminalOwner = "right-panel"; | |
| 73 | sidebarButton.dataset.terminalOwner = "right-panel"; | |
| 67 | 74 | |
| 68 | 75 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; |
| 69 | globalThis.document = { activeElement: sidebarButton } as Document; | |
| 76 | globalThis.document = { activeElement: sidebarButton } as unknown as Document; | |
| 70 | 77 | |
| 78 | expect(getTerminalFocusOwner()).toBe("right-panel"); | |
| 71 | 79 | expect(isTerminalFocused()).toBe(true); |
| 72 | 80 | }); |
| 73 | 81 | }); |
apps/web/src/lib/terminalFocus.ts+12-7| ... | ... | @@ -1,9 +1,14 @@ |
| 1 | export function isTerminalFocused(): boolean { | |
| 1 | export type TerminalFocusOwner = "drawer" | "right-panel"; | |
| 2 | ||
| 3 | export function getTerminalFocusOwner(): TerminalFocusOwner | null { | |
| 2 | 4 | const activeElement = document.activeElement; |
| 3 | if (!(activeElement instanceof HTMLElement)) return false; | |
| 4 | if (!activeElement.isConnected) return false; | |
| 5 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; | |
| 6 | if (activeElement.closest(".thread-terminal-drawer .xterm") !== null) return true; | |
| 7 | // Sidebar / toolbar / resize affordances: still "terminal UI" for split vs diff.toggle (⌘D). | |
| 8 | return activeElement.closest(".thread-terminal-drawer") !== null; | |
| 5 | if (!(activeElement instanceof HTMLElement)) return null; | |
| 6 | if (!activeElement.isConnected) return null; | |
| 7 | const owner = activeElement.closest<HTMLElement>("[data-terminal-owner]")?.dataset.terminalOwner; | |
| 8 | if (owner === "drawer" || owner === "right-panel") return owner; | |
| 9 | return null; | |
| 10 | } | |
| 11 | ||
| 12 | export function isTerminalFocused(): boolean { | |
| 13 | return getTerminalFocusOwner() !== null; | |
| 9 | 14 | } |
apps/web/src/portDiscoveryState.ts created+86| ... | ... | @@ -0,0 +1,86 @@ |
| 1 | import type { | |
| 2 | DiscoveredLocalServer, | |
| 3 | EnvironmentApi, | |
| 4 | EnvironmentId, | |
| 5 | ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { useMemo } from "react"; | |
| 8 | import { create } from "zustand"; | |
| 9 | ||
| 10 | const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]); | |
| 11 | ||
| 12 | interface PortDiscoveryState { | |
| 13 | readonly byEnvironment: Record<string, ReadonlyArray<DiscoveredLocalServer>>; | |
| 14 | setPorts: (environmentId: EnvironmentId, ports: ReadonlyArray<DiscoveredLocalServer>) => void; | |
| 15 | clearEnvironment: (environmentId: EnvironmentId) => void; | |
| 16 | reset: () => void; | |
| 17 | } | |
| 18 | ||
| 19 | export const usePortDiscoveryStore = create<PortDiscoveryState>((set) => ({ | |
| 20 | byEnvironment: {}, | |
| 21 | setPorts: (environmentId, ports) => | |
| 22 | set((state) => ({ | |
| 23 | byEnvironment: { | |
| 24 | ...state.byEnvironment, | |
| 25 | [environmentId]: ports, | |
| 26 | }, | |
| 27 | })), | |
| 28 | clearEnvironment: (environmentId) => | |
| 29 | set((state) => { | |
| 30 | if (!(environmentId in state.byEnvironment)) return state; | |
| 31 | const { [environmentId]: _removed, ...byEnvironment } = state.byEnvironment; | |
| 32 | return { byEnvironment }; | |
| 33 | }), | |
| 34 | reset: () => set({ byEnvironment: {} }), | |
| 35 | })); | |
| 36 | ||
| 37 | export function subscribePortDiscovery(input: { | |
| 38 | readonly environmentId: EnvironmentId; | |
| 39 | readonly previewApi: Pick<EnvironmentApi["preview"], "subscribePorts">; | |
| 40 | }): () => void { | |
| 41 | usePortDiscoveryStore.getState().clearEnvironment(input.environmentId); | |
| 42 | return input.previewApi.subscribePorts((snapshot) => { | |
| 43 | usePortDiscoveryStore.getState().setPorts(input.environmentId, snapshot.servers); | |
| 44 | }); | |
| 45 | } | |
| 46 | ||
| 47 | export function useDiscoveredPorts( | |
| 48 | environmentId: EnvironmentId | null, | |
| 49 | ): ReadonlyArray<DiscoveredLocalServer> { | |
| 50 | return usePortDiscoveryStore( | |
| 51 | (state) => (environmentId ? state.byEnvironment[environmentId] : undefined) ?? EMPTY_PORTS, | |
| 52 | ); | |
| 53 | } | |
| 54 | ||
| 55 | export function useThreadDiscoveredPorts(input: { | |
| 56 | readonly environmentId: EnvironmentId | null; | |
| 57 | readonly threadId: ThreadId | null; | |
| 58 | }): ReadonlyArray<DiscoveredLocalServer> { | |
| 59 | const ports = useDiscoveredPorts(input.environmentId); | |
| 60 | return useMemo( | |
| 61 | () => | |
| 62 | input.threadId | |
| 63 | ? ports.filter((port) => port.terminal?.threadId === input.threadId) | |
| 64 | : EMPTY_PORTS, | |
| 65 | [input.threadId, ports], | |
| 66 | ); | |
| 67 | } | |
| 68 | ||
| 69 | export function useTerminalDiscoveredPorts(input: { | |
| 70 | readonly environmentId: EnvironmentId | null; | |
| 71 | readonly threadId: ThreadId | null; | |
| 72 | readonly terminalId: string | null; | |
| 73 | }): ReadonlyArray<DiscoveredLocalServer> { | |
| 74 | const ports = useDiscoveredPorts(input.environmentId); | |
| 75 | return useMemo( | |
| 76 | () => | |
| 77 | input.threadId && input.terminalId | |
| 78 | ? ports.filter( | |
| 79 | (port) => | |
| 80 | port.terminal?.threadId === input.threadId && | |
| 81 | port.terminal.terminalId === input.terminalId, | |
| 82 | ) | |
| 83 | : EMPTY_PORTS, | |
| 84 | [input.terminalId, input.threadId, ports], | |
| 85 | ); | |
| 86 | } |
apps/web/src/previewStateStore.test.ts+36| ... | ... | @@ -154,6 +154,42 @@ describe("previewStateStore (single-tab)", () => { |
| 154 | 154 | expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); |
| 155 | 155 | }); |
| 156 | 156 | |
| 157 | it("optimistically removes a session before the server close event arrives", () => { | |
| 158 | const first = makeSnapshot({ tabId: "tab_a" }); | |
| 159 | const second = makeSnapshot({ | |
| 160 | tabId: "tab_b", | |
| 161 | updatedAt: "2026-01-01T00:00:01.000Z", | |
| 162 | }); | |
| 163 | const store = usePreviewStateStore.getState(); | |
| 164 | store.applyServerSnapshot(ref, first); | |
| 165 | store.applyServerSnapshot(ref, second); | |
| 166 | ||
| 167 | store.removeSession(ref, second.tabId); | |
| 168 | ||
| 169 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | |
| 170 | expect(Object.keys(state.sessions)).toEqual([first.tabId]); | |
| 171 | expect(state.activeTabId).toBe(first.tabId); | |
| 172 | expect(state.snapshot?.tabId).toBe(first.tabId); | |
| 173 | }); | |
| 174 | ||
| 175 | it("treats a late server close event after optimistic removal as a no-op", () => { | |
| 176 | const snapshot = makeSnapshot(); | |
| 177 | const store = usePreviewStateStore.getState(); | |
| 178 | store.applyServerSnapshot(ref, snapshot); | |
| 179 | store.removeSession(ref, snapshot.tabId); | |
| 180 | ||
| 181 | store.applyServerEvent(ref, { | |
| 182 | type: "closed", | |
| 183 | threadId: "thread-1", | |
| 184 | tabId: snapshot.tabId, | |
| 185 | createdAt: "2026-01-01T00:00:01.000Z", | |
| 186 | }); | |
| 187 | ||
| 188 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); | |
| 189 | expect(state.sessions).toEqual({}); | |
| 190 | expect(state.snapshot).toBeNull(); | |
| 191 | }); | |
| 192 | ||
| 157 | 193 | it("closed event for a different tab is a no-op", () => { |
| 158 | 194 | const snapshot = makeSnapshot({ tabId: "tab_a" }); |
| 159 | 195 | const store = usePreviewStateStore.getState(); |
apps/web/src/previewStateStore.ts+32-22| ... | ... | @@ -58,6 +58,7 @@ export interface PreviewStateStoreState { |
| 58 | 58 | tabId: string, |
| 59 | 59 | overlay: DesktopPreviewOverlay | null, |
| 60 | 60 | ) => void; |
| 61 | removeSession: (ref: ScopedThreadRef, tabId: string) => void; | |
| 61 | 62 | setActiveTab: (ref: ScopedThreadRef, tabId: string) => void; |
| 62 | 63 | rememberUrl: (ref: ScopedThreadRef, url: string) => void; |
| 63 | 64 | removeThread: (ref: ScopedThreadRef) => void; |
| ... | ... | @@ -93,6 +94,27 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => { |
| 93 | 94 | return next.slice(0, PREVIEW_RECENT_URL_LIMIT); |
| 94 | 95 | }; |
| 95 | 96 | |
| 97 | const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => { | |
| 98 | if (!current.sessions[tabId]) return current; | |
| 99 | const { [tabId]: _closed, ...sessions } = current.sessions; | |
| 100 | const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; | |
| 101 | const nextSnapshot = | |
| 102 | Object.values(sessions) | |
| 103 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) | |
| 104 | .at(-1) ?? null; | |
| 105 | const activeTabId = | |
| 106 | current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId; | |
| 107 | const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; | |
| 108 | return { | |
| 109 | ...current, | |
| 110 | sessions, | |
| 111 | desktopByTabId, | |
| 112 | activeTabId: snapshot?.tabId ?? null, | |
| 113 | snapshot, | |
| 114 | desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null, | |
| 115 | }; | |
| 116 | }; | |
| 117 | ||
| 96 | 118 | export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ |
| 97 | 119 | byThreadKey: {}, |
| 98 | 120 | applyServerEvent: (ref, event) => |
| ... | ... | @@ -145,28 +167,9 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ |
| 145 | 167 | }); |
| 146 | 168 | break; |
| 147 | 169 | case "closed": |
| 148 | nextByThread = updateThread(state, threadKey, (current) => { | |
| 149 | if (!current.sessions[event.tabId]) return current; | |
| 150 | const { [event.tabId]: _closed, ...sessions } = current.sessions; | |
| 151 | const { [event.tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; | |
| 152 | const nextSnapshot = | |
| 153 | Object.values(sessions) | |
| 154 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) | |
| 155 | .at(-1) ?? null; | |
| 156 | const activeTabId = | |
| 157 | current.activeTabId === event.tabId | |
| 158 | ? (nextSnapshot?.tabId ?? null) | |
| 159 | : current.activeTabId; | |
| 160 | const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; | |
| 161 | return { | |
| 162 | ...current, | |
| 163 | sessions, | |
| 164 | desktopByTabId, | |
| 165 | activeTabId: snapshot?.tabId ?? null, | |
| 166 | snapshot, | |
| 167 | desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null, | |
| 168 | }; | |
| 169 | }); | |
| 170 | nextByThread = updateThread(state, threadKey, (current) => | |
| 171 | removeSession(current, event.tabId), | |
| 172 | ); | |
| 170 | 173 | break; |
| 171 | 174 | } |
| 172 | 175 | return { byThreadKey: nextByThread }; |
| ... | ... | @@ -216,6 +219,13 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ |
| 216 | 219 | }); |
| 217 | 220 | return { byThreadKey: nextByThread }; |
| 218 | 221 | }), |
| 222 | removeSession: (ref, tabId) => | |
| 223 | set((state) => { | |
| 224 | const threadKey = scopedThreadKey(ref); | |
| 225 | return { | |
| 226 | byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)), | |
| 227 | }; | |
| 228 | }), | |
| 219 | 229 | setActiveTab: (ref, tabId) => |
| 220 | 230 | set((state) => { |
| 221 | 231 | const threadKey = scopedThreadKey(ref); |
apps/web/src/rightPanelStore.test.ts+160-5| ... | ... | @@ -3,6 +3,7 @@ import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | migratePersistedRightPanelState, | |
| 6 | 7 | selectActiveRightPanel, |
| 7 | 8 | selectActiveRightPanelSurface, |
| 8 | 9 | selectActiveRightPanelKindWithUrl, |
| ... | ... | @@ -18,6 +19,60 @@ beforeEach(() => { |
| 18 | 19 | }); |
| 19 | 20 | |
| 20 | 21 | describe("rightPanelStore", () => { |
| 22 | it("drops the legacy singleton terminal surface during migration", () => { | |
| 23 | expect( | |
| 24 | migratePersistedRightPanelState({ | |
| 25 | byThreadKey: { | |
| 26 | "env-1:thread-A": { | |
| 27 | activeSurfaceId: "terminal", | |
| 28 | surfaces: [ | |
| 29 | { id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }, | |
| 30 | { id: "terminal", kind: "terminal" }, | |
| 31 | ], | |
| 32 | }, | |
| 33 | }, | |
| 34 | }), | |
| 35 | ).toEqual({ | |
| 36 | byThreadKey: { | |
| 37 | "env-1:thread-A": { | |
| 38 | isOpen: false, | |
| 39 | activeSurfaceId: null, | |
| 40 | surfaces: [{ id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }], | |
| 41 | }, | |
| 42 | }, | |
| 43 | }); | |
| 44 | }); | |
| 45 | ||
| 46 | it("upgrades saved single-session terminal surfaces to split-capable surfaces", () => { | |
| 47 | expect( | |
| 48 | migratePersistedRightPanelState({ | |
| 49 | byThreadKey: { | |
| 50 | "env-1:thread-A": { | |
| 51 | isOpen: true, | |
| 52 | activeSurfaceId: "terminal:term-1", | |
| 53 | surfaces: [{ id: "terminal:term-1", kind: "terminal", resourceId: "term-1" }], | |
| 54 | }, | |
| 55 | }, | |
| 56 | }), | |
| 57 | ).toEqual({ | |
| 58 | byThreadKey: { | |
| 59 | "env-1:thread-A": { | |
| 60 | isOpen: true, | |
| 61 | activeSurfaceId: "terminal:term-1", | |
| 62 | surfaces: [ | |
| 63 | { | |
| 64 | id: "terminal:term-1", | |
| 65 | kind: "terminal", | |
| 66 | resourceId: "term-1", | |
| 67 | terminalIds: ["term-1"], | |
| 68 | activeTerminalId: "term-1", | |
| 69 | }, | |
| 70 | ], | |
| 71 | }, | |
| 72 | }, | |
| 73 | }); | |
| 74 | }); | |
| 75 | ||
| 21 | 76 | it("open sets the active panel for a thread", () => { |
| 22 | 77 | useRightPanelStore.getState().open(refA, "preview"); |
| 23 | 78 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); |
| ... | ... | @@ -33,10 +88,27 @@ describe("rightPanelStore", () => { |
| 33 | 88 | ).toHaveLength(2); |
| 34 | 89 | }); |
| 35 | 90 | |
| 36 | it("close clears the active panel", () => { | |
| 91 | it("close hides the panel without clearing its selected surface", () => { | |
| 37 | 92 | useRightPanelStore.getState().open(refA, "plan"); |
| 38 | 93 | useRightPanelStore.getState().close(refA); |
| 39 | 94 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); |
| 95 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | |
| 96 | isOpen: false, | |
| 97 | activeSurfaceId: "plan", | |
| 98 | surfaces: [{ id: "plan", kind: "plan" }], | |
| 99 | }); | |
| 100 | }); | |
| 101 | ||
| 102 | it("toggles empty panel visibility without creating a surface", () => { | |
| 103 | useRightPanelStore.getState().toggleVisibility(refA); | |
| 104 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | |
| 105 | isOpen: true, | |
| 106 | activeSurfaceId: null, | |
| 107 | surfaces: [], | |
| 108 | }); | |
| 109 | ||
| 110 | useRightPanelStore.getState().toggleVisibility(refA); | |
| 111 | expect(useRightPanelStore.getState().byThreadKey).toEqual({}); | |
| 40 | 112 | }); |
| 41 | 113 | |
| 42 | 114 | it("toggle opens then closes the same kind", () => { |
| ... | ... | @@ -86,18 +158,101 @@ describe("rightPanelStore", () => { |
| 86 | 158 | }); |
| 87 | 159 | }); |
| 88 | 160 | |
| 161 | it("tracks one surface per terminal session", () => { | |
| 162 | useRightPanelStore.getState().openTerminal(refA, "term-1"); | |
| 163 | useRightPanelStore.getState().openTerminal(refA, "term-2"); | |
| 164 | ||
| 165 | const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); | |
| 166 | expect(state.surfaces).toEqual([ | |
| 167 | { | |
| 168 | id: "terminal:term-1", | |
| 169 | kind: "terminal", | |
| 170 | resourceId: "term-1", | |
| 171 | terminalIds: ["term-1"], | |
| 172 | activeTerminalId: "term-1", | |
| 173 | }, | |
| 174 | { | |
| 175 | id: "terminal:term-2", | |
| 176 | kind: "terminal", | |
| 177 | resourceId: "term-2", | |
| 178 | terminalIds: ["term-2"], | |
| 179 | activeTerminalId: "term-2", | |
| 180 | }, | |
| 181 | ]); | |
| 182 | expect(state.activeSurfaceId).toBe("terminal:term-2"); | |
| 183 | }); | |
| 184 | ||
| 185 | it("tracks split panes and the active pane within a terminal surface", () => { | |
| 186 | useRightPanelStore.getState().openTerminal(refA, "term-1"); | |
| 187 | useRightPanelStore.getState().splitTerminal(refA, "terminal:term-1", "term-2"); | |
| 188 | ||
| 189 | expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | |
| 190 | id: "terminal:term-1", | |
| 191 | kind: "terminal", | |
| 192 | resourceId: "term-1", | |
| 193 | terminalIds: ["term-1", "term-2"], | |
| 194 | activeTerminalId: "term-2", | |
| 195 | }); | |
| 196 | ||
| 197 | useRightPanelStore.getState().activateTerminal(refA, "terminal:term-1", "term-1"); | |
| 198 | useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1"); | |
| 199 | expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | |
| 200 | id: "terminal:term-1", | |
| 201 | kind: "terminal", | |
| 202 | resourceId: "term-1", | |
| 203 | terminalIds: ["term-2"], | |
| 204 | activeTerminalId: "term-2", | |
| 205 | }); | |
| 206 | }); | |
| 207 | ||
| 208 | it("tracks vertical layout for a terminal surface", () => { | |
| 209 | useRightPanelStore.getState().openTerminal(refA, "term-1"); | |
| 210 | useRightPanelStore.getState().splitTerminal(refA, "terminal:term-1", "term-2", "vertical"); | |
| 211 | ||
| 212 | expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | |
| 213 | id: "terminal:term-1", | |
| 214 | kind: "terminal", | |
| 215 | resourceId: "term-1", | |
| 216 | terminalIds: ["term-1", "term-2"], | |
| 217 | activeTerminalId: "term-2", | |
| 218 | splitDirection: "vertical", | |
| 219 | }); | |
| 220 | }); | |
| 221 | ||
| 222 | it("closing the final terminal pane removes its surface but keeps the panel open", () => { | |
| 223 | useRightPanelStore.getState().openTerminal(refA, "term-1"); | |
| 224 | useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1"); | |
| 225 | ||
| 226 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | |
| 227 | isOpen: true, | |
| 228 | activeSurfaceId: null, | |
| 229 | surfaces: [], | |
| 230 | }); | |
| 231 | }); | |
| 232 | ||
| 89 | 233 | it("closing the active surface activates a neighboring surface", () => { |
| 90 | 234 | useRightPanelStore.getState().openBrowser(refA, "tab-a"); |
| 91 | useRightPanelStore.getState().open(refA, "terminal"); | |
| 92 | useRightPanelStore.getState().closeSurface(refA, "terminal"); | |
| 235 | useRightPanelStore.getState().openTerminal(refA, "term-1"); | |
| 236 | useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); | |
| 93 | 237 | |
| 94 | 238 | expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe( |
| 95 | 239 | "browser:tab-a", |
| 96 | 240 | ); |
| 97 | 241 | }); |
| 98 | 242 | |
| 243 | it("closing the final surface leaves the panel open and empty", () => { | |
| 244 | useRightPanelStore.getState().openTerminal(refA, "term-1"); | |
| 245 | useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); | |
| 246 | ||
| 247 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | |
| 248 | isOpen: true, | |
| 249 | activeSurfaceId: null, | |
| 250 | surfaces: [], | |
| 251 | }); | |
| 252 | }); | |
| 253 | ||
| 99 | 254 | it("reconciles browser surfaces without deleting other surface kinds", () => { |
| 100 | useRightPanelStore.getState().open(refA, "terminal"); | |
| 255 | useRightPanelStore.getState().openTerminal(refA, "term-1"); | |
| 101 | 256 | useRightPanelStore.getState().openBrowser(refA, "tab-a"); |
| 102 | 257 | useRightPanelStore.getState().openBrowser(refA, "tab-b"); |
| 103 | 258 | useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-b", "tab-c"]); |
| ... | ... | @@ -106,6 +261,6 @@ describe("rightPanelStore", () => { |
| 106 | 261 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( |
| 107 | 262 | (surface) => surface.id, |
| 108 | 263 | ), |
| 109 | ).toEqual(["terminal", "browser:tab-b", "browser:tab-c"]); | |
| 264 | ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]); | |
| 110 | 265 | }); |
| 111 | 266 | }); |
apps/web/src/rightPanelStore.ts+214-15| ... | ... | @@ -3,8 +3,9 @@ |
| 3 | 3 | * |
| 4 | 4 | * This is intentionally a shallow workspace model: it owns an ordered set of |
| 5 | 5 | * surface descriptors and the active surface, while each feature continues to |
| 6 | * own its durable resource state. Browser surfaces point at preview tab ids; | |
| 7 | * singleton surfaces bridge the existing terminal, diff, and plan features. | |
| 6 | * own its durable resource state. Browser surfaces point at preview tab ids, | |
| 7 | * terminal surfaces point at terminal session ids, and diff/plan remain | |
| 8 | * singleton surfaces. | |
| 8 | 9 | */ |
| 9 | 10 | import { scopedThreadKey } from "@t3tools/client-runtime"; |
| 10 | 11 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| ... | ... | @@ -19,35 +20,59 @@ export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; |
| 19 | 20 | export type RightPanelSurface = |
| 20 | 21 | | { id: `browser:${string}`; kind: "preview"; resourceId: string } |
| 21 | 22 | | { id: "browser:new"; kind: "preview"; resourceId: null } |
| 22 | | { id: "terminal"; kind: "terminal" } | |
| 23 | | { | |
| 24 | id: `terminal:${string}`; | |
| 25 | kind: "terminal"; | |
| 26 | resourceId: string; | |
| 27 | terminalIds: string[]; | |
| 28 | activeTerminalId: string; | |
| 29 | splitDirection?: "horizontal" | "vertical"; | |
| 30 | } | |
| 23 | 31 | | { id: "diff"; kind: "diff" } |
| 24 | 32 | | { id: "plan"; kind: "plan" }; |
| 25 | 33 | |
| 26 | 34 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; |
| 35 | const RIGHT_PANEL_STORAGE_VERSION = 5; | |
| 27 | 36 | |
| 28 | 37 | export interface ThreadRightPanelState { |
| 38 | isOpen: boolean; | |
| 29 | 39 | activeSurfaceId: string | null; |
| 30 | 40 | surfaces: RightPanelSurface[]; |
| 31 | 41 | } |
| 32 | 42 | |
| 33 | 43 | interface RightPanelStoreState { |
| 34 | 44 | byThreadKey: Record<string, ThreadRightPanelState>; |
| 35 | open: (ref: ScopedThreadRef, kind: RightPanelKind) => void; | |
| 45 | open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void; | |
| 36 | 46 | openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; |
| 47 | openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; | |
| 48 | splitTerminal: ( | |
| 49 | ref: ScopedThreadRef, | |
| 50 | surfaceId: string, | |
| 51 | terminalId: string, | |
| 52 | direction?: "horizontal" | "vertical", | |
| 53 | ) => void; | |
| 54 | activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; | |
| 55 | closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; | |
| 37 | 56 | activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; |
| 38 | 57 | closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; |
| 39 | 58 | reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; |
| 59 | show: (ref: ScopedThreadRef) => void; | |
| 40 | 60 | close: (ref: ScopedThreadRef) => void; |
| 41 | toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void; | |
| 61 | toggleVisibility: (ref: ScopedThreadRef) => void; | |
| 62 | toggle: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void; | |
| 42 | 63 | removeThread: (ref: ScopedThreadRef) => void; |
| 43 | 64 | } |
| 44 | 65 | |
| 45 | const EMPTY_THREAD_STATE: ThreadRightPanelState = { activeSurfaceId: null, surfaces: [] }; | |
| 66 | const EMPTY_THREAD_STATE: ThreadRightPanelState = { | |
| 67 | isOpen: false, | |
| 68 | activeSurfaceId: null, | |
| 69 | surfaces: [], | |
| 70 | }; | |
| 46 | 71 | |
| 47 | const singletonSurface = (kind: Exclude<RightPanelKind, "preview">): RightPanelSurface => { | |
| 72 | const singletonSurface = ( | |
| 73 | kind: Exclude<RightPanelKind, "preview" | "terminal">, | |
| 74 | ): RightPanelSurface => { | |
| 48 | 75 | switch (kind) { |
| 49 | case "terminal": | |
| 50 | return { id: "terminal", kind }; | |
| 51 | 76 | case "diff": |
| 52 | 77 | return { id: "diff", kind }; |
| 53 | 78 | case "plan": |
| ... | ... | @@ -60,11 +85,20 @@ const browserSurface = (tabId: string | null): RightPanelSurface => |
| 60 | 85 | ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } |
| 61 | 86 | : { id: "browser:new", kind: "preview", resourceId: null }; |
| 62 | 87 | |
| 88 | const terminalSurface = (terminalId: string): RightPanelSurface => ({ | |
| 89 | id: `terminal:${terminalId}`, | |
| 90 | kind: "terminal", | |
| 91 | resourceId: terminalId, | |
| 92 | terminalIds: [terminalId], | |
| 93 | activeTerminalId: terminalId, | |
| 94 | }); | |
| 95 | ||
| 63 | 96 | const upsertSurface = ( |
| 64 | 97 | current: ThreadRightPanelState, |
| 65 | 98 | surface: RightPanelSurface, |
| 66 | 99 | activate = true, |
| 67 | 100 | ): ThreadRightPanelState => ({ |
| 101 | isOpen: true, | |
| 68 | 102 | surfaces: current.surfaces.some((entry) => entry.id === surface.id) |
| 69 | 103 | ? current.surfaces |
| 70 | 104 | : [...current.surfaces, surface], |
| ... | ... | @@ -78,7 +112,7 @@ const updateThread = ( |
| 78 | 112 | ): Record<string, ThreadRightPanelState> => { |
| 79 | 113 | const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; |
| 80 | 114 | const next = updater(current); |
| 81 | if (next.activeSurfaceId === null && next.surfaces.length === 0) { | |
| 115 | if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) { | |
| 82 | 116 | if (!(threadKey in byThreadKey)) return byThreadKey; |
| 83 | 117 | const { [threadKey]: _removed, ...rest } = byThreadKey; |
| 84 | 118 | return rest; |
| ... | ... | @@ -87,6 +121,74 @@ const updateThread = ( |
| 87 | 121 | return { ...byThreadKey, [threadKey]: next }; |
| 88 | 122 | }; |
| 89 | 123 | |
| 124 | export function migratePersistedRightPanelState(persistedState: unknown): { | |
| 125 | byThreadKey: Record<string, ThreadRightPanelState>; | |
| 126 | } { | |
| 127 | if (!persistedState || typeof persistedState !== "object") { | |
| 128 | return { byThreadKey: {} }; | |
| 129 | } | |
| 130 | const byThreadKey = | |
| 131 | "byThreadKey" in persistedState && | |
| 132 | persistedState.byThreadKey && | |
| 133 | typeof persistedState.byThreadKey === "object" | |
| 134 | ? Object.fromEntries( | |
| 135 | Object.entries(persistedState.byThreadKey as Record<string, ThreadRightPanelState>).map( | |
| 136 | ([threadKey, threadState]) => { | |
| 137 | const validThreadState = | |
| 138 | threadState && typeof threadState === "object" ? threadState : null; | |
| 139 | const surfaces = Array.isArray(validThreadState?.surfaces) | |
| 140 | ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => { | |
| 141 | if (surface.kind !== "terminal") return [surface]; | |
| 142 | if ( | |
| 143 | !("resourceId" in surface) || | |
| 144 | typeof surface.resourceId !== "string" || | |
| 145 | surface.id !== `terminal:${surface.resourceId}` | |
| 146 | ) { | |
| 147 | return []; | |
| 148 | } | |
| 149 | const terminalIds = | |
| 150 | "terminalIds" in surface && Array.isArray(surface.terminalIds) | |
| 151 | ? [ | |
| 152 | ...new Set( | |
| 153 | surface.terminalIds.filter( | |
| 154 | (terminalId): terminalId is string => | |
| 155 | typeof terminalId === "string", | |
| 156 | ), | |
| 157 | ), | |
| 158 | ] | |
| 159 | : [surface.resourceId]; | |
| 160 | const activeTerminalId = | |
| 161 | "activeTerminalId" in surface && | |
| 162 | typeof surface.activeTerminalId === "string" && | |
| 163 | terminalIds.includes(surface.activeTerminalId) | |
| 164 | ? surface.activeTerminalId | |
| 165 | : (terminalIds[0] ?? surface.resourceId); | |
| 166 | return [ | |
| 167 | { | |
| 168 | ...surface, | |
| 169 | terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId], | |
| 170 | activeTerminalId, | |
| 171 | }, | |
| 172 | ]; | |
| 173 | }) | |
| 174 | : []; | |
| 175 | const activeSurfaceId = surfaces.some( | |
| 176 | (surface) => surface.id === validThreadState?.activeSurfaceId, | |
| 177 | ) | |
| 178 | ? (validThreadState?.activeSurfaceId ?? null) | |
| 179 | : null; | |
| 180 | const isOpen = | |
| 181 | typeof validThreadState?.isOpen === "boolean" | |
| 182 | ? validThreadState.isOpen | |
| 183 | : activeSurfaceId !== null; | |
| 184 | return [threadKey, { isOpen, surfaces, activeSurfaceId }]; | |
| 185 | }, | |
| 186 | ), | |
| 187 | ) | |
| 188 | : {}; | |
| 189 | return { byThreadKey }; | |
| 190 | } | |
| 191 | ||
| 90 | 192 | export const useRightPanelStore = create<RightPanelStoreState>()( |
| 91 | 193 | persist( |
| 92 | 194 | (set) => ({ |
| ... | ... | @@ -111,11 +213,89 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 111 | 213 | return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); |
| 112 | 214 | }), |
| 113 | 215 | })), |
| 216 | openTerminal: (ref, terminalId) => | |
| 217 | set((state) => ({ | |
| 218 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => | |
| 219 | upsertSurface(current, terminalSurface(terminalId)), | |
| 220 | ), | |
| 221 | })), | |
| 222 | splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") => | |
| 223 | set((state) => ({ | |
| 224 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ | |
| 225 | ...current, | |
| 226 | isOpen: true, | |
| 227 | activeSurfaceId: surfaceId, | |
| 228 | surfaces: current.surfaces.map((surface) => { | |
| 229 | if (surface.id !== surfaceId || surface.kind !== "terminal") return surface; | |
| 230 | const { splitDirection: _splitDirection, ...baseSurface } = surface; | |
| 231 | return { | |
| 232 | ...baseSurface, | |
| 233 | terminalIds: surface.terminalIds.includes(terminalId) | |
| 234 | ? surface.terminalIds | |
| 235 | : [...surface.terminalIds, terminalId], | |
| 236 | activeTerminalId: terminalId, | |
| 237 | ...(direction === "vertical" ? { splitDirection: "vertical" as const } : {}), | |
| 238 | }; | |
| 239 | }), | |
| 240 | })), | |
| 241 | })), | |
| 242 | activateTerminal: (ref, surfaceId, terminalId) => | |
| 243 | set((state) => ({ | |
| 244 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ | |
| 245 | ...current, | |
| 246 | activeSurfaceId: surfaceId, | |
| 247 | surfaces: current.surfaces.map((surface) => | |
| 248 | surface.id === surfaceId && | |
| 249 | surface.kind === "terminal" && | |
| 250 | surface.terminalIds.includes(terminalId) | |
| 251 | ? { ...surface, activeTerminalId: terminalId } | |
| 252 | : surface, | |
| 253 | ), | |
| 254 | })), | |
| 255 | })), | |
| 256 | closeTerminal: (ref, surfaceId, terminalId) => | |
| 257 | set((state) => ({ | |
| 258 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { | |
| 259 | const surface = current.surfaces.find( | |
| 260 | (entry) => entry.id === surfaceId && entry.kind === "terminal", | |
| 261 | ); | |
| 262 | if (!surface || surface.kind !== "terminal") return current; | |
| 263 | const terminalIds = surface.terminalIds.filter((id) => id !== terminalId); | |
| 264 | if (terminalIds.length === 0) { | |
| 265 | const index = current.surfaces.findIndex((entry) => entry.id === surfaceId); | |
| 266 | const surfaces = current.surfaces.filter((entry) => entry.id !== surfaceId); | |
| 267 | const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; | |
| 268 | return { | |
| 269 | ...current, | |
| 270 | surfaces, | |
| 271 | activeSurfaceId: | |
| 272 | current.activeSurfaceId === surfaceId | |
| 273 | ? (fallback?.id ?? null) | |
| 274 | : current.activeSurfaceId, | |
| 275 | }; | |
| 276 | } | |
| 277 | return { | |
| 278 | ...current, | |
| 279 | surfaces: current.surfaces.map((entry) => | |
| 280 | entry.id === surfaceId && entry.kind === "terminal" | |
| 281 | ? { | |
| 282 | ...entry, | |
| 283 | terminalIds, | |
| 284 | activeTerminalId: | |
| 285 | entry.activeTerminalId === terminalId | |
| 286 | ? (terminalIds.at(-1) ?? terminalIds[0]!) | |
| 287 | : entry.activeTerminalId, | |
| 288 | } | |
| 289 | : entry, | |
| 290 | ), | |
| 291 | }; | |
| 292 | }), | |
| 293 | })), | |
| 114 | 294 | activateSurface: (ref, surfaceId) => |
| 115 | 295 | set((state) => ({ |
| 116 | 296 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => |
| 117 | 297 | current.surfaces.some((surface) => surface.id === surfaceId) |
| 118 | ? { ...current, activeSurfaceId: surfaceId } | |
| 298 | ? { ...current, isOpen: true, activeSurfaceId: surfaceId } | |
| 119 | 299 | : current, |
| 120 | 300 | ), |
| 121 | 301 | })), |
| ... | ... | @@ -127,7 +307,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 127 | 307 | const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); |
| 128 | 308 | if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; |
| 129 | 309 | const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; |
| 130 | return { surfaces, activeSurfaceId: fallback?.id ?? null }; | |
| 310 | return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null }; | |
| 131 | 311 | }), |
| 132 | 312 | })), |
| 133 | 313 | reconcileBrowserSurfaces: (ref, tabIds) => |
| ... | ... | @@ -151,6 +331,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 151 | 331 | ); |
| 152 | 332 | const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview"); |
| 153 | 333 | return { |
| 334 | ...current, | |
| 154 | 335 | surfaces, |
| 155 | 336 | activeSurfaceId: activeStillExists |
| 156 | 337 | ? current.activeSurfaceId |
| ... | ... | @@ -158,11 +339,23 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 158 | 339 | }; |
| 159 | 340 | }), |
| 160 | 341 | })), |
| 342 | show: (ref) => | |
| 343 | set((state) => ({ | |
| 344 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => | |
| 345 | current.isOpen ? current : { ...current, isOpen: true }, | |
| 346 | ), | |
| 347 | })), | |
| 161 | 348 | close: (ref) => |
| 349 | set((state) => ({ | |
| 350 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => | |
| 351 | current.isOpen ? { ...current, isOpen: false } : current, | |
| 352 | ), | |
| 353 | })), | |
| 354 | toggleVisibility: (ref) => | |
| 162 | 355 | set((state) => ({ |
| 163 | 356 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ |
| 164 | 357 | ...current, |
| 165 | activeSurfaceId: null, | |
| 358 | isOpen: !current.isOpen, | |
| 166 | 359 | })), |
| 167 | 360 | })), |
| 168 | 361 | toggle: (ref, kind) => |
| ... | ... | @@ -171,7 +364,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 171 | 364 | const active = current.surfaces.find( |
| 172 | 365 | (surface) => surface.id === current.activeSurfaceId, |
| 173 | 366 | ); |
| 174 | if (active?.kind === kind) return { ...current, activeSurfaceId: null }; | |
| 367 | if (current.isOpen && active?.kind === kind) { | |
| 368 | return { ...current, isOpen: false }; | |
| 369 | } | |
| 175 | 370 | if (kind === "preview") { |
| 176 | 371 | const existing = current.surfaces.find((surface) => surface.kind === "preview"); |
| 177 | 372 | return upsertSurface(current, existing ?? browserSurface(null)); |
| ... | ... | @@ -189,11 +384,12 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 189 | 384 | }), |
| 190 | 385 | { |
| 191 | 386 | name: RIGHT_PANEL_STORAGE_KEY, |
| 192 | version: 2, | |
| 387 | version: RIGHT_PANEL_STORAGE_VERSION, | |
| 193 | 388 | storage: createJSONStorage(() => |
| 194 | 389 | resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), |
| 195 | 390 | ), |
| 196 | 391 | partialize: (state) => ({ byThreadKey: state.byThreadKey }), |
| 392 | migrate: migratePersistedRightPanelState, | |
| 197 | 393 | }, |
| 198 | 394 | ), |
| 199 | 395 | ); |
| ... | ... | @@ -211,6 +407,7 @@ export function selectActiveRightPanel( |
| 211 | 407 | ref: ScopedThreadRef | null | undefined, |
| 212 | 408 | ): RightPanelKind | null { |
| 213 | 409 | const state = selectThreadRightPanelState(byThreadKey, ref); |
| 410 | if (!state.isOpen) return null; | |
| 214 | 411 | return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null; |
| 215 | 412 | } |
| 216 | 413 | |
| ... | ... | @@ -219,6 +416,7 @@ export function selectActiveRightPanelSurface( |
| 219 | 416 | ref: ScopedThreadRef | null | undefined, |
| 220 | 417 | ): RightPanelSurface | null { |
| 221 | 418 | const state = selectThreadRightPanelState(byThreadKey, ref); |
| 419 | if (!state.isOpen) return null; | |
| 222 | 420 | return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; |
| 223 | 421 | } |
| 224 | 422 | |
| ... | ... | @@ -227,6 +425,7 @@ export function selectActiveRightPanelKindWithUrl( |
| 227 | 425 | ref: ScopedThreadRef | null | undefined, |
| 228 | 426 | diffSearchActive: boolean, |
| 229 | 427 | ): RightPanelKind | null { |
| 428 | if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null; | |
| 230 | 429 | if (diffSearchActive) return "diff"; |
| 231 | 430 | return selectActiveRightPanel(byThreadKey, ref); |
| 232 | 431 | } |
apps/web/src/rpc/requestLatencyState.test.ts+8| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | |
| 1 | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 3 | |
| 3 | 4 | import { |
| ... | ... | @@ -50,6 +51,13 @@ describe("requestLatencyState", () => { |
| 50 | 51 | expect(getSlowRpcAckRequests()).toEqual([]); |
| 51 | 52 | }); |
| 52 | 53 | |
| 54 | it("ignores the long-lived preview automation connection", () => { | |
| 55 | trackRpcRequestSent("1", WS_METHODS.previewAutomationConnect); | |
| 56 | vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); | |
| 57 | ||
| 58 | expect(getSlowRpcAckRequests()).toEqual([]); | |
| 59 | }); | |
| 60 | ||
| 53 | 61 | it("evicts the oldest pending requests once the tracker reaches capacity", () => { |
| 54 | 62 | for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) { |
| 55 | 63 | trackRpcRequestSent(String(index), "server.getConfig"); |
apps/web/src/rpc/requestLatencyState.ts+3-1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { WS_METHODS } from "@t3tools/contracts"; | |
| 2 | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 4 | |
| 4 | 5 | import { appAtomRegistry } from "./atomRegistry"; |
| ... | ... | @@ -21,6 +22,7 @@ interface PendingRpcAckRequest { |
| 21 | 22 | } |
| 22 | 23 | |
| 23 | 24 | const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>(); |
| 25 | const untrackedRpcAckTags = new Set<string>([WS_METHODS.previewAutomationConnect]); | |
| 24 | 26 | |
| 25 | 27 | const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe( |
| 26 | 28 | Atom.keepAlive, |
| ... | ... | @@ -36,7 +38,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> { |
| 36 | 38 | } |
| 37 | 39 | |
| 38 | 40 | function shouldTrackRpcAck(tag: string): boolean { |
| 39 | return !tag.includes("subscribe"); | |
| 41 | return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag); | |
| 40 | 42 | } |
| 41 | 43 | |
| 42 | 44 | export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> { |
apps/web/src/terminalUiStateStore.test.ts+18| ... | ... | @@ -56,6 +56,24 @@ describe("terminalUiStateStore actions", () => { |
| 56 | 56 | ]); |
| 57 | 57 | }); |
| 58 | 58 | |
| 59 | it("stacks vertically split terminals in the active group", () => { | |
| 60 | const store = useTerminalUiStateStore.getState(); | |
| 61 | store.setTerminalOpen(THREAD_REF, true); | |
| 62 | store.splitTerminalVertical(THREAD_REF, "terminal-2"); | |
| 63 | ||
| 64 | const terminalUiState = selectThreadTerminalUiState( | |
| 65 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | |
| 66 | THREAD_REF, | |
| 67 | ); | |
| 68 | expect(terminalUiState.terminalGroups).toEqual([ | |
| 69 | { | |
| 70 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | |
| 71 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "terminal-2"], | |
| 72 | splitDirection: "vertical", | |
| 73 | }, | |
| 74 | ]); | |
| 75 | }); | |
| 76 | ||
| 59 | 77 | it("materializes the default terminal when opening an empty drawer", () => { |
| 60 | 78 | useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true); |
| 61 | 79 |
apps/web/src/terminalUiStateStore.ts+18-1| ... | ... | @@ -126,6 +126,7 @@ function normalizeTerminalGroups( |
| 126 | 126 | nextGroups.push({ |
| 127 | 127 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), |
| 128 | 128 | terminalIds: groupTerminalIds, |
| 129 | ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), | |
| 129 | 130 | }); |
| 130 | 131 | } |
| 131 | 132 | |
| ... | ... | @@ -155,6 +156,11 @@ function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalG |
| 155 | 156 | const rightGroup = right[index]; |
| 156 | 157 | if (!leftGroup || !rightGroup) return false; |
| 157 | 158 | if (leftGroup.id !== rightGroup.id) return false; |
| 159 | if ( | |
| 160 | (leftGroup.splitDirection ?? "horizontal") !== (rightGroup.splitDirection ?? "horizontal") | |
| 161 | ) { | |
| 162 | return false; | |
| 163 | } | |
| 158 | 164 | if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; |
| 159 | 165 | } |
| 160 | 166 | return true; |
| ... | ... | @@ -241,6 +247,7 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ |
| 241 | 247 | return groups.map((group) => ({ |
| 242 | 248 | id: group.id, |
| 243 | 249 | terminalIds: [...group.terminalIds], |
| 250 | ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), | |
| 244 | 251 | })); |
| 245 | 252 | } |
| 246 | 253 | |
| ... | ... | @@ -248,6 +255,7 @@ function upsertTerminalIntoGroups( |
| 248 | 255 | state: ThreadTerminalUiState, |
| 249 | 256 | terminalId: string, |
| 250 | 257 | mode: "split" | "new", |
| 258 | splitDirection: "horizontal" | "vertical" = "horizontal", | |
| 251 | 259 | ): ThreadTerminalUiState { |
| 252 | 260 | const normalized = normalizeThreadTerminalUiState(state); |
| 253 | 261 | const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; |
| ... | ... | @@ -323,6 +331,11 @@ function upsertTerminalIntoGroups( |
| 323 | 331 | destinationGroup.terminalIds.push(terminalId); |
| 324 | 332 | } |
| 325 | 333 | } |
| 334 | if (splitDirection === "vertical") { | |
| 335 | destinationGroup.splitDirection = "vertical"; | |
| 336 | } else { | |
| 337 | delete destinationGroup.splitDirection; | |
| 338 | } | |
| 326 | 339 | |
| 327 | 340 | return normalizeThreadTerminalUiState({ |
| 328 | 341 | ...normalized, |
| ... | ... | @@ -357,8 +370,9 @@ function setThreadTerminalHeight( |
| 357 | 370 | function splitThreadTerminal( |
| 358 | 371 | state: ThreadTerminalUiState, |
| 359 | 372 | terminalId: string, |
| 373 | direction: "horizontal" | "vertical" = "horizontal", | |
| 360 | 374 | ): ThreadTerminalUiState { |
| 361 | return upsertTerminalIntoGroups(state, terminalId, "split"); | |
| 375 | return upsertTerminalIntoGroups(state, terminalId, "split", direction); | |
| 362 | 376 | } |
| 363 | 377 | |
| 364 | 378 | function newThreadTerminal( |
| ... | ... | @@ -510,6 +524,7 @@ interface TerminalUiStateStoreState { |
| 510 | 524 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; |
| 511 | 525 | setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; |
| 512 | 526 | splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 527 | splitTerminalVertical: (threadRef: ScopedThreadRef, terminalId: string) => void; | |
| 513 | 528 | newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 514 | 529 | ensureTerminal: ( |
| 515 | 530 | threadRef: ScopedThreadRef, |
| ... | ... | @@ -554,6 +569,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 554 | 569 | updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), |
| 555 | 570 | splitTerminal: (threadRef, terminalId) => |
| 556 | 571 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), |
| 572 | splitTerminalVertical: (threadRef, terminalId) => | |
| 573 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical")), | |
| 557 | 574 | newTerminal: (threadRef, terminalId) => |
| 558 | 575 | updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), |
| 559 | 576 | ensureTerminal: (threadRef, terminalId, options) => |
apps/web/src/types.ts+1| ... | ... | @@ -30,6 +30,7 @@ export type ProjectScript = ContractProjectScript; |
| 30 | 30 | export interface ThreadTerminalGroup { |
| 31 | 31 | id: string; |
| 32 | 32 | terminalIds: string[]; |
| 33 | splitDirection?: "horizontal" | "vertical"; | |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | export interface ChatImageAttachment { |
packages/contracts/src/ipc.ts+33| ... | ... | @@ -455,6 +455,15 @@ export interface DesktopPreviewTabState { |
| 455 | 455 | updatedAt: string; |
| 456 | 456 | } |
| 457 | 457 | |
| 458 | export interface DesktopPreviewPointerEvent { | |
| 459 | tabId: string; | |
| 460 | phase: "move" | "click"; | |
| 461 | x: number; | |
| 462 | y: number; | |
| 463 | sequence: number; | |
| 464 | createdAt: string; | |
| 465 | } | |
| 466 | ||
| 458 | 467 | /** |
| 459 | 468 | * Static config a renderer needs to mount a preview `<webview>`. Returned |
| 460 | 469 | * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer |
| ... | ... | @@ -478,6 +487,26 @@ export interface DesktopPreviewWebviewConfig { |
| 478 | 487 | preloadUrl: string | null; |
| 479 | 488 | } |
| 480 | 489 | |
| 490 | export interface DesktopPreviewAnnotationTheme { | |
| 491 | colorScheme: "light" | "dark"; | |
| 492 | radius: string; | |
| 493 | background: string; | |
| 494 | foreground: string; | |
| 495 | popover: string; | |
| 496 | popoverForeground: string; | |
| 497 | primary: string; | |
| 498 | primaryForeground: string; | |
| 499 | muted: string; | |
| 500 | mutedForeground: string; | |
| 501 | accent: string; | |
| 502 | accentForeground: string; | |
| 503 | border: string; | |
| 504 | input: string; | |
| 505 | ring: string; | |
| 506 | fontSans: string; | |
| 507 | fontMono: string; | |
| 508 | } | |
| 509 | ||
| 481 | 510 | export interface DesktopPreviewRecordingFrame { |
| 482 | 511 | tabId: string; |
| 483 | 512 | data: string; |
| ... | ... | @@ -700,6 +729,7 @@ export interface DesktopPreviewBridge { |
| 700 | 729 | * the contract + main, not the renderer's mount logic. |
| 701 | 730 | */ |
| 702 | 731 | getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>; |
| 732 | setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>; | |
| 703 | 733 | /** |
| 704 | 734 | * Activate the in-page element picker for the given tab. Resolves with |
| 705 | 735 | * the picked payload, or `null` when the user cancels (Escape / nav). The |
| ... | ... | @@ -709,6 +739,8 @@ export interface DesktopPreviewBridge { |
| 709 | 739 | /** Cancel an in-flight preview annotation session. */ |
| 710 | 740 | cancelPickElement: (tabId: string) => Promise<void>; |
| 711 | 741 | captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; |
| 742 | revealArtifact: (path: string) => Promise<void>; | |
| 743 | copyArtifactToClipboard: (path: string) => Promise<void>; | |
| 712 | 744 | recording: { |
| 713 | 745 | startScreencast: (tabId: string) => Promise<void>; |
| 714 | 746 | stopScreencast: (tabId: string) => Promise<void>; |
| ... | ... | @@ -730,6 +762,7 @@ export interface DesktopPreviewBridge { |
| 730 | 762 | waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>; |
| 731 | 763 | }; |
| 732 | 764 | onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; |
| 765 | onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void; | |
| 733 | 766 | } |
| 734 | 767 | |
| 735 | 768 | /** |
packages/contracts/src/keybindings.test.ts+8-1| ... | ... | @@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () => |
| 29 | 29 | }); |
| 30 | 30 | assert.strictEqual(parsed.command, "terminal.toggle"); |
| 31 | 31 | |
| 32 | const parsedRightPanelToggle = yield* decode(KeybindingRule, { | |
| 33 | key: "mod+alt+b", | |
| 34 | command: "rightPanel.toggle", | |
| 35 | }); | |
| 36 | assert.strictEqual(parsedRightPanelToggle.command, "rightPanel.toggle"); | |
| 37 | ||
| 32 | 38 | const parsedClose = yield* decode(KeybindingRule, { |
| 33 | 39 | key: "mod+w", |
| 34 | 40 | command: "terminal.close", |
| ... | ... | @@ -100,8 +106,9 @@ it.effect("parses keybindings array payload", () => |
| 100 | 106 | const parsed = yield* decode(KeybindingsConfig, [ |
| 101 | 107 | { key: "mod+j", command: "terminal.toggle" }, |
| 102 | 108 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 109 | { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, | |
| 103 | 110 | ]); |
| 104 | assert.lengthOf(parsed, 2); | |
| 111 | assert.lengthOf(parsed, 3); | |
| 105 | 112 | }), |
| 106 | 113 | ); |
| 107 | 114 |
packages/contracts/src/keybindings.ts+2| ... | ... | @@ -50,8 +50,10 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA |
| 50 | 50 | const STATIC_KEYBINDING_COMMANDS = [ |
| 51 | 51 | "terminal.toggle", |
| 52 | 52 | "terminal.split", |
| 53 | "terminal.splitVertical", | |
| 53 | 54 | "terminal.new", |
| 54 | 55 | "terminal.close", |
| 56 | "rightPanel.toggle", | |
| 55 | 57 | "diff.toggle", |
| 56 | 58 | "preview.toggle", |
| 57 | 59 | "preview.refresh", |
packages/contracts/src/preview.test.ts+4| ... | ... | @@ -123,6 +123,7 @@ describe("DiscoveredLocalServer", () => { |
| 123 | 123 | url: "http://localhost:5173", |
| 124 | 124 | processName: "node", |
| 125 | 125 | pid: 12345, |
| 126 | terminal: null, | |
| 126 | 127 | }); |
| 127 | 128 | expect(server.port).toBe(5173); |
| 128 | 129 | expect(server.processName).toBe("node"); |
| ... | ... | @@ -135,6 +136,7 @@ describe("DiscoveredLocalServer", () => { |
| 135 | 136 | url: "http://localhost:3000", |
| 136 | 137 | processName: null, |
| 137 | 138 | pid: null, |
| 139 | terminal: null, | |
| 138 | 140 | }); |
| 139 | 141 | expect(server.processName).toBeNull(); |
| 140 | 142 | }); |
| ... | ... | @@ -147,6 +149,7 @@ describe("DiscoveredLocalServer", () => { |
| 147 | 149 | url: "http://localhost:0", |
| 148 | 150 | processName: null, |
| 149 | 151 | pid: null, |
| 152 | terminal: null, | |
| 150 | 153 | }), |
| 151 | 154 | ).toThrow(); |
| 152 | 155 | expect(() => |
| ... | ... | @@ -156,6 +159,7 @@ describe("DiscoveredLocalServer", () => { |
| 156 | 159 | url: "http://localhost:70000", |
| 157 | 160 | processName: null, |
| 158 | 161 | pid: null, |
| 162 | terminal: null, | |
| 159 | 163 | }), |
| 160 | 164 | ).toThrow(); |
| 161 | 165 | }); |
packages/contracts/src/preview.ts+6| ... | ... | @@ -142,6 +142,12 @@ export const DiscoveredLocalServer = Schema.Struct({ |
| 142 | 142 | url: Url, |
| 143 | 143 | processName: Schema.NullOr(TrimmedNonEmptyString), |
| 144 | 144 | pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), |
| 145 | terminal: Schema.NullOr( | |
| 146 | Schema.Struct({ | |
| 147 | threadId: ThreadId, | |
| 148 | terminalId: TrimmedNonEmptyString, | |
| 149 | }), | |
| 150 | ), | |
| 145 | 151 | }); |
| 146 | 152 | export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; |
| 147 | 153 |
packages/shared/src/keybindings.ts+2| ... | ... | @@ -20,7 +20,9 @@ type WhenToken = |
| 20 | 20 | |
| 21 | 21 | export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 22 | 22 | { key: "mod+j", command: "terminal.toggle" }, |
| 23 | { key: "mod+alt+b", command: "rightPanel.toggle" }, | |
| 23 | 24 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 25 | { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, | |
| 24 | 26 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 25 | 27 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, |
| 26 | 28 | { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, |
pnpm-lock.yaml+3| ... | ... | @@ -147,6 +147,9 @@ importers: |
| 147 | 147 | electron-builder: |
| 148 | 148 | specifier: 26.8.1 |
| 149 | 149 | version: 26.8.1(electron-builder-squirrel-windows@26.8.1) |
| 150 | tailwindcss: | |
| 151 | specifier: ^4.0.0 | |
| 152 | version: 4.3.0 | |
| 150 | 153 | vite-plus: |
| 151 | 154 | specifier: 'catalog:' |
| 152 | 155 | version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |