diff --git a/README.md b/README.md index b8465b0..7bbe525 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.48.0** makes DevDeck lighter at rest — a quiet terminal is no longer re-scanned every second, the process listing that detects a tile's agent runs only after that tile printed something, git is re-read every two minutes for tiles you are not looking at, and the background-session listing slows to a minute when the group is empty and two when the window is not focused ([release notes](docs/releases/1.48.0.md)). 1.47.1 makes closing a Claude Code background session from the sidebar work when Claude Code's background service is not running — DevDeck starts it as Claude Code does and tries again, and a failure names the PC the command has to run on ([release notes](docs/releases/1.47.1.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). +> **1.49.0** closes several sessions at once — a Select button above the sidebar's session groups (and Select… in every row's ⋯ menu) turns on a selection mode with checkboxes on rows and group headings; local, remote, background and saved rows close after one question that says what is on which PC, a tile attached to a Claude background session can end the session too, and the Background listing no longer starts a Claude Code process when its job records have not changed. Also fixes from a review of the sidebar and close flows: a confirmation that an unrelated Enter could answer, a row's ⋯ menu clipped on short lists, keyboard focus lost when rows reorder, Undo for forgetting a saved entry, one notice instead of a stack for bulk flows ([release notes](docs/releases/1.49.0.md)). 1.48.0 makes DevDeck lighter at rest — a quiet terminal is no longer re-scanned every second, the process listing that detects a tile's agent runs only after that tile printed something, git is re-read every two minutes for tiles you are not looking at, and the background-session listing slows to a minute when the group is empty and two when the window is not focused ([release notes](docs/releases/1.48.0.md)). 1.47.1 makes closing a Claude Code background session from the sidebar work when Claude Code's background service is not running — DevDeck starts it as Claude Code does and tries again, and a failure names the PC the command has to run on ([release notes](docs/releases/1.47.1.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).
DevDeck project overview with repository and session navigation diff --git a/docs/releases/1.49.0.md b/docs/releases/1.49.0.md new file mode 100644 index 0000000..d27cb5d --- /dev/null +++ b/docs/releases/1.49.0.md @@ -0,0 +1,29 @@ +# DevDeck 1.49.0 + +## Close several sessions at once + +The sidebar could already close a hand-picked set of sessions — after a Ctrl+click that nothing on screen mentioned. So most decks were closed one row at a time, and a deck of twenty sessions stayed a deck of twenty sessions. + +- **A Select button above the session groups** turns on a selection mode. Every row shows a checkbox, a plain click picks a row instead of opening it, the box on a group heading picks the whole group (opening it in full first, so nothing is picked that cannot be seen), Shift+click picks a range, and **Select all** opens every group and picks everything. **Close** closes the picked rows after one question; **Done** or Esc leaves the mode; Delete closes the picked rows from the keyboard. Every row's ⋯ menu also has **Select…**, which picks that row and starts the mode. Ctrl+click and Shift+click work as before, in and out of the mode. +- **Local, remote, background and saved rows mix freely**, and the one question says what the set holds — *2 on this PC · 3 on SIHYEONG-4080 · 1 background · 4 saved* — because a close on another PC stops an agent in front of someone else. The tiles go in one batch and the pane settles once at the end (it used to re-select and re-render after each one, a visible stall for a dozen). Closes on other PCs answer later; a group of refusals or owed closes is one line, not a stack. Background sessions close with one progress line and one summary, machines side by side and one after another within a machine, so the first refusal starts Claude Code's background service once and the rest find it running. +- **A tile attached to a Claude background session** (one opened from the Background group) used to leave the session running when closed; its row came straight back under Background, to be closed a second time. The question now offers to end the session as well, ticked by default. The conversation is kept either way. +- Closing one picked row asks that row's own question (*Close session 'x'?*) rather than *Close 1 selected sessions?*. + +## The Background listing no longer starts a process on every poll + +`claude agents --json --all` is a Claude Code process — about 340 ms of a 300 MB executable — that every poll started, on this PC and on every paired one, nearly always to answer what it had answered the time before. DevDeck now looks first at what that command reads: the job records under `~/.claude/jobs`, one folder per background session with its `state.json` (a readdir and a stat per record, about a millisecond). No records means no sessions and no process at all; records unchanged since the last listing means the last answer, for up to five minutes; only a change runs the command. On a PC with no background sessions the process never starts. + +## From a review of the sidebar and the close flows + +- **The confirmation could be answered by an Enter that was not aimed at it.** Ctrl+Shift+P pressed while a question was open moved focus to the quick-open field behind it, and the Enter meant for that field closed the sessions. Chords no longer leave the question, an Enter from outside it only brings focus back to it, a held key's auto-repeat is ignored, and a second question is never stacked on the first — a row's ⋯ menu stayed open above the dialog and could ask one; it closes first now, and the dialog sits above every menu. After Cancel the keyboard goes back to where it was, so the next Delete or Enter acts on the same thing. +- **A row's ⋯ menu was clipped when the list was short.** The session list is its own scrolling box, sized to its content; a menu dropped from the last row ran past its bottom and one flipped up from the first row ran past its top. On a fresh install with one session only the bottom item (Close) showed. The menu is placed against the window now, below the ⋯ when there is room and above it otherwise, and closes when the list scrolls. +- **Keyboard focus survived a re-render only on a row.** Every publish that reorders rows (every 30 s with two active sessions) blurred the ⋯ button, an open menu's item or a group heading's controls; a keyboard user lost the menu they were in. They are put back now. +- **Forgetting a saved entry from its ⋯ menu** — the one destructive row action with no question, right under Pin — offers Undo. +- **Bulk flows toasted once per row**: restoring saved entries for a PC that is offline, or closing a group on a PC that refuses, stacked a notice per session past the top of the window. They show one line for the set; the diagnostics log keeps the rest. +- **A pinned row wears its pin.** The groups that come before Pinned (needs you, working, another PC) keep their rows, so pinning one moved nothing and read as having done nothing. +- Smaller: the Korean group confirmations use the same register as every other question; a group section is named for screen readers by its name and count alone, not by its buttons as well; rows carry a pressed state only while picking; the selection bar's Close is drawn as the destructive action it is. + +## Also in this release + +- `npm run qa:multi-close` drives the selection mode end to end in the real app — 24 checks, no agent needed. +- The selection itself lives in `src/shared/sessionSelection.ts` as pure functions, with tests; the job-record signature in `src/main/backgroundAgents.ts` is tested against a real temporary folder. diff --git a/package-lock.json b/package-lock.json index bb72fe1..065f462 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devdeck", - "version": "1.48.0", + "version": "1.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devdeck", - "version": "1.48.0", + "version": "1.49.0", "license": "MIT", "dependencies": { "@fontsource-variable/geist": "5.3.0", diff --git a/package.json b/package.json index 20eebe5..bc57ed0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devdeck", - "version": "1.48.0", + "version": "1.49.0", "description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume", "main": "dist/main/main.js", "type": "commonjs", @@ -35,6 +35,7 @@ "qa:audit": "npm run build && node qa/audit.mjs", "qa:link": "npm run build && node qa/link.mjs", "qa:close": "npm run build && node qa/close.mjs", + "qa:multi-close": "npm run build && node qa/multi-close.mjs", "qa:resilience": "npm run build && node qa/resilience.mjs", "qa:perf": "npm run build && node qa/perf.mjs", "qa:perf-gate": "npm run build && node qa/perf-gate.mjs", diff --git a/qa/multi-close.mjs b/qa/multi-close.mjs new file mode 100644 index 0000000..4add198 --- /dev/null +++ b/qa/multi-close.mjs @@ -0,0 +1,134 @@ +// Closing several sidebar sessions at once, driven the way a person drives it. +// +// What this guards: the selection MODE (the Select button above the groups, "Select…" in a row's ⋯ +// menu, a plain click picking a row while the mode is on, the group heading's checkbox, Shift+click +// ranges, Escape leaving, Delete closing), the one confirmation for the whole set, and the rows +// actually leaving afterwards. It uses saved ("previous") entries, which need no agent: closing them +// is a forget, so the whole flow runs without a `claude` on the PC. Also checked: a row's ⋯ menu is +// positioned inside the window even when the list is short (it used to be clipped by the list box). +// +// Run with `npm run qa:multi-close`. One Electron instance; do not run alongside `npm run qa`. +import { _electron as electron } from 'playwright'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { closeElectron } from './electron-lifecycle.mjs'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const projectRoot = mkdtempSync(join(tmpdir(), 'devdeck-multiclose-fixture-')); +writeFileSync(join(projectRoot, 'README.md'), '# QA fixture\n'); +execFileSync('git', ['init', '-q', '-b', 'qa-fixture'], { cwd: projectRoot }); +execFileSync('git', ['add', '.'], { cwd: projectRoot }); +execFileSync('git', ['-c', 'user.name=QA', '-c', 'user.email=qa@devdeck', 'commit', '-qm', 'fixture'], { cwd: projectRoot }); + +const qaUserData = mkdtempSync(join(tmpdir(), 'devdeck-multiclose-')); +const saved = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']; +writeFileSync(join(qaUserData, 'state.json'), JSON.stringify({ + projects: {}, + settings: { + language: 'ko', + folders: [{ path: projectRoot, kind: 'repo' }], + cockpitSessions: saved.map((label) => ({ projectPath: projectRoot, name: 'fixture', sessionId: null, agentId: 'claude', label })), + }, +}, null, 2)); + +const consoleErrors = []; +const pageErrors = []; +const results = []; +const check = (name, ok, detail = '') => { results.push({ name, ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); }; + +const app = await electron.launch({ + args: ['.', `--user-data-dir=${qaUserData}`, '--no-sandbox', '--disable-gpu'], + cwd: root, + env: { ...process.env, CLAUDE_CODE_SSE_PORT: '', CLAUDECODE: '', CLAUDE_CODE_ENTRYPOINT: '' }, +}); +try { + const win = await app.firstWindow(); + win.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); }); + win.on('pageerror', (e) => pageErrors.push(String(e))); + await win.waitForSelector('#shell-session-groups', { timeout: 20000 }); + await win.waitForFunction((n) => document.querySelectorAll('.shell-session-wrap[data-previous="true"]').length >= n, saved.length, { timeout: 20000 }); + const previousRows = () => win.locator('.shell-session-wrap[data-previous="true"]'); + const rowByLabel = (label) => previousRows().filter({ hasText: label }).locator('.shell-session'); + const selectedCount = () => win.evaluate(() => document.querySelectorAll('.shell-session-wrap.is-selected').length); + const inMode = () => win.evaluate(() => document.getElementById('shell-session-groups').classList.contains('is-selecting')); + + // 1. The Select button is on screen and turns the mode on with nothing picked. + const toggle = win.locator('#shell-select-toggle'); + check('select button visible', await toggle.isVisible()); + await toggle.click(); + check('mode on after Select', await inMode()); + check('bar visible, close disabled with nothing picked', await win.locator('.shell-selection').isVisible() && await win.locator('.shell-selection-close').isDisabled()); + check('checkboxes drawn in the mode', await win.evaluate(() => getComputedStyle(document.querySelector('.shell-session-check')).display !== 'none')); + + // 2. Plain clicks pick (and do not open) while the mode is on. + await rowByLabel('Alpha').click(); + await rowByLabel('Bravo').click(); + check('two plain clicks picked two rows', (await selectedCount()) === 2, `selected=${await selectedCount()}`); + check('picked rows carry aria-pressed', await rowByLabel('Alpha').getAttribute('aria-pressed') === 'true'); + check('count says 2', (await win.locator('.shell-selection-count').textContent()).includes('2')); + + // 3. The group checkbox picks the whole group (opening the "show more" cut first). + const groupCheck = win.locator('.shell-group.group-previous .shell-group-check'); + await groupCheck.click(); + check('group checkbox picked every saved row', (await selectedCount()) === saved.length, `selected=${await selectedCount()}`); + check('group checkbox reads checked', await groupCheck.getAttribute('aria-checked') === 'true'); + await groupCheck.click(); + check('group checkbox again un-picked them', (await selectedCount()) === 0); + + // 4. Escape leaves the mode. + await win.keyboard.press('Escape'); + check('Escape left the mode', !(await inMode())); + check('toggle is back', await toggle.isVisible()); + + // 5. "Select…" in a row's ⋯ menu starts a selection with that row; the menu was inside the window. + const charlie = previousRows().filter({ hasText: 'Charlie' }); + await charlie.locator('.shell-session-actions').click(); + const menuBox = await charlie.locator('.shell-session-menu').boundingBox(); + const viewport = win.viewportSize() ?? await win.evaluate(() => ({ width: innerWidth, height: innerHeight })); + check('row menu is placed inside the window', !!menuBox && menuBox.y >= 0 && menuBox.y + menuBox.height <= viewport.height && menuBox.x >= 0, JSON.stringify(menuBox)); + await charlie.locator('.shell-session-pick').click(); + check('Select… picked that row and turned the mode on', (await inMode()) && (await selectedCount()) === 1); + + // 6. Shift+click ranges from the last pick. + await rowByLabel('Echo').click({ modifiers: ['Shift'] }); + check('Shift+click picked the range Charlie..Echo', (await selectedCount()) === 3, `selected=${await selectedCount()}`); + + // 7. Close asks once; Enter answers; the rows leave and the mode ends. + await win.locator('.shell-selection-close').click(); + await win.waitForSelector('.ck-confirm', { timeout: 5000 }); + const question = await win.locator('.ck-confirm-msg').textContent(); + check('one confirmation for the set', question.includes('3'), question); + await win.keyboard.press('Enter'); + await win.waitForFunction((n) => document.querySelectorAll('.shell-session-wrap[data-previous="true"]').length === n, saved.length - 3, { timeout: 5000 }); + check('three rows left the list', (await previousRows().count()) === saved.length - 3); + check('mode ended after the close', !(await inMode())); + + // 8. Delete closes the picked rows (after the question); Cancel keeps them. + await toggle.click(); + await rowByLabel('Alpha').click(); + await win.keyboard.press('Delete'); + await win.waitForSelector('.ck-confirm', { timeout: 5000 }); + await win.locator('.ck-confirm-cancel').click(); + check('Cancel kept the row and the pick', (await previousRows().count()) === saved.length - 3 && (await selectedCount()) === 1); + // The question keeps the keyboard: the quick-open chord must not move focus behind it, where a + // later Enter would still answer it. + await win.keyboard.press('Delete'); + await win.waitForSelector('.ck-confirm', { timeout: 5000 }); + await win.keyboard.press('Control+Shift+P'); + check('a chord cannot leave the question', await win.evaluate(() => document.activeElement?.closest('.ck-confirm') != null)); + check('one confirmation on screen', (await win.locator('.ck-confirm').count()) === 1); + await win.keyboard.press('Enter'); + await win.waitForFunction((n) => document.querySelectorAll('.shell-session-wrap[data-previous="true"]').length === n, saved.length - 4, { timeout: 5000 }); + check('Delete + Enter closed the picked row', (await previousRows().count()) === saved.length - 4); +} finally { + await closeElectron(app); +} + +check('no console errors', consoleErrors.length === 0, consoleErrors.join(' | ')); +check('no page errors', pageErrors.length === 0, pageErrors.join(' | ')); +const failed = results.filter((r) => !r.ok); +console.log(`\n${results.length - failed.length}/${results.length} checks passed`); +if (failed.length) process.exit(1); diff --git a/src/main/backgroundAgents.test.ts b/src/main/backgroundAgents.test.ts index 2873070..66fbfce 100644 --- a/src/main/backgroundAgents.test.ts +++ b/src/main/backgroundAgents.test.ts @@ -1,4 +1,7 @@ import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; const spawn = vi.fn(); @@ -86,3 +89,62 @@ describe('closing a background session when the background service is not runnin expect(spawn).not.toHaveBeenCalled(); }); }); + +describe('listing off the job records before starting a claude process', () => { + const LISTING = JSON.stringify([{ id: '8e64da40', cwd: 'C:/g/shipwright', kind: 'background', startedAt: 1, sessionId: '8e64da40-4c50-4e80-ae72-bddba02e6bcb', name: 'triage', state: 'blocked' }]); + const dirs: string[] = []; + const jobsDir = (): string => { const d = mkdtempSync(join(tmpdir(), 'devdeck-jobs-')); dirs.push(d); return d; }; + const record = (dir: string, id: string, body = '{}'): void => { mkdirSync(join(dir, id), { recursive: true }); writeFileSync(join(dir, id, 'state.json'), body); }; + afterEach(async () => { + const m = await import('./backgroundAgents.js'); m._clearBackgroundAgentCache(); spawn.mockReset(); + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + it('answers an empty list without starting claude when there are no records (or no folder)', async () => { + fakeClaude(() => ({ code: 0 })); + const m = await import('./backgroundAgents.js'); + const dir = jobsDir(); + writeFileSync(join(dir, 'pins.json'), '[]'); // not a record + expect(await m.listBackgroundAgents(Date.now(), { jobsDir: dir })).toEqual([]); + m._clearBackgroundAgentCache(); + expect(await m.listBackgroundAgents(Date.now(), { jobsDir: join(dir, 'missing') })).toEqual([]); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('runs the listing when a record exists, and not again while the records are unchanged', async () => { + spawn.mockImplementation((_file: string, args: string[]) => { + const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter(), pid: 1, kill: () => true, unref: () => undefined }); + setTimeout(() => { if (args[0] === 'agents') child.stdout.emit('data', Buffer.from(LISTING)); child.emit('close', 0); }, 0); + return child; + }); + const m = await import('./backgroundAgents.js'); + const dir = jobsDir(); + record(dir, '8e64da40'); + const t0 = Date.now(); + const first = await m.listBackgroundAgents(t0, { jobsDir: dir }); + expect(first.map((b) => b.id)).toEqual(['8e64da40']); + expect(calls()).toEqual(['agents --json --all']); + // Past the short cache, same records: the last answer, no process. + expect(await m.listBackgroundAgents(t0 + 60_000, { jobsDir: dir })).toBe(first); + expect(calls()).toHaveLength(1); + // A record changed (the session moved on, or one was added): the listing runs again. + record(dir, '8e64da40', '{"state":"idle"}'); + expect(await m.listBackgroundAgents(t0 + 120_000, { jobsDir: dir })).toEqual(first); + expect(calls()).toHaveLength(2); + // Unchanged for longer than the reuse window: listed again regardless. + expect(await m.listBackgroundAgents(t0 + 120_000 + 6 * 60_000, { jobsDir: dir })).toEqual(first); + expect(calls()).toHaveLength(3); + }); + + it('signs the records by name, change time and size, ignoring other files', async () => { + const m = await import('./backgroundAgents.js'); + const dir = jobsDir(); + writeFileSync(join(dir, 'pins.json'), '[]'); + expect(await m.jobRecordsSignature(dir)).toBe(''); + record(dir, 'b54cc363', '{"a":1}'); + record(dir, '09644674', '{"a":1}'); + const sig = await m.jobRecordsSignature(dir); + expect(sig).toMatch(/^09644674:\d+:7\|b54cc363:\d+:7$/); + expect(await m.jobRecordsSignature(join(dir, 'nope'))).toBe(''); + }); +}); diff --git a/src/main/backgroundAgents.ts b/src/main/backgroundAgents.ts index 1984e3a..cd2ea2b 100644 --- a/src/main/backgroundAgents.ts +++ b/src/main/backgroundAgents.ts @@ -2,14 +2,20 @@ // Read Claude Code's background sessions from `claude agents --json --all` (~200 ms; cached briefly, // since the sidebar asks every 30 s and a paired machine may ask too). import { spawn, type ChildProcess } from 'node:child_process'; +import { readdir, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; +import { join } from 'node:path'; import { isBackgroundAgentId, parseBackgroundAgents, type BackgroundAgent } from '../shared/backgroundAgents'; import { cliInvocation, killChildTree } from './aiSummary'; import { resolveAgentCliPath } from './cliExecutable'; const CACHE_MS = 15_000; const TIMEOUT_MS = 10_000; -let cached: { at: number; list: BackgroundAgent[] } | null = null; +/** With the records unchanged, the last answer is reused this long before the listing runs anyway. */ +const UNCHANGED_RELIST_MS = 5 * 60_000; +/** Claude Code keeps one folder per background session here (its `state.json` inside); `claude rm` deletes it. */ +const JOBS_DIR = join(homedir(), '.claude', 'jobs'); +let cached: { at: number; listedAt: number; list: BackgroundAgent[]; records: string | null } | null = null; let inFlight: Promise | null = null; let paused = false; @@ -22,16 +28,47 @@ export async function pauseBackgroundAgents(on: boolean): Promise { if (on) await inFlight?.catch(() => undefined); // one already running finishes first } -export async function listBackgroundAgents(nowMs = Date.now()): Promise { +/** + * The background sessions, as `claude agents --json --all` lists them — but that command is a + * Claude Code process (~340 ms, a 300 MB executable) started every poll, here and on every paired + * PC, almost always to answer what it answered last time. It reads the job records under + * `~/.claude/jobs`, so those are looked at first (a readdir and a stat per record, about a + * millisecond): no records means no sessions and no process; records unchanged since the last + * listing means the last answer, for up to five minutes; only a change runs the command. + */ +export async function listBackgroundAgents(nowMs = Date.now(), opts: { jobsDir?: string } = {}): Promise { if (paused) return cached?.list ?? []; if (cached && nowMs - cached.at < CACHE_MS) return cached.list; if (inFlight) return inFlight; - inFlight = run().then((json) => parseBackgroundAgents(json, Date.now())).catch(() => [] as BackgroundAgent[]) - .then((list) => { cached = { at: Date.now(), list }; return list; }) + inFlight = (async () => { + const records = await jobRecordsSignature(opts.jobsDir ?? JOBS_DIR); + if (records === '') return { list: [], records, listedAt: nowMs }; + if (cached && records !== null && cached.records === records && nowMs - cached.listedAt < UNCHANGED_RELIST_MS) { + return { list: cached.list, records, listedAt: cached.listedAt }; + } + const list = await run().then((json) => parseBackgroundAgents(json, Date.now())).catch(() => [] as BackgroundAgent[]); + return { list, records, listedAt: Date.now() }; + })() + .then(({ list, records, listedAt }) => { cached = { at: Date.now(), listedAt, list, records }; return list; }) .finally(() => { inFlight = null; }); return inFlight; } +/** + * What the listing would be reading: the session folders under the jobs dir and when each one's + * `state.json` last changed. '' when there are none (or no such dir); null when it could not be read, + * in which case the command runs as before. + */ +export async function jobRecordsSignature(dir: string): Promise { + let names: string[]; + try { names = await readdir(dir); } catch (err) { return (err as NodeJS.ErrnoException).code === 'ENOENT' ? '' : null; } + const parts = await Promise.all(names.filter((name) => isBackgroundAgentId(name)).map(async (name) => { + try { const s = await stat(join(dir, name, 'state.json')); return `${name}:${Math.round(s.mtimeMs)}:${s.size}`; } + catch { return `${name}:-`; } + })); + return parts.sort().join('|'); +} + export function _clearBackgroundAgentCache(): void { cached = null; } /** The listing child while one runs, so quitting can end it instead of orphaning it. */ diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index e972e81..0421d34 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -17,7 +17,8 @@ import { cockpitNavigationId, cockpitNavigationIdForRuntime, createCockpitTileId import { OPEN_FOLDER_MISSING, toAgentId, type AgentId, type OpenMode, type StaleLevel } from '../shared/types'; import { createProviderLogo, providerName } from './providerLogo'; import { tr, currentLang } from './i18n-runtime'; -import { toast, reportSaveError, progressToast } from './loadError'; +import { toast, reportSaveError, progressToast, undoToast, collectToasts } from './loadError'; +import { closeBreakdown, describeBreakdown } from '../shared/sessionSelection'; import { setActiveUsageProvider } from './usageBar'; import { reportShutdownActivity } from './shutdown'; import { createIcon, type IconName } from './icons'; @@ -672,30 +673,78 @@ async function closeBackgroundAgents(ids: readonly string[], ask: boolean): Prom for (const id of ids) { const m = /^bg:(.+):([0-9a-f]+)$/i.exec(id); const agent = m ? backgroundAgents.get(m[1])?.find((x) => x.id === m[2]) : undefined; - if (m && agent) targets.push({ machineId: m[1], agent }); + if (m && agent && !targets.some((t) => t.machineId === m[1] && t.agent.id === agent.id)) targets.push({ machineId: m[1], agent }); } if (!targets.length) return; const nameOf = (a: BackgroundAgent): string => a.name ?? basename(a.cwd); if (ask && !(await confirmDialog(tr('cockpit.bg_close_confirm', { name: nameOf(targets[0].agent) }), tr('cockpit.close')))) return; - for (const { machineId, agent } of targets) { - const result = await deckFor(machineId).cockpit.backgroundClose(agent.id); - window.devdeck.logDiagnostic(`close background ${agent.id} on ${machineId === LOCAL_MACHINE_ID ? 'this pc' : machineName(machineId)}: ${result.ok ? 'removed' : result.error}`, result.ok ? 'info' : 'warn', 'sidebar'); - if (result.ok) { - // Gone from the list it came from; drop the row now rather than at the next 30 s poll. - backgroundAgents.set(machineId, (backgroundAgents.get(machineId) ?? []).filter((x) => x.id !== agent.id)); - toast(tr('cockpit.bg_closed', { name: nameOf(agent) })); - } else if (result.error === 'host-too-old') { + const many = targets.length > 1; + // Several at once: one progress line while they go, one summary after — not a toast per session. + // Each `claude stop` + `claude rm` is a process or two (and up to ten seconds more the first time + // the background service has to be started), so a dozen sessions take a while, and the rows leave + // the list one by one as they are confirmed gone. + const progress = many ? progressToast() : null; + let done = 0; + progress?.set(tr('cockpit.bg_closing', { done: '0', total: String(targets.length) })); + const closed: BackgroundAgent[] = []; + const failed: Array<{ machineId: string; agent: BackgroundAgent; error: string }> = []; + // Machines side by side; within a machine one after another, so that the first refusal starts + // its background service once and the rest find it running. + const byMachine = new Map(); + for (const { machineId, agent } of targets) byMachine.set(machineId, [...(byMachine.get(machineId) ?? []), agent]); + await Promise.all([...byMachine].map(async ([machineId, agents]) => { + for (const agent of agents) { + const result = await deckFor(machineId).cockpit.backgroundClose(agent.id) + .catch((err): { ok: false; error: 'failed'; detail: string } => ({ ok: false, error: 'failed', detail: String(err) })); + window.devdeck.logDiagnostic(`close background ${agent.id} on ${machineId === LOCAL_MACHINE_ID ? 'this pc' : machineName(machineId)}: ${result.ok ? 'removed' : result.error}`, result.ok ? 'info' : 'warn', 'sidebar'); + if (result.ok) { + // Gone from the list it came from; drop the row now rather than at the next poll. + backgroundAgents.set(machineId, (backgroundAgents.get(machineId) ?? []).filter((x) => x.id !== agent.id)); + closed.push(agent); + } else failed.push({ machineId, agent, error: result.error }); + done += 1; + progress?.set(tr('cockpit.bg_closing', { done: String(done), total: String(targets.length) })); + publishCockpitNavigation(); + } + })); + progress?.close(); + if (!many) { + const { machineId, agent } = targets[0]; + const failure = failed[0]; + if (!failure) toast(tr('cockpit.bg_closed', { name: nameOf(agent) })); + else if (failure.error === 'host-too-old') toast(tr('cockpit.bg_close_old_host', { machine: machineName(machineId) })); + else if (machineId === LOCAL_MACHINE_ID) toast(tr('cockpit.bg_close_failed', { name: nameOf(agent), id: agent.id })); + // The command has to run on the PC that has the session — the one named here, not this one. + else toast(tr('cockpit.bg_close_failed_on_machine', { name: nameOf(agent), id: agent.id, machine: machineName(machineId) })); + } else { + if (closed.length) toast(tr('cockpit.bg_closed_many', { n: String(closed.length) })); + for (const machineId of new Set(failed.filter((f) => f.error === 'host-too-old').map((f) => f.machineId))) { toast(tr('cockpit.bg_close_old_host', { machine: machineName(machineId) })); - } else if (machineId === LOCAL_MACHINE_ID) { - toast(tr('cockpit.bg_close_failed', { name: nameOf(agent), id: agent.id })); - } else { - // The command has to run on the PC that has the session — the one named here, not this one. - toast(tr('cockpit.bg_close_failed_on_machine', { name: nameOf(agent), id: agent.id, machine: machineName(machineId) })); } + const refused = failed.filter((f) => f.error !== 'host-too-old'); + if (refused.length) toast(tr('cockpit.bg_close_failed_many', { n: String(refused.length), names: refused.map((f) => nameOf(f.agent)).join(', ') })); } publishCockpitNavigation(); } +/** + * Among these sidebar rows, the live tiles attached to one of Claude's background sessions (on the + * tile's own machine), with the session each is attached to. Closing such a tile ends only the + * `claude attach`: the session runs on, and its row comes straight back under "Background" to be + * closed a second time — so a close of the tile offers to end the session too. + */ +function attachedBackground(ids: readonly string[]): Array<{ machineId: string; agent: BackgroundAgent }> { + const out: Array<{ machineId: string; agent: BackgroundAgent }> = []; + for (const id of ids) { + if (id.startsWith('bg:')) continue; + const l = [...live.values()].find((entry) => navigationIdForLive(entry) === id); + if (!l?.openedSessionId || l.session.agentId !== 'claude') continue; + const agent = backgroundAgents.get(l.machineId)?.find((b) => b.sessionId === l.openedSessionId); + if (agent && !out.some((o) => o.machineId === l.machineId && o.agent.id === agent.id)) out.push({ machineId: l.machineId, agent }); + } + return out; +} + /** One-release migration map for shell contexts saved before opaque tile identities existed. */ export function cockpitNavigationAliases(): ReadonlyMap { const aliases = new Map(); @@ -2393,7 +2442,17 @@ export function manageCockpitSessionAction(id: string, action: ShellSessionActio } const entry = restorable.find((item) => cockpitNavigationId(item) === id || legacyCockpitNavigationId(item) === id); if (!entry) return; - if (action === 'forget') { forgetSession(entry); return; } + if (action === 'forget') { + // The one destructive row action with no question: it is a saved entry, not a running agent, + // so it goes at once — with a way back, since the ⋯ menu puts it right under Pin. + forgetSession(entry); + undoToast(tr('cockpit.forgot', { name: entry.label ?? entry.name }), tr('shell.undo'), () => { + if (restorable.includes(entry)) return; + restorable = [entry, ...restorable]; + persist(); renderList(); + }); + return; + } if (action === 'rename' || action === 'close') return; // no terminal to rename or close yet entry.pinned = action === 'pin' ? true : undefined; persist(); renderList(); @@ -2414,47 +2473,85 @@ export async function closeCockpitSessionGroup(group: ShellSessionGroup): Promis if (!ids.length) return; const forgetting = group.kind === 'previous'; const name = group.kind === 'remote' ? (group.machineLabel ?? '') : tr(GROUP_CONFIRM_LABELS[group.kind]); - const ok = await confirmDialog( - tr(forgetting ? 'shell.forget_group_confirm' : 'shell.close_group_confirm', { n: String(ids.length), name }), - tr(forgetting ? 'cockpit.forget' : 'cockpit.close'), - ); - if (!ok) return; - window.devdeck.logDiagnostic(`bulk ${forgetting ? 'forget' : 'close'} ${ids.length} in ${group.key}`, 'info', 'sidebar'); - void closeBackgroundAgents(ids.filter((id) => id.startsWith('bg:')), false); // asked above, once - for (const id of ids) { - if (id.startsWith('bg:')) continue; - // Resolved per id at the moment it is acted on: the list was captured before the confirmation, - // and a session can exit or be closed while the question is on screen. - const current = [...live.values()].find((entry) => navigationIdForLive(entry) === id); - if (current) { closeSession(current.session.id); continue; } - const entry = restorable.find((item) => cockpitNavigationId(item) === id); - if (entry) restorable = restorable.filter((r) => r !== entry); - } - persist(); - renderList(); + await closeRows(ids, { + question: tr(forgetting ? 'shell.forget_group_confirm' : 'shell.close_group_confirm', { n: String(ids.length), name }), + confirmLabel: tr(forgetting ? 'cockpit.forget' : 'cockpit.close'), + log: `bulk ${forgetting ? 'forget' : 'close'} ${ids.length} in ${group.key}`, + }); } /** * Close (or forget) the sessions the person selected in the sidebar, asked about once. * - * Groups close whole headings; this closes exactly what was picked — Ctrl+click and Shift+click in - * the sidebar — because "these six, but not that one" is how a deck actually gets tidied. + * Groups close whole headings; this closes exactly what was picked — the Select button's checkboxes, + * Ctrl+click and Shift+click in the sidebar — because "these six, but not that one" is how a deck + * actually gets tidied. */ export async function closeCockpitSessions(ids: readonly string[]): Promise { if (!ids.length) return false; - const ok = await confirmDialog(tr('shell.close_selected_confirm', { n: String(ids.length) }), tr('cockpit.close')); - if (!ok) return false; - window.devdeck.logDiagnostic(`bulk close ${ids.length} selected`, 'info', 'sidebar'); - void closeBackgroundAgents(ids.filter((id) => id.startsWith('bg:')), false); // asked above, once - for (const id of ids) { - if (id.startsWith('bg:')) continue; - const current = [...live.values()].find((entry) => navigationIdForLive(entry) === id); - if (current) { closeSession(current.session.id); continue; } - const entry = restorable.find((item) => cockpitNavigationId(item) === id); - if (entry) restorable = restorable.filter((r) => r !== entry); + // One picked row gets the single row's own question ("Close session 'x'?"), not "1 sessions". + const one = ids.length === 1 ? cockpitNavigationItems().find((item) => item.id === ids[0]) : undefined; + const question = !one ? tr('shell.close_selected_confirm', { n: String(ids.length) }) + : one.backgroundAgent ? tr('cockpit.bg_close_confirm', { name: one.label }) + : one.previous ? tr('shell.forget_group_confirm', { n: '1', name: one.label }) + : one.machineId && one.machineId !== LOCAL_MACHINE_ID ? tr('cockpit.close_confirm_remote', { name: one.label, machine: machineName(one.machineId) }) + : tr('cockpit.close_confirm', { name: one.label }); + return closeRows(ids, { + question, + confirmLabel: tr(one?.previous ? 'cockpit.forget' : 'cockpit.close'), + log: `bulk close ${ids.length} selected`, + }); +} + +/** + * Close (or forget) a set of sidebar rows, asked about ONCE — and the question says what the set + * holds: how many here, how many on which other PC (each of those stops an agent in front of + * someone else), how many background, how many saved. When some of the tiles are attached to Claude + * background sessions it also offers to end those, since closing only the tile leaves the session + * running and its row straight back under "Background". + * + * The tiles go first, in one batch: with a dozen of them, re-selecting and re-rendering after each + * one was a visible stall, so the pane settles once at the end. Background sessions go after the + * tiles (their `claude attach` must be gone before `claude stop`), with their own progress. + */ +async function closeRows(ids: readonly string[], opts: { question: string; confirmLabel: string; log: string }): Promise { + const parts = describeBreakdown(closeBreakdown(cockpitNavigationItems(), ids), { + local: (n) => tr('shell.close_breakdown_local', { n: String(n) }), + remote: (n, machine) => tr('shell.close_breakdown_remote', { n: String(n), machine }), + background: (n) => tr('shell.close_breakdown_background', { n: String(n) }), + previous: (n) => tr('shell.close_breakdown_previous', { n: String(n) }), + }); + const attached = attachedBackground(ids); + const message = parts.length > 1 ? `${opts.question}\n${parts.join(' · ')}` : opts.question; + const option = attached.length ? { label: tr('cockpit.close_also_background', { n: String(attached.length) }), checked: true } : undefined; + const answer = await openConfirm(message, opts.confirmLabel, option); + if (!answer.ok) return false; + window.devdeck.logDiagnostic(opts.log, 'info', 'sidebar'); + const delivered: Promise[] = []; + closingMany = true; + try { + for (const id of ids) { + if (id.startsWith('bg:')) continue; + // Resolved per id at the moment it is acted on: the list was captured before the confirmation, + // and a session can exit or be closed while the question is on screen. + const current = [...live.values()].find((entry) => navigationIdForLive(entry) === id); + if (current) { delivered.push(closeSession(current.session.id)); continue; } + const entry = restorable.find((item) => cockpitNavigationId(item) === id); + if (entry) restorable = restorable.filter((r) => r !== entry); + } + } finally { + closingMany = false; } + settleAfterBulkClose(); persist(); - renderList(); + // Closes on other PCs answer later, and each refusal or owed close toasts: for a group of them, + // one line instead of a stack. + if (delivered.length > 2) { + void collectToasts(() => Promise.allSettled(delivered), (messages) => tr('cockpit.bulk_notices', { first: messages[0], m: String(messages.length - 1) })); + } + const background = ids.filter((id) => id.startsWith('bg:')); + if (answer.checked) for (const { machineId, agent } of attached) background.push(backgroundNavigationId(machineId, agent.id)); + void closeBackgroundAgents(background, false); // asked above, once return true; } @@ -2792,6 +2889,28 @@ function waitForExit(id: string, timeoutMs: number): Promise { /** Small in-app confirmation modal (a DOM overlay, NOT window.confirm). Resolves true on confirm. */ function confirmDialog(message: string, confirmLabel: string): Promise { + return openConfirm(message, confirmLabel).then((answer) => answer.ok); +} + +/** The confirmation that is on screen, if any: a second question is never stacked on the first. */ +let confirmOpen = false; + +/** + * The confirmation, with an optional checkbox for one more thing the action may do ("also end the + * background session"), answered in the same breath. `checked` is the box's final state, false + * when there was none. + * + * One at a time: a ⋯ menu that stayed open above the first question could ask a second, and one + * Enter then answered both. While it is open, keyboard chords (the quick-open palette and the like) + * are kept out of the app behind it, so focus cannot leave the question and a later Enter cannot + * land on it from somewhere else. + */ +function openConfirm(message: string, confirmLabel: string, option?: { label: string; checked: boolean }): Promise<{ ok: boolean; checked: boolean }> { + if (confirmOpen) return Promise.resolve({ ok: false, checked: false }); + confirmOpen = true; + // The keyboard goes back where it was (the row, the bar's Close, the ⋯ item): after Cancel, a + // second Delete or Enter should act on the same thing, not on nothing. + const before = document.activeElement instanceof HTMLElement ? document.activeElement : null; return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'ck-confirm-overlay'; const panel = document.createElement('div'); panel.className = 'ck-confirm'; @@ -2800,29 +2919,50 @@ function confirmDialog(message: string, confirmLabel: string): Promise // stray text over the terminal, with no indication that the app is waiting on an answer. panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); panel.setAttribute('aria-label', message); const msg = document.createElement('div'); msg.className = 'ck-confirm-msg'; msg.textContent = message; + let box: HTMLInputElement | null = null; + const extra: HTMLElement[] = []; + if (option) { + const label = document.createElement('label'); label.className = 'ck-confirm-option'; + box = document.createElement('input'); box.type = 'checkbox'; box.checked = option.checked; + label.append(box, document.createTextNode(option.label)); + extra.push(label); + } const acts = document.createElement('div'); acts.className = 'ck-confirm-acts'; const cancel = document.createElement('button'); cancel.className = 'ck-confirm-cancel'; cancel.textContent = tr('cockpit.cancel'); const ok = document.createElement('button'); ok.className = 'ck-confirm-ok'; ok.textContent = confirmLabel; - const done = (v: boolean) => { document.removeEventListener('keydown', onKey, true); overlay.remove(); resolve(v); }; + const stops: HTMLElement[] = [...(box ? [box] : []), cancel, ok]; + const done = (v: boolean) => { + document.removeEventListener('keydown', onKey, true); + overlay.remove(); confirmOpen = false; + if (before?.isConnected && !before.closest('.hidden')) before.focus(); + resolve({ ok: v, checked: v && box ? box.checked : false }); + }; const onKey = (e: KeyboardEvent) => { - // Tab stays between the two buttons: the terminal behind this is focusable, and tabbing into it + // Tab stays inside the question: the terminal behind this is focusable, and tabbing into it // leaves the question open with the caret somewhere that will answer it by accident. if (e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); - (document.activeElement === ok ? cancel : ok).focus(); + const at = stops.indexOf(document.activeElement as HTMLElement); + stops[(at + (e.shiftKey ? -1 : 1) + stops.length) % stops.length].focus(); return; } + // A chord (Ctrl+Shift+P…) would move focus behind the question; nothing else is listening. + if (e.ctrlKey || e.metaKey || e.altKey) { e.stopPropagation(); return; } if (e.key !== 'Escape' && e.key !== 'Enter') return; e.preventDefault(); e.stopPropagation(); // keep Esc/Enter inside the dialog (don't leak to terminal/rename) - // Enter presses the FOCUSED button. Confirming regardless meant tabbing to Cancel and pressing - // Enter closed (or restarted) the sessions anyway. + // A key held down from before the question appeared (auto-repeat) is not an answer to it. + if (e.repeat) return; + // Enter presses the FOCUSED button, and only one inside this question. Confirming regardless + // meant tabbing to Cancel and pressing Enter closed (or restarted) the sessions anyway. + if (e.key === 'Enter' && !panel.contains(document.activeElement)) { ok.focus(); return; } + if (e.key === 'Enter' && document.activeElement === box) { box!.checked = !box!.checked; return; } done(e.key === 'Enter' && document.activeElement !== cancel); }; cancel.addEventListener('click', () => done(false)); ok.addEventListener('click', () => done(true)); overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) done(false); }); document.addEventListener('keydown', onKey, true); - acts.append(cancel, ok); panel.append(msg, acts); overlay.append(panel); + acts.append(cancel, ok); panel.append(msg, ...extra, acts); overlay.append(panel); document.body.appendChild(overlay); ok.focus(); }); @@ -2836,20 +2976,32 @@ async function requestClose(id: string): Promise { const question = l.machineId === LOCAL_MACHINE_ID ? tr('cockpit.close_confirm', { name }) : tr('cockpit.close_confirm_remote', { name, machine: machineName(l.machineId) }); - if (await confirmDialog(question, tr('cockpit.close'))) closeSession(id); + // A tile attached to one of Claude's background sessions: closing it alone leaves the session + // running, and its row comes straight back under "Background" — so the question offers both. + const attached = attachedBackground([navigationIdForLive(l)]); + const answer = await openConfirm(question, tr('cockpit.close'), attached.length ? { label: tr('cockpit.close_also_background_one'), checked: true } : undefined); + if (!answer.ok || !live.has(id)) return; + closeSession(id); + if (answer.checked) void closeBackgroundAgents(attached.map(({ machineId, agent }) => backgroundNavigationId(machineId, agent.id)), false); } -function closeSession(id: string): void { - const l = live.get(id); if (!l) return; +/** Close a tile. Resolves once the machine running it has answered — at once for this PC. */ +function closeSession(id: string): Promise { + const l = live.get(id); if (!l) return Promise.resolve(false); // Remembered BEFORE anything is sent: the machine's next announcement can be one it made before it // processed this close, and it must not bring the tile straight back (see CloseTombstones). closedTiles.add(l.machineId, id); - if (l.machineId === LOCAL_MACHINE_ID) window.devdeck.cockpit.close(id); - else void deliverClose(l.machineId, id, liveLabels.get(id) ?? l.customLabel ?? l.session.name); + let delivered: Promise; + if (l.machineId === LOCAL_MACHINE_ID) { window.devdeck.cockpit.close(id); delivered = Promise.resolve(true); } + else delivered = deliverClose(l.machineId, id, liveLabels.get(id) ?? l.customLabel ?? l.session.name); removeTile(l); - persist(); // close = forget (the closed session drops out of persistence) + if (!closingMany) persist(); // close = forget (the closed session drops out of persistence); a batch persists once + return delivered; } +/** Set while a bulk close takes tiles down: the pane settles once afterwards, not after each one. */ +let closingMany = false; + /** * Take a tile off the screen and out of `live`, and hand the selection to the session that was * looked at most recently — not to whichever happened to be opened first. @@ -2857,6 +3009,7 @@ function closeSession(id: string): void { function removeTile(l: Live): void { const id = l.session.id; l.term.dispose(); l.el.remove(); live.delete(id); updateRailBadge(); + if (closingMany) { if (selectedId === id) selectedId = null; return; } // settleAfterBulkClose does the rest, once if (selectedId === id) { const next = [...live.values()].sort((a, b) => b.lastSelectedAt - a.lastSelectedAt)[0]?.session.id ?? null; selectedId = null; @@ -2865,6 +3018,14 @@ function removeTile(l: Live): void { } else renderList(); } +/** After a batch of removeTile calls: select what is left (most recently looked at), or show the empty pane. */ +function settleAfterBulkClose(): void { + if (selectedId !== null) { renderList(); return; } + const next = [...live.values()].sort((a, b) => b.lastSelectedAt - a.lastSelectedAt)[0]?.session.id ?? null; + if (next) select(next); + else { hideAllTerminals(); setActiveUsageProvider(null); renderAll(); mainEl.classList.toggle('has-session', false); } +} + /** Closes owed to a machine that could not be reached when they were pressed. */ const owedCloseNames = new Map(); @@ -3115,7 +3276,8 @@ function renameInput(id: string, current: string): HTMLElement { } async function restoreAll(): Promise { - await restoreInTurn([...restorable]); + // Ten saved entries for a PC that is offline toasted ten times; one line says it once. + await collectToasts(() => restoreInTurn([...restorable]), (messages) => tr('cockpit.bulk_notices', { first: messages[0], m: String(messages.length - 1) })); } function forgetSession(entry: PersistedSession): void { diff --git a/src/renderer/icons.ts b/src/renderer/icons.ts index 096a1ff..7a4ce80 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' | 'image' | 'paperclip' | 'download' | 'copy' | 'terminal' | 'reply' | 'timeline' | 'pen' | 'send' | 'undo' | 'square' | 'arrow' | 'branch'; + | 'trash' | 'chevron-left' | 'chevron-right' | 'chevron-down' | 'minimize' | 'maximize' | 'restore' | 'moon' | 'power' | 'globe' | 'machine' | 'image' | 'paperclip' | 'download' | 'copy' | 'terminal' | 'reply' | 'timeline' | 'pen' | 'send' | 'undo' | 'square' | 'arrow' | 'branch' | 'check' | 'select'; const paths: Record = { search: '', @@ -51,6 +51,9 @@ const paths: Record = { square: '', arrow: '', branch: '', + // A tick on its own (inside a row's checkbox) and a ticked box (the Select button). + check: '', + select: '', }; export function iconMarkup(name: IconName): string { diff --git a/src/renderer/index.html b/src/renderer/index.html index 6742826..6b76b17 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -57,6 +57,9 @@
+
diff --git a/src/renderer/loadError.ts b/src/renderer/loadError.ts index 9a53594..d4870f0 100644 --- a/src/renderer/loadError.ts +++ b/src/renderer/loadError.ts @@ -17,6 +17,7 @@ export function renderLoadError(container: HTMLElement, retry: () => void): void /** Transient toast in the shared #toast-host (same pattern main.ts uses for main-process errors). */ export function toast(message: string): void { + if (collecting) { collecting.push(message); return; } const host = document.getElementById('toast-host'); if (!host) return; const el = document.createElement('div'); el.className = 'toast'; el.textContent = message; @@ -24,6 +25,28 @@ export function toast(message: string): void { setTimeout(() => el.remove(), 6000); } +/** Messages gathered by collectToasts while it runs, or null outside one. */ +let collecting: string[] | null = null; + +/** + * Run a bulk action and show the toasts it raises as ONE toast when there are many. + * + * Closing a dozen sessions on a PC that refuses, or restoring ten saved entries for a PC that is + * offline, toasted once per row — a stack past the top of the window, every line saying the same + * thing with a different name. Up to two messages are shown as they are; more become one line from + * `summary`, which gets them all (the diagnostics log keeps the individual ones). + */ +export async function collectToasts(run: () => Promise, summary: (messages: string[]) => string): Promise { + if (collecting) return run(); // already inside one: the outer collection takes these too + const mine: string[] = []; + collecting = mine; + try { return await run(); } finally { + collecting = null; + if (mine.length <= 2) for (const message of mine) toast(message); + else toast(summary(mine)); + } +} + /** A toast that stays and can be rewritten while something long runs (a large file being sent). */ export function progressToast(): { set(message: string): void; close(): void } { const host = document.getElementById('toast-host'); diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index a0fdaa7..56e14cc 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -53,7 +53,16 @@ "shell.close_selected": "Close", "shell.select_all": "Select all", "shell.clear_selection": "Clear", - "shell.selection_hint": "Ctrl+click selects a session, Shift+click a range; then close them at once.", + "shell.selection_hint": "Click rows to pick them (Shift+click for a range, the box on a heading for a whole group), then Close. Delete closes the picked ones; Esc leaves.", + "shell.select_mode": "Select", + "shell.select_mode_hint": "Pick several sessions and close them at once. Ctrl+click and Shift+click pick too.", + "shell.select_rows": "Select…", + "shell.selection_done": "Done", + "shell.select_group": "Select every session in {name}", + "shell.close_breakdown_local": "{n} on this PC", + "shell.close_breakdown_remote": "{n} on {machine}", + "shell.close_breakdown_background": "{n} background", + "shell.close_breakdown_previous": "{n} saved", "shell.projects": "Projects", "shell.collapse": "Collapse sidebar", "shell.expand": "Expand sidebar", @@ -277,6 +286,7 @@ "cockpit.open_failed": "Could not open a session in {name}", "cockpit.open_folder_missing": "its folder no longer exists: {path} (deleted or moved)", "cockpit.forget": "Forget", + "cockpit.forgot": "'{name}' removed from the list — the conversation is kept", "cockpit.empty_prev": "Restore a previous session, or open one from Projects.", "usage.bar_5h": "5h", "usage.bar_week": "Weekly", @@ -649,9 +659,18 @@ "cockpit.bg_close_failed": "Could not close the background session '{name}' — open claude agents once in a terminal on this PC (it starts Claude Code's background service), then try again, or run claude rm {id} there", "cockpit.bg_close_failed_on_machine": "Could not close the background session '{name}' on {machine} — open claude agents once in a terminal on {machine} (it starts Claude Code's background service), then try again, or run claude rm {id} there", "cockpit.bg_close_old_host": "DevDeck on {machine} is too old to close a background session — update it there", + "cockpit.close_also_background": "Also end the Claude background session behind {n} of these — removed from Claude Code’s list; the conversation is kept", + "cockpit.close_also_background_one": "Also end the Claude background session behind this tile — removed from Claude Code’s list; the conversation is kept", + "cockpit.bg_closing": "Closing background sessions… {done}/{total}", + "cockpit.bg_closed_many": "Closed {n} background sessions — their conversations are kept", + "cockpit.bg_close_failed_many": "{n} background sessions could not be closed ({names}) — open claude agents once in a terminal on the PC that runs them, then try again", + "cockpit.bulk_notices": "{first} — and {m} more (see Diagnostics)", "whatsnew.f.bg-close.title": "Close background sessions", "whatsnew.f.bg-close.body": "A Claude Code background session (the sidebar's Background group) can now be closed from its row's ⋯ menu or with the group's bulk close. Finished or failed sessions no longer sit in the list forever. The conversation is kept.", "whatsnew.f.bg-close.where": "Sidebar → Background group → the row's ⋯ → Close", + "whatsnew.f.multi-close.title": "Close several sessions at once", + "whatsnew.f.multi-close.body": "A Select button above the session groups (and Select… in every row’s ⋯ menu) turns on a selection mode: tick the rows — local, on another PC, background or saved — or a whole group by its heading, then Close once. The confirmation says what is on which PC. Ctrl+click and Shift+click still work.", + "whatsnew.f.multi-close.where": "Sidebar → Select (above the session groups)", "whatsnew.f.send-any-file.title": "Send any file to a session on another PC", "whatsnew.f.send-any-file.body": "Zip, spreadsheet, source file, video: any kind, up to 4 GB (it was 21 document kinds, 5 MB). Large files go in pieces with their progress shown.", "whatsnew.f.send-any-file.where": "The 📎 button in a remote session's header, or drop onto its terminal", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index 5c74546..5019247 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -53,7 +53,16 @@ "shell.close_selected": "閉じる", "shell.select_all": "すべて選択", "shell.clear_selection": "選択解除", - "shell.selection_hint": "Ctrl+クリックでセッションを選択、Shift+クリックで範囲選択して、まとめて閉じられます。", + "shell.selection_hint": "行をクリックして選び(Shift+クリックで範囲、見出しのボックスでグループ全体)、閉じるを押します。Delete キーで選んだものを閉じ、Esc で選択を終了します。", + "shell.select_mode": "選択", + "shell.select_mode_hint": "複数のセッションを選んでまとめて閉じます。Ctrl+クリック、Shift+クリックでも選べます。", + "shell.select_rows": "複数選択…", + "shell.selection_done": "完了", + "shell.select_group": "{name} のセッションをすべて選択", + "shell.close_breakdown_local": "この PC {n} 件", + "shell.close_breakdown_remote": "{machine} {n} 件", + "shell.close_breakdown_background": "バックグラウンド {n} 件", + "shell.close_breakdown_previous": "保存済み {n} 件", "shell.projects": "プロジェクト", "shell.collapse": "サイドバーを折りたたむ", "shell.expand": "サイドバーを展開", @@ -277,6 +286,7 @@ "cockpit.open_failed": "{name} でセッションを開けませんでした", "cockpit.open_folder_missing": "フォルダーが存在しません: {path} (削除または移動されました)", "cockpit.forget": "削除", + "cockpit.forgot": "'{name}' を一覧から削除しました — 会話の記録は残っています", "cockpit.empty_prev": "前回のセッションを復元するか、プロジェクトから開いてください。", "usage.bar_5h": "5時間", "usage.bar_week": "週間", @@ -649,9 +659,18 @@ "cockpit.bg_close_failed": "バックグラウンドセッション '{name}' を閉じられませんでした — この PC のターミナルで claude agents を一度開いてから(Claude Code のバックグラウンドサービスが起動します)もう一度試すか、そこで claude rm {id} を実行してください", "cockpit.bg_close_failed_on_machine": "{machine} のバックグラウンドセッション '{name}' を閉じられませんでした — {machine} のターミナルで claude agents を一度開いてから(Claude Code のバックグラウンドサービスが起動します)もう一度試すか、そこで claude rm {id} を実行してください", "cockpit.bg_close_old_host": "{machine} の DevDeck が古いためバックグラウンドセッションを閉じられません — そちらを更新してください", + "cockpit.close_also_background": "このうち {n} 件に接続中の Claude バックグラウンドセッションも終了 — Claude Code の一覧から削除され、会話の記録は残ります", + "cockpit.close_also_background_one": "このタイルに接続中の Claude バックグラウンドセッションも終了 — Claude Code の一覧から削除され、会話の記録は残ります", + "cockpit.bg_closing": "バックグラウンドセッションを閉じています… {done}/{total}", + "cockpit.bg_closed_many": "バックグラウンドセッション {n} 件を閉じました — 会話の記録は残っています", + "cockpit.bg_close_failed_many": "バックグラウンドセッション {n} 件を閉じられませんでした({names})— そのセッションがある PC のターミナルで claude agents を一度開いてから、もう一度お試しください", + "cockpit.bulk_notices": "{first} — ほか {m} 件(診断ログを参照)", "whatsnew.f.bg-close.title": "バックグラウンドセッションを閉じる", "whatsnew.f.bg-close.body": "Claude Code のバックグラウンドセッション(サイドバーの「バックグラウンド」グループ)を、行の ⋯ メニューやグループの一括クローズで閉じられます。終了・失敗したセッションが一覧に残り続ける問題が解消されます。会話の記録は残ります。", "whatsnew.f.bg-close.where": "サイドバー「バックグラウンド」グループ → 行の ⋯ → 閉じる", + "whatsnew.f.multi-close.title": "複数のセッションをまとめて閉じる", + "whatsnew.f.multi-close.body": "セッショングループ上の「選択」ボタン(または行の ⋯ メニューの「複数選択…」)で選択モードになります。ローカル・他の PC・バックグラウンド・保存済みの行にチェックを入れるか、見出しでグループ全体を選び、閉じるを一度押すだけです。確認ダイアログにはどの PC に何件あるかが表示されます。Ctrl+クリックと Shift+クリックも引き続き使えます。", + "whatsnew.f.multi-close.where": "サイドバー → セッショングループ上の「選択」", "whatsnew.f.send-any-file.title": "別の PC のセッションにどんなファイルでも", "whatsnew.f.send-any-file.body": "zip、Excel、ソースファイル、動画まで、種類を問わず 4GB まで送れます (以前はドキュメント 21 種、5MB)。大きなファイルは分割して送り、進行状況を表示します。", "whatsnew.f.send-any-file.where": "リモートセッションのヘッダーの 📎 ボタン、またはターミナルへのドラッグ&ドロップ", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 2093bcf..2f74e03 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -46,14 +46,23 @@ "shell.grp_remote": "다른 PC", "shell.close_group": "{name} 세션 {n}개 모두 닫기", "shell.forget_group": "{name} {n}개 모두 목록에서 지우기", - "shell.close_group_confirm": "{name}의 세션 {n}개를 모두 닫습니까? 실행 중인 에이전트도 함께 종료됩니다.", - "shell.forget_group_confirm": "{name} {n}개를 목록에서 지웁니까? 대화 기록 자체는 삭제되지 않습니다.", + "shell.close_group_confirm": "{name}의 세션 {n}개를 모두 닫을까요? 실행 중인 에이전트도 함께 종료됩니다.", + "shell.forget_group_confirm": "{name} {n}개를 목록에서 지울까요? 대화 기록 자체는 삭제되지 않습니다.", "shell.close_selected_confirm": "선택한 세션 {n}개를 닫을까요? 실행 중인 에이전트도 함께 종료됩니다. 저장된 항목은 목록에서 제거됩니다.", "shell.selected_n": "{n}개 선택", "shell.close_selected": "닫기", "shell.select_all": "모두 선택", "shell.clear_selection": "선택 해제", - "shell.selection_hint": "Ctrl+클릭으로 세션을 선택하고 Shift+클릭으로 범위를 선택한 뒤 한 번에 닫을 수 있습니다.", + "shell.selection_hint": "행을 클릭해 고르고(Shift+클릭은 범위, 그룹 머리글의 상자는 그룹 전체) 닫기를 누르세요. Delete 키는 고른 것을 닫고, Esc는 선택을 끝냅니다.", + "shell.select_mode": "선택", + "shell.select_mode_hint": "세션 여러 개를 골라 한 번에 닫습니다. Ctrl+클릭, Shift+클릭으로도 고를 수 있습니다.", + "shell.select_rows": "여러 개 선택…", + "shell.selection_done": "완료", + "shell.select_group": "{name}의 세션 모두 선택", + "shell.close_breakdown_local": "이 PC {n}개", + "shell.close_breakdown_remote": "{machine} {n}개", + "shell.close_breakdown_background": "백그라운드 {n}개", + "shell.close_breakdown_previous": "저장된 항목 {n}개", "shell.projects": "프로젝트", "shell.collapse": "사이드바 접기", "shell.expand": "사이드바 펼치기", @@ -277,6 +286,7 @@ "cockpit.open_failed": "{name}에서 세션을 열지 못했습니다", "cockpit.open_folder_missing": "폴더가 더 이상 없습니다: {path} (삭제되었거나 옮겨졌습니다)", "cockpit.forget": "지우기", + "cockpit.forgot": "'{name}' 항목을 목록에서 지웠습니다 — 대화 기록은 남아 있습니다", "cockpit.empty_prev": "이전 세션을 복원하거나 프로젝트에서 새로 여세요.", "usage.bar_5h": "5시간", "usage.bar_week": "주간", @@ -649,9 +659,18 @@ "cockpit.bg_close_failed": "'{name}' 백그라운드 세션을 닫지 못했습니다 — 이 PC의 터미널에서 claude agents 를 한 번 연 뒤(Claude Code 백그라운드 서비스가 시작됩니다) 다시 시도하거나, 거기서 claude rm {id} 를 실행해 보세요", "cockpit.bg_close_failed_on_machine": "{machine}의 '{name}' 백그라운드 세션을 닫지 못했습니다 — {machine}의 터미널에서 claude agents 를 한 번 연 뒤(Claude Code 백그라운드 서비스가 시작됩니다) 다시 시도하거나, 거기서 claude rm {id} 를 실행해 보세요", "cockpit.bg_close_old_host": "{machine}의 DevDeck이 오래되어 백그라운드 세션을 닫을 수 없습니다 — 그쪽을 업데이트해 주세요", + "cockpit.close_also_background": "이 중 {n}개에 붙어 있는 Claude 백그라운드 세션도 종료 — Claude Code 목록에서 제거되며 대화 기록은 남습니다", + "cockpit.close_also_background_one": "이 타일에 붙어 있는 Claude 백그라운드 세션도 종료 — Claude Code 목록에서 제거되며 대화 기록은 남습니다", + "cockpit.bg_closing": "백그라운드 세션을 닫는 중… {done}/{total}", + "cockpit.bg_closed_many": "백그라운드 세션 {n}개를 닫았습니다 — 대화 기록은 남아 있습니다", + "cockpit.bg_close_failed_many": "백그라운드 세션 {n}개를 닫지 못했습니다({names}) — 그 세션이 있는 PC의 터미널에서 claude agents 를 한 번 연 뒤 다시 시도해 주세요", + "cockpit.bulk_notices": "{first} — 외 {m}건(진단 로그 참고)", "whatsnew.f.bg-close.title": "백그라운드 세션 닫기", "whatsnew.f.bg-close.body": "Claude Code 백그라운드 세션(사이드바 「백그라운드」 그룹)을 행의 ⋯ 메뉴나 그룹 묶음 닫기로 닫을 수 있습니다. 끝났거나 실패한 세션이 목록에 영원히 남던 문제가 해결됩니다. 대화 기록은 남습니다.", "whatsnew.f.bg-close.where": "사이드바 「백그라운드」 그룹 → 행의 ⋯ → 닫기", + "whatsnew.f.multi-close.title": "세션 여러 개 한 번에 닫기", + "whatsnew.f.multi-close.body": "세션 그룹 위의 「선택」 버튼(또는 행의 ⋯ 메뉴의 「여러 개 선택…」)으로 선택 모드를 켭니다. 로컬·다른 PC·백그라운드·저장된 세션을 체크하거나 그룹 머리글로 그룹 전체를 고른 뒤 닫기를 한 번만 누르면 됩니다. 확인 창은 어느 PC에 몇 개인지 알려 줍니다. Ctrl+클릭과 Shift+클릭도 그대로 됩니다.", + "whatsnew.f.multi-close.where": "사이드바 → 세션 그룹 위의 「선택」", "whatsnew.f.send-any-file.title": "다른 PC 세션에 어떤 파일이든", "whatsnew.f.send-any-file.body": "zip, 엑셀, 소스 파일, 영상까지 종류를 가리지 않고 4GB까지 보낼 수 있습니다(이전에는 문서 21종, 5MB). 큰 파일은 나눠서 보내며 진행률이 표시됩니다.", "whatsnew.f.send-any-file.where": "다른 PC 세션 헤더의 📎 버튼, 또는 터미널에 끌어다 놓기", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index d035a5b..2f2c5c7 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -53,7 +53,16 @@ "shell.close_selected": "关闭", "shell.select_all": "全选", "shell.clear_selection": "取消选择", - "shell.selection_hint": "Ctrl+点击选择会话,Shift+点击选择范围,然后一次性关闭。", + "shell.selection_hint": "点击行以选中(Shift+点击选范围,分组标题上的方框选整组),然后点关闭。Delete 键关闭所选,Esc 退出选择。", + "shell.select_mode": "选择", + "shell.select_mode_hint": "选中多个会话并一次性关闭。也可以用 Ctrl+点击、Shift+点击来选。", + "shell.select_rows": "多选…", + "shell.selection_done": "完成", + "shell.select_group": "选中 {name} 中的所有会话", + "shell.close_breakdown_local": "本机 {n} 个", + "shell.close_breakdown_remote": "{machine} {n} 个", + "shell.close_breakdown_background": "后台 {n} 个", + "shell.close_breakdown_previous": "已保存 {n} 个", "shell.projects": "项目", "shell.collapse": "收起侧栏", "shell.expand": "展开侧栏", @@ -277,6 +286,7 @@ "cockpit.open_failed": "无法在 {name} 中打开会话", "cockpit.open_folder_missing": "文件夹已不存在: {path} (已删除或移动)", "cockpit.forget": "移除", + "cockpit.forgot": "已从列表移除 '{name}' — 对话记录已保留", "cockpit.empty_prev": "恢复上次会话,或从项目中打开。", "usage.bar_5h": "5小时", "usage.bar_week": "每周", @@ -649,9 +659,18 @@ "cockpit.bg_close_failed": "无法关闭后台会话 '{name}' — 请在本机终端先打开一次 claude agents(这会启动 Claude Code 的后台服务),然后重试,或在那里运行 claude rm {id}", "cockpit.bg_close_failed_on_machine": "无法关闭 {machine} 上的后台会话 '{name}' — 请在 {machine} 的终端先打开一次 claude agents(这会启动 Claude Code 的后台服务),然后重试,或在那里运行 claude rm {id}", "cockpit.bg_close_old_host": "{machine} 上的 DevDeck 版本过旧,无法关闭后台会话 — 请在那台电脑上更新", + "cockpit.close_also_background": "同时结束其中 {n} 个所连接的 Claude 后台会话 — 将从 Claude Code 列表中移除,对话记录保留", + "cockpit.close_also_background_one": "同时结束此磁贴所连接的 Claude 后台会话 — 将从 Claude Code 列表中移除,对话记录保留", + "cockpit.bg_closing": "正在关闭后台会话… {done}/{total}", + "cockpit.bg_closed_many": "已关闭 {n} 个后台会话 — 对话记录已保留", + "cockpit.bg_close_failed_many": "{n} 个后台会话无法关闭({names})— 请在运行它们的电脑的终端里打开一次 claude agents,然后重试", + "cockpit.bulk_notices": "{first} — 另有 {m} 条(见诊断日志)", "whatsnew.f.bg-close.title": "关闭后台会话", "whatsnew.f.bg-close.body": "Claude Code 的后台会话(侧栏「后台」分组)现在可以通过行的 ⋯ 菜单或分组批量关闭来关闭。已结束或失败的会话不再永远留在列表中。对话记录会保留。", "whatsnew.f.bg-close.where": "侧栏「后台」分组 → 行的 ⋯ → 关闭", + "whatsnew.f.multi-close.title": "一次关闭多个会话", + "whatsnew.f.multi-close.body": "会话分组上方的「选择」按钮(或每行 ⋯ 菜单中的「多选…」)会开启选择模式:勾选本机、其他电脑、后台或已保存的行,或通过分组标题选中整组,然后只需点一次关闭。确认框会说明各台电脑上有多少个。Ctrl+点击和 Shift+点击仍然可用。", + "whatsnew.f.multi-close.where": "侧栏 → 会话分组上方的「选择」", "whatsnew.f.send-any-file.title": "向另一台 PC 上的会话发送任何文件", "whatsnew.f.send-any-file.body": "zip、表格、源文件、视频,任何类型,最大 4GB (以前为 21 种文档、5MB)。大文件分块发送并显示进度。", "whatsnew.f.send-any-file.where": "远程会话标题栏的 📎 按钮,或拖放到其终端", diff --git a/src/renderer/shell.ts b/src/renderer/shell.ts index 6f3ecb3..106e830 100644 --- a/src/renderer/shell.ts +++ b/src/renderer/shell.ts @@ -28,6 +28,11 @@ import { type ShellSessionInput, } from '../shared/shellNavigation'; import type { ViewId } from './nav'; +import { + allSelected, beginSelecting, emptySelection, endSelecting, pruneSelection, rangeTo, selectAll, toggleGroup, toggleKey, + type SessionSelection, +} from '../shared/sessionSelection'; +import { newDot } from './whatsNew'; const viewIcons: Record = { projects: 'projects', usage: 'usage', settings: 'settings', next: 'tasks', cockpit: 'sessions', @@ -129,6 +134,8 @@ export function mountShell(options: { const projectsToggle = document.getElementById('shell-projects-toggle') as HTMLButtonElement; const projectsMore = document.getElementById('shell-projects-more') as HTMLButtonElement; const restoreAll = document.getElementById('shell-restore-all') as HTMLButtonElement; + const sessionTools = document.getElementById('shell-session-tools')!; + const selectToggle = document.getElementById('shell-select-toggle') as HTMLButtonElement; const mobileToggle = document.getElementById('shell-mobile-toggle') as HTMLButtonElement; const mobileToggleLabel = document.getElementById('shell-mobile-toggle-label')!; const mobileBackdrop = document.getElementById('shell-mobile-backdrop') as HTMLButtonElement; @@ -212,6 +219,40 @@ export function mountShell(options: { const row = focusedKey ? rows.get(focusedKey) : undefined; if (row && (document.activeElement === document.body || document.activeElement == null)) row.focus(); }; + /** + * Where the keyboard is inside the session list, as something that survives a re-render: a row's + * key plus which of its controls (the row, its ⋯, an item of its open menu), or a group's key plus + * which of its heading controls. Moving a section or a row in the DOM (every publish that + * reorders) blurs whatever was focused inside it; a row was already put back, but the ⋯ button, + * an open menu's item, a group's fold/check/close were not — a keyboard user lost the menu they + * were in every time a sibling changed state. + */ + const focusedSessionControl = (): { wrapKey?: string; groupKey?: string; selector: string } | null => { + const el = document.activeElement; + if (!(el instanceof HTMLElement) || !sessionHost.contains(el)) return null; + const wrap = el.closest('.shell-session-wrap'); + if (wrap) { + const key = wrap.querySelector('.shell-session')?.dataset.shellEntityKey; + if (!key) return null; + if (el.classList.contains('shell-session')) return { wrapKey: key, selector: '.shell-session' }; + if (el.classList.contains('shell-session-actions')) return { wrapKey: key, selector: '.shell-session-actions' }; + if (el.classList.contains('shell-session-pick')) return { wrapKey: key, selector: '.shell-session-pick' }; + if (el.dataset.sessionAction) return { wrapKey: key, selector: `[data-session-action="${el.dataset.sessionAction}"]` }; + return null; + } + const section = el.closest('.shell-group'); + if (!section?.dataset.groupKey) return null; + for (const cls of ['shell-group-toggle', 'shell-group-check', 'shell-group-bulk', 'shell-more']) { + if (el.classList.contains(cls)) return { groupKey: section.dataset.groupKey, selector: `.${cls}` }; + } + return null; + }; + const restoreFocusedSessionControl = (at: ReturnType): void => { + if (!at || (document.activeElement !== document.body && document.activeElement != null)) return; + const scope = at.wrapKey ? sessionWraps.get(at.wrapKey) : at.groupKey ? sessionSections.get(at.groupKey) : undefined; + const target = scope?.querySelector(at.selector); + if (target && !target.classList.contains('hidden') && !target.closest('.hidden')) target.focus(); + }; mobileToggle.querySelector('.shell-mobile-toggle-icon')?.append(createIcon('sessions')); restoreAll.prepend(createIcon('restart')); @@ -232,11 +273,40 @@ export function mountShell(options: { for (const [key, menu] of sessionMenus) { if (menu.classList.contains('hidden')) continue; menu.classList.add('hidden'); + menu.style.cssText = ''; const trigger = sessionWraps.get(key)?.querySelector('.shell-session-actions'); trigger?.setAttribute('aria-expanded', 'false'); if (restoreFocus) trigger?.focus(); } }; + /** + * Put a row's ⋯ menu where it can be seen. The session list is its own `overflow` box, sized to its + * content when short: a menu dropping down from the last row is clipped at the box's bottom, and + * one flipped up from the first row is clipped at its top — on a fresh install with one session, + * only the bottom item (Close) showed. So on the desktop the menu is positioned against the + * viewport instead, below the ⋯ when there is room and above it otherwise, and closed on scroll + * (it would stay put while the row moved). The mobile drawer is a transformed box, inside which + * fixed coordinates mean something else, so there the menu stays where the CSS puts it. + */ + const placeSessionMenu = (menu: HTMLElement, trigger: HTMLElement): void => { + menu.classList.remove('drop-up'); + if (matchMedia('(max-width: 720px)').matches) { + const scroller = menu.closest('#shell-session-groups'); + if (scroller && menu.getBoundingClientRect().bottom > scroller.getBoundingClientRect().bottom) menu.classList.add('drop-up'); + return; + } + const at = trigger.getBoundingClientRect(); + const height = menu.offsetHeight, width = menu.offsetWidth; + const below = at.bottom - 2; + const top = below + height <= window.innerHeight - 4 ? below : Math.max(4, at.top + 2 - height); + menu.style.position = 'fixed'; + menu.style.right = 'auto'; + menu.style.bottom = 'auto'; + menu.style.top = `${Math.round(top)}px`; + menu.style.left = `${Math.round(Math.max(4, at.right - width))}px`; + }; + sessionHost.addEventListener('scroll', () => closeSessionMenus(), { passive: true }); + window.addEventListener('resize', () => closeSessionMenus()); const mobileFocusable = (): HTMLElement[] => Array.from(sidebar.querySelectorAll( 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', @@ -301,11 +371,13 @@ export function mountShell(options: { const id = row.dataset.sessionId; if (!id) return; // Ctrl+click picks a session (or un-picks it); Shift+click picks everything between the last - // pick and this row, in the order the rail shows them. Either way the row is NOT opened — a - // selection is for closing several at once, and opening each while picking would be absurd. - if (event.ctrlKey || event.metaKey) { event.preventDefault(); toggleSelected(key); return; } - if (event.shiftKey && selectionAnchor) { event.preventDefault(); selectRange(selectionAnchor, key); return; } - clearSelection(); + // pick and this row, in the order the rail shows them; and while the selection mode is on (the + // Select button, a row's "Select…", or any pick), a plain click picks too. None of these opens + // the row — a selection is for closing several at once, and opening each while picking would + // be absurd. + if (event.ctrlKey || event.metaKey) { event.preventDefault(); setSelection(toggleKey(selection, key)); return; } + if (event.shiftKey) { event.preventDefault(); setSelection(rangeTo(selection, visibleSessionKeys(), key)); return; } + if (selection.mode) { event.preventDefault(); setSelection(toggleKey(selection, key)); return; } updateActiveEntity(row, key); closeMobileDrawer(); options.onSession(id); @@ -313,6 +385,17 @@ export function mountShell(options: { const actions = document.createElement('button'); actions.type = 'button'; actions.className = 'shell-session-actions'; actions.append(createIcon('more')); actions.setAttribute('aria-haspopup', 'menu'); actions.setAttribute('aria-expanded', 'false'); const menu = document.createElement('div'); menu.className = 'menu shell-session-menu hidden'; menu.setAttribute('role', 'menu'); + // "Select…" picks this row and turns the selection mode on — the way in for anyone who does not + // know Ctrl+click. Every row offers it, whatever else it can do. + const pick = document.createElement('button'); + pick.type = 'button'; pick.className = 'menu-item shell-session-pick'; pick.setAttribute('role', 'menuitem'); + pick.addEventListener('click', (event) => { + event.stopPropagation(); + closeSessionMenus(); + setSelection(toggleKey(selection, key)); + sessionRows.get(key)?.focus(); + }); + menu.appendChild(pick); // One menu item per action the row can currently offer. `close` and `forget` both destroy state, // so they keep the destructive styling — and `close` still routes through cockpit's confirm. for (const action of ['pin', 'unpin', 'rename', 'close', 'forget'] as ShellSessionAction[]) { @@ -337,14 +420,7 @@ export function mountShell(options: { closeSessionMenus(); if (!opening) return; menu.classList.remove('hidden'); actions.setAttribute('aria-expanded', 'true'); - // The session list is its own `overflow-y: auto` box, so a menu dropping DOWN from a row near - // the bottom is clipped by the scroller and its lower items become unreachable. Flip it above - // the row when there isn't room below. - menu.classList.remove('drop-up'); - const scroller = menu.closest('#shell-session-groups'); - if (scroller && menu.getBoundingClientRect().bottom > scroller.getBoundingClientRect().bottom) { - menu.classList.add('drop-up'); - } + placeSessionMenu(menu, actions); }); actions.addEventListener('keydown', (event) => { if (event.key !== 'ArrowDown') return; @@ -380,14 +456,28 @@ export function mountShell(options: { let signal = row.querySelector('.shell-signal'); let copy = row.querySelector('.shell-entity-copy'); if (!signal || !copy) { + // The checkbox is drawn only while the selection mode is on (CSS); aria-pressed on the row + // carries the picked state either way. + const check = document.createElement('span'); check.className = 'shell-session-check'; check.setAttribute('aria-hidden', 'true'); + check.appendChild(createIcon('check', 'ui-icon shell-session-check-mark')); signal = document.createElement('span'); signal.className = 'shell-signal'; signal.setAttribute('aria-hidden', 'true'); copy = document.createElement('span'); copy.className = 'shell-entity-copy'; copy.append(document.createElement('strong'), ' ', document.createElement('small'), ' ', document.createElement('em')); - row.replaceChildren(signal, copy); + row.replaceChildren(check, signal, copy); + } + const pickItem = wrap.querySelector('.shell-session-pick'); + if (pickItem && pickItem.dataset.rendered !== currentLang()) { + pickItem.replaceChildren(createIcon('select'), document.createTextNode(tr('shell.select_rows'))); + pickItem.dataset.rendered = currentLang(); } // Shape (not just hue) carries the state, and "working" spins — a still sidebar reads as a dead one. signal.className = `shell-signal signal-${sessionStatusShape(item)}`; copy.querySelector('strong')!.textContent = item.label; + // A pinned row says so on the row. The urgent groups (needs you, working, another PC) come + // before the pinned one, so a pin on such a row moved nothing — and read as having done nothing. + let pinMark = row.querySelector('.shell-session-pin'); + if (item.pinned && !pinMark) { pinMark = createIcon('pin', 'ui-icon shell-session-pin') as unknown as HTMLElement; row.appendChild(pinMark); } + else if (!item.pinned && pinMark) pinMark.remove(); const detail = copy.querySelector('small')!; detail.textContent = item.detail; detail.classList.toggle('shell-session-warning', item.conversationGone === true); @@ -431,7 +521,12 @@ export function mountShell(options: { const domId = key.replace(/[^A-Za-z0-9_-]/g, '-'); const section = document.createElement('section'); section.className = `shell-group group-${kind}`; section.dataset.groupKey = key; - const heading = document.createElement('h2'); heading.className = 'shell-group-heading'; heading.id = `shell-session-${domId}`; + // The sticky bar is a div; the HEADING inside it is only the fold toggle with the name and count. + // The section is named by that heading (`aria-labelledby` below), so the group checkbox and the + // bulk close, which share the bar, do not end up in the name — a region announced as "Idle 5 + // Select every session in Idle Close all 5 sessions in Idle" is not a name. + const heading = document.createElement('div'); heading.className = 'shell-group-heading'; + const title = document.createElement('h2'); title.className = 'shell-group-title'; title.id = `shell-session-${domId}`; const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'shell-group-toggle'; toggle.setAttribute('aria-controls', `shell-session-${domId}-body`); toggle.append( @@ -448,7 +543,25 @@ export function mountShell(options: { try { localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify(collapsedGroups)); } catch { /* private mode / quota — folding just won't survive the restart */ } renderSessions(sessions); }); - heading.appendChild(toggle); + title.appendChild(toggle); + heading.appendChild(title); + // In the selection mode, a checkbox on the heading picks the whole group (opening it in full + // first, so nothing is picked that cannot be seen) — or un-picks it when it is all picked. + const groupCheck = document.createElement('button'); + groupCheck.type = 'button'; groupCheck.className = 'shell-group-check'; groupCheck.setAttribute('role', 'checkbox'); + groupCheck.appendChild(createIcon('check', 'ui-icon shell-session-check-mark')); + groupCheck.addEventListener('click', (event) => { + event.stopPropagation(); + closeSessionMenus(); + if (collapsedGroups.includes(key)) { + collapsedGroups = toggleCollapsedGroup(collapsedGroups, key); + try { localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify(collapsedGroups)); } catch { /* see above */ } + } + expandedGroups.add(key); + renderSessions(sessions); + setSelection(toggleGroup(selection, groupSessionKeys(key))); + }); + heading.appendChild(groupCheck); // Close (or forget) a whole group at once. Shutting down a dozen sessions one confirmation at a // time is the kind of chore people simply stop doing, and the groups are already the units // someone thinks in: everything idle, everything that has exited, everything on that machine. @@ -457,6 +570,7 @@ export function mountShell(options: { bulk.appendChild(createIcon('close', 'ui-icon')); bulk.addEventListener('click', (event) => { event.stopPropagation(); + closeSessionMenus(); // a ⋯ menu left open would sit above the question and could ask a second const current = buildSessionGroups(sessions).find((entry) => entry.key === key); if (current) options.onGroupClose?.(current); }); @@ -464,64 +578,116 @@ export function mountShell(options: { const body = document.createElement('div'); body.className = 'shell-group-body'; body.id = `shell-session-${domId}-body`; const more = document.createElement('button'); more.type = 'button'; more.className = 'shell-more hidden'; more.addEventListener('click', () => { expandedGroups.add(key); renderSessions(sessions); }); - section.setAttribute('aria-labelledby', heading.id); + section.setAttribute('aria-labelledby', title.id); section.append(heading, body, more); return section; }; // ---- picking several sessions to close at once ---- - const selected = new Set(); - let selectionAnchor: string | null = null; + // The selection itself is a value (shared/sessionSelection); this block is the DOM around it: + // the Select button that turns the mode on, the bar that shows the count and closes, the + // checkboxes on rows and group headings, and the keys (Escape, Delete, Ctrl+A). + let selection: SessionSelection = emptySelection(); const selectionBar = document.createElement('div'); selectionBar.className = 'shell-selection hidden'; selectionBar.setAttribute('role', 'toolbar'); const selectionCount = document.createElement('span'); selectionCount.className = 'shell-selection-count'; selectionCount.setAttribute('aria-live', 'polite'); const selectionClose = document.createElement('button'); selectionClose.type = 'button'; selectionClose.className = 'chip chip-danger shell-selection-close'; const selectionAll = document.createElement('button'); selectionAll.type = 'button'; selectionAll.className = 'chip shell-selection-all'; - const selectionClear = document.createElement('button'); selectionClear.type = 'button'; selectionClear.className = 'chip shell-selection-clear'; - selectionBar.append(selectionCount, selectionClose, selectionAll, selectionClear); + const selectionDone = document.createElement('button'); selectionDone.type = 'button'; selectionDone.className = 'chip shell-selection-done'; + selectionBar.append(selectionCount, selectionClose, selectionAll, selectionDone); sessionHost.parentElement?.insertBefore(selectionBar, sessionHost); + selectToggle.replaceChildren(createIcon('select'), document.createElement('span')); + newDot(selectToggle, 'multi-close'); /** Rows in rail order — what a Shift+click range and "select all" mean. */ const visibleSessionKeys = (): string[] => Array.from(sessionHost.querySelectorAll('.shell-session-wrap')) .map((wrap) => (wrap.querySelector('.shell-session') as HTMLButtonElement | null)?.dataset.shellEntityKey ?? '').filter(Boolean); + /** The rows one group currently shows, in rail order. */ + const groupSessionKeys = (groupKey: string): string[] => { + const section = sessionSections.get(groupKey); + if (!section) return []; + return Array.from(section.querySelectorAll('.shell-session')).map((row) => row.dataset.shellEntityKey ?? '').filter(Boolean); + }; const applySelection = (): void => { + const { keys, mode } = selection; + sessionHost.classList.toggle('is-selecting', mode); for (const [key, row] of sessionRows) { - const on = selected.has(key); + const on = keys.has(key); sessionWraps.get(key)?.classList.toggle('is-selected', on); - row.setAttribute('aria-pressed', String(on)); + // A toggle only while picking: outside the mode the row is a plain navigation button, and + // `aria-pressed="false"` on every row made each one read as a switch. + if (mode) row.setAttribute('aria-pressed', String(on)); else row.removeAttribute('aria-pressed'); + } + for (const [groupKey, section] of sessionSections) { + const check = section.querySelector('.shell-group-check'); + if (!check) continue; + const rows = groupSessionKeys(groupKey); + const all = allSelected(selection, rows); + const some = !all && rows.some((k) => keys.has(k)); + check.classList.toggle('is-checked', all); + check.classList.toggle('is-mixed', some); + check.setAttribute('aria-checked', all ? 'true' : some ? 'mixed' : 'false'); + const name = section.querySelector('.shell-group-name')?.textContent ?? ''; + check.title = tr('shell.select_group', { name }); + check.setAttribute('aria-label', tr('shell.select_group', { name })); } - selectionBar.classList.toggle('hidden', selected.size === 0); - selectionCount.textContent = tr('shell.selected_n', { n: String(selected.size) }); - selectionClose.textContent = tr('shell.close_selected'); + selectionBar.classList.toggle('hidden', !mode); + sessionTools.classList.toggle('hidden', mode || sessions.length === 0); + selectionCount.textContent = tr('shell.selected_n', { n: String(keys.size) }); + selectionClose.textContent = keys.size > 0 ? `${tr('shell.close_selected')} · ${keys.size}` : tr('shell.close_selected'); + selectionClose.disabled = keys.size === 0; selectionAll.textContent = tr('shell.select_all'); - selectionClear.textContent = tr('shell.clear_selection'); + selectionDone.textContent = tr('shell.selection_done'); selectionBar.title = tr('shell.selection_hint'); - selectionBar.setAttribute('aria-label', tr('shell.selected_n', { n: String(selected.size) })); + selectionBar.setAttribute('aria-label', tr('shell.selected_n', { n: String(keys.size) })); }; - const clearSelection = (): void => { if (selected.size === 0) return; selected.clear(); selectionAnchor = null; applySelection(); }; - const toggleSelected = (key: string): void => { - if (selected.has(key)) selected.delete(key); else selected.add(key); - selectionAnchor = key; - applySelection(); + const setSelection = (next: SessionSelection): void => { selection = next; applySelection(); }; + const closeSelected = (): void => { + const ids = [...selection.keys].map((key) => sessionRows.get(key)?.dataset.sessionId ?? '').filter(Boolean); + if (!ids.length) return; + void options.onCloseSessions(ids).then((done) => { if (done) setSelection(endSelecting()); }); }; - const selectRange = (from: string, to: string): void => { - const keys = visibleSessionKeys(); - const a = keys.indexOf(from), b = keys.indexOf(to); - if (a < 0 || b < 0) { toggleSelected(to); return; } - for (const key of keys.slice(Math.min(a, b), Math.max(a, b) + 1)) selected.add(key); - applySelection(); + selectToggle.addEventListener('click', () => { + setSelection(beginSelecting(selection)); + // The keyboard goes to the first row: Space/Enter picks it, arrows and Tab move on, Delete closes. + const first = visibleSessionKeys()[0]; + if (first) sessionRows.get(first)?.focus(); + }); + selectionDone.addEventListener('click', () => { setSelection(endSelecting()); selectToggle.focus(); }); + /** "Select all" means every session, so the folded and cut-away groups are opened first — a pick + * that cannot be seen is not made (and would be dropped by the next render anyway). */ + const selectEverything = (): void => { + let opened = false; + for (const group of buildSessionGroups(sessions)) { + if (collapsedGroups.includes(group.key)) { collapsedGroups = toggleCollapsedGroup(collapsedGroups, group.key); opened = true; } + if (!expandedGroups.has(group.key)) { expandedGroups.add(group.key); opened = true; } + } + if (opened) { + try { localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify(collapsedGroups)); } catch { /* see above */ } + renderSessions(sessions); + } + setSelection(selectAll(selection, visibleSessionKeys())); }; - selectionClear.addEventListener('click', clearSelection); - selectionAll.addEventListener('click', () => { for (const key of visibleSessionKeys()) selected.add(key); applySelection(); }); - selectionClose.addEventListener('click', () => { - const ids = [...selected].map((key) => sessionRows.get(key)?.dataset.sessionId ?? '').filter(Boolean); - void options.onCloseSessions(ids).then((done) => { if (done) clearSelection(); }); + selectionAll.addEventListener('click', selectEverything); + selectionClose.addEventListener('click', closeSelected); + /** Keys act on the selection only where typing them cannot mean something else — never inside a terminal or a text field. */ + const typingElsewhere = (): boolean => { + const el = document.activeElement; + return el instanceof HTMLElement && (el.closest('.xterm') != null || el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el.isContentEditable); + }; + document.addEventListener('keydown', (event) => { + if (!selection.mode || typingElsewhere()) return; + if (event.key === 'Escape') { event.preventDefault(); setSelection(endSelecting()); return; } + // Delete and Ctrl+A act from the session rows and the bar, not from a project row or the resizer. + if (!sessionSection.contains(document.activeElement)) return; + if ((event.key === 'Delete' || event.key === 'Backspace') && selection.keys.size > 0) { event.preventDefault(); closeSelected(); } + else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); selectEverything(); } }); - document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && selected.size > 0) { event.preventDefault(); clearSelection(); } }); const renderSessions = (items: ShellSessionInput[]): void => { const focusedKey = document.activeElement instanceof HTMLButtonElement ? document.activeElement.dataset.shellEntityKey : undefined; + const focusedControl = focusedSessionControl(); const groups = buildSessionGroups(items); // Only rows that are actually RENDERED may keep their DOM: a row cut by a "show more" limit is // gone from the rail, so leaving its node cached would resurrect it under the next group. @@ -610,10 +776,10 @@ export function mountShell(options: { renderCollapsedStatus(); applyProjectActivity(); // session state changed → the project rows' inherited marks follow // A picked row that left the rail (closed, folded away) leaves the selection with it. - for (const key of [...selected]) if (!sessionRows.has(key)) selected.delete(key); - if (selectionAnchor && !sessionRows.has(selectionAnchor)) selectionAnchor = null; + selection = pruneSelection(selection, new Set(sessionRows.keys())); applySelection(); preserveFocusedRow(focusedKey, sessionRows); + restoreFocusedSessionControl(focusedControl); }; /** Selecting a session that lives in a folded (or truncated-away) group must not select something @@ -942,6 +1108,9 @@ export function mountShell(options: { button.title = label; button.setAttribute('aria-label', label); } projectSection.setAttribute('aria-labelledby', 'shell-projects-label'); + selectToggle.querySelector('span')!.textContent = tr('shell.select_mode'); + selectToggle.title = tr('shell.select_mode_hint'); + selectToggle.setAttribute('aria-label', tr('shell.select_mode')); resizer.title = tr('shell.resize'); resizer.setAttribute('aria-label', tr('shell.resize')); mobileBackdrop.setAttribute('aria-label', tr('shell.collapse')); diff --git a/src/renderer/shell/shell.css b/src/renderer/shell/shell.css index 80844e5..5d62a55 100644 --- a/src/renderer/shell/shell.css +++ b/src/renderer/shell/shell.css @@ -120,6 +120,9 @@ color: var(--dd-text-faint); cursor: pointer; opacity: 0; } .shell-group-heading { display: flex; align-items: center; } +/* The heading proper (the fold toggle with name and count) fills the bar; the checkbox and the bulk + close sit beside it, outside the section's accessible name. */ +.shell-group-title { flex: 1 1 auto; min-width: 0; display: flex; margin: 0; font-size: inherit; font-weight: inherit; } .shell-group-heading:hover .shell-group-bulk, .shell-group-heading:focus-within .shell-group-bulk { opacity: 1; } .shell-group-bulk:hover, .shell-group-bulk:focus-visible { background: var(--dd-surface-raised); color: var(--dd-destructive); opacity: 1; } @@ -182,8 +185,46 @@ .shell-session-wrap.is-selected .shell-session { background: color-mix(in srgb, var(--dd-accent, #58a6ff) 16%, transparent); box-shadow: inset 2px 0 0 var(--dd-accent, #58a6ff); } .shell-session.activity-offline .shell-entity-copy { opacity: .55; } .shell-session.activity-offline .shell-signal { background: transparent; border: 1px dashed var(--dd-text-faint); } -.shell-selection { display: flex; align-items: center; gap: 6px; padding: 4px 8px; margin: 0 6px 4px; border: 1px solid var(--border); border-radius: 6px; background: var(--dd-surface-raised); font-size: 12px; } -.shell-selection-count { flex: 1; min-width: 0; color: var(--dd-text); } +.shell-selection { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 4px 8px; margin: 0 6px 4px; border: 1px solid var(--dd-accent, #58a6ff); border-radius: 6px; background: var(--dd-surface-raised); font-size: 12px; } +.shell-selection.hidden { display: none; } +.shell-selection-count { flex: 1; min-width: 0; color: var(--dd-text); font-variant-numeric: tabular-nums; } +.shell-selection .chip:disabled { opacity: .5; cursor: default; } +/* The bar's Close is the destructive one and has to look it; the class existed, the style did not. */ +.chip.chip-danger { border-color: color-mix(in srgb, var(--dd-destructive) 60%, var(--dd-border)); color: var(--dd-destructive); } +.chip.chip-danger:not(:disabled):hover, .chip.chip-danger:focus-visible { background: color-mix(in srgb, var(--dd-destructive) 14%, transparent); border-color: var(--dd-destructive); } + +/* The way INTO a selection. Ctrl+click was the only way, and nothing on screen said so, so decks + were closed one row at a time. A small, always-present button above the groups turns the mode on; + while it is on, the bar above takes its place. */ +.shell-session-tools { display: flex; justify-content: flex-end; padding: 2px 6px 0; } +.shell-session-tools.hidden { display: none; } +.shell-select-toggle { + position: relative; display: inline-flex; align-items: center; gap: 5px; min-height: 24px; padding: 0 8px; + border: 1px solid transparent; border-radius: var(--dd-radius-sm); background: transparent; + color: var(--dd-text-muted); font-size: var(--dd-fs-meta); cursor: pointer; +} +.shell-select-toggle:hover, .shell-select-toggle:focus-visible { background: var(--dd-surface-raised); color: var(--dd-text); border-color: var(--dd-border); } + +/* Checkboxes exist only in the mode: the rows stay as quiet as before until a selection is wanted. + Drawn with a box and a tick (not color alone) so a picked row reads the same in monochrome. */ +.shell-session-check, .shell-group-check { + display: none; flex: 0 0 auto; width: 16px; height: 16px; box-sizing: border-box; + align-items: center; justify-content: center; + border: 1.5px solid var(--dd-text-faint); border-radius: 4px; background: var(--dd-canvas); color: transparent; +} +.shell-session-check-mark { width: 12px; height: 12px; } +#shell-session-groups.is-selecting .shell-session-check { display: inline-flex; } +#shell-session-groups.is-selecting .shell-group-check { display: inline-flex; margin-right: 6px; padding: 0; cursor: pointer; } +.shell-session-wrap.is-selected .shell-session-check, +.shell-group-check.is-checked { border-color: var(--dd-accent, #58a6ff); background: var(--dd-accent, #58a6ff); color: #fff; } +.shell-group-check.is-mixed { border-color: var(--dd-accent, #58a6ff); } +.shell-group-check.is-mixed::after { content: ''; width: 8px; height: 2px; border-radius: 1px; background: var(--dd-accent, #58a6ff); } +.shell-group-check.is-mixed .shell-session-check-mark { display: none; } +.shell-group-check:focus-visible { outline: 2px solid var(--dd-accent, #58a6ff); outline-offset: 1px; } +/* While picking, the rows are a list of checkboxes — the ⋯ and its hover affordance step back. */ +#shell-session-groups.is-selecting .shell-session-actions { opacity: .5; } +/* A pinned row wears its pin: in the groups that come before "Pinned", pinning moves nothing. */ +.shell-session-pin { flex: 0 0 auto; width: 12px; height: 12px; color: var(--dd-text-faint); } .shell-collapsed-status { align-self: center; min-height: 26px; margin: 2px 0; padding: 0 7px; diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 916c109..794dfd0 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -642,9 +642,13 @@ button.primary:hover { background: var(--accent-hover); } #ck-new-session { min-width: 0; } #ck-new-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* In-app close confirmation (DOM overlay, not window.confirm). */ -.ck-confirm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; z-index: 80; } +/* Above every menu (a sidebar row's ⋯ is 90): a menu left open on top of the question could ask a second one. */ +.ck-confirm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; z-index: 100; } .ck-confirm { background: var(--surface); border: 1px solid var(--border-strong); border-radius: 12px; padding: 18px 20px; width: 320px; max-width: calc(100vw - 32px); box-shadow: 0 12px 40px rgba(0,0,0,.55); } -.ck-confirm-msg { color: var(--text); font-size: 13px; margin-bottom: 16px; line-height: 1.4; } +/* pre-line: a bulk question carries a second line saying what is where ("2 on this PC · 3 on 4080"). */ +.ck-confirm-msg { color: var(--text); font-size: 13px; margin-bottom: 16px; line-height: 1.4; white-space: pre-line; } +.ck-confirm-option { display: flex; align-items: flex-start; gap: 8px; margin: -6px 0 14px; color: var(--text-dim); font-size: 12px; line-height: 1.4; cursor: pointer; } +.ck-confirm-option input { margin: 2px 0 0; accent-color: var(--accent); flex: 0 0 auto; } .ck-confirm-acts { display: flex; justify-content: flex-end; gap: 8px; } .ck-confirm-cancel, .ck-confirm-ok { font: inherit; border-radius: 7px; padding: 6px 14px; cursor: pointer; border: 1px solid var(--border-strong); } .ck-confirm-cancel { background: transparent; color: var(--text-dim); } diff --git a/src/renderer/whatsNew.test.ts b/src/renderer/whatsNew.test.ts index 0c8eef8..d6d5b47 100644 --- a/src/renderer/whatsNew.test.ts +++ b/src/renderer/whatsNew.test.ts @@ -13,9 +13,9 @@ describe('when the What\'s new window opens', () => { }); it('lists only what is newer than what was last shown, newest first', () => { - expect(featuresAfter('1.41.0').map((f) => f.version)).toEqual(['1.46.0', '1.45.0', '1.44.0', '1.43.0', '1.42.0']); + expect(featuresAfter('1.41.0').map((f) => f.version)).toEqual(['1.49.0', '1.46.0', '1.45.0', '1.44.0', '1.43.0', '1.42.0']); const all = featuresAfter(null).map((f) => f.version); - expect(all[0]).toBe('1.46.0'); + expect(all[0]).toBe('1.49.0'); expect([...all].sort((a, b) => compareVersions(b, a))).toEqual(all); }); diff --git a/src/renderer/whatsNew.ts b/src/renderer/whatsNew.ts index 3b50459..d9b0c63 100644 --- a/src/renderer/whatsNew.ts +++ b/src/renderer/whatsNew.ts @@ -24,6 +24,7 @@ export interface Feature { /** Newest first. A feature's NEW dot is shown on its `target` until the person uses it. */ export const FEATURES: readonly Feature[] = [ + { id: 'multi-close', version: '1.49.0', icon: 'select', target: { view: 'cockpit', selector: '#shell-select-toggle' } }, // No Show me: the ⋯ of a background row exists only while Claude has background sessions. { id: 'bg-close', version: '1.46.0', icon: 'close' }, { id: 'send-any-file', version: '1.45.0', icon: 'paperclip', target: { view: 'cockpit', selector: '#ck-header .ck-attach-btn' } }, diff --git a/src/shared/sessionSelection.test.ts b/src/shared/sessionSelection.test.ts new file mode 100644 index 0000000..444531e --- /dev/null +++ b/src/shared/sessionSelection.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { + allSelected, beginSelecting, closeBreakdown, describeBreakdown, emptySelection, endSelecting, + pruneSelection, rangeTo, selectAll, toggleGroup, toggleKey, +} from './sessionSelection'; +import type { ShellSessionInput } from './shellNavigation'; + +const order = ['a', 'b', 'c', 'd', 'e']; + +describe('picking sessions to close at once', () => { + it('starts empty and out of the mode; the Select button turns the mode on with nothing picked', () => { + const s = emptySelection(); + expect(s.mode).toBe(false); + const on = beginSelecting(s); + expect(on.mode).toBe(true); + expect(on.keys.size).toBe(0); + }); + + it('a pick turns the mode on and becomes the anchor; picking again un-picks', () => { + let s = toggleKey(emptySelection(), 'b'); + expect([...s.keys]).toEqual(['b']); + expect(s.anchor).toBe('b'); + expect(s.mode).toBe(true); + s = toggleKey(s, 'b'); + expect(s.keys.size).toBe(0); + expect(s.mode).toBe(true); // the mode outlives an empty selection — Done or Escape ends it + }); + + it('Shift+click ranges from the anchor in rail order, either direction, and keeps the anchor', () => { + let s = toggleKey(emptySelection(), 'd'); + s = rangeTo(s, order, 'b'); + expect([...s.keys].sort()).toEqual(['b', 'c', 'd']); + expect(s.anchor).toBe('d'); + s = rangeTo(s, order, 'e'); // re-ranges from the same anchor + expect([...s.keys].sort()).toEqual(['b', 'c', 'd', 'e']); + }); + + it('Shift+click with no anchor on screen is a plain pick', () => { + const s = rangeTo(emptySelection(), order, 'c'); + expect([...s.keys]).toEqual(['c']); + expect(s.anchor).toBe('c'); + }); + + it('select all picks every row the rail shows and nothing else', () => { + const s = selectAll(toggleKey(emptySelection(), 'zzz'), order); + expect([...s.keys].sort()).toEqual(['a', 'b', 'c', 'd', 'e', 'zzz']); + expect(allSelected(s, order)).toBe(true); + expect(allSelected(s, [])).toBe(false); + }); + + it('a group checkbox fills a half-picked group, and empties one that is all picked', () => { + let s = toggleKey(emptySelection(), 'b'); + s = toggleGroup(s, ['a', 'b', 'c']); + expect([...s.keys].sort()).toEqual(['a', 'b', 'c']); + s = toggleGroup(s, ['a', 'b', 'c']); + expect(s.keys.size).toBe(0); + expect(s.mode).toBe(true); + }); + + it('rows that left the rail leave the selection; the anchor goes with them', () => { + const s = selectAll(emptySelection(), order); + const pruned = pruneSelection(s, new Set(['a', 'c'])); + expect([...pruned.keys]).toEqual(['a', 'c']); + expect(pruned.anchor).toBe(null); + expect(pruned.mode).toBe(true); + expect(pruneSelection(pruned, new Set(['a', 'c', 'x']))).toBe(pruned); // nothing to drop → same object + }); + + it('Done drops the picks and leaves the mode', () => { + const s = endSelecting(); + expect(s.keys.size).toBe(0); + expect(s.mode).toBe(false); + }); +}); + +describe('what a close of the picked rows amounts to', () => { + const items: ShellSessionInput[] = [ + { id: 'l1', projectPath: 'C:\\a', label: 'a', detail: '', activity: 'idle', pinned: false }, + { id: 'l2', projectPath: 'C:\\b', label: 'b', detail: '', activity: 'working', pinned: false, machineId: 'local' }, + { id: 'r1', projectPath: 'D:\\x', label: 'x', detail: '', activity: 'idle', pinned: false, machineId: 'm-4080', machineLabel: 'SIHYEONG-4080' }, + { id: 'r2', projectPath: 'D:\\y', label: 'y', detail: '', activity: 'idle', pinned: false, machineId: 'm-4080', machineLabel: 'SIHYEONG-4080' }, + { id: 'r3', projectPath: 'D:\\z', label: 'z', detail: '', activity: 'idle', pinned: false, machineId: 'm-nb', machineLabel: '' }, + { id: 'bg:local:1', projectPath: 'C:\\c', label: 'c', detail: '', activity: 'turn', pinned: false, backgroundAgent: true }, + { id: 'p1', projectPath: 'C:\\d', label: 'd', detail: '', activity: 'idle', pinned: false, previous: true }, + ]; + + it('counts each kind, remote ones per machine, and skips ids no longer listed', () => { + const b = closeBreakdown(items, ['l1', 'l2', 'r1', 'r2', 'r3', 'bg:local:1', 'p1', 'gone']); + expect(b.local).toBe(2); + expect([...b.remote]).toEqual([['SIHYEONG-4080', 2], ['m-nb', 1]]); // a machine without a label is named by its id + expect(b.background).toBe(1); + expect(b.previous).toBe(1); + }); + + it('describes only the kinds that are present, in rail order', () => { + const words = { + local: (n: number) => `${n} here`, remote: (n: number, m: string) => `${n} on ${m}`, + background: (n: number) => `${n} background`, previous: (n: number) => `${n} saved`, + }; + expect(describeBreakdown(closeBreakdown(items, ['p1', 'r1', 'l1']), words)).toEqual(['1 here', '1 on SIHYEONG-4080', '1 saved']); + expect(describeBreakdown(closeBreakdown(items, ['gone']), words)).toEqual([]); + }); +}); diff --git a/src/shared/sessionSelection.ts b/src/shared/sessionSelection.ts new file mode 100644 index 0000000..bb2ea97 --- /dev/null +++ b/src/shared/sessionSelection.ts @@ -0,0 +1,134 @@ +// src/shared/sessionSelection.ts +// Picking several sidebar sessions to close at once — the selection itself, kept out of the DOM. +// +// Until 1.49.0 a selection could only be started with Ctrl+click, which nothing on screen +// mentioned, so most people closed a long deck one row at a time. The sidebar now has a visible +// way in (a Select button, and "Select…" in every row's ⋯ menu) and a selection MODE: while it is +// on, every row shows a checkbox and a plain click picks the row instead of opening it. Ctrl+click +// and Shift+click keep working, in and out of the mode. + +import type { ShellSessionInput } from './shellNavigation'; + +export interface SessionSelection { + /** Entity keys of the picked rows, in the order they were picked. */ + keys: Set; + /** The row a Shift+click ranges from: the last row picked or un-picked. */ + anchor: string | null; + /** Selection mode: rows show checkboxes and a plain click picks. On while anything is picked. */ + mode: boolean; +} + +export function emptySelection(): SessionSelection { + return { keys: new Set(), anchor: null, mode: false }; +} + +/** Enter the mode with nothing picked yet — the Select button. */ +export function beginSelecting(s: SessionSelection): SessionSelection { + return { ...s, keys: new Set(s.keys), mode: true }; +} + +/** Leave the mode and drop every pick — Done, Escape, or the picks having been closed. */ +export function endSelecting(): SessionSelection { + return emptySelection(); +} + +/** Pick a row, or un-pick a picked one; either way it becomes the range anchor. */ +export function toggleKey(s: SessionSelection, key: string): SessionSelection { + const keys = new Set(s.keys); + if (keys.has(key)) keys.delete(key); else keys.add(key); + return { keys, anchor: key, mode: true }; +} + +/** + * Shift+click: pick everything from the anchor to this row, in the order the rail shows them + * (`order`). Without an anchor on screen it is an ordinary pick. The anchor stays where it was, so + * a second Shift+click re-ranges from the same start, as every file manager does. + */ +export function rangeTo(s: SessionSelection, order: readonly string[], key: string): SessionSelection { + const a = s.anchor ? order.indexOf(s.anchor) : -1; + const b = order.indexOf(key); + if (a < 0 || b < 0) return toggleKey(s, key); + const keys = new Set(s.keys); + for (const k of order.slice(Math.min(a, b), Math.max(a, b) + 1)) keys.add(k); + return { keys, anchor: s.anchor, mode: true }; +} + +/** Pick every row the rail shows. */ +export function selectAll(s: SessionSelection, order: readonly string[]): SessionSelection { + const keys = new Set(s.keys); + for (const k of order) keys.add(k); + return { keys, anchor: s.anchor, mode: true }; +} + +/** True when every one of these rows is picked (and there is at least one). */ +export function allSelected(s: SessionSelection, keys: readonly string[]): boolean { + return keys.length > 0 && keys.every((k) => s.keys.has(k)); +} + +/** + * The checkbox on a group heading: picks the whole group, or un-picks it when it is already all + * picked. Half-picked groups fill up rather than empty — the common case is "all of these, and + * I had already started on them". + */ +export function toggleGroup(s: SessionSelection, groupKeys: readonly string[]): SessionSelection { + const keys = new Set(s.keys); + if (allSelected(s, groupKeys)) for (const k of groupKeys) keys.delete(k); + else for (const k of groupKeys) keys.add(k); + return { keys, anchor: groupKeys.at(-1) ?? s.anchor, mode: true }; +} + +/** + * Rows that left the rail (closed, folded away, cut by a "show more") leave the selection with + * them: a pick you cannot see must not be closed on your behalf. The mode survives an empty + * selection — it ends only on Done, Escape, or a close. + */ +export function pruneSelection(s: SessionSelection, present: ReadonlySet): SessionSelection { + let changed = false; + const keys = new Set(); + for (const k of s.keys) { if (present.has(k)) keys.add(k); else changed = true; } + const anchor = s.anchor && present.has(s.anchor) ? s.anchor : null; + if (!changed && anchor === s.anchor) return s; + return { keys, anchor, mode: s.mode }; +} + +// ---- what a close of these rows amounts to ---- + +/** How many of each kind a set of rows holds, for the confirmation to say before it is answered. */ +export interface CloseBreakdown { + /** Live sessions on this PC. */ + local: number; + /** Live sessions on other PCs, by that PC's name — each stops an agent in front of someone else. */ + remote: Map; + /** Claude Code background sessions (`claude stop` + `claude rm`). */ + background: number; + /** Saved entries, which are only forgotten. */ + previous: number; +} + +export function closeBreakdown(items: readonly ShellSessionInput[], ids: readonly string[]): CloseBreakdown { + const byId = new Map(items.map((item) => [item.id, item])); + const out: CloseBreakdown = { local: 0, remote: new Map(), background: 0, previous: 0 }; + for (const id of ids) { + const item = byId.get(id); + if (!item) continue; + if (item.backgroundAgent === true) out.background += 1; + else if (item.previous === true) out.previous += 1; + else if (typeof item.machineId === 'string' && item.machineId && item.machineId !== 'local') { + const name = item.machineLabel || item.machineId; + out.remote.set(name, (out.remote.get(name) ?? 0) + 1); + } else out.local += 1; + } + return out; +} + +/** The breakdown as the confirmation reads it, one phrase per kind that is present, in rail order. */ +export function describeBreakdown(b: CloseBreakdown, words: { + local(n: number): string; remote(n: number, machine: string): string; background(n: number): string; previous(n: number): string; +}): string[] { + const parts: string[] = []; + if (b.local > 0) parts.push(words.local(b.local)); + for (const [machine, n] of b.remote) parts.push(words.remote(n, machine)); + if (b.background > 0) parts.push(words.background(b.background)); + if (b.previous > 0) parts.push(words.previous(b.previous)); + return parts; +}