diff --git a/docs/quality.md b/docs/quality.md index 00ba5c2..2e63394 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -30,6 +30,8 @@ Results and screenshots are written to `qa/shots/maintenance/` and `qa/shots/res `qa:link` is a supervised two-app harness that may start installed provider CLIs. `qa:close` is a supervised two-app harness for the session close lifecycle (remote, offline, refused and bulk close, adoption focus, restart, Apply update); it starts the installed `claude` CLI in a throwaway repository and sends no prompts. `npm run qa:perf -- 4 --allow-live-agents` is a manual Windows benchmark and explicitly opts into real-agent startup. Performance QA refuses missing terminals, invisible workload, incomplete streaming/repaint activity and missing CPU samples instead of treating low CPU as success. It has not become a deterministic CI performance gate; that conversion remains a separate task. +`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. None of these starts a real agent. + Do not run these against a live development conversation. Do not equate a short loopback test with sleep/resume, VPN changes or multi-day field validation. ## Release gates diff --git a/package-lock.json b/package-lock.json index 72cc9f3..0aa2928 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devdeck", - "version": "1.38.1", + "version": "1.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devdeck", - "version": "1.38.1", + "version": "1.39.0", "license": "MIT", "dependencies": { "@fontsource-variable/geist": "5.3.0", diff --git a/package.json b/package.json index f846fef..bbd9eab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devdeck", - "version": "1.38.1", + "version": "1.39.0", "description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume", "main": "dist/main/main.js", "type": "commonjs", @@ -39,7 +39,10 @@ "qa:perf": "npm run build && node qa/perf.mjs", "qa:maintenance": "npm run build && node qa/maintenance.mjs", "check:docs": "node scripts/check-docs.mjs", - "qa:session-sync": "npm run build && node qa/session-sync.mjs" + "qa:session-sync": "npm run build && node qa/session-sync.mjs", + "qa:images": "npm run build && node qa/images.mjs", + "qa:images-remote": "npm run build && node qa/images-remote.mjs", + "qa:update": "npm run build && node qa/claude-update.mjs" }, "build": { "appId": "com.soursea.devdeck", diff --git a/qa/claude-update.mjs b/qa/claude-update.mjs new file mode 100644 index 0000000..9bb1725 --- /dev/null +++ b/qa/claude-update.mjs @@ -0,0 +1,107 @@ +// Verifies "update all Claude sessions" in the real app with a fixture `claude`: the button appears +// when the agent's screen says its update failed, every session is stopped, `claude update` runs with +// none of them alive, and the same conversations come back. +import { _electron as electron } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Windows harness (fixture claude.cmd on PATH). Run with npm run qa:update. +const root = resolve(process.argv[2] ?? dirname(dirname(fileURLToPath(import.meta.url)))); +const shots = resolve(process.argv[3] ?? join(root, 'qa', 'shots', 'claude-update')); +mkdirSync(shots, { recursive: true }); +const { closeElectron } = await import('file:///' + join(root, 'qa', 'electron-lifecycle.mjs').replace(/\\/g, '/')); + +const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-update-')); +const repo = join(sandbox, 'update-workspace'); +const bin = join(sandbox, 'bin'); +const profile = join(sandbox, 'profile'); +const home = join(profile, 'home'); +const log = join(sandbox, 'calls.log'); +for (const p of [repo, bin, profile, home]) mkdirSync(p, { recursive: true }); +execFileSync('git', ['init', '-q', '-b', 'qa-update'], { cwd: repo }); +// Records every invocation with how many sessions were alive at that moment (a live session holds a +// lock file); a session prints Claude's failed-update footer so the cockpit offers the fix. +writeFileSync(join(bin, 'agent.js'), ` +const fs = require('fs'); const path = require('path'); +const log = ${JSON.stringify(log)}; const locks = ${JSON.stringify(sandbox)}; +// A tree kill on Windows skips exit handlers, so a lock can outlive its process: ask the OS instead. +const running = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } }; +const alive = () => fs.readdirSync(locks).filter((f) => f.startsWith('alive-') && running(Number(f.slice(6)))).length; +const args = process.argv.slice(2); +if (args[0] === 'update') { fs.appendFileSync(log, 'update alive=' + alive() + '\\n'); console.log('Successfully updated from 2.1.1 to 2.1.2'); process.exit(0); } +const me = path.join(locks, 'alive-' + process.pid); fs.writeFileSync(me, ''); +const bye = () => { try { fs.unlinkSync(me); } catch {} process.exit(0); }; +process.on('SIGINT', bye); process.on('SIGTERM', bye); process.on('exit', () => { try { fs.unlinkSync(me); } catch {} }); +fs.appendFileSync(log, 'start ' + args.join(' ') + '\\n'); +process.stdout.write('FIXTURE_READY\\r\\n \\u2717 Auto-update failed \\u00b7 Try claude doctor\\r\\n'); +if (process.stdin.isTTY) process.stdin.setRawMode(true); +process.stdin.on('data', () => {}); +setInterval(() => {}, 1 << 30); +`); +writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0agent.js" %*\r\n'); + +const sids = ['5a1b2c3d-1111-4222-8333-944455556666', '6b2c3d4e-1111-4222-8333-944455556666']; +const enc = repo.replace(/[^a-zA-Z0-9]/g, '-'); +const tdir = join(home, '.claude', 'projects', enc); +mkdirSync(tdir, { recursive: true }); +for (const sid of sids) writeFileSync(join(tdir, sid + '.jsonl'), JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: repo, sessionId: sid, message: { role: 'user', content: 'hi' } }) + '\n'); +writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings: { + language: 'ko', folders: [{ path: repo, kind: 'repo' }], viewMode: 'list', + cockpitSessions: sids.map((sid, i) => ({ tileId: 'tile-up-' + i, projectPath: repo, name: 'update-workspace', sessionId: sid, agentId: 'claude', label: 'Update QA ' + i, lastActiveMs: Date.now() })), +} })); +const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDECODE: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_CODE_ENTRYPOINT: '' }; +const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH'; +env[pathKey] = bin + ';' + env[pathKey]; +const app = await electron.launch({ args: ['.', '--user-data-dir=' + profile, '--no-sandbox', '--disable-gpu'], cwd: root, env }); +const errors = []; +const check = async (name, fn) => { await fn(); console.log('PASS ' + name); }; +try { + const win = await app.firstWindow(); + win.setDefaultTimeout(20000); + win.on('pageerror', (e) => errors.push(String(e))); + win.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + await check('both sessions restore and run', async () => { + await win.waitForFunction(() => document.querySelectorAll('#shell-session-groups .shell-session').length === 2, null, { timeout: 30000 }); + await win.locator('#shell-session-groups .shell-session').first().click(); + await win.getByRole('button', { name: /모두 복원/ }).click(); + const until = Date.now() + 20000; + const starts = () => (existsSync(log) ? readFileSync(log, 'utf8') : '').split(/\r?\n/).filter((l) => l.startsWith('start')).length; + while (Date.now() < until && starts() < 2) await new Promise((r) => setTimeout(r, 300)); + assert.equal(starts(), 2); + }); + await check('a failed background update offers "update all"', async () => { + await win.waitForFunction(() => [...document.querySelectorAll('#ck-header .ck-update')].some((b) => b.textContent.includes('모두 업데이트 (2)')), null, { timeout: 15000 }); + await win.screenshot({ path: join(shots, 'u1-offer.png') }); + }); + await check('confirm → sessions stop, claude update runs with none alive, both come back', async () => { + const startsBefore = readFileSync(log, 'utf8').split('\n').filter((l) => l.startsWith('start')).length; + await win.locator('#ck-header .ck-update', { hasText: '모두 업데이트' }).click(); + await win.locator('.ck-confirm-ok').click(); + await win.waitForFunction(() => document.querySelectorAll('#shell-session-groups .shell-session').length === 2, null, { timeout: 30000 }); + const until = Date.now() + 30000; + let calls = ''; + while (Date.now() < until) { + calls = readFileSync(log, 'utf8'); + if (calls.includes('update alive=') && calls.split('\n').filter((l) => l.startsWith('start')).length >= startsBefore + 2) break; + await new Promise((r) => setTimeout(r, 500)); + } + assert.match(calls, /update alive=0/, 'update ran with no session alive:\n' + calls); + const after = calls.slice(calls.indexOf('update alive=')); + for (const sid of sids) assert.ok(after.includes(sid), 'resumed ' + sid + ':\n' + calls); + await win.screenshot({ path: join(shots, 'u2-after.png') }); + }); + assert.deepEqual(errors, [], 'no page/console errors'); + console.log('done'); +} catch (e) { + console.log('FAIL', e?.message ?? e); + try { await (await app.firstWindow()).screenshot({ path: join(shots, 'u-fail.png') }); } catch {} + console.log('calls:\n' + (existsSync(log) ? readFileSync(log, 'utf8') : '(none)')); + console.log('errors:', errors); + process.exitCode = 1; +} finally { + await closeElectron(app); +} diff --git a/qa/images-remote.mjs b/qa/images-remote.mjs new file mode 100644 index 0000000..4a14c7c --- /dev/null +++ b/qa/images-remote.mjs @@ -0,0 +1,142 @@ +// Windows harness: a host and a paired viewer, real TLS, fixture CLI. From the viewer, a session on +// the host shows the images its transcript holds, opens them full size, and takes an attached JPEG +// (sent as PNG and typed as the host's path); the image methods refuse what they must over the link. +// Never starts a real agent. Run with npm run qa:images-remote. +import { _electron as electron } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { closeElectron } from './electron-lifecycle.mjs'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const shots = resolve(process.env.DEVDECK_IMAGES_OUT || join(root, 'qa', 'shots', 'images-remote')); +mkdirSync(shots, { recursive: true }); +const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-images-remote-')); +const repo = join(sandbox, 'host-workspace'); +const bin = join(sandbox, 'bin'); +for (const p of [repo, bin]) mkdirSync(p, { recursive: true }); +execFileSync('git', ['init', '-q', '-b', 'qa-remote-images'], { cwd: repo }); +writeFileSync(join(bin, 'echo.js'), "process.stdout.write('FIXTURE_READY\\r\\n'); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.on('data', (d) => process.stdout.write('GOT:' + d.toString().replace(/\\r/g, '') + '\\r\\n'));\n"); +writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0echo.js"\r\n'); +copyFileSync(join(root, 'build', 'icon.png'), join(repo, 'render.png')); +writeFileSync(join(repo, 'secret.png'), 'not an image: TOKEN=abc'); + +const sid = '7c3d4e5f-1111-4222-8333-944455556666'; +const png = readFileSync(join(root, 'docs', 'demo', 'poster.png')).toString('base64'); +const ts = new Date().toISOString(); +const transcript = [ + { type: 'assistant', timestamp: ts, message: { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'mcp__playwright__browser_take_screenshot', input: {} }] } }, + { type: 'user', timestamp: ts, message: { role: 'user', content: [{ tool_use_id: 'toolu_1', type: 'tool_result', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: png } }] }] } }, + { type: 'assistant', timestamp: ts, message: { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Write', input: { file_path: 'render.png', content: '' } }] } }, +].map((l) => JSON.stringify(l)).join('\n') + '\n'; + +const apps = []; +async function launch(name, seed) { + const profile = join(sandbox, name.replace(/\W/g, '-')); const home = join(profile, 'home'); + mkdirSync(home, { recursive: true }); + const settings = { language: 'ko', machineName: name, folders: seed ? [{ path: repo, kind: 'repo' }] : [], viewMode: 'list' }; + if (seed) { + const tdir = join(home, '.claude', 'projects', repo.replace(/[^a-zA-Z0-9]/g, '-')); + mkdirSync(tdir, { recursive: true }); + writeFileSync(join(tdir, sid + '.jsonl'), transcript); + settings.cockpitSessions = [{ tileId: 'tile-remote-img', projectPath: repo, name: 'host-workspace', sessionId: sid, agentId: 'claude', label: 'Remote images QA', lastActiveMs: Date.now() }]; + } + writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings })); + const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDECODE: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_CODE_ENTRYPOINT: '' }; + const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH'; + env[pathKey] = bin + ';' + env[pathKey]; + const app = await electron.launch({ args: ['.', '--user-data-dir=' + profile, '--no-sandbox', '--disable-gpu'], cwd: root, env }); + apps.push(app); + const win = await app.firstWindow(); win.setDefaultTimeout(20000); + await app.evaluate(({ app: a }) => { + const require = process.getBuiltinModule('module').createRequire(a.getAppPath() + '/package.json'); + require(a.getAppPath() + '/dist/main/link/portMap.js').mapPort = async () => ({ state: 'not-found' }); // no router mappings from a test + }); + return { app, win }; +} +const freePort = () => new Promise((res, rej) => { const s = createServer(); s.on('error', rej); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); }); +const errors = []; +const check = async (name, fn) => { await fn(); console.log('PASS ' + name); }; +const rowsInclude = (win, text) => win.waitForFunction((t) => [...document.querySelectorAll('.ck-term.show .xterm-rows')].some((r) => r.textContent.replace(/\s/g, '').includes(t)), text, { timeout: 15000 }); + +try { + if (process.platform !== 'win32') { console.log('SKIP: Windows native PTY required'); process.exit(0); } + const host = await launch('Image host', true); + host.win.on('pageerror', (e) => errors.push('host: ' + e)); + await host.win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 }); + await host.win.locator('#shell-session-groups .shell-session').first().click(); + await rowsInclude(host.win, 'FIXTURE_READY'); + await host.win.evaluate((p) => window.devdeck.link.setPort(p), await freePort()); + const viewer = await launch('Image viewer', false); + viewer.win.on('pageerror', (e) => errors.push('viewer: ' + e)); + viewer.win.on('console', (m) => { if (m.type() === 'error') errors.push('viewer: ' + m.text()); }); + const status = await host.win.evaluate(() => window.devdeck.link.createInvite(['observe', 'control', 'spawn', 'write'])); + assert.ok(status.listening && status.invite?.code, 'host is listening with an invite'); + const added = await viewer.win.evaluate((code) => window.devdeck.link.addMachine(code), status.invite.code); + assert.equal(added.ok, true); + const machineId = status.machineId; + + await check('the viewer opens the host session and sees its image count', async () => { + await viewer.win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 }); + await viewer.win.locator('#shell-session-groups .shell-session').first().click(); + await viewer.win.waitForFunction(() => document.querySelector('#ck-header')?.textContent.includes('Remote images QA')); + await viewer.win.waitForFunction(() => document.querySelector('#ck-header .ck-img-count')?.textContent === '2', null, { timeout: 40000 }); + }); + + await check('thumbnails and the full view travel over the link', async () => { + await viewer.win.locator('#ck-header button[aria-pressed]').click(); + await viewer.win.waitForFunction(() => document.querySelectorAll('#ck-images .ck-img-thumb img[src^="data:image"]').length === 2); + await viewer.win.locator('#ck-images .ck-img-thumb.kind-screenshot').click(); + await viewer.win.waitForFunction(() => document.querySelector('.ck-img-viewer .ck-img-stage img')?.getAttribute('src')?.startsWith('data:image')); + await viewer.win.screenshot({ path: join(shots, 'viewer-full.png') }); + await viewer.win.keyboard.press('Escape'); + }); + + await check('a JPEG attached on the viewer lands on the host as PNG and its host path is typed', async () => { + const jpg = join(sandbox, 'photo.jpg'); + await host.app.evaluate(({ nativeImage }, [src, dst]) => { + require('node:fs').writeFileSync(dst, nativeImage.createFromPath(src).toJPEG(80)); + }, [join(root, 'build', 'icon.png'), jpg]).catch(async () => { + // `require` is not in scope inside evaluate on every Electron build; fall back to the module loader. + await host.app.evaluate(({ nativeImage, app: a }, [src, dst]) => { + const req = process.getBuiltinModule('module').createRequire(a.getAppPath() + '/package.json'); + req('node:fs').writeFileSync(dst, nativeImage.createFromPath(src).toJPEG(80)); + }, [join(root, 'build', 'icon.png'), jpg]); + }); + const chooser = viewer.win.waitForEvent('filechooser'); + await viewer.win.locator('#ck-header .ck-act').filter({ has: viewer.win.locator('svg') }).and(viewer.win.locator('[title*="이미지 첨부"]')).click(); + await (await chooser).setFiles(jpg); + await rowsInclude(viewer.win, 'devdeck-paste-'); + const typed = await host.win.evaluate(() => [...document.querySelectorAll('.ck-term.show .xterm-rows')].map((r) => r.textContent).join('\n')); + const m = /GOT:(\S*devdeck-paste-[0-9a-f-]+\.png)/.exec(typed.replace(/\n/g, '')); + assert.ok(m, 'host typed a pasted-image path:\n' + typed); + const head = readFileSync(m[1]).subarray(0, 8); + assert.equal(head.toString('hex'), '89504e470d0a1a0a', 'the file on the host is a PNG'); + await viewer.win.screenshot({ path: join(shots, 'viewer-attached.png') }); + }); + + await check('over the link, a real image is served and a fake .png or unindexed id is refused', async () => { + const r = await viewer.win.evaluate(async ([id, path]) => { + const m = window.devdeck.machine(id).cockpit; + return { + real: !!(await m.imageFile(path, 'render.png', 'thumb')), + fake: await m.imageFile(path, 'secret.png', 'thumb'), + unindexed: await m.sessionImage(path, '7c3d4e5f-1111-4222-8333-944455556666', 'f:secret.png', 'full'), + }; + }, [machineId, repo]); + assert.deepEqual(r, { real: true, fake: null, unindexed: null }); + }); + + assert.deepEqual(errors, [], 'no page/console errors'); + console.log('done'); +} catch (e) { + console.log('FAIL', e?.message ?? e); + console.log('errors:', errors); + process.exitCode = 1; +} finally { + for (const a of apps) await closeElectron(a).catch(() => {}); +} diff --git a/qa/images.mjs b/qa/images.mjs new file mode 100644 index 0000000..4e6a639 --- /dev/null +++ b/qa/images.mjs @@ -0,0 +1,143 @@ +// Verifies the image strip, viewer and 📎 attach in the real app, against an isolated HOME and a +// fixture `claude` that echoes what it is sent. Never starts a real agent. +import { _electron as electron } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, symlinkSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Windows harness (fixture claude.cmd on PATH). Run with npm run qa:images. +const root = resolve(process.argv[2] ?? dirname(dirname(fileURLToPath(import.meta.url)))); +const shots = resolve(process.argv[3] ?? join(root, 'qa', 'shots', 'images')); +mkdirSync(shots, { recursive: true }); +const { closeElectron } = await import('file:///' + join(root, 'qa', 'electron-lifecycle.mjs').replace(/\\/g, '/')); + +const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-images-')); +const repo = join(sandbox, 'image-workspace'); +const bin = join(sandbox, 'bin'); +const profile = join(sandbox, 'profile'); +const home = join(profile, 'home'); +for (const p of [repo, bin, profile, home]) mkdirSync(p, { recursive: true }); +execFileSync('git', ['init', '-q', '-b', 'qa-images'], { cwd: repo }); +// An echoing agent: whatever is pasted into it shows up on its screen. +writeFileSync(join(bin, 'echo.js'), "process.stdout.write('FIXTURE_READY\\r\\n'); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.on('data', (d) => process.stdout.write('GOT:' + d.toString().replace(/\\r/g, '') + '\\r\\n'));\n"); +writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0echo.js"\r\n'); + +// A transcript with a tool screenshot, a pasted image and an image file the agent wrote. +const sid = '3f6c2a1e-1111-4222-8333-944455556666'; +const pngA = readFileSync(join(root, 'docs', 'demo', 'poster.png')).toString('base64'); +const pngB = readFileSync(join(root, 'build', 'icon.png')).toString('base64'); +copyFileSync(join(root, 'build', 'icon.png'), join(repo, 'render.png')); +// What a peer must NOT be able to read through the image methods: a non-image named .png, a real +// image outside the project, and one reached through a junction inside the project. +writeFileSync(join(repo, 'secret.png'), 'not an image: TOKEN=abc'); +const outside = join(sandbox, 'outside'); mkdirSync(outside); +copyFileSync(join(root, 'build', 'icon.png'), join(outside, 'private.png')); +symlinkSync(outside, join(repo, 'linked'), 'junction'); +const ts = new Date().toISOString(); +const enc = repo.replace(/[^a-zA-Z0-9]/g, '-'); +const tdir = join(home, '.claude', 'projects', enc); +mkdirSync(tdir, { recursive: true }); +const lines = [ + { type: 'user', timestamp: ts, cwd: repo, sessionId: sid, message: { role: 'user', content: 'take a screenshot' } }, + { type: 'assistant', timestamp: ts, message: { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'mcp__playwright__browser_take_screenshot', input: {} }] } }, + { type: 'user', timestamp: ts, message: { role: 'user', content: [{ tool_use_id: 'toolu_1', type: 'tool_result', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: pngA } }] }] } }, + { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: pngB } }, { type: 'text', text: 'like this' }] } }, + { type: 'assistant', timestamp: ts, message: { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Write', input: { file_path: 'render.png', content: '' } }] } }, +]; +writeFileSync(join(tdir, sid + '.jsonl'), lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); + +writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings: { + language: 'ko', folders: [{ path: repo, kind: 'repo' }], viewMode: 'list', + cockpitSessions: [{ tileId: 'tile-img-1', projectPath: repo, name: 'image-workspace', sessionId: sid, agentId: 'claude', label: 'Images QA', lastActiveMs: Date.now() }], +} })); +const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDECODE: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_CODE_ENTRYPOINT: '' }; +const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH'; +env[pathKey] = bin + ';' + env[pathKey]; +const app = await electron.launch({ args: ['.', '--user-data-dir=' + profile, '--no-sandbox', '--disable-gpu'], cwd: root, env }); +const errors = []; +const passed = []; +const check = async (name, fn) => { await fn(); passed.push(name); console.log('PASS ' + name); }; +try { + const win = await app.firstWindow(); + win.setDefaultTimeout(15000); + win.on('pageerror', (e) => errors.push(String(e))); + win.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + await win.setViewportSize?.({ width: 1440, height: 900 }); + + await check('restored session opens in the cockpit', async () => { + await win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 }); + await win.locator('#shell-session-groups .shell-session').first().click(); + await win.waitForFunction(() => document.querySelector('#ck-header')?.textContent.includes('Images QA')); + await win.waitForFunction(() => [...document.querySelectorAll('.ck-term.show .xterm-rows')].some((r) => r.textContent.includes('FIXTURE_READY')), null, { timeout: 20000 }); + }); + + await check('header shows the image toggle with the session image count', async () => { + await win.waitForFunction(() => document.querySelector('#ck-header .ck-img-count')?.textContent === '3'); + }); + + await check('toggle opens the strip with three thumbnails, newest first', async () => { + await win.locator('#ck-header button[aria-pressed]').click(); + await win.waitForFunction(() => document.querySelectorAll('#ck-images .ck-img-thumb img[src^="data:image"]').length === 3); + const kinds = await win.evaluate(() => [...document.querySelectorAll('#ck-images .ck-img-thumb')].map((b) => b.className.match(/kind-(\w+)/)[1])); + assert.deepEqual(kinds, ['file', 'pasted', 'screenshot']); + await win.screenshot({ path: join(shots, '1-strip.png') }); + }); + + await check('a thumbnail opens the full viewer; arrows step, Esc closes', async () => { + await win.locator('#ck-images .ck-img-thumb.kind-screenshot').click(); + await win.waitForFunction(() => document.querySelector('.ck-img-viewer .ck-img-stage img')?.getAttribute('src')?.startsWith('data:image')); + const caption = await win.locator('.ck-img-caption').textContent(); + assert.match(caption, /playwright · browser_take_screenshot/); + assert.equal(await win.locator('.ck-img-counter').textContent(), '3 / 3'); + await win.screenshot({ path: join(shots, '2-viewer.png') }); + await win.keyboard.press('ArrowLeft'); + await win.waitForFunction(() => document.querySelector('.ck-img-counter')?.textContent === '2 / 3'); + await win.keyboard.press('Escape'); + await win.waitForSelector('.ck-img-viewer', { state: 'detached' }); + }); + + await check('📎 attach types the picked file path into the session', async () => { + const chooser = win.waitForEvent('filechooser'); + await win.locator('#ck-header button[title="파일 첨부"]').click(); + const fc = await chooser; + await fc.setFiles(join(repo, 'render.png')); + await win.waitForFunction((p) => [...document.querySelectorAll('.ck-term.show .xterm-rows')].some((r) => r.textContent.replace(/\s/g, '').includes(p.replace(/\s/g, ''))), join(repo, 'render.png'), { timeout: 10000 }); + await win.screenshot({ path: join(shots, '3-attached.png') }); + }); + + await check('image reads refuse non-images, paths outside the project, and junction escapes', async () => { + const read = (fp) => win.evaluate(([r, f]) => window.devdeck.cockpit.imageFile(r, f, 'thumb'), [repo, fp]); + assert.ok(await read('render.png'), 'a real image in the project is served'); + assert.equal(await read('secret.png'), null, 'a non-image named .png'); + assert.equal(await read(join(outside, 'private.png')), null, 'an absolute path outside the folders'); + assert.equal(await read('../outside/private.png'), null, 'a relative escape'); + assert.equal(await read('linked/private.png'), null, 'a junction out of the project'); + const bogus = await win.evaluate(([r]) => window.devdeck.cockpit.sessionImage(r, '3f6c2a1e-1111-4222-8333-944455556666', 'f:secret.png', 'full'), [repo]); + assert.equal(bogus, null, 'an id the session index does not hold'); + }); + + await check('a new image in the transcript shows up as unseen when the strip is closed', async () => { + await win.locator('#ck-header button[aria-pressed="true"]').click(); // close + writeFileSync(join(tdir, sid + '.jsonl'), readFileSync(join(tdir, sid + '.jsonl'), 'utf8') + JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), message: { role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: pngB } }] } }) + '\n'); + // Re-select to refresh (a turn end does the same in real use). + await win.evaluate(() => window.dispatchEvent(new Event('focus'))); + await win.locator('#shell-session-groups .shell-session').first().click(); + await win.waitForFunction(() => { + const n = document.querySelector('#ck-header .ck-img-count'); + return !!n && (n.textContent === '+1' || n.textContent === '4'); + }, null, { timeout: 40000 }); + }); + + assert.deepEqual(errors, [], 'no page/console errors'); + console.log(`done: ${passed.length} passed`); +} catch (e) { + console.log('FAIL', e?.message ?? e); + try { const w = await app.firstWindow(); await w.screenshot({ path: join(shots, 'fail.png') }); } catch {} + console.log('errors:', errors); + process.exitCode = 1; +} finally { + await closeElectron(app); +} diff --git a/src/main/agentUpdate.ts b/src/main/agentUpdate.ts new file mode 100644 index 0000000..17abe7c --- /dev/null +++ b/src/main/agentUpdate.ts @@ -0,0 +1,42 @@ +// src/main/agentUpdate.ts +// `claude update`, run by DevDeck while no session holds the executable. +// +// On Windows the updater cannot replace claude.exe while any session is running it +// (`windows_running_exe_lock`), so with a deck of sessions open an update never lands. The cockpit +// stops its Claude sessions, calls this, and brings the same conversations back. +import { spawn } from 'node:child_process'; +import { cliInvocation, killChildTree } from './aiSummary'; +import { resolveAgentCliPath } from './cliExecutable'; + +export interface AgentUpdateResult { ok: boolean; code: number | null; output: string } + +const UPDATE_TIMEOUT_MS = 5 * 60_000; +const OUTPUT_TAIL_CHARS = 2000; +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07/g; + +export function runClaudeUpdate(): Promise { + return new Promise((resolve) => { + const how = cliInvocation(resolveAgentCliPath('claude'), ['update']); + let output = ''; + let settled = false; + const finish = (r: AgentUpdateResult): void => { if (settled) return; settled = true; clearTimeout(timer); resolve(r); }; + let child: ReturnType; + try { + child = spawn(how.file, how.args, { windowsHide: true, windowsVerbatimArguments: how.windowsVerbatimArguments, stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err) { + resolve({ ok: false, code: null, output: err instanceof Error ? err.message : String(err) }); + return; + } + const take = (c: Buffer): void => { output = (output + c.toString('utf8')).slice(-OUTPUT_TAIL_CHARS * 4); }; + child.stdout?.on('data', take); + child.stderr?.on('data', take); + const timer = setTimeout(() => { killChildTree(child); finish({ ok: false, code: null, output: tail(output) + '\n(timed out)' }); }, UPDATE_TIMEOUT_MS); + child.on('error', (err) => finish({ ok: false, code: null, output: err.message })); + child.on('close', (code) => finish({ ok: code === 0, code, output: tail(output) })); + }); +} + +function tail(s: string): string { + return s.replace(ANSI_RE, '').replace(/\r\n?/g, '\n').trim().slice(-OUTPUT_TAIL_CHARS); +} diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts index cf2327a..f74d5a8 100644 --- a/src/main/api/deckApi.ts +++ b/src/main/api/deckApi.ts @@ -2,7 +2,7 @@ import { createSessionLabels } from '../sessionLabels'; import { dialog, shell, app, clipboard, type BrowserWindow } from 'electron'; import { homedir, release, tmpdir } from 'node:os'; import path, { join } from 'node:path'; -import { stat } from 'node:fs/promises'; +import { readFile, realpath, stat } from 'node:fs/promises'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import type { Store } from '../store'; @@ -31,6 +31,9 @@ import { validThresholds } from '../../shared/settingsValidation'; import { sanitizeTodos } from '../../shared/tasks'; import { getClaudeUsage, readClaudeCredentials, fetchUsageApi } from '../claudeUsage'; import { getCodexUsage, spawnCodexAppServer } from '../codexUsage'; +import { claudeTranscriptPath, listSessionImages, readEmbeddedImage } from '../sessionImages'; +import { encodeImageForView, MAX_VIEW_IMAGE_FILE_BYTES, VIEWABLE_IMAGE_EXT, type ViewImage } from '../imageView'; +import { runClaudeUpdate } from '../agentUpdate'; import { UsageCoordinator, antigravityUsage } from '../usageProviders'; import { pickAdoptedSessionId, pickDriftedSessionId, type PersistedSession } from '../../shared/cockpitPersist'; import { makeAgentProbe } from '../agentProcess'; @@ -75,6 +78,22 @@ const REPO_URL = 'https://github.com/writingdeveloper/devdeck'; * link's frame limit once base64 expansion is counted. */ const MAX_PASTE_IMAGE_BYTES = 5 * 1024 * 1024; const REMOTE_IMAGE_BYTES_PER_HOUR = 200 * 1024 * 1024; +/** + * Image reads decode and re-encode on THIS process. Opening the strip asks for dozens of thumbnails + * at once, and a paired device can ask as fast as its rate limit allows; unbounded, that is the + * main-process stall v1.37.0 removed. A few at a time, and thumbnails are remembered. + */ +const IMAGE_WORK_CONCURRENCY = 3; +const IMAGE_THUMB_CACHE = 300; +const imageThumbs = new Map(); +let imageWorkActive = 0; +const imageWorkQueue: (() => void)[] = []; +async function imageWork(work: () => Promise): Promise { + // A finishing job hands its slot straight to the next waiter, so a newcomer cannot slip in between. + if (imageWorkActive >= IMAGE_WORK_CONCURRENCY) await new Promise((resolve) => imageWorkQueue.push(resolve)); + else imageWorkActive++; + try { return await work(); } finally { const next = imageWorkQueue.shift(); if (next) next(); else imageWorkActive--; } +} /** The note box says so too (maxLength): a note longer than this was cut off silently on save. */ const MAX_NOTE_CHARS = 10_000; @@ -1154,6 +1173,82 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { return 'ok'; }); + /** + * The images a session handled — tool screenshots, pasted images, image files it touched — for the + * cockpit's image panel, which is the terminal's answer to Claude Desktop showing them inline. + * Claude only: the other agents do not record images in their logs. + */ + invoke('cockpit:sessionImages', allow('observe'), async (projectPath: string, sessionId: string, agentId?: AgentId) => { + const pp = String(projectPath); + if (!isAllowedPath(effFolders(), pp) || agentFor(agentId).id !== 'claude' || typeof sessionId !== 'string') return []; + const file = claudeTranscriptPath(pp, sessionId, CLAUDE_PROJECTS); + return file ? listSessionImages(file) : []; + }); + /** + * One of those images, as a thumbnail or at full size. + * + * Only an id the session's own index holds is served — an arbitrary byte offset or file path is + * not a request this method answers, so a paired device holding `observe` reads exactly the images + * the strip would show it and nothing else. + */ + invoke('cockpit:sessionImage', allow('observe'), async (projectPath: string, sessionId: string, imageId: string, size?: string) => { + const pp = String(projectPath); + if (!isAllowedPath(effFolders(), pp) || typeof sessionId !== 'string' || typeof imageId !== 'string') return null; + const file = claudeTranscriptPath(pp, sessionId, CLAUDE_PROJECTS); + if (!file) return null; + const ref = (await listSessionImages(file)).find((r) => r.id === imageId); + if (!ref) return null; + const key = `${file}|${imageId}|${ref.ts ?? ''}`; // a file the agent wrote again is a new image + if (size === 'thumb') { const hit = imageThumbs.get(key); if (hit) return hit; } + const out = await imageWork(async () => { + if (ref.kind === 'file' && ref.path) return readImageFileFor(pp, ref.path, size); + const img = await readEmbeddedImage(file, imageId); + return img ? encodeImageForView(img.data, size) : null; + }); + if (out && size === 'thumb') { + imageThumbs.set(key, out); + if (imageThumbs.size > IMAGE_THUMB_CACHE) imageThumbs.delete(imageThumbs.keys().next().value as string); + } + return out; + }); + /** + * An image file a session named, read where the session runs. A remote session's path means + * nothing on the viewer's disk, so clicking one used to say "not available here"; now its pixels + * travel instead. + */ + invoke('cockpit:imageFile', allow('observe'), (projectPath: string, filePath: string, size?: string) => + imageWork(() => readImageFileFor(String(projectPath), String(filePath), size))); + /** + * Stricter than opening a file locally, because the caller may be another machine: + * - containment is checked on the REAL path too, so a `.png` symlink or junction inside a project + * cannot reach outside it; + * - of the temp directory only Claude Code's own scratch area and DevDeck's pasted images count — + * not every other application's temp files; + * - the bytes must BE an image (sniffed), whatever the file is called. + */ + async function readImageFileFor(projectPath: string, filePath: string, size?: string): Promise { + const folders = effFolders(); + if (!isAllowedPath(folders, projectPath)) return null; + const resolved = resolveAgentFilePath(projectPath, filePath, homedir()); + if (!VIEWABLE_IMAGE_EXT.test(resolved)) return null; + const tmp = tmpdir(); + const isPaste = path.dirname(resolved).toLowerCase() === tmp.toLowerCase() && path.basename(resolved).startsWith(PASTE_IMAGE_PREFIX); + const roots = [...folders.map((f) => f.path), join(tmp, 'claude')]; + const lexicalRoots = roots.filter((r) => isAllowedFilePath([], resolved, [r])); + if (!isPaste && lexicalRoots.length === 0) return null; + try { + const real = await realpath(resolved); + const realRoots = isPaste ? [await realpath(tmp)] : await Promise.all(lexicalRoots.map((r) => realpath(r).catch(() => r))); + if (!isAllowedFilePath([], real, realRoots)) return null; + const st = await stat(real); + if (!st.isFile() || st.size > MAX_VIEW_IMAGE_FILE_BYTES) return null; + return encodeImageForView(await readFile(real), size); + } catch { return null; } + } + + /** Run `claude update` here; the cockpit has already stopped every Claude session holding the executable. */ + invoke('agent:updateClaude', localOnly, () => runClaudeUpdate()); + // One-shot idle shutdown (win32 only — cfg.shutdown is null elsewhere so none of this registers). // Lifecycle invokes return the fresh status so the renderer can update without a round-trip race. if (cfg.shutdown && cfg.shutdownLog) { diff --git a/src/main/imageView.ts b/src/main/imageView.ts new file mode 100644 index 0000000..0e39f24 --- /dev/null +++ b/src/main/imageView.ts @@ -0,0 +1,48 @@ +// src/main/imageView.ts +// Images sized for showing in the cockpit: a small thumbnail for the strip, a bounded full view. +// Encoded where the image lives, so a remote machine sends kilobytes, not a raw 4K screenshot. +import { nativeImage } from 'electron'; +import { sniffImageType } from '../shared/imageSniff'; + +export interface ViewImage { mediaType: string; data: string; width: number; height: number } + +export const VIEWABLE_IMAGE_EXT = /\.(?:png|jpe?g|gif|webp|bmp)$/i; +export const MAX_VIEW_IMAGE_FILE_BYTES = 40 * 1024 * 1024; +const THUMB_WIDTH = 360; +const FULL_MAX_WIDTH = 2560; +const FULL_MAX_HEIGHT = 8192; +/** Under the link's 8MB frame once base64 has grown it by a third. */ +const MAX_SENT_BYTES = 5 * 1024 * 1024; + +/** + * `size` 'thumb' → a ~360px-wide JPEG; anything else → the original when it is small enough to send, + * else a downscaled JPEG that is guaranteed to fit a link frame (or nothing). Formats Electron cannot + * decode (webp, gif) go as they are — the renderer's can — or not at all when too large. + * Null for anything that does not sniff as an image. + */ +export function encodeImageForView(bytes: Buffer, size?: string): ViewImage | null { + const mediaType = sniffImageType(bytes); + if (!mediaType) return null; + const img = nativeImage.createFromBuffer(bytes); + if (img.isEmpty()) { + if (bytes.length > MAX_SENT_BYTES) return null; + return { mediaType, data: bytes.toString('base64'), width: 0, height: 0 }; + } + const { width, height } = img.getSize(); + if (size === 'thumb') { + const t = width > THUMB_WIDTH ? img.resize({ width: THUMB_WIDTH, quality: 'good' }) : img; + const ts = t.getSize(); + return { mediaType: 'image/jpeg', data: t.toJPEG(78).toString('base64'), width: ts.width, height: ts.height }; + } + if (bytes.length <= MAX_SENT_BYTES) return { mediaType, data: bytes.toString('base64'), width, height }; + // Bounded on BOTH sides: a full-page screenshot is narrow and 20,000px tall, and only capping its + // width would send a JPEG bigger than the link carries — which drops the whole connection. + const scale = Math.min(1, FULL_MAX_WIDTH / width, FULL_MAX_HEIGHT / height); + const scaled = scale < 1 ? img.resize({ width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)), quality: 'best' }) : img; + const ss = scaled.getSize(); + for (const quality of [88, 75, 60, 45]) { + const jpeg = scaled.toJPEG(quality); + if (jpeg.length <= MAX_SENT_BYTES) return { mediaType: 'image/jpeg', data: jpeg.toString('base64'), width: ss.width, height: ss.height }; + } + return null; +} diff --git a/src/main/sessionImages.test.ts b/src/main/sessionImages.test.ts new file mode 100644 index 0000000..4ade352 --- /dev/null +++ b/src/main/sessionImages.test.ts @@ -0,0 +1,95 @@ +// src/main/sessionImages.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, appendFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { listSessionImages, readEmbeddedImage, _clearSessionImageCache } from './sessionImages'; +import { mergeImageRefs, SessionImageCollector } from '../shared/sessionImages'; + +const PNG_B64 = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]).toString('base64'); +const TS = '2026-10-16T18:02:06.182Z'; + +const toolCall = (id: string, name: string, input: Record = {}) => JSON.stringify({ + type: 'assistant', timestamp: TS, message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, +}); +const toolResultWithImage = (id: string, data = PNG_B64) => JSON.stringify({ + type: 'user', timestamp: TS, message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: [ + { type: 'text', text: 'Screenshot' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data } }, + ] }] }, +}); +const pasted = () => JSON.stringify({ + type: 'user', timestamp: TS, message: { role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: PNG_B64 } }, { type: 'text', text: 'look' }] }, +}); +const plain = (text: string) => JSON.stringify({ type: 'user', timestamp: TS, message: { role: 'user', content: text } }); + +let dir: string; let file: string; +beforeEach(() => { _clearSessionImageCache(); dir = mkdtempSync(join(tmpdir(), 'devdeck-img-')); file = join(dir, 's.jsonl'); }); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe('listSessionImages', () => { + it('finds tool screenshots (named by their call), pasted images and image files a tool touched', async () => { + writeFileSync(file, [ + plain('hi'), + toolCall('t1', 'mcp__playwright__browser_take_screenshot'), + toolResultWithImage('t1'), + pasted(), + toolCall('t2', 'Write', { file_path: 'out/render.png', content: 'x' }), + toolCall('t3', 'Read', { file_path: 'src/a.ts' }), + ].join('\n') + '\n'); + const refs = await listSessionImages(file); + expect(refs.map((r) => [r.kind, r.tool, r.path])).toEqual([ + ['screenshot', 'mcp__playwright__browser_take_screenshot', null], + ['pasted', null, null], + ['file', 'Write', 'out/render.png'], + ]); + expect(refs[0].ts).toBe(Date.parse(TS)); + }); + + it('reads only what was appended, and never a half-written last line', async () => { + writeFileSync(file, toolCall('t1', 'shot') + '\n' + toolResultWithImage('t1') + '\n'); + expect(await listSessionImages(file)).toHaveLength(1); + const half = pasted(); + appendFileSync(file, half.slice(0, 40)); + expect(await listSessionImages(file)).toHaveLength(1); + appendFileSync(file, half.slice(40) + '\n'); + const refs = await listSessionImages(file); + expect(refs.map((r) => r.kind)).toEqual(['screenshot', 'pasted']); + }); + + it('starts over when the file was replaced', async () => { + writeFileSync(file, pasted() + '\n' + pasted() + '\n'); + expect(await listSessionImages(file)).toHaveLength(2); + writeFileSync(file, plain('new conversation') + '\n'); + expect(await listSessionImages(file)).toEqual([]); + }); + + it('a missing transcript is an empty list, not an error', async () => { + expect(await listSessionImages(join(dir, 'nope.jsonl'))).toEqual([]); + }); +}); + +describe('readEmbeddedImage', () => { + it('returns the bytes behind an id, and null for an id that points at nothing', async () => { + writeFileSync(file, plain('a') + '\n' + toolCall('t1', 'shot') + '\n' + toolResultWithImage('t1') + '\n' + pasted() + '\n'); + const refs = await listSessionImages(file); + const shot = await readEmbeddedImage(file, refs[0].id); + expect(shot?.mediaType).toBe('image/png'); + expect(shot?.data.equals(Buffer.from(PNG_B64, 'base64'))).toBe(true); + expect((await readEmbeddedImage(file, refs[1].id))?.mediaType).toBe('image/jpeg'); + expect(await readEmbeddedImage(file, '0.0')).toBeNull(); // first line holds no image + expect(await readEmbeddedImage(file, '999999.0')).toBeNull(); + expect(await readEmbeddedImage(file, '../etc')).toBeNull(); + }); +}); + +describe('mergeImageRefs', () => { + it('keeps one entry per file at its latest touch, and only the newest `limit`', () => { + const c = new SessionImageCollector(); + const a = c.add(toolCall('1', 'Write', { file_path: 'a.png' }), 0); + const b = c.add(pasted(), 10); + const a2 = c.add(toolCall('2', 'Edit', { file_path: 'a.png' }), 20); + const merged = mergeImageRefs(mergeImageRefs([], [...a, ...b], 10), a2, 10); + expect(merged.map((r) => [r.kind, r.tool])).toEqual([['pasted', null], ['file', 'Edit']]); + expect(mergeImageRefs([], [...b, ...c.add(pasted(), 30)], 1).map((r) => r.id)).toEqual(['30.0']); + }); +}); diff --git a/src/main/sessionImages.ts b/src/main/sessionImages.ts new file mode 100644 index 0000000..724bed6 --- /dev/null +++ b/src/main/sessionImages.ts @@ -0,0 +1,125 @@ +// src/main/sessionImages.ts +// The images a Claude session handled, indexed incrementally off its transcript (see shared/sessionImages). +import { open, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { encodeProjectPath, isValidSessionId } from '../shared/paths'; +import { SessionImageCollector, embeddedImageBlocks, mergeImageRefs, parseEmbeddedImageId, type SessionImageRef } from '../shared/sessionImages'; + +/** How far back the first look reaches. Transcripts reach 500MB; the images worth seeing are recent. */ +const FIRST_SCAN_TAIL_BYTES = 64 * 1024 * 1024; +const CHUNK_BYTES = 4 * 1024 * 1024; +const READ_CHUNK_BYTES = 512 * 1024; +/** One line holding a screenshot is a few MB; past this it is not a line worth buffering. */ +const MAX_LINE_BYTES = 48 * 1024 * 1024; +export const MAX_SESSION_IMAGES = 300; + +interface Index { size: number; mtimeMs: number; offset: number; refs: SessionImageRef[]; collector: SessionImageCollector; head: string } +const cache = new Map(); +const building = new Map>(); + +export function claudeTranscriptPath(projectPath: string, sessionId: string, claudeProjectsDir: string): string | null { + if (!isValidSessionId(sessionId)) return null; // never let a crafted id leave ~/.claude/projects + return join(claudeProjectsDir, encodeProjectPath(projectPath), sessionId + '.jsonl'); +} + +/** The session's image references, oldest first. Reads only what was appended since the last call. */ +export async function listSessionImages(file: string): Promise { + for (let inFlight = building.get(file); inFlight; inFlight = building.get(file)) await inFlight.catch(() => undefined); + const work = refresh(file); + building.set(file, work); + try { return await work; } finally { if (building.get(file) === work) building.delete(file); } +} + +async function refresh(file: string): Promise { + let size: number; let mtimeMs: number; + try { ({ size, mtimeMs } = await stat(file)); } catch { cache.delete(file); return []; } + let idx = cache.get(file); + if (idx && idx.size === size && idx.mtimeMs === mtimeMs) return idx.refs; + const fh = await open(file, 'r'); + try { + // A transcript is append-only. One that shrank, or whose first bytes changed, was replaced: + // start over rather than read new bytes against offsets that no longer mean anything. + const headBuf = Buffer.alloc(Math.min(256, size)); + await fh.read(headBuf, 0, headBuf.length, 0); + const head = headBuf.toString('latin1'); + if (idx && (size < idx.offset || !head.startsWith(idx.head))) idx = undefined; + if (!idx) { + const start = Math.max(0, size - FIRST_SCAN_TAIL_BYTES); + idx = { size: 0, mtimeMs: 0, offset: start, refs: [], collector: new SessionImageCollector(), head }; + // Starting mid-file lands mid-line: skip to the next line start. + if (start > 0) idx.offset = await nextLineStart(fh, start, size); + } + let pos = idx.offset; + let carry: Buffer = Buffer.alloc(0); + let carryStart = pos; + const added: SessionImageRef[] = []; + while (pos < size) { + const len = Math.min(CHUNK_BYTES, size - pos); + const buf = Buffer.alloc(len); + const { bytesRead } = await fh.read(buf, 0, len, pos); + if (bytesRead <= 0) break; + pos += bytesRead; + let data = carry.length ? Buffer.concat([carry, buf.subarray(0, bytesRead)]) : buf.subarray(0, bytesRead); + let lineStart = 0; + for (let nl = data.indexOf(10, lineStart); nl !== -1; nl = data.indexOf(10, lineStart)) { + const line = data.subarray(lineStart, nl); + added.push(...idx.collector.add(line.toString('utf8'), carryStart + lineStart)); + lineStart = nl + 1; + } + carryStart += lineStart; + data = data.subarray(lineStart); + // A runaway "line" is dropped whole: its tail is skipped until the next newline. + if (data.length > MAX_LINE_BYTES) { + const resume = await nextLineStart(fh, pos, size); + carry = Buffer.alloc(0); carryStart = resume; pos = resume; + } else carry = Buffer.from(data); + } + // Only COMPLETE lines are indexed; a line still being written is read again next time. + idx.offset = carryStart; + idx.size = size; idx.mtimeMs = mtimeMs; idx.head = head; + idx.refs = mergeImageRefs(idx.refs, added, MAX_SESSION_IMAGES); + cache.set(file, idx); + return idx.refs; + } finally { await fh.close(); } +} + +async function nextLineStart(fh: import('node:fs/promises').FileHandle, from: number, size: number): Promise { + const buf = Buffer.alloc(64 * 1024); + for (let pos = from; pos < size;) { + const { bytesRead } = await fh.read(buf, 0, Math.min(buf.length, size - pos), pos); + if (bytesRead <= 0) break; + const nl = buf.subarray(0, bytesRead).indexOf(10); + if (nl !== -1) return pos + nl + 1; + pos += bytesRead; + } + return size; +} + +/** The bytes of one embedded image, found again by its id. Null when the id no longer points at one. */ +export async function readEmbeddedImage(file: string, id: string): Promise<{ mediaType: string; data: Buffer } | null> { + const where = parseEmbeddedImageId(id); + if (!where) return null; + let fh: import('node:fs/promises').FileHandle; + try { fh = await open(file, 'r'); } catch { return null; } + try { + const { size } = await fh.stat(); + if (where.offset >= size) return null; + const parts: Buffer[] = []; + let total = 0; + for (let pos = where.offset; pos < size && total < MAX_LINE_BYTES;) { + const buf = Buffer.alloc(Math.min(READ_CHUNK_BYTES, size - pos)); // most lines are small; a screenshot line takes a few reads + const { bytesRead } = await fh.read(buf, 0, buf.length, pos); + if (bytesRead <= 0) break; + const nl = buf.subarray(0, bytesRead).indexOf(10); + if (nl !== -1) { parts.push(buf.subarray(0, nl)); total += nl; break; } + parts.push(buf.subarray(0, bytesRead)); total += bytesRead; pos += bytesRead; + } + let entry: unknown; + try { entry = JSON.parse(Buffer.concat(parts).toString('utf8')); } catch { return null; } + const img = embeddedImageBlocks(entry)[where.n]; + if (!img) return null; + return { mediaType: img.block.media_type, data: Buffer.from(img.block.data, 'base64') }; + } finally { await fh.close(); } +} + +export function _clearSessionImageCache(): void { cache.clear(); } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 4cafe23..d6c396a 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from 'electron'; +import { contextBridge, ipcRenderer, webUtils } from 'electron'; contextBridge.exposeInMainWorld('devdeck', { listProjects: () => ipcRenderer.invoke('projects:list'), @@ -106,6 +106,13 @@ contextBridge.exposeInMainWorld('devdeck', { gitInfo: (projectPath: string) => ipcRenderer.invoke('cockpit:gitInfo', projectPath), openLink: (url: string) => ipcRenderer.invoke('cockpit:openLink', url), openFile: (projectPath: string, filePath: string) => ipcRenderer.invoke('cockpit:openFile', projectPath, filePath), + receiveImage: (base64: string) => ipcRenderer.invoke('cockpit:receiveImage', base64), + sessionImages: (projectPath: string, sessionId: string, agentId?: string) => ipcRenderer.invoke('cockpit:sessionImages', projectPath, sessionId, agentId), + sessionImage: (projectPath: string, sessionId: string, imageId: string, size?: 'thumb' | 'full') => ipcRenderer.invoke('cockpit:sessionImage', projectPath, sessionId, imageId, size), + imageFile: (projectPath: string, filePath: string, size?: 'thumb' | 'full') => ipcRenderer.invoke('cockpit:imageFile', projectPath, filePath, size), + updateClaude: () => ipcRenderer.invoke('agent:updateClaude'), + /** The on-disk path of a dropped or picked File; '' when it has none (dragged out of a browser). */ + pathForFile: (file: File): string => { try { return webUtils.getPathForFile(file); } catch { return ''; } }, }, shutdown: { arm: () => ipcRenderer.invoke('shutdown:arm'), @@ -179,6 +186,9 @@ contextBridge.exposeInMainWorld('devdeck', { liveAgent: (id: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:liveAgent', [id]), gitInfo: (projectPath: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:gitInfo', [projectPath]), receiveImage: (base64: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:receiveImage', [base64]), + sessionImages: (projectPath: string, sessionId: string, agentId?: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:sessionImages', [projectPath, sessionId, agentId]), + sessionImage: (projectPath: string, sessionId: string, imageId: string, size?: 'thumb' | 'full') => ipcRenderer.invoke('link:call', machineId, 'cockpit:sessionImage', [projectPath, sessionId, imageId, size]), + imageFile: (projectPath: string, filePath: string, size?: 'thumb' | 'full') => ipcRenderer.invoke('link:call', machineId, 'cockpit:imageFile', [projectPath, filePath, size]), liveSessions: () => ipcRenderer.invoke('link:call', machineId, 'cockpit:liveSessions', []), sessionBuffer: (id: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:sessionBuffer', [id]), sessionScreen: (id: string) => ipcRenderer.invoke('link:call', machineId, 'cockpit:sessionScreen', [id]), diff --git a/src/renderer/attachments.ts b/src/renderer/attachments.ts new file mode 100644 index 0000000..c010b2c --- /dev/null +++ b/src/renderer/attachments.ts @@ -0,0 +1,79 @@ +// src/renderer/attachments.ts +// Files given to a session by dropping them on its terminal or picking them with 📎. +// +// An agent takes a file by its PATH, typed into its prompt — the same trick the Ctrl+V image paste +// uses. A local session gets the file's own path. A session on another machine cannot read this +// disk, so an image travels instead (as PNG, the one format the host accepts) and the path it was +// written to over there is what gets typed. + +/** What the host accepts per image (see cockpit:receiveImage); kept a little under it. */ +const MAX_IMAGE_BYTES = 5 * 1024 * 1024 - 64 * 1024; +const IMAGE_TYPE_RE = /^image\/(?:png|jpe?g|gif|webp|bmp)$/i; +const IMAGE_NAME_RE = /\.(?:png|jpe?g|gif|webp|bmp)$/i; + +export interface AttachTarget { + local: boolean; + /** Writes PNG bytes where the session runs and returns that path. */ + receiveImage(base64: string): Promise; + pathForFile(file: File): string; +} + +export interface AttachOutcome { paths: string[]; skippedNonImage: number; failed: number; tooLarge: number } + +export function isImageFile(file: File): boolean { + return IMAGE_TYPE_RE.test(file.type) || IMAGE_NAME_RE.test(file.name); +} + +export async function attachFiles(files: File[], target: AttachTarget): Promise { + const out: AttachOutcome = { paths: [], skippedNonImage: 0, failed: 0, tooLarge: 0 }; + for (const file of files) { + const own = target.local ? target.pathForFile(file) : ''; + if (own) { out.paths.push(own); continue; } + // Remote, or dragged out of a browser with no file behind it: only an image can travel. + if (!isImageFile(file)) { out.skippedNonImage++; continue; } + let png: string | null; + try { png = await toPngBase64(file); } catch { png = null; } + if (png === null) { out.tooLarge++; continue; } + let landed: string | null = null; + try { landed = await target.receiveImage(png); } catch { landed = null; } + if (landed) out.paths.push(landed); else out.failed++; + } + return out; +} + +/** Paths as one prompt fragment; a path with a space is quoted so it stays one argument. */ +export function pathsAsPrompt(paths: string[]): string { + return paths.map((p) => (/\s/.test(p) ? `"${p}"` : p)).join(' ') + ' '; +} + +/** + * The image as base64 PNG within the size the host accepts. A PNG that already fits goes as it is; + * anything else is redrawn — and scaled down until it fits, since a phone photo as PNG is 20MB. + * Null when it cannot be decoded or will not fit even small. + */ +async function toPngBase64(file: File): Promise { + if (/png$/i.test(file.type) && file.size <= MAX_IMAGE_BYTES) return bytesToBase64(new Uint8Array(await file.arrayBuffer())); + const bitmap = await createImageBitmap(file); + try { + let scale = 1; + for (let attempt = 0; attempt < 6; attempt++) { + const w = Math.max(1, Math.round(bitmap.width * scale)); + const h = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; + const ctx = canvas.getContext('2d'); if (!ctx) return null; + ctx.drawImage(bitmap, 0, 0, w, h); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (!blob) return null; + if (blob.size <= MAX_IMAGE_BYTES) return bytesToBase64(new Uint8Array(await blob.arrayBuffer())); + scale *= Math.sqrt(MAX_IMAGE_BYTES / blob.size) * 0.9; + } + return null; + } finally { bitmap.close(); } +} + +function bytesToBase64(bytes: Uint8Array): string { + let s = ''; + const CHUNK = 0x8000; // String.fromCharCode's argument list is bounded + for (let i = 0; i < bytes.length; i += CHUNK) s += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + return btoa(s); +} diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index f02ff5f..3cbaddc 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -7,7 +7,7 @@ import { FitAddon } from '@xterm/addon-fit'; import { SearchAddon } from '@xterm/addon-search'; import { activityOrderStamp, filterSessions, groupByActivity, needsAttentionCount, numberCollidingNames, cockpitListSignature, shouldNotifyAttention, foldProjectActivity, sessionNavigationItem, tileHoldingSession, type CockpitSession } from '../shared/cockpitModel'; import type { ShellSessionAction, ShellSessionGroup, ShellSessionInput } from '../shared/shellNavigation'; -import { agentUpdateReady, computeActivity, stripAnsi, type ActivityState } from '../shared/sessionStatus'; +import { agentUpdateFailed, agentUpdateReady, computeActivity, stripAnsi, type ActivityState } from '../shared/sessionStatus'; import { friendlyModel, contextPercent, contextSeverity } from '../shared/sessionMeta'; import { formatDuration } from '../shared/usage'; import { decideKeyAction, selectionCellLength } from '../shared/terminalKeys'; @@ -27,6 +27,8 @@ import { ptyCompatFor } from './ptyCompat'; import { followPtySize, type TerminalDims } from '../shared/terminalSize'; import { basename } from '../shared/paths'; import type { PtySessionInfo as RunningSession } from '../main/ptyHost'; +import { imageStripState, initImageStrip, openSingleImage, refreshImages, showImagesFor, toggleImageStrip, type ImageSource } from './imageStrip'; +import { attachFiles, pathsAsPrompt } from './attachments'; /** What cockpit:sessionMeta answers with: the log-derived facts plus the ready-made summary line. */ type SessionMetaView = { model: string | null; activeMs: number; contextTokens: number; contextWindow?: number; summary: string | null }; @@ -66,6 +68,8 @@ interface Live { ptyCap: TerminalDims | null; /** The agent says a newer version of itself is installed and waiting for a restart. */ updateReady: boolean; + /** Its screen says its own background update failed (on Windows: the executable was in use). */ + updateFailed: boolean; /** The person asked for that restart while a turn was running; it happens when the turn ends. */ updateWhenIdle: boolean; /** When this terminal last changed size: the repaint that follows is not the agent doing anything. */ @@ -80,7 +84,7 @@ const RESIZE_ECHO_MS = 700; /** Sessions closed here that their machine may still list for a moment — never adopted back. */ const closedTiles = new CloseTombstones(); /** Restart waits for the old terminal to be gone before resuming its conversation. */ -const exitWaiters = new Map void>(); +const exitWaiters = new Map void>>(); const navigationListeners = new Set<(items: readonly ShellSessionInput[]) => void>(); const sessionSelectionListeners = new Set<(id: string) => void>(); const sessionsLoadedListeners = new Set<(items: readonly ShellSessionInput[]) => void>(); @@ -152,6 +156,20 @@ export function mountCockpit(): void { termsEl = document.getElementById('ck-terms')!; emptyEl = document.getElementById('ck-empty')!; mainEl = document.querySelector('#view-cockpit .ck-main')!; + const strip = document.getElementById('ck-images'); + // The strip opening or closing changes the terminal's height, and its count lives in the header. + if (strip) initImageStrip(strip, () => { if (!editingId) renderHeader(); scheduleLayout(); }); + // Mid-turn, new screenshots show up as they are taken rather than at the end of the turn; between + // turns a slow re-read still catches images written some other way (another process, a resume). + // Only the selected session is ever read, and an unchanged transcript costs one stat. + let imageTick = 0; + setInterval(() => { + if (document.hidden) return; + const sel = selectedId ? live.get(selectedId) : null; + if (!sel || sel.session.agentId !== 'claude') return; + imageTick = (imageTick + 1) % 6; + if (sel.session.activity === 'working' || imageTick === 0) void refreshImages(); + }, 5000); // The toolbar's Refresh is the button users already press when a terminal looks wrong; make it // mean that. The deck reload is wired separately, in projectsView — both listeners fire. document.getElementById('refresh')?.addEventListener('click', () => { @@ -186,7 +204,7 @@ export function mountCockpit(): void { l.recentOutput = (l.recentOutput + stripAnsi(chunk)).slice(-4096); }); window.devdeck.cockpit.onExit(({ id, closed }) => { - exitWaiters.get(id)?.(); + for (const done of [...(exitWaiters.get(id) ?? [])]) done(); const l = live.get(id); if (!l) return; if (closed) { // Somebody closed it — at the machine running it, or from another deck watching it. That was a @@ -653,6 +671,47 @@ function pasteClipboardText(term: Terminal): void { void window.devdeck.clipboard.readText().then((t) => { if (t) term.paste(t); }); } +/** Give files to a session: by their own path locally, as uploaded images on another machine. */ +async function attachToTerminal(files: File[], machineId: string, term: Terminal): Promise { + const out = await attachFiles(files, { + local: machineId === LOCAL_MACHINE_ID, + receiveImage: (b64) => deckFor(machineId).cockpit.receiveImage(b64), + pathForFile: (f) => window.devdeck.cockpit.pathForFile(f), + }); + if (out.paths.length) { term.paste(pathsAsPrompt(out.paths)); term.focus(); } + // Every file that did not make it is said out loud: an attachment that silently went missing is + // one the person goes on talking about while the agent never saw it. + const notes: string[] = []; + if (out.paths.length) notes.push(tr('cockpit.attached', { count: String(out.paths.length) })); + if (out.skippedNonImage) notes.push(tr('cockpit.attach_images_only', { count: String(out.skippedNonImage), machine: machineName(machineId) })); + if (out.tooLarge) notes.push(tr('cockpit.remote_image_too_large')); + if (out.failed) notes.push(tr('cockpit.remote_image_failed')); + if (notes.length) toast(notes.join(' · ')); +} + +/** 📎: the same as a drop, through the system file picker. */ +function pickAttachments(machineId: string, term: Terminal): void { + const input = document.createElement('input'); input.type = 'file'; input.multiple = true; + // A remote session can only take images, so the picker offers only those there. + if (machineId !== LOCAL_MACHINE_ID) input.accept = 'image/png,image/jpeg,image/gif,image/webp,image/bmp'; + input.addEventListener('change', () => { const files = Array.from(input.files ?? []); if (files.length) void attachToTerminal(files, machineId, term); }); + input.click(); +} + +/** Where the image strip reads a session's images from: the machine running it. */ +function imageSourceFor(l: { session: CockpitSession; machineId: string; openedSessionId: string | null }): ImageSource | null { + const sid = l.openedSessionId; + if (!sid || l.session.agentId !== 'claude') return null; + const deck = deckFor(l.machineId); + const projectPath = l.session.projectPath; + return { + key: `${l.machineId}|${projectPath}|${sid}`, + list: () => deck.cockpit.sessionImages(projectPath, sid, 'claude'), + get: (id, size) => deck.cockpit.sessionImage(projectPath, sid, id, size), + openFile: l.machineId === LOCAL_MACHINE_ID ? (path) => { void window.devdeck.cockpit.openFile(projectPath, path); } : undefined, + }; +} + async function pasteImageToRemote(machineId: string, term: Terminal): Promise { let payload: { tooLarge: boolean; bytes: string | null } | null = null; try { payload = await window.devdeck.clipboard.readImageBytes(); } catch { payload = null; } @@ -778,7 +837,11 @@ async function buildTile(p: OpenReq): Promise { // A remote session's paths belong to the OTHER machine. Opening them here would either // fail the allowlist or — far worse — open a same-named file that exists locally and is // entirely unrelated work. Refuse and say why rather than open the wrong thing. - if (machineId !== LOCAL_MACHINE_ID) { toast(tr('cockpit.remote_file_unavailable', { machine: machineName(machineId) })); return; } + // An IMAGE, though, can travel: its pixels are read over there and shown here. + if (machineId !== LOCAL_MACHINE_ID) { + if (/\.(?:png|jpe?g|gif|webp|bmp)$/i.test(text)) { openSingleImage(() => deckFor(machineId).cockpit.imageFile(p.path, text, 'full'), text); return; } + toast(tr('cockpit.remote_file_unavailable', { machine: machineName(machineId) })); return; + } void window.devdeck.cockpit.openFile(p.path, text); }, })), @@ -787,6 +850,19 @@ async function buildTile(p: OpenReq): Promise { }, }; term.registerLinkProvider(linkProvider); + // Files dropped on the terminal are handed to the agent by path, as 📎 does (see attachments.ts). + // preventDefault on dragover is what makes this a drop target at all — without it Electron would + // navigate the window to the dropped file. + el.addEventListener('dragover', (e) => { + if (!e.dataTransfer?.types.includes('Files')) return; + e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; el.classList.add('drop-target'); + }); + el.addEventListener('dragleave', (e) => { if (!el.contains(e.relatedTarget as Node | null)) el.classList.remove('drop-target'); }); + el.addEventListener('drop', (e) => { + e.preventDefault(); el.classList.remove('drop-target'); + const files = Array.from(e.dataTransfer?.files ?? []); + if (files.length) void attachToTerminal(files, machineId, term); + }); // Resolve the Ctrl+C copy-vs-SIGINT conflict (and Ctrl+V paste) before xterm forwards the key. // Returning false stops xterm processing it, so 'copy'/'paste' never reach the PTY as keystrokes. term.attachCustomKeyEventHandler((e) => { @@ -902,7 +978,7 @@ async function buildTile(p: OpenReq): Promise { // history to paint, so it writes through from the first byte. replayPending: adopting ? [] : null, ptyCap: null, - updateReady: false, updateWhenIdle: false, resizedAt: 0, + updateReady: false, updateFailed: false, updateWhenIdle: false, resizedAt: 0, }); // Repaint what is already on that terminal. Without this, attaching to work in progress shows a // blank rectangle until the agent next speaks — which, while it is thinking, can be minutes, and @@ -1471,6 +1547,7 @@ function select(id: string): void { renderList(); renderHeader(); void refreshMeta(id); void refreshGit(id); + void refreshImages(); // renderHeader pointed the strip at this session; a re-select re-reads it too // Selecting a tile is when its provider is most visible (header mark + usage footer) — re-check it // here too instead of waiting up to 30s for the tick. Main's listing is cached, so this is cheap. void refreshProvider(id); @@ -1587,18 +1664,25 @@ function tickActivity(): void { recentOutput: l.recentOutput, screenText, prev: prev === 'offline' ? 'idle' : prev, spinnerReliable: l.session.agentId === 'claude', }); const updateReady = l.session.status !== 'exited' && agentUpdateReady(l.session.agentId, screenText); + const updateFailed = l.session.status !== 'exited' && !updateReady && agentUpdateFailed(l.session.agentId, screenText); + if (updateFailed !== l.updateFailed) { + l.updateFailed = updateFailed; + if (l.session.id === selectedId && !editingId) renderHeader(); + } if (updateReady !== l.updateReady) { l.updateReady = updateReady; if (!updateReady) l.updateWhenIdle = false; if (l.session.id === selectedId && !editingId) renderHeader(); } // Asked for while the agent was mid-turn: restart the moment the turn is over, not in the middle. - if (l.updateWhenIdle && next !== 'working' && next !== 'offline' && next !== 'exited') { + if (l.updateWhenIdle && !claudeUpdateRunning && next !== 'working' && next !== 'offline' && next !== 'exited') { l.updateWhenIdle = false; void restartSession(l.session.id, false); } if (next !== prev) { l.session.activity = next; changed = true; + // A turn just ended: whatever it looked at is in the transcript now. + if (prev === 'working' && l.session.id === selectedId) void refreshImages(); // A turn just finished → a new assistant model may have been logged; refresh so the sidebar model // isn't stuck on the previous turn's model after a /model switch (only otherwise refreshed on the // 30s tick). The drift check rides along so a /clear is adopted within seconds of the next turn, @@ -1980,6 +2064,7 @@ function renderHeader(): void { if (!headerEl) return; // may be called (via setCockpitContextWindow) before mountCockpit wired the DOM headerEl.replaceChildren(); const l = selectedId ? live.get(selectedId) : null; + showImagesFor(l ? imageSourceFor(l) : null); if (!l) return; const s = l.session; const title = document.createElement('span'); title.className = 'title'; @@ -2012,6 +2097,21 @@ function renderHeader(): void { void window.devdeck.openFolder(s.projectPath); }); const restart = actBtn('restart', tr('cockpit.restart'), () => restartSession(s.id)); + const attach = actBtn('paperclip', tr(l.machineId === LOCAL_MACHINE_ID ? 'cockpit.attach' : 'cockpit.attach_remote'), () => pickAttachments(l.machineId, l.term)); + // The image strip's toggle, with how many images there are — and how many arrived unseen, which is + // the reason to open it. + const images: HTMLElement[] = []; + if (s.agentId === 'claude') { + const st = imageStripState(); + const b = actBtn('image', tr(st.open ? 'cockpit.images_hide' : 'cockpit.images_show'), () => toggleImageStrip()); + b.classList.toggle('on', st.open); b.setAttribute('aria-pressed', String(st.open)); + if (st.total > 0) { + const n = document.createElement('span'); n.className = `ck-img-count${st.unseen > 0 ? ' new' : ''}`; + n.textContent = st.unseen > 0 ? `+${st.unseen}` : String(st.total); + b.appendChild(n); + } + images.push(b); + } const close = actBtn('close', tr('cockpit.close'), () => void requestClose(s.id)); // The agent installed a newer version of itself and is waiting to be restarted. Claude Code's own // in-session `/restart` exists only on macOS and Linux; on Windows the way to the new version was to @@ -2024,7 +2124,18 @@ function renderHeader(): void { b.addEventListener('click', () => applyAgentUpdate(s.id)); update.push(b); } - headerEl.append(...pills, sp, ...update, newSession, pin, rename, folder, restart, close); + // Every Claude session here at once: stop them, run `claude update` with nothing holding the + // executable, bring the same conversations back. Offered beside a single tile's update when there + // are others to take along, and as the fix when the agent's own background update failed. + const localClaude = localClaudeTiles().length; + if (l.machineId === LOCAL_MACHINE_ID && s.agentId === 'claude' && (l.updateFailed || (l.updateReady && localClaude > 1))) { + const b = document.createElement('button'); b.className = 'ck-pill ck-update'; + b.append(createIcon('restart'), claudeUpdateRunning ? tr('cockpit.update_all_running') : tr('cockpit.update_all', { count: String(localClaude) })); + b.title = tr('cockpit.update_all_title'); b.disabled = claudeUpdateRunning; + b.addEventListener('click', () => void updateAllClaude()); + update.push(b); + } + headerEl.append(...pills, sp, ...update, ...images, attach, newSession, pin, rename, folder, restart, close); } /** "+ New session": spawn another, fresh conversation in the SAME project as the selected session. */ @@ -2055,6 +2166,21 @@ async function restartSession(id: string, ask = true): Promise { const name = liveLabels.get(id) ?? l.session.name; if (ask && !(await confirmDialog(tr('cockpit.restart_confirm', { name }), tr('cockpit.restart')))) return; const still = live.get(id); if (!still) return; // closed while the question was up + const plan = await planReopen(still); + if (!plan) return; // closed or rebuilt while we asked + // The old agent may still be flushing its transcript; resuming the same conversation on top of a + // process that has not exited yet gives two writers one file. + const exited = still.machineId === LOCAL_MACHINE_ID && still.session.status !== 'exited' ? waitForExit(id, 3_000) : Promise.resolve(); + closeSession(id); + await exited; + await reopen(plan); +} + +interface ReopenPlan { p: OpenReq; entry: PersistedSession; tileId: string } + +/** How to bring a tile's conversation back after its process is stopped — null if it went away meanwhile. */ +async function planReopen(still: Live): Promise { + const id = still.session.id; // Carry the user-given label + pin into the re-created session — ⟳ must not silently reset them. // A tile that never learned its conversation id (Codex/Antigravity before drift settles) restarts // FRESH: "auto" without an id is "continue", the project's newest conversation, which may well be @@ -2068,7 +2194,7 @@ async function restartSession(id: string, ask = true): Promise { const [exists] = await deckFor(still.machineId).cockpit.sessionsExist([{ projectPath: still.session.projectPath, sessionId: still.openedSessionId, agentId: still.session.agentId }]); resumable = exists !== false; } catch { /* could not ask: try the resume, as before */ } - if (live.get(id) !== still) return; // closed or rebuilt while we asked + if (live.get(id) !== still) return null; // closed or rebuilt while we asked } const p: OpenReq = { path: still.session.projectPath, name: still.session.name, staleLevel: still.session.staleLevel, @@ -2080,16 +2206,74 @@ async function restartSession(id: string, ask = true): Promise { label: still.customLabel, pinned: still.pinned || undefined, lastActiveMs: liveActivityAt(still), machineId: still.machineId === LOCAL_MACHINE_ID ? undefined : still.machineId, }; - // The old agent may still be flushing its transcript; resuming the same conversation on top of a - // process that has not exited yet gives two writers one file. - const exited = still.machineId === LOCAL_MACHINE_ID && still.session.status !== 'exited' ? waitForExit(id, 3_000) : Promise.resolve(); - closeSession(id); - await exited; - // A restart that cannot reopen (machine went away, path refused) must not cost the tile: it goes - // back to the saved list with its name and pin, one click from trying again. - if (!(await createSession({ ...p, tileId: still.tileId }))) { - restorable = [entry, ...restorable.filter((r) => r.tileId !== entry.tileId)]; - persist(); renderList(); + return { p, entry, tileId: still.tileId }; +} + +/** A restart that cannot reopen (machine went away, path refused) must not cost the tile: it goes + * back to the saved list with its name and pin, one click from trying again. */ +async function reopen(plan: ReopenPlan): Promise { + if (await createSession({ ...plan.p, tileId: plan.tileId })) return true; + restorable = [plan.entry, ...restorable.filter((r) => r.tileId !== plan.entry.tileId)]; + persist(); renderList(); + return false; +} + +function localClaudeTiles(): Live[] { + return [...live.values()].filter((l) => l.machineId === LOCAL_MACHINE_ID && l.session.agentId === 'claude' && l.session.status !== 'exited'); +} + +let claudeUpdateRunning = false; +const UPDATE_WAIT_FOR_TURNS_MS = 15 * 60_000; + +/** + * Update Claude Code with every local Claude session stopped, then resume each conversation. + * + * On Windows the updater cannot replace claude.exe while any session runs it, so with a deck of + * sessions open the background update fails forever. Turns in progress are waited out first — a + * restart mid-turn throws that turn away. + */ +async function updateAllClaude(): Promise { + if (claudeUpdateRunning) return; + const count = localClaudeTiles().length; + if (!(await confirmDialog(tr('cockpit.update_all_confirm', { count: String(count) }), tr('cockpit.update_all', { count: String(count) })))) return; + claudeUpdateRunning = true; + // This takes over any single-tile update that was queued for the end of a turn. + for (const l of live.values()) l.updateWhenIdle = false; + renderHeader(); + try { + // A turn in progress, or one waiting on the person at a permission prompt, is not killed. + const busy = (): boolean => localClaudeTiles().some((l) => l.session.activity === 'working' || l.session.activity === 'attention'); + const deadline = Date.now() + UPDATE_WAIT_FOR_TURNS_MS; + let told = false; + while (busy() && Date.now() < deadline) { + if (!told) { toast(tr('cockpit.update_all_waiting')); told = true; } + await new Promise((r) => setTimeout(r, 1500)); + } + if (busy()) { toast(tr('cockpit.update_all_gave_up')); return; } + const tiles = localClaudeTiles(); + const plans: ReopenPlan[] = []; + for (const l of tiles) { const plan = await planReopen(l); if (plan) plans.push(plan); } + // Closing a tile forgets it. The update can take minutes, and a quit or crash in that window + // must not lose every Claude tile's name, pin and conversation: they are saved as restorable + // first, and each comes off the list again once it is back. + const planned = new Set(plans.map((pl) => pl.entry.tileId)); + restorable = [...plans.map((pl) => pl.entry), ...restorable.filter((r) => !planned.has(r.tileId))]; + const exits = tiles.map((l) => waitForExit(l.session.id, 5_000)); + for (const l of tiles) closeSession(l.session.id); + persist(); + await Promise.all(exits); + toast(tr('cockpit.update_all_updating')); + let result: { ok: boolean; output: string } = { ok: false, output: '' }; + try { result = await window.devdeck.cockpit.updateClaude(); } catch (err) { result = { ok: false, output: String(err) }; } + const lastLine = result.output.split('\n').map((s) => s.trim()).filter(Boolean).pop() ?? ''; + toast(result.ok ? tr('cockpit.update_all_done', { detail: lastLine.slice(0, 160) }) : tr('cockpit.update_all_failed', { detail: lastLine.slice(0, 160) })); + window.devdeck.logDiagnostic(`claude update ${result.ok ? 'ok' : 'failed'}: ${lastLine.slice(0, 200)}`, result.ok ? 'info' : 'warn', 'cockpit'); + // Every conversation comes back whether or not the update did: the sessions were stopped for it. + for (const plan of plans) { + if (await reopen(plan)) { restorable = restorable.filter((r) => r.tileId !== plan.entry.tileId); persist(); renderList(); } + } + } finally { + claudeUpdateRunning = false; renderHeader(); } } @@ -2107,9 +2291,16 @@ function applyAgentUpdate(id: string): void { function waitForExit(id: string, timeoutMs: number): Promise { return new Promise((resolve) => { - const done = (): void => { clearTimeout(timer); exitWaiters.delete(id); resolve(); }; + // A set: a restart and an update can both be waiting on the same tile, and one finishing must + // not take the other's waiter with it. + const done = (): void => { + clearTimeout(timer); + const set = exitWaiters.get(id); set?.delete(done); if (set && set.size === 0) exitWaiters.delete(id); + resolve(); + }; const timer = setTimeout(done, timeoutMs); - exitWaiters.set(id, done); + let set = exitWaiters.get(id); if (!set) { set = new Set(); exitWaiters.set(id, set); } + set.add(done); }); } diff --git a/src/renderer/features/cockpit/cockpit.css b/src/renderer/features/cockpit/cockpit.css index 346ddf7..c698c4a 100644 --- a/src/renderer/features/cockpit/cockpit.css +++ b/src/renderer/features/cockpit/cockpit.css @@ -70,3 +70,176 @@ .ck-rename-input[aria-invalid="true"] { border-color: var(--dd-attention); } .ck-rename-actions { display: flex; gap: 8px; } + +/* The image strip: what the session looked at, newest first, between the header and the terminal. */ +#view-cockpit .ck-images { + display: flex; + gap: 8px; + padding: 8px 14px; + overflow-x: auto; + overflow-y: hidden; + border-bottom: 1px solid var(--border); + background: var(--dd-sidebar); + flex: 0 0 auto; + scrollbar-width: thin; +} + +#view-cockpit .ck-images.hidden { + display: none; +} + +#view-cockpit .ck-img-thumb { + flex: 0 0 auto; + height: 84px; + min-width: 56px; + max-width: 180px; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--dd-canvas); + overflow: hidden; + cursor: zoom-in; +} + +#view-cockpit .ck-img-thumb img { + display: block; + height: 100%; + width: auto; + max-width: 180px; + object-fit: cover; +} + +#view-cockpit .ck-img-thumb:hover, +#view-cockpit .ck-img-thumb:focus-visible { + border-color: var(--accent-hover); +} + +/* A pasted image is the person's own; a tool's screenshot is the agent's. The edge says which. */ +#view-cockpit .ck-img-thumb.kind-pasted { + border-style: dashed; +} + +#view-cockpit .ck-img-thumb.missing { + width: 84px; + opacity: 0.45; +} + +#view-cockpit .ck-act.on { + color: var(--text); + background: var(--surface-hover); +} + +#view-cockpit .ck-act .ck-img-count { + position: absolute; + transform: translate(10px, -9px); + min-width: 14px; + padding: 0 3px; + border-radius: 7px; + font-size: 9.5px; + line-height: 14px; + background: var(--surface-hover); + color: var(--text); +} + +#view-cockpit .ck-act:has(.ck-img-count) { + position: relative; +} + +#view-cockpit .ck-act .ck-img-count.new { + background: var(--accent); + color: #fff; +} + +/* A file held over the terminal: say it will be taken. */ +#view-cockpit .ck-term.drop-target { + outline: 2px dashed var(--accent-hover); + outline-offset: -6px; +} + +/* Full-size viewer, over the whole window. */ +.ck-img-viewer { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + flex-direction: column; + background: rgba(5, 6, 9, 0.92); +} + +/* A flex box with a definite height, so the image's max-height: 100% resolves against it — in a + content-sized grid row it did not, and a tall image covered the bar below. */ +.ck-img-stage { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + position: relative; + overflow: hidden; +} + +.ck-img-stage img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 4px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.ck-img-status { + position: absolute; + color: #c9ccd4; + font-size: 13px; +} + +.ck-img-bar { + position: relative; + z-index: 1; + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + color: #e6e8ee; + background: rgba(15, 17, 22, 0.95); + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.ck-img-bar .ck-act { + color: #c9ccd4; + display: inline-grid; + width: 32px; + height: 32px; + place-items: center; + padding: 0; +} + +.ck-img-bar .ck-act:hover:not(:disabled) { + color: #fff; + background: rgba(255, 255, 255, 0.08); +} + +.ck-img-bar .ck-act:disabled { + opacity: 0.35; + cursor: default; +} + +.ck-img-bar .ck-act[hidden] { + display: none; +} + +.ck-img-counter { + font-variant-numeric: tabular-nums; + font-size: 12px; + color: #a7abb6; +} + +.ck-img-caption { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12.5px; +} diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index e4b1e53..41849da 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -65,6 +65,9 @@ declare global { liveAgent(id: string): Promise; gitInfo(projectPath: string): Promise<{ branch: string | null; dirty: number } | null>; receiveImage(base64: string): Promise; + sessionImages(projectPath: string, sessionId: string, agentId?: string): Promise; + sessionImage(projectPath: string, sessionId: string, imageId: string, size?: 'thumb' | 'full'): Promise; + imageFile(projectPath: string, filePath: string, size?: 'thumb' | 'full'): Promise; liveSessions(): Promise; sessionBuffer(id: string): Promise; sessionScreen(id: string): Promise<{ data: string; cols: number; rows: number }>; @@ -151,6 +154,12 @@ declare global { gitInfo(projectPath: string): Promise<{ branch: string | null; dirty: number } | null>; openLink(url: string): Promise; openFile(projectPath: string, filePath: string): Promise; + receiveImage(base64: string): Promise; + sessionImages(projectPath: string, sessionId: string, agentId?: string): Promise; + sessionImage(projectPath: string, sessionId: string, imageId: string, size?: 'thumb' | 'full'): Promise; + imageFile(projectPath: string, filePath: string, size?: 'thumb' | 'full'): Promise; + updateClaude(): Promise; + pathForFile(file: File): string; }; shutdown: { arm(): Promise; diff --git a/src/renderer/icons.ts b/src/renderer/icons.ts index cb74e0e..7255c7e 100644 --- a/src/renderer/icons.ts +++ b/src/renderer/icons.ts @@ -2,7 +2,7 @@ export type IconName = | 'search' | 'projects' | 'sessions' | 'tasks' | 'usage' | 'settings' | 'refresh' | 'more' | 'play' | 'panel-left' | 'folder' | 'plus' | 'restart' | 'close' | 'pin' | 'edit' | 'brain' | 'clock' - | 'trash' | 'chevron-left' | 'chevron-right' | 'chevron-down' | 'minimize' | 'maximize' | 'restore' | 'moon' | 'power' | 'globe' | 'machine'; + | 'trash' | 'chevron-left' | 'chevron-right' | 'chevron-down' | 'minimize' | 'maximize' | 'restore' | 'moon' | 'power' | 'globe' | 'machine' | 'image' | 'paperclip' | 'download'; const paths: Record = { search: '', @@ -38,6 +38,9 @@ const paths: Record = { restore: '', moon: '', power: '', + image: '', + paperclip: '', + download: '', }; export function iconMarkup(name: IconName): string { diff --git a/src/renderer/imageStrip.ts b/src/renderer/imageStrip.ts new file mode 100644 index 0000000..5e48342 --- /dev/null +++ b/src/renderer/imageStrip.ts @@ -0,0 +1,222 @@ +// src/renderer/imageStrip.ts +// The cockpit's image strip: the screenshots, pasted images and image files the selected session +// handled, as thumbnails between the header and the terminal, and a full-size viewer on click. +// +// Claude Desktop shows these inline in the conversation. A terminal draws text, so the cockpit shows +// them beside it instead — the same "did it actually do what I think it did" check, at a glance. +import type { SessionImageRef } from '../shared/sessionImages'; +import type { ViewImage } from '../main/imageView'; +import { tr } from './i18n-runtime'; +import { createIcon } from './icons'; + +/** Everything the strip needs to know about one session — where its images come from. */ +export interface ImageSource { + /** Stable per session; a change of key is a change of session. */ + key: string; + list(): Promise; + get(id: string, size: 'thumb' | 'full'): Promise; + /** Open an image FILE in this machine's viewer; absent for a session on another machine. */ + openFile?(path: string): void; +} + +const THUMB_CACHE_LIMIT = 240; +const SHOWN_LIMIT = 60; +const OPEN_PREF = 'devdeck.imageStrip.open'; + +let stripEl: HTMLElement | null = null; +let source: ImageSource | null = null; +let refs: SessionImageRef[] = []; +let open = readOpenPref(); +/** + * Per session: the newest image the person had seen when they last had the strip open (null = none). + * An id rather than a count, because the list is capped — past the cap its length stops growing. + */ +const seenId = new Map(); +const thumbs = new Map>(); +let onCountChange: () => void = () => {}; +let refreshSeq = 0; + +function readOpenPref(): boolean { + try { return localStorage.getItem(OPEN_PREF) === '1'; } catch { return false; } +} +function writeOpenPref(v: boolean): void { + try { localStorage.setItem(OPEN_PREF, v ? '1' : '0'); } catch { /* a private window simply forgets */ } +} + +export function initImageStrip(el: HTMLElement, countChanged: () => void): void { + stripEl = el; + onCountChange = countChanged; + render(); +} + +/** Point the strip at a session (or none). Cheap to call on every selection change. */ +export function showImagesFor(next: ImageSource | null): void { + if (next?.key === source?.key) { source = next; return; } + source = next; + refs = []; + render(); + void refreshImages(); +} + +export async function refreshImages(): Promise { + const src = source; if (!src) return; + const seq = ++refreshSeq; + let list: SessionImageRef[]; + try { list = await src.list(); } catch { return; } + // Compared by key: the header rebuilds the source object for the SAME session on every meta or + // git refresh, and a list arriving after one of those is still this session's list. + if (seq !== refreshSeq || src.key !== source?.key) return; + const changed = list.length !== refs.length || list[list.length - 1]?.id !== refs[refs.length - 1]?.id; + refs = list; + const newest = list.length ? list[list.length - 1].id : null; + if (!seenId.has(src.key)) seenId.set(src.key, newest); // what was there before we looked is not "new" + if (open) seenId.set(src.key, newest); + if (changed) render(); + onCountChange(); +} + +export function imageStripState(): { total: number; unseen: number; open: boolean } { + const total = refs.length; + if (!source || !seenId.has(source.key)) return { total, unseen: 0, open }; + const last = seenId.get(source.key) ?? null; + const at = last === null ? -1 : refs.findIndex((r) => r.id === last); + // Not found: either nothing had been seen, or what was seen has scrolled past the cap — all new. + return { total, unseen: at === -1 ? total : total - 1 - at, open }; +} + +export function toggleImageStrip(): void { + open = !open; + writeOpenPref(open); + if (open && source) seenId.set(source.key, refs.length ? refs[refs.length - 1].id : null); + render(); + onCountChange(); +} + +export function isImageStripOpen(): boolean { return open; } + +function thumbUrl(src: ImageSource, ref: SessionImageRef): Promise { + const key = `${src.key}|${ref.id}|${ref.ts ?? ''}`; + let hit = thumbs.get(key); + if (!hit) { + hit = src.get(ref.id, 'thumb').then((img) => (img ? `data:${img.mediaType};base64,${img.data}` : null)).catch(() => null); + thumbs.set(key, hit); + if (thumbs.size > THUMB_CACHE_LIMIT) thumbs.delete(thumbs.keys().next().value as string); + } else { thumbs.delete(key); thumbs.set(key, hit); } // most recently used stays + return hit; +} + +function render(): void { + if (!stripEl) return; + const show = open && !!source && refs.length > 0; + stripEl.classList.toggle('hidden', !show); + stripEl.replaceChildren(); + if (!show || !source) return; + const src = source; + const shown = refs.slice(-SHOWN_LIMIT).reverse(); // newest first: what just happened is what you look for + for (const ref of shown) { + const b = document.createElement('button'); b.type = 'button'; b.className = `ck-img-thumb kind-${ref.kind}`; + const label = describe(ref); + b.title = label; b.setAttribute('aria-label', label); + const img = document.createElement('img'); img.alt = ''; img.decoding = 'async'; img.loading = 'lazy'; + b.appendChild(img); + void thumbUrl(src, ref).then((url) => { + if (url) img.src = url; + else { b.classList.add('missing'); b.title = `${label} · ${tr('cockpit.image_unavailable')}`; } + }); + // Looked up by id at click time: a refresh replaces the list with fresh objects without + // necessarily re-rendering, and the button must still open the image it shows. + const id = ref.id; + b.addEventListener('click', () => openViewer(src, refs, refs.findIndex((r) => r.id === id))); + stripEl.appendChild(b); + } +} + +function describe(ref: SessionImageRef): string { + const what = ref.kind === 'file' ? (ref.path ?? '') : ref.kind === 'pasted' ? tr('cockpit.image_pasted_kind') : prettyTool(ref.tool) || tr('cockpit.image_screenshot_kind'); + const when = ref.ts ? new Date(ref.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; + return when ? `${what} · ${when}` : what; +} + +/** `mcp__playwright__browser_take_screenshot` → `playwright · browser_take_screenshot`. */ +function prettyTool(tool: string | null): string { + if (!tool) return ''; + const m = /^mcp__(.+?)__(.+)$/.exec(tool); + return m ? `${m[1]} · ${m[2]}` : tool; +} + +/** Full-size viewer over the whole window: ←/→ step through the session's images, Esc closes. */ +function openViewer(src: ImageSource, list: SessionImageRef[], startIndex: number): void { + let index = startIndex < 0 ? list.length - 1 : startIndex; + const overlay = document.createElement('div'); overlay.className = 'ck-img-viewer'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); + const stage = document.createElement('div'); stage.className = 'ck-img-stage'; + const img = document.createElement('img'); img.alt = ''; + const status = document.createElement('div'); status.className = 'ck-img-status'; + stage.append(img, status); + const bar = document.createElement('div'); bar.className = 'ck-img-bar'; + const caption = document.createElement('span'); caption.className = 'ck-img-caption'; + const counter = document.createElement('span'); counter.className = 'ck-img-counter'; + const mk = (icon: Parameters[0], label: string, fn: () => void): HTMLButtonElement => { + const b = document.createElement('button'); b.type = 'button'; b.className = 'ck-act'; b.title = label; b.setAttribute('aria-label', label); + b.appendChild(createIcon(icon)); b.addEventListener('click', (e) => { e.stopPropagation(); fn(); }); return b; + }; + // Spatially the same as the strip it was opened from, where the newest image is leftmost. + const prev = mk('chevron-left', tr('cockpit.image_prev'), () => step(1)); + const next = mk('chevron-right', tr('cockpit.image_next'), () => step(-1)); + const save = mk('download', tr('cockpit.image_save'), () => { + if (!img.src) return; + const a = document.createElement('a'); a.href = img.src; a.download = fileNameFor(list[index], img.src); a.click(); + }); + const openBtn = mk('folder', tr('cockpit.image_open_file'), () => { const r = list[index]; if (r.path && src.openFile) src.openFile(r.path); }); + const close = mk('close', tr('cockpit.close'), () => done()); + bar.append(prev, counter, next, caption, save, openBtn, close); + overlay.append(stage, bar); + + let seq = 0; + const show = (): void => { + const ref = list[index]; + const my = ++seq; + caption.textContent = describe(ref); + counter.textContent = `${list.length - index} / ${list.length}`; // counted newest-first, like the strip + openBtn.hidden = !(ref.kind === 'file' && src.openFile); + prev.disabled = index >= list.length - 1; next.disabled = index <= 0; // prev = newer, next = older + status.textContent = tr('cockpit.image_loading'); img.removeAttribute('src'); + void src.get(ref.id, 'full').then((v) => { + if (my !== seq) return; + if (!v) { status.textContent = tr('cockpit.image_unavailable'); return; } + status.textContent = ''; + img.src = `data:${v.mediaType};base64,${v.data}`; + }).catch(() => { if (my === seq) status.textContent = tr('cockpit.image_unavailable'); }); + }; + /** +1 = one newer, -1 = one older. */ + const step = (d: number): void => { + const to = index + d; + if (to < 0 || to >= list.length) return; + index = to; show(); + }; + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { e.preventDefault(); done(); } + else if (e.key === 'ArrowLeft') { e.preventDefault(); step(1); } + else if (e.key === 'ArrowRight') { e.preventDefault(); step(-1); } + }; + const restoreFocus = document.activeElement as HTMLElement | null; + const done = (): void => { document.removeEventListener('keydown', onKey, true); overlay.remove(); restoreFocus?.focus?.(); }; + overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target === stage) done(); }); + document.addEventListener('keydown', onKey, true); + document.body.appendChild(overlay); + close.focus(); + show(); +} + +function fileNameFor(ref: SessionImageRef, dataUrl: string): string { + if (ref.path) return ref.path.split(/[\\/]/).pop() || 'image.png'; + const ext = /^data:image\/(\w+)/.exec(dataUrl)?.[1]?.replace('jpeg', 'jpg') ?? 'png'; + const t = ref.ts ? new Date(ref.ts) : new Date(); + const p = (n: number): string => String(n).padStart(2, '0'); + return `devdeck-${t.getFullYear()}${p(t.getMonth() + 1)}${p(t.getDate())}-${p(t.getHours())}${p(t.getMinutes())}${p(t.getSeconds())}.${ext}`; +} + +/** Show one image that is not part of a session's list — a path clicked in a remote terminal. */ +export function openSingleImage(load: () => Promise, caption: string): void { + const ref: SessionImageRef = { id: 'single', kind: 'file', ts: null, tool: null, path: caption }; + openViewer({ key: `single:${caption}`, list: async () => [ref], get: () => load() }, [ref], 0); +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 77bf4e6..6742826 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -126,6 +126,7 @@

