create-pmos 0.213.0 → 0.215.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/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/c3-ib-more-test.js +23 -4
- package/workspace/c3-sessions-panel-test.js +69 -11
- package/workspace/c3-work-test.js +2 -2
- package/workspace/guide/console3-sessions.md +33 -9
- package/workspace/guide/jira.md +13 -1
- package/workspace/guide/my-work.md +4 -1
- package/workspace/lib/day-run.js +30 -1
- package/workspace/lib/engine/boards.js +51 -7
- package/workspace/lib/jira-status.js +46 -1
- package/workspace/lib/my-work.js +1 -1
- package/workspace/lib/proactive-cli.js +8 -2
- package/workspace/lib/routes-my-work.js +44 -17
- package/workspace/lib/routes-pm-items.js +8 -0
- package/workspace/lib/transcript-read.js +93 -10
- package/workspace/lib/work-assigned.js +23 -11
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-boards.js +12 -3
- package/workspace/public/pm/c3-ib-sprints.js +142 -39
- package/workspace/public/pm/c3-initiatives.css +18 -0
- package/workspace/public/pm/c3-session-conv.js +164 -0
- package/workspace/public/pm/c3-session-term.js +63 -16
- package/workspace/public/pm/c3-settings.js +1 -1
- package/workspace/public/pm/console3.css +62 -0
- package/workspace/public/pm/pm-work-view.js +37 -11
- package/workspace/public/pm/tab-term.js +9 -0
- package/workspace/server.js +32 -12
- package/workspace/skills/day-task.skill +1 -0
- package/workspace/skills/jira-sync.skill +9 -3
- package/workspace/test-files.json +1 -1
|
@@ -1,43 +1,116 @@
|
|
|
1
1
|
/* pm/c3-ib-sprints.js — the Sprints tab of a board with Build and deliver on (mockup v5, 34-initiatives.js tabSprints; the sprint goal
|
|
2
|
-
* and team summary from mockup-c3-sprint-goal v3, initiative sprint-goal-summary).
|
|
2
|
+
* and team summary from mockup-c3-sprint-goal v3; the sprint brief from mockup-c3-sprint-brief v3, initiative sprint-goal-summary).
|
|
3
3
|
* registerBoardTab('sprints', (host, ctx) => ...)
|
|
4
|
-
* Reads GET /api/boards/sprints { source: jira|none, readAt,
|
|
5
|
-
* status, category: todo|doing|done, initiative }], counts, goal: { from: 'tickets', text,
|
|
6
|
-
* themes: [{ title, line, tickets, done, blocked, people, initiative }],
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
4
|
+
* Reads GET /api/boards/sprints { source: jira|none, readAt, velocity: [{ name, committed, done }], sprints: [{ name, state: active|future|closed,
|
|
5
|
+
* start, end, issues: [{ key, summary, status, category: todo|doing|done, initiative, flagged, points }], counts, goal: { from: 'tickets', text,
|
|
6
|
+
* jira } | null, jiraGoal?, summary, themes: [{ title, line, tickets, done, blocked, people, initiative }], brief: { health, verdict,
|
|
7
|
+
* issues: [{ sev, title, keys, initiative, why, by, plan, doneWhen }], done: [{ title, keys, initiative, line, waiting }], asks } | null,
|
|
8
|
+
* points: { committed, done, unestimated } | null, flow: [{ status, category, n }], pace: { day, days, daysLeft, avg, hitRate, forecast }
|
|
9
|
+
* (active only), writtenAt, closed: { done, total, carried, carriedTo, points } | null, hit, got, frozen }] }.
|
|
10
|
+
* READ-ONLY: the sprints mirror Jira, so there is no create, edit or move control. "Add to my Work" files a note in the PM's Work inbox
|
|
11
|
+
* (POST /api/capture); nothing is ever written to Jira.
|
|
12
|
+
* Left: the active sprint's brief (the goal, always written from the tickets; where the sprint stands; the issues to resolve, each with
|
|
13
|
+
* the agent's proposed plan; what is done; where the tickets are; what the team is working on; the asks for the PM), its tickets
|
|
14
|
+
* behind "Show all", then the next sprint's goal and summary. Right: the active sprint's card with points and the forecast, the
|
|
15
|
+
* velocity of the last four closed sprints, the burndown from GET /api/stage-tools/burndown, the past sprints (each opens in a
|
|
16
|
+
* sheet; closed sprints are kept out of the page, Wawan 2026-10-14) and how sprints are read. source 'none' says there is no tracker. */
|
|
13
17
|
import { el } from './dom.js';
|
|
14
18
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
15
|
-
import { icon, lk, chip, tag, st,
|
|
19
|
+
import { icon, lk, chip, tag, st, lbl, sect, card, list, empty, nrow, cols, kv, jiraLabel, day, put, plural, pbtn, geo, healthSt, note } from './c3-ini-ui.js';
|
|
16
20
|
import { loadItems } from './c3-ini-work.js';
|
|
17
21
|
import { recordCards } from './c3-ini-records.js';
|
|
18
22
|
|
|
19
23
|
const CAT = { todo: 'To do', doing: 'In progress', done: 'Done' };
|
|
20
24
|
const readTime = (s) => { const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(String(s || '')); return m ? day(m[1]) + ', ' + m[2] : ''; };
|
|
21
25
|
const timeOf = (s) => { const m = /T(\d{2}:\d{2})/.exec(String(s || '')); return m ? m[1] : ''; };
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
const num = (v) => (Number.isInteger(v) ? String(v) : String(Math.round(v * 10) / 10));
|
|
27
|
+
/* Quiet pages (c3-quiet.css) hide .ini-note, .ini-lhm .dim and a row's third chip. The goal's reason, the theme chips, the plans and
|
|
28
|
+
* the read-once note are the content of this tab, so they use their own .ini-sp-* classes. */
|
|
29
|
+
const spNote = (text, attrs) => el('div', { class: 'dim ini-sp-note', ...(attrs || {}) }, text);
|
|
24
30
|
/* The goal is always written from the sprint's tickets: Jira's sprint goal is often empty, too broad or incomplete (the PM,
|
|
25
31
|
* 2026-10-14). Jira's own text shows underneath as a reference. */
|
|
26
|
-
const jiraRef = (j) => (j ?
|
|
32
|
+
const jiraRef = (j) => (j ? spNote('In Jira: “' + j + '”', { 'data-inb': 'sprint-goal-jira' }) : null);
|
|
27
33
|
function goalBlock(g, jiraGoal) {
|
|
28
34
|
if (!g) return el('div', { class: 'lh', 'data-inb': 'sprint-goal', 'data-inb-goal': 'missing' }, el('span', { class: 'ki' }, icon('target')),
|
|
29
|
-
el('div', { class: 'ini-sp-goal' }, lbl('SPRINT GOAL'),
|
|
35
|
+
el('div', { class: 'ini-sp-goal' }, lbl('SPRINT GOAL'), spNote('No goal yet. The Jira status check writes one from the tickets at its next check.'), jiraRef(jiraGoal)));
|
|
30
36
|
return el('div', { class: 'lh', 'data-inb': 'sprint-goal', 'data-inb-goal': 'tickets' }, el('span', { class: 'ki' }, icon('target')),
|
|
31
37
|
el('div', { class: 'ini-sp-goal' }, lbl('SPRINT GOAL · from the tickets'), el('b', null, g.text), jiraRef(g.jira)));
|
|
32
38
|
}
|
|
39
|
+
const iniChip = (id, ctx, attr) => (id ? lk({ onclick: () => ctx.openInitiative(id), [attr]: id }, icon('init', 'sm'), id) : el('span', { class: 'chip2 dim' }, 'No initiative'));
|
|
33
40
|
const themeRow = (t, ctx) => nrow({ ic: t.blocked ? 'alert' : t.tickets && t.done === t.tickets ? 'check' : t.initiative ? 'bolt' : 'task', hot: !t.blocked && t.done < t.tickets && !!t.initiative,
|
|
34
41
|
title: t.title, attrs: { 'data-inb-theme': t.initiative || 'none' },
|
|
35
|
-
after: el('div', null, t.line ?
|
|
36
|
-
t.people ? chip(icon('user', 'sm'), t.people) : null,
|
|
37
|
-
|
|
38
|
-
const summaryBody = (sp, ctx) => [goalBlock(sp.goal, sp.jiraGoal), sp.summary ? el('p', { class: 'ini-sp-sum' }, sp.summary) : null,
|
|
39
|
-
sp.themes && sp.themes.length ? [lbl('WHAT THE TEAM IS WORKING ON'), list(sp.themes.map((t) => themeRow(t, ctx)), { 'data-inb': 'sprint-themes' })] : null];
|
|
42
|
+
after: el('div', null, t.line ? spNote(t.line) : null, el('div', { class: 'chips ini-sp-chips' }, ...[chip(plural(t.tickets, 'ticket')), t.done ? st('ok', 'check', t.done + ' done') : null, t.blocked ? st('warn', 'alert', t.blocked + ' blocked') : null,
|
|
43
|
+
t.people ? chip(icon('user', 'sm'), t.people) : null, iniChip(t.initiative, ctx, 'data-inb-theme-ini')].filter(Boolean))) });
|
|
44
|
+
const themesBlock = (sp, ctx) => (sp.themes && sp.themes.length ? [lbl('WHAT THE TEAM IS WORKING ON'), list(sp.themes.map((t) => themeRow(t, ctx)), { 'data-inb': 'sprint-themes' })] : []);
|
|
40
45
|
const countsOf = (sp) => { const c = sp.counts || {}; const f = (k) => (c[k] != null ? Number(c[k]) : (sp.issues || []).filter((i) => i.category === k).length); return { todo: f('todo'), doing: f('doing'), done: f('done') }; };
|
|
46
|
+
const dates = (s) => (s.start && s.end ? day(s.start) + ' to ' + day(s.end) : '');
|
|
47
|
+
|
|
48
|
+
/* ── the sprint brief: PMOS counts the numbers, the agent writes the words ── */
|
|
49
|
+
function verdictBlock(sp) {
|
|
50
|
+
const p = sp.points; const pace = sp.pace || {};
|
|
51
|
+
const blocked = (sp.issues || []).filter((i) => i.flagged).length;
|
|
52
|
+
const facts = [
|
|
53
|
+
pace.day && pace.days ? chip(icon('cal', 'sm'), 'Day ' + pace.day + ' of ' + pace.days + ' · ' + Math.round(pace.day / pace.days * 100) + '% of the time') : null,
|
|
54
|
+
p ? chip(num(p.done) + ' of ' + num(p.committed) + ' points · ' + (p.committed ? Math.round(p.done / p.committed * 100) : 0) + '%') : null,
|
|
55
|
+
p && p.unestimated ? chip(plural(p.unestimated, 'ticket') + ' with no estimate') : null,
|
|
56
|
+
blocked ? st('bad', 'alert', blocked + ' blocked') : null,
|
|
57
|
+
].filter(Boolean);
|
|
58
|
+
return el('div', { class: 'lh ini-sp-verdict', 'data-inb': 'sprint-verdict', 'data-inb-health': sp.brief.health },
|
|
59
|
+
el('span', { class: 'ki' + (sp.brief.health === 'ok' ? '' : ' hot') }, icon(sp.brief.health === 'ok' ? 'check' : 'alert')),
|
|
60
|
+
el('div', { class: 'ini-sp-goal' }, lbl('WHERE THE SPRINT STANDS'), el('div', null, sp.brief.verdict), facts.length ? el('div', { class: 'chips ini-sp-chips' }, ...facts) : null));
|
|
61
|
+
}
|
|
62
|
+
function addToWork(ctx, x) {
|
|
63
|
+
const text = 'Sprint issue: ' + x.title + (x.keys ? ' (' + x.keys + ')' : '') + (x.plan.length ? '. Next: ' + x.plan[0] : '') + (x.doneWhen ? ' Done when: ' + x.doneWhen : '');
|
|
64
|
+
if (ctx.demo) { ctx.toast('Added to your Work inbox: ' + x.title); return; }
|
|
65
|
+
ctx.api.post('/api/capture', { ws: ctx.ws, text }).then((r) => ctx.toast(r && r.ok !== false ? 'Added to your Work inbox. PMOS sorts it.' : { text: 'That was not added' + (r && r.error ? ': ' + r.error : '.'), tone: 'error' }));
|
|
66
|
+
}
|
|
67
|
+
const issueRow = (x, ctx) => nrow({ ic: x.sev === 'bad' ? 'alert' : 'clock', hot: x.sev === 'bad', title: x.title, attrs: { 'data-inb-sp-issue': x.sev },
|
|
68
|
+
after: el('div', null, x.why ? spNote(x.why) : null,
|
|
69
|
+
el('div', { class: 'chips ini-sp-chips' }, ...[x.sev === 'bad' ? st('bad', 'alert', 'Blocked') : st('warn', 'alert', 'Watch'), x.keys ? el('span', { class: 'chip2 mono' }, x.keys) : null,
|
|
70
|
+
iniChip(x.initiative, ctx, 'data-inb-sp-issue-ini'), x.by ? chip(icon('clock', 'sm'), x.by) : null].filter(Boolean)),
|
|
71
|
+
el('div', { class: 'ini-sp-plan', 'data-inb': 'sprint-plan' }, lbl('PROPOSED PLAN'), el('ol', null, ...x.plan.map((t) => el('li', null, t))),
|
|
72
|
+
x.doneWhen ? spNote('Done when: ' + x.doneWhen) : null,
|
|
73
|
+
el('div', { class: 'chips ini-sp-chips' }, pbtn('outline', 'Add to my Work', 'plus', { onclick: () => addToWork(ctx, x), 'data-inb-sp-addwork': x.title })))) });
|
|
74
|
+
const doneRow = (d, ctx) => nrow({ ic: 'check', title: d.title, attrs: { 'data-inb-sp-done': d.waiting ? 'waiting' : 'done' },
|
|
75
|
+
after: el('div', null, d.line ? spNote(d.line) : null, el('div', { class: 'chips ini-sp-chips' }, ...[d.waiting ? st('warn', 'clock', 'Not in Prod yet') : st('ok', 'check', 'Done'),
|
|
76
|
+
d.keys ? el('span', { class: 'chip2 mono' }, d.keys) : null, iniChip(d.initiative, ctx, 'data-inb-sp-done-ini')].filter(Boolean))) });
|
|
77
|
+
const FLOW_TONE = { todo: 'todo', doing: 'doing', done: 'done' };
|
|
78
|
+
function flowBlock(sp) {
|
|
79
|
+
const f = sp.flow || [];
|
|
80
|
+
if (!f.length) return [];
|
|
81
|
+
const label = f.map((x) => x.n + ' ' + x.status).join(', ');
|
|
82
|
+
return [lbl('WHERE THE TICKETS ARE'),
|
|
83
|
+
el('div', { class: 'ini-sp-flow', role: 'img', 'aria-label': label, 'data-inb': 'sprint-flow' }, ...f.map((x) => geo(el('u', { class: 'ini-sp-f-' + FLOW_TONE[x.category], title: x.n + ' ' + x.status }), { '--sp-n': x.n }))),
|
|
84
|
+
el('div', { class: 'chips ini-sp-chips' }, ...f.map((x) => chip(el('i', { class: 'ini-sp-dot ini-sp-f-' + FLOW_TONE[x.category], 'aria-hidden': 'true' }), x.n + ' ' + x.status.toLowerCase())))];
|
|
85
|
+
}
|
|
86
|
+
function briefBody(sp, ctx) {
|
|
87
|
+
const b = sp.brief;
|
|
88
|
+
if (!b) return [goalBlock(sp.goal, sp.jiraGoal), sp.summary ? el('p', { class: 'ini-sp-sum' }, sp.summary) : null, ...flowBlock(sp), ...themesBlock(sp, ctx)];
|
|
89
|
+
return [goalBlock(sp.goal, sp.jiraGoal), verdictBlock(sp),
|
|
90
|
+
b.issues.length ? [lbl('ISSUES TO RESOLVE · and how to close them'), list(b.issues.map((x) => issueRow(x, ctx)), { 'data-inb': 'sprint-issues' })] : spNote('No issue to resolve.', { 'data-inb': 'sprint-no-issues-to-resolve' }),
|
|
91
|
+
b.done.length ? [lbl('COMPLETED SO FAR'), list(b.done.map((d) => doneRow(d, ctx)), { 'data-inb': 'sprint-done' })] : null,
|
|
92
|
+
...flowBlock(sp), ...themesBlock(sp, ctx),
|
|
93
|
+
b.asks.length ? [lbl('FOR YOU'), list(b.asks.map((a, k) => nrow({ ic: k ? 'next' : 'target', hot: !k, title: a })), { 'data-inb': 'sprint-asks' })] : null];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ── velocity: the last four closed sprints, committed against done, and this sprint so far ── */
|
|
97
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
98
|
+
const svg = (tag, attrs, text) => { const n = document.createElementNS(SVGNS, tag); for (const [k, v] of Object.entries(attrs || {})) n.setAttribute(k, String(v)); if (text != null) n.textContent = text; return n; };
|
|
99
|
+
function velocitySvg(vel, active) {
|
|
100
|
+
const rows = [...vel.map((v) => ({ n: v.name.replace(/^.*?(\d+)\s*$/, 'S$1'), c: v.committed, d: v.done })), ...(active && active.points ? [{ n: 'now', c: active.points.committed, d: active.points.done, now: true }] : [])];
|
|
101
|
+
const W = 360; const H = 150; const max = Math.max(1, ...rows.map((r) => r.c)) * 1.1;
|
|
102
|
+
const avg = vel.reduce((a, v) => a + v.done, 0) / vel.length; const bw = (W - 50) / rows.length; const y = (v) => 10 + (1 - v / max) * (H - 32);
|
|
103
|
+
const s = svg('svg', { class: 'ini-vel', viewBox: '0 0 ' + W + ' ' + H, role: 'img', 'aria-label': 'Velocity: ' + num(avg) + ' points done a sprint on average' });
|
|
104
|
+
s.append(svg('line', { class: 'ax', x1: 34, y1: y(0), x2: W - 10, y2: y(0) }));
|
|
105
|
+
rows.forEach((r, k) => {
|
|
106
|
+
const x = 36 + k * bw + bw * 0.18; const w = bw * 0.64;
|
|
107
|
+
s.append(svg('rect', { class: 'com' + (r.now ? ' now' : ''), x, y: y(r.c), width: w, height: Math.max(0, y(0) - y(r.c)), rx: 2 }),
|
|
108
|
+
svg('rect', { class: 'done' + (r.now ? ' now' : ''), x, y: y(r.d), width: w, height: Math.max(0, y(0) - y(r.d)), rx: 2 }),
|
|
109
|
+
svg('text', { class: 'v', x: x + w / 2, y: y(r.d) - 4, 'text-anchor': 'middle' }, num(r.d)), svg('text', { x: x + w / 2, y: H - 6, 'text-anchor': 'middle' }, r.n));
|
|
110
|
+
});
|
|
111
|
+
s.append(svg('line', { class: 'avg', x1: 34, y1: y(avg), x2: W - 10, y2: y(avg) }), svg('text', { x: 30, y: y(avg) + 3, 'text-anchor': 'end' }, num(avg)));
|
|
112
|
+
return s;
|
|
113
|
+
}
|
|
41
114
|
|
|
42
115
|
registerBoardTab('sprints', async (host, ctx) => {
|
|
43
116
|
let dead = false;
|
|
@@ -48,49 +121,79 @@ registerBoardTab('sprints', async (host, ctx) => {
|
|
|
48
121
|
if (!r || !r.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The sprints could not be read: ' + ((r && r.error) || 'no answer') + '.')); return () => { dead = true; }; }
|
|
49
122
|
if (r.source === 'none') { put(host, empty('Waiting for the first sync from Jira ' + jiraLabel(b) + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
|
|
50
123
|
const sprints = r.sprints || [];
|
|
124
|
+
const vel = Array.isArray(r.velocity) ? r.velocity : [];
|
|
51
125
|
const active = sprints.find((s) => s.state === 'active');
|
|
52
|
-
const
|
|
53
|
-
const
|
|
126
|
+
const future = sprints.filter((s) => s.state !== 'closed' && s !== active);
|
|
127
|
+
const past = sprints.filter((s) => s.state === 'closed');
|
|
128
|
+
const ticketRow = (i) => nrow({ ic: i.category === 'done' ? 'check' : i.category === 'doing' ? 'bolt' : 'todo', hot: i.category === 'doing', title: i.summary || i.key,
|
|
54
129
|
chips: [el('span', { class: 'chip2 mono' }, i.key), tag(icon('jira', 'sm'), 'Jira'), i.category === 'done' ? st('ok', 'check', i.status || 'Done') : chip(i.status || CAT[i.category] || ''),
|
|
55
130
|
i.initiative ? lk({ onclick: () => ctx.openInitiative(i.initiative), 'data-inb-issue-ini': i.initiative }, icon('init', 'sm'), i.initiative) : el('span', { class: 'dim' }, 'No initiative')],
|
|
56
131
|
right: [el('span', { class: 'chip2', title: 'Status lives in Jira' }, icon('lock', 'sm'))], attrs: { 'data-inb-issue': i.key } });
|
|
57
132
|
const t = readTime(r.readAt);
|
|
58
|
-
const
|
|
59
|
-
|
|
133
|
+
const itemsRow = (s) => { const c = countsOf(s); return nrow({ ic: 'clock', title: c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do', xp: (s.issues || []).length ? [list(s.issues.map(ticketRow))] : [note('No items.')] }); };
|
|
134
|
+
/* the tickets stay behind a press: the brief says what matters, the list is there when it is needed (Wawan, 2026-10-14) */
|
|
135
|
+
function ticketsSect(s) {
|
|
136
|
+
const n = (s.issues || []).length; const c = countsOf(s);
|
|
137
|
+
const hold = el('div', { 'data-inb': 'sprint-ticket-list', hidden: true }, n ? list(s.issues.map(ticketRow)) : empty('No items in this sprint yet.', { 'data-inb': 'sprint-no-issues' }));
|
|
138
|
+
const show = pbtn('outline', 'Show all ' + plural(n, 'ticket'), 'ul', { 'data-inb': 'sprint-show-tickets', 'aria-expanded': 'false', onclick: () => {
|
|
139
|
+
hold.hidden = !hold.hidden; show.setAttribute('aria-expanded', String(!hold.hidden));
|
|
140
|
+
const w = document.createTreeWalker(show, NodeFilter.SHOW_TEXT); let last = null; while (w.nextNode()) last = w.currentNode; if (last) last.textContent = (hold.hidden ? 'Show all ' : 'Hide ') + plural(n, 'ticket');
|
|
141
|
+
} });
|
|
142
|
+
return sect({ ic: 'check', title: s.name + ' tickets', count: n, right: el('span', { class: 'chip2', 'data-inb': 'sprints-source', title: 'Mirrored from Jira ' + jiraLabel(b) + '. Every issue in the sprint, whoever it is assigned to. Change sprints in Jira.' }, icon('lock', 'sm'), 'Jira, read-only' + (t ? ' · read ' + t : '')), attrs: { 'data-inb-sprint': s.name, 'data-inb-state': 'active' } },
|
|
143
|
+
el('div', { class: 'chips ini-sp-chips' }, show, spNote(c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do.')), hold);
|
|
144
|
+
}
|
|
60
145
|
const closedSect = (s) => {
|
|
61
146
|
const c = countsOf(s); const x = s.closed || {}; const n = x.total || (s.issues || []).length; const done = x.total ? x.done : c.done;
|
|
62
147
|
return sect({ ic: 'check', title: s.name, count: n, right: tag('closed'), attrs: { 'data-inb-sprint': s.name, 'data-inb-state': 'closed' } },
|
|
63
|
-
|
|
148
|
+
goalBlock(s.goal, s.jiraGoal),
|
|
149
|
+
s.summary || s.hit ? el('p', { class: 'ini-sp-sum' }, s.hit === 'met' ? st('ok', 'check', 'Goal met') : s.hit === 'partly' ? st('warn', 'alert', 'Goal partly met') : s.hit === 'missed' ? st('bad', 'alert', 'Goal missed') : null, s.hit && s.summary ? ' ' : null, s.summary || null) : null,
|
|
64
150
|
el('div', { class: 'chips ini-sp-facts' }, dates(s) ? chip(icon('cal', 'sm'), dates(s)) : null, st('ok', 'check', done + ' of ' + n + ' done'),
|
|
65
151
|
x.points ? chip(x.points + ' points') : null, x.carried ? chip(icon('next', 'sm'), x.carried + ' carried' + (x.carriedTo ? ' to ' + x.carriedTo : '')) : null),
|
|
66
|
-
el('div', { class: '
|
|
67
|
-
|
|
152
|
+
s.got ? el('div', { class: 'lh', 'data-inb': 'sprint-got' }, el('span', { class: 'ki' }, icon('alert')), el('div', { class: 'ini-sp-goal' }, lbl('WHAT GOT IN THE WAY'), el('div', null, s.got))) : null,
|
|
153
|
+
spNote([icon('lock', 'sm'), s.frozen ? ' Closed. Read and summarised once on ' + day(String(s.writtenAt || '').slice(0, 10)) + '. PMOS does not read a closed sprint again.'
|
|
154
|
+
: ' Closed. The Jira status check summarises it once at its next check, then does not read it again.'], { 'data-inb': 'sprint-read-once' }),
|
|
68
155
|
itemsRow(s));
|
|
69
156
|
};
|
|
157
|
+
const openPast = () => ctx.openSheet({ title: 'Past sprints · ' + b.name, icon: 'check', wide: true, body: el('div', { 'data-inb': 'sprints-past-sheet' }, ...past.map(closedSect)) });
|
|
70
158
|
const n = active ? (active.issues || []).length : 0;
|
|
71
159
|
const left = [
|
|
72
|
-
active ? sect({ ic: 'clock', title: active.name + ' ·
|
|
73
|
-
...
|
|
74
|
-
|
|
75
|
-
: 'No
|
|
76
|
-
active ?
|
|
77
|
-
|
|
78
|
-
: empty(sprints.length ? 'No sprint is active.' : 'Jira has no sprints for this board.', { 'data-inb': sprints.length ? 'sprints-no-active' : 'sprints-empty' }),
|
|
79
|
-
...others.filter((s) => s.state !== 'closed').map((s) => sect({ ic: 'cal', title: s.name, count: (s.issues || []).length, right: tag(s.state || 'sprint'), attrs: { 'data-inb-sprint': s.name, 'data-inb-state': s.state } },
|
|
160
|
+
active ? sect({ ic: 'clock', title: active.name + ' · sprint brief', right: active.brief ? healthSt(active.brief.health) : st('ok', null, 'active'), attrs: { 'data-inb': 'sprint-summary' } },
|
|
161
|
+
...briefBody(active, ctx),
|
|
162
|
+
spNote(active.summary ? 'Written by the Jira status check' + (timeOf(active.writtenAt) ? ' at ' + timeOf(active.writtenAt) : '') + ' from all ' + plural(active.summarisedTickets != null ? active.summarisedTickets : n, 'ticket') + ' in the sprint' + (vel.length ? ' and the last ' + plural(vel.length, 'closed sprint') : '') + '. PMOS counted the numbers; the agent wrote the words. Read again at each check while the sprint is active.'
|
|
163
|
+
: 'No brief yet. The Jira status check writes one from the sprint\'s tickets at its next check.', { 'data-inb': 'sprint-written' })) : null,
|
|
164
|
+
active ? ticketsSect(active) : empty(sprints.length ? 'No sprint is active.' : 'Jira has no sprints for this board.', { 'data-inb': sprints.length ? 'sprints-no-active' : 'sprints-empty' }),
|
|
165
|
+
...future.map((s) => sect({ ic: 'cal', title: s.name, count: (s.issues || []).length, right: tag(s.state || 'sprint'), attrs: { 'data-inb-sprint': s.name, 'data-inb-state': s.state } },
|
|
80
166
|
goalBlock(s.goal, s.jiraGoal), s.summary ? el('p', { class: 'ini-sp-sum' }, s.summary) : null, itemsRow(s))),
|
|
81
|
-
...others.filter((s) => s.state === 'closed').map(closedSect),
|
|
82
167
|
];
|
|
83
168
|
const right = [];
|
|
84
169
|
if (active) {
|
|
85
|
-
const c = countsOf(active);
|
|
170
|
+
const c = countsOf(active); const p = active.points; const pace = active.pace || {};
|
|
86
171
|
right.push(card({ ic: 'clock', title: active.name, attrs: { 'data-inb': 'sprint-counts' } }, kv([dates(active) ? ['Dates', dates(active)] : null,
|
|
87
|
-
['Items', n + ': ' + c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do']
|
|
172
|
+
['Items', n + ': ' + c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do'],
|
|
173
|
+
p ? ['Committed', num(p.committed) + ' points'] : null, p ? ['Done', num(p.done) + ' points'] : null,
|
|
174
|
+
p && pace.daysLeft != null ? ['Left · ' + plural(pace.daysLeft, 'day'), num(Math.max(0, p.committed - p.done)) + ' points'] : null,
|
|
175
|
+
pace.forecast != null ? ['Forecast', 'about ' + num(pace.forecast) + ' of ' + num(p.committed) + ' points' + (active.end ? ' by ' + day(active.end) : '')] : null])));
|
|
176
|
+
}
|
|
177
|
+
if (vel.length) {
|
|
178
|
+
const avg = vel.reduce((a, v) => a + v.done, 0) / vel.length; const pace = (active && active.pace) || {};
|
|
179
|
+
const over = active && active.points && avg ? Math.round((active.points.committed / avg - 1) * 100) : null;
|
|
180
|
+
right.push(card({ ic: 'up', title: 'Velocity', sub: 'last ' + plural(vel.length, 'sprint'), attrs: { 'data-inb': 'sprint-velocity' } }, velocitySvg(vel, active),
|
|
181
|
+
spNote('Average ' + num(avg) + ' points done a sprint' + (pace.hitRate != null ? ', ' + Math.round(pace.hitRate * 100) + '% of what was committed' : '') + '.' + (over != null ? ' This sprint committed ' + num(active.points.committed) + (over > 0 ? ', ' + over + '% above that average.' : over < 0 ? ', ' + -over + '% below that average.' : ', the same as the average.') : ''))));
|
|
182
|
+
}
|
|
183
|
+
if (active) {
|
|
88
184
|
const ids = items.ok ? items.initiatives.map((i) => i.id) : [];
|
|
89
185
|
const pseudo = { ...ctx, row: { id: ids.join(','), title: '' } };
|
|
90
186
|
right.push(...await recordCards(pseudo, ['burndown']));
|
|
91
187
|
}
|
|
188
|
+
if (past.length) right.push(card({ ic: 'lock', title: 'Past sprints', attrs: { 'data-inb': 'sprints-past' } },
|
|
189
|
+
list(past.map((s) => nrow({ ic: 'check', title: s.name, onOpen: openPast, attrs: { 'data-inb-past': s.name },
|
|
190
|
+
after: el('div', null, spNote([dates(s), s.closed && s.closed.total ? s.closed.done + ' of ' + s.closed.total + ' done' : null, s.closed && s.closed.points ? s.closed.points + ' points' : null].filter(Boolean).join(' · ')),
|
|
191
|
+
s.hit ? el('div', { class: 'chips ini-sp-chips' }, s.hit === 'met' ? st('ok', 'check', 'Goal met') : s.hit === 'partly' ? st('warn', 'alert', 'Goal partly met') : st('bad', 'alert', 'Goal missed')) : null) }))),
|
|
192
|
+
el('div', { class: 'chips ini-sp-chips' }, pbtn('outline', 'Open ' + plural(past.length, 'past sprint'), 'next', { onclick: openPast, 'data-inb': 'sprints-past-open' })),
|
|
193
|
+
spNote('Closed sprints are read and summarised once, then kept here.')));
|
|
92
194
|
if (sprints.length) right.push(card({ ic: 'redo', title: 'How sprints are read', attrs: { 'data-inb': 'sprints-how' } }, kv([
|
|
93
|
-
['Goal', 'Written from the sprint\'s tickets. Jira\'s goal is shown for reference'], ['
|
|
195
|
+
['Goal', 'Written from the sprint\'s tickets. Jira\'s goal is shown for reference'], ['Numbers', 'Counted by PMOS from the Jira read: points, statuses and flags'],
|
|
196
|
+
['Words', 'The verdict, the issues and their plans, and the asks are written by the agent from the tickets'], ['Active sprint', 'Read and summarised at each Jira status check'],
|
|
94
197
|
['Next sprint', 'Read at each check, until it starts'], ['Closed sprints', 'Read and summarised once, then kept'], ['Jira', 'Read-only. PMOS changes nothing there']])));
|
|
95
198
|
if (dead) return () => {};
|
|
96
199
|
put(host, cols(left, right));
|
|
@@ -60,6 +60,24 @@
|
|
|
60
60
|
.c3 .ini-sp-goal{min-width:0}
|
|
61
61
|
.c3 .ini-sp-note{font-size:var(--fs-2);line-height:1.45;margin-top:var(--sp-1)}
|
|
62
62
|
.c3 .ini-sp-chips{margin-top:var(--sp-1)}
|
|
63
|
+
.c3 .ini-sp-verdict{margin:var(--sp-3) 0;padding:var(--sp-3);border:1px solid var(--border);border-radius:8px} /* the sprint brief (sprint-goal-summary) */
|
|
64
|
+
.c3 .ini-sp-plan{margin-top:var(--sp-2);padding:var(--sp-2) var(--sp-3);border-left:2px solid var(--acc)}
|
|
65
|
+
.c3 .ini-sp-plan ol{margin:var(--sp-1) 0;padding-left:1.2em;display:grid;gap:var(--sp-1);line-height:1.45}
|
|
66
|
+
.c3 .ini-sp-flow{display:flex;gap:2px;height:10px;border-radius:6px;overflow:hidden;margin:var(--sp-2) 0}
|
|
67
|
+
.c3 .ini-sp-flow u{display:block;flex:var(--sp-n,1) 1 0;min-width:4px}
|
|
68
|
+
.c3 .ini-sp-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px}
|
|
69
|
+
.c3 .ini-sp-f-todo{background:var(--muted-foreground)}
|
|
70
|
+
.c3 .ini-sp-f-doing{background:var(--acc)}
|
|
71
|
+
.c3 .ini-sp-f-done{background:var(--foreground)}
|
|
72
|
+
.c3 .ini-vel{display:block;width:100%;max-width:420px;height:auto}
|
|
73
|
+
.c3 .ini-vel .ax{stroke:var(--border)}
|
|
74
|
+
.c3 .ini-vel .com{fill:none;stroke:var(--muted-foreground)}
|
|
75
|
+
.c3 .ini-vel .com.now{stroke-dasharray:3 3}
|
|
76
|
+
.c3 .ini-vel .done{fill:var(--acc)}
|
|
77
|
+
.c3 .ini-vel .done.now{fill:var(--warning)}
|
|
78
|
+
.c3 .ini-vel .avg{stroke:var(--foreground);stroke-dasharray:4 3}
|
|
79
|
+
.c3 .ini-vel text{fill:var(--muted-foreground);font:var(--fs-1) var(--mono)}
|
|
80
|
+
.c3 .ini-vel text.v{fill:var(--foreground)}
|
|
63
81
|
.c3 .ini-num{width:84px;flex:none}
|
|
64
82
|
.c3 .ini-up,.c3 .ini-down{display:inline-flex}
|
|
65
83
|
.c3 .ini-up{transform:rotate(-90deg)}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/* pm/c3-session-conv.js — a session as a conversation, the first view of the session sheet (c3-session-term.js).
|
|
2
|
+
* The approved mockup (2026-10-14, version 1) without its side-by-side layout; see planning/prd/c3-session-conversation-prd.md.
|
|
3
|
+
* convView({ id, onTerminal }) -> { node, input, start(), stop() }
|
|
4
|
+
* The words come from GET /api/sessions/conversation: Claude Code's own transcript, word for word (nothing is summarised and
|
|
5
|
+
* nothing is read from terminal bytes). A run of tool calls is one line that opens to list each call and its target. While a
|
|
6
|
+
* tool call is open and Claude Code's "Do you want to …" prompt is on the pane's screen, the reply's `ask` draws a card whose
|
|
7
|
+
* buttons type the option's number. Sends go over the page's terminal socket (tab-term.js typeIntoSession), as the Sessions
|
|
8
|
+
* panel's message box does; Stop types Esc. A plain shell, an unreadable record and an ended session each say so plainly. */
|
|
9
|
+
import { el } from './dom.js';
|
|
10
|
+
import { pmGet } from './http.js';
|
|
11
|
+
import { toast } from './c3-toast.js';
|
|
12
|
+
import { c3ic } from './pm-work-view.js';
|
|
13
|
+
import { renderMd } from './c3-session-chat.js';
|
|
14
|
+
import { button } from './primitives.js';
|
|
15
|
+
|
|
16
|
+
const POLL_MS = 1500;
|
|
17
|
+
const NOT_SENT = 'Not sent: the connection to the workspace server is down. Your message is still in the box.';
|
|
18
|
+
|
|
19
|
+
/** "Used Read ×2, Edit" for a run of tool calls. Pure, for Node. */
|
|
20
|
+
export const toolSummary = (tools) => 'Used ' + Object.entries(tools || {}).map(([n, k]) => (k > 1 ? n + ' ×' + k : n)).join(', ');
|
|
21
|
+
/** The status tag for a reply: [label, tone]. Pure, for Node. */
|
|
22
|
+
export function statusOf(r) {
|
|
23
|
+
if (!r) return ['Reading', 'muted'];
|
|
24
|
+
if (r.state === 'ended') return ['Ended', 'muted'];
|
|
25
|
+
if (r.state === 'shell') return ['Shell', 'muted'];
|
|
26
|
+
if (r.state !== 'conv') return ['Running', 'acc'];
|
|
27
|
+
if (r.ask) return ['Needs you', 'warning'];
|
|
28
|
+
if (r.turn === 'agent' || r.turn === 'tool') return ['Working', 'acc'];
|
|
29
|
+
if (r.turn === 'user') return ['Your turn', 'muted'];
|
|
30
|
+
return ['Running', 'acc'];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const keys = async (id, data) => { const t = await import('./tab-term.js').catch(() => null); return !!(t && typeof t.pressKeys === 'function' && await t.pressKeys(id, data)); };
|
|
34
|
+
const type = async (id, text) => { const t = await import('./tab-term.js').catch(() => null); return !!(t && typeof t.typeIntoSession === 'function' && await t.typeIntoSession(id, text)); };
|
|
35
|
+
/* a primitives button (keyboard, role) carrying the console3 look and children (the pattern c3-dock.js uses) */
|
|
36
|
+
const c3btn = (attrs, onClick, ...kids) => { const b = button({ variant: 'ghost', attrs, onClick }); b.replaceChildren(...kids); return b; };
|
|
37
|
+
const pill = (label, onClick, attrs = {}) => c3btn({ class: 'pill', ...attrs }, onClick, label);
|
|
38
|
+
|
|
39
|
+
export function convView({ id, onTerminal } = {}) {
|
|
40
|
+
const st = { data: null, pending: [], busy: false, timer: null, stick: true, sig: '' };
|
|
41
|
+
const tag = el('span', { class: 'tg', 'data-cv': 'status' });
|
|
42
|
+
const metaRest = el('span', { class: 'chips2', 'data-cv': 'meta' });
|
|
43
|
+
const meta = el('div', { class: 'c3t-cv-meta' }, tag, metaRest);
|
|
44
|
+
const feed = el('div', { class: 'c3t-cv-feed', 'data-cv': 'feed', 'aria-live': 'polite' });
|
|
45
|
+
const input = el('textarea', { rows: '1', 'aria-label': 'Message', placeholder: 'Message Claude', 'data-cv': 'msg',
|
|
46
|
+
onkeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } },
|
|
47
|
+
oninput: () => grow() });
|
|
48
|
+
const stopBtn = c3btn({ class: 'ib sm', title: 'Stop Claude (types Esc)', 'aria-label': 'Stop Claude', 'data-cv': 'stop', hidden: '' },
|
|
49
|
+
async () => { if (!await keys(id, '\x1b')) toast({ text: NOT_SENT, tone: 'error' }); else toast({ text: 'Stopped. Claude waits for your next message.' }); }, c3ic('stop', 'sm'));
|
|
50
|
+
const sendBtn = c3btn({ class: 'ib pri sm', title: 'Send (Enter)', 'aria-label': 'Send', 'data-cv': 'send' }, () => send(), c3ic('out', 'sm'));
|
|
51
|
+
const slash = el('span', { class: 'chips2' },
|
|
52
|
+
pill('/compact', () => { input.value = '/compact'; input.focus(); grow(); }, { title: 'Claude summarises the conversation so far to free room', 'data-cv-slash': '/compact' }),
|
|
53
|
+
pill('/clear', () => { input.value = '/clear'; input.focus(); grow(); }, { title: 'Start a fresh conversation in this session', 'data-cv-slash': '/clear' }));
|
|
54
|
+
const comp = el('div', { class: 'c3t-cv-comp', 'data-cv': 'composer' },
|
|
55
|
+
el('div', { class: 'box' }, input, stopBtn, sendBtn),
|
|
56
|
+
el('div', { class: 'hint' }, slash, el('span', { class: 'kb' }, 'Enter sends · Shift Enter for a new line')));
|
|
57
|
+
const node = el('div', { class: 'c3t-cv-main', 'data-c3-conv': id }, meta, feed, comp);
|
|
58
|
+
|
|
59
|
+
// the box grows with its lines, up to eight, by its own rows (no inline style)
|
|
60
|
+
function grow() { input.rows = Math.max(1, Math.min(8, input.value.split('\n').length)); }
|
|
61
|
+
|
|
62
|
+
async function send() {
|
|
63
|
+
const text = input.value.trim();
|
|
64
|
+
if (!text) return;
|
|
65
|
+
const p = { text, at: Date.now() };
|
|
66
|
+
st.pending.push(p); st.stick = true; input.value = ''; grow(); paint();
|
|
67
|
+
if (!await type(id, text)) {
|
|
68
|
+
st.pending = st.pending.filter((x) => x !== p); input.value = text; grow(); paint();
|
|
69
|
+
toast({ text: NOT_SENT, tone: 'error' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setTimeout(poll, 400);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toolsRow(m) {
|
|
76
|
+
const calls = Array.isArray(m.calls) ? m.calls : [];
|
|
77
|
+
return el('details', { class: 'tools', 'data-chat': 'tool' },
|
|
78
|
+
el('summary', null, el('span', { class: 'car' }, c3ic('next', 'sm')), c3ic('tree', 'sm'), toolSummary(m.tools)),
|
|
79
|
+
calls.length ? el('ul', null, ...calls.map((c) => el('li', null, el('b', null, c.tool), c.target ? el('span', { title: c.target }, c.target) : null))) : null);
|
|
80
|
+
}
|
|
81
|
+
function askCard(a) {
|
|
82
|
+
return el('div', { class: 'perm', role: 'group', 'aria-label': 'Claude asks before it runs a tool', 'data-cv': 'ask' },
|
|
83
|
+
el('b', { class: 'chips2' }, c3ic('alert', 'sm'), st.data && st.data.openCall ? 'Claude wants to run ' + st.data.openCall : 'Claude asks before it goes on'),
|
|
84
|
+
el('div', { class: 'q' }, a.question),
|
|
85
|
+
el('div', { class: 'c3t-tray' },
|
|
86
|
+
...a.options.map((o) => pill(o.label, async () => { if (!await keys(id, o.n)) toast({ text: NOT_SENT, tone: 'error' }); else setTimeout(poll, 500); },
|
|
87
|
+
{ 'data-cv-opt': o.n, title: 'Answer "' + o.label + '" (types ' + o.n + ')' })),
|
|
88
|
+
c3btn({ class: 'chip2 lk', 'data-cv': 'ask-term' }, () => onTerminal && onTerminal(), c3ic('term', 'sm'), 'Answer in the terminal')));
|
|
89
|
+
}
|
|
90
|
+
function none(icon, title, text, ...btns) {
|
|
91
|
+
return el('div', { class: 'none', 'data-cv': 'none' }, c3ic(icon), el('b', null, title), el('span', { class: 'dim' }, text), el('div', { class: 'chips2' }, ...btns));
|
|
92
|
+
}
|
|
93
|
+
const termBtn = () => c3btn({ class: 'pill', 'data-cv': 'open-term' }, () => onTerminal && onTerminal(), c3ic('term', 'sm'), 'Open the terminal');
|
|
94
|
+
|
|
95
|
+
function paint() {
|
|
96
|
+
const d = st.data;
|
|
97
|
+
const [label, tone] = statusOf(d);
|
|
98
|
+
tag.dataset.tone = tone;
|
|
99
|
+
tag.textContent = label;
|
|
100
|
+
metaRest.replaceChildren(...[d && d.agent ? (d.agent === 'shell' ? 'Shell' : d.agent === 'claude' ? 'Claude' : d.agent) : null, d && d.model]
|
|
101
|
+
.filter(Boolean).map((t) => el('span', null, t)));
|
|
102
|
+
const conv = d && d.state === 'conv';
|
|
103
|
+
comp.hidden = !d || d.state === 'ended' || d.state === 'unlinked' || d.state === 'other';
|
|
104
|
+
input.placeholder = d && d.state === 'shell' ? 'Type a command for the shell' : d && d.ask ? 'Answer above, or type to tell Claude something else' : 'Message Claude';
|
|
105
|
+
slash.hidden = !conv;
|
|
106
|
+
stopBtn.hidden = !(conv && (d.turn === 'agent' || d.turn === 'tool') && !d.ask);
|
|
107
|
+
|
|
108
|
+
const kids = [];
|
|
109
|
+
if (!d) kids.push(el('div', { class: 'm st' }, 'Reading the conversation…'));
|
|
110
|
+
else if (d.state === 'shell') {
|
|
111
|
+
kids.push(none('term', 'No Claude in this shell yet', 'This is a plain shell. Start Claude here and its conversation shows in this view.',
|
|
112
|
+
c3btn({ class: 'pill', 'aria-pressed': 'true', 'data-cv': 'start-claude' },
|
|
113
|
+
async () => { if (!await type(id, 'claude')) toast({ text: NOT_SENT, tone: 'error' }); else setTimeout(poll, 1500); }, c3ic('spark', 'sm'), 'Start Claude here'),
|
|
114
|
+
termBtn()));
|
|
115
|
+
} else if (d.state === 'unlinked') {
|
|
116
|
+
kids.push(none('alert', "Claude's conversation can't be read here", d.missing + '. PMOS will not guess which transcript is this session\'s, because that could show another session\'s words.', termBtn()));
|
|
117
|
+
} else if (d.state === 'other') {
|
|
118
|
+
kids.push(none('term', 'This agent keeps no Claude transcript', 'Its output is in the terminal.', termBtn()));
|
|
119
|
+
} else if (d.state === 'ended') {
|
|
120
|
+
kids.push(none('clock', 'This session has ended', 'Its terminal output is kept in the Sessions panel under Past.'));
|
|
121
|
+
} else {
|
|
122
|
+
for (const m of d.messages || []) {
|
|
123
|
+
if (m.role === 'user') kids.push(el('div', { class: 'who me' }, 'You'), el('div', { class: 'm me', 'data-chat': 'me' }, m.text));
|
|
124
|
+
else if (m.role === 'assistant') kids.push(el('div', { class: 'who' }, c3ic('spark', 'sm'), 'Claude'), el('div', { class: 'm bot', 'data-chat': 'bot' }, renderMd(m.text)));
|
|
125
|
+
else kids.push(toolsRow(m));
|
|
126
|
+
}
|
|
127
|
+
if (!(d.messages || []).length && !st.pending.length) kids.push(el('div', { class: 'm st', 'data-chat': 'empty' }, 'No messages yet. Type below to talk to Claude.'));
|
|
128
|
+
}
|
|
129
|
+
for (const p of st.pending) kids.push(el('div', { class: 'who me' }, 'You'), el('div', { class: 'm me', 'data-chat': 'me', 'data-pending': '' }, p.text));
|
|
130
|
+
if (conv && d.ask) kids.push(askCard(d.ask));
|
|
131
|
+
else if (conv && (d.turn === 'agent' || d.turn === 'tool' || st.pending.length)) {
|
|
132
|
+
kids.push(el('div', { class: 'live', role: 'status', 'data-chat': 'working' }, el('span', { class: 'c3spin' }, c3ic('clock', 'sm')),
|
|
133
|
+
d.turn === 'tool' && d.openCall ? 'Running ' + d.openCall + ', or waiting for your allow in the terminal' : 'Claude is working…'));
|
|
134
|
+
}
|
|
135
|
+
const atEnd = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60;
|
|
136
|
+
feed.replaceChildren(el('div', { class: 'wrap' }, ...kids));
|
|
137
|
+
if (atEnd || st.stick) { feed.scrollTop = feed.scrollHeight; st.stick = false; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function poll() {
|
|
141
|
+
if (st.busy) return;
|
|
142
|
+
st.busy = true;
|
|
143
|
+
try {
|
|
144
|
+
const r = await pmGet('/api/sessions/conversation?id=' + encodeURIComponent(id)).catch(() => null);
|
|
145
|
+
if (!r || !r.ok) { if (!st.data) { st.data = { state: 'unlinked', messages: [], missing: (r && r.error) || 'The conversation could not be read' }; paint(); } return; }
|
|
146
|
+
const msgs = r.messages || [];
|
|
147
|
+
// a message sent from here is done when the transcript holds it; one Claude never recorded goes after a minute
|
|
148
|
+
st.pending = st.pending.filter((p) => !msgs.some((m) => m.role === 'user' && m.text.trim() === p.text && (!m.at || Date.parse(m.at) >= p.at - 5000))
|
|
149
|
+
&& Date.now() - p.at < 60000);
|
|
150
|
+
const sig = [r.stamp, r.state, r.turn, r.ask ? JSON.stringify(r.ask) : '', st.pending.length].join('|');
|
|
151
|
+
if (sig === st.sig) return;
|
|
152
|
+
st.sig = sig; st.data = r;
|
|
153
|
+
paint();
|
|
154
|
+
} finally { st.busy = false; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
paint();
|
|
158
|
+
return {
|
|
159
|
+
node, input,
|
|
160
|
+
start() { if (!st.timer) { poll(); st.timer = setInterval(() => { if (!node.isConnected) return; poll(); }, POLL_MS); } },
|
|
161
|
+
stop() { if (st.timer) { clearInterval(st.timer); st.timer = null; } },
|
|
162
|
+
get state() { return st.data ? st.data.state : null; },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
/* pm/c3-session-term.js — a session
|
|
2
|
-
* openSessionTerm({ id, name })
|
|
1
|
+
/* pm/c3-session-term.js — a session inside console3, in a sheet, so "Open the session" no longer leaves /c3.html.
|
|
2
|
+
* openSessionTerm({ id, name, view }) view: 'conv' (the default) or 'term'
|
|
3
|
+
* The sheet opens on the conversation (c3-session-conv.js); Terminal shows the session's real terminal and Conversation comes back.
|
|
4
|
+
* The terminal is mounted on first use and stays attached while the sheet is open, so switching never restarts anything.
|
|
3
5
|
* The terminal is tab-term.js's own (the one the main app draws): the same socket, attach, resize and input. Closing the sheet detaches the
|
|
4
6
|
* pane and leaves the session running, as closing a tab in the main app does. xterm and its addons are the main app's vendored globals,
|
|
5
7
|
* loaded here on the first open because /c3.html does not load them up front. */
|
|
@@ -9,6 +11,7 @@ import { toast } from './c3-toast.js';
|
|
|
9
11
|
import { pmGet, pmPost } from './http.js';
|
|
10
12
|
import { button } from './primitives.js';
|
|
11
13
|
import { c3ic } from './pm-work-view.js';
|
|
14
|
+
import { convView } from './c3-session-conv.js';
|
|
12
15
|
|
|
13
16
|
/* a primitives button (keyboard, role) carrying the console3 look and children (the pattern c3-dock.js uses) */
|
|
14
17
|
const c3btn = (attrs, onClick, ...kids) => { const b = button({ variant: 'ghost', attrs, onClick }); b.replaceChildren(...kids); return b; };
|
|
@@ -135,35 +138,79 @@ function makePad(id, term, tab) {
|
|
|
135
138
|
return { box, ta, dispose() { if (closed) return; closed = true; window.removeEventListener('pagehide', onHide); flush(); } };
|
|
136
139
|
}
|
|
137
140
|
|
|
138
|
-
|
|
141
|
+
/* the sheet's two views: the conversation (c3-session-conv.js) first, the live terminal one press away. VIEWS: [key, label, title] */
|
|
142
|
+
export const VIEWS = [['conv', 'Conversation', 'The conversation: your messages and Claude\'s replies'], ['term', 'Terminal', 'The live terminal of this session']];
|
|
143
|
+
/** Which view a session opens on: the one asked for, else Terminal for an agent that keeps no Claude transcript, else Conversation. Pure. */
|
|
144
|
+
export const firstView = (agent, asked) => (asked === 'term' || asked === 'conv' ? asked : agent && agent !== 'claude' && agent !== 'shell' ? 'term' : 'conv');
|
|
145
|
+
/* the running sessions the sheet's list shows (at 2/3 and Whole): newest first, background runs left out */
|
|
146
|
+
const listed = (all) => (Array.isArray(all) ? all : []).filter((x) => x && x.id && x.state !== 'exited' && x.shown !== 'exited' && !x.headless && !x.automatic)
|
|
147
|
+
.sort((a, b) => String(b.created || '').localeCompare(String(a.created || '')));
|
|
148
|
+
|
|
149
|
+
/** The session sheet: Conversation | Terminal, All sessions and the sizes. `view` 'term' opens straight on the terminal (Set up Laya). */
|
|
150
|
+
export async function openSessionTerm({ id, name, view } = {}) {
|
|
139
151
|
if (!id) return null;
|
|
140
152
|
rememberSession(id);
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
153
|
+
const r = await pmGet('/api/sessions').catch(() => null);
|
|
154
|
+
const all = r && Array.isArray(r.sessions) ? r.sessions : [];
|
|
155
|
+
const me = all.find((y) => y.id === id) || null;
|
|
156
|
+
if (!name) name = me ? me.name : id;
|
|
144
157
|
const tab = { id: 'c3-session:' + id, kind: 'term', sessionId: id, spawn: false, sess: id };
|
|
145
158
|
const host = el('div', { class: 'c3t-term', 'data-c3-session-term': id });
|
|
146
|
-
|
|
147
|
-
const wrap = el('div', { class: 'c3t-tw' }, host
|
|
159
|
+
let term = null; let pad = null; let mounted = false;
|
|
160
|
+
const wrap = el('div', { class: 'c3t-tw', 'data-c3-view-pane': 'term' }, host);
|
|
161
|
+
const conv = convView({ id, onTerminal: () => show('term') });
|
|
162
|
+
conv.node.setAttribute('data-c3-view-pane', 'conv');
|
|
163
|
+
const list = el('nav', { class: 'c3t-cv-list', 'aria-label': 'Running sessions', 'data-c3-sess-list': '' },
|
|
164
|
+
el('div', { class: 'eyebrow' }, 'Running'),
|
|
165
|
+
...listed(all).map((x) => c3btn({ 'aria-current': String(x.id === id), 'data-c3-sess-pick': x.id, title: x.name },
|
|
166
|
+
() => { if (x.id !== id) openSessionTerm({ id: x.id, name: x.name }); },
|
|
167
|
+
el('span', { class: 'nm' }, x.name || x.id), el('span', { class: 'sub' }, [x.agent === 'shell' ? 'Shell' : x.agent, x.shown || x.state].filter(Boolean).join(' · ')))));
|
|
168
|
+
const body = el('div', { class: 'c3t-cv', 'data-c3-session': id }, list, el('div', { class: 'c3t-cv-panes' }, conv.node, wrap));
|
|
169
|
+
|
|
148
170
|
// the size control: Normal is the 600px sheet, the others are the classic dock's presets. Hidden on a phone, where the sheet is full width.
|
|
149
171
|
let size = sizeOf(store.get(SIZE_KEY));
|
|
150
172
|
const sizeBtns = SIZES.map(([k, label, title]) => c3btn({ 'aria-pressed': String(k === size), 'data-c3-size': k, title, 'aria-label': title }, () => setSize(k), label));
|
|
151
173
|
const seg = el('div', { class: 'seg c3t-size', role: 'group', 'aria-label': 'Terminal size', 'data-c3-size-seg': '' }, ...sizeBtns);
|
|
174
|
+
let cur = firstView(me && me.agent, view);
|
|
175
|
+
const viewBtns = VIEWS.map(([k, label, title]) => c3btn({ 'aria-pressed': String(k === cur), 'data-c3-view': k, title, 'aria-label': label }, () => show(k),
|
|
176
|
+
c3ic(k === 'conv' ? 'reply' : 'term', 'sm'), el('span', { class: 'lb' }, label)));
|
|
177
|
+
const views = el('div', { class: 'seg c3t-views', role: 'group', 'aria-label': 'View', 'data-c3-view-seg': '' }, ...viewBtns);
|
|
152
178
|
// the way back to every session: the Sessions panel, in place of this sheet
|
|
153
|
-
const
|
|
154
|
-
c3ic('menu', 'sm'), 'All sessions');
|
|
179
|
+
const allBtn = c3btn({ class: 'chip2 lk c3t-all', 'data-c3-all-sessions': '', title: 'All sessions: the Sessions panel', 'aria-label': 'All sessions' }, () => { sheet.close(); import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
|
|
180
|
+
c3ic('menu', 'sm'), el('span', { class: 'lb' }, 'All sessions'));
|
|
155
181
|
const cls = (k) => 'tsz-' + k;
|
|
156
182
|
// Esc and Tab belong to the shell while the terminal has focus (Esc interrupts an agent); the close button and a press outside still close.
|
|
157
|
-
// The scratchpad
|
|
158
|
-
const sheet = openSheet({ title: name || id, icon: 'term', wide: true, body
|
|
159
|
-
onClose: () => { pad.dispose(); term.disposeTerm(tab); }, passKeys: (e) => host.contains(e.target) });
|
|
183
|
+
// The scratchpad and the conversation are outside the terminal host, so their keys are the page's own.
|
|
184
|
+
const sheet = openSheet({ title: name || id, icon: 'term', wide: true, body, actions: [views, allBtn, seg], cls: 'c3t-ss ' + cls(size),
|
|
185
|
+
onClose: () => { conv.stop(); if (pad) pad.dispose(); if (term && mounted) term.disposeTerm(tab); }, passKeys: (e) => host.contains(e.target) });
|
|
186
|
+
|
|
187
|
+
async function mountTerminal() {
|
|
188
|
+
if (mounted) return true;
|
|
189
|
+
const [ok, t] = await Promise.all([loadVendor(), import('./tab-term.js').catch(() => null)]);
|
|
190
|
+
if (!ok || !t) { toast({ text: 'The terminal could not be loaded on this page.', tone: 'error' }); return false; }
|
|
191
|
+
if (mounted || !host.isConnected) return mounted;
|
|
192
|
+
term = t; mounted = true;
|
|
193
|
+
pad = makePad(id, term, tab);
|
|
194
|
+
wrap.append(pad.box);
|
|
195
|
+
term.mountTerm(host, tab);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
async function show(k) {
|
|
199
|
+
cur = k === 'term' ? 'term' : 'conv';
|
|
200
|
+
viewBtns.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.c3View === cur)));
|
|
201
|
+
body.dataset.view = cur;
|
|
202
|
+
conv.node.hidden = cur !== 'conv'; wrap.hidden = cur !== 'term';
|
|
203
|
+
if (cur === 'conv') { conv.start(); requestAnimationFrame(() => conv.input.focus()); return; }
|
|
204
|
+
conv.stop(); // the hidden conversation does not read the transcript; it reads it again on the way back
|
|
205
|
+
if (!await mountTerminal()) return;
|
|
206
|
+
requestAnimationFrame(() => { term.refitVisible(); term.focusTerm(tab); });
|
|
207
|
+
}
|
|
160
208
|
function setSize(k) {
|
|
161
209
|
sheet.sheet.classList.remove(cls(size)); size = sizeOf(k); sheet.sheet.classList.add(cls(size));
|
|
162
210
|
store.set(SIZE_KEY, size);
|
|
163
211
|
sizeBtns.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.c3Size === size)));
|
|
164
|
-
requestAnimationFrame(() => { term.refitVisible(); requestAnimationFrame(() => term.refitVisible()); });
|
|
212
|
+
if (term) requestAnimationFrame(() => { term.refitVisible(); requestAnimationFrame(() => term.refitVisible()); });
|
|
165
213
|
}
|
|
166
|
-
|
|
167
|
-
requestAnimationFrame(() => { term.refitVisible(); term.focusTerm(tab); });
|
|
214
|
+
await show(cur);
|
|
168
215
|
return sheet;
|
|
169
216
|
}
|
|
@@ -613,7 +613,7 @@ export function mountSettings(host, ctx = {}) {
|
|
|
613
613
|
M.typesafe = r.key; delete M.err['typesafe.local'];
|
|
614
614
|
M.localTest = { ok: !!r.started, text: r.started ? 'A terminal named Set up Laya opened with the commands typed in. Press Enter there, wait until Laya says it is running, then press Test.' : 'No terminal opened (' + (r.reason || 'unknown') + '). Run this in a terminal: ' + r.command };
|
|
615
615
|
// the server only starts the session; the page must open it, or nothing appears to happen
|
|
616
|
-
if (r.started && r.session) import('./c3-session-term.js').then((m) => m.openSessionTerm({ id: r.session, name: 'Set up Laya' })).catch(() => {});
|
|
616
|
+
if (r.started && r.session) import('./c3-session-term.js').then((m) => m.openSessionTerm({ id: r.session, name: 'Set up Laya', view: 'term' })).catch(() => {});
|
|
617
617
|
} else { M.localTest = null; M.err['typesafe.local'] = (r && r.error) || 'Not started.'; }
|
|
618
618
|
draw();
|
|
619
619
|
};
|