| 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,6 +29,7 @@ |
| 29 | "@types/node": "catalog:", | 29 | "@types/node": "catalog:", |
| 30 | "cross-env": "^10.1.0", | 30 | "cross-env": "^10.1.0", |
| 31 | "electron-builder": "26.8.1", | 31 | "electron-builder": "26.8.1", |
| 32 | "tailwindcss": "^4.0.0", | ||
| 32 | "vite-plus": "catalog:" | 33 | "vite-plus": "catalog:" |
| 33 | }, | 34 | }, |
| 34 | "productName": "T3 Code (Alpha)" | 35 | "productName": "T3 Code (Alpha)" |
apps/desktop/scripts/build-preview-annotation-css.mjs created+40| ... | @@ -0,0 +1,40 @@ | ||
| 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,6 +59,7 @@ describe("DesktopEnvironment", () => { |
| 59 | assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); | 59 | assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); |
| 60 | assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); | 60 | assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); |
| 61 | assert.equal(environment.logDir, "/tmp/t3/dev/logs"); | 61 | assert.equal(environment.logDir, "/tmp/t3/dev/logs"); |
| 62 | assert.equal(environment.browserArtifactsDir, "/tmp/t3/dev/browser-artifacts"); | ||
| 62 | assert.equal(environment.rootDir, "/repo"); | 63 | assert.equal(environment.rootDir, "/repo"); |
| 63 | assert.equal(environment.appRoot, "/repo"); | 64 | assert.equal(environment.appRoot, "/repo"); |
| 64 | assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); | 65 | assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); |
| ... | @@ -89,6 +90,7 @@ describe("DesktopEnvironment", () => { | ... | @@ -89,6 +90,7 @@ describe("DesktopEnvironment", () => { |
| 89 | assert.equal(environment.isDevelopment, false); | 90 | assert.equal(environment.isDevelopment, false); |
| 90 | assert.equal(environment.stateDir, "/tmp/t3/userdata"); | 91 | assert.equal(environment.stateDir, "/tmp/t3/userdata"); |
| 91 | assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); | 92 | assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); |
| 93 | assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); | ||
| 92 | assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); | 94 | assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); |
| 93 | }), | 95 | }), |
| 94 | ); | 96 | ); |
apps/desktop/src/app/DesktopEnvironment.ts+2| ... | @@ -49,6 +49,7 @@ export interface DesktopEnvironmentShape { | ... | @@ -49,6 +49,7 @@ export interface DesktopEnvironmentShape { |
| 49 | readonly savedEnvironmentRegistryPath: string; | 49 | readonly savedEnvironmentRegistryPath: string; |
| 50 | readonly serverSettingsPath: string; | 50 | readonly serverSettingsPath: string; |
| 51 | readonly logDir: string; | 51 | readonly logDir: string; |
| 52 | readonly browserArtifactsDir: string; | ||
| 52 | readonly rootDir: string; | 53 | readonly rootDir: string; |
| 53 | readonly appRoot: string; | 54 | readonly appRoot: string; |
| 54 | readonly backendEntryPath: string; | 55 | readonly backendEntryPath: string; |
| ... | @@ -183,6 +184,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( | ... | @@ -183,6 +184,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( |
| 183 | savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), | 184 | savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), |
| 184 | serverSettingsPath: path.join(stateDir, "settings.json"), | 185 | serverSettingsPath: path.join(stateDir, "settings.json"), |
| 185 | logDir: path.join(stateDir, "logs"), | 186 | logDir: path.join(stateDir, "logs"), |
| 187 | browserArtifactsDir: path.join(stateDir, "browser-artifacts"), | ||
| 186 | rootDir, | 188 | rootDir, |
| 187 | appRoot, | 189 | appRoot, |
| 188 | backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), | 190 | backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+4| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import * as Effect from "effect/Effect"; | 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 | import * as DesktopIpc from "./DesktopIpc.ts"; | 5 | import * as DesktopIpc from "./DesktopIpc.ts"; |
| 4 | import { | 6 | import { |
| 5 | clearCloudAuthToken, | 7 | clearCloudAuthToken, |
| ... | @@ -52,6 +54,8 @@ import { previewMethods } from "./methods/preview.ts"; | ... | @@ -52,6 +54,8 @@ import { previewMethods } from "./methods/preview.ts"; |
| 52 | 54 | ||
| 53 | export const installDesktopIpcHandlers = Effect.gen(function* () { | 55 | export const installDesktopIpcHandlers = Effect.gen(function* () { |
| 54 | const ipc = yield* DesktopIpc.DesktopIpc; | 56 | const ipc = yield* DesktopIpc.DesktopIpc; |
| 57 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 58 | previewViewManager.configureArtifactDirectory(environment.browserArtifactsDir); | ||
| 55 | 59 | ||
| 56 | yield* ipc.handleSync(getAppBranding); | 60 | yield* ipc.handleSync(getAppBranding); |
| 57 | yield* ipc.handleSync(getLocalEnvironmentBootstrap); | 61 | yield* ipc.handleSync(getLocalEnvironmentBootstrap); |
apps/desktop/src/ipc/channels.ts+4| ... | @@ -54,9 +54,12 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; | ... | @@ -54,9 +54,12 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; |
| 54 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; | 54 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; |
| 55 | export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; | 55 | export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; |
| 56 | export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; | 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 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; | 58 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; |
| 58 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; | 59 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; |
| 59 | export const PREVIEW_CAPTURE_SCREENSHOT_CHANNEL = "desktop:preview-capture-screenshot"; | 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 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; | 63 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; |
| 61 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; | 64 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; |
| 62 | export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; | 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,3 +73,4 @@ export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; |
| 70 | export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; | 73 | export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; |
| 71 | export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; | 74 | export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; |
| 72 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; | 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,6 +1,7 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as Data from "effect/Data"; | 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts"; | ||
| 4 | import { BrowserWindow } from "electron"; | 5 | import { BrowserWindow } from "electron"; |
| 5 | import { pathToFileURL } from "node:url"; | 6 | import { pathToFileURL } from "node:url"; |
| 6 | 7 | ||
| ... | @@ -25,6 +26,14 @@ previewViewManager.onRecordingFrame((frame) => { | ... | @@ -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 | const tabIdFrom = (raw: unknown): string => { | 37 | const tabIdFrom = (raw: unknown): string => { |
| 29 | if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { | 38 | if (typeof raw !== "object" || raw === null || !("tabId" in raw)) { |
| 30 | throw new Error("preview tab id is required"); | 39 | throw new Error("preview tab id is required"); |
| ... | @@ -43,6 +52,44 @@ const inputFrom = (raw: unknown): unknown => { | ... | @@ -43,6 +52,44 @@ const inputFrom = (raw: unknown): unknown => { |
| 43 | return raw.input; | 52 | return raw.input; |
| 44 | }; | 53 | }; |
| 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 | class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ | 93 | class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ |
| 47 | readonly cause: unknown; | 94 | readonly cause: unknown; |
| 48 | }> {} | 95 | }> {} |
| ... | @@ -117,6 +164,9 @@ export const previewMethods = [ | ... | @@ -117,6 +164,9 @@ export const previewMethods = [ |
| 117 | preloadUrl: pathToFileURL(preloadPath).href, | 164 | preloadUrl: pathToFileURL(preloadPath).href, |
| 118 | }; | 165 | }; |
| 119 | }), | 166 | }), |
| 167 | method(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, (raw) => | ||
| 168 | previewViewManager.setAnnotationTheme(annotationThemeFrom(raw)), | ||
| 169 | ), | ||
| 120 | method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => | 170 | method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) => |
| 121 | previewViewManager.pickElement(tabIdFrom(raw)), | 171 | previewViewManager.pickElement(tabIdFrom(raw)), |
| 122 | ), | 172 | ), |
| ... | @@ -126,6 +176,20 @@ export const previewMethods = [ | ... | @@ -126,6 +176,20 @@ export const previewMethods = [ |
| 126 | method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => | 176 | method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) => |
| 127 | previewViewManager.captureScreenshot(tabIdFrom(raw)), | 177 | previewViewManager.captureScreenshot(tabIdFrom(raw)), |
| 128 | ), | 178 | ), |
| 179 | method(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, (raw) => { | ||
| 180 | const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; | ||
| 181 | if (typeof path !== "string" || path.trim().length === 0) { | ||
| 182 | throw new Error("preview artifact path is required"); | ||
| 183 | } | ||
| 184 | return previewViewManager.revealArtifact(path); | ||
| 185 | }), | ||
| 186 | method(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, (raw) => { | ||
| 187 | const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null; | ||
| 188 | if (typeof path !== "string" || path.trim().length === 0) { | ||
| 189 | throw new Error("preview artifact path is required"); | ||
| 190 | } | ||
| 191 | return previewViewManager.copyArtifactToClipboard(path); | ||
| 192 | }), | ||
| 129 | method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => | 193 | method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => |
| 130 | previewViewManager.automationStatus(tabIdFrom(raw)), | 194 | previewViewManager.automationStatus(tabIdFrom(raw)), |
| 131 | ), | 195 | ), |
apps/desktop/src/preload.ts+16| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { | 1 | import type { |
| 2 | DesktopBridge, | 2 | DesktopBridge, |
| 3 | DesktopPreviewPointerEvent, | ||
| 3 | DesktopPreviewRecordingFrame, | 4 | DesktopPreviewRecordingFrame, |
| 4 | DesktopPreviewTabState, | 5 | DesktopPreviewTabState, |
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| ... | @@ -165,11 +166,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -165,11 +166,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 165 | clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), | 166 | clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL), |
| 166 | getPreviewConfig: (environmentId) => | 167 | getPreviewConfig: (environmentId) => |
| 167 | ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), | 168 | ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }), |
| 169 | setAnnotationTheme: (theme) => | ||
| 170 | ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), | ||
| 168 | pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), | 171 | pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), |
| 169 | cancelPickElement: (tabId) => | 172 | cancelPickElement: (tabId) => |
| 170 | ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), | 173 | ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), |
| 171 | captureScreenshot: (tabId) => | 174 | captureScreenshot: (tabId) => |
| 172 | ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }), | 175 | ipcRenderer.invoke(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, { tabId }), |
| 176 | revealArtifact: (path) => | ||
| 177 | ipcRenderer.invoke(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, { path }), | ||
| 178 | copyArtifactToClipboard: (path) => | ||
| 179 | ipcRenderer.invoke(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, { path }), | ||
| 173 | recording: { | 180 | recording: { |
| 174 | startScreencast: (tabId) => | 181 | startScreencast: (tabId) => |
| 175 | ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), | 182 | ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }), |
| ... | @@ -222,5 +229,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -222,5 +229,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 222 | return () => | 229 | return () => |
| 223 | ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); | 230 | ipcRenderer.removeListener(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, wrappedListener); |
| 224 | }, | 231 | }, |
| 232 | onPointerEvent: (listener) => { | ||
| 233 | const wrappedListener = (_event: Electron.IpcRendererEvent, pointerEvent: unknown) => { | ||
| 234 | if (typeof pointerEvent !== "object" || pointerEvent === null) return; | ||
| 235 | listener(pointerEvent as DesktopPreviewPointerEvent); | ||
| 236 | }; | ||
| 237 | ipcRenderer.on(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); | ||
| 238 | return () => | ||
| 239 | ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); | ||
| 240 | }, | ||
| 225 | }, | 241 | }, |
| 226 | } satisfies DesktopBridge); | 242 | } satisfies DesktopBridge); |
apps/desktop/src/preview-annotation-styles.generated.ts created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. | ||
| 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,6 +2,7 @@ |
| 2 | import { ipcRenderer } from "electron"; | 2 | import { ipcRenderer } from "electron"; |
| 3 | import { getElementContext } from "react-grab/primitives"; | 3 | import { getElementContext } from "react-grab/primitives"; |
| 4 | import type { | 4 | import type { |
| 5 | DesktopPreviewAnnotationTheme, | ||
| 5 | PickedElementPayload, | 6 | PickedElementPayload, |
| 6 | PickedElementStackFrame, | 7 | PickedElementStackFrame, |
| 7 | PreviewAnnotationPayload, | 8 | PreviewAnnotationPayload, |
| ... | @@ -12,16 +13,18 @@ import type { | ... | @@ -12,16 +13,18 @@ import type { |
| 12 | PreviewAnnotationStyleChange, | 13 | PreviewAnnotationStyleChange, |
| 13 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 14 | 15 | ||
| 16 | import { previewAnnotationStyles } from "./preview-annotation-styles.generated.ts"; | ||
| 17 | |||
| 15 | const START_PICK_CHANNEL = "preview:start-pick"; | 18 | const START_PICK_CHANNEL = "preview:start-pick"; |
| 16 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; | 19 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; |
| 17 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | 20 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; |
| 18 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; | 21 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; |
| 22 | const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; | ||
| 19 | const HUMAN_INPUT_CHANNEL = "preview:human-input"; | 23 | const HUMAN_INPUT_CHANNEL = "preview:human-input"; |
| 20 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; | 24 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; |
| 21 | const Z_INDEX_OVERLAY = 2147483646; | 25 | const Z_INDEX_OVERLAY = 2147483646; |
| 22 | const ACCENT = "#7c3aed"; | 26 | const PRIMARY = "var(--t3-primary)"; |
| 23 | const ACCENT_FILL = "rgba(124,58,237,0.12)"; | 27 | const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; |
| 24 | const BLUE = "#2563eb"; | ||
| 25 | const MAX_MARQUEE_ELEMENTS = 20; | 28 | const MAX_MARQUEE_ELEMENTS = 20; |
| 26 | const CONTENT_LAYER_Z_INDEX = 1; | 29 | const CONTENT_LAYER_Z_INDEX = 1; |
| 27 | const CHROME_LAYER_Z_INDEX = 10; | 30 | const CHROME_LAYER_Z_INDEX = 10; |
| ... | @@ -38,17 +41,63 @@ interface SelectedElement { | ... | @@ -38,17 +41,63 @@ interface SelectedElement { |
| 38 | 41 | ||
| 39 | interface AnnotationSession { | 42 | interface AnnotationSession { |
| 40 | teardown: (notifyMain: boolean) => void; | 43 | teardown: (notifyMain: boolean) => void; |
| 44 | applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; | ||
| 41 | } | 45 | } |
| 42 | 46 | ||
| 43 | let activeSession: AnnotationSession | null = null; | 47 | let activeSession: AnnotationSession | null = null; |
| 44 | let idSequence = 0; | 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 => { | 90 | const reportHumanKeyInput = (event: KeyboardEvent): void => { |
| 47 | if (event.isTrusted) ipcRenderer.send(HUMAN_INPUT_CHANNEL); | 91 | if (!event.isTrusted) return; |
| 92 | ipcRenderer.send(HUMAN_INPUT_CHANNEL, { | ||
| 93 | kind: "key", | ||
| 94 | key: event.key, | ||
| 95 | code: event.code, | ||
| 96 | }); | ||
| 48 | }; | 97 | }; |
| 49 | 98 | ||
| 50 | window.addEventListener("pointerdown", reportHumanInput, true); | 99 | window.addEventListener("pointerdown", reportHumanPointerInput, true); |
| 51 | window.addEventListener("keydown", reportHumanInput, true); | 100 | window.addEventListener("keydown", reportHumanKeyInput, true); |
| 52 | 101 | ||
| 53 | const nextId = (prefix: string): string => { | 102 | const nextId = (prefix: string): string => { |
| 54 | idSequence += 1; | 103 | idSequence += 1; |
| ... | @@ -76,13 +125,15 @@ const normalizeRect = ( | ... | @@ -76,13 +125,15 @@ const normalizeRect = ( |
| 76 | 125 | ||
| 77 | const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; | 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 | if (rects.length === 0) return null; | 132 | if (rects.length === 0) return null; |
| 81 | const left = Math.min(...rects.map((rect) => rect.x)); | 133 | const left = Math.min(...rects.map((rect) => rect.x)); |
| 82 | const top = Math.min(...rects.map((rect) => rect.y)); | 134 | const top = Math.min(...rects.map((rect) => rect.y)); |
| 83 | const right = Math.max(...rects.map((rect) => rect.x + rect.width)); | 135 | const right = Math.max(...rects.map((rect) => rect.x + rect.width)); |
| 84 | const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); | 136 | const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); |
| 85 | const padding = 20; | ||
| 86 | const x = Math.max(0, left - padding); | 137 | const x = Math.max(0, left - padding); |
| 87 | const y = Math.max(0, top - padding); | 138 | const y = Math.max(0, top - padding); |
| 88 | const maxWidth = Math.max(1, window.innerWidth - x); | 139 | const maxWidth = Math.max(1, window.innerWidth - x); |
| ... | @@ -151,19 +202,13 @@ function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { | ... | @@ -151,19 +202,13 @@ function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { |
| 151 | function createLabel(): HTMLDivElement { | 202 | function createLabel(): HTMLDivElement { |
| 152 | const label = document.createElement("div"); | 203 | const label = document.createElement("div"); |
| 153 | label.setAttribute(OVERLAY_ATTRIBUTE, ""); | 204 | label.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 205 | label.className = | ||
| 206 | "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md"; | ||
| 154 | label.style.cssText = [ | 207 | label.style.cssText = [ |
| 155 | "position:fixed", | 208 | "position:fixed", |
| 156 | "pointer-events:none", | 209 | "pointer-events:none", |
| 157 | `background:${ACCENT}`, | ||
| 158 | "color:white", | ||
| 159 | "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", | ||
| 160 | "padding:2px 6px", | ||
| 161 | "border-radius:4px", | ||
| 162 | "white-space:nowrap", | 210 | "white-space:nowrap", |
| 163 | "max-width:280px", | ||
| 164 | "overflow:hidden", | ||
| 165 | "text-overflow:ellipsis", | 211 | "text-overflow:ellipsis", |
| 166 | "box-shadow:0 1px 3px rgba(0,0,0,.3)", | ||
| 167 | `z-index:${CONTENT_LAYER_Z_INDEX}`, | 212 | `z-index:${CONTENT_LAYER_Z_INDEX}`, |
| 168 | ].join(";"); | 213 | ].join(";"); |
| 169 | return label; | 214 | return label; |
| ... | @@ -222,22 +267,15 @@ function createButton(label: string, title: string): HTMLButtonElement { | ... | @@ -222,22 +267,15 @@ function createButton(label: string, title: string): HTMLButtonElement { |
| 222 | button.type = "button"; | 267 | button.type = "button"; |
| 223 | button.textContent = label; | 268 | button.textContent = label; |
| 224 | button.title = title; | 269 | button.title = title; |
| 225 | button.style.cssText = [ | 270 | button.className = |
| 226 | "border:0", | 271 | "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60"; |
| 227 | "border-radius:7px", | ||
| 228 | "padding:6px 9px", | ||
| 229 | "font:600 12px/1 ui-sans-serif,system-ui,-apple-system,sans-serif", | ||
| 230 | "background:transparent", | ||
| 231 | "color:#27272a", | ||
| 232 | "cursor:pointer", | ||
| 233 | ].join(";"); | ||
| 234 | return button; | 272 | return button; |
| 235 | } | 273 | } |
| 236 | 274 | ||
| 237 | function styleControl(input: HTMLInputElement | HTMLSelectElement): void { | 275 | function styleControl(input: HTMLInputElement | HTMLSelectElement): void { |
| 238 | input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); | 276 | input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value"); |
| 239 | input.style.cssText += | 277 | input.className = |
| 240 | ";min-width:0;width:100%;height:26px;border:0;border-radius:7px;background:rgba(255,255,255,.78);color:#27272a;padding:3px 8px;box-sizing:border-box;font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none;box-shadow:inset 0 0 0 1px rgba(0,0,0,.09);appearance:none"; | 278 | "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none"; |
| 241 | } | 279 | } |
| 242 | 280 | ||
| 243 | function createUnitControl(input: HTMLInputElement): HTMLElement { | 281 | function createUnitControl(input: HTMLInputElement): HTMLElement { |
| ... | @@ -245,8 +283,8 @@ function createUnitControl(input: HTMLInputElement): HTMLElement { | ... | @@ -245,8 +283,8 @@ function createUnitControl(input: HTMLInputElement): HTMLElement { |
| 245 | wrapper.style.cssText = "position:relative;min-width:0"; | 283 | wrapper.style.cssText = "position:relative;min-width:0"; |
| 246 | const unit = document.createElement("span"); | 284 | const unit = document.createElement("span"); |
| 247 | unit.textContent = input.dataset.unit ?? ""; | 285 | unit.textContent = input.dataset.unit ?? ""; |
| 248 | unit.style.cssText = | 286 | unit.className = |
| 249 | "position:absolute;right:8px;top:50%;transform:translateY(-50%);pointer-events:none;color:#a1a1aa;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace"; | 287 | "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground"; |
| 250 | wrapper.append(input, unit); | 288 | wrapper.append(input, unit); |
| 251 | return wrapper; | 289 | return wrapper; |
| 252 | } | 290 | } |
| ... | @@ -256,8 +294,8 @@ function createField( | ... | @@ -256,8 +294,8 @@ function createField( |
| 256 | input: HTMLInputElement | HTMLSelectElement, | 294 | input: HTMLInputElement | HTMLSelectElement, |
| 257 | ): HTMLLabelElement { | 295 | ): HTMLLabelElement { |
| 258 | const label = document.createElement("label"); | 296 | const label = document.createElement("label"); |
| 259 | label.style.cssText = | 297 | label.className = |
| 260 | "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; | 298 | "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; |
| 261 | const text = document.createElement("span"); | 299 | const text = document.createElement("span"); |
| 262 | text.textContent = labelText; | 300 | text.textContent = labelText; |
| 263 | styleControl(input); | 301 | styleControl(input); |
| ... | @@ -270,7 +308,7 @@ function createField( | ... | @@ -270,7 +308,7 @@ function createField( |
| 270 | 308 | ||
| 271 | function createStyleSection(): HTMLElement { | 309 | function createStyleSection(): HTMLElement { |
| 272 | const section = document.createElement("section"); | 310 | const section = document.createElement("section"); |
| 273 | section.style.cssText = "display:grid;gap:3px;padding:7px 0;border-top:1px solid rgba(0,0,0,.07)"; | 311 | section.className = "grid gap-1 border-t border-border py-2"; |
| 274 | return section; | 312 | return section; |
| 275 | } | 313 | } |
| 276 | 314 | ||
| ... | @@ -314,16 +352,27 @@ function strokeBounds( | ... | @@ -314,16 +352,27 @@ function strokeBounds( |
| 314 | function startAnnotation(): void { | 352 | function startAnnotation(): void { |
| 315 | activeSession?.teardown(false); | 353 | activeSession?.teardown(false); |
| 316 | let finished = false; | 354 | let finished = false; |
| 355 | const host = document.createElement("div"); | ||
| 356 | host.setAttribute(OVERLAY_ATTRIBUTE, ""); | ||
| 357 | host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`; | ||
| 358 | applyAnnotationTheme(host, annotationTheme); | ||
| 359 | const shadowRoot = host.attachShadow({ mode: "closed" }); | ||
| 360 | const themeStyle = document.createElement("style"); | ||
| 361 | themeStyle.textContent = previewAnnotationStyles; | ||
| 362 | shadowRoot.appendChild(themeStyle); | ||
| 363 | |||
| 317 | const root = document.createElement("div"); | 364 | const root = document.createElement("div"); |
| 318 | root.setAttribute(OVERLAY_ATTRIBUTE, ""); | 365 | root.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 319 | root.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif`; | 366 | root.className = "fixed inset-0 font-sans text-foreground"; |
| 367 | root.style.cssText = "pointer-events:none"; | ||
| 320 | const cursorStyle = document.createElement("style"); | 368 | const cursorStyle = document.createElement("style"); |
| 321 | cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); | 369 | cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 322 | cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; | 370 | cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`; |
| 323 | root.appendChild(cursorStyle); | 371 | document.documentElement.appendChild(cursorStyle); |
| 372 | shadowRoot.appendChild(root); | ||
| 324 | 373 | ||
| 325 | const hoverOutline = createBox(BLUE, "rgba(37,99,235,.10)"); | 374 | const hoverOutline = createBox(PRIMARY, PRIMARY_FILL); |
| 326 | const marqueeBox = createBox(ACCENT, ACCENT_FILL); | 375 | const marqueeBox = createBox(PRIMARY, PRIMARY_FILL); |
| 327 | root.append(hoverOutline, marqueeBox); | 376 | root.append(hoverOutline, marqueeBox); |
| 328 | 377 | ||
| 329 | const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); | 378 | const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); |
| ... | @@ -337,57 +386,55 @@ function startAnnotation(): void { | ... | @@ -337,57 +386,55 @@ function startAnnotation(): void { |
| 337 | 386 | ||
| 338 | const toolbar = document.createElement("div"); | 387 | const toolbar = document.createElement("div"); |
| 339 | toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); | 388 | toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 340 | toolbar.style.cssText = `position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:${CHROME_LAYER_Z_INDEX};pointer-events:auto;display:flex;gap:2px;padding:3px;border:1px solid rgba(0,0,0,.10);border-radius:9px;background:rgba(255,255,255,.94);box-shadow:0 5px 16px rgba(0,0,0,.12);backdrop-filter:blur(12px)`; | 389 | toolbar.className = |
| 390 | "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl"; | ||
| 391 | toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX); | ||
| 341 | root.appendChild(toolbar); | 392 | root.appendChild(toolbar); |
| 342 | 393 | ||
| 343 | const editor = document.createElement("div"); | 394 | const editor = document.createElement("div"); |
| 344 | editor.setAttribute(OVERLAY_ATTRIBUTE, ""); | 395 | editor.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 345 | editor.style.cssText = `position:fixed;right:12px;bottom:12px;z-index:${CHROME_LAYER_Z_INDEX};width:min(306px,calc(100vw - 24px));max-height:calc(100vh - 32px);overflow:hidden;pointer-events:auto;display:none;flex-direction:column;border:1px solid rgba(0,0,0,.09);border-radius:14px;background:rgba(255,255,255,.96);box-shadow:0 14px 36px rgba(0,0,0,.18);color:#18181b;box-sizing:border-box;backdrop-filter:blur(18px)`; | 396 | editor.className = |
| 397 | "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl"; | ||
| 398 | editor.style.zIndex = String(CHROME_LAYER_Z_INDEX); | ||
| 346 | root.appendChild(editor); | 399 | root.appendChild(editor); |
| 347 | 400 | ||
| 348 | const editorHeader = document.createElement("div"); | 401 | const composerRow = document.createElement("div"); |
| 349 | editorHeader.style.cssText = | 402 | composerRow.className = "flex items-start gap-2 p-2"; |
| 350 | "display:flex;align-items:center;gap:7px;padding:7px 8px;border-bottom:1px solid rgba(0,0,0,.06);cursor:grab;user-select:none"; | 403 | |
| 351 | const adjustIcon = document.createElement("div"); | 404 | const adjust = createButton("", "Expand annotation editor"); |
| 352 | adjustIcon.innerHTML = | 405 | adjust.setAttribute("aria-label", "Expand annotation editor"); |
| 353 | '<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M3 6h8M15 6h2M3 14h2M9 14h8M11 3v6M7 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><circle cx="13" cy="6" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="14" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/></svg>'; | 406 | adjust.setAttribute("aria-expanded", "false"); |
| 354 | adjustIcon.style.cssText = | 407 | adjust.className += |
| 355 | "display:grid;place-items:center;width:24px;height:24px;border-radius:999px;background:#f4f4f5;color:#52525b;font:700 14px/1 ui-sans-serif"; | 408 | " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"; |
| 356 | editorHeader.appendChild(adjustIcon); | 409 | adjust.innerHTML = |
| 357 | 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>'; | |
| 358 | const status = document.createElement("div"); | 411 | composerRow.appendChild(adjust); |
| 359 | status.style.cssText = | ||
| 360 | "min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 11px/1.2 ui-sans-serif,system-ui;color:#71717a"; | ||
| 361 | editorHeader.appendChild(status); | ||
| 362 | const dragHandle = document.createElement("div"); | ||
| 363 | dragHandle.textContent = "â ¿"; | ||
| 364 | dragHandle.title = "Drag annotation editor"; | ||
| 365 | dragHandle.style.cssText = "color:#a1a1aa;font:700 18px/1 ui-sans-serif"; | ||
| 366 | editorHeader.appendChild(dragHandle); | ||
| 367 | editor.appendChild(editorHeader); | ||
| 368 | 412 | ||
| 369 | const comment = document.createElement("textarea"); | 413 | const comment = document.createElement("textarea"); |
| 370 | comment.placeholder = "Describe the change…"; | 414 | comment.placeholder = "Describe the change…"; |
| 371 | comment.rows = 2; | 415 | comment.rows = 1; |
| 372 | comment.style.cssText = | 416 | comment.className = |
| 373 | "width:100%;resize:none;min-height:48px;border:0;padding:10px 12px;font:500 12px/1.35 ui-sans-serif,system-ui;color:#18181b;box-sizing:border-box;outline:none;background:transparent"; | 417 | "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0"; |
| 374 | editor.appendChild(comment); | 418 | composerRow.appendChild(comment); |
| 375 | 419 | ||
| 376 | const stylePanel = document.createElement("div"); | 420 | const dragHandle = document.createElement("button"); |
| 377 | stylePanel.style.cssText = | 421 | dragHandle.type = "button"; |
| 378 | "display:none;max-height:min(380px,calc(100vh - 180px));overflow:auto;padding:0 10px;background:rgba(250,250,250,.62);border-top:1px solid rgba(0,0,0,.06)"; | 422 | dragHandle.textContent = "â ¿"; |
| 379 | editor.appendChild(stylePanel); | 423 | dragHandle.title = "Drag annotation editor"; |
| 424 | dragHandle.className = | ||
| 425 | "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; | ||
| 426 | composerRow.appendChild(dragHandle); | ||
| 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 | const submit = createButton("Attach", "Attach annotation and screenshot"); | 428 | const submit = createButton("Attach", "Attach annotation and screenshot"); |
| 388 | submit.style.cssText += `;background:${ACCENT};color:white;padding:6px 9px;font-size:11px`; | 429 | submit.className += |
| 389 | footer.append(adjust, submit); | 430 | " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; |
| 390 | editor.appendChild(footer); | 431 | composerRow.appendChild(submit); |
| 432 | editor.appendChild(composerRow); | ||
| 433 | |||
| 434 | const stylePanel = document.createElement("div"); | ||
| 435 | stylePanel.className = | ||
| 436 | "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3"; | ||
| 437 | editor.appendChild(stylePanel); | ||
| 391 | 438 | ||
| 392 | const selected = new Map<Element, SelectedElement>(); | 439 | const selected = new Map<Element, SelectedElement>(); |
| 393 | const regions: PreviewAnnotationRegionTarget[] = []; | 440 | const regions: PreviewAnnotationRegionTarget[] = []; |
| ... | @@ -398,28 +445,30 @@ function startAnnotation(): void { | ... | @@ -398,28 +445,30 @@ function startAnnotation(): void { |
| 398 | let dragStart: PreviewAnnotationPoint | null = null; | 445 | let dragStart: PreviewAnnotationPoint | null = null; |
| 399 | let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; | 446 | let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; |
| 400 | let pendingCapture = false; | 447 | let pendingCapture = false; |
| 401 | let stylesExpanded = false; | 448 | let editorExpanded = false; |
| 402 | let editorWasShown = false; | 449 | let editorWasShown = false; |
| 403 | let editorPosition: { left: number; top: number } | null = null; | 450 | let editorPosition: { left: number; top: number } | null = null; |
| 404 | let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; | 451 | let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; |
| 452 | let editorLayoutFrame: number | null = null; | ||
| 453 | |||
| 454 | const resizeComment = (): void => { | ||
| 455 | const maxHeight = 96; | ||
| 456 | comment.style.height = "auto"; | ||
| 457 | const nextHeight = Math.min(comment.scrollHeight, maxHeight); | ||
| 458 | comment.style.height = `${nextHeight}px`; | ||
| 459 | comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden"; | ||
| 460 | queueEditorLayout(); | ||
| 461 | }; | ||
| 462 | comment.addEventListener("input", resizeComment); | ||
| 405 | 463 | ||
| 406 | const updateStatus = (): void => { | 464 | const updateStatus = (): void => { |
| 407 | const parts = [ | 465 | const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0; |
| 408 | selected.size > 0 ? `${selected.size} element${selected.size === 1 ? "" : "s"}` : "", | ||
| 409 | regions.length > 0 ? `${regions.length} region${regions.length === 1 ? "" : "s"}` : "", | ||
| 410 | strokes.length > 0 ? `${strokes.length} drawing${strokes.length === 1 ? "" : "s"}` : "", | ||
| 411 | ].filter(Boolean); | ||
| 412 | const hasTargets = parts.length > 0; | ||
| 413 | status.textContent = parts.join(" · "); | ||
| 414 | editor.style.display = hasTargets ? "flex" : "none"; | 466 | editor.style.display = hasTargets ? "flex" : "none"; |
| 415 | submit.disabled = !hasTargets; | 467 | submit.disabled = !hasTargets; |
| 416 | submit.style.opacity = hasTargets ? "1" : "0.45"; | 468 | submit.style.opacity = hasTargets ? "1" : "0.45"; |
| 417 | adjust.style.display = selected.size > 0 ? "block" : "none"; | 469 | adjust.disabled = !hasTargets; |
| 418 | if (selected.size === 0 && stylesExpanded) { | 470 | stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none"; |
| 419 | stylesExpanded = false; | 471 | queueEditorLayout(); |
| 420 | stylePanel.style.display = "none"; | ||
| 421 | adjust.textContent = "Adjust"; | ||
| 422 | } | ||
| 423 | if (hasTargets && !editorWasShown) { | 472 | if (hasTargets && !editorWasShown) { |
| 424 | editorWasShown = true; | 473 | editorWasShown = true; |
| 425 | window.setTimeout(() => comment.focus({ preventScroll: true }), 0); | 474 | window.setTimeout(() => comment.focus({ preventScroll: true }), 0); |
| ... | @@ -429,8 +478,9 @@ function startAnnotation(): void { | ... | @@ -429,8 +478,9 @@ function startAnnotation(): void { |
| 429 | const refreshToolButtons = (): void => { | 478 | const refreshToolButtons = (): void => { |
| 430 | for (const [candidate, button] of toolButtons) { | 479 | for (const [candidate, button] of toolButtons) { |
| 431 | const active = candidate === tool; | 480 | const active = candidate === tool; |
| 432 | button.style.background = active ? ACCENT_FILL : "transparent"; | 481 | button.classList.toggle("bg-primary/10", active); |
| 433 | button.style.color = active ? ACCENT : "#27272a"; | 482 | button.classList.toggle("text-primary", active); |
| 483 | button.classList.toggle("text-foreground", !active); | ||
| 434 | } | 484 | } |
| 435 | if (tool !== "select") hoverOutline.style.display = "none"; | 485 | if (tool !== "select") hoverOutline.style.display = "none"; |
| 436 | if (tool !== "marquee") marqueeBox.style.display = "none"; | 486 | if (tool !== "marquee") marqueeBox.style.display = "none"; |
| ... | @@ -458,7 +508,7 @@ function startAnnotation(): void { | ... | @@ -458,7 +508,7 @@ function startAnnotation(): void { |
| 458 | const target: SelectedElement = { | 508 | const target: SelectedElement = { |
| 459 | id: nextId("element"), | 509 | id: nextId("element"), |
| 460 | element, | 510 | element, |
| 461 | outline: createBox(ACCENT, ACCENT_FILL), | 511 | outline: createBox(PRIMARY, PRIMARY_FILL), |
| 462 | label: createLabel(), | 512 | label: createLabel(), |
| 463 | baselineStyles: new Map(), | 513 | baselineStyles: new Map(), |
| 464 | }; | 514 | }; |
| ... | @@ -466,7 +516,10 @@ function startAnnotation(): void { | ... | @@ -466,7 +516,10 @@ function startAnnotation(): void { |
| 466 | root.append(target.outline, target.label); | 516 | root.append(target.outline, target.label); |
| 467 | updateSelectedVisual(target); | 517 | updateSelectedVisual(target); |
| 468 | updateStatus(); | 518 | updateStatus(); |
| 469 | if (stylesExpanded) syncStyleControls(); | 519 | if (editorExpanded) { |
| 520 | stylePanel.style.display = "grid"; | ||
| 521 | syncStyleControls(); | ||
| 522 | } | ||
| 470 | }; | 523 | }; |
| 471 | 524 | ||
| 472 | const toggleSelected = (element: Element, additive: boolean): void => { | 525 | const toggleSelected = (element: Element, additive: boolean): void => { |
| ... | @@ -552,13 +605,13 @@ function startAnnotation(): void { | ... | @@ -552,13 +605,13 @@ function startAnnotation(): void { |
| 552 | section: HTMLElement, | 605 | section: HTMLElement, |
| 553 | ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { | 606 | ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { |
| 554 | const row = document.createElement("label"); | 607 | const row = document.createElement("label"); |
| 555 | row.style.cssText = | 608 | row.className = |
| 556 | "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; | 609 | "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground"; |
| 557 | const label = document.createElement("span"); | 610 | const label = document.createElement("span"); |
| 558 | label.textContent = labelText; | 611 | label.textContent = labelText; |
| 559 | const control = document.createElement("div"); | 612 | const control = document.createElement("div"); |
| 560 | control.style.cssText = | 613 | control.className = |
| 561 | "display:grid;grid-template-columns:22px minmax(0,1fr);align-items:center;gap:5px;height:26px;padding:2px 5px;border-radius:7px;background:rgba(255,255,255,.78);box-shadow:inset 0 0 0 1px rgba(0,0,0,.09)"; | 614 | "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs"; |
| 562 | const color = document.createElement("input"); | 615 | const color = document.createElement("input"); |
| 563 | color.type = "color"; | 616 | color.type = "color"; |
| 564 | color.setAttribute("aria-label", labelText); | 617 | color.setAttribute("aria-label", labelText); |
| ... | @@ -567,8 +620,8 @@ function startAnnotation(): void { | ... | @@ -567,8 +620,8 @@ function startAnnotation(): void { |
| 567 | const text = document.createElement("input"); | 620 | const text = document.createElement("input"); |
| 568 | text.type = "text"; | 621 | text.type = "text"; |
| 569 | text.setAttribute("aria-label", `${labelText} value`); | 622 | text.setAttribute("aria-label", `${labelText} value`); |
| 570 | text.style.cssText = | 623 | text.className = |
| 571 | "min-width:0;width:100%;border:0;background:transparent;color:#52525b;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none"; | 624 | "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none"; |
| 572 | color.addEventListener("input", () => { | 625 | color.addEventListener("input", () => { |
| 573 | text.value = color.value; | 626 | text.value = color.value; |
| 574 | setStyleForSelected(property, color.value); | 627 | setStyleForSelected(property, color.value); |
| ... | @@ -594,7 +647,7 @@ function startAnnotation(): void { | ... | @@ -594,7 +647,7 @@ function startAnnotation(): void { |
| 594 | opacity.max = "1"; | 647 | opacity.max = "1"; |
| 595 | opacity.step = "0.05"; | 648 | opacity.step = "0.05"; |
| 596 | opacity.value = "1"; | 649 | opacity.value = "1"; |
| 597 | opacity.style.accentColor = ACCENT; | 650 | opacity.style.accentColor = PRIMARY; |
| 598 | opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); | 651 | opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); |
| 599 | colorsSection.appendChild(createField("Opacity", opacity)); | 652 | colorsSection.appendChild(createField("Opacity", opacity)); |
| 600 | 653 | ||
| ... | @@ -623,8 +676,7 @@ function startAnnotation(): void { | ... | @@ -623,8 +676,7 @@ function startAnnotation(): void { |
| 623 | dimensions.style.cssText = | 676 | dimensions.style.cssText = |
| 624 | "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; | 677 | "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; |
| 625 | const dimensionLabel = document.createElement("div"); | 678 | const dimensionLabel = document.createElement("div"); |
| 626 | dimensionLabel.style.cssText = | 679 | dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground"; |
| 627 | "display:grid;gap:9px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; | ||
| 628 | dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>"; | 680 | dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>"; |
| 629 | const dimensionControls = document.createElement("div"); | 681 | const dimensionControls = document.createElement("div"); |
| 630 | dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; | 682 | dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; |
| ... | @@ -635,7 +687,8 @@ function startAnnotation(): void { | ... | @@ -635,7 +687,8 @@ function startAnnotation(): void { |
| 635 | const aspectLock = createButton("", "Lock aspect ratio"); | 687 | const aspectLock = createButton("", "Lock aspect ratio"); |
| 636 | aspectLock.setAttribute("aria-pressed", "true"); | 688 | aspectLock.setAttribute("aria-pressed", "true"); |
| 637 | aspectLock.style.cssText += | 689 | aspectLock.style.cssText += |
| 638 | ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0;border-radius:6px;background:#ede9fe;color:#6d28d9"; | 690 | ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0"; |
| 691 | aspectLock.className += " bg-primary/10 text-primary"; | ||
| 639 | dimensionControls.append( | 692 | dimensionControls.append( |
| 640 | createUnitControl(widthInput), | 693 | createUnitControl(widthInput), |
| 641 | createUnitControl(heightInput), | 694 | createUnitControl(heightInput), |
| ... | @@ -651,8 +704,10 @@ function startAnnotation(): void { | ... | @@ -651,8 +704,10 @@ function startAnnotation(): void { |
| 651 | ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>' | 704 | ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>' |
| 652 | : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'; | 705 | : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'; |
| 653 | aspectLock.setAttribute("aria-pressed", String(aspectLocked)); | 706 | aspectLock.setAttribute("aria-pressed", String(aspectLocked)); |
| 654 | aspectLock.style.background = aspectLocked ? "#ede9fe" : "#f4f4f5"; | 707 | aspectLock.classList.toggle("bg-primary/10", aspectLocked); |
| 655 | aspectLock.style.color = aspectLocked ? "#6d28d9" : "#71717a"; | 708 | aspectLock.classList.toggle("text-primary", aspectLocked); |
| 709 | aspectLock.classList.toggle("bg-muted", !aspectLocked); | ||
| 710 | aspectLock.classList.toggle("text-muted-foreground", !aspectLocked); | ||
| 656 | }; | 711 | }; |
| 657 | aspectLock.addEventListener("click", () => { | 712 | aspectLock.addEventListener("click", () => { |
| 658 | aspectLocked = !aspectLocked; | 713 | aspectLocked = !aspectLocked; |
| ... | @@ -733,7 +788,7 @@ function startAnnotation(): void { | ... | @@ -733,7 +788,7 @@ function startAnnotation(): void { |
| 733 | ]; | 788 | ]; |
| 734 | for (const [candidate, label, title] of tools) { | 789 | for (const [candidate, label, title] of tools) { |
| 735 | const button = createButton(label, title); | 790 | const button = createButton(label, title); |
| 736 | button.style.cssText += ";padding:5px 7px;font-size:11px;border-radius:6px"; | 791 | button.className += " h-8 px-2.5 text-sm"; |
| 737 | button.addEventListener("click", () => { | 792 | button.addEventListener("click", () => { |
| 738 | tool = candidate; | 793 | tool = candidate; |
| 739 | refreshToolButtons(); | 794 | refreshToolButtons(); |
| ... | @@ -742,14 +797,6 @@ function startAnnotation(): void { | ... | @@ -742,14 +797,6 @@ function startAnnotation(): void { |
| 742 | toolbar.appendChild(button); | 797 | toolbar.appendChild(button); |
| 743 | } | 798 | } |
| 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 | const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { | 800 | const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { |
| 754 | const margin = 8; | 801 | const margin = 8; |
| 755 | const rect = editor.getBoundingClientRect(); | 802 | const rect = editor.getBoundingClientRect(); |
| ... | @@ -766,23 +813,98 @@ function startAnnotation(): void { | ... | @@ -766,23 +813,98 @@ function startAnnotation(): void { |
| 766 | }; | 813 | }; |
| 767 | 814 | ||
| 768 | const applyEditorPosition = (position: { left: number; top: number }): void => { | 815 | const applyEditorPosition = (position: { left: number; top: number }): void => { |
| 769 | editorPosition = clampEditorPosition(position.left, position.top); | 816 | const clamped = clampEditorPosition(position.left, position.top); |
| 770 | editor.style.left = `${editorPosition.left}px`; | 817 | editor.style.left = `${clamped.left}px`; |
| 771 | editor.style.top = `${editorPosition.top}px`; | 818 | editor.style.top = `${clamped.top}px`; |
| 772 | editor.style.right = "auto"; | 819 | editor.style.right = "auto"; |
| 773 | editor.style.bottom = "auto"; | 820 | editor.style.bottom = "auto"; |
| 821 | if (editorExpanded) editorPosition = clamped; | ||
| 822 | }; | ||
| 823 | |||
| 824 | const getAnnotationBounds = (): PreviewAnnotationRect | null => | ||
| 825 | unionRects( | ||
| 826 | [ | ||
| 827 | ...Array.from(selected.values(), (target) => | ||
| 828 | rectFromDomRect(target.element.getBoundingClientRect()), | ||
| 829 | ), | ||
| 830 | ...regions.map((region) => region.rect), | ||
| 831 | ...strokes.map((stroke) => stroke.bounds), | ||
| 832 | ], | ||
| 833 | 0, | ||
| 834 | ); | ||
| 835 | |||
| 836 | const positionCompactEditor = (): void => { | ||
| 837 | const bounds = getAnnotationBounds(); | ||
| 838 | if (!bounds) return; | ||
| 839 | const editorRect = editor.getBoundingClientRect(); | ||
| 840 | const gap = 8; | ||
| 841 | const candidates = [ | ||
| 842 | { left: bounds.x + bounds.width + gap, top: bounds.y }, | ||
| 843 | { left: bounds.x - editorRect.width - gap, top: bounds.y }, | ||
| 844 | { | ||
| 845 | left: bounds.x + bounds.width - editorRect.width, | ||
| 846 | top: bounds.y + bounds.height + gap, | ||
| 847 | }, | ||
| 848 | { | ||
| 849 | left: bounds.x + bounds.width - editorRect.width, | ||
| 850 | top: bounds.y - editorRect.height - gap, | ||
| 851 | }, | ||
| 852 | ]; | ||
| 853 | const overflow = (position: { left: number; top: number }): number => | ||
| 854 | Math.max(0, -position.left) + | ||
| 855 | Math.max(0, -position.top) + | ||
| 856 | Math.max(0, position.left + editorRect.width - window.innerWidth) + | ||
| 857 | Math.max(0, position.top + editorRect.height - window.innerHeight); | ||
| 858 | const best = candidates.reduce((current, candidate) => | ||
| 859 | overflow(candidate) < overflow(current) ? candidate : current, | ||
| 860 | ); | ||
| 861 | applyEditorPosition(best); | ||
| 774 | }; | 862 | }; |
| 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 | const onEditorPointerDown = (event: PointerEvent): void => { | 898 | const onEditorPointerDown = (event: PointerEvent): void => { |
| 777 | if (event.button !== 0) return; | 899 | if (event.button !== 0 || !editorExpanded) return; |
| 778 | const rect = editor.getBoundingClientRect(); | 900 | const rect = editor.getBoundingClientRect(); |
| 779 | editorDrag = { | 901 | editorDrag = { |
| 780 | pointerId: event.pointerId, | 902 | pointerId: event.pointerId, |
| 781 | offsetX: event.clientX - rect.left, | 903 | offsetX: event.clientX - rect.left, |
| 782 | offsetY: event.clientY - rect.top, | 904 | offsetY: event.clientY - rect.top, |
| 783 | }; | 905 | }; |
| 784 | editorHeader.setPointerCapture(event.pointerId); | 906 | dragHandle.setPointerCapture(event.pointerId); |
| 785 | editorHeader.style.cursor = "grabbing"; | 907 | dragHandle.style.cursor = "grabbing"; |
| 786 | event.preventDefault(); | 908 | event.preventDefault(); |
| 787 | event.stopPropagation(); | 909 | event.stopPropagation(); |
| 788 | }; | 910 | }; |
| ... | @@ -800,20 +922,20 @@ function startAnnotation(): void { | ... | @@ -800,20 +922,20 @@ function startAnnotation(): void { |
| 800 | const onEditorPointerUp = (event: PointerEvent): void => { | 922 | const onEditorPointerUp = (event: PointerEvent): void => { |
| 801 | if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; | 923 | if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; |
| 802 | editorDrag = null; | 924 | editorDrag = null; |
| 803 | editorHeader.style.cursor = "grab"; | 925 | dragHandle.style.cursor = "grab"; |
| 804 | if (editorHeader.hasPointerCapture(event.pointerId)) | 926 | if (dragHandle.hasPointerCapture(event.pointerId)) |
| 805 | editorHeader.releasePointerCapture(event.pointerId); | 927 | dragHandle.releasePointerCapture(event.pointerId); |
| 806 | event.preventDefault(); | 928 | event.preventDefault(); |
| 807 | event.stopPropagation(); | 929 | event.stopPropagation(); |
| 808 | }; | 930 | }; |
| 809 | editorHeader.addEventListener("pointerdown", onEditorPointerDown); | 931 | dragHandle.addEventListener("pointerdown", onEditorPointerDown); |
| 810 | editorHeader.addEventListener("pointermove", onEditorPointerMove); | 932 | dragHandle.addEventListener("pointermove", onEditorPointerMove); |
| 811 | editorHeader.addEventListener("pointerup", onEditorPointerUp); | 933 | dragHandle.addEventListener("pointerup", onEditorPointerUp); |
| 812 | editorHeader.addEventListener("pointercancel", onEditorPointerUp); | 934 | dragHandle.addEventListener("pointercancel", onEditorPointerUp); |
| 813 | 935 | ||
| 814 | const repaint = (): void => { | 936 | const repaint = (): void => { |
| 815 | for (const target of selected.values()) updateSelectedVisual(target); | 937 | for (const target of selected.values()) updateSelectedVisual(target); |
| 816 | if (editorPosition) applyEditorPosition(editorPosition); | 938 | queueEditorLayout(); |
| 817 | }; | 939 | }; |
| 818 | 940 | ||
| 819 | const removeTargetAtPoint = (x: number, y: number): boolean => { | 941 | const removeTargetAtPoint = (x: number, y: number): boolean => { |
| ... | @@ -941,7 +1063,7 @@ function startAnnotation(): void { | ... | @@ -941,7 +1063,7 @@ function startAnnotation(): void { |
| 941 | if (tool === "draw") { | 1063 | if (tool === "draw") { |
| 942 | const stroke: PreviewAnnotationStrokeTarget = { | 1064 | const stroke: PreviewAnnotationStrokeTarget = { |
| 943 | id: nextId("stroke"), | 1065 | id: nextId("stroke"), |
| 944 | color: ACCENT, | 1066 | color: annotationTheme?.primary ?? "#2563eb", |
| 945 | width: 4, | 1067 | width: 4, |
| 946 | points: [dragStart], | 1068 | points: [dragStart], |
| 947 | bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, | 1069 | bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, |
| ... | @@ -971,7 +1093,10 @@ function startAnnotation(): void { | ... | @@ -971,7 +1093,10 @@ function startAnnotation(): void { |
| 971 | if (found === 0) { | 1093 | if (found === 0) { |
| 972 | const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; | 1094 | const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; |
| 973 | regions.push(region); | 1095 | regions.push(region); |
| 974 | const regionBox = createBox(ACCENT, "rgba(124,58,237,.06)"); | 1096 | const regionBox = createBox( |
| 1097 | PRIMARY, | ||
| 1098 | "color-mix(in srgb, var(--t3-primary) 6%, transparent)", | ||
| 1099 | ); | ||
| 975 | regionBox.setAttribute("data-region-id", region.id); | 1100 | regionBox.setAttribute("data-region-id", region.id); |
| 976 | positionBox(regionBox, rect); | 1101 | positionBox(regionBox, rect); |
| 977 | root.appendChild(regionBox); | 1102 | root.appendChild(regionBox); |
| ... | @@ -1024,14 +1149,16 @@ function startAnnotation(): void { | ... | @@ -1024,14 +1149,16 @@ function startAnnotation(): void { |
| 1024 | window.removeEventListener("keydown", onKeyDown, true); | 1149 | window.removeEventListener("keydown", onKeyDown, true); |
| 1025 | window.removeEventListener("scroll", repaint, true); | 1150 | window.removeEventListener("scroll", repaint, true); |
| 1026 | window.removeEventListener("resize", repaint); | 1151 | window.removeEventListener("resize", repaint); |
| 1027 | editorHeader.removeEventListener("pointerdown", onEditorPointerDown); | 1152 | dragHandle.removeEventListener("pointerdown", onEditorPointerDown); |
| 1028 | editorHeader.removeEventListener("pointermove", onEditorPointerMove); | 1153 | dragHandle.removeEventListener("pointermove", onEditorPointerMove); |
| 1029 | editorHeader.removeEventListener("pointerup", onEditorPointerUp); | 1154 | dragHandle.removeEventListener("pointerup", onEditorPointerUp); |
| 1030 | editorHeader.removeEventListener("pointercancel", onEditorPointerUp); | 1155 | dragHandle.removeEventListener("pointercancel", onEditorPointerUp); |
| 1156 | if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame); | ||
| 1031 | ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); | 1157 | ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); |
| 1032 | ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); | 1158 | ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); |
| 1033 | document.documentElement.removeAttribute("data-t3code-annotation-tool"); | 1159 | document.documentElement.removeAttribute("data-t3code-annotation-tool"); |
| 1034 | root.remove(); | 1160 | cursorStyle.remove(); |
| 1161 | host.remove(); | ||
| 1035 | activeSession = null; | 1162 | activeSession = null; |
| 1036 | if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); | 1163 | if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); |
| 1037 | }; | 1164 | }; |
| ... | @@ -1115,11 +1242,21 @@ function startAnnotation(): void { | ... | @@ -1115,11 +1242,21 @@ function startAnnotation(): void { |
| 1115 | window.addEventListener("resize", repaint, { passive: true }); | 1242 | window.addEventListener("resize", repaint, { passive: true }); |
| 1116 | ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); | 1243 | ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); |
| 1117 | ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); | 1244 | ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); |
| 1118 | document.documentElement.appendChild(root); | 1245 | document.documentElement.appendChild(host); |
| 1119 | refreshToolButtons(); | 1246 | refreshToolButtons(); |
| 1120 | updateStatus(); | 1247 | updateStatus(); |
| 1121 | activeSession = { teardown }; | 1248 | activeSession = { |
| 1249 | teardown, | ||
| 1250 | applyTheme: (theme) => applyAnnotationTheme(host, theme), | ||
| 1251 | }; | ||
| 1122 | } | 1252 | } |
| 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 | ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); | 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,16 +3,26 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | const fromId = vi.fn(() => null); | 3 | const fromId = vi.fn(() => null); |
| 4 | const mkdir = vi.fn(async () => undefined); | 4 | const mkdir = vi.fn(async () => undefined); |
| 5 | const writeFile = vi.fn(async () => undefined); | 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 | vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); | 11 | vi.mock("node:fs/promises", () => ({ mkdir, writeFile })); |
| 8 | 12 | ||
| 9 | vi.mock("electron", () => ({ | 13 | vi.mock("electron", () => ({ |
| 10 | app: { | 14 | clipboard: { |
| 11 | getPath: vi.fn(() => "/tmp/t3-code-test"), | 15 | writeImage, |
| 16 | }, | ||
| 17 | nativeImage: { | ||
| 18 | createFromPath, | ||
| 12 | }, | 19 | }, |
| 13 | session: { | 20 | session: { |
| 14 | fromPartition: vi.fn(), | 21 | fromPartition: vi.fn(), |
| 15 | }, | 22 | }, |
| 23 | shell: { | ||
| 24 | showItemInFolder, | ||
| 25 | }, | ||
| 16 | webContents: { | 26 | webContents: { |
| 17 | fromId, | 27 | fromId, |
| 18 | }, | 28 | }, |
| ... | @@ -23,6 +33,10 @@ describe("PreviewViewManager automation status", () => { | ... | @@ -23,6 +33,10 @@ describe("PreviewViewManager automation status", () => { |
| 23 | fromId.mockClear(); | 33 | fromId.mockClear(); |
| 24 | mkdir.mockClear(); | 34 | mkdir.mockClear(); |
| 25 | writeFile.mockClear(); | 35 | writeFile.mockClear(); |
| 36 | showItemInFolder.mockClear(); | ||
| 37 | writeImage.mockClear(); | ||
| 38 | createFromPath.mockClear(); | ||
| 39 | webviewSend.mockClear(); | ||
| 26 | }); | 40 | }); |
| 27 | 41 | ||
| 28 | it("reports an unregistered webview as temporarily unavailable", async () => { | 42 | it("reports an unregistered webview as temporarily unavailable", async () => { |
| ... | @@ -59,7 +73,7 @@ describe("PreviewViewManager automation status", () => { | ... | @@ -59,7 +73,7 @@ describe("PreviewViewManager automation status", () => { |
| 59 | id: 42, | 73 | id: 42, |
| 60 | isDestroyed: () => false, | 74 | isDestroyed: () => false, |
| 61 | getType: () => "webview", | 75 | getType: () => "webview", |
| 62 | getURL: () => "https://example.com/", | 76 | getURL: () => "https://example.com:8443/path?query=value", |
| 63 | getTitle: () => "Example", | 77 | getTitle: () => "Example", |
| 64 | isLoading: () => false, | 78 | isLoading: () => false, |
| 65 | getZoomFactor: () => 1, | 79 | getZoomFactor: () => 1, |
| ... | @@ -69,6 +83,7 @@ describe("PreviewViewManager automation status", () => { | ... | @@ -69,6 +83,7 @@ describe("PreviewViewManager automation status", () => { |
| 69 | }), | 83 | }), |
| 70 | off: vi.fn(), | 84 | off: vi.fn(), |
| 71 | ipc: { on: vi.fn(), off: vi.fn() }, | 85 | ipc: { on: vi.fn(), off: vi.fn() }, |
| 86 | send: webviewSend, | ||
| 72 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | 87 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, |
| 73 | setWindowOpenHandler: vi.fn(), | 88 | setWindowOpenHandler: vi.fn(), |
| 74 | debugger: { | 89 | debugger: { |
| ... | @@ -82,13 +97,22 @@ describe("PreviewViewManager automation status", () => { | ... | @@ -82,13 +97,22 @@ describe("PreviewViewManager automation status", () => { |
| 82 | } as never); | 97 | } as never); |
| 83 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | 98 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); |
| 84 | const manager = new PreviewViewManager(); | 99 | const manager = new PreviewViewManager(); |
| 100 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | ||
| 85 | manager.createTab("tab_1"); | 101 | manager.createTab("tab_1"); |
| 86 | manager.registerWebview("tab_1", 42); | 102 | manager.registerWebview("tab_1", 42); |
| 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 | const artifact = await manager.captureScreenshot("tab_1"); | 112 | const artifact = await manager.captureScreenshot("tab_1"); |
| 89 | 113 | ||
| 90 | expect(capturePage).toHaveBeenCalledOnce(); | 114 | expect(capturePage).toHaveBeenCalledOnce(); |
| 91 | expect(mkdir).toHaveBeenCalledWith("/tmp/t3-code-test/browser-artifacts", { | 115 | expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", { |
| 92 | recursive: true, | 116 | recursive: true, |
| 93 | }); | 117 | }); |
| 94 | expect(writeFile).toHaveBeenCalledWith(artifact.path, png); | 118 | expect(writeFile).toHaveBeenCalledWith(artifact.path, png); |
| ... | @@ -97,6 +121,163 @@ describe("PreviewViewManager automation status", () => { | ... | @@ -97,6 +121,163 @@ describe("PreviewViewManager automation status", () => { |
| 97 | mimeType: "image/png", | 121 | mimeType: "image/png", |
| 98 | sizeBytes: png.byteLength, | 122 | sizeBytes: png.byteLength, |
| 99 | }); | 123 | }); |
| 100 | expect(artifact.path).toMatch(/\/browser-artifacts\/browser-screenshot-[^.]+\.png$/); | 124 | expect(artifact.path).toMatch( |
| 125 | /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, | ||
| 126 | ); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("reveals only files inside the configured browser artifact directory", async () => { | ||
| 130 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 131 | const manager = new PreviewViewManager(); | ||
| 132 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | ||
| 133 | |||
| 134 | manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"); | ||
| 135 | |||
| 136 | expect(showItemInFolder).toHaveBeenCalledWith( | ||
| 137 | "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png", | ||
| 138 | ); | ||
| 139 | expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow( | ||
| 140 | "outside the configured artifact directory", | ||
| 141 | ); | ||
| 142 | }); | ||
| 143 | |||
| 144 | it("copies screenshot artifacts to the system clipboard", async () => { | ||
| 145 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 146 | const manager = new PreviewViewManager(); | ||
| 147 | manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts"); | ||
| 148 | const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; | ||
| 149 | |||
| 150 | manager.copyArtifactToClipboard(artifactPath); | ||
| 151 | |||
| 152 | expect(createFromPath).toHaveBeenCalledWith(artifactPath); | ||
| 153 | expect(writeImage).toHaveBeenCalledOnce(); | ||
| 154 | expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow( | ||
| 155 | "outside the configured artifact directory", | ||
| 156 | ); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("emits the resolved pointer target before dispatching an automation click", async () => { | ||
| 160 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | ||
| 161 | const activity: string[] = []; | ||
| 162 | const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => { | ||
| 163 | if (method === "Runtime.evaluate") { | ||
| 164 | return { | ||
| 165 | result: { | ||
| 166 | value: { width: 800, height: 600 }, | ||
| 167 | }, | ||
| 168 | }; | ||
| 169 | } | ||
| 170 | if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") { | ||
| 171 | activity.push("mousePressed"); | ||
| 172 | humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 }); | ||
| 173 | } | ||
| 174 | return undefined; | ||
| 175 | }); | ||
| 176 | fromId.mockReturnValue({ | ||
| 177 | id: 42, | ||
| 178 | isDestroyed: () => false, | ||
| 179 | getType: () => "webview", | ||
| 180 | getURL: () => "https://example.com", | ||
| 181 | getTitle: () => "Example", | ||
| 182 | isLoading: () => false, | ||
| 183 | isDevToolsOpened: () => false, | ||
| 184 | getZoomFactor: () => 1, | ||
| 185 | setZoomFactor: vi.fn(), | ||
| 186 | on: vi.fn(), | ||
| 187 | off: vi.fn(), | ||
| 188 | ipc: { | ||
| 189 | on: vi.fn((channel: string, listener: typeof humanInput) => { | ||
| 190 | if (channel === "preview:human-input") humanInput = listener; | ||
| 191 | }), | ||
| 192 | off: vi.fn(), | ||
| 193 | }, | ||
| 194 | send: webviewSend, | ||
| 195 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 196 | setWindowOpenHandler: vi.fn(), | ||
| 197 | debugger: { | ||
| 198 | isAttached: () => false, | ||
| 199 | attach: vi.fn(), | ||
| 200 | sendCommand, | ||
| 201 | on: vi.fn(), | ||
| 202 | off: vi.fn(), | ||
| 203 | }, | ||
| 204 | } as never); | ||
| 205 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 206 | const manager = new PreviewViewManager(); | ||
| 207 | manager.onPointerEvent((event) => activity.push(event.phase)); | ||
| 208 | manager.createTab("tab_1"); | ||
| 209 | manager.registerWebview("tab_1", 42); | ||
| 210 | |||
| 211 | await manager.automationClick("tab_1", { x: 120, y: 80 }); | ||
| 212 | |||
| 213 | expect(activity).toEqual(["move", "click", "mousePressed"]); | ||
| 214 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { | ||
| 215 | type: "mousePressed", | ||
| 216 | x: 120, | ||
| 217 | y: 80, | ||
| 218 | button: "left", | ||
| 219 | clickCount: 1, | ||
| 220 | }); | ||
| 221 | expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { | ||
| 222 | type: "mouseReleased", | ||
| 223 | x: 120, | ||
| 224 | y: 80, | ||
| 225 | button: "left", | ||
| 226 | clickCount: 1, | ||
| 227 | }); | ||
| 228 | }); | ||
| 229 | |||
| 230 | it("still interrupts agent control for a different human pointer event", async () => { | ||
| 231 | let humanInput: ((_event: unknown, signal: unknown) => void) | undefined; | ||
| 232 | const sendCommand = vi.fn(async (method: string) => { | ||
| 233 | if (method === "Runtime.evaluate") { | ||
| 234 | return { | ||
| 235 | result: { | ||
| 236 | value: { width: 800, height: 600 }, | ||
| 237 | }, | ||
| 238 | }; | ||
| 239 | } | ||
| 240 | if (method === "Input.dispatchMouseEvent") { | ||
| 241 | humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 }); | ||
| 242 | } | ||
| 243 | return undefined; | ||
| 244 | }); | ||
| 245 | fromId.mockReturnValue({ | ||
| 246 | id: 42, | ||
| 247 | isDestroyed: () => false, | ||
| 248 | getType: () => "webview", | ||
| 249 | getURL: () => "https://example.com", | ||
| 250 | getTitle: () => "Example", | ||
| 251 | isLoading: () => false, | ||
| 252 | isDevToolsOpened: () => false, | ||
| 253 | getZoomFactor: () => 1, | ||
| 254 | setZoomFactor: vi.fn(), | ||
| 255 | on: vi.fn(), | ||
| 256 | off: vi.fn(), | ||
| 257 | ipc: { | ||
| 258 | on: vi.fn((channel: string, listener: typeof humanInput) => { | ||
| 259 | if (channel === "preview:human-input") humanInput = listener; | ||
| 260 | }), | ||
| 261 | off: vi.fn(), | ||
| 262 | }, | ||
| 263 | send: webviewSend, | ||
| 264 | navigationHistory: { canGoBack: () => false, canGoForward: () => false }, | ||
| 265 | setWindowOpenHandler: vi.fn(), | ||
| 266 | debugger: { | ||
| 267 | isAttached: () => false, | ||
| 268 | attach: vi.fn(), | ||
| 269 | sendCommand, | ||
| 270 | on: vi.fn(), | ||
| 271 | off: vi.fn(), | ||
| 272 | }, | ||
| 273 | } as never); | ||
| 274 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | ||
| 275 | const manager = new PreviewViewManager(); | ||
| 276 | manager.createTab("tab_1"); | ||
| 277 | manager.registerWebview("tab_1", 42); | ||
| 278 | |||
| 279 | await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({ | ||
| 280 | name: "PreviewAutomationControlInterruptedError", | ||
| 281 | }); | ||
| 101 | }); | 282 | }); |
| 102 | }); | 283 | }); |
apps/desktop/src/preview-view-manager.ts+224-8| ... | @@ -8,6 +8,8 @@ | ... | @@ -8,6 +8,8 @@ |
| 8 | * here). Single layer-scoped browser session partition. | 8 | * here). Single layer-scoped browser session partition. |
| 9 | */ | 9 | */ |
| 10 | import type { | 10 | import type { |
| 11 | DesktopPreviewAnnotationTheme, | ||
| 12 | DesktopPreviewPointerEvent, | ||
| 11 | PreviewAnnotationPayload, | 13 | PreviewAnnotationPayload, |
| 12 | PreviewAnnotationRect, | 14 | PreviewAnnotationRect, |
| 13 | DesktopPreviewRecordingArtifact, | 15 | DesktopPreviewRecordingArtifact, |
| ... | @@ -26,9 +28,17 @@ import type { | ... | @@ -26,9 +28,17 @@ import type { |
| 26 | PreviewAutomationWaitForInput, | 28 | PreviewAutomationWaitForInput, |
| 27 | } from "@t3tools/contracts"; | 29 | } from "@t3tools/contracts"; |
| 28 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; | 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 | import { mkdir, writeFile } from "node:fs/promises"; | 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 | import { createHash } from "node:crypto"; | 42 | import { createHash } from "node:crypto"; |
| 33 | import { setTimeout as sleep } from "node:timers/promises"; | 43 | import { setTimeout as sleep } from "node:timers/promises"; |
| 34 | 44 | ||
| ... | @@ -40,6 +50,7 @@ const START_PICK_CHANNEL = "preview:start-pick"; | ... | @@ -40,6 +50,7 @@ const START_PICK_CHANNEL = "preview:start-pick"; |
| 40 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; | 50 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; |
| 41 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | 51 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; |
| 42 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; | 52 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; |
| 53 | const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; | ||
| 43 | const HUMAN_INPUT_CHANNEL = "preview:human-input"; | 54 | const HUMAN_INPUT_CHANNEL = "preview:human-input"; |
| 44 | 55 | ||
| 45 | // Re-export the guest webview security posture from its dedicated module so | 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,6 +94,43 @@ const MAX_VISIBLE_TEXT_LENGTH = 20_000; |
| 83 | const MAX_INTERACTIVE_ELEMENTS = 200; | 94 | const MAX_INTERACTIVE_ELEMENTS = 200; |
| 84 | const MAX_SCREENSHOT_WIDTH = 1280; | 95 | const MAX_SCREENSHOT_WIDTH = 1280; |
| 85 | const DIAGNOSTIC_BUFFER_LIMIT = 200; | 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 | interface CdpEvaluationResult { | 135 | interface CdpEvaluationResult { |
| 88 | readonly result?: { | 136 | readonly result?: { |
| ... | @@ -182,10 +230,14 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { | ... | @@ -182,10 +230,14 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { |
| 182 | type Listener = (tabId: string, state: PreviewTabState) => void; | 230 | type Listener = (tabId: string, state: PreviewTabState) => void; |
| 183 | type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; | 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 | interface ManagedListeners { | 237 | interface ManagedListeners { |
| 186 | navigate: () => void; | 238 | navigate: () => void; |
| 187 | failed: (event: Event, code: number, description: string) => void; | 239 | failed: (event: Event, code: number, description: string) => void; |
| 188 | humanInput: () => void; | 240 | humanInput: (_event: unknown, signal?: unknown) => void; |
| 189 | } | 241 | } |
| 190 | 242 | ||
| 191 | interface PickSession { | 243 | interface PickSession { |
| ... | @@ -205,6 +257,13 @@ interface BrowserDiagnostics { | ... | @@ -205,6 +257,13 @@ interface BrowserDiagnostics { |
| 205 | readonly requests: Map<string, { url: string; method: string }>; | 257 | readonly requests: Map<string, { url: string; method: string }>; |
| 206 | } | 258 | } |
| 207 | 259 | ||
| 260 | type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; | ||
| 261 | |||
| 262 | interface ExpectedAgentInput { | ||
| 263 | readonly signal: PreviewInputSignal; | ||
| 264 | readonly expiresAt: number; | ||
| 265 | } | ||
| 266 | |||
| 208 | const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ | 267 | const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ |
| 209 | key: string; | 268 | key: string; |
| 210 | meta: boolean; | 269 | meta: boolean; |
| ... | @@ -221,23 +280,116 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ | ... | @@ -221,23 +280,116 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ |
| 221 | { key: "w", meta: true, shift: false, control: false }, | 280 | { key: "w", meta: true, shift: false, control: false }, |
| 222 | ]); | 281 | ]); |
| 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 | export class PreviewViewManager { | 321 | export class PreviewViewManager { |
| 322 | private annotationTheme = DEFAULT_ANNOTATION_THEME; | ||
| 323 | private artifactDirectory: string | null = null; | ||
| 225 | private mainWindow: BrowserWindow | null = null; | 324 | private mainWindow: BrowserWindow | null = null; |
| 226 | private readonly tabs = new Map<string, PreviewTabState>(); | 325 | private readonly tabs = new Map<string, PreviewTabState>(); |
| 227 | private readonly attached = new Map<number, ManagedListeners>(); | 326 | private readonly attached = new Map<number, ManagedListeners>(); |
| 228 | private readonly browserSessions = new Map<string, Session>(); | 327 | private readonly browserSessions = new Map<string, Session>(); |
| 229 | private readonly listeners = new Set<Listener>(); | 328 | private readonly listeners = new Set<Listener>(); |
| 329 | private readonly pointerEventListeners = new Set<PointerEventListener>(); | ||
| 230 | private readonly recordingFrameListeners = new Set<RecordingFrameListener>(); | 330 | private readonly recordingFrameListeners = new Set<RecordingFrameListener>(); |
| 231 | /** In-flight preview annotation sessions, keyed by tabId. */ | 331 | /** In-flight preview annotation sessions, keyed by tabId. */ |
| 232 | private readonly pickSessions = new Map<string, PickSession>(); | 332 | private readonly pickSessions = new Map<string, PickSession>(); |
| 233 | /** One long-lived CDP attachment and serialized command queue per guest. */ | 333 | /** One long-lived CDP attachment and serialized command queue per guest. */ |
| 234 | private readonly controlSessions = new Map<number, BrowserControlSession>(); | 334 | private readonly controlSessions = new Map<number, BrowserControlSession>(); |
| 235 | private readonly diagnostics = new Map<number, BrowserDiagnostics>(); | 335 | private readonly diagnostics = new Map<number, BrowserDiagnostics>(); |
| 336 | private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>(); | ||
| 236 | private readonly controlEpoch = new Map<string, number>(); | 337 | private readonly controlEpoch = new Map<string, number>(); |
| 237 | private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>(); | 338 | private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>(); |
| 238 | private actionSequence = 0; | 339 | private actionSequence = 0; |
| 340 | private pointerSequence = 0; | ||
| 239 | private recordingTabId: string | null = null; | 341 | private recordingTabId: string | null = null; |
| 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 | setMainWindow(window: BrowserWindow): void { | 393 | setMainWindow(window: BrowserWindow): void { |
| 242 | this.mainWindow = window; | 394 | this.mainWindow = window; |
| 243 | } | 395 | } |
| ... | @@ -338,6 +490,7 @@ export class PreviewViewManager { | ... | @@ -338,6 +490,7 @@ export class PreviewViewManager { |
| 338 | throw new PreviewWebContentsNotFoundError(tabId, webContentsId); | 490 | throw new PreviewWebContentsNotFoundError(tabId, webContentsId); |
| 339 | } | 491 | } |
| 340 | if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) { | 492 | if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) { |
| 493 | wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); | ||
| 341 | return; | 494 | return; |
| 342 | } | 495 | } |
| 343 | if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { | 496 | if (tab.webContentsId != null && tab.webContentsId !== webContentsId) { |
| ... | @@ -366,6 +519,7 @@ export class PreviewViewManager { | ... | @@ -366,6 +519,7 @@ export class PreviewViewManager { |
| 366 | canGoForward: wc.navigationHistory.canGoForward(), | 519 | canGoForward: wc.navigationHistory.canGoForward(), |
| 367 | zoomFactor: tab.zoomFactor, | 520 | zoomFactor: tab.zoomFactor, |
| 368 | }); | 521 | }); |
| 522 | wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme); | ||
| 369 | } | 523 | } |
| 370 | 524 | ||
| 371 | async navigate(tabId: string, rawUrl: string): Promise<void> { | 525 | async navigate(tabId: string, rawUrl: string): Promise<void> { |
| ... | @@ -513,7 +667,7 @@ export class PreviewViewManager { | ... | @@ -513,7 +667,7 @@ export class PreviewViewManager { |
| 513 | // wc may be torn down; the next try/catch settles. | 667 | // wc may be torn down; the next try/catch settles. |
| 514 | } | 668 | } |
| 515 | try { | 669 | try { |
| 516 | wc.send(START_PICK_CHANNEL); | 670 | wc.send(START_PICK_CHANNEL, this.annotationTheme); |
| 517 | } catch { | 671 | } catch { |
| 518 | settle(null); | 672 | settle(null); |
| 519 | } | 673 | } |
| ... | @@ -561,8 +715,8 @@ export class PreviewViewManager { | ... | @@ -561,8 +715,8 @@ export class PreviewViewManager { |
| 561 | async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> { | 715 | async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> { |
| 562 | const wc = this.requireWebContents(tabId); | 716 | const wc = this.requireWebContents(tabId); |
| 563 | const createdAt = new Date().toISOString(); | 717 | const createdAt = new Date().toISOString(); |
| 564 | const id = `browser-screenshot-${Date.now().toString(36)}`; | 718 | const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`; |
| 565 | const directory = join(app.getPath("userData"), "browser-artifacts"); | 719 | const directory = this.requireArtifactDirectory(); |
| 566 | const path = join(directory, `${id}.png`); | 720 | const path = join(directory, `${id}.png`); |
| 567 | const data = (await wc.capturePage()).toPNG(); | 721 | const data = (await wc.capturePage()).toPNG(); |
| 568 | await mkdir(directory, { recursive: true }); | 722 | await mkdir(directory, { recursive: true }); |
| ... | @@ -587,7 +741,7 @@ export class PreviewViewManager { | ... | @@ -587,7 +741,7 @@ export class PreviewViewManager { |
| 587 | const createdAt = new Date().toISOString(); | 741 | const createdAt = new Date().toISOString(); |
| 588 | const id = `browser-recording-${Date.now().toString(36)}`; | 742 | const id = `browser-recording-${Date.now().toString(36)}`; |
| 589 | const extension = mimeType.includes("mp4") ? "mp4" : "webm"; | 743 | const extension = mimeType.includes("mp4") ? "mp4" : "webm"; |
| 590 | const directory = join(app.getPath("userData"), "browser-artifacts"); | 744 | const directory = this.requireArtifactDirectory(); |
| 591 | const path = join(directory, `${id}.${extension}`); | 745 | const path = join(directory, `${id}.${extension}`); |
| 592 | await mkdir(directory, { recursive: true }); | 746 | await mkdir(directory, { recursive: true }); |
| 593 | await writeFile(path, data); | 747 | await writeFile(path, data); |
| ... | @@ -793,6 +947,25 @@ export class PreviewViewManager { | ... | @@ -793,6 +947,25 @@ export class PreviewViewManager { |
| 793 | `Click coordinates (${x}, ${y}) are outside the preview viewport.`, | 947 | `Click coordinates (${x}, ${y}) are outside the preview viewport.`, |
| 794 | ); | 948 | ); |
| 795 | } | 949 | } |
| 950 | this.emitPointerEvent({ | ||
| 951 | tabId, | ||
| 952 | phase: "move", | ||
| 953 | x, | ||
| 954 | y, | ||
| 955 | sequence: this.pointerSequence++, | ||
| 956 | createdAt: new Date().toISOString(), | ||
| 957 | }); | ||
| 958 | await sleep(AGENT_CURSOR_MOVE_MS); | ||
| 959 | this.emitPointerEvent({ | ||
| 960 | tabId, | ||
| 961 | phase: "click", | ||
| 962 | x, | ||
| 963 | y, | ||
| 964 | sequence: this.pointerSequence++, | ||
| 965 | createdAt: new Date().toISOString(), | ||
| 966 | }); | ||
| 967 | await sleep(AGENT_CURSOR_CLICK_LEAD_MS); | ||
| 968 | this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 }); | ||
| 796 | await send("Input.dispatchMouseEvent", { | 969 | await send("Input.dispatchMouseEvent", { |
| 797 | type: "mousePressed", | 970 | type: "mousePressed", |
| 798 | x, | 971 | x, |
| ... | @@ -886,6 +1059,7 @@ export class PreviewViewManager { | ... | @@ -886,6 +1059,7 @@ export class PreviewViewManager { |
| 886 | modifiers, | 1059 | modifiers, |
| 887 | ...(text ? { text, unmodifiedText: text } : {}), | 1060 | ...(text ? { text, unmodifiedText: text } : {}), |
| 888 | }; | 1061 | }; |
| 1062 | this.expectAgentInput(tabId, { kind: "key", key, code: params.code }); | ||
| 889 | await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); | 1063 | await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); |
| 890 | await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); | 1064 | await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); |
| 891 | }); | 1065 | }); |
| ... | @@ -1345,11 +1519,50 @@ export class PreviewViewManager { | ... | @@ -1345,11 +1519,50 @@ export class PreviewViewManager { |
| 1345 | }; | 1519 | }; |
| 1346 | } | 1520 | } |
| 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 | destroy(): void { | 1559 | destroy(): void { |
| 1349 | for (const tabId of Array.from(this.tabs.keys())) { | 1560 | for (const tabId of Array.from(this.tabs.keys())) { |
| 1350 | this.closeTab(tabId); | 1561 | this.closeTab(tabId); |
| 1351 | } | 1562 | } |
| 1352 | this.listeners.clear(); | 1563 | this.listeners.clear(); |
| 1564 | this.expectedAgentInputs.clear(); | ||
| 1565 | this.pointerEventListeners.clear(); | ||
| 1353 | this.recordingFrameListeners.clear(); | 1566 | this.recordingFrameListeners.clear(); |
| 1354 | } | 1567 | } |
| 1355 | 1568 | ||
| ... | @@ -1375,7 +1588,10 @@ export class PreviewViewManager { | ... | @@ -1375,7 +1588,10 @@ export class PreviewViewManager { |
| 1375 | }, | 1588 | }, |
| 1376 | }); | 1589 | }); |
| 1377 | }; | 1590 | }; |
| 1378 | const humanInput = (): void => { | 1591 | const humanInput = (_event: unknown, rawSignal?: unknown): void => { |
| 1592 | if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) { | ||
| 1593 | return; | ||
| 1594 | } | ||
| 1379 | this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); | 1595 | this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1); |
| 1380 | this.update(tabId, { controller: "human" }); | 1596 | this.update(tabId, { controller: "human" }); |
| 1381 | void sleep(750).then(() => { | 1597 | void sleep(750).then(() => { |
apps/desktop/vite.config.ts+4-3| ... | @@ -14,17 +14,18 @@ export default defineConfig({ | ... | @@ -14,17 +14,18 @@ export default defineConfig({ |
| 14 | run: { | 14 | run: { |
| 15 | tasks: { | 15 | tasks: { |
| 16 | build: { | 16 | build: { |
| 17 | command: "vp pack", | 17 | command: "node scripts/build-preview-annotation-css.mjs && vp pack", |
| 18 | dependsOn: ["t3#build"], | 18 | dependsOn: ["t3#build"], |
| 19 | cache: false, | 19 | cache: false, |
| 20 | }, | 20 | }, |
| 21 | dev: { | 21 | dev: { |
| 22 | command: "cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", | 22 | command: |
| 23 | "node scripts/build-preview-annotation-css.mjs && cross-env T3CODE_DESKTOP_DEV=1 vp pack --watch", | ||
| 23 | dependsOn: ["t3#build"], | 24 | dependsOn: ["t3#build"], |
| 24 | cache: false, | 25 | cache: false, |
| 25 | }, | 26 | }, |
| 26 | "dev:bundle": { | 27 | "dev:bundle": { |
| 27 | command: "vp pack --watch", | 28 | command: "node scripts/build-preview-annotation-css.mjs && vp pack --watch", |
| 28 | cache: false, | 29 | cache: false, |
| 29 | }, | 30 | }, |
| 30 | "dev:electron": { | 31 | "dev:electron": { |
apps/server/src/keybindings.test.ts+2| ... | @@ -205,6 +205,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { | ... | @@ -205,6 +205,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 205 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); | 205 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); |
| 206 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); | 206 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); |
| 207 | assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); | 207 | assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); |
| 208 | assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); | ||
| 209 | assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); | ||
| 208 | assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); | 210 | assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); |
| 209 | assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); | 211 | assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); |
| 210 | }), | 212 | }), |
apps/server/src/mcp/Layers/McpHttpServer.test.ts+20-8| ... | @@ -75,14 +75,16 @@ it.effect("registers annotated tools and preserves authenticated request context | ... | @@ -75,14 +75,16 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 75 | height: 5, | 75 | height: 5, |
| 76 | }, | 76 | }, |
| 77 | } | 77 | } |
| 78 | : { | 78 | : request.operation === "press" |
| 79 | available: true, | 79 | ? undefined |
| 80 | visible: true, | 80 | : { |
| 81 | tabId, | 81 | available: true, |
| 82 | url: "http://example.test/", | 82 | visible: true, |
| 83 | title: "Example", | 83 | tabId, |
| 84 | loading: false, | 84 | url: "http://example.test/", |
| 85 | }, | 85 | title: "Example", |
| 86 | loading: false, | ||
| 87 | }, | ||
| 86 | }), | 88 | }), |
| 87 | ).pipe(Effect.forkScoped); | 89 | ).pipe(Effect.forkScoped); |
| 88 | yield* Effect.yieldNow; | 90 | yield* Effect.yieldNow; |
| ... | @@ -146,6 +148,16 @@ it.effect("registers annotated tools and preserves authenticated request context | ... | @@ -146,6 +148,16 @@ it.effect("registers annotated tools and preserves authenticated request context |
| 146 | expect(snapshot.structuredContent).toMatchObject({ | 148 | expect(snapshot.structuredContent).toMatchObject({ |
| 147 | screenshot: { mimeType: "image/png", width: 10, height: 5 }, | 149 | screenshot: { mimeType: "image/png", width: 10, height: 5 }, |
| 148 | }); | 150 | }); |
| 151 | |||
| 152 | const press = yield* server | ||
| 153 | .callTool({ name: "preview_press", arguments: { key: "Enter" } }) | ||
| 154 | .pipe( | ||
| 155 | Effect.provideService(McpInvocationContext, invocation), | ||
| 156 | Effect.provideService(McpSchema.McpServerClient, client), | ||
| 157 | ); | ||
| 158 | expect(press.isError).toBe(false); | ||
| 159 | expect(press.structuredContent).toBeUndefined(); | ||
| 160 | expect(press.content).toEqual([{ type: "text", text: "null" }]); | ||
| 149 | }), | 161 | }), |
| 150 | ).pipe(Effect.provide(TestLayer)), | 162 | ).pipe(Effect.provide(TestLayer)), |
| 151 | ); | 163 | ); |
apps/server/src/mcp/Layers/McpHttpServer.ts+5-2| ... | @@ -143,11 +143,14 @@ export const PreviewToolkitRegistrationLive = Layer.effectDiscard( | ... | @@ -143,11 +143,14 @@ export const PreviewToolkitRegistrationLive = Layer.effectDiscard( |
| 143 | ], | 143 | ], |
| 144 | }); | 144 | }); |
| 145 | } | 145 | } |
| 146 | const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; | ||
| 146 | return new McpSchema.CallToolResult({ | 147 | return new McpSchema.CallToolResult({ |
| 147 | isError: false, | 148 | isError: false, |
| 148 | structuredContent: | 149 | structuredContent: |
| 149 | typeof result.encodedResult === "object" ? result.encodedResult : undefined, | 150 | result.encodedResult !== null && typeof result.encodedResult === "object" |
| 150 | content: [{ type: "text", text: JSON.stringify(result.encodedResult) }], | 151 | ? result.encodedResult |
| 152 | : undefined, | ||
| 153 | content: [{ type: "text", text: encodedResultText }], | ||
| 151 | }); | 154 | }); |
| 152 | }, | 155 | }, |
| 153 | }), | 156 | }), |
apps/server/src/preview/Layers/PortScanner.test.ts+66-9| ... | @@ -1,18 +1,20 @@ | ... | @@ -1,18 +1,20 @@ |
| 1 | import * as net from "node:net"; | 1 | import * as net from "node:net"; |
| 2 | 2 | ||
| 3 | import { it as effectIt } from "@effect/vitest"; | 3 | import { it as effectIt } from "@effect/vitest"; |
| 4 | import { ThreadId } from "@t3tools/contracts"; | ||
| 4 | import { Effect, Layer } from "effect"; | 5 | import { Effect, Layer } from "effect"; |
| 5 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; | 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 | import { ProcessRunner } from "../../processRunner.ts"; | 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 | const TestProcessRunner = Layer.succeed(ProcessRunner, { | 14 | const TestProcessRunner = Layer.succeed(ProcessRunner, { |
| 13 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), | 15 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), |
| 14 | }); | 16 | }); |
| 15 | const TestPreviewPortScannerLive = PreviewPortScannerLive.pipe(Layer.provide(TestProcessRunner)); | 17 | const TestPortDiscoveryLive = PortDiscoveryLive.pipe(Layer.provide(TestProcessRunner)); |
| 16 | 18 | ||
| 17 | describe("parsePortFromLsofName", () => { | 19 | describe("parsePortFromLsofName", () => { |
| 18 | it("parses *:port", () => { | 20 | it("parses *:port", () => { |
| ... | @@ -70,6 +72,7 @@ describe("parseLsofOutput", () => { | ... | @@ -70,6 +72,7 @@ describe("parseLsofOutput", () => { |
| 70 | url: "http://localhost:3000", | 72 | url: "http://localhost:3000", |
| 71 | processName: "next-server", | 73 | processName: "next-server", |
| 72 | pid: 67890, | 74 | pid: 67890, |
| 75 | terminal: null, | ||
| 73 | }, | 76 | }, |
| 74 | { | 77 | { |
| 75 | host: "localhost", | 78 | host: "localhost", |
| ... | @@ -77,6 +80,7 @@ describe("parseLsofOutput", () => { | ... | @@ -77,6 +80,7 @@ describe("parseLsofOutput", () => { |
| 77 | url: "http://localhost:5173", | 80 | url: "http://localhost:5173", |
| 78 | processName: "node", | 81 | processName: "node", |
| 79 | pid: 12345, | 82 | pid: 12345, |
| 83 | terminal: null, | ||
| 80 | }, | 84 | }, |
| 81 | { | 85 | { |
| 82 | host: "localhost", | 86 | host: "localhost", |
| ... | @@ -84,6 +88,7 @@ describe("parseLsofOutput", () => { | ... | @@ -84,6 +88,7 @@ describe("parseLsofOutput", () => { |
| 84 | url: "http://localhost:9229", | 88 | url: "http://localhost:9229", |
| 85 | processName: "next-server", | 89 | processName: "next-server", |
| 86 | pid: 67890, | 90 | pid: 67890, |
| 91 | terminal: null, | ||
| 87 | }, | 92 | }, |
| 88 | ]); | 93 | ]); |
| 89 | }); | 94 | }); |
| ... | @@ -98,6 +103,26 @@ describe("parseLsofOutput", () => { | ... | @@ -98,6 +103,26 @@ describe("parseLsofOutput", () => { |
| 98 | expect(servers).toHaveLength(1); | 103 | expect(servers).toHaveLength(1); |
| 99 | expect(servers[0]?.port).toBe(5173); | 104 | expect(servers[0]?.port).toBe(5173); |
| 100 | }); | 105 | }); |
| 106 | |||
| 107 | it("attributes listeners to a registered terminal process", () => { | ||
| 108 | const servers = parseLsofOutput( | ||
| 109 | ["p12345", "cnode", "n*:5173"].join("\n"), | ||
| 110 | new Map([ | ||
| 111 | [ | ||
| 112 | 12345, | ||
| 113 | { | ||
| 114 | threadId: ThreadId.make("thread-1"), | ||
| 115 | terminalId: "terminal-1", | ||
| 116 | }, | ||
| 117 | ], | ||
| 118 | ]), | ||
| 119 | ); | ||
| 120 | |||
| 121 | expect(servers[0]?.terminal).toEqual({ | ||
| 122 | threadId: "thread-1", | ||
| 123 | terminalId: "terminal-1", | ||
| 124 | }); | ||
| 125 | }); | ||
| 101 | }); | 126 | }); |
| 102 | 127 | ||
| 103 | describe("serversEqual", () => { | 128 | describe("serversEqual", () => { |
| ... | @@ -107,6 +132,7 @@ describe("serversEqual", () => { | ... | @@ -107,6 +132,7 @@ describe("serversEqual", () => { |
| 107 | url: "http://localhost:5173", | 132 | url: "http://localhost:5173", |
| 108 | processName: "node", | 133 | processName: "node", |
| 109 | pid: 1, | 134 | pid: 1, |
| 135 | terminal: null, | ||
| 110 | }; | 136 | }; |
| 111 | it("returns true for identical lists", () => { | 137 | it("returns true for identical lists", () => { |
| 112 | expect(serversEqual([a], [{ ...a }])).toBe(true); | 138 | expect(serversEqual([a], [{ ...a }])).toBe(true); |
| ... | @@ -119,12 +145,43 @@ describe("serversEqual", () => { | ... | @@ -119,12 +145,43 @@ describe("serversEqual", () => { |
| 119 | }); | 145 | }); |
| 120 | }); | 146 | }); |
| 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 | * Integration tests against a real TCP listener. We force the Windows code | 180 | * Integration tests against a real TCP listener. We force the Windows code |
| 124 | * path (TCP-probe fallback) by monkey-patching `process.platform` for the | 181 | * path (TCP-probe fallback) by monkey-patching `process.platform` for the |
| 125 | * duration of the test so we don't depend on `lsof` being installed. | 182 | * duration of the test so we don't depend on `lsof` being installed. |
| 126 | */ | 183 | */ |
| 127 | describe("PreviewPortScanner integration (TCP probe path)", () => { | 184 | describe("PortDiscovery integration (TCP probe fallback)", () => { |
| 128 | let originalPlatform: NodeJS.Platform; | 185 | let originalPlatform: NodeJS.Platform; |
| 129 | let server: net.Server; | 186 | let server: net.Server; |
| 130 | let port: number; | 187 | let port: number; |
| ... | @@ -158,18 +215,18 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { | ... | @@ -158,18 +215,18 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { |
| 158 | 215 | ||
| 159 | effectIt.effect("scan() returns a server we just opened on a curated dev port", () => | 216 | effectIt.effect("scan() returns a server we just opened on a curated dev port", () => |
| 160 | Effect.gen(function* () { | 217 | Effect.gen(function* () { |
| 161 | const scanner = yield* PreviewPortScanner; | 218 | const scanner = yield* PortDiscovery; |
| 162 | const result = yield* scanner.scan(); | 219 | const result = yield* scanner.scan(); |
| 163 | const found = result.find((server) => server.port === port); | 220 | const found = result.find((server) => server.port === port); |
| 164 | expect(found).toBeDefined(); | 221 | expect(found).toBeDefined(); |
| 165 | expect(found?.host).toBe("localhost"); | 222 | expect(found?.host).toBe("localhost"); |
| 166 | }).pipe(Effect.provide(TestPreviewPortScannerLive)), | 223 | }).pipe(Effect.provide(TestPortDiscoveryLive)), |
| 167 | ); | 224 | ); |
| 168 | 225 | ||
| 169 | effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { | 226 | effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { |
| 170 | const received: number[] = []; | 227 | const received: number[] = []; |
| 171 | return Effect.gen(function* () { | 228 | return Effect.gen(function* () { |
| 172 | const scanner = yield* PreviewPortScanner; | 229 | const scanner = yield* PortDiscovery; |
| 173 | const unsubscribe = yield* scanner.subscribe((servers) => | 230 | const unsubscribe = yield* scanner.subscribe((servers) => |
| 174 | Effect.sync(() => { | 231 | Effect.sync(() => { |
| 175 | for (const server of servers) received.push(server.port); | 232 | for (const server of servers) received.push(server.port); |
| ... | @@ -179,6 +236,6 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { | ... | @@ -179,6 +236,6 @@ describe("PreviewPortScanner integration (TCP probe path)", () => { |
| 179 | unsubscribe(); | 236 | unsubscribe(); |
| 180 | release(); | 237 | release(); |
| 181 | expect(received).toContain(port); | 238 | expect(received).toContain(port); |
| 182 | }).pipe(Effect.provide(TestPreviewPortScannerLive)); | 239 | }).pipe(Effect.provide(TestPortDiscoveryLive)); |
| 183 | }); | 240 | }); |
| 184 | }); | 241 | }); |
apps/server/src/preview/Layers/PortScanner.ts+121-34| ... | @@ -13,32 +13,42 @@ | ... | @@ -13,32 +13,42 @@ |
| 13 | */ | 13 | */ |
| 14 | import * as net from "node:net"; | 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 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; | 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 | import { | 21 | import { |
| 22 | COMMON_DEV_PORTS, | 22 | COMMON_DEV_PORTS, |
| 23 | PreviewPortScanner, | 23 | PortDiscovery, |
| 24 | type PreviewPortScannerShape, | 24 | type PortDiscoveryShape, |
| 25 | } from "../Services/PortScanner.ts"; | 25 | } from "../Services/PortScanner.ts"; |
| 26 | 26 | ||
| 27 | const POLL_INTERVAL = Duration.seconds(3); | 27 | const POLL_INTERVAL = Duration.seconds(3); |
| 28 | const TCP_PROBE_TIMEOUT_MS = 200; | 28 | const TCP_PROBE_TIMEOUT_MS = 200; |
| 29 | const LSOF_TIMEOUT_MS = 5_000; | 29 | const LSOF_TIMEOUT_MS = 5_000; |
| 30 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; | ||
| 30 | 31 | ||
| 31 | type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>; | 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 | interface ScannerState { | 34 | interface ScannerState { |
| 38 | readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; | 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 | const seen = new Map<string, DiscoveredLocalServer>(); | 52 | const seen = new Map<string, DiscoveredLocalServer>(); |
| 43 | let pid: number | null = null; | 53 | let pid: number | null = null; |
| 44 | let processName: string | null = null; | 54 | let processName: string | null = null; |
| ... | @@ -69,6 +79,7 @@ const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => { | ... | @@ -69,6 +79,7 @@ const parseLsofOutput = (raw: string): ReadonlyArray<DiscoveredLocalServer> => { |
| 69 | url, | 79 | url, |
| 70 | processName, | 80 | processName, |
| 71 | pid, | 81 | pid, |
| 82 | terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), | ||
| 72 | }); | 83 | }); |
| 73 | } | 84 | } |
| 74 | } | 85 | } |
| ... | @@ -91,22 +102,31 @@ const parsePortFromLsofName = (name: string): number | null => { | ... | @@ -91,22 +102,31 @@ const parsePortFromLsofName = (name: string): number | null => { |
| 91 | return port; | 102 | return port; |
| 92 | }; | 103 | }; |
| 93 | 104 | ||
| 94 | const probeLsof = ( | 105 | const parseWindowsListenerOutput = ( |
| 95 | processRunner: ProcessRunnerShape, | 106 | raw: string, |
| 96 | ): Effect.Effect<ReadonlyArray<DiscoveredLocalServer> | null> => | 107 | terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), |
| 97 | processRunner | 108 | ): ReadonlyArray<DiscoveredLocalServer> => { |
| 98 | .run({ | 109 | const seen = new Map<number, DiscoveredLocalServer>(); |
| 99 | command: "lsof", | 110 | for (const line of raw.split(/\r?\n/g)) { |
| 100 | args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], | 111 | const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); |
| 101 | timeout: Duration.millis(LSOF_TIMEOUT_MS), | 112 | const host = hostRaw?.trim() ?? ""; |
| 102 | maxOutputBytes: 1024 * 1024, | 113 | if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; |
| 103 | outputMode: "truncate", | 114 | const port = Number(portRaw); |
| 104 | }) | 115 | const pid = Number(pidRaw); |
| 105 | .pipe( | 116 | if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; |
| 106 | Effect.mapError((cause) => new LsofProbeError({ cause })), | 117 | const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; |
| 107 | Effect.map((result) => parseLsofOutput(result.stdout)), | 118 | if (seen.has(port)) continue; |
| 108 | Effect.orElseSucceed(() => null), | 119 | seen.set(port, { |
| 109 | ); | 120 | host: "localhost", |
| 121 | port, | ||
| 122 | url: `http://localhost:${port}`, | ||
| 123 | processName: processNameRaw?.trim() || null, | ||
| 124 | pid: normalizedPid, | ||
| 125 | terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), | ||
| 126 | }); | ||
| 127 | } | ||
| 128 | return [...seen.values()].toSorted((left, right) => left.port - right.port); | ||
| 129 | }; | ||
| 110 | 130 | ||
| 111 | const probeTcpPort = (port: number): Promise<boolean> => | 131 | const probeTcpPort = (port: number): Promise<boolean> => |
| 112 | new Promise((resolve) => { | 132 | new Promise((resolve) => { |
| ... | @@ -138,6 +158,7 @@ const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> | ... | @@ -138,6 +158,7 @@ const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> |
| 138 | url: `http://localhost:${r.port}`, | 158 | url: `http://localhost:${r.port}`, |
| 139 | processName: null, | 159 | processName: null, |
| 140 | pid: null, | 160 | pid: null, |
| 161 | terminal: null, | ||
| 141 | })); | 162 | })); |
| 142 | }); | 163 | }); |
| 143 | 164 | ||
| ... | @@ -155,7 +176,9 @@ const serversEqual = ( | ... | @@ -155,7 +176,9 @@ const serversEqual = ( |
| 155 | a.port !== b.port || | 176 | a.port !== b.port || |
| 156 | a.url !== b.url || | 177 | a.url !== b.url || |
| 157 | a.processName !== b.processName || | 178 | a.processName !== b.processName || |
| 158 | a.pid !== b.pid | 179 | a.pid !== b.pid || |
| 180 | a.terminal?.threadId !== b.terminal?.threadId || | ||
| 181 | a.terminal?.terminalId !== b.terminal?.terminalId | ||
| 159 | ) { | 182 | ) { |
| 160 | return false; | 183 | return false; |
| 161 | } | 184 | } |
| ... | @@ -163,12 +186,19 @@ const serversEqual = ( | ... | @@ -163,12 +186,19 @@ const serversEqual = ( |
| 163 | return true; | 186 | return true; |
| 164 | }; | 187 | }; |
| 165 | 188 | ||
| 166 | export const makePreviewPortScanner = Effect.gen(function* () { | 189 | export const makePortDiscovery = Effect.gen(function* () { |
| 167 | const processRunner = yield* ProcessRunner; | 190 | const processRunner = yield* ProcessRunner; |
| 168 | const stateRef = yield* Ref.make<ScannerState>({ | 191 | const stateRef = yield* Ref.make<ScannerState>({ |
| 169 | lastSnapshot: [], | 192 | lastSnapshot: [], |
| 170 | }); | 193 | }); |
| 171 | const listeners = new Set<Listener>(); | 194 | const listeners = new Set<Listener>(); |
| 195 | const terminalProcesses = new Map< | ||
| 196 | string, | ||
| 197 | { | ||
| 198 | readonly owner: TerminalProcessOwner; | ||
| 199 | readonly processIds: ReadonlySet<number>; | ||
| 200 | } | ||
| 201 | >(); | ||
| 172 | // Plain integer because the release callback returned by `retain()` runs | 202 | // Plain integer because the release callback returned by `retain()` runs |
| 173 | // outside any Effect context (the WS subscriber's release path) and must | 203 | // outside any Effect context (the WS subscriber's release path) and must |
| 174 | // be a synchronous side-effect-only function. | 204 | // be a synchronous side-effect-only function. |
| ... | @@ -176,11 +206,43 @@ export const makePreviewPortScanner = Effect.gen(function* () { | ... | @@ -176,11 +206,43 @@ export const makePreviewPortScanner = Effect.gen(function* () { |
| 176 | 206 | ||
| 177 | const scanOnce = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> => | 207 | const scanOnce = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> => |
| 178 | Effect.gen(function* () { | 208 | Effect.gen(function* () { |
| 209 | const terminalByProcessId = new Map<number, TerminalProcessOwner>(); | ||
| 210 | for (const registration of terminalProcesses.values()) { | ||
| 211 | for (const processId of registration.processIds) { | ||
| 212 | terminalByProcessId.set(processId, registration.owner); | ||
| 213 | } | ||
| 214 | } | ||
| 179 | if (process.platform === "win32") { | 215 | if (process.platform === "win32") { |
| 216 | const command = | ||
| 217 | 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; | ||
| 218 | const listeners = yield* processRunner | ||
| 219 | .run({ | ||
| 220 | command: "powershell.exe", | ||
| 221 | args: ["-NoProfile", "-NonInteractive", "-Command", command], | ||
| 222 | timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), | ||
| 223 | maxOutputBytes: 1024 * 1024, | ||
| 224 | outputMode: "truncate", | ||
| 225 | }) | ||
| 226 | .pipe( | ||
| 227 | Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), | ||
| 228 | Effect.catchCause(() => Effect.succeed(null)), | ||
| 229 | ); | ||
| 230 | if (listeners !== null) return listeners; | ||
| 180 | return yield* probeCommonPorts(); | 231 | return yield* probeCommonPorts(); |
| 181 | } | 232 | } |
| 182 | const lsof = yield* probeLsof(processRunner); | 233 | const lsofResult = yield* processRunner |
| 183 | if (lsof !== null) return lsof; | 234 | .run({ |
| 235 | command: "lsof", | ||
| 236 | args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], | ||
| 237 | timeout: Duration.millis(LSOF_TIMEOUT_MS), | ||
| 238 | maxOutputBytes: 1024 * 1024, | ||
| 239 | outputMode: "truncate", | ||
| 240 | }) | ||
| 241 | .pipe( | ||
| 242 | Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), | ||
| 243 | Effect.catchCause(() => Effect.succeed(null)), | ||
| 244 | ); | ||
| 245 | if (lsofResult !== null) return lsofResult; | ||
| 184 | return yield* probeCommonPorts(); | 246 | return yield* probeCommonPorts(); |
| 185 | }); | 247 | }); |
| 186 | 248 | ||
| ... | @@ -204,7 +266,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { | ... | @@ -204,7 +266,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { |
| 204 | // currently retained, so the cost is one Ref.get every POLL_INTERVAL. | 266 | // currently retained, so the cost is one Ref.get every POLL_INTERVAL. |
| 205 | yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); | 267 | yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); |
| 206 | 268 | ||
| 207 | const retain: PreviewPortScannerShape["retain"] = () => | 269 | const retain: PortDiscoveryShape["retain"] = () => |
| 208 | Effect.gen(function* () { | 270 | Effect.gen(function* () { |
| 209 | const wasIdle = retainCount === 0; | 271 | const wasIdle = retainCount === 0; |
| 210 | retainCount += 1; | 272 | retainCount += 1; |
| ... | @@ -221,7 +283,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { | ... | @@ -221,7 +283,7 @@ export const makePreviewPortScanner = Effect.gen(function* () { |
| 221 | }; | 283 | }; |
| 222 | }); | 284 | }); |
| 223 | 285 | ||
| 224 | const subscribe: PreviewPortScannerShape["subscribe"] = (listener) => | 286 | const subscribe: PortDiscoveryShape["subscribe"] = (listener) => |
| 225 | Effect.sync(() => { | 287 | Effect.sync(() => { |
| 226 | listeners.add(listener); | 288 | listeners.add(listener); |
| 227 | return () => { | 289 | return () => { |
| ... | @@ -229,18 +291,43 @@ export const makePreviewPortScanner = Effect.gen(function* () { | ... | @@ -229,18 +291,43 @@ export const makePreviewPortScanner = Effect.gen(function* () { |
| 229 | }; | 291 | }; |
| 230 | }); | 292 | }); |
| 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 | return { | 316 | return { |
| 233 | scan: scanOnce, | 317 | scan: scanOnce, |
| 234 | subscribe, | 318 | subscribe, |
| 235 | retain, | 319 | retain, |
| 236 | } satisfies PreviewPortScannerShape; | 320 | registerTerminalProcesses, |
| 321 | unregisterTerminal, | ||
| 322 | } satisfies PortDiscoveryShape; | ||
| 237 | }); | 323 | }); |
| 238 | 324 | ||
| 239 | export const PreviewPortScannerLive = Layer.effect(PreviewPortScanner, makePreviewPortScanner); | 325 | export const PortDiscoveryLive = Layer.effect(PortDiscovery, makePortDiscovery); |
| 240 | 326 | ||
| 241 | /** Exposed for tests. */ | 327 | /** Exposed for tests. */ |
| 242 | export const __testing = { | 328 | export const __testing = { |
| 243 | parseLsofOutput, | 329 | parseLsofOutput, |
| 244 | parsePortFromLsofName, | 330 | parsePortFromLsofName, |
| 331 | parseWindowsListenerOutput, | ||
| 245 | serversEqual, | 332 | serversEqual, |
| 246 | }; | 333 | }; |
apps/server/src/preview/Services/PortScanner.ts+18-8| ... | @@ -1,17 +1,17 @@ | ... | @@ -1,17 +1,17 @@ |
| 1 | /** | 1 | /** |
| 2 | * PreviewPortScanner - Discovers listening localhost ports for the preview | 2 | * PortDiscovery - Discovers listening localhost ports and attributes them to |
| 3 | * empty-state recommendations. | 3 | * registered terminal process families. |
| 4 | * | 4 | * |
| 5 | * Reference-counted polling: the scanner only runs when at least one client | 5 | * Reference-counted polling: the scanner only runs when at least one client |
| 6 | * has called `retain()`. This keeps idle desktops from running `lsof` every | 6 | * has called `retain()`. This keeps idle desktops from running `lsof` every |
| 7 | * 3 seconds for nothing. | 7 | * 3 seconds for nothing. |
| 8 | * | 8 | * |
| 9 | * @module PreviewPortScanner | 9 | * @module PortDiscovery |
| 10 | */ | 10 | */ |
| 11 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; | 11 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; |
| 12 | import { Context, type Effect } from "effect"; | 12 | import { Context, type Effect } from "effect"; |
| 13 | 13 | ||
| 14 | export interface PreviewPortScannerShape { | 14 | export interface PortDiscoveryShape { |
| 15 | /** One-shot snapshot of currently listening localhost ports. */ | 15 | /** One-shot snapshot of currently listening localhost ports. */ |
| 16 | readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; | 16 | readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; |
| 17 | /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */ | 17 | /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */ |
| ... | @@ -23,12 +23,22 @@ export interface PreviewPortScannerShape { | ... | @@ -23,12 +23,22 @@ export interface PreviewPortScannerShape { |
| 23 | * release fn. When release count hits 0, polling stops. | 23 | * release fn. When release count hits 0, polling stops. |
| 24 | */ | 24 | */ |
| 25 | readonly retain: () => Effect.Effect<() => void>; | 25 | readonly retain: () => Effect.Effect<() => void>; |
| 26 | /** Associate a terminal with its current process family for port attribution. */ | ||
| 27 | readonly registerTerminalProcesses: (input: { | ||
| 28 | readonly threadId: string; | ||
| 29 | readonly terminalId: string; | ||
| 30 | readonly processIds: ReadonlyArray<number>; | ||
| 31 | }) => Effect.Effect<void>; | ||
| 32 | /** Remove process attribution for a terminal that stopped or closed. */ | ||
| 33 | readonly unregisterTerminal: (input: { | ||
| 34 | readonly threadId: string; | ||
| 35 | readonly terminalId: string; | ||
| 36 | }) => Effect.Effect<void>; | ||
| 26 | } | 37 | } |
| 27 | 38 | ||
| 28 | export class PreviewPortScanner extends Context.Service< | 39 | export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()( |
| 29 | PreviewPortScanner, | 40 | "t3/preview/Services/PortScanner/PortDiscovery", |
| 30 | PreviewPortScannerShape | 41 | ) {} |
| 31 | >()("t3/preview/Services/PortScanner/PreviewPortScanner") {} | ||
| 32 | 42 | ||
| 33 | /** Curated list of common dev-server ports for the Windows TCP-probe fallback. */ | 43 | /** Curated list of common dev-server ports for the Windows TCP-probe fallback. */ |
| 34 | export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ | 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,7 +100,7 @@ import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRu |
| 100 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; | 100 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; |
| 101 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; | 101 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; |
| 102 | import { PreviewManager } from "./preview/Services/Manager.ts"; | 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 | import { | 104 | import { |
| 105 | BrowserTraceCollector, | 105 | BrowserTraceCollector, |
| 106 | type BrowserTraceCollectorShape, | 106 | type BrowserTraceCollectorShape, |
| ... | @@ -681,10 +681,12 @@ const buildAppUnderTest = (options?: { | ... | @@ -681,10 +681,12 @@ const buildAppUnderTest = (options?: { |
| 681 | PubSub.subscribe(pubsub), | 681 | PubSub.subscribe(pubsub), |
| 682 | ), | 682 | ), |
| 683 | }), | 683 | }), |
| 684 | Layer.mock(PreviewPortScanner)({ | 684 | Layer.mock(PortDiscovery)({ |
| 685 | scan: () => Effect.succeed([]), | 685 | scan: () => Effect.succeed([]), |
| 686 | subscribe: () => Effect.succeed(() => {}), | 686 | subscribe: () => Effect.succeed(() => {}), |
| 687 | retain: () => Effect.succeed(() => {}), | 687 | retain: () => Effect.succeed(() => {}), |
| 688 | registerTerminalProcesses: () => Effect.void, | ||
| 689 | unregisterTerminal: () => Effect.void, | ||
| 688 | }), | 690 | }), |
| 689 | ), | 691 | ), |
| 690 | ), | 692 | ), |
apps/server/src/server.ts+7-5| ... | @@ -36,7 +36,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts"; | ... | @@ -36,7 +36,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 36 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; | 36 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; |
| 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; | 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; |
| 38 | import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; | 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 | import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; | 40 | import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; |
| 41 | import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; | 41 | import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; |
| 42 | import * as ProcessRunner from "./processRunner.ts"; | 42 | import * as ProcessRunner from "./processRunner.ts"; |
| ... | @@ -236,13 +236,15 @@ const CheckpointingLayerLive = Layer.empty.pipe( | ... | @@ -236,13 +236,15 @@ const CheckpointingLayerLive = Layer.empty.pipe( |
| 236 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), | 236 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), |
| 237 | ); | 237 | ); |
| 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( | 241 | const TerminalLayerLive = TerminalManagerLive.pipe( |
| 242 | PreviewManagerLive, | 242 | Layer.provide(PtyAdapterLive), |
| 243 | PreviewPortScannerLive.pipe(Layer.provide(ProcessRunner.layer)), | 243 | Layer.provide(PortScannerLayerLive), |
| 244 | ); | 244 | ); |
| 245 | 245 | ||
| 246 | const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PortScannerLayerLive); | ||
| 247 | |||
| 246 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( | 248 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( |
| 247 | Layer.provide(WorkspacePathsLive), | 249 | Layer.provide(WorkspacePathsLive), |
| 248 | Layer.provideMerge(VcsDriverRegistryLayerLive), | 250 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
apps/server/src/terminal/Layers/Manager.test.ts+10-4| ... | @@ -204,6 +204,7 @@ interface CreateManagerOptions { | ... | @@ -204,6 +204,7 @@ interface CreateManagerOptions { |
| 204 | subprocessInspector?: (terminalPid: number) => Effect.Effect<{ | 204 | subprocessInspector?: (terminalPid: number) => Effect.Effect<{ |
| 205 | readonly hasRunningSubprocess: boolean; | 205 | readonly hasRunningSubprocess: boolean; |
| 206 | readonly childCommand: string | null; | 206 | readonly childCommand: string | null; |
| 207 | readonly processIds: ReadonlyArray<number>; | ||
| 207 | }>; | 208 | }>; |
| 208 | subprocessPollIntervalMs?: number; | 209 | subprocessPollIntervalMs?: number; |
| 209 | processKillGraceMs?: number; | 210 | processKillGraceMs?: number; |
| ... | @@ -745,7 +746,8 @@ it.layer( | ... | @@ -745,7 +746,8 @@ it.layer( |
| 745 | let inspect: { | 746 | let inspect: { |
| 746 | readonly hasRunningSubprocess: boolean; | 747 | readonly hasRunningSubprocess: boolean; |
| 747 | readonly childCommand: string | null; | 748 | readonly childCommand: string | null; |
| 748 | } = { hasRunningSubprocess: false, childCommand: null }; | 749 | readonly processIds: ReadonlyArray<number>; |
| 750 | } = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; | ||
| 749 | const { manager, getEvents } = yield* createManager(5, { | 751 | const { manager, getEvents } = yield* createManager(5, { |
| 750 | subprocessInspector: () => Effect.succeed(inspect), | 752 | subprocessInspector: () => Effect.succeed(inspect), |
| 751 | subprocessPollIntervalMs: 20, | 753 | subprocessPollIntervalMs: 20, |
| ... | @@ -754,7 +756,7 @@ it.layer( | ... | @@ -754,7 +756,7 @@ it.layer( |
| 754 | yield* manager.open(openInput()); | 756 | yield* manager.open(openInput()); |
| 755 | expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); | 757 | expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); |
| 756 | 758 | ||
| 757 | inspect = { hasRunningSubprocess: true, childCommand: "vim" }; | 759 | inspect = { hasRunningSubprocess: true, childCommand: "vim", processIds: [100, 101] }; |
| 758 | yield* waitFor( | 760 | yield* waitFor( |
| 759 | Effect.map(getEvents, (events) => | 761 | Effect.map(getEvents, (events) => |
| 760 | events.some( | 762 | events.some( |
| ... | @@ -767,7 +769,7 @@ it.layer( | ... | @@ -767,7 +769,7 @@ it.layer( |
| 767 | "1200 millis", | 769 | "1200 millis", |
| 768 | ); | 770 | ); |
| 769 | 771 | ||
| 770 | inspect = { hasRunningSubprocess: false, childCommand: null }; | 772 | inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; |
| 771 | yield* waitFor( | 773 | yield* waitFor( |
| 772 | Effect.map(getEvents, (events) => | 774 | Effect.map(getEvents, (events) => |
| 773 | events.some( | 775 | events.some( |
| ... | @@ -788,7 +790,11 @@ it.layer( | ... | @@ -788,7 +790,11 @@ it.layer( |
| 788 | const { manager } = yield* createManager(5, { | 790 | const { manager } = yield* createManager(5, { |
| 789 | subprocessInspector: () => { | 791 | subprocessInspector: () => { |
| 790 | checks += 1; | 792 | checks += 1; |
| 791 | return Effect.succeed({ hasRunningSubprocess: false, childCommand: null }); | 793 | return Effect.succeed({ |
| 794 | hasRunningSubprocess: false, | ||
| 795 | childCommand: null, | ||
| 796 | processIds: [], | ||
| 797 | }); | ||
| 792 | }, | 798 | }, |
| 793 | subprocessPollIntervalMs: 20, | 799 | subprocessPollIntervalMs: 20, |
| 794 | }); | 800 | }); |
apps/server/src/terminal/Layers/Manager.ts+97-15| ... | @@ -33,6 +33,7 @@ import { | ... | @@ -33,6 +33,7 @@ import { |
| 33 | terminalSessionsTotal, | 33 | terminalSessionsTotal, |
| 34 | } from "../../observability/Metrics.ts"; | 34 | } from "../../observability/Metrics.ts"; |
| 35 | import * as ProcessRunner from "../../processRunner.ts"; | 35 | import * as ProcessRunner from "../../processRunner.ts"; |
| 36 | import { PortDiscovery } from "../../preview/Services/PortScanner.ts"; | ||
| 36 | import { | 37 | import { |
| 37 | TerminalCwdError, | 38 | TerminalCwdError, |
| 38 | TerminalHistoryError, | 39 | TerminalHistoryError, |
| ... | @@ -82,6 +83,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess | ... | @@ -82,6 +83,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess |
| 82 | interface TerminalSubprocessInspectResult { | 83 | interface TerminalSubprocessInspectResult { |
| 83 | readonly hasRunningSubprocess: boolean; | 84 | readonly hasRunningSubprocess: boolean; |
| 84 | readonly childCommand: string | null; | 85 | readonly childCommand: string | null; |
| 86 | readonly processIds: ReadonlyArray<number>; | ||
| 85 | } | 87 | } |
| 86 | 88 | ||
| 87 | interface TerminalSubprocessInspector { | 89 | interface TerminalSubprocessInspector { |
| ... | @@ -505,12 +507,8 @@ function windowsInspectSubprocess( | ... | @@ -505,12 +507,8 @@ function windowsInspectSubprocess( |
| 505 | TerminalSubprocessCheckError, | 507 | TerminalSubprocessCheckError, |
| 506 | ProcessRunner.ProcessRunner | 508 | ProcessRunner.ProcessRunner |
| 507 | > { | 509 | > { |
| 508 | const command = [ | 510 | const command = |
| 509 | `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`, | 511 | 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; |
| 510 | "if ($null -eq $c) { exit 1 }", | ||
| 511 | "Write-Output $c.Name", | ||
| 512 | "exit 0", | ||
| 513 | ].join("; "); | ||
| 514 | return Effect.gen(function* () { | 512 | return Effect.gen(function* () { |
| 515 | const processRunner = yield* ProcessRunner.ProcessRunner; | 513 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 516 | return yield* processRunner.run({ | 514 | return yield* processRunner.run({ |
| ... | @@ -524,16 +522,41 @@ function windowsInspectSubprocess( | ... | @@ -524,16 +522,41 @@ function windowsInspectSubprocess( |
| 524 | }).pipe( | 522 | }).pipe( |
| 525 | Effect.map((result) => { | 523 | Effect.map((result) => { |
| 526 | if (result.code !== 0) { | 524 | if (result.code !== 0) { |
| 527 | return { hasRunningSubprocess: false, childCommand: null } as const; | 525 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; |
| 528 | } | 526 | } |
| 529 | const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? ""; | 527 | const processNameById = new Map<number, string>(); |
| 530 | if (name.length === 0) { | 528 | const childrenByParent = new Map<number, number[]>(); |
| 531 | return { hasRunningSubprocess: true, childCommand: null } as const; | 529 | for (const line of result.stdout.split(/\r?\n/g)) { |
| 530 | const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); | ||
| 531 | const pid = Number(pidRaw); | ||
| 532 | const parentPid = Number(parentPidRaw); | ||
| 533 | if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; | ||
| 534 | processNameById.set(pid, nameRaw?.trim() ?? ""); | ||
| 535 | const children = childrenByParent.get(parentPid) ?? []; | ||
| 536 | children.push(pid); | ||
| 537 | childrenByParent.set(parentPid, children); | ||
| 532 | } | 538 | } |
| 533 | const normalized = normalizeChildCommandName(name, platform); | 539 | const directChildren = childrenByParent.get(terminalPid) ?? []; |
| 540 | const childPid = directChildren[0]; | ||
| 541 | if (childPid === undefined) { | ||
| 542 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; | ||
| 543 | } | ||
| 544 | const processIds = new Set<number>([terminalPid]); | ||
| 545 | const pending = [terminalPid]; | ||
| 546 | while (pending.length > 0) { | ||
| 547 | const parentPid = pending.pop(); | ||
| 548 | if (parentPid === undefined) continue; | ||
| 549 | for (const pid of childrenByParent.get(parentPid) ?? []) { | ||
| 550 | if (processIds.has(pid)) continue; | ||
| 551 | processIds.add(pid); | ||
| 552 | pending.push(pid); | ||
| 553 | } | ||
| 554 | } | ||
| 555 | const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); | ||
| 534 | return { | 556 | return { |
| 535 | hasRunningSubprocess: true, | 557 | hasRunningSubprocess: true, |
| 536 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, | 558 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, |
| 559 | processIds: [...processIds], | ||
| 537 | } as const; | 560 | } as const; |
| 538 | }), | 561 | }), |
| 539 | Effect.mapError( | 562 | Effect.mapError( |
| ... | @@ -606,14 +629,14 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func | ... | @@ -606,14 +629,14 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func |
| 606 | if (pgrepResult.value.code === 0) { | 629 | if (pgrepResult.value.code === 0) { |
| 607 | childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); | 630 | childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); |
| 608 | } else if (pgrepResult.value.code === 1) { | 631 | } else if (pgrepResult.value.code === 1) { |
| 609 | return { hasRunningSubprocess: false, childCommand: null }; | 632 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; |
| 610 | } | 633 | } |
| 611 | } | 634 | } |
| 612 | 635 | ||
| 613 | if (childPid === null) { | 636 | if (childPid === null) { |
| 614 | const psResult = yield* Effect.exit(runPs); | 637 | const psResult = yield* Effect.exit(runPs); |
| 615 | if (psResult._tag === "Failure" || psResult.value.code !== 0) { | 638 | if (psResult._tag === "Failure" || psResult.value.code !== 0) { |
| 616 | return { hasRunningSubprocess: false, childCommand: null }; | 639 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; |
| 617 | } | 640 | } |
| 618 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | 641 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { |
| 619 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | 642 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); |
| ... | @@ -628,7 +651,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func | ... | @@ -628,7 +651,7 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func |
| 628 | } | 651 | } |
| 629 | 652 | ||
| 630 | if (childPid === null) { | 653 | if (childPid === null) { |
| 631 | return { hasRunningSubprocess: false, childCommand: null }; | 654 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; |
| 632 | } | 655 | } |
| 633 | 656 | ||
| 634 | const runComm = processRunner.run({ | 657 | const runComm = processRunner.run({ |
| ... | @@ -663,16 +686,43 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func | ... | @@ -663,16 +686,43 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func |
| 663 | } | 686 | } |
| 664 | 687 | ||
| 665 | const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; | 688 | const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; |
| 689 | const processIds = new Set<number>([terminalPid]); | ||
| 690 | const psResult = yield* Effect.exit(runPs); | ||
| 691 | if (psResult._tag === "Success" && psResult.value.code === 0) { | ||
| 692 | const childrenByParent = new Map<number, number[]>(); | ||
| 693 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | ||
| 694 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | ||
| 695 | const pid = Number(pidRaw); | ||
| 696 | const ppid = Number(ppidRaw); | ||
| 697 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | ||
| 698 | const children = childrenByParent.get(ppid) ?? []; | ||
| 699 | children.push(pid); | ||
| 700 | childrenByParent.set(ppid, children); | ||
| 701 | } | ||
| 702 | const pending = [terminalPid]; | ||
| 703 | while (pending.length > 0) { | ||
| 704 | const parentPid = pending.pop(); | ||
| 705 | if (parentPid === undefined) continue; | ||
| 706 | for (const child of childrenByParent.get(parentPid) ?? []) { | ||
| 707 | if (processIds.has(child)) continue; | ||
| 708 | processIds.add(child); | ||
| 709 | pending.push(child); | ||
| 710 | } | ||
| 711 | } | ||
| 712 | } else { | ||
| 713 | processIds.add(childPid); | ||
| 714 | } | ||
| 666 | return { | 715 | return { |
| 667 | hasRunningSubprocess: true, | 716 | hasRunningSubprocess: true, |
| 668 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, | 717 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, |
| 718 | processIds: [...processIds], | ||
| 669 | }; | 719 | }; |
| 670 | }); | 720 | }); |
| 671 | 721 | ||
| 672 | function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { | 722 | function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { |
| 673 | return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { | 723 | return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { |
| 674 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | 724 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { |
| 675 | return { hasRunningSubprocess: false, childCommand: null }; | 725 | return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; |
| 676 | } | 726 | } |
| 677 | if (platform === "win32") { | 727 | if (platform === "win32") { |
| 678 | return yield* windowsInspectSubprocess(terminalPid, platform); | 728 | return yield* windowsInspectSubprocess(terminalPid, platform); |
| ... | @@ -932,14 +982,26 @@ interface TerminalManagerOptions { | ... | @@ -932,14 +982,26 @@ interface TerminalManagerOptions { |
| 932 | subprocessPollIntervalMs?: number; | 982 | subprocessPollIntervalMs?: number; |
| 933 | processKillGraceMs?: number; | 983 | processKillGraceMs?: number; |
| 934 | maxRetainedInactiveSessions?: number; | 984 | maxRetainedInactiveSessions?: number; |
| 985 | registerTerminalProcesses?: (input: { | ||
| 986 | readonly threadId: string; | ||
| 987 | readonly terminalId: string; | ||
| 988 | readonly processIds: ReadonlyArray<number>; | ||
| 989 | }) => Effect.Effect<void>; | ||
| 990 | unregisterTerminal?: (input: { | ||
| 991 | readonly threadId: string; | ||
| 992 | readonly terminalId: string; | ||
| 993 | }) => Effect.Effect<void>; | ||
| 935 | } | 994 | } |
| 936 | 995 | ||
| 937 | const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { | 996 | const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { |
| 938 | const { terminalLogsDir } = yield* ServerConfig; | 997 | const { terminalLogsDir } = yield* ServerConfig; |
| 939 | const ptyAdapter = yield* PtyAdapter; | 998 | const ptyAdapter = yield* PtyAdapter; |
| 999 | const portDiscovery = yield* PortDiscovery; | ||
| 940 | return yield* makeTerminalManagerWithOptions({ | 1000 | return yield* makeTerminalManagerWithOptions({ |
| 941 | logsDir: terminalLogsDir, | 1001 | logsDir: terminalLogsDir, |
| 942 | ptyAdapter, | 1002 | ptyAdapter, |
| 1003 | registerTerminalProcesses: portDiscovery.registerTerminalProcesses, | ||
| 1004 | unregisterTerminal: portDiscovery.unregisterTerminal, | ||
| 943 | }); | 1005 | }); |
| 944 | }); | 1006 | }); |
| 945 | 1007 | ||
| ... | @@ -967,6 +1029,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -967,6 +1029,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 967 | const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; | 1029 | const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; |
| 968 | const maxRetainedInactiveSessions = | 1030 | const maxRetainedInactiveSessions = |
| 969 | options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; | 1031 | options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; |
| 1032 | const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); | ||
| 1033 | const unregisterTerminal = options.unregisterTerminal ?? (() => Effect.void); | ||
| 970 | 1034 | ||
| 971 | yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); | 1035 | yield* fileSystem.makeDirectory(logsDir, { recursive: true }).pipe(Effect.orDie); |
| 972 | 1036 | ||
| ... | @@ -1495,6 +1559,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1495,6 +1559,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1495 | } | 1559 | } |
| 1496 | 1560 | ||
| 1497 | yield* clearKillFiber(action.process); | 1561 | yield* clearKillFiber(action.process); |
| 1562 | yield* unregisterTerminal({ | ||
| 1563 | threadId: action.threadId, | ||
| 1564 | terminalId: action.terminalId, | ||
| 1565 | }); | ||
| 1498 | yield* publishEvent({ | 1566 | yield* publishEvent({ |
| 1499 | type: "exited", | 1567 | type: "exited", |
| 1500 | threadId: action.threadId, | 1568 | threadId: action.threadId, |
| ... | @@ -1531,6 +1599,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1531,6 +1599,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1531 | }); | 1599 | }); |
| 1532 | 1600 | ||
| 1533 | yield* clearKillFiber(process); | 1601 | yield* clearKillFiber(process); |
| 1602 | yield* unregisterTerminal({ | ||
| 1603 | threadId: session.threadId, | ||
| 1604 | terminalId: session.terminalId, | ||
| 1605 | }); | ||
| 1534 | yield* startKillEscalation(process, session.threadId, session.terminalId); | 1606 | yield* startKillEscalation(process, session.threadId, session.terminalId); |
| 1535 | yield* evictInactiveSessionsIfNeeded(); | 1607 | yield* evictInactiveSessionsIfNeeded(); |
| 1536 | }); | 1608 | }); |
| ... | @@ -1700,6 +1772,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1700,6 +1772,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1700 | advanceEventSequence(session); | 1772 | advanceEventSequence(session); |
| 1701 | return [undefined, state] as const; | 1773 | return [undefined, state] as const; |
| 1702 | }); | 1774 | }); |
| 1775 | yield* unregisterTerminal({ | ||
| 1776 | threadId: session.threadId, | ||
| 1777 | terminalId: session.terminalId, | ||
| 1778 | }); | ||
| 1703 | 1779 | ||
| 1704 | yield* evictInactiveSessionsIfNeeded(); | 1780 | yield* evictInactiveSessionsIfNeeded(); |
| 1705 | 1781 | ||
| ... | @@ -1731,6 +1807,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1731,6 +1807,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1731 | 1807 | ||
| 1732 | if (Option.isSome(session)) { | 1808 | if (Option.isSome(session)) { |
| 1733 | yield* stopProcess(session.value); | 1809 | yield* stopProcess(session.value); |
| 1810 | yield* unregisterTerminal({ threadId, terminalId }); | ||
| 1734 | yield* persistHistory(threadId, terminalId, session.value.history); | 1811 | yield* persistHistory(threadId, terminalId, session.value.history); |
| 1735 | } | 1812 | } |
| 1736 | 1813 | ||
| ... | @@ -1791,6 +1868,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1791,6 +1868,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1791 | } | 1868 | } |
| 1792 | 1869 | ||
| 1793 | const next = inspectResult.value; | 1870 | const next = inspectResult.value; |
| 1871 | yield* registerTerminalProcesses({ | ||
| 1872 | threadId: session.threadId, | ||
| 1873 | terminalId: session.terminalId, | ||
| 1874 | processIds: next.processIds, | ||
| 1875 | }); | ||
| 1794 | const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; | 1876 | const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; |
| 1795 | const event = yield* modifyManagerState((state) => { | 1877 | const event = yield* modifyManagerState((state) => { |
| 1796 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( | 1878 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( |
apps/server/src/ws.ts+5-5| ... | @@ -72,7 +72,7 @@ import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; | ... | @@ -72,7 +72,7 @@ import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; |
| 72 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; | 72 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; |
| 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 74 | import { PreviewManager } from "./preview/Services/Manager.ts"; | 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 | import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; | 76 | import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; |
| 77 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; | 77 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 78 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | 78 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| ... | @@ -257,7 +257,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -257,7 +257,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 257 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; | 257 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 258 | const terminalManager = yield* TerminalManager; | 258 | const terminalManager = yield* TerminalManager; |
| 259 | const previewManager = yield* PreviewManager; | 259 | const previewManager = yield* PreviewManager; |
| 260 | const previewPortScanner = yield* PreviewPortScanner; | 260 | const portDiscovery = yield* PortDiscovery; |
| 261 | const providerRegistry = yield* ProviderRegistry; | 261 | const providerRegistry = yield* ProviderRegistry; |
| 262 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; | 262 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 263 | const config = yield* ServerConfig; | 263 | const config = yield* ServerConfig; |
| ... | @@ -1426,14 +1426,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -1426,14 +1426,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1426 | Stream.callback<DiscoveredLocalServerList>((queue) => | 1426 | Stream.callback<DiscoveredLocalServerList>((queue) => |
| 1427 | Effect.acquireRelease( | 1427 | Effect.acquireRelease( |
| 1428 | Effect.gen(function* () { | 1428 | Effect.gen(function* () { |
| 1429 | const release = yield* previewPortScanner.retain(); | 1429 | const release = yield* portDiscovery.retain(); |
| 1430 | const initial = yield* previewPortScanner.scan(); | 1430 | const initial = yield* portDiscovery.scan(); |
| 1431 | const initialScannedAt = DateTime.formatIso(yield* DateTime.now); | 1431 | const initialScannedAt = DateTime.formatIso(yield* DateTime.now); |
| 1432 | yield* Queue.offer(queue, { | 1432 | yield* Queue.offer(queue, { |
| 1433 | servers: initial, | 1433 | servers: initial, |
| 1434 | scannedAt: initialScannedAt, | 1434 | scannedAt: initialScannedAt, |
| 1435 | }); | 1435 | }); |
| 1436 | const unsubscribe = yield* previewPortScanner.subscribe((servers) => | 1436 | const unsubscribe = yield* portDiscovery.subscribe((servers) => |
| 1437 | Effect.gen(function* () { | 1437 | Effect.gen(function* () { |
| 1438 | const scannedAt = DateTime.formatIso(yield* DateTime.now); | 1438 | const scannedAt = DateTime.formatIso(yield* DateTime.now); |
| 1439 | yield* Queue.offer(queue, { servers, scannedAt }); | 1439 | yield* Queue.offer(queue, { servers, scannedAt }); |
apps/web/src/browser/ElectronBrowserHost.tsx+46-1| ... | @@ -1,14 +1,18 @@ | ... | @@ -1,14 +1,18 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime"; | 3 | import { parseScopedThreadKey } from "@t3tools/client-runtime"; |
| 4 | import { useMemo } from "react"; | 4 | import { useEffect, useMemo } from "react"; |
| 5 | 5 | ||
| 6 | import { isElectron } from "~/env"; | 6 | import { isElectron } from "~/env"; |
| 7 | import { useTheme } from "~/hooks/useTheme"; | ||
| 7 | import { usePreviewStateStore } from "~/previewStateStore"; | 8 | import { usePreviewStateStore } from "~/previewStateStore"; |
| 8 | 9 | ||
| 10 | import { readPreviewAnnotationTheme } from "./annotationTheme"; | ||
| 11 | import { useBrowserPointerStore } from "./browserPointerStore"; | ||
| 9 | import { HostedBrowserWebview } from "./HostedBrowserWebview"; | 12 | import { HostedBrowserWebview } from "./HostedBrowserWebview"; |
| 10 | 13 | ||
| 11 | export function ElectronBrowserHost() { | 14 | export function ElectronBrowserHost() { |
| 15 | const { resolvedTheme } = useTheme(); | ||
| 12 | const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); | 16 | const previewByThreadKey = usePreviewStateStore((state) => state.byThreadKey); |
| 13 | const sessions = useMemo( | 17 | const sessions = useMemo( |
| 14 | () => | 18 | () => |
| ... | @@ -25,6 +29,47 @@ export function ElectronBrowserHost() { | ... | @@ -25,6 +29,47 @@ export function ElectronBrowserHost() { |
| 25 | [previewByThreadKey], | 29 | [previewByThreadKey], |
| 26 | ); | 30 | ); |
| 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 | if (!isElectron) return null; | 73 | if (!isElectron) return null; |
| 29 | return ( | 74 | return ( |
| 30 | <div className="contents" data-electron-browser-host> | 75 | <div className="contents" data-electron-browser-host> |
apps/web/src/browser/annotationTheme.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 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,6 +65,7 @@ import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; |
| 65 | import { getServerConfig } from "../rpc/serverState"; | 65 | import { getServerConfig } from "../rpc/serverState"; |
| 66 | import { getRouter } from "../router"; | 66 | import { getRouter } from "../router"; |
| 67 | import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; | 67 | import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; |
| 68 | import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; | ||
| 68 | import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; | 69 | import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; |
| 69 | import { terminalSessionManager } from "../terminalSessionState"; | 70 | import { terminalSessionManager } from "../terminalSessionState"; |
| 70 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; | 71 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; |
| ... | @@ -1801,6 +1802,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1801,6 +1802,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1801 | useTerminalUiStateStore.setState({ | 1802 | useTerminalUiStateStore.setState({ |
| 1802 | terminalUiStateByThreadKey: {}, | 1803 | terminalUiStateByThreadKey: {}, |
| 1803 | }); | 1804 | }); |
| 1805 | useRightPanelStore.persist.clearStorage(); | ||
| 1806 | useRightPanelStore.setState({ byThreadKey: {} }); | ||
| 1804 | }); | 1807 | }); |
| 1805 | 1808 | ||
| 1806 | afterEach(() => { | 1809 | afterEach(() => { |
| ... | @@ -2054,12 +2057,15 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2054,12 +2057,15 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2054 | }); | 2057 | }); |
| 2055 | 2058 | ||
| 2056 | try { | 2059 | try { |
| 2057 | const toggle = await waitForElement( | 2060 | window.dispatchEvent( |
| 2058 | () => | 2061 | new KeyboardEvent("keydown", { |
| 2059 | document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), | 2062 | key: "j", |
| 2060 | "Unable to find terminal drawer toggle.", | 2063 | metaKey: isMacPlatform(navigator.platform), |
| 2064 | ctrlKey: !isMacPlatform(navigator.platform), | ||
| 2065 | bubbles: true, | ||
| 2066 | cancelable: true, | ||
| 2067 | }), | ||
| 2061 | ); | 2068 | ); |
| 2062 | toggle.click(); | ||
| 2063 | 2069 | ||
| 2064 | await vi.waitFor( | 2070 | await vi.waitFor( |
| 2065 | () => { | 2071 | () => { |
| ... | @@ -2072,9 +2078,136 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2072,9 +2078,136 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2072 | terminalId: DEFAULT_TERMINAL_ID, | 2078 | terminalId: DEFAULT_TERMINAL_ID, |
| 2073 | cwd: "/repo/project", | 2079 | cwd: "/repo/project", |
| 2074 | }); | 2080 | }); |
| 2081 | expect( | ||
| 2082 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) | ||
| 2083 | .isOpen, | ||
| 2084 | ).toBe(false); | ||
| 2085 | }, | ||
| 2086 | { timeout: 8_000, interval: 16 }, | ||
| 2087 | ); | ||
| 2088 | } finally { | ||
| 2089 | await mounted.cleanup(); | ||
| 2090 | } | ||
| 2091 | }); | ||
| 2092 | |||
| 2093 | it("keeps multiple terminal panel surfaces separate from the bottom drawer", async () => { | ||
| 2094 | const mounted = await mountChatView({ | ||
| 2095 | viewport: WIDE_FOOTER_VIEWPORT, | ||
| 2096 | snapshot: createSnapshotForTargetUser({ | ||
| 2097 | targetMessageId: "msg-user-open-inline-terminal-panel" as MessageId, | ||
| 2098 | targetText: "open inline terminal panel", | ||
| 2099 | }), | ||
| 2100 | }); | ||
| 2101 | |||
| 2102 | try { | ||
| 2103 | window.dispatchEvent( | ||
| 2104 | new KeyboardEvent("keydown", { | ||
| 2105 | key: "b", | ||
| 2106 | altKey: true, | ||
| 2107 | metaKey: isMacPlatform(navigator.platform), | ||
| 2108 | ctrlKey: !isMacPlatform(navigator.platform), | ||
| 2109 | bubbles: true, | ||
| 2110 | cancelable: true, | ||
| 2111 | }), | ||
| 2112 | ); | ||
| 2113 | |||
| 2114 | const addSurface = await waitForElement( | ||
| 2115 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Add panel surface"]'), | ||
| 2116 | "Unable to find add panel surface button.", | ||
| 2117 | ); | ||
| 2118 | expect(document.body.textContent).toContain("Open a surface"); | ||
| 2119 | expect( | ||
| 2120 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), | ||
| 2121 | ).toEqual({ | ||
| 2122 | isOpen: true, | ||
| 2123 | activeSurfaceId: null, | ||
| 2124 | surfaces: [], | ||
| 2125 | }); | ||
| 2126 | expect(wsRequests.some((request) => request._tag === WS_METHODS.terminalOpen)).toBe(false); | ||
| 2127 | |||
| 2128 | addSurface.click(); | ||
| 2129 | |||
| 2130 | const terminalItem = await waitForElement( | ||
| 2131 | () => | ||
| 2132 | Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find( | ||
| 2133 | (item) => item.textContent?.trim() === "Terminal", | ||
| 2134 | ) ?? null, | ||
| 2135 | "Unable to find Terminal panel menu item.", | ||
| 2136 | ); | ||
| 2137 | terminalItem.click(); | ||
| 2138 | |||
| 2139 | await vi.waitFor(() => { | ||
| 2140 | expect( | ||
| 2141 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) | ||
| 2142 | .surfaces.filter((surface) => surface.kind === "terminal") | ||
| 2143 | .map((surface) => surface.resourceId), | ||
| 2144 | ).toEqual(["term-1"]); | ||
| 2145 | }); | ||
| 2146 | |||
| 2147 | addSurface.click(); | ||
| 2148 | const secondTerminalItem = await waitForElement( | ||
| 2149 | () => | ||
| 2150 | Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find( | ||
| 2151 | (item) => item.textContent?.trim() === "Terminal", | ||
| 2152 | ) ?? null, | ||
| 2153 | "Unable to find Terminal panel menu item.", | ||
| 2154 | ); | ||
| 2155 | secondTerminalItem.click(); | ||
| 2156 | |||
| 2157 | await vi.waitFor( | ||
| 2158 | () => { | ||
| 2159 | expect( | ||
| 2160 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF) | ||
| 2161 | .surfaces.filter((surface) => surface.kind === "terminal") | ||
| 2162 | .map((surface) => surface.resourceId), | ||
| 2163 | ).toEqual(["term-1", "term-2"]); | ||
| 2164 | expect( | ||
| 2165 | document.querySelector('[data-preview-panel-mode="inline"] .thread-terminal-drawer'), | ||
| 2166 | ).not.toBeNull(); | ||
| 2167 | expect( | ||
| 2168 | wsRequests | ||
| 2169 | .filter((request) => request._tag === WS_METHODS.terminalOpen) | ||
| 2170 | .map((request) => ("terminalId" in request ? request.terminalId : null)), | ||
| 2171 | ).toEqual(expect.arrayContaining(["term-1", "term-2"])); | ||
| 2172 | const attachRequest = wsRequests.find( | ||
| 2173 | (request) => | ||
| 2174 | request._tag === WS_METHODS.terminalAttach && | ||
| 2175 | "terminalId" in request && | ||
| 2176 | request.terminalId === "term-2", | ||
| 2177 | ); | ||
| 2178 | expect(attachRequest).toMatchObject({ | ||
| 2179 | _tag: WS_METHODS.terminalAttach, | ||
| 2180 | threadId: THREAD_ID, | ||
| 2181 | terminalId: "term-2", | ||
| 2182 | cwd: "/repo/project", | ||
| 2183 | }); | ||
| 2075 | }, | 2184 | }, |
| 2076 | { timeout: 8_000, interval: 16 }, | 2185 | { timeout: 8_000, interval: 16 }, |
| 2077 | ); | 2186 | ); |
| 2187 | |||
| 2188 | const drawerToggle = await waitForElement( | ||
| 2189 | () => | ||
| 2190 | document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), | ||
| 2191 | "Unable to find terminal drawer toggle.", | ||
| 2192 | ); | ||
| 2193 | drawerToggle.click(); | ||
| 2194 | |||
| 2195 | await vi.waitFor(() => { | ||
| 2196 | expect( | ||
| 2197 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey[THREAD_KEY], | ||
| 2198 | ).toMatchObject({ | ||
| 2199 | terminalOpen: true, | ||
| 2200 | terminalIds: ["term-3"], | ||
| 2201 | }); | ||
| 2202 | expect( | ||
| 2203 | wsRequests.some( | ||
| 2204 | (request) => | ||
| 2205 | request._tag === WS_METHODS.terminalAttach && | ||
| 2206 | "terminalId" in request && | ||
| 2207 | request.terminalId === "term-3", | ||
| 2208 | ), | ||
| 2209 | ).toBe(true); | ||
| 2210 | }); | ||
| 2078 | } finally { | 2211 | } finally { |
| 2079 | await mounted.cleanup(); | 2212 | await mounted.cleanup(); |
| 2080 | } | 2213 | } |
apps/web/src/components/ChatView.tsx+580-142| ... | @@ -103,6 +103,7 @@ import { | ... | @@ -103,6 +103,7 @@ import { |
| 103 | usePreviewStateStore, | 103 | usePreviewStateStore, |
| 104 | } from "../previewStateStore"; | 104 | } from "../previewStateStore"; |
| 105 | import { subscribePreviewAction } from "./preview/previewActionBus"; | 105 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 106 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; | ||
| 106 | // Lazy: keeps the entire preview component graph (webview host, favicon | 107 | // Lazy: keeps the entire preview component graph (webview host, favicon |
| 107 | // helper, Chromium error icon) out of the web bundle until first open. | 108 | // helper, Chromium error icon) out of the web bundle until first open. |
| 108 | const PreviewPanel = lazy(() => | 109 | const PreviewPanel = lazy(() => |
| ... | @@ -129,7 +130,7 @@ import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils | ... | @@ -129,7 +130,7 @@ import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils |
| 129 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; | 130 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 130 | import { useSettings } from "../hooks/useSettings"; | 131 | import { useSettings } from "../hooks/useSettings"; |
| 131 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 132 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 132 | import { isTerminalFocused } from "../lib/terminalFocus"; | 133 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; |
| 133 | import { | 134 | import { |
| 134 | deriveLogicalProjectKeyFromSettings, | 135 | deriveLogicalProjectKeyFromSettings, |
| 135 | selectProjectGroupingSettings, | 136 | selectProjectGroupingSettings, |
| ... | @@ -539,6 +540,7 @@ interface PersistentThreadTerminalDrawerProps { | ... | @@ -539,6 +540,7 @@ interface PersistentThreadTerminalDrawerProps { |
| 539 | launchContext: PersistentTerminalLaunchContext | null; | 540 | launchContext: PersistentTerminalLaunchContext | null; |
| 540 | focusRequestId: number; | 541 | focusRequestId: number; |
| 541 | splitShortcutLabel: string | undefined; | 542 | splitShortcutLabel: string | undefined; |
| 543 | splitVerticalShortcutLabel: string | undefined; | ||
| 542 | newShortcutLabel: string | undefined; | 544 | newShortcutLabel: string | undefined; |
| 543 | closeShortcutLabel: string | undefined; | 545 | closeShortcutLabel: string | undefined; |
| 544 | keybindings: ResolvedKeybindingsConfig; | 546 | keybindings: ResolvedKeybindingsConfig; |
| ... | @@ -553,6 +555,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -553,6 +555,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 553 | launchContext, | 555 | launchContext, |
| 554 | focusRequestId, | 556 | focusRequestId, |
| 555 | splitShortcutLabel, | 557 | splitShortcutLabel, |
| 558 | splitVerticalShortcutLabel, | ||
| 556 | newShortcutLabel, | 559 | newShortcutLabel, |
| 557 | closeShortcutLabel, | 560 | closeShortcutLabel, |
| 558 | keybindings, | 561 | keybindings, |
| ... | @@ -573,16 +576,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -573,16 +576,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 573 | environmentId: threadRef.environmentId, | 576 | environmentId: threadRef.environmentId, |
| 574 | threadId, | 577 | threadId, |
| 575 | }); | 578 | }); |
| 579 | const panelSurfaces = useRightPanelStore( | ||
| 580 | (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, | ||
| 581 | ); | ||
| 582 | const panelTerminalIds = useMemo( | ||
| 583 | () => | ||
| 584 | new Set( | ||
| 585 | panelSurfaces.flatMap((surface) => | ||
| 586 | surface.kind === "terminal" ? surface.terminalIds : [], | ||
| 587 | ), | ||
| 588 | ), | ||
| 589 | [panelSurfaces], | ||
| 590 | ); | ||
| 591 | const drawerTerminalSessions = useMemo( | ||
| 592 | () => | ||
| 593 | knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), | ||
| 594 | [knownTerminalSessions, panelTerminalIds], | ||
| 595 | ); | ||
| 576 | const terminalLabelsById = useMemo(() => { | 596 | const terminalLabelsById = useMemo(() => { |
| 577 | const next = new Map<string, string>(); | 597 | const next = new Map<string, string>(); |
| 578 | for (const session of knownTerminalSessions) { | 598 | for (const session of drawerTerminalSessions) { |
| 579 | next.set( | 599 | next.set( |
| 580 | session.target.terminalId, | 600 | session.target.terminalId, |
| 581 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | 601 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), |
| 582 | ); | 602 | ); |
| 583 | } | 603 | } |
| 584 | return next; | 604 | return next; |
| 585 | }, [knownTerminalSessions]); | 605 | }, [drawerTerminalSessions]); |
| 586 | const terminalLaunchLocationsById = useMemo(() => { | 606 | const terminalLaunchLocationsById = useMemo(() => { |
| 587 | const next = new Map< | 607 | const next = new Map< |
| 588 | string, | 608 | string, |
| ... | @@ -596,7 +616,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -596,7 +616,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 596 | return next; | 616 | return next; |
| 597 | } | 617 | } |
| 598 | 618 | ||
| 599 | for (const session of knownTerminalSessions) { | 619 | for (const session of drawerTerminalSessions) { |
| 600 | const summary = session.state.summary; | 620 | const summary = session.state.summary; |
| 601 | if (!summary) { | 621 | if (!summary) { |
| 602 | continue; | 622 | continue; |
| ... | @@ -614,13 +634,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -614,13 +634,16 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 614 | } | 634 | } |
| 615 | 635 | ||
| 616 | return next; | 636 | return next; |
| 617 | }, [knownTerminalSessions, launchContext, project]); | 637 | }, [drawerTerminalSessions, launchContext, project]); |
| 618 | const serverOrderedTerminalIds = useMemo( | 638 | const serverOrderedTerminalIds = useMemo( |
| 619 | () => knownTerminalSessions.map((session) => session.target.terminalId), | 639 | () => drawerTerminalSessions.map((session) => session.target.terminalId), |
| 620 | [knownTerminalSessions], | 640 | [drawerTerminalSessions], |
| 621 | ); | 641 | ); |
| 622 | const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); | 642 | const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); |
| 623 | const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); | 643 | const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); |
| 644 | const storeSplitTerminalVertical = useTerminalUiStateStore( | ||
| 645 | (state) => state.splitTerminalVertical, | ||
| 646 | ); | ||
| 624 | const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); | 647 | const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); |
| 625 | const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); | 648 | const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); |
| 626 | const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); | 649 | const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); |
| ... | @@ -712,6 +735,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -712,6 +735,33 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 712 | threadId, | 735 | threadId, |
| 713 | threadRef, | 736 | threadRef, |
| 714 | ]); | 737 | ]); |
| 738 | const splitTerminalVertical = useCallback(() => { | ||
| 739 | const api = readEnvironmentApi(threadRef.environmentId); | ||
| 740 | if (!api || !cwd) { | ||
| 741 | return; | ||
| 742 | } | ||
| 743 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | ||
| 744 | storeSplitTerminalVertical(threadRef, terminalId); | ||
| 745 | bumpFocusRequestId(); | ||
| 746 | void api.terminal | ||
| 747 | .open({ | ||
| 748 | threadId, | ||
| 749 | terminalId, | ||
| 750 | cwd, | ||
| 751 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), | ||
| 752 | env: runtimeEnv, | ||
| 753 | }) | ||
| 754 | .catch(() => undefined); | ||
| 755 | }, [ | ||
| 756 | bumpFocusRequestId, | ||
| 757 | cwd, | ||
| 758 | effectiveWorktreePath, | ||
| 759 | runtimeEnv, | ||
| 760 | serverOrderedTerminalIds, | ||
| 761 | storeSplitTerminalVertical, | ||
| 762 | threadId, | ||
| 763 | threadRef, | ||
| 764 | ]); | ||
| 715 | 765 | ||
| 716 | const createNewTerminal = useCallback(() => { | 766 | const createNewTerminal = useCallback(() => { |
| 717 | const api = readEnvironmentApi(threadRef.environmentId); | 767 | const api = readEnvironmentApi(threadRef.environmentId); |
| ... | @@ -814,8 +864,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -814,8 +864,10 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 814 | activeTerminalGroupId={terminalUiState.activeTerminalGroupId} | 864 | activeTerminalGroupId={terminalUiState.activeTerminalGroupId} |
| 815 | focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} | 865 | focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} |
| 816 | onSplitTerminal={splitTerminal} | 866 | onSplitTerminal={splitTerminal} |
| 867 | onSplitTerminalVertical={splitTerminalVertical} | ||
| 817 | onNewTerminal={createNewTerminal} | 868 | onNewTerminal={createNewTerminal} |
| 818 | splitShortcutLabel={visible ? splitShortcutLabel : undefined} | 869 | splitShortcutLabel={visible ? splitShortcutLabel : undefined} |
| 870 | splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} | ||
| 819 | newShortcutLabel={visible ? newShortcutLabel : undefined} | 871 | newShortcutLabel={visible ? newShortcutLabel : undefined} |
| 820 | closeShortcutLabel={visible ? closeShortcutLabel : undefined} | 872 | closeShortcutLabel={visible ? closeShortcutLabel : undefined} |
| 821 | keybindings={keybindings} | 873 | keybindings={keybindings} |
| ... | @@ -830,6 +882,177 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -830,6 +882,177 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 830 | ); | 882 | ); |
| 831 | }); | 883 | }); |
| 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 | export default function ChatView(props: ChatViewProps) { | 1056 | export default function ChatView(props: ChatViewProps) { |
| 834 | const { | 1057 | const { |
| 835 | environmentId, | 1058 | environmentId, |
| ... | @@ -967,6 +1190,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -967,6 +1190,7 @@ export default function ChatView(props: ChatViewProps) { |
| 967 | ); | 1190 | ); |
| 968 | const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); | 1191 | const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); |
| 969 | const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); | 1192 | const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); |
| 1193 | const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); | ||
| 970 | const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); | 1194 | const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); |
| 971 | const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); | 1195 | const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); |
| 972 | const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); | 1196 | const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); |
| ... | @@ -1029,50 +1253,73 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1029,50 +1253,73 @@ export default function ChatView(props: ChatViewProps) { |
| 1029 | () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], | 1253 | () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], |
| 1030 | [activeServerOrderedTerminalIds, terminalUiState.terminalIds], | 1254 | [activeServerOrderedTerminalIds, terminalUiState.terminalIds], |
| 1031 | ); | 1255 | ); |
| 1256 | const activeTerminalLabelsById = useMemo(() => { | ||
| 1257 | const next = new Map<string, string>(); | ||
| 1258 | for (const session of activeThreadKnownSessions) { | ||
| 1259 | next.set( | ||
| 1260 | session.target.terminalId, | ||
| 1261 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | ||
| 1262 | ); | ||
| 1263 | } | ||
| 1264 | return next; | ||
| 1265 | }, [activeThreadKnownSessions]); | ||
| 1032 | const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); | 1266 | const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); |
| 1033 | const activeThreadRef = useMemo( | 1267 | const activeThreadRef = useMemo( |
| 1034 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), | 1268 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), |
| 1035 | [activeThread], | 1269 | [activeThread], |
| 1036 | ); | 1270 | ); |
| 1037 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; | 1271 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1272 | const activeRightPanelKind = useRightPanelStore((store) => | ||
| 1273 | selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), | ||
| 1274 | ); | ||
| 1275 | const rightPanelState = useRightPanelStore((store) => | ||
| 1276 | selectThreadRightPanelState(store.byThreadKey, activeThreadRef), | ||
| 1277 | ); | ||
| 1278 | const activeRightPanelSurface = useRightPanelStore((store) => | ||
| 1279 | selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), | ||
| 1280 | ); | ||
| 1281 | const activePreviewState = usePreviewStateStore((state) => | ||
| 1282 | selectThreadPreviewState(state.byThreadKey, activeThreadRef), | ||
| 1283 | ); | ||
| 1284 | const panelTerminalIds = useMemo( | ||
| 1285 | () => | ||
| 1286 | new Set( | ||
| 1287 | rightPanelState.surfaces.flatMap((surface) => | ||
| 1288 | surface.kind === "terminal" ? surface.terminalIds : [], | ||
| 1289 | ), | ||
| 1290 | ), | ||
| 1291 | [rightPanelState.surfaces], | ||
| 1292 | ); | ||
| 1293 | const drawerServerOrderedTerminalIds = useMemo( | ||
| 1294 | () => activeServerOrderedTerminalIds.filter((terminalId) => !panelTerminalIds.has(terminalId)), | ||
| 1295 | [activeServerOrderedTerminalIds, panelTerminalIds], | ||
| 1296 | ); | ||
| 1038 | useEffect(() => { | 1297 | useEffect(() => { |
| 1039 | if (!activeThreadRef) { | 1298 | if (!activeThreadRef) { |
| 1040 | return; | 1299 | return; |
| 1041 | } | 1300 | } |
| 1042 | if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) { | 1301 | if (terminalIdListsEqual(drawerServerOrderedTerminalIds, terminalUiState.terminalIds)) { |
| 1043 | return; | 1302 | return; |
| 1044 | } | 1303 | } |
| 1045 | if ( | 1304 | if ( |
| 1046 | serverTerminalIdsStrictSubsetOfClient( | 1305 | serverTerminalIdsStrictSubsetOfClient( |
| 1047 | activeServerOrderedTerminalIds, | 1306 | drawerServerOrderedTerminalIds, |
| 1048 | terminalUiState.terminalIds, | 1307 | terminalUiState.terminalIds, |
| 1049 | ) | 1308 | ) |
| 1050 | ) { | 1309 | ) { |
| 1051 | return; | 1310 | return; |
| 1052 | } | 1311 | } |
| 1053 | reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds); | 1312 | reconcileTerminalIds(activeThreadRef, drawerServerOrderedTerminalIds); |
| 1054 | }, [ | 1313 | }, [ |
| 1055 | activeThreadRef, | 1314 | activeThreadRef, |
| 1056 | activeServerOrderedTerminalIds, | 1315 | drawerServerOrderedTerminalIds, |
| 1057 | reconcileTerminalIds, | 1316 | reconcileTerminalIds, |
| 1058 | terminalUiState.terminalIds, | 1317 | terminalUiState.terminalIds, |
| 1059 | ]); | 1318 | ]); |
| 1060 | const activeRightPanelKind = useRightPanelStore((store) => | ||
| 1061 | selectActiveRightPanelKindWithUrl(store.byThreadKey, activeThreadRef, diffOpen), | ||
| 1062 | ); | ||
| 1063 | const rightPanelState = useRightPanelStore((store) => | ||
| 1064 | selectThreadRightPanelState(store.byThreadKey, activeThreadRef), | ||
| 1065 | ); | ||
| 1066 | const activeRightPanelSurface = useRightPanelStore((store) => | ||
| 1067 | selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), | ||
| 1068 | ); | ||
| 1069 | const activePreviewState = usePreviewStateStore((state) => | ||
| 1070 | selectThreadPreviewState(state.byThreadKey, activeThreadRef), | ||
| 1071 | ); | ||
| 1072 | const planSidebarOpen = activeRightPanelKind === "plan"; | 1319 | const planSidebarOpen = activeRightPanelKind === "plan"; |
| 1073 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); | 1320 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); |
| 1074 | const terminalPanelOpen = activeRightPanelKind === "terminal"; | 1321 | const rightPanelOpen = rightPanelState.isOpen; |
| 1075 | const rightPanelOpen = activeRightPanelSurface !== null; | 1322 | const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; |
| 1076 | 1323 | ||
| 1077 | useEffect(() => { | 1324 | useEffect(() => { |
| 1078 | if (!activeThreadRef) return; | 1325 | if (!activeThreadRef) return; |
| ... | @@ -1085,15 +1332,6 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1085,15 +1332,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1085 | if (!activeThreadRef || !diffOpen) return; | 1332 | if (!activeThreadRef || !diffOpen) return; |
| 1086 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | 1333 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| 1087 | }, [activeThreadRef, diffOpen]); | 1334 | }, [activeThreadRef, diffOpen]); |
| 1088 | useEffect(() => { | ||
| 1089 | if (!activeThreadRef || !terminalUiState.terminalOpen) return; | ||
| 1090 | const state = selectThreadRightPanelState( | ||
| 1091 | useRightPanelStore.getState().byThreadKey, | ||
| 1092 | activeThreadRef, | ||
| 1093 | ); | ||
| 1094 | if (state.surfaces.some((surface) => surface.kind === "terminal")) return; | ||
| 1095 | useRightPanelStore.getState().open(activeThreadRef, "terminal"); | ||
| 1096 | }, [activeThreadRef, terminalUiState.terminalOpen]); | ||
| 1097 | const activeLatestTurn = activeThread?.latestTurn ?? null; | 1335 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 1098 | const threadPlanCatalog = useThreadPlanCatalog( | 1336 | const threadPlanCatalog = useThreadPlanCatalog( |
| 1099 | useMemo(() => { | 1337 | useMemo(() => { |
| ... | @@ -1115,6 +1353,10 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1115,6 +1353,10 @@ export default function ChatView(props: ChatViewProps) { |
| 1115 | const activeProject = useStore( | 1353 | const activeProject = useStore( |
| 1116 | useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), | 1354 | useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), |
| 1117 | ); | 1355 | ); |
| 1356 | const configuredPreviewUrls = useMemo( | ||
| 1357 | () => getConfiguredPreviewUrls(activeProject?.scripts), | ||
| 1358 | [activeProject?.scripts], | ||
| 1359 | ); | ||
| 1118 | 1360 | ||
| 1119 | useEffect(() => { | 1361 | useEffect(() => { |
| 1120 | if (routeKind !== "server") { | 1362 | if (routeKind !== "server") { |
| ... | @@ -1906,6 +2148,11 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1906,6 +2148,11 @@ export default function ChatView(props: ChatViewProps) { |
| 1906 | () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), | 2148 | () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), |
| 1907 | [keybindings, terminalShortcutLabelOptions], | 2149 | [keybindings, terminalShortcutLabelOptions], |
| 1908 | ); | 2150 | ); |
| 2151 | const splitTerminalVerticalShortcutLabel = useMemo( | ||
| 2152 | () => | ||
| 2153 | shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions), | ||
| 2154 | [keybindings, terminalShortcutLabelOptions], | ||
| 2155 | ); | ||
| 1909 | const newTerminalShortcutLabel = useMemo( | 2156 | const newTerminalShortcutLabel = useMemo( |
| 1910 | () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), | 2157 | () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), |
| 1911 | [keybindings, terminalShortcutLabelOptions], | 2158 | [keybindings, terminalShortcutLabelOptions], |
| ... | @@ -1988,7 +2235,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1988,7 +2235,8 @@ export default function ChatView(props: ChatViewProps) { |
| 1988 | if (!isServerThread) { | 2235 | if (!isServerThread) { |
| 1989 | return; | 2236 | return; |
| 1990 | } | 2237 | } |
| 1991 | if (!diffOpen) { | 2238 | const diffPanelOpen = activeRightPanelKind === "diff"; |
| 2239 | if (!diffPanelOpen) { | ||
| 1992 | onDiffPanelOpen?.(); | 2240 | onDiffPanelOpen?.(); |
| 1993 | if (activeThreadRef) { | 2241 | if (activeThreadRef) { |
| 1994 | useRightPanelStore.getState().open(activeThreadRef, "diff"); | 2242 | useRightPanelStore.getState().open(activeThreadRef, "diff"); |
| ... | @@ -2005,12 +2253,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2005,12 +2253,12 @@ export default function ChatView(props: ChatViewProps) { |
| 2005 | replace: true, | 2253 | replace: true, |
| 2006 | search: (previous) => { | 2254 | search: (previous) => { |
| 2007 | const rest = stripDiffSearchParams(previous); | 2255 | const rest = stripDiffSearchParams(previous); |
| 2008 | return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; | 2256 | return diffPanelOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; |
| 2009 | }, | 2257 | }, |
| 2010 | }); | 2258 | }); |
| 2011 | }, [ | 2259 | }, [ |
| 2260 | activeRightPanelKind, | ||
| 2012 | activeThreadRef, | 2261 | activeThreadRef, |
| 2013 | diffOpen, | ||
| 2014 | environmentId, | 2262 | environmentId, |
| 2015 | isServerThread, | 2263 | isServerThread, |
| 2016 | navigate, | 2264 | navigate, |
| ... | @@ -2105,61 +2353,184 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2105,61 +2353,184 @@ export default function ChatView(props: ChatViewProps) { |
| 2105 | const setTerminalOpen = useCallback( | 2353 | const setTerminalOpen = useCallback( |
| 2106 | (open: boolean) => { | 2354 | (open: boolean) => { |
| 2107 | if (!activeThreadRef) return; | 2355 | if (!activeThreadRef) return; |
| 2356 | if (open && terminalUiState.terminalIds.length === 0) { | ||
| 2357 | storeNewTerminal( | ||
| 2358 | activeThreadRef, | ||
| 2359 | nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]), | ||
| 2360 | ); | ||
| 2361 | return; | ||
| 2362 | } | ||
| 2108 | storeSetTerminalOpen(activeThreadRef, open); | 2363 | storeSetTerminalOpen(activeThreadRef, open); |
| 2109 | }, | 2364 | }, |
| 2110 | [activeThreadRef, storeSetTerminalOpen], | 2365 | [ |
| 2366 | activeKnownTerminalIds, | ||
| 2367 | activeThreadRef, | ||
| 2368 | panelTerminalIds, | ||
| 2369 | storeNewTerminal, | ||
| 2370 | storeSetTerminalOpen, | ||
| 2371 | terminalUiState.terminalIds.length, | ||
| 2372 | ], | ||
| 2111 | ); | 2373 | ); |
| 2112 | const toggleTerminalVisibility = useCallback(() => { | 2374 | const addTerminalSurface = useCallback(() => { |
| 2113 | if (!activeThreadRef) return; | 2375 | if (!activeThreadRef || !activeThreadId || !activeProject) return; |
| 2114 | if (terminalPanelOpen) { | 2376 | const api = readEnvironmentApi(activeThreadRef.environmentId); |
| 2115 | useRightPanelStore.getState().close(activeThreadRef); | 2377 | const cwd = gitCwd ?? activeProject.cwd; |
| 2116 | return; | 2378 | if (!api || !cwd) return; |
| 2117 | } | 2379 | const panelIds = selectThreadRightPanelState( |
| 2118 | setTerminalOpen(true); | 2380 | useRightPanelStore.getState().byThreadKey, |
| 2119 | useRightPanelStore.getState().open(activeThreadRef, "terminal"); | 2381 | activeThreadRef, |
| 2120 | }, [activeThreadRef, setTerminalOpen, terminalPanelOpen]); | 2382 | ).surfaces.flatMap((surface) => (surface.kind === "terminal" ? surface.terminalIds : [])); |
| 2121 | const splitTerminal = useCallback(() => { | 2383 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelIds]); |
| 2122 | if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { | 2384 | useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); |
| 2123 | return; | ||
| 2124 | } | ||
| 2125 | const cwdForOpen = gitCwd ?? activeProject.cwd; | ||
| 2126 | if (!cwdForOpen) { | ||
| 2127 | return; | ||
| 2128 | } | ||
| 2129 | const api = readEnvironmentApi(environmentId); | ||
| 2130 | if (!api) { | ||
| 2131 | return; | ||
| 2132 | } | ||
| 2133 | const terminalId = nextTerminalId(activeKnownTerminalIds); | ||
| 2134 | storeSplitTerminal(activeThreadRef, terminalId); | ||
| 2135 | setTerminalFocusRequestId((value) => value + 1); | 2385 | setTerminalFocusRequestId((value) => value + 1); |
| 2136 | void (async () => { | 2386 | void api.terminal |
| 2137 | try { | 2387 | .open({ |
| 2138 | await api.terminal.open({ | 2388 | threadId: activeThreadId, |
| 2389 | terminalId, | ||
| 2390 | cwd, | ||
| 2391 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | ||
| 2392 | env: projectScriptRuntimeEnv({ | ||
| 2393 | project: { cwd: activeProject.cwd }, | ||
| 2394 | worktreePath: activeThreadWorktreePath, | ||
| 2395 | }), | ||
| 2396 | }) | ||
| 2397 | .catch(() => undefined); | ||
| 2398 | }, [ | ||
| 2399 | activeKnownTerminalIds, | ||
| 2400 | activeProject, | ||
| 2401 | activeThreadId, | ||
| 2402 | activeThreadRef, | ||
| 2403 | activeThreadWorktreePath, | ||
| 2404 | gitCwd, | ||
| 2405 | ]); | ||
| 2406 | const splitPanelTerminal = useCallback( | ||
| 2407 | (direction: "horizontal" | "vertical" = "horizontal") => { | ||
| 2408 | if ( | ||
| 2409 | !activeThreadRef || | ||
| 2410 | !activeThreadId || | ||
| 2411 | !activeProject || | ||
| 2412 | activeRightPanelSurface?.kind !== "terminal" || | ||
| 2413 | activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP | ||
| 2414 | ) { | ||
| 2415 | return; | ||
| 2416 | } | ||
| 2417 | const api = readEnvironmentApi(activeThreadRef.environmentId); | ||
| 2418 | const cwd = gitCwd ?? activeProject.cwd; | ||
| 2419 | if (!api || !cwd) return; | ||
| 2420 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | ||
| 2421 | useRightPanelStore | ||
| 2422 | .getState() | ||
| 2423 | .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); | ||
| 2424 | setTerminalFocusRequestId((value) => value + 1); | ||
| 2425 | void api.terminal | ||
| 2426 | .open({ | ||
| 2139 | threadId: activeThreadId, | 2427 | threadId: activeThreadId, |
| 2140 | terminalId, | 2428 | terminalId, |
| 2141 | cwd: cwdForOpen, | 2429 | cwd, |
| 2142 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | 2430 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), |
| 2143 | env: projectScriptRuntimeEnv({ | 2431 | env: projectScriptRuntimeEnv({ |
| 2144 | project: { cwd: activeProject.cwd }, | 2432 | project: { cwd: activeProject.cwd }, |
| 2145 | worktreePath: activeThreadWorktreePath, | 2433 | worktreePath: activeThreadWorktreePath, |
| 2146 | }), | 2434 | }), |
| 2147 | }); | 2435 | }) |
| 2148 | } catch { | 2436 | .catch(() => undefined); |
| 2149 | // Opening failed; the tab is already in the store — user can retry or close it. | 2437 | }, |
| 2438 | [ | ||
| 2439 | activeKnownTerminalIds, | ||
| 2440 | activeProject, | ||
| 2441 | activeRightPanelSurface, | ||
| 2442 | activeThreadId, | ||
| 2443 | activeThreadRef, | ||
| 2444 | activeThreadWorktreePath, | ||
| 2445 | gitCwd, | ||
| 2446 | panelTerminalIds, | ||
| 2447 | ], | ||
| 2448 | ); | ||
| 2449 | const splitPanelTerminalVertical = useCallback(() => { | ||
| 2450 | splitPanelTerminal("vertical"); | ||
| 2451 | }, [splitPanelTerminal]); | ||
| 2452 | const activatePanelTerminal = useCallback( | ||
| 2453 | (terminalId: string) => { | ||
| 2454 | if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; | ||
| 2455 | useRightPanelStore | ||
| 2456 | .getState() | ||
| 2457 | .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); | ||
| 2458 | setTerminalFocusRequestId((value) => value + 1); | ||
| 2459 | }, | ||
| 2460 | [activeRightPanelSurface, activeThreadRef], | ||
| 2461 | ); | ||
| 2462 | const closePanelTerminal = useCallback( | ||
| 2463 | (terminalId: string) => { | ||
| 2464 | if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; | ||
| 2465 | const api = readEnvironmentApi(activeThreadRef.environmentId); | ||
| 2466 | void api?.terminal | ||
| 2467 | .close({ | ||
| 2468 | threadId: activeThreadRef.threadId, | ||
| 2469 | terminalId, | ||
| 2470 | deleteHistory: true, | ||
| 2471 | }) | ||
| 2472 | .catch(() => undefined); | ||
| 2473 | useRightPanelStore | ||
| 2474 | .getState() | ||
| 2475 | .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); | ||
| 2476 | setTerminalFocusRequestId((value) => value + 1); | ||
| 2477 | }, | ||
| 2478 | [activeRightPanelSurface, activeThreadRef], | ||
| 2479 | ); | ||
| 2480 | const toggleTerminalVisibility = useCallback(() => { | ||
| 2481 | if (!activeThreadRef) return; | ||
| 2482 | setTerminalOpen(!terminalUiState.terminalOpen); | ||
| 2483 | }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); | ||
| 2484 | const splitTerminal = useCallback( | ||
| 2485 | (direction: "horizontal" | "vertical" = "horizontal") => { | ||
| 2486 | if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { | ||
| 2487 | return; | ||
| 2150 | } | 2488 | } |
| 2151 | })(); | 2489 | const cwdForOpen = gitCwd ?? activeProject.cwd; |
| 2152 | }, [ | 2490 | if (!cwdForOpen) { |
| 2153 | activeProject, | 2491 | return; |
| 2154 | activeKnownTerminalIds, | 2492 | } |
| 2155 | activeThreadId, | 2493 | const api = readEnvironmentApi(environmentId); |
| 2156 | activeThreadRef, | 2494 | if (!api) { |
| 2157 | activeThreadWorktreePath, | 2495 | return; |
| 2158 | environmentId, | 2496 | } |
| 2159 | gitCwd, | 2497 | const terminalId = nextTerminalId(activeKnownTerminalIds); |
| 2160 | hasReachedSplitLimit, | 2498 | if (direction === "vertical") { |
| 2161 | storeSplitTerminal, | 2499 | storeSplitTerminalVertical(activeThreadRef, terminalId); |
| 2162 | ]); | 2500 | } else { |
| 2501 | storeSplitTerminal(activeThreadRef, terminalId); | ||
| 2502 | } | ||
| 2503 | setTerminalFocusRequestId((value) => value + 1); | ||
| 2504 | void (async () => { | ||
| 2505 | try { | ||
| 2506 | await api.terminal.open({ | ||
| 2507 | threadId: activeThreadId, | ||
| 2508 | terminalId, | ||
| 2509 | cwd: cwdForOpen, | ||
| 2510 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | ||
| 2511 | env: projectScriptRuntimeEnv({ | ||
| 2512 | project: { cwd: activeProject.cwd }, | ||
| 2513 | worktreePath: activeThreadWorktreePath, | ||
| 2514 | }), | ||
| 2515 | }); | ||
| 2516 | } catch { | ||
| 2517 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 2518 | } | ||
| 2519 | })(); | ||
| 2520 | }, | ||
| 2521 | [ | ||
| 2522 | activeProject, | ||
| 2523 | activeKnownTerminalIds, | ||
| 2524 | activeThreadId, | ||
| 2525 | activeThreadRef, | ||
| 2526 | activeThreadWorktreePath, | ||
| 2527 | environmentId, | ||
| 2528 | gitCwd, | ||
| 2529 | hasReachedSplitLimit, | ||
| 2530 | storeSplitTerminal, | ||
| 2531 | storeSplitTerminalVertical, | ||
| 2532 | ], | ||
| 2533 | ); | ||
| 2163 | const createNewTerminal = useCallback(() => { | 2534 | const createNewTerminal = useCallback(() => { |
| 2164 | if (!activeThreadRef || !activeThreadId || !activeProject) { | 2535 | if (!activeThreadRef || !activeThreadId || !activeProject) { |
| 2165 | return; | 2536 | return; |
| ... | @@ -2552,15 +2923,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2552,15 +2923,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2552 | const closePreviewPanel = useCallback(() => { | 2923 | const closePreviewPanel = useCallback(() => { |
| 2553 | if (!activeThreadRef) return; | 2924 | if (!activeThreadRef) return; |
| 2554 | useRightPanelStore.getState().close(activeThreadRef); | 2925 | useRightPanelStore.getState().close(activeThreadRef); |
| 2555 | if (diffOpen) { | 2926 | }, [activeThreadRef]); |
| 2556 | void navigate({ | ||
| 2557 | to: "/$environmentId/$threadId", | ||
| 2558 | params: { environmentId, threadId }, | ||
| 2559 | replace: true, | ||
| 2560 | search: (previous) => ({ ...stripDiffSearchParams(previous), diff: undefined }), | ||
| 2561 | }); | ||
| 2562 | } | ||
| 2563 | }, [activeThreadRef, diffOpen, environmentId, navigate, threadId]); | ||
| 2564 | const activateRightPanelSurface = useCallback( | 2927 | const activateRightPanelSurface = useCallback( |
| 2565 | (surface: RightPanelSurface) => { | 2928 | (surface: RightPanelSurface) => { |
| 2566 | if (!activeThreadRef) return; | 2929 | if (!activeThreadRef) return; |
| ... | @@ -2569,7 +2932,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2569,7 +2932,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2569 | usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); | 2932 | usePreviewStateStore.getState().setActiveTab(activeThreadRef, surface.resourceId); |
| 2570 | } | 2933 | } |
| 2571 | if (surface.kind === "terminal") { | 2934 | if (surface.kind === "terminal") { |
| 2572 | setTerminalOpen(true); | 2935 | setTerminalFocusRequestId((value) => value + 1); |
| 2573 | } | 2936 | } |
| 2574 | if (surface.kind === "diff" && !diffOpen) { | 2937 | if (surface.kind === "diff" && !diffOpen) { |
| 2575 | onDiffPanelOpen?.(); | 2938 | onDiffPanelOpen?.(); |
| ... | @@ -2588,48 +2951,41 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2588,48 +2951,41 @@ export default function ChatView(props: ChatViewProps) { |
| 2588 | }); | 2951 | }); |
| 2589 | } | 2952 | } |
| 2590 | }, | 2953 | }, |
| 2591 | [ | 2954 | [activeThreadRef, diffOpen, environmentId, navigate, onDiffPanelOpen, threadId], |
| 2592 | activeThreadRef, | ||
| 2593 | diffOpen, | ||
| 2594 | environmentId, | ||
| 2595 | navigate, | ||
| 2596 | onDiffPanelOpen, | ||
| 2597 | setTerminalOpen, | ||
| 2598 | threadId, | ||
| 2599 | ], | ||
| 2600 | ); | 2955 | ); |
| 2601 | const toggleRightPanel = useCallback(() => { | 2956 | const toggleRightPanel = useCallback(() => { |
| 2602 | if (!activeThreadRef) return; | 2957 | if (!activeThreadRef) return; |
| 2603 | if (rightPanelOpen) { | 2958 | useRightPanelStore.getState().toggleVisibility(activeThreadRef); |
| 2604 | closePreviewPanel(); | 2959 | }, [activeThreadRef]); |
| 2605 | return; | ||
| 2606 | } | ||
| 2607 | const surface = rightPanelState.surfaces.at(-1) ?? null; | ||
| 2608 | if (surface) { | ||
| 2609 | activateRightPanelSurface(surface); | ||
| 2610 | return; | ||
| 2611 | } | ||
| 2612 | createBrowserSurface(); | ||
| 2613 | }, [ | ||
| 2614 | activeThreadRef, | ||
| 2615 | activateRightPanelSurface, | ||
| 2616 | closePreviewPanel, | ||
| 2617 | createBrowserSurface, | ||
| 2618 | rightPanelOpen, | ||
| 2619 | rightPanelState.surfaces, | ||
| 2620 | ]); | ||
| 2621 | const closeRightPanelSurface = useCallback( | 2960 | const closeRightPanelSurface = useCallback( |
| 2622 | (surface: RightPanelSurface) => { | 2961 | (surface: RightPanelSurface) => { |
| 2623 | if (!activeThreadRef) return; | 2962 | if (!activeThreadRef) return; |
| 2624 | useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); | ||
| 2625 | if (surface.kind === "preview" && surface.resourceId) { | 2963 | if (surface.kind === "preview" && surface.resourceId) { |
| 2964 | usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId); | ||
| 2626 | const api = readEnvironmentApi(activeThreadRef.environmentId); | 2965 | const api = readEnvironmentApi(activeThreadRef.environmentId); |
| 2627 | void api?.preview | 2966 | void api?.preview |
| 2628 | .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) | 2967 | .close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId }) |
| 2629 | .catch(() => undefined); | 2968 | .catch(() => undefined); |
| 2630 | } | 2969 | } |
| 2970 | useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); | ||
| 2971 | const nextActiveSurface = selectActiveRightPanelSurface( | ||
| 2972 | useRightPanelStore.getState().byThreadKey, | ||
| 2973 | activeThreadRef, | ||
| 2974 | ); | ||
| 2975 | if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { | ||
| 2976 | usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId); | ||
| 2977 | } | ||
| 2631 | if (surface.kind === "terminal") { | 2978 | if (surface.kind === "terminal") { |
| 2632 | setTerminalOpen(false); | 2979 | const api = readEnvironmentApi(activeThreadRef.environmentId); |
| 2980 | for (const terminalId of surface.terminalIds) { | ||
| 2981 | void api?.terminal | ||
| 2982 | .close({ | ||
| 2983 | threadId: activeThreadRef.threadId, | ||
| 2984 | terminalId, | ||
| 2985 | deleteHistory: true, | ||
| 2986 | }) | ||
| 2987 | .catch(() => undefined); | ||
| 2988 | } | ||
| 2633 | } | 2989 | } |
| 2634 | if (surface.kind === "diff" && diffOpen) { | 2990 | if (surface.kind === "diff" && diffOpen) { |
| 2635 | void navigate({ | 2991 | void navigate({ |
| ... | @@ -2640,7 +2996,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2640,7 +2996,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2640 | }); | 2996 | }); |
| 2641 | } | 2997 | } |
| 2642 | }, | 2998 | }, |
| 2643 | [activeThreadRef, diffOpen, environmentId, navigate, setTerminalOpen, threadId], | 2999 | [activeThreadRef, diffOpen, environmentId, navigate, threadId], |
| 2644 | ); | 3000 | ); |
| 2645 | const persistThreadSettingsForNextTurn = useCallback( | 3001 | const persistThreadSettingsForNextTurn = useCallback( |
| 2646 | async (input: { | 3002 | async (input: { |
| ... | @@ -2920,8 +3276,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2920,8 +3276,9 @@ export default function ChatView(props: ChatViewProps) { |
| 2920 | if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { | 3276 | if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { |
| 2921 | return; | 3277 | return; |
| 2922 | } | 3278 | } |
| 3279 | const terminalFocusOwner = getTerminalFocusOwner(); | ||
| 2923 | const shortcutContext = { | 3280 | const shortcutContext = { |
| 2924 | terminalFocus: isTerminalFocused(), | 3281 | terminalFocus: terminalFocusOwner !== null, |
| 2925 | terminalOpen: Boolean(terminalUiState.terminalOpen), | 3282 | terminalOpen: Boolean(terminalUiState.terminalOpen), |
| 2926 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, | 3283 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, |
| 2927 | }; | 3284 | }; |
| ... | @@ -2950,9 +3307,20 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2950,9 +3307,20 @@ export default function ChatView(props: ChatViewProps) { |
| 2950 | return; | 3307 | return; |
| 2951 | } | 3308 | } |
| 2952 | 3309 | ||
| 3310 | if (command === "rightPanel.toggle") { | ||
| 3311 | event.preventDefault(); | ||
| 3312 | event.stopPropagation(); | ||
| 3313 | toggleRightPanel(); | ||
| 3314 | return; | ||
| 3315 | } | ||
| 3316 | |||
| 2953 | if (command === "terminal.split") { | 3317 | if (command === "terminal.split") { |
| 2954 | event.preventDefault(); | 3318 | event.preventDefault(); |
| 2955 | event.stopPropagation(); | 3319 | event.stopPropagation(); |
| 3320 | if (terminalFocusOwner === "right-panel") { | ||
| 3321 | splitPanelTerminal(); | ||
| 3322 | return; | ||
| 3323 | } | ||
| 2956 | if (!terminalUiState.terminalOpen) { | 3324 | if (!terminalUiState.terminalOpen) { |
| 2957 | setTerminalOpen(true); | 3325 | setTerminalOpen(true); |
| 2958 | } | 3326 | } |
| ... | @@ -2960,9 +3328,27 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2960,9 +3328,27 @@ export default function ChatView(props: ChatViewProps) { |
| 2960 | return; | 3328 | return; |
| 2961 | } | 3329 | } |
| 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 | if (command === "terminal.close") { | 3345 | if (command === "terminal.close") { |
| 2964 | event.preventDefault(); | 3346 | event.preventDefault(); |
| 2965 | event.stopPropagation(); | 3347 | event.stopPropagation(); |
| 3348 | if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { | ||
| 3349 | closePanelTerminal(activeRightPanelSurface.activeTerminalId); | ||
| 3350 | return; | ||
| 3351 | } | ||
| 2966 | if (!terminalUiState.terminalOpen) return; | 3352 | if (!terminalUiState.terminalOpen) return; |
| 2967 | closeTerminal(terminalUiState.activeTerminalId); | 3353 | closeTerminal(terminalUiState.activeTerminalId); |
| 2968 | return; | 3354 | return; |
| ... | @@ -2971,6 +3357,10 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2971,6 +3357,10 @@ export default function ChatView(props: ChatViewProps) { |
| 2971 | if (command === "terminal.new") { | 3357 | if (command === "terminal.new") { |
| 2972 | event.preventDefault(); | 3358 | event.preventDefault(); |
| 2973 | event.stopPropagation(); | 3359 | event.stopPropagation(); |
| 3360 | if (terminalFocusOwner === "right-panel") { | ||
| 3361 | addTerminalSurface(); | ||
| 3362 | return; | ||
| 3363 | } | ||
| 2974 | if (!terminalUiState.terminalOpen) { | 3364 | if (!terminalUiState.terminalOpen) { |
| 2975 | setTerminalOpen(true); | 3365 | setTerminalOpen(true); |
| 2976 | } | 3366 | } |
| ... | @@ -3003,17 +3393,22 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3003,17 +3393,22 @@ export default function ChatView(props: ChatViewProps) { |
| 3003 | window.addEventListener("keydown", handler, true); | 3393 | window.addEventListener("keydown", handler, true); |
| 3004 | return () => window.removeEventListener("keydown", handler, true); | 3394 | return () => window.removeEventListener("keydown", handler, true); |
| 3005 | }, [ | 3395 | }, [ |
| 3396 | activeRightPanelSurface, | ||
| 3006 | activeProject, | 3397 | activeProject, |
| 3398 | addTerminalSurface, | ||
| 3007 | terminalUiState.terminalOpen, | 3399 | terminalUiState.terminalOpen, |
| 3008 | terminalUiState.activeTerminalId, | 3400 | terminalUiState.activeTerminalId, |
| 3009 | activeThreadId, | 3401 | activeThreadId, |
| 3402 | closePanelTerminal, | ||
| 3010 | closeTerminal, | 3403 | closeTerminal, |
| 3011 | createNewTerminal, | 3404 | createNewTerminal, |
| 3012 | setTerminalOpen, | 3405 | setTerminalOpen, |
| 3013 | runProjectScript, | 3406 | runProjectScript, |
| 3014 | splitTerminal, | 3407 | splitTerminal, |
| 3408 | splitPanelTerminal, | ||
| 3015 | keybindings, | 3409 | keybindings, |
| 3016 | onToggleDiff, | 3410 | onToggleDiff, |
| 3411 | toggleRightPanel, | ||
| 3017 | toggleTerminalVisibility, | 3412 | toggleTerminalVisibility, |
| 3018 | composerRef, | 3413 | composerRef, |
| 3019 | ]); | 3414 | ]); |
| ... | @@ -4026,6 +4421,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4026,6 +4421,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4026 | ? cn( | 4421 | ? cn( |
| 4027 | "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]", | 4422 | "drag-region flex h-[52px] items-center px-3 sm:px-5 wco:h-[env(titlebar-area-height)]", |
| 4028 | reserveTitleBarControlInset && | 4423 | reserveTitleBarControlInset && |
| 4424 | !inlineRightPanelOwnsTitleBar && | ||
| 4029 | "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", | 4425 | "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]", |
| 4030 | ) | 4426 | ) |
| 4031 | : "pb-2 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-2 sm:pb-3 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-3", | 4427 | : "pb-2 pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-2 sm:pb-3 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-3", |
| ... | @@ -4044,7 +4440,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4044,7 +4440,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4044 | } | 4440 | } |
| 4045 | keybindings={keybindings} | 4441 | keybindings={keybindings} |
| 4046 | availableEditors={availableEditors} | 4442 | availableEditors={availableEditors} |
| 4047 | rightPanelAvailable={isPreviewSupportedInRuntime() || isGitRepo} | 4443 | rightPanelAvailable={Boolean(activeProject)} |
| 4048 | rightPanelOpen={rightPanelOpen} | 4444 | rightPanelOpen={rightPanelOpen} |
| 4049 | gitCwd={gitCwd} | 4445 | gitCwd={gitCwd} |
| 4050 | onRunProjectScript={runProjectScript} | 4446 | onRunProjectScript={runProjectScript} |
| ... | @@ -4238,33 +4634,67 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4238,33 +4634,67 @@ export default function ChatView(props: ChatViewProps) { |
| 4238 | {/* end chat column */} | 4634 | {/* end chat column */} |
| 4239 | </div> | 4635 | </div> |
| 4240 | {/* end horizontal flex container */} | 4636 | {/* end horizontal flex container */} |
| 4637 | {activeThreadRef ? ( | ||
| 4638 | <PersistentThreadTerminalDrawer | ||
| 4639 | threadRef={activeThreadRef} | ||
| 4640 | threadId={activeThreadRef.threadId} | ||
| 4641 | visible={terminalUiState.terminalOpen} | ||
| 4642 | launchContext={activeTerminalLaunchContext ?? null} | ||
| 4643 | focusRequestId={terminalFocusRequestId} | ||
| 4644 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | ||
| 4645 | splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} | ||
| 4646 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | ||
| 4647 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | ||
| 4648 | keybindings={keybindings} | ||
| 4649 | onAddTerminalContext={addTerminalContextToDraft} | ||
| 4650 | /> | ||
| 4651 | ) : null} | ||
| 4241 | </div> | 4652 | </div> |
| 4242 | 4653 | ||
| 4243 | {!shouldUsePlanSidebarSheet && | 4654 | {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( |
| 4244 | rightPanelOpen && | ||
| 4245 | activeThreadRef && | ||
| 4246 | activeRightPanelSurface ? ( | ||
| 4247 | <RightPanelTabs | 4655 | <RightPanelTabs |
| 4248 | mode="inline" | 4656 | mode="inline" |
| 4249 | surfaces={rightPanelState.surfaces} | 4657 | surfaces={rightPanelState.surfaces} |
| 4250 | activeSurfaceId={activeRightPanelSurface.id} | 4658 | activeSurfaceId={activeRightPanelSurface?.id ?? null} |
| 4251 | previewSessions={activePreviewState.sessions} | 4659 | previewSessions={activePreviewState.sessions} |
| 4660 | terminalLabelsById={activeTerminalLabelsById} | ||
| 4252 | onActivate={activateRightPanelSurface} | 4661 | onActivate={activateRightPanelSurface} |
| 4253 | onCloseSurface={closeRightPanelSurface} | 4662 | onCloseSurface={closeRightPanelSurface} |
| 4254 | onAddBrowser={createBrowserSurface} | 4663 | onAddBrowser={createBrowserSurface} |
| 4664 | onAddTerminal={addTerminalSurface} | ||
| 4255 | onAddDiff={addDiffSurface} | 4665 | onAddDiff={addDiffSurface} |
| 4666 | browserAvailable={isPreviewSupportedInRuntime()} | ||
| 4256 | diffAvailable={isServerThread && isGitRepo} | 4667 | diffAvailable={isServerThread && isGitRepo} |
| 4257 | > | 4668 | > |
| 4258 | {activeRightPanelSurface.kind === "preview" ? ( | 4669 | {activeRightPanelSurface?.kind === "preview" ? ( |
| 4259 | <Suspense fallback={null}> | 4670 | <Suspense fallback={null}> |
| 4260 | <PreviewPanel | 4671 | <PreviewPanel |
| 4261 | mode="embedded" | 4672 | mode="embedded" |
| 4262 | threadRef={activeThreadRef} | 4673 | threadRef={activeThreadRef} |
| 4263 | tabId={activeRightPanelSurface.resourceId} | 4674 | tabId={activeRightPanelSurface.resourceId} |
| 4675 | configuredUrls={configuredPreviewUrls} | ||
| 4264 | visible | 4676 | visible |
| 4265 | /> | 4677 | /> |
| 4266 | </Suspense> | 4678 | </Suspense> |
| 4267 | ) : activeRightPanelSurface.kind === "diff" ? ( | 4679 | ) : activeRightPanelSurface?.kind === "terminal" ? ( |
| 4680 | <PersistentThreadTerminalPanel | ||
| 4681 | threadRef={activeThreadRef} | ||
| 4682 | surface={activeRightPanelSurface} | ||
| 4683 | launchContext={activeTerminalLaunchContext ?? null} | ||
| 4684 | focusRequestId={terminalFocusRequestId} | ||
| 4685 | keybindings={keybindings} | ||
| 4686 | onAddTerminalContext={addTerminalContextToDraft} | ||
| 4687 | onSplitTerminal={splitPanelTerminal} | ||
| 4688 | onSplitTerminalVertical={splitPanelTerminalVertical} | ||
| 4689 | onNewTerminal={addTerminalSurface} | ||
| 4690 | onActiveTerminalChange={activatePanelTerminal} | ||
| 4691 | onCloseTerminal={closePanelTerminal} | ||
| 4692 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | ||
| 4693 | splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} | ||
| 4694 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | ||
| 4695 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | ||
| 4696 | /> | ||
| 4697 | ) : activeRightPanelSurface?.kind === "diff" ? ( | ||
| 4268 | <DiffWorkerPoolProvider> | 4698 | <DiffWorkerPoolProvider> |
| 4269 | <Suspense fallback={null}> | 4699 | <Suspense fallback={null}> |
| 4270 | <DiffPanel mode="embedded" /> | 4700 | <DiffPanel mode="embedded" /> |
| ... | @@ -4274,49 +4704,57 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4274,49 +4704,57 @@ export default function ChatView(props: ChatViewProps) { |
| 4274 | </RightPanelTabs> | 4704 | </RightPanelTabs> |
| 4275 | ) : null} | 4705 | ) : null} |
| 4276 | 4706 | ||
| 4277 | {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef && activeRightPanelSurface ? ( | 4707 | {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( |
| 4278 | <RightPanelSheet open onClose={closePreviewPanel}> | 4708 | <RightPanelSheet open onClose={closePreviewPanel}> |
| 4279 | <RightPanelTabs | 4709 | <RightPanelTabs |
| 4280 | mode="sheet" | 4710 | mode="sheet" |
| 4281 | surfaces={rightPanelState.surfaces} | 4711 | surfaces={rightPanelState.surfaces} |
| 4282 | activeSurfaceId={activeRightPanelSurface.id} | 4712 | activeSurfaceId={activeRightPanelSurface?.id ?? null} |
| 4283 | previewSessions={activePreviewState.sessions} | 4713 | previewSessions={activePreviewState.sessions} |
| 4714 | terminalLabelsById={activeTerminalLabelsById} | ||
| 4284 | onActivate={activateRightPanelSurface} | 4715 | onActivate={activateRightPanelSurface} |
| 4285 | onCloseSurface={closeRightPanelSurface} | 4716 | onCloseSurface={closeRightPanelSurface} |
| 4286 | onAddBrowser={createBrowserSurface} | 4717 | onAddBrowser={createBrowserSurface} |
| 4718 | onAddTerminal={addTerminalSurface} | ||
| 4287 | onAddDiff={addDiffSurface} | 4719 | onAddDiff={addDiffSurface} |
| 4720 | browserAvailable={isPreviewSupportedInRuntime()} | ||
| 4288 | diffAvailable={isServerThread && isGitRepo} | 4721 | diffAvailable={isServerThread && isGitRepo} |
| 4289 | > | 4722 | > |
| 4290 | {activeRightPanelSurface.kind === "preview" ? ( | 4723 | {activeRightPanelSurface?.kind === "preview" ? ( |
| 4291 | <Suspense fallback={null}> | 4724 | <Suspense fallback={null}> |
| 4292 | <PreviewPanel | 4725 | <PreviewPanel |
| 4293 | mode="embedded" | 4726 | mode="embedded" |
| 4294 | threadRef={activeThreadRef} | 4727 | threadRef={activeThreadRef} |
| 4295 | tabId={activeRightPanelSurface.resourceId} | 4728 | tabId={activeRightPanelSurface.resourceId} |
| 4729 | configuredUrls={configuredPreviewUrls} | ||
| 4296 | visible | 4730 | visible |
| 4297 | /> | 4731 | /> |
| 4298 | </Suspense> | 4732 | </Suspense> |
| 4299 | ) : activeRightPanelSurface.kind === "terminal" ? ( | 4733 | ) : activeRightPanelSurface?.kind === "terminal" ? ( |
| 4300 | <PersistentThreadTerminalDrawer | 4734 | <PersistentThreadTerminalPanel |
| 4301 | threadRef={activeThreadRef} | 4735 | threadRef={activeThreadRef} |
| 4302 | threadId={activeThreadRef.threadId} | 4736 | surface={activeRightPanelSurface} |
| 4303 | visible | ||
| 4304 | mode="panel" | ||
| 4305 | launchContext={activeTerminalLaunchContext ?? null} | 4737 | launchContext={activeTerminalLaunchContext ?? null} |
| 4306 | focusRequestId={terminalFocusRequestId} | 4738 | focusRequestId={terminalFocusRequestId} |
| 4739 | keybindings={keybindings} | ||
| 4740 | onAddTerminalContext={addTerminalContextToDraft} | ||
| 4741 | onSplitTerminal={splitPanelTerminal} | ||
| 4742 | onSplitTerminalVertical={splitPanelTerminalVertical} | ||
| 4743 | onNewTerminal={addTerminalSurface} | ||
| 4744 | onActiveTerminalChange={activatePanelTerminal} | ||
| 4745 | onCloseTerminal={closePanelTerminal} | ||
| 4307 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | 4746 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} |
| 4747 | splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} | ||
| 4308 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | 4748 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} |
| 4309 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | 4749 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} |
| 4310 | keybindings={keybindings} | ||
| 4311 | onAddTerminalContext={addTerminalContextToDraft} | ||
| 4312 | /> | 4750 | /> |
| 4313 | ) : activeRightPanelSurface.kind === "diff" ? ( | 4751 | ) : activeRightPanelSurface?.kind === "diff" ? ( |
| 4314 | <DiffWorkerPoolProvider> | 4752 | <DiffWorkerPoolProvider> |
| 4315 | <Suspense fallback={null}> | 4753 | <Suspense fallback={null}> |
| 4316 | <DiffPanel mode="embedded" /> | 4754 | <DiffPanel mode="embedded" /> |
| 4317 | </Suspense> | 4755 | </Suspense> |
| 4318 | </DiffWorkerPoolProvider> | 4756 | </DiffWorkerPoolProvider> |
| 4319 | ) : ( | 4757 | ) : activeRightPanelSurface?.kind === "plan" ? ( |
| 4320 | <PlanSidebar | 4758 | <PlanSidebar |
| 4321 | activePlan={activePlan} | 4759 | activePlan={activePlan} |
| 4322 | activeProposedPlan={sidebarProposedPlan} | 4760 | activeProposedPlan={sidebarProposedPlan} |
| ... | @@ -4328,7 +4766,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4328,7 +4766,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4328 | mode="embedded" | 4766 | mode="embedded" |
| 4329 | onClose={closePlanSidebar} | 4767 | onClose={closePlanSidebar} |
| 4330 | /> | 4768 | /> |
| 4331 | )} | 4769 | ) : null} |
| 4332 | </RightPanelTabs> | 4770 | </RightPanelTabs> |
| 4333 | </RightPanelSheet> | 4771 | </RightPanelSheet> |
| 4334 | ) : null} | 4772 | ) : null} |
apps/web/src/components/RightPanelTabs.tsx+105-6| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | import type { PreviewSessionSnapshot } from "@t3tools/contracts"; | 1 | import type { PreviewSessionSnapshot } from "@t3tools/contracts"; |
| 2 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | ||
| 2 | import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; | 3 | import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; |
| 3 | import { type ReactNode, useState } from "react"; | 4 | import { type ReactNode, useState } from "react"; |
| 4 | 5 | ||
| 6 | import { isElectron } from "~/env"; | ||
| 5 | import type { RightPanelSurface } from "~/rightPanelStore"; | 7 | import type { RightPanelSurface } from "~/rightPanelStore"; |
| 6 | import { cn } from "~/lib/utils"; | 8 | import { cn } from "~/lib/utils"; |
| 7 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; | 9 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| ... | @@ -12,25 +14,97 @@ import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanel | ... | @@ -12,25 +14,97 @@ import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanel |
| 12 | interface RightPanelTabsProps { | 14 | interface RightPanelTabsProps { |
| 13 | mode: PreviewPanelMode; | 15 | mode: PreviewPanelMode; |
| 14 | surfaces: readonly RightPanelSurface[]; | 16 | surfaces: readonly RightPanelSurface[]; |
| 15 | activeSurfaceId: string; | 17 | activeSurfaceId: string | null; |
| 16 | previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; | 18 | previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; |
| 19 | terminalLabelsById: ReadonlyMap<string, string>; | ||
| 17 | onActivate: (surface: RightPanelSurface) => void; | 20 | onActivate: (surface: RightPanelSurface) => void; |
| 18 | onCloseSurface: (surface: RightPanelSurface) => void; | 21 | onCloseSurface: (surface: RightPanelSurface) => void; |
| 19 | onAddBrowser: () => void; | 22 | onAddBrowser: () => void; |
| 23 | onAddTerminal: () => void; | ||
| 20 | onAddDiff: () => void; | 24 | onAddDiff: () => void; |
| 25 | browserAvailable: boolean; | ||
| 21 | diffAvailable: boolean; | 26 | diffAvailable: boolean; |
| 22 | children: ReactNode; | 27 | children: ReactNode; |
| 23 | } | 28 | } |
| 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 | function surfaceTitle( | 95 | function surfaceTitle( |
| 26 | surface: RightPanelSurface, | 96 | surface: RightPanelSurface, |
| 27 | sessions: Readonly<Record<string, PreviewSessionSnapshot>>, | 97 | sessions: Readonly<Record<string, PreviewSessionSnapshot>>, |
| 98 | terminalLabelsById: ReadonlyMap<string, string>, | ||
| 28 | ): string { | 99 | ): string { |
| 29 | switch (surface.kind) { | 100 | switch (surface.kind) { |
| 30 | case "diff": | 101 | case "diff": |
| 31 | return "Diff"; | 102 | return "Diff"; |
| 32 | case "terminal": | 103 | case "terminal": |
| 33 | return "Terminal"; | 104 | return ( |
| 105 | terminalLabelsById.get(surface.activeTerminalId) ?? | ||
| 106 | getTerminalLabel(surface.activeTerminalId) | ||
| 107 | ); | ||
| 34 | case "plan": | 108 | case "plan": |
| 35 | return "Plan"; | 109 | return "Plan"; |
| 36 | case "preview": { | 110 | case "preview": { |
| ... | @@ -85,13 +159,22 @@ function SurfaceIcon({ | ... | @@ -85,13 +159,22 @@ function SurfaceIcon({ |
| 85 | } | 159 | } |
| 86 | 160 | ||
| 87 | export function RightPanelTabs(props: RightPanelTabsProps) { | 161 | export function RightPanelTabs(props: RightPanelTabsProps) { |
| 162 | const ownsDesktopTitleBar = isElectron && props.mode === "inline"; | ||
| 163 | |||
| 88 | return ( | 164 | return ( |
| 89 | <PreviewPanelShell mode={props.mode}> | 165 | <PreviewPanelShell mode={props.mode}> |
| 90 | <div className="flex h-10 shrink-0 items-center px-2"> | 166 | <div |
| 167 | className={cn( | ||
| 168 | "flex shrink-0 items-center px-2", | ||
| 169 | ownsDesktopTitleBar | ||
| 170 | ? "drag-region h-[52px] wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" | ||
| 171 | : "h-10", | ||
| 172 | )} | ||
| 173 | > | ||
| 91 | <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"> | 174 | <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"> |
| 92 | {props.surfaces.map((surface) => { | 175 | {props.surfaces.map((surface) => { |
| 93 | const active = surface.id === props.activeSurfaceId; | 176 | const active = surface.id === props.activeSurfaceId; |
| 94 | const title = surfaceTitle(surface, props.previewSessions); | 177 | const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); |
| 95 | return ( | 178 | return ( |
| 96 | <div | 179 | <div |
| 97 | key={surface.id} | 180 | key={surface.id} |
| ... | @@ -131,10 +214,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -131,10 +214,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 131 | <Plus className="size-4" /> | 214 | <Plus className="size-4" /> |
| 132 | </MenuTrigger> | 215 | </MenuTrigger> |
| 133 | <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44"> | 216 | <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44"> |
| 134 | <MenuItem onClick={props.onAddBrowser}> | 217 | <MenuItem onClick={props.onAddBrowser} disabled={!props.browserAvailable}> |
| 135 | <Globe2 /> | 218 | <Globe2 /> |
| 136 | Browser | 219 | Browser |
| 137 | </MenuItem> | 220 | </MenuItem> |
| 221 | <MenuItem onClick={props.onAddTerminal}> | ||
| 222 | <TerminalSquare /> | ||
| 223 | Terminal | ||
| 224 | </MenuItem> | ||
| 138 | <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}> | 225 | <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}> |
| 139 | <FileDiff /> | 226 | <FileDiff /> |
| 140 | Diff | 227 | Diff |
| ... | @@ -142,7 +229,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) { | ... | @@ -142,7 +229,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 142 | </MenuPopup> | 229 | </MenuPopup> |
| 143 | </Menu> | 230 | </Menu> |
| 144 | </div> | 231 | </div> |
| 145 | <div className="flex min-h-0 flex-1 flex-col">{props.children}</div> | 232 | <div className="flex min-h-0 flex-1 flex-col"> |
| 233 | {props.activeSurfaceId === null ? ( | ||
| 234 | <RightPanelEmptyState | ||
| 235 | onAddBrowser={props.onAddBrowser} | ||
| 236 | onAddTerminal={props.onAddTerminal} | ||
| 237 | onAddDiff={props.onAddDiff} | ||
| 238 | browserAvailable={props.browserAvailable} | ||
| 239 | diffAvailable={props.diffAvailable} | ||
| 240 | /> | ||
| 241 | ) : ( | ||
| 242 | props.children | ||
| 243 | )} | ||
| 244 | </div> | ||
| 146 | </PreviewPanelShell> | 245 | </PreviewPanelShell> |
| 147 | ); | 246 | ); |
| 148 | } | 247 | } |
apps/web/src/components/Sidebar.tsx+38| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | ChevronRightIcon, | 4 | ChevronRightIcon, |
| 5 | CloudIcon, | 5 | CloudIcon, |
| 6 | FolderPlusIcon, | 6 | FolderPlusIcon, |
| 7 | Globe2Icon, | ||
| 7 | SearchIcon, | 8 | SearchIcon, |
| 8 | SettingsIcon, | 9 | SettingsIcon, |
| 9 | SquarePenIcon, | 10 | SquarePenIcon, |
| ... | @@ -75,6 +76,7 @@ import { | ... | @@ -75,6 +76,7 @@ import { |
| 75 | } from "../store"; | 76 | } from "../store"; |
| 76 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 77 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 77 | import { useThreadRunningTerminalIds } from "../terminalSessionState"; | 78 | import { useThreadRunningTerminalIds } from "../terminalSessionState"; |
| 79 | import { useThreadDiscoveredPorts } from "../portDiscoveryState"; | ||
| 78 | import { useUiStateStore } from "../uiStateStore"; | 80 | import { useUiStateStore } from "../uiStateStore"; |
| 79 | import { | 81 | import { |
| 80 | resolveShortcutCommand, | 82 | resolveShortcutCommand, |
| ... | @@ -198,6 +200,7 @@ import { | ... | @@ -198,6 +200,7 @@ import { |
| 198 | type SidebarProjectSnapshot, | 200 | type SidebarProjectSnapshot, |
| 199 | } from "../sidebarProjectGrouping"; | 201 | } from "../sidebarProjectGrouping"; |
| 200 | import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; | 202 | import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; |
| 203 | import { openDiscoveredPort } from "./preview/openDiscoveredPort"; | ||
| 201 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { | 204 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| 202 | updated_at: "Last user message", | 205 | updated_at: "Last user message", |
| 203 | created_at: "Created at", | 206 | created_at: "Created at", |
| ... | @@ -349,6 +352,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -349,6 +352,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 349 | environmentId: thread.environmentId, | 352 | environmentId: thread.environmentId, |
| 350 | threadId: thread.id, | 353 | threadId: thread.id, |
| 351 | }); | 354 | }); |
| 355 | const discoveredPorts = useThreadDiscoveredPorts({ | ||
| 356 | environmentId: thread.environmentId, | ||
| 357 | threadId: thread.id, | ||
| 358 | }); | ||
| 352 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 359 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 353 | const isRemoteThread = | 360 | const isRemoteThread = |
| 354 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; | 361 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; |
| ... | @@ -419,6 +426,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -419,6 +426,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 419 | }, | 426 | }, |
| 420 | [handleThreadClick, orderedProjectThreadKeys, threadRef], | 427 | [handleThreadClick, orderedProjectThreadKeys, threadRef], |
| 421 | ); | 428 | ); |
| 429 | const handleOpenDiscoveredPort = useCallback( | ||
| 430 | (event: React.MouseEvent<HTMLButtonElement>) => { | ||
| 431 | const port = discoveredPorts[0]; | ||
| 432 | if (!port) return; | ||
| 433 | event.preventDefault(); | ||
| 434 | event.stopPropagation(); | ||
| 435 | navigateToThread(threadRef); | ||
| 436 | void openDiscoveredPort({ threadRef, port }); | ||
| 437 | }, | ||
| 438 | [discoveredPorts, navigateToThread, threadRef], | ||
| 439 | ); | ||
| 422 | const handleRowKeyDown = useCallback( | 440 | const handleRowKeyDown = useCallback( |
| 423 | (event: React.KeyboardEvent) => { | 441 | (event: React.KeyboardEvent) => { |
| 424 | if (event.key !== "Enter" && event.key !== " ") return; | 442 | if (event.key !== "Enter" && event.key !== " ") return; |
| ... | @@ -609,6 +627,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -609,6 +627,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 609 | )} | 627 | )} |
| 610 | </div> | 628 | </div> |
| 611 | <div className="ml-auto flex shrink-0 items-center gap-1.5"> | 629 | <div className="ml-auto flex shrink-0 items-center gap-1.5"> |
| 630 | {discoveredPorts.length > 0 && ( | ||
| 631 | <Tooltip> | ||
| 632 | <TooltipTrigger | ||
| 633 | render={ | ||
| 634 | <button | ||
| 635 | type="button" | ||
| 636 | aria-label={`Open localhost:${discoveredPorts[0]?.port ?? ""}`} | ||
| 637 | className="inline-flex cursor-pointer items-center justify-center text-emerald-600 outline-hidden focus-visible:ring-1 focus-visible:ring-ring dark:text-emerald-400" | ||
| 638 | onClick={handleOpenDiscoveredPort} | ||
| 639 | /> | ||
| 640 | } | ||
| 641 | > | ||
| 642 | <Globe2Icon className="size-3" /> | ||
| 643 | </TooltipTrigger> | ||
| 644 | <TooltipPopup side="top"> | ||
| 645 | Open localhost:{discoveredPorts[0]?.port} | ||
| 646 | {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} | ||
| 647 | </TooltipPopup> | ||
| 648 | </Tooltip> | ||
| 649 | )} | ||
| 612 | {terminalStatus && ( | 650 | {terminalStatus && ( |
| 613 | <Tooltip> | 651 | <Tooltip> |
| 614 | <TooltipTrigger | 652 | <TooltipTrigger |
apps/web/src/components/ThreadTerminalDrawer.tsx+148-8| ... | @@ -1,5 +1,13 @@ | ... | @@ -1,5 +1,13 @@ |
| 1 | import { FitAddon } from "@xterm/addon-fit"; | 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 | import { | 11 | import { |
| 4 | type ResolvedKeybindingsConfig, | 12 | type ResolvedKeybindingsConfig, |
| 5 | type ScopedThreadRef, | 13 | type ScopedThreadRef, |
| ... | @@ -37,6 +45,7 @@ import { | ... | @@ -37,6 +45,7 @@ import { |
| 37 | isTerminalCloseShortcut, | 45 | isTerminalCloseShortcut, |
| 38 | isTerminalNewShortcut, | 46 | isTerminalNewShortcut, |
| 39 | isTerminalSplitShortcut, | 47 | isTerminalSplitShortcut, |
| 48 | isTerminalSplitVerticalShortcut, | ||
| 40 | isTerminalToggleShortcut, | 49 | isTerminalToggleShortcut, |
| 41 | terminalDeleteShortcutData, | 50 | terminalDeleteShortcutData, |
| 42 | terminalNavigationShortcutData, | 51 | terminalNavigationShortcutData, |
| ... | @@ -50,6 +59,8 @@ import { readEnvironmentApi } from "~/environmentApi"; | ... | @@ -50,6 +59,8 @@ import { readEnvironmentApi } from "~/environmentApi"; |
| 50 | import { readLocalApi } from "~/localApi"; | 59 | import { readLocalApi } from "~/localApi"; |
| 51 | import { attachTerminalSession } from "../terminalSessionState"; | 60 | import { attachTerminalSession } from "../terminalSessionState"; |
| 52 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; | 61 | import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; |
| 62 | import { useDiscoveredPorts } from "../portDiscoveryState"; | ||
| 63 | import { openDiscoveredPort } from "./preview/openDiscoveredPort"; | ||
| 53 | 64 | ||
| 54 | const MIN_DRAWER_HEIGHT = 180; | 65 | const MIN_DRAWER_HEIGHT = 180; |
| 55 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; | 66 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | @@ -437,6 +448,7 @@ export function TerminalViewport({ | ... | @@ -437,6 +448,7 @@ export function TerminalViewport({ |
| 437 | if ( | 448 | if ( |
| 438 | isTerminalToggleShortcut(event, currentKeybindings, options) || | 449 | isTerminalToggleShortcut(event, currentKeybindings, options) || |
| 439 | isTerminalSplitShortcut(event, currentKeybindings, options) || | 450 | isTerminalSplitShortcut(event, currentKeybindings, options) || |
| 451 | isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || | ||
| 440 | isTerminalNewShortcut(event, currentKeybindings, options) || | 452 | isTerminalNewShortcut(event, currentKeybindings, options) || |
| 441 | isTerminalCloseShortcut(event, currentKeybindings, options) || | 453 | isTerminalCloseShortcut(event, currentKeybindings, options) || |
| 442 | isDiffToggleShortcut(event, currentKeybindings, options) | 454 | isDiffToggleShortcut(event, currentKeybindings, options) |
| ... | @@ -723,12 +735,44 @@ export function TerminalViewport({ | ... | @@ -723,12 +735,44 @@ export function TerminalViewport({ |
| 723 | .catch(() => undefined); | 735 | .catch(() => undefined); |
| 724 | }, 30); | 736 | }, 30); |
| 725 | attachTerminal(); | 737 | attachTerminal(); |
| 738 | let resizeFrame = 0; | ||
| 739 | const resizeObserver = | ||
| 740 | typeof ResizeObserver === "undefined" | ||
| 741 | ? null | ||
| 742 | : new ResizeObserver(() => { | ||
| 743 | if (resizeFrame !== 0) return; | ||
| 744 | resizeFrame = window.requestAnimationFrame(() => { | ||
| 745 | resizeFrame = 0; | ||
| 746 | const activeTerminal = terminalRef.current; | ||
| 747 | const activeFitAddon = fitAddonRef.current; | ||
| 748 | if (!activeTerminal || !activeFitAddon) return; | ||
| 749 | const wasAtBottom = | ||
| 750 | activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; | ||
| 751 | fitTerminalSafely(activeFitAddon); | ||
| 752 | if (wasAtBottom) { | ||
| 753 | activeTerminal.scrollToBottom(); | ||
| 754 | } | ||
| 755 | void api.terminal | ||
| 756 | .resize({ | ||
| 757 | threadId, | ||
| 758 | terminalId, | ||
| 759 | cols: activeTerminal.cols, | ||
| 760 | rows: activeTerminal.rows, | ||
| 761 | }) | ||
| 762 | .catch(() => undefined); | ||
| 763 | }); | ||
| 764 | }); | ||
| 765 | resizeObserver?.observe(mount); | ||
| 726 | 766 | ||
| 727 | return () => { | 767 | return () => { |
| 728 | disposed = true; | 768 | disposed = true; |
| 729 | unsubscribeAttach?.(); | 769 | unsubscribeAttach?.(); |
| 730 | unsubscribeAttach = null; | 770 | unsubscribeAttach = null; |
| 731 | window.clearTimeout(fitTimer); | 771 | window.clearTimeout(fitTimer); |
| 772 | if (resizeFrame !== 0) { | ||
| 773 | window.cancelAnimationFrame(resizeFrame); | ||
| 774 | } | ||
| 775 | resizeObserver?.disconnect(); | ||
| 732 | inputDisposable.dispose(); | 776 | inputDisposable.dispose(); |
| 733 | selectionDisposable.dispose(); | 777 | selectionDisposable.dispose(); |
| 734 | terminalLinksDisposable.dispose(); | 778 | terminalLinksDisposable.dispose(); |
| ... | @@ -806,8 +850,10 @@ interface ThreadTerminalDrawerProps { | ... | @@ -806,8 +850,10 @@ interface ThreadTerminalDrawerProps { |
| 806 | activeTerminalGroupId: string; | 850 | activeTerminalGroupId: string; |
| 807 | focusRequestId: number; | 851 | focusRequestId: number; |
| 808 | onSplitTerminal: () => void; | 852 | onSplitTerminal: () => void; |
| 853 | onSplitTerminalVertical: () => void; | ||
| 809 | onNewTerminal: () => void; | 854 | onNewTerminal: () => void; |
| 810 | splitShortcutLabel?: string | undefined; | 855 | splitShortcutLabel?: string | undefined; |
| 856 | splitVerticalShortcutLabel?: string | undefined; | ||
| 811 | newShortcutLabel?: string | undefined; | 857 | newShortcutLabel?: string | undefined; |
| 812 | closeShortcutLabel?: string | undefined; | 858 | closeShortcutLabel?: string | undefined; |
| 813 | onActiveTerminalChange: (terminalId: string) => void; | 859 | onActiveTerminalChange: (terminalId: string) => void; |
| ... | @@ -865,8 +911,10 @@ export default function ThreadTerminalDrawer({ | ... | @@ -865,8 +911,10 @@ export default function ThreadTerminalDrawer({ |
| 865 | activeTerminalGroupId, | 911 | activeTerminalGroupId, |
| 866 | focusRequestId, | 912 | focusRequestId, |
| 867 | onSplitTerminal, | 913 | onSplitTerminal, |
| 914 | onSplitTerminalVertical, | ||
| 868 | onNewTerminal, | 915 | onNewTerminal, |
| 869 | splitShortcutLabel, | 916 | splitShortcutLabel, |
| 917 | splitVerticalShortcutLabel, | ||
| 870 | newShortcutLabel, | 918 | newShortcutLabel, |
| 871 | closeShortcutLabel, | 919 | closeShortcutLabel, |
| 872 | onActiveTerminalChange, | 920 | onActiveTerminalChange, |
| ... | @@ -957,6 +1005,9 @@ export default function ThreadTerminalDrawer({ | ... | @@ -957,6 +1005,9 @@ export default function ThreadTerminalDrawer({ |
| 957 | nextGroups.push({ | 1005 | nextGroups.push({ |
| 958 | id: assignUniqueGroupId(baseGroupId), | 1006 | id: assignUniqueGroupId(baseGroupId), |
| 959 | terminalIds: nextTerminalIds, | 1007 | terminalIds: nextTerminalIds, |
| 1008 | ...(terminalGroup.splitDirection === "vertical" | ||
| 1009 | ? { splitDirection: "vertical" as const } | ||
| 1010 | : {}), | ||
| 960 | }); | 1011 | }); |
| 961 | } | 1012 | } |
| 962 | 1013 | ||
| ... | @@ -994,6 +1045,8 @@ export default function ThreadTerminalDrawer({ | ... | @@ -994,6 +1045,8 @@ export default function ThreadTerminalDrawer({ |
| 994 | const visibleTerminalIds = | 1045 | const visibleTerminalIds = |
| 995 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? | 1046 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? |
| 996 | (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); | 1047 | (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); |
| 1048 | const splitDirection = | ||
| 1049 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; | ||
| 997 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; | 1050 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; |
| 998 | const isSplitView = visibleTerminalIds.length > 1; | 1051 | const isSplitView = visibleTerminalIds.length > 1; |
| 999 | const showGroupHeaders = | 1052 | const showGroupHeaders = |
| ... | @@ -1007,6 +1060,17 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1007,6 +1060,17 @@ export default function ThreadTerminalDrawer({ |
| 1007 | } | 1060 | } |
| 1008 | return next; | 1061 | return next; |
| 1009 | }, [normalizedTerminalIds, terminalLabelsById]); | 1062 | }, [normalizedTerminalIds, terminalLabelsById]); |
| 1063 | const discoveredPorts = useDiscoveredPorts(threadRef.environmentId); | ||
| 1064 | const discoveredPortByTerminalId = useMemo(() => { | ||
| 1065 | const next = new Map<string, (typeof discoveredPorts)[number]>(); | ||
| 1066 | for (const port of discoveredPorts) { | ||
| 1067 | if (port.terminal?.threadId !== threadId) continue; | ||
| 1068 | if (!next.has(port.terminal.terminalId)) { | ||
| 1069 | next.set(port.terminal.terminalId, port); | ||
| 1070 | } | ||
| 1071 | } | ||
| 1072 | return next; | ||
| 1073 | }, [discoveredPorts, threadId]); | ||
| 1010 | const resolveTerminalLaunchLocation = useCallback( | 1074 | const resolveTerminalLaunchLocation = useCallback( |
| 1011 | (terminalId: string): TerminalLaunchLocation => { | 1075 | (terminalId: string): TerminalLaunchLocation => { |
| 1012 | return ( | 1076 | return ( |
| ... | @@ -1020,10 +1084,15 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1020,10 +1084,15 @@ export default function ThreadTerminalDrawer({ |
| 1020 | [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], | 1084 | [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], |
| 1021 | ); | 1085 | ); |
| 1022 | const splitTerminalActionLabel = hasReachedSplitLimit | 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 | : splitShortcutLabel | 1088 | : splitShortcutLabel |
| 1025 | ? `Split Terminal (${splitShortcutLabel})` | 1089 | ? `Split Terminal Horizontally (${splitShortcutLabel})` |
| 1026 | : "Split Terminal"; | 1090 | : "Split Terminal Horizontally"; |
| 1091 | const splitTerminalVerticalActionLabel = hasReachedSplitLimit | ||
| 1092 | ? `Split Terminal Vertically (max ${MAX_TERMINALS_PER_GROUP} per group)` | ||
| 1093 | : splitVerticalShortcutLabel | ||
| 1094 | ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` | ||
| 1095 | : "Split Terminal Vertically"; | ||
| 1027 | const newTerminalActionLabel = newShortcutLabel | 1096 | const newTerminalActionLabel = newShortcutLabel |
| 1028 | ? `New Terminal (${newShortcutLabel})` | 1097 | ? `New Terminal (${newShortcutLabel})` |
| 1029 | : "New Terminal"; | 1098 | : "New Terminal"; |
| ... | @@ -1034,6 +1103,10 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1034,6 +1103,10 @@ export default function ThreadTerminalDrawer({ |
| 1034 | if (hasReachedSplitLimit) return; | 1103 | if (hasReachedSplitLimit) return; |
| 1035 | onSplitTerminal(); | 1104 | onSplitTerminal(); |
| 1036 | }, [hasReachedSplitLimit, onSplitTerminal]); | 1105 | }, [hasReachedSplitLimit, onSplitTerminal]); |
| 1106 | const onSplitTerminalVerticalAction = useCallback(() => { | ||
| 1107 | if (hasReachedSplitLimit) return; | ||
| 1108 | onSplitTerminalVertical(); | ||
| 1109 | }, [hasReachedSplitLimit, onSplitTerminalVertical]); | ||
| 1037 | const onNewTerminalAction = useCallback(() => { | 1110 | const onNewTerminalAction = useCallback(() => { |
| 1038 | onNewTerminal(); | 1111 | onNewTerminal(); |
| 1039 | }, [onNewTerminal]); | 1112 | }, [onNewTerminal]); |
| ... | @@ -1143,6 +1216,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1143,6 +1216,7 @@ export default function ThreadTerminalDrawer({ |
| 1143 | if (normalizedTerminalIds.length === 0) { | 1216 | if (normalizedTerminalIds.length === 0) { |
| 1144 | return ( | 1217 | return ( |
| 1145 | <aside | 1218 | <aside |
| 1219 | data-terminal-owner={isPanel ? "right-panel" : "drawer"} | ||
| 1146 | className={cn( | 1220 | className={cn( |
| 1147 | "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", | 1221 | "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", |
| 1148 | isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", | 1222 | isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", |
| ... | @@ -1176,6 +1250,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1176,6 +1250,7 @@ export default function ThreadTerminalDrawer({ |
| 1176 | 1250 | ||
| 1177 | return ( | 1251 | return ( |
| 1178 | <aside | 1252 | <aside |
| 1253 | data-terminal-owner={isPanel ? "right-panel" : "drawer"} | ||
| 1179 | className={cn( | 1254 | className={cn( |
| 1180 | "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", | 1255 | "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", |
| 1181 | isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", | 1256 | isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", |
| ... | @@ -1207,6 +1282,18 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1207,6 +1282,18 @@ export default function ThreadTerminalDrawer({ |
| 1207 | <SquareSplitHorizontal className="size-3.25" /> | 1282 | <SquareSplitHorizontal className="size-3.25" /> |
| 1208 | </TerminalActionButton> | 1283 | </TerminalActionButton> |
| 1209 | <div className="h-4 w-px bg-border/80" /> | 1284 | <div className="h-4 w-px bg-border/80" /> |
| 1285 | <TerminalActionButton | ||
| 1286 | className={`p-1 text-foreground/90 transition-colors ${ | ||
| 1287 | hasReachedSplitLimit | ||
| 1288 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | ||
| 1289 | : "hover:bg-accent" | ||
| 1290 | }`} | ||
| 1291 | onClick={onSplitTerminalVerticalAction} | ||
| 1292 | label={splitTerminalVerticalActionLabel} | ||
| 1293 | > | ||
| 1294 | <SquareSplitVertical className="size-3.25" /> | ||
| 1295 | </TerminalActionButton> | ||
| 1296 | <div className="h-4 w-px bg-border/80" /> | ||
| 1210 | <TerminalActionButton | 1297 | <TerminalActionButton |
| 1211 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | 1298 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 1212 | onClick={onNewTerminalAction} | 1299 | onClick={onNewTerminalAction} |
| ... | @@ -1232,16 +1319,26 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1232,16 +1319,26 @@ export default function ThreadTerminalDrawer({ |
| 1232 | {isSplitView ? ( | 1319 | {isSplitView ? ( |
| 1233 | <div | 1320 | <div |
| 1234 | className="grid h-full w-full min-w-0 gap-0 overflow-hidden" | 1321 | className="grid h-full w-full min-w-0 gap-0 overflow-hidden" |
| 1235 | style={{ | 1322 | style={ |
| 1236 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | 1323 | splitDirection === "vertical" |
| 1237 | }} | 1324 | ? { |
| 1325 | gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | ||
| 1326 | } | ||
| 1327 | : { | ||
| 1328 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | ||
| 1329 | } | ||
| 1330 | } | ||
| 1238 | > | 1331 | > |
| 1239 | {visibleTerminalIds.map((terminalId) => { | 1332 | {visibleTerminalIds.map((terminalId) => { |
| 1240 | const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); | 1333 | const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); |
| 1241 | return ( | 1334 | return ( |
| 1242 | <div | 1335 | <div |
| 1243 | key={terminalId} | 1336 | key={terminalId} |
| 1244 | className={`min-h-0 min-w-0 border-l first:border-l-0 ${ | 1337 | className={`min-h-0 min-w-0 ${ |
| 1338 | splitDirection === "vertical" | ||
| 1339 | ? "border-t first:border-t-0" | ||
| 1340 | : "border-l first:border-l-0" | ||
| 1341 | } ${ | ||
| 1245 | terminalId === resolvedActiveTerminalId | 1342 | terminalId === resolvedActiveTerminalId |
| 1246 | ? "border-border" | 1343 | ? "border-border" |
| 1247 | : "border-border/70" | 1344 | : "border-border/70" |
| ... | @@ -1320,6 +1417,17 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1320,6 +1417,17 @@ export default function ThreadTerminalDrawer({ |
| 1320 | > | 1417 | > |
| 1321 | <SquareSplitHorizontal className="size-3.25" /> | 1418 | <SquareSplitHorizontal className="size-3.25" /> |
| 1322 | </TerminalActionButton> | 1419 | </TerminalActionButton> |
| 1420 | <TerminalActionButton | ||
| 1421 | className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${ | ||
| 1422 | hasReachedSplitLimit | ||
| 1423 | ? "cursor-not-allowed opacity-45 hover:bg-transparent" | ||
| 1424 | : "hover:bg-accent/70" | ||
| 1425 | }`} | ||
| 1426 | onClick={onSplitTerminalVerticalAction} | ||
| 1427 | label={splitTerminalVerticalActionLabel} | ||
| 1428 | > | ||
| 1429 | <SquareSplitVertical className="size-3.25" /> | ||
| 1430 | </TerminalActionButton> | ||
| 1323 | <TerminalActionButton | 1431 | <TerminalActionButton |
| 1324 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" | 1432 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 1325 | onClick={onNewTerminalAction} | 1433 | onClick={onNewTerminalAction} |
| ... | @@ -1366,6 +1474,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1366,6 +1474,7 @@ export default function ThreadTerminalDrawer({ |
| 1366 | > | 1474 | > |
| 1367 | {terminalGroup.terminalIds.map((terminalId) => { | 1475 | {terminalGroup.terminalIds.map((terminalId) => { |
| 1368 | const isActive = terminalId === resolvedActiveTerminalId; | 1476 | const isActive = terminalId === resolvedActiveTerminalId; |
| 1477 | const discoveredPort = discoveredPortByTerminalId.get(terminalId); | ||
| 1369 | const closeTerminalLabel = `Close ${ | 1478 | const closeTerminalLabel = `Close ${ |
| 1370 | terminalLabelById.get(terminalId) ?? "terminal" | 1479 | terminalLabelById.get(terminalId) ?? "terminal" |
| 1371 | }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; | 1480 | }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; |
| ... | @@ -1391,6 +1500,37 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1391,6 +1500,37 @@ export default function ThreadTerminalDrawer({ |
| 1391 | {terminalLabelById.get(terminalId) ?? "Terminal"} | 1500 | {terminalLabelById.get(terminalId) ?? "Terminal"} |
| 1392 | </span> | 1501 | </span> |
| 1393 | </button> | 1502 | </button> |
| 1503 | {discoveredPort && ( | ||
| 1504 | <Popover> | ||
| 1505 | <PopoverTrigger | ||
| 1506 | openOnHover | ||
| 1507 | render={ | ||
| 1508 | <button | ||
| 1509 | type="button" | ||
| 1510 | className="inline-flex size-4 items-center justify-center rounded text-emerald-600 transition hover:bg-accent dark:text-emerald-400" | ||
| 1511 | onClick={() => | ||
| 1512 | void openDiscoveredPort({ | ||
| 1513 | threadRef, | ||
| 1514 | port: discoveredPort, | ||
| 1515 | }) | ||
| 1516 | } | ||
| 1517 | aria-label={`Open localhost:${discoveredPort.port}`} | ||
| 1518 | /> | ||
| 1519 | } | ||
| 1520 | > | ||
| 1521 | <Globe2 className="size-3" /> | ||
| 1522 | </PopoverTrigger> | ||
| 1523 | <PopoverPopup | ||
| 1524 | tooltipStyle | ||
| 1525 | side="bottom" | ||
| 1526 | sideOffset={6} | ||
| 1527 | align="center" | ||
| 1528 | className="pointer-events-none select-none" | ||
| 1529 | > | ||
| 1530 | Open localhost:{discoveredPort.port} | ||
| 1531 | </PopoverPopup> | ||
| 1532 | </Popover> | ||
| 1533 | )} | ||
| 1394 | {normalizedTerminalIds.length > 1 && ( | 1534 | {normalizedTerminalIds.length > 1 && ( |
| 1395 | <Popover> | 1535 | <Popover> |
| 1396 | <PopoverTrigger | 1536 | <PopoverTrigger |
apps/web/src/components/chat/ChatHeader.tsx+5-1| ... | @@ -16,6 +16,7 @@ import { Toggle } from "../ui/toggle"; | ... | @@ -16,6 +16,7 @@ import { Toggle } from "../ui/toggle"; |
| 16 | import { SidebarTrigger } from "../ui/sidebar"; | 16 | import { SidebarTrigger } from "../ui/sidebar"; |
| 17 | import { OpenInPicker } from "./OpenInPicker"; | 17 | import { OpenInPicker } from "./OpenInPicker"; |
| 18 | import { usePrimaryEnvironmentId } from "../../environments/primary"; | 18 | import { usePrimaryEnvironmentId } from "../../environments/primary"; |
| 19 | import { shortcutLabelForCommand } from "../../keybindings"; | ||
| 19 | 20 | ||
| 20 | interface ChatHeaderProps { | 21 | interface ChatHeaderProps { |
| 21 | activeThreadEnvironmentId: EnvironmentId; | 22 | activeThreadEnvironmentId: EnvironmentId; |
| ... | @@ -76,6 +77,7 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -76,6 +77,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 76 | activeThreadEnvironmentId, | 77 | activeThreadEnvironmentId, |
| 77 | primaryEnvironmentId, | 78 | primaryEnvironmentId, |
| 78 | }); | 79 | }); |
| 80 | const rightPanelShortcutLabel = shortcutLabelForCommand(keybindings, "rightPanel.toggle"); | ||
| 79 | 81 | ||
| 80 | return ( | 82 | return ( |
| 81 | <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> | 83 | <div className="@container/header-actions flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> |
| ... | @@ -138,7 +140,9 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -138,7 +140,9 @@ export const ChatHeader = memo(function ChatHeader({ |
| 138 | } | 140 | } |
| 139 | /> | 141 | /> |
| 140 | <TooltipPopup side="bottom"> | 142 | <TooltipPopup side="bottom"> |
| 141 | {rightPanelAvailable ? "Toggle right panel" : "Right panel is unavailable"} | 143 | {rightPanelAvailable |
| 144 | ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}` | ||
| 145 | : "Right panel is unavailable"} | ||
| 142 | </TooltipPopup> | 146 | </TooltipPopup> |
| 143 | </Tooltip> | 147 | </Tooltip> |
| 144 | </div> | 148 | </div> |
apps/web/src/components/preview/AgentBrowserCursor.tsx created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 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,16 +22,19 @@ import { PreviewChromeRow } from "./PreviewChromeRow"; |
| 22 | import { PreviewEmptyState } from "./PreviewEmptyState"; | 22 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 23 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; | 23 | import { PreviewMoreMenu } from "./PreviewMoreMenu"; |
| 24 | import { PreviewUnreachable } from "./PreviewUnreachable"; | 24 | import { PreviewUnreachable } from "./PreviewUnreachable"; |
| 25 | import { revealInFileExplorerLabel } from "./fileExplorerLabel"; | ||
| 26 | import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; | ||
| 25 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; | 27 | import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; |
| 26 | import { useLoadingProgress } from "./useLoadingProgress"; | 28 | import { useLoadingProgress } from "./useLoadingProgress"; |
| 27 | import { usePreviewSession } from "./usePreviewSession"; | 29 | import { usePreviewSession } from "./usePreviewSession"; |
| 28 | import { ZoomIndicator } from "./ZoomIndicator"; | 30 | import { ZoomIndicator } from "./ZoomIndicator"; |
| 31 | import { AgentBrowserCursor } from "./AgentBrowserCursor"; | ||
| 29 | import { | 32 | import { |
| 30 | startBrowserRecording, | 33 | startBrowserRecording, |
| 31 | stopBrowserRecording, | 34 | stopBrowserRecording, |
| 32 | useBrowserRecordingStore, | 35 | useBrowserRecordingStore, |
| 33 | } from "~/browser/browserRecording"; | 36 | } from "~/browser/browserRecording"; |
| 34 | import { toastManager } from "~/components/ui/toast"; | 37 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 35 | 38 | ||
| 36 | interface Props { | 39 | interface Props { |
| 37 | threadRef: ScopedThreadRef; | 40 | threadRef: ScopedThreadRef; |
| ... | @@ -47,7 +50,7 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); | ... | @@ -47,7 +50,7 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); |
| 47 | * state when no session exists for the thread. | 50 | * state when no session exists for the thread. |
| 48 | */ | 51 | */ |
| 49 | export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { | 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 | const [pickActive, setPickActive] = useState(false); | 54 | const [pickActive, setPickActive] = useState(false); |
| 52 | const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId); | 55 | const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId); |
| 53 | const pickActiveRef = useRef(false); | 56 | const pickActiveRef = useRef(false); |
| ... | @@ -81,6 +84,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -81,6 +84,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 81 | const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false; | 84 | const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false; |
| 82 | const refreshDisabled = navStatus._tag === "Idle"; | 85 | const refreshDisabled = navStatus._tag === "Idle"; |
| 83 | const isUnreachable = navStatus._tag === "LoadFailed"; | 86 | const isUnreachable = navStatus._tag === "LoadFailed"; |
| 87 | const showEmptyState = shouldShowPreviewEmptyState(snapshot); | ||
| 84 | const controller = desktopOverlay?.controller ?? "none"; | 88 | const controller = desktopOverlay?.controller ?? "none"; |
| 85 | const loadProgress = useLoadingProgress(loading); | 89 | const loadProgress = useLoadingProgress(loading); |
| 86 | 90 | ||
| ... | @@ -142,16 +146,89 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -142,16 +146,89 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 142 | const handleCapture = useCallback( | 146 | const handleCapture = useCallback( |
| 143 | (record: boolean) => { | 147 | (record: boolean) => { |
| 144 | if (!previewBridge || !tabId) return; | 148 | if (!previewBridge || !tabId) return; |
| 149 | const bridge = previewBridge; | ||
| 145 | const recordingThisTab = activeRecordingTabId === tabId; | 150 | const recordingThisTab = activeRecordingTabId === tabId; |
| 146 | if (recordingThisTab) { | 151 | if (recordingThisTab) { |
| 147 | void stopBrowserRecording(tabId).then( | 152 | void stopBrowserRecording(tabId).then( |
| 148 | (artifact) => { | 153 | (artifact) => { |
| 149 | if (!artifact) return; | 154 | if (!artifact) return; |
| 150 | toastManager.add({ | 155 | let pathCopied = false; |
| 151 | type: "success", | 156 | let toastId: ReturnType<typeof toastManager.add>; |
| 152 | title: "Recording saved", | 157 | |
| 153 | description: artifact.path, | 158 | const copyPath = () => { |
| 154 | }); | 159 | if (!navigator.clipboard?.writeText) { |
| 160 | toastManager.update( | ||
| 161 | toastId, | ||
| 162 | stackedThreadToast({ | ||
| 163 | type: "error", | ||
| 164 | title: "Unable to copy recording path", | ||
| 165 | description: "Clipboard API unavailable.", | ||
| 166 | actionProps: revealAction, | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | return; | ||
| 170 | } | ||
| 171 | |||
| 172 | void navigator.clipboard.writeText(artifact.path).then( | ||
| 173 | () => { | ||
| 174 | pathCopied = true; | ||
| 175 | updateRecordingToast(); | ||
| 176 | window.setTimeout(() => { | ||
| 177 | pathCopied = false; | ||
| 178 | updateRecordingToast(); | ||
| 179 | }, 2_000); | ||
| 180 | }, | ||
| 181 | (error) => { | ||
| 182 | toastManager.update( | ||
| 183 | toastId, | ||
| 184 | stackedThreadToast({ | ||
| 185 | type: "error", | ||
| 186 | title: "Unable to copy recording path", | ||
| 187 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 188 | actionProps: revealAction, | ||
| 189 | }), | ||
| 190 | ); | ||
| 191 | }, | ||
| 192 | ); | ||
| 193 | }; | ||
| 194 | |||
| 195 | const revealAction = { | ||
| 196 | children: revealInFileExplorerLabel(navigator.platform), | ||
| 197 | onClick: () => void bridge.revealArtifact(artifact.path), | ||
| 198 | }; | ||
| 199 | const updateRecordingToast = () => { | ||
| 200 | toastManager.update( | ||
| 201 | toastId, | ||
| 202 | stackedThreadToast({ | ||
| 203 | type: "success", | ||
| 204 | title: "Recording saved", | ||
| 205 | actionProps: revealAction, | ||
| 206 | data: { | ||
| 207 | secondaryActionProps: { | ||
| 208 | children: pathCopied ? "Copied!" : "Copy path", | ||
| 209 | disabled: pathCopied, | ||
| 210 | onClick: copyPath, | ||
| 211 | }, | ||
| 212 | secondaryActionVariant: "outline", | ||
| 213 | }, | ||
| 214 | }), | ||
| 215 | ); | ||
| 216 | }; | ||
| 217 | |||
| 218 | toastId = toastManager.add( | ||
| 219 | stackedThreadToast({ | ||
| 220 | type: "success", | ||
| 221 | title: "Recording saved", | ||
| 222 | actionProps: revealAction, | ||
| 223 | data: { | ||
| 224 | secondaryActionProps: { | ||
| 225 | children: "Copy path", | ||
| 226 | onClick: copyPath, | ||
| 227 | }, | ||
| 228 | secondaryActionVariant: "outline", | ||
| 229 | }, | ||
| 230 | }), | ||
| 231 | ); | ||
| 155 | }, | 232 | }, |
| 156 | (error) => { | 233 | (error) => { |
| 157 | toastManager.add({ | 234 | toastManager.add({ |
| ... | @@ -181,13 +258,126 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -181,13 +258,126 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 181 | }); | 258 | }); |
| 182 | return; | 259 | return; |
| 183 | } | 260 | } |
| 184 | void previewBridge.captureScreenshot(tabId).then( | 261 | void bridge.captureScreenshot(tabId).then( |
| 185 | (artifact) => { | 262 | (artifact) => { |
| 186 | toastManager.add({ | 263 | const revealAction = { |
| 187 | type: "success", | 264 | children: revealInFileExplorerLabel(navigator.platform), |
| 188 | title: "Screenshot saved", | 265 | onClick: () => void bridge.revealArtifact(artifact.path), |
| 189 | description: artifact.path, | 266 | }; |
| 190 | }); | 267 | let pathCopied = false; |
| 268 | let imageCopied = false; | ||
| 269 | let toastId: ReturnType<typeof toastManager.add>; | ||
| 270 | |||
| 271 | const updateScreenshotToast = ( | ||
| 272 | type: "success" | "error" = "success", | ||
| 273 | title = "Screenshot saved", | ||
| 274 | description?: string, | ||
| 275 | ) => { | ||
| 276 | toastManager.update( | ||
| 277 | toastId, | ||
| 278 | stackedThreadToast({ | ||
| 279 | type, | ||
| 280 | title, | ||
| 281 | description, | ||
| 282 | actionProps: { | ||
| 283 | children: imageCopied ? "Copied!" : "Copy image", | ||
| 284 | disabled: imageCopied, | ||
| 285 | onClick: copyImage, | ||
| 286 | }, | ||
| 287 | data: { | ||
| 288 | additionalActions: [ | ||
| 289 | { | ||
| 290 | id: "copy-path", | ||
| 291 | props: { | ||
| 292 | children: pathCopied ? "Copied!" : "Copy path", | ||
| 293 | disabled: pathCopied, | ||
| 294 | onClick: copyPath, | ||
| 295 | }, | ||
| 296 | }, | ||
| 297 | ], | ||
| 298 | secondaryActionProps: { | ||
| 299 | ...revealAction, | ||
| 300 | }, | ||
| 301 | secondaryActionVariant: "outline", | ||
| 302 | }, | ||
| 303 | }), | ||
| 304 | ); | ||
| 305 | }; | ||
| 306 | |||
| 307 | const copyPath = () => { | ||
| 308 | if (!navigator.clipboard?.writeText) { | ||
| 309 | updateScreenshotToast( | ||
| 310 | "error", | ||
| 311 | "Unable to copy screenshot path", | ||
| 312 | "Clipboard API unavailable.", | ||
| 313 | ); | ||
| 314 | return; | ||
| 315 | } | ||
| 316 | |||
| 317 | void navigator.clipboard.writeText(artifact.path).then( | ||
| 318 | () => { | ||
| 319 | pathCopied = true; | ||
| 320 | updateScreenshotToast(); | ||
| 321 | window.setTimeout(() => { | ||
| 322 | pathCopied = false; | ||
| 323 | updateScreenshotToast(); | ||
| 324 | }, 2_000); | ||
| 325 | }, | ||
| 326 | (error) => { | ||
| 327 | updateScreenshotToast( | ||
| 328 | "error", | ||
| 329 | "Unable to copy screenshot path", | ||
| 330 | error instanceof Error ? error.message : "An error occurred.", | ||
| 331 | ); | ||
| 332 | }, | ||
| 333 | ); | ||
| 334 | }; | ||
| 335 | |||
| 336 | const copyImage = () => { | ||
| 337 | void bridge.copyArtifactToClipboard(artifact.path).then( | ||
| 338 | () => { | ||
| 339 | imageCopied = true; | ||
| 340 | updateScreenshotToast(); | ||
| 341 | window.setTimeout(() => { | ||
| 342 | imageCopied = false; | ||
| 343 | updateScreenshotToast(); | ||
| 344 | }, 2_000); | ||
| 345 | }, | ||
| 346 | (error) => { | ||
| 347 | updateScreenshotToast( | ||
| 348 | "error", | ||
| 349 | "Unable to copy screenshot", | ||
| 350 | error instanceof Error ? error.message : "An error occurred.", | ||
| 351 | ); | ||
| 352 | }, | ||
| 353 | ); | ||
| 354 | }; | ||
| 355 | |||
| 356 | toastId = toastManager.add( | ||
| 357 | stackedThreadToast({ | ||
| 358 | type: "success", | ||
| 359 | title: "Screenshot saved", | ||
| 360 | actionProps: { | ||
| 361 | children: "Copy image", | ||
| 362 | onClick: copyImage, | ||
| 363 | }, | ||
| 364 | data: { | ||
| 365 | additionalActions: [ | ||
| 366 | { | ||
| 367 | id: "copy-path", | ||
| 368 | props: { | ||
| 369 | children: "Copy path", | ||
| 370 | onClick: copyPath, | ||
| 371 | }, | ||
| 372 | }, | ||
| 373 | ], | ||
| 374 | secondaryActionProps: { | ||
| 375 | ...revealAction, | ||
| 376 | }, | ||
| 377 | secondaryActionVariant: "outline", | ||
| 378 | }, | ||
| 379 | }), | ||
| 380 | ); | ||
| 191 | }, | 381 | }, |
| 192 | (error) => { | 382 | (error) => { |
| 193 | toastManager.add({ | 383 | toastManager.add({ |
| ... | @@ -287,7 +477,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -287,7 +477,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 287 | handleRefresh(); | 477 | handleRefresh(); |
| 288 | return; | 478 | return; |
| 289 | case "focus-url": | 479 | case "focus-url": |
| 290 | setFocusUrlNonce((value) => value + 1); | 480 | setFocusUrlNonce((value) => (value ?? 0) + 1); |
| 291 | return; | 481 | return; |
| 292 | case "zoom-in": | 482 | case "zoom-in": |
| 293 | handleZoomIn(); | 483 | handleZoomIn(); |
| ... | @@ -346,24 +536,32 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, | ... | @@ -346,24 +536,32 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, |
| 346 | /> | 536 | /> |
| 347 | 537 | ||
| 348 | <div className="relative min-h-0 flex-1 overflow-hidden"> | 538 | <div className="relative min-h-0 flex-1 overflow-hidden"> |
| 349 | {tabId && snapshot ? ( | 539 | {tabId && snapshot && !showEmptyState ? ( |
| 350 | <BrowserSurfaceSlot | 540 | <BrowserSurfaceSlot |
| 351 | key={tabId} | 541 | key={tabId} |
| 352 | tabId={tabId} | 542 | tabId={tabId} |
| 353 | visible={visible && !isUnreachable} | 543 | visible={visible && !isUnreachable} |
| 354 | className="absolute inset-0 h-full w-full" | 544 | className="absolute inset-0 h-full w-full" |
| 355 | /> | 545 | /> |
| 356 | ) : ( | 546 | ) : null} |
| 547 | {showEmptyState ? ( | ||
| 357 | <PreviewEmptyState | 548 | <PreviewEmptyState |
| 358 | environmentId={threadRef.environmentId} | 549 | environmentId={threadRef.environmentId} |
| 359 | configuredUrls={configuredUrls} | 550 | configuredUrls={configuredUrls} |
| 360 | recentlySeenUrls={previewState.recentlySeenUrls} | 551 | recentlySeenUrls={previewState.recentlySeenUrls} |
| 361 | onOpenUrl={(next) => void handleSubmitUrl(next)} | 552 | onOpenUrl={(next) => void handleSubmitUrl(next)} |
| 362 | /> | 553 | /> |
| 363 | )} | 554 | ) : null} |
| 364 | {snapshot && desktopOverlay ? ( | 555 | {snapshot && desktopOverlay ? ( |
| 365 | <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} /> | 556 | <ZoomIndicator zoomFactor={desktopOverlay.zoomFactor} /> |
| 366 | ) : null} | 557 | ) : null} |
| 558 | {tabId && desktopOverlay && !showEmptyState && !isUnreachable ? ( | ||
| 559 | <AgentBrowserCursor | ||
| 560 | tabId={tabId} | ||
| 561 | zoomFactor={desktopOverlay.zoomFactor} | ||
| 562 | controller={controller} | ||
| 563 | /> | ||
| 564 | ) : null} | ||
| 367 | {controller !== "none" ? ( | 565 | {controller !== "none" ? ( |
| 368 | <div className="pointer-events-none absolute left-3 top-3 z-40 rounded-full border border-border/70 bg-background/90 px-2.5 py-1 text-[11px] font-medium shadow-sm backdrop-blur"> | 566 | <div className="pointer-events-none absolute left-3 top-3 z-40 rounded-full border border-border/70 bg-background/90 px-2.5 py-1 text-[11px] font-medium shadow-sm backdrop-blur"> |
| 369 | {controller === "agent" ? "Agent controlling browser" : "Human control"} | 567 | {controller === "agent" ? "Agent controlling browser" : "Human control"} |
apps/web/src/components/preview/agentBrowserCursorLogic.test.ts created+19| ... | @@ -0,0 +1,19 @@ | ||
| 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,4 +1,4 @@ |
| 1 | import type { EnvironmentApi, ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { EnvironmentApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import type { PreviewStateStoreState } from "~/previewStateStore"; | 3 | import type { PreviewStateStoreState } from "~/previewStateStore"; |
| 4 | 4 | ||
| ... | @@ -10,7 +10,9 @@ interface OpenPreviewSessionInput { | ... | @@ -10,7 +10,9 @@ interface OpenPreviewSessionInput { |
| 10 | rememberUrl: PreviewStateStoreState["rememberUrl"]; | 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 | const snapshot = await input.previewApi.open({ | 16 | const snapshot = await input.previewApi.open({ |
| 15 | threadId: input.threadRef.threadId, | 17 | threadId: input.threadRef.threadId, |
| 16 | url: input.url, | 18 | url: input.url, |
| ... | @@ -20,4 +22,5 @@ export async function openPreviewSession(input: OpenPreviewSessionInput): Promis | ... | @@ -20,4 +22,5 @@ export async function openPreviewSession(input: OpenPreviewSessionInput): Promis |
| 20 | input.threadRef, | 22 | input.threadRef, |
| 21 | snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, | 23 | snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, |
| 22 | ); | 24 | ); |
| 25 | return snapshot; | ||
| 23 | } | 26 | } |
apps/web/src/components/preview/previewEmptyStateLogic.test.ts created+42| ... | @@ -0,0 +1,42 @@ | ||
| 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,6 +9,7 @@ const scannerServer = (overrides: Partial<DiscoveredLocalServer>): DiscoveredLoc |
| 9 | url: "http://localhost:5173", | 9 | url: "http://localhost:5173", |
| 10 | processName: "vite", | 10 | processName: "vite", |
| 11 | pid: 1234, | 11 | pid: 1234, |
| 12 | terminal: null, | ||
| 12 | ...overrides, | 13 | ...overrides, |
| 13 | }); | 14 | }); |
| 14 | 15 |
apps/web/src/components/preview/useDiscoveredLocalServers.ts+8-15| ... | @@ -1,10 +1,10 @@ | ... | @@ -1,10 +1,10 @@ |
| 1 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; | 1 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; |
| 2 | import { isLoopbackHost } from "@t3tools/shared/preview"; | 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 | import type { EnvironmentId } from "@t3tools/contracts"; | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 7 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 6 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 7 | import { useDiscoveredPorts } from "~/portDiscoveryState"; | ||
| 8 | 8 | ||
| 9 | export interface PreviewableServer extends DiscoveredLocalServer { | 9 | export interface PreviewableServer extends DiscoveredLocalServer { |
| 10 | source: "scanner" | "configured" | "recent"; | 10 | source: "scanner" | "configured" | "recent"; |
| ... | @@ -22,23 +22,13 @@ interface UseDiscoveredLocalServersInput { | ... | @@ -22,23 +22,13 @@ interface UseDiscoveredLocalServersInput { |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | /** | 24 | /** |
| 25 | * Subscribe to live localhost port scans, merge in configured / | 25 | * Merge the environment-level port snapshot with configured / recently-seen |
| 26 | * recently-seen URLs, and return a stable sorted list. Retains the scanner | 26 | * URLs and return a stable sorted list. |
| 27 | * while mounted. | ||
| 28 | */ | 27 | */ |
| 29 | export function useDiscoveredLocalServers( | 28 | export function useDiscoveredLocalServers( |
| 30 | input: UseDiscoveredLocalServersInput, | 29 | input: UseDiscoveredLocalServersInput, |
| 31 | ): ReadonlyArray<PreviewableServer> { | 30 | ): ReadonlyArray<PreviewableServer> { |
| 32 | const [scannerSnapshot, setScannerSnapshot] = useState<ReadonlyArray<DiscoveredLocalServer>>([]); | 31 | const scannerSnapshot = useDiscoveredPorts(input.environmentId); |
| 33 | |||
| 34 | useEffect(() => { | ||
| 35 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 36 | setScannerSnapshot([]); | ||
| 37 | const unsubscribe = api.preview.subscribePorts((next) => { | ||
| 38 | setScannerSnapshot(next.servers); | ||
| 39 | }); | ||
| 40 | return unsubscribe; | ||
| 41 | }, [input.environmentId]); | ||
| 42 | 32 | ||
| 43 | return useMemo( | 33 | return useMemo( |
| 44 | () => | 34 | () => |
| ... | @@ -72,6 +62,7 @@ export function mergeServers(input: { | ... | @@ -72,6 +62,7 @@ export function mergeServers(input: { |
| 72 | url: parsed.url, | 62 | url: parsed.url, |
| 73 | processName: null, | 63 | processName: null, |
| 74 | pid: null, | 64 | pid: null, |
| 65 | terminal: null, | ||
| 75 | source: "configured", | 66 | source: "configured", |
| 76 | listening: false, | 67 | listening: false, |
| 77 | }); | 68 | }); |
| ... | @@ -87,6 +78,7 @@ export function mergeServers(input: { | ... | @@ -87,6 +78,7 @@ export function mergeServers(input: { |
| 87 | ...existing, | 78 | ...existing, |
| 88 | processName: server.processName ?? existing.processName, | 79 | processName: server.processName ?? existing.processName, |
| 89 | pid: server.pid ?? existing.pid, | 80 | pid: server.pid ?? existing.pid, |
| 81 | terminal: server.terminal ?? existing.terminal, | ||
| 90 | listening: true, | 82 | listening: true, |
| 91 | }); | 83 | }); |
| 92 | continue; | 84 | continue; |
| ... | @@ -105,6 +97,7 @@ export function mergeServers(input: { | ... | @@ -105,6 +97,7 @@ export function mergeServers(input: { |
| 105 | url: parsed.url, | 97 | url: parsed.url, |
| 106 | processName: null, | 98 | processName: null, |
| 107 | pid: null, | 99 | pid: null, |
| 100 | terminal: null, | ||
| 108 | source: "recent", | 101 | source: "recent", |
| 109 | listening: false, | 102 | listening: false, |
| 110 | }); | 103 | }); |
apps/web/src/components/preview/usePreviewBridge.ts+19-1| ... | @@ -8,6 +8,7 @@ import type { | ... | @@ -8,6 +8,7 @@ import type { |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { useEffect, useRef } from "react"; | 9 | import { useEffect, useRef } from "react"; |
| 10 | 10 | ||
| 11 | import { useBrowserPointerStore } from "~/browser/browserPointerStore"; | ||
| 11 | import { ensureEnvironmentApi } from "~/environmentApi"; | 12 | import { ensureEnvironmentApi } from "~/environmentApi"; |
| 12 | import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore"; | 13 | import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore"; |
| 13 | 14 | ||
| ... | @@ -20,19 +21,26 @@ import { previewBridge } from "./previewBridge"; | ... | @@ -20,19 +21,26 @@ import { previewBridge } from "./previewBridge"; |
| 20 | export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { | 21 | export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { |
| 21 | const { threadRef, tabId } = input; | 22 | const { threadRef, tabId } = input; |
| 22 | const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); | 23 | const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); |
| 24 | const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); | ||
| 23 | const bridge = previewBridge; | 25 | const bridge = previewBridge; |
| 24 | 26 | ||
| 25 | // One bridge subscription does both jobs (mirror state + forward to | 27 | // One bridge subscription does both jobs (mirror state + forward to |
| 26 | // server) so the desktop bridge keeps a single listener entry per tab. | 28 | // server) so the desktop bridge keeps a single listener entry per tab. |
| 27 | const lastReportedUrl = useRef<string | null>(null); | 29 | const lastReportedUrl = useRef<string | null>(null); |
| 28 | const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null); | 30 | const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null); |
| 31 | const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null); | ||
| 29 | useEffect(() => { | 32 | useEffect(() => { |
| 30 | if (!bridge || typeof window === "undefined") return; | 33 | if (!bridge || typeof window === "undefined") return; |
| 31 | const api = ensureEnvironmentApi(threadRef.environmentId); | 34 | const api = ensureEnvironmentApi(threadRef.environmentId); |
| 32 | lastReportedUrl.current = null; | 35 | lastReportedUrl.current = null; |
| 33 | lastReportedKind.current = null; | 36 | lastReportedKind.current = null; |
| 37 | lastDesktopNavStatus.current = null; | ||
| 34 | const unsubscribe = bridge.onStateChange((changedTabId, state) => { | 38 | const unsubscribe = bridge.onStateChange((changedTabId, state) => { |
| 35 | if (changedTabId !== tabId) return; | 39 | if (changedTabId !== tabId) return; |
| 40 | if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) { | ||
| 41 | clearBrowserPointer(tabId); | ||
| 42 | } | ||
| 43 | lastDesktopNavStatus.current = state.navStatus; | ||
| 36 | applyDesktopState(threadRef, tabId, projectDesktopState(state)); | 44 | applyDesktopState(threadRef, tabId, projectDesktopState(state)); |
| 37 | const reported = buildReportInput({ | 45 | const reported = buildReportInput({ |
| 38 | threadId: threadRef.threadId, | 46 | threadId: threadRef.threadId, |
| ... | @@ -47,7 +55,17 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str | ... | @@ -47,7 +55,17 @@ export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: str |
| 47 | void api.preview.reportStatus(reported.input).catch(() => undefined); | 55 | void api.preview.reportStatus(reported.input).catch(() => undefined); |
| 48 | }); | 56 | }); |
| 49 | return unsubscribe; | 57 | return unsubscribe; |
| 50 | }, [applyDesktopState, bridge, tabId, threadRef]); | 58 | }, [applyDesktopState, bridge, clearBrowserPointer, tabId, threadRef]); |
| 59 | } | ||
| 60 | |||
| 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 | function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { | 71 | function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { |
apps/web/src/components/ui/toast.tsx+21-1| ... | @@ -45,6 +45,10 @@ export type ThreadToastData = { | ... | @@ -45,6 +45,10 @@ export type ThreadToastData = { |
| 45 | onClose?: (() => void) | undefined; | 45 | onClose?: (() => void) | undefined; |
| 46 | dismissAfterVisibleMs?: number; | 46 | dismissAfterVisibleMs?: number; |
| 47 | hideCopyButton?: boolean; | 47 | hideCopyButton?: boolean; |
| 48 | additionalActions?: ReadonlyArray<{ | ||
| 49 | id: string; | ||
| 50 | props: ComponentPropsWithoutRef<"button">; | ||
| 51 | }>; | ||
| 48 | secondaryActionProps?: ComponentPropsWithoutRef<"button">; | 52 | secondaryActionProps?: ComponentPropsWithoutRef<"button">; |
| 49 | secondaryActionVariant?: | 53 | secondaryActionVariant?: |
| 50 | | "default" | 54 | | "default" |
| ... | @@ -292,9 +296,13 @@ function deriveToastBodyDescriptor(toast: { | ... | @@ -292,9 +296,13 @@ function deriveToastBodyDescriptor(toast: { |
| 292 | toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton | 296 | toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton |
| 293 | ? toast.description | 297 | ? toast.description |
| 294 | : null; | 298 | : null; |
| 299 | const hasAdditionalActions = (toast.data?.additionalActions?.length ?? 0) > 0; | ||
| 295 | const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; | 300 | const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; |
| 296 | const hasTrailingControls = | 301 | const hasTrailingControls = |
| 297 | copyErrorText !== null || toast.actionProps !== undefined || hasSecondaryAction; | 302 | copyErrorText !== null || |
| 303 | toast.actionProps !== undefined || | ||
| 304 | hasAdditionalActions || | ||
| 305 | hasSecondaryAction; | ||
| 298 | const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; | 306 | const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; |
| 299 | return { | 307 | return { |
| 300 | Icon, | 308 | Icon, |
| ... | @@ -326,6 +334,7 @@ function ToastBodyContent({ | ... | @@ -326,6 +334,7 @@ function ToastBodyContent({ |
| 326 | toastDescription, | 334 | toastDescription, |
| 327 | toastType, | 335 | toastType, |
| 328 | }: ToastBodyContentProps) { | 336 | }: ToastBodyContentProps) { |
| 337 | const additionalActions = toastData?.additionalActions ?? []; | ||
| 329 | const secondaryActionProps = toastData?.secondaryActionProps; | 338 | const secondaryActionProps = toastData?.secondaryActionProps; |
| 330 | const leadingIcon = toastData?.leadingIcon; | 339 | const leadingIcon = toastData?.leadingIcon; |
| 331 | const { className: secondaryActionClassName, ...secondaryActionRest } = | 340 | const { className: secondaryActionClassName, ...secondaryActionRest } = |
| ... | @@ -371,6 +380,17 @@ function ToastBodyContent({ | ... | @@ -371,6 +380,17 @@ function ToastBodyContent({ |
| 371 | )} | 380 | )} |
| 372 | > | 381 | > |
| 373 | {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null} | 382 | {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null} |
| 383 | {additionalActions.map(({ id, props: { className, ...props } }) => ( | ||
| 384 | <button | ||
| 385 | {...props} | ||
| 386 | className={cn( | ||
| 387 | buttonVariants({ size: "xs", variant: secondaryActionVariant }), | ||
| 388 | className, | ||
| 389 | )} | ||
| 390 | key={id} | ||
| 391 | type="button" | ||
| 392 | /> | ||
| 393 | ))} | ||
| 374 | {secondaryActionProps ? ( | 394 | {secondaryActionProps ? ( |
| 375 | <button | 395 | <button |
| 376 | {...secondaryActionRest} | 396 | {...secondaryActionRest} |
apps/web/src/environments/runtime/service.ts+18| ... | @@ -90,6 +90,7 @@ import { | ... | @@ -90,6 +90,7 @@ import { |
| 90 | const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope)); | 90 | const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope)); |
| 91 | import { getClientSettings } from "~/hooks/useSettings"; | 91 | import { getClientSettings } from "~/hooks/useSettings"; |
| 92 | import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; | 92 | import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; |
| 93 | import { subscribePortDiscovery, usePortDiscoveryStore } from "../../portDiscoveryState"; | ||
| 93 | import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; | 94 | import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; |
| 94 | import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; | 95 | import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; |
| 95 | 96 | ||
| ... | @@ -140,6 +141,7 @@ const lastAppliedProjectionVersionByEnvironment = new Map< | ... | @@ -140,6 +141,7 @@ const lastAppliedProjectionVersionByEnvironment = new Map< |
| 140 | } | 141 | } |
| 141 | >(); | 142 | >(); |
| 142 | const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>(); | 143 | const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>(); |
| 144 | const portDiscoverySubscriptions = new Map<EnvironmentId, () => void>(); | ||
| 143 | 145 | ||
| 144 | let activeService: EnvironmentServiceState | null = null; | 146 | let activeService: EnvironmentServiceState | null = null; |
| 145 | let needsProviderInvalidation = false; | 147 | let needsProviderInvalidation = false; |
| ... | @@ -1397,6 +1399,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne | ... | @@ -1397,6 +1399,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne |
| 1397 | client: connection.client, | 1399 | client: connection.client, |
| 1398 | }), | 1400 | }), |
| 1399 | ); | 1401 | ); |
| 1402 | portDiscoverySubscriptions.get(connection.environmentId)?.(); | ||
| 1403 | portDiscoverySubscriptions.set( | ||
| 1404 | connection.environmentId, | ||
| 1405 | subscribePortDiscovery({ | ||
| 1406 | environmentId: connection.environmentId, | ||
| 1407 | previewApi: connection.client.preview, | ||
| 1408 | }), | ||
| 1409 | ); | ||
| 1400 | attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); | 1410 | attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); |
| 1401 | emitEnvironmentConnectionRegistryChange(); | 1411 | emitEnvironmentConnectionRegistryChange(); |
| 1402 | return connection; | 1412 | return connection; |
| ... | @@ -1412,6 +1422,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> | ... | @@ -1412,6 +1422,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> |
| 1412 | environmentConnections.delete(environmentId); | 1422 | environmentConnections.delete(environmentId); |
| 1413 | terminalMetadataSubscriptions.get(environmentId)?.(); | 1423 | terminalMetadataSubscriptions.get(environmentId)?.(); |
| 1414 | terminalMetadataSubscriptions.delete(environmentId); | 1424 | terminalMetadataSubscriptions.delete(environmentId); |
| 1425 | portDiscoverySubscriptions.get(environmentId)?.(); | ||
| 1426 | portDiscoverySubscriptions.delete(environmentId); | ||
| 1427 | usePortDiscoveryStore.getState().clearEnvironment(environmentId); | ||
| 1415 | terminalSessionManager.invalidateEnvironment(environmentId); | 1428 | terminalSessionManager.invalidateEnvironment(environmentId); |
| 1416 | emitEnvironmentConnectionRegistryChange(); | 1429 | emitEnvironmentConnectionRegistryChange(); |
| 1417 | detachThreadDetailSubscriptionsForEnvironment(environmentId); | 1430 | detachThreadDetailSubscriptionsForEnvironment(environmentId); |
| ... | @@ -2048,6 +2061,11 @@ export async function resetEnvironmentServiceForTests(): Promise<void> { | ... | @@ -2048,6 +2061,11 @@ export async function resetEnvironmentServiceForTests(): Promise<void> { |
| 2048 | unsubscribe(); | 2061 | unsubscribe(); |
| 2049 | } | 2062 | } |
| 2050 | terminalMetadataSubscriptions.clear(); | 2063 | terminalMetadataSubscriptions.clear(); |
| 2064 | for (const unsubscribe of portDiscoverySubscriptions.values()) { | ||
| 2065 | unsubscribe(); | ||
| 2066 | } | ||
| 2067 | portDiscoverySubscriptions.clear(); | ||
| 2068 | usePortDiscoveryStore.getState().reset(); | ||
| 2051 | terminalSessionManager.reset(); | 2069 | terminalSessionManager.reset(); |
| 2052 | await Promise.all( | 2070 | await Promise.all( |
| 2053 | [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), | 2071 | [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), |
apps/web/src/keybindings.test.ts+33-2| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | isTerminalCloseShortcut, | 18 | isTerminalCloseShortcut, |
| 19 | isTerminalNewShortcut, | 19 | isTerminalNewShortcut, |
| 20 | isTerminalSplitShortcut, | 20 | isTerminalSplitShortcut, |
| 21 | isTerminalSplitVerticalShortcut, | ||
| 21 | isTerminalToggleShortcut, | 22 | isTerminalToggleShortcut, |
| 22 | resolveShortcutCommand, | 23 | resolveShortcutCommand, |
| 23 | shouldShowModelPickerJumpHints, | 24 | shouldShowModelPickerJumpHints, |
| ... | @@ -85,6 +86,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { | ... | @@ -85,6 +86,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { |
| 85 | 86 | ||
| 86 | const DEFAULT_BINDINGS = compile([ | 87 | const DEFAULT_BINDINGS = compile([ |
| 87 | { shortcut: modShortcut("j"), command: "terminal.toggle" }, | 88 | { shortcut: modShortcut("j"), command: "terminal.toggle" }, |
| 89 | { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" }, | ||
| 88 | { | 90 | { |
| 89 | shortcut: modShortcut("d"), | 91 | shortcut: modShortcut("d"), |
| 90 | command: "terminal.split", | 92 | command: "terminal.split", |
| ... | @@ -92,6 +94,11 @@ const DEFAULT_BINDINGS = compile([ | ... | @@ -92,6 +94,11 @@ const DEFAULT_BINDINGS = compile([ |
| 92 | }, | 94 | }, |
| 93 | { | 95 | { |
| 94 | shortcut: modShortcut("d", { shiftKey: true }), | 96 | shortcut: modShortcut("d", { shiftKey: true }), |
| 97 | command: "terminal.splitVertical", | ||
| 98 | whenAst: whenIdentifier("terminalFocus"), | ||
| 99 | }, | ||
| 100 | { | ||
| 101 | shortcut: modShortcut("n"), | ||
| 95 | command: "terminal.new", | 102 | command: "terminal.new", |
| 96 | whenAst: whenIdentifier("terminalFocus"), | 103 | whenAst: whenIdentifier("terminalFocus"), |
| 97 | }, | 104 | }, |
| ... | @@ -174,7 +181,17 @@ describe("split/new/close terminal shortcuts", () => { | ... | @@ -174,7 +181,17 @@ describe("split/new/close terminal shortcuts", () => { |
| 174 | }), | 181 | }), |
| 175 | ); | 182 | ); |
| 176 | assert.isFalse( | 183 | assert.isFalse( |
| 177 | isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | 184 | isTerminalSplitVerticalShortcut( |
| 185 | event({ key: "d", metaKey: true, shiftKey: true }), | ||
| 186 | DEFAULT_BINDINGS, | ||
| 187 | { | ||
| 188 | platform: "MacIntel", | ||
| 189 | context: { terminalFocus: false }, | ||
| 190 | }, | ||
| 191 | ), | ||
| 192 | ); | ||
| 193 | assert.isFalse( | ||
| 194 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, { | ||
| 178 | platform: "Linux", | 195 | platform: "Linux", |
| 179 | context: { terminalFocus: false }, | 196 | context: { terminalFocus: false }, |
| 180 | }), | 197 | }), |
| ... | @@ -195,7 +212,17 @@ describe("split/new/close terminal shortcuts", () => { | ... | @@ -195,7 +212,17 @@ describe("split/new/close terminal shortcuts", () => { |
| 195 | }), | 212 | }), |
| 196 | ); | 213 | ); |
| 197 | assert.isTrue( | 214 | assert.isTrue( |
| 198 | isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | 215 | isTerminalSplitVerticalShortcut( |
| 216 | event({ key: "d", metaKey: true, shiftKey: true }), | ||
| 217 | DEFAULT_BINDINGS, | ||
| 218 | { | ||
| 219 | platform: "MacIntel", | ||
| 220 | context: { terminalFocus: true }, | ||
| 221 | }, | ||
| 222 | ), | ||
| 223 | ); | ||
| 224 | assert.isTrue( | ||
| 225 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, { | ||
| 199 | platform: "Linux", | 226 | platform: "Linux", |
| 200 | context: { terminalFocus: true }, | 227 | context: { terminalFocus: true }, |
| 201 | }), | 228 | }), |
| ... | @@ -287,6 +314,10 @@ describe("shortcutLabelForCommand", () => { | ... | @@ -287,6 +314,10 @@ describe("shortcutLabelForCommand", () => { |
| 287 | it("returns effective labels for non-terminal commands", () => { | 314 | it("returns effective labels for non-terminal commands", () => { |
| 288 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); | 315 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); |
| 289 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); | 316 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); |
| 317 | assert.strictEqual( | ||
| 318 | shortcutLabelForCommand(DEFAULT_BINDINGS, "rightPanel.toggle", "MacIntel"), | ||
| 319 | "⌥⌘B", | ||
| 320 | ); | ||
| 290 | assert.strictEqual( | 321 | assert.strictEqual( |
| 291 | shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), | 322 | shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), |
| 292 | "⌘K", | 323 | "⌘K", |
apps/web/src/keybindings.ts+8| ... | @@ -359,6 +359,14 @@ export function isTerminalSplitShortcut( | ... | @@ -359,6 +359,14 @@ export function isTerminalSplitShortcut( |
| 359 | return matchesCommandShortcut(event, keybindings, "terminal.split", options); | 359 | return matchesCommandShortcut(event, keybindings, "terminal.split", options); |
| 360 | } | 360 | } |
| 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 | export function isTerminalNewShortcut( | 370 | export function isTerminalNewShortcut( |
| 363 | event: ShortcutEventLike, | 371 | event: ShortcutEventLike, |
| 364 | keybindings: ResolvedKeybindingsConfig, | 372 | keybindings: ResolvedKeybindingsConfig, |
apps/web/src/lib/terminalFocus.test.ts+15-7| ... | @@ -1,10 +1,12 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import { afterEach, describe, expect, it } from "vite-plus/test"; | 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 | class MockHTMLElement { | 5 | class MockHTMLElement { |
| 6 | isConnected = false; | 6 | isConnected = false; |
| 7 | className = ""; | 7 | className = ""; |
| 8 | terminalOwner: string | null = null; | ||
| 9 | readonly dataset: { terminalOwner?: string } = {}; | ||
| 8 | 10 | ||
| 9 | readonly classList = { | 11 | readonly classList = { |
| 10 | contains: (value: string) => this.className.split(/\s+/).includes(value), | 12 | contains: (value: string) => this.className.split(/\s+/).includes(value), |
| ... | @@ -14,7 +16,7 @@ class MockHTMLElement { | ... | @@ -14,7 +16,7 @@ class MockHTMLElement { |
| 14 | if (!this.isConnected) { | 16 | if (!this.isConnected) { |
| 15 | return null; | 17 | return null; |
| 16 | } | 18 | } |
| 17 | if (selector === ".thread-terminal-drawer .xterm" || selector === ".thread-terminal-drawer") { | 19 | if (selector === "[data-terminal-owner]" && this.terminalOwner !== null) { |
| 18 | return this; | 20 | return this; |
| 19 | } | 21 | } |
| 20 | return null; | 22 | return null; |
| ... | @@ -44,30 +46,36 @@ describe("isTerminalFocused", () => { | ... | @@ -44,30 +46,36 @@ describe("isTerminalFocused", () => { |
| 44 | detached.className = "xterm-helper-textarea"; | 46 | detached.className = "xterm-helper-textarea"; |
| 45 | 47 | ||
| 46 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; | 48 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; |
| 47 | globalThis.document = { activeElement: detached } as Document; | 49 | globalThis.document = { activeElement: detached } as unknown as Document; |
| 48 | 50 | ||
| 49 | expect(isTerminalFocused()).toBe(false); | 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 | const attached = new MockHTMLElement(); | 55 | const attached = new MockHTMLElement(); |
| 54 | attached.className = "xterm-helper-textarea"; | 56 | attached.className = "xterm-helper-textarea"; |
| 55 | attached.isConnected = true; | 57 | attached.isConnected = true; |
| 58 | attached.terminalOwner = "drawer"; | ||
| 59 | attached.dataset.terminalOwner = "drawer"; | ||
| 56 | 60 | ||
| 57 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; | 61 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; |
| 58 | globalThis.document = { activeElement: attached } as Document; | 62 | globalThis.document = { activeElement: attached } as unknown as Document; |
| 59 | 63 | ||
| 64 | expect(getTerminalFocusOwner()).toBe("drawer"); | ||
| 60 | expect(isTerminalFocused()).toBe(true); | 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 | const sidebarButton = new MockHTMLElement(); | 69 | const sidebarButton = new MockHTMLElement(); |
| 65 | sidebarButton.className = "terminal-sidebar-button"; | 70 | sidebarButton.className = "terminal-sidebar-button"; |
| 66 | sidebarButton.isConnected = true; | 71 | sidebarButton.isConnected = true; |
| 72 | sidebarButton.terminalOwner = "right-panel"; | ||
| 73 | sidebarButton.dataset.terminalOwner = "right-panel"; | ||
| 67 | 74 | ||
| 68 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; | 75 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; |
| 69 | globalThis.document = { activeElement: sidebarButton } as Document; | 76 | globalThis.document = { activeElement: sidebarButton } as unknown as Document; |
| 70 | 77 | ||
| 78 | expect(getTerminalFocusOwner()).toBe("right-panel"); | ||
| 71 | expect(isTerminalFocused()).toBe(true); | 79 | expect(isTerminalFocused()).toBe(true); |
| 72 | }); | 80 | }); |
| 73 | }); | 81 | }); |
apps/web/src/lib/terminalFocus.ts+12-7| ... | @@ -1,9 +1,14 @@ | ... | @@ -1,9 +1,14 @@ |
| 1 | export function isTerminalFocused(): boolean { | 1 | export type TerminalFocusOwner = "drawer" | "right-panel"; |
| 2 | |||
| 3 | export function getTerminalFocusOwner(): TerminalFocusOwner | null { | ||
| 2 | const activeElement = document.activeElement; | 4 | const activeElement = document.activeElement; |
| 3 | if (!(activeElement instanceof HTMLElement)) return false; | 5 | if (!(activeElement instanceof HTMLElement)) return null; |
| 4 | if (!activeElement.isConnected) return false; | 6 | if (!activeElement.isConnected) return null; |
| 5 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; | 7 | const owner = activeElement.closest<HTMLElement>("[data-terminal-owner]")?.dataset.terminalOwner; |
| 6 | if (activeElement.closest(".thread-terminal-drawer .xterm") !== null) return true; | 8 | if (owner === "drawer" || owner === "right-panel") return owner; |
| 7 | // Sidebar / toolbar / resize affordances: still "terminal UI" for split vs diff.toggle (⌘D). | 9 | return null; |
| 8 | return activeElement.closest(".thread-terminal-drawer") !== null; | 10 | } |
| 11 | |||
| 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,6 +154,42 @@ describe("previewStateStore (single-tab)", () => { |
| 154 | expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); | 154 | expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); |
| 155 | }); | 155 | }); |
| 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 | it("closed event for a different tab is a no-op", () => { | 193 | it("closed event for a different tab is a no-op", () => { |
| 158 | const snapshot = makeSnapshot({ tabId: "tab_a" }); | 194 | const snapshot = makeSnapshot({ tabId: "tab_a" }); |
| 159 | const store = usePreviewStateStore.getState(); | 195 | const store = usePreviewStateStore.getState(); |
apps/web/src/previewStateStore.ts+32-22| ... | @@ -58,6 +58,7 @@ export interface PreviewStateStoreState { | ... | @@ -58,6 +58,7 @@ export interface PreviewStateStoreState { |
| 58 | tabId: string, | 58 | tabId: string, |
| 59 | overlay: DesktopPreviewOverlay | null, | 59 | overlay: DesktopPreviewOverlay | null, |
| 60 | ) => void; | 60 | ) => void; |
| 61 | removeSession: (ref: ScopedThreadRef, tabId: string) => void; | ||
| 61 | setActiveTab: (ref: ScopedThreadRef, tabId: string) => void; | 62 | setActiveTab: (ref: ScopedThreadRef, tabId: string) => void; |
| 62 | rememberUrl: (ref: ScopedThreadRef, url: string) => void; | 63 | rememberUrl: (ref: ScopedThreadRef, url: string) => void; |
| 63 | removeThread: (ref: ScopedThreadRef) => void; | 64 | removeThread: (ref: ScopedThreadRef) => void; |
| ... | @@ -93,6 +94,27 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => { | ... | @@ -93,6 +94,27 @@ const dedupeRecentUrls = (existing: string[], url: string): string[] => { |
| 93 | return next.slice(0, PREVIEW_RECENT_URL_LIMIT); | 94 | return next.slice(0, PREVIEW_RECENT_URL_LIMIT); |
| 94 | }; | 95 | }; |
| 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 | export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ | 118 | export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ |
| 97 | byThreadKey: {}, | 119 | byThreadKey: {}, |
| 98 | applyServerEvent: (ref, event) => | 120 | applyServerEvent: (ref, event) => |
| ... | @@ -145,28 +167,9 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ | ... | @@ -145,28 +167,9 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ |
| 145 | }); | 167 | }); |
| 146 | break; | 168 | break; |
| 147 | case "closed": | 169 | case "closed": |
| 148 | nextByThread = updateThread(state, threadKey, (current) => { | 170 | nextByThread = updateThread(state, threadKey, (current) => |
| 149 | if (!current.sessions[event.tabId]) return current; | 171 | removeSession(current, event.tabId), |
| 150 | const { [event.tabId]: _closed, ...sessions } = current.sessions; | 172 | ); |
| 151 | const { [event.tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; | ||
| 152 | const nextSnapshot = | ||
| 153 | Object.values(sessions) | ||
| 154 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) | ||
| 155 | .at(-1) ?? null; | ||
| 156 | const activeTabId = | ||
| 157 | current.activeTabId === event.tabId | ||
| 158 | ? (nextSnapshot?.tabId ?? null) | ||
| 159 | : current.activeTabId; | ||
| 160 | const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; | ||
| 161 | return { | ||
| 162 | ...current, | ||
| 163 | sessions, | ||
| 164 | desktopByTabId, | ||
| 165 | activeTabId: snapshot?.tabId ?? null, | ||
| 166 | snapshot, | ||
| 167 | desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null, | ||
| 168 | }; | ||
| 169 | }); | ||
| 170 | break; | 173 | break; |
| 171 | } | 174 | } |
| 172 | return { byThreadKey: nextByThread }; | 175 | return { byThreadKey: nextByThread }; |
| ... | @@ -216,6 +219,13 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ | ... | @@ -216,6 +219,13 @@ export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ |
| 216 | }); | 219 | }); |
| 217 | return { byThreadKey: nextByThread }; | 220 | return { byThreadKey: nextByThread }; |
| 218 | }), | 221 | }), |
| 222 | removeSession: (ref, tabId) => | ||
| 223 | set((state) => { | ||
| 224 | const threadKey = scopedThreadKey(ref); | ||
| 225 | return { | ||
| 226 | byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)), | ||
| 227 | }; | ||
| 228 | }), | ||
| 219 | setActiveTab: (ref, tabId) => | 229 | setActiveTab: (ref, tabId) => |
| 220 | set((state) => { | 230 | set((state) => { |
| 221 | const threadKey = scopedThreadKey(ref); | 231 | const threadKey = scopedThreadKey(ref); |
apps/web/src/rightPanelStore.test.ts+160-5| ... | @@ -3,6 +3,7 @@ import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; | ... | @@ -3,6 +3,7 @@ import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | 3 | import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | migratePersistedRightPanelState, | ||
| 6 | selectActiveRightPanel, | 7 | selectActiveRightPanel, |
| 7 | selectActiveRightPanelSurface, | 8 | selectActiveRightPanelSurface, |
| 8 | selectActiveRightPanelKindWithUrl, | 9 | selectActiveRightPanelKindWithUrl, |
| ... | @@ -18,6 +19,60 @@ beforeEach(() => { | ... | @@ -18,6 +19,60 @@ beforeEach(() => { |
| 18 | }); | 19 | }); |
| 19 | 20 | ||
| 20 | describe("rightPanelStore", () => { | 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 | it("open sets the active panel for a thread", () => { | 76 | it("open sets the active panel for a thread", () => { |
| 22 | useRightPanelStore.getState().open(refA, "preview"); | 77 | useRightPanelStore.getState().open(refA, "preview"); |
| 23 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); | 78 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); |
| ... | @@ -33,10 +88,27 @@ describe("rightPanelStore", () => { | ... | @@ -33,10 +88,27 @@ describe("rightPanelStore", () => { |
| 33 | ).toHaveLength(2); | 88 | ).toHaveLength(2); |
| 34 | }); | 89 | }); |
| 35 | 90 | ||
| 36 | it("close clears the active panel", () => { | 91 | it("close hides the panel without clearing its selected surface", () => { |
| 37 | useRightPanelStore.getState().open(refA, "plan"); | 92 | useRightPanelStore.getState().open(refA, "plan"); |
| 38 | useRightPanelStore.getState().close(refA); | 93 | useRightPanelStore.getState().close(refA); |
| 39 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); | 94 | expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); |
| 95 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | ||
| 96 | isOpen: false, | ||
| 97 | activeSurfaceId: "plan", | ||
| 98 | surfaces: [{ id: "plan", kind: "plan" }], | ||
| 99 | }); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("toggles empty panel visibility without creating a surface", () => { | ||
| 103 | useRightPanelStore.getState().toggleVisibility(refA); | ||
| 104 | expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ | ||
| 105 | isOpen: true, | ||
| 106 | activeSurfaceId: null, | ||
| 107 | surfaces: [], | ||
| 108 | }); | ||
| 109 | |||
| 110 | useRightPanelStore.getState().toggleVisibility(refA); | ||
| 111 | expect(useRightPanelStore.getState().byThreadKey).toEqual({}); | ||
| 40 | }); | 112 | }); |
| 41 | 113 | ||
| 42 | it("toggle opens then closes the same kind", () => { | 114 | it("toggle opens then closes the same kind", () => { |
| ... | @@ -86,18 +158,101 @@ describe("rightPanelStore", () => { | ... | @@ -86,18 +158,101 @@ describe("rightPanelStore", () => { |
| 86 | }); | 158 | }); |
| 87 | }); | 159 | }); |
| 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 | it("closing the active surface activates a neighboring surface", () => { | 233 | it("closing the active surface activates a neighboring surface", () => { |
| 90 | useRightPanelStore.getState().openBrowser(refA, "tab-a"); | 234 | useRightPanelStore.getState().openBrowser(refA, "tab-a"); |
| 91 | useRightPanelStore.getState().open(refA, "terminal"); | 235 | useRightPanelStore.getState().openTerminal(refA, "term-1"); |
| 92 | useRightPanelStore.getState().closeSurface(refA, "terminal"); | 236 | useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); |
| 93 | 237 | ||
| 94 | expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe( | 238 | expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe( |
| 95 | "browser:tab-a", | 239 | "browser:tab-a", |
| 96 | ); | 240 | ); |
| 97 | }); | 241 | }); |
| 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 | it("reconciles browser surfaces without deleting other surface kinds", () => { | 254 | it("reconciles browser surfaces without deleting other surface kinds", () => { |
| 100 | useRightPanelStore.getState().open(refA, "terminal"); | 255 | useRightPanelStore.getState().openTerminal(refA, "term-1"); |
| 101 | useRightPanelStore.getState().openBrowser(refA, "tab-a"); | 256 | useRightPanelStore.getState().openBrowser(refA, "tab-a"); |
| 102 | useRightPanelStore.getState().openBrowser(refA, "tab-b"); | 257 | useRightPanelStore.getState().openBrowser(refA, "tab-b"); |
| 103 | useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-b", "tab-c"]); | 258 | useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-b", "tab-c"]); |
| ... | @@ -106,6 +261,6 @@ describe("rightPanelStore", () => { | ... | @@ -106,6 +261,6 @@ describe("rightPanelStore", () => { |
| 106 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( | 261 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( |
| 107 | (surface) => surface.id, | 262 | (surface) => surface.id, |
| 108 | ), | 263 | ), |
| 109 | ).toEqual(["terminal", "browser:tab-b", "browser:tab-c"]); | 264 | ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]); |
| 110 | }); | 265 | }); |
| 111 | }); | 266 | }); |
apps/web/src/rightPanelStore.ts+214-15| ... | @@ -3,8 +3,9 @@ | ... | @@ -3,8 +3,9 @@ |
| 3 | * | 3 | * |
| 4 | * This is intentionally a shallow workspace model: it owns an ordered set of | 4 | * This is intentionally a shallow workspace model: it owns an ordered set of |
| 5 | * surface descriptors and the active surface, while each feature continues to | 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; | 6 | * own its durable resource state. Browser surfaces point at preview tab ids, |
| 7 | * singleton surfaces bridge the existing terminal, diff, and plan features. | 7 | * terminal surfaces point at terminal session ids, and diff/plan remain |
| 8 | * singleton surfaces. | ||
| 8 | */ | 9 | */ |
| 9 | import { scopedThreadKey } from "@t3tools/client-runtime"; | 10 | import { scopedThreadKey } from "@t3tools/client-runtime"; |
| 10 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 11 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| ... | @@ -19,35 +20,59 @@ export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; | ... | @@ -19,35 +20,59 @@ export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; |
| 19 | export type RightPanelSurface = | 20 | export type RightPanelSurface = |
| 20 | | { id: `browser:${string}`; kind: "preview"; resourceId: string } | 21 | | { id: `browser:${string}`; kind: "preview"; resourceId: string } |
| 21 | | { id: "browser:new"; kind: "preview"; resourceId: null } | 22 | | { id: "browser:new"; kind: "preview"; resourceId: null } |
| 22 | | { id: "terminal"; kind: "terminal" } | 23 | | { |
| 24 | id: `terminal:${string}`; | ||
| 25 | kind: "terminal"; | ||
| 26 | resourceId: string; | ||
| 27 | terminalIds: string[]; | ||
| 28 | activeTerminalId: string; | ||
| 29 | splitDirection?: "horizontal" | "vertical"; | ||
| 30 | } | ||
| 23 | | { id: "diff"; kind: "diff" } | 31 | | { id: "diff"; kind: "diff" } |
| 24 | | { id: "plan"; kind: "plan" }; | 32 | | { id: "plan"; kind: "plan" }; |
| 25 | 33 | ||
| 26 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; | 34 | const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; |
| 35 | const RIGHT_PANEL_STORAGE_VERSION = 5; | ||
| 27 | 36 | ||
| 28 | export interface ThreadRightPanelState { | 37 | export interface ThreadRightPanelState { |
| 38 | isOpen: boolean; | ||
| 29 | activeSurfaceId: string | null; | 39 | activeSurfaceId: string | null; |
| 30 | surfaces: RightPanelSurface[]; | 40 | surfaces: RightPanelSurface[]; |
| 31 | } | 41 | } |
| 32 | 42 | ||
| 33 | interface RightPanelStoreState { | 43 | interface RightPanelStoreState { |
| 34 | byThreadKey: Record<string, ThreadRightPanelState>; | 44 | byThreadKey: Record<string, ThreadRightPanelState>; |
| 35 | open: (ref: ScopedThreadRef, kind: RightPanelKind) => void; | 45 | open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void; |
| 36 | openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; | 46 | openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; |
| 47 | openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; | ||
| 48 | splitTerminal: ( | ||
| 49 | ref: ScopedThreadRef, | ||
| 50 | surfaceId: string, | ||
| 51 | terminalId: string, | ||
| 52 | direction?: "horizontal" | "vertical", | ||
| 53 | ) => void; | ||
| 54 | activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; | ||
| 55 | closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; | ||
| 37 | activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; | 56 | activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; |
| 38 | closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; | 57 | closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; |
| 39 | reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; | 58 | reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; |
| 59 | show: (ref: ScopedThreadRef) => void; | ||
| 40 | close: (ref: ScopedThreadRef) => void; | 60 | close: (ref: ScopedThreadRef) => void; |
| 41 | toggle: (ref: ScopedThreadRef, kind: RightPanelKind) => void; | 61 | toggleVisibility: (ref: ScopedThreadRef) => void; |
| 62 | toggle: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void; | ||
| 42 | removeThread: (ref: ScopedThreadRef) => void; | 63 | removeThread: (ref: ScopedThreadRef) => void; |
| 43 | } | 64 | } |
| 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 | switch (kind) { | 75 | switch (kind) { |
| 49 | case "terminal": | ||
| 50 | return { id: "terminal", kind }; | ||
| 51 | case "diff": | 76 | case "diff": |
| 52 | return { id: "diff", kind }; | 77 | return { id: "diff", kind }; |
| 53 | case "plan": | 78 | case "plan": |
| ... | @@ -60,11 +85,20 @@ const browserSurface = (tabId: string | null): RightPanelSurface => | ... | @@ -60,11 +85,20 @@ const browserSurface = (tabId: string | null): RightPanelSurface => |
| 60 | ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } | 85 | ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } |
| 61 | : { id: "browser:new", kind: "preview", resourceId: null }; | 86 | : { id: "browser:new", kind: "preview", resourceId: null }; |
| 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 | const upsertSurface = ( | 96 | const upsertSurface = ( |
| 64 | current: ThreadRightPanelState, | 97 | current: ThreadRightPanelState, |
| 65 | surface: RightPanelSurface, | 98 | surface: RightPanelSurface, |
| 66 | activate = true, | 99 | activate = true, |
| 67 | ): ThreadRightPanelState => ({ | 100 | ): ThreadRightPanelState => ({ |
| 101 | isOpen: true, | ||
| 68 | surfaces: current.surfaces.some((entry) => entry.id === surface.id) | 102 | surfaces: current.surfaces.some((entry) => entry.id === surface.id) |
| 69 | ? current.surfaces | 103 | ? current.surfaces |
| 70 | : [...current.surfaces, surface], | 104 | : [...current.surfaces, surface], |
| ... | @@ -78,7 +112,7 @@ const updateThread = ( | ... | @@ -78,7 +112,7 @@ const updateThread = ( |
| 78 | ): Record<string, ThreadRightPanelState> => { | 112 | ): Record<string, ThreadRightPanelState> => { |
| 79 | const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; | 113 | const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; |
| 80 | const next = updater(current); | 114 | const next = updater(current); |
| 81 | if (next.activeSurfaceId === null && next.surfaces.length === 0) { | 115 | if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) { |
| 82 | if (!(threadKey in byThreadKey)) return byThreadKey; | 116 | if (!(threadKey in byThreadKey)) return byThreadKey; |
| 83 | const { [threadKey]: _removed, ...rest } = byThreadKey; | 117 | const { [threadKey]: _removed, ...rest } = byThreadKey; |
| 84 | return rest; | 118 | return rest; |
| ... | @@ -87,6 +121,74 @@ const updateThread = ( | ... | @@ -87,6 +121,74 @@ const updateThread = ( |
| 87 | return { ...byThreadKey, [threadKey]: next }; | 121 | return { ...byThreadKey, [threadKey]: next }; |
| 88 | }; | 122 | }; |
| 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 | export const useRightPanelStore = create<RightPanelStoreState>()( | 192 | export const useRightPanelStore = create<RightPanelStoreState>()( |
| 91 | persist( | 193 | persist( |
| 92 | (set) => ({ | 194 | (set) => ({ |
| ... | @@ -111,11 +213,89 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -111,11 +213,89 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 111 | return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); | 213 | return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); |
| 112 | }), | 214 | }), |
| 113 | })), | 215 | })), |
| 216 | openTerminal: (ref, terminalId) => | ||
| 217 | set((state) => ({ | ||
| 218 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => | ||
| 219 | upsertSurface(current, terminalSurface(terminalId)), | ||
| 220 | ), | ||
| 221 | })), | ||
| 222 | splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") => | ||
| 223 | set((state) => ({ | ||
| 224 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ | ||
| 225 | ...current, | ||
| 226 | isOpen: true, | ||
| 227 | activeSurfaceId: surfaceId, | ||
| 228 | surfaces: current.surfaces.map((surface) => { | ||
| 229 | if (surface.id !== surfaceId || surface.kind !== "terminal") return surface; | ||
| 230 | const { splitDirection: _splitDirection, ...baseSurface } = surface; | ||
| 231 | return { | ||
| 232 | ...baseSurface, | ||
| 233 | terminalIds: surface.terminalIds.includes(terminalId) | ||
| 234 | ? surface.terminalIds | ||
| 235 | : [...surface.terminalIds, terminalId], | ||
| 236 | activeTerminalId: terminalId, | ||
| 237 | ...(direction === "vertical" ? { splitDirection: "vertical" as const } : {}), | ||
| 238 | }; | ||
| 239 | }), | ||
| 240 | })), | ||
| 241 | })), | ||
| 242 | activateTerminal: (ref, surfaceId, terminalId) => | ||
| 243 | set((state) => ({ | ||
| 244 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ | ||
| 245 | ...current, | ||
| 246 | activeSurfaceId: surfaceId, | ||
| 247 | surfaces: current.surfaces.map((surface) => | ||
| 248 | surface.id === surfaceId && | ||
| 249 | surface.kind === "terminal" && | ||
| 250 | surface.terminalIds.includes(terminalId) | ||
| 251 | ? { ...surface, activeTerminalId: terminalId } | ||
| 252 | : surface, | ||
| 253 | ), | ||
| 254 | })), | ||
| 255 | })), | ||
| 256 | closeTerminal: (ref, surfaceId, terminalId) => | ||
| 257 | set((state) => ({ | ||
| 258 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { | ||
| 259 | const surface = current.surfaces.find( | ||
| 260 | (entry) => entry.id === surfaceId && entry.kind === "terminal", | ||
| 261 | ); | ||
| 262 | if (!surface || surface.kind !== "terminal") return current; | ||
| 263 | const terminalIds = surface.terminalIds.filter((id) => id !== terminalId); | ||
| 264 | if (terminalIds.length === 0) { | ||
| 265 | const index = current.surfaces.findIndex((entry) => entry.id === surfaceId); | ||
| 266 | const surfaces = current.surfaces.filter((entry) => entry.id !== surfaceId); | ||
| 267 | const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; | ||
| 268 | return { | ||
| 269 | ...current, | ||
| 270 | surfaces, | ||
| 271 | activeSurfaceId: | ||
| 272 | current.activeSurfaceId === surfaceId | ||
| 273 | ? (fallback?.id ?? null) | ||
| 274 | : current.activeSurfaceId, | ||
| 275 | }; | ||
| 276 | } | ||
| 277 | return { | ||
| 278 | ...current, | ||
| 279 | surfaces: current.surfaces.map((entry) => | ||
| 280 | entry.id === surfaceId && entry.kind === "terminal" | ||
| 281 | ? { | ||
| 282 | ...entry, | ||
| 283 | terminalIds, | ||
| 284 | activeTerminalId: | ||
| 285 | entry.activeTerminalId === terminalId | ||
| 286 | ? (terminalIds.at(-1) ?? terminalIds[0]!) | ||
| 287 | : entry.activeTerminalId, | ||
| 288 | } | ||
| 289 | : entry, | ||
| 290 | ), | ||
| 291 | }; | ||
| 292 | }), | ||
| 293 | })), | ||
| 114 | activateSurface: (ref, surfaceId) => | 294 | activateSurface: (ref, surfaceId) => |
| 115 | set((state) => ({ | 295 | set((state) => ({ |
| 116 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => | 296 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => |
| 117 | current.surfaces.some((surface) => surface.id === surfaceId) | 297 | current.surfaces.some((surface) => surface.id === surfaceId) |
| 118 | ? { ...current, activeSurfaceId: surfaceId } | 298 | ? { ...current, isOpen: true, activeSurfaceId: surfaceId } |
| 119 | : current, | 299 | : current, |
| 120 | ), | 300 | ), |
| 121 | })), | 301 | })), |
| ... | @@ -127,7 +307,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -127,7 +307,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 127 | const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); | 307 | const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); |
| 128 | if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; | 308 | if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; |
| 129 | const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; | 309 | const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; |
| 130 | return { surfaces, activeSurfaceId: fallback?.id ?? null }; | 310 | return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null }; |
| 131 | }), | 311 | }), |
| 132 | })), | 312 | })), |
| 133 | reconcileBrowserSurfaces: (ref, tabIds) => | 313 | reconcileBrowserSurfaces: (ref, tabIds) => |
| ... | @@ -151,6 +331,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -151,6 +331,7 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 151 | ); | 331 | ); |
| 152 | const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview"); | 332 | const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview"); |
| 153 | return { | 333 | return { |
| 334 | ...current, | ||
| 154 | surfaces, | 335 | surfaces, |
| 155 | activeSurfaceId: activeStillExists | 336 | activeSurfaceId: activeStillExists |
| 156 | ? current.activeSurfaceId | 337 | ? current.activeSurfaceId |
| ... | @@ -158,11 +339,23 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -158,11 +339,23 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 158 | }; | 339 | }; |
| 159 | }), | 340 | }), |
| 160 | })), | 341 | })), |
| 342 | show: (ref) => | ||
| 343 | set((state) => ({ | ||
| 344 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => | ||
| 345 | current.isOpen ? current : { ...current, isOpen: true }, | ||
| 346 | ), | ||
| 347 | })), | ||
| 161 | close: (ref) => | 348 | close: (ref) => |
| 349 | set((state) => ({ | ||
| 350 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => | ||
| 351 | current.isOpen ? { ...current, isOpen: false } : current, | ||
| 352 | ), | ||
| 353 | })), | ||
| 354 | toggleVisibility: (ref) => | ||
| 162 | set((state) => ({ | 355 | set((state) => ({ |
| 163 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ | 356 | byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ |
| 164 | ...current, | 357 | ...current, |
| 165 | activeSurfaceId: null, | 358 | isOpen: !current.isOpen, |
| 166 | })), | 359 | })), |
| 167 | })), | 360 | })), |
| 168 | toggle: (ref, kind) => | 361 | toggle: (ref, kind) => |
| ... | @@ -171,7 +364,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -171,7 +364,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 171 | const active = current.surfaces.find( | 364 | const active = current.surfaces.find( |
| 172 | (surface) => surface.id === current.activeSurfaceId, | 365 | (surface) => surface.id === current.activeSurfaceId, |
| 173 | ); | 366 | ); |
| 174 | if (active?.kind === kind) return { ...current, activeSurfaceId: null }; | 367 | if (current.isOpen && active?.kind === kind) { |
| 368 | return { ...current, isOpen: false }; | ||
| 369 | } | ||
| 175 | if (kind === "preview") { | 370 | if (kind === "preview") { |
| 176 | const existing = current.surfaces.find((surface) => surface.kind === "preview"); | 371 | const existing = current.surfaces.find((surface) => surface.kind === "preview"); |
| 177 | return upsertSurface(current, existing ?? browserSurface(null)); | 372 | return upsertSurface(current, existing ?? browserSurface(null)); |
| ... | @@ -189,11 +384,12 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -189,11 +384,12 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 189 | }), | 384 | }), |
| 190 | { | 385 | { |
| 191 | name: RIGHT_PANEL_STORAGE_KEY, | 386 | name: RIGHT_PANEL_STORAGE_KEY, |
| 192 | version: 2, | 387 | version: RIGHT_PANEL_STORAGE_VERSION, |
| 193 | storage: createJSONStorage(() => | 388 | storage: createJSONStorage(() => |
| 194 | resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), | 389 | resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), |
| 195 | ), | 390 | ), |
| 196 | partialize: (state) => ({ byThreadKey: state.byThreadKey }), | 391 | partialize: (state) => ({ byThreadKey: state.byThreadKey }), |
| 392 | migrate: migratePersistedRightPanelState, | ||
| 197 | }, | 393 | }, |
| 198 | ), | 394 | ), |
| 199 | ); | 395 | ); |
| ... | @@ -211,6 +407,7 @@ export function selectActiveRightPanel( | ... | @@ -211,6 +407,7 @@ export function selectActiveRightPanel( |
| 211 | ref: ScopedThreadRef | null | undefined, | 407 | ref: ScopedThreadRef | null | undefined, |
| 212 | ): RightPanelKind | null { | 408 | ): RightPanelKind | null { |
| 213 | const state = selectThreadRightPanelState(byThreadKey, ref); | 409 | const state = selectThreadRightPanelState(byThreadKey, ref); |
| 410 | if (!state.isOpen) return null; | ||
| 214 | return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null; | 411 | return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null; |
| 215 | } | 412 | } |
| 216 | 413 | ||
| ... | @@ -219,6 +416,7 @@ export function selectActiveRightPanelSurface( | ... | @@ -219,6 +416,7 @@ export function selectActiveRightPanelSurface( |
| 219 | ref: ScopedThreadRef | null | undefined, | 416 | ref: ScopedThreadRef | null | undefined, |
| 220 | ): RightPanelSurface | null { | 417 | ): RightPanelSurface | null { |
| 221 | const state = selectThreadRightPanelState(byThreadKey, ref); | 418 | const state = selectThreadRightPanelState(byThreadKey, ref); |
| 419 | if (!state.isOpen) return null; | ||
| 222 | return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; | 420 | return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; |
| 223 | } | 421 | } |
| 224 | 422 | ||
| ... | @@ -227,6 +425,7 @@ export function selectActiveRightPanelKindWithUrl( | ... | @@ -227,6 +425,7 @@ export function selectActiveRightPanelKindWithUrl( |
| 227 | ref: ScopedThreadRef | null | undefined, | 425 | ref: ScopedThreadRef | null | undefined, |
| 228 | diffSearchActive: boolean, | 426 | diffSearchActive: boolean, |
| 229 | ): RightPanelKind | null { | 427 | ): RightPanelKind | null { |
| 428 | if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null; | ||
| 230 | if (diffSearchActive) return "diff"; | 429 | if (diffSearchActive) return "diff"; |
| 231 | return selectActiveRightPanel(byThreadKey, ref); | 430 | return selectActiveRightPanel(byThreadKey, ref); |
| 232 | } | 431 | } |
apps/web/src/rpc/requestLatencyState.test.ts+8| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| ... | @@ -50,6 +51,13 @@ describe("requestLatencyState", () => { | ... | @@ -50,6 +51,13 @@ describe("requestLatencyState", () => { |
| 50 | expect(getSlowRpcAckRequests()).toEqual([]); | 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 | it("evicts the oldest pending requests once the tracker reaches capacity", () => { | 61 | it("evicts the oldest pending requests once the tracker reaches capacity", () => { |
| 54 | for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) { | 62 | for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) { |
| 55 | trackRpcRequestSent(String(index), "server.getConfig"); | 63 | trackRpcRequestSent(String(index), "server.getConfig"); |
apps/web/src/rpc/requestLatencyState.ts+3-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 4 | ||
| 4 | import { appAtomRegistry } from "./atomRegistry"; | 5 | import { appAtomRegistry } from "./atomRegistry"; |
| ... | @@ -21,6 +22,7 @@ interface PendingRpcAckRequest { | ... | @@ -21,6 +22,7 @@ interface PendingRpcAckRequest { |
| 21 | } | 22 | } |
| 22 | 23 | ||
| 23 | const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>(); | 24 | const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>(); |
| 25 | const untrackedRpcAckTags = new Set<string>([WS_METHODS.previewAutomationConnect]); | ||
| 24 | 26 | ||
| 25 | const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe( | 27 | const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe( |
| 26 | Atom.keepAlive, | 28 | Atom.keepAlive, |
| ... | @@ -36,7 +38,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> { | ... | @@ -36,7 +38,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> { |
| 36 | } | 38 | } |
| 37 | 39 | ||
| 38 | function shouldTrackRpcAck(tag: string): boolean { | 40 | function shouldTrackRpcAck(tag: string): boolean { |
| 39 | return !tag.includes("subscribe"); | 41 | return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag); |
| 40 | } | 42 | } |
| 41 | 43 | ||
| 42 | export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> { | 44 | export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> { |
apps/web/src/terminalUiStateStore.test.ts+18| ... | @@ -56,6 +56,24 @@ describe("terminalUiStateStore actions", () => { | ... | @@ -56,6 +56,24 @@ describe("terminalUiStateStore actions", () => { |
| 56 | ]); | 56 | ]); |
| 57 | }); | 57 | }); |
| 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 | it("materializes the default terminal when opening an empty drawer", () => { | 77 | it("materializes the default terminal when opening an empty drawer", () => { |
| 60 | useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true); | 78 | useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true); |
| 61 | 79 |
apps/web/src/terminalUiStateStore.ts+18-1| ... | @@ -126,6 +126,7 @@ function normalizeTerminalGroups( | ... | @@ -126,6 +126,7 @@ function normalizeTerminalGroups( |
| 126 | nextGroups.push({ | 126 | nextGroups.push({ |
| 127 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), | 127 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), |
| 128 | terminalIds: groupTerminalIds, | 128 | terminalIds: groupTerminalIds, |
| 129 | ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), | ||
| 129 | }); | 130 | }); |
| 130 | } | 131 | } |
| 131 | 132 | ||
| ... | @@ -155,6 +156,11 @@ function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalG | ... | @@ -155,6 +156,11 @@ function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalG |
| 155 | const rightGroup = right[index]; | 156 | const rightGroup = right[index]; |
| 156 | if (!leftGroup || !rightGroup) return false; | 157 | if (!leftGroup || !rightGroup) return false; |
| 157 | if (leftGroup.id !== rightGroup.id) return false; | 158 | if (leftGroup.id !== rightGroup.id) return false; |
| 159 | if ( | ||
| 160 | (leftGroup.splitDirection ?? "horizontal") !== (rightGroup.splitDirection ?? "horizontal") | ||
| 161 | ) { | ||
| 162 | return false; | ||
| 163 | } | ||
| 158 | if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; | 164 | if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; |
| 159 | } | 165 | } |
| 160 | return true; | 166 | return true; |
| ... | @@ -241,6 +247,7 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ | ... | @@ -241,6 +247,7 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ |
| 241 | return groups.map((group) => ({ | 247 | return groups.map((group) => ({ |
| 242 | id: group.id, | 248 | id: group.id, |
| 243 | terminalIds: [...group.terminalIds], | 249 | terminalIds: [...group.terminalIds], |
| 250 | ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), | ||
| 244 | })); | 251 | })); |
| 245 | } | 252 | } |
| 246 | 253 | ||
| ... | @@ -248,6 +255,7 @@ function upsertTerminalIntoGroups( | ... | @@ -248,6 +255,7 @@ function upsertTerminalIntoGroups( |
| 248 | state: ThreadTerminalUiState, | 255 | state: ThreadTerminalUiState, |
| 249 | terminalId: string, | 256 | terminalId: string, |
| 250 | mode: "split" | "new", | 257 | mode: "split" | "new", |
| 258 | splitDirection: "horizontal" | "vertical" = "horizontal", | ||
| 251 | ): ThreadTerminalUiState { | 259 | ): ThreadTerminalUiState { |
| 252 | const normalized = normalizeThreadTerminalUiState(state); | 260 | const normalized = normalizeThreadTerminalUiState(state); |
| 253 | const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; | 261 | const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; |
| ... | @@ -323,6 +331,11 @@ function upsertTerminalIntoGroups( | ... | @@ -323,6 +331,11 @@ function upsertTerminalIntoGroups( |
| 323 | destinationGroup.terminalIds.push(terminalId); | 331 | destinationGroup.terminalIds.push(terminalId); |
| 324 | } | 332 | } |
| 325 | } | 333 | } |
| 334 | if (splitDirection === "vertical") { | ||
| 335 | destinationGroup.splitDirection = "vertical"; | ||
| 336 | } else { | ||
| 337 | delete destinationGroup.splitDirection; | ||
| 338 | } | ||
| 326 | 339 | ||
| 327 | return normalizeThreadTerminalUiState({ | 340 | return normalizeThreadTerminalUiState({ |
| 328 | ...normalized, | 341 | ...normalized, |
| ... | @@ -357,8 +370,9 @@ function setThreadTerminalHeight( | ... | @@ -357,8 +370,9 @@ function setThreadTerminalHeight( |
| 357 | function splitThreadTerminal( | 370 | function splitThreadTerminal( |
| 358 | state: ThreadTerminalUiState, | 371 | state: ThreadTerminalUiState, |
| 359 | terminalId: string, | 372 | terminalId: string, |
| 373 | direction: "horizontal" | "vertical" = "horizontal", | ||
| 360 | ): ThreadTerminalUiState { | 374 | ): ThreadTerminalUiState { |
| 361 | return upsertTerminalIntoGroups(state, terminalId, "split"); | 375 | return upsertTerminalIntoGroups(state, terminalId, "split", direction); |
| 362 | } | 376 | } |
| 363 | 377 | ||
| 364 | function newThreadTerminal( | 378 | function newThreadTerminal( |
| ... | @@ -510,6 +524,7 @@ interface TerminalUiStateStoreState { | ... | @@ -510,6 +524,7 @@ interface TerminalUiStateStoreState { |
| 510 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; | 524 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; |
| 511 | setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; | 525 | setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; |
| 512 | splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | 526 | splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 527 | splitTerminalVertical: (threadRef: ScopedThreadRef, terminalId: string) => void; | ||
| 513 | newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | 528 | newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; |
| 514 | ensureTerminal: ( | 529 | ensureTerminal: ( |
| 515 | threadRef: ScopedThreadRef, | 530 | threadRef: ScopedThreadRef, |
| ... | @@ -554,6 +569,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ... | @@ -554,6 +569,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( |
| 554 | updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), | 569 | updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), |
| 555 | splitTerminal: (threadRef, terminalId) => | 570 | splitTerminal: (threadRef, terminalId) => |
| 556 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), | 571 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), |
| 572 | splitTerminalVertical: (threadRef, terminalId) => | ||
| 573 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical")), | ||
| 557 | newTerminal: (threadRef, terminalId) => | 574 | newTerminal: (threadRef, terminalId) => |
| 558 | updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), | 575 | updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), |
| 559 | ensureTerminal: (threadRef, terminalId, options) => | 576 | ensureTerminal: (threadRef, terminalId, options) => |
apps/web/src/types.ts+1| ... | @@ -30,6 +30,7 @@ export type ProjectScript = ContractProjectScript; | ... | @@ -30,6 +30,7 @@ export type ProjectScript = ContractProjectScript; |
| 30 | export interface ThreadTerminalGroup { | 30 | export interface ThreadTerminalGroup { |
| 31 | id: string; | 31 | id: string; |
| 32 | terminalIds: string[]; | 32 | terminalIds: string[]; |
| 33 | splitDirection?: "horizontal" | "vertical"; | ||
| 33 | } | 34 | } |
| 34 | 35 | ||
| 35 | export interface ChatImageAttachment { | 36 | export interface ChatImageAttachment { |
packages/contracts/src/ipc.ts+33| ... | @@ -455,6 +455,15 @@ export interface DesktopPreviewTabState { | ... | @@ -455,6 +455,15 @@ export interface DesktopPreviewTabState { |
| 455 | updatedAt: string; | 455 | updatedAt: string; |
| 456 | } | 456 | } |
| 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 | * Static config a renderer needs to mount a preview `<webview>`. Returned | 468 | * Static config a renderer needs to mount a preview `<webview>`. Returned |
| 460 | * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer | 469 | * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer |
| ... | @@ -478,6 +487,26 @@ export interface DesktopPreviewWebviewConfig { | ... | @@ -478,6 +487,26 @@ export interface DesktopPreviewWebviewConfig { |
| 478 | preloadUrl: string | null; | 487 | preloadUrl: string | null; |
| 479 | } | 488 | } |
| 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 | export interface DesktopPreviewRecordingFrame { | 510 | export interface DesktopPreviewRecordingFrame { |
| 482 | tabId: string; | 511 | tabId: string; |
| 483 | data: string; | 512 | data: string; |
| ... | @@ -700,6 +729,7 @@ export interface DesktopPreviewBridge { | ... | @@ -700,6 +729,7 @@ export interface DesktopPreviewBridge { |
| 700 | * the contract + main, not the renderer's mount logic. | 729 | * the contract + main, not the renderer's mount logic. |
| 701 | */ | 730 | */ |
| 702 | getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>; | 731 | getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>; |
| 732 | setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>; | ||
| 703 | /** | 733 | /** |
| 704 | * Activate the in-page element picker for the given tab. Resolves with | 734 | * Activate the in-page element picker for the given tab. Resolves with |
| 705 | * the picked payload, or `null` when the user cancels (Escape / nav). The | 735 | * the picked payload, or `null` when the user cancels (Escape / nav). The |
| ... | @@ -709,6 +739,8 @@ export interface DesktopPreviewBridge { | ... | @@ -709,6 +739,8 @@ export interface DesktopPreviewBridge { |
| 709 | /** Cancel an in-flight preview annotation session. */ | 739 | /** Cancel an in-flight preview annotation session. */ |
| 710 | cancelPickElement: (tabId: string) => Promise<void>; | 740 | cancelPickElement: (tabId: string) => Promise<void>; |
| 711 | captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; | 741 | captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; |
| 742 | revealArtifact: (path: string) => Promise<void>; | ||
| 743 | copyArtifactToClipboard: (path: string) => Promise<void>; | ||
| 712 | recording: { | 744 | recording: { |
| 713 | startScreencast: (tabId: string) => Promise<void>; | 745 | startScreencast: (tabId: string) => Promise<void>; |
| 714 | stopScreencast: (tabId: string) => Promise<void>; | 746 | stopScreencast: (tabId: string) => Promise<void>; |
| ... | @@ -730,6 +762,7 @@ export interface DesktopPreviewBridge { | ... | @@ -730,6 +762,7 @@ export interface DesktopPreviewBridge { |
| 730 | waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>; | 762 | waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>; |
| 731 | }; | 763 | }; |
| 732 | onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; | 764 | onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; |
| 765 | onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void; | ||
| 733 | } | 766 | } |
| 734 | 767 | ||
| 735 | /** | 768 | /** |
packages/contracts/src/keybindings.test.ts+8-1| ... | @@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () => | ... | @@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () => |
| 29 | }); | 29 | }); |
| 30 | assert.strictEqual(parsed.command, "terminal.toggle"); | 30 | assert.strictEqual(parsed.command, "terminal.toggle"); |
| 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 | const parsedClose = yield* decode(KeybindingRule, { | 38 | const parsedClose = yield* decode(KeybindingRule, { |
| 33 | key: "mod+w", | 39 | key: "mod+w", |
| 34 | command: "terminal.close", | 40 | command: "terminal.close", |
| ... | @@ -100,8 +106,9 @@ it.effect("parses keybindings array payload", () => | ... | @@ -100,8 +106,9 @@ it.effect("parses keybindings array payload", () => |
| 100 | const parsed = yield* decode(KeybindingsConfig, [ | 106 | const parsed = yield* decode(KeybindingsConfig, [ |
| 101 | { key: "mod+j", command: "terminal.toggle" }, | 107 | { key: "mod+j", command: "terminal.toggle" }, |
| 102 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | 108 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 109 | { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, | ||
| 103 | ]); | 110 | ]); |
| 104 | assert.lengthOf(parsed, 2); | 111 | assert.lengthOf(parsed, 3); |
| 105 | }), | 112 | }), |
| 106 | ); | 113 | ); |
| 107 | 114 |
packages/contracts/src/keybindings.ts+2| ... | @@ -50,8 +50,10 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA | ... | @@ -50,8 +50,10 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA |
| 50 | const STATIC_KEYBINDING_COMMANDS = [ | 50 | const STATIC_KEYBINDING_COMMANDS = [ |
| 51 | "terminal.toggle", | 51 | "terminal.toggle", |
| 52 | "terminal.split", | 52 | "terminal.split", |
| 53 | "terminal.splitVertical", | ||
| 53 | "terminal.new", | 54 | "terminal.new", |
| 54 | "terminal.close", | 55 | "terminal.close", |
| 56 | "rightPanel.toggle", | ||
| 55 | "diff.toggle", | 57 | "diff.toggle", |
| 56 | "preview.toggle", | 58 | "preview.toggle", |
| 57 | "preview.refresh", | 59 | "preview.refresh", |
packages/contracts/src/preview.test.ts+4| ... | @@ -123,6 +123,7 @@ describe("DiscoveredLocalServer", () => { | ... | @@ -123,6 +123,7 @@ describe("DiscoveredLocalServer", () => { |
| 123 | url: "http://localhost:5173", | 123 | url: "http://localhost:5173", |
| 124 | processName: "node", | 124 | processName: "node", |
| 125 | pid: 12345, | 125 | pid: 12345, |
| 126 | terminal: null, | ||
| 126 | }); | 127 | }); |
| 127 | expect(server.port).toBe(5173); | 128 | expect(server.port).toBe(5173); |
| 128 | expect(server.processName).toBe("node"); | 129 | expect(server.processName).toBe("node"); |
| ... | @@ -135,6 +136,7 @@ describe("DiscoveredLocalServer", () => { | ... | @@ -135,6 +136,7 @@ describe("DiscoveredLocalServer", () => { |
| 135 | url: "http://localhost:3000", | 136 | url: "http://localhost:3000", |
| 136 | processName: null, | 137 | processName: null, |
| 137 | pid: null, | 138 | pid: null, |
| 139 | terminal: null, | ||
| 138 | }); | 140 | }); |
| 139 | expect(server.processName).toBeNull(); | 141 | expect(server.processName).toBeNull(); |
| 140 | }); | 142 | }); |
| ... | @@ -147,6 +149,7 @@ describe("DiscoveredLocalServer", () => { | ... | @@ -147,6 +149,7 @@ describe("DiscoveredLocalServer", () => { |
| 147 | url: "http://localhost:0", | 149 | url: "http://localhost:0", |
| 148 | processName: null, | 150 | processName: null, |
| 149 | pid: null, | 151 | pid: null, |
| 152 | terminal: null, | ||
| 150 | }), | 153 | }), |
| 151 | ).toThrow(); | 154 | ).toThrow(); |
| 152 | expect(() => | 155 | expect(() => |
| ... | @@ -156,6 +159,7 @@ describe("DiscoveredLocalServer", () => { | ... | @@ -156,6 +159,7 @@ describe("DiscoveredLocalServer", () => { |
| 156 | url: "http://localhost:70000", | 159 | url: "http://localhost:70000", |
| 157 | processName: null, | 160 | processName: null, |
| 158 | pid: null, | 161 | pid: null, |
| 162 | terminal: null, | ||
| 159 | }), | 163 | }), |
| 160 | ).toThrow(); | 164 | ).toThrow(); |
| 161 | }); | 165 | }); |
packages/contracts/src/preview.ts+6| ... | @@ -142,6 +142,12 @@ export const DiscoveredLocalServer = Schema.Struct({ | ... | @@ -142,6 +142,12 @@ export const DiscoveredLocalServer = Schema.Struct({ |
| 142 | url: Url, | 142 | url: Url, |
| 143 | processName: Schema.NullOr(TrimmedNonEmptyString), | 143 | processName: Schema.NullOr(TrimmedNonEmptyString), |
| 144 | pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), | 144 | pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), |
| 145 | terminal: Schema.NullOr( | ||
| 146 | Schema.Struct({ | ||
| 147 | threadId: ThreadId, | ||
| 148 | terminalId: TrimmedNonEmptyString, | ||
| 149 | }), | ||
| 150 | ), | ||
| 145 | }); | 151 | }); |
| 146 | export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; | 152 | export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; |
| 147 | 153 |
packages/shared/src/keybindings.ts+2| ... | @@ -20,7 +20,9 @@ type WhenToken = | ... | @@ -20,7 +20,9 @@ type WhenToken = |
| 20 | 20 | ||
| 21 | export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | 21 | export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 22 | { key: "mod+j", command: "terminal.toggle" }, | 22 | { key: "mod+j", command: "terminal.toggle" }, |
| 23 | { key: "mod+alt+b", command: "rightPanel.toggle" }, | ||
| 23 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | 24 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, |
| 25 | { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, | ||
| 24 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | 26 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 25 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, | 27 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, |
| 26 | { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, | 28 | { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, |
pnpm-lock.yaml+3| ... | @@ -147,6 +147,9 @@ importers: | ... | @@ -147,6 +147,9 @@ importers: |
| 147 | electron-builder: | 147 | electron-builder: |
| 148 | specifier: 26.8.1 | 148 | specifier: 26.8.1 |
| 149 | version: 26.8.1(electron-builder-squirrel-windows@26.8.1) | 149 | version: 26.8.1(electron-builder-squirrel-windows@26.8.1) |
| 150 | tailwindcss: | ||
| 151 | specifier: ^4.0.0 | ||
| 152 | version: 4.3.0 | ||
| 150 | vite-plus: | 153 | vite-plus: |
| 151 | specifier: 'catalog:' | 154 | specifier: 'catalog:' |
| 152 | version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | 155 | version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |