diff --git a/.gitignore b/.gitignore index edb896aa..213a9111 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Personal planning docs (reagan_ prefix per CLAUDE.md) — never committed reagan_* app/reagan_* +app/docs/PRODUCT_*.md # Dependencies node_modules/ diff --git a/app/forge.config.ts b/app/forge.config.ts index 5f12696f..6bafbde4 100644 --- a/app/forge.config.ts +++ b/app/forge.config.ts @@ -55,6 +55,15 @@ const runNpm = (args: string[], cwd: string): void => { execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, { cwd, stdio: 'inherit' }); }; +function isViteOutputPath(file: string): boolean { + const normalized = file.split(path.sep).join('/'); + if (normalized === '/.vite' || normalized.startsWith('/.vite/')) return true; + if (normalized === '.vite' || normalized.startsWith('.vite/')) return true; + if (!path.isAbsolute(file)) return false; + const rel = path.relative(__dirname, file).split(path.sep).join('/'); + return rel === '.vite' || rel.startsWith('.vite/'); +} + // --------------------------------------------------------------------------- // Forge configuration // --------------------------------------------------------------------------- @@ -68,32 +77,13 @@ const config: ForgeConfig = { name: 'Browser Use', executableName: 'browser-use-desktop', - // Exclude dev-only files from the packaged app.asar. Without this, - // Forge ships src/, tests/, personal planning .md files, Vite - // source configs, CI configs, README, etc. — the DMG balloons and - // users get dev notes. Paths arrive with a leading slash. - ignore: [ - /^\/src($|\/)/, // TS source; compiled output is in .vite/build - /^\/tests($|\/)/, - /^\/scripts($|\/)/, - /^\/python($|\/)/, // daemon removed; defensive - /^\/harnessless($|\/)/, - /^\/\.github($|\/)/, - /^\/\.claude($|\/)/, - /^\/\.vscode($|\/)/, - /^\/coverage($|\/)/, - /^\/reagan_.*$/, // personal planning docs - /^\/.*\.md$/, // all markdown (README, docs, etc.) - /^\/tsconfig.*\.json$/, - /^\/eslint\.config\.(ts|mts|js|mjs|cjs)$/, - /^\/vite\..*\.(ts|mts)$/, // dev-time vite configs - /^\/forge\.config\.(ts|js)$/, - /^\/playwright\.config\.(ts|js)$/, - /^\/knip\.json$/, - /^\/\.env(\..+)?$/, // never ship .env to users - /^\/Taskfile\.ya?ml$/, - /^\/.+\.map$/, // sourcemaps - ], + // The Vite plugin expects packaged app contents to come only from .vite. + // Production externals are installed back into the build path in the + // packageAfterPrune hook below, and app-update.yml is copied as a resource. + ignore: (file) => { + if (!file) return false; + return !isViteOutputPath(file); + }, // macOS bundle identity — only set when credentials are available. // Real Developer ID signing only runs via @electron/osx-sign when diff --git a/app/package.json b/app/package.json index 3bdca56e..0318e3a5 100644 --- a/app/package.json +++ b/app/package.json @@ -123,9 +123,11 @@ "dotenv": "^17.4.2", "electron-squirrel-startup": "^1.0.1", "electron-updater": "^6.8.3", + "immer": "^11.1.8", "keytar": "^7.9.0", "node-cache": "^5.1.2", "node-pty": "^1.1.0", + "prism-react-renderer": "^2.4.1", "qrcode": "^1.5.4", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -133,6 +135,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "ws": "^8.20.0", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zustand": "^5.0.13" } } diff --git a/app/src/main/hl/engines/browserHarnessEnv.ts b/app/src/main/hl/engines/browserHarnessEnv.ts index ed1c9628..153fee85 100644 --- a/app/src/main/hl/engines/browserHarnessEnv.ts +++ b/app/src/main/hl/engines/browserHarnessEnv.ts @@ -15,5 +15,10 @@ export function applyBrowserHarnessEnv(ctx: SpawnContext, env: NodeJS.ProcessEnv env.CDP_REPL_PORT = env.CDP_REPL_PORT ?? browserHarnessReplPort(ctx.sessionId, ctx.targetId); env.CDP_REPL_LOG = env.CDP_REPL_LOG ?? path.join(ctx.harnessDir, `browser-harness-js-${ctx.sessionId}.log`); env.BU_SESSION_ID = ctx.sessionId; + // Watched session outputs dir — any file written here triggers a `file_output` + // event in runEngine. The Page.captureScreenshot wrapper in repl.ts auto-saves + // PNGs into this dir so screenshots surface in the chat instead of being + // dumped as base64 into stdout. + env.BU_OUTPUTS_DIR = path.join(ctx.harnessDir, 'outputs', ctx.sessionId); return env; } diff --git a/app/src/main/hl/engines/runEngine.ts b/app/src/main/hl/engines/runEngine.ts index 509eac2f..511fe7fc 100644 --- a/app/src/main/hl/engines/runEngine.ts +++ b/app/src/main/hl/engines/runEngine.ts @@ -13,7 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { engineLogger } from '../../logger'; import { resolveAuth, loadOpenAIKey, loadClaudeSubscriptionType, loadBrowserCodeConfig } from '../../identity/authStore'; -import { helpersPath, skillPath } from '../harness'; +import { helpersPath, skillPath, skillMetaFromPath as resolveSkillMetaFromPath } from '../harness'; import { get as getAdapter } from './registry'; import { spawnCli } from './cliSpawn'; import { registerResourceOwner, unregisterResourceOwner } from '../../resourceMonitor'; @@ -454,12 +454,20 @@ export async function runEngine(opts: RunEngineOptions): Promise { if (!stat.isFile()) return; if (seenOutputs.get(filename) === stat.size) return; seenOutputs.set(filename, stat.size); + const mime = mimeFromExt(filename); + engineLogger.info('engines.run.outputs.fileDetected', { + sessionId: opts.sessionId, + filename, + absPath: filePath, + bytes: stat.size, + mime, + }); opts.onEvent({ type: 'file_output', name: filename, path: filePath, size: stat.size, - mime: mimeFromExt(filename), + mime, }); }); } catch (err) { @@ -484,18 +492,9 @@ export async function runEngine(opts: RunEngineOptions): Promise { // reads. Harness edits are emitted by the file watcher above, using actual // file content as the source of truth instead of provider-specific tool // metadata. + // Shared resolver - keep path <-> skill-id conversion in one place (see harness.ts). function skillMetaFromPath(resolved: string): { domain: string; topic: string } | null { - const rel = path.relative(opts.harnessDir, resolved).split(path.sep).join('/'); - if (rel.startsWith('domain-skills/') && rel.endsWith('.md')) { - return { domain: 'domain', topic: rel.slice('domain-skills/'.length, -'.md'.length) }; - } - if (rel.startsWith('interaction-skills/') && rel.endsWith('.md')) { - return { domain: 'interaction', topic: rel.slice('interaction-skills/'.length, -'.md'.length) }; - } - if (rel.startsWith('skills/') && rel.endsWith('/SKILL.md')) { - return { domain: 'user', topic: rel.slice('skills/'.length, -'/SKILL.md'.length) }; - } - return null; + return resolveSkillMetaFromPath(resolved, opts.harnessDir); } function skillMetaFromSkillId(id: string): { domain: string; topic: string } | null { diff --git a/app/src/main/hl/harness.ts b/app/src/main/hl/harness.ts index 24583a85..a203064b 100644 --- a/app/src/main/hl/harness.ts +++ b/app/src/main/hl/harness.ts @@ -65,6 +65,57 @@ export function browserHarnessJsDir(): string { return path.join(harnessDir(), ' export function agentSkillDir(): string { return path.join(harnessDir(), 'agent-skill'); } export function userSkillsDir(): string { return path.join(harnessDir(), 'skills'); } +/** + * Skill IDs are `/` strings (e.g. `user/fun/page-word-count`, + * `domain/github/repo`, `interaction/cookies`). The on-disk layout differs per + * domain - these helpers are the single source of truth for the conversion so + * the engine post-processor and any IPC handler stay aligned. + */ +export interface SkillId { domain: string; topic: string } + +function sanitizeSkillTopic(rawTopic: string): string | null { + const topic = rawTopic.trim().replace(/^['"]+|['"]+$/g, '').replace(/\.md$/i, '').replace(/\\/g, '/'); + if (!topic || topic.startsWith('/') || path.isAbsolute(rawTopic) || path.isAbsolute(topic)) return null; + const segments = topic.split('/'); + if (segments.some((segment) => !segment || segment === '.' || segment === '..' || /^[A-Za-z]:/.test(segment))) { + return null; + } + return segments.join('/'); +} + +export function skillPathFromMeta(meta: SkillId, rootDir: string = harnessDir()): string | null { + const topic = sanitizeSkillTopic(meta.topic); + if (!topic) return null; + if (meta.domain === 'user') return path.join(rootDir, 'skills', topic, 'SKILL.md'); + if (meta.domain === 'domain') return path.join(rootDir, 'domain-skills', topic + '.md'); + if (meta.domain === 'interaction') return path.join(rootDir, 'interaction-skills', topic + '.md'); + return null; +} + +export function skillMetaFromPath(resolved: string, rootDir: string = harnessDir()): SkillId | null { + const rel = path.relative(rootDir, resolved).split(path.sep).join('/'); + if (rel.startsWith('domain-skills/') && rel.endsWith('.md')) { + return { domain: 'domain', topic: rel.slice('domain-skills/'.length, -'.md'.length) }; + } + if (rel.startsWith('interaction-skills/') && rel.endsWith('.md')) { + return { domain: 'interaction', topic: rel.slice('interaction-skills/'.length, -'.md'.length) }; + } + if (rel.startsWith('skills/') && rel.endsWith('/SKILL.md')) { + return { domain: 'user', topic: rel.slice('skills/'.length, -'/SKILL.md'.length) }; + } + return null; +} + +export function skillIdToPath(skillId: string, rootDir: string = harnessDir()): string | null { + const cleaned = skillId.trim().replace(/^['"]+|['"]+$/g, '').replace(/\\/g, '/'); + if (!cleaned || cleaned.startsWith('--') || cleaned.startsWith('/') || path.isAbsolute(skillId) || path.isAbsolute(cleaned)) return null; + const parts = cleaned.split('/'); + if (parts.some((part) => !part)) return null; + if (parts.length < 2) return null; + const [domain, ...rest] = parts; + return skillPathFromMeta({ domain, topic: rest.join('/') }, rootDir); +} + /** * Ensure `/harness/` exists and contains the stock files. * - Writes helpers.js if missing OR if the on-disk version predates the diff --git a/app/src/main/hl/stock/AGENTS.md b/app/src/main/hl/stock/AGENTS.md index c5cf4318..38719905 100644 --- a/app/src/main/hl/stock/AGENTS.md +++ b/app/src/main/hl/stock/AGENTS.md @@ -185,14 +185,36 @@ Verify after every meaningful browser action: For screenshots: ```bash +# Internal screenshot (for your own vision — not shown to the user): browser-harness-js <<'EOF' await connectToAssignedTarget() const { data } = await session.Page.captureScreenshot({ format: 'png' }) -await Bun.write('/tmp/browser-use-shot.png', Buffer.from(data, 'base64')) -return '/tmp/browser-use-shot.png' +// inspect `data` (base64 PNG) however you need; do NOT save unless the user +// explicitly asked to see the screenshot. +EOF + +# User-facing screenshot (renders inline in the chat): +browser-harness-js <<'EOF' +await connectToAssignedTarget() +const { data } = await session.Page.captureScreenshot({ format: 'png' }) +await Bun.write(`${process.env.BU_OUTPUTS_DIR}/screenshot-${Date.now()}.png`, Buffer.from(data, 'base64')) EOF ``` +**When a screenshot is worth showing the user:** save to `$BU_OUTPUTS_DIR` when +the user genuinely benefits from seeing the page. Use your own judgment — these +are guideposts, not rules: +- Confirming a delegated task finished (a post went up, a message sent, a form + submitted, a checkout completed). +- Mid-progress check-in on a long task, so the user knows you haven't stalled. +- Something unexpected or interesting showed up that's worth flagging visually. +- You're stuck on a captcha, login wall, or page state you can't resolve, and + showing it helps the user see what you see. + +Don't save screenshots you took purely to look at the page yourself (finding +a selector, checking element state, verifying navigation) — those clutter the +chat without giving the user new information. + ## Uploads And Outputs - Uploads from the user appear under `./uploads//`. diff --git a/app/src/main/hl/stock/agent-skill/agent-skill b/app/src/main/hl/stock/agent-skill/agent-skill index 94659050..552329ba 100644 --- a/app/src/main/hl/stock/agent-skill/agent-skill +++ b/app/src/main/hl/stock/agent-skill/agent-skill @@ -292,7 +292,10 @@ function validateEntry(entry) { const errors = []; const warnings = []; if (!entry.title.trim()) errors.push('missing title'); - if (!entry.description.trim() && !String(data.description || '').trim()) errors.push('missing description'); + // Description MUST come from frontmatter - falling back to the first + // paragraph used to mask broken skills and surface raw markdown (H2 hooks, + // code fences) anywhere the description was rendered. + if (!String(data.description || '').trim()) errors.push('missing frontmatter description (add `description: ...` to the front-matter block)'); if (entry.bytes > 100_000) warnings.push('skill is large; move examples/templates/scripts into support files'); if (!/(^|\n)#{1,3}\s+/m.test(entry.content)) warnings.push('no markdown section headings found'); if (!/(when|use|run|step|verify|check|done|success)/i.test(entry.content)) { diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 233a2fa3..16e755ae 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -18,6 +18,10 @@ loadDotEnv({ path: path.resolve(__dirname, '..', '..', '.env') }); import { app, BrowserWindow, crashReporter, globalShortcut, ipcMain, Menu, MenuItemConstructorOptions, nativeImage, shell } from 'electron'; import { mergeChromiumFeature } from './startup/chromiumFeatures'; +import { registerChatfilePrivileges, registerChatfileHandler } from './protocols/chatfile'; + +// Must run before app.whenReady — Electron caches scheme privileges at startup. +registerChatfilePrivileges(); if (process.platform === 'linux') { app.commandLine.appendSwitch( @@ -104,7 +108,7 @@ import { import { assertString, assertAttachments, type ValidatedAttachment } from './ipc-validators'; // Agent loop: CLI subprocess driving the browser harness. Engine is // pluggable (claude-code, codex, …) — see src/main/hl/engines/. -import { bootstrapHarness, harnessDir } from './hl/harness'; +import { bootstrapHarness, harnessDir, skillIdToPath, skillMetaFromPath } from './hl/harness'; import { runEngine, DEFAULT_ENGINE_ID } from './hl/engines'; import type { EngineRunControl } from './hl/engines/types'; import { getEngine, setEngine, type EngineId } from './hl/engine'; @@ -112,6 +116,7 @@ import { forwardAgentEvent } from './pill'; // Session management import { SessionManager } from './sessions/SessionManager'; import { BrowserPool } from './sessions/BrowserPool'; +import { SessionScreencast } from './sessions/SessionScreencast'; import { snapshotResourceUsage, startResourceMonitor, @@ -183,6 +188,7 @@ const sessionManager = new SessionManager(path.join(app.getPath('userData'), 'se // to /harness/ on first run, preserves user edits on subsequent runs. bootstrapHarness(); const browserPool = new BrowserPool(); +const sessionScreencast = new SessionScreencast(browserPool); let interruptBrowserSessionFromShortcut: ((sessionId: string) => boolean) | null = null; const resourceMonitorContext: ResourceMonitorContext = { browserSessions: () => browserPool.getStats().sessions, @@ -190,6 +196,12 @@ const resourceMonitorContext: ResourceMonitorContext = { }; // Push browser-gone notifications to the shell renderer so the UI can stop // showing "Browser starting…" when a WebContents is destroyed or crashes. +browserPool.setOnCreate((sessionId) => { + mainLogger.info('main.sessions.browserAttached', { sessionId }); + if (shellWindow && !shellWindow.isDestroyed()) { + shellWindow.webContents.send('sessions:browser-attached', sessionId); + } +}); browserPool.setOnGone((sessionId) => { if (shellWindow && !shellWindow.isDestroyed()) { shellWindow.webContents.send('sessions:browser-gone', sessionId); @@ -288,6 +300,8 @@ function openShellAndWire(): BrowserWindow { mainLogger.info('main.openShellAndWire', { msg: 'Creating shell window' }); shellWindow = createShellWindow(); + sessionScreencast.setWindow(shellWindow); + shellWindow.on('closed', () => { void sessionScreencast.stopAll(); }); // Create pill window (hidden) and register global hotkey createPillWindow(); @@ -381,6 +395,7 @@ function openShellAndWire(): BrowserWindow { // --------------------------------------------------------------------------- app.whenReady().then(async () => { mainLogger.info('main.appReady', { msg: 'Electron app ready — initializing Browser Use' }); + registerChatfileHandler(); startResourceMonitor(resourceMonitorContext); // Verify the CDP endpoint at our announced port is actually OUR Electron @@ -516,7 +531,8 @@ app.whenReady().then(async () => { hidePill(); - const id = sessionManager.createSession(validatedPrompt); + const initialAttachmentTurnIndex = attachments.length > 0 ? 0 : undefined; + const id = sessionManager.createSession(validatedPrompt, { attachmentTurnIndex: initialAttachmentTurnIndex }); // Stamp the engine so the hub card shows the provider icon. Respect // an explicit engine from the pill payload, else default to the // canonical per-session default. getEngine() returns the legacy @@ -529,7 +545,7 @@ app.whenReady().then(async () => { : DEFAULT_ENGINE_ID; sessionManager.setSessionEngine(id, pillEngineId); if (attachments.length > 0) { - const turnIndex = sessionManager.getNextAttachmentTurnIndex(id); + const turnIndex = initialAttachmentTurnIndex ?? sessionManager.getNextAttachmentTurnIndex(id); for (const a of attachments) { sessionManager.saveAttachment(id, a, turnIndex); } @@ -958,12 +974,13 @@ app.whenReady().then(async () => { } await browserPool.markSessionActive(validatedId); + let attachmentTurnIndex: number | undefined; if (resumeAttachments.length > 0) { - const turnIndex = sessionManager.getNextAttachmentTurnIndex(validatedId); + attachmentTurnIndex = sessionManager.getNextAttachmentTurnIndex(validatedId); for (const a of resumeAttachments) { - sessionManager.saveAttachment(validatedId, a, turnIndex); + sessionManager.saveAttachment(validatedId, a, attachmentTurnIndex); } - mainLogger.info('main.sessions:resume.persistedAttachments', { id: validatedId, turnIndex, count: resumeAttachments.length, source }); + mainLogger.info('main.sessions:resume.persistedAttachments', { id: validatedId, turnIndex: attachmentTurnIndex, count: resumeAttachments.length, source }); } let webContents = browserPool.getWebContents(validatedId); @@ -1001,7 +1018,7 @@ app.whenReady().then(async () => { const engineId = sessionManager.getSessionEngine(validatedId) ?? DEFAULT_ENGINE_ID; await stampConfiguredSessionModel(validatedId, engineId, source); - const abortController = sessionManager.resumeSession(validatedId, validatedPrompt); + const abortController = sessionManager.resumeSession(validatedId, validatedPrompt, { attachmentTurnIndex }); if (resumeAttachments.length > 0) { mainLogger.info('main.sessions:resume.attachments', { id: validatedId, count: resumeAttachments.length, source }); } @@ -1126,7 +1143,7 @@ app.whenReady().then(async () => { engineId, harnessDir: harnessDir(), sessionId: id, - prompt: sessionManager.getSession(id)!.prompt, + prompt: sessionManager.getInitialPrompt(id) ?? sessionManager.getSession(id)!.prompt, attachments: attachmentsForRun.map((a) => ({ name: a.name, mime: a.mime, bytes: a.bytes })), webContents: view.webContents, cdpPort: resolvedCdp.port, @@ -1194,6 +1211,24 @@ app.whenReady().then(async () => { }); }); + // Chat-side browser preview via CDP screencast. Renderer starts/stops per + // mount; we never auto-start so a session without a chat-view consumer + // costs zero CPU. + ipcMain.handle('sessions:preview-start', async (_evt, payload: { id: unknown }) => { + const id = typeof payload?.id === 'string' ? payload.id : ''; + if (!id) return { ok: false, reason: 'bad_id' }; + return sessionScreencast.start(id); + }); + ipcMain.handle('sessions:preview-stop', async (_evt, payload: unknown) => { + const id = typeof payload === 'string' + ? payload + : (payload && typeof payload === 'object' && typeof (payload as { id?: unknown }).id === 'string' + ? (payload as { id: string }).id + : ''); + if (typeof id !== 'string' || !id) return; + await sessionScreencast.stop(id); + }); + ipcMain.handle('sessions:create', (_event, payload: unknown) => { let promptRaw: unknown; let attachmentsRaw: unknown; @@ -1216,10 +1251,11 @@ app.whenReady().then(async () => { engineId, attachmentMeta: attachments.map((a) => ({ name: a.name, mime: a.mime, size: a.bytes.byteLength })), }); - const id = sessionManager.createSession(validatedPrompt); + const initialAttachmentTurnIndex = attachments.length > 0 ? 0 : undefined; + const id = sessionManager.createSession(validatedPrompt, { attachmentTurnIndex: initialAttachmentTurnIndex }); sessionManager.setSessionEngine(id, engineId); if (attachments.length > 0) { - const turnIndex = sessionManager.getNextAttachmentTurnIndex(id); + const turnIndex = initialAttachmentTurnIndex ?? sessionManager.getNextAttachmentTurnIndex(id); for (const a of attachments) { sessionManager.saveAttachment(id, a, turnIndex); } @@ -1271,10 +1307,13 @@ app.whenReady().then(async () => { return resumeSessionWithAgent(validatedId, validatedPrompt, resumeAttachments, 'resume'); }); - ipcMain.handle('sessions:rerun', async (_event, id: string) => { - const validatedId = assertString(id, 'id', 100); + ipcMain.handle('sessions:rerun', async (_event, payload: string | { id?: unknown; prompt?: unknown }) => { + const idRaw = typeof payload === 'string' ? payload : payload?.id; + const promptRaw = typeof payload === 'string' ? undefined : payload?.prompt; + const validatedId = assertString(idRaw, 'id', 100); + const kickoffOverride = promptRaw == null ? undefined : assertString(promptRaw, 'prompt', 10000); const t0 = Date.now(); - mainLogger.info('main.sessions:rerun', { id: validatedId }); + mainLogger.info('main.sessions:rerun', { id: validatedId, edited: kickoffOverride !== undefined }); const session = sessionManager.getSession(validatedId); if (!session) return { error: 'Session not found' }; @@ -1284,7 +1323,8 @@ app.whenReady().then(async () => { const engineId = sessionManager.getSessionEngine(validatedId) ?? DEFAULT_ENGINE_ID; await stampConfiguredSessionModel(validatedId, engineId, 'rerun'); - const abortController = sessionManager.rerunSession(validatedId); + const abortController = sessionManager.rerunSession(validatedId, kickoffOverride); + const kickoffPrompt = sessionManager.getInitialPrompt(validatedId) ?? session.prompt; captureEvent('session_rerun', { engine: engineId, }); @@ -1318,7 +1358,7 @@ app.whenReady().then(async () => { engineId, harnessDir: harnessDir(), sessionId: validatedId, - prompt: session.prompt, + prompt: kickoffPrompt, attachments: rerunAttachments.map((a) => ({ name: a.name, mime: a.mime, bytes: a.bytes })), webContents: view.webContents, cdpPort: resolvedCdp.port, @@ -1489,14 +1529,123 @@ app.whenReady().then(async () => { return { ...result, installed }; }); + // Read a skill file by domain/topic (e.g. "user/fun/page-word-count") OR by + // absolute path under the harness dir. Returns light metadata (title from the + // first H1 or frontmatter `name`, description from frontmatter `description`) + // plus the raw body capped at 64 KB so the renderer can + // expand a SkillCard inline without a full file viewer. + ipcMain.handle('sessions:read-skill', async (_event, payload: { domainTopic?: string; absPath?: string }) => { + const MAX_BYTES = 64 * 1024; + const domainTopic = typeof payload?.domainTopic === 'string' ? payload.domainTopic.trim() : ''; + const absPathIn = typeof payload?.absPath === 'string' ? payload.absPath.trim() : ''; + + // Resolution delegates to the shared `skillIdToPath` in harness.ts - that + // helper knows the on-disk layout (user skills are dirs containing SKILL.md; + // domain/interaction skills are flat .md files). When the caller already + // has an absolute path we trust it as a second candidate. + const candidates: string[] = []; + if (domainTopic) { + const resolved = skillIdToPath(domainTopic); + if (resolved) candidates.push(resolved); + } + if (absPathIn && path.isAbsolute(absPathIn) && absPathIn.endsWith('.md')) { + candidates.push(path.resolve(absPathIn)); + } + + const root = path.resolve(harnessDir()); + let resolved: string | null = null; + for (const c of candidates) { + const r = path.resolve(c); + if (!r.startsWith(root + path.sep)) continue; + try { + const stat = fs.statSync(r); + if (stat.isFile()) { resolved = r; break; } + } catch { + // try next + } + } + if (!resolved) { + mainLogger.info('main.sessions:read-skill.notFound', { domainTopic, absPath: absPathIn, tried: candidates.length }); + return { ok: false, error: 'skill not found' }; + } + + let body: string; + let truncated: boolean; + let sizeBytes: number; + let mtimeMs: number; + try { + const stat = fs.statSync(resolved); + sizeBytes = stat.size; + mtimeMs = stat.mtimeMs; + const fd = fs.openSync(resolved, 'r'); + try { + const buf = Buffer.alloc(Math.min(MAX_BYTES, sizeBytes)); + const n = fs.readSync(fd, buf, 0, buf.length, 0); + body = buf.slice(0, n).toString('utf-8'); + truncated = sizeBytes > buf.length; + } finally { + fs.closeSync(fd); + } + } catch (err) { + mainLogger.warn('main.sessions:read-skill.readFailed', { path: resolved, error: (err as Error).message }); + return { ok: false, error: 'read failed' }; + } + + // Parse optional YAML frontmatter (first --- block). Only `name` and + // `description` are extracted; we intentionally don't pull in a full YAML + // parser for this - skills follow a flat single-line `key: value` convention. + let title = ''; + let description = ''; + let stripped = body; + const fmMatch = body.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (fmMatch) { + const fm = fmMatch[1]; + for (const line of fm.split(/\r?\n/)) { + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*?)\s*$/); + if (!m) continue; + const key = m[1].toLowerCase(); + const value = m[2].replace(/^["']|["']$/g, ''); + if (key === 'name' && !title) title = value; + if (key === 'description' && !description) description = value; + } + stripped = body.slice(fmMatch[0].length); + } + if (!title) { + const h1 = stripped.match(/^#\s+(.+?)\s*$/m); + if (h1) title = h1[1].trim(); + } + // Description comes from frontmatter ONLY. The agent-skill validator now + // enforces that every skill ships with a real `description:` line, so + // there's no longer a body-paragraph fallback - that fallback used to + // surface raw markdown (H2 hooks, code fences) as the skill summary. + + const lineCount = body.split('\n').length; + mainLogger.info('main.sessions:read-skill.ok', { path: resolved, sizeBytes, truncated }); + return { + ok: true, + path: resolved, + filename: path.basename(resolved), + sizeBytes, + mtimeMs, + lineCount, + title, + description, + body, + truncated, + }; + }); + ipcMain.handle('sessions:reveal-output', async (_event, filePath: string) => { const validated = assertString(filePath, 'filePath', 2000); const resolvedPath = path.isAbsolute(validated) ? path.resolve(validated) : path.resolve(harnessDir(), validated); - const outputsRoot = path.resolve(harnessDir(), 'outputs'); - if (!resolvedPath.startsWith(outputsRoot + path.sep)) { - throw new Error('refused: path outside outputs dir'); + const harnessRoot = path.resolve(harnessDir()); + const outputsRoot = path.resolve(harnessRoot, 'outputs'); + const isOutputFile = resolvedPath.startsWith(outputsRoot + path.sep); + const isSkillFile = skillMetaFromPath(resolvedPath, harnessRoot) !== null; + if (!isOutputFile && !isSkillFile) { + throw new Error('refused: path outside outputs or skills dir'); } shell.showItemInFolder(resolvedPath); mainLogger.info('main.sessions:reveal-output', { path: resolvedPath }); @@ -1623,15 +1772,12 @@ app.whenReady().then(async () => { if (!shellWindow) return; const view = browserPool.getView(id); if (!view) return; - const fitted = browserPool.setViewBoundsFitted(id, bounds) ?? bounds; - // (Intentionally no setZoomFactor here — previously we recomputed zoom - // on every resize to fit the emulated viewport, but that clobbered any - // manual zoom the user set via Cmd+=/Cmd+- and felt like the browser - // was "resetting itself" on layout changes.) const children = shellWindow.contentView.children; - if (!children.includes(view)) { - shellWindow.contentView.addChildView(view); + if (!browserPool.isAttached(id) || !children.includes(view)) { + const ok = browserPool.attachToWindow(id, shellWindow, bounds); + if (!ok) return; } + const fitted = browserPool.setViewBoundsFitted(id, bounds) ?? bounds; // Keep takeover overlay tracking the browser rect and sitting above it. // Use the fitted (centered) rect so the overlay aligns with the visible // view, not the wider hub box. diff --git a/app/src/main/protocols/chatfile.ts b/app/src/main/protocols/chatfile.ts new file mode 100644 index 00000000..6c42305c --- /dev/null +++ b/app/src/main/protocols/chatfile.ts @@ -0,0 +1,90 @@ +/** + * `chatfile://` protocol — serves files that live under the harness outputs dir + * so the renderer can `` screenshots and other + * agent-produced media without granting blanket filesystem access. + * + * Security: the requested abs path is canonicalized, then required to live + * under `/outputs/`. Anything else returns 403. Symlink escapes are + * blocked by `fs.realpathSync`. + * + * Register order matters in Electron: `registerSchemesAsPrivileged` MUST run + * before `app.whenReady`, while `protocol.handle` must run after. + */ + +import { protocol, net } from 'electron'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { mainLogger } from '../logger'; +import { harnessDir } from '../hl/harness'; + +export const CHATFILE_SCHEME = 'chatfile'; + +function withTrailingSep(p: string): string { + return p.endsWith(path.sep) ? p : p + path.sep; +} + +export function registerChatfilePrivileges(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: CHATFILE_SCHEME, + privileges: { + // `standard: true` makes the URL parser treat `chatfile://` like http — + // so `chatfile:///abs/path` reliably parses with hostname="" and + // pathname="/abs/path" rather than the looser opaque-path semantics + // non-standard schemes get. + standard: true, + secure: true, + supportFetchAPI: true, + bypassCSP: false, + stream: true, + }, + }, + ]); +} + +export function registerChatfileHandler(): void { + const configuredRoot = path.resolve(path.join(harnessDir(), 'outputs')); + let canonicalRoot: string | null = null; + + const getRoot = (): string => { + if (canonicalRoot) return canonicalRoot; + try { + canonicalRoot = withTrailingSep(fs.realpathSync(configuredRoot)); + } catch { + canonicalRoot = withTrailingSep(configuredRoot); + } + return canonicalRoot; + }; + + protocol.handle(CHATFILE_SCHEME, async (req) => { + let absPath: string; + try { + const url = new URL(req.url); + absPath = decodeURIComponent(url.pathname); + } catch (err) { + mainLogger.warn('chatfile.badUrl', { url: req.url, error: (err as Error).message }); + return new Response('bad url', { status: 400 }); + } + + let realPath: string; + try { + realPath = fs.realpathSync(absPath); + } catch (err) { + mainLogger.warn('chatfile.notFound', { url: req.url, absPath, error: (err as Error).message }); + return new Response('not found', { status: 404 }); + } + + const root = getRoot(); + if (!realPath.startsWith(root)) { + mainLogger.warn('chatfile.deniedOutsideRoot', { requested: absPath, realPath, root }); + return new Response('forbidden', { status: 403 }); + } + + // Use pathToFileURL so paths containing spaces ("Application Support") and + // other URL-significant chars get encoded correctly. + return net.fetch(pathToFileURL(realPath).toString()); + }); + + mainLogger.info('chatfile.registered', { root: configuredRoot }); +} diff --git a/app/src/main/sessions/BrowserPool.ts b/app/src/main/sessions/BrowserPool.ts index e306b289..39b3bc86 100644 --- a/app/src/main/sessions/BrowserPool.ts +++ b/app/src/main/sessions/BrowserPool.ts @@ -11,6 +11,7 @@ const IDLE_FRAME_RATE = 1; const ACTIVE_FRAME_RATE = 60; const DEFAULT_IDLE_FREEZE_DELAY_MS = 15_000; const CDP_PROTOCOL_VERSION = '1.3'; +const PREVIEW_PARK_VISIBLE_PX = 1; // Edge-to-edge fill. View rect = slot rect, no gutters ever. Page sees // a viewport sized purely by setZoomFactor: window.innerWidth = slot.width // / zoom, window.innerHeight = slot.height / zoom. zoom is pinned so the @@ -19,11 +20,15 @@ const CDP_PROTOCOL_VERSION = '1.3'; // ambiguity about where Chromium positions the rendered page. const EMULATED_VIEWPORT_HEIGHT = 900; +type ViewBounds = { x: number; y: number; width: number; height: number }; + interface PoolEntry { sessionId: string; view: WebContentsView; createdAt: number; attached: boolean; + parked: boolean; + lastVisibleBounds: ViewBounds | null; idleFreezeEligible: boolean; frozen: boolean; freezeTimer: ReturnType | null; @@ -42,6 +47,7 @@ export class BrowserPool { private maxConcurrent: number; private queue: string[] = []; private onGone?: (sessionId: string) => void; + private onCreate?: (sessionId: string) => void; private onNavigate?: (sessionId: string, url: string) => void; private onInterruptShortcut?: (sessionId: string) => boolean | void; private idleFreezeDelayMs: number; @@ -71,6 +77,14 @@ export class BrowserPool { this.onGone = listener; } + /** Register a listener that fires whenever a new WebContentsView is created + * for a session — used by main to push `sessions:browser-attached` IPC so + * the renderer flips `hasBrowser` to true mid-session without waiting for + * the next listAll. */ + setOnCreate(listener: (sessionId: string) => void): void { + this.onCreate = listener; + } + /** Register a listener that fires on every top-frame navigation (including * in-page hash/pushState). Used by SessionManager to keep session.primarySite * in sync with the actual browser — the source of truth, not tool-call args. */ @@ -234,6 +248,8 @@ export class BrowserPool { view, createdAt: startupStartedAt, attached: false, + parked: false, + lastVisibleBounds: null, idleFreezeEligible: false, frozen: false, freezeTimer: null, @@ -241,6 +257,13 @@ export class BrowserPool { this.entries.set(sessionId, entry); + // Notify subscribers (main wires this to a `sessions:browser-attached` + // IPC so the renderer flips `hasBrowser` to true the moment the view + // appears, without waiting for the next listAll snapshot). + try { this.onCreate?.(sessionId); } catch (err) { + browserLogger.warn('BrowserPool.onCreate.error', { sessionId, error: (err as Error).message }); + } + // Fire onGone if the renderer process crashes, closes, or otherwise dies // out-of-band so the UI can react (stop showing "Browser starting…"). const wc = view.webContents; @@ -563,7 +586,7 @@ export class BrowserPool { * wide). zoom alone is enough — no device emulation. The page renders * at exactly bounds.width x bounds.height physical pixels. */ private fitBoundsToView( - bounds: { x: number; y: number; width: number; height: number }, + bounds: ViewBounds, ): { x: number; y: number; width: number; height: number; zoom: number } { const zoom = Math.max(0.25, bounds.height / EMULATED_VIEWPORT_HEIGHT); return { @@ -575,20 +598,49 @@ export class BrowserPool { }; } + private rememberVisibleBounds(entry: PoolEntry, bounds: ViewBounds): void { + if (entry.parked) return; + if (bounds.width <= 0 || bounds.height <= 0) return; + entry.lastVisibleBounds = { ...bounds }; + } + + private ensureChildView(window: BrowserWindow, view: WebContentsView): void { + if (!window.contentView.children.includes(view)) { + window.contentView.addChildView(view); + } + } + + private getPreviewParkBounds(window: BrowserWindow, width: number, height: number): ViewBounds { + const fallback = { width: DEFAULT_BROWSER_WIDTH, height: DEFAULT_BROWSER_HEIGHT }; + const contentBounds = typeof window.getContentBounds === 'function' + ? window.getContentBounds() + : fallback; + const contentWidth = Math.max(PREVIEW_PARK_VISIBLE_PX, contentBounds.width || fallback.width); + const contentHeight = Math.max(PREVIEW_PARK_VISIBLE_PX, contentBounds.height || fallback.height); + return { + x: contentWidth - PREVIEW_PARK_VISIBLE_PX, + y: contentHeight - PREVIEW_PARK_VISIBLE_PX, + width, + height, + }; + } + /** Public helper for the resize fast path: applies the same fit logic as * attach so the rendered page stays edge-to-edge as the hub layout * changes. Returns the fitted rect, or null if the view doesn't exist. */ - setViewBoundsFitted(sessionId: string, bounds: { x: number; y: number; width: number; height: number }): { x: number; y: number; width: number; height: number } | null { + setViewBoundsFitted(sessionId: string, bounds: ViewBounds): { x: number; y: number; width: number; height: number } | null { const entry = this.entries.get(sessionId); if (!entry) return null; if (!Number.isFinite(bounds.width) || !Number.isFinite(bounds.height) || bounds.width <= 0 || bounds.height <= 0) return null; const fitted = this.fitBoundsToView(bounds); entry.view.setBounds({ x: fitted.x, y: fitted.y, width: fitted.width, height: fitted.height }); try { entry.view.webContents.setZoomFactor(fitted.zoom); } catch { /* ignore */ } + entry.parked = false; + this.rememberVisibleBounds(entry, { x: fitted.x, y: fitted.y, width: fitted.width, height: fitted.height }); return { x: fitted.x, y: fitted.y, width: fitted.width, height: fitted.height }; } - attachToWindow(sessionId: string, window: BrowserWindow, bounds: { x: number; y: number; width: number; height: number }): boolean { + attachToWindow(sessionId: string, window: BrowserWindow, bounds: ViewBounds): boolean { const entry = this.entries.get(sessionId); if (!entry) { browserLogger.warn('BrowserPool.attach.notFound', { sessionId }); @@ -615,14 +667,21 @@ export class BrowserPool { if (entry.attached) { browserLogger.debug('BrowserPool.attach.alreadyAttached', { sessionId }); + this.ensureChildView(window, entry.view); entry.view.setBounds({ x: fitted.x, y: fitted.y, width: fitted.width, height: fitted.height }); try { entry.view.webContents.setZoomFactor(fitted.zoom); } catch { /* ignore */ } + entry.parked = false; + this.rememberVisibleBounds(entry, { x: fitted.x, y: fitted.y, width: fitted.width, height: fitted.height }); + void this.wakeForVisibility(entry, 'attach'); + this.applyFrameRate(entry); return true; } entry.view.setBounds({ x: fitted.x, y: fitted.y, width: fitted.width, height: fitted.height }); - window.contentView.addChildView(entry.view); + this.ensureChildView(window, entry.view); entry.attached = true; + entry.parked = false; + this.rememberVisibleBounds(entry, { x: fitted.x, y: fitted.y, width: fitted.width, height: fitted.height }); void this.wakeForVisibility(entry, 'attach'); this.applyFrameRate(entry); @@ -660,6 +719,7 @@ export class BrowserPool { window.contentView.removeChildView(entry.view); entry.attached = false; + entry.parked = false; this.applyFrameRate(entry); this.scheduleIdleFreeze(entry, 'detached'); @@ -682,9 +742,18 @@ export class BrowserPool { } temporarilyDetachAll(window: BrowserWindow): void { + let parked = 0; for (const entry of this.entries.values()) { if (entry.attached) { - window.contentView.removeChildView(entry.view); + this.ensureChildView(window, entry.view); + const current = entry.view.getBounds(); + this.rememberVisibleBounds(entry, current); + const stableBounds = entry.lastVisibleBounds ?? current; + const width = Math.max(1, stableBounds.width || DEFAULT_BROWSER_WIDTH); + const height = Math.max(1, stableBounds.height || DEFAULT_BROWSER_HEIGHT); + entry.view.setBounds(this.getPreviewParkBounds(window, width, height)); + entry.parked = true; + parked += 1; try { entry.view.webContents.setFrameRate(entry.idleFreezeEligible ? IDLE_FRAME_RATE : THROTTLED_FRAME_RATE); } catch (err) { @@ -695,18 +764,84 @@ export class BrowserPool { } } } - browserLogger.info('BrowserPool.temporarilyDetachAll'); + browserLogger.info('BrowserPool.temporarilyDetachAll', { parked }); + } + + async parkForPreview(sessionId: string, window: BrowserWindow): Promise<{ ok: boolean; parkedByUs: boolean; reason?: string }> { + const entry = this.entries.get(sessionId); + if (!entry) { + browserLogger.warn('BrowserPool.parkForPreview.notFound', { sessionId }); + return { ok: false, parkedByUs: false, reason: 'not_found' }; + } + if (entry.view.webContents.isDestroyed()) { + browserLogger.warn('BrowserPool.parkForPreview.destroyed', { sessionId }); + return { ok: false, parkedByUs: false, reason: 'destroyed' }; + } + + const parkedByUs = !entry.attached; + this.ensureChildView(window, entry.view); + const current = entry.view.getBounds(); + this.rememberVisibleBounds(entry, current); + const stableBounds = entry.lastVisibleBounds ?? current; + const width = Math.max(1, stableBounds.width || DEFAULT_BROWSER_WIDTH); + const height = Math.max(1, stableBounds.height || DEFAULT_BROWSER_HEIGHT); + entry.view.setBounds(this.getPreviewParkBounds(window, width, height)); + entry.attached = true; + entry.parked = true; + this.clearIdleFreezeTimer(entry); + await this.wakeForVisibility(entry, 'preview'); + try { + entry.view.webContents.setFrameRate(entry.idleFreezeEligible ? IDLE_FRAME_RATE : THROTTLED_FRAME_RATE); + } catch (err) { + browserLogger.warn('BrowserPool.parkForPreview.frameRate.error', { + sessionId, + error: (err as Error).message, + }); + } + browserLogger.info('BrowserPool.parkForPreview', { sessionId, parkedByUs, width, height, bounds: entry.view.getBounds() }); + return { ok: true, parkedByUs }; + } + + releasePreviewParking(sessionId: string, window: BrowserWindow | null): void { + const entry = this.entries.get(sessionId); + if (!entry || !entry.attached || !entry.parked) return; + + if (window && !window.isDestroyed()) { + try { + window.contentView.removeChildView(entry.view); + } catch (err) { + browserLogger.warn('BrowserPool.releasePreviewParking.removeError', { + sessionId, + error: (err as Error).message, + }); + } + } + entry.attached = false; + entry.parked = false; + this.applyFrameRate(entry); + this.scheduleIdleFreeze(entry, 'preview-stopped'); + browserLogger.info('BrowserPool.releasePreviewParking', { + sessionId, + frameRate: this.frameRateFor(entry), + idleFreezeEligible: entry.idleFreezeEligible, + }); } reattachAll(window: BrowserWindow): void { + let reattached = 0; for (const entry of this.entries.values()) { if (entry.attached) { - window.contentView.addChildView(entry.view); + this.ensureChildView(window, entry.view); + if (entry.parked && entry.lastVisibleBounds) { + entry.view.setBounds(entry.lastVisibleBounds); + } + entry.parked = false; void this.wakeForVisibility(entry, 'reattach'); this.applyFrameRate(entry); + reattached += 1; } } - browserLogger.info('BrowserPool.reattachAll'); + browserLogger.info('BrowserPool.reattachAll', { reattached }); } async getTabs(sessionId: string): Promise { diff --git a/app/src/main/sessions/SessionDb.ts b/app/src/main/sessions/SessionDb.ts index ab8c691c..4a9e101c 100644 --- a/app/src/main/sessions/SessionDb.ts +++ b/app/src/main/sessions/SessionDb.ts @@ -55,12 +55,14 @@ export class SessionDb { getEvents: Database.Statement; getEventsAfter: Database.Statement; getEventCount: Database.Statement; + getFirstUserInput: Database.Statement; recoverCrashed: Database.Statement; recoverIdle: Database.Statement; insertAttachment: Database.Statement; getAttachmentsMeta: Database.Statement; getAttachmentBytes: Database.Statement; getLatestTurnAttachments: Database.Statement; + getAttachmentsByTurnIndex: Database.Statement; getAttachmentTotalSize: Database.Statement; getNextTurnIndex: Database.Statement; getAttachmentCount: Database.Statement; @@ -145,6 +147,9 @@ export class SessionDb { 'SELECT payload FROM session_events WHERE session_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?' ), getEventCount: this.db.prepare('SELECT COUNT(*) as cnt FROM session_events WHERE session_id = ?'), + getFirstUserInput: this.db.prepare( + "SELECT payload FROM session_events WHERE session_id = ? AND type = 'user_input' ORDER BY seq ASC LIMIT 1" + ), recoverCrashed: this.db.prepare( "UPDATE sessions SET status = 'stopped', error = ?, updated_at = ? WHERE status IN ('running', 'stuck')" ), @@ -166,6 +171,9 @@ export class SessionDb { SELECT COALESCE(MAX(turn_index), 0) FROM session_attachments WHERE session_id = ? ) ORDER BY id ASC` ), + getAttachmentsByTurnIndex: this.db.prepare( + 'SELECT id, name, mime, bytes, size, turn_index FROM session_attachments WHERE session_id = ? AND turn_index = ? ORDER BY id ASC' + ), getAttachmentTotalSize: this.db.prepare( 'SELECT COALESCE(SUM(size), 0) AS total FROM session_attachments WHERE session_id = ?' ), @@ -643,6 +651,22 @@ export class SessionDb { return row.cnt; } + getFirstUserInputText(sessionId: string): string | null { + const row = this.stmts.getFirstUserInput.get(sessionId) as { payload: string } | undefined; + if (!row) return null; + try { + const event = JSON.parse(row.payload) as Partial; + return event.type === 'user_input' && typeof event.text === 'string' ? event.text : null; + } catch (err) { + mainLogger.error('SessionDb.getFirstUserInputText.parseFailed', { + sessionId, + payload: row.payload.slice(0, 100), + error: (err as Error).message, + }); + return null; + } + } + // -- Attachments ---------------------------------------------------------- getNextTurnIndex(sessionId: string): number { @@ -696,6 +720,17 @@ export class SessionDb { return rows; } + getAttachmentsByTurnIndex(sessionId: string, turnIndex: number): Array<{ id: number; name: string; mime: string; bytes: Buffer; size: number; turn_index: number }> { + const rows = this.stmts.getAttachmentsByTurnIndex.all(sessionId, turnIndex) as Array<{ id: number; name: string; mime: string; bytes: Buffer; size: number; turn_index: number }>; + mainLogger.info('SessionDb.getAttachmentsByTurnIndex', { + sessionId, + count: rows.length, + turnIndex, + totalBytes: rows.reduce((a, r) => a + r.size, 0), + }); + return rows; + } + getAttachmentTotalSize(sessionId: string): number { const row = this.stmts.getAttachmentTotalSize.get(sessionId) as { total: number }; return row.total ?? 0; diff --git a/app/src/main/sessions/SessionManager.ts b/app/src/main/sessions/SessionManager.ts index 51b2db8d..4d0db1f0 100644 --- a/app/src/main/sessions/SessionManager.ts +++ b/app/src/main/sessions/SessionManager.ts @@ -16,6 +16,9 @@ export type { AgentSession, SessionStatus, SessionEvents }; const STUCK_TIMEOUT_MS = 30_000; +type UserInputEvent = Extract; +type AttachmentRow = { id: number; name: string; mime: string; bytes: Buffer; size: number; turn_index: number }; + function isRestorableUrl(url: string): boolean { if (!url || typeof url !== 'string') return false; try { @@ -122,6 +125,8 @@ export class SessionManager extends EventEmitter { const events = this.db.getEvents(id); if (events.length > 0) { session.output = events; + const kickoff = this.firstUserInput(session)?.text; + if (kickoff) session.prompt = kickoff; mainLogger.info('SessionManager.hydrateOutput', { id, eventCount: events.length }); } this.hydratedOutputs.add(id); @@ -137,9 +142,63 @@ export class SessionManager extends EventEmitter { return this.on(event, listener as (...args: unknown[]) => void); } + private createUserInputEvent(text: string, attachmentTurnIndex?: number): UserInputEvent { + const event: UserInputEvent = { type: 'user_input', text }; + if (attachmentTurnIndex !== undefined) { + event.attachmentTurnIndex = attachmentTurnIndex; + } + return event; + } + + private firstUserInput(session: AgentSession): UserInputEvent | undefined { + return session.output.find((event): event is UserInputEvent => event.type === 'user_input'); + } + + private appendUserInputToLog( + id: string, + text: string, + opts: { emit?: boolean; attachmentTurnIndex?: number } = {}, + ): UserInputEvent | null { + const session = this.sessions.get(id); + if (!session) { + mainLogger.warn('SessionManager.appendUserInputToLog', { id, reason: 'not_found' }); + return null; + } + const event = this.createUserInputEvent(text, opts.attachmentTurnIndex); + session.output.push(event); + const seq = session.output.length - 1; + this.db.appendEvent(id, seq, event); + mainLogger.info('SessionManager.appendOutput.event', { + id, + seq, + type: event.type, + engine: session.engine ?? this.getSessionEngine(id), + model: session.model ?? null, + detail: this.describeEventForLog(event), + }); + if (opts.emit !== false) { + this.emitEvent('session-output', id, event); + this.emitTermBytes(id, event); + } + return event; + } + + getInitialPrompt(id: string): string | undefined { + const session = this.sessions.get(id); + if (!session) return undefined; + this.hydrateOutput(id); + return this.firstUserInput(session)?.text ?? (session.prompt || undefined); + } + + private getSnapshotPrompt(session: AgentSession): string { + return this.firstUserInput(session)?.text + ?? this.db.getFirstUserInputText(session.id) + ?? session.prompt; + } + // -- public API ----------------------------------------------------------- - createSession(prompt: string, opts?: { originChannel?: string; originConversationId?: string }): string { + createSession(prompt: string, opts?: { originChannel?: string; originConversationId?: string; attachmentTurnIndex?: number }): string { const id = randomUUID(); const now = Date.now(); const session: AgentSession = { @@ -153,6 +212,7 @@ export class SessionManager extends EventEmitter { }; this.sessions.set(id, session); this.db.insertSession({ id, prompt, status: 'draft', createdAt: now, originChannel: opts?.originChannel, originConversationId: opts?.originConversationId }); + this.appendUserInputToLog(id, prompt, { emit: false, attachmentTurnIndex: opts?.attachmentTurnIndex }); mainLogger.info('SessionManager.createSession', { id, promptLength: prompt.length, originChannel: opts?.originChannel ?? null }); this.emitEvent('session-created', { ...session }); return id; @@ -171,6 +231,7 @@ export class SessionManager extends EventEmitter { throw new Error(`Session ${id} is ${session.status}, expected draft or idle`); } + const resumed = session.status === 'idle'; session.status = 'running'; this.db.updateSessionStatus(id, 'running'); const abortController = new AbortController(); @@ -178,17 +239,13 @@ export class SessionManager extends EventEmitter { this.resetStuckTimer(id); - // Emit the initial prompt as a user_input term event so a freshly-mounted - // xterm sees the user's message at the top of the live stream. It isn't - // persisted as an HlEvent (session.prompt already holds it), and replay - // synthesizes it from session.prompt in getTermReplay(). if (session.output.length === 0 && session.prompt) { - this.emitTermBytes(id, { type: 'user_input', text: session.prompt }); + this.appendUserInputToLog(id, session.prompt, { emit: false }); } mainLogger.info('SessionManager.startSession', { id, - resumed: session.output.length > 0, + resumed, engine: session.engine ?? this.getSessionEngine(id), model: session.model ?? null, }); @@ -375,8 +432,10 @@ export class SessionManager extends EventEmitter { if (!session) return ''; this.hydrateOutput(id); const events: HlEvent[] = []; - if (session.prompt) events.push({ type: 'user_input', text: session.prompt }); events.push(...session.output); + if (events.length === 0 && session.prompt) { + events.push({ type: 'user_input', text: session.prompt }); + } return eventsToTermBytes(events); } @@ -431,7 +490,7 @@ export class SessionManager extends EventEmitter { this.emitEvent('session-completed', { ...session }); } - resumeSession(id: string, prompt: string): AbortController { + resumeSession(id: string, prompt: string, opts: { attachmentTurnIndex?: number } = {}): AbortController { const session = this.sessions.get(id); if (!session) { throw new Error(`Session not found: ${id}`); @@ -441,17 +500,10 @@ export class SessionManager extends EventEmitter { } this.hydrateOutput(id); - const userEvent: HlEvent = { type: 'user_input', text: prompt }; - session.output.push(userEvent); - const seq = session.output.length - 1; - this.db.appendEvent(id, seq, userEvent); - this.emitEvent('session-output', id, userEvent); - this.emitTermBytes(id, userEvent); + this.appendUserInputToLog(id, prompt, { attachmentTurnIndex: opts.attachmentTurnIndex }); - session.prompt = prompt; session.status = 'running'; session.error = undefined; - this.db.updateSessionPrompt(id, prompt); this.db.updateSessionStatus(id, 'running'); const abortController = new AbortController(); this.abortControllers.set(id, abortController); @@ -493,14 +545,22 @@ export class SessionManager extends EventEmitter { mainLogger.info('SessionManager.deleteSession', { id }); } - rerunSession(id: string): AbortController { + rerunSession(id: string, kickoffOverride?: string): AbortController { const session = this.sessions.get(id); if (!session) throw new Error(`Session not found: ${id}`); + this.hydrateOutput(id); const ctrl = this.abortControllers.get(id); if (ctrl) { ctrl.abort(); this.abortControllers.delete(id); } this.clearStuckTimer(id); + const originalUserInput = this.firstUserInput(session); + const nextPrompt = kickoffOverride ?? originalUserInput?.text ?? session.prompt; + const attachmentTurnIndex = originalUserInput?.attachmentTurnIndex; + if (session.prompt !== nextPrompt) { + session.prompt = nextPrompt; + this.db.updateSessionPrompt(id, nextPrompt); + } session.output = []; session.error = undefined; session.status = 'running'; @@ -521,14 +581,10 @@ export class SessionManager extends EventEmitter { this.abortControllers.set(id, abortController); this.resetStuckTimer(id); - // After clearing the terminal (`\x1bc`), re-emit the user prompt so the - // rerun starts with the user's message visible at the top. - if (session.prompt) { - this.emitTermBytes(id, { type: 'user_input', text: session.prompt }); - } - - mainLogger.info('SessionManager.rerunSession', { id, promptLength: session.prompt.length }); this.emitEvent('session-updated', { ...session }); + this.appendUserInputToLog(id, nextPrompt, { attachmentTurnIndex }); + + mainLogger.info('SessionManager.rerunSession', { id, promptLength: nextPrompt.length }); return abortController; } @@ -552,10 +608,17 @@ export class SessionManager extends EventEmitter { return this.db.getAttachmentsMeta(sessionId); } - // For rerun / start: only the latest turn's attachments are replayed, because - // `session.prompt` is updated to the latest follow-up prompt on resume. Older - // turns' attachments are already represented textually in priorMessages. - loadAttachmentsForRun(sessionId: string): Array<{ id: number; name: string; mime: string; bytes: Buffer; size: number; turn_index: number }> { + loadAttachmentsForRun(sessionId: string): AttachmentRow[] { + const session = this.sessions.get(sessionId); + if (session) { + this.hydrateOutput(sessionId); + const kickoff = this.firstUserInput(session); + if (kickoff) { + return kickoff.attachmentTurnIndex === undefined + ? [] + : this.db.getAttachmentsByTurnIndex(sessionId, kickoff.attachmentTurnIndex); + } + } return this.db.getLatestTurnAttachments(sessionId); } @@ -583,14 +646,14 @@ export class SessionManager extends EventEmitter { const session = this.sessions.get(id); if (!session) return undefined; this.hydrateOutput(id); - return { ...session }; + return { ...session, prompt: this.getSnapshotPrompt(session) }; } getResourceInfo(id: string): { prompt: string; status: SessionStatus; engine: string | null } | undefined { const session = this.sessions.get(id); if (!session) return undefined; return { - prompt: session.prompt, + prompt: this.getSnapshotPrompt(session), status: session.status, engine: session.engine ?? this.getSessionEngine(id), }; @@ -607,7 +670,7 @@ export class SessionManager extends EventEmitter { }); return list .sort((a, b) => b.createdAt - a.createdAt) - .map((s) => ({ ...s, output: [] })); + .map((s) => ({ ...s, prompt: this.getSnapshotPrompt(s), output: [] })); } /** Store the provider conversation id reported by the engine stream. */ @@ -753,7 +816,7 @@ export class SessionManager extends EventEmitter { case 'notify': return { level: event.level, messageLength: event.message.length }; case 'user_input': - return { textLength: event.text.length }; + return { textLength: event.text.length, attachmentTurnIndex: event.attachmentTurnIndex ?? null }; case 'thinking': return { textLength: event.text.length }; } diff --git a/app/src/main/sessions/SessionScreencast.ts b/app/src/main/sessions/SessionScreencast.ts new file mode 100644 index 00000000..92d70de1 --- /dev/null +++ b/app/src/main/sessions/SessionScreencast.ts @@ -0,0 +1,215 @@ +import type { BrowserWindow, Debugger, WebContents } from 'electron'; +import { mainLogger } from '../logger'; +import type { BrowserPool } from './BrowserPool'; + +const CDP_PROTOCOL_VERSION = '1.3'; + +type PreviewFormat = 'jpeg' | 'png'; + +interface PreviewOptions { + format: PreviewFormat; + quality: number; + intervalMs: number; +} + +const DEFAULT_OPTIONS: PreviewOptions = { + format: 'jpeg', + quality: 55, + intervalMs: 1000, +}; +const CAPTURE_TIMEOUT_MS = 2500; + +interface ActivePreview { + wc: WebContents; + dbg: Debugger; + options: PreviewOptions; + timer: NodeJS.Timeout; + attachedByUs: boolean; + inFlight: boolean; + stopped: boolean; + framesSent: number; + lastFrameLogAt: number; + parkedByUs: boolean; +} + +type CaptureScreenshotParams = { + format: PreviewFormat; + quality?: number; + captureBeyondViewport: boolean; + fromSurface: boolean; +}; + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`capture_timeout_${timeoutMs}ms`)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +export class SessionScreencast { + private readonly previews = new Map(); + private readonly pool: BrowserPool; + private window: BrowserWindow | null = null; + + constructor(pool: BrowserPool) { + this.pool = pool; + } + + setWindow(win: BrowserWindow | null): void { + this.window = win; + } + + async start(sessionId: string, opts: Partial = {}): Promise<{ ok: boolean; reason?: string }> { + if (this.previews.has(sessionId)) return { ok: true }; + + const previewWindow = this.window && !this.window.isDestroyed() ? this.window : null; + const parking = previewWindow ? await this.pool.parkForPreview(sessionId, previewWindow) : { ok: true, parkedByUs: false }; + if (!parking.ok) return { ok: false, reason: parking.reason ?? 'park_failed' }; + + const wc = this.pool.getWebContents(sessionId); + if (!wc || wc.isDestroyed()) { + if (parking.parkedByUs && previewWindow) this.pool.releasePreviewParking(sessionId, previewWindow); + return { ok: false, reason: 'no_view' }; + } + + const dbg = wc.debugger; + const wasAttached = dbg.isAttached(); + if (!wasAttached) { + try { + dbg.attach(CDP_PROTOCOL_VERSION); + } catch (err) { + mainLogger.warn('SessionScreencast.start.attachFailed', { sessionId, error: (err as Error).message }); + if (parking.parkedByUs && previewWindow) this.pool.releasePreviewParking(sessionId, previewWindow); + return { ok: false, reason: 'attach_failed' }; + } + } + + if (this.previews.has(sessionId)) { + if (parking.parkedByUs && previewWindow) this.pool.releasePreviewParking(sessionId, previewWindow); + return { ok: true }; + } + + const options = { ...DEFAULT_OPTIONS, ...opts }; + const preview: ActivePreview = { + wc, + dbg, + options, + attachedByUs: !wasAttached, + timer: setInterval(() => { + void this.capture(sessionId); + }, options.intervalMs), + inFlight: false, + stopped: false, + framesSent: 0, + lastFrameLogAt: 0, + parkedByUs: parking.parkedByUs, + }; + + this.previews.set(sessionId, preview); + void this.capture(sessionId); + + mainLogger.info('SessionScreencast.start.ok', { + sessionId, + intervalMs: options.intervalMs, + format: options.format, + attachedByUs: preview.attachedByUs, + }); + return { ok: true }; + } + + async stop(sessionId: string): Promise { + const preview = this.previews.get(sessionId); + if (!preview) return; + + this.previews.delete(sessionId); + preview.stopped = true; + clearInterval(preview.timer); + if (!preview.inFlight) this.cleanupPreview(sessionId, preview); + + mainLogger.info('SessionScreencast.stop.ok', { + sessionId, + framesSent: preview.framesSent, + }); + } + + async stopAll(): Promise { + await Promise.all(Array.from(this.previews.keys()).map((id) => this.stop(id))); + } + + isActive(sessionId: string): boolean { + return this.previews.has(sessionId); + } + + private async capture(sessionId: string): Promise { + const preview = this.previews.get(sessionId); + if (!preview || preview.inFlight || preview.stopped) return; + + if (preview.wc.isDestroyed()) { + await this.stop(sessionId); + return; + } + + preview.inFlight = true; + try { + const params: CaptureScreenshotParams = { + format: preview.options.format, + captureBeyondViewport: false, + // The browser view is parked with a 1px window intersection so Chromium + // keeps a compositor surface alive without covering the chat UI. + fromSurface: true, + }; + if (preview.options.format === 'jpeg') params.quality = preview.options.quality; + + const result = await withTimeout( + preview.dbg.sendCommand('Page.captureScreenshot', params) as Promise<{ data?: unknown }>, + CAPTURE_TIMEOUT_MS, + ); + if (preview.stopped || this.previews.get(sessionId) !== preview) return; + if (typeof result.data !== 'string' || result.data.length === 0) return; + + preview.framesSent += 1; + const now = Date.now(); + if (preview.framesSent === 1 || now - preview.lastFrameLogAt >= 5000) { + preview.lastFrameLogAt = now; + mainLogger.info('SessionScreencast.frame', { + sessionId, + framesSent: preview.framesSent, + bytes: result.data.length, + }); + } + if (this.window && !this.window.isDestroyed()) { + this.window.webContents.send('session-preview-frame', sessionId, result.data); + } + } catch (err) { + const error = (err as Error).message; + mainLogger.warn(error.startsWith('capture_timeout_') ? 'SessionScreencast.capture.timeout' : 'SessionScreencast.capture.error', { sessionId, error }); + } finally { + preview.inFlight = false; + if (preview.stopped) this.cleanupPreview(sessionId, preview); + } + } + + private cleanupPreview(sessionId: string, preview: ActivePreview): void { + this.detachIfOwned(preview); + if (!preview.parkedByUs) return; + this.pool.releasePreviewParking(sessionId, this.window); + } + + private detachIfOwned(preview: ActivePreview): void { + if (!preview.attachedByUs || preview.wc.isDestroyed() || !preview.dbg.isAttached()) return; + try { + preview.dbg.detach(); + } catch (err) { + mainLogger.debug('SessionScreencast.detach.error', { error: (err as Error).message }); + } + } +} diff --git a/app/src/preload/shell.ts b/app/src/preload/shell.ts index f1eda3b8..d5e6a71b 100644 --- a/app/src/preload/shell.ts +++ b/app/src/preload/shell.ts @@ -228,6 +228,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('sessions:download-output', filePath), revealOutput: (filePath: string): Promise<{ revealed: boolean }> => ipcRenderer.invoke('sessions:reveal-output', filePath), + readSkill: (payload: { domainTopic?: string; absPath?: string }): Promise< + | { ok: true; path: string; filename: string; sizeBytes: number; mtimeMs: number; lineCount: number; title: string; description: string; body: string; truncated: boolean } + | { ok: false; error: string } + > => ipcRenderer.invoke('sessions:read-skill', payload), listEditors: (): Promise> => ipcRenderer.invoke('sessions:list-editors'), openInEditor: (editorId: string, filePath: string): Promise<{ opened: boolean }> => @@ -263,6 +267,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('sessions:resume', { id, prompt, attachments }), rerun: (id: string): Promise<{ rerun?: boolean; error?: string }> => ipcRenderer.invoke('sessions:rerun', id), + editAndRerun: (id: string, prompt: string): Promise<{ rerun?: boolean; error?: string }> => + ipcRenderer.invoke('sessions:rerun', { id, prompt }), + previewStart: (id: string): Promise<{ ok: boolean; reason?: string }> => + ipcRenderer.invoke('sessions:preview-start', { id }), + previewStop: (id: string): Promise => + ipcRenderer.invoke('sessions:preview-stop', id), list: async (): Promise => { const raw = await ipcRenderer.invoke('sessions:list'); return validateSessionList(raw); @@ -393,6 +403,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('sessions:browser-gone', handler); return () => ipcRenderer.removeListener('sessions:browser-gone', handler); }, + sessionBrowserAttached: (cb: (id: string) => void): (() => void) => { + const handler = (_event: unknown, id: string) => { + if (typeof id === 'string') cb(id); + }; + ipcRenderer.on('sessions:browser-attached', handler); + return () => ipcRenderer.removeListener('sessions:browser-attached', handler); + }, sessionOutput: (cb: (id: string, event: HlEvent) => void): (() => void) => { const handler = (_event: unknown, id: string, raw: unknown) => { try { @@ -411,6 +428,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('session-output-term', handler); return () => ipcRenderer.removeListener('session-output-term', handler); }, + sessionPreviewFrame: (cb: (id: string, dataB64: string) => void): (() => void) => { + const handler = (_event: unknown, id: string, dataB64: string) => { + if (typeof id === 'string' && typeof dataB64 === 'string') cb(id, dataB64); + }; + ipcRenderer.on('session-preview-frame', handler); + return () => ipcRenderer.removeListener('session-preview-frame', handler); + }, openSettings: (cb: (payload?: SettingsOpenPayload) => void): (() => void) => { const handler = (_event: unknown, rawPayload?: unknown) => cb(normalizeSettingsOpenPayload(rawPayload)); ipcRenderer.on('open-settings', handler); diff --git a/app/src/renderer/components/base/Toast.tsx b/app/src/renderer/components/base/Toast.tsx index 9d9319cf..912c1f93 100644 --- a/app/src/renderer/components/base/Toast.tsx +++ b/app/src/renderer/components/base/Toast.tsx @@ -3,6 +3,13 @@ * Variants: info | success | warning | error | agent * Renders a stack of toasts via ToastProvider + useToast hook. * No !important, no Inter references. + * + * Usage: + * const toast = useToast(); + * toast.show({ variant: 'success', title: 'Copied to clipboard' }); + * toast.show({ variant: 'error', title: 'Save failed', message: 'Try again.' }); + * const id = toast.show({ variant: 'info', title: 'Uploading...', persistent: true }); + * toast.update(id, { variant: 'success', title: 'Upload complete', persistent: false }); */ import React, { diff --git a/app/src/renderer/components/base/components.css b/app/src/renderer/components/base/components.css index 39b531d8..cf517a1f 100644 --- a/app/src/renderer/components/base/components.css +++ b/app/src/renderer/components/base/components.css @@ -333,36 +333,63 @@ .agb-toast__stack { position: fixed; - bottom: 20px; - right: 20px; + bottom: 24px; + right: 24px; z-index: var(--z-toast); display: flex; flex-direction: column; - gap: 8px; + gap: 10px; pointer-events: none; + max-width: 400px; } +/* Toast: frosted-glass panel matching the app's elevated surfaces. + Layered shadow = depth (drop) + ambient ring + variant-tinted glow. */ .agb-toast { + position: relative; display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; + align-items: flex-start; + gap: 11px; + padding: 12px 14px 12px 13px; border-radius: var(--radius-lg); - background-color: var(--color-bg-overlay); + background-color: color-mix(in srgb, var(--color-bg-overlay) 88%, transparent); border: 1px solid var(--color-border-default); - box-shadow: var(--shadow-lg); - min-width: 280px; - max-width: 380px; + backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(140%); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 12px 32px -8px rgba(0, 0, 0, 0.55), + 0 4px 12px -4px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(0, 0, 0, 0.2); + min-width: 300px; + max-width: 400px; pointer-events: auto; - animation: slide-up var(--duration-normal) var(--ease-spring) forwards; + transform-origin: bottom right; + animation: agb-toast-in 280ms var(--ease-spring) forwards; +} + +@keyframes agb-toast-in { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } .agb-toast__icon { flex-shrink: 0; - font-size: var(--font-size-sm); - line-height: 1.6; - width: 16px; - text-align: center; + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + font-size: 13px; + font-weight: var(--font-weight-semibold); + line-height: 1; + margin-top: 3px; } .agb-toast__content { @@ -371,6 +398,7 @@ gap: 2px; flex: 1; min-width: 0; + padding-top: 1px; } .agb-toast__title { @@ -378,6 +406,7 @@ font-weight: var(--font-weight-medium); color: var(--color-fg-primary); line-height: var(--line-height-snug); + letter-spacing: -0.005em; } .agb-toast__message { @@ -393,11 +422,13 @@ justify-content: center; width: 20px; height: 20px; - border-radius: var(--radius-xs); + border-radius: var(--radius-sm); color: var(--color-fg-tertiary); + background: transparent; transition: background-color var(--duration-fast) var(--ease-out), - color var(--duration-fast) var(--ease-out); + color var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); margin-top: 1px; } @@ -405,26 +436,58 @@ background-color: var(--color-surface-interactive-hover); color: var(--color-fg-primary); } - -/* Variants — tinted borders for each status type. - why-not-a-token: these alpha variants (30%) of the status colors are - toast-specific border tints. A full --color-toast-border-{type} family - would be the clean solution; for now they are documented here. */ -.agb-toast--info { border-color: rgba(96, 165, 250, 0.30); } -.agb-toast--info .agb-toast__icon { color: var(--color-status-info); } - -.agb-toast--success { border-color: rgba(74, 222, 128, 0.30); } +.agb-toast__dismiss:active { transform: scale(0.92); } + +/* Variants — each picks up a tinted border, a tinted icon chip, and a + subtle outer glow in the variant color. The glow rides on top of the + base layered shadow defined on .agb-toast. */ +.agb-toast--info { + border-color: color-mix(in srgb, var(--color-status-info) 35%, var(--color-border-default)); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 12px 32px -8px rgba(0, 0, 0, 0.55), + 0 4px 12px -4px rgba(0, 0, 0, 0.4), + 0 0 24px -8px color-mix(in srgb, var(--color-status-info) 50%, transparent); +} +.agb-toast--info .agb-toast__icon { color: var(--color-status-info); } + +.agb-toast--success { + border-color: color-mix(in srgb, var(--color-status-success) 38%, var(--color-border-default)); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 12px 32px -8px rgba(0, 0, 0, 0.55), + 0 4px 12px -4px rgba(0, 0, 0, 0.4), + 0 0 24px -8px color-mix(in srgb, var(--color-status-success) 50%, transparent); +} .agb-toast--success .agb-toast__icon { color: var(--color-status-success); } -.agb-toast--warning { border-color: rgba(245, 158, 11, 0.30); } +.agb-toast--warning { + border-color: color-mix(in srgb, var(--color-status-warning) 38%, var(--color-border-default)); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 12px 32px -8px rgba(0, 0, 0, 0.55), + 0 4px 12px -4px rgba(0, 0, 0, 0.4), + 0 0 24px -8px color-mix(in srgb, var(--color-status-warning) 55%, transparent); +} .agb-toast--warning .agb-toast__icon { color: var(--color-status-warning); } -.agb-toast--error { border-color: rgba(248, 113, 113, 0.30); } -.agb-toast--error .agb-toast__icon { color: var(--color-status-error); } +.agb-toast--error { + border-color: color-mix(in srgb, var(--color-status-error) 40%, var(--color-border-default)); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 12px 32px -8px rgba(0, 0, 0, 0.55), + 0 4px 12px -4px rgba(0, 0, 0, 0.4), + 0 0 28px -8px color-mix(in srgb, var(--color-status-error) 55%, transparent); +} +.agb-toast--error .agb-toast__icon { color: var(--color-status-error); } .agb-toast--agent { - border-color: var(--color-pill-border, #2e2e38); - box-shadow: var(--glow-accent); + border-color: color-mix(in srgb, var(--color-accent-default) 40%, var(--color-border-default)); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 12px 32px -8px rgba(0, 0, 0, 0.55), + 0 4px 12px -4px rgba(0, 0, 0, 0.4), + 0 0 28px -8px color-mix(in srgb, var(--color-accent-default) 60%, transparent); } .agb-toast--agent .agb-toast__icon { color: var(--color-accent-default); } diff --git a/app/src/renderer/components/empty/ErrorBoundary.tsx b/app/src/renderer/components/empty/ErrorBoundary.tsx index 8ce0a155..10880f89 100644 --- a/app/src/renderer/components/empty/ErrorBoundary.tsx +++ b/app/src/renderer/components/empty/ErrorBoundary.tsx @@ -46,11 +46,13 @@ export class ErrorBoundary extends Component { diff --git a/app/src/renderer/globals.d.ts b/app/src/renderer/globals.d.ts index 5334aed0..a21b9848 100644 --- a/app/src/renderer/globals.d.ts +++ b/app/src/renderer/globals.d.ts @@ -45,6 +45,10 @@ interface ElectronSessionAPI { delete: (id: string) => Promise; downloadOutput: (filePath: string) => Promise<{ opened: boolean }>; revealOutput: (filePath: string) => Promise<{ revealed: boolean }>; + readSkill: (payload: { domainTopic?: string; absPath?: string }) => Promise< + | { ok: true; path: string; filename: string; sizeBytes: number; mtimeMs: number; lineCount: number; title: string; description: string; body: string; truncated: boolean } + | { ok: false; error: string } + >; listEditors: () => Promise>; openInEditor: (editorId: string, filePath: string) => Promise<{ opened: boolean }>; listEngines: () => Promise>; @@ -73,6 +77,9 @@ interface ElectronSessionAPI { attachments?: Array<{ name: string; mime: string; bytes: Uint8Array }>, ) => Promise<{ resumed?: boolean; queued?: boolean; error?: string }>; rerun: (id: string) => Promise<{ rerun?: boolean; error?: string }>; + editAndRerun: (id: string, prompt: string) => Promise<{ rerun?: boolean; error?: string }>; + previewStart: (id: string) => Promise<{ ok: boolean; reason?: string }>; + previewStop: (id: string) => Promise; list: () => Promise; listAll: () => Promise; get: (id: string) => Promise; @@ -171,8 +178,10 @@ interface ElectronChromeImportAPI { interface ElectronOnAPI { sessionUpdated: (cb: (session: import('./hub/types').AgentSession) => void) => () => void; sessionBrowserGone: (cb: (id: string) => void) => () => void; + sessionBrowserAttached: (cb: (id: string) => void) => () => void; sessionOutput: (cb: (id: string, event: import('./hub/types').HlEvent) => void) => () => void; sessionOutputTerm: (cb: (id: string, bytes: string) => void) => () => void; + sessionPreviewFrame: (cb: (id: string, dataB64: string) => void) => () => void; openSettings?: (cb: (payload?: { focusBrowserCodeProvider?: string }) => void) => () => void; zoomChanged?: (cb: (factor: number) => void) => () => void; whatsappQr?: (cb: (dataUrl: string) => void) => () => void; diff --git a/app/src/renderer/hub/AgentPane.tsx b/app/src/renderer/hub/AgentPane.tsx index ae27ec2a..6f8fed9f 100644 --- a/app/src/renderer/hub/AgentPane.tsx +++ b/app/src/renderer/hub/AgentPane.tsx @@ -699,14 +699,17 @@ interface AgentPaneProps { onSelect?: (sessionId: string) => void; onOpenFollowUp?: () => void; onOpenSettings?: () => void; + onOpenChat?: (sessionId: string) => void; + shouldDetachBrowserOnUnmount?: () => boolean; followUpShortcut?: string; cycleShortcut?: string; } -export function AgentPane({ session, focused, onRerun, onResume, onPause, onFollowUp, onDismiss, onCancel, onSelect, onOpenFollowUp, onOpenSettings, followUpShortcut, cycleShortcut }: AgentPaneProps): React.ReactElement { +export function AgentPane({ session, focused, onRerun, onResume, onPause, onFollowUp, onDismiss, onCancel, onSelect, onOpenFollowUp, onOpenSettings, onOpenChat, shouldDetachBrowserOnUnmount, followUpShortcut, cycleShortcut }: AgentPaneProps): React.ReactElement { const openaiLogo = useThemedAsset(openaiLogoDark, openaiLogoLight); const opencodeLogo = useThemedAsset(opencodeLogoDark, opencodeLogoLight); const paneRef = useRef(null); + const pendingUnmountDetachRef = useRef(null); const [browserDead, setBrowserDead] = useState(false); const [browserMissing, setBrowserMissing] = useState(false); const [frameRect, setFrameRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null); @@ -738,6 +741,13 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll } }, [session.id, session.status]); + useEffect(() => { + if (session.hasBrowser) { + setBrowserDead(false); + setBrowserMissing(false); + } + }, [session.id, session.hasBrowser]); + useEffect(() => { const api = window.electronAPI; if (!api?.on?.sessionBrowserGone) return; @@ -791,7 +801,7 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll if (!paneEl) return; const api = window.electronAPI; if (!api) return; - if (browserDead) { + if (browserDead && !session.hasBrowser) { // Dead browser — ensure any lingering view is detached. // Keep frameRect so the "Browser ended" overlay can paint over the // pane__output slot; nulling it leaves the pane black with no label. @@ -832,6 +842,7 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll api.takeover?.hide(session.id).catch(() => {}); } else { attachSucceeded = true; + setBrowserDead(false); setBrowserMissing(false); if (session.status === 'running') { void api.takeover?.show(session.id, bounds, overlayMode); @@ -912,17 +923,32 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll window.removeEventListener('pane:layout-change', onLayoutChange); if (rafScheduled) cancelAnimationFrame(rafScheduled); }; - }, [session.id, computeBounds, browserDead, session.status, session.primarySite]); + }, [session.id, computeBounds, browserDead, session.hasBrowser, session.status, session.primarySite]); useEffect(() => { + if (pendingUnmountDetachRef.current !== null) { + window.clearTimeout(pendingUnmountDetachRef.current); + pendingUnmountDetachRef.current = null; + } return () => { const api = window.electronAPI; if (!api) return; - console.log('[AgentPane] unmount -> detach', { id: session.id }); - api.sessions.viewDetach(session.id).catch(() => {}); - api.takeover?.hide(session.id).catch(() => {}); + const sessionId = session.id; + // React dev StrictMode replays effect cleanup/setup on mount; defer the + // real detach so the immediate setup can cancel it. + pendingUnmountDetachRef.current = window.setTimeout(() => { + pendingUnmountDetachRef.current = null; + if (shouldDetachBrowserOnUnmount && !shouldDetachBrowserOnUnmount()) { + console.log('[AgentPane] unmount -> keep browser parked offscreen', { id: sessionId }); + api.takeover?.hide(sessionId).catch(() => {}); + return; + } + console.log('[AgentPane] unmount -> detach', { id: sessionId }); + api.sessions.viewDetach(sessionId).catch(() => {}); + api.takeover?.hide(sessionId).catch(() => {}); + }, 0); }; - }, [session.id]); + }, [session.id, shouldDetachBrowserOnUnmount]); // Hide the takeover overlay whenever the session leaves 'running' state. // Show is driven by the bounds-update effect above so it tracks the same @@ -939,6 +965,12 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll const statusText = STATUS_LABEL[session.status] ?? session.status; const isCancellation = !!session.error && session.error.toLowerCase().includes('cancel'); const showErrorUi = !!session.error && !isCancellation; + const hasLiveBrowser = session.hasBrowser && !browserDead && !browserMissing; + const endedWithoutBrowser = !hasLiveBrowser && ( + session.status === 'stopped' || + session.status === 'idle' || + session.status === 'stuck' + ); const isRunningLike = session.status === 'running' || session.status === 'stuck'; const isPaused = session.status === 'paused'; const canResume = Boolean( @@ -1033,6 +1065,19 @@ export function AgentPane({ session, focused, onRerun, onResume, onPause, onFoll Browser ended )} + {onOpenChat && ( + + )} - - - - ); -}); - export function HubApp(): React.ReactElement { const isMock = import.meta.env.VITE_MOCK_MODE === '1'; const [mockSessions, setMockSessions] = useState(isMock ? MOCK_SESSIONS : []); @@ -115,6 +41,21 @@ export function HubApp(): React.ReactElement { const sessions = isMock ? mockSessions : (sessionsQuery.data ?? []); const setSessions = isMock ? setMockSessions : () => {}; + // Mirror sessions into Zustand for the chat view + future fine-grained + // subscribers. Uses the same per-event `session-output` IPC stream that the + // logs pane uses (not the heavier `session-updated` snapshot channel), so + // chat updates are true push events. Old consumers (Sidebar, AgentPane, + // Dashboard) keep reading from useSessionsQuery — no behavior change for + // grid mode. + useSessionsBridge(); + + // Chat target lives in useUIStore so the selection persists across reloads. + // viewMode itself remains HubApp-local for now (avoids a full migration of + // every other view-mode consumer); we just extend it with 'chat'. + const chatSessionId = useUIStore((s) => s.chatSessionId); + const setChatSession = useUIStore((s) => s.setChatSession); + const keepBrowserParkedForChatRef = useRef(false); + useEffect(() => { console.log('[HubApp] sessions changed', { count: sessions.length, ts: Date.now(), ids: sessions.map((s) => s.id.slice(0, 8)) }); }, [sessions.length]); @@ -132,12 +73,27 @@ export function HubApp(): React.ReactElement { return 'dashboard'; }); const setViewMode = useCallback((mode: ViewMode) => { + const shouldShowBrowserViews = mode === 'grid'; + keepBrowserParkedForChatRef.current = mode === 'chat'; + if (!shouldShowBrowserViews) { + window.electronAPI?.sessions?.viewsSetVisible?.(false)?.catch(() => {}); + } setViewModeRaw(mode); - window.electronAPI?.sessions?.viewsSetVisible?.(mode !== 'settings')?.catch(() => {}); + // Browser views are only used by AgentPane (grid mode). Hide everywhere else + // so they don't bleed through the chat/dashboard/settings UI. + if (shouldShowBrowserViews) { + window.electronAPI?.sessions?.viewsSetVisible?.(true)?.catch(() => {}); + } if (mode === 'dashboard' || mode === 'grid') { try { window.localStorage.setItem('hub-view-mode', mode); } catch { /* ignore */ } } }, []); + const shouldDetachBrowserOnPaneUnmount = useCallback(() => !keepBrowserParkedForChatRef.current, []); + const enterChat = useCallback((id: string) => { + console.log('[HubApp] enterChat', { id }); + setChatSession(id); + setViewMode('chat'); + }, [setChatSession, setViewMode]); const openPill = useCallback(() => { window.electronAPI?.pill.toggle(); }, []); const [helpOpen, setHelpOpen] = useState(false); const [settingsIntent, setSettingsIntent] = useState(null); @@ -159,10 +115,6 @@ export function HubApp(): React.ReactElement { return saved === 'top' ? 'top' : 'side'; } catch { return 'side'; } }); - const setTabsPosition = useCallback((pos: 'side' | 'top') => { - setTabsPositionRaw(pos); - try { window.localStorage.setItem('hub-tabs-position', pos); } catch { /* ignore */ } - }, []); useEffect(() => { const onChange = (e: Event): void => { const next = (e as CustomEvent<{ position: 'side' | 'top' }>).detail?.position; @@ -187,9 +139,9 @@ export function HubApp(): React.ReactElement { try { window.localStorage.setItem('hub-cmdbar-visible', '0'); } catch { /* ignore */ } }, []); - const showBrowserViews = useCallback(() => { - window.electronAPI?.sessions?.viewsSetVisible?.(true)?.catch(() => {}); - }, []); + const restoreBrowserViewsForCurrentMode = useCallback(() => { + window.electronAPI?.sessions?.viewsSetVisible?.(viewMode === 'grid')?.catch(() => {}); + }, [viewMode]); const openSettingsPage = useCallback((payload?: SettingsOpenPayload) => { window.electronAPI?.pill.hide(); @@ -285,12 +237,12 @@ export function HubApp(): React.ReactElement { 'meta.escape': () => { if (helpOpen) { setHelpOpen(false); - if (viewMode !== 'settings') showBrowserViews(); + restoreBrowserViewsForCurrentMode(); return; } setFocusIndex(-1); }, - }), [sessions, orderedSessions, focusIndex, helpOpen, viewMode, setViewMode, openSettingsPage, showBrowserViews]); + }), [sessions, orderedSessions, focusIndex, helpOpen, setViewMode, openSettingsPage, restoreBrowserViewsForCurrentMode]); const vim = useVimKeys(vimHandlers); @@ -315,10 +267,10 @@ export function HubApp(): React.ReactElement { useEffect(() => { const unsub = window.electronAPI?.on?.pillToggled?.(() => { setHelpOpen(false); - if (viewMode !== 'settings') showBrowserViews(); + restoreBrowserViewsForCurrentMode(); }); return unsub; - }, [showBrowserViews, viewMode]); + }, [restoreBrowserViewsForCurrentMode]); // Main-process signal (e.g. fired by onboarding:complete after Skip) telling // the hub to switch to a specific view regardless of the saved preference. @@ -346,14 +298,6 @@ export function HubApp(): React.ReactElement { // Grid density auto-clamp removed — grid is always 1x1. - useEffect(() => { - const api = window.electronAPI; - if (!api || isMock) return; - sessions.forEach((s) => { - api.sessions.viewDetach(s.id).catch(() => {}); - }); - }, [viewMode, gridColumns, gridPage]); - // Logs overlay is anchored to the AgentPane, which only renders in 'grid' // view. Hide it whenever the user switches away so it doesn't float over // the dashboard / list UI. @@ -386,10 +330,10 @@ export function HubApp(): React.ReactElement { knownIdsRef.current = new Set(sessions.map((s) => s.id)); if (!newSession) return; const globalIdx = sessions.findIndex((s) => s.id === newSession.id); - console.log('[HubApp] new session detected -> focus', { id: newSession.id, globalIdx }); - setViewMode('grid'); + console.log('[HubApp] new session detected -> chat', { id: newSession.id, globalIdx }); + enterChat(newSession.id); setFocusIndex(globalIdx); - }, [sessions, setViewMode]); + }, [sessions, enterChat]); useEffect(() => { const visible = sessions; @@ -419,7 +363,7 @@ export function HubApp(): React.ReactElement { }; console.log('[HubApp] createSession (mock)', { id, prompt }); pendingFocusIdRef.current = id; - setViewMode('grid'); + enterChat(id); setSessions((prev) => [...prev, newSession]); const pushEvent = (event: HlEvent, statusOverride?: AgentSession['status']) => { @@ -453,13 +397,13 @@ export function HubApp(): React.ReactElement { ); console.log('[HubApp] session created', { id }); pendingFocusIdRef.current = id; - setViewMode('grid'); + enterChat(id); await api.sessions.start(id); console.log('[HubApp] session started', { id }); } catch (err) { console.error('[HubApp] createSession failed', err); } - }, [isMock, setViewMode]); + }, [isMock, setViewMode, enterChat]); const handleFollowUp = useCallback(async ( @@ -520,21 +464,6 @@ export function HubApp(): React.ReactElement { console.log('[HubApp] selectSession', { id }); }, [sessions]); - const visibleCount = sessions.length; - const gridPageSize = Math.max(1, gridColumns); - const gridTotalPages = Math.max(1, Math.ceil(visibleCount / gridPageSize)); - const gridSafePage = Math.min(gridPage, gridTotalPages - 1); - const goToPage = useCallback((target: number) => { - const clamped = Math.max(0, Math.min(target, gridTotalPages - 1)); - const visible = sessions; - const firstOnPage = visible[clamped * gridPageSize]; - if (firstOnPage) { - const globalIdx = sessions.findIndex((s) => s.id === firstOnPage.id); - if (globalIdx >= 0) setFocusIndex(globalIdx); - } - setGridPage(clamped); - }, [gridTotalPages, gridPageSize, sessions]); - const selectedSessionId = sessions[focusIndex]?.id ?? null; return ( @@ -547,16 +476,7 @@ export function HubApp(): React.ReactElement { settingsShortcut={shortcutFor('goto.settings')} /> -
- openSettingsPage()} - tipDashboard={tip('Dashboard', 'goto.dashboard')} - tipGrid={tip('Grid view', 'goto.agents')} - tipSettings={tip('Settings', 'goto.settings')} - /> -
+
{/* Grid density + pager removed — single-pane (1x1) layout. */} {viewMode !== 'dashboard' && ( @@ -574,7 +494,6 @@ export function HubApp(): React.ReactElement {
) : viewMode === 'dashboard' ? ( { openSettingsPage(); }} + onOpenChat={enterChat} + shouldDetachBrowserOnUnmount={shouldDetachBrowserOnPaneUnmount} followUpShortcut={shortcutFor('action.followUp')} /> ); @@ -721,7 +653,7 @@ export function HubApp(): React.ReactElement { open={helpOpen} onClose={() => { setHelpOpen(false); - if (viewMode !== 'settings') showBrowserViews(); + restoreBrowserViewsForCurrentMode(); }} keybindings={vim.keybindings} onOpenSettings={() => { diff --git a/app/src/renderer/hub/SettingsPane.tsx b/app/src/renderer/hub/SettingsPane.tsx index a38af75b..aa7f3fc6 100644 --- a/app/src/renderer/hub/SettingsPane.tsx +++ b/app/src/renderer/hub/SettingsPane.tsx @@ -4,6 +4,14 @@ import type { ActionId, KeyBinding } from './keybindings'; import { fallbackShortcutPlatform, keyboardEventToShortcut } from '../../shared/hotkeys'; import { useThemeMode } from '../design/useThemeMode'; import type { ThemeMode } from '../design/themeMode'; +import { useToast } from '@/renderer/components/base/Toast'; +import { + PRESETS as SPINNER_VERB_PRESETS, + useSpinnerVerbsStore, + MIN_CYCLE_MS, + MAX_CYCLE_MS, + type SpinnerPresetId, +} from './chat/spinnerVerbs'; /** * Generic settings primitives. Add a new option type and every section that @@ -91,6 +99,107 @@ function AppearanceSection(): React.ReactElement { ); } +function SpinnerVerbsSection(): React.ReactElement { + const presetId = useSpinnerVerbsStore((s) => s.presetId); + const customVerbs = useSpinnerVerbsStore((s) => s.customVerbs); + const cycleMs = useSpinnerVerbsStore((s) => s.cycleMs); + const setPreset = useSpinnerVerbsStore((s) => s.setPreset); + const setCustomVerbs = useSpinnerVerbsStore((s) => s.setCustomVerbs); + const setCycleMs = useSpinnerVerbsStore((s) => s.setCycleMs); + + const [draft, setDraft] = useState(customVerbs.join('\n')); + // Keep the local textarea in sync when something else mutates the store + // (e.g. preset reset), but don't fight the user mid-edit. + const lastSyncedRef = useRef(customVerbs.join('\n')); + useEffect(() => { + const next = customVerbs.join('\n'); + if (next !== lastSyncedRef.current && next !== draft) { + setDraft(next); + lastSyncedRef.current = next; + } + }, [customVerbs, draft]); + + const presetOptions = [ + ...(Object.entries(SPINNER_VERB_PRESETS) as Array<[Exclude, typeof SPINNER_VERB_PRESETS[keyof typeof SPINNER_VERB_PRESETS]]>), + ]; + + const activePreview = presetId === 'custom' + ? (customVerbs.length > 0 ? customVerbs : ['Working']) + : SPINNER_VERB_PRESETS[presetId].verbs; + + const commitDraft = (): void => { + const next = draft.split('\n').map((v) => v.trim()).filter(Boolean); + setCustomVerbs(next); + lastSyncedRef.current = next.join('\n'); + }; + + return ( +
+ + + + + +
+ {activePreview.slice(0, 6).join(' / ')}{activePreview.length > 6 ? ' ...' : ''} +
+
+ + {presetId === 'custom' && ( + +