+
diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 368317c..589a4e1 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -468,6 +468,29 @@ "cockpit.stale_to_previous": "{n} sessions are no longer running on {machine} — moved to previous sessions", "cockpit.remote_file_unavailable": "That file is on {machine}, not on this computer.", "cockpit.redrawn": "Redrew {count} terminal(s) from their sessions.", + "cockpit.attach": "Attach files", + "cockpit.attach_remote": "Attach images (sent to the machine running this session)", + "cockpit.attached": "📎 Attached {count}", + "cockpit.attach_images_only": "{count} skipped — only images can be sent to {machine}", + "cockpit.images_show": "Show images from this session", + "cockpit.images_hide": "Hide images", + "cockpit.image_unavailable": "Image not available", + "cockpit.image_pasted_kind": "Pasted image", + "cockpit.image_screenshot_kind": "Screenshot", + "cockpit.image_prev": "Newer", + "cockpit.image_next": "Older", + "cockpit.image_save": "Save image", + "cockpit.image_open_file": "Open file", + "cockpit.image_loading": "Loading…", + "cockpit.update_all": "Update all ({count})", + "cockpit.update_all_running": "Updating Claude…", + "cockpit.update_all_title": "Stop every Claude session here, run \"claude update\", then resume the same conversations", + "cockpit.update_all_confirm": "Stop {count} Claude session(s), update Claude Code, and reopen the same conversations? Sessions in the middle of a turn are waited for first.", + "cockpit.update_all_waiting": "Waiting for running turns to finish before updating…", + "cockpit.update_all_updating": "Running claude update…", + "cockpit.update_all_done": "Claude updated — reopening sessions. {detail}", + "cockpit.update_all_failed": "Claude update failed — reopening sessions anyway. {detail}", + "cockpit.update_all_gave_up": "Some Claude sessions are still busy after 15 minutes — update cancelled, nothing was stopped.", "cockpit.remote_image_unsupported": "Pasting an image into a session on another machine is not supported yet.", "proj.remote_local_only": "That folder is on {machine}. This action only works on projects on this computer.", "usage.machine_title": "Local usage on {machine}", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index ced6e3c..4bdb44d 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -468,6 +468,29 @@ "cockpit.stale_to_previous": "{machine} で実行されていないセッション {n} 件を以前のセッションへ移動しました", "cockpit.remote_file_unavailable": "そのファイルはこの PC ではなく {machine} にあります。", "cockpit.redrawn": "ターミナル {count} 個をセッションから描き直しました。", + "cockpit.attach": "ファイルを添付", + "cockpit.attach_remote": "画像を添付(このセッションを実行中のマシンへ送信)", + "cockpit.attached": "📎 {count} 件を添付", + "cockpit.attach_images_only": "{count} 件をスキップ — {machine} には画像のみ送信できます", + "cockpit.images_show": "このセッションの画像を表示", + "cockpit.images_hide": "画像を隠す", + "cockpit.image_unavailable": "画像を読み込めません", + "cockpit.image_pasted_kind": "貼り付けた画像", + "cockpit.image_screenshot_kind": "スクリーンショット", + "cockpit.image_prev": "新しい方", + "cockpit.image_next": "古い方", + "cockpit.image_save": "画像を保存", + "cockpit.image_open_file": "ファイルを開く", + "cockpit.image_loading": "読み込み中…", + "cockpit.update_all": "すべて更新 ({count})", + "cockpit.update_all_running": "Claude を更新中…", + "cockpit.update_all_title": "この PC の Claude セッションをすべて停止して \"claude update\" を実行し、同じ会話を再開します", + "cockpit.update_all_confirm": "Claude セッション {count} 件を停止して Claude Code を更新し、同じ会話を再び開きますか?作業中のセッションはターンの終了を待ちます。", + "cockpit.update_all_waiting": "実行中のターンが終わるのを待ってから更新します…", + "cockpit.update_all_updating": "claude update を実行中…", + "cockpit.update_all_done": "Claude を更新しました — セッションを再び開きます。{detail}", + "cockpit.update_all_failed": "Claude の更新に失敗しました — セッションはそのまま再び開きます。{detail}", + "cockpit.update_all_gave_up": "15 分経っても作業中の Claude セッションがあるため更新を中止しました — どのセッションも停止していません。", "cockpit.remote_image_unsupported": "ほかのデバイスのセッションへの画像貼り付けはまだ対応していません。", "proj.remote_local_only": "そのフォルダーは {machine} にあります。この操作はこの PC のプロジェクトでのみ使えます。", "usage.machine_title": "{machine} のローカル使用量", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 57707e2..fdc5d88 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -468,6 +468,29 @@ "cockpit.stale_to_previous": "{machine}에서 더 이상 실행되지 않는 세션 {n}개를 이전 세션으로 옮겼습니다", "cockpit.remote_file_unavailable": "그 파일은 이 컴퓨터가 아니라 {machine}에 있습니다.", "cockpit.redrawn": "터미널 {count}개를 세션에서 다시 그렸습니다.", + "cockpit.attach": "파일 첨부", + "cockpit.attach_remote": "이미지 첨부 (이 세션을 실행 중인 기기로 전송)", + "cockpit.attached": "📎 {count}개 첨부함", + "cockpit.attach_images_only": "{count}개 건너뜀 — {machine}에는 이미지만 보낼 수 있습니다", + "cockpit.images_show": "이 세션의 이미지 보기", + "cockpit.images_hide": "이미지 숨기기", + "cockpit.image_unavailable": "이미지를 불러올 수 없음", + "cockpit.image_pasted_kind": "붙여넣은 이미지", + "cockpit.image_screenshot_kind": "스크린샷", + "cockpit.image_prev": "더 최근", + "cockpit.image_next": "이전", + "cockpit.image_save": "이미지 저장", + "cockpit.image_open_file": "파일 열기", + "cockpit.image_loading": "불러오는 중…", + "cockpit.update_all": "모두 업데이트 ({count})", + "cockpit.update_all_running": "Claude 업데이트 중…", + "cockpit.update_all_title": "이 PC의 Claude 세션을 모두 멈추고 \"claude update\"를 실행한 뒤 같은 대화로 다시 엽니다", + "cockpit.update_all_confirm": "Claude 세션 {count}개를 멈추고 Claude Code를 업데이트한 뒤 같은 대화로 다시 열까요? 작업 중인 세션은 턴이 끝날 때까지 기다립니다.", + "cockpit.update_all_waiting": "진행 중인 턴이 끝나길 기다린 뒤 업데이트합니다…", + "cockpit.update_all_updating": "claude update 실행 중…", + "cockpit.update_all_done": "Claude 업데이트 완료 — 세션을 다시 엽니다. {detail}", + "cockpit.update_all_failed": "Claude 업데이트 실패 — 세션은 그대로 다시 엽니다. {detail}", + "cockpit.update_all_gave_up": "15분이 지나도 작업 중인 Claude 세션이 있어 업데이트를 취소했습니다 — 아무 세션도 멈추지 않았습니다.", "cockpit.remote_image_unsupported": "다른 기기의 세션에 이미지를 붙여넣는 건 아직 지원하지 않습니다.", "proj.remote_local_only": "그 폴더는 {machine}에 있습니다. 이 동작은 이 컴퓨터의 프로젝트에서만 됩니다.", "usage.machine_title": "{machine}의 로컬 사용량", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 7306a5f..cace641 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -468,6 +468,29 @@ "cockpit.stale_to_previous": "{machine} 上已不再运行的 {n} 个会话已移至以前的会话", "cockpit.remote_file_unavailable": "该文件位于 {machine},而不是这台电脑上。", "cockpit.redrawn": "已从会话重绘 {count} 个终端。", + "cockpit.attach": "附加文件", + "cockpit.attach_remote": "附加图片(发送到运行此会话的设备)", + "cockpit.attached": "📎 已附加 {count} 个", + "cockpit.attach_images_only": "跳过 {count} 个 — 只能向 {machine} 发送图片", + "cockpit.images_show": "显示此会话的图片", + "cockpit.images_hide": "隐藏图片", + "cockpit.image_unavailable": "无法加载图片", + "cockpit.image_pasted_kind": "粘贴的图片", + "cockpit.image_screenshot_kind": "截图", + "cockpit.image_prev": "较新", + "cockpit.image_next": "较旧", + "cockpit.image_save": "保存图片", + "cockpit.image_open_file": "打开文件", + "cockpit.image_loading": "加载中…", + "cockpit.update_all": "全部更新 ({count})", + "cockpit.update_all_running": "正在更新 Claude…", + "cockpit.update_all_title": "停止此电脑上所有 Claude 会话,运行 \"claude update\",然后恢复相同的对话", + "cockpit.update_all_confirm": "要停止 {count} 个 Claude 会话、更新 Claude Code 并重新打开相同的对话吗?正在工作的会话会先等待本轮结束。", + "cockpit.update_all_waiting": "等待进行中的回合结束后再更新…", + "cockpit.update_all_updating": "正在运行 claude update…", + "cockpit.update_all_done": "Claude 已更新 — 正在重新打开会话。{detail}", + "cockpit.update_all_failed": "Claude 更新失败 — 仍会重新打开会话。{detail}", + "cockpit.update_all_gave_up": "15 分钟后仍有 Claude 会话在工作,已取消更新 — 没有停止任何会话。", "cockpit.remote_image_unsupported": "暂不支持向其他设备上的会话粘贴图片。", "proj.remote_local_only": "该文件夹位于 {machine}。此操作仅适用于本机上的项目。", "usage.machine_title": "{machine} 的本地用量", diff --git a/src/renderer/machineDeck.ts b/src/renderer/machineDeck.ts index c6d2477..11737b1 100644 --- a/src/renderer/machineDeck.ts +++ b/src/renderer/machineDeck.ts @@ -41,6 +41,10 @@ export interface DeckFacade { liveSessionId: (projectPath: string, opts: unknown) => Promise; liveAgent: (id: string) => Promise; gitInfo: (projectPath: string) => Promise<{ branch: string | null; dirty: number } | null>; + receiveImage: (base64: string) => Promise; + sessionImages: (projectPath: string, sessionId: string, agentId?: string) => Promise; + sessionImage: (projectPath: string, sessionId: string, imageId: string, size?: 'thumb' | 'full') => Promise; + imageFile: (projectPath: string, filePath: string, size?: 'thumb' | 'full') => Promise; }; } @@ -98,6 +102,11 @@ export function deckFor(machineId: string): DeckFacade { // themselves; the machine's own API only knows the bare id it minted. liveAgent: (id) => remote.cockpit.liveAgent(parseRemoteId(id).hostId), gitInfo: (projectPath) => remote.cockpit.gitInfo(projectPath), + receiveImage: (base64) => remote.cockpit.receiveImage(base64), + // A host older than the image panel has no such method; it simply has no images to show. + sessionImages: (projectPath, sessionId, agentId) => remote.cockpit.sessionImages(projectPath, sessionId, agentId).catch(() => []), + sessionImage: (projectPath, sessionId, imageId, size) => remote.cockpit.sessionImage(projectPath, sessionId, imageId, size).catch(() => null), + imageFile: (projectPath, filePath, size) => remote.cockpit.imageFile(projectPath, filePath, size).catch(() => null), }, }; } diff --git a/src/shared/imageSniff.test.ts b/src/shared/imageSniff.test.ts new file mode 100644 index 0000000..4b97382 --- /dev/null +++ b/src/shared/imageSniff.test.ts @@ -0,0 +1,24 @@ +// src/shared/imageSniff.test.ts +import { describe, it, expect } from 'vitest'; +import { sniffImageType } from './imageSniff'; + +const bytes = (...parts: (number[] | string)[]): Uint8Array => + Uint8Array.from(parts.flatMap((p) => (typeof p === 'string' ? [...p].map((c) => c.charCodeAt(0)) : p))); + +describe('sniffImageType', () => { + it('names each supported format by its magic bytes', () => { + expect(sniffImageType(bytes([0x89], 'PNG', [0x0d, 0x0a, 0x1a, 0x0a, 0]))).toBe('image/png'); + expect(sniffImageType(bytes([0xff, 0xd8, 0xff, 0xe0]))).toBe('image/jpeg'); + expect(sniffImageType(bytes('GIF89a'))).toBe('image/gif'); + expect(sniffImageType(bytes('RIFF', [0, 0, 0, 0], 'WEBPVP8 '))).toBe('image/webp'); + expect(sniffImageType(bytes('BM', new Array(30).fill(0)))).toBe('image/bmp'); + }); + + it('refuses anything else, whatever it is called — text, keys, a truncated header', () => { + expect(sniffImageType(bytes('-----BEGIN OPENSSH PRIVATE KEY-----'))).toBeNull(); + expect(sniffImageType(bytes('{"token":"x"}'))).toBeNull(); + expect(sniffImageType(bytes([0x89], 'PN'))).toBeNull(); + expect(sniffImageType(bytes('BM'))).toBeNull(); // two letters are not a bitmap + expect(sniffImageType(new Uint8Array())).toBeNull(); + }); +}); diff --git a/src/shared/imageSniff.ts b/src/shared/imageSniff.ts new file mode 100644 index 0000000..edb323a --- /dev/null +++ b/src/shared/imageSniff.ts @@ -0,0 +1,17 @@ +// src/shared/imageSniff.ts + +const ascii = (b: Uint8Array, start: number, text: string): boolean => + b.length >= start + text.length && [...text].every((ch, i) => b[start + i] === ch.charCodeAt(0)); + +/** + * What the bytes ARE, from their first bytes — never from a file name or a claimed type. A file that + * is not one of these is not an image, whatever it is called, and is never sent. + */ +export function sniffImageType(b: Uint8Array): string | null { + if (b.length >= 8 && b[0] === 0x89 && ascii(b, 1, 'PNG') && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) return 'image/png'; + if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'; + if (ascii(b, 0, 'GIF8')) return 'image/gif'; + if (ascii(b, 0, 'RIFF') && ascii(b, 8, 'WEBP')) return 'image/webp'; + if (ascii(b, 0, 'BM') && b.length >= 26) return 'image/bmp'; + return null; +} diff --git a/src/shared/sessionImages.ts b/src/shared/sessionImages.ts new file mode 100644 index 0000000..87f7cce --- /dev/null +++ b/src/shared/sessionImages.ts @@ -0,0 +1,143 @@ +// src/shared/sessionImages.ts +// What a Claude session has LOOKED AT, read off its transcript: the screenshots its tools returned, +// the images pasted into it, and the image files it wrote or read. +// +// Claude Desktop shows those inline; a terminal cannot, so the cockpit shows them beside it. The +// transcript is the only record that covers every source — a screenshot from an MCP tool exists +// nowhere else on disk, only as a base64 block inside the tool result that carried it. + +/** `screenshot` = an image a tool returned; `pasted` = an image the person sent; `file` = an image path a tool touched. */ +export type SessionImageKind = 'screenshot' | 'pasted' | 'file'; + +export interface SessionImageRef { + /** `.` for an embedded image (the n-th image block on that line); `f:` for a file. */ + id: string; + kind: SessionImageKind; + /** Epoch ms of the transcript line, when it carried one. */ + ts: number | null; + /** The tool that produced or touched it (`mcp__playwright__browser_take_screenshot`, `Write`, …). */ + tool: string | null; + /** For `file`: the path exactly as the tool named it. */ + path: string | null; +} + +const IMAGE_FILE_RE = /\.(?:png|jpe?g|gif|webp|bmp)$/i; +/** Tool-input fields that name a file the tool reads or writes. */ +const PATH_FIELDS = ['file_path', 'path', 'filePath', 'output_path', 'outputPath', 'filename']; +/** How long a tool name is worth remembering for: tool results follow their call within a few lines. */ +const TOOL_NAME_MEMORY = 256; +const TOOL_USE_RE = /"type":"tool_use","id":"([^"]{1,100})","name":"([^"]{1,200})"/g; + +/** Cheap pre-filter on the raw bytes of a line, so most lines are never JSON-parsed at all. */ +export function lineMayHoldImage(line: string): boolean { + return line.includes('"type":"image"') || (line.includes('"tool_use"') && /\.(?:png|jpe?g|gif|webp|bmp)"/i.test(line)); +} + +interface Block { type?: unknown; source?: unknown; content?: unknown; tool_use_id?: unknown; id?: unknown; name?: unknown; input?: unknown } + +function isEmbeddedImage(b: Block): boolean { + if (b.type !== 'image' || !b.source || typeof b.source !== 'object') return false; + const s = b.source as { type?: unknown; data?: unknown }; + return s.type === 'base64' && typeof s.data === 'string' && s.data.length > 0; +} + +/** + * Every embedded image block on a parsed line, in a fixed order — the order `id`'s `n` counts in, so + * the same walk finds a block again when its bytes are asked for. + */ +export function embeddedImageBlocks(entry: unknown): { block: { media_type: string; data: string }; kind: 'screenshot' | 'pasted'; toolUseId: string | null }[] { + const out: { block: { media_type: string; data: string }; kind: 'screenshot' | 'pasted'; toolUseId: string | null }[] = []; + const content = (entry as { message?: { content?: unknown } })?.message?.content; + if (!Array.isArray(content)) return out; + const push = (b: Block, kind: 'screenshot' | 'pasted', toolUseId: string | null): void => { + const s = b.source as { media_type?: unknown; data: string }; + out.push({ block: { media_type: typeof s.media_type === 'string' ? s.media_type : 'image/png', data: s.data }, kind, toolUseId }); + }; + for (const raw of content) { + const b = (raw ?? {}) as Block; + if (isEmbeddedImage(b)) push(b, 'pasted', null); + else if (b.type === 'tool_result' && Array.isArray(b.content)) { + const toolUseId = typeof b.tool_use_id === 'string' ? b.tool_use_id : null; + for (const inner of b.content) if (isEmbeddedImage((inner ?? {}) as Block)) push(inner as Block, 'screenshot', toolUseId); + } + } + return out; +} + +/** + * Reads image references off transcript lines, in file order. + * + * Stateful only to name tools: a tool RESULT carries the call's id, not its name, so the names of + * recent calls are remembered (boundedly) as their lines go by. + */ +export class SessionImageCollector { + private toolNames = new Map(); + + /** `line` is one complete transcript line; `offset` is the byte offset it starts at. */ + add(line: string, offset: number): SessionImageRef[] { + // Tool calls are named on every line that makes one — most never hold an image, so this is a + // regex over the raw text rather than a parse. + if (line.includes('"tool_use"')) { + for (const m of line.matchAll(TOOL_USE_RE)) this.remember(m[1], m[2]); + } + if (!lineMayHoldImage(line)) return []; + let entry: unknown; + try { entry = JSON.parse(line); } catch { return []; } + const tsRaw = (entry as { timestamp?: unknown }).timestamp; + const parsedTs = typeof tsRaw === 'string' ? Date.parse(tsRaw) : NaN; + const ts = Number.isFinite(parsedTs) ? parsedTs : null; + const refs: SessionImageRef[] = []; + + const content = (entry as { message?: { content?: unknown } })?.message?.content; + if (Array.isArray(content)) { + for (const raw of content) { + const b = (raw ?? {}) as Block; + if (b.type !== 'tool_use' || typeof b.name !== 'string') continue; + if (typeof b.id === 'string') this.remember(b.id, b.name); + const input = (b.input ?? {}) as Record; + for (const field of PATH_FIELDS) { + const p = input[field]; + if (typeof p === 'string' && p.length < 1024 && IMAGE_FILE_RE.test(p)) { + refs.push({ id: `f:${p}`, kind: 'file', ts, tool: b.name, path: p }); + break; + } + } + } + } + embeddedImageBlocks(entry).forEach((img, n) => { + refs.push({ id: `${offset}.${n}`, kind: img.kind, ts, tool: img.toolUseId ? this.toolNames.get(img.toolUseId) ?? null : null, path: null }); + }); + return refs; + } + + private remember(id: string, name: string): void { + this.toolNames.set(id, name); + if (this.toolNames.size > TOOL_NAME_MEMORY) this.toolNames.delete(this.toolNames.keys().next().value as string); + } +} + +/** + * Keep the newest `limit` references, one per file path (a file the agent rewrote ten times is one + * image, shown at its latest touch). Newest last, as in the transcript. + */ +export function mergeImageRefs(existing: SessionImageRef[], added: SessionImageRef[], limit: number): SessionImageRef[] { + if (added.length === 0) return existing; + const addedFiles = new Set(added.filter((r) => r.kind === 'file').map((r) => r.id)); + const merged = existing.filter((r) => !addedFiles.has(r.id)); + const seen = new Set(); + const dedupedAdded: SessionImageRef[] = []; + for (let i = added.length - 1; i >= 0; i--) { + const r = added[i]; + if (r.kind === 'file') { if (seen.has(r.id)) continue; seen.add(r.id); } + dedupedAdded.unshift(r); + } + const all = merged.concat(dedupedAdded); + return all.length > limit ? all.slice(all.length - limit) : all; +} + +/** Parse an embedded-image id back into where its line starts and which block it is. */ +export function parseEmbeddedImageId(id: string): { offset: number; n: number } | null { + const m = /^(\d{1,15})\.(\d{1,4})$/.exec(id); + if (!m) return null; + return { offset: Number(m[1]), n: Number(m[2]) }; +} diff --git a/src/shared/sessionStatus.test.ts b/src/shared/sessionStatus.test.ts index 89a15ec..e222f1b 100644 --- a/src/shared/sessionStatus.test.ts +++ b/src/shared/sessionStatus.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { agentUpdateReady, stripAnsi, hasPromptPattern, hasWorkingSpinner, computeActivity, WORKING_MS, IDLE_MS } from './sessionStatus'; +import { agentUpdateReady, agentUpdateFailed, stripAnsi, hasPromptPattern, hasWorkingSpinner, computeActivity, WORKING_MS, IDLE_MS } from './sessionStatus'; describe('stripAnsi', () => { const ESC = String.fromCharCode(27); @@ -115,6 +115,14 @@ describe('computeActivity', () => { }); }); +describe('agentUpdateFailed', () => { + it('reads Claude Code\'s failed background update off its footer, for Claude only', () => { + expect(agentUpdateFailed('claude', '> \n ✗ Auto-update failed · Try claude doctor\n')).toBe(true); + expect(agentUpdateFailed('claude', '> \n Update installed · Restart to apply\n')).toBe(false); + expect(agentUpdateFailed('codex', 'Auto-update failed')).toBe(false); + }); +}); + describe('agentUpdateReady', () => { it("reads Claude Code's footer notice", () => { expect(agentUpdateReady('claude', '> \n ⏵⏵ bypass permissions on Update installed · Restart to apply\n')).toBe(true); diff --git a/src/shared/sessionStatus.ts b/src/shared/sessionStatus.ts index e7340d9..9fe2e79 100644 --- a/src/shared/sessionStatus.ts +++ b/src/shared/sessionStatus.ts @@ -107,3 +107,13 @@ export function agentUpdateReady(agentId: string, screenText: string): boolean { // carry it turns it into something else without changing what the notice says. return /Update installed\W{1,6}Restart to apply/i.test(screenText); } + +/** + * Whether the agent's screen says its own background update FAILED. On Windows that is what happens + * while other sessions are running the executable it needs to replace — the fix is to stop them all, + * update, and start them again, which the cockpit offers as one action. + */ +export function agentUpdateFailed(agentId: string, screenText: string): boolean { + if (agentId !== 'claude') return false; + return /Auto-?update failed/i.test(screenText); +}