Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ See what needs attention, resume the right conversation, and work on a paired PC

</div>

> **1.46.0** lets you close Claude Code's background sessions from the sidebar — the row's ⋯ menu or the group's bulk close runs `claude stop` and `claude rm` for you, so a finished or failed session no longer sits in the Background group forever; its conversation is kept ([release notes](docs/releases/1.46.0.md)). 1.45.2 stops an update from doubling the sessions on a paired PC — the relaunch no longer restores remote tiles itself, it adopts them from that PC, and no restore starts a second conversation for one that is already open here ([release notes](docs/releases/1.45.2.md)). 1.45.1 stops offering Claude background sessions whose folder was deleted, and says so plainly when a session's folder is gone ([release notes](docs/releases/1.45.1.md)). 1.45.0 sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.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).
> **1.47.0** moves to Electron 44 (Chromium 152, Node.js 24); macOS 13 or later is now required, and screenshot paste / image copy use Electron's new clipboard API ([release notes](docs/releases/1.47.0.md)). 1.46.0 lets you close Claude Code's background sessions from the sidebar — the row's ⋯ menu or the group's bulk close runs `claude stop` and `claude rm` for you, so a finished or failed session no longer sits in the Background group forever; its conversation is kept ([release notes](docs/releases/1.46.0.md)). 1.45.2 stops an update from doubling the sessions on a paired PC — the relaunch no longer restores remote tiles itself, it adopts them from that PC, and no restore starts a second conversation for one that is already open here ([release notes](docs/releases/1.45.2.md)). 1.45.1 stops offering Claude background sessions whose folder was deleted, and says so plainly when a session's folder is gone ([release notes](docs/releases/1.45.1.md)). 1.45.0 sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.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).

<div align="center">
<img src="docs/screenshots/projects.png" width="820" alt="DevDeck project overview with repository and session navigation" />
Expand Down
10 changes: 10 additions & 0 deletions docs/releases/1.47.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# DevDeck 1.47.0

## Electron 44

DevDeck now runs on Electron 44.10.0 (Chromium 152, Node.js 24). No feature changes; this release exists so the runtime update gets its own regression run.

