create-pmos 0.123.0 → 0.125.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 (127) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/scripts/nine-stages.js +81 -0
  6. package/payload/scripts/workflow-state.js +2 -0
  7. package/payload/state/discoveries.md +1 -1
  8. package/payload/state/log.md +1 -1
  9. package/workspace/c3-one-home-test.js +131 -0
  10. package/workspace/fixtures/initiatives-api/list.json +171 -3
  11. package/workspace/fixtures/initiatives-api/roadmap.json +228 -4
  12. package/workspace/fixtures/initiatives-api/sheet.json +57 -1
  13. package/workspace/fixtures/one-home-baseline.json +83 -0
  14. package/workspace/guide/layout.md +2 -0
  15. package/workspace/guide/notes.md +1 -0
  16. package/workspace/lib/engine/nine-stages.js +81 -0
  17. package/workspace/lib/engine/workflow-state.js +2 -0
  18. package/workspace/lib/facts-async.js +51 -0
  19. package/workspace/lib/item-steps.js +52 -0
  20. package/workspace/lib/jira-status.js +8 -3
  21. package/workspace/lib/jobs-to-be-done.js +52 -0
  22. package/workspace/lib/outbox-store.js +87 -0
  23. package/workspace/lib/pm-items.js +115 -0
  24. package/workspace/lib/route-modules.js +21 -0
  25. package/workspace/lib/routes-briefing.js +100 -0
  26. package/workspace/lib/routes-c3-init.js +53 -0
  27. package/workspace/lib/routes-c3-life.js +119 -0
  28. package/workspace/lib/routes-c3-strategy.js +169 -0
  29. package/workspace/lib/routes-c3-timeline.js +191 -0
  30. package/workspace/lib/routes-calendar-import.js +60 -0
  31. package/workspace/lib/routes-calendar-views.js +61 -0
  32. package/workspace/lib/routes-doc-health.js +79 -0
  33. package/workspace/lib/routes-doc-sheet.js +169 -0
  34. package/workspace/lib/routes-forecast.js +85 -0
  35. package/workspace/lib/routes-hooks.js +121 -0
  36. package/workspace/lib/routes-initiatives.js +3 -0
  37. package/workspace/lib/routes-intake.js +24 -0
  38. package/workspace/lib/routes-outbox.js +97 -0
  39. package/workspace/lib/routes-pm-items.js +216 -0
  40. package/workspace/lib/routes-records.js +61 -0
  41. package/workspace/lib/routes-releases.js +129 -0
  42. package/workspace/lib/routes-report-facts.js +84 -0
  43. package/workspace/lib/routes-report-families.js +32 -0
  44. package/workspace/lib/routes-roadmap-ops.js +141 -0
  45. package/workspace/lib/routes-runs.js +119 -0
  46. package/workspace/lib/routes-signals.js +50 -0
  47. package/workspace/lib/run-answers.js +69 -0
  48. package/workspace/package-lock.json +2 -2
  49. package/workspace/package.json +1 -1
  50. package/workspace/public/c3.html +26 -0
  51. package/workspace/public/pm/briefing-deck.js +415 -0
  52. package/workspace/public/pm/briefing-model.js +176 -0
  53. package/workspace/public/pm/c3-breakdown.js +240 -0
  54. package/workspace/public/pm/c3-brief.js +278 -0
  55. package/workspace/public/pm/c3-cal-strategy.js +528 -0
  56. package/workspace/public/pm/c3-case.js +173 -0
  57. package/workspace/public/pm/c3-demo-brief-ix.js +350 -0
  58. package/workspace/public/pm/c3-demo-cal-ix.js +74 -0
  59. package/workspace/public/pm/c3-demo-cal2.js +66 -0
  60. package/workspace/public/pm/c3-demo-cal3.js +173 -0
  61. package/workspace/public/pm/c3-demo-data.js +560 -0
  62. package/workspace/public/pm/c3-demo-init.js +373 -0
  63. package/workspace/public/pm/c3-demo-life.js +77 -0
  64. package/workspace/public/pm/c3-demo-misc2.js +46 -0
  65. package/workspace/public/pm/c3-demo-plan2.js +30 -0
  66. package/workspace/public/pm/c3-demo-rep-ix.js +126 -0
  67. package/workspace/public/pm/c3-demo-rep2.js +92 -0
  68. package/workspace/public/pm/c3-demo-term.js +153 -0
  69. package/workspace/public/pm/c3-demo-time.js +49 -0
  70. package/workspace/public/pm/c3-demo-work-ix.js +80 -0
  71. package/workspace/public/pm/c3-demo-work2.js +13 -0
  72. package/workspace/public/pm/c3-demo.js +113 -0
  73. package/workspace/public/pm/c3-doc-sheet.js +314 -0
  74. package/workspace/public/pm/c3-dock.js +180 -0
  75. package/workspace/public/pm/c3-docs.js +290 -0
  76. package/workspace/public/pm/c3-expand.js +96 -0
  77. package/workspace/public/pm/c3-init-extras.js +170 -0
  78. package/workspace/public/pm/c3-lifecycle-panels.js +476 -0
  79. package/workspace/public/pm/c3-misc-panels.js +89 -0
  80. package/workspace/public/pm/c3-plan-panels.js +163 -0
  81. package/workspace/public/pm/c3-plan.js +192 -0
  82. package/workspace/public/pm/c3-roadmap.js +249 -0
  83. package/workspace/public/pm/c3-sheet.js +107 -0
  84. package/workspace/public/pm/c3-shell.js +637 -0
  85. package/workspace/public/pm/c3-signals.js +196 -0
  86. package/workspace/public/pm/c3-strategy-panels.js +45 -0
  87. package/workspace/public/pm/c3-swipe.js +138 -0
  88. package/workspace/public/pm/c3-terminal-data.js +26 -0
  89. package/workspace/public/pm/c3-terminal-edge.js +91 -0
  90. package/workspace/public/pm/c3-terminal-icons.js +41 -0
  91. package/workspace/public/pm/c3-terminal-results.js +141 -0
  92. package/workspace/public/pm/c3-terminal.js +52 -0
  93. package/workspace/public/pm/c3-tickets.js +163 -0
  94. package/workspace/public/pm/c3-timeline.js +201 -0
  95. package/workspace/public/pm/c3-toast.js +16 -0
  96. package/workspace/public/pm/calendar-views.js +1015 -0
  97. package/workspace/public/pm/console3-lift.md +220 -0
  98. package/workspace/public/pm/console3.css +1480 -0
  99. package/workspace/public/pm/dest-initiatives.js +16 -3
  100. package/workspace/public/pm/dest-mywork.js +8 -1
  101. package/workspace/public/pm/dest-notes.js +25 -3
  102. package/workspace/public/pm/dest-reports.js +47 -1
  103. package/workspace/public/pm/dest-today.js +55 -1
  104. package/workspace/public/pm/doc-health-panel.js +110 -0
  105. package/workspace/public/pm/doc-rail.js +9 -3
  106. package/workspace/public/pm/lifecycle-lens-model.js +228 -0
  107. package/workspace/public/pm/lifecycle-lens.js +261 -0
  108. package/workspace/public/pm/new-initiative-sheet.js +116 -21
  109. package/workspace/public/pm/outbox-panel.js +125 -0
  110. package/workspace/public/pm/pm-work-view.js +919 -0
  111. package/workspace/public/pm/releases-view.js +248 -0
  112. package/workspace/public/pm/report-families-view.js +503 -0
  113. package/workspace/public/pm/run-card.js +244 -0
  114. package/workspace/public/pm/sessions-panel.js +149 -0
  115. package/workspace/public/pm/shell-bell.js +162 -0
  116. package/workspace/public/pm/shell-model.js +83 -0
  117. package/workspace/public/pm/shell-phone.js +124 -0
  118. package/workspace/public/pm/shell-readaloud.js +53 -0
  119. package/workspace/public/pm/shell-recording.js +110 -0
  120. package/workspace/public/pm/shell-ticker.js +90 -0
  121. package/workspace/public/pm/slide-confirm.js +163 -0
  122. package/workspace/public/pm/timeline-forecast-view.js +164 -0
  123. package/workspace/public/pm/today-day.js +8 -2
  124. package/workspace/public/pm/tokens.css +1 -0
  125. package/workspace/server.js +5 -2
  126. package/workspace/test-files.json +8 -0
  127. package/workspace/test-lists/browser-suites-green.json +5 -0
