create-pmos 0.105.0 → 0.107.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/notes.md +2 -2
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/dom.js +70 -3
- package/workspace/public/pm/note-dock.js +13 -3
- package/workspace/public/pm/view-notes-menu.js +168 -43
- package/workspace/public/pm/view-notes.js +15 -7
- package/workspace/test-lists/meeting-notes.json +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.107.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-08T02:26:00Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-08T02:26:00Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-08T02:26:00Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/guide/notes.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: notes"
|
|
4
4
|
description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; final notes written after Stop from the whole recording, with speakers and a quote for each item, and Wispr Flow's notes beside them; listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository for conflicts, capture highlights during the meeting, create tasks from next steps, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-08
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Guide: notes
|
|
@@ -165,7 +165,7 @@ Under the title, a note shows two lines: **Meeting** and **Initiative**.
|
|
|
165
165
|
|
|
166
166
|
When a note has no initiative, the summary suggests one: the open initiative that shares 3 or more words with what was said, when no other initiative shares as many. A suggestion never replaces an initiative you chose.
|
|
167
167
|
|
|
168
|
-
To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. PMOS starts to listen at once, to your microphone and to the meeting when local transcription is set up. Pick the meeting's tab or window when the browser asks. Let people know that you are taking notes. The note is linked to the event, and to the event's initiative when it has one. The note stays small at the bottom right of the workspace, with a red dot, its title and its time, on every page. Press it to open the note as a pop-up: add your notes, read the key points, and press **Stop** when the meeting ends. Press **Minimize** or Esc to make it small again. **Open in Notes** in the **⋯** menu shows it on the full screen. One note is taken at a time. If you press **Take notes** on another event while a note listens, PMOS asks first: **Keep it**, or **Stop it and start this one**. The note stays at the bottom right after a reload. When it no longer listens, press the x beside it to put it away. An event that already has a note shows its summary and **Open notes**, which opens the note in the pop-up. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
|
|
168
|
+
To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. PMOS starts to listen at once, to your microphone and to the meeting when local transcription is set up. Pick the meeting's tab or window when the browser asks. Let people know that you are taking notes. The note is linked to the event, and to the event's initiative when it has one. The note stays small at the bottom right of the workspace, with a red dot, its title and its time, on every page. Press it to open the note as a pop-up: add your notes, read the key points, and press **Stop** when the meeting ends. The pop-up stays steady while it listens: new lines are added at the end, and your place in the pop-up, the line you are typing and its cursor do not move. If the transcript is scrolled to its end, it follows the new lines. Press **Minimize** or Esc to make it small again. **Open in Notes** in the **⋯** menu shows it on the full screen. One note is taken at a time. If you press **Take notes** on another event while a note listens, PMOS asks first: **Keep it**, or **Stop it and start this one**. The note stays at the bottom right after a reload. When it no longer listens, press the x beside it to put it away. An event that already has a note shows its summary and **Open notes**, which opens the note in the pop-up. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
|
|
169
169
|
|
|
170
170
|
## Dictate into your notes
|
|
171
171
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.107.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.107.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -53,9 +53,8 @@ export function el(tag, attrs, ...children) {
|
|
|
53
53
|
else if (k === 'ref') { if (typeof v === 'function') v(node); }
|
|
54
54
|
else if (k === 'value' || k === 'checked' || k === 'disabled' || k === 'selected') node[k] = v;
|
|
55
55
|
else if (k === 'html') node.innerHTML = String(v);
|
|
56
|
-
else if (k.length > 2 && k[0] === 'o' && k[1] === 'n' && typeof v === 'function')
|
|
57
|
-
|
|
58
|
-
} else if (v === true) node.setAttribute(k, '');
|
|
56
|
+
else if (k.length > 2 && k[0] === 'o' && k[1] === 'n' && typeof v === 'function') listen(node, k.slice(2).toLowerCase(), v);
|
|
57
|
+
else if (v === true) node.setAttribute(k, '');
|
|
59
58
|
else node.setAttribute(k, String(v));
|
|
60
59
|
}
|
|
61
60
|
}
|
|
@@ -63,6 +62,14 @@ export function el(tag, attrs, ...children) {
|
|
|
63
62
|
return node;
|
|
64
63
|
}
|
|
65
64
|
|
|
65
|
+
/** An `onX` handler of el(). The listener reads the handler from the node, so patch() can give a node it keeps the handler of
|
|
66
|
+
* the node drawn in its place: a kept button then runs what the newest draw says, not what the first draw said. */
|
|
67
|
+
function listen(node, type, fn) {
|
|
68
|
+
const h = node.__pmOn || (node.__pmOn = Object.create(null));
|
|
69
|
+
if (!(type in h)) node.addEventListener(type, function (e) { const f = node.__pmOn[type]; return f ? f.call(this, e) : undefined; });
|
|
70
|
+
h[type] = fn;
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
/** {backgroundColor:'red', minHeight:'30px'} -> 'background-color:red;min-height:30px'. */
|
|
67
74
|
export function style(obj) {
|
|
68
75
|
if (typeof obj === 'string') return obj;
|
|
@@ -174,3 +181,63 @@ export function keepScroll(scroller) {
|
|
|
174
181
|
scroller.removeEventListener('scroll', onScroll);
|
|
175
182
|
};
|
|
176
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Patch `live`'s children to look like `fresh`'s, keeping every node that is still there (note pop-up flicker, 2026-10-08).
|
|
187
|
+
* A view that draws itself again on every state change can draw into a detached `fresh` and patch the screen from it: what
|
|
188
|
+
* did not change stays the same element, so its scroll, focus, caret, open state and CSS animation stay; what changed is
|
|
189
|
+
* updated in place; what is new is inserted. Children are matched by tag and by their data-* attributes (so a list keyed
|
|
190
|
+
* by `data-x="3"` keeps its rows), else by position. A kept node takes the attributes, the el() handlers and the form
|
|
191
|
+
* values of the node drawn in its place. A `kept(node)` placeholder in `fresh` stands for a node the view owns and keeps
|
|
192
|
+
* itself (an editor field): that node is left where it is, or moved there, and never patched.
|
|
193
|
+
*/
|
|
194
|
+
export function patchChildren(live, fresh) {
|
|
195
|
+
let cur = live.firstChild;
|
|
196
|
+
for (const n of [...fresh.childNodes]) {
|
|
197
|
+
if (n.__pmKeep) {
|
|
198
|
+
const t = n.__pmKeep;
|
|
199
|
+
if (cur === t) cur = cur.nextSibling;
|
|
200
|
+
else live.insertBefore(t, cur);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (cur && sameNode(cur, n)) { patchNode(cur, n); cur = cur.nextSibling; continue; }
|
|
204
|
+
let found = null;
|
|
205
|
+
if (n.nodeType === 1 && keyOf(n)) for (let c = cur; c; c = c.nextSibling) if (sameNode(c, n)) { found = c; break; }
|
|
206
|
+
if (found) { live.insertBefore(found, cur); patchNode(found, n); continue; }
|
|
207
|
+
live.insertBefore(n, cur);
|
|
208
|
+
}
|
|
209
|
+
while (cur) { const x = cur.nextSibling; live.removeChild(cur); cur = x; }
|
|
210
|
+
return live;
|
|
211
|
+
}
|
|
212
|
+
/** A placeholder for patchChildren(): `node` is the view's own, and stays as it is. Off a page, the node itself. */
|
|
213
|
+
export function kept(node) {
|
|
214
|
+
if (!node || !node.isConnected) return node;
|
|
215
|
+
const p = document.createElement('pm-kept');
|
|
216
|
+
p.__pmKeep = node;
|
|
217
|
+
node.__pmOwned = true; // never matched to, and patched into, another node drawn beside it
|
|
218
|
+
return p;
|
|
219
|
+
}
|
|
220
|
+
function keyOf(n) {
|
|
221
|
+
let k = '';
|
|
222
|
+
for (const a of n.attributes) if (a.name.startsWith('data-')) k += a.name + '=' + a.value + ';';
|
|
223
|
+
return k;
|
|
224
|
+
}
|
|
225
|
+
function sameNode(a, b) {
|
|
226
|
+
if (a.nodeType !== b.nodeType) return false;
|
|
227
|
+
if (a.nodeType !== 1) return true;
|
|
228
|
+
return a.tagName === b.tagName && !a.__pmOwned && keyOf(a) === keyOf(b);
|
|
229
|
+
}
|
|
230
|
+
function patchNode(a, b) {
|
|
231
|
+
if (a.nodeType !== 1) { if (a.nodeValue !== b.nodeValue) a.nodeValue = b.nodeValue; return; }
|
|
232
|
+
for (const x of [...a.attributes]) if (!b.hasAttribute(x.name)) a.removeAttribute(x.name);
|
|
233
|
+
for (const x of b.attributes) if (a.getAttribute(x.name) !== x.value) a.setAttribute(x.name, x.value);
|
|
234
|
+
const ha = a.__pmOn; const hb = b.__pmOn;
|
|
235
|
+
if (ha) for (const t in ha) if (!hb || !hb[t]) ha[t] = null;
|
|
236
|
+
if (hb) for (const t in hb) listen(a, t, hb[t]);
|
|
237
|
+
const tag = a.tagName;
|
|
238
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
|
239
|
+
if (a.value !== b.value && a !== document.activeElement) a.value = b.value;
|
|
240
|
+
if (a.checked !== b.checked) a.checked = b.checked;
|
|
241
|
+
}
|
|
242
|
+
patchChildren(a, b);
|
|
243
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* openDock(id) → shows a note in the pop-up
|
|
14
14
|
* minimize() · activeId() · forget(id)
|
|
15
15
|
*/
|
|
16
|
-
import { el } from './dom.js';
|
|
16
|
+
import { el, patchChildren } from './dom.js';
|
|
17
17
|
import { button, iconButton, layer, toast, ensureComponentsCss } from './primitives.js';
|
|
18
18
|
import { askConfirm } from './dialogs.js';
|
|
19
19
|
import { pmPost } from './http.js';
|
|
@@ -123,6 +123,10 @@ function draw() {
|
|
|
123
123
|
const live = !!active && !!v && liveId() === active.id;
|
|
124
124
|
clearInterval(tick); tick = null;
|
|
125
125
|
if (!active || !v) { host.replaceChildren(); return; }
|
|
126
|
+
// The dock draws on every state change, and a transcript line comes in every few seconds while it listens. It draws into
|
|
127
|
+
// 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 and the red dot's pulse stay as they are, and a new line is appended (2026-10-08 flicker).
|
|
129
|
+
const fresh = el('div');
|
|
126
130
|
if (live) tick = setInterval(() => { const c = host.querySelector('[data-nt-dock-clock]'); if (c) c.textContent = clockText(); }, 1000);
|
|
127
131
|
if (!open) {
|
|
128
132
|
const pill = button({ label: el('span', { class: 'nt-pill__txt' },
|
|
@@ -131,14 +135,20 @@ function draw() {
|
|
|
131
135
|
live ? el('span', { class: 'nt-pill__clock', 'data-nt-dock-clock': '' }, clockText()) : null),
|
|
132
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.' },
|
|
133
137
|
onClick: () => openDock(active.id) });
|
|
134
|
-
|
|
138
|
+
fresh.appendChild(el('div', { class: 'nt-pillrow' }, pill,
|
|
135
139
|
live ? null : iconButton({ icon: 'x', ariaLabel: 'Put the note away', onClick: close, attrs: { 'data-nt-dock-close': '' } })));
|
|
140
|
+
patchChildren(host, fresh);
|
|
136
141
|
return;
|
|
137
142
|
}
|
|
138
143
|
// the note draws its own bar (status, Stop, Minimize and the ⋯ menu): the pop-up adds no second head (Run 7)
|
|
139
144
|
const body = el('div', { class: 'nt-pop__body' });
|
|
140
145
|
const pop = el('section', { class: 'nt-pop', role: 'dialog', 'aria-label': 'Meeting note', 'data-nt-pop': active.id }, body);
|
|
141
|
-
|
|
146
|
+
fresh.appendChild(pop);
|
|
142
147
|
if (!v.renderInline(body, viewCtx, active.id, { onMinimize: minimize })) body.appendChild(el('p', { class: 'nt-hint' }, 'Another note is being taken.'),
|
|
143
148
|
button({ label: 'Minimize', variant: 'ghost', size: 'sm', onClick: minimize, attrs: { 'data-nt-minimize': '' } }));
|
|
149
|
+
// a transcript read to its end follows the new lines; one the PM scrolled up in stays where they left it
|
|
150
|
+
const box = host.querySelector('[data-nt-pop] [data-nt-transcript]');
|
|
151
|
+
const atEnd = !!box && box.scrollTop + box.clientHeight >= box.scrollHeight - 8;
|
|
152
|
+
patchChildren(host, fresh);
|
|
153
|
+
if (atEnd && box.isConnected) box.scrollTop = box.scrollHeight;
|
|
144
154
|
}
|
|
@@ -130,56 +130,181 @@ export function renderBoard(inner, ctx) {
|
|
|
130
130
|
|
|
131
131
|
const rightPanel = note
|
|
132
132
|
? await (async () => {
|
|
133
|
-
// Load and render the full note detail using the note view's render logic
|
|
134
133
|
try {
|
|
135
|
-
// Import the note view's rendering for inline display
|
|
136
134
|
const noteModule = await import('./view-notes.js');
|
|
137
|
-
// Parse
|
|
135
|
+
// Parse sections from the note's data
|
|
138
136
|
let actions = [];
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
let decisions = [];
|
|
138
|
+
let questions = [];
|
|
139
|
+
let highlights = [];
|
|
140
|
+
let conflicts = [];
|
|
141
|
+
if (note.final_state) {
|
|
142
|
+
if (note.final_state.actions) actions = note.final_state.actions;
|
|
143
|
+
if (note.final_state.decisions) decisions = note.final_state.decisions;
|
|
144
|
+
if (note.final_state.questions) questions = note.final_state.questions;
|
|
141
145
|
}
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
if (note.highlights) highlights = note.highlights;
|
|
147
|
+
if (note.conflicts) conflicts = note.conflicts;
|
|
148
|
+
|
|
149
|
+
const parts = [];
|
|
150
|
+
|
|
151
|
+
// Header with title, date, initiative, and action buttons
|
|
152
|
+
parts.push(
|
|
144
153
|
el('div', { class: 'nt-detail-header' },
|
|
145
|
-
el('div', { class: 'nt-detail-title' },
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
...actions.map((action, idx) =>
|
|
154
|
-
el('div', { class: 'nt-detail-action-row' },
|
|
155
|
-
el('div', { class: 'nt-detail-action-text' }, action.text || ''),
|
|
156
|
-
action.owner && el('div', { class: 'nt-detail-action-owner' }, `Owner: ${action.owner}`),
|
|
157
|
-
action.due && el('div', { class: 'nt-detail-action-due' }, `Due: ${action.due}`),
|
|
158
|
-
iconButton({ icon: 'plus', class: 'nt-detail-make-task', title: 'Create task from this step' },
|
|
159
|
-
async () => {
|
|
160
|
-
try {
|
|
161
|
-
const result = await pmPost('/api/notes/create-task', {
|
|
162
|
-
id: note.id,
|
|
163
|
-
text: action.text,
|
|
164
|
-
owner: action.owner,
|
|
165
|
-
due: action.due,
|
|
166
|
-
});
|
|
167
|
-
if (result && result.ok) {
|
|
168
|
-
toast({ text: 'Task created' });
|
|
169
|
-
} else {
|
|
170
|
-
toast({ text: 'Could not create task' });
|
|
171
|
-
}
|
|
172
|
-
} catch (e) {
|
|
173
|
-
console.error('Task creation error:', e);
|
|
174
|
-
toast({ text: 'Error creating task' });
|
|
175
|
-
}
|
|
176
|
-
},
|
|
177
|
-
),
|
|
178
|
-
),
|
|
154
|
+
el('div', { class: 'nt-detail-title-bar' },
|
|
155
|
+
el('div', { class: 'nt-detail-title' }, note.title || 'Untitled note'),
|
|
156
|
+
el('div', { class: 'nt-detail-actions-bar' },
|
|
157
|
+
iconButton({ icon: 'play', ariaLabel: 'Resume recording', attrs: { class: 'nt-detail-resume' } }),
|
|
158
|
+
el('div', { class: 'nt-menu-dots' },
|
|
159
|
+
iconButton({ icon: 'more-vertical', ariaLabel: 'More options', attrs: { class: 'nt-detail-more' } })
|
|
160
|
+
)
|
|
161
|
+
)
|
|
179
162
|
),
|
|
180
|
-
|
|
163
|
+
el('div', { class: 'nt-detail-meta' },
|
|
164
|
+
el('span', { class: 'nt-detail-date' }, new Date(note.updated_at).toLocaleDateString()),
|
|
165
|
+
note.initiative && el('span', { class: 'nt-detail-initiative' }, typeof note.initiative === 'string' ? note.initiative : (note.initiative.title || note.initiative.id)),
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Summary/Overview
|
|
171
|
+
parts.push(
|
|
172
|
+
el('div', { class: 'nt-detail-summary' },
|
|
173
|
+
el('h3', { class: 'nt-detail-section-title' }, 'Overview'),
|
|
174
|
+
note.summary
|
|
175
|
+
? el('div', { class: 'nt-detail-summary-text' }, note.summary)
|
|
176
|
+
: el('p', { class: 'nt-hint' }, 'No summary yet'),
|
|
177
|
+
)
|
|
181
178
|
);
|
|
182
|
-
|
|
179
|
+
|
|
180
|
+
// Conflicts section
|
|
181
|
+
if (conflicts && conflicts.length > 0) {
|
|
182
|
+
parts.push(
|
|
183
|
+
el('div', { class: 'nt-detail-conflicts' },
|
|
184
|
+
el('h3', { class: 'nt-detail-section-title' }, `Conflicts (${conflicts.length})`),
|
|
185
|
+
el('div', { class: 'nt-detail-conflicts-list' },
|
|
186
|
+
...conflicts.map((conflict) =>
|
|
187
|
+
el('div', { class: 'nt-detail-conflict-item' },
|
|
188
|
+
el('div', { class: 'nt-conflict-text' }, conflict.text || ''),
|
|
189
|
+
el('div', { class: 'nt-conflict-details' },
|
|
190
|
+
conflict.file && el('span', { class: 'nt-conflict-file' }, conflict.file),
|
|
191
|
+
iconButton({ icon: 'eye', ariaLabel: 'Review conflict', attrs: { class: 'nt-conflict-review' } })
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Highlights section
|
|
201
|
+
if (highlights && highlights.length > 0) {
|
|
202
|
+
parts.push(
|
|
203
|
+
el('div', { class: 'nt-detail-highlights' },
|
|
204
|
+
el('h3', { class: 'nt-detail-section-title' }, `Highlights (${highlights.length})`),
|
|
205
|
+
el('div', { class: 'nt-detail-highlights-list' },
|
|
206
|
+
...highlights.map((h) =>
|
|
207
|
+
el('div', { class: 'nt-detail-highlight-item' },
|
|
208
|
+
el('div', { class: 'nt-highlight-text' }, h.text || ''),
|
|
209
|
+
el('div', { class: 'nt-highlight-meta' },
|
|
210
|
+
el('span', { class: 'nt-highlight-label' }, h.label || ''),
|
|
211
|
+
h.timestamp && el('span', { class: 'nt-highlight-time' }, h.timestamp),
|
|
212
|
+
iconButton({ icon: 'play', ariaLabel: 'Play audio', attrs: { class: 'nt-highlight-play' } })
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Decisions section
|
|
222
|
+
if (decisions && decisions.length > 0) {
|
|
223
|
+
parts.push(
|
|
224
|
+
el('div', { class: 'nt-detail-decisions' },
|
|
225
|
+
el('h3', { class: 'nt-detail-section-title' }, `Decisions (${decisions.length})`),
|
|
226
|
+
el('div', { class: 'nt-detail-decisions-list' },
|
|
227
|
+
...decisions.map((decision, idx) =>
|
|
228
|
+
el('div', { class: 'nt-detail-decision-item' },
|
|
229
|
+
el('div', { class: 'nt-decision-text' }, decision.text || decision),
|
|
230
|
+
el('div', { class: 'nt-decision-actions' },
|
|
231
|
+
iconButton({ icon: 'edit', ariaLabel: 'Edit decision', attrs: { class: 'nt-decision-edit' } })
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Open questions section
|
|
241
|
+
if (questions && questions.length > 0) {
|
|
242
|
+
parts.push(
|
|
243
|
+
el('div', { class: 'nt-detail-questions' },
|
|
244
|
+
el('h3', { class: 'nt-detail-section-title' }, `Open questions (${questions.length})`),
|
|
245
|
+
el('div', { class: 'nt-detail-questions-list' },
|
|
246
|
+
...questions.map((question) =>
|
|
247
|
+
el('div', { class: 'nt-detail-question-item' },
|
|
248
|
+
el('div', { class: 'nt-question-text' }, question.text || question),
|
|
249
|
+
el('div', { class: 'nt-question-actions' },
|
|
250
|
+
button({ label: 'Draft answer', variant: 'ghost', size: 'sm' })
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Actions/Next steps section
|
|
260
|
+
if (actions && actions.length > 0) {
|
|
261
|
+
parts.push(
|
|
262
|
+
el('div', { class: 'nt-detail-actions' },
|
|
263
|
+
el('h3', { class: 'nt-detail-section-title' }, `Next steps (${actions.length})`),
|
|
264
|
+
el('div', { class: 'nt-detail-actions-list' },
|
|
265
|
+
...actions.map((action) =>
|
|
266
|
+
el('div', { class: 'nt-detail-action-row' },
|
|
267
|
+
el('div', { class: 'nt-detail-action-text' }, action.text || ''),
|
|
268
|
+
el('div', { class: 'nt-action-meta' },
|
|
269
|
+
action.owner && el('span', { class: 'nt-action-owner' }, `Owner: ${action.owner}`),
|
|
270
|
+
action.due && el('span', { class: 'nt-action-due' }, `Due: ${action.due}`),
|
|
271
|
+
),
|
|
272
|
+
iconButton({
|
|
273
|
+
icon: 'plus',
|
|
274
|
+
ariaLabel: 'Create task',
|
|
275
|
+
attrs: { class: 'nt-detail-make-task' },
|
|
276
|
+
onClick: async () => {
|
|
277
|
+
try {
|
|
278
|
+
const result = await pmPost('/api/notes/create-task', {
|
|
279
|
+
id: note.id,
|
|
280
|
+
text: action.text,
|
|
281
|
+
owner: action.owner,
|
|
282
|
+
due: action.due,
|
|
283
|
+
});
|
|
284
|
+
if (result && result.ok) {
|
|
285
|
+
toast({ text: 'Task created' });
|
|
286
|
+
} else {
|
|
287
|
+
toast({ text: 'Could not create task' });
|
|
288
|
+
}
|
|
289
|
+
} catch (e) {
|
|
290
|
+
console.error('Task creation error:', e);
|
|
291
|
+
toast({ text: 'Error creating task' });
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
}),
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Files section (placeholder for future)
|
|
303
|
+
// Discussion/Transcript section (collapsible placeholder)
|
|
304
|
+
|
|
305
|
+
return el('div', { class: 'nt-menu-right' }, ...parts);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
console.error('Note detail error:', e);
|
|
183
308
|
return el('div', { class: 'nt-menu-right' }, empty({ text: 'Could not load note detail' }));
|
|
184
309
|
}
|
|
185
310
|
})()
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* POST /api/notes/new · save · dictate · record · transcript · audio · label · speaker · switch · whisper-setup · private
|
|
40
40
|
* · summarize · summarize-agent · delete · line · recording-delete · link
|
|
41
41
|
*/
|
|
42
|
-
import { el } from './dom.js';
|
|
42
|
+
import { el, kept } from './dom.js';
|
|
43
43
|
import { askConfirm, askText } from './dialogs.js';
|
|
44
44
|
import { pmGet, pmPost } from './http.js';
|
|
45
45
|
import { mdRender } from './markdown.js';
|
|
@@ -66,6 +66,10 @@ let speech = null; // the browser recogniser: { mode: 'dictate'|'reco
|
|
|
66
66
|
let audio = null; // local Whisper capture: { id, state, sources, ctx, streams, started, clock, msg }
|
|
67
67
|
let engine = null; // { ws, whisper, jev, at }
|
|
68
68
|
let showList = false;
|
|
69
|
+
let inlineDraw = false; // the pop-up is drawing: the fields and the player it shows are left in place (pm/note-dock.js)
|
|
70
|
+
/** A node this view keeps across redraws (an editor field, the player). The pop-up patches itself (dom.js patchChildren), so
|
|
71
|
+
* there the node is a placeholder and stays where it is: a field that is typed in is never moved, and keeps its focus. */
|
|
72
|
+
const own = (node) => (inlineDraw ? kept(node) : node);
|
|
69
73
|
let pollTimer = null; let agentTimer = null;
|
|
70
74
|
let busy = '';
|
|
71
75
|
let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
|
|
@@ -1204,11 +1208,11 @@ function header(ctx, n, ed, opts = {}) {
|
|
|
1204
1208
|
}
|
|
1205
1209
|
if (opts.onMinimize) acts.push(button({ label: 'Minimize', variant: 'ghost', size: 'sm', onClick: opts.onMinimize, attrs: { 'data-nt-minimize': '' } }));
|
|
1206
1210
|
if (!opts.inline) acts.push(button({ label: 'All notes', variant: 'ghost', size: 'sm', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
|
|
1207
|
-
const more = iconButton({ icon: 'ellipsis', ariaLabel: 'More', onClick: () => moreMenu(ctx, n, opts, more), attrs: { 'data-nt-more': '' } });
|
|
1211
|
+
const more = iconButton({ icon: 'ellipsis', ariaLabel: 'More', onClick: (e) => moreMenu(ctx, n, opts, (e && e.currentTarget) || more), attrs: { 'data-nt-more': '' } });
|
|
1208
1212
|
acts.push(more);
|
|
1209
1213
|
return el('header', { class: 'nt-header', 'data-nt-part': 'header' },
|
|
1210
1214
|
el('div', { class: 'nt-bar' }, status, el('div', { class: 'nt-acts' }, acts)),
|
|
1211
|
-
el('div', { class: 'nt-headl' }, ed.titleWrap, whenLine(ctx, n), peopleRow(n)));
|
|
1215
|
+
el('div', { class: 'nt-headl' }, own(ed.titleWrap), whenLine(ctx, n), peopleRow(n)));
|
|
1212
1216
|
}
|
|
1213
1217
|
function listPane(ctx) {
|
|
1214
1218
|
const rows = (list && list.notes) || [];
|
|
@@ -1421,7 +1425,7 @@ function myNotes(ctx, n, ed) {
|
|
|
1421
1425
|
const t = liveMs(n);
|
|
1422
1426
|
const parts = [el('h3', { class: 'nt-sec__h' }, 'Your notes')];
|
|
1423
1427
|
if (items.length) parts.push(el('ul', { class: 'nt-items', 'data-nt-notelines': '' }, items));
|
|
1424
|
-
if (!replaying) parts.push(el('div', { class: 'nt-addrow' }, el('span', { class: 'nt-at', 'data-nt-clock-now': '' }, t !== null ? fmtTime(t) : ''), ed.addWrap));
|
|
1428
|
+
if (!replaying) parts.push(el('div', { class: 'nt-addrow' }, el('span', { class: 'nt-at', 'data-nt-clock-now': '' }, t !== null ? fmtTime(t) : ''), own(ed.addWrap)));
|
|
1425
1429
|
else parts.push(el('p', { class: 'nt-hint' }, 'Your notes, as the meeting plays. Pause to add more.'));
|
|
1426
1430
|
const sp = speech && speech.id === n.id ? speech : null;
|
|
1427
1431
|
const bar = [];
|
|
@@ -1618,16 +1622,20 @@ export function renderInline(host, ctx, id, opts = {}) {
|
|
|
1618
1622
|
if (!n) { loadNote(ctx, id); host.appendChild(el('p', { class: 'nt-hint' }, 'reading the note …')); return true; }
|
|
1619
1623
|
const keep = keepFocus();
|
|
1620
1624
|
const ed = editorFor(ctx, n);
|
|
1625
|
+
inlineDraw = true;
|
|
1626
|
+
try { drawInline(ctx, n, ed, host, opts); } finally { inlineDraw = false; }
|
|
1627
|
+
keep();
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
function drawInline(ctx, n, ed, host, opts) {
|
|
1621
1631
|
const wrap = el('div', { class: 'nt-wrap nt-wrap--inline', 'data-nt-part': 'inline', 'data-nt-id': n.id });
|
|
1622
1632
|
if (banner) wrap.appendChild(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
|
|
1623
1633
|
wrap.appendChild(header(ctx, n, ed, { inline: true, onMinimize: opts.onMinimize }));
|
|
1624
1634
|
if (n.private) wrap.appendChild(el('p', { class: 'nt-hint', 'data-nt-private': '' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
|
|
1625
1635
|
const pl = !listening(n) ? playerFor(ctx, n) : null;
|
|
1626
|
-
if (pl) wrap.appendChild(el('div', { class: 'nt-replay', 'data-nt-part': 'replay' }, el('span', { class: 'nt-sec__h' }, 'Replay'), pl.el));
|
|
1636
|
+
if (pl) wrap.appendChild(el('div', { class: 'nt-replay', 'data-nt-part': 'replay' }, el('span', { class: 'nt-sec__h' }, 'Replay'), own(pl.el)));
|
|
1627
1637
|
wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n, { inline: true })));
|
|
1628
1638
|
if (pl) queueMicrotask(paintNow);
|
|
1629
1639
|
if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
|
|
1630
1640
|
host.appendChild(wrap);
|
|
1631
|
-
keep();
|
|
1632
|
-
return true;
|
|
1633
1641
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "meeting-notes Runs 1 to 10: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), Qwen3-ASR kept loaded (notes-qwen.test.js), and the Notes view in a browser (pm-notes-test.js).",
|
|
2
|
+
"_comment": "meeting-notes Runs 1 to 10: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), Qwen3-ASR kept loaded (notes-qwen.test.js), and the Notes view in a browser (pm-notes-test.js), plus the note pop-up patched in place while it listens (pm-note-dock-test.js).",
|
|
3
3
|
"modules": [
|
|
4
4
|
"notes.test.js",
|
|
5
5
|
"notes-listen.test.js",
|
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
"notes-qwen.test.js"
|
|
8
8
|
],
|
|
9
9
|
"panels": [
|
|
10
|
-
"pm-notes-test.js"
|
|
10
|
+
"pm-notes-test.js",
|
|
11
|
+
"pm-note-dock-test.js"
|
|
11
12
|
],
|
|
12
13
|
"seconds": {
|
|
13
14
|
"notes.test.js": 12,
|
|
14
15
|
"pm-notes-test.js": 40,
|
|
15
16
|
"notes-listen.test.js": 10,
|
|
16
17
|
"notes-final.test.js": 6,
|
|
17
|
-
"notes-qwen.test.js": 4
|
|
18
|
+
"notes-qwen.test.js": 4,
|
|
19
|
+
"pm-note-dock-test.js": 20
|
|
18
20
|
}
|
|
19
21
|
}
|