From c6f1ca090cbbdce14a3048c1926aa905c8dd3f93 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 20 Oct 2026 10:27:33 -0700 Subject: [PATCH 1/4] feat(cockpit): session image strip, file attach, and update all Claude sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Images a session handled, seen at a glance. Claude Desktop shows tool screenshots and pasted images inline; a terminal cannot, so the cockpit now shows them in a strip between the header and the terminal, newest first, with a full-size viewer (arrows step, Esc closes, Save). The source is the transcript itself โ€” an MCP screenshot exists nowhere else on disk, only as a base64 block in the tool result โ€” indexed incrementally (first look reads the last 64MB; measured 133MB transcript: 100ms, then 1ms per unchanged re-read). The header button shows the count, and "+N" for images that arrived while the strip was closed. Works for sessions on another machine: thumbnails are made where the image lives, so a 4K screenshot crosses the link as a ~30KB JPEG. A remote session's image path is now viewable: clicking one used to say "that file is on "; its pixels are read over there and shown here. Attach files: ๐Ÿ“Ž in the header and drag-and-drop onto the terminal. Locally the file's own path is typed into the prompt; for a session on another machine an image is converted to PNG (scaled down until it fits the 5MB the host accepts) and sent, and the host's path is typed. Anything that could not be attached is reported, never silently dropped. Update all Claude sessions: 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. When a Claude screen shows "Auto-update failed" (or "Update installed" with other sessions to take along), the header offers "Update all (N)": wait out running turns, stop every local Claude session, run `claude update`, and resume each conversation by id โ€” reopened even if the update failed. New harnesses qa:images and qa:update drive all of this in the real app with fixture CLIs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NLGwiBY5uEivJqERQzcoY --- docs/quality.md | 2 + package.json | 4 +- qa/claude-update.mjs | 107 +++++++++++ qa/images.mjs | 126 +++++++++++++ src/main/agentUpdate.ts | 42 +++++ src/main/api/deckApi.ts | 47 ++++- src/main/imageView.ts | 39 ++++ src/main/sessionImages.test.ts | 95 ++++++++++ src/main/sessionImages.ts | 124 +++++++++++++ src/preload/preload.ts | 12 +- src/renderer/attachments.ts | 79 ++++++++ src/renderer/cockpitView.ts | 200 +++++++++++++++++++-- src/renderer/features/cockpit/cockpit.css | 166 +++++++++++++++++ src/renderer/global.d.ts | 9 + src/renderer/icons.ts | 5 +- src/renderer/imageStrip.ts | 210 ++++++++++++++++++++++ src/renderer/index.html | 1 + src/renderer/locales/en.json | 22 +++ src/renderer/locales/ja.json | 22 +++ src/renderer/locales/ko.json | 22 +++ src/renderer/locales/zh.json | 22 +++ src/renderer/machineDeck.ts | 9 + src/shared/sessionImages.ts | 143 +++++++++++++++ src/shared/sessionStatus.test.ts | 10 +- src/shared/sessionStatus.ts | 10 ++ 25 files changed, 1508 insertions(+), 20 deletions(-) create mode 100644 qa/claude-update.mjs create mode 100644 qa/images.mjs create mode 100644 src/main/agentUpdate.ts create mode 100644 src/main/imageView.ts create mode 100644 src/main/sessionImages.test.ts create mode 100644 src/main/sessionImages.ts create mode 100644 src/renderer/attachments.ts create mode 100644 src/renderer/imageStrip.ts create mode 100644 src/shared/sessionImages.ts diff --git a/docs/quality.md b/docs/quality.md index 00ba5c2..69f6d9a 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. Neither 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.json b/package.json index f846fef..dbead8a 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "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: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.mjs b/qa/images.mjs new file mode 100644 index 0000000..d04db27 --- /dev/null +++ b/qa/images.mjs @@ -0,0 +1,126 @@ +// 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 } 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')); +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('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..de445d4 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, 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, IMAGE_MIME, 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'; @@ -1154,6 +1157,48 @@ 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. */ + 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; + if (imageId.startsWith('f:')) return readImageFileFor(pp, imageId.slice(2), size); + const file = claudeTranscriptPath(pp, sessionId, CLAUDE_PROJECTS); + const img = file ? await readEmbeddedImage(file, imageId) : null; + return img ? encodeImageForView(img.data, img.mediaType, size) : null; + }); + /** + * 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. Same guards as opening one locally, minus anything that is not an image. + */ + invoke('cockpit:imageFile', allow('observe'), (projectPath: string, filePath: string, size?: string) => + readImageFileFor(String(projectPath), String(filePath), size)); + async function readImageFileFor(projectPath: string, filePath: string, size?: string): Promise { + if (!isAllowedPath(effFolders(), projectPath)) return null; + const resolved = resolveAgentFilePath(projectPath, filePath, homedir()); + if (!VIEWABLE_IMAGE_EXT.test(resolved) || !isAllowedFilePath(effFolders(), resolved, [tmpdir()])) return null; + try { + const st = await stat(resolved); + if (!st.isFile() || st.size > MAX_VIEW_IMAGE_FILE_BYTES) return null; + const ext = resolved.slice(resolved.lastIndexOf('.') + 1).toLowerCase(); + return encodeImageForView(await readFile(resolved), IMAGE_MIME[ext] ?? 'image/png', 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..a8cf095 --- /dev/null +++ b/src/main/imageView.ts @@ -0,0 +1,39 @@ +// 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'; + +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 IMAGE_MIME: Record = { + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', +}; +export const MAX_VIEW_IMAGE_FILE_BYTES = 40 * 1024 * 1024; +const THUMB_WIDTH = 360; +const FULL_MAX_WIDTH = 2560; +/** 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. Formats Electron cannot decode (webp, gif) go as they are โ€” the renderer's + * can โ€” or not at all when too large. + */ +export function encodeImageForView(bytes: Buffer, mediaType: string, size?: string): ViewImage | 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 && width <= FULL_MAX_WIDTH * 2) return { mediaType, data: bytes.toString('base64'), width, height }; + const scaled = width > FULL_MAX_WIDTH ? img.resize({ width: FULL_MAX_WIDTH, quality: 'best' }) : img; + const ss = scaled.getSize(); + return { mediaType: 'image/jpeg', data: scaled.toJPEG(88).toString('base64'), width: ss.width, height: ss.height }; +} 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..23dd2ce --- /dev/null +++ b/src/main/sessionImages.ts @@ -0,0 +1,124 @@ +// 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; +/** 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(CHUNK_BYTES, size - pos)); + 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..76efbb5 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. */ @@ -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', () => { @@ -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,6 +1664,11 @@ 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; @@ -1599,6 +1681,8 @@ function tickActivity(): void { } 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,60 @@ 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; renderHeader(); + try { + const deadline = Date.now() + UPDATE_WAIT_FOR_TURNS_MS; + let told = false; + while (localClaudeTiles().some((l) => l.session.activity === 'working') && Date.now() < deadline) { + if (!told) { toast(tr('cockpit.update_all_waiting')); told = true; } + await new Promise((r) => setTimeout(r, 1500)); + } + const tiles = localClaudeTiles(); + const plans: ReopenPlan[] = []; + for (const l of tiles) { const plan = await planReopen(l); if (plan) plans.push(plan); } + const exits = tiles.map((l) => waitForExit(l.session.id, 5_000)); + for (const l of tiles) closeSession(l.session.id); + 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) await reopen(plan); + } finally { + claudeUpdateRunning = false; renderHeader(); } } diff --git a/src/renderer/features/cockpit/cockpit.css b/src/renderer/features/cockpit/cockpit.css index 346ddf7..23de568 100644 --- a/src/renderer/features/cockpit/cockpit.css +++ b/src/renderer/features/cockpit/cockpit.css @@ -70,3 +70,169 @@ .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); +} + +.ck-img-stage { + flex: 1; + min-height: 0; + display: grid; + place-items: center; + padding: 16px; + position: relative; +} + +.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 { + 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..7b4fa54 --- /dev/null +++ b/src/renderer/imageStrip.ts @@ -0,0 +1,210 @@ +// 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: how many images the person had seen when they last had the strip open. */ +const seenCount = 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; } + if (seq !== refreshSeq || src !== source) return; // the selection moved on while we asked + const changed = list.length !== refs.length || list[list.length - 1]?.id !== refs[refs.length - 1]?.id; + refs = list; + if (!seenCount.has(src.key)) seenCount.set(src.key, list.length); // what was there before we looked is not "new" + if (open) seenCount.set(src.key, list.length); + if (changed) render(); + onCountChange(); +} + +export function imageStripState(): { total: number; unseen: number; open: boolean } { + const total = refs.length; + const seen = source ? seenCount.get(source.key) ?? total : total; + return { total, unseen: Math.max(0, total - seen), open }; +} + +export function toggleImageStrip(): void { + open = !open; + writeOpenPref(open); + if (open && source) seenCount.set(source.key, refs.length); + 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')}`; } + }); + b.addEventListener('click', () => openViewer(src, refs, refs.indexOf(ref))); + 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..3b0fafb 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -468,6 +468,28 @@ "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.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..22161b0 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -468,6 +468,28 @@ "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.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..d4af8be 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -468,6 +468,28 @@ "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.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..356be3d 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -468,6 +468,28 @@ "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.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/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); +} From 39628e18b642e4c286a69158d5810287b0d8f57c Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 20 Oct 2026 10:35:33 -0700 Subject: [PATCH 2/4] fix(cockpit): harden image reads for paired devices; strip and update-all races From review of the previous commit: - A device holding only `observe` could read any file named like an image: containment was lexical (a .png symlink or junction inside a project reached outside it), undecodable bytes were sent raw, and all of %TEMP% counted. Now the real path must also be inside the folder, the bytes must sniff as an image, only Claude's scratch area and DevDeck's own pastes count in temp, and cockpit:sessionImage serves only ids the session's index holds. - Image decode/encode runs on the main process: at most 3 at a time, thumbnails cached. - A tall full-page screenshot could exceed the 8MB link frame and drop the connection; full views are now bounded in both dimensions and re-encoded until they fit, or not sent. - Clicking a thumbnail after a refresh opened the newest image (stale object identity); a list arriving after a meta/git refresh was discarded (source compared by object, not key); the "+N" badge stopped counting at the 300-image cap (now tracks the last-seen id). - Update all: a queued single-tile update no longer fires mid-way; tiles at a permission prompt are waited for too; past 15 minutes it gives up instead of killing a turn; the tiles are saved as restorable before they are closed, so a quit during the update loses nothing; several waiters on one tile's exit no longer clobber each other. qa:images now also asserts that a non-image .png, a path outside the project, a relative escape, a junction out of the project and an unindexed id are all refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NLGwiBY5uEivJqERQzcoY --- qa/images.mjs | 19 ++++++++- src/main/api/deckApi.ts | 76 +++++++++++++++++++++++++++++------ src/main/imageView.ts | 27 ++++++++----- src/main/sessionImages.ts | 3 +- src/renderer/cockpitView.ts | 39 +++++++++++++----- src/renderer/imageStrip.ts | 30 +++++++++----- src/renderer/locales/en.json | 1 + src/renderer/locales/ja.json | 1 + src/renderer/locales/ko.json | 1 + src/renderer/locales/zh.json | 1 + src/shared/imageSniff.test.ts | 24 +++++++++++ src/shared/imageSniff.ts | 17 ++++++++ 12 files changed, 197 insertions(+), 42 deletions(-) create mode 100644 src/shared/imageSniff.test.ts create mode 100644 src/shared/imageSniff.ts diff --git a/qa/images.mjs b/qa/images.mjs index d04db27..4e6a639 100644 --- a/qa/images.mjs +++ b/qa/images.mjs @@ -2,7 +2,7 @@ // 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 } from 'node:fs'; +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'; @@ -30,6 +30,12 @@ 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); @@ -102,6 +108,17 @@ try { 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'); diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts index de445d4..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 { readFile, 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'; @@ -32,7 +32,7 @@ import { sanitizeTodos } from '../../shared/tasks'; import { getClaudeUsage, readClaudeCredentials, fetchUsageApi } from '../claudeUsage'; import { getCodexUsage, spawnCodexAppServer } from '../codexUsage'; import { claudeTranscriptPath, listSessionImages, readEmbeddedImage } from '../sessionImages'; -import { encodeImageForView, IMAGE_MIME, MAX_VIEW_IMAGE_FILE_BYTES, VIEWABLE_IMAGE_EXT, type ViewImage } from '../imageView'; +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'; @@ -78,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; @@ -1168,31 +1184,65 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { const file = claudeTranscriptPath(pp, sessionId, CLAUDE_PROJECTS); return file ? listSessionImages(file) : []; }); - /** One of those images, as a thumbnail or at full size. */ + /** + * 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; - if (imageId.startsWith('f:')) return readImageFileFor(pp, imageId.slice(2), size); const file = claudeTranscriptPath(pp, sessionId, CLAUDE_PROJECTS); - const img = file ? await readEmbeddedImage(file, imageId) : null; - return img ? encodeImageForView(img.data, img.mediaType, size) : null; + 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. Same guards as opening one locally, minus anything that is not an image. + * travel instead. */ invoke('cockpit:imageFile', allow('observe'), (projectPath: string, filePath: string, size?: string) => - readImageFileFor(String(projectPath), String(filePath), size)); + 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 { - if (!isAllowedPath(effFolders(), projectPath)) return null; + const folders = effFolders(); + if (!isAllowedPath(folders, projectPath)) return null; const resolved = resolveAgentFilePath(projectPath, filePath, homedir()); - if (!VIEWABLE_IMAGE_EXT.test(resolved) || !isAllowedFilePath(effFolders(), resolved, [tmpdir()])) return null; + 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 st = await stat(resolved); + 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; - const ext = resolved.slice(resolved.lastIndexOf('.') + 1).toLowerCase(); - return encodeImageForView(await readFile(resolved), IMAGE_MIME[ext] ?? 'image/png', size); + return encodeImageForView(await readFile(real), size); } catch { return null; } } diff --git a/src/main/imageView.ts b/src/main/imageView.ts index a8cf095..0e39f24 100644 --- a/src/main/imageView.ts +++ b/src/main/imageView.ts @@ -2,25 +2,27 @@ // 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 IMAGE_MIME: Record = { - png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', -}; 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. Formats Electron cannot decode (webp, gif) go as they are โ€” the renderer's - * can โ€” or not at all when too large. + * 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, mediaType: string, size?: string): ViewImage | null { +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; @@ -32,8 +34,15 @@ export function encodeImageForView(bytes: Buffer, mediaType: string, size?: stri 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 && width <= FULL_MAX_WIDTH * 2) return { mediaType, data: bytes.toString('base64'), width, height }; - const scaled = width > FULL_MAX_WIDTH ? img.resize({ width: FULL_MAX_WIDTH, quality: 'best' }) : img; + 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(); - return { mediaType: 'image/jpeg', data: scaled.toJPEG(88).toString('base64'), width: ss.width, height: ss.height }; + 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.ts b/src/main/sessionImages.ts index 23dd2ce..724bed6 100644 --- a/src/main/sessionImages.ts +++ b/src/main/sessionImages.ts @@ -8,6 +8,7 @@ import { SessionImageCollector, embeddedImageBlocks, mergeImageRefs, parseEmbedd /** 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; @@ -106,7 +107,7 @@ export async function readEmbeddedImage(file: string, id: string): Promise<{ med const parts: Buffer[] = []; let total = 0; for (let pos = where.offset; pos < size && total < MAX_LINE_BYTES;) { - const buf = Buffer.alloc(Math.min(CHUNK_BYTES, size - pos)); + 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); diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index 76efbb5..3cbaddc 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -84,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>(); @@ -204,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 @@ -839,7 +839,7 @@ async function buildTile(p: OpenReq): Promise { // entirely unrelated work. Refuse and say why rather than open the wrong thing. // 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; } + 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); @@ -1675,7 +1675,7 @@ function tickActivity(): void { 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); } @@ -2236,19 +2236,31 @@ 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; renderHeader(); + 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 (localClaudeTiles().some((l) => l.session.activity === 'working') && Date.now() < deadline) { + 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: '' }; @@ -2257,7 +2269,9 @@ async function updateAllClaude(): Promise { 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) await reopen(plan); + for (const plan of plans) { + if (await reopen(plan)) { restorable = restorable.filter((r) => r.tileId !== plan.entry.tileId); persist(); renderList(); } + } } finally { claudeUpdateRunning = false; renderHeader(); } @@ -2277,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/imageStrip.ts b/src/renderer/imageStrip.ts index 7b4fa54..5e48342 100644 --- a/src/renderer/imageStrip.ts +++ b/src/renderer/imageStrip.ts @@ -27,8 +27,11 @@ let stripEl: HTMLElement | null = null; let source: ImageSource | null = null; let refs: SessionImageRef[] = []; let open = readOpenPref(); -/** Per session: how many images the person had seen when they last had the strip open. */ -const seenCount = new Map(); +/** + * 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; @@ -60,25 +63,31 @@ export async function refreshImages(): Promise { const seq = ++refreshSeq; let list: SessionImageRef[]; try { list = await src.list(); } catch { return; } - if (seq !== refreshSeq || src !== source) return; // the selection moved on while we asked + // 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; - if (!seenCount.has(src.key)) seenCount.set(src.key, list.length); // what was there before we looked is not "new" - if (open) seenCount.set(src.key, list.length); + 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; - const seen = source ? seenCount.get(source.key) ?? total : total; - return { total, unseen: Math.max(0, total - seen), open }; + 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) seenCount.set(source.key, refs.length); + if (open && source) seenId.set(source.key, refs.length ? refs[refs.length - 1].id : null); render(); onCountChange(); } @@ -114,7 +123,10 @@ function render(): void { if (url) img.src = url; else { b.classList.add('missing'); b.title = `${label} ยท ${tr('cockpit.image_unavailable')}`; } }); - b.addEventListener('click', () => openViewer(src, refs, refs.indexOf(ref))); + // 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); } } diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 3b0fafb..589a4e1 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -490,6 +490,7 @@ "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 22161b0..4bdb44d 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -490,6 +490,7 @@ "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 d4af8be..fdc5d88 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -490,6 +490,7 @@ "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 356be3d..cace641 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -490,6 +490,7 @@ "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/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; +} From 20857f8d7703a44d383f6d2de1af9840bcd009c2 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 20 Oct 2026 10:37:36 -0700 Subject: [PATCH 3/4] fix(cockpit): image viewer no longer covers its own controls; add qa:images-remote A tall image overflowed the viewer's stage and drew over the bottom bar (counter, caption, Save were hidden): max-height: 100% never resolved inside a content-sized grid row. The stage is now a flex box with a definite height and clips. qa:images-remote pairs a host and a viewer over real TLS and drives the image strip, the full view, a JPEG attach (sent as PNG, host path typed) and the link-side refusals from the viewer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NLGwiBY5uEivJqERQzcoY --- docs/quality.md | 2 +- package.json | 1 + qa/images-remote.mjs | 142 ++++++++++++++++++++++ src/renderer/features/cockpit/cockpit.css | 11 +- 4 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 qa/images-remote.mjs diff --git a/docs/quality.md b/docs/quality.md index 69f6d9a..2e63394 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -30,7 +30,7 @@ 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. Neither starts a real agent. +`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. diff --git a/package.json b/package.json index dbead8a..d1b9d0d 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "check:docs": "node scripts/check-docs.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": { 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/src/renderer/features/cockpit/cockpit.css b/src/renderer/features/cockpit/cockpit.css index 23de568..c698c4a 100644 --- a/src/renderer/features/cockpit/cockpit.css +++ b/src/renderer/features/cockpit/cockpit.css @@ -166,13 +166,17 @@ 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: grid; - place-items: center; + display: flex; + align-items: center; + justify-content: center; padding: 16px; position: relative; + overflow: hidden; } .ck-img-stage img { @@ -190,6 +194,9 @@ } .ck-img-bar { + position: relative; + z-index: 1; + flex: 0 0 auto; display: flex; align-items: center; gap: 8px; From f5ffd7a97172ae064efc225ed8ed334fd3fdc1e7 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 20 Oct 2026 11:00:00 -0700 Subject: [PATCH 4/4] release: 1.39.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NLGwiBY5uEivJqERQzcoY --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 d1b9d0d..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",