@@ -0,0 +1,244 @@
1
+ /* pm/run-card.js — one run as a card, on agent-surfaces.html's card anatomy, from scripts/card-state.js cardFor() output.
2
+ * mountRunCard(host, ctx) ctx = { id, name, card, observed?, origin?, src, refresh(), openSession(), fileBody?(path), glossary?, accepted? }
3
+ * disposeRunCard(host)
4
+ * The card, top to bottom: the tag and where it came from; the headline; the work as a thin strip (the CLI's own tool calls when the card carries
5
+ * them: `observed`, otherwise the agent's reported steps, labelled as that); a permission card ("Wants to run"), the agent's question with its
6
+ * options as chips, or a failure box; or the result (hero and labelled claims, by type: c3-terminal-results.js); then the next moves the contract
7
+ * gives (Accept, Revise, Reject, Define, the agent's suggestions, Say) and a mic.
8
+ * Gestures (c3-swipe.js, card-state.swipeAction): swipe right = the card's primary move (Accept, on a finished result), swipe left = the reply tray
9
+ * (the agent's suggested chips and a free line). A swipe sends nothing outward. Keys: ArrowRight, ArrowLeft on the focused card.
10
+ * Presses: Accept is POST /api/runs/answer {behavior:'accept'}; Revise and Reject are POST /api/runs/correction (the correction signal); a permission
11
+ * is POST /api/runs/answer {allow|deny}; the agent's question is {answer, message: an offered option}. Every one is the PM's press. Anything that
12
+ * would send a prompt to the session (a suggestion, a revision, the free line, the mic) is drawn disabled with the reason: ctx.src.canPrompt.
13
+ * Per-run UI state (the open tray, the Revise chips, the flipped face) lives in `UI`, outside the render, so a poll never closes it. */
14
+ import { el } from './dom.js';
15
+ import { ic } from './c3-terminal-icons.js';
16
+ import { ensureC3Css } from './pm-work-view.js';
17
+ import { attachSwipe } from './c3-swipe.js';
18
+ import { toast } from './c3-toast.js';
19
+ import { resultNodes, claimsList } from './c3-terminal-results.js';
20
+
21
+ const live = new WeakMap();
22
+ const UI = new Map();
23
+ export const uiFor = (id) => { if (!UI.has(id)) UI.set(id, {}); return UI.get(id); };
24
+ const MOVE_ICON = { accept: 'check', revise: 'edit', reject: 'x', define: 'book', say: 'reply', suggest: 'spark' };
25
+ const TAGS = { asking: ['Needs your answer', 'var(--warning)'], permission: ['Needs your permission', 'var(--warning)'], attention: ['Needs the terminal', 'var(--warning)'], working: ['Working', 'var(--acc)'], queued: ['Starting', 'var(--acc)'],
26
+ not_started: ['Not started', 'var(--warning)'], paused: ['Paused', 'var(--warning)'], quiet: ['Quiet', 'var(--warning)'], degraded: ['Plain text only', 'var(--warning)'], failed: ['Failed', 'var(--destructive)'], done: ['Result', 'var(--success)'] };
27
+ const RESULT_TAG = { Critical: 'var(--destructive)', 'Just in': 'var(--acc)', 'Small win': 'var(--success)', 'Big win': 'var(--success)', Opportunity: 'var(--purple)' };
28
+ const MIN_QUIET = '2 minutes';
29
+
30
+ const pressErr = (r, what) => toast({ text: what + ' not recorded: ' + ((r && r.error) || 'the server did not answer'), tone: 'error' });
31
+ const dim = (t) => el('span', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, t);
32
+
33
+ function stripNode(card, observed) {
34
+ const obs = (observed || []).slice();
35
+ const steps = card.steps || [];
36
+ const last = obs.length ? (obs[obs.length - 1].tool + ' ' + obs[obs.length - 1].what) : steps[steps.length - 1];
37
+ const working = card.state === 'working' || card.state === 'queued';
38
+ const kids = [];
39
+ if (working) kids.push(el('div', { class: 'line', role: 'status' }, el('span', { class: 'c3spin' }, ic('run', 'sm')), el('span', { class: 't' }, last || card.title)));
40
+ if (obs.length) {
41
+ kids.push(el('div', { class: 'c3t-obs', 'aria-label': 'Tool calls the CLI made' }, obs.slice(-4).map((o) => el('span', { class: 'chip2', title: o.tool + ' ' + o.what }, el('b', null, o.tool), ' ' + o.what))));
42
+ kids.push(dim('observed: ' + obs.length + ' tool call' + (obs.length === 1 ? '' : 's') + ' the CLI made' + (steps.length ? ' · the agent reported ' + steps.length + ' step' + (steps.length === 1 ? '' : 's') : '')));
43
+ } else if (steps.length) {
44
+ kids.push(el('div', { class: 'c3t-obs' }, steps.slice(-3).map((s) => el('span', { class: 'chip2' }, s))), dim('reported by the agent, not checked'));
45
+ }
46
+ if (working && steps.length) kids.push(el('div', { class: 'prog', 'aria-hidden': 'true' }, steps.map(() => el('i', { class: 'd' })), el('i')));
47
+ return kids.length ? el('div', { class: 'c3t-run' }, kids) : null;
48
+ }
49
+
50
+ /* ── the held requests ── */
51
+ function permissionBox(ctx, p) {
52
+ const { src, id } = ctx;
53
+ const send = async (behavior, message) => { const r = await src.answer({ run: id, request: p.request, behavior, message: message || '' }); if (r && r.ok) { toast(behavior === 'allow' ? 'Allowed once. The CLI continues.' : 'Denied. Nothing left the repo.'); ctx.refresh(); } else pressErr(r, 'The answer'); };
54
+ return el('div', { class: 'ask', 'data-run-permission': p.request },
55
+ el('b', { class: 'chips2' }, ic('alert', 'sm'), 'Wants to run'),
56
+ el('div', { class: 'raw' }, '$ ' + (p.command || '')),
57
+ dim([p.why, 'in ' + (p.cwd || 'the session folder')].filter(Boolean).join(' · ')),
58
+ dim('The CLI is holding this call until you press.'),
59
+ el('div', { class: 'c3t-tray' }, el('button', { type: 'button', class: 'pill rec', 'data-perm': 'allow', onclick: () => send('allow') }, 'Allow once'),
60
+ p.remember ? el('button', { type: 'button', class: 'pill', 'data-perm': 'always', onclick: () => send('allow', 'always ' + p.remember) }, 'Allow pushes to ' + String(p.remember).split('/').pop()) : null,
61
+ el('button', { type: 'button', class: 'pill', 'data-perm': 'deny', onclick: () => send('deny', 'the PM denied this') }, 'Deny')));
62
+ }
63
+ function questionBox(ctx, card) {
64
+ const { src, id } = ctx;
65
+ const send = async (label) => { const r = await src.answer({ run: id, request: card.asked || card.ask.id, behavior: 'answer', message: label }); if (r && r.ok) { toast('Answered. The agent continues with your choice.'); ctx.refresh(); } else pressErr(r, 'The answer'); };
66
+ return el('div', { class: 'ask', 'data-run-question': card.ask.id }, el('b', null, card.ask.q), el('div', { class: 'c3t-tray' }, (card.ask.options || []).map((o) => el('button', { type: 'button', class: 'pill' + (o.rec ? ' rec' : ''), onclick: () => send(o.label) }, o.label))));
67
+ }
68
+
69
+ /* ── failure boxes: what failed, in words, and the way on ── */
70
+ function failBox(ctx, card, ui) {
71
+ const code = card.failure && card.failure.code;
72
+ const rejected = (card.problems || []).find((p) => /^result rejected/.test(p));
73
+ let title, why, warn = false; let retry = false; let asAgain = false;
74
+ if (card.state === 'quiet') { title = 'No output for ' + MIN_QUIET; why = 'It may be stuck. You can open the session.'; retry = true; warn = true; }
75
+ else if (card.state === 'not_started') { title = 'The session has not started'; why = 'No sign of life yet. The CLI may be waiting for you to trust the folder.'; warn = true; retry = true; }
76
+ else if (card.state === 'attention') { title = 'This session needs something only the terminal can show'; why = (card.attention && card.attention.message) || 'Open the session to continue.'; warn = true; }
77
+ else if (card.state === 'degraded') { title = 'The agent replied in plain text'; why = 'It did not use the format, so nothing here is checked: no labels, no sources. It cannot back a message.'; warn = true; asAgain = true; }
78
+ else if (card.state === 'failed') {
79
+ if (rejected) { title = 'The reply could not be read'; why = rejected.replace(/^result rejected: /, 'Result rejected: ') + '. Nothing on the card changed.'; }
80
+ else if (code === 'blocked') { title = 'Blocked'; why = card.failure.why; }
81
+ else if (code === 'api_error') { title = 'The turn failed on an API error'; why = 'Nothing on the card changed. Retrying usually works.'; retry = true; }
82
+ else if (code === 'format') { title = 'Recorded in a newer format'; why = card.failure.why; }
83
+ else if (code === 'crashed') { title = 'The session ended with an error'; why = 'It exited with a non-zero code. Nothing on the card changed.'; retry = true; }
84
+ else { title = 'The session ended without a usable result'; why = 'Nothing on the card changed.'; retry = true; }
85
+ } else return null;
86
+ const can = ctx.src.canPrompt;
87
+ const nodes = [el('b', { class: 'chips2' }, ic(warn ? 'alert' : 'x', 'sm'), title), dim(why)];
88
+ if (card.state === 'degraded' && card.fallbackText) nodes.push(el('div', { class: 'draft' }, card.fallbackText));
89
+ if (ui.raw && (card.problems || []).length) nodes.push(el('div', { class: 'raw' }, (card.problems || []).map((p) => el('div', null, '> ' + p))));
90
+ nodes.push(el('div', { class: 'c3t-tray' },
91
+ (retry || asAgain) ? el('button', { type: 'button', class: 'pill rec', 'data-retry': '', disabled: !can, title: can ? '' : ctx.src.why, onclick: async () => { const r = await ctx.src.send(ctx.id, { verb: 'retry' }); if (r && r.ok) ctx.refresh(); } }, asAgain ? 'Ask again in the format' : card.state === 'not_started' ? 'Try again' : 'Retry') : null,
92
+ openBtn(ctx),
93
+ (card.problems || []).length ? el('button', { type: 'button', class: 'pill', 'aria-pressed': String(!!ui.raw), onclick: () => { ui.raw = !ui.raw; ctx.redraw(); } }, ui.raw ? 'Hide' : 'What it said') : null,
94
+ el('button', { type: 'button', class: 'ib', 'aria-label': 'Dismiss', title: 'Dismiss this notice', onclick: () => { ui.hidden = card.state + ':' + (code || ''); ctx.redraw(); } }, ic('x', 'sm'))));
95
+ return el('div', { class: 'fail' + (warn ? ' w' : ''), 'data-run-fail': card.state, role: 'group' }, nodes);
96
+ }
97
+ function pausedBox(ctx, card) {
98
+ const at = (card.pause && card.pause.resume_at) || null;
99
+ return el('div', { class: 'ask', 'data-run-paused': '' }, el('b', { class: 'chips2' }, ic('run', 'sm'), 'Paused: usage limit'), dim(at ? 'Resumes by itself at about ' + at + '. Everything done so far is kept.' : 'The run is paused, not failed. Everything done so far is kept.'),
100
+ el('div', { class: 'prog', 'aria-hidden': 'true' }, el('i', { class: 'd' }), el('i', { class: 'd' }), el('i'), el('i')), el('div', { class: 'c3t-tray' }, openBtn(ctx)));
101
+ }
102
+ export function openBtn(ctx) {
103
+ return el('button', { type: 'button', class: 'pill c3t-open', 'data-open-session': '', title: 'Open the session: its real terminal, in the main app', onclick: () => ctx.openSession && ctx.openSession(ctx.id) }, ic('term', 'sm'), 'Open the session');
104
+ }
105
+
106
+ /* ── the next moves, the sub-chips, the tray, the voice line ── */
107
+ function movesNode(ctx, card, ui) {
108
+ const can = ctx.src.canPrompt;
109
+ const doMove = async (m) => {
110
+ if (m.verb === 'accept') {
111
+ const r = await ctx.src.answer({ run: ctx.id, request: 'result', behavior: 'accept' });
112
+ if (r && r.ok) { ui.accepted = true; ui.sub = null; toast('Recorded as your acceptance.'); ctx.refresh(); ctx.redraw(); } else pressErr(r, 'The acceptance');
113
+ } else if (m.verb === 'revise' || m.verb === 'reject' || m.verb === 'define') { ui.sub = ui.sub === m.verb ? null : m.verb; ui.def = null; ctx.redraw(); }
114
+ else if (m.verb === 'say') { ui.tray = !ui.tray; ctx.redraw(); }
115
+ else if (m.verb === 'suggest') { const r = await ctx.src.send(ctx.id, { verb: 'suggest', label: m.label, prompt: m.prompt }); if (r && r.ok) { toast('Asked: ' + m.label); ctx.refresh(); } }
116
+ };
117
+ const bar = el('div', { class: 'c3t-tray c3mv', 'data-run-moves': '' }, (card.nextMoves || []).map((m) => {
118
+ const off = (m.verb === 'suggest') && !can;
119
+ return el('button', { type: 'button', class: 'pill' + (ui.sub === m.verb || (m.verb === 'say' && ui.tray) ? ' on' : ''), 'data-move': m.verb, 'aria-pressed': ['revise', 'reject', 'define', 'say'].includes(m.verb) ? String(ui.sub === m.verb || (m.verb === 'say' && !!ui.tray)) : null,
120
+ disabled: off, title: m.by === 'agent' ? (off ? ctx.src.why + ' It would send: ' + m.prompt : 'The agent suggests this and will be told: ' + m.prompt) : null, onclick: () => doMove(m) }, ic(MOVE_ICON[m.verb] || 'spark', 'sm'), m.label);
121
+ }),
122
+ el('button', { type: 'button', class: 'pill', 'data-move': 'mic', 'aria-label': 'Speak', disabled: !(can || ctx.src.voice), title: ctx.src.voice ? 'Speak (transcribed on this machine; nothing leaves it)' : ctx.src.why, onclick: () => voiceStart(ctx) }, ic('mic', 'sm')),
123
+ el('span', { style: 'flex:1' }),
124
+ el('button', { type: 'button', class: 'ib', 'data-flip': '', 'aria-label': 'Flip to the case file', title: 'Case file', onclick: () => { ui.flip = true; ctx.redraw(); } }, ic('flip', 'sm')));
125
+ const sub = [];
126
+ if (ui.sub === 'revise') sub.push(el('div', { class: 'c3t-tray', 'data-run-revise': '' }, dim('How?'), (card.revise || []).map((t) => el('button', { type: 'button', class: 'pill', onclick: () => correct(ctx, 'revise', t) }, t))));
127
+ if (ui.sub === 'reject') sub.push(el('div', { class: 'c3t-tray', 'data-run-reject': '' }, dim('Why?'), (card.reject || []).map((t) => el('button', { type: 'button', class: 'pill', onclick: () => correct(ctx, 'reject', t) }, t))));
128
+ if (ui.sub === 'define') {
129
+ const g = ctx.glossary || {}; const keys = Object.keys(g);
130
+ sub.push(keys.length ? el('div', { class: 'c3t-tray' }, keys.map((k) => el('button', { type: 'button', class: 'pill' + (ui.defK === k ? ' on' : ''), onclick: () => { ui.defK = k; ui.def = g[k]; ctx.redraw(); } }, k))) : dim('No glossary term is defined for this run.'));
131
+ if (ui.def) sub.push(dim(ui.def));
132
+ }
133
+ return [bar, ...sub];
134
+ }
135
+ async function correct(ctx, kind, reason) {
136
+ const ui = uiFor(ctx.id);
137
+ const r = await ctx.src.correct({ run: ctx.id, kind, reason });
138
+ if (!(r && r.ok)) { pressErr(r, 'The correction'); return; }
139
+ ui.sub = null;
140
+ if (kind === 'reject') { ui.rejected = reason; toast('Rejected: ' + reason + '. Counted as a correction.'); }
141
+ else if (ctx.src.canPrompt) { await ctx.src.send(ctx.id, { verb: 'revise', arg: reason }); toast('Revised. Counted as a correction.'); }
142
+ else toast('Counted as a correction. The revision itself cannot be sent from this page: open the session.');
143
+ ctx.refresh(); ctx.redraw();
144
+ }
145
+
146
+ function trayNode(ctx, card, ui) {
147
+ const can = ctx.src.canPrompt;
148
+ const chips = (card.nextMoves || []).filter((m) => m.verb === 'suggest');
149
+ const send = async (text) => { const r = await ctx.src.send(ctx.id, { verb: 'free', text }); if (r && r.ok) { (ui.say = ui.say || []).push({ q: text, a: r.say }); ui.tray = false; ctx.refresh(); ctx.redraw(); } };
150
+ const input = el('input', { type: 'text', 'aria-label': 'Reply to the agent', placeholder: can ? 'Reply…' : 'Reply (not available here)', readonly: !can, disabled: !can, title: can ? '' : ctx.src.why, 'data-run-free': '' });
151
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) send(input.value.trim()); });
152
+ return el('div', { class: 'c3t-tray', 'data-run-tray': '' },
153
+ chips.map((m) => el('button', { type: 'button', class: 'pill', disabled: !can, title: can ? m.prompt : ctx.src.why + ' It would send: ' + m.prompt, onclick: async () => { const r = await ctx.src.send(ctx.id, { verb: 'suggest', label: m.label, prompt: m.prompt }); if (r && r.ok) { ui.tray = false; ctx.refresh(); ctx.redraw(); } } }, m.label)),
154
+ input,
155
+ el('button', { type: 'button', class: 'ib', 'aria-label': 'Speak', disabled: !ctx.src.voice, title: ctx.src.voice ? 'Speak (transcribed on this machine)' : ctx.src.why, onclick: () => voiceStart(ctx) }, ic('mic', 'sm')),
156
+ el('button', { type: 'button', class: 'ib', 'aria-label': 'Close reply', onclick: () => { ui.tray = false; ctx.redraw(); } }, ic('x', 'sm')),
157
+ can ? null : el('div', { class: 'dim mono', style: 'flex-basis:100%;font-size:var(--fs-1)' }, ctx.src.why));
158
+ }
159
+
160
+ function voiceStart(ctx) {
161
+ const ui = uiFor(ctx.id); if (ui.voice || !ctx.src.voice) return;
162
+ ui.tray = false; ui.voice = { phase: 'listen' }; ctx.redraw();
163
+ ui.vt = setTimeout(() => {
164
+ if (!ui.voice) return;
165
+ const v = ctx.src.voice(); ui.voice = { phase: 'heard', ...v, left: v.autosend_ms ? Math.round(v.autosend_ms / 1000) : 0 }; ctx.redraw();
166
+ if (ui.voice.left) ui.vi = setInterval(() => { if (!ui.voice || ui.voice.phase !== 'heard') { clearInterval(ui.vi); return; } ui.voice.left -= 1; if (ui.voice.left <= 0) voiceSend(ctx); else ctx.redraw(); }, 1000);
167
+ }, 1300);
168
+ }
169
+ async function voiceSend(ctx, text) {
170
+ const ui = uiFor(ctx.id); const v = ui.voice; if (!v) return;
171
+ clearInterval(ui.vi); const t = text || v.text; ui.voice = null;
172
+ const r = await ctx.src.send(ctx.id, { verb: 'free', text: t });
173
+ if (r && r.ok) (ui.say = ui.say || []).push({ q: t, a: r.say });
174
+ ctx.refresh(); ctx.redraw();
175
+ }
176
+ function voiceNode(ctx, ui) {
177
+ const v = ui.voice;
178
+ const stop = () => { clearInterval(ui.vi); clearTimeout(ui.vt); ui.voice = null; ctx.redraw(); };
179
+ if (v.phase === 'listen') return el('div', { class: 'ask', role: 'status' }, el('b', { class: 'chips2' }, el('span', { class: 'dotp' }), 'Listening'), dim('Transcribed on this machine. Nothing leaves it.'), el('div', { class: 'c3t-tray' }, el('button', { type: 'button', class: 'pill', onclick: stop }, 'Cancel')));
180
+ if (v.phase === 'edit') { const i = el('input', { type: 'text', value: v.text, 'aria-label': 'Edit the words' }); return el('div', { class: 'ask' }, dim('Edit the words, then send'), el('div', { class: 'c3t-tray' }, i, el('button', { type: 'button', class: 'pill rec', onclick: () => voiceSend(ctx, i.value) }, 'Send'), el('button', { type: 'button', class: 'pill', onclick: stop }, 'Cancel'))); }
181
+ return el('div', { class: 'ask', role: 'status' }, dim('Heard · ' + Math.round(v.confidence * 100) + '% sure' + (v.warn ? ' · check the words' : '')), el('b', null, '“' + v.text + '”'),
182
+ el('div', { class: 'c3t-tray' }, el('button', { type: 'button', class: 'pill rec', onclick: () => voiceSend(ctx) }, v.left ? 'Send in ' + v.left : 'Send'), el('button', { type: 'button', class: 'pill', onclick: () => { clearInterval(ui.vi); v.phase = 'edit'; ctx.redraw(); } }, 'Edit'), el('button', { type: 'button', class: 'pill', onclick: stop }, 'Cancel')));
183
+ }
184
+
185
+ function backNode(ctx, card, ui) {
186
+ const n = (card.claims || []).length;
187
+ const log = (ctx.observed || []).map((o) => o.tool + ' ' + o.what).join(' › ');
188
+ const row = (k, v) => [el('dt', null, k), el('dd', null, v)];
189
+ return [el('div', { class: 'top' }, el('span', { class: 'tg', style: '--tc:var(--acc)' }, ctx.card.state), dim('case file')), el('h3', null, ctx.name || ctx.id),
190
+ el('dl', { class: 'c3t-back' }, ...row('Origin', ctx.origin || 'your swipe'), ...row('Session', ctx.id + ' · a real CLI in a PTY, not shown'), ...row('State', card.state), ...row('Evidence', n + ' labelled claim' + (n === 1 ? '' : 's')), ...row('Log', log || (card.steps || []).join(' › ') || 'nothing run yet')),
191
+ el('div', { class: 'c3t-tray' }, openBtn(ctx), el('span', { style: 'flex:1' }), el('button', { type: 'button', class: 'ib', 'aria-label': 'Back to the card', onclick: () => { ui.flip = false; ctx.redraw(); } }, ic('flip', 'sm')))];
192
+ }
193
+
194
+ function draw(host, ctx) {
195
+ const card = ctx.card || {}; const ui = uiFor(ctx.id);
196
+ ctx.redraw = () => draw(host, ctx);
197
+ const old = live.get(host); if (old) old.off();
198
+ const done = card.state === 'done' && card.result;
199
+ const perms = (card.permissions || []).slice(); if (!perms.length && card.permission) perms.push({ request: card.permission.id, tool: card.permission.tool, command: card.permission.command, cwd: card.permission.cwd, why: card.permission.why });
200
+ const tag = done ? [(card.result.tag) || (ui.accepted ? 'Accepted' : 'Result'), RESULT_TAG[card.result.tag] || 'var(--success)'] : perms.length ? TAGS.permission : (TAGS[card.state] || ['Run', 'var(--acc)']);
201
+ const kids = [el('div', { class: 'top' }, el('span', { class: 'tg', style: '--tc:' + tag[1] }, tag[0]), dim(ctx.origin || ''))];
202
+ let swipeRight = false;
203
+ if (ui.flip) { kids.length = 0; kids.push(...backNode(ctx, card, ui)); }
204
+ else {
205
+ kids.push(el('h3', null, done && card.result.headline ? card.result.headline : (ctx.name || card.title)));
206
+ const strip = stripNode(card, ctx.observed); if (strip) kids.push(strip);
207
+ if (perms.length) perms.forEach((p) => kids.push(permissionBox(ctx, p)));
208
+ else if (card.state === 'asking' && card.ask) kids.push(questionBox(ctx, card));
209
+ else if (card.state === 'paused') kids.push(pausedBox(ctx, card));
210
+ else if (ui.hidden !== card.state + ':' + ((card.failure && card.failure.code) || '')) { const f = failBox(ctx, card, ui); if (f) kids.push(f); }
211
+ if (card.state === 'permission' && !perms.length) kids.push(dim('Waiting for your permission. Open the session to see it.'));
212
+ if (done) {
213
+ kids.push(...resultNodes(card.result, { ui, redraw: ctx.redraw, fileBody: ctx.fileBody }).filter(Boolean));
214
+ (ui.say || []).forEach((s) => kids.push(el('div', { class: 'sayb' }, el('div', { class: 'm me' }, s.q), el('div', { class: 'm bot' }, s.a && s.a.basis ? el('span', { class: 'lab l-est' }, s.a.basis) : null, s.a ? s.a.text : ''))));
215
+ if (ui.rejected) kids.push(el('div', { class: 'chips2' }, el('span', { class: 'chip2' }, 'Rejected: ' + ui.rejected + ' · counted as a correction')));
216
+ else if (ui.accepted || ctx.accepted) kids.push(el('div', { class: 'chips2' }, el('span', { class: 'chip2 ok' }, 'Accepted by you'), el('button', { type: 'button', class: 'ib', 'aria-label': 'Flip to the case file', onclick: () => { ui.flip = true; ctx.redraw(); } }, ic('flip', 'sm'))));
217
+ else if (!ctx.readonly) { kids.push(...movesNode(ctx, card, ui)); swipeRight = (card.nextMoves || []).some((m) => m.verb === 'accept'); }
218
+ }
219
+ if (ui.voice) kids.push(voiceNode(ctx, ui));
220
+ else if (ui.tray) kids.push(trayNode(ctx, card, ui));
221
+ if (!done && !ui.tray && !ui.voice && !perms.length && card.state !== 'asking' && !['working', 'queued'].includes(card.state)) { /* a failure box carries its own moves */ }
222
+ if (!done && !ctx.readonly) kids.push(el('div', { class: 'c3t-tray' }, el('span', { style: 'flex:1' }), el('button', { type: 'button', class: 'ib', 'aria-label': 'Flip to the case file', title: 'Case file', onclick: () => { ui.flip = true; ctx.redraw(); } }, ic('flip', 'sm'))));
223
+ }
224
+ const art = el('article', { class: 'c3t-card' + (['working', 'queued'].includes(card.state) ? ' busy' : '') + (ui.flash ? ' flash' : ''), 'data-run-card': card.state || '', 'data-run-id': ctx.id, 'aria-label': ctx.name || ctx.id }, ...kids);
225
+ host.replaceChildren(el('div', { class: 'c3 c3e' }, art));
226
+ const off = attachSwipe(art, {
227
+ onRight: swipeRight && !ctx.readonly ? () => { const m = (card.nextMoves || []).find((x) => x.verb === 'accept'); if (m) ctx.src.answer({ run: ctx.id, request: 'result', behavior: 'accept' }).then((r) => { if (r && r.ok) { ui.accepted = true; toast('Recorded as your acceptance.'); ctx.refresh(); ctx.redraw(); } else pressErr(r, 'The acceptance'); }); } : null,
228
+ onLeft: ui.flip || ctx.readonly ? null : () => { ui.tray = true; ui.sub = null; ctx.redraw(); },
229
+ labelRight: 'accept', labelLeft: 'reply', iconRight: 'check', iconLeft: 'reply' });
230
+ live.set(host, { off });
231
+ }
232
+
233
+ export function mountRunCard(host, ctx = {}) {
234
+ ensureC3Css();
235
+ const c = { ...ctx };
236
+ if (!c.src) c.src = { canPrompt: false, why: 'No prompt route.', answer: async () => ({ ok: false, error: 'no source' }), correct: async () => ({ ok: false, error: 'no source' }), send: async () => ({ ok: false }) };
237
+ if (!c.src.send) c.src = { ...c.src, send: async () => ({ ok: false, error: c.src.why }) };
238
+ c.refresh = c.refresh || (() => {}); c.id = c.id || 'run';
239
+ draw(host, c);
240
+ }
241
+ export function disposeRunCard(host) {
242
+ const o = live.get(host); if (o) o.off();
243
+ live.delete(host); host.replaceChildren();
244
+ }
@@ -0,0 +1,149 @@
1
+ /* pm/sessions-panel.js — the sessions surface of the agent surfaces page (agent-surfaces.html: the Sessions popover plus the cards it lists).
2
+ * mountSessions(host, ctx) disposeSessions(host)
3
+ * Four groups from GET /api/runs (scripts/sessions-list.js groupSessions): On a card (a run tied to a card, drawn as its card, open), Started by me
4
+ * (rows that open to the run's card and a blank-session panel), Past (7 days; rows that open to the timeline and the result), Automatic (behind a
5
+ * switch, off; a trigger started them and their result arrives as a card). Each run is a run-card.js card (card-state.js cardFor): the question, the
6
+ * permission card, the result with labelled claims and its next moves. Data and presses go through c3-terminal-data.js: real = /api/runs,
7
+ * /api/runs/answer, /api/runs/correction; demo = the fixtures of c3-demo-term.js.
8
+ * Not drawn: the mockup's Rules card ("Ask before the first push": no such setting exists). Nothing here sends anything outward. */
9
+ import { el } from './dom.js';
10
+ import { ensureC3Css } from './pm-work-view.js';
11
+ import { ic } from './c3-terminal-icons.js';
12
+ import { mountRunCard, disposeRunCard, openBtn, uiFor } from './run-card.js';
13
+ import { source, needsYou } from './c3-terminal-data.js';
14
+ import { attachExpand, makeXp } from './c3-expand.js';
15
+ import { toast } from './c3-toast.js';
16
+
17
+ const live = new WeakMap();
18
+ const DOT = { working: 'w', queued: 'w', asking: 'n', permission: 'n', attention: 'n', not_started: 'n', failed: 'b', quiet: '', paused: 'n', done: '' };
19
+
20
+ const ago = (iso) => { const m = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60000)); return Number.isFinite(m) ? (m < 1 ? 'just now' : m < 60 ? m + ' min' : m < 1440 ? Math.round(m / 60) + ' h' : Math.round(m / 1440) + ' d') : ''; };
21
+ const when = (iso) => { const d = new Date(iso); return Number.isFinite(+d) ? d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : ''; };
22
+ const stateWord = (c) => (c.permissions && c.permissions.length ? 'waiting for your allow or deny' : ({ asking: 'asking you', permission: 'waiting for your allow or deny', working: 'working', queued: 'starting', quiet: 'quiet', failed: 'failed', done: 'result ready', attention: 'needs the terminal', not_started: 'not started', paused: 'paused', degraded: 'plain text only' })[c.state] || c.state);
23
+ function outcomeOf(card, meta) {
24
+ if (meta && meta.out) return meta.out;
25
+ if (!card) return 'ended';
26
+ if (card.state === 'done' && card.result) return 'Result: ' + card.result.type;
27
+ if (card.state === 'failed') return (card.failure && card.failure.why) || 'Failed';
28
+ return card.title || 'ended';
29
+ }
30
+
31
+ function cardHost(st, s, card, extra) {
32
+ const host = el('div', { 'data-run-host': s.id });
33
+ const ctx = { id: s.id, name: s.name, card, observed: st.data.demo && st.data.demo.observed ? st.data.demo.observed[s.id] : null, origin: s.origin === 'manual' ? (s.cardId ? 'on a card' : 'started by you') : s.origin, src: st.src,
34
+ refresh: st.refresh, openSession: st.openSession, glossary: st.data.demo && st.data.demo.glossary, fileBody: st.data.demo && st.data.demo.fileBody, accepted: false, ...(extra || {}) };
35
+ mountRunCard(host, ctx);
36
+ return host;
37
+ }
38
+
39
+ function section(icon, title, count, right, body) {
40
+ return el('section', { class: 'card sect c3t-sect' }, el('div', { class: 'sh2' }, el('h2', null, ic(icon, 'sm'), title + ' ', el('span', { class: 'bdg g' }, String(count))), right || null), body);
41
+ }
42
+ const none = (t) => el('div', { class: 'fold' }, ic('check', 'sm'), t);
43
+
44
+ function row(st, s, card, parts) {
45
+ const d = DOT[(card && card.state)] || '';
46
+ const li = el('li', { class: 'nrow c3t-row', 'data-row': '', 'data-run': s.id });
47
+ const xp = makeXp(parts.body);
48
+ li.append(el('span', { class: 'ki' }, el('span', { class: 'mk ' + d }, '●')), el('div', { class: 'nb' }, el('span', { class: 'tt' }, s.name), el('div', { class: 'chips' }, dim(parts.sub))), el('span', { class: 'c3t-chev' }, ic('chev', 'sm')), xp);
49
+ li.style.gridTemplateColumns = 'auto minmax(0,1fr) auto';
50
+ xp.style.gridColumn = '1 / -1';
51
+ const key = 'r-' + s.id;
52
+ attachExpand(li, xp, { key, initial: st.open.has(key), onToggle: (on) => { if (on) st.open.add(key); else st.open.delete(key); } });
53
+ return li;
54
+ }
55
+ const dim = (t) => el('span', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, t);
56
+
57
+ function blankPanel(st, s, card) {
58
+ const ui = uiFor(s.id); const can = st.src.canPrompt;
59
+ const chat = ui.chat || (ui.chat = [['bot', 'What would you like to look at?']]);
60
+ const redraw = () => { st.redraw(); };
61
+ const say = async (v) => { chat.push(['me', v], ['st', 'Reading planning/ …']); redraw(); const r = await st.src.send(s.id, { verb: 'free', text: v }); chat.pop(); chat.push(['bot', (r && r.say && r.say.text) || 'Done.']); redraw(); };
62
+ const input = el('input', { type: 'text', 'aria-label': 'Message to the agent', placeholder: can ? 'Message the agent' : 'Message (not available here)', readonly: !can, disabled: !can, title: can ? '' : st.src.why, 'data-run-chat': '' });
63
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) say(input.value.trim()); });
64
+ return el('div', { class: 'c3t-panel', 'data-blank-panel': s.id },
65
+ can ? el('div', { class: 'feed' }, chat.map((m) => el('div', { class: 'm ' + m[0] }, m[1]))) : null,
66
+ can ? el('div', { class: 'c3t-tray' }, ["Review this week's slips", 'Draft the monthly report'].map((t, i) => el('button', { type: 'button', class: 'pill' + (i ? '' : ' rec'), onclick: () => say(t) }, t))) : null,
67
+ el('div', { class: 'c3t-tray' }, input, openBtn({ id: s.id, openSession: st.openSession })),
68
+ can ? null : dim(st.src.why));
69
+ }
70
+
71
+ function tlBody(st, s, card, meta) {
72
+ const tl = st.data.demo && st.data.demo.timeline;
73
+ const kids = [];
74
+ const items = tl ? tl.entries.filter((e) => e.run === s.id).reverse() : null;
75
+ if (items && items.length) {
76
+ kids.push(el('div', { class: 'c3t-tl2', 'data-timeline': s.id }, items.map((e) => el('div', { class: 'tlr' }, el('span', { class: 'lab ' + (e.src === 'observed' ? 'l-obs' : e.src === 'you' ? 'l-you' : 'l-rep') }, e.src), el('span', null, e.text)))),
77
+ dim('Observed is what the CLI did. Reported is what the agent said, with labels. You is what you pressed.'));
78
+ } else kids.push(dim('The timeline of what the CLI did, what the agent said and what you pressed is not served: GET /api/runs carries the cards only.'));
79
+ const resume = tl && tl.resume.find((r) => r.run === s.id);
80
+ kids.push(el('div', { class: 'c3t-tray' }, el('button', { type: 'button', class: 'pill', disabled: true, title: 'Resuming starts a session over the terminal connection. This page has no route for it.' }, ic('run', 'sm'), 'Continue this session'), openBtn({ id: s.id, openSession: st.openSession }),
81
+ resume ? el('span', { class: 'dim mono', style: 'font-size:var(--fs-1)' }, resume.command) : null));
82
+ return kids;
83
+ }
84
+
85
+ function draw(host, st) {
86
+ const d = st.data;
87
+ host.querySelectorAll('[data-run-host]').forEach((h) => { try { disposeRunCard(h); } catch { /* gone */ } });
88
+ host.replaceChildren();
89
+ if (!d || !d.ok) { host.append(el('p', { class: 'dim mono', role: 'status' }, d && d.error ? 'The runs could not be read: ' + d.error : 'Reading the runs')); return; }
90
+ const root = el('div', { class: 'c3 c3e c3t', 'data-sessions': '' });
91
+ const g = d.groups || {}; const cards = d.cards || {};
92
+ /* On a card: each run is its card */
93
+ const on = g.onCard || [];
94
+ root.append(section('spark', 'On a card', on.length, null, on.length
95
+ ? el('div', { class: 'c3t-cards', 'data-c3t': 'on-card' }, on.map((s) => cardHost(st, s, cards[s.id] || { state: 'working', title: 'Working', nextMoves: [], claims: [], steps: [] })))
96
+ : none('None running. A result appears on its card.')));
97
+ /* Started by me */
98
+ const mine = g.startedByMe || [];
99
+ const add = el('a', { class: 'chip2 lk c3t-new', href: '/', title: 'Start a session in the main app. A session starts over the terminal connection; this page has no route for it.' }, ic('plus', 'sm'), 'New');
100
+ root.append(section('term', 'Started by me', mine.length, add, mine.length
101
+ ? el('ul', { class: 'list', 'data-c3t': 'mine' }, mine.map((s) => { const c = cards[s.id]; return row(st, s, c, { sub: stateWord(c || { state: 'working' }) + ' · ' + ago(s.startedAt) + ' · main tree', body: [cardHost(st, s, c || { state: 'working', title: 'Working', nextMoves: [], claims: [], steps: [] }), blankPanel(st, s, c)] }); }))
102
+ : none('None. Start one in the main app.')));
103
+ /* Past */
104
+ const past = g.past || [];
105
+ root.append(section('clock', 'Past (7 days)', past.length, null, past.length
106
+ ? el('ul', { class: 'list', 'data-c3t': 'past' }, past.map((s) => { const c = cards[s.id]; const meta = d.demo && d.demo.pastMeta && d.demo.pastMeta.find((m) => m.id === s.id);
107
+ return row(st, s, c, { sub: when(s.endedAt || s.startedAt) + ' · ' + outcomeOf(c, meta), body: [...tlBody(st, s, c, meta), c ? cardHost(st, s, c, { readonly: true }) : null].filter(Boolean) }); }))
108
+ : none('Nothing in the last 7 days.')));
109
+ /* Automatic: hidden unless switched on */
110
+ const auto = g.automatic || [];
111
+ const running = auto.filter((s) => s.endedAt == null).length;
112
+ const sw = el('button', { type: 'button', class: 'c3t-sw', role: 'switch', 'aria-checked': String(st.showAuto), 'aria-label': 'Show automatic sessions', 'data-sessions-auto': '', onclick: () => { st.showAuto = !st.showAuto; st.redraw(); } }, el('i'));
113
+ root.append(section('bolt', 'Automatic', auto.length, el('span', { class: 'chips2' }, dim(auto.length + ' today' + (running ? ', ' + running + ' running' : '')), sw), st.showAuto
114
+ ? (auto.length ? el('ul', { class: 'list', 'data-c3t': 'auto' }, auto.map((s) => { const c = cards[s.id]; return row(st, s, c, { sub: s.origin + ' · ' + (s.endedAt == null ? 'running' : 'ended') + ' · ' + ago(s.startedAt) + ' · ' + outcomeOf(c), body: c ? [cardHost(st, s, c, { readonly: true })] : [dim('No card yet.')] }); })) : none('None.'))
115
+ : el('div', { class: 'dim', style: 'font-size:var(--fs-2)' }, 'Hidden. They run, write their result onto a card and clean up. Switch on to see them.')));
116
+ host.append(root);
117
+ }
118
+
119
+ export function mountSessions(host, ctx = {}) {
120
+ disposeSessions(host);
121
+ if (ctx && ctx.FIXTURE) return;
122
+ ensureC3Css();
123
+ const st = { src: source(), data: null, showAuto: false, last: '', gone: false, timer: null, refresh: null, redraw: null, open: new Set(), openSession: (id) => { try { window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id } })); } catch { /* no event */ } } };
124
+ if (st.src.demo) st.openSession = () => toast('In the real workspace this opens the CLI session.');
125
+ st.redraw = () => draw(host, st);
126
+ const busy = () => host.contains(document.activeElement) && ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName);
127
+ st.refresh = async () => {
128
+ const r = await st.src.getRuns();
129
+ if (st.gone || !r) return;
130
+ const key = JSON.stringify(r);
131
+ if (key === st.last) return;
132
+ st.last = key; st.data = r;
133
+ if (r.ok && ctx.onNeeds) ctx.onNeeds(needsYou(r));
134
+ if (!busy()) draw(host, st); else st.last = '';
135
+ };
136
+ st.timer = setInterval(() => { if (!busy()) st.refresh(); }, 5000);
137
+ if (st.src.subscribe) st.unsub = st.src.subscribe(() => st.refresh());
138
+ live.set(host, st);
139
+ host.append(el('p', { class: 'dim mono', role: 'status' }, 'Reading the runs'));
140
+ st.refresh();
141
+ }
142
+
143
+ export function disposeSessions(host) {
144
+ const st = live.get(host);
145
+ if (st) { st.gone = true; clearInterval(st.timer); if (st.unsub) st.unsub(); }
146
+ live.delete(host);
147
+ host.querySelectorAll('[data-run-host]').forEach((h) => { try { disposeRunCard(h); } catch { /* gone */ } });
148
+ host.replaceChildren();
149
+ }
@@ -0,0 +1,162 @@
1
+ /* pm/shell-bell.js — the bell, its list and the banner (console3 wave E2b), and haptics.
2
+ *
3
+ * mountBell(host, { go }) / disposeBell(host) the bell button with a count; a press opens the list and does nothing else
4
+ * openBellList() the list popup (also what a press on the ticker opens)
5
+ * buzz(kind) navigator.vibrate(hapticFor(kind)) only when it exists and motion is allowed
6
+ *
7
+ * The count is the number of cards (bellCount). The list shows each card's headline under its tag. The banner is the pinned Critical card
8
+ * the briefing names; it dismisses for the session and never comes back for the same card. A bell press never runs an action: a row
9
+ * only closes the list and goes to Today, where the card is. Lifted: console3.html lines 2476 (bellBtn), 2477 (syncBell), 2506 to 2515
10
+ * (bsHtml, bannerHtml), and the .nli row (line 503). Not lifted: the mockup's invented notifications and its timed live arrival.
11
+ */
12
+ import { el } from './dom.js';
13
+ import { ensureC3Css, c3ic, c3Phone } from './pm-work-view.js';
14
+ import { TAG_STYLE } from './briefing-model.js';
15
+ import { bellCount, bannerOf, hapticFor } from './shell-model.js';
16
+ import { notifsOf } from './briefing-model.js';
17
+ import { openSheet } from './c3-sheet.js';
18
+ import { onBriefing, loadBriefing, reducedMotion } from './shell-ticker.js';
19
+
20
+ export function buzz(kind) {
21
+ try {
22
+ const p = hapticFor(kind, reducedMotion());
23
+ if (p && typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function') navigator.vibrate(p);
24
+ } catch { /* no haptics here */ }
25
+ }
26
+
27
+ const seenKeys = new Set(); // banners dismissed this session
28
+ let goTo = null;
29
+ let popup = null; // { node, off }
30
+
31
+ function closePopup() {
32
+ if (!popup) return;
33
+ document.removeEventListener('keydown', popup.onKey, true);
34
+ popup.off && popup.off();
35
+ popup.stop && popup.stop();
36
+ popup.node.remove();
37
+ popup = null;
38
+ }
39
+
40
+ /** A popup root: `.c3.c3pop` carries the scope, `.ph` on a narrow window turns the sheet into a bottom sheet. */
41
+ export function openPopup({ title, icon, body, wide, onClose }) {
42
+ closePopup();
43
+ ensureC3Css();
44
+ const root = el('div', { class: 'c3 c3e c3pop', 'data-shell': 'popup' });
45
+ const stopPhone = c3Phone(root);
46
+ const close = () => { closePopup(); if (onClose) onClose(); };
47
+ const sheet = el('div', { class: 'sheet bs' + (wide ? ' wide' : ''), role: 'dialog', 'aria-label': title },
48
+ el('div', { class: 'grab' }),
49
+ el('div', { class: 'sh2' }, el('span', { class: 'chips2' }, c3ic(icon || 'bell'), el('b', null, title)),
50
+ el('button', { type: 'button', class: 'ib sm', 'aria-label': 'Close', title: 'Close', onclick: close }, c3ic('x'))),
51
+ el('div', { class: 'sb' }, el('div', { class: 'brief sbb' }, body)));
52
+ root.append(el('div', { class: 'scrim', onclick: close }), sheet);
53
+ const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } };
54
+ document.addEventListener('keydown', onKey, true);
55
+ document.body.appendChild(root);
56
+ popup = { node: root, onKey, off: null, stop: stopPhone };
57
+ return { root, sheet, close, body: sheet.querySelector('.sbb') };
58
+ }
59
+
60
+ /* The bell list, as the mockup draws it (console3.html bsHtml 2493 to 2499, nopen 2530): a sheet (c3-sheet.js) with one `button.nli` per entry, its tag colour,
61
+ * title, when and an unread dot. Opening it reads everything (the badge goes). On a desktop window there is no scrim, so a press outside acts on the page; Esc, the
62
+ * X and the bell again close it. A press on an entry closes the sheet and goes to its target: the card to flip, the row to open, or the sheet to show. */
63
+ const live = new WeakMap();
64
+ const arrived = []; // notifications that arrived while the page was open (demo: the 7 s banner)
65
+ let unreadSeen = 0; // how many the person has already looked at
66
+ let lastCount = 0;
67
+ let bellSheet = null;
68
+ let liveTimer = null; let liveDone = false;
69
+ const notifList = (b) => [...arrived, ...notifsOf(b)];
70
+
71
+ export function openBellList() {
72
+ if (bellSheet && bellSheet.root.isConnected) { bellSheet.close(); return bellSheet; }
73
+ const list = el('div');
74
+ const fill = (b) => {
75
+ const items = notifList(b);
76
+ list.replaceChildren(...(items.length ? items.map((x) => {
77
+ const st = TAG_STYLE[x.tag] || { icon: 'bolt', color: 'var(--muted-foreground)' };
78
+ return el('button', { type: 'button', class: 'nli', 'data-bell-item': x.key || '', onclick: () => open(x) },
79
+ el('span', { class: 'ki', style: 'color:' + st.color }, c3ic(x.icon || st.icon)),
80
+ el('span', { class: 't' }, el('b', { class: 'mono', style: 'font-size:var(--fs-1);letter-spacing:.06em;text-transform:uppercase;color:' + st.color }, x.tag), el('br'), x.headline),
81
+ el('span', { class: 'w' }, x.when || '', x.unread ? el('i', { class: 'u' }) : null));
82
+ }) : [el('div', { class: 'fold' }, c3ic('check', 'sm'), 'Nothing is waiting for you')]));
83
+ };
84
+ let off = null;
85
+ const open = (x) => { x.unread = false; if (bellSheet) bellSheet.close(); if (goTo) goTo('today', x.go || null); };
86
+ const p = openSheet({ title: 'Notifications', icon: 'bell', body: list, onClose: () => { if (off) off(); bellSheet = null; for (const x of arrived) x.unread = false; } });
87
+ p.root.classList.add('noscrim');
88
+ bellSheet = p;
89
+ off = onBriefing(fill);
90
+ /* opening reads them all: the badge shows only what arrives after this */
91
+ unreadSeen = lastCount; for (const rec of liveRecs()) rec.sync();
92
+ loadBriefing();
93
+ return p;
94
+ }
95
+
96
+ const liveRecs = () => [...recSet];
97
+ const recSet = new Set();
98
+
99
+ export function mountBell(host, opts) {
100
+ if (!host) return;
101
+ disposeBell(host);
102
+ ensureC3Css();
103
+ if (opts && opts.go) goTo = opts.go;
104
+ const badge = el('span', { class: 'nbdg', hidden: true, 'data-bell-count': '' }, '0');
105
+ const btn = el('button', { type: 'button', class: 'ib', title: 'Notifications', 'aria-label': 'Notifications', 'data-shell': 'bell', onclick: () => openBellList() }, c3ic('bell'), badge);
106
+ host.appendChild(btn);
107
+ const rec = { btn, count: 0, banner: null, b: null, sync: null, timer: null };
108
+ const dropBanner = () => { if (rec.banner) { clearTimeout(rec.timer); rec.banner.remove(); rec.banner = null; } };
109
+ const showBanner = (bn, auto) => {
110
+ const st = TAG_STYLE[bn.tag] || TAG_STYLE.Critical;
111
+ const wrap = el('div', { class: 'c3 c3e c3pop', 'data-shell': 'banner', 'data-key': bn.key });
112
+ const dismiss = (e) => { if (e) e.stopPropagation(); seenKeys.add(bn.key); dropBanner(); };
113
+ wrap.appendChild(el('div', { class: 'banner', role: 'status', 'data-a': 'nopen', tabindex: '0',
114
+ onclick: () => { dismiss(); if (bn.entry) bn.entry.unread = false; if (goTo) goTo('today', bn.go || null); },
115
+ onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.currentTarget.click(); } else if (e.key === 'Escape') dismiss(e); } },
116
+ el('span', { style: 'color:' + st.color + ';filter:brightness(1.4)' }, c3ic(st.icon, 'sm')),
117
+ el('span', null, el('b', null, bn.tag), el('br'), bn.headline),
118
+ auto ? null : el('button', { type: 'button', class: 'bx', 'aria-label': 'Dismiss', title: 'Dismiss', onclick: dismiss }, c3ic('x', 'sm'))));
119
+ document.body.appendChild(wrap);
120
+ rec.banner = wrap;
121
+ if (auto) { clearTimeout(rec.timer); rec.timer = setTimeout(dropBanner, 5500); }
122
+ buzz(auto ? 'confirm' : 'critical');
123
+ };
124
+ rec.sync = () => {
125
+ const n = Math.max(0, rec.count - unreadSeen);
126
+ badge.textContent = String(n); badge.hidden = n === 0;
127
+ btn.setAttribute('aria-label', n ? 'Notifications, ' + n : 'Notifications');
128
+ };
129
+ rec.off = onBriefing((b) => {
130
+ rec.b = b;
131
+ const n = bellCount(b) + arrived.length;
132
+ const bump = n > rec.count; rec.count = n; lastCount = n;
133
+ rec.sync();
134
+ if (bump) { badge.classList.remove('bump'); void badge.offsetWidth; badge.classList.add('bump'); }
135
+ /* the demo's live arrival (console3.html liveArrive, line 2509): 7 s after load a banner drops in, the count goes up by one, it leaves by itself after 5.5 s */
136
+ if (b && b.live && !liveDone && !liveTimer) {
137
+ liveTimer = setTimeout(() => {
138
+ liveDone = true;
139
+ const x = { key: 'live', tag: b.live.tag || 'Just in', headline: b.live.headline, when: 'now', unread: true, go: b.live.go || null };
140
+ arrived.unshift(x);
141
+ for (const r of recSet) { r.count += 1; lastCount = r.count; r.sync(); }
142
+ showBanner({ key: 'live', tag: x.tag, headline: x.headline, go: x.go, entry: x }, true);
143
+ }, Math.max(0, 7000 - (typeof performance !== 'undefined' ? performance.now() : 0))); // 7 s after the page loaded, as the mockup's setTimeout(liveArrive, 7000), not 7 s after the briefing came back
144
+ }
145
+ const bn = bannerOf(b, seenKeys);
146
+ if (!bn) { if (!rec.banner || rec.banner.dataset.key !== 'live') dropBanner(); return; }
147
+ if (rec.banner && rec.banner.dataset.key === bn.key) return;
148
+ dropBanner();
149
+ showBanner({ ...bn, go: { card: bn.key } }, false);
150
+ });
151
+ rec.dropBanner = dropBanner;
152
+ recSet.add(rec);
153
+ live.set(host, rec);
154
+ loadBriefing();
155
+ }
156
+
157
+ export function disposeBell(host) {
158
+ const rec = host && live.get(host);
159
+ if (!rec) return;
160
+ rec.off && rec.off(); rec.dropBanner(); rec.btn.remove(); closePopup(); if (bellSheet) bellSheet.close();
161
+ recSet.delete(rec); live.delete(host);
162
+ }