create-pmos 0.92.0 → 0.94.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.
@@ -244,7 +244,7 @@ function go(screen) {
244
244
  }
245
245
 
246
246
  const frameOn = () => !!(ctx && ctx.frame);
247
- const tabScreen = (tab, id) => ({ kind: (tab && (tab.kind === 'doc' || tab.kind === 'breakdown' || tab.kind === 'dest')) ? 'doc' : 'terminal', id, title: (tab && (tab.title || tab.name)) || String(id) });
247
+ const tabScreen = (tab, id) => ({ kind: (tab && (tab.kind === 'doc' || tab.kind === 'breakdown' || tab.kind === 'dest' || tab.kind === 'report')) ? 'doc' : 'terminal', id, title: (tab && (tab.title || tab.name)) || String(id) });
248
248
 
249
249
  /* The frame on a phone: the PM's own moves through the panels, the palette or Settings, read back as screens. A place
250
250
  * opened from elsewhere (⌘K, Settings › Show its initiatives) is a tab switch; Settings is pushed on top; an open
@@ -42,6 +42,7 @@ const liveId = () => (V ? V.listeningId() : null);
42
42
  const titleOf = () => { if (!active) return ''; const r = noteById(active.id); return (V && V.titleOf(active.id)) || (r && r.title) || active.title || 'Untitled note'; };
43
43
 
44
44
  export const activeId = () => (active ? active.id : null);
45
+ export const isRecording = () => !!(active && V && liveId() === active.id);
45
46
 
46
47
  export function mountDock() {
47
48
  if (host || FIXTURE || typeof document === 'undefined') return;
@@ -46,6 +46,8 @@ export const VIEWS = {
46
46
  livenotes: { label: 'live notes', mode: null, scoped: true, palette: true, hint: 'confirm talking points' },
47
47
  // meeting-notes Run 1: typed notes, dictation, meeting transcripts and summaries; the PM's own, the same in every repository
48
48
  notes: { label: 'notes', mode: null, scoped: false, palette: true, hint: 'type, dictate or record a meeting' },
49
+ // meeting-notes Run 11: notes menu with two-column view grouped by initiative (R47)
50
+ notesmenu: { label: 'notes menu', mode: null, scoped: false, hint: 'view and organize all notes by initiative' },
49
51
  };
50
52
 
51
53
  /** The view a list mode opens as, or null when the mode is not a view. `product` is the list itself. */
