create-pmos 0.38.7 → 0.38.8
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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +35 -0
- package/workspace/bin/pmos.js +10 -1
- package/workspace/lib/initiative-files.js +111 -0
- package/workspace/lib/next-steps.js +113 -0
- package/workspace/lib/prompt-input.js +18 -0
- package/workspace/lib/schedule-cli.js +256 -0
- package/workspace/lib/schedule-logic.js +23 -1
- package/workspace/lib/skill-store.js +11 -3
- package/workspace/lib/status-groups.js +66 -0
- package/workspace/lib/transcripts.js +73 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/index.html +2 -0
- package/workspace/public/pm/data.js +23 -2
- package/workspace/public/pm/modes-live.js +19 -0
- package/workspace/public/pm/panel-detail.js +84 -0
- package/workspace/public/pm/panel-initiatives.js +37 -1
- package/workspace/public/pm/panel-tabs.js +38 -2
- package/workspace/public/pm/panel-trees.js +9 -2
- package/workspace/public/pm/state.js +7 -2
- package/workspace/server.js +112 -5
|
@@ -86,10 +86,25 @@ function nextSlot(schedule, now) {
|
|
|
86
86
|
}
|
|
87
87
|
function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
|
|
88
88
|
|
|
89
|
+
// R5 — an optional end. A schedule ends when it has run `max_runs` times or when the day after `until` begins
|
|
90
|
+
// (the `until` day itself still runs). No `until` and no `max_runs` means it never ends on its own.
|
|
91
|
+
function endOfDay(dateStr) {
|
|
92
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(dateStr || ''));
|
|
93
|
+
return m ? new Date(+m[1], +m[2] - 1, +m[3] + 1, 0, 0, 0, 0) : null;
|
|
94
|
+
}
|
|
95
|
+
function finishReason(schedule, now = new Date()) {
|
|
96
|
+
if (!schedule) return null;
|
|
97
|
+
const max = +schedule.max_runs;
|
|
98
|
+
if (max > 0 && (+schedule.runs || 0) >= max) return `max_runs reached (${max} run${max === 1 ? '' : 's'})`;
|
|
99
|
+
const end = endOfDay(schedule.until);
|
|
100
|
+
if (end && now >= end) return `until ${schedule.until} has passed`;
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
89
103
|
// Is the schedule due to fire at `now`? Enabled + a slot in (lastRun, now]. For bi-weekly, also
|
|
90
104
|
// require ≥ 13 days since lastRun (weekly slots, fired every other week).
|
|
91
105
|
function isDue(schedule, now = new Date()) {
|
|
92
106
|
if (!schedule || !schedule.enabled) return false;
|
|
107
|
+
if (finishReason(schedule, now)) return false;
|
|
93
108
|
const slot = lastSlot(schedule, now);
|
|
94
109
|
if (!slot) return false;
|
|
95
110
|
const last = schedule.lastRun ? new Date(schedule.lastRun) : null;
|
|
@@ -130,6 +145,11 @@ function normalizeSchedule(input) {
|
|
|
130
145
|
if (!s.name || !String(s.name).trim()) return { ok: false, error: 'name is required' };
|
|
131
146
|
if (!CADENCES.includes(s.cadence)) return { ok: false, error: 'invalid cadence' };
|
|
132
147
|
if (!s.skill || !String(s.skill).trim()) return { ok: false, error: 'a skill-backed task is required' };
|
|
148
|
+
if (s.until != null && s.until !== '') {
|
|
149
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(s.until)) || !endOfDay(s.until) || Number.isNaN(endOfDay(s.until).getTime())) return { ok: false, error: 'until must be a date, YYYY-MM-DD' };
|
|
150
|
+
if (endOfDay(s.until) <= new Date()) return { ok: false, error: 'until is already in the past — the schedule would never run' };
|
|
151
|
+
}
|
|
152
|
+
if (s.max_runs != null && s.max_runs !== '' && !(Number.isInteger(+s.max_runs) && +s.max_runs >= 1 && +s.max_runs <= 10000)) return { ok: false, error: 'max_runs must be a whole number from 1 to 10000' };
|
|
133
153
|
if (s.cadence === 'once') {
|
|
134
154
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(s.date || ''))) return { ok: false, error: 'a one-off needs a date (YYYY-MM-DD)' };
|
|
135
155
|
const slot = onceSlot({ date: s.date, time: s.time });
|
|
@@ -153,9 +173,11 @@ function normalizeSchedule(input) {
|
|
|
153
173
|
task: s.task || s.skill,
|
|
154
174
|
skill: String(s.skill).trim(),
|
|
155
175
|
enabled: s.enabled !== false,
|
|
176
|
+
...(s.until ? { until: String(s.until) } : {}),
|
|
177
|
+
...(s.max_runs ? { max_runs: Math.floor(+s.max_runs) } : {}),
|
|
156
178
|
},
|
|
157
179
|
};
|
|
158
180
|
}
|
|
159
181
|
function parseTimeStr(t) { const { h, m } = parseTime(t); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; }
|
|
160
182
|
|
|
161
|
-
module.exports = { CADENCES, isMissed, ONCE_GRACE_MS, isDue, nextRun, humanCadence, normalizeSchedule, lastSlot, nextSlot };
|
|
183
|
+
module.exports = { finishReason, endOfDay, CADENCES, isMissed, ONCE_GRACE_MS, isDue, nextRun, humanCadence, normalizeSchedule, lastSlot, nextSlot };
|
|
@@ -266,7 +266,7 @@ function retargetFiling(text, filing) {
|
|
|
266
266
|
// Scaffold a well-formed user skill file from the New Skill flow's fields. Returns { ok, slug, content }
|
|
267
267
|
// or { ok:false, error }. User skills default to run:agent — a user-authored skill reasons through a
|
|
268
268
|
// claude session (the existing agent path); the deterministic fast-path is reserved for the built-ins.
|
|
269
|
-
function scaffoldSkill({ name, description, output, body, filing } = {}) {
|
|
269
|
+
function scaffoldSkill({ name, description, output, body, filing, work } = {}) {
|
|
270
270
|
const slug = slugify(name);
|
|
271
271
|
if (!NAME_RE.test(slug)) return { ok: false, error: 'name must be a short slug (letters, digits, hyphens)' };
|
|
272
272
|
const label = titleCase(name) || slug;
|
|
@@ -276,7 +276,7 @@ function scaffoldSkill({ name, description, output, body, filing } = {}) {
|
|
|
276
276
|
if (!out.length) out = ['Summary', 'Findings', 'Next steps']; // always a valid contract
|
|
277
277
|
// A hand-written body is retargeted too: a PM pasting the old contract text in gets a skill that
|
|
278
278
|
// files where the console reads, not one that quietly writes into a directory nobody opens.
|
|
279
|
-
const proc = retargetFiling(String(body || '').trim(), filing) || defaultBody(label, desc, out, slug, filing);
|
|
279
|
+
const proc = retargetFiling(String(body || '').trim(), filing) || defaultBody(label, desc, out, slug, filing, work ? String(work).slice(0, 8000) : null);
|
|
280
280
|
const content = [
|
|
281
281
|
'---',
|
|
282
282
|
`name: ${slug}`,
|
|
@@ -306,7 +306,7 @@ function scaffoldSkill({ name, description, output, body, filing } = {}) {
|
|
|
306
306
|
// suite could not reach it. A scaffold that still said "workspace/state/deliverables/" would send
|
|
307
307
|
// every scheduled agent's report to a directory the console never reads: a silent, permanent
|
|
308
308
|
// no-show in the needs-you queue. The defaults keep this module usable standalone.
|
|
309
|
-
function defaultBody(label, desc, out, slug, filing) {
|
|
309
|
+
function defaultBody(label, desc, out, slug, filing, work) {
|
|
310
310
|
const id = slug || slugify(label) || 'run';
|
|
311
311
|
const deliverables = (filing && filing.deliverables) || 'workspace/state/deliverables';
|
|
312
312
|
const needs = (filing && filing.needs) || 'workspace/state/needs.json';
|
|
@@ -318,9 +318,17 @@ function defaultBody(label, desc, out, slug, filing) {
|
|
|
318
318
|
'',
|
|
319
319
|
'Procedure — run the numbered steps in order; each ends on a checkable done-condition:',
|
|
320
320
|
'',
|
|
321
|
+
...(work ? [
|
|
322
|
+
'1. Repeat the work below. It was captured from an earlier terminal session by the PM: do the same job again,',
|
|
323
|
+
" with today's dates and this repository's current files. Do not repeat one-off details (old names, old dates, old errors).",
|
|
324
|
+
' Done when: the same kind of result exists for today, each statement traceable to a real source.',
|
|
325
|
+
' Captured work:',
|
|
326
|
+
...String(work).split('\n').map((l) => ' | ' + l),
|
|
327
|
+
] : [
|
|
321
328
|
"1. Gather inputs from this repo's own local sources — name the exact commands/paths the run",
|
|
322
329
|
' depends on (e.g. `git log`, `gh pr view <n> --json title,body`, a file under `planning/`).',
|
|
323
330
|
' Done when: every input the sections below need is in hand, each traceable to a real source.',
|
|
331
|
+
]),
|
|
324
332
|
'2. Produce each required section from those inputs, in the exact order of the output contract.',
|
|
325
333
|
' An empty section is the literal line "None this period." — never a dropped heading, never invented content.',
|
|
326
334
|
' Done when: every contract section is filled or explicitly "None this period.".',
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Grouping the initiative list by status (initiative-surface-batch R2).
|
|
3
|
+
//
|
|
4
|
+
// The status is not typed by anyone: the server derives it for every initiative from the ledgers and
|
|
5
|
+
// git (`deriveState` in server.js — stopped, accepted, blocked, delivered, building, planning, unknown)
|
|
6
|
+
// and, for a stopped one, the lifecycle ledger says which kind (on-hold, closed, cancelled). This maps
|
|
7
|
+
// that derived pair to the PM's words and puts the groups in one order. It reads the derived value and
|
|
8
|
+
// nothing else, so a hand-typed `status:` in a record can never decide where a row lands, and an
|
|
9
|
+
// unreadable history is its own group instead of passing for Planning.
|
|
10
|
+
//
|
|
11
|
+
// Classic script (a UMD setting window.PMOSStatusGroups) like the other lib/ files the board shares
|
|
12
|
+
// with node, so the order and the mapping are walkable in the test suite.
|
|
13
|
+
(function (root, factory) {
|
|
14
|
+
const api = factory();
|
|
15
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
16
|
+
else root.PMOSStatusGroups = api;
|
|
17
|
+
}(typeof self !== 'undefined' ? self : this, function () {
|
|
18
|
+
|
|
19
|
+
// In the order the PM reads them: what needs them, what is moving, what has not started, what waits on
|
|
20
|
+
// their acceptance, what is done, then what is parked. `active` marks the four that are live work.
|
|
21
|
+
const GROUPS = [
|
|
22
|
+
{ key: 'needs-you', label: 'Needs you', active: true, hint: 'returned at acceptance, or has open items in the needs queue' },
|
|
23
|
+
{ key: 'in-progress', label: 'In progress', active: true, hint: 'runs logged, nothing merged yet' },
|
|
24
|
+
{ key: 'planning', label: 'Planning', active: true, hint: 'recorded, no run logged yet' },
|
|
25
|
+
{ key: 'awaiting-acceptance', label: 'Awaiting acceptance', active: true, hint: 'delivered; the PM has not accepted it' },
|
|
26
|
+
{ key: 'accepted', label: 'Accepted', active: false, hint: 'accepted by the PM' },
|
|
27
|
+
{ key: 'on-hold', label: 'On hold', active: false, hint: 'paused by the PM; off the board, not gating' },
|
|
28
|
+
{ key: 'closed', label: 'Closed', active: false, hint: 'closed by the PM; off the board, not gating' },
|
|
29
|
+
{ key: 'cancelled', label: 'Cancelled', active: false, hint: 'cancelled by the PM; off the board, not gating' },
|
|
30
|
+
{ key: 'unknown', label: 'Unknown', active: false, hint: 'the history could not be read, so the status cannot be derived' },
|
|
31
|
+
];
|
|
32
|
+
const BY_KEY = Object.fromEntries(GROUPS.map((g) => [g.key, g]));
|
|
33
|
+
|
|
34
|
+
/** The group key for one derived status row `{status, lifecycle}`; `unknown` when the row is missing or unrecognised. */
|
|
35
|
+
function keyOf(row) {
|
|
36
|
+
if (!row || typeof row !== 'object') return 'unknown';
|
|
37
|
+
switch (row.status) {
|
|
38
|
+
case 'blocked': return 'needs-you';
|
|
39
|
+
case 'building': return 'in-progress';
|
|
40
|
+
case 'planning': return 'planning';
|
|
41
|
+
case 'delivered': return 'awaiting-acceptance';
|
|
42
|
+
case 'accepted': return 'accepted';
|
|
43
|
+
case 'stopped': {
|
|
44
|
+
const s = row.lifecycle && row.lifecycle.state;
|
|
45
|
+
return s === 'on-hold' || s === 'closed' || s === 'cancelled' ? s : 'unknown';
|
|
46
|
+
}
|
|
47
|
+
default: return 'unknown';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string[]} slugs every initiative on the board
|
|
53
|
+
* @param {Object<string,Object>} rows slug → derived status row (missing → Unknown)
|
|
54
|
+
* @param {{activeOnly?:boolean}} opts
|
|
55
|
+
* @returns {Array<{key,label,hint,active,slugs:string[]}>} non-empty groups only, in order
|
|
56
|
+
*/
|
|
57
|
+
function group(slugs, rows, opts) {
|
|
58
|
+
const activeOnly = !!(opts && opts.activeOnly);
|
|
59
|
+
const buckets = new Map(GROUPS.map((g) => [g.key, []]));
|
|
60
|
+
for (const slug of slugs || []) buckets.get(keyOf(rows && rows[slug])).push(slug);
|
|
61
|
+
return GROUPS.filter((g) => (!activeOnly || g.active) && buckets.get(g.key).length)
|
|
62
|
+
.map((g) => ({ ...g, slugs: buckets.get(g.key) }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { GROUPS, BY_KEY, keyOf, group };
|
|
66
|
+
}));
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* transcripts.js — the record of a conversation that belongs to an initiative.
|
|
3
|
+
*
|
|
4
|
+
* A session's terminal text used to live and die with the session: removing it deleted the only copy,
|
|
5
|
+
* so an initiative's discussion could not be reviewed later. A session that belongs to an initiative is
|
|
6
|
+
* now saved here when it ends or is removed, and listed beside the initiative's other artifacts.
|
|
7
|
+
*
|
|
8
|
+
* WHERE: one JSON file per session under `<runtime state>/transcripts/`. The runtime state directory is
|
|
9
|
+
* machine-local and outside every repository (lib/state-root.js). A transcript can hold anything the PM
|
|
10
|
+
* typed or an agent printed — including a secret — so it is deliberately NOT written into the repository
|
|
11
|
+
* (PM ruling 2026-10-03) and is never sent anywhere. Pure of the network (D54): fs only.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const MAX_CHARS = 400000; // the tail of a very long session; the record says when it was cut
|
|
17
|
+
const ID_RE = /^[A-Za-z0-9._-]+$/;
|
|
18
|
+
|
|
19
|
+
// Terminal text without its escape sequences: colours, cursor moves, the window-title and hyperlink codes.
|
|
20
|
+
function stripAnsi(text) {
|
|
21
|
+
return String(text == null ? '' : text)
|
|
22
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
23
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
24
|
+
.replace(/\x1b[()][A-Za-z0-9]/g, '')
|
|
25
|
+
.replace(/\r(?!\n)/g, '\n').replace(/\r/g, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const dirOf = (stateDir) => path.join(stateDir, 'transcripts');
|
|
29
|
+
const fileOf = (stateDir, id) => path.join(dirOf(stateDir), id + '.json');
|
|
30
|
+
|
|
31
|
+
/** Write (or refresh) one session's record. Returns {ok, path} or {ok:false, error}; never throws. */
|
|
32
|
+
function save(stateDir, rec) {
|
|
33
|
+
try {
|
|
34
|
+
const id = String(rec && rec.id || '');
|
|
35
|
+
if (!ID_RE.test(id)) return { ok: false, error: 'not a session id' };
|
|
36
|
+
if (!rec.initiative || !ID_RE.test(String(rec.initiative))) return { ok: false, error: 'the session is on no initiative — nothing to record' };
|
|
37
|
+
const full = stripAnsi(rec.text);
|
|
38
|
+
const cut = full.length > MAX_CHARS;
|
|
39
|
+
const out = {
|
|
40
|
+
id, name: String(rec.name || id).slice(0, 80), agent: rec.agent || null, ws: rec.ws || null,
|
|
41
|
+
initiative: String(rec.initiative), state: rec.state || 'exited',
|
|
42
|
+
started: rec.started || null, saved: rec.saved || new Date().toISOString(),
|
|
43
|
+
cut, chars: Math.min(full.length, MAX_CHARS), text: cut ? full.slice(-MAX_CHARS) : full,
|
|
44
|
+
};
|
|
45
|
+
fs.mkdirSync(dirOf(stateDir), { recursive: true });
|
|
46
|
+
const file = fileOf(stateDir, id);
|
|
47
|
+
fs.writeFileSync(file + '.tmp', JSON.stringify(out));
|
|
48
|
+
fs.renameSync(file + '.tmp', file);
|
|
49
|
+
return { ok: true, path: file };
|
|
50
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e).slice(0, 160) }; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function read(stateDir, id) {
|
|
54
|
+
if (!ID_RE.test(String(id || ''))) return null;
|
|
55
|
+
try { return JSON.parse(fs.readFileSync(fileOf(stateDir, id), 'utf8')); } catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every saved record for one initiative, newest first, without the text. */
|
|
59
|
+
function listFor(stateDir, initiative) {
|
|
60
|
+
let names = [];
|
|
61
|
+
try { names = fs.readdirSync(dirOf(stateDir)); } catch { return []; }
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const n of names) {
|
|
64
|
+
if (!n.endsWith('.json')) continue;
|
|
65
|
+
let r = null;
|
|
66
|
+
try { r = JSON.parse(fs.readFileSync(path.join(dirOf(stateDir), n), 'utf8')); } catch { continue; }
|
|
67
|
+
if (!r || r.initiative !== initiative) continue;
|
|
68
|
+
out.push({ id: r.id, name: r.name, ws: r.ws, state: r.state, started: r.started, saved: r.saved, chars: r.chars, cut: !!r.cut });
|
|
69
|
+
}
|
|
70
|
+
return out.sort((a, b) => String(b.saved).localeCompare(String(a.saved)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { stripAnsi, save, read, listFor, dirOf, MAX_CHARS };
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.8",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
12
|
"test": "node test.js && npm run test:modules",
|
|
13
|
-
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js",
|
|
13
|
+
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
17
17
|
"doctor": "node doctor.js",
|
|
18
|
-
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js",
|
|
18
|
+
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js",
|
|
19
19
|
"test:offline": "node offline-tarball.test.js"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
— test.js requires it and walks its whole input domain. It reads window.PMOSSessionState for
|
|
43
43
|
the attention order, so it must come AFTER that script. -->
|
|
44
44
|
<script src="/lib/working-set.js"></script>
|
|
45
|
+
<!-- Group-by-status for the initiative list: the order and the mapping from the DERIVED status, walkable in node. -->
|
|
46
|
+
<script src="/lib/status-groups.js"></script>
|
|
45
47
|
<!-- The phone's navigation reducer. DOM-free, loaded as a classic script (it is a UMD that sets
|
|
46
48
|
window.PMOSMobileNav) and required by test.js, so the whole screen domain is walkable in node
|
|
47
49
|
rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not
|
|
@@ -452,6 +452,11 @@ function recordFrom(payload) {
|
|
|
452
452
|
sessions: (payload.sessions || []).map(sessionRow),
|
|
453
453
|
links: linksFor(payload),
|
|
454
454
|
arts: artsFor(payload),
|
|
455
|
+
// R4/R5/R6 — what remains to acceptance, the conversations that belong to it, and the files git says
|
|
456
|
+
// were changed for it. Each arrives already derived by the server; this layer only carries them.
|
|
457
|
+
nextSteps: payload.next_steps || null,
|
|
458
|
+
convs: Array.isArray(payload.conversations) ? payload.conversations : [],
|
|
459
|
+
gitFiles: payload.files || null,
|
|
455
460
|
eval: evalFor(row && row.steps),
|
|
456
461
|
l2: l2For(row),
|
|
457
462
|
};
|
|
@@ -646,10 +651,24 @@ function foldWorkspaces(rows) {
|
|
|
646
651
|
|
|
647
652
|
/** Load one product's board: the spine (structure) joined with the library (artifacts). */
|
|
648
653
|
async function loadBoard(prod) {
|
|
649
|
-
const [spine, lib] = await Promise.all([
|
|
654
|
+
const [spine, lib, surf] = await Promise.all([
|
|
650
655
|
getJson('/api/console', { name: 'spine', ws: prod.ws }),
|
|
651
656
|
getJson('/api/library', { ws: prod.ws }),
|
|
657
|
+
// The DERIVED status of every initiative (server.js deriveState + the lifecycle ledger) — what "group by
|
|
658
|
+
// status" reads. A failed read is kept as a reason, never as an empty map: an empty map would file every
|
|
659
|
+
// initiative under Unknown without saying why.
|
|
660
|
+
getJson('/api/surface', { name: 'initiatives', ws: prod.ws }),
|
|
652
661
|
]);
|
|
662
|
+
{
|
|
663
|
+
const items = (surf && surf.ok !== false && ((surf.surface && surf.surface.initiatives) || surf.initiatives)) || null;
|
|
664
|
+
prod.statuses = {};
|
|
665
|
+
if (items) {
|
|
666
|
+
for (const i of items) if (i && i.id) prod.statuses[i.id] = { status: i.status, statusText: i.statusText || '', lifecycle: i.lifecycle || null };
|
|
667
|
+
prod.statusError = null;
|
|
668
|
+
} else {
|
|
669
|
+
prod.statusError = (surf && surf.error) || 'the derived status of the initiatives could not be read';
|
|
670
|
+
}
|
|
671
|
+
}
|
|
653
672
|
if (spine.ok === false) {
|
|
654
673
|
prod.error = spine.error || 'the initiative spine could not be read';
|
|
655
674
|
prod.board = [];
|
|
@@ -722,6 +741,7 @@ function publishBoards() {
|
|
|
722
741
|
* Scoping it by the selected workspace is how a tab in one repo attached to a shell in another
|
|
723
742
|
* (tab-term.js `wsForTab`). */
|
|
724
743
|
const productWs = {};
|
|
744
|
+
const statuses = {}; const statusErrors = {};
|
|
725
745
|
let records = {};
|
|
726
746
|
for (const name of productOrder) {
|
|
727
747
|
const p = products.get(name);
|
|
@@ -729,13 +749,14 @@ function publishBoards() {
|
|
|
729
749
|
if (p.ws) productWs[name] = p.ws;
|
|
730
750
|
if (!p.board) continue;
|
|
731
751
|
board[name] = p.board;
|
|
752
|
+
statuses[name] = p.statuses || {}; if (p.statusError) statusErrors[name] = p.statusError;
|
|
732
753
|
records = { ...records, ...p.records };
|
|
733
754
|
}
|
|
734
755
|
// `wsBranches` rides the same patch as `productWs` and for the same reason: it is a fact about
|
|
735
756
|
// every registered working tree, read once from /api/workspaces, that a panel needs in order to
|
|
736
757
|
// draw a row. panel 2 joins it against `state.sessions` to say which initiative each live agent is
|
|
737
758
|
// on — the same `initiative/<id>` join lib/four-panel.js makes per initiative on the server.
|
|
738
|
-
setState({ board, productWs, wsBranches: branchMap(), records: { ...records, ...(hydrated) } });
|
|
759
|
+
setState({ board, statuses, statusErrors, productWs, wsBranches: branchMap(), records: { ...records, ...(hydrated) } });
|
|
739
760
|
}
|
|
740
761
|
|
|
741
762
|
/* Hydrated (fully derived) records live apart from the light ones so that a board reload cannot
|
|
@@ -392,6 +392,25 @@ async function openSkillTab(sk) {
|
|
|
392
392
|
const ini = await import('./panel-initiatives.js');
|
|
393
393
|
ini.openTab({ id: 'skill:' + sk.id, kind: 'skill', title: sk.label || sk.skill || sk.id, skillId: sk.id, repo: state.product || null, slug: null, role: 'SKILL' });
|
|
394
394
|
}
|
|
395
|
+
/** The body of a conversation tab (panel 4): one recorded conversation, read-only. The text is the live session's, or
|
|
396
|
+
* the saved record once the session has ended; the line above it says where it is kept. */
|
|
397
|
+
export function mountTranscriptTab(host, tab) {
|
|
398
|
+
const r = read('conv:' + tab.convId, '/api/transcript?id=' + encodeURIComponent(tab.convId || ''));
|
|
399
|
+
const st = status(r, 'this conversation');
|
|
400
|
+
const kids = [];
|
|
401
|
+
if (st) kids.push(st);
|
|
402
|
+
else {
|
|
403
|
+
const d = r.data;
|
|
404
|
+
kids.push(el('div', { 'data-r16': 'transcript-head', style: 'padding:12px 14px 4px;font-size:11.5px;color:var(--muted-foreground)' },
|
|
405
|
+
iv((d.live ? 'live — ' : 'ended — ') + (d.name || d.id) + (d.initiative ? ' · initiative ' + d.initiative : '') + (d.cut ? ' · the start was cut (long session)' : '') + ' · ' + (d.stored || ''))));
|
|
406
|
+
kids.push(el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r16': 'transcript-refresh', onClick: () => { reload('conv:' + tab.convId); setState({}); } }, 'refresh'));
|
|
407
|
+
kids.push(el('pre', { 'data-r16': 'transcript-text', style: 'margin:0;padding:10px 14px 18px;font-family:var(--mono);font-size:11.5px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--foreground)' },
|
|
408
|
+
iv(d.text && d.text.trim() ? d.text : '(this conversation recorded no text)')));
|
|
409
|
+
}
|
|
410
|
+
setChildren(host, [el('div', { style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground)' }, kids)]);
|
|
411
|
+
return host;
|
|
412
|
+
}
|
|
413
|
+
|
|
395
414
|
/** The body of a skill tab (panel 4). Rebuilt on each paint, like the task tab; the draft lives in `skillDraft`. */
|
|
396
415
|
export function mountSkillTab(host, tab) {
|
|
397
416
|
const body = el('div', { style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground)' }, skillDetailLive({ kind: 'skill', id: tab.skillId, tab: true }));
|
|
@@ -989,6 +989,85 @@ function realSyncPanel(slug, a, key, dest) {
|
|
|
989
989
|
);
|
|
990
990
|
}
|
|
991
991
|
|
|
992
|
+
/* ── NEXT STEPS TO DONE (initiative-surface-batch R4) ─────────────────────────────────────────
|
|
993
|
+
* The whole remaining path, in order, from the ladder, the ledgers and the sessions — derived on the server
|
|
994
|
+
* (lib/next-steps.js), never written by a model. Each item names the fact it was read from; the first is
|
|
995
|
+
* marked. A stopped initiative says so, and an accepted one says nothing remains. */
|
|
996
|
+
function nextStepsBlock(rec) {
|
|
997
|
+
const ns = rec && rec.nextSteps;
|
|
998
|
+
if (FIXTURE || !ns) return null;
|
|
999
|
+
const TONE = { blocked: 'var(--destructive)', current: 'var(--brand)', pending: 'var(--muted-foreground)', unknown: 'var(--destructive)' };
|
|
1000
|
+
const rows = (ns.items || []).map((it) => el('div', { 'data-r16': 'next-item', 'data-first': it.first ? '1' : '0', 'data-kind': it.kind,
|
|
1001
|
+
style: 'display:grid;grid-template-columns:22px 1fr;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)' + (it.held ? ';opacity:.6' : '') },
|
|
1002
|
+
el('span', { style: 'font-family:var(--mono);font-size:11px;color:' + (TONE[it.status] || 'var(--muted-foreground)') }, iv(String(it.n))),
|
|
1003
|
+
el('div', { style: 'min-width:0;display:flex;flex-direction:column;gap:2px' },
|
|
1004
|
+
el('div', { style: 'display:flex;gap:7px;align-items:baseline;flex-wrap:wrap' },
|
|
1005
|
+
el('span', { style: 'font-size:12.5px;color:var(--foreground)' }, iv(it.text)),
|
|
1006
|
+
it.first ? el('span', { 'data-r16': 'next-first', style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--brand)' }, 'next') : null,
|
|
1007
|
+
it.status === 'blocked' ? el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--destructive)' }, 'blocked') : null,
|
|
1008
|
+
it.held ? el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'after reopening') : null),
|
|
1009
|
+
el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--foreground)' }, iv(it.action)),
|
|
1010
|
+
it.why && it.why !== it.action ? el('div', { style: 'font-size:11px;line-height:1.5;color:var(--muted-foreground)' }, iv(it.why)) : null,
|
|
1011
|
+
el('div', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv('from: ' + it.source)))));
|
|
1012
|
+
return el('div', { 'data-r16': 'next-steps', style: 'padding:8px 14px;border-top:1px solid var(--border)' },
|
|
1013
|
+
el('div', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground);padding-bottom:4px' }, 'next steps to done'),
|
|
1014
|
+
el('div', { 'data-r16': 'next-summary', style: 'font-size:12px;line-height:1.5;color:var(--foreground);padding-bottom:4px' }, iv(ns.summary || '')),
|
|
1015
|
+
rows);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/* Conversations and git files, listed with the document artifacts (R5, R6). A conversation opens a read-only
|
|
1019
|
+
* transcript tab; the row says where it is stored. A file read from git is marked as such and is not a link:
|
|
1020
|
+
* it is evidence of what changed, not a document the reader can open here. */
|
|
1021
|
+
function openConversation(slug, c) {
|
|
1022
|
+
const ini = iniModLazy;
|
|
1023
|
+
if (!ini || typeof ini.openTab !== 'function') return;
|
|
1024
|
+
ini.openTab({ id: 'conv:' + c.id, kind: 'transcript', title: c.name || c.id, convId: c.id, repo: repoOf(slug) || null, slug: slug || null, role: 'CONVERSATION' });
|
|
1025
|
+
}
|
|
1026
|
+
let iniModLazy = null;
|
|
1027
|
+
import('./panel-initiatives.js').then((m) => { iniModLazy = m; }).catch(() => { iniModLazy = null; });
|
|
1028
|
+
|
|
1029
|
+
function conversationsBlock(slug, rec) {
|
|
1030
|
+
if (FIXTURE) return null;
|
|
1031
|
+
const cs = rec.convs || [];
|
|
1032
|
+
const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
1033
|
+
const kids = cs.map((c) => el('button', { onClick: () => openConversation(slug, c), 'data-r16': 'conv-row', 'data-id': c.id,
|
|
1034
|
+
style: 'display:flex;gap:8px;align-items:center;width:100%;padding:4px 6px;margin:0 -6px;min-width:0;min-height:30px', hover: 'background:var(--accent)',
|
|
1035
|
+
title: 'a recorded conversation — read-only; kept in the runtime state directory on this machine, not in the repository' },
|
|
1036
|
+
el('span', { style: 'flex:none;width:52px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--info)' }, 'talk'),
|
|
1037
|
+
el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(c.name || c.id)),
|
|
1038
|
+
el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
|
|
1039
|
+
iv((c.live ? 'live' : c.state === 'exited' ? 'ended' : 'saved') + (c.saved ? ' · ' + String(c.saved).slice(0, 10) : '')))));
|
|
1040
|
+
return el('div', { 'data-r16': 'conversations', style: 'display:flex;flex-direction:column;gap:2px' },
|
|
1041
|
+
el('span', { style: LAB + ';padding-top:4px' }, 'conversations · ' + cs.length),
|
|
1042
|
+
cs.length ? kids : el('span', { 'data-r16': 'conv-empty', style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
|
|
1043
|
+
'none — no session is bound to this initiative. A session on its branch, or one you bind, is recorded here and kept after it ends.'),
|
|
1044
|
+
cs.length ? el('span', { style: 'font-size:10.5px;color:var(--muted-foreground)' }, 'stored in this machine’s runtime state directory — not in the repository, never sent anywhere') : null);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function gitFilesBlock(rec) {
|
|
1048
|
+
if (FIXTURE) return null;
|
|
1049
|
+
const f = rec.gitFiles;
|
|
1050
|
+
const LAB = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
1051
|
+
if (!f) return null;
|
|
1052
|
+
if (f.ok === false) {
|
|
1053
|
+
return el('div', { 'data-r16': 'git-files', style: 'display:flex;flex-direction:column;gap:2px' },
|
|
1054
|
+
el('span', { style: LAB + ';padding-top:4px' }, 'files from git'),
|
|
1055
|
+
el('span', { 'data-r16': 'git-error', style: 'font-size:11.5px;color:var(--destructive)' }, 'could not be read — ' + (f.reason || 'git did not answer')));
|
|
1056
|
+
}
|
|
1057
|
+
const groups = {};
|
|
1058
|
+
for (const it of f.items || []) (groups[it.group] = groups[it.group] || []).push(it);
|
|
1059
|
+
const order = ['code', 'tests', 'documents', 'other'].filter((g) => groups[g]);
|
|
1060
|
+
return el('div', { 'data-r16': 'git-files', style: 'display:flex;flex-direction:column;gap:2px' },
|
|
1061
|
+
el('span', { style: LAB + ';padding-top:4px' }, 'files from git · ' + (f.total || 0) + (f.via && f.via.length ? ' · via ' + f.via.join(', ') : '')),
|
|
1062
|
+
order.length ? order.map((g) => el('div', { style: 'display:flex;flex-direction:column;gap:1px' },
|
|
1063
|
+
el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--info);padding-top:2px' }, g + ' · ' + groups[g].length),
|
|
1064
|
+
groups[g].map((it) => el('span', { 'data-r16': 'git-file', title: it.commits + ' commit' + (it.commits === 1 ? '' : 's') + ', last ' + it.last,
|
|
1065
|
+
style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(it.path)))))
|
|
1066
|
+
: el('span', { 'data-r16': 'git-empty', style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
|
|
1067
|
+
'none — git shows no commit naming this initiative and no initiative/<id> branch with changes'),
|
|
1068
|
+
f.truncated ? el('span', { style: 'font-size:10.5px;color:var(--muted-foreground)' }, 'showing the first 200 of ' + f.total) : null);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
992
1071
|
function artifactsRow(slug, rec) {
|
|
993
1072
|
const arts = rec.arts || [];
|
|
994
1073
|
return el('div', { style: 'display:grid;grid-template-columns:76px 1fr;gap:10px;padding:8px 14px' },
|
|
@@ -998,6 +1077,8 @@ function artifactsRow(slug, rec) {
|
|
|
998
1077
|
arts.length === 0
|
|
999
1078
|
? el('span', { style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' }, 'none filed')
|
|
1000
1079
|
: null,
|
|
1080
|
+
conversationsBlock(slug, rec),
|
|
1081
|
+
gitFilesBlock(rec),
|
|
1001
1082
|
),
|
|
1002
1083
|
);
|
|
1003
1084
|
}
|
|
@@ -1050,6 +1131,9 @@ function initBody(slug) {
|
|
|
1050
1131
|
const sess = sessionsBlock(slug);
|
|
1051
1132
|
if (sess) out.push(sess);
|
|
1052
1133
|
|
|
1134
|
+
const nextB = nextStepsBlock(rec);
|
|
1135
|
+
if (nextB) out.push(nextB);
|
|
1136
|
+
|
|
1053
1137
|
const phases = phaseBlock(slug, rec);
|
|
1054
1138
|
if (phases) out.push(phases);
|
|
1055
1139
|
|
|
@@ -828,7 +828,43 @@ export function renderInitiatives(host) {
|
|
|
828
828
|
|
|
829
829
|
const hits = searchHits(q, DATA, live);
|
|
830
830
|
|
|
831
|
-
|
|
831
|
+
/* GROUP BY STATUS (initiative-surface-batch R2). The status is the one the server DERIVES (deriveState + the
|
|
832
|
+
* lifecycle ledger), mapped to the PM's words by lib/status-groups.js; nothing typed in a record decides where a
|
|
833
|
+
* row lands. A failed read of the statuses is said under the control and everything files under Unknown. */
|
|
834
|
+
const SG = typeof window !== 'undefined' ? window.PMOSStatusGroups : null;
|
|
835
|
+
const byStatus = state.groupBy === 'status' && !!SG;
|
|
836
|
+
const statusRows = (state.statuses && state.statuses[product]) || {};
|
|
837
|
+
const statusErr = (state.statusErrors && state.statusErrors[product]) || null;
|
|
838
|
+
const STATUS_TONE = { 'needs-you': 'var(--destructive)', 'in-progress': 'var(--brand)', planning: 'var(--muted-foreground)',
|
|
839
|
+
'awaiting-acceptance': 'var(--foreground)', accepted: 'var(--muted-foreground)', 'on-hold': 'var(--muted-foreground)',
|
|
840
|
+
closed: 'var(--muted-foreground)', cancelled: 'var(--muted-foreground)', unknown: 'var(--destructive)' };
|
|
841
|
+
if (byStatus) {
|
|
842
|
+
const seen = new Set(); const all = [];
|
|
843
|
+
for (const g of groupsFor(product)) for (const sl of g.slugs) if (!seen.has(sl)) { seen.add(sl); all.push(sl); }
|
|
844
|
+
const shown = all.filter((sl) => !q || sl.includes(q));
|
|
845
|
+
// An active search shows every match whatever its status: the "active" filter must not hide a hit.
|
|
846
|
+
const sgs = SG.group(shown, statusRows, { activeOnly: !!state.activeOnly && !q });
|
|
847
|
+
total = 0;
|
|
848
|
+
groups.length = 0;
|
|
849
|
+
for (const sg of sgs) {
|
|
850
|
+
const inits = sg.slugs.map((sl) => rowOf(sl, null));
|
|
851
|
+
total += inits.length;
|
|
852
|
+
groups.push({ gid: 'status:' + sg.key, idc: STATUS_TONE[sg.key] || 'var(--muted-foreground)', gname: sg.label, gct: String(inits.length),
|
|
853
|
+
hasKr: true, krid: '●', krname: sg.hint, krct: String(inits.length), slugs: sg.slugs, inits, isEmpty: false });
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const CHIP = 'font-family:var(--mono);font-size:10.5px;padding:2px 8px;border:1px solid var(--border);min-height:24px';
|
|
857
|
+
const chip = (label, on, onClick, attr) => el('button', { onClick, 'data-r16': attr,
|
|
858
|
+
style: CHIP + ';' + (on ? 'color:var(--foreground);background:var(--secondary);border-color:var(--input)' : 'color:var(--muted-foreground)'),
|
|
859
|
+
hover: 'color:var(--foreground)', 'aria-pressed': on ? 'true' : 'false' }, label);
|
|
860
|
+
const groupBar = FIXTURE ? null : el('div', { 'data-r16': 'group-bar', style: 'display:flex;gap:5px;flex-wrap:wrap;align-items:center;padding:4px 12px 6px' },
|
|
861
|
+
el('span', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted-foreground)' }, 'group by'),
|
|
862
|
+
chip('objective', !byStatus, () => setState({ groupBy: 'objective' }), 'by-objective'),
|
|
863
|
+
chip('status', byStatus, () => setState({ groupBy: 'status' }), 'by-status'),
|
|
864
|
+
byStatus ? chip('active only', !!state.activeOnly, () => setState({ activeOnly: !state.activeOnly }), 'active-only') : null,
|
|
865
|
+
byStatus && statusErr ? el('span', { 'data-r16': 'status-error', style: 'flex-basis:100%;font-size:10.5px;color:var(--destructive)' }, 'status could not be read — ' + statusErr + ' (every initiative is under Unknown)') : null);
|
|
866
|
+
|
|
867
|
+
const kids = [header, searchBox, groupBar];
|
|
832
868
|
/* THE WORKING SET, pinned above the Objectives.
|
|
833
869
|
*
|
|
834
870
|
* It is `groupNode` — the same element, the same collapse key, the same forced-open-on-search
|
|
@@ -42,7 +42,7 @@ import { boardOf, currentProduct } from './board-read.js';
|
|
|
42
42
|
let termMod = null;
|
|
43
43
|
let liveMod = null;
|
|
44
44
|
/* Loaded lazily; a repaint is asked for only when a skill tab is already open (restored on reload) and waiting for it. */
|
|
45
|
-
import('./modes-live.js').then((m) => { liveMod = m; if ((state.tabs || []).some((t) => t.kind === 'skill')) setState({}); }).catch(() => { liveMod = null; });
|
|
45
|
+
import('./modes-live.js').then((m) => { liveMod = m; if ((state.tabs || []).some((t) => t.kind === 'skill' || t.kind === 'transcript')) setState({}); }).catch(() => { liveMod = null; });
|
|
46
46
|
import('./tab-term.js')
|
|
47
47
|
.then((m) => { termMod = m; setState({}); })
|
|
48
48
|
.catch(() => { termMod = null; });
|
|
@@ -144,6 +144,40 @@ export function closeTab(id) {
|
|
|
144
144
|
});
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The terminal tabs attached to ONE session, by tab id (initiative-surface-batch R1).
|
|
149
|
+
*
|
|
150
|
+
* A terminal tab carries the SERVER's session id (`sessionId`), which is the exact join. Read it BEFORE the
|
|
151
|
+
* server is asked to end the session, not after: the moment the server drops the session it pushes the new
|
|
152
|
+
* list, and a tab whose id is no longer listed re-joins by NAME (tab-term.js `resolveSessionId`) — so when two
|
|
153
|
+
* sessions share a name the doomed tab quietly adopts its sibling and starts reporting the sibling's id, and a
|
|
154
|
+
* lookup made after the answer finds nothing to close.
|
|
155
|
+
*/
|
|
156
|
+
export function tabIdsOfSession(sessionId) {
|
|
157
|
+
if (!sessionId) return [];
|
|
158
|
+
return (state.tabs || []).filter((t) => t && t.kind === 'term' && t.sessionId === sessionId).map((t) => t.id);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Close tabs by id. Called only after the server said the session is gone; closing a tab by hand still only
|
|
163
|
+
* detaches. Another session's tab, and a tab that is not in `ids`, are untouched. Returns how many closed.
|
|
164
|
+
*/
|
|
165
|
+
export function closeTabsById(ids) {
|
|
166
|
+
const doomed = (state.tabs || []).filter((t) => t && (ids || []).includes(t.id)).map((t) => t.id);
|
|
167
|
+
if (!doomed.length) return 0;
|
|
168
|
+
setState((s) => {
|
|
169
|
+
const rest = s.tabs.filter((t) => !doomed.includes(t.id));
|
|
170
|
+
return {
|
|
171
|
+
tabs: rest,
|
|
172
|
+
active: doomed.includes(s.active) ? (rest.length ? rest[rest.length - 1].id : null) : s.active,
|
|
173
|
+
splitId: doomed.includes(s.splitId) ? null : s.splitId,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
return doomed.length;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const closeTabsOfSession = (sessionId) => closeTabsById(tabIdsOfSession(sessionId));
|
|
180
|
+
|
|
147
181
|
/* The data layer's optional session-start hook. In fixture mode nothing is ever started; in real
|
|
148
182
|
* mode the PTY is data.js's to create, and panel 4's part is only to show the tab for it. */
|
|
149
183
|
let dataMod = null;
|
|
@@ -797,6 +831,8 @@ function paneFor(tab) {
|
|
|
797
831
|
mountTask(host, tab);
|
|
798
832
|
} else if (tab.kind === 'skill') {
|
|
799
833
|
if (liveMod && typeof liveMod.mountSkillTab === 'function') liveMod.mountSkillTab(host, tab);
|
|
834
|
+
} else if (tab.kind === 'transcript') {
|
|
835
|
+
if (liveMod && typeof liveMod.mountTranscriptTab === 'function') liveMod.mountTranscriptTab(host, tab);
|
|
800
836
|
} else if (tab.kind === 'web') {
|
|
801
837
|
mountWeb(host, tab); // overhaul-r5 — never falls through to an empty doc tab
|
|
802
838
|
} else {
|
|
@@ -913,7 +949,7 @@ function tabNode(t, act, splitTab) {
|
|
|
913
949
|
* fixture render keeps the design's literal — the comparator reads this text node. */
|
|
914
950
|
const meta = t.kind === 'term'
|
|
915
951
|
? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
|
|
916
|
-
: (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.role);
|
|
952
|
+
: (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
|
|
917
953
|
|
|
918
954
|
return el('div', {
|
|
919
955
|
style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
|
|
@@ -23,10 +23,11 @@ const MONO = 'font-family:var(--mono);font-size:10.5px';
|
|
|
23
23
|
const BTN = MONO + ';padding:2px 8px;border:1px solid var(--border);color:var(--muted-foreground);min-height:24px';
|
|
24
24
|
const MUTED = 'color:var(--muted-foreground)';
|
|
25
25
|
|
|
26
|
-
let dataMod = null; let termMod = null; let iniMod = null;
|
|
26
|
+
let dataMod = null; let termMod = null; let iniMod = null; let tabsMod = null;
|
|
27
27
|
import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
|
|
28
28
|
import('./tab-term.js').then((m) => { termMod = m; }).catch(() => { termMod = null; });
|
|
29
29
|
import('./panel-initiatives.js').then((m) => { iniMod = m; }).catch(() => { iniMod = null; });
|
|
30
|
+
import('./panel-tabs.js').then((m) => { tabsMod = m; }).catch(() => { tabsMod = null; });
|
|
30
31
|
|
|
31
32
|
const note = (name, text, tone) => setState({ treeNote: { name, text, tone: tone || null } });
|
|
32
33
|
const refresh = () => (dataMod && dataMod.refreshTrees ? dataMod.refreshTrees() : Promise.resolve());
|
|
@@ -62,10 +63,16 @@ export async function startHere(name, t) {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
async function endSession(name, s) {
|
|
66
|
+
// Which tabs are on this session, read BEFORE asking: once the server drops it, a tab re-joins by name and stops saying.
|
|
67
|
+
const tabIds = tabsMod && typeof tabsMod.tabIdsOfSession === 'function' ? tabsMod.tabIdsOfSession(s.id) : [];
|
|
65
68
|
const out = dataMod ? await dataMod.removeSessionById(s.id) : null;
|
|
66
69
|
setState({ treeConfirm: null });
|
|
67
70
|
if (!out || out.ok === false) { note(name, (out && out.error) || 'the session could not be removed', 'bad'); return; }
|
|
68
|
-
|
|
71
|
+
// The session is gone: the tab attached to it has nothing left to show, so it closes too (R1). A refusal returned
|
|
72
|
+
// above and leaves the tab open beside the reason; so does "asked it to end, not exited yet" — the process is still
|
|
73
|
+
// there, and pressing again removes it and closes the tab.
|
|
74
|
+
const closed = out.removed !== false && tabsMod && typeof tabsMod.closeTabsById === 'function' ? tabsMod.closeTabsById(tabIds) : 0;
|
|
75
|
+
note(name, (out.removed === false ? (out.note || 'asked it to end') : 'session removed') + (closed ? ' — its tab was closed' : ''));
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
async function removeTree(name, t) {
|