diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c834a79..2f20b35 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -133,6 +133,9 @@ jobs:
- name: Images tied to their terminal rows
if: runner.os == 'Windows'
run: node qa/image-rows.mjs
+ - name: Quick replies, timeline, image markup and sent documents
+ if: runner.os == 'Windows'
+ run: node qa/cockpit-tools.mjs
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
@@ -145,4 +148,5 @@ jobs:
qa/shots/images/
qa/shots/images-remote/
qa/shots/image-rows/
+ qa/shots/cockpit-tools/
qa/shots/claude-update/
diff --git a/README.md b/README.md
index 04d712f..cc4cd14 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ See what needs attention, resume the right conversation, and work on a paired PC
-> **1.40.0** ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).
+> **1.41.0** adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).

diff --git a/docs/quality.md b/docs/quality.md
index dc6e995..d525d12 100644
--- a/docs/quality.md
+++ b/docs/quality.md
@@ -32,7 +32,7 @@ Results and screenshots are written to `qa/shots/maintenance/` and `qa/shots/res
The CI performance gate is `qa:perf-gate` (Windows, every push): a fixture agent instead of a real one, and units that do not depend on the runner's speed — running CSS animations and DOM mutation records on an idle deck (both must be ~0), DOM mutations outside the terminals while two tiles repaint at 10fps (a per-chunk sidebar rebuild is ~8,000 against a limit of 400), and the main process's longest event-loop stall while working and while 20,000 lines stream. Each limit was checked by injecting the regression it guards against: an infinite CSS animation, a sidebar render per PTY chunk, and a 700ms synchronous block in the main process all fail it. Results go to `qa/shots/perf-gate/results.json`.
-`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. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). `qa:image-rows` (Windows) ties images to the terminal: a fixture `claude` draws a screen the way Claude Code does (`● server - tool (MCP)` calls, `⎿ [Image]` results, shaded `❯` prompts, a composer between rules) over a transcript holding the same screenshots, and the harness checks that each placeholder is marked and opens its own image, that the viewer shows what led to an image and scrolls the terminal to its row, that copying puts an image on the clipboard (the person's clipboard is restored afterwards), that a new screenshot is previewed while the strip is closed, and that Ctrl+Up/Down steps between sent prompts without entering the composer. None of these 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. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). `qa:image-rows` (Windows) ties images to the terminal: a fixture `claude` draws a screen the way Claude Code does (`● server - tool (MCP)` calls, `⎿ [Image]` results, shaded `❯` prompts, a composer between rules) over a transcript holding the same screenshots, and the harness checks that each placeholder is marked and opens its own image, that the viewer shows what led to an image and scrolls the terminal to its row, that copying puts an image on the clipboard (the person's clipboard is restored afterwards), that a new screenshot is previewed while the strip is closed, and that Ctrl+Up/Down steps between sent prompts without entering the composer. `qa:cockpit-tools` (Windows) uses a fixture `claude` that records every byte typed into it: a quick reply from the header menu and from Ctrl+Shift+
arrives as the phrase followed by Enter, phrases edited in Settings are saved and offered, the timeline lists each prompt with its time, reply and image count and scrolls the terminal to it without resizing it, and a screenshot marked up in the viewer reaches the session as a PNG that has the red marks in it. `qa:images-remote` also sends a log file from the viewer and checks it lands on the host under its own name. 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/docs/releases/1.41.0.md b/docs/releases/1.41.0.md
new file mode 100644
index 0000000..9941481
--- /dev/null
+++ b/docs/releases/1.41.0.md
@@ -0,0 +1,41 @@
+# DevDeck 1.41.0
+
+## Quick replies
+
+- **One click for what you keep typing.** A 💬 button in a session's header opens your phrases: "Yes, go ahead", "Commit it", "Run the tests and check the result", "Summarize what you changed". Picking one types it and presses Enter. A green dot on the button means the session is waiting for you.
+- **Ctrl+Shift+1…9** in the terminal sends phrase 1 to 9 without opening the menu. The digit is never typed into the agent. A number with no phrase behind it reaches the terminal as before.
+- **Your own phrases.** Settings → Quick replies takes one phrase per line, up to nine. Leave it empty for the defaults in your language.
+
+## Session timeline
+
+- A panel over the right edge of the terminal lists every prompt you sent in the session. Each entry shows when you sent it, its first line, the agent's last words in reply, and how many images that turn produced.
+- Click a prompt to scroll the terminal to it, briefly highlighted. Click the image count to open that turn's screenshot.
+- Only what you typed is listed: the summary Claude Code writes when it compacts a conversation is left out, and pasted text shows its own first line.
+- The list comes from the session's own record, so it is complete even when the terminal's scrollback no longer reaches back. Prompts that have scrolled out are shown dimmed and cannot be jumped to.
+- The panel floats over the terminal and never resizes it.
+
+## Mark up a screenshot and send it back
+
+- In the full-size viewer, **Mark up** gives you a box, an arrow and a pen in red. Undo is Ctrl+Z, and Esc stops drawing.
+- **Attach to the session** sends the image, with your marks, to the session it came from. The image is written where the session runs and its path is typed into the prompt, the same way a pasted screenshot is. This works for sessions on another PC too.
+- The marks are drawn in the image's own pixels and sent at full resolution, then scaled down only if the picture would exceed the 5MB attachment limit.
+- **Copy image** copies the marked-up version when there are marks.
+
+## Documents for a session on another PC
+
+- 📎 and drag-and-drop for a session on another PC now take text, log, data and PDF files as well as images: `.txt .log .md .json .jsonl .csv .tsv .yaml .xml .toml .ini .cfg .conf .diff .patch .sql .pdf`, up to 5MB each.
+- The other PC writes the file into its temp folder under its own name and types that path. Old files are cleaned up after a day, like pasted images.
+- That PC checks every file before writing it:
+ - only the kinds listed above are accepted, never scripts, executables or HTML
+ - a PDF must start like a PDF, and a text file must contain no binary bytes
+ - it needs the same "type in sessions" permission as an image, and shares the same hourly limit
+- A PC still on an older DevDeck cannot receive documents. You are told how many files it could not take and that the PC needs updating; nothing is typed into the session for them.
+
+## Under the hood
+
+- A new automated check (`qa:cockpit-tools`) runs on every change on Windows. A fixture agent records every byte typed into it, and the check covers:
+ - both ways of sending a quick reply, and phrases edited in Settings
+ - the timeline's contents, and that clicking a prompt jumps to it
+ - that a marked-up screenshot reaches the session with its red marks in the pixels
+- The remote-image check (`qa:images-remote`) now also sends a log file from one PC to another over the real link.
+- Turns are read in the same incremental pass over the session record as the images. The timeline costs no second read of a transcript that can reach hundreds of megabytes. The performance gate is unchanged.
diff --git a/package-lock.json b/package-lock.json
index d98bfce..eea1436 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "devdeck",
- "version": "1.40.0",
+ "version": "1.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devdeck",
- "version": "1.40.0",
+ "version": "1.41.0",
"license": "MIT",
"dependencies": {
"@fontsource-variable/geist": "5.3.0",
diff --git a/package.json b/package.json
index 6e53ae1..f3e9747 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "devdeck",
- "version": "1.40.0",
+ "version": "1.41.0",
"description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume",
"main": "dist/main/main.js",
"type": "commonjs",
@@ -43,6 +43,7 @@
"qa:session-sync": "npm run build && node qa/session-sync.mjs",
"qa:images": "npm run build && node qa/images.mjs",
"qa:image-rows": "npm run build && node qa/image-rows.mjs",
+ "qa:cockpit-tools": "npm run build && node qa/cockpit-tools.mjs",
"qa:images-remote": "npm run build && node qa/images-remote.mjs",
"qa:update": "npm run build && node qa/claude-update.mjs",
"qa:restore-pacing": "npm run build && node qa/restore-pacing.mjs"
diff --git a/qa/cockpit-tools.mjs b/qa/cockpit-tools.mjs
new file mode 100644
index 0000000..4f0472f
--- /dev/null
+++ b/qa/cockpit-tools.mjs
@@ -0,0 +1,198 @@
+// Windows harness: the cockpit's quick replies, session timeline, image markup sent back to the
+// session, and documents received for a session from another machine. A fixture `claude` draws a
+// Claude-shaped screen and records every byte typed into it, over an isolated HOME whose transcript
+// holds the matching prompts, replies and screenshots. Never starts a real agent.
+// Run with npm run qa:cockpit-tools.
+import { _electron as electron } from 'playwright';
+import assert from 'node:assert/strict';
+import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } 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';
+
+const root = resolve(process.argv[2] ?? dirname(dirname(fileURLToPath(import.meta.url))));
+if (process.platform !== 'win32') { console.log('SKIP: Windows native PTY required'); process.exit(0); }
+const shots = resolve(process.argv[3] ?? join(root, 'qa', 'shots', 'cockpit-tools'));
+mkdirSync(shots, { recursive: true });
+const { closeElectron } = await import('file:///' + join(root, 'qa', 'electron-lifecycle.mjs').replace(/\\/g, '/'));
+
+const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-cockpit-tools-'));
+const repo = join(sandbox, 'tools-workspace');
+const bin = join(sandbox, 'bin');
+const profile = join(sandbox, 'profile');
+const home = join(profile, 'home');
+for (const p of [repo, bin, home]) mkdirSync(p, { recursive: true });
+execFileSync('git', ['init', '-q', '-b', 'qa-tools'], { cwd: repo });
+
+const sid = '7a1c2b3d-2222-4333-8444-955566667777';
+const tdir = join(home, '.claude', 'projects', repo.replace(/[^a-zA-Z0-9]/g, '-'));
+mkdirSync(tdir, { recursive: true });
+const transcript = join(tdir, sid + '.jsonl');
+const typedLog = join(bin, 'typed.log');
+const png = (f) => readFileSync(join(root, f)).toString('base64');
+const at = (min) => new Date(Date.now() - min * 60_000).toISOString();
+const said = (text, min) => ({ type: 'assistant', timestamp: at(min), message: { role: 'assistant', content: [{ type: 'text', text }] } });
+const call = (id, name, min, input = {}) => ({ type: 'assistant', timestamp: at(min), message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } });
+const shot = (id, data, min) => ({ type: 'user', timestamp: at(min), message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data } }] }] } });
+const prompt = (text, min) => ({ type: 'user', timestamp: at(min), cwd: repo, sessionId: sid, message: { role: 'user', content: text } });
+writeFileSync(transcript, [
+ prompt('로그인 화면 확인해줘', 30),
+ said('로그인 페이지를 열어 보겠습니다.', 29),
+ call('s1', 'mcp__playwright__browser_take_screenshot', 29, { element: 'Submit button' }),
+ shot('s1', png('docs/demo/poster.png'), 29),
+ said('제출 버튼이 비활성 상태입니다.', 28),
+ prompt('다음 단계', 10),
+ said('바탕화면도 확인했습니다.', 9),
+].map((l) => JSON.stringify(l)).join('\n') + '\n');
+
+const SHADE = '\x1b[48;5;237m';
+const RESET = '\x1b[0m';
+writeFileSync(join(bin, 'agent.js'), `
+const fs = require('fs');
+const out = (s) => process.stdout.write(s.replace(/\\n/g, '\\r\\n'));
+const filler = (tag, n) => Array.from({ length: n }, (_, i) => ' ' + tag + ' output line ' + i).join('\\n') + '\\n';
+out(${JSON.stringify(SHADE)} + '❯ 로그인 화면 확인해줘' + ${JSON.stringify(RESET)} + '\\n');
+out('● playwright - browser_take_screenshot (MCP)(element: "Submit button")\\n ⎿ [Image]\\n');
+out(filler('first', 70));
+out(${JSON.stringify(SHADE)} + '❯ 다음 단계' + ${JSON.stringify(RESET)} + '\\n');
+out(filler('second', 70));
+out('────────────────────────────────\\n❯ \\n────────────────────────────────\\n');
+out('FIXTURE_READY\\n');
+if (process.stdin.isTTY) process.stdin.setRawMode(true);
+process.stdin.on('data', (d) => fs.appendFileSync(${JSON.stringify(typedLog)}, d));
+setInterval(() => {}, 1 << 30);
+`);
+writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0agent.js" %*\r\n');
+
+writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings: {
+ language: 'ko', folders: [{ path: repo, kind: 'repo' }], viewMode: 'list',
+ cockpitSessions: [{ tileId: 'tile-tools-1', projectPath: repo, name: 'tools-workspace', sessionId: sid, agentId: 'claude', label: 'Tools 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 = [];
+let failed = false;
+const check = async (name, fn) => { await fn(); console.log('PASS ' + name); };
+const typed = () => (existsSync(typedLog) ? readFileSync(typedLog, 'utf8') : '');
+const waitTyped = async (needle, ms = 8000) => {
+ for (const until = Date.now() + ms; Date.now() < until; await new Promise((r) => setTimeout(r, 100))) if (typed().includes(needle)) return;
+ assert.fail(`the session never received ${JSON.stringify(needle)}; got ${JSON.stringify(typed().slice(-200))}`);
+};
+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()); });
+ const topRow = () => win.evaluate(() => document.querySelector('.ck-term.show .xterm-rows > div')?.textContent ?? '');
+
+ await check('session opens with the fixture screen', 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.querySelectorAll('.ck-term.show .xterm-rows')].some((r) => r.textContent.includes('FIXTURE_READY')), null, { timeout: 20000 });
+ await win.waitForFunction(() => document.querySelector('#ck-header .ck-img-count')?.textContent === '1');
+ });
+
+ await check('a quick reply from the menu is typed and sent', async () => {
+ const button = win.locator('#ck-header button[aria-label="빠른 답장"]');
+ // Waiting for the person: the button says so.
+ await win.waitForFunction(() => document.querySelector('#ck-header .ck-qr-btn')?.classList.contains('ready'), null, { timeout: 20000 });
+ await button.click();
+ await win.waitForSelector('.ck-qr-menu .ck-qr-item');
+ assert.deepEqual(await win.locator('.ck-qr-item .ck-qr-text').allTextContents(), ['네 계속 진행해줘', '커밋해줘', '테스트 돌려서 결과 확인해줘', '변경한 내용 요약해줘']);
+ await win.screenshot({ path: join(shots, '1-quick-replies.png') });
+ await win.locator('.ck-qr-item').first().click();
+ await waitTyped('네 계속 진행해줘\r');
+ assert.equal(await win.locator('.ck-qr-menu').count(), 0, 'the menu closed');
+ });
+
+ await check('Ctrl+Shift+ sends the n-th quick reply from the terminal', async () => {
+ await win.locator('.ck-term.show .xterm-screen').click();
+ await win.keyboard.press('Control+Shift+Digit2');
+ await waitTyped('커밋해줘\r');
+ assert.ok(!typed().includes('@'), 'Shift+2 itself never reached the agent');
+ });
+
+ await check('quick replies edited in Settings are the ones offered', async () => {
+ await win.click('.rail-item[data-view="settings"]');
+ const field = win.locator('#settings-form .set-quick-replies');
+ await field.fill('배포 전 체크리스트 확인해줘\n로그 다시 봐줘');
+ await field.blur();
+ await win.waitForFunction(() => document.querySelector('#settings-form .set-quick-replies')?.closest('.set-row')?.querySelector('.set-save-status')?.textContent === '저장됨');
+ const saved = JSON.parse(readFileSync(join(profile, 'state.json'), 'utf8')).settings.quickReplies;
+ assert.deepEqual(saved, ['배포 전 체크리스트 확인해줘', '로그 다시 봐줘']);
+ await win.locator('#shell-session-groups .shell-session').first().click();
+ await win.locator('#ck-header button[aria-label="빠른 답장"]').click();
+ assert.deepEqual(await win.locator('.ck-qr-item .ck-qr-text').allTextContents(), ['배포 전 체크리스트 확인해줘', '로그 다시 봐줘']);
+ await win.keyboard.press('Escape');
+ await win.waitForSelector('.ck-qr-menu', { state: 'detached' });
+ });
+
+ await check('the timeline lists each prompt with when, the reply and its images, and jumps to it', async () => {
+ await win.locator('#ck-header button[aria-label="타임라인 보기"]').click();
+ await win.waitForFunction(() => document.querySelectorAll('.ck-timeline .ck-tl-item').length === 2);
+ const items = await win.locator('.ck-tl-item .ck-tl-text').allTextContents();
+ assert.deepEqual(items, ['로그인 화면 확인해줘', '다음 단계']);
+ const replies = await win.locator('.ck-tl-item .ck-tl-reply').allTextContents();
+ assert.deepEqual(replies, ['제출 버튼이 비활성 상태입니다.', '바탕화면도 확인했습니다.']);
+ assert.equal(await win.locator('.ck-tl-item').first().locator('.ck-tl-images').textContent(), '1');
+ assert.equal(await win.locator('.ck-tl-item.gone').count(), 0, 'both prompts are still on screen');
+ // The terminal underneath kept its size: the panel floats over it.
+ const cols = await win.evaluate(() => document.querySelector('.ck-term.show .xterm-rows > div')?.textContent.length ?? 0);
+ await win.screenshot({ path: join(shots, '2-timeline.png') });
+ await win.locator('.ck-tl-item .ck-tl-jump').first().click();
+ await win.waitForFunction(() => document.querySelector('.ck-term.show .xterm-rows > div')?.textContent.includes('로그인 화면 확인해줘'));
+ assert.ok((await topRow()).includes('로그인 화면 확인해줘'));
+ assert.ok(cols > 0);
+ await win.locator('.ck-tl-item .ck-tl-images').first().click();
+ await win.waitForFunction(() => document.querySelector('.ck-img-context')?.textContent.includes('Submit button'));
+ });
+
+ await check('a screenshot marked up in the viewer goes back to the session with the marks on it', async () => {
+ await win.locator('.ck-img-bar button[aria-label="표시하기"]').click();
+ await win.waitForSelector('.ck-img-markup.on');
+ const box = await win.locator('.ck-img-stage img').boundingBox();
+ const from = { x: box.x + box.width * 0.3, y: box.y + box.height * 0.3 };
+ const to = { x: box.x + box.width * 0.6, y: box.y + box.height * 0.6 };
+ await win.mouse.move(from.x, from.y); await win.mouse.down();
+ await win.mouse.move(to.x, to.y, { steps: 6 }); await win.mouse.up();
+ await win.locator('.ck-img-tools button[aria-label="화살표"]').click();
+ await win.mouse.move(to.x + 60, to.y + 40); await win.mouse.down();
+ await win.mouse.move(to.x + 5, to.y + 5, { steps: 6 }); await win.mouse.up();
+ assert.equal(await win.locator('.ck-img-tools button[aria-label="되돌리기 (Ctrl+Z)"]').isDisabled(), false);
+ await win.screenshot({ path: join(shots, '3-markup.png') });
+ await win.locator('.ck-img-bar button[aria-label="표시한 이미지를 세션에 첨부"]').click();
+ await win.waitForSelector('.ck-img-viewer', { state: 'detached' });
+ await waitTyped('.png ');
+ const path = /(\S+devdeck-paste-[0-9a-f-]+\.png)/.exec(typed())?.[1];
+ assert.ok(path && existsSync(path), 'the marked-up image was written where the session can read it');
+ // Red marks in the sent picture: count strongly red pixels (the bitmap is BGRA).
+ const red = await app.evaluate(({ nativeImage }, p) => {
+ const img = nativeImage.createFromPath(p); const b = img.toBitmap(); let n = 0;
+ for (let i = 0; i < b.length; i += 4) if (b[i + 2] > 220 && b[i + 1] < 90 && b[i] < 90) n++;
+ return n;
+ }, path);
+ assert.ok(red > 200, `the marks are in the sent image (${red} red pixels)`);
+ rmSync(path, { force: true });
+ });
+
+ await check('a document sent from another machine lands in the temp directory under its own name', async () => {
+ const landed = await win.evaluate(() => window.devdeck.cockpit.receiveFile('build.log', btoa('step 1 ok\nstep 2 failed\n')));
+ assert.match(landed, /devdeck-file-[0-9a-f-]{36}-build\.log$/);
+ assert.equal(readFileSync(landed, 'utf8'), 'step 1 ok\nstep 2 failed\n');
+ assert.equal(await win.evaluate(() => window.devdeck.cockpit.receiveFile('run.ps1', btoa('Remove-Item'))), null);
+ rmSync(landed, { force: true });
+ });
+
+ assert.deepEqual(errors, [], 'no renderer errors');
+} catch (e) {
+ console.log('FAIL', e?.message ?? e);
+ failed = true;
+ try { const w = await app.firstWindow(); await w.screenshot({ path: join(shots, 'failure.png') }); } catch { /* window gone */ }
+} finally {
+ await closeElectron(app).catch(() => {});
+}
+if (failed) process.exitCode = 1;
diff --git a/qa/image-rows.mjs b/qa/image-rows.mjs
index 92f5e8c..ddd9612 100644
--- a/qa/image-rows.mjs
+++ b/qa/image-rows.mjs
@@ -171,7 +171,7 @@ try {
});
await check('the viewer says what led to an image and goes to its row in the terminal', async () => {
- await win.locator('#ck-header button[aria-pressed]').click();
+ await win.locator('#ck-header button.ck-img-toggle').click();
await win.waitForFunction(() => document.querySelectorAll('#ck-images .ck-img-thumb').length === 2);
await win.locator('#ck-images .ck-img-thumb').nth(1).click(); // oldest: the login screenshot
await win.waitForFunction(() => document.querySelector('.ck-img-viewer .ck-img-stage img')?.getAttribute('src')?.startsWith('data:image'));
@@ -193,7 +193,7 @@ try {
assert.ok(spans.some((s) => s.row.includes('[Image]')), 'the login screenshot row is on screen');
assert.ok((await screenText()).some((r) => r.includes('browser_take_screenshot (MCP)(element: "Submit button")')), 'with its call above it');
await win.screenshot({ path: join(shots, '4-revealed.png') });
- await win.locator('#ck-header button[aria-pressed]').click(); // close the strip again
+ await win.locator('#ck-header button.ck-img-toggle').click(); // close the strip again
await win.waitForFunction(() => document.querySelector('#ck-images')?.classList.contains('hidden'));
});
diff --git a/qa/images-remote.mjs b/qa/images-remote.mjs
index 4a14c7c..83f54d7 100644
--- a/qa/images-remote.mjs
+++ b/qa/images-remote.mjs
@@ -88,7 +88,7 @@ try {
});
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.locator('#ck-header button.ck-img-toggle').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'));
@@ -108,7 +108,7 @@ try {
}, [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 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'));
@@ -119,6 +119,19 @@ try {
await viewer.win.screenshot({ path: join(shots, 'viewer-attached.png') });
});
+ await check('a log file attached on the viewer lands on the host under its own name and its host path is typed', async () => {
+ const log = join(sandbox, 'build.log');
+ writeFileSync(log, 'step 1 ok\nstep 2 failed\n');
+ const chooser = viewer.win.waitForEvent('filechooser');
+ await viewer.win.locator('#ck-header .ck-act[title*="이미지·문서 첨부"]').click();
+ await (await chooser).setFiles(log);
+ await rowsInclude(viewer.win, 'build.log');
+ const typed = await host.win.evaluate(() => [...document.querySelectorAll('.ck-term.show .xterm-rows')].map((r) => r.textContent).join('\n'));
+ const m = /GOT:(\S*devdeck-file-[0-9a-f-]{36}-build\.log)/.exec(typed.replace(/\n/g, ''));
+ assert.ok(m, 'host typed the sent file path:\n' + typed);
+ assert.equal(readFileSync(m[1], 'utf8'), 'step 1 ok\nstep 2 failed\n');
+ });
+
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;
diff --git a/qa/images.mjs b/qa/images.mjs
index 4e6a639..d6fb630 100644
--- a/qa/images.mjs
+++ b/qa/images.mjs
@@ -79,7 +79,7 @@ try {
});
await check('toggle opens the strip with three thumbnails, newest first', async () => {
- await win.locator('#ck-header button[aria-pressed]').click();
+ await win.locator('#ck-header button.ck-img-toggle').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']);
@@ -120,7 +120,7 @@ try {
});
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
+ await win.locator('#ck-header button.ck-img-toggle').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')));
diff --git a/src/main/api/deckApi.surface.test.ts b/src/main/api/deckApi.surface.test.ts
index c9ae341..67a80d9 100644
--- a/src/main/api/deckApi.surface.test.ts
+++ b/src/main/api/deckApi.surface.test.ts
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
-import { join } from 'node:path';
+import { basename, dirname, join } from 'node:path';
+import { readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
// deckApi reaches Electron for dialogs/clipboard/app info; none of that is exercised here — this file
// is about the SHAPE of the capability table, not its handlers' behavior (ipc.guard/ipc.cockpit cover those).
@@ -291,6 +293,51 @@ describe('receiving a pasted image from another machine', () => {
});
});
+describe('receiving a document from another machine', () => {
+ const b64 = (s: string | Buffer) => Buffer.from(s).toString('base64');
+ const receive = (name: unknown, data: unknown) => api['cockpit:receiveFile'].handler(name, data) as string | null;
+
+ it('needs control, like an image', () => {
+ expect(mayCallRemotely(api['cockpit:receiveFile'], ['observe'])).toBe(false);
+ expect(mayCallRemotely(api['cockpit:receiveFile'], ['control'])).toBe(true);
+ });
+
+ it('writes a text file into the temp directory under its own name, never a path the caller picked', () => {
+ const written = receive('..\\..\\Windows\\build.log', b64('line 1\nline 2\n'));
+ expect(written).not.toBeNull();
+ expect(dirname(written!)).toBe(tmpdir());
+ expect(basename(written!)).toMatch(/^devdeck-file-[0-9a-f-]{36}-build\.log$/);
+ expect(readFileSync(written!, 'utf8')).toBe('line 1\nline 2\n');
+ rmSync(written!);
+ });
+
+ it('refuses kinds that could run, and bytes that are not what the name says', () => {
+ expect(receive('setup.exe', b64('MZ'))).toBeNull();
+ expect(receive('run.ps1', b64('Remove-Item'))).toBeNull();
+ expect(receive('page.html', b64('