diff --git a/apps/chrome-extension/src/extension/recorder/generators/yamlGenerator.ts b/apps/chrome-extension/src/extension/recorder/generators/yamlGenerator.ts index 5ddde66c41..09a3f4ce23 100644 --- a/apps/chrome-extension/src/extension/recorder/generators/yamlGenerator.ts +++ b/apps/chrome-extension/src/extension/recorder/generators/yamlGenerator.ts @@ -3,16 +3,35 @@ import type { StreamingCodeGenerationOptions, } from '@midscene/core'; import { - generateYamlTest as generateYamlTestCore, - generateYamlTestStream as generateYamlTestStreamCore, + generateRecorderYamlTest, + generateRecorderYamlTestStream, } from '@midscene/core/ai-model'; import type { ChromeRecordedEvent } from '@midscene/recorder'; import type { IModelConfig } from '@midscene/shared/env'; +import type { MidsceneRecorderTarget } from '@midscene/shared/recorder'; import { recordLogger } from '../logger'; import { extractNavigationAndViewportInfo } from './playwrightGenerator'; import { handleTestGenerationError } from './shared/testGenerationUtils'; import type { YamlGenerationOptions } from './shared/types'; +function createWebRecorderTarget( + events: ChromeRecordedEvent[], +): MidsceneRecorderTarget { + const navigationInfo = extractNavigationAndViewportInfo(events); + const url = navigationInfo.urls[0] || ''; + const viewport = navigationInfo.initialViewport; + return { + platformId: 'web', + deviceId: url || undefined, + label: url || 'Web', + values: { + url, + ...(viewport?.width ? { viewportWidth: viewport.width } : {}), + ...(viewport?.height ? { viewportHeight: viewport.height } : {}), + }, + }; +} + /** * Generates YAML test configuration from recorded events using AI * Uses the core package implementation @@ -23,22 +42,16 @@ export const generateYamlTest = async ( modelConfig: IModelConfig, ): Promise => { try { - // Extract navigation and viewport information - const navigationInfo = extractNavigationAndViewportInfo(events); - recordLogger.info('Starting AI-powered YAML test generation', { eventsCount: events.length, }); - // Merge navigation and viewport info into options - const enhancedOptions = { - ...options, - navigationInfo, - }; - - const yamlContent = await generateYamlTestCore( - events, - enhancedOptions, + const yamlContent = await generateRecorderYamlTest( + { + ...options, + target: createWebRecorderTarget(events), + events, + }, modelConfig, ); @@ -106,9 +119,6 @@ export const generateYamlTestStream = async ( modelConfig: IModelConfig, ): Promise => { try { - // Extract navigation and viewport information - const navigationInfo = extractNavigationAndViewportInfo(events); - recordLogger.info( 'Starting AI-powered YAML test generation with streaming', { @@ -116,15 +126,13 @@ export const generateYamlTestStream = async ( }, ); - // Merge navigation and viewport info into options - const enhancedOptions = { - ...options, - navigationInfo, - }; - - const result = await generateYamlTestStreamCore( - events, - enhancedOptions, + const result = await generateRecorderYamlTestStream( + { + ...options, + target: createWebRecorderTarget(events), + events, + }, + options, modelConfig, ); diff --git a/apps/chrome-extension/tests/recorder-yaml-generator.test.ts b/apps/chrome-extension/tests/recorder-yaml-generator.test.ts new file mode 100644 index 0000000000..23db0b31ed --- /dev/null +++ b/apps/chrome-extension/tests/recorder-yaml-generator.test.ts @@ -0,0 +1,98 @@ +import { + generateRecorderYamlTest, + generateRecorderYamlTestStream, +} from '@midscene/core/ai-model'; +import type { IModelConfig } from '@midscene/shared/env'; +import { describe, expect, it, vi } from 'vitest'; +import { + generateYamlTest, + generateYamlTestStream, +} from '../src/extension/recorder/generators/yamlGenerator'; + +vi.mock('@midscene/core/ai-model', () => ({ + generateRecorderYamlTest: vi.fn( + async () => 'web:\n url: "https://example.com"\n', + ), + generateRecorderYamlTestStream: vi.fn(async () => ({ + content: 'web:\n url: "https://example.com"\n', + usage: undefined, + isStreamed: true, + })), +})); + +vi.mock('../src/extension/recorder/logger', () => ({ + recordLogger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + success: vi.fn(), + debug: vi.fn(), + }, +})); + +const mockedModelConfig = { + modelName: 'mock', + modelDescription: 'mock', + intent: 'default', + slot: 'default', +} as const satisfies IModelConfig; + +describe('chrome extension recorder YAML generator', () => { + it('passes a web recorder target to core YAML generation', async () => { + await generateYamlTest( + [ + { + type: 'navigation', + url: 'https://example.com', + title: 'Example', + pageInfo: { width: 1280, height: 720 }, + timestamp: 1, + hashId: 'nav-1', + }, + ], + { testName: 'extension recording' }, + mockedModelConfig, + ); + + expect(generateRecorderYamlTest).toHaveBeenCalledWith( + expect.objectContaining({ + target: { + platformId: 'web', + deviceId: 'https://example.com', + label: 'https://example.com', + values: { + url: 'https://example.com', + viewportWidth: 1280, + viewportHeight: 720, + }, + }, + }), + mockedModelConfig, + ); + }); + + it('passes streaming options through to core YAML generation', async () => { + const onChunk = vi.fn(); + await generateYamlTestStream( + [ + { + type: 'click', + elementDescription: 'Login button', + pageInfo: { width: 1280, height: 720 }, + timestamp: 2, + hashId: 'click-1', + }, + ], + { stream: true, onChunk }, + mockedModelConfig, + ); + + expect(generateRecorderYamlTestStream).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ platformId: 'web' }), + }), + expect.objectContaining({ stream: true, onChunk }), + mockedModelConfig, + ); + }); +}); diff --git a/apps/report/src/components/detail-side/index.tsx b/apps/report/src/components/detail-side/index.tsx index bf009ed20e..2a2d071b1a 100644 --- a/apps/report/src/components/detail-side/index.tsx +++ b/apps/report/src/components/detail-side/index.tsx @@ -169,6 +169,14 @@ const renderMetaContent = ( const extractTaskImages = ( param: any, ): Array<{ name: string; url: string }> | undefined => { + // For aiAct planning tasks + if ( + param?.userInstruction?.images && + Array.isArray(param.userInstruction.images) + ) { + return param.userInstruction.images; + } + // For locate params (Planning and Action Space tasks) if (param?.prompt?.images && Array.isArray(param.prompt.images)) { return param.prompt.images; @@ -479,11 +487,10 @@ const DetailSide = (): JSX.Element => { const memoriesStatus = (planningTask.param as any)?.memoriesStatus; if (planningTask.param?.userInstruction) { - // Ensure userInstruction is a string const instructionContent = typeof planningTask.param.userInstruction === 'string' ? planningTask.param.userInstruction - : JSON.stringify(planningTask.param.userInstruction); + : planningTask.param.userInstruction.prompt; taskInput = MetaKV({ data: [ diff --git a/apps/studio/package.json b/apps/studio/package.json index b660ae197a..cb304a87cb 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "build": "rsbuild build && node scripts/sync-static-assets.mjs", - "dev": "pnpm run dev:deps && concurrently -k -n deps,build,app \"nx run-many --target=dev --projects=@midscene/visualizer,@midscene/playground-app,@midscene/android-playground,@midscene/playground,@midscene/web --parallel --exclude-task-dependencies\" \"rsbuild dev\" \"node scripts/wait-for-electron-build.mjs && node scripts/launch-electron-dev.mjs\"", - "dev:deps": "nx run @midscene/visualizer:build --outputStyle=stream && nx run-many --target=build --projects=@midscene/playground-app,@midscene/android-playground,@midscene/playground,@midscene/web --parallel --outputStyle=stream", + "dev": "pnpm run dev:deps && concurrently -k -n deps,build,app \"nx run-many --target=dev --projects=@midscene/visualizer,@midscene/playground-app,@midscene/android-playground,@midscene/computer,@midscene/computer-playground,@midscene/playground,@midscene/web,@midscene/recorder --parallel --exclude-task-dependencies\" \"rsbuild dev\" \"node scripts/wait-for-electron-build.mjs && node scripts/launch-electron-dev.mjs\"", + "dev:deps": "nx run @midscene/visualizer:build --outputStyle=stream && nx run-many --target=build --projects=@midscene/playground-app,@midscene/android-playground,@midscene/computer,@midscene/computer-playground,@midscene/playground,@midscene/web,@midscene/recorder --parallel --outputStyle=stream", "package:release": "node scripts/package-electron.mjs", "preview": "rsbuild preview --environment renderer", "start": "node scripts/sync-static-assets.mjs && node scripts/launch-electron-prod.mjs", @@ -25,11 +25,13 @@ "@midscene/ios": "workspace:*", "@midscene/playground": "workspace:*", "@midscene/playground-app": "workspace:*", + "@midscene/recorder": "workspace:*", "@midscene/shared": "workspace:*", "@midscene/visualizer": "workspace:*", "@midscene/web": "workspace:*", "antd": "^5.21.6", "electron-updater": "6.8.3", + "jszip": "3.10.1", "puppeteer": "24.6.0", "react": "18.3.1", "react-dom": "18.3.1" diff --git a/apps/studio/rsbuild.config.ts b/apps/studio/rsbuild.config.ts index 89dc0e9fc2..db110d2d4f 100644 --- a/apps/studio/rsbuild.config.ts +++ b/apps/studio/rsbuild.config.ts @@ -16,6 +16,8 @@ import { // `file://` HTML. A relative prefix works in both places; an absolute // `/static/...` prefix leaves packaged/build smoke runs with a blank renderer. const rendererAssetPrefix = './'; +const studioRecorderEntryEnabled = + process.env.VITE_STUDIO_RECORDER_ENABLED !== 'false'; export default defineConfig({ tools: { @@ -79,6 +81,10 @@ export default defineConfig({ __dirname, '../../packages/visualizer/src/index.tsx', ), + '@midscene/visualizer/history-selector$': path.join( + __dirname, + '../../packages/visualizer/src/component/history-selector/index.tsx', + ), '@midscene/web/static$': path.join( __dirname, '../../packages/web-integration/src/static/index.ts', @@ -102,6 +108,9 @@ export default defineConfig({ }, define: { __APP_VERSION__: JSON.stringify(appVersion), + __STUDIO_RECORDER_ENTRY_ENABLED__: JSON.stringify( + studioRecorderEntryEnabled, + ), }, }, output: { diff --git a/apps/studio/src/main/index.ts b/apps/studio/src/main/index.ts index ab74cc735d..0076b25de7 100644 --- a/apps/studio/src/main/index.ts +++ b/apps/studio/src/main/index.ts @@ -1,9 +1,20 @@ -import { existsSync } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; +import { existsSync, mkdirSync } from 'node:fs'; +import { + mkdir, + mkdtemp, + readFile, + readdir, + stat, + writeFile as writeFileToDisk, +} from 'node:fs/promises'; import path from 'node:path'; import { + type ChooseFileSavePathRequest, + type ChooseReplayFileResult, type DiscoverDevicesRequest, IPC_CHANNELS, + type PrepareRecorderMarkdownReplayRequest, + type WriteFileRequest, type WriteReportFileRequest, } from '@shared/electron-contract'; import type { NativeThemeMode } from '@shared/electron-contract'; @@ -11,6 +22,7 @@ import { resolveExternalUrl } from '@shared/external-links'; import { BrowserWindow, type NativeImage, + type OpenDialogOptions, app, dialog, ipcMain, @@ -21,6 +33,11 @@ import { import type { TitleBarOverlay } from 'electron'; import { requestPlaygroundBootstrap } from './playground/bootstrap-request'; import type { PlaygroundRuntimeService } from './playground/types'; +import { + describeRecorderUIEventsInMain, + generateRecorderCodeInMain, + generateRecorderMetadataInMain, +} from './recorder/codegen'; import { configureStudioShellEnvHydration } from './shell-env'; import { studioUpdater } from './updater'; import { registerUpdaterHandlers } from './updater-handlers'; @@ -110,6 +127,24 @@ const getAppIcon = () => { return icon; }; +const resolveStudioUserRunDir = () => + path.join(app.getPath('userData'), 'midscene_run'); + +const resolveStudioTempRunDir = () => + path.join(app.getPath('temp'), 'midscene-studio'); + +const ensureStudioRunDirEnv = () => { + const currentRunDir = process.env.MIDSCENE_RUN_DIR; + const tempRunDir = resolveStudioTempRunDir(); + const runDir = + currentRunDir && path.resolve(currentRunDir) !== path.resolve(tempRunDir) + ? currentRunDir + : resolveStudioUserRunDir(); + + process.env.MIDSCENE_RUN_DIR = runDir; + mkdirSync(runDir, { recursive: true }); +}; + // macOS `vibrancy` and Windows `backgroundMaterial: 'acrylic'` only show // through when the BrowserWindow's own backgroundColor is fully transparent // — any opaque fill paints over the OS material. Linux has no native @@ -125,6 +160,7 @@ const getTitleBarOverlay = (): TitleBarOverlay => ({ }); const DEFAULT_REPORT_FILE_NAME = 'midscene_report.html'; +const DEFAULT_EXPORT_FILE_NAME = 'midscene_export.json'; const ensureHtmlFileName = (value: string) => value.toLowerCase().endsWith('.html') ? value : `${value}.html`; @@ -136,6 +172,119 @@ const resolveDefaultReportSavePath = (defaultFileName?: string) => { return path.join(app.getPath('downloads'), safeFileName); }; +const resolveDefaultFileSavePath = (defaultFileName?: string) => { + const safeFileName = path.basename( + defaultFileName?.trim() || DEFAULT_EXPORT_FILE_NAME, + ); + return path.join(app.getPath('downloads'), safeFileName); +}; + +const ensureFileExtensionFromFilters = ( + filePath: string, + filters?: ChooseFileSavePathRequest['filters'], +) => { + if (path.extname(filePath)) { + return filePath; + } + const firstExtension = filters?.find((filter) => filter.extensions.length) + ?.extensions[0]; + if (!firstExtension) { + return filePath; + } + if (firstExtension === '*') { + return filePath; + } + return `${filePath}.${firstExtension.replace(/^\./, '')}`; +}; + +const MARKDOWN_REPLAY_EXTENSIONS = new Set(['.md', '.markdown']); +const YAML_REPLAY_EXTENSIONS = new Set(['.yaml', '.yml']); + +const getReplayFileType = (filePath: string) => { + const extension = path.extname(filePath).toLowerCase(); + if (MARKDOWN_REPLAY_EXTENSIONS.has(extension)) { + return 'markdown' as const; + } + if (YAML_REPLAY_EXTENSIONS.has(extension)) { + return 'yaml' as const; + } + return null; +}; + +async function findMarkdownReplayFileInDirectory(directoryPath: string) { + const preferredFileNames = ['recording.md', 'recording.markdown']; + for (const fileName of preferredFileNames) { + const candidatePath = path.join(directoryPath, fileName); + try { + const candidateStats = await stat(candidatePath); + if (candidateStats.isFile()) { + return candidatePath; + } + } catch { + // Continue to the generic scan below. + } + } + + const entries = await readdir(directoryPath, { withFileTypes: true }); + const markdownEntry = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)) + .find((fileName) => MARKDOWN_REPLAY_EXTENSIONS.has(path.extname(fileName))); + + return markdownEntry ? path.join(directoryPath, markdownEntry) : null; +} + +const resolveReplayBundlePath = (baseDir: string, relativePath: string) => { + const normalized = relativePath.replace(/\\/g, '/').replace(/^\.\/+/, ''); + if (!normalized || path.isAbsolute(normalized)) { + throw new Error(`Invalid replay screenshot path: ${relativePath}`); + } + const targetPath = path.resolve(baseDir, normalized); + const normalizedBaseDir = path.resolve(baseDir); + if ( + targetPath !== normalizedBaseDir && + !targetPath.startsWith(`${normalizedBaseDir}${path.sep}`) + ) { + throw new Error(`Replay screenshot path escapes bundle: ${relativePath}`); + } + return targetPath; +}; + +async function prepareRecorderMarkdownReplayBundle( + request: PrepareRecorderMarkdownReplayRequest, +) { + if (!request || typeof request.markdown !== 'string') { + throw new Error('prepareRecorderMarkdownReplay: markdown is required'); + } + const bundleDir = await mkdtemp( + path.join(app.getPath('temp'), 'midscene-studio-replay-'), + ); + const markdownPath = path.join(bundleDir, 'recording.md'); + await writeFileToDisk(markdownPath, request.markdown, 'utf-8'); + + for (const screenshot of request.screenshots || []) { + if ( + !screenshot || + typeof screenshot.relativePath !== 'string' || + typeof screenshot.base64Data !== 'string' + ) { + continue; + } + const screenshotPath = resolveReplayBundlePath( + bundleDir, + screenshot.relativePath, + ); + await mkdir(path.dirname(screenshotPath), { recursive: true }); + await writeFileToDisk( + screenshotPath, + Buffer.from(screenshot.base64Data, 'base64'), + ); + } + + return { markdownPath }; +} + const getPlaygroundRuntime = async (): Promise => { if (!playgroundRuntimePromise) { playgroundRuntimePromise = import('./playground/multi-platform-runtime') @@ -316,6 +465,90 @@ const registerIpcHandlers = () => { return ensureHtmlFileName(result.filePath); }, ); + ipcMain.handle( + IPC_CHANNELS.chooseFileSavePath, + async (_event, request?: ChooseFileSavePathRequest) => { + const filters = + request?.filters && request.filters.length > 0 + ? request.filters + : [{ name: 'All Files', extensions: ['*'] }]; + const dialogOptions = { + title: request?.title || 'Save Midscene Export', + defaultPath: resolveDefaultFileSavePath(request?.defaultFileName), + filters, + }; + const result = mainWindow + ? await dialog.showSaveDialog(mainWindow, dialogOptions) + : await dialog.showSaveDialog(dialogOptions); + + if (result.canceled || !result.filePath) { + return null; + } + + return ensureFileExtensionFromFilters(result.filePath, filters); + }, + ); + ipcMain.handle( + IPC_CHANNELS.chooseReplayFile, + async (): Promise => { + const dialogOptions: OpenDialogOptions = { + title: 'Choose Markdown or YAML Replay', + properties: ['openFile', 'openDirectory', 'treatPackageAsDirectory'], + filters: [ + { + name: 'Replay Files', + extensions: ['md', 'markdown', 'yaml', 'yml'], + }, + { + name: 'All Files', + extensions: ['*'], + }, + ], + }; + const result = mainWindow + ? await dialog.showOpenDialog(mainWindow, dialogOptions) + : await dialog.showOpenDialog(dialogOptions); + + if (result.canceled || !result.filePaths[0]) { + return null; + } + + const filePath = result.filePaths[0]; + const fileStats = await stat(filePath); + if (fileStats.isDirectory()) { + const markdownPath = await findMarkdownReplayFileInDirectory(filePath); + if (!markdownPath) { + throw new Error( + 'Selected folder does not contain recording.md or a Markdown replay file.', + ); + } + return { + type: 'markdown', + path: markdownPath, + displayName: `${path.basename(filePath)}/${path.basename(markdownPath)}`, + }; + } + + const type = getReplayFileType(filePath); + if (!type) { + throw new Error('Only Markdown and YAML replay files are supported.'); + } + + if (type === 'markdown') { + return { + type, + path: filePath, + displayName: path.basename(filePath), + }; + } + + return { + type, + content: await readFile(filePath, 'utf-8'), + displayName: path.basename(filePath), + }; + }, + ); ipcMain.handle(IPC_CHANNELS.toggleMaximizeWindow, () => { if (!mainWindow) return; if (mainWindow.isMaximized()) { @@ -368,7 +601,34 @@ const registerIpcHandlers = () => { throw new Error('writeReportFile: content must be a string'); } - await writeFile(ensureHtmlFileName(targetPath), request.content, 'utf-8'); + await writeFileToDisk( + ensureHtmlFileName(targetPath), + request.content, + 'utf-8', + ); + }, + ); + ipcMain.handle( + IPC_CHANNELS.writeFile, + async (_event, request: WriteFileRequest) => { + const targetPath = request?.path?.trim(); + if (!targetPath) { + throw new Error('writeFile: path is required'); + } + if (typeof request.content !== 'string') { + throw new Error('writeFile: content must be a string'); + } + if (request.encoding === 'base64') { + await writeFileToDisk( + targetPath, + Buffer.from(request.content, 'base64'), + ); + return; + } + if (request.encoding && request.encoding !== 'utf-8') { + throw new Error(`writeFile: unsupported encoding ${request.encoding}`); + } + await writeFileToDisk(targetPath, request.content, 'utf-8'); }, ); // Multi-platform playground — a single server for Android, iOS, @@ -406,9 +666,30 @@ const registerIpcHandlers = () => { ); return runConnectivityTest(request); }); + ipcMain.handle(IPC_CHANNELS.generateRecorderCode, async (_event, request) => { + return generateRecorderCodeInMain(request); + }); + ipcMain.handle( + IPC_CHANNELS.generateRecorderMetadata, + async (_event, request) => { + return generateRecorderMetadataInMain(request); + }, + ); + ipcMain.handle( + IPC_CHANNELS.describeRecorderUIEvents, + async (_event, request) => { + return describeRecorderUIEventsInMain(request); + }, + ); + ipcMain.handle( + IPC_CHANNELS.prepareRecorderMarkdownReplay, + async (_event, request) => prepareRecorderMarkdownReplayBundle(request), + ); }; app.whenReady().then(() => { + ensureStudioRunDirEnv(); + if (process.platform === 'darwin' && app.dock) { app.dock.setIcon(getAppIcon()); } diff --git a/apps/studio/src/main/playground/device-discovery.ts b/apps/studio/src/main/playground/device-discovery.ts index 190451a81b..a0885ef4e2 100644 --- a/apps/studio/src/main/playground/device-discovery.ts +++ b/apps/studio/src/main/playground/device-discovery.ts @@ -1,3 +1,6 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; import { DEFAULT_WDA_PORT } from '@midscene/shared/constants'; import { getDebug } from '@midscene/shared/logger'; import type { @@ -26,7 +29,9 @@ function toolchainMissing( const debugLog = getDebug('studio:device-discovery', { console: true }); const IOS_WDA_DISCOVERY_HOST = 'localhost'; const IOS_WDA_DISCOVERY_TIMEOUT_MS = 1000; +const DEVICE_CLI_DISCOVERY_TIMEOUT_MS = 5000; export const DEVICE_DISCOVERY_POLL_INTERVAL_MS = 5000; +const execFileAsync = promisify(execFile); export interface DeviceDiscoveryService { close(): void; @@ -58,6 +63,140 @@ function isString(value: unknown): value is string { return typeof value === 'string' && value.length > 0; } +function uniqueStrings(values: Array): string[] { + return Array.from(new Set(values.filter(isString))); +} + +function getPlatformExecutableName(name: string): string { + return process.platform === 'win32' ? `${name}.exe` : name; +} + +async function execFirstAvailable( + candidates: string[], + args: string[], +): Promise { + let lastError: unknown; + + for (const candidate of uniqueStrings(candidates)) { + try { + const { stdout } = await execFileAsync(candidate, args, { + encoding: 'utf8', + timeout: DEVICE_CLI_DISCOVERY_TIMEOUT_MS, + windowsHide: true, + }); + return stdout; + } catch (error) { + lastError = error; + } + } + + throw lastError ?? new Error('No executable candidate provided'); +} + +function resolveAdbCandidates(): string[] { + const adbName = getPlatformExecutableName('adb'); + return uniqueStrings([ + process.env.ANDROID_HOME + ? path.join(process.env.ANDROID_HOME, 'platform-tools', adbName) + : undefined, + process.env.ANDROID_SDK_ROOT + ? path.join(process.env.ANDROID_SDK_ROOT, 'platform-tools', adbName) + : undefined, + adbName, + ]); +} + +function resolveHdcCandidates(): string[] { + const hdcName = getPlatformExecutableName('hdc'); + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + return uniqueStrings([ + process.env.HDC_HOME ? path.join(process.env.HDC_HOME, hdcName) : undefined, + homeDir + ? path.join( + homeDir, + 'Library/HarmonyOS/next/command-line-tools/sdk/default/openharmony/toolchains', + hdcName, + ) + : undefined, + homeDir + ? path.join( + homeDir, + 'Library/HarmonyOS/sdk/hmscore/3.1.0/toolchains', + hdcName, + ) + : undefined, + hdcName, + ]); +} + +function parseAdbDevices(stdout: string): DiscoveredDevice[] { + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter( + (line) => + line && + !line.startsWith('List of devices') && + !line.startsWith('* daemon'), + ) + .map((line) => { + const parts = line.split(/\s+/); + const udid = parts[0]; + const status = parts[1] || 'unknown'; + const model = line.match(/\bmodel:([^\s]+)/)?.[1]?.replace(/_/g, ' '); + return { + platformId: 'android' as const, + id: udid, + label: model || udid, + description: `ADB: ${udid}`, + status, + sessionValues: { + deviceId: udid, + }, + }; + }) + .filter((device) => Boolean(device.id)); +} + +function parseHdcTargets(stdout: string): DiscoveredDevice[] { + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter( + (line) => + line && + !line.startsWith('[') && + line.toLowerCase() !== 'empty' && + line.toLowerCase() !== '[empty]', + ) + .map((deviceId) => ({ + platformId: 'harmony' as const, + id: deviceId, + label: deviceId, + description: `HDC: ${deviceId}`, + status: 'device', + sessionValues: { + deviceId, + }, + })); +} + +async function scanAndroidDevicesFromCli(): Promise { + const stdout = await execFirstAvailable(resolveAdbCandidates(), [ + 'devices', + '-l', + ]); + return { devices: parseAdbDevices(stdout) }; +} + +async function scanHarmonyDevicesFromCli(): Promise { + const stdout = await execFirstAvailable(resolveHdcCandidates(), [ + 'list', + 'targets', + ]); + return { devices: parseHdcTargets(stdout) }; +} + function buildIOSDiscoveryLabel(status: WDAStatusResponse): string { const device = status.value?.device; if (isString(device)) { @@ -253,7 +392,12 @@ async function scanAndroidDevices(): Promise { }; } catch (err) { debugLog('android scan failed:', err); - return { devices: [], error: toolchainMissing('android') }; + try { + return await scanAndroidDevicesFromCli(); + } catch (fallbackError) { + debugLog('android cli fallback failed:', fallbackError); + return { devices: [], error: toolchainMissing('android') }; + } } } @@ -332,7 +476,12 @@ async function scanHarmonyDevices(): Promise { }; } catch (err) { debugLog('harmony scan failed:', err); - return { devices: [], error: toolchainMissing('harmony') }; + try { + return await scanHarmonyDevicesFromCli(); + } catch (fallbackError) { + debugLog('harmony cli fallback failed:', fallbackError); + return { devices: [], error: toolchainMissing('harmony') }; + } } } diff --git a/apps/studio/src/main/recorder/codegen.ts b/apps/studio/src/main/recorder/codegen.ts new file mode 100644 index 0000000000..0df48d56af --- /dev/null +++ b/apps/studio/src/main/recorder/codegen.ts @@ -0,0 +1,145 @@ +import { + convertRecordLogIntoMarkdown, + describeRecorderUIEvents, + generatePlaywrightTest, + generateRecorderSessionMetadata, + generateRecorderYamlTest, +} from '@midscene/core/ai-model'; +import type { + DescribeRecorderUIEventsRequest, + DescribeRecorderUIEventsResult, + GenerateRecorderCodeRequest, + GenerateRecorderCodeResult, + GenerateRecorderMetadataRequest, + GenerateRecorderMetadataResult, +} from '@shared/electron-contract'; + +function validateRecorderCodeRequest(request: GenerateRecorderCodeRequest) { + if (!request?.input) { + throw new Error('generateRecorderCode: input is required.'); + } + if (!request.modelConfig?.modelName) { + throw new Error('generateRecorderCode: modelConfig.modelName is required.'); + } +} + +function toNumber(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +} + +function resolveViewportSize(request: GenerateRecorderCodeRequest) { + const targetValues = request.input.target.values; + const width = + toNumber(targetValues.viewportWidth) ?? + toNumber(targetValues.width) ?? + request.input.events[0]?.pageInfo?.width ?? + 1280; + const height = + toNumber(targetValues.viewportHeight) ?? + toNumber(targetValues.height) ?? + request.input.events[0]?.pageInfo?.height ?? + 800; + + return { width, height }; +} + +export async function generateRecorderCodeInMain( + request: GenerateRecorderCodeRequest, +): Promise { + validateRecorderCodeRequest(request); + + if (request.type === 'yaml') { + const code = await generateRecorderYamlTest( + request.input, + request.modelConfig, + ); + return { type: 'yaml', code }; + } + + if (request.type === 'markdown') { + const code = await convertRecordLogIntoMarkdown( + request.input, + request.modelConfig, + ); + return { type: 'markdown', code }; + } + + if (request.type === 'playwright') { + if (request.input.target.platformId !== 'web') { + throw new Error( + 'Playwright generation is only available for Web recordings.', + ); + } + const code = await generatePlaywrightTest( + request.input.events, + { + testName: request.input.testName, + includeTimestamps: request.input.includeTimestamps, + maxScreenshots: request.input.maxScreenshots, + description: request.input.description, + viewportSize: resolveViewportSize(request), + }, + request.modelConfig, + ); + return { type: 'playwright', code }; + } + + throw new Error(`Unsupported recorder code type: ${request.type}`); +} + +export async function generateRecorderMetadataInMain( + request: GenerateRecorderMetadataRequest, +): Promise { + if (!request?.input?.events?.length) { + throw new Error('generateRecorderMetadata: events are required.'); + } + if (!request.modelConfig?.modelName) { + throw new Error( + 'generateRecorderMetadata: modelConfig.modelName is required.', + ); + } + + return generateRecorderSessionMetadata(request.input, request.modelConfig); +} + +export async function describeRecorderUIEventsInMain( + request: DescribeRecorderUIEventsRequest, +): Promise { + if (!request?.input?.events?.length) { + return { events: [], results: [] }; + } + if (!request.modelConfig?.modelName) { + throw new Error( + 'describeRecorderUIEvents: modelConfig.modelName is required.', + ); + } + + const results = await describeRecorderUIEvents( + request.input.events.map((event) => ({ + event, + target: request.input.target, + })), + request.modelConfig, + { + concurrency: 2, + }, + ); + + return { + events: results.map((result) => result.event), + results: results.map((result) => ({ + hashId: result.event.hashId, + usedFallback: result.usedFallback, + ...(result.error ? { error: result.error } : {}), + })), + }; +} diff --git a/apps/studio/src/preload/index.ts b/apps/studio/src/preload/index.ts index ffbdc42b2d..785366c98b 100644 --- a/apps/studio/src/preload/index.ts +++ b/apps/studio/src/preload/index.ts @@ -18,10 +18,13 @@ const electronShellApi: ElectronShellApi = { ipcRenderer.invoke(IPC_CHANNELS.openExternalUrl, url), chooseReportSavePath: (defaultFileName) => ipcRenderer.invoke(IPC_CHANNELS.chooseReportSavePath, defaultFileName), + chooseFileSavePath: (request) => + ipcRenderer.invoke(IPC_CHANNELS.chooseFileSavePath, request), toggleMaximizeWindow: () => ipcRenderer.invoke(IPC_CHANNELS.toggleMaximizeWindow), writeReportFile: (request) => ipcRenderer.invoke(IPC_CHANNELS.writeReportFile, request), + writeFile: (request) => ipcRenderer.invoke(IPC_CHANNELS.writeFile, request), setNativeTheme: (mode) => ipcRenderer.invoke(IPC_CHANNELS.setNativeTheme, mode), onSystemThemeChanged: (listener) => { @@ -59,6 +62,15 @@ const studioRuntimeApi: StudioRuntimeApi = { ipcRenderer.invoke(IPC_CHANNELS.setDiscoveryPollingPaused, paused), runConnectivityTest: (request) => ipcRenderer.invoke(IPC_CHANNELS.runConnectivityTest, request), + generateRecorderCode: (request) => + ipcRenderer.invoke(IPC_CHANNELS.generateRecorderCode, request), + generateRecorderMetadata: (request) => + ipcRenderer.invoke(IPC_CHANNELS.generateRecorderMetadata, request), + describeRecorderUIEvents: (request) => + ipcRenderer.invoke(IPC_CHANNELS.describeRecorderUIEvents, request), + prepareRecorderMarkdownReplay: (request) => + ipcRenderer.invoke(IPC_CHANNELS.prepareRecorderMarkdownReplay, request), + chooseReplayFile: () => ipcRenderer.invoke(IPC_CHANNELS.chooseReplayFile), }; const updaterApi: UpdaterApi = { diff --git a/apps/studio/src/renderer/App.css b/apps/studio/src/renderer/App.css index d451ecdc33..9e0bfdb480 100644 --- a/apps/studio/src/renderer/App.css +++ b/apps/studio/src/renderer/App.css @@ -17,6 +17,7 @@ --color-surface-active: var(--midscene-surface-active); --color-border-subtle: var(--midscene-border-subtle); --color-border-strong: var(--midscene-border-strong); + --color-border-control: var(--midscene-border-control); --color-divider: var(--midscene-divider); --color-text-primary: var(--midscene-text-primary); --color-text-secondary: var(--midscene-text-secondary); @@ -45,6 +46,7 @@ --midscene-surface-active: rgba(0, 0, 0, 0.1); --midscene-border-subtle: #ececec; --midscene-border-strong: #e9ecf3; + --midscene-border-control: #e9ecf3; --midscene-divider: rgba(0, 0, 0, 0.08); --midscene-text-primary: #0d0d0d; --midscene-text-secondary: #474848; @@ -73,6 +75,7 @@ --midscene-surface-active: rgba(255, 255, 255, 0.15); --midscene-border-subtle: #2e2e2e; --midscene-border-strong: rgba(255, 255, 255, 0.08); + --midscene-border-control: #454545; --midscene-divider: rgba(255, 255, 255, 0.08); --midscene-text-primary: #ffffff; --midscene-text-secondary: #d0d0d1; diff --git a/apps/studio/src/renderer/App.tsx b/apps/studio/src/renderer/App.tsx index 0e23e78dd7..963a828ccb 100644 --- a/apps/studio/src/renderer/App.tsx +++ b/apps/studio/src/renderer/App.tsx @@ -1,6 +1,7 @@ import './App.css'; import { ShellLayout } from './components'; import { StudioPlaygroundProvider } from './playground/StudioPlaygroundProvider'; +import { StudioRecorderProvider } from './recorder/StudioRecorderProvider'; import { StudioAntdProvider } from './theme/StudioAntdProvider'; import { ThemeProvider } from './theme/ThemeProvider'; @@ -10,7 +11,9 @@ export default function App() {
- + + +
diff --git a/apps/studio/src/renderer/assets/index.ts b/apps/studio/src/renderer/assets/index.ts index 30eaed33cc..809ac03864 100644 --- a/apps/studio/src/renderer/assets/index.ts +++ b/apps/studio/src/renderer/assets/index.ts @@ -61,6 +61,8 @@ export const assetUrls = { ios: new URL('./sidebar-ios.png', import.meta.url).href, leftSidebar: new URL('../../../assets/left-sidebar.svg', import.meta.url) .href, + collapse: new URL('./sidebar-collapse.svg', import.meta.url).href, + expand: new URL('./sidebar-expand.svg', import.meta.url).href, overview: new URL('./sidebar-overview.png', import.meta.url).href, settings: new URL('./sidebar-settings.png', import.meta.url).href, web: new URL('./sidebar-web.png', import.meta.url).href, diff --git a/apps/studio/src/renderer/assets/sidebar-collapse.svg b/apps/studio/src/renderer/assets/sidebar-collapse.svg new file mode 100644 index 0000000000..9082f048b2 --- /dev/null +++ b/apps/studio/src/renderer/assets/sidebar-collapse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/studio/src/renderer/assets/sidebar-expand.svg b/apps/studio/src/renderer/assets/sidebar-expand.svg new file mode 100644 index 0000000000..44d648a40f --- /dev/null +++ b/apps/studio/src/renderer/assets/sidebar-expand.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/studio/src/renderer/components/ConnectionFailedPreview/index.tsx b/apps/studio/src/renderer/components/ConnectionFailedPreview/index.tsx index 04ff0dfacb..6e907efd06 100644 --- a/apps/studio/src/renderer/components/ConnectionFailedPreview/index.tsx +++ b/apps/studio/src/renderer/components/ConnectionFailedPreview/index.tsx @@ -41,7 +41,7 @@ export default function ConnectionFailedPreview({ + ); +} + function WebNavigationButton({ 'aria-label': ariaLabel, disabled, @@ -234,17 +276,17 @@ function OverviewToolbar({ // explicitly opt back out via `app-no-drag` or the OS swallows hover // and click events. return ( -
+ <> -
+ ); } @@ -257,6 +299,9 @@ export default function MainContent({ modelConfigComplete = true, modelEnvText, onOpenEnvModal, + onRightPanelModeChange, + rightPanelMode = 'playground', + titlebarInsetLeft = 0, }: MainContentProps) { const studioPlayground = useStudioPlayground(); const [previewStatus, setPreviewStatus] = @@ -386,6 +431,24 @@ export default function MainContent({ failed: { bg: '#FDE7E7', fg: '#C0392B', dot: '#E53935' }, }; const pillColors = pillPalette[connectionStatus]; + const selectedPreviewToolbarKey = + rightPanelMode === 'recorder' ? 'recorder' : 'api-playground'; + const previewToolbarIcons = STUDIO_RECORDER_ENTRY_ENABLED + ? [ + { + icon: , + key: 'recorder', + label: 'Recorder', + mode: 'recorder' as const, + }, + { + icon: , + key: 'api-playground', + label: 'API Playground', + mode: 'playground' as const, + }, + ] + : []; useEffect(() => { if (!isConnected) { @@ -427,7 +490,6 @@ export default function MainContent({ studioPlayground.phase === 'ready' ? studioPlayground.controller.state.playgroundSDK : null; - useEffect(() => { if (!showWebNavigation || !webNavigationServerOnline || !webNavigationSDK) { setWebIsLoading(false); @@ -459,8 +521,10 @@ export default function MainContent({ const runWebNavigationAction = async ( actionType: 'GoBack' | 'GoForward' | 'Stop' | 'Reload', ) => { + const sdk = webNavigationSDK; if ( studioPlayground.phase !== 'ready' || + !sdk || webNavigationBusyAction !== null || previewPlatform !== 'web' ) { @@ -471,10 +535,9 @@ export default function MainContent({ if (actionType !== 'Stop') { setWebIsLoading(true); } - const result = - await studioPlayground.controller.state.playgroundSDK.interact({ - actionType, - }); + const result = await sdk.interact({ + actionType, + }); if (!result.ok) { debugWebNavigation( 'failed to run web navigation action "%s": %s', @@ -553,7 +616,7 @@ export default function MainContent({ return (
-
+
{ if (!isReady || overviewRefreshing) { @@ -697,19 +760,28 @@ export default function MainContent({ } return ( -
+
{/* * Device-preview top bar: icon + device name with ADB / Viewport meta * on the left, and a pill-shaped status/disconnect control on the * right that reveals a "Disconnect" tooltip on hover. */} -
-
-
+
0 ? { paddingLeft: titlebarInsetLeft } : undefined + } + > +
+
@@ -795,61 +867,73 @@ export default function MainContent({ ) : null}
-
- - {!disconnectDisabled ? ( - - ) : null} + + {!disconnectDisabled ? ( + + ) : null} +
+ {previewToolbarIcons.map((item) => ( + onRightPanelModeChange?.(item.mode)} + selected={item.key === selectedPreviewToolbarKey} + > + {item.icon} + + ))}
diff --git a/apps/studio/src/renderer/components/Playground/index.tsx b/apps/studio/src/renderer/components/Playground/index.tsx index 1142645646..5df0645099 100644 --- a/apps/studio/src/renderer/components/Playground/index.tsx +++ b/apps/studio/src/renderer/components/Playground/index.tsx @@ -1,9 +1,27 @@ import { PlaygroundConversationPanel } from '@midscene/playground-app'; -import type { UniversalPlaygroundConfig } from '@midscene/visualizer'; -import { useMemo } from 'react'; +import type { + ExternalRunRequest, + FormValue, + UniversalPlaygroundConfig, +} from '@midscene/visualizer'; +import { Tooltip, message } from 'antd'; +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { downloadStudioReport } from '../../playground/report-download'; import { useStudioPlayground } from '../../playground/useStudioPlayground'; +import { createRecorderMarkdownReplayRequest } from '../../recorder/replay'; +import { createStudioRecorderTargetSignature } from '../../recorder/selectors'; +import type { + StudioRecorderPanelMode, + StudioRecordingSession, +} from '../../recorder/types'; +import { useStudioRecorder } from '../../recorder/useStudioRecorder'; import { PlaygroundShell } from '../PlaygroundShell'; +import { + ApiPlaygroundModeIcon, + RecorderModeIcon, +} from '../PlaygroundShell/mode-icons'; +import { StudioRecorderPanel } from '../Recorder'; import { StudioPlaygroundEmptyState } from './StudioPlaygroundEmptyState'; // Studio drives device selection from the Overview page (middle area), so the @@ -23,23 +41,284 @@ function NotConnectedFallback() { } declare const __APP_VERSION__: string; +declare const __STUDIO_RECORDER_ENTRY_ENABLED__: boolean; +const STUDIO_RECORDER_ENTRY_ENABLED = __STUDIO_RECORDER_ENTRY_ENABLED__; +type StudioExternalRunRequest = ExternalRunRequest & { + targetSignature: string | null; +}; -export function createStudioPlaygroundConfig(): Partial { +interface PlaygroundProps { + rightPanelMode: StudioRecorderPanelMode; + onRightPanelModeChange: (mode: StudioRecorderPanelMode) => void; +} + +function ImportReplayIcon() { + return ( + + ); +} + +function createExternalRunRequest( + value: FormValue, + displayContent: string, + targetSignature: string | null, +): StudioExternalRunRequest { + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + value, + displayContent, + targetSignature, + }; +} + +export function createStudioPlaygroundStorageNamespace( + targetSignature: string | null, +): string { + return targetSignature + ? `studio-playground-${encodeURIComponent(targetSignature)}` + : 'studio-playground-unresolved-target'; +} + +export function createStudioPlaygroundConfig( + options: { + externalRunRequest?: ExternalRunRequest | null; + importReplayAction?: ReactNode; + storageNamespace?: string; + } = {}, +): Partial { return { emptyState: , + externalRunRequest: options.externalRunRequest ?? null, onDownloadReport: downloadStudioReport, + persistMessages: false, + showClearButton: true, + storageNamespace: options.storageNamespace, promptInputChrome: { variant: 'default', + inputActions: options.importReplayAction, }, }; } -export default function Playground() { +export default function Playground({ + onRightPanelModeChange, + rightPanelMode, +}: PlaygroundProps) { const studioPlayground = useStudioPlayground(); - const playgroundConfig = useMemo(() => createStudioPlaygroundConfig(), []); + const recorder = useStudioRecorder(); + const stopRecording = recorder.stopRecording; + const [externalRunRequest, setExternalRunRequest] = + useState(null); + const currentTargetSignature = useMemo( + () => createStudioRecorderTargetSignature(recorder.currentTarget), + [recorder.currentTarget], + ); + const showPlaygroundPanel = useCallback(() => { + onRightPanelModeChange('playground'); + }, [onRightPanelModeChange]); + const triggerExternalRun = useCallback( + (value: FormValue, displayContent: string) => { + showPlaygroundPanel(); + setExternalRunRequest( + createExternalRunRequest(value, displayContent, currentTargetSignature), + ); + }, + [currentTargetSignature, showPlaygroundPanel], + ); + useEffect(() => { + setExternalRunRequest(null); + }, [currentTargetSignature]); + const activeExternalRunRequest = useMemo( + () => + currentTargetSignature && + externalRunRequest?.targetSignature === currentTargetSignature + ? externalRunRequest + : null, + [currentTargetSignature, externalRunRequest], + ); + const playgroundStorageNamespace = useMemo( + () => createStudioPlaygroundStorageNamespace(currentTargetSignature), + [currentTargetSignature], + ); + const importReplayDisabledReason = + studioPlayground.phase !== 'ready' || + !studioPlayground.controller.state.serverOnline || + !studioPlayground.controller.state.sessionViewState.connected + ? 'Connect a target before replaying a file.' + : null; + const handleImportReplay = useCallback(async () => { + try { + if (importReplayDisabledReason) { + message.info(importReplayDisabledReason); + return; + } + if (!window.studioRuntime?.chooseReplayFile) { + message.error('Studio replay file picker is unavailable.'); + return; + } + const replayFile = await window.studioRuntime.chooseReplayFile(); + if (!replayFile) { + return; + } + if (replayFile.type === 'markdown') { + triggerExternalRun( + { type: 'runMarkdown', prompt: replayFile.path }, + `Imported Markdown Replay: ${replayFile.displayName}`, + ); + return; + } + triggerExternalRun( + { type: 'runYaml', prompt: replayFile.content }, + `Imported YAML Replay: ${replayFile.displayName}`, + ); + } catch (error) { + message.error(error instanceof Error ? error.message : String(error)); + } + }, [importReplayDisabledReason, triggerExternalRun]); + const handleReplayRecorderMarkdown = useCallback( + async (session: StudioRecordingSession) => { + try { + if (importReplayDisabledReason) { + message.info(importReplayDisabledReason); + return; + } + if (recorder.state.isRecording) { + throw new Error('Stop recording before replay.'); + } + if (!currentTargetSignature) { + throw new Error('Connect a target before replay.'); + } + if ( + createStudioRecorderTargetSignature(session.target) !== + currentTargetSignature + ) { + throw new Error('Connect the recorded target before replay.'); + } + if (!window.studioRuntime?.prepareRecorderMarkdownReplay) { + message.error('Studio replay preparation is unavailable.'); + return; + } + const replayBundle = + await window.studioRuntime.prepareRecorderMarkdownReplay( + createRecorderMarkdownReplayRequest(session), + ); + triggerExternalRun( + { type: 'runMarkdown', prompt: replayBundle.markdownPath }, + `Recorder Markdown Replay: ${session.name}`, + ); + } catch (error) { + message.error(error instanceof Error ? error.message : String(error)); + } + }, + [ + currentTargetSignature, + importReplayDisabledReason, + recorder.state.isRecording, + triggerExternalRun, + ], + ); + const importReplayAction = useMemo( + () => + STUDIO_RECORDER_ENTRY_ENABLED ? ( + + + + + + ) : null, + [handleImportReplay, importReplayDisabledReason], + ); + const playgroundConfig = useMemo( + () => + createStudioPlaygroundConfig({ + externalRunRequest: activeExternalRunRequest, + importReplayAction, + storageNamespace: playgroundStorageNamespace, + }), + [activeExternalRunRequest, importReplayAction, playgroundStorageNamespace], + ); + const modeMenuItems = useMemo( + () => + STUDIO_RECORDER_ENTRY_ENABLED + ? [ + { + key: 'playground', + label: 'API Playground', + icon: , + }, + { key: 'recorder', label: 'Recorder', icon: }, + ] + : [], + [], + ); + useEffect(() => { + if (!STUDIO_RECORDER_ENTRY_ENABLED && rightPanelMode === 'recorder') { + onRightPanelModeChange('playground'); + void stopRecording(); + } + }, [onRightPanelModeChange, rightPanelMode, stopRecording]); + + useEffect(() => { + if (rightPanelMode !== 'recorder') { + void stopRecording(); + } + }, [rightPanelMode, stopRecording]); + + useEffect(() => { + return () => { + void stopRecording(); + }; + }, [stopRecording]); + + if (STUDIO_RECORDER_ENTRY_ENABLED && rightPanelMode === 'recorder') { + return ( +
+ +
+ ); + } return ( - +
{studioPlayground.phase === 'booting' ? (
diff --git a/apps/studio/src/renderer/components/PlaygroundShell/PlaygroundShell.tsx b/apps/studio/src/renderer/components/PlaygroundShell/PlaygroundShell.tsx index 53313046d0..bc18470e77 100644 --- a/apps/studio/src/renderer/components/PlaygroundShell/PlaygroundShell.tsx +++ b/apps/studio/src/renderer/components/PlaygroundShell/PlaygroundShell.tsx @@ -1,25 +1,55 @@ import type { ReactNode } from 'react'; import './playground-shell-skin.css'; +export interface PlaygroundShellModeMenuItem { + key: string; + label: string; + disabled?: boolean; + icon?: ReactNode; +} + export interface PlaygroundShellProps { children: ReactNode; - /** Label shown in the shell header. Defaults to `'Playground'`. */ + modeMenu?: { + items: PlaygroundShellModeMenuItem[]; + selectedKey: string; + }; + /** Label shown in the shell header. Defaults to `'API Playground'`. */ title?: string; } export function PlaygroundShell({ children, - title = 'Playground', + modeMenu, + title = 'API Playground', }: PlaygroundShellProps) { + const selectedItem = modeMenu?.items.find( + (item) => item.key === modeMenu.selectedKey, + ); + return (
-
+
+ {modeMenu ? ( +
+ +
+ ) : null} - {title} + {selectedItem?.label || title}
-
{children}
+
{children}
); } diff --git a/apps/studio/src/renderer/components/PlaygroundShell/icons/api-playground.svg b/apps/studio/src/renderer/components/PlaygroundShell/icons/api-playground.svg new file mode 100644 index 0000000000..84578ec0f9 --- /dev/null +++ b/apps/studio/src/renderer/components/PlaygroundShell/icons/api-playground.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/studio/src/renderer/components/PlaygroundShell/icons/recorder.svg b/apps/studio/src/renderer/components/PlaygroundShell/icons/recorder.svg new file mode 100644 index 0000000000..d29f648945 --- /dev/null +++ b/apps/studio/src/renderer/components/PlaygroundShell/icons/recorder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/studio/src/renderer/components/PlaygroundShell/mode-icons.tsx b/apps/studio/src/renderer/components/PlaygroundShell/mode-icons.tsx new file mode 100644 index 0000000000..f0fcdc6a13 --- /dev/null +++ b/apps/studio/src/renderer/components/PlaygroundShell/mode-icons.tsx @@ -0,0 +1,32 @@ +interface ModeIconProps { + className?: string; +} + +const recorderModeIconUrl = new URL('./icons/recorder.svg', import.meta.url) + .href; +const apiPlaygroundModeIconUrl = new URL( + './icons/api-playground.svg', + import.meta.url, +).href; + +export function RecorderModeIcon({ className }: ModeIconProps) { + return ( + + ); +} + +export function ApiPlaygroundModeIcon({ className }: ModeIconProps) { + return ( + + ); +} diff --git a/apps/studio/src/renderer/components/PlaygroundShell/playground-shell-skin.css b/apps/studio/src/renderer/components/PlaygroundShell/playground-shell-skin.css index 88959f1270..c270ea1863 100644 --- a/apps/studio/src/renderer/components/PlaygroundShell/playground-shell-skin.css +++ b/apps/studio/src/renderer/components/PlaygroundShell/playground-shell-skin.css @@ -31,3 +31,33 @@ .playground-shell .playground-container { background: var(--midscene-surface); } + +.playground-shell-mode-menu { + position: relative; + display: flex; + align-items: center; +} + +.playground-shell-mode-icon { + display: inline-flex; + height: 16px; + width: 16px; + align-items: center; + justify-content: center; + color: var(--midscene-text-secondary); +} + +.playground-shell-mode-button-icon { + display: inline-flex; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; +} + +.playground-shell-mode-button-icon img, +.playground-shell-mode-button-icon svg { + width: 16px; + height: 16px; + stroke: currentColor; +} diff --git a/apps/studio/src/renderer/components/Recorder/RecorderDetailView.tsx b/apps/studio/src/renderer/components/Recorder/RecorderDetailView.tsx new file mode 100644 index 0000000000..be9c9435d5 --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/RecorderDetailView.tsx @@ -0,0 +1,288 @@ +import { RecordTimeline } from '@midscene/recorder'; +import type { StudioRecorderCodeType } from '@shared/electron-contract'; +import type { ReactNode } from 'react'; +import type { StudioRecordingSession } from '../../recorder/types'; +import { + ArrowIcon, + BackIcon, + CheckIcon, + CodeIcon, + CopyIcon, + DownloadIcon, + ReloadIcon, + TimelineIcon, +} from './assets/recorder-icons'; +import { + LANGUAGE_OPTIONS, + type StudioRecorderGenerationState, + type StudioRecorderTab, + codeTypeLabel, + generatingText, + getGenerationSteps, + isPlaywrightAvailable, + platformLabel, +} from './recorder-panel-utils'; + +interface RecorderDetailViewProps { + activeCode: string; + activeCodeType: StudioRecorderCodeType; + activeTab: StudioRecorderTab; + codeLabel: string; + detailSession: StudioRecordingSession | null; + fallback: ReactNode; + generation: StudioRecorderGenerationState; + isGenerating: boolean; + onBackToList: () => void; + onCodeTabClick: () => void; + onCodeTypeChange: (type: StudioRecorderCodeType) => void; + onCopyCode: () => void; + onExportCode: () => void; + onLanguageChange: (language: string) => void; + onRegenerateCode: () => void; + onTabChange: (tab: StudioRecorderTab) => void; + selectedLanguage: string; +} + +function RecorderGenerationStatus({ + detailSession, + generation, +}: { + detailSession: StudioRecordingSession | null; + generation: StudioRecorderGenerationState; +}) { + if (!detailSession || generation.sessionId !== detailSession.id) { + return null; + } + if (generation.status !== 'generating') { + return null; + } + + if (generation.steps.code.status === 'loading') { + return ( +
+ {generatingText(generation.type)} + Analyzing... +
+ ); + } + + const steps = getGenerationSteps(generation.type, generation.steps); + return ( +
+
+ {steps.map((step) => ( +
+ + {step.state === 'success' ? : null} + +
+
+ {step.title} +
+
+ {step.description} +
+ {step.details ? ( +
+ {step.details} +
+ ) : null} +
+
+ ))} +
+
+ ); +} + +export function RecorderDetailView({ + activeCode, + activeCodeType, + activeTab, + codeLabel, + detailSession, + fallback, + generation, + isGenerating, + onBackToList, + onCodeTabClick, + onCodeTypeChange, + onCopyCode, + onExportCode, + onLanguageChange, + onRegenerateCode, + onTabChange, + selectedLanguage, +}: RecorderDetailViewProps) { + if (!detailSession) { + return fallback; + } + + return ( +
+
+ +
+
+ {detailSession.name} +
+
+ {platformLabel(detailSession.target.platformId)} /{' '} + {detailSession.target.label} / {detailSession.events.length} events +
+
+
+ +
+ + + + + +
+ + {activeTab === 'timeline' ? ( + detailSession.events.length > 0 ? ( +
+ +
+ ) : ( +
+ Operate the connected device while recording to capture events. +
+ ) + ) : ( +
+
+ + + {activeCodeType !== 'playwright' ? ( + + ) : null} + +
+ + + +
+ + + + {detailSession.events.length === 0 ? ( +
+ Record events before generating code. +
+ ) : generation.status === 'error' && + generation.sessionId === detailSession.id && + generation.type === activeCodeType ? ( +
+ {generation.error || `Failed to generate ${codeLabel}.`} +
+ ) : isGenerating ? null : activeCode ? ( +
{activeCode}
+ ) : ( +
+ Generated code will appear here. +
+ )} +
+ )} +
+ ); +} diff --git a/apps/studio/src/renderer/components/Recorder/RecorderFloatingPanel.tsx b/apps/studio/src/renderer/components/Recorder/RecorderFloatingPanel.tsx new file mode 100644 index 0000000000..dc34e2578b --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/RecorderFloatingPanel.tsx @@ -0,0 +1,349 @@ +import { RecordTimeline } from '@midscene/recorder'; +import { Tooltip } from 'antd'; +import type { ReactNode } from 'react'; +import type { + StudioRecordedEvent, + StudioRecordingSession, +} from '../../recorder/types'; +import { + DownloadIcon, + EmptyRecorderPanelIcon, + RecorderButtonIcon, + RecorderOutputIcon, + RecorderPanelIcon, + ReplayIcon, + TimelineChevronIcon, +} from './assets/recorder-icons'; + +const recordingButtonIconUrl = new URL( + './assets/recording-button.svg', + import.meta.url, +).href; + +interface RecorderFloatingPanelProps { + canStartRecording: boolean; + error?: string | null; + generatedMarkdown: string; + historyControl: ReactNode; + isMarkdownGenerating: boolean; + isRecording: boolean; + isStoppingRecording: boolean; + markdownOutputLabel: string; + onExportMarkdown: () => void; + onReplayMarkdown?: () => void; + onToggleAllTimelineEvents: () => void; + onToggleCollapsed: () => void; + onToggleRecording: () => void; + recorderPanelEvents: StudioRecordedEvent[]; + recorderPanelSession: StudioRecordingSession | null; + showAllTimelineEvents: boolean; + showCollapsed: boolean; + showExpandedDetail: boolean; + statusText: string; + detailView: ReactNode; +} + +function RecorderFloatingTimeline({ + events, + isRecording, + onToggleAllTimelineEvents, + showAllTimelineEvents, +}: { + events: StudioRecordedEvent[]; + isRecording: boolean; + onToggleAllTimelineEvents: () => void; + showAllTimelineEvents: boolean; +}) { + if (events.length === 0 && !isRecording) { + return ( +
+ + + + The recording task has not yet begun. +
+ ); + } + + const visibleEvents = showAllTimelineEvents ? events : events.slice(-2); + + return ( +
+
+
+ Record Timeline + +
+ {events.length > 0 ? ( + + ) : null} +
+
+ +
+
+ ); +} + +function RecorderFloatingOutputs({ + generatedMarkdown, + isMarkdownGenerating, + markdownOutputLabel, + onExportMarkdown, + onReplayMarkdown, + recorderPanelSession, +}: { + generatedMarkdown: string; + isMarkdownGenerating: boolean; + markdownOutputLabel: string; + onExportMarkdown: () => void; + onReplayMarkdown?: () => void; + recorderPanelSession: StudioRecordingSession | null; +}) { + if (isMarkdownGenerating) { + return ( + + ); + } + + if (generatedMarkdown && recorderPanelSession) { + return ( +
+
+ + {markdownOutputLabel} +
+
+ {onReplayMarkdown ? ( + + + + ) : null} + +
+
+ ); + } + + return ( +
No outputs yet
+ ); +} + +export function RecorderFloatingPanel({ + canStartRecording, + detailView, + error, + generatedMarkdown, + historyControl, + isMarkdownGenerating, + isRecording, + isStoppingRecording, + markdownOutputLabel, + onExportMarkdown, + onReplayMarkdown, + onToggleAllTimelineEvents, + onToggleCollapsed, + onToggleRecording, + recorderPanelEvents, + recorderPanelSession, + showAllTimelineEvents, + showCollapsed, + showExpandedDetail, + statusText, +}: RecorderFloatingPanelProps) { + const showRecordingVisual = isRecording && !isStoppingRecording; + const recordingButtonLabel = isStoppingRecording + ? 'Stopping recording' + : showRecordingVisual + ? 'Stop recording' + : 'Start recording'; + const recordingButtonTitle = isStoppingRecording + ? 'Stopping recording' + : showRecordingVisual + ? 'Stop recording' + : statusText; + const cardClassName = [ + 'studio-recorder-floating-card', + showCollapsed ? 'studio-recorder-floating-card-collapsed' : '', + showAllTimelineEvents && !showExpandedDetail && !showCollapsed + ? 'studio-recorder-floating-card-expanded' + : '', + ] + .filter(Boolean) + .join(' '); + const mainClassName = [ + 'studio-recorder-floating-main', + showCollapsed ? 'studio-recorder-floating-main-collapsed' : '', + ] + .filter(Boolean) + .join(' '); + const outputsClassName = [ + 'studio-recorder-floating-outputs', + showCollapsed || showExpandedDetail + ? 'studio-recorder-floating-outputs-hidden' + : '', + ] + .filter(Boolean) + .join(' '); + + return ( +
+ {error ?
{error}
: null} + +
+
+
+ + Record and replay +
+
+ {showCollapsed && showRecordingVisual ? ( + + ) : null} + + {historyControl} + +
+
+ +
+
+ {showExpandedDetail ? ( + detailView + ) : ( + + )} +
+
+ +
+
+ Outputs +
+ +
+
+
+ ); +} diff --git a/apps/studio/src/renderer/components/Recorder/RecorderHistoryList.tsx b/apps/studio/src/renderer/components/Recorder/RecorderHistoryList.tsx new file mode 100644 index 0000000000..ad510661f3 --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/RecorderHistoryList.tsx @@ -0,0 +1,200 @@ +import { HistorySelector } from '@midscene/visualizer/history-selector'; +import { Popover } from 'antd'; +import { + type ComponentProps, + type ComponentType, + type ReactNode, + useEffect, + useMemo, + useState, +} from 'react'; +import type { StudioRecordingSession } from '../../recorder/types'; +import { DownloadIcon, EditIcon, TrashIcon } from './assets/recorder-icons'; +import type { StudioRecorderTab } from './recorder-panel-utils'; + +const RECORDER_HISTORY_TYPE = 'studio-recorder'; +type HistorySelectorHistory = Parameters< + ComponentProps['onSelect'] +>[0]; +type HistorySelectorWithActionControlsProps = Omit< + ComponentProps, + 'renderItemActions' +> & { + renderItemActions?: ( + history: HistorySelectorHistory, + controls: { close: () => void; scrollVersion: number }, + ) => ReactNode; +}; +const RecorderHistorySelector = + HistorySelector as ComponentType; + +interface RecorderHistoryListProps { + currentSessionId?: string; + isRecording: boolean; + onDeleteSession: (sessionId: string) => void; + onExportMarkdown: (sessionId: string) => void; + onOpenDetail: (sessionId: string, tab?: StudioRecorderTab) => void; + sessions: StudioRecordingSession[]; + trigger: ReactNode; +} + +function RecorderHistoryActions({ + currentSessionId, + isRecording, + onDeleteSession, + onExportMarkdown, + onOpenDetail, + onCloseHistory, + scrollVersion, + session, +}: Omit & { + onCloseHistory: () => void; + scrollVersion: number; + session: StudioRecordingSession; +}) { + const [open, setOpen] = useState(false); + const deleteDisabled = isRecording && session.id === currentSessionId; + const hasAiMarkdown = Boolean(session.generatedCode?.markdown); + + useEffect(() => { + if (scrollVersion > 0) { + setOpen(false); + } + }, [scrollVersion]); + + return ( + + + + +
+ } + onOpenChange={setOpen} + open={open} + overlayClassName="studio-recorder-history-actions-popover" + placement="bottomRight" + trigger="click" + > + + + ); +} + +export function RecorderHistoryList({ + currentSessionId, + isRecording, + onDeleteSession, + onExportMarkdown, + onOpenDetail, + sessions, + trigger, +}: RecorderHistoryListProps) { + const sessionById = useMemo( + () => new Map(sessions.map((session) => [session.id, session])), + [sessions], + ); + const historyItems = useMemo( + () => + sessions.map((session) => ({ + params: { sessionId: session.id }, + prompt: session.name, + timestamp: session.createdAt, + type: RECORDER_HISTORY_TYPE, + })), + [sessions], + ); + + return ( + { + const sessionId = history.params?.sessionId; + if (typeof sessionId === 'string') { + onOpenDetail(sessionId, 'timeline'); + } + }} + overlayClassName="studio-recorder-history-modal-overlay" + popupHeight={305} + popupPlacement="bottom" + popupWidth={280} + portalContainerSelector=".studio-recorder-floating-card" + renderItemActions={(history, { close, scrollVersion }) => { + const sessionId = history.params?.sessionId; + const session = + typeof sessionId === 'string' ? sessionById.get(sessionId) : null; + if (!session) { + return null; + } + return ( + + ); + }} + searchPlaceholder="search" + showClear={false} + title="History" + trigger={trigger} + /> + ); +} diff --git a/apps/studio/src/renderer/components/Recorder/StudioRecorderPanel.tsx b/apps/studio/src/renderer/components/Recorder/StudioRecorderPanel.tsx new file mode 100644 index 0000000000..33d5121ea6 --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/StudioRecorderPanel.tsx @@ -0,0 +1,528 @@ +import type { StudioRecorderCodeType } from '@shared/electron-contract'; +import { message } from 'antd'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + filterStudioRecorderSessionsForTarget, + isStudioRecorderSessionForTarget, +} from '../../recorder/selectors'; +import type { StudioRecordingSession } from '../../recorder/types'; +import { useStudioRecorder } from '../../recorder/useStudioRecorder'; +import { RecorderDetailView } from './RecorderDetailView'; +import { RecorderFloatingPanel } from './RecorderFloatingPanel'; +import { RecorderHistoryList } from './RecorderHistoryList'; +import { + CODE_TYPE_STORAGE_KEY, + LANGUAGE_STORAGE_KEY, + type StudioRecorderGenerationState, + type StudioRecorderTab, + codeTypeLabel, + createInitialGenerationSteps, + getAvailableCodeType, + getMarkdownOutputLabel, + isPlaywrightAvailable, + mergeGenerationProgress, + platformLabel, + readPersistedCodeType, + readPersistedLanguage, +} from './recorder-panel-utils'; +import './studio-recorder-panel.css'; + +interface StudioRecorderPanelProps { + onReplayMarkdown?: (session: StudioRecordingSession) => Promise; +} + +async function runPanelAction(action: () => Promise) { + try { + return await action(); + } catch (error) { + message.error(error instanceof Error ? error.message : 'Recorder failed.'); + return undefined; + } +} + +export function StudioRecorderPanel({ + onReplayMarkdown, +}: StudioRecorderPanelProps = {}) { + const recorder = useStudioRecorder(); + const { + state, + currentSession, + currentTarget, + canStartRecording, + startRecording, + stopRecording, + deleteSession, + selectSession, + generateSessionCode, + exportSessionCode, + } = recorder; + const sessions = state.sessions; + const visibleSessions = useMemo( + () => filterStudioRecorderSessionsForTarget(sessions, currentTarget), + [currentTarget, sessions], + ); + const [detailSessionId, setDetailSessionId] = useState(null); + const [activeTab, setActiveTab] = useState('timeline'); + const [selectedCodeType, setSelectedCodeType] = + useState(readPersistedCodeType); + const [selectedLanguage, setSelectedLanguage] = useState( + readPersistedLanguage, + ); + const [showExpandedDetail, setShowExpandedDetail] = useState(false); + const [showCollapsed, setShowCollapsed] = useState(false); + const [showAllTimelineEvents, setShowAllTimelineEvents] = useState(false); + const [isStoppingRecording, setIsStoppingRecording] = useState(false); + const [generation, setGeneration] = useState({ + sessionId: null, + type: 'markdown', + status: 'idle', + content: '', + error: null, + steps: createInitialGenerationSteps(), + }); + + const detailSession = useMemo(() => { + const selectedSession = + visibleSessions.find((session) => session.id === detailSessionId) ?? null; + if (selectedSession) { + return selectedSession; + } + return state.isRecording && + currentSession && + isStudioRecorderSessionForTarget(currentSession, currentTarget) + ? currentSession + : null; + }, [ + currentSession, + currentTarget, + detailSessionId, + state.isRecording, + visibleSessions, + ]); + const activeCodeType = getAvailableCodeType(detailSession, selectedCodeType); + const activeGeneratedCode = + detailSession?.generatedCode?.[activeCodeType] || ''; + const activeCode = + generation.sessionId === detailSession?.id && + generation.type === activeCodeType + ? generation.content || activeGeneratedCode + : activeGeneratedCode; + const codeLabel = codeTypeLabel(activeCodeType); + const isGenerating = + generation.status === 'generating' && + generation.sessionId === detailSession?.id && + generation.type === activeCodeType; + const statusText = useMemo(() => { + if (state.initializing) { + return 'Loading recorder...'; + } + if (state.isRecording) { + return 'Recording'; + } + if (!canStartRecording) { + return 'Connect a device to start recording.'; + } + return currentTarget + ? `Ready for ${platformLabel(currentTarget.platformId)}` + : 'Ready'; + }, [canStartRecording, currentTarget, state.initializing, state.isRecording]); + + useEffect(() => { + if (state.isRecording && currentSession?.id) { + setDetailSessionId(currentSession.id); + setActiveTab('timeline'); + } + }, [currentSession?.id, state.isRecording]); + + useEffect(() => { + if (!state.isRecording) { + setIsStoppingRecording(false); + } + }, [state.isRecording]); + + useEffect(() => { + if ( + detailSessionId && + !visibleSessions.some((session) => session.id === detailSessionId) + ) { + setDetailSessionId(null); + setActiveTab('timeline'); + } + }, [detailSessionId, visibleSessions]); + + useEffect(() => { + if ( + selectedCodeType === 'playwright' && + !isPlaywrightAvailable(detailSession) + ) { + setSelectedCodeType('markdown'); + } + }, [detailSession, selectedCodeType]); + + const runCodeGeneration = useCallback( + async ( + sessionId: string, + preferredType: StudioRecorderCodeType = selectedCodeType, + force = false, + ) => { + const session = + sessions.find((item) => item.id === sessionId) ?? + (currentSession?.id === sessionId ? currentSession : null); + const type = getAvailableCodeType(session, preferredType); + setActiveTab('code'); + setSelectedCodeType(type); + setGeneration({ + sessionId, + type, + status: 'generating', + content: session?.generatedCode?.[type] || '', + error: null, + steps: createInitialGenerationSteps(), + }); + + try { + const code = await generateSessionCode(sessionId, { + type, + force, + language: + type !== 'playwright' && selectedLanguage !== 'auto' + ? selectedLanguage + : undefined, + onChunk: (content) => { + setGeneration((current) => { + if (current.sessionId !== sessionId || current.type !== type) { + return current; + } + return { + ...current, + status: 'generating', + content, + error: null, + }; + }); + }, + onProgress: (progress) => { + setGeneration((current) => { + if (current.sessionId !== sessionId || current.type !== type) { + return current; + } + return { + ...current, + status: 'generating', + steps: mergeGenerationProgress(current.steps, progress), + }; + }); + }, + }); + setGeneration((current) => { + const steps = + current.sessionId === sessionId && current.type === type + ? current.steps + : createInitialGenerationSteps(); + return { + sessionId, + type, + status: 'success', + content: code, + error: null, + steps: mergeGenerationProgress(steps, { + step: 'code', + status: 'completed', + }), + }; + }); + message.success(`AI ${codeTypeLabel(type)} generated successfully!`); + return code; + } catch (error) { + setGeneration((current) => { + const errorMessage = + error instanceof Error + ? error.message + : `Failed to generate ${type}.`; + const steps = + current.sessionId === sessionId && current.type === type + ? mergeGenerationProgress(current.steps, { + step: 'code', + status: 'error', + details: errorMessage, + }) + : createInitialGenerationSteps(); + return { + sessionId, + type, + status: 'error', + content: '', + error: errorMessage, + steps, + }; + }); + throw error; + } + }, + [ + currentSession, + generateSessionCode, + selectedCodeType, + selectedLanguage, + sessions, + ], + ); + + const openDetail = useCallback( + (sessionId: string, tab: StudioRecorderTab = 'timeline') => { + selectSession(sessionId); + setDetailSessionId(sessionId); + setActiveTab(tab); + setShowExpandedDetail(false); + setShowCollapsed(false); + }, + [selectSession], + ); + + const handleCodeTabClick = useCallback(() => { + if (!detailSession) { + return; + } + setActiveTab('code'); + if ( + detailSession.events.length > 0 && + !state.isRecording && + !detailSession.generatedCode?.[activeCodeType] && + generation.status !== 'generating' + ) { + void runPanelAction(() => runCodeGeneration(detailSession.id)); + } + }, [ + activeCodeType, + detailSession, + generation.status, + runCodeGeneration, + state.isRecording, + ]); + + const handleCopyCode = useCallback(async () => { + if (!activeCode) { + return; + } + await navigator.clipboard.writeText(activeCode); + message.success(`${codeLabel} copied to clipboard`); + }, [activeCode, codeLabel]); + + const handleCodeTypeChange = useCallback( + (nextType: StudioRecorderCodeType) => { + if (!detailSession) { + return; + } + setSelectedCodeType(nextType); + window.localStorage.setItem(CODE_TYPE_STORAGE_KEY, nextType); + if ( + detailSession.events.length > 0 && + !detailSession.generatedCode?.[nextType] + ) { + void runPanelAction(() => + runCodeGeneration(detailSession.id, nextType), + ); + } + }, + [detailSession, runCodeGeneration], + ); + + const handleLanguageChange = useCallback((nextLanguage: string) => { + setSelectedLanguage(nextLanguage); + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, nextLanguage); + }, []); + + const recorderPanelSession = detailSession; + const recorderPanelEvents = recorderPanelSession?.events ?? []; + const isMarkdownGenerating = + generation.status === 'generating' && + generation.sessionId === recorderPanelSession?.id && + generation.type === 'markdown'; + const generatedMarkdown = + recorderPanelSession?.generatedCode?.markdown || + (generation.sessionId === recorderPanelSession?.id && + generation.type === 'markdown' + ? generation.content + : ''); + const markdownOutputLabel = getMarkdownOutputLabel( + generatedMarkdown, + recorderPanelSession, + ); + + const handleRecorderToggle = useCallback(() => { + if (state.isRecording) { + if (isStoppingRecording) { + return; + } + setIsStoppingRecording(true); + void runPanelAction(async () => { + const sessionId = currentSession?.id; + const generationType = getAvailableCodeType( + currentSession, + selectedCodeType, + ); + if (sessionId && generationType === 'markdown') { + setGeneration({ + sessionId, + type: 'markdown', + status: 'generating', + content: currentSession?.generatedCode?.markdown || '', + error: null, + steps: createInitialGenerationSteps(), + }); + } + try { + await stopRecording(); + } finally { + setIsStoppingRecording(false); + } + if (sessionId) { + await runCodeGeneration(sessionId, generationType, true); + } + }); + return; + } + + void runPanelAction(async () => { + const session = await startRecording(); + if (session) { + setShowCollapsed(false); + setShowAllTimelineEvents(false); + openDetail(session.id); + } + }); + }, [ + currentSession?.id, + currentSession, + isStoppingRecording, + openDetail, + runCodeGeneration, + selectedCodeType, + startRecording, + state.isRecording, + stopRecording, + ]); + + const historyControl = ( + { + void runPanelAction(async () => { + await deleteSession(sessionId); + if (detailSessionId === sessionId) { + setDetailSessionId(null); + } + }); + }} + onExportMarkdown={(sessionId) => { + void runPanelAction(() => exportSessionCode(sessionId, 'markdown')); + }} + onOpenDetail={openDetail} + sessions={visibleSessions} + trigger={ + + } + /> + ); + + const detailView = ( + + Select a recording from history. +
+ } + generation={generation} + isGenerating={isGenerating} + onBackToList={() => { + setDetailSessionId(null); + setActiveTab('timeline'); + }} + onCodeTabClick={handleCodeTabClick} + onCodeTypeChange={handleCodeTypeChange} + onCopyCode={() => { + void runPanelAction(handleCopyCode); + }} + onExportCode={() => { + if (!detailSession) { + return; + } + void runPanelAction(() => + exportSessionCode(detailSession.id, activeCodeType), + ); + }} + onLanguageChange={handleLanguageChange} + onRegenerateCode={() => { + if (!detailSession) { + return; + } + void runPanelAction(() => + runCodeGeneration(detailSession.id, activeCodeType, true), + ); + }} + onTabChange={setActiveTab} + selectedLanguage={selectedLanguage} + /> + ); + + return ( + { + if (!recorderPanelSession) { + return; + } + void runPanelAction(() => + exportSessionCode(recorderPanelSession.id, 'markdown'), + ); + }} + onReplayMarkdown={ + onReplayMarkdown && recorderPanelSession + ? () => { + void runPanelAction(() => onReplayMarkdown(recorderPanelSession)); + } + : undefined + } + onToggleAllTimelineEvents={() => { + setShowAllTimelineEvents((current) => !current); + }} + onToggleCollapsed={() => { + setShowExpandedDetail(false); + setShowCollapsed((current) => { + if (!current) { + setShowAllTimelineEvents(false); + } + return !current; + }); + }} + onToggleRecording={handleRecorderToggle} + recorderPanelEvents={recorderPanelEvents} + recorderPanelSession={recorderPanelSession} + showAllTimelineEvents={showAllTimelineEvents} + showCollapsed={showCollapsed} + showExpandedDetail={showExpandedDetail} + statusText={statusText} + /> + ); +} diff --git a/apps/studio/src/renderer/components/Recorder/assets/click-to-expand.svg b/apps/studio/src/renderer/components/Recorder/assets/click-to-expand.svg new file mode 100644 index 0000000000..713067b046 --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/assets/click-to-expand.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/studio/src/renderer/components/Recorder/assets/click-to-fold.svg b/apps/studio/src/renderer/components/Recorder/assets/click-to-fold.svg new file mode 100644 index 0000000000..8fc543dcc5 --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/assets/click-to-fold.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/studio/src/renderer/components/Recorder/assets/history.svg b/apps/studio/src/renderer/components/Recorder/assets/history.svg new file mode 100644 index 0000000000..f5aef60f18 --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/assets/history.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/studio/src/renderer/components/Recorder/assets/record-button.svg b/apps/studio/src/renderer/components/Recorder/assets/record-button.svg new file mode 100644 index 0000000000..2371a33718 --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/assets/record-button.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/studio/src/renderer/components/Recorder/assets/recorder-icons.tsx b/apps/studio/src/renderer/components/Recorder/assets/recorder-icons.tsx new file mode 100644 index 0000000000..cff202a972 --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/assets/recorder-icons.tsx @@ -0,0 +1,203 @@ +export function TimelineIcon() { + return ( + + ); +} + +export function CodeIcon() { + return ( + + ); +} + +export function CopyIcon() { + return ( + + ); +} + +export function ReloadIcon() { + return ( + + ); +} + +export function DownloadIcon() { + return ( + + ); +} + +export function ArrowIcon() { + return ( + + ); +} + +export function BackIcon() { + return ( + + ); +} + +export function EditIcon() { + return ( + + ); +} + +export function TrashIcon() { + return ( + + ); +} + +export function ReplayIcon() { + return ( + + ); +} + +export function CheckIcon() { + return ( + + ); +} + +export function RecorderPanelIcon() { + return ( + + ); +} + +export function EmptyRecorderPanelIcon() { + return ( + + ); +} + +export function RecorderOutputIcon() { + return ( + + ); +} + +export function TimelineChevronIcon() { + return ( + + ); +} + +export function RecorderButtonIcon() { + return ( + + ); +} diff --git a/apps/studio/src/renderer/components/Recorder/assets/recording-button.svg b/apps/studio/src/renderer/components/Recorder/assets/recording-button.svg new file mode 100644 index 0000000000..262c80f1f5 --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/assets/recording-button.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/studio/src/renderer/components/Recorder/index.ts b/apps/studio/src/renderer/components/Recorder/index.ts new file mode 100644 index 0000000000..58844a0246 --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/index.ts @@ -0,0 +1 @@ +export { StudioRecorderPanel } from './StudioRecorderPanel'; diff --git a/apps/studio/src/renderer/components/Recorder/recorder-panel-utils.ts b/apps/studio/src/renderer/components/Recorder/recorder-panel-utils.ts new file mode 100644 index 0000000000..df83c54c0b --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/recorder-panel-utils.ts @@ -0,0 +1,205 @@ +import type { StudioRecorderCodeType } from '@shared/electron-contract'; +import type { + StudioRecordedEvent, + StudioRecorderGenerationProgress, + StudioRecorderGenerationStepId, + StudioRecorderGenerationStepStatus, + StudioRecordingSession, +} from '../../recorder/types'; + +export const CODE_TYPE_STORAGE_KEY = 'studio.recorder.defaultCodeType.v2'; +export const LANGUAGE_STORAGE_KEY = 'studio.recorder.yamlLanguage'; + +export const LANGUAGE_OPTIONS = [ + { value: 'auto', label: 'Auto' }, + { value: 'English', label: 'English' }, + { value: 'Chinese', label: 'Chinese' }, + { value: 'Japanese', label: 'Japanese' }, + { value: 'Korean', label: 'Korean' }, + { value: 'French', label: 'French' }, + { value: 'Spanish', label: 'Spanish' }, +]; + +export type ReplayableCodeType = Extract< + StudioRecorderCodeType, + 'markdown' | 'yaml' +>; + +export type StudioRecorderTab = 'timeline' | 'code'; + +export type StudioRecorderGenerationStepState = Record< + StudioRecorderGenerationStepId, + { + status: StudioRecorderGenerationStepStatus; + details?: string; + } +>; + +export type StudioRecorderGenerationState = { + sessionId: string | null; + type: StudioRecorderCodeType; + status: 'idle' | 'generating' | 'success' | 'error'; + content: string; + error: string | null; + steps: StudioRecorderGenerationStepState; +}; + +export function formatDate(timestamp: number) { + return new Date(timestamp).toLocaleString(); +} + +export function platformLabel(platformId: string) { + return platformId.charAt(0).toUpperCase() + platformId.slice(1); +} + +export function readPersistedCodeType(): StudioRecorderCodeType { + if (typeof window === 'undefined') { + return 'markdown'; + } + const storedType = window.localStorage.getItem(CODE_TYPE_STORAGE_KEY); + return storedType === 'markdown' || + storedType === 'yaml' || + storedType === 'playwright' + ? storedType + : 'markdown'; +} + +export function readPersistedLanguage() { + if (typeof window === 'undefined') { + return 'auto'; + } + return window.localStorage.getItem(LANGUAGE_STORAGE_KEY) || 'auto'; +} + +export function getSessionTargetText(session: StudioRecordingSession) { + if (session.url) { + return session.url; + } + const targetUrl = session.target.values.url; + if (typeof targetUrl === 'string' && targetUrl) { + return targetUrl; + } + return session.target.label || platformLabel(session.target.platformId); +} + +export function getSessionTargetLabel(session: StudioRecordingSession) { + return session.url || session.target.platformId === 'web' ? 'URL' : 'Target'; +} + +export function isPlaywrightAvailable(session: StudioRecordingSession | null) { + return session?.target.platformId === 'web'; +} + +export function getAvailableCodeType( + session: StudioRecordingSession | null, + preferredType: StudioRecorderCodeType, +): StudioRecorderCodeType { + if (preferredType === 'playwright' && !isPlaywrightAvailable(session)) { + return 'markdown'; + } + return preferredType; +} + +export function codeTypeLabel(type: StudioRecorderCodeType) { + switch (type) { + case 'markdown': + return 'Markdown'; + case 'playwright': + return 'Playwright'; + default: + return 'YAML'; + } +} + +export function generatingText(type: StudioRecorderCodeType) { + switch (type) { + case 'markdown': + return 'Generating markdown...'; + case 'playwright': + return 'Generating Playwright...'; + default: + return 'Generating YAML...'; + } +} + +export function getMarkdownOutputLabel( + content: string, + session?: StudioRecordingSession | null, +) { + const firstHeading = content + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('# ')); + + if (firstHeading) { + return firstHeading.replace(/^#\s+/, ''); + } + + return session?.name || 'Markdown replay'; +} + +export function createInitialGenerationSteps(): StudioRecorderGenerationStepState { + return { + prepare: { status: 'pending' }, + metadata: { status: 'pending' }, + code: { status: 'pending' }, + }; +} + +export function mergeGenerationProgress( + steps: StudioRecorderGenerationStepState, + progress: StudioRecorderGenerationProgress, +): StudioRecorderGenerationStepState { + return { + ...steps, + [progress.step]: { + status: progress.status, + details: progress.details, + }, + }; +} + +function getGenerationStepState(status: StudioRecorderGenerationStepStatus) { + if (status === 'completed') { + return 'success'; + } + if (status === 'loading') { + return 'running'; + } + if (status === 'error') { + return 'error'; + } + return 'idle'; +} + +export function getGenerationSteps( + type: StudioRecorderCodeType, + steps: StudioRecorderGenerationStepState, +) { + const label = codeTypeLabel(type); + return [ + { + title: 'Prepare Recorded Events', + description: 'Collecting timeline events and target metadata', + details: steps.prepare.details, + state: getGenerationStepState(steps.prepare.status), + }, + { + title: 'Generate Title & Description', + description: 'Creating session title and description using AI', + details: steps.metadata.details, + state: getGenerationStepState(steps.metadata.status), + }, + { + title: `Generate ${label}`, + description: + type === 'playwright' + ? 'Creating executable Playwright test code' + : type === 'markdown' + ? 'Creating Midscene Markdown replay script' + : 'Creating YAML configuration', + details: steps.code.details, + state: getGenerationStepState(steps.code.status), + }, + ] as const; +} diff --git a/apps/studio/src/renderer/components/Recorder/studio-recorder-panel.css b/apps/studio/src/renderer/components/Recorder/studio-recorder-panel.css new file mode 100644 index 0000000000..deb328f43f --- /dev/null +++ b/apps/studio/src/renderer/components/Recorder/studio-recorder-panel.css @@ -0,0 +1,1503 @@ +.studio-recorder-panel { + --studio-recorder-motion: 200ms cubic-bezier(0.2, 0, 0, 1); + position: relative; + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + background: var(--midscene-surface); + color: var(--midscene-text-primary); +} + +.studio-recorder-panel button, +.studio-recorder-panel select { + outline: none; +} + +.studio-recorder-panel button:focus-visible, +.studio-recorder-panel select:focus-visible { + box-shadow: 0 0 0 3px rgba(50, 136, 255, 0.18); +} + +.studio-recorder-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 24px 22px 14px; +} + +.studio-recorder-title { + font-size: 16px; + font-weight: 700; + line-height: 24px; +} + +.studio-recorder-subtitle { + margin-top: 2px; + overflow: hidden; + color: var(--midscene-text-secondary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.studio-recorder-icon-button, +.studio-recorder-inline-icon-button { + display: inline-flex; + width: 32px; + height: 32px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--midscene-text-primary); + cursor: pointer; +} + +.studio-recorder-icon-button:hover:not(:disabled), +.studio-recorder-inline-icon-button:hover:not(:disabled) { + background: var(--midscene-surface-hover); +} + +.studio-recorder-icon-button:disabled, +.studio-recorder-inline-icon-button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.studio-recorder-icon-button svg, +.studio-recorder-inline-icon-button svg, +.studio-recorder-card-actions svg { + width: 17px; + height: 17px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.studio-recorder-notice { + margin: 0 22px 12px; + border-radius: 8px; + background: #fff2f0; + padding: 10px 12px; + font-size: 12px; + line-height: 18px; + color: #a8071a; +} + +.studio-recorder-content { + min-height: 0; + flex: 1; + overflow: auto; + padding: 0 18px 104px; +} + +.studio-recorder-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.studio-recorder-card { + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; + border: 1px solid var(--midscene-border-subtle); + border-radius: 8px; + background: var(--midscene-surface); + cursor: pointer; +} + +.studio-recorder-card:hover { + border-color: rgba(50, 136, 255, 0.24); +} + +.studio-recorder-card-body { + display: flex; + min-width: 0; + flex-direction: column; + gap: 8px; + padding: 16px; +} + +.studio-recorder-card-title, +.studio-recorder-session-title { + overflow: hidden; + color: var(--midscene-text-primary); + font-size: 14px; + font-weight: 700; + line-height: 21px; + text-overflow: ellipsis; +} + +.studio-recorder-card-title { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.studio-recorder-card-summary, +.studio-recorder-card-target, +.studio-recorder-card-time, +.studio-recorder-session-meta { + overflow: hidden; + color: var(--midscene-text-secondary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; +} + +.studio-recorder-card-target, +.studio-recorder-card-time, +.studio-recorder-session-meta { + white-space: nowrap; +} + +.studio-recorder-card-actions { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-top: 1px solid var(--midscene-border-subtle); + background: var(--midscene-surface-muted); +} + +.studio-recorder-card-actions button { + display: inline-flex; + height: 40px; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: var(--midscene-text-secondary); + cursor: pointer; +} + +.studio-recorder-card-action-tooltip { + display: block; + min-width: 0; + height: 40px; +} + +.studio-recorder-card-action-tooltip button { + width: 100%; + height: 100%; +} + +.studio-recorder-card-actions button:hover:not(:disabled) { + background: var(--midscene-surface-hover); + color: var(--midscene-text-primary); +} + +.studio-recorder-card-actions button:disabled { + cursor: not-allowed; + opacity: 0.35; +} + +.studio-recorder-detail { + display: flex; + min-height: 0; + flex-direction: column; + gap: 14px; +} + +.studio-recorder-detail-nav { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.studio-recorder-session-heading { + min-width: 0; + flex: 1; +} + +.studio-recorder-tabs { + display: flex; + align-items: center; + gap: 8px; + border-radius: 8px; + background: var(--midscene-surface-muted); + padding: 8px; +} + +.studio-recorder-tab { + display: inline-flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: center; + gap: 6px; + border: 0; + border-radius: 6px; + background: transparent; + padding: 8px 6px; + color: var(--midscene-text-secondary); + font-size: 12px; + font-weight: 700; + line-height: 18px; + cursor: pointer; +} + +.studio-recorder-tab:hover:not(:disabled) { + background: var(--midscene-surface-hover); +} + +.studio-recorder-tab-active { + color: var(--midscene-text-primary); +} + +.studio-recorder-tab svg, +.studio-recorder-code-toolbar svg { + width: 16px; + height: 16px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.studio-recorder-tab:disabled { + cursor: not-allowed; + opacity: 0.35; +} + +.studio-recorder-tab-arrow { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + color: var(--midscene-text-secondary); +} + +.studio-recorder-tab-arrow svg { + width: 14px; + height: 14px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.studio-recorder-timeline { + min-height: 0; +} + +.studio-recorder-timeline .ant-card { + border-radius: 8px; +} + +[data-theme="dark"] .studio-recorder-timeline .ant-card .ant-card-body { + border: 1px solid var(--midscene-border-subtle); + background: var(--midscene-surface-muted) !important; + color: var(--midscene-text-primary) !important; +} + +[data-theme="dark"] .studio-recorder-timeline .ant-space, +[data-theme="dark"] .studio-recorder-timeline .ant-typography { + color: var(--midscene-text-primary) !important; +} + +.studio-recorder-empty { + display: flex; + min-height: 160px; + align-items: center; + justify-content: center; + border: 1px dashed var(--midscene-border-subtle); + border-radius: 8px; + padding: 24px; + text-align: center; + color: var(--midscene-text-secondary); + font-size: 13px; + line-height: 21px; +} + +.studio-recorder-code-pane { + display: flex; + min-height: 0; + flex-direction: column; + gap: 12px; +} + +.studio-recorder-code-toolbar { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: 6px; + overflow: hidden; +} + +.studio-recorder-code-toolbar button { + display: inline-flex; + width: 32px; + min-width: 32px; + height: 32px; + flex: 0 0 32px; + align-items: center; + justify-content: center; + border: 1px solid var(--midscene-border-subtle); + border-radius: 8px; + background: var(--midscene-surface); + padding: 0; + color: var(--midscene-text-primary); + cursor: pointer; +} + +.studio-recorder-code-toolbar button:hover:not(:disabled) { + background: var(--midscene-surface-hover); +} + +.studio-recorder-code-toolbar button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.studio-recorder-select-shell { + display: inline-flex; + height: 32px; + min-width: 0; + max-width: 140px; + flex: 1 1 104px; + align-items: center; + gap: 4px; + border: 1px solid var(--midscene-border-subtle); + border-radius: 8px; + background: var(--midscene-surface); + padding: 0 6px; + color: var(--midscene-text-primary); +} + +.studio-recorder-select-shell svg { + width: 14px; + height: 14px; + flex: 0 0 auto; +} + +.studio-recorder-select-shell select { + width: 100%; + min-width: 0; + overflow: hidden; + border: 0; + background: transparent; + color: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; + text-overflow: ellipsis; +} + +.studio-recorder-select-shell:has(select:disabled) { + opacity: 0.45; +} + +.studio-recorder-language-select { + max-width: 92px; + flex: 0 1 82px; +} + +.studio-recorder-code-spacer { + min-width: 0; + flex: 1 1 0; +} + +.studio-recorder-generation { + display: flex; + flex-direction: column; + gap: 12px; +} + +.studio-recorder-generating-card { + display: flex; + min-height: 54px; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--midscene-border-subtle); + border-radius: 8px; + background: var(--midscene-surface); + padding: 12px 14px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-size: 13px; + line-height: 20px; +} + +.studio-recorder-generating-pill { + flex: 0 0 auto; + border-radius: 999px; + background: rgba(50, 136, 255, 0.14); + padding: 4px 10px; + color: #3288ff; + font-weight: 700; +} + +.studio-recorder-generation-steps { + display: flex; + flex-direction: column; + gap: 10px; +} + +.studio-recorder-generation-step { + display: grid; + grid-template-columns: 20px 1fr; + gap: 10px; + border: 1px solid var(--midscene-border-subtle); + border-radius: 8px; + background: var(--midscene-surface); + padding: 12px; +} + +.studio-recorder-generation-step-marker { + display: inline-flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--midscene-surface-muted); + color: var(--midscene-text-secondary); +} + +.studio-recorder-generation-step-marker svg { + width: 12px; + height: 12px; +} + +.studio-recorder-generation-step-success + .studio-recorder-generation-step-marker { + background: rgba(52, 199, 89, 0.16); + color: #23ad44; +} + +.studio-recorder-generation-step-running + .studio-recorder-generation-step-marker { + background: rgba(50, 136, 255, 0.16); +} + +.studio-recorder-generation-step-running + .studio-recorder-generation-step-marker::after { + width: 7px; + height: 7px; + border-radius: 50%; + background: #3288ff; + content: ""; +} + +.studio-recorder-generation-step-running + .studio-recorder-generation-step-title { + color: #3288ff; +} + +.studio-recorder-generation-step-error .studio-recorder-generation-step-marker { + background: rgba(239, 68, 68, 0.16); +} + +.studio-recorder-generation-step-error + .studio-recorder-generation-step-marker::after { + width: 7px; + height: 7px; + border-radius: 50%; + background: #ef4444; + content: ""; +} + +.studio-recorder-generation-step-title { + color: var(--midscene-text-primary); + font-size: 13px; + font-weight: 700; + line-height: 20px; +} + +.studio-recorder-generation-step-description { + margin-top: 2px; + color: var(--midscene-text-secondary); + font-size: 12px; + line-height: 18px; +} + +.studio-recorder-generation-step-details { + margin-top: 4px; + color: var(--midscene-text-primary); + font-size: 12px; + line-height: 18px; +} + +.studio-recorder-code-block { + min-height: 280px; + margin: 0; + overflow: auto; + border: 1px solid var(--midscene-border-subtle); + border-radius: 8px; + background: var(--midscene-surface); + padding: 16px; + color: var(--midscene-text-primary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-size: 13px; + line-height: 20px; + white-space: pre; +} + +.studio-recorder-footer { + position: absolute; + right: 0; + bottom: 0; + left: 0; + display: flex; + justify-content: center; + padding: 18px 20px 24px; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0), + var(--midscene-surface) 34% + ); +} + +.studio-recorder-primary { + min-width: 188px; + height: 44px; + border: 1px solid transparent; + border-radius: 22px; + background: #3288ff; + color: #fff; + font-size: 15px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 8px 18px rgba(50, 136, 255, 0.22); +} + +.studio-recorder-primary:focus-visible { + box-shadow: 0 8px 18px rgba(50, 136, 255, 0.22), 0 0 0 3px + rgba(50, 136, 255, 0.2); +} + +.studio-recorder-primary:hover:not(:disabled) { + background: #1f78ee; +} + +.studio-recorder-primary:disabled { + cursor: not-allowed; + opacity: 0.45; + box-shadow: none; +} + +.studio-recorder-stop { + background: #ef4444; + box-shadow: 0 8px 18px rgba(239, 68, 68, 0.18); +} + +.studio-recorder-stop:focus-visible { + box-shadow: 0 8px 18px rgba(239, 68, 68, 0.18), 0 0 0 3px + rgba(239, 68, 68, 0.18); +} + +.studio-recorder-stop:hover:not(:disabled) { + background: #dc2626; +} + +.studio-recorder-panel { + position: absolute; + top: 4px; + right: 12px; + bottom: 12px; + display: flex; + width: 320px; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + overflow: visible; + background: transparent; + box-sizing: border-box; + padding: 0; + pointer-events: none; +} + +.studio-recorder-floating-card { + position: relative; + display: flex; + width: 320px; + height: 368px; + min-height: 368px; + max-height: 100%; + flex-direction: column; + box-sizing: border-box; + overflow: hidden; + border: 1px solid #ebebeb; + border-radius: 20px; + background: #fff; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); + pointer-events: auto; + transition: height var(--studio-recorder-motion), min-height + var(--studio-recorder-motion), max-height var(--studio-recorder-motion), + border-radius var(--studio-recorder-motion), box-shadow + var(--studio-recorder-motion); + will-change: height, max-height; +} + +.studio-recorder-floating-card-expanded { + height: calc(100% - 12px); + max-height: calc(100% - 12px); +} + +.studio-recorder-floating-card-collapsed { + min-height: 44px; + height: 44px; + flex: 0 0 44px; + border-radius: 20px; +} + +.studio-recorder-floating-header { + display: flex; + width: calc(100% + 2px); + height: 44px; + flex: 0 0 44px; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + margin: -1px -1px 0; + border: 1px solid #ebebeb; + border-radius: 20px 20px 16px 16px; + background: radial-gradient( + 48px 44px at 97% 0%, + rgba(26, 121, 255, 0.04), + rgba(26, 121, 255, 0) 70% + ), + radial-gradient( + 64px 44px at 74% 0%, + rgba(153, 95, 245, 0.04), + rgba(153, 95, 245, 0) 72% + ), + radial-gradient( + 56px 44px at 60% 0%, + rgba(255, 142, 0, 0.04), + rgba(255, 142, 0, 0) 70% + ), #f5f5f5; + padding: 0 8px 0 16px; + transition: border-radius var(--studio-recorder-motion), background + var(--studio-recorder-motion); +} + +.studio-recorder-floating-header-collapsed { + border-radius: 20px; +} + +.studio-recorder-floating-title { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 12px; + color: #1a1a1a; + font-size: 14px; + font-weight: 600; + line-height: 22px; +} + +.studio-recorder-floating-title > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.studio-recorder-floating-title svg { + width: 16px; + height: 16px; + flex: 0 0 16px; +} + +.studio-recorder-floating-status { + position: relative; + display: inline-flex; + width: 24px; + height: 24px; + flex: 0 0 24px; + align-items: center; + justify-content: center; +} + +.studio-recorder-floating-status::before { + width: 20px; + height: 20px; + border: 2px solid rgba(26, 121, 255, 0.22); + border-top-color: #1a79ff; + border-radius: 50%; + content: ""; +} + +.studio-recorder-floating-status-running::before { + animation: studio-recorder-floating-status-spin 1s linear infinite; +} + +@keyframes studio-recorder-floating-status-spin { + to { + transform: rotate(360deg); + } +} + +.studio-recorder-floating-actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; +} + +.studio-recorder-floating-tool-button { + display: inline-flex; + width: 24px; + height: 24px; + flex: 0 0 24px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 6px; + background: transparent; + padding: 0; + color: #6d6d6d; + cursor: pointer; + transition: background var(--studio-recorder-motion), color + var(--studio-recorder-motion), transform 150ms ease-out; +} + +.studio-recorder-floating-tool-button:hover, +.studio-recorder-floating-tool-button-active { + color: #1a1a1a; +} + +.studio-recorder-floating-tool-button:active { + transform: scale(0.92); +} + +.studio-recorder-floating-tool-button svg { + width: 20px; + height: 20px; + flex: 0 0 20px; +} + +.studio-recorder-floating-fold-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + background: currentColor; + -webkit-mask: url("./assets/click-to-fold.svg") center / 16px 16px no-repeat; + mask: url("./assets/click-to-fold.svg") center / 16px 16px no-repeat; + transition: transform var(--studio-recorder-motion), opacity + var(--studio-recorder-motion); +} + +.studio-recorder-floating-header-collapsed .studio-recorder-floating-fold-icon { + -webkit-mask-image: url("./assets/click-to-expand.svg"); + mask-image: url("./assets/click-to-expand.svg"); + transform: rotate(180deg); +} + +.studio-recorder-floating-history-icon { + width: 14px; + height: 14px; + flex: 0 0 14px; + background: currentColor; + -webkit-mask: url("./assets/history.svg") center / 14px 14px no-repeat; + mask: url("./assets/history.svg") center / 14px 14px no-repeat; +} + +.studio-recorder-floating-actions .history-selector-wrapper, +.studio-recorder-floating-actions .selector-trigger { + display: inline-flex; + flex: 0 0 auto; +} + +.history-modal-overlay.studio-recorder-history-modal-overlay { + box-sizing: border-box; + width: 280px; + height: 305px; + border: 1px solid #ebebeb; + border-radius: 20px; + background: #fff; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); + z-index: 20; +} + +.history-modal-overlay.studio-recorder-history-modal-overlay.history-modal-overlay-in-container { + right: 8px; + bottom: 24px; + left: auto !important; + top: auto !important; +} + +.studio-recorder-floating-card-expanded + .history-modal-overlay.studio-recorder-history-modal-overlay.history-modal-overlay-in-container { + top: 52px !important; + bottom: auto; +} + +.history-modal-overlay.studio-recorder-history-modal-overlay + .history-modal-container { + border-radius: 20px; +} + +.history-modal-overlay.studio-recorder-history-modal-overlay .history-content { + padding: 0 20px 20px; +} + +.history-modal-overlay.studio-recorder-history-modal-overlay .history-item { + border-radius: 8px; +} + +.history-modal-overlay.studio-recorder-history-modal-overlay + .history-item:hover { + margin: 0; + background: #f3f3f4; + padding: 0 8px; +} + +.studio-recorder-history-action-trigger { + display: inline-flex; + min-width: 24px; + height: 24px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 6px; + background: transparent; + padding: 0 4px; + color: #909090; + font-size: 13px; + line-height: 20px; + cursor: pointer; +} + +.studio-recorder-history-action-trigger:hover { + background: rgba(0, 0, 0, 0.04); + color: #0d0d0d; +} + +.studio-recorder-history-actions-popover .ant-popover-inner { + border-radius: 12px; + padding: 8px; +} + +.studio-recorder-history-actions-menu { + display: flex; + min-width: 108px; + flex-direction: column; + gap: 2px; +} + +.studio-recorder-history-actions-menu button { + display: flex; + height: 30px; + align-items: center; + gap: 8px; + border: 0; + border-radius: 6px; + background: transparent; + padding: 0 8px; + color: #5f5f5f; + font-size: 13px; + line-height: 20px; + text-align: left; + cursor: pointer; +} + +.studio-recorder-history-actions-menu button:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.04); + color: #0d0d0d; +} + +.studio-recorder-history-actions-menu button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.studio-recorder-history-actions-menu svg { + width: 14px; + height: 14px; + flex: 0 0 14px; +} + +.studio-recorder-floating-record-button { + display: inline-flex; + width: 28px; + height: 28px; + flex: 0 0 28px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 50%; + background: #1a79ff; + padding: 0; + color: #fff; + cursor: pointer; + box-shadow: none; + transition: background var(--studio-recorder-motion), opacity + var(--studio-recorder-motion), transform 150ms ease-out; +} + +.studio-recorder-floating-record-button:active:not(:disabled) { + transform: scale(0.92); +} + +.studio-recorder-floating-record-button svg { + width: 16px; + height: 16px; + flex: 0 0 16px; +} + +.studio-recorder-floating-record-button-icon { + width: 29px; + height: 29px; + flex: 0 0 29px; +} + +.studio-recorder-floating-record-button-active { + background: transparent; +} + +.studio-recorder-floating-record-button:disabled { + cursor: not-allowed; + opacity: 0.45; + box-shadow: none; +} + +.studio-recorder-floating-main { + display: flex; + min-width: 0; + box-sizing: border-box; + height: 208px; + max-height: 208px; + flex: 0 0 208px; + flex-direction: column; + margin: 8px 8px 0; + overflow: hidden; + padding: 12px; + opacity: 1; + transform: translateY(0); + visibility: visible; + transition: + height var(--studio-recorder-motion), + max-height var(--studio-recorder-motion), + flex-basis var(--studio-recorder-motion), + margin var(--studio-recorder-motion), + padding var(--studio-recorder-motion), + opacity 140ms ease-out, + transform var(--studio-recorder-motion), + visibility 0s linear 0s; + will-change: height, opacity, transform; +} + +.studio-recorder-floating-card-expanded .studio-recorder-floating-main { + height: auto; + max-height: none; + flex: 1 1 auto; + min-height: 0; +} + +.studio-recorder-floating-main-collapsed { + height: 0; + max-height: 0; + flex-basis: 0; + margin-top: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + pointer-events: none; + transform: translateY(-6px); + transition: + height var(--studio-recorder-motion), + max-height var(--studio-recorder-motion), + flex-basis var(--studio-recorder-motion), + margin var(--studio-recorder-motion), + padding var(--studio-recorder-motion), + opacity 140ms ease-out, + transform var(--studio-recorder-motion), + visibility 0s linear 200ms; + visibility: hidden; +} + +.studio-recorder-floating-main-content { + display: flex; + min-width: 0; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; +} + +.studio-recorder-floating-empty { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: #909090; + text-align: center; + font-size: 14px; + line-height: 22px; +} + +.studio-recorder-floating-empty > span:last-child { + width: 197px; +} + +.studio-recorder-floating-empty-icon { + display: inline-flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #e9e9e9; + color: #7a7a7a; +} + +.studio-recorder-floating-empty-icon svg { + width: 18px; + height: 18px; +} + +.studio-recorder-floating-section { + display: flex; + height: 100%; + min-width: 0; + min-height: 0; + flex-direction: column; +} + +.studio-recorder-floating-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.studio-recorder-floating-section-title { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 8px; + color: #909090; + font-size: 13px; + font-weight: 400; + line-height: 16px; +} + +.studio-recorder-floating-section-title svg, +.studio-recorder-floating-show-more svg { + width: 12px; + height: 12px; +} + +.studio-recorder-floating-show-more { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; + border: 0; + background: transparent; + padding: 0; + color: #909090; + font-size: 12px; + font-weight: 400; + line-height: 16px; + cursor: pointer; + transition: color var(--studio-recorder-motion), opacity + var(--studio-recorder-motion), transform 150ms ease-out; +} + +.studio-recorder-floating-show-more svg { + transform: scaleY(-1); + transition: transform var(--studio-recorder-motion); +} + +.studio-recorder-floating-show-more-expanded svg { + transform: none; +} + +.studio-recorder-floating-show-more:hover { + color: #5f5f5f; +} + +.studio-recorder-floating-show-more:active { + transform: scale(0.96); +} + +.studio-recorder-floating-timeline { + display: flex; + min-width: 0; + min-height: 0; + height: 156px; + flex: 0 0 156px; + flex-direction: column; + justify-content: flex-start; + gap: 8px; + max-height: 156px; + margin-top: 12px; + overflow: hidden; + transition: height var(--studio-recorder-motion), max-height + var(--studio-recorder-motion), flex-basis var(--studio-recorder-motion); +} + +.studio-recorder-floating-timeline-expanded { + flex: 1 1 auto; + height: auto; + max-height: none; + justify-content: flex-start; + overflow: hidden; +} + +.studio-recorder-floating-timeline-empty { + min-height: 156px; + justify-content: flex-start; +} + +.studio-recorder-floating-timeline > div { + display: flex; + width: 100%; + height: 100%; + min-height: 0; + flex: 1 1 auto; + padding: 0 !important; +} + +.studio-recorder-floating-timeline .ant-space { + max-width: 100%; + min-width: 0; +} + +.studio-recorder-floating-timeline > div > .ant-space { + display: flex !important; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + flex: 1 1 auto; +} + +.studio-recorder-floating-timeline > div > .ant-space > .ant-space-item { + width: 100%; + height: 100%; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.studio-recorder-floating-timeline .timeline-scrollable { + display: block; + width: 100%; + min-width: 0; + height: 100%; + max-height: 100%; + margin: 0; + padding-top: 2px !important; + min-height: 0; + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + padding-left: 4px; +} + +.studio-recorder-floating-timeline .timeline-scrollable > div { + max-height: none; + overflow: visible; +} + +.studio-recorder-floating-timeline .ant-timeline-item { + padding-bottom: 18px; +} + +.studio-recorder-floating-timeline .ant-timeline-item-last { + padding-bottom: 0; +} + +.studio-recorder-floating-timeline .ant-timeline-item-tail { + display: none; +} + +.studio-recorder-floating-timeline .ant-timeline-item-head { + background: transparent; +} + +.studio-recorder-floating-timeline .ant-timeline-item-head-custom { + inset-inline-start: 8px; + inset-block-start: 6px; +} + +.studio-recorder-floating-timeline .ant-timeline-item-head-custom svg { + width: 16px; + height: 16px; +} + +.studio-recorder-floating-timeline .ant-timeline-item-content { + min-height: 0; + margin-inline-start: 28px; + color: #0d0d0d; + font-size: 13px; + line-height: 16px; +} + +.studio-recorder-floating-timeline .ant-card { + margin-bottom: 0 !important; + background: transparent; + box-shadow: none; +} + +.studio-recorder-floating-timeline .ant-card-body { + border-radius: 0 !important; + background: transparent !important; + padding: 0 !important; +} + +.studio-recorder-floating-timeline .ant-card-body > .ant-space { + display: flex !important; + width: 100%; + flex-direction: column; + align-items: flex-start !important; + gap: 8px !important; + color: #0d0d0d !important; +} + +.studio-recorder-floating-timeline .ant-card-body > .ant-space > .ant-space { + max-width: 100%; + min-width: 0; +} + +.studio-recorder-floating-timeline .ant-typography { + color: #0d0d0d; + font-size: 13px; + line-height: 16px; + white-space: normal; + word-break: break-word; +} + +.studio-recorder-floating-timeline .step-title-shiny { + font-size: 13px; + line-height: 16px; +} + +.studio-recorder-floating-timeline .ant-image { + width: 24px !important; + height: 24px !important; + flex: 0 0 24px; +} + +.studio-recorder-floating-outputs { + position: relative; + box-sizing: border-box; + height: 84px; + max-height: 84px; + flex: 0 0 84px; + margin: 16px 8px 8px; + border-top: 0; + padding: 12px; + opacity: 1; + transform: translateY(0); + visibility: visible; + transition: + height var(--studio-recorder-motion), + max-height var(--studio-recorder-motion), + flex-basis var(--studio-recorder-motion), + margin var(--studio-recorder-motion), + padding var(--studio-recorder-motion), + opacity 140ms ease-out, + transform var(--studio-recorder-motion), + visibility 0s linear 0s; + will-change: height, opacity, transform; +} + +.studio-recorder-floating-outputs-hidden { + height: 0; + max-height: 0; + flex-basis: 0; + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + pointer-events: none; + transform: translateY(-6px); + transition: + height var(--studio-recorder-motion), + max-height var(--studio-recorder-motion), + flex-basis var(--studio-recorder-motion), + margin var(--studio-recorder-motion), + padding var(--studio-recorder-motion), + opacity 140ms ease-out, + transform var(--studio-recorder-motion), + visibility 0s linear 200ms; + visibility: hidden; +} + +.studio-recorder-floating-outputs::before { + display: block; + position: absolute; + top: -8px; + right: 12px; + left: 12px; + height: 1px; + background: var(--midscene-border-subtle); + content: ""; +} + +.studio-recorder-floating-outputs-title { + display: inline-flex; + align-items: center; + gap: 8px; + color: #909090; + text-align: left; + font-family: Inter, sans-serif; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: 16px; +} + +.studio-recorder-floating-outputs-title svg { + width: 16px; + height: 16px; + color: rgba(144, 144, 144, 0.22); +} + +.studio-recorder-floating-output-empty { + display: flex; + height: 32px; + align-items: center; + margin-top: 12px; + padding: 0; + color: #909090; + text-align: left; + font-family: Inter, sans-serif; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: 21px; +} + +.studio-recorder-floating-output { + display: flex; + width: calc(100% + 24px); + height: 32px; + min-width: 0; + box-sizing: border-box; + align-items: center; + justify-content: center; + gap: 24px; + margin: 12px -12px 0; + border: 0; + background: transparent; + border-radius: 8px; + padding: 0 12px; + color: #0d0d0d; + font-size: 13px; + font-weight: 400; + line-height: 21px; + text-align: left; +} + +.studio-recorder-floating-output:hover, +.studio-recorder-floating-output:focus-within { + background: #f3f3f4; +} + +.studio-recorder-floating-output-info { + display: inline-flex; + min-width: 0; + flex: 1 1 0; + align-items: center; + gap: 8px; + color: #0d0d0d; +} + +.studio-recorder-floating-output-info svg, +.studio-recorder-floating-output-action svg { + width: 16px; + height: 16px; + flex: 0 0 16px; +} + +.studio-recorder-floating-output-info svg { + color: #333; +} + +.studio-recorder-floating-output-info span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.studio-recorder-floating-output-actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + opacity: 0; + transition: opacity var(--studio-recorder-motion); +} + +.studio-recorder-floating-output-action { + display: inline-flex; + width: 16px; + height: 16px; + flex: 0 0 16px; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + padding: 0; + color: #5f5f5f; + cursor: pointer; +} + +.studio-recorder-floating-output:hover .studio-recorder-floating-output-actions, +.studio-recorder-floating-output:focus-within + .studio-recorder-floating-output-actions { + opacity: 1; +} + +.studio-recorder-floating-output-action:hover { + color: #0d0d0d; +} + +.studio-recorder-output-tooltip .ant-tooltip-inner { + min-height: 0; + border-radius: 6px; + background: #000; + padding: 6px 12px; + color: #fff; + font-family: Inter, sans-serif; + font-size: 13px; + font-weight: 400; + line-height: 18px; +} + +.studio-recorder-output-tooltip .ant-tooltip-arrow-content { + background: #000; +} + +.studio-recorder-floating-output-generating { + gap: 8px; + justify-content: flex-start; + background: transparent; + cursor: default; +} + +.studio-recorder-floating-output-generating:hover { + background: transparent; +} + +.studio-recorder-floating-output-generating svg { + width: 16px; + height: 16px; + flex: 0 0 16px; +} + +.studio-recorder-floating-output-generating span { + min-width: 0; + overflow: hidden; + background-image: linear-gradient(92.8202deg, #0066ff 0%, #7b02c5 56.414%); + background-clip: text; + -webkit-background-clip: text; + text-overflow: ellipsis; + white-space: nowrap; + color: transparent; + font-weight: 500; +} diff --git a/apps/studio/src/renderer/components/SettingsDock/index.tsx b/apps/studio/src/renderer/components/SettingsDock/index.tsx index a415438839..988c6f3b1b 100644 --- a/apps/studio/src/renderer/components/SettingsDock/index.tsx +++ b/apps/studio/src/renderer/components/SettingsDock/index.tsx @@ -27,7 +27,6 @@ interface DockRowProps { icon: React.ReactNode; label: string; onClick?: () => void; - showDot?: boolean; title?: string; } @@ -37,14 +36,15 @@ function DockRow({ icon, label, onClick, - showDot, title, }: DockRowProps) { return (