@@ -95,7 +97,7 @@ function modes() {
95
97
  * every `data-*` hook the suites use, and only adds the board's frame around it. A view with no board module draws as before. */
96
98
  export const BOARD_MODULES = {
97
99
  today: './view-today.js', validate: './view-validate.js', addrepo: './view-addrepo.js', livenotes: './view-livenotes.js',
98
- notes: './view-notes.js',
100
+ notes: './view-notes.js', notesmenu: './view-notes-menu.js',
99
101
  };
100
102
  const boardMods = {}; // view -> undefined (not tried) · null (loading or absent) · module
101
103
  function boardOf(view) {
@@ -63,6 +63,11 @@ import('./panel-breakdown.js')
63
63
  .then((m) => { breakdownMod = m; if ((state.tabs || []).some((t) => t.kind === 'breakdown')) setState({}); })
64
64
  .catch(() => { breakdownMod = null; });
65
65
 
66
+ let reportTabMod = null;
67
+ import('./report-tab.js')
68
+ .then((m) => { reportTabMod = m; if ((state.tabs || []).some((t) => t.kind === 'report')) setState({}); })
69
+ .catch(() => { reportTabMod = null; });
70
+
66
71
  /** `{{ expr }}` — one interpolated value in its own span, the way the prototype's runtime emits it. */
67
72
  /* A dock control's face: a lucide icon from the vendored sprite, then its word (C6: no Unicode glyph controls). */
68
73
  const iconLabel = (name, text) => (text ? [icon(name), el('span', null, text)] : [icon(name)]);
@@ -1043,6 +1048,8 @@ function paneFor(tab) {
1043
1048
  if (cockpitMod && typeof cockpitMod.mountView === 'function') cockpitMod.mountView(host, tab);
1044
1049
  } else if (tab.kind === 'web') {
1045
1050
  mountWeb(host, tab); // overhaul-r5 — never falls through to an empty doc tab
1051
+ } else if (tab.kind === 'report') {
1052
+ if (reportTabMod && typeof reportTabMod.mountReportTab === 'function') reportTabMod.mountReportTab(host, tab);
1046
1053
  } else if (tab.kind === 'breakdown') {
1047
1054
  // pm-os-absorb Run 1 — an initiative's items (Tree, Board, item sheet); an empty pane until the module lands
1048
1055
  if (breakdownMod && typeof breakdownMod.mountBreakdown === 'function') breakdownMod.mountBreakdown(host, tab);
@@ -1205,7 +1212,7 @@ function cockpitTabNode(t, act, splitTab, vertical) {
1205
1212
  const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
1206
1213
  const meta = t.kind === 'term'
1207
1214
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
1208
- : (t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1215
+ : (t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.kind === 'report' ? 'report' : t.role);
1209
1216
  const br = tabBranch(t);
1210
1217
  return el('div', {
1211
1218
  'data-tab': t.id, 'data-tab-active': front ? '1' : '0',
@@ -1242,7 +1249,7 @@ function cockpitTabRow(t, front) {
1242
1249
  const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
1243
1250
  const meta = t.kind === 'term'
1244
1251
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
1245
- : (t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1252
+ : (t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.kind === 'report' ? 'report' : t.role);
1246
1253
  const br = tabBranch(t);
1247
1254
  return el('div', {
1248
1255
  'data-tab': t.id, 'data-tab-active': front ? '1' : '0', 'data-tab-row': '',
@@ -1319,7 +1326,7 @@ function tabNode(t, act, splitTab) {
1319
1326
  * fixture render keeps the design's literal — the comparator reads this text node. */
1320
1327
  const meta = t.kind === 'term'
1321
1328
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
1322
- : (t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1329
+ : (t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.kind === 'report' ? 'report' : t.role);
1323
1330
 
1324
1331
  return el('div', {
1325
1332
  style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
@@ -289,7 +289,7 @@ export function card({ title, titleIcon, meta, signals, lead, trail, onOpen, ope
289
289
  /* ── tabs ────────────────────────────────────────────────────────────────────────────────────────────────────────── */
290
290
 
291
291
  /**
292
- * tabs({ variant: 'segmented'|'line', role: 'tablist'|'radiogroup', items: [{ id, label, count? }], value, onChange(id),
292
+ * tabs({ variant: 'segmented'|'line', role: 'tablist'|'radiogroup', items: [{ id, label, count?, icon? }], value, onChange(id),
293
293
  * ariaLabel, full? })
294
294
  * Arrow keys move focus between tabs. In a tablist the focused tab is also selected; in a radiogroup (the item sheet's
295
295
  * state buttons, where a press writes) selection needs Space or Enter, so moving focus writes nothing.
@@ -303,7 +303,7 @@ export function tabs({ variant = 'segmented', role = 'tablist', items, value, on
303
303
  'aria-selected': radio ? null : (on ? 'true' : 'false'), 'aria-checked': radio ? (on ? 'true' : 'false') : null,
304
304
  tabindex: on || (value == null && it === items[0]) ? '0' : '-1',
305
305
  onClick: () => { if (onChange) onChange(it.id); },
306
- }, el('span', null, it.label), it.count !== undefined && it.count !== null ? el('span', { class: 'pmc-tabs__count' }, String(it.count)) : null);
306
+ }, it.icon ? icon(it.icon) : null, el('span', null, it.label), it.count !== undefined && it.count !== null ? el('span', { class: 'pmc-tabs__count' }, String(it.count)) : null);
307
307
  });
308
308
  const box = el('div', { role, 'aria-label': ariaLabel, ...withClass(cx('pmc-tabs', 'pmc-tabs--' + variant, full && 'pmc-tabs--full'), attrs) }, btns);
309
309
  box.addEventListener('keydown', (e) => {
@@ -0,0 +1,33 @@
1
+ /* pm/report-tab.js — a full report, opened as a tab in the dock (reports-redesign follow-up).
2
+ *
3
+ * The dock tab reads the same draft the Reports viewer reads, and draws it whole: the report as a document,
4
+ * not its slide cards. Print and Copy are the viewer's own actions. The tab writes nothing; a report is read here
5
+ * and changed only from the Reports page, where the lock applies.
6
+ *
7
+ * mountReportTab(host, tab) tab: { id, ws, cadence, slot, title }
8
+ */
9
+ import { el } from './dom.js';
10
+ import { button, ensureComponentsCss } from './primitives.js';
11
+ import { pmGet } from './http.js';
12
+ import { COPY } from './reports-copy.js';
13
+ import { renderReport, printReport, copyMarkdown } from './report-view.js';
14
+
15
+ export function mountReportTab(host, tab) {
16
+ const root = host.firstElementChild;
17
+ if (root && root.getAttribute('data-report-tab') === tab.id) return;
18
+ ensureComponentsCss();
19
+ const page = el('div', { class: 'rp-page rp-tab', 'data-report-tab': tab.id, style: 'flex:1;min-height:0;overflow:auto;padding:12px 16px' });
20
+ host.replaceChildren(page);
21
+ page.appendChild(el('p', { class: 'rp-section__empty' }, COPY.loading));
22
+ const q = '/api/report/draft?ws=' + encodeURIComponent(tab.ws) + '&cadence=' + encodeURIComponent(tab.cadence) + '&slot=' + encodeURIComponent(tab.slot);
23
+ pmGet(q).then((out) => {
24
+ if (!page.isConnected) return;
25
+ if (!out || !out.ok || !out.exists) { page.replaceChildren(el('p', { class: 'rp-section__empty rp-section__empty--bad', role: 'alert' }, COPY.readError)); return; }
26
+ const acts = el('div', { class: 'rp-toolbar__acts' },
27
+ button({ label: COPY.print, variant: 'outline', size: 'sm', onClick: () => printReport(out.markdown) }),
28
+ button({ label: COPY.copyMd, variant: 'outline', size: 'sm', onClick: () => copyMarkdown(out.markdown) }));
29
+ page.replaceChildren(el('div', { class: 'rp-toolbar' }, el('h3', { class: 'rp-section__title rp-toolbar__title' }, tab.title || ''), acts), renderReport(out.markdown));
30
+ }).catch(() => {
31
+ if (page.isConnected) page.replaceChildren(el('p', { class: 'rp-section__empty rp-section__empty--bad', role: 'alert' }, COPY.readError));
32
+ });
33
+ }
@@ -48,6 +48,8 @@ export const COPY = {
48
48
  reportName: (cadence, slot) => cadenceName(cadence) + ' report, ' + shortDate(slot),
49
49
  rowTitle: (cadence, slot) => cadenceName(cadence) + ' · ' + shortDate(slot),
50
50
  draft: 'Draft',
51
+ issuedState: 'Issued',
52
+ typeSummary: (cadence, state) => 'Slides · ' + cadenceName(cadence) + ' · ' + state,
51
53
  issued: (at, by) => 'Issued ' + (dayOf(at) || '') + (by ? ' by ' + by : ''),
52
54
  edited: 'Edited after issue',
53
55
  editedWhy: 'This file changed after it was issued. The issued bytes are in the issues ledger.',
@@ -94,6 +96,10 @@ export const COPY = {
94
96
  schedulesFailed: 'The schedules were not changed.',
95
97
  pause: 'Pause',
96
98
  resume: 'Resume',
99
+ runNow: 'Run now',
100
+ nextRun: (iso) => (dayOf(iso) ? 'next ' + dayOf(iso) : ''),
101
+ oneOff: (cadence) => 'No schedule drafts this ' + cadence + ' report. Make it recurring to draft it on a schedule.',
102
+ makeRecurring: 'Make recurring',
97
103
  paused: 'Paused',
98
104
  pausedMsg: (name) => name + ' is paused.',
99
105
  resumed: (name) => name + ' runs again.',
@@ -105,7 +111,13 @@ export const COPY = {
105
111
 
106
112
  flagTitle: 'The issues ledger has a line that is not read',
107
113
 
114
+ openTab: 'Open as tab',
115
+ newReport: 'New report',
108
116
  present: 'Present',
117
+ lock: 'Lock',
118
+ unlock: 'Unlock',
119
+ locked: 'Locked. Edit notes, Redraft and Issue are off until you unlock.',
120
+ fullScreen: 'Full screen',
109
121
  };
110
122
 
111
123
  export const GLOSSARY = {
@@ -138,6 +138,8 @@ export const COPY = {
138
138
  unreached: 'The workspace did not answer.',
139
139
  refused: (why) => `Nothing changed. ${why}`,
140
140
  oldServer: 'This workspace server has no day run yet. Only your goals show.',
141
+ sections: { label: 'Agenda sections', agenda: 'Agenda', goals: 'Goals', jobs: 'Jobs', mentions: 'Mentions', summary: 'Result' },
142
+ noResult: 'No agent results yet.',
141
143
  panels: { summary: 'Agent results', calendar: 'Calendar', agenda: 'Agenda', jobs: 'Jobs', goals: 'Goals for today', mentions: 'Mentions since yesterday', detail: 'Detail' },
142
144
  summary: (c) => [c.review ? `${c.review} ${c.review === 1 ? 'draft waits' : 'drafts wait'} for you` : null, c.done ? `${c.done} done` : null,
143
145
  c.working ? `${c.working} working` : null, c.failed ? `${c.failed} failed` : null].filter(Boolean).join(' · ') || 'No agent job yet.',
@@ -50,6 +50,7 @@ const store = {
50
50
  // A new key: a fold stored by the old Today page must not hide the Agenda month.
51
51
  const OPEN_KEY = 'pmos.agenda.day.open';
52
52
  const SEEN_KEY = 'pmos.today.day.seen';
53
+ const SECTION_KEY = 'pm.today.section';
53
54
  let seen = new Set();
54
55
  try { seen = new Set(JSON.parse(store.get(SEEN_KEY) || '[]')); } catch { seen = new Set(); }
55
56
 
@@ -57,6 +58,7 @@ const cache = new Map(); // ws -> { data, error }
57
58
  const runs = new Map(); // ws -> the last "Prepare my day" press: { state: 'busy'|'started'|'running'|'failed', at, session, error }
58
59
  const ui = {
59
60
  open: (() => { const v = store.get(OPEN_KEY); return v === 'true' ? true : v === 'false' ? false : null; })(),
61
+ section: (() => { const v = store.get(SECTION_KEY); return ['agenda', 'goals', 'jobs', 'mentions', 'summary'].includes(v) ? v : 'agenda'; })(),
60
62
  sel: null, mtab: 'all', openSrc: null, showIgn: {}, showIgnDigests: false,
61
63
  revising: new Set(), notes: new Map(), siteDraft: '', placeDraft: {}, busy: new Set(), scroll: false,
62
64
  // today-act-prepare: the folded groups the PM opened, the item whose hold form is open, and its draft
@@ -380,6 +382,10 @@ async function setSettings(ws, patch) {
380
382
  function select(type, id) {
381
383
  ui.sel = type ? { type, id: String(id) } : null;
382
384
  if (type === 'job' && !seen.has(id)) { seen.add(id); store.set(SEEN_KEY, JSON.stringify([...seen].slice(-200))); }
385
+ // Reveal the appropriate section when selecting an item
386
+ if (type === 'job') { ui.section = 'jobs'; }
387
+ else if (type === 'goal') { ui.section = 'goals'; }
388
+ else if (type === 'mention' || type === 'channel') { ui.section = 'mentions'; }
383
389
  ui.scroll = true;
384
390
  rerender();
385
391
  }
@@ -1188,6 +1194,41 @@ function fold(open) {
1188
1194
  rerender();
1189
1195
  }
1190
1196
 
1197
+ /** Render the section selector tabs. */
1198
+ function sectionSelector(ctx) {
1199
+ const { m } = ctx;
1200
+ const openGoals = m.allGoals.filter((g) => g.status === 'open').length;
1201
+ const items = [
1202
+ { id: 'agenda', icon: 'calendar', label: D.sections.agenda, count: null },
1203
+ { id: 'goals', icon: 'target', label: D.sections.goals, count: openGoals },
1204
+ { id: 'jobs', icon: 'list-checks', label: D.sections.jobs, count: m.jobs.length },
1205
+ { id: 'mentions', icon: 'text-quote', label: D.sections.mentions, count: m.mentions.length },
1206
+ { id: 'summary', icon: 'bot', label: D.sections.summary, count: null },
1207
+ ];
1208
+ return tabs({ variant: 'segmented', role: 'tablist', ariaLabel: D.sections.label, value: ui.section, full: true, attrs: { 'data-day': 'section-tabs' },
1209
+ items,
1210
+ onChange: (v) => { ui.section = v; store.set(SECTION_KEY, v); rerender(); } });
1211
+ }
1212
+
1213
+ /** Render the chosen section's content. */
1214
+ function sectionContent(ctx) {
1215
+ const { m } = ctx;
1216
+ switch (ui.section) {
1217
+ case 'agenda':
1218
+ return agenda(ctx);
1219
+ case 'goals':
1220
+ return goalsPanel(ctx);
1221
+ case 'jobs':
1222
+ return jobsPanel(ctx);
1223
+ case 'mentions':
1224
+ return mentionsPanel(ctx);
1225
+ case 'summary':
1226
+ return summaryPanel(ctx) || note(D.noResult, { 'data-day': 'no-result' });
1227
+ default:
1228
+ return agenda(ctx);
1229
+ }
1230
+ }
1231
+
1191
1232
  /** The section. `ws` is the repository the day run belongs to; `openIni(id)` opens an initiative of it. */
1192
1233
  export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
1193
1234
  if (FIXTURE) return null;
@@ -1230,12 +1271,12 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
1230
1271
  c.error ? el('p', { class: 'td-day__run td-day__run--failed', role: 'status', 'data-day': 'error' }, D.refused(c.error)) : null,
1231
1272
  m.d && m.d.legacy ? note(D.oldServer) : null,
1232
1273
  el('div', { class: 'td-day__cols' },
1233
- el('div', { class: 'td-day__left' }, agenda(ctx), summaryPanel(ctx), goalsPanel(ctx), jobsPanel(ctx), mentionsPanel(ctx)),
1274
+ el('div', { class: 'td-day__left' }, sectionSelector(ctx), sectionContent(ctx)),
1234
1275
  detail(ctx)));
1235
1276
  }
1236
1277
 
1237
1278
  export function daySig(ws) {
1238
1279
  const c = cache.get(ws) || {};
1239
- return JSON.stringify([forced, ui.open, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], [...ui.groupsOpen], ui.holding, ui.holdDraft.when, [...ui.verdict], c.error, runs.get(ws) || null, [...seen].length])
1280
+ return JSON.stringify([forced, ui.open, ui.section, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], [...ui.groupsOpen], ui.holding, ui.holdDraft.when, [...ui.verdict], c.error, runs.get(ws) || null, [...seen].length])
1240
1281
  + dataSig(c.data) + goalsSig() + agendaSig();
1241
1282
  }
@@ -21,6 +21,7 @@
21
21
  import { el, setChildren } from './dom.js';
22
22
  import { button, needsDot, needsCount } from './primitives.js';
23
23
  import { COPY } from './frame-copy.js';
24
+ import { isRecording } from './note-dock.js';
24
25
 
25
26
  /** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. Docs joined the bar at the
26
27
  * PM's word (2026-10-06, docs-three-way-sync: "I thought Docs is one of the main menu"). People joined after Docs when the
@@ -39,13 +40,19 @@ export function unsyncedOf(st) {
39
40
  /** The lucide symbol each place wears in the bar (UI comfort, 2026-10-07: a word alone is slow to find; the phone's tab bar already wears these). */
40
41
  const NAV_ICON = Object.freeze({ today: 'house', mywork: 'list-checks', initiatives: 'target', board: 'square-kanban', reports: 'chart-column', docs: 'file-text', people: 'users', settings: 'settings' });
41
42
 
42
- export function createTopbar({ go, openPalette, repoMenu, sync } = {}) {
43
+ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {}) {
43
44
  const node = el('header', { class: 'fr-bar', 'data-shell': 'top', 'data-fr': 'bar' });
44
45
 
45
46
  let repoBtn = null;
46
47
  let repoLabel = null;
47
48
  const repoSlot = el('div', { class: 'fr-bar__repo' });
48
49
 
50
+ const recordingIndicator = el('span', {
51
+ class: 'fr-recording-dot', 'data-fr-recording': '',
52
+ title: 'Notes are being recorded',
53
+ 'aria-hidden': 'true',
54
+ });
55
+
49
56
  const count = needsCount('0', { 'data-today-badge': '' });
50
57
  const dot = needsDot();
51
58
  const links = {};
@@ -80,6 +87,12 @@ export function createTopbar({ go, openPalette, repoMenu, sync } = {}) {
80
87
  });
81
88
  search.appendChild(el('kbd', { class: 'fr-kbd' }, COPY.searchKey));
82
89
 
90
+ const notes = button({
91
+ label: 'Notes', variant: 'outline', icon: 'notebook-pen',
92
+ attrs: { class: 'fr-notes', 'data-fr-view': 'notes', title: 'Open notes menu (all notes by initiative)' },
93
+ onClick: () => openView && openView('notesmenu'),
94
+ });
95
+
83
96
  let syncBtn = null;
84
97
  let syncLabel = null;
85
98
 
@@ -116,7 +129,8 @@ export function createTopbar({ go, openPalette, repoMenu, sync } = {}) {
116
129
  syncLabel = want;
117
130
  syncBtn = want ? syncButton(want, !!model.syncing) : null;
118
131
  }
119
- setChildren(node, [repoSlot, nav, search, syncBtn, links.settings]);
132
+ recordingIndicator.classList.toggle('fr-recording-dot--active', isRecording());
133
+ setChildren(node, [repoSlot, recordingIndicator, nav, search, notes, syncBtn, links.settings]);
120
134
  }
121
135
 
122
136
  update({});
@@ -0,0 +1,190 @@
1
+ /* pm/view-notes-menu.js — the Notes menu: all notes grouped by initiative (Run 11, R47).
2
+ *
3
+ * Two-column layout:
4
+ * left all notes grouped by initiative; click an initiative title to collapse/expand;
5
+ * click a note to open its detail on the right
6
+ * right the note detail (same single column as the full-screen note, Run 7)
7
+ */
8
+
9
+ import { el, setChildren } from './dom.js';
10
+ import { askConfirm } from './dialogs.js';
11
+ import { pmGet, pmPost } from './http.js';
12
+ import { icon } from './icons.js';
13
+ import { button, iconButton, empty, toast, ensureComponentsCss } from './primitives.js';
14
+ import * as viewNotes from './view-notes.js';
15
+
16
+ ensureComponentsCss();
17
+
18
+ let cache = new Map();
19
+ let list = null;
20
+ let selectedId = null;
21
+ let expandedInitiatives = new Set();
22
+
23
+ async function loadList() {
24
+ const r = await pmGet('/api/notes');
25
+ if (r && r.ok) {
26
+ list = { notes: r.notes || [] };
27
+ } else {
28
+ list = { notes: [], error: (r && r.error) || 'the notes could not be read' };
29
+ }
30
+ return list;
31
+ }
32
+
33
+ async function loadNote(id) {
34
+ const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
35
+ if (r && r.ok) {
36
+ cache.set(id, r.note);
37
+ return r.note;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function groupByInitiative(notes) {
43
+ const groups = {};
44
+ notes.forEach((n) => {
45
+ const key = n.initiative || '(no initiative)';
46
+ if (!groups[key]) groups[key] = [];
47
+ groups[key].push(n);
48
+ });
49
+ return Object.entries(groups).sort((a, b) => {
50
+ if (a[0] === '(no initiative)') return 1;
51
+ if (b[0] === '(no initiative)') return -1;
52
+ return a[0].localeCompare(b[0]);
53
+ });
54
+ }
55
+
56
+ function renderNoteRow(note, isSelected, onClick) {
57
+ const summary = note.summary || '';
58
+ const summaryText = summary
59
+ .split('\n')
60
+ .slice(0, 2)
61
+ .map((line) => line.trim())
62
+ .filter(Boolean)
63
+ .join(' ')
64
+ .substring(0, 60);
65
+
66
+ return el('div', {
67
+ class: 'nt-menu-row' + (isSelected ? ' nt-menu-row--selected' : ''),
68
+ 'data-nt-menu-row': note.id,
69
+ onClick,
70
+ },
71
+ el('div', { class: 'nt-menu-row-title' }, note.title || 'Untitled note'),
72
+ el('div', { class: 'nt-menu-row-date' }, new Date(note.updated_at).toLocaleDateString()),
73
+ summaryText && el('div', { class: 'nt-menu-row-summary' }, summaryText),
74
+ );
75
+ }
76
+
77
+ function renderLeftPanel(notes, selectedId, onSelectNote, onToggleInitiative) {
78
+ const groups = groupByInitiative(notes);
79
+
80
+ return el('div', { class: 'nt-menu-left' },
81
+ ...groups.map(([initiative, initiativeNotes]) => {
82
+ const isExpanded = expandedInitiatives.has(initiative);
83
+ return el('div', { class: 'nt-menu-group' },
84
+ el('div', {
85
+ class: 'nt-menu-group-title',
86
+ onClick: () => onToggleInitiative(initiative),
87
+ },
88
+ icon(isExpanded ? 'chevron-down' : 'chevron-right', { class: 'nt-menu-group-icon' }),
89
+ el('span', {}, initiative),
90
+ ),
91
+ isExpanded && el('div', { class: 'nt-menu-group-items' },
92
+ ...initiativeNotes.map((note) =>
93
+ renderNoteRow(note, note.id === selectedId, () => onSelectNote(note.id)),
94
+ ),
95
+ ),
96
+ );
97
+ }),
98
+ );
99
+ }
100
+
101
+ export async function renderBoard(inner, ctx) {
102
+ ensureComponentsCss();
103
+ const root = el('div', { class: 'nt-menu' });
104
+
105
+ async function render() {
106
+ if (!list) await loadList();
107
+ const note = selectedId ? cache.get(selectedId) : null;
108
+
109
+ const leftPanel = renderLeftPanel(
110
+ list.notes || [],
111
+ selectedId,
112
+ async (id) => {
113
+ selectedId = id;
114
+ await loadNote(id);
115
+ render();
116
+ },
117
+ (initiative) => {
118
+ if (expandedInitiatives.has(initiative)) {
119
+ expandedInitiatives.delete(initiative);
120
+ } else {
121
+ expandedInitiatives.add(initiative);
122
+ }
123
+ render();
124
+ },
125
+ );
126
+
127
+ const rightPanel = note
128
+ ? await (async () => {
129
+ // Load and render the full note detail using the note view's render logic
130
+ try {
131
+ // Import the note view's rendering for inline display
132
+ const noteModule = await import('./view-notes.js');
133
+ // Parse next steps/actions from the note's final state
134
+ let actions = [];
135
+ if (note.final_state && note.final_state.actions) {
136
+ actions = note.final_state.actions;
137
+ }
138
+ // Use the note's renderInline if available, otherwise render basic detail
139
+ return el('div', { class: 'nt-menu-right' },
140
+ el('div', { class: 'nt-detail-header' },
141
+ el('div', { class: 'nt-detail-title' }, note.title || 'Untitled note'),
142
+ el('div', { class: 'nt-detail-date' }, new Date(note.updated_at).toLocaleDateString()),
143
+ ),
144
+ el('div', { class: 'nt-detail-summary' },
145
+ note.summary ? el('pre', {}, note.summary) : el('p', { class: 'nt-hint' }, 'No summary yet'),
146
+ ),
147
+ actions.length > 0 && el('div', { class: 'nt-detail-actions' },
148
+ el('div', { class: 'nt-detail-actions-title' }, 'Next steps'),
149
+ ...actions.map((action, idx) =>
150
+ el('div', { class: 'nt-detail-action-row' },
151
+ el('div', { class: 'nt-detail-action-text' }, action.text || ''),
152
+ action.owner && el('div', { class: 'nt-detail-action-owner' }, `Owner: ${action.owner}`),
153
+ action.due && el('div', { class: 'nt-detail-action-due' }, `Due: ${action.due}`),
154
+ iconButton({ icon: 'plus', class: 'nt-detail-make-task', title: 'Create task from this step' },
155
+ async () => {
156
+ try {
157
+ const result = await pmPost('/api/notes/create-task', {
158
+ id: note.id,
159
+ text: action.text,
160
+ owner: action.owner,
161
+ due: action.due,
162
+ });
163
+ if (result && result.ok) {
164
+ toast({ text: 'Task created' });
165
+ } else {
166
+ toast({ text: 'Could not create task' });
167
+ }
168
+ } catch (e) {
169
+ console.error('Task creation error:', e);
170
+ toast({ text: 'Error creating task' });
171
+ }
172
+ },
173
+ ),
174
+ ),
175
+ ),
176
+ ),
177
+ );
178
+ } catch {
179
+ return el('div', { class: 'nt-menu-right' }, empty({ text: 'Could not load note detail' }));
180
+ }
181
+ })()
182
+ : el('div', { class: 'nt-menu-right' }, empty({ text: 'Select a note to view details' }));
183
+
184
+ setChildren(root, [leftPanel, rightPanel]);
185
+ }
186
+
187
+ await render();
188
+ ctx.setState = (state) => { render(); };
189
+ return root;
190
+ }
@@ -571,7 +571,8 @@ async function deleteAudio(ctx, n) {
571
571
  async function openSource(kind) {
572
572
  const md = navigator.mediaDevices;
573
573
  if (kind === 'mic') return md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 } });
574
- const s = await md.getDisplayMedia({ video: true, audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, systemAudio: 'include' });
574
+ // preferCurrentTab + selfBrowserSurface defaults to the current tab with tab audio, reducing friction for the common case
575
+ const s = await md.getDisplayMedia({ video: true, audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, systemAudio: 'include', preferCurrentTab: true, selfBrowserSurface: 'include' });
575
576
  const tracks = s.getAudioTracks();
576
577
  for (const t of s.getVideoTracks()) t.stop(); // only the sound is used
577
578
  if (!tracks.length) throw Object.assign(new Error('The shared tab or window has no audio. Share it again and tick "Share tab audio".'), { noAudio: true });
@@ -908,22 +909,9 @@ async function stopRecording(ctx) {
908
909
  async function chooseSources(ctx, n) {
909
910
  const eng = engine && engine.ws === wsOf(ctx) && engine.whisper ? engine : await loadEngine(ctx);
910
911
  const local = !!(eng.whisper && eng.whisper.ready);
911
- const mic = field({ label: 'My microphone', kind: 'check', value: true, attrs: { 'data-nt-src-mic': '' } });
912
- const meet = field({ label: 'The meeting', kind: 'check', value: local, attrs: { 'data-nt-src-meeting': '', disabled: !local } });
913
- const body = [mic, meet,
914
- el('p', { class: 'nt-hint' }, local ? 'Pick the meeting\'s tab or window next, and tick "Share tab audio".' : 'The meeting can be heard once local transcription is set up in Settings. Until then the microphone is recognised by the browser on this device.'),
915
- el('p', { class: 'nt-consent', 'data-nt-consent': '' }, local
916
- ? 'Speech is transcribed on this machine' + (eng.whisper.keep_audio === false ? ', and no audio is kept' : ', and the audio is kept on this machine so you can replay the meeting') + '. Let people know you\'re taking notes.'
917
- : 'Speech is recognised by the browser on this device, and no audio is kept. Let people know you\'re taking notes.')];
918
- let h = null;
919
- const go = () => {
920
- const want = [mic.control.checked && 'mic', local && meet.control.checked && 'meeting'].filter(Boolean);
921
- h.close();
922
- if (!want.length) return;
923
- if (local) startAudio(ctx, n, want); else startSpeech(ctx, n, 'record');
924
- };
925
- h = sheet({ title: 'Listen to', body, attrs: { 'data-nt-chooser': '' },
926
- actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Start', variant: 'primary', onClick: go, attrs: { 'data-nt-start': '' } })] });
912
+ // Default to both mic and meeting (skip the chooser dialog entirely per Wawan's streamlining request)
913
+ const want = local ? ['mic', 'meeting'] : ['speech'];
914
+ if (local) startAudio(ctx, n, want); else startSpeech(ctx, n, 'record');
927
915
  }
928
916
  async function setSwitch(ctx, patch) {
929
917
  const ws = wsOf(ctx);
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: report-new
3
+ label: "New report (brainstorm, then save as a template)"
4
+ description: "Brainstorm one new recurring or ad hoc report with the PM: its purpose, audience, period, sections and format. Saves only the chosen structure as a template and registers its schedule. Never saves the sample artifacts."
5
+ run: agent
6
+ agent: claude
7
+ output:
8
+ - Template
9
+ - Schedule
10
+ - First draft
11
+ ---
12
+
13
+ /report-new — help the PM design one new report, then save its structure as a template. You brainstorm, then write one definition file and register one schedule. The sample text, the slides and any other draft from the session are not saved.
14
+
15
+ Rules that always hold (D71):
16
+ 1. DRAFT ONLY. A report is issued by the PM from the Reports page. Never send, post, share, email, publish or upload a report.
17
+ 2. READ-ONLY on every source. Never change mail, chat, Jira, Confluence, calendar or any other source.
18
+ 3. SAVE ONLY THE STRUCTURE. The template is the chosen structure: id, cadence, audience, sections, purpose and tone. Never save a sample, a slide or a draft as a template.
19
+
20
+ Procedure:
21
+ 1. Ask, one question at a time: what the report is for; who reads it; the sources it should draw on; the sections it needs; and the format (slides, a document, or a message). Offer a sensible default for each and keep going unless the PM corrects it.
22
+ 2. Ask for the period. Accept a cadence word (daily, weekly, bi-weekly, monthly), a single date, a range, or a phrase such as "every Tuesday", "every 2 weeks on Monday" or "the first Monday of the month". Map the phrase to the nearest schedule cadence and say what you chose. For a one-off report, ask for its date and set no recurring schedule.
23
+ 3. Propose a template id: lowercase, starting with a letter, up to 40 characters from a-z, 0-9 and hyphens. Confirm it with the PM.
24
+ 4. Write `<state>/reports/definitions/<id>.md`, where <state> is `pmos/state/` if it exists, otherwise `planning/`. Use this frontmatter, then the prose: `type: Reference`, `title`, `timestamp` (today), `cadence: <id>`, `audience`, and `sections:` as a list. Put the purpose, sources, length and tone in the body.
25
+ 5. If the period is recurring, register the schedule: `pmos schedule add --skill report-template --cadence <daily|weekly|bi-weekly|monthly> --args <id> --name "<Name>"`. Use the cadence from step 2. The schedule drafts the report; it never issues one.
26
+ 6. Run the first draft now: `pmos report draft <id>`. Then say where it is, and that the PM issues it from Reports.
27
+ 7. Report in one short message: the template path, the schedule (or the one-off date), and the first draft path.
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: report-template
3
+ label: "Saved report (from its template)"
4
+ description: "Prepare a saved report as a draft. The schedule's args name the template; the script writes every number, then this skill writes the notes between the markers. Never sends or posts."
5
+ run: agent
6
+ agent: claude
7
+ output:
8
+ - Headline
9
+ - Sections from the template
10
+ - Needs your call
11
+ - Sources reached and not reached
12
+ ---
13
+
14
+ /report-template — prepare a saved report for THIS repository as a draft. The template id is the schedule's args, for example `quarterly-ops`. A script with no model writes the draft and every number in it; you write the words between the note markers.
15
+
16
+ Rules that always hold (D71):
17
+ 1. DRAFT ONLY. You write a file in this repository. Never send, post, share, email, publish or upload the report anywhere; the PM does that.
18
+ 2. READ-ONLY on every source. Never send, reply, forward, post, comment, create, edit, transition, label, archive or delete anything in mail, chat, Jira, Confluence, calendar or any other source.
19
+ 3. SOURCED. Every claim carries a pointer the reader can open: an initiative id, a file path in this repository, or an acceptance. A claim you cannot point to is left out or marked "unverified".
20
+ 4. NOTHING INVENTED. The script lists the sources it reached and did not reach under "Sources reached and not reached": that section is not yours to edit.
21
+ 5. THE TEMPLATE'S RULES. The script shapes the file from the template: its sections, their order and its blocks. The template's purpose, audience, length and tone govern your words. Do not substitute your own format.
22
+ 6. THE SCRIPT WRITES EVERY NUMBER. In a note you type no count, ratio, percentage or date.
23
+ 7. PROSE ONLY BETWEEN NOTE MARKERS. Write only between a `<!-- pmos:note <id> -->` line and its `<!-- /pmos:note -->` line. Never edit, move or delete a marker or any line outside one. Never add a section to the file.
24
+
25
+ Procedure:
26
+ 1. The template id is the schedule's `args`. It must match `^[a-z][a-z0-9-]{0,39}$`. If it is missing or does not match, stop and say which schedule has no valid template id.
27
+ 2. Find the state directory: `pmos/state/` if it exists, otherwise `planning/`. Call it <state>.
28
+ 3. Read the template: `<state>/reports/definitions/<id>.md`. If it does not exist, stop and say the template is missing. Do not invent one.
29
+ 4. Run `pmos report draft <id>`. It writes `<state>/reports/<id>/YYYY-MM-DD.md`, with one empty note marker for each block.
30
+ - If it refuses because a draft for the slot exists, do not overwrite it. Write your notes into the file that exists.
31
+ - If it refuses because the slot is issued, stop. An issued report is frozen. Say so in one line.
32
+ - If it refuses for any other reason, stop and quote the refusal. Do not write the file yourself.
33
+ 5. Read the draft, then the sources it names. Write one or two sentences for each `<!-- pmos:note <block-id> -->`. Start with "Warning:" when a block needs attention. Leave any note that already has text.
34
+ 6. Run `pmos report draft <id> --redraft` so the script rewrites the file and keeps every note. Read the file once more.
@@ -103,6 +103,7 @@
103
103
  "pm-r29-filespad-test.js",
104
104
  "pm-r29-newsession-alignment-test.js",
105
105
  "pm-live-notes-test.js",
106
+ "pm-notes-menu-test.js",
106
107
  "pm-r30-test.js",
107
108
  "pm-breakdown-test.js",
108
109
  "pm-breakdown-phone-test.js",
@@ -201,6 +202,7 @@
201
202
  "pm-r29-filespad-test.js": 5,
202
203
  "pm-r29-newsession-alignment-test.js": 8,
203
204
  "pm-live-notes-test.js": 24,
205
+ "pm-notes-menu-test.js": 8,
204
206
  "pm-r30-test.js": 4,
205
207
  "ui-test.js": 30,
206
208
  "plan-items.test.js": 1,