claude-mission-control 1.12.1 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/lib/collector.js +32 -2
- package/lib/demo.js +6 -0
- package/lib/jobs.js +117 -0
- package/package.json +1 -1
- package/public/index.html +30 -5
package/README.md
CHANGED
|
@@ -35,6 +35,11 @@ Claude Code runs in a terminal. Once you have two or three sessions going, you l
|
|
|
35
35
|
|
|
36
36
|
*Every screenshot here is demo data (`CLAUDE_DASH_DEMO=1`). The projects are invented.*
|
|
37
37
|
|
|
38
|
+
**New in 1.13.0**
|
|
39
|
+
|
|
40
|
+
- Background sessions from `claude --bg` and Agent View carry a `BG` chip, the name Agent View gives them, and its one-line summary of what they did.
|
|
41
|
+
- The unpushed strip warns when a background job's worktree holds uncommitted files or commits on no other branch. Deleting that session from Agent View's list would discard them.
|
|
42
|
+
|
|
38
43
|
**New in 1.12.0**
|
|
39
44
|
|
|
40
45
|
- A limit forecast on the session and week meters: an amber `→ 3:40 PM` when your current pace runs out before the reset, and one notification per window.
|
|
@@ -155,6 +160,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
|
|
|
155
160
|
- **Departures** — every live `claude` CLI session as a row on the board: start time, project, current task, model, cost so far with a `$/h` burn rate, a context-window meter, elapsed time, and a split-flap status cell. The cell flips to an amber **needs you** when a session is waiting on your input — readable from across the room. A second line under the title says what it needs: `?` for a question Claude asked (with its options), `🔒` for a tool call awaiting permission (with the command or file), or the last question in Claude's reply, or failing that the first line of its final paragraph.
|
|
156
161
|
- **Mission Control** — the `▦` button in the header (or `⌘K` → Mission Control) tiles every live session side by side, each pane following its own transcript. Each pane head shows the model, cost and burn rate, the context meter, and how many tool calls the session has made (failures in amber). One screen for "what is everything doing right now".
|
|
157
162
|
- **Agents** — a session running subagents or an agent team shows them under its Mission Control pane: name, type, model, and a pulsing `●` while an agent has written in the last two minutes, `○` once it has gone quiet (idle teammates stay alive, so quiet is not finished), with the team name when there is one. Finished sessions list them at the top of the transcript.
|
|
163
|
+
- **Background sessions** — sessions started with `claude --bg` or from Agent View (`claude agents`) carry a `BG` chip on the board and in the digest, go by the name Agent View gives them, and show Agent View's one-line summary of what they did. When a background job's worktree still holds work, the unpushed strip lists it: `●n` uncommitted files and `⑂n` commits on that branch and no other branch or remote. Deleting the session from Agent View's list (ctrl+x twice) discards that work, while `claude rm` refuses until it is committed and pushed, so merge or push first. Read from `~/.claude/jobs/`, which keeps finished jobs too.
|
|
158
164
|
- **Pinned** — `☆ pin` on any transcript sticks that session to a strip under the departures board, so the thing you care about stays in view whether it's running or not.
|
|
159
165
|
- **Digest** — what happened across every project, grouped by day. Each entry shows the session's recap (Claude's own "away summary" where one exists — click to expand), how many tasks it completed, and an open button. Switch the window between day / 3 days / week; collapses out of the way and remembers your choice.
|
|
160
166
|
- **Project cards** — sorted by last activity. Branch chip, `●n` uncommitted changes, `↑n` unpushed commits. The sparkline is prompts per day for the last two weeks. Each session row has an `open ⬈` button that resumes the session in your terminal (new window, right directory) or imports it into the Claude desktop app when it's installed — the **Open in** selector in the header names whichever terminal you've configured (auto-detected on first run). The `⧉` button copies the `claude --resume` command instead.
|
package/lib/collector.js
CHANGED
|
@@ -18,6 +18,7 @@ const { estimateCost, planValue, costReport, budgetLevel, typicalWait, burnRate,
|
|
|
18
18
|
const { readPlan } = require('./plan');
|
|
19
19
|
const { readTeams, agentStatus, resolveTeam } = require('./teams');
|
|
20
20
|
const { WINDOWS, recordSample, forecastWindow, shouldAlert, clockLabel, durationLabel } = require('./forecast');
|
|
21
|
+
const { readJobs, worktreeStatus, jobAttention } = require('./jobs');
|
|
21
22
|
|
|
22
23
|
// A busy session whose transcript hasn't grown for this long may be stalled.
|
|
23
24
|
const QUIET_FLAG_MS = 10 * 60 * 1000;
|
|
@@ -179,6 +180,12 @@ class Collector {
|
|
|
179
180
|
async refreshGit() {
|
|
180
181
|
const paths = this.projectPaths().filter((p) => existsDir(p));
|
|
181
182
|
this.raw.git = await collectGitStatus(paths);
|
|
183
|
+
// Background jobs' worktrees: work that removing the session would delete.
|
|
184
|
+
const jobWt = new Map();
|
|
185
|
+
for (const job of readJobs()) {
|
|
186
|
+
if (job.worktreePath) jobWt.set(job.jobId, await worktreeStatus(job));
|
|
187
|
+
}
|
|
188
|
+
this.raw.jobWt = jobWt;
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
async refreshQuota() {
|
|
@@ -357,9 +364,13 @@ class Collector {
|
|
|
357
364
|
}
|
|
358
365
|
this.stuckNotified = this.stuckNotified || new Set();
|
|
359
366
|
|
|
367
|
+
const jobs = readJobs();
|
|
368
|
+
const jobBySession = new Map(jobs.map((j) => [j.sessionId, j]));
|
|
369
|
+
|
|
360
370
|
const nagMs = (cfg.idleNagMinutes || 0) * 60000;
|
|
361
371
|
const liveSessions = this.raw.live.map((s) => {
|
|
362
372
|
const { root, worktree } = worktreeRoot(s.cwd);
|
|
373
|
+
const job = jobBySession.get(s.sessionId) || null;
|
|
363
374
|
// "Quiet" = busy but the transcript hasn't grown. Long quiet spells
|
|
364
375
|
// usually mean the session is stalled (weak signal: it may just be
|
|
365
376
|
// waiting on slow background work).
|
|
@@ -409,7 +420,11 @@ class Collector {
|
|
|
409
420
|
assumed: report.assumedModels.length > 0,
|
|
410
421
|
context: contextBySession.get(s.sessionId) || null,
|
|
411
422
|
tools: toolsBySession.get(s.sessionId) || null,
|
|
412
|
-
|
|
423
|
+
// Background sessions go by the name Agent View shows.
|
|
424
|
+
title: (job && job.name) || titleBySession.get(s.sessionId) || s.name,
|
|
425
|
+
background: s.kind === 'bg' || job
|
|
426
|
+
? { jobId: job ? job.jobId : null, detail: job ? job.detail : null, state: job ? job.state : null }
|
|
427
|
+
: null,
|
|
413
428
|
status: s.status,
|
|
414
429
|
waitingFor: s.waitingFor,
|
|
415
430
|
waitingReason: s.status === 'waiting' ? this.reasonFor(s) : null,
|
|
@@ -481,7 +496,22 @@ class Collector {
|
|
|
481
496
|
|
|
482
497
|
const forecast = this.limitForecast();
|
|
483
498
|
|
|
484
|
-
|
|
499
|
+
// Background jobs, live or finished, with any worktree work at risk.
|
|
500
|
+
const liveIds = new Set(this.raw.live.map((s) => s.sessionId));
|
|
501
|
+
const jobWt = this.raw.jobWt || new Map();
|
|
502
|
+
const jobsOut = jobs.map((j) => ({
|
|
503
|
+
jobId: j.jobId,
|
|
504
|
+
sessionId: j.sessionId,
|
|
505
|
+
name: j.name || j.intent || j.jobId,
|
|
506
|
+
state: j.state,
|
|
507
|
+
detail: j.detail,
|
|
508
|
+
projectName: j.cwd ? friendlyName(worktreeRoot(j.cwd).root) : null,
|
|
509
|
+
updatedAt: j.updatedAt,
|
|
510
|
+
live: liveIds.has(j.sessionId),
|
|
511
|
+
atRisk: jobAttention(j, jobWt.get(j.jobId)),
|
|
512
|
+
}));
|
|
513
|
+
|
|
514
|
+
const next = { quota: this.raw.quota, forecast, jobs: jobsOut, plan: readPlan(), theme: readConfig().theme || 'board', liveSessions, projects, weeklyCost, budget, pinned, events: this.events.slice(0, 50), errors: this.state.errors.slice(-5) };
|
|
485
515
|
const fp = JSON.stringify(next);
|
|
486
516
|
if (fp !== this.fingerprint) {
|
|
487
517
|
this.fingerprint = fp;
|
package/lib/demo.js
CHANGED
|
@@ -55,6 +55,11 @@ function demoState() {
|
|
|
55
55
|
generatedAt: now,
|
|
56
56
|
plan: { tier: 'Max 20x' },
|
|
57
57
|
quota: { utilization: 64, weeklyUtilization: 52, resetsAt: '2pm', weeklyResetsAt: 'Wed 9am' },
|
|
58
|
+
jobs: [
|
|
59
|
+
{ jobId: 'b7e2c901', sessionId: S2, name: 'Migrate posts to the new block format', state: 'working', detail: 'converting shortcodes post by post; 41 of 120 done', projectName: 'Blog Engine', updatedAt: now - MIN, live: true, atRisk: null },
|
|
60
|
+
{ jobId: 'a41f0d3e', sessionId: 'demo5555-5555-4555-8555-555555555555', name: 'Fix the flaky checkout test', state: 'done', detail: 'race in the cart fixture; fixed and committed on the branch, not merged', projectName: 'Acme Storefront', updatedAt: now - 2 * HOUR, live: false,
|
|
61
|
+
atRisk: { jobId: 'a41f0d3e', sessionId: 'demo5555-5555-4555-8555-555555555555', name: 'Fix the flaky checkout test', worktree: 'fix-flaky-checkout', branch: 'worktree-fix-flaky-checkout', changes: 1, onlyHere: 2 } },
|
|
62
|
+
],
|
|
58
63
|
forecast: {
|
|
59
64
|
session: { hitAt: now + 70 * MIN, projected: 128, resetsAt: now + 3 * HOUR, utilization: 64, basis: 'recent' },
|
|
60
65
|
week: { hitAt: null, projected: 81, resetsAt: now + 3 * 24 * HOUR, utilization: 52, basis: 'average' },
|
|
@@ -87,6 +92,7 @@ function demoState() {
|
|
|
87
92
|
projectName: 'Blog Engine', isWorktree: false, model: 'claude-fable-5',
|
|
88
93
|
estCost: 61.2, burnRate: 19, assumed: false, context: { tokens: 731000, window: 1000000, pct: 73 }, tools: { total: 388, errors: 0 },
|
|
89
94
|
title: 'Migrate posts to the new block format', status: 'busy',
|
|
95
|
+
background: { jobId: 'b7e2c901', detail: 'converting shortcodes post by post; 41 of 120 done', state: 'working' },
|
|
90
96
|
waitingFor: null, waitingReason: null, startedAt: now - 3 * HOUR - 12 * MIN, statusUpdatedAt: now - MIN, quietMin: null, waitingMin: null,
|
|
91
97
|
currentTask: { activeForm: 'Converting legacy shortcodes' },
|
|
92
98
|
tasksSummary: { completed: 7, inProgress: 1, pending: 3 },
|
package/lib/jobs.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Background sessions (`claude --bg`, Agent View) from ~/.claude/jobs/<id>/
|
|
3
|
+
// state.json: Agent View's name and one-line summary, the job's state, and
|
|
4
|
+
// its worktree. The files outlive the session, so finished jobs show too.
|
|
5
|
+
//
|
|
6
|
+
// For a job whose worktree still exists we also count work that would be
|
|
7
|
+
// lost if the session were deleted from Agent View's list, which discards
|
|
8
|
+
// the worktree: uncommitted files, and commits on no other branch or remote.
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { execFile } = require('child_process');
|
|
12
|
+
const { CLAUDE_DIR } = require('./paths');
|
|
13
|
+
const { parsePorcelainV2 } = require('./gitstatus');
|
|
14
|
+
|
|
15
|
+
const JOBS_DIR = path.join(CLAUDE_DIR, 'jobs');
|
|
16
|
+
const KEEP_MS = 7 * 24 * 60 * 60 * 1000; // finished jobs older than this drop off
|
|
17
|
+
const MAX_JOBS = 30;
|
|
18
|
+
|
|
19
|
+
const cache = new Map(); // jobId -> { mtimeMs, job }
|
|
20
|
+
|
|
21
|
+
function ms(v) {
|
|
22
|
+
if (typeof v === 'number') return v;
|
|
23
|
+
const t = Date.parse(v);
|
|
24
|
+
return Number.isFinite(t) ? t : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeJob(jobId, raw) {
|
|
28
|
+
if (!raw || !raw.sessionId) return null;
|
|
29
|
+
return {
|
|
30
|
+
jobId,
|
|
31
|
+
sessionId: raw.sessionId,
|
|
32
|
+
name: raw.name || null,
|
|
33
|
+
state: raw.state || null,
|
|
34
|
+
detail: raw.detail ? String(raw.detail).slice(0, 200) : null,
|
|
35
|
+
intent: raw.intent ? String(raw.intent).slice(0, 200) : null,
|
|
36
|
+
cwd: raw.cwd || null,
|
|
37
|
+
worktreePath: raw.worktreePath || null,
|
|
38
|
+
worktreeBranch: raw.worktreeBranch || null,
|
|
39
|
+
createdAt: ms(raw.createdAt),
|
|
40
|
+
updatedAt: ms(raw.updatedAt),
|
|
41
|
+
tokens: typeof raw.tokens === 'number' ? raw.tokens : null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Recent jobs, newest first. Cached per file by mtime.
|
|
46
|
+
function readJobs(now = Date.now()) {
|
|
47
|
+
let ids;
|
|
48
|
+
try {
|
|
49
|
+
ids = fs.readdirSync(JOBS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const id of ids) {
|
|
55
|
+
const file = path.join(JOBS_DIR, id, 'state.json');
|
|
56
|
+
let st;
|
|
57
|
+
try {
|
|
58
|
+
st = fs.statSync(file);
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let hit = cache.get(id);
|
|
63
|
+
if (!hit || hit.mtimeMs !== st.mtimeMs) {
|
|
64
|
+
let job = null;
|
|
65
|
+
try {
|
|
66
|
+
job = normalizeJob(id, JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
67
|
+
} catch { /* partial write: try again next poll */ }
|
|
68
|
+
hit = { mtimeMs: st.mtimeMs, job };
|
|
69
|
+
cache.set(id, hit);
|
|
70
|
+
}
|
|
71
|
+
if (hit.job && now - (hit.job.updatedAt || st.mtimeMs) <= KEEP_MS) out.push(hit.job);
|
|
72
|
+
}
|
|
73
|
+
out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
74
|
+
return out.slice(0, MAX_JOBS);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function git(args) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
execFile('git', args, { timeout: 4000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => resolve(err ? null : stdout));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Args for "commits reachable from HEAD but from no other branch or remote".
|
|
84
|
+
// --exclude patterns for --branches must leave off the refs/heads/ prefix.
|
|
85
|
+
function onlyHereArgs(worktreePath, branch) {
|
|
86
|
+
return ['-C', worktreePath, 'rev-list', '--count', 'HEAD', '--not',
|
|
87
|
+
...(branch ? [`--exclude=${branch}`] : []), '--branches', '--remotes'];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function worktreeStatus(job) {
|
|
91
|
+
if (!job.worktreePath || !fs.existsSync(path.join(job.worktreePath, '.git'))) return { exists: false };
|
|
92
|
+
const [status, count] = await Promise.all([
|
|
93
|
+
git(['-C', job.worktreePath, 'status', '--porcelain=v2', '--branch']),
|
|
94
|
+
git(onlyHereArgs(job.worktreePath, job.worktreeBranch)),
|
|
95
|
+
]);
|
|
96
|
+
if (status == null) return { exists: true, error: true };
|
|
97
|
+
const s = parsePorcelainV2(status);
|
|
98
|
+
const n = Number.parseInt(count, 10);
|
|
99
|
+
return { exists: true, changes: s.dirty + s.untracked, onlyHere: Number.isFinite(n) ? n : 0 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// What the unpushed strip should say about a job's worktree, or null.
|
|
103
|
+
function jobAttention(job, wt) {
|
|
104
|
+
if (!wt || !wt.exists || wt.error) return null;
|
|
105
|
+
if (!wt.changes && !wt.onlyHere) return null;
|
|
106
|
+
return {
|
|
107
|
+
jobId: job.jobId,
|
|
108
|
+
sessionId: job.sessionId,
|
|
109
|
+
name: job.name || job.jobId,
|
|
110
|
+
worktree: path.basename(job.worktreePath),
|
|
111
|
+
branch: job.worktreeBranch,
|
|
112
|
+
changes: wt.changes,
|
|
113
|
+
onlyHere: wt.onlyHere,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { readJobs, normalizeJob, worktreeStatus, jobAttention, onlyHereArgs };
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -351,6 +351,7 @@
|
|
|
351
351
|
.badge.dirty { color: var(--warn); }
|
|
352
352
|
.badge.ahead { color: var(--accent); }
|
|
353
353
|
.badge.err { color: var(--bad); }
|
|
354
|
+
.badge.bg { color: var(--accent); letter-spacing: 0.06em; }
|
|
354
355
|
.badge.missing { color: var(--bad); }
|
|
355
356
|
.note-mark { color: var(--warn); cursor: default; font-size: 12px; flex: none; }
|
|
356
357
|
#note-editor { margin: 10px 0 4px; display: flex; flex-direction: column; gap: 6px; }
|
|
@@ -1099,6 +1100,8 @@ function copyBtn(cmd) {
|
|
|
1099
1100
|
|
|
1100
1101
|
// Live sessions are already open somewhere; only offer open on idle ones.
|
|
1101
1102
|
let liveIds = new Set();
|
|
1103
|
+
let jobBySession = new Map(); // background jobs (Agent View), by session id
|
|
1104
|
+
const BG_TIP = 'Background session (claude --bg or Agent View)';
|
|
1102
1105
|
function renameBtn(sessionId, title) {
|
|
1103
1106
|
return `<button class="copy rename-btn" data-rename-session="${esc(sessionId)}" data-title="${esc(title || '')}" title="Rename this session">✎</button>`;
|
|
1104
1107
|
}
|
|
@@ -1161,13 +1164,21 @@ function liveCard(s) {
|
|
|
1161
1164
|
const reason = rl
|
|
1162
1165
|
? `<div class="dep-reason" title="${esc(rl.full)}">${rl.glyph ? `<span class="glyph">${rl.glyph}</span>` : ''}${esc(rl.short)}</div>`
|
|
1163
1166
|
: '';
|
|
1167
|
+
// Agent View's one-line summary of a background session, when nothing more specific shows.
|
|
1168
|
+
const bgDetail = s.background && s.background.detail && !rl && !task
|
|
1169
|
+
? `<div class="dep-task mono" title="Agent View's summary"><span class="prompt-ch">↳</span>${esc(s.background.detail)}</div>`
|
|
1170
|
+
: '';
|
|
1171
|
+
const kindBadge = s.background
|
|
1172
|
+
? ` <span class="badge bg" title="${BG_TIP}">BG</span>`
|
|
1173
|
+
: s.isWorktree ? ' <span class="badge">worktree</span>' : '';
|
|
1164
1174
|
return `<div class="dep-row ${s.status === 'waiting' ? 'waiting' : ''}">
|
|
1165
1175
|
<span class="dep-time" title="session started">${esc(dep)}</span>
|
|
1166
|
-
<span class="dep-proj disp">${esc(s.projectName)}${
|
|
1176
|
+
<span class="dep-proj disp">${esc(s.projectName)}${kindBadge}</span>
|
|
1167
1177
|
<div class="dep-mid">
|
|
1168
1178
|
<div class="dep-title" data-session="${esc(s.sessionId)}" title="Click to watch the transcript live">${esc(s.title || '')}</div>
|
|
1169
1179
|
${task}
|
|
1170
1180
|
${reason}
|
|
1181
|
+
${bgDetail}
|
|
1171
1182
|
</div>
|
|
1172
1183
|
<div class="dep-right">
|
|
1173
1184
|
${modelChip(s.model)}
|
|
@@ -1246,17 +1257,27 @@ function renderGitAttention(state) {
|
|
|
1246
1257
|
if (!changes && !ahead) continue;
|
|
1247
1258
|
items.push({ name: p.name, changes, ahead, when: p.lastActivityAt });
|
|
1248
1259
|
}
|
|
1249
|
-
|
|
1260
|
+
// Background jobs' worktrees: Agent View deletes the worktree with the session.
|
|
1261
|
+
const atRisk = (state.jobs || []).filter((j) => j.atRisk).map((j) => ({ ...j.atRisk, projectName: j.projectName, when: j.updatedAt }));
|
|
1262
|
+
if (!items.length && !atRisk.length) {
|
|
1250
1263
|
el.hidden = true;
|
|
1251
1264
|
return;
|
|
1252
1265
|
}
|
|
1253
1266
|
items.sort((a, b) => (b.when || 0) - (a.when || 0));
|
|
1267
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
1254
1268
|
el.hidden = false;
|
|
1255
1269
|
patch(el,
|
|
1256
|
-
`<span class="ga-label">Unpushed work — ${items.length
|
|
1270
|
+
`<span class="ga-label">Unpushed work — ${[items.length && plural(items.length, 'repo'), atRisk.length && plural(atRisk.length, 'worktree')].filter(Boolean).join(', ')}</span>` +
|
|
1257
1271
|
items.map((it) => `<span class="ga-item">${esc(it.name)}${
|
|
1258
1272
|
it.changes ? `<span class="badge dirty" title="${it.changes} uncommitted/untracked files">●${it.changes}</span>` : ''}${
|
|
1259
|
-
it.ahead ? `<span class="badge ahead" title="${it.ahead} commits not pushed">↑${it.ahead}</span>` : ''}</span>`).join('')
|
|
1273
|
+
it.ahead ? `<span class="badge ahead" title="${it.ahead} commits not pushed">↑${it.ahead}</span>` : ''}</span>`).join('') +
|
|
1274
|
+
atRisk.map((w) => {
|
|
1275
|
+
const parts = [w.onlyHere && `${plural(w.onlyHere, 'commit')} on ${w.branch || 'this branch'} and no other branch`, w.changes && plural(w.changes, 'uncommitted file')].filter(Boolean);
|
|
1276
|
+
const tip = `Background job "${w.name}": ${parts.join(', ')}. Deleting the session from Agent View's list (ctrl+x twice) discards this; claude rm refuses until it is committed and pushed. Merge or push first.`;
|
|
1277
|
+
return `<span class="ga-item" title="${esc(tip)}">${esc(w.projectName || '')} <span class="wt mono">⎇ ${esc(w.worktree)}</span><span class="badge bg" title="${BG_TIP}">BG</span>${
|
|
1278
|
+
w.changes ? `<span class="badge dirty">●${w.changes}</span>` : ''}${
|
|
1279
|
+
w.onlyHere ? `<span class="badge dirty">⑂${w.onlyHere}</span>` : ''}</span>`;
|
|
1280
|
+
}).join(''));
|
|
1260
1281
|
}
|
|
1261
1282
|
|
|
1262
1283
|
// ---------- digest: what happened recently, across every project ----------
|
|
@@ -1289,11 +1310,14 @@ function digestItem(it) {
|
|
|
1289
1310
|
const t = it.tasksSummary;
|
|
1290
1311
|
const total = t ? t.completed + t.inProgress + t.pending : 0;
|
|
1291
1312
|
const running = liveIds.has(it.sessionId);
|
|
1313
|
+
const job = jobBySession.get(it.sessionId);
|
|
1314
|
+
const summary = it.awaySummary || (job && job.detail);
|
|
1292
1315
|
const time = new Date(it.lastActivityAt).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
1293
1316
|
return `<div class="digest-item">
|
|
1294
1317
|
<div class="di-top">
|
|
1295
1318
|
<span class="di-proj">${esc(it.project)}</span>
|
|
1296
1319
|
<span class="di-title" data-session="${esc(it.sessionId)}" title="Click to read the transcript">${it.worktree ? `<span class="wt mono">⎇</span> ` : ''}${esc(it.title)}</span>${noteMark(it)}
|
|
1320
|
+
${job ? `<span class="badge bg" title="${BG_TIP}">BG</span>` : ''}
|
|
1297
1321
|
${running ? '<span class="flap disp st st-busy">running</span>' : ''}
|
|
1298
1322
|
${modelChip(it.model)}
|
|
1299
1323
|
${changesChip(it)}
|
|
@@ -1301,7 +1325,7 @@ function digestItem(it) {
|
|
|
1301
1325
|
<span class="di-when">${time}</span>
|
|
1302
1326
|
${openBtn(it.sessionId)}
|
|
1303
1327
|
</div>
|
|
1304
|
-
${
|
|
1328
|
+
${summary ? `<div class="di-summary" title="Click to expand">${esc(summary)}</div>` : ''}
|
|
1305
1329
|
${total ? `<div class="di-tasks"><span class="done">${t.completed}</span>/${total} tasks done${
|
|
1306
1330
|
t.inProgress ? `, ${t.inProgress} in progress` : ''}${
|
|
1307
1331
|
t.pending ? `, ${t.pending} left` : ''}</div>` : ''}
|
|
@@ -1337,6 +1361,7 @@ function render(state) {
|
|
|
1337
1361
|
if (state.theme && state.theme !== 'board') document.documentElement.dataset.theme = state.theme;
|
|
1338
1362
|
else delete document.documentElement.dataset.theme;
|
|
1339
1363
|
liveIds = new Set((state.liveSessions || []).map((s) => s.sessionId));
|
|
1364
|
+
jobBySession = new Map((state.jobs || []).map((j) => [j.sessionId, j]));
|
|
1340
1365
|
// A transcript being followed live keeps its header cost in step with the board.
|
|
1341
1366
|
if (follow.id && exportableSession?.id === follow.id && exportableSession.usage) {
|
|
1342
1367
|
const live = (state.liveSessions || []).find((s) => s.sessionId === follow.id);
|