- **macOS 13 (Ventura) or later is required** from this version on. Electron 44 dropped macOS 12. Windows and Linux requirements are unchanged (DevDeck ships x64 builds only, so the removal of 32-bit builds does not affect it).
- **Pasting a screenshot and copying an image** were moved to Electron 44's clipboard API (`clipboard.read()` / `write()` with `ClipboardItem`; `readImage()` / `writeImage()` are gone). Verified on Windows: a bitmap on the clipboard is read as `image/png` at its original size, and an image DevDeck copies can be read back.
- Reading a connection code from the clipboard (Settings → Machines) follows the same API, which is now asynchronous.
- Dependency advisories: none open (`npm audit --audit-level=high` clean).
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "devdeck",
"version": "1.46.0",
"version": "1.47.0",
"description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume",
"main": "dist/main/main.js",
"type": "commonjs",
Expand Down Expand Up @@ -116,7 +116,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axe-core": "^4.14.0",
"electron": "^43.8.4",
"electron": "^44.10.0",
"electron-builder": "^26.15.3",
"esbuild": "^0.28.2",
"playwright": "^1.64.1",
Expand Down
16 changes: 11 additions & 5 deletions qa/image-rows.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ const errors = [];
let failed = false;
const check = async (name, fn) => { await fn(); console.log('PASS ' + name); };
// The person's clipboard is put back afterwards: this harness copies an image onto it.
const savedClip = await app.evaluate(({ clipboard }) => ({ text: clipboard.readText(), image: clipboard.readImage().toDataURL() }));
// (Electron 44: the clipboard is read through the W3C-shaped read(); readImage() is gone.)
const savedClip = await app.evaluate(async ({ clipboard }) => {
const items = await clipboard.read().catch(() => []);
const item = items.find((i) => i.types.includes('image/png'));
const image = item ? Buffer.from(await (await item.getType('image/png')).arrayBuffer()).toString('base64') : null;
return { text: await clipboard.readText().catch(() => ''), image };
});
try {
const win = await app.firstWindow();
win.setDefaultTimeout(15000);
Expand Down Expand Up @@ -185,7 +191,7 @@ try {

await win.locator('.ck-img-bar button[aria-label="이미지 복사"]').click();
await win.waitForFunction(() => document.body.textContent.includes('이미지를 클립보드에 복사했습니다'));
assert.equal(await app.evaluate(({ clipboard }) => clipboard.readImage().isEmpty()), false, 'an image is on the clipboard');
assert.equal(await app.evaluate(async ({ clipboard }) => (await clipboard.read()).some((i) => i.types.includes('image/png'))), true, 'an image is on the clipboard');

await win.locator('.ck-img-bar button[aria-label="터미널에서 위치 보기"]').click();
await win.waitForSelector('.ck-img-viewer', { state: 'detached' });
Expand Down Expand Up @@ -220,9 +226,9 @@ try {
failed = true;
try { const w = await app.firstWindow(); await w.screenshot({ path: join(shots, 'failure.png') }); } catch { /* window gone */ }
} finally {
await app.evaluate(({ clipboard, nativeImage }, saved) => {
if (saved.image && saved.image !== 'data:image/png;base64,') clipboard.writeImage(nativeImage.createFromDataURL(saved.image));
else clipboard.writeText(saved.text);
await app.evaluate(async ({ clipboard, ClipboardItem }, saved) => {
if (saved.image) await clipboard.write([new ClipboardItem({ 'image/png': new Blob([new Uint8Array(Buffer.from(saved.image, 'base64'))], { type: 'image/png' }) })]);
else await clipboard.writeText(saved.text);
}, savedClip).catch(() => {});
await closeElectron(app).catch(() => {});
}
Expand Down
5 changes: 3 additions & 2 deletions qa/link.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,11 @@ try {
// key handler is what decides, not the harness.
// Watched at the PTY INPUT rather than on screen: what the paste types is sent to the agent, and
// whether the agent happens to echo it back is its business, not this feature's.
await viewer.app.evaluate(({ ipcMain, clipboard, nativeImage }, png) => {
await viewer.app.evaluate(async ({ ipcMain, clipboard, ClipboardItem }, png) => {
globalThis.__typed = '';
ipcMain.on('cockpit:input', (_e, _id, data) => { globalThis.__typed += String(data); });
clipboard.writeImage(nativeImage.createFromBuffer(Buffer.from(png, 'base64')));
// Electron 44: images go on the clipboard through write() + ClipboardItem (writeImage is gone).
await clipboard.write([new ClipboardItem({ 'image/png': new Blob([new Uint8Array(Buffer.from(png, 'base64'))], { type: 'image/png' }) })]);
}, await viewer.win.screenshot({ type: 'png' }).then((buf) => buf.toString('base64')));
await viewer.win.evaluate(() => {
const box = document.querySelector('.ck-term.show .xterm-helper-textarea');
Expand Down
20 changes: 10 additions & 10 deletions src/main/api/deckApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join, resolve } from 'node:path';
import { readFile, realpath, stat } from 'node:fs/promises';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { readClipboardPng, writeClipboardPng } from '../clipboardImage';
import type { Store } from '../store';
import type { PtyHost } from '../ptyHost';
import { PtyBatcher } from '../ptyBatch';
Expand Down Expand Up @@ -1191,29 +1192,28 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
send('clipboard:writeText', localOnly, (text: string) => clipboard.writeText(String(text ?? '')));
invoke('clipboard:readText', localOnly, () => clipboard.readText());
/** Put an image the viewer is showing on the clipboard. Only image data URLs; never a path. */
invoke('clipboard:writeImage', localOnly, (dataUrl: string) => {
invoke('clipboard:writeImage', localOnly, async (dataUrl: string) => {
if (typeof dataUrl !== 'string' || !/^data:image\/(?:png|jpeg);base64,/.test(dataUrl) || dataUrl.length > MAX_CLIPBOARD_IMAGE_CHARS) return false;
const img = nativeImage.createFromDataURL(dataUrl);
if (img.isEmpty()) return false;
clipboard.writeImage(img);
try { await writeClipboardPng(img.toPNG()); } catch { return false; }
return true;
});
// Paste a CLIPBOARD IMAGE (e.g. a screenshot) into the terminal: Claude Code can't read the OS
// clipboard (esp. native Windows), but it DOES read an image off a file path. So on Ctrl+V with an
// image on the clipboard, write it to a temp PNG and return the path for the renderer to inject as
// text — the one image-input method that works cross-platform. null when the clipboard has no image.
invoke('clipboard:readImage', localOnly, () => {
const img = clipboard.readImage();
if (img.isEmpty()) return null;
invoke('clipboard:readImage', localOnly, async () => {
const png = await readClipboardPng();
if (!png) return null;
const file = join(tmpdir(), `${PASTE_IMAGE_PREFIX}${randomUUID()}.png`);
try { writeFileSync(file, img.toPNG()); } catch { return null; }
try { writeFileSync(file, png); } catch { return null; }
return file;
});
/** The same clipboard image, as bytes, so it can be sent to the machine running the session. */
invoke('clipboard:readImageBytes', localOnly, () => {
const img = clipboard.readImage();
if (img.isEmpty()) return null;
const png = img.toPNG();
invoke('clipboard:readImageBytes', localOnly, async () => {
const png = await readClipboardPng();
if (!png) return null;
// Bounded well under the protocol's frame ceiling, base64 expansion included. A screenshot is
// ~1MB; anything past this is not a paste, and refusing beats a dropped connection.
if (png.length > MAX_PASTE_IMAGE_BYTES) return { tooLarge: true, bytes: null };
Expand Down
32 changes: 32 additions & 0 deletions src/main/clipboardImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// src/main/clipboardImage.ts
// The image on the OS clipboard, as PNG bytes — and the reverse. Electron 44 removed
// `clipboard.readImage()` / `writeImage()` in favour of the W3C-shaped `clipboard.read()` /
// `write()` (ClipboardItem + Blob), so the paste-a-screenshot and copy-an-image paths go through here.
import { ClipboardItem, clipboard, nativeImage } from 'electron';

/**
* PNG bytes of the image on the clipboard, or null when there is none. A screenshot on Windows is a
* bitmap; Chromium offers it as `image/png` through `read()`. Any other image type is converted.
*/
export async function readClipboardPng(): Promise<Buffer | null> {
let items: Electron.ClipboardItem[];
try { items = await clipboard.read(); } catch { return null; }
for (const item of items) {
const type = item.types.includes('image/png') ? 'image/png' : item.types.find((t) => t.startsWith('image/'));
if (!type) continue;
try {
const blob = await item.getType(type) as Blob;
const bytes = Buffer.from(await blob.arrayBuffer());
if (!bytes.length) continue;
if (type === 'image/png') return bytes;
const img = nativeImage.createFromBuffer(bytes);
if (!img.isEmpty()) return img.toPNG();
} catch { /* try the next item */ }
}
return null;
}

/** Put PNG bytes on the clipboard as an image. */
export async function writeClipboardPng(png: Buffer): Promise<void> {
await clipboard.write([new ClipboardItem({ 'image/png': new Blob([new Uint8Array(png)], { type: 'image/png' }) })]);
}
9 changes: 6 additions & 3 deletions src/main/ipc.guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ vi.mock('./projectMemory', () => ({

import { registerIpc, type IpcConfig } from './ipc';
import { getGitBranchDirty } from './gitInfo';
import { dialog, clipboard } from 'electron';
import { dialog } from 'electron';
import { readClipboardPng } from './clipboardImage';
// Electron 44 reads images through the W3C-shaped clipboard.read(); the helper is what the handlers call.
vi.mock('./clipboardImage', () => ({ readClipboardPng: vi.fn(), writeClipboardPng: vi.fn() }));

const ALLOWED_ROOT = join(process.cwd(), 'allowed-root');
const sendError = vi.fn();
Expand Down Expand Up @@ -245,13 +248,13 @@ describe('settings:addFolder picker handshake', () => {

describe('clipboard:readImage (paste a screenshot into the terminal)', () => {
it('returns null when the clipboard holds no image', async () => {
vi.mocked(clipboard.readImage).mockReturnValue({ isEmpty: () => true } as never);
vi.mocked(readClipboardPng).mockResolvedValue(null);
expect(await handlers.get('clipboard:readImage')!(null)).toBeNull();
});

it('writes the clipboard image to a temp .png under tmpdir and returns its path', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic bytes
vi.mocked(clipboard.readImage).mockReturnValue({ isEmpty: () => false, toPNG: () => png } as never);
vi.mocked(readClipboardPng).mockResolvedValue(png);
const p = (await handlers.get('clipboard:readImage')!(null)) as string;
expect(p).toMatch(/devdeck-paste-.+\.png$/);
expect(p.startsWith(tmpdir())).toBe(true);
Expand Down
Loading
Loading