create-pmos 0.48.0 → 0.49.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 +2 -2
  3. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  4. package/payload/mods/pmos-cockpit/README.md +10 -1
  5. package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
  6. package/payload/mods/pmos-cockpit/hooks/register.tsx +67 -1
  7. package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
  8. package/payload/planning/evals/eval-calibration.md +1 -1
  9. package/payload/planning/okrs/health-budgets.md +1 -1
  10. package/payload/scripts/status-facts.js +13 -3
  11. package/payload/scripts/workflow-state.js +7 -2
  12. package/payload/state/discoveries.md +1 -1
  13. package/payload/state/log.md +1 -1
  14. package/workspace/guide/index.md +2 -1
  15. package/workspace/guide/initiatives-and-next-steps.md +3 -1
  16. package/workspace/guide/knowledge-graph.md +12 -1
  17. package/workspace/guide/layout.md +2 -0
  18. package/workspace/guide/live-notes.md +78 -0
  19. package/workspace/guide/validate-with-typesafe.md +15 -1
  20. package/workspace/lib/engine/status-facts.js +13 -3
  21. package/workspace/lib/engine/workflow-state.js +7 -2
  22. package/workspace/lib/jev-categorize.js +47 -13
  23. package/workspace/lib/jev-live.js +37 -0
  24. package/workspace/lib/jev-route.js +11 -1
  25. package/workspace/lib/live-notes.js +171 -26
  26. package/workspace/package-lock.json +1 -1
  27. package/workspace/package.json +2 -2
  28. package/workspace/public/pm/data.js +21 -1
  29. package/workspace/public/pm/modes-live.js +68 -5
  30. package/workspace/public/pm/panel-cockpit.js +3 -1
  31. package/workspace/public/pm/panel-detail.js +3 -1
  32. package/workspace/public/pm/panel-tabs.js +112 -10
  33. package/workspace/public/pm/state.js +3 -0
  34. package/workspace/public/pm/view-livenotes.js +397 -0
  35. package/workspace/server.js +66 -7
