Skip to content

Commit 0735d53

Browse files
feat(cockpit): status hysteresis + per-session model/time + tray alert
Four cockpit improvements: - Fix the Working↔your-turn flicker while the agent is working: status is now hysteretic — once 'working', a brief output gap (<10s, no input prompt) stays 'working' instead of flipping to "your turn" during the agent's thinking/tool pauses. (computeActivity takes prev.) - Show each session's model (read from its log) + active working time as header pills, and the model in each list row. - Tray attention alert (Discord-style red tray icon) when sessions need you — configurable in Settings (Off / Questions only [default] / Questions + your turn). "Questions only" is rare, so the tray isn't constantly red even though "your turn" is common. The renderer draws a red-dotted tray icon on a canvas (no new asset/dep) and hands it to main. New: shared sessionMeta (friendlyModel + parseSessionMeta), main readClaudeSessionMeta (session-id-guarded — no path traversal), store trayAlert, tray.ts controller. v1.9.0. 277 tests (+10). QA 0 errors. Final review APPROVED after fixing a sessionId path-traversal it caught. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ab3de5e commit 0735d53

21 files changed

Lines changed: 266 additions & 25 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ See every repo's state at a glance — git status, how long it's been neglected,
1111
![License](https://img.shields.io/badge/license-MIT-blue)
1212
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-0078D6)
1313
![Built with Electron](https://img.shields.io/badge/Electron-31-47848F)
14-
![Tests](https://img.shields.io/badge/tests-267%20passing-3fb950)
14+
![Tests](https://img.shields.io/badge/tests-277%20passing-3fb950)
1515
![CI](https://github.com/writingdeveloper/devdeck/actions/workflows/ci.yml/badge.svg)
1616

1717
<img src="docs/demo/demo.gif" width="820" alt="DevDeck demo" />
@@ -30,7 +30,7 @@ If you run Claude Code across a dozen side projects, you lose the thread: *Which
3030
- **📂 Multiple scan locations** — point DevDeck at several folders to scan for repos, or add individual repos that live anywhere; each is auto-detected.
3131
- **🚦 Staleness traffic-light** — fresh / warning / neglected, so dirty or abandoned repos surface themselves.
3232
- **▶ One-click resume** — opens a terminal in the repo and continues your last session with the active agent (`claude -c` / `codex resume`) — or pick a specific past session.
33-
- **🖥 Cockpit (embedded terminals · Windows)** — on Windows, **Open** drops you straight into an in-app terminal instead of a pile of external windows. A searchable session list shows each session's **live status** — working (spinner) / awaiting-you / idle — and floats the ones needing you to the top, with a count badge on the 🖥 icon so you can see "who's waiting on me" from any view. The live agent terminal + branch · agent status bar fill the right, with selection **copy (Ctrl+C) / paste (Ctrl+V)** that doesn't clash with the agent's own Ctrl+C interrupt. Running 10+ projects no longer means a wall of shrinking tabs you have to click through to see which finished. Your open sessions are **remembered across restarts** — after a quit or crash the cockpit lists them as one-click **restorable** entries (re-attaching via `claude -c` / `codex resume`). You can run **several sessions in the same repo at once** — a **+ New session** button forks another conversation; each is tracked separately and you can **name it** (double-click the name) so you remember what each one is doing. (macOS/Linux keep opening your external terminal.)
33+
- **🖥 Cockpit (embedded terminals · Windows)** — on Windows, **Open** drops you straight into an in-app terminal instead of a pile of external windows. A searchable session list shows each session's **live status** — working (spinner) / awaiting-you / idle — and floats the ones needing you to the top, with a count badge on the 🖥 icon (and an optional **tray icon alert**, off-by-default-able) so you can see "who's waiting on me" from any view. Each session shows its **model and active working time**, and you can **name** sessions and run **several per repo**. The live agent terminal + branch · agent status bar fill the right, with selection **copy (Ctrl+C) / paste (Ctrl+V)** that doesn't clash with the agent's own Ctrl+C interrupt. Running 10+ projects no longer means a wall of shrinking tabs you have to click through to see which finished. Your open sessions are **remembered across restarts** — after a quit or crash the cockpit lists them as one-click **restorable** entries (re-attaching via `claude -c` / `codex resume`). You can run **several sessions in the same repo at once** — a **+ New session** button forks another conversation; each is tracked separately and you can **name it** (double-click the name) so you remember what each one is doing. (macOS/Linux keep opening your external terminal.)
3434
- **↩ Resume cue** — auto-reads the *last thing you asked* in each project's newest session (Claude or Codex) and shows it in the note slot, so "where was I?" needs no typing. Click to adopt it as your note.
3535
- **📋 "Next" view** — every project's note (or resume cue) gathered into one cross-project "what's next" list.
3636
- **↑ Unpushed signal** — commits ahead of your remote, flagged on the card so unprotected work stands out.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "devdeck",
3-
"version": "1.8.1",
3+
"version": "1.9.0",
44
"description": "Project command deck — at-a-glance state + claude -c resume",
55
"main": "dist/main/main.js",
66
"type": "commonjs",

src/main/ipc.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import type { WtTab } from '../shared/wtArgs';
2020
import { scanUsage } from './usageScan';
2121
import { getUsageWindows, readClaudeCredentials, fetchUsageApi, type CacheEntry } from './claudeUsage';
2222
import type { PersistedSession } from '../shared/cockpitPersist';
23+
import { readClaudeSessionMeta } from './sessionMeta';
24+
import type { TrayController } from './tray';
2325
import { DEFAULT_THRESHOLDS } from '../shared/staleness';
2426

2527
const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
@@ -32,9 +34,11 @@ export interface IpcConfig {
3234
sendError: (msg: string) => void;
3335
defaultLanguage: string;
3436
ptyHost: PtyHost;
37+
tray: TrayController;
3538
}
3639

3740
export function registerIpc(cfg: IpcConfig): void {
41+
let lastTrayCounts = { attention: 0, turn: 0 }; // remember the latest needs-you counts so a tray-alert setting change re-applies at once
3842
// Legacy single-base value, retained only for the settings:get response (back-compat); not used for scanning or the security guard.
3943
const effBaseDir = () => cfg.store.getBaseDir() ?? cfg.defaultBaseDir;
4044
const effThresholds = () => cfg.store.getThresholds() ?? DEFAULT_THRESHOLDS;
@@ -87,8 +91,12 @@ export function registerIpc(cfg: IpcConfig): void {
8791
ipcMain.handle('settings:get', () => ({
8892
baseDir: effBaseDir(), thresholds: effThresholds(), language: cfg.store.getLanguage() ?? cfg.defaultLanguage,
8993
openAtLogin: effectiveOpenAtLogin(cfg.store.getOpenAtLogin()), platform: process.platform,
90-
viewMode: cfg.store.getViewMode(),
94+
viewMode: cfg.store.getViewMode(), trayAlert: cfg.store.getTrayAlert(),
9195
}));
96+
ipcMain.handle('settings:setTrayAlert', (_e, mode: string) => {
97+
cfg.store.setTrayAlert(mode === 'off' || mode === 'all' ? mode : 'attention');
98+
cfg.tray.applyCounts(lastTrayCounts, cfg.store.getTrayAlert()); // re-apply immediately with the latest counts
99+
});
92100
ipcMain.handle('settings:setOpenAtLogin', (_e, enabled: boolean) => {
93101
const on = !!enabled;
94102
cfg.store.setOpenAtLogin(on);
@@ -241,6 +249,19 @@ export function registerIpc(cfg: IpcConfig): void {
241249
ipcMain.handle('cockpit:loadSessions', () => cfg.store.getCockpitSessions());
242250
ipcMain.on('cockpit:saveSessions', (_e, list: PersistedSession[]) => cfg.store.setCockpitSessions(Array.isArray(list) ? list : []));
243251

252+
// Per-session model + active working time (read from the Claude session log) for the cockpit header/list.
253+
ipcMain.handle('cockpit:sessionMeta', (_e, projectPath: string, sessionId: string) => {
254+
if (agent().id !== 'claude' || typeof sessionId !== 'string' || !sessionId) return { model: null, activeMs: 0 };
255+
return readClaudeSessionMeta(String(projectPath), sessionId, CLAUDE_PROJECTS);
256+
});
257+
258+
// Tray attention indicator (Discord-style): the renderer supplies the red-dotted icon once + live needs-you counts.
259+
ipcMain.on('tray:alertImage', (_e, dataUrl: string) => cfg.tray.setAlertImage(String(dataUrl)));
260+
ipcMain.on('tray:counts', (_e, counts: { attention?: number; turn?: number }) => {
261+
lastTrayCounts = { attention: Math.max(0, Number(counts?.attention) | 0), turn: Math.max(0, Number(counts?.turn) | 0) };
262+
cfg.tray.applyCounts(lastTrayCounts, cfg.store.getTrayAlert());
263+
});
264+
244265
// Opt-in usage monitor. Token is read + used ONLY in the main process (claudeUsage);
245266
// only computed percentages/plan/reset cross IPC.
246267
const usageCachePath = () => join(app.getPath('userData'), 'usage-cache.json');

src/main/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,16 @@ if (!gotLock) {
6969
applyOpenAtLogin(store.getOpenAtLogin());
7070
const w = createWindow();
7171
win = w;
72+
const tray = setupTray(w);
7273
registerIpc({
7374
win: w,
7475
defaultBaseDir: path.join(app.getPath('home'), 'Documents', 'GitHub'),
7576
store,
7677
sendError: (msg) => w.webContents.send('devdeck:error', msg),
7778
defaultLanguage: app.getLocale().split('-')[0] || 'en',
7879
ptyHost,
80+
tray,
7981
});
80-
setupTray(w);
8182
registerUpdater(w);
8283
globalShortcut.register('Control+Alt+D', showWindow);
8384
app.on('activate', () => { if (!win) win = createWindow(); });

src/main/sessionMeta.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { readClaudeSessionMeta } from './sessionMeta';
3+
4+
describe('readClaudeSessionMeta', () => {
5+
it('rejects a traversal sessionId — no path escape (returns {null,0})', () => {
6+
expect(readClaudeSessionMeta('C:/a/b', '../../../../etc/passwd', '/tmp/claude')).toEqual({ model: null, activeMs: 0 });
7+
expect(readClaudeSessionMeta('C:/a/b', 'a/../../b', '/tmp/claude')).toEqual({ model: null, activeMs: 0 });
8+
});
9+
it('returns {null,0} for a valid id whose file is missing', () => {
10+
expect(readClaudeSessionMeta('C:/a/b', 'a0b1c2d3-e4f5-6789-abcd-ef0123456789', '/no/such/dir')).toEqual({ model: null, activeMs: 0 });
11+
});
12+
});

src/main/sessionMeta.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { readFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { encodeProjectPath } from '../shared/paths';
4+
import { parseSessionMeta } from '../shared/sessionMeta';
5+
import { isValidSessionId } from './sessions';
6+
7+
/** Read a Claude session's { model, activeMs } from its on-disk .jsonl (best-effort; {null,0} if missing). */
8+
export function readClaudeSessionMeta(projectPath: string, sessionId: string, claudeProjectsDir: string): { model: string | null; activeMs: number } {
9+
// Guard the id before it touches a path — a crafted sessionId must not escape ~/.claude/projects (path traversal).
10+
if (!isValidSessionId(sessionId)) return { model: null, activeMs: 0 };
11+
const file = join(claudeProjectsDir, encodeProjectPath(projectPath), sessionId + '.jsonl');
12+
let raw: string;
13+
try { raw = readFileSync(file, 'utf8'); } catch { return { model: null, activeMs: 0 }; }
14+
return parseSessionMeta(raw);
15+
}

src/main/store.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ describe('Store', () => {
125125
expect(new Store(file).getCockpitSessions()).toEqual([{ projectPath: 'C:/a/dev', name: 'dev', sessionId: 's1', agentId: 'codex', label: 'auth' }]);
126126
});
127127

128+
it('round-trips trayAlert (default attention; bad value coerced)', () => {
129+
const s = new Store(file);
130+
expect(s.getTrayAlert()).toBe('attention');
131+
s.setTrayAlert('all');
132+
expect(new Store(file).getTrayAlert()).toBe('all');
133+
s.setTrayAlert('garbage' as 'off');
134+
expect(s.getTrayAlert()).toBe('attention');
135+
});
136+
128137
it('sanitizes corrupted cockpitSessions on read', () => {
129138
const s = new Store(file);
130139
// bypass the typed setter to simulate a hand-corrupted state.json

src/main/store.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { sanitizePersistedList, type PersistedSession } from '../shared/cockpitP
55

66
interface StateFile {
77
projects: Record<string, StoreEntry>;
8-
settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string; openAtLogin?: boolean; viewMode?: 'cards' | 'list'; cockpitSessions?: PersistedSession[] };
8+
settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string; openAtLogin?: boolean; viewMode?: 'cards' | 'list'; cockpitSessions?: PersistedSession[]; trayAlert?: 'off' | 'attention' | 'all' };
99
}
1010

1111
const EMPTY: StoreEntry = {
@@ -94,6 +94,9 @@ export class Store {
9494
getCockpitSessions(): PersistedSession[] { return sanitizePersistedList(this.state.settings?.cockpitSessions); }
9595
setCockpitSessions(list: PersistedSession[]): void { this.state.settings = { ...(this.state.settings ?? {}), cockpitSessions: sanitizePersistedList(list) }; this.save(); }
9696

97+
getTrayAlert(): 'off' | 'attention' | 'all' { const t = this.state.settings?.trayAlert; return t === 'off' || t === 'all' ? t : 'attention'; }
98+
setTrayAlert(t: 'off' | 'attention' | 'all'): void { this.state.settings = { ...(this.state.settings ?? {}), trayAlert: t === 'off' || t === 'all' ? t : 'attention' }; this.save(); }
99+
97100

98101
setNote(path: string, note: string): void { this.mutate(path, { note }); }
99102
setPinned(path: string, pinned: boolean): void { this.mutate(path, { pinned }); }

src/main/tray.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
import { app, Tray, Menu, nativeImage, type BrowserWindow } from 'electron';
1+
import { app, Tray, Menu, nativeImage, type BrowserWindow, type NativeImage } from 'electron';
22
import { join } from 'node:path';
33

4-
/** Build a tray icon with Open/Quit, and make window-close hide to tray instead of quitting. */
5-
export function setupTray(win: BrowserWindow): Tray {
6-
const icon = nativeImage.createFromPath(join(__dirname, '..', 'renderer', 'assets', 'tray.png'));
7-
const tray = new Tray(icon);
4+
export type TrayAlertMode = 'off' | 'attention' | 'all';
5+
export interface TrayController {
6+
/** Receive the renderer-rendered red-dotted alert icon (a PNG data URL). */
7+
setAlertImage(dataUrl: string): void;
8+
/** Apply needs-you counts: redden the tray icon when the mode says there's something for you. */
9+
applyCounts(counts: { attention: number; turn: number }, mode: TrayAlertMode): void;
10+
}
11+
12+
/** Build a tray icon with Open/Quit, make close hide to tray, and return a controller for the alert state. */
13+
export function setupTray(win: BrowserWindow): TrayController {
14+
const normalIcon = nativeImage.createFromPath(join(__dirname, '..', 'renderer', 'assets', 'tray.png'));
15+
const tray = new Tray(normalIcon);
816
tray.setToolTip('DevDeck');
917
const menu = Menu.buildFromTemplate([
1018
{ label: 'Open DevDeck', click: () => { win.show(); win.focus(); } },
@@ -20,5 +28,16 @@ export function setupTray(win: BrowserWindow): Tray {
2028
win.hide();
2129
}
2230
});
23-
return tray;
31+
32+
let alertIcon: NativeImage | null = null;
33+
return {
34+
setAlertImage(dataUrl: string): void {
35+
try { const img = nativeImage.createFromDataURL(dataUrl); if (!img.isEmpty()) alertIcon = img; } catch { /* ignore */ }
36+
},
37+
applyCounts(counts, mode): void {
38+
const red = mode === 'off' ? 0 : mode === 'all' ? counts.attention + counts.turn : counts.attention;
39+
tray.setImage(red > 0 && alertIcon ? alertIcon : normalIcon);
40+
tray.setToolTip(red > 0 ? `DevDeck — ${red}` : 'DevDeck');
41+
},
42+
};
2443
}

src/preload/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,9 @@ contextBridge.exposeInMainWorld('devdeck', {
5959
ipcRenderer.on('cockpit:exit', (_e, p) => cb(p)),
6060
loadSessions: () => ipcRenderer.invoke('cockpit:loadSessions'),
6161
saveSessions: (list: unknown) => ipcRenderer.send('cockpit:saveSessions', list),
62+
sessionMeta: (projectPath: string, sessionId: string) => ipcRenderer.invoke('cockpit:sessionMeta', projectPath, sessionId),
6263
},
64+
setTrayAlert: (mode: string) => ipcRenderer.invoke('settings:setTrayAlert', mode),
65+
setTrayCounts: (counts: { attention: number; turn: number }) => ipcRenderer.send('tray:counts', counts),
66+
setTrayAlertImage: (dataUrl: string) => ipcRenderer.send('tray:alertImage', dataUrl),
6367
});

0 commit comments

Comments
 (0)