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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-ib-more-test.js +23 -4
  7. package/workspace/c3-sessions-panel-test.js +69 -11
  8. package/workspace/c3-work-test.js +2 -2
  9. package/workspace/guide/console3-sessions.md +33 -9
  10. package/workspace/guide/jira.md +13 -1
  11. package/workspace/guide/my-work.md +4 -1
  12. package/workspace/lib/day-run.js +30 -1
  13. package/workspace/lib/engine/boards.js +51 -7
  14. package/workspace/lib/jira-status.js +46 -1
  15. package/workspace/lib/my-work.js +1 -1
  16. package/workspace/lib/proactive-cli.js +8 -2
  17. package/workspace/lib/routes-my-work.js +44 -17
  18. package/workspace/lib/routes-pm-items.js +8 -0
  19. package/workspace/lib/transcript-read.js +93 -10
  20. package/workspace/lib/work-assigned.js +23 -11
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/public/pm/c3-demo-boards.js +12 -3
  24. package/workspace/public/pm/c3-ib-sprints.js +142 -39
  25. package/workspace/public/pm/c3-initiatives.css +18 -0
  26. package/workspace/public/pm/c3-session-conv.js +164 -0
  27. package/workspace/public/pm/c3-session-term.js +63 -16
  28. package/workspace/public/pm/c3-settings.js +1 -1
  29. package/workspace/public/pm/console3.css +62 -0
  30. package/workspace/public/pm/pm-work-view.js +37 -11
  31. package/workspace/public/pm/tab-term.js +9 -0
  32. package/workspace/server.js +32 -12
  33. package/workspace/skills/day-task.skill +1 -0
  34. package/workspace/skills/jira-sync.skill +9 -3
  35. 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, sprints: [{ name, state: active|future|closed, start, end, issues: [{ key, summary,
5
- * status, category: todo|doing|done, initiative }], counts, goal: { from: 'tickets', text, jira } | null, jiraGoal?, summary,
6
- * themes: [{ title, line, tickets, done, blocked, people, initiative }], writtenAt, closed: { done, total, carried, carriedTo, points } | null,
7
- * frozen }] }. READ-ONLY: the sprints mirror Jira, so there is no create, edit or move control.
8
- * Left: the active sprint's goal and summary (the goal, always written from the tickets, with Jira's goal as a reference; what the team is working on,
9
- * one row per theme), its items (header chip: Jira, read-only and the read time; c3-board-declutter: no banner), then the next sprint
10
- * with its goal and summary, and each closed sprint with its goal, summary, done and carried counts and the read-once note. Right: the
11
- * active sprint's card, its burndown from GET /api/stage-tools/burndown (when that route is not on this server yet, the card says so
12
- * quietly), and how sprints are read. source 'none' says there is no tracker. */
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, note, lbl, sect, card, list, empty, nrow, cols, kv, jiraLabel, day, put, plural } from './c3-ini-ui.js';
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
- /* Quiet pages (c3-quiet.css) hide .ini-note, .ini-lhm .dim and a row's third chip. The goal's reason, the theme chips and the
23
- * read-once note are the content of this tab (mockup v3), so they use their own classes. */
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 ? el('div', { class: 'dim ini-sp-note', 'data-inb': 'sprint-goal-jira' }, 'In Jira: “' + j + '”') : null);
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'), el('div', { class: 'dim ini-sp-note' }, 'No goal yet. The Jira status check writes one from the tickets at its next check.'), jiraRef(jiraGoal)));
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 ? el('div', { class: 'dim ini-sp-note' }, 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,
36
- t.people ? chip(icon('user', 'sm'), t.people) : null,
37
- t.initiative ? lk({ onclick: () => ctx.openInitiative(t.initiative), 'data-inb-theme-ini': t.initiative }, icon('init', 'sm'), t.initiative) : el('span', { class: 'chip2 dim' }, 'No initiative')].filter(Boolean))) });
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 others = sprints.filter((s) => s !== active);
53
- const issueRow = (i) => nrow({ ic: i.category === 'done' ? 'check' : i.category === 'doing' ? 'bolt' : 'todo', hot: i.category === 'doing', title: i.summary || i.key,
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 dates = (s) => (s.start && s.end ? day(s.start) + ' to ' + day(s.end) : '');
59
- 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(issueRow))] : [note('No items.')] }); };
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
- ...summaryBody({ ...s, themes: [] }, ctx),
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: 'dim ini-sp-note', 'data-inb': 'sprint-read-once' }, 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.'
67
- : ' Closed. The Jira status check summarises it once at its next check, then does not read it again.'),
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 + ' · goal and summary', right: st('ok', null, 'active'), attrs: { 'data-inb': 'sprint-summary' } },
73
- ...summaryBody(active, ctx),
74
- el('div', { class: 'dim ini-sp-note', 'data-inb': 'sprint-written' }, active.summary ? 'Goal and 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. Read and summarised again at each check while the sprint is active.'
75
- : 'No summary yet. The Jira status check writes one from the sprint\'s tickets at its next check.')) : null,
76
- active ? sect({ ic: 'check', title: active.name + ' items', 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': active.name, 'data-inb-state': 'active' } },
77
- n ? list(active.issues.map(issueRow)) : empty('No items in this sprint yet.', { 'data-inb': 'sprint-no-issues' }))
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'], ['Active sprint', 'Read and summarised at each Jira status check'],
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's real terminal inside console3, in a sheet, so "Open the session" no longer leaves /c3.html.
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
- export async function openSessionTerm({ id, name } = {}) {
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
- if (!name) { const r = await pmGet('/api/sessions').catch(() => null); const x = r && Array.isArray(r.sessions) ? r.sessions.find((y) => y.id === id) : null; name = x ? x.name : id; }
142
- const [ok, term] = await Promise.all([loadVendor(), import('./tab-term.js').catch(() => null)]);
143
- if (!ok || !term) { toast({ text: 'The terminal could not be loaded on this page.', tone: 'error' }); return null; }
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
- const pad = makePad(id, term, tab);
147
- const wrap = el('div', { class: 'c3t-tw' }, host, pad.box);
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 all = c3btn({ class: 'chip2 lk', 'data-c3-all-sessions': '', title: 'All sessions: the Sessions panel' }, () => { sheet.close(); import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
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 is outside the terminal host, so its keys are the page's own.
158
- const sheet = openSheet({ title: name || id, icon: 'term', wide: true, body: wrap, actions: [all, seg], cls: cls(size),
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
- term.mountTerm(host, tab);
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
  };