create-pmos 0.107.0 → 0.109.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/layout.md +2 -2
- package/workspace/guide/notes.md +6 -4
- package/workspace/lib/notes.js +98 -1
- package/workspace/lib/routes-notes.js +32 -11
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +2 -2
- package/workspace/public/pm/components.css +315 -163
- package/workspace/public/pm/dest-notes.js +383 -0
- package/workspace/public/pm/dest-settings.js +9 -2
- package/workspace/public/pm/dock-sessions.js +9 -2
- package/workspace/public/pm/dock.js +11 -0
- package/workspace/public/pm/frame-copy.js +2 -0
- package/workspace/public/pm/note-dock.js +68 -22
- package/workspace/public/pm/notes-links.js +3 -3
- package/workspace/public/pm/notes-task.js +36 -0
- package/workspace/public/pm/panel-cockpit.js +1 -2
- package/workspace/public/pm/panel-newsess.js +5 -1
- package/workspace/public/pm/registry.js +2 -0
- package/workspace/public/pm/topbar.js +42 -17
- package/workspace/public/pm/view-notes.js +120 -158
- package/workspace/public/vendor/lucide-sprite.js +8 -2
- package/workspace/test-files.json +3 -1
- package/workspace/third-party/README.md +1 -1
- package/workspace/public/pm/view-notes-menu.js +0 -317
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/* pm/dest-notes.js — the Notes place: every meeting note, grouped by initiative, and the note itself (meeting-notes Run 12,
|
|
2
|
+
* the "Notes: next features" mockup the PM approved on 2026-10-07, board 1 "Notes menu").
|
|
3
|
+
*
|
|
4
|
+
* A DESTINATION MODULE (registry.js): mountDestination(host, ctx) on every render with the same host, disposeDestination(host).
|
|
5
|
+
* It keeps ONE persistent root per host and redraws its two cards only when what they show changed, so a title being typed
|
|
6
|
+
* keeps its focus. A link from another place (a calendar event, an initiative's Notes tab) opens a note through the tab's slug.
|
|
7
|
+
*
|
|
8
|
+
* Layout, as the mockup: the page title and a search field, then two cards side by side.
|
|
9
|
+
* left BY INITIATIVE · n notes. One group per initiative, then "No initiative"; each row is the meeting time or day,
|
|
10
|
+
* the title, and an amber dot when a statement in the note differs from a file in the repository.
|
|
11
|
+
* right the note: one meta line (Meeting note · day time · length · initiative), the title with Record / Resume / Stop
|
|
12
|
+
* and the ⋯ menu, then only what is there: Conflicts with other files, Highlights, the Overview, Decisions,
|
|
13
|
+
* Next steps (owner · due, and Make task), Open questions, and the discussion and transcript folded at the end.
|
|
14
|
+
*
|
|
15
|
+
* WHERE EACH PART COMES FROM. After Stop and the final pass, the agent's final notes (note.final_notes: grounded in quotes);
|
|
16
|
+
* before that, what was caught live (view-notes.js caughtItems: the agent's minute bullets, else the labelled lines) and
|
|
17
|
+
* the overview the agent or this machine wrote. Conflicts are the lines, bullets and highlights whose repository check says
|
|
18
|
+
* the file differs (lib/notes.js conflictsOf). The overview and the final items are edited in place: a click puts the
|
|
19
|
+
* cursor in the text and leaving it saves (POST /api/notes/final-edit), as the mockup shows. Make task adds the next step
|
|
20
|
+
* to the note's initiative as a plan item in its first stage (POST /api/plan-items, the Breakdown's own writer), with Undo.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { el } from './dom.js';
|
|
24
|
+
import { button, iconButton, field, row, menu, toast, empty, ensureComponentsCss } from './primitives.js';
|
|
25
|
+
import { ensureSprite } from './icons.js';
|
|
26
|
+
import { pmGet, pmPost } from './http.js';
|
|
27
|
+
import { registerCommand, openDestination } from './registry.js';
|
|
28
|
+
import { askConfirm } from './dialogs.js';
|
|
29
|
+
import * as dock from './note-dock.js';
|
|
30
|
+
import * as V from './view-notes.js';
|
|
31
|
+
import { makeTask as makeTaskFrom } from './notes-task.js';
|
|
32
|
+
|
|
33
|
+
export const COPY = Object.freeze({
|
|
34
|
+
title: 'Notes',
|
|
35
|
+
fixture: 'Notes shows your meeting notes in a real session.',
|
|
36
|
+
search: 'Search notes',
|
|
37
|
+
byInitiative: 'By initiative',
|
|
38
|
+
count: (n) => `${n} ${n === 1 ? 'note' : 'notes'}`,
|
|
39
|
+
noInitiative: 'No initiative',
|
|
40
|
+
none: 'No notes yet. Press Record at the top right to start one.',
|
|
41
|
+
noMatch: (q) => `No note matches “${q}”.`,
|
|
42
|
+
pick: 'Choose a note to read it.',
|
|
43
|
+
reading: 'Reading…',
|
|
44
|
+
readError: 'The notes could not be read.',
|
|
45
|
+
meetingNote: 'Meeting note',
|
|
46
|
+
record: 'Record', resume: 'Resume', stop: 'Stop',
|
|
47
|
+
conflicts: 'Conflicts with other files',
|
|
48
|
+
conflictWith: 'conflicts with',
|
|
49
|
+
openFile: 'Open file',
|
|
50
|
+
highlights: 'Highlights',
|
|
51
|
+
overview: 'Overview',
|
|
52
|
+
noOverview: 'No overview yet. It is written when you stop recording.',
|
|
53
|
+
decisions: 'Decisions', actions: 'Next steps', questions: 'Open questions',
|
|
54
|
+
makeTask: 'Make task',
|
|
55
|
+
taskAdded: (ini, stage) => `Added to ${ini}, under ${stage}`,
|
|
56
|
+
taskNoInitiative: 'Link the note to an initiative first. Make task adds the step to its breakdown.',
|
|
57
|
+
taskFailed: 'The task was not made',
|
|
58
|
+
undo: 'Undo',
|
|
59
|
+
discussion: 'Discussion',
|
|
60
|
+
transcript: (n) => `${n} transcript ${n === 1 ? 'line' : 'lines'}`,
|
|
61
|
+
openFull: 'Open the full note',
|
|
62
|
+
del: 'Delete note',
|
|
63
|
+
delAsk: (t) => `Delete “${t}”?`,
|
|
64
|
+
delBody: 'The note, its transcript and its audio are deleted from this machine.',
|
|
65
|
+
saved: 'Saved',
|
|
66
|
+
notSaved: 'Not saved',
|
|
67
|
+
edited: 'edited',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
registerCommand({ id: 'notes-open', label: 'Notes', hint: 'Your meeting notes, by initiative', cli: null, run: () => openDestination('notes') });
|
|
71
|
+
registerCommand({ id: 'notes-menu-open', label: 'notes menu', hint: 'All notes by initiative', cli: null, run: () => openDestination('notes') });
|
|
72
|
+
|
|
73
|
+
/* ── words ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
74
|
+
|
|
75
|
+
const dayStart = (d) => { const t = new Date(d); t.setHours(0, 0, 0, 0); return t.getTime(); };
|
|
76
|
+
/** The left column's time: "14:00" today, "Mon" this week, else "1 Oct". */
|
|
77
|
+
export function whenShort(iso, now = Date.now()) {
|
|
78
|
+
const t = Date.parse(iso);
|
|
79
|
+
if (!Number.isFinite(t)) return '';
|
|
80
|
+
const d = new Date(t);
|
|
81
|
+
const days = Math.round((dayStart(now) - dayStart(t)) / 86400000);
|
|
82
|
+
if (days === 0) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
83
|
+
if (days > 0 && days < 7) return d.toLocaleDateString('en-GB', { weekday: 'short' });
|
|
84
|
+
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
|
85
|
+
}
|
|
86
|
+
/** "Today 14:00", "Mon 6 Oct 14:00". */
|
|
87
|
+
function whenLong(iso, now = Date.now()) {
|
|
88
|
+
const t = Date.parse(iso);
|
|
89
|
+
if (!Number.isFinite(t)) return '';
|
|
90
|
+
const d = new Date(t);
|
|
91
|
+
const hm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
92
|
+
return (dayStart(now) === dayStart(t) ? 'Today' : d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })) + ' ' + hm;
|
|
93
|
+
}
|
|
94
|
+
const minutes = (ms) => (ms >= 60000 ? Math.round(ms / 60000) + ' min' : '');
|
|
95
|
+
const startOf = (n) => (n.meeting && n.meeting.start) || n.recorded_from || n.created_at || n.updated_at;
|
|
96
|
+
const iniName = (i) => (i ? i.title || i.id : null);
|
|
97
|
+
const lengthOf = (n) => {
|
|
98
|
+
const ts = (n.transcript || []).map((x) => x.t || 0);
|
|
99
|
+
return ts.length ? Math.max(...ts) : 0;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** The notes, grouped by initiative title, "No initiative" last; each group newest first. Pure. */
|
|
103
|
+
export function groupNotes(rows) {
|
|
104
|
+
const groups = new Map();
|
|
105
|
+
for (const r of rows) {
|
|
106
|
+
const k = iniName(r.initiative) || '';
|
|
107
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
108
|
+
groups.get(k).push(r);
|
|
109
|
+
}
|
|
110
|
+
return [...groups.entries()]
|
|
111
|
+
.sort((a, b) => (a[0] === '' ? 1 : b[0] === '' ? -1 : a[0].localeCompare(b[0])))
|
|
112
|
+
.map(([k, list]) => ({ key: k, label: k || COPY.noInitiative, rows: list }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What the note shows, from the final notes when there are some, else from what was caught live. Pure. */
|
|
116
|
+
export function partsOf(n) {
|
|
117
|
+
const f = n.final_notes;
|
|
118
|
+
if (f) {
|
|
119
|
+
return {
|
|
120
|
+
from: 'final',
|
|
121
|
+
overview: f.overview || f.purpose || '',
|
|
122
|
+
decisions: (f.decisions || []).map((x, i) => ({ ...x, sec: 'decision', i })),
|
|
123
|
+
actions: (f.actions || []).map((x, i) => ({ ...x, sec: 'action', i })),
|
|
124
|
+
questions: (f.questions || []).map((x, i) => ({ ...x, sec: 'question', i })),
|
|
125
|
+
topics: f.topics || [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const c = V.caughtItems(n);
|
|
129
|
+
let overview = '';
|
|
130
|
+
if (n.agent && n.agent.markdown) overview = String(n.agent.markdown).split(/\r?\n/).map((l) => l.replace(/^\s*(?:#+|[-*]|\d+[.)])\s*/, '').replace(/\*\*|__|`/g, '').trim()).filter((l) => l && !/^(summary|overview)$/i.test(l)).join(' ');
|
|
131
|
+
else {
|
|
132
|
+
const ov = n.summary && n.summary.sections && n.summary.sections.overview;
|
|
133
|
+
if (Array.isArray(ov)) overview = ov.map((x) => String((x && x.text) || '')).filter(Boolean).join(' ');
|
|
134
|
+
}
|
|
135
|
+
return { from: 'live', overview, decisions: c.decision, actions: c.action, questions: c.question, topics: [] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The statements that differ from a repository file (lib/notes.js conflictsOf, read on the page). Pure. */
|
|
139
|
+
export function conflictsOf(n) {
|
|
140
|
+
const out = []; const seen = new Set();
|
|
141
|
+
const add = (x) => {
|
|
142
|
+
const c = x && !x.dropped && x.check;
|
|
143
|
+
if (!c || c.verdict !== 'differs' || !c.doc) return;
|
|
144
|
+
const k = c.doc + ':' + (c.line || 0);
|
|
145
|
+
if (seen.has(k)) return;
|
|
146
|
+
seen.add(k);
|
|
147
|
+
out.push({ text: x.text, doc: c.doc, line: c.line || null, t: typeof x.t === 'number' ? x.t : null });
|
|
148
|
+
};
|
|
149
|
+
(n.highlights || []).forEach(add); (n.transcript || []).forEach(add); (n.bullets || []).forEach(add);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ── mount ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
154
|
+
|
|
155
|
+
const insts = new WeakMap();
|
|
156
|
+
const REREAD_MS = 4000;
|
|
157
|
+
|
|
158
|
+
export function mountDestination(host, ctx) {
|
|
159
|
+
let inst = insts.get(host);
|
|
160
|
+
if (!inst) {
|
|
161
|
+
inst = { host, root: el('div', { class: 'rp-page nt-pg', 'data-notes-page': '' }), head: null, left: el('section', { class: 'nt-pg-card nt-pg-list', 'data-ntm': 'list' }),
|
|
162
|
+
right: el('section', { class: 'nt-pg-card nt-pg-note', 'data-ntm': 'note' }), rows: null, error: null, q: '', open: null, note: null, slug: null,
|
|
163
|
+
readAt: 0, seq: 0, leftSig: '', rightSig: '', repo: null, ctx };
|
|
164
|
+
insts.set(host, inst);
|
|
165
|
+
}
|
|
166
|
+
inst.ctx = ctx;
|
|
167
|
+
ensureComponentsCss();
|
|
168
|
+
if (!inst.root.isConnected || inst.root.parentNode !== host) host.replaceChildren(inst.root);
|
|
169
|
+
if (ctx && ctx.FIXTURE) { inst.root.replaceChildren(empty({ text: COPY.fixture })); return; }
|
|
170
|
+
ensureSprite();
|
|
171
|
+
if (!inst.head) {
|
|
172
|
+
const find = field({ label: COPY.search, kind: 'text', hideLabel: true, placeholder: COPY.search, attrs: { 'data-ntm': 'search', class: 'nt-pg-search' } });
|
|
173
|
+
find.control.addEventListener('input', () => { inst.q = find.control.value; drawLeft(inst, true); });
|
|
174
|
+
inst.head = el('div', { class: 'rp-head nt-pg-head' }, el('h1', { class: 'rp-head__title' }, COPY.title), find);
|
|
175
|
+
inst.root.replaceChildren(inst.head, el('div', { class: 'nt-pg-grid' }, inst.left, inst.right));
|
|
176
|
+
}
|
|
177
|
+
inst.repo = (ctx && ctx.tab && ctx.tab.repo) || (ctx && ctx.state && ctx.state.product) || null;
|
|
178
|
+
const slug = (ctx && ctx.tab && ctx.tab.slug) || null;
|
|
179
|
+
if (slug !== inst.slug) { inst.slug = slug; if (slug) { inst.open = slug; inst.note = null; } }
|
|
180
|
+
if (!inst.rows || Date.now() - inst.readAt > REREAD_MS) refresh(inst);
|
|
181
|
+
drawLeft(inst);
|
|
182
|
+
drawRight(inst);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function disposeDestination(host) {
|
|
186
|
+
const inst = insts.get(host);
|
|
187
|
+
if (!inst) return;
|
|
188
|
+
inst.seq++;
|
|
189
|
+
inst.root.remove();
|
|
190
|
+
insts.delete(host);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function refresh(inst) {
|
|
194
|
+
const seq = ++inst.seq;
|
|
195
|
+
inst.readAt = Date.now();
|
|
196
|
+
const [r, one] = await Promise.all([pmGet('/api/notes'), inst.open ? pmGet('/api/notes/one?id=' + encodeURIComponent(inst.open)) : null]);
|
|
197
|
+
if (seq !== inst.seq) return;
|
|
198
|
+
if (r && r.ok) { inst.rows = r.notes || []; inst.error = null; } else { inst.rows = inst.rows || []; inst.error = (r && r.error) || COPY.readError; }
|
|
199
|
+
if (!inst.open && inst.rows.length) inst.open = inst.rows[0].id;
|
|
200
|
+
if (one && one.ok && one.note && one.note.id === inst.open) inst.note = one.note;
|
|
201
|
+
else if (inst.open && !one) loadOpen(inst);
|
|
202
|
+
drawLeft(inst);
|
|
203
|
+
drawRight(inst);
|
|
204
|
+
}
|
|
205
|
+
async function loadOpen(inst) {
|
|
206
|
+
const id = inst.open;
|
|
207
|
+
const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
|
|
208
|
+
if (inst.open !== id) return;
|
|
209
|
+
inst.note = r && r.ok ? r.note : null;
|
|
210
|
+
drawRight(inst);
|
|
211
|
+
}
|
|
212
|
+
function choose(inst, id) {
|
|
213
|
+
if (inst.open === id) return;
|
|
214
|
+
inst.open = id; inst.note = null;
|
|
215
|
+
drawLeft(inst, true); drawRight(inst);
|
|
216
|
+
loadOpen(inst);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ── the left card: notes by initiative ──────────────────────────────────────────────────────────────────────────── */
|
|
220
|
+
|
|
221
|
+
function drawLeft(inst, force) {
|
|
222
|
+
const rows = inst.rows;
|
|
223
|
+
const q = inst.q.trim().toLowerCase();
|
|
224
|
+
const sig = JSON.stringify([rows && rows.map((r) => [r.id, r.title, r.updated_at, iniName(r.initiative), r.conflicts]), q, inst.open, inst.error]);
|
|
225
|
+
if (!force && sig === inst.leftSig) return;
|
|
226
|
+
inst.leftSig = sig;
|
|
227
|
+
const shown = (rows || []).filter((r) => !q || String(r.title || '').toLowerCase().includes(q) || String(iniName(r.initiative) || '').toLowerCase().includes(q));
|
|
228
|
+
const head = el('h2', { class: 'nt-pg-label' }, el('span', null, COPY.byInitiative), el('span', { class: 'nt-pg-label__n' }, rows ? COPY.count(shown.length) : ''));
|
|
229
|
+
let body;
|
|
230
|
+
if (!rows) body = el('p', { class: 'nt-hint' }, COPY.reading);
|
|
231
|
+
else if (!rows.length) body = empty({ text: inst.error || COPY.none });
|
|
232
|
+
else if (!shown.length) body = empty({ text: COPY.noMatch(inst.q.trim()) });
|
|
233
|
+
else {
|
|
234
|
+
body = el('div', { class: 'nt-pg-groups' }, groupNotes(shown).map((g) => el('div', { class: 'nt-pg-group', 'data-ntm-group': g.label },
|
|
235
|
+
el('h3', { class: 'nt-pg-group__h' }, el('span', null, g.label), el('span', { class: 'nt-pg-label__n' }, String(g.rows.length))),
|
|
236
|
+
g.rows.map((r) => row({ title: r.title || 'Untitled note', compact: true,
|
|
237
|
+
lead: [el('span', { class: 'nt-pg-row__at' }, whenShort(startOf(r)))],
|
|
238
|
+
trail: r.conflicts ? [el('span', { class: 'nt-pg-warn-dot', 'data-ntm-conflict-dot': '', title: r.conflicts + (r.conflicts === 1 ? ' conflict' : ' conflicts') })] : null,
|
|
239
|
+
onOpen: () => choose(inst, r.id), openLabel: 'Open ' + (r.title || 'Untitled note'),
|
|
240
|
+
openAttrs: { 'aria-current': r.id === inst.open ? 'true' : 'false' },
|
|
241
|
+
attrs: { class: 'nt-pg-row', 'data-ntm-row': r.id } })))));
|
|
242
|
+
}
|
|
243
|
+
inst.left.replaceChildren(head, body);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* ── the right card: the note ────────────────────────────────────────────────────────────────────────────────────── */
|
|
247
|
+
|
|
248
|
+
function drawRight(inst, force) {
|
|
249
|
+
const n = inst.note;
|
|
250
|
+
const live = dock.recordState();
|
|
251
|
+
const sig = JSON.stringify([inst.open, n && [n.updated_at, n.title, n.final_notes && n.final_notes.at, (n.transcript || []).length, (n.highlights || []).length, n.agent && n.agent.markdown, iniName(n.initiative)], live.mode, live.id, !!inst.rows]);
|
|
252
|
+
if (!force && sig === inst.rightSig) return;
|
|
253
|
+
// a field being typed in is not redrawn under the cursor; the next read after it is left draws it
|
|
254
|
+
if (inst.right.contains(document.activeElement) && document.activeElement.matches('input, [contenteditable="true"]')) return;
|
|
255
|
+
inst.rightSig = sig;
|
|
256
|
+
if (!inst.open) { inst.right.replaceChildren(empty({ text: inst.rows && inst.rows.length ? COPY.pick : COPY.none })); return; }
|
|
257
|
+
if (!n) { inst.right.replaceChildren(el('p', { class: 'nt-hint' }, COPY.reading)); return; }
|
|
258
|
+
inst.right.replaceChildren(...noteParts(inst, n, live));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function noteParts(inst, n, live) {
|
|
262
|
+
const p = partsOf(n);
|
|
263
|
+
const meta = [COPY.meetingNote, whenLong(startOf(n)), minutes(lengthOf(n)), iniName(n.initiative)].filter(Boolean).join(' · ');
|
|
264
|
+
const title = field({ label: 'Title', kind: 'title', hideLabel: true, value: n.title || '', placeholder: 'Untitled note',
|
|
265
|
+
attrs: { 'data-ntm': 'title', class: 'nt-pg-title' },
|
|
266
|
+
onCommit: async (v) => { const r = await pmPost('/api/notes/save', { id: n.id, title: v }); if (r && r.ok) { n.title = v; refresh(inst); } else toast({ text: COPY.notSaved, tone: 'destructive' }); } });
|
|
267
|
+
const isLive = live.mode === 'recording' && live.id === n.id;
|
|
268
|
+
const acts = [];
|
|
269
|
+
if (!n.private) {
|
|
270
|
+
acts.push(isLive
|
|
271
|
+
? button({ label: COPY.stop, variant: 'outline', size: 'sm', onClick: () => dock.finish(), attrs: { 'data-ntm': 'stop' } })
|
|
272
|
+
: button({ label: (n.transcript || []).length ? COPY.resume : COPY.record, variant: 'primary', size: 'sm', icon: 'mic',
|
|
273
|
+
onClick: () => dock.recordNote(n.id, n.title), attrs: { 'data-ntm': (n.transcript || []).length ? 'resume' : 'record' } }));
|
|
274
|
+
}
|
|
275
|
+
const more = iconButton({ icon: 'ellipsis', ariaLabel: 'More', attrs: { 'data-ntm': 'more' },
|
|
276
|
+
onClick: () => menu({ anchor: more, ariaLabel: 'Note actions', items: [
|
|
277
|
+
{ label: COPY.openFull, onSelect: () => dock.openDock(n.id) },
|
|
278
|
+
{ label: COPY.del, onSelect: () => deleteNote(inst, n) },
|
|
279
|
+
] }) });
|
|
280
|
+
acts.push(more);
|
|
281
|
+
const out = [
|
|
282
|
+
el('div', { class: 'nt-pg-top' },
|
|
283
|
+
el('span', { class: 'nt-pg-meta', 'data-ntm': 'meta' }, meta),
|
|
284
|
+
el('div', { class: 'nt-pg-titlerow' }, el('div', { class: 'nt-pg-titlewrap' }, title), el('div', { class: 'nt-pg-acts' }, acts))),
|
|
285
|
+
];
|
|
286
|
+
if (n.private) out.push(el('p', { class: 'nt-hint' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
|
|
287
|
+
|
|
288
|
+
const cf = conflictsOf(n);
|
|
289
|
+
if (cf.length) {
|
|
290
|
+
out.push(el('section', { class: 'nt-pg-conflicts', 'data-ntm': 'conflicts' },
|
|
291
|
+
el('h3', { class: 'nt-pg-sec__h nt-pg-sec__h--warn' }, el('span', null, COPY.conflicts), el('span', { class: 'nt-pg-label__n' }, String(cf.length))),
|
|
292
|
+
cf.map((c) => el('div', { class: 'nt-pg-li', 'data-ntm-conflict': c.doc },
|
|
293
|
+
el('span', { class: 'nt-pg-warn-dot' }),
|
|
294
|
+
el('span', { class: 'nt-pg-li__text' }, '“' + c.text + '” ', el('span', { class: 'nt-pg-dim' }, COPY.conflictWith + ' '), el('span', { class: 'nt-pg-mono' }, c.doc + (c.line ? ':' + c.line : ''))),
|
|
295
|
+
button({ label: COPY.openFile, variant: 'outline', size: 'sm', onClick: () => openFile(inst, c.doc), attrs: { 'data-ntm-open-file': c.doc } })))));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const hl = n.highlights || [];
|
|
299
|
+
if (hl.length) {
|
|
300
|
+
out.push(el('section', { class: 'nt-pg-sec nt-pg-sec--first', 'data-ntm': 'highlights' },
|
|
301
|
+
el('h3', { class: 'nt-pg-sec__h' }, el('span', null, COPY.highlights), el('span', { class: 'nt-pg-label__n' }, String(hl.length))),
|
|
302
|
+
hl.map((h) => el('div', { class: 'nt-pg-li', 'data-ntm-highlight': h.id },
|
|
303
|
+
el('span', { class: 'nt-pg-li__at' }, typeof h.t === 'number' ? V.fmtTime(h.t) : ''),
|
|
304
|
+
el('span', { class: 'nt-pg-tag' }, h.label || 'Highlight'),
|
|
305
|
+
el('span', { class: 'nt-pg-li__text' }, h.text,
|
|
306
|
+
h.check && h.check.doc ? el('span', { class: 'nt-pg-sub' }, (h.check.verdict === 'differs' ? 'Differs from ' : h.check.verdict === 'agrees' ? 'Agrees with ' : 'Closest file: ') + h.check.doc + (h.check.line ? ':' + h.check.line : '')) : null)))));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
out.push(el('section', { class: 'nt-pg-sec nt-pg-sec--overview', 'data-ntm': 'overview' },
|
|
310
|
+
el('h3', { class: 'nt-pg-sec__h nt-pg-sr' }, COPY.overview),
|
|
311
|
+
p.overview
|
|
312
|
+
? editable(p.from === 'final' ? { tag: 'p', cls: 'nt-pg-para', text: p.overview, label: 'Overview', save: (t) => saveFinal(inst, n, 'overview', 0, t) } : { tag: 'p', cls: 'nt-pg-para', text: p.overview })
|
|
313
|
+
: el('p', { class: 'nt-hint' }, COPY.noOverview)));
|
|
314
|
+
|
|
315
|
+
const section = (key, label, items, mark, trail) => {
|
|
316
|
+
if (!items.length) return null;
|
|
317
|
+
return el('section', { class: 'nt-pg-sec', 'data-ntm-sec': key },
|
|
318
|
+
el('h3', { class: 'nt-pg-sec__h' }, el('span', null, label), el('span', { class: 'nt-pg-label__n' }, String(items.length))),
|
|
319
|
+
items.map((x) => el('div', { class: 'nt-pg-li', 'data-ntm-item': key },
|
|
320
|
+
el('span', { class: 'nt-pg-li__mk', 'aria-hidden': 'true' }, mark),
|
|
321
|
+
p.from === 'final' ? editable({ tag: 'span', cls: 'nt-pg-li__text', text: x.text, label: label, save: (t) => saveFinal(inst, n, x.sec, x.i, t) }) : el('span', { class: 'nt-pg-li__text' }, x.text),
|
|
322
|
+
x.orig_text !== undefined ? el('span', { class: 'nt-pg-dim', title: 'First written as: ' + x.orig_text }, COPY.edited) : null,
|
|
323
|
+
trail ? trail(x) : null)));
|
|
324
|
+
};
|
|
325
|
+
out.push(section('decision', COPY.decisions, p.decisions, '—'));
|
|
326
|
+
out.push(section('action', COPY.actions, p.actions, '○', (x) => [
|
|
327
|
+
x.owner || x.due ? el('span', { class: 'nt-pg-li__who' }, [x.owner, x.due].filter(Boolean).join(' · ')) : null,
|
|
328
|
+
button({ label: COPY.makeTask, variant: 'outline', size: 'sm', onClick: (e) => makeTaskFrom(n, x, { repo: inst.repo, btn: e && e.currentTarget }), attrs: { 'data-ntm-make-task': '' } }),
|
|
329
|
+
]));
|
|
330
|
+
out.push(section('question', COPY.questions, p.questions, '?'));
|
|
331
|
+
|
|
332
|
+
const tr = (n.final && n.final.segments && n.final.segments.length ? n.final.segments.map((g) => ({ t: g.t, text: g.text })) : (n.transcript || []).filter((x) => !x.dropped));
|
|
333
|
+
if (p.topics.length || tr.length) {
|
|
334
|
+
const bits = [p.topics.length ? p.topics.length + (p.topics.length === 1 ? ' topic' : ' topics') : null, tr.length ? COPY.transcript(tr.length) : null].filter(Boolean).join(' · ');
|
|
335
|
+
out.push(el('details', { class: 'nt-pg-sec nt-pg-fold', 'data-ntm': 'discussion' },
|
|
336
|
+
el('summary', { class: 'nt-pg-fold__sum' }, COPY.discussion + ' · ' + bits),
|
|
337
|
+
p.topics.map((tp) => el('div', { class: 'nt-pg-topic' }, el('h4', { class: 'nt-pg-topic__h' }, tp.title), (tp.points || []).map((pt) => el('p', { class: 'nt-pg-topic__p' }, pt)))),
|
|
338
|
+
tr.length ? el('div', { class: 'nt-pg-trans' }, tr.map((x) => el('div', { class: 'nt-pg-li' }, el('span', { class: 'nt-pg-li__at' }, V.fmtTime(x.t || 0)), el('span', { class: 'nt-pg-li__text' }, x.text)))) : null));
|
|
339
|
+
}
|
|
340
|
+
return out.filter(Boolean);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A text the PM corrects in place: a click puts the cursor in it, leaving it saves, Esc puts the words back. */
|
|
344
|
+
function editable({ tag, cls, text, label, save }) {
|
|
345
|
+
if (!save) return el(tag, { class: cls }, text);
|
|
346
|
+
const node = el(tag, { class: cls + ' nt-pg-edit', contenteditable: 'true', spellcheck: 'true', role: 'textbox', 'aria-label': 'Edit ' + label, 'data-ntm-edit': '' }, text);
|
|
347
|
+
let last = text;
|
|
348
|
+
node.addEventListener('keydown', (e) => {
|
|
349
|
+
if (e.key === 'Enter' && tag !== 'p') { e.preventDefault(); node.blur(); }
|
|
350
|
+
if (e.key === 'Escape') { e.preventDefault(); node.textContent = last; node.blur(); }
|
|
351
|
+
});
|
|
352
|
+
node.addEventListener('blur', async () => {
|
|
353
|
+
const v = node.textContent.replace(/\s+/g, ' ').trim();
|
|
354
|
+
if (!v || v === last) { node.textContent = last; return; }
|
|
355
|
+
if (await save(v)) last = v; else node.textContent = last;
|
|
356
|
+
});
|
|
357
|
+
return node;
|
|
358
|
+
}
|
|
359
|
+
async function saveFinal(inst, n, sec, i, text) {
|
|
360
|
+
const r = await pmPost('/api/notes/final-edit', { id: n.id, sec, i, text });
|
|
361
|
+
if (!r || !r.ok) { toast({ text: COPY.notSaved + ((r && r.error) ? ': ' + r.error : ''), tone: 'destructive' }); return false; }
|
|
362
|
+
n.final_notes = r.final_notes;
|
|
363
|
+
toast({ text: COPY.saved, timeout: 1500 });
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function openFile(inst, doc) {
|
|
368
|
+
if (!inst.repo) return;
|
|
369
|
+
import('./panel-tabs.js').then((m) => m.openDoc(inst.repo, doc, 'notes')).catch(() => toast({ text: 'The file could not be opened.', tone: 'destructive' }));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function deleteNote(inst, n) {
|
|
373
|
+
const sure = await askConfirm({ title: COPY.delAsk(n.title || 'Untitled note'), body: COPY.delBody, confirmLabel: 'Delete', cancelLabel: 'Keep it' });
|
|
374
|
+
if (!sure) return;
|
|
375
|
+
const r = await pmPost('/api/notes/delete', { id: n.id });
|
|
376
|
+
if (!r || !r.ok) { toast({ text: (r && r.error) || 'The note was not deleted.', tone: 'destructive' }); return; }
|
|
377
|
+
dock.forget(n.id);
|
|
378
|
+
inst.open = null; inst.note = null;
|
|
379
|
+
refresh(inst);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Open the Notes place on one note. */
|
|
383
|
+
export function openNoteHere(id) { return openDestination('notes', { slug: id }); }
|
|
@@ -28,7 +28,7 @@ import { pmGet, pmPost } from './http.js';
|
|
|
28
28
|
import { button, iconButton, field, badge, row, tabs, menu, empty } from './primitives.js';
|
|
29
29
|
import { repoTuples, activeProduct, renameRepo, removeRepo } from './panel-repos.js';
|
|
30
30
|
import { repoRow } from './fixture.js';
|
|
31
|
-
import { sessionDefaults, setSessionDefaults } from './dock.js';
|
|
31
|
+
import { sessionDefaults, setSessionDefaults, adoptProfileDefault } from './dock.js';
|
|
32
32
|
import { COPY } from './frame-copy.js';
|
|
33
33
|
import { askConfirm } from './dialogs.js';
|
|
34
34
|
import { icon } from './icons.js';
|
|
@@ -219,8 +219,13 @@ function profilesSection() {
|
|
|
219
219
|
/** The default Claude profile, from a row or the select: kept for the dock, and remembered by the server for the New session form. */
|
|
220
220
|
function chooseDefaultProfile(id) {
|
|
221
221
|
setSessionDefaults({ profile: id || null });
|
|
222
|
-
pmPost('/api/profiles/prefer', { agent: 'claude', profile: id || null });
|
|
223
222
|
setState({});
|
|
223
|
+
// the server's answer is the default: a refusal reads the profiles again, so the select goes back to what is stored
|
|
224
|
+
pmPost('/api/profiles/prefer', { agent: 'claude', profile: id || null }).then((j) => {
|
|
225
|
+
if (j && j.ok && j.prefs) { adoptProfileDefault({ prefs: j.prefs }); if (sess.profiles) sess.profiles = { ...sess.profiles, prefs: j.prefs }; }
|
|
226
|
+
else { sess.msg = (j && j.error) || P.profileDefaultFailed; readProfilesAgain(); }
|
|
227
|
+
setState({});
|
|
228
|
+
});
|
|
224
229
|
}
|
|
225
230
|
|
|
226
231
|
/* ── the Claude profile and launch flags a new session starts with (workspace-launch-flags) ───────────────────────── */
|
|
@@ -244,6 +249,8 @@ function readProfilesAgain() {
|
|
|
244
249
|
pmGet('/api/profiles').then((j) => {
|
|
245
250
|
sess.reading = false; sess.readAt = Date.now();
|
|
246
251
|
sess.profiles = j || { ok: false, error: 'no answer', profiles: [] };
|
|
252
|
+
// the server's default wins over this browser's copy, so every browser and port shows and starts with the same one
|
|
253
|
+
adoptProfileDefault(sess.profiles);
|
|
247
254
|
// the detail column and the New session form read the same answer
|
|
248
255
|
state.profiles = sess.profiles;
|
|
249
256
|
setState({});
|
|
@@ -24,7 +24,7 @@ import { state, setState } from './state.js';
|
|
|
24
24
|
import { boardOf } from './board-read.js';
|
|
25
25
|
import { button, iconButton, row, field, sheet, toast } from './primitives.js';
|
|
26
26
|
import { COPY } from './frame-copy.js';
|
|
27
|
-
import { sessionDefaults, loadSkills } from './dock.js';
|
|
27
|
+
import { sessionDefaults, adoptProfileDefault, loadSkills } from './dock.js';
|
|
28
28
|
|
|
29
29
|
let dataMod = null; let termMod = null; let iniMod = null; let tabsMod = null;
|
|
30
30
|
import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
|
|
@@ -298,10 +298,17 @@ export function openNewSessionSheet(repo, preset) {
|
|
|
298
298
|
const typed = nameF.control.value.trim();
|
|
299
299
|
const base = (sk ? (sk.label || sk.skill || sk.id) : (d.cli === 'claude' ? 'Claude' : 'Shell')) + (m.initiative ? ' · ' + m.initiative : '');
|
|
300
300
|
const name = typed || uniqueName(base);
|
|
301
|
+
// The default profile is the server's. This browser's copy can be empty or old (another browser, port or host name),
|
|
302
|
+
// so a Claude start reads it first; if the read fails, the copy is used.
|
|
303
|
+
const claudeStart = !!sk || d.cli === 'claude';
|
|
304
|
+
m.busy = true; // one start per press, while the read is out too
|
|
305
|
+
const profile = claudeStart && dataMod && typeof dataMod.loadProfiles === 'function'
|
|
306
|
+
? adoptProfileDefault(await dataMod.loadProfiles().catch(() => null)) : d.profile;
|
|
307
|
+
m.busy = false;
|
|
301
308
|
const common = {
|
|
302
309
|
name, agent: sk ? 'claude' : d.cli, initiative: m.initiative || null,
|
|
303
310
|
// the Settings defaults for a Claude session (workspace-launch-flags); a shell takes neither
|
|
304
|
-
profile:
|
|
311
|
+
profile: claudeStart ? profile : null,
|
|
305
312
|
flags: !sk && d.cli === 'claude' && d.flags.length ? d.flags.map((x) => ({ flag: x.flag, ...(x.value ? { value: x.value } : {}) })) : undefined,
|
|
306
313
|
skill: sk ? sk.id : null, workType: sk ? typeOfSkill(sk) : 'general',
|
|
307
314
|
tier: !sk && d.cli === 'claude' && d.tier !== 'auto' ? d.tier : null,
|
|
@@ -47,6 +47,17 @@ export function sessionDefaults() {
|
|
|
47
47
|
return { cli: CLIS.includes(r.cli) ? r.cli : 'shell', tier: TIERS.includes(r.tier) ? r.tier : 'auto', profile: cleanProfile(r.profile), flags: cleanFlags(r.flags) };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/* The default Claude profile is the SERVER's (prefs.json, `prefs.claude`), not this browser's. The copy above is a cache: it
|
|
51
|
+
* lives per browser origin, so another browser, a phone, or the workspace on another port or host name starts with an
|
|
52
|
+
* empty or old copy. A dock start then sent no profile, or an old one, and the session opened under a different Claude
|
|
53
|
+
* profile than the one set as the default. Every profiles read carries `prefs`; this makes the cache follow it. */
|
|
54
|
+
export function adoptProfileDefault(read) {
|
|
55
|
+
if (!read || read.ok === false || !read.prefs || typeof read.prefs !== 'object') return sessionDefaults().profile;
|
|
56
|
+
const want = cleanProfile(read.prefs.claude);
|
|
57
|
+
if (sessionDefaults().profile !== want) setSessionDefaults({ profile: want });
|
|
58
|
+
return want;
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
export function setSessionDefaults(patch) {
|
|
51
62
|
const next = { ...sessionDefaults(), ...(patch || {}) };
|
|
52
63
|
const clean = { cli: CLIS.includes(next.cli) ? next.cli : 'shell', tier: TIERS.includes(next.tier) ? next.tier : 'auto', profile: cleanProfile(next.profile), flags: cleanFlags(next.flags) };
|
|
@@ -36,6 +36,7 @@ export const COPY = {
|
|
|
36
36
|
board: 'Board',
|
|
37
37
|
reports: 'Reports',
|
|
38
38
|
docs: 'Docs',
|
|
39
|
+
notes: 'Notes',
|
|
39
40
|
people: 'People',
|
|
40
41
|
agent: 'Agent',
|
|
41
42
|
settings: 'Settings',
|
|
@@ -174,6 +175,7 @@ export const COPY = {
|
|
|
174
175
|
modelDeep: 'Deep',
|
|
175
176
|
sessionsNote: 'The dock starts new sessions with these defaults. A model, a profile and launch flags apply only to a Claude session. The New session form can change them for one session.',
|
|
176
177
|
profileDefault: 'Default Claude profile',
|
|
178
|
+
profileDefaultFailed: 'The default profile could not be saved.',
|
|
177
179
|
noProfile: 'No profile',
|
|
178
180
|
profileMissing: (id) => id + ' (not found now)',
|
|
179
181
|
flagsDefault: 'Default launch flags',
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
/* pm/note-dock.js — the meeting note being taken
|
|
1
|
+
/* pm/note-dock.js — the meeting note being taken: the Record control's brain and the pop-up (meeting-notes Run 6, Run 12).
|
|
2
2
|
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* The Record control lives in the top bar (pm/topbar.js, mockup board 2): Record when idle, the red dot with the clock, the
|
|
4
|
+
* highlighter and Pause while listening, Resume when paused. Take notes on a calendar event makes the note and starts
|
|
5
|
+
* listening at once. A press on the clock opens the note as a pop-up: the same note as the Notes tab, in one column
|
|
6
|
+
* (pm/view-notes.js renderInline). Minimize or Esc puts it away; the highlight pop-up sits bottom right on every place.
|
|
7
7
|
*
|
|
8
|
-
* One note at a time. The note is kept in this browser (KEY), so it is still there after a reload
|
|
9
|
-
*
|
|
8
|
+
* One note at a time. The note is kept in this browser (KEY), so it is still there after a reload. Starting another note
|
|
9
|
+
* while one is listening asks first.
|
|
10
10
|
*
|
|
11
11
|
* mountDock() → draws the dock on every render (pm/app.js boot)
|
|
12
|
-
*
|
|
12
|
+
* recordState() → { mode: idle | recording | paused, id, title, started } for the top bar
|
|
13
|
+
* recordNew(btn) · recordNote(id, title) · pauseNow() · resumeNow() · finish() · highlightNow()
|
|
14
|
+
* takeNotes(e, opts, btn) → makes a note linked to the event, starts listening
|
|
13
15
|
* openDock(id) → shows a note in the pop-up
|
|
14
16
|
* minimize() · activeId() · forget(id)
|
|
15
17
|
*/
|
|
@@ -96,6 +98,57 @@ async function switchFrom() {
|
|
|
96
98
|
return true;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
/* ── the Record control at the right of the top bar (meeting-notes Run 12, mockup board 2) ────────────────────────────
|
|
102
|
+
* It replaces the pill: idle it says Record; while the note listens it shows the red dot and the clock (a press opens the
|
|
103
|
+
* note), the highlight and Pause; paused it shows the clock and Resume. Stop, in the note, ends it and the control is idle
|
|
104
|
+
* again. The note it holds is the dock's one note (active). */
|
|
105
|
+
|
|
106
|
+
/** { mode: 'idle'|'recording'|'paused', id, title, started } — what the Record control shows. */
|
|
107
|
+
export function recordState() {
|
|
108
|
+
if (!active) return { mode: 'idle', id: null, title: '', started: null };
|
|
109
|
+
const live = !!V && liveId() === active.id;
|
|
110
|
+
return { mode: live ? 'recording' : 'paused', id: active.id, title: titleOf(), started: live && V.liveStarted ? V.liveStarted() : null };
|
|
111
|
+
}
|
|
112
|
+
/** Record: a new note, linked to nothing, that listens at once. */
|
|
113
|
+
export async function recordNew(btn) {
|
|
114
|
+
if (active && liveId() === active.id) { openDock(active.id); return; }
|
|
115
|
+
if (btn) btn.disabled = true;
|
|
116
|
+
const r = await pmPost('/api/notes/new', {});
|
|
117
|
+
if (btn) btn.disabled = false;
|
|
118
|
+
if (!r || !r.ok) { toast({ text: (r && r.error) || 'The note was not made.', tone: 'destructive' }); return; }
|
|
119
|
+
await recordNote(r.note.id, r.note.title || '');
|
|
120
|
+
refreshNotes();
|
|
121
|
+
}
|
|
122
|
+
/** Record or Resume on one note: it becomes the dock's note and listens. Another note that is listening is asked about first. */
|
|
123
|
+
export async function recordNote(id, title) {
|
|
124
|
+
if (active && active.id !== id && liveId() === active.id && !await switchFrom()) return;
|
|
125
|
+
active = { id, title: title || '' }; write();
|
|
126
|
+
const v = V || await import('./view-notes.js');
|
|
127
|
+
V = v;
|
|
128
|
+
setState({});
|
|
129
|
+
if (await v.listenNow(viewCtx, id)) toast({ text: 'Notes are being taken. Let people know.' });
|
|
130
|
+
setState({});
|
|
131
|
+
}
|
|
132
|
+
/** Pause: listening stops and the note stays here, ready to Resume. */
|
|
133
|
+
export async function pauseNow() {
|
|
134
|
+
if (V && active && liveId() === active.id) await V.stopListening(viewCtx);
|
|
135
|
+
setState({});
|
|
136
|
+
}
|
|
137
|
+
/** Resume the paused note. */
|
|
138
|
+
export function resumeNow() { if (active) return recordNote(active.id, active.title); return null; }
|
|
139
|
+
/** Stop: listening stops and the note leaves the Record control (the final notes are written after Stop). */
|
|
140
|
+
export async function finish() {
|
|
141
|
+
if (V && active && liveId() === active.id) await V.stopListening(viewCtx);
|
|
142
|
+
active = null; open = false; write();
|
|
143
|
+
if (V) V.setPopped(null);
|
|
144
|
+
setState({});
|
|
145
|
+
}
|
|
146
|
+
/** Highlight: the last 30 seconds and what is said next, on the note that is listening. */
|
|
147
|
+
export function highlightNow() {
|
|
148
|
+
if (!V || !active || liveId() !== active.id) return;
|
|
149
|
+
V.highlightNow(viewCtx, active.id);
|
|
150
|
+
}
|
|
151
|
+
|
|
99
152
|
/** Take notes on a calendar event: make the note, start listening at once, and keep it minimized. */
|
|
100
153
|
export async function takeNotes(e, { ws, repo, initiative } = {}, btn) {
|
|
101
154
|
if (active && liveId() === active.id && !await switchFrom()) { openDock(active.id); return; }
|
|
@@ -122,28 +175,21 @@ function draw() {
|
|
|
122
175
|
const v = view();
|
|
123
176
|
const live = !!active && !!v && liveId() === active.id;
|
|
124
177
|
clearInterval(tick); tick = null;
|
|
125
|
-
if (!active || !v) { host.replaceChildren(); return; }
|
|
126
178
|
// The dock draws on every state change, and a transcript line comes in every few seconds while it listens. It draws into
|
|
127
179
|
// a detached node and patches the screen from it, so only what changed is touched: the lines already shown, the field
|
|
128
|
-
// being typed in, the scroll
|
|
180
|
+
// being typed in, the scroll stay as they are, and a new line is appended (2026-10-08 flicker).
|
|
129
181
|
const fresh = el('div');
|
|
182
|
+
// the highlight's pop-up is drawn here, on every place, whether the note is open or not
|
|
183
|
+
const hl = v && v.highlightPanel ? v.highlightPanel(viewCtx) : null;
|
|
184
|
+
if (!active || !v) { if (hl) fresh.appendChild(hl); patchChildren(host, fresh); return; }
|
|
130
185
|
if (live) tick = setInterval(() => { const c = host.querySelector('[data-nt-dock-clock]'); if (c) c.textContent = clockText(); }, 1000);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
live ? el('span', { class: 'nt-live-dot', 'aria-hidden': 'true' }) : null,
|
|
134
|
-
el('span', { class: 'nt-pill__title' }, titleOf()),
|
|
135
|
-
live ? el('span', { class: 'nt-pill__clock', 'data-nt-dock-clock': '' }, clockText()) : null),
|
|
136
|
-
variant: 'outline', attrs: { class: 'nt-pill', 'data-nt-pill': live ? 'live' : 'idle', 'aria-label': (live ? 'Notes are being taken: ' : 'Meeting note: ') + titleOf() + '. Open the note.' },
|
|
137
|
-
onClick: () => openDock(active.id) });
|
|
138
|
-
fresh.appendChild(el('div', { class: 'nt-pillrow' }, pill,
|
|
139
|
-
live ? null : iconButton({ icon: 'x', ariaLabel: 'Put the note away', onClick: close, attrs: { 'data-nt-dock-close': '' } })));
|
|
140
|
-
patchChildren(host, fresh);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
186
|
+
// the Record control at the top right shows the note now (Run 12): no pill
|
|
187
|
+
if (!open) { if (hl) fresh.appendChild(hl); patchChildren(host, fresh); return; }
|
|
143
188
|
// the note draws its own bar (status, Stop, Minimize and the ⋯ menu): the pop-up adds no second head (Run 7)
|
|
144
189
|
const body = el('div', { class: 'nt-pop__body' });
|
|
145
190
|
const pop = el('section', { class: 'nt-pop', role: 'dialog', 'aria-label': 'Meeting note', 'data-nt-pop': active.id }, body);
|
|
146
191
|
fresh.appendChild(pop);
|
|
192
|
+
if (hl) fresh.appendChild(hl);
|
|
147
193
|
if (!v.renderInline(body, viewCtx, active.id, { onMinimize: minimize })) body.appendChild(el('p', { class: 'nt-hint' }, 'Another note is being taken.'),
|
|
148
194
|
button({ label: 'Minimize', variant: 'ghost', size: 'sm', onClick: minimize, attrs: { 'data-nt-minimize': '' } }));
|
|
149
195
|
// a transcript read to its end follows the new lines; one the PM scrolled up in stays where they left it
|
|
@@ -58,10 +58,10 @@ export function meetingOf(e) {
|
|
|
58
58
|
start: e.start, end: e.end || null, title: e.title };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Open a note in the Notes place (meeting-notes Run 12): the list by initiative, with this note open. */
|
|
61
62
|
export async function openNote(id) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
c.openView('notes');
|
|
63
|
+
const r = await import('./registry.js');
|
|
64
|
+
r.openDestination('notes', { slug: id });
|
|
65
65
|
}
|
|
66
66
|
const dayOf = (iso) => { try { return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); } catch { return ''; } };
|
|
67
67
|
const clock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
|