@@ -351,7 +351,7 @@ function factsFor(row) {
351
351
  for (const a of sa.approvals || []) {
352
352
  const what = a.kind === 'contract' ? 'the contract was approved' : 'you accepted it';
353
353
  if (a.status === 'unknown') out.push({ tone: 'info', label: 'Since approval', text: 'Cannot tell for ' + what + '. ' + (a.reason || '') });
354
- else if (!(a.changed || []).length) out.push({ tone: 'ok', label: 'Since approval', text: 'No spec file changed after ' + what + '.' });
354
+ else if (!(a.changed || []).length) out.push({ tone: 'ok', label: 'Since approval', text: (row.profile === 'pm-artifacts' ? 'No brief or deliverable' : 'No spec file') + ' changed after ' + what + '.' });
355
355
  else {
356
356
  const files = [...new Set(a.changed.map((x) => x.file.split('/').pop()))].join(', ');
357
357
  const last = a.changed[a.changed.length - 1];
@@ -385,6 +385,25 @@ function suggestionLines(sg) {
385
385
  return (sg.items || []).map((x) => ({ tone: 'info', label: 'Documents suggest', text: (x.sentence || (x.doc + ': ' + x.kind)) + ' A suggestion, not a check' + (x.model ? ' (' + x.model + ')' : '') + '.' }));
386
386
  }
387
387
 
388
+ /**
389
+ * Why "What the documents say" has no line yet, in words, so an empty section is never mistaken for a missing one. Each
390
+ * check names what it waits for. Null when there is no ladder (the panel says that elsewhere) or when a line exists.
391
+ */
392
+ function factsWhy(row, lines) {
393
+ if (!row || (lines && lines.length)) return null;
394
+ const why = [];
395
+ if (row.profile === 'pm-artifacts') {
396
+ why.push('Since approval starts when you accept this work: after that, a commit to its brief or deliverable shows here.');
397
+ } else {
398
+ why.push('Since approval starts when a contract is approved or the work is accepted.');
399
+ if (row.lane !== 'feature') why.push('Coverage is for feature-lane initiatives, and this one is ' + (row.lane || 'not in a lane') + '.');
400
+ else why.push('Coverage needs both a PRD and a rubric named in the record.');
401
+ why.push('Record says checks a status line that starts with a stage word (intake, spec, contract, build, eval or accepted).');
402
+ }
403
+ why.push('Jev suggestions appear only when "read documents for status" is on in Validate.');
404
+ return 'Nothing to compare yet. ' + why.join(' ');
405
+ }
406
+
388
407
  /** The Review-Gate line, read off the `review` rung — no verdict is not a pass. */
389
408
  function evalFor(ladderSteps) {
390
409
  const r = (ladderSteps || []).find((s) => s.key === 'review');
@@ -517,6 +536,7 @@ function recordFrom(payload) {
517
536
  gitFiles: payload.files || null,
518
537
  eval: evalFor(row && row.steps),
519
538
  facts: factsFor(row).concat(suggestionLines(payload.suggestions)),
539
+ factsWhy: factsWhy(row, factsFor(row).concat(suggestionLines(payload.suggestions))),
520
540
  l2: l2For(row),
521
541
  };
522
542
  if (!row) {
@@ -508,7 +508,7 @@ export function skillDetailLive(det) {
508
508
 
509
509
  /* ── knowledge ──────────────────────────────────────────────────────────────────────────────── */
510
510
  function flatten(node, out, depth) {
511
- for (const d of node.docs || []) out.push({ path: d.path, name: d.name, depth, category: d.category || null, basis: d.category_basis || null, why: d.category_why || null, kind: d.category_kind || null, disagrees: d.category_disagrees || [] });
511
+ for (const d of node.docs || []) out.push({ path: d.path, name: d.name, depth, category: d.category || null, basis: d.category_basis || null, why: d.category_why || null, kind: d.category_kind || null, jev: d.category_jev || null, disagrees: d.category_disagrees || [] });
512
512
  if (depth < 3) for (const sub of node.dirs || []) { out.push({ dir: sub.name || sub.path, depth }); flatten(sub, out, depth + 1); }
513
513
  return out;
514
514
  }
@@ -518,7 +518,7 @@ function flatten(node, out, depth) {
518
518
  let knCat = '';
519
519
  const catTitle = (it) => it.category ? 'category ' + it.category + (it.kind ? ' (' + it.kind + ')' : '') + (it.basis ? ' · decided by ' + it.basis : ' · no evidence') + (it.why ? ' (' + it.why + ')' : '')
520
520
  + (it.disagrees && it.disagrees.length ? ' · ' + it.disagrees.map((d) => d.basis + ' says ' + d.category).join(', ') : '') + ' · advisory' : '';
521
- const catChip = (it) => it.category ? el('span', { 'data-cat': it.category, 'data-cat-basis': it.basis || '', title: catTitle(it), style: 'margin-left:8px;font-size:9.5px;letter-spacing:.06em;color:var(--muted-foreground)' }, iv('· ' + it.category + (it.basis === 'jev' ? ' (jev)' : ''))) : null;
521
+ const catChip = (it) => it.category ? el('span', { 'data-cat': it.category, 'data-cat-basis': it.basis || '', title: catTitle(it), style: 'margin-left:8px;font-size:9.5px;letter-spacing:.06em;color:var(--muted-foreground)' }, iv('· ' + it.category + (it.kind ? ' (' + it.kind + ')' : '') + (it.basis === 'jev' ? ' (jev)' : it.jev === 'unsure' ? ' (jev unsure)' : ''))) : null;
522
522
  function categoryBar(trees) {
523
523
  const counts = {};
524
524
  for (const t of trees) for (const it of flatten(t, [], 0)) if (it.path && it.category) counts[it.category] = (counts[it.category] || 0) + 1;
@@ -557,7 +557,56 @@ async function dRun() {
557
557
  dPlan = null; reload('library:' + ws);
558
558
  dSay(!out || out.ok === false ? ((out && out.error) || 'the run failed') : 'asked ' + out.sent + ' · ' + out.decided + ' categorized · ' + out.unsure + ' unsure · ' + (out.unreadable || 0) + ' unreadable · ' + out.cached + ' answered from the cache · ' + out.failed + ' failed');
559
559
  }
560
- function doccatBlock(ws, j) {
560
+ /* Run 4 — what the last pass did to each undetermined file, so the PM can see which ones Jev categorized, which stayed
561
+ * undetermined and why, and which failed. Problems first. The record is the server's (per user, paths and outcomes only). */
562
+ let dFilesAll = false;
563
+ const D_ORDER = ['failed', 'not-asked', 'unreadable', 'unsure', 'withheld', 'excluded', 'categorized'];
564
+ const D_LABEL = { failed: 'failed', 'not-asked': 'not asked', unreadable: 'unreadable', unsure: 'unsure', withheld: 'withheld', excluded: 'excluded', categorized: 'categorized' };
565
+ const D_TONE = { failed: 'var(--destructive)', 'not-asked': 'var(--destructive)', unreadable: 'var(--destructive)', categorized: 'var(--foreground)' };
566
+ const KEEPS = ' The file keeps the local result: undetermined.';
567
+ function dOutcomeWords(f) {
568
+ const cached = f.cached ? ' Answer from the cache.' : '';
569
+ if (f.outcome === 'categorized') return 'Jev: ' + (f.category === 'ticket' ? 'ticket (' + (f.ticket_kind || 'other') + ')' : f.category) + '. ' + (f.sentence || '') + cached;
570
+ if (f.outcome === 'unsure') return 'Jev was unsure (' + (f.leading || []).join(' or ') + ').' + KEEPS + cached;
571
+ if (f.outcome === 'unreadable') return 'Jev’s answer could not be read.' + KEEPS + ' It is asked again only when the file changes.' + cached;
572
+ if (f.outcome === 'failed') return 'The request failed: ' + (f.reason || 'no reason given') + '.' + KEEPS + ' It is asked again on the next pass.';
573
+ if (f.outcome === 'withheld') return 'Not sent: the credential scan matched (' + (f.rules || []).join(', ') + ').';
574
+ if (f.outcome === 'excluded') return 'Not sent: the path is excluded.';
575
+ if (f.outcome === 'not-asked') return 'Not asked: ' + (f.reason || 'the pass stopped first');
576
+ return f.outcome;
577
+ }
578
+ function dLibraryLine(trees) {
579
+ let local = 0, jev = 0, undet = 0;
580
+ for (const t of trees || []) for (const it of flatten(t, [], 0)) {
581
+ if (!it.path || !it.category) continue;
582
+ if (it.category === 'undetermined') undet++; else if (it.basis === 'jev') jev++; else local++;
583
+ }
584
+ if (!local && !jev && !undet) return null;
585
+ return el('div', { 'data-doccat': 'library-line', style: NOTE }, iv('In this list: ' + local + ' decided by the local rules or your corrections · ' + jev + ' categorized by Jev · ' + undet + ' undetermined.'));
586
+ }
587
+ function dResultsBlock(ws, res) {
588
+ if (!res || !Array.isArray(res.files)) return [];
589
+ const counts = {};
590
+ for (const f of res.files) counts[f.outcome] = (counts[f.outcome] || 0) + 1;
591
+ const parts = D_ORDER.filter((k) => counts[k]).map((k) => counts[k] + ' ' + D_LABEL[k]);
592
+ const when = String(res.at || '').replace('T', ' ').slice(0, 16);
593
+ const out = [el('div', { 'data-doccat': 'results-summary', style: NOTE + ';color:var(--foreground)' },
594
+ iv('Last Jev pass (' + (res.kind === 'confirmed' ? 'confirmed by you' : 'automatic') + ', ' + when + '), ' + res.total + ' undetermined file(s): ' + (parts.join(' · ') || 'none') + '.'
595
+ + (res.total > res.files.length ? ' Only the first ' + res.files.length + ' are listed.' : '')))];
596
+ const rows = res.files.slice().sort((a, b) => D_ORDER.indexOf(a.outcome) - D_ORDER.indexOf(b.outcome) || a.doc.localeCompare(b.doc));
597
+ const shown = dFilesAll ? rows : rows.slice(0, 50);
598
+ for (const f of shown) {
599
+ out.push(el('div', { 'data-doccat-file': f.doc, 'data-outcome': f.outcome, style: 'display:flex;gap:8px;align-items:baseline;padding:2px 14px 2px 20px;font-size:11px' },
600
+ el('span', { 'data-doccat-outcome': f.outcome, style: 'flex:none;width:76px;font-family:var(--mono);font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;color:' + (D_TONE[f.outcome] || 'var(--muted-foreground)') }, iv(D_LABEL[f.outcome] || f.outcome)),
601
+ el('span', { style: 'flex:1 1 auto;min-width:0' },
602
+ el('button', { style: 'font-family:var(--mono);font-size:11px;text-decoration:underline;color:var(--foreground);text-align:left', onClick: () => openDocIn(null, f.doc) }, iv(f.doc)),
603
+ el('span', { 'data-doccat-why': '1', style: 'display:block;color:var(--muted-foreground)' }, iv(dOutcomeWords(f))))));
604
+ }
605
+ if (rows.length > 50) out.push(el('div', { style: 'padding:2px 14px 6px 20px' },
606
+ el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-doccat': 'files-all', onClick: () => { dFilesAll = !dFilesAll; setState({}); } }, dFilesAll ? 'show the first 50' : 'show all ' + rows.length)));
607
+ return out;
608
+ }
609
+ function doccatBlock(ws, j, trees) {
561
610
  if (!ws || !j) return [];
562
611
  const kids = [];
563
612
  kids.push(el('div', { 'data-doccat': 'explain', style: NOTE }, iv('Files with no local evidence are “undetermined”. Jev can be asked to categorize them from a short snippet: the title, the headings and the opening, never the whole file. Its answers are marked (jev), are advisory, and an unsure answer stays undetermined.')));
@@ -574,12 +623,14 @@ function doccatBlock(ws, j) {
574
623
  el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-doccat': 'resume', onClick: () => dResume(ws) }, 'resume')));
575
624
  const l = j.last;
576
625
  if (l && l.waiting) kids.push(el('div', { 'data-doccat': 'waiting', style: NOTE + ';color:var(--foreground)' }, iv(l.waiting + ' new or changed file(s) are waiting: more than an automatic pass sends. Ask Jev and confirm the count to send them.')));
577
- else if (l) kids.push(el('div', { 'data-doccat': 'last', style: NOTE }, iv('Last Jev pass ' + String(l.at).replace('T', ' ').slice(0, 16) + ': sent ' + l.sent + ' · categorized ' + l.decided + ' · unsure ' + l.unsure + ' · unreadable ' + (l.unreadable || 0) + ' · withheld ' + (l.withheld_count || 0) + ' · excluded ' + l.excluded + ' · failed ' + l.failed + '.')));
626
+ else if (l && !j.results) kids.push(el('div', { 'data-doccat': 'last', style: NOTE }, iv('Last Jev pass ' + String(l.at).replace('T', ' ').slice(0, 16) + ': sent ' + l.sent + ' · categorized ' + l.decided + ' · unsure ' + l.unsure + ' · unreadable ' + (l.unreadable || 0) + ' · withheld ' + (l.withheld_count || 0) + ' · excluded ' + l.excluded + ' · failed ' + l.failed + '.')));
578
627
  if (dPlan && dPlan.ws === ws) {
579
628
  const p = dPlan.plan;
580
629
  kids.push(el('div', { 'data-doccat': 'plan-text', style: NOTE }, iv(p.requests + ' request(s), about ' + p.characters.toLocaleString() + ' characters, to ' + p.host + ' · ' + p.candidates + ' undetermined file(s) · ' + p.cached + ' already answered · ' + p.excluded + ' excluded · ' + p.withheld_count + ' withheld by the credential scan.')));
581
630
  }
582
631
  if (dNote) kids.push(el('div', { 'data-doccat': 'note', style: NOTE }, iv(dNote)));
632
+ const line = dLibraryLine(trees); if (line) kids.push(line);
633
+ kids.push(...dResultsBlock(ws, j.results));
583
634
  return kids;
584
635
  }
585
636
  /* jev-doc-categorizer Run 3 (C7) — the PM corrects a category. `fix` on a row opens a small control: a category, a ticket kind for
@@ -657,7 +708,7 @@ export function knowledgeLive() {
657
708
  const trees = (r.data && r.data.knowledge) || [];
658
709
  const kids = [el('div', { style: NOTE }, iv('This repository\'s knowledge: the OKF bundle, skills and build-skills. Open a document to read it.'))];
659
710
  const bar = categoryBar(trees); if (bar) kids.push(bar);
660
- kids.push(...doccatBlock(ws, r.data && r.data.doccat));
711
+ kids.push(...doccatBlock(ws, r.data && r.data.doccat, trees));
661
712
  for (const t of trees) {
662
713
  kids.push(el('div', { style: LABEL }, iv(t.path || t.name || 'knowledge')));
663
714
  for (const it of flatten(t, [], 0)) {
@@ -715,6 +766,14 @@ async function vConsent(on) {
715
766
  reload('vrepo'); vPlan = null;
716
767
  vSay(out && out.ok ? 'ok' : 'bad', out && out.ok ? (on ? 'validation is on for this repository' : 'validation is off; nothing from this repository will be sent') : ((out && out.error) || 'not saved'));
717
768
  }
769
+ /* prompt-model-routing Run 3 — the third D84 gate: route this repository's prompts with Jev. With it on (and the key and
770
+ * consent above), the cockpit mod in each terminal switches the model for a prompt when Jev decides its tier. */
771
+ async function vRoute(on) {
772
+ const ws = vWs(); if (!ws) return vSay('bad', 'select a repository first');
773
+ const out = await post('/api/model/route/switch', { ws, route: on });
774
+ reload('vrepo');
775
+ vSay(out && out.ok ? 'ok' : 'bad', out && out.ok ? (on ? (out.open ? 'routing prompts with Jev is on: each prompt is checked before it runs' : 'routing prompts with Jev is on, but not active yet: ' + out.sentence) : 'routing prompts with Jev is off; no prompt is sent') : ((out && out.error) || 'not saved'));
776
+ }
718
777
  async function vExclude(text) {
719
778
  const ws = vWs(); if (!ws) return;
720
779
  const exclude = String(text || '').split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
@@ -931,6 +990,10 @@ export function validateList() {
931
990
  kids.push(el('div', { style: 'display:flex;gap:8px;align-items:center;padding:4px 14px' },
932
991
  el('span', { style: 'font-size:12px', 'data-r19': 'consent-state' }, iv(on ? 'validation is ON for this repository' : 'validation is OFF — nothing from this repository is sent')),
933
992
  el('button', { style: BTN + ';margin-left:auto', hover: 'color:var(--foreground)', 'data-r19': 'consent-toggle', onClick: () => vConsent(!on) }, on ? 'turn off' : 'turn on')));
993
+ const ro = !!(rp.data && rp.data.route);
994
+ kids.push(el('div', { style: 'display:flex;gap:8px;align-items:center;padding:4px 14px' },
995
+ el('span', { style: 'font-size:12px', 'data-route': 'state' }, iv(ro ? 'route prompts with Jev: ON — each prompt typed in a workspace terminal is checked, and the model switches when Jev decides' : 'route prompts with Jev: OFF — no prompt is sent')),
996
+ el('button', { style: BTN + ';margin-left:auto', hover: 'color:var(--foreground)', 'data-route': 'toggle', onClick: () => vRoute(!ro) }, ro ? 'turn off' : 'turn on')));
934
997
  const ta = el('textarea', { rows: 2, placeholder: 'paths never sent, one per line (e.g. private/ or notes/*.md)', 'data-r19': 'exclude-input',
935
998
  style: 'width:calc(100% - 28px);margin:4px 14px 0;font-family:var(--mono);font-size:10.5px;padding:4px 8px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' }, (rp.data.exclude || []).join('\n'));
936
999
  kids.push(ta, el('div', { style: 'padding:4px 14px 6px' }, el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r19': 'exclude-save', onClick: () => vExclude(ta.value) }, 'save excluded paths')));
@@ -48,6 +48,8 @@ export const VIEWS = {
48
48
  // Run 3: views with no list mode — they draw only through their board module
49
49
  reports: { label: 'reports', mode: null, scoped: true, menu: 'Library', hint: 'run reports' },
50
50
  addrepo: { label: 'add repository', mode: null, scoped: false, menu: null },
51
+ // jev-live-notes Run 6: confirm each talking point as it arrives
52
+ livenotes: { label: 'live notes', mode: null, scoped: true, menu: 'Library', hint: 'confirm talking points' },
51
53
  };
52
54
 
53
55
  /** Modes that exist in the code and are deliberately not views, with the reason. `product` is the list itself. */
@@ -110,7 +112,7 @@ function modes() {
110
112
  export const BOARD_MODULES = {
111
113
  today: './view-today.js', portfolio: './view-portfolio.js', knowledge: './view-knowledge.js', memory: './view-memory.js',
112
114
  skills: './view-skills.js', scheduled: './view-scheduled.js', validate: './view-validate.js', guide: './view-guide.js',
113
- reports: './view-reports.js', addrepo: './view-addrepo.js',
115
+ reports: './view-reports.js', addrepo: './view-addrepo.js', livenotes: './view-livenotes.js',
114
116
  };
115
117
  const boardMods = {}; // view -> undefined (not tried) · null (loading or absent) · module
116
118
  function boardOf(view) {
@@ -385,9 +385,11 @@ function noLadderBlock(ansBg, ansC, ansText, noLadderText) {
385
385
  */
386
386
  function factsBlock(rec) {
387
387
  const facts = (rec && rec.facts) || [];
388
- if (!facts.length) return null;
388
+ // An initiative with a ladder always gets the section; with nothing to compare it says what each check waits for.
389
+ if (!facts.length && !(rec && rec.factsWhy)) return null;
389
390
  return el('div', { 'data-facts': 'status-from-artifacts', style: 'border-top:1px solid var(--border);padding:8px 14px 8px' },
390
391
  el('div', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground);padding-bottom:5px' }, iv('What the documents say')),
392
+ facts.length ? null : el('div', { 'data-facts-empty': '', style: 'font-size:12px;line-height:1.45;color:var(--muted-foreground)' }, iv(rec.factsWhy)),
391
393
  ...facts.map((f) => el('div', { style: 'display:grid;grid-template-columns:14px 104px 1fr;gap:6px;align-items:baseline;padding:2px 0;font-size:12px;line-height:1.45' },
392
394
  el('span', { style: 'font-size:9px;color:' + (FX.TONE[f.tone] || FX.C.mut) }, iv('●')),
393
395
  el('span', { style: 'color:var(--muted-foreground)' }, iv(f.label)),
@@ -335,7 +335,7 @@ const CHIP_STYLE = 'flex:none;align-self:center;font-family:var(--mono);font-siz
335
335
  const SPLIT_STYLE = 'flex:none;margin-left:auto;align-self:center;font-family:var(--mono);font-size:11px;color:oklch(0.85 0.01 214);padding:5px 12px;min-height:30px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);white-space:nowrap';
336
336
  const CHIP_HOVER = 'border-color:oklch(0.56 0.021 213.5)';
337
337
 
338
- const tabBar = el('div', { 'data-tabs': 'bar', style: BAR_STYLE });
338
+ const tabBar = el('div', { 'data-tabs': 'bar', style: BAR_STYLE, onWheel: stripWheel });
339
339
 
340
340
  /* ── the cockpit's tab strip (workspace-cockpit-nav Run 4, planning/design/cockpit-mockup/WsA.dc.html) ─────────────────────────
341
341
  * On a desktop the strip is the mockup's: a 44px band whose tabs sit on its floor, the tab in front a card with rounded top corners,
@@ -345,6 +345,30 @@ const tabBar = el('div', { 'data-tabs': 'bar', style: BAR_STYLE });
345
345
  * control, in its right padding; the web, options and split controls sit to the left of `+ session`. */
346
346
  const cockpitLook = () => !LEGACY_LAYOUT_FLAG && !isMobile();
347
347
  const CK_BAR = 'flex:none;display:flex;align-items:flex-end;gap:4px;padding:0 10px;height:45px;border-bottom:1px solid var(--border);min-width:0;overflow-x:auto';
348
+ /* ── the tabs as a vertical list (the PM's request after testing 0.48.0: the strip gets crowded with many tabs open) ─────────────
349
+ * `state.tabsVertical` puts the open tabs in a column on the left of the panes, one row per tab with its title, its second line and
350
+ * its close control, and the session controls under them. The choice is stored with the layout, so it survives a reload. It is a
351
+ * cockpit (desktop) choice only: a phone and `?layout=legacy` keep the strip.
352
+ *
353
+ * NOTHING MOVES WHEN IT IS SWITCHED. The panes, the chooser, the scratchpad and the bottom bar live in one persistent column
354
+ * (`tabsMain`) that is always the second child of the host; the switch only turns the host's flex direction and restyles the bar.
355
+ * A live terminal is never re-parented by it. */
356
+ const CK_VBAR = 'flex:none;width:224px;display:flex;flex-direction:column;align-items:stretch;gap:2px;padding:8px 6px;border-right:1px solid var(--border);min-height:0;overflow-y:auto;overflow-x:hidden';
357
+ const tabsMain = el('div', { 'data-tabs': 'main', style: 'flex:1;min-height:0;min-width:0;display:flex;flex-direction:column' });
358
+ const orientLabel = iv('');
359
+ const orientBtn = el('button', {
360
+ onClick: () => setState({ tabsVertical: !state.tabsVertical }), 'data-tabs': 'orient',
361
+ }, orientLabel);
362
+ /* In the strip, a mouse wheel scrolls the tabs sideways: with many tabs open the strip is wider than the panel, and a wheel that
363
+ * did nothing there left only the thin scrollbar. */
364
+ function stripWheel(e) {
365
+ if (state.tabsVertical || !e.deltaY || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
366
+ const bar = e.currentTarget;
367
+ if (bar.scrollWidth <= bar.clientWidth) return;
368
+ bar.scrollLeft += e.deltaY;
369
+ e.preventDefault();
370
+ }
371
+
348
372
  const CK_BTN = 'flex:none;margin:0 0 4px;height:30px;display:grid;place-items:center;font:500 12.5px var(--sans);line-height:normal;background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;white-space:nowrap';
349
373
 
350
374
  const newSessionBtn = el('button', {
@@ -994,8 +1018,9 @@ export function tabBranch(t, includeMain) {
994
1018
  return trees.length > 1 && main ? main.branch : null;
995
1019
  }
996
1020
 
997
- function cockpitTabNode(t, act, splitTab) {
1021
+ function cockpitTabNode(t, act, splitTab, vertical) {
998
1022
  const front = (act && act.id === t.id) || (splitTab && splitTab.id === t.id);
1023
+ if (vertical) return cockpitTabRow(t, front);
999
1024
  const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
1000
1025
  const meta = t.kind === 'term'
1001
1026
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
@@ -1003,14 +1028,15 @@ function cockpitTabNode(t, act, splitTab) {
1003
1028
  const br = tabBranch(t);
1004
1029
  return el('div', {
1005
1030
  'data-tab': t.id, 'data-tab-active': front ? '1' : '0',
1006
- style: 'position:relative;flex:none;display:flex;align-items:center;gap:8px;padding:0 14px;font:12px var(--mono);'
1031
+ // A tab may shrink to 96px when the strip is crowded; the title is clipped with an ellipsis and the full title is its tooltip.
1032
+ style: 'position:relative;flex:0 1 auto;min-width:96px;display:flex;align-items:center;gap:8px;padding:0 14px;font:12px var(--mono);'
1007
1033
  + (front ? 'height:37px;border:1px solid var(--border);border-bottom:0;border-radius:8px 8px 0 0;background:var(--card);color:var(--foreground)'
1008
1034
  : 'height:36px;color:var(--muted-foreground)'),
1009
1035
  },
1010
1036
  el('button', {
1011
1037
  onClick: () => setState({ active: t.id }),
1012
1038
  title: t.title + (t.slug ? '\n' + t.slug + ' · ' + meta : ''),
1013
- style: 'display:flex;gap:8px;align-items:center;align-self:stretch;color:inherit',
1039
+ style: 'display:flex;gap:8px;align-items:center;align-self:stretch;color:inherit;min-width:0',
1014
1040
  },
1015
1041
  el('span', { 'data-tab-dot': t.kind, style: 'width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:' + dotColor + ';color:' + dotColor }),
1016
1042
  el('span', { style: 'display:flex;flex-direction:column;min-width:0;align-items:flex-start' },
@@ -1030,6 +1056,40 @@ function cockpitTabNode(t, act, splitTab) {
1030
1056
  );
1031
1057
  }
1032
1058
 
1059
+ /** One tab as a row of the vertical list: the dot, the title over its second line, and the close control, all inside the row. */
1060
+ function cockpitTabRow(t, front) {
1061
+ const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
1062
+ const meta = t.kind === 'term'
1063
+ ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
1064
+ : (t.kind === 'task' ? 'task' : t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1065
+ const br = tabBranch(t);
1066
+ return el('div', {
1067
+ 'data-tab': t.id, 'data-tab-active': front ? '1' : '0', 'data-tab-row': '',
1068
+ style: 'flex:none;display:flex;align-items:center;gap:6px;padding:5px 6px 5px 10px;border-radius:6px;font:12px var(--mono);border:1px solid '
1069
+ + (front ? 'var(--border);background:var(--card);color:var(--foreground)' : 'transparent;color:var(--muted-foreground)'),
1070
+ hover: front ? null : 'background:var(--accent)',
1071
+ },
1072
+ el('button', {
1073
+ onClick: () => setState({ active: t.id }),
1074
+ title: t.title + (t.slug ? '\n' + t.slug + ' · ' + meta : ''),
1075
+ style: 'flex:1;min-width:0;display:flex;gap:8px;align-items:center;color:inherit;text-align:left',
1076
+ },
1077
+ el('span', { 'data-tab-dot': t.kind, style: 'width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:' + dotColor + ';color:' + dotColor }),
1078
+ el('span', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;align-items:stretch' },
1079
+ el('span', { style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(t.title)),
1080
+ el('span', {
1081
+ 'data-tab-sub': '',
1082
+ style: 'font:9.5px var(--mono);line-height:12px;color:oklch(0.52 0.02 213);overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
1083
+ }, iv(t.slug), ' · ', iv(meta), br ? el('span', { 'data-r1': 'tab-branch' }, iv(' · ⑂ ' + br)) : null),
1084
+ ),
1085
+ ),
1086
+ el('button', {
1087
+ onClick: () => closeTab(t.id), 'data-tab-close': '', 'aria-label': 'close ' + t.title, title: 'close',
1088
+ style: 'flex:none;color:var(--muted-foreground);padding:0 4px', hover: 'color:var(--foreground)',
1089
+ }, '×'),
1090
+ );
1091
+ }
1092
+
1033
1093
  function tabNode(t, act, splitTab) {
1034
1094
  const selected = (act && act.id === t.id) || (splitTab && splitTab.id === t.id);
1035
1095
  const bg = selected ? FX.TAB_COLORS.activeBg : 'transparent';
@@ -1078,6 +1138,8 @@ function tabNode(t, act, splitTab) {
1078
1138
  * prototype's, in the prototype's order: tab bar · (empty state) · panes · (scratchpad) · bottom
1079
1139
  * bar. The panes row renders even when nothing is open, exactly as the design does.
1080
1140
  */
1141
+ let lastFrontId = null;
1142
+
1081
1143
  export function renderTabs(host) {
1082
1144
  // Which pad the drawer is on is decided from the active tab, before anything is painted from it.
1083
1145
  syncPad();
@@ -1093,10 +1155,32 @@ export function renderTabs(host) {
1093
1155
  const ck = cockpitLook();
1094
1156
  newSessionBtn.textContent = ck ? '+ session' : '+ session';
1095
1157
  emptyState.textContent = "nothing open — click a task's output or an artifact, or " + (ck ? '+' : '+') + ' session above';
1096
- if (ck) {
1097
- tabBar.setAttribute('style', CK_BAR);
1158
+ /* The tabs are drawn anew on every paint, so the bar is empty for a moment and its scroll would snap back to the start: a
1159
+ * crowded strip (or a long list) scrolled by hand jumped back on the next repaint. The offset is kept across the redraw. */
1160
+ const keepX = tabBar.scrollLeft, keepY = tabBar.scrollTop;
1161
+ const vertical = ck && !!state.tabsVertical;
1162
+ host.style.flexDirection = vertical ? 'row' : 'column';
1163
+ orientLabel.textContent = vertical ? '⇆ tabs on top' : '⇅ tabs at side';
1164
+ orientBtn.title = vertical ? 'show the tabs as a strip above the panes' : 'show the tabs as a list at the left of the panes';
1165
+ if (vertical) {
1166
+ tabBar.setAttribute('style', CK_VBAR);
1167
+ const V_BTN = CK_BTN.replace('margin:0 0 4px', 'margin:0') + ';justify-content:start';
1168
+ newSessionBtn.setAttribute('style', V_BTN + ';margin-bottom:6px');
1169
+ for (const b of [newWebBtn, newSessionOptsBtn, splitBtn, orientBtn]) b.setAttribute('style', V_BTN);
1170
+ setChildren(tabBar, [
1171
+ newSessionBtn,
1172
+ ...visTabs.map((t) => cockpitTabNode(t, act, splitTab, true)),
1173
+ el('div', { 'data-tabs': 'vcontrols', style: 'margin-top:auto;padding-top:8px;display:flex;flex-direction:column;gap:4px' },
1174
+ FIXTURE ? null : newWebBtn,
1175
+ !FIXTURE && state.webNotice ? el('span', { 'data-r5': 'notice', style: 'font-size:11px;color:var(--destructive);padding:0 6px' }, state.webNotice) : null,
1176
+ newSessionOptsBtn,
1177
+ canSplit ? splitBtn : null,
1178
+ orientBtn),
1179
+ ]);
1180
+ } else if (ck) {
1181
+ tabBar.setAttribute('style', CK_BAR + ';scrollbar-width:thin');
1098
1182
  newSessionBtn.setAttribute('style', CK_BTN + ';margin-left:0');
1099
- for (const b of [newWebBtn, newSessionOptsBtn, splitBtn]) b.setAttribute('style', CK_BTN);
1183
+ for (const b of [newWebBtn, newSessionOptsBtn, splitBtn, orientBtn]) b.setAttribute('style', CK_BTN);
1100
1184
  const extras = [
1101
1185
  FIXTURE ? null : newWebBtn,
1102
1186
  !FIXTURE && state.webNotice ? el('span', { 'data-r5': 'notice', style: 'align-self:center;font-size:11px;color:var(--destructive);padding:0 6px' }, state.webNotice) : null,
@@ -1104,7 +1188,8 @@ export function renderTabs(host) {
1104
1188
  canSplit ? splitBtn : null,
1105
1189
  ].filter(Boolean);
1106
1190
  extras[0].style.marginLeft = 'auto';
1107
- setChildren(tabBar, [...visTabs.map((t) => cockpitTabNode(t, act, splitTab)), ...extras, newSessionBtn]);
1191
+ // The switch sits after `+ session`, so the strip still ends the mockup's way in the fixture, which does not draw it.
1192
+ setChildren(tabBar, [...visTabs.map((t) => cockpitTabNode(t, act, splitTab)), ...extras, newSessionBtn, FIXTURE ? null : orientBtn]);
1108
1193
  } else {
1109
1194
  tabBar.setAttribute('style', BAR_STYLE);
1110
1195
  newSessionBtn.setAttribute('style', CHIP_STYLE);
@@ -1119,6 +1204,23 @@ export function renderTabs(host) {
1119
1204
  ]);
1120
1205
  }
1121
1206
 
1207
+ if (tabBar.scrollLeft !== keepX) tabBar.scrollLeft = keepX;
1208
+ if (tabBar.scrollTop !== keepY) tabBar.scrollTop = keepY;
1209
+ // When the tab in front changes, the bar scrolls just enough to show it, so a tab opened at the far end of a crowded strip or a
1210
+ // long list is seen. Only the bar's own offset is moved (not scrollIntoView, which would also scroll the panels around it).
1211
+ if (ck && act && act.id !== lastFrontId) {
1212
+ const n = [...tabBar.querySelectorAll('[data-tab]')].find((x) => x.getAttribute('data-tab') === act.id);
1213
+ if (n) {
1214
+ const b = tabBar.getBoundingClientRect(), r = n.getBoundingClientRect();
1215
+ if (vertical) {
1216
+ if (r.top < b.top) tabBar.scrollTop -= b.top - r.top;
1217
+ else if (r.bottom > b.bottom) tabBar.scrollTop += r.bottom - b.bottom;
1218
+ } else if (r.left < b.left) tabBar.scrollLeft -= b.left - r.left;
1219
+ else if (r.right > b.right) tabBar.scrollLeft += r.right - b.right;
1220
+ }
1221
+ }
1222
+ lastFrontId = act ? act.id : null;
1223
+
1122
1224
  // ── panes ──
1123
1225
  reapClosedPanes();
1124
1226
  const panes = [act ? paneFor(act) : null, splitTab ? paneFor(splitTab) : null].filter(Boolean);
@@ -1159,8 +1261,8 @@ export function renderTabs(host) {
1159
1261
  colTitleLabel.textContent = act ? act.title : 'scratchpad';
1160
1262
  copyLabel.textContent = state.copied ? 'copied ✓' : 'copy';
1161
1263
 
1162
- setChildren(host, [
1163
- tabBar,
1264
+ setChildren(host, [tabBar, tabsMain]);
1265
+ setChildren(tabsMain, [
1164
1266
  // Above the panes and below the tab bar: the chooser is about the click that was just made in
1165
1267
  // that bar, and putting it here leaves every open pane — and every live PTY — exactly where it
1166
1268
  // was. Replacing the panes with it would re-parent a terminal, which is the quiet regression
@@ -130,6 +130,7 @@ function initialBase() {
130
130
  newSessIsolate: null, newSessError: null, newSessBusy: false,
131
131
  newSessBranches: null, preflightProfile: null, profiles: null, branchInfo: null,
132
132
  col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
133
+ tabsVertical: false, // the cockpit's tabs as a vertical list instead of a strip — persisted, per browser
133
134
  /* The scratchpad drawer's height in px (panel-tabs.js). 96 is the prototype's literal and stays
134
135
  * the default; the PM can drag it, and the drag is clamped there rather than here — this store
135
136
  * only has to refuse a value that could not have come from the clamp. */
@@ -234,6 +235,8 @@ const PERSIST = {
234
235
  active: isSlug,
235
236
  splitId: isSlug,
236
237
  scratchOpen: isBool,
238
+ // the cockpit's tabs as a vertical list (true) or the horizontal strip (false, the default)
239
+ tabsVertical: isBool,
237
240
  /* A restored height is re-validated on the way IN, not merely on the way out: a corrupted or
238
241
  * hand-edited record must not be able to render a drawer with no textarea in it, or one taller
239
242
  * than any window it could be restored into. The upper bound here is deliberately generous (the