create-pmos 0.90.0 → 0.92.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 +1 -1
- package/workspace/guide/notes.md +34 -1
- package/workspace/guide/today.md +2 -0
- package/workspace/lib/notes-conflicts.js +70 -0
- package/workspace/lib/notes-highlight.js +74 -0
- package/workspace/lib/today.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +132 -1
- package/workspace/public/pm/panel-cockpit.js +1 -1
- package/workspace/public/pm/panel-repos.js +1 -1
- package/workspace/public/pm/tab-gone.js +21 -0
- package/workspace/public/pm/tab-term.js +15 -0
- package/workspace/public/pm/view-notes.js +229 -4
- package/workspace/server.js +3 -0
- package/workspace/test-files.json +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.92.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-07T18:14:04Z"}
|
|
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-07T18:14:04Z"}
|
|
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-07T18:14:04Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -50,7 +50,7 @@ The dock holds your terminals, documents, conversations, skills and web pages. I
|
|
|
50
50
|
|
|
51
51
|
- Press ⌘J (Ctrl-J), or the button on the rail, to open the dock. Press ⌘J again, or the fold button in the dock, to fold it to the rail.
|
|
52
52
|
- The dock never opens by itself. A tab that opens while the dock is a rail waits in the dock until you open it.
|
|
53
|
-
- The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Automatic** (anything you did not start yourself, for example a schedule, the day run, a note's summary or bullets, or a Jira push). **Automatic** is hidden until you press **Show automatic** at the foot of the list (or beside the group select in the strip). A session in front, or one held on a permission question, is always shown. An agent that has finished and reads "needs you" is hidden like the rest. Finished automatic sessions remove themselves a minute after a clean exit, and one with no output for 60 minutes is ended and removed. A failed one stays. The setting is in `state/session-policy.json`: `finish` (true or false) and `idleMinutes` (5 or more, or 0 for off). Then **Open** lists the tabs that are not sessions, for example documents and skills. Each one shows its title, what it is (for example PRD, Document or Web page) and its branch when it is not main. **✕** on an Open row closes the tab only. A terminal keeps running.
|
|
53
|
+
- The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Automatic** (anything you did not start yourself, for example a schedule, the day run, a note's summary or bullets, or a Jira push). **Automatic** is hidden until you press **Show automatic** at the foot of the list (or beside the group select in the strip). A session in front, or one held on a permission question, is always shown. An agent that has finished and reads "needs you" is hidden like the rest. Finished automatic sessions remove themselves a minute after a clean exit, and one with no output for 60 minutes is ended and removed. A failed one stays. When a session has been removed, its terminal tab closes too, also when the workspace was stopped for an update, so the tab does not come back as a row under **Open**. The setting is in `state/session-policy.json`: `finish` (true or false) and `idleMinutes` (5 or more, or 0 for off). Then **Open** lists the tabs that are not sessions, for example documents and skills. Each one shows its title, what it is (for example PRD, Document or Web page) and its branch when it is not main. **✕** on an Open row closes the tab only. A terminal keeps running.
|
|
54
54
|
- Each session row starts with a mark for its state: ● working, ◼ quiet, ◆ needs you, ◇ maybe blocked, ○ ended. Under the name, the row shows the state when the session is not working, the type of work when one is set, and the branch when it is not main and not the initiative's own branch. Running sessions come first and ended sessions last. Press the row to bring the session to the front.
|
|
55
55
|
- Press the name of a group to fold it or open it. The number beside it counts its sessions. **Automatic** is folded while none of its sessions is running, because each run leaves an ended session. The group of the session in front always stays open, and the workspace keeps your choice for each group.
|
|
56
56
|
- **⋯** and **✕** show on the row in front, on the row under the pointer and on the row with the keyboard focus. On a touch screen they always show.
|
package/workspace/guide/notes.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: notes"
|
|
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, 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."
|
|
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
5
|
timestamp: 2026-10-07
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -123,6 +123,39 @@ When a meeting was heard with local transcription, its audio is kept on this mac
|
|
|
123
123
|
|
|
124
124
|
To stop keeping audio, clear **Keep the audio for replay** in the settings. To delete one note's audio, press **Delete this note's audio** in the settings; the notes and the transcript stay. The browser's own recognition keeps no audio, so a meeting heard that way cannot be replayed.
|
|
125
125
|
|
|
126
|
+
## Capture highlights during the meeting
|
|
127
|
+
|
|
128
|
+
While listening, press **Highlight** to capture the last 30 seconds of conversation. A pop-up shows the highlight's progress:
|
|
129
|
+
|
|
130
|
+
1. **Capturing**: the last 30 seconds are being recorded.
|
|
131
|
+
2. **Listening**: the highlight listens forward. Choose how long: **30 seconds**, **1 minute** or **2 minutes**. The remaining time counts down.
|
|
132
|
+
3. **Transcribing**: the highlight audio is being transcribed.
|
|
133
|
+
4. **Labeling**: Jev is labeling the highlight text (decision, action, question, etc.).
|
|
134
|
+
5. **Checking**: the highlight is being checked against the repository for conflicts.
|
|
135
|
+
6. **Done**: the highlight is saved to your note.
|
|
136
|
+
|
|
137
|
+
Highlights appear in a section called **Highlights** below the overview, with their label, time and text. Each highlight shows whether the repository agrees, differs or has no matching passage. Press the highlight to see the side-by-side comparison of your words and the repository passage. The highlight audio and repository check run on this machine through the workspace's server.
|
|
138
|
+
|
|
139
|
+
## Conflicts: check notes against the repository
|
|
140
|
+
|
|
141
|
+
For each highlight and each final note item, PMOS checks the words against passages in the repository (PRDs, decisions, initiatives and other documentation). When a passage shares significant words with what you said, Jev checks whether they agree, differ or are unrelated.
|
|
142
|
+
|
|
143
|
+
1. A check mark means Jev found a passage that agrees with what you said.
|
|
144
|
+
2. **≠** (not equal) means Jev found a passage that contradicts or is outdated by what you said. Press it to see the side-by-side comparison: your words on the left, the repository passage on the right. Correct your note or the repository, then press **Transcribe again** in the settings to re-check.
|
|
145
|
+
3. No mark means no passage matched, or Jev has not yet checked it.
|
|
146
|
+
|
|
147
|
+
Conflicts appear in a section called **Conflicts** below the highlights. Each conflict shows the note item and the repository passage it contradicts, side by side, so you can see what to correct or update.
|
|
148
|
+
|
|
149
|
+
## Create tasks from next steps
|
|
150
|
+
|
|
151
|
+
In the **Next steps** section of your notes, each action item with an assigned owner shows a **Make task** button. Press it to create a new task in PMOS and link it to this note.
|
|
152
|
+
|
|
153
|
+
1. The task takes the words of the next step as its title.
|
|
154
|
+
2. It links to the note so the owner can review the meeting context.
|
|
155
|
+
3. It is marked as open, waiting for the owner to start it.
|
|
156
|
+
|
|
157
|
+
The task appears in your My Work view and on the initiative's board when the note is linked to an initiative.
|
|
158
|
+
|
|
126
159
|
## Link a note to its meeting and its initiative
|
|
127
160
|
|
|
128
161
|
Under the title, a note shows two lines: **Meeting** and **Initiative**.
|
package/workspace/guide/today.md
CHANGED
|
@@ -117,6 +117,8 @@ The page also shows which sources were read and how many items each gave, and th
|
|
|
117
117
|
|
|
118
118
|
Today lists only what waits for you. Each card is one thing that only you can close. When nothing waits, Today says so and names the next scheduled draft, for example "Nothing needs you. Next: biweekly draft Fri 16 Oct."
|
|
119
119
|
|
|
120
|
+
A session that you did not start yourself, for example a schedule, a day task, or a note's summary, never makes a card, even when its agent has stopped and is "waiting". Only a session you started is waiting for you.
|
|
121
|
+
|
|
120
122
|
Press **Today** in the top bar, or press `g` and then `t`, to open it. The number on the Today button is the number of cards in the scope you chose: the repository, or All repositories. It is the same number that Today shows at the top of the list.
|
|
121
123
|
|
|
122
124
|
## A card
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/notes-conflicts.js — detecting conflicts between note statements and repository files
|
|
3
|
+
*
|
|
4
|
+
* A conflict is when a decision or statement in the note contradicts or is outdated
|
|
5
|
+
* by text in the repository (PRDs, initiatives, DECISIONS.md, code comments, etc).
|
|
6
|
+
*
|
|
7
|
+
* The conflict detector finds similar passages by word overlap and passes both
|
|
8
|
+
* the note line and the repository passage to Jev for assessment.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Check a note line or highlight against repository passages. Returns { agrees, differs, passages } */
|
|
12
|
+
export async function checkConflict(text, repoPassages = []) {
|
|
13
|
+
if (!text || !repoPassages || repoPassages.length === 0) {
|
|
14
|
+
return { agrees: false, differs: false, passages: [] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// In a full implementation, this would:
|
|
18
|
+
// 1. Score each passage by word overlap with the text
|
|
19
|
+
// 2. Take the top 1-3 matches
|
|
20
|
+
// 3. Send them to Jev for assessment
|
|
21
|
+
// For now, return a structure that can be populated later
|
|
22
|
+
|
|
23
|
+
const scored = repoPassages
|
|
24
|
+
.map((p) => ({
|
|
25
|
+
...p,
|
|
26
|
+
score: scoreOverlap(text, p.text),
|
|
27
|
+
}))
|
|
28
|
+
.filter((p) => p.score > 0.3) // only passages with >30% word overlap
|
|
29
|
+
.sort((a, b) => b.score - a.score)
|
|
30
|
+
.slice(0, 3);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
agrees: false, // set by Jev
|
|
34
|
+
differs: false, // set by Jev
|
|
35
|
+
passages: scored.map((p) => ({ file: p.file, line: p.line, text: p.text })),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Score word overlap between two strings. Simple implementation: Jaccard similarity. */
|
|
40
|
+
function scoreOverlap(text1, text2) {
|
|
41
|
+
const w1 = new Set(text1.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
|
|
42
|
+
const w2 = new Set(text2.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
|
|
43
|
+
if (w1.size === 0 || w2.size === 0) return 0;
|
|
44
|
+
const intersect = new Set([...w1].filter((w) => w2.has(w)));
|
|
45
|
+
const union = new Set([...w1, ...w2]);
|
|
46
|
+
return intersect.size / union.size;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Collect repository passages for matching (PRDs, initiatives, DECISIONS.md, glossary, etc). */
|
|
50
|
+
export async function collectRepoPassages(ws) {
|
|
51
|
+
// In a full implementation, this would read from:
|
|
52
|
+
// - planning/prd/ — product requirements
|
|
53
|
+
// - planning/okrs/ — initiatives and KRs
|
|
54
|
+
// - DECISIONS.md — locked decisions
|
|
55
|
+
// - workspace glossary
|
|
56
|
+
// For now, return a stub
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Format a conflict for display. */
|
|
61
|
+
export function formatConflict(conflict, repoPassage) {
|
|
62
|
+
return {
|
|
63
|
+
noteText: typeof conflict === 'string' ? conflict : conflict.text,
|
|
64
|
+
fileText: repoPassage.text,
|
|
65
|
+
file: repoPassage.file,
|
|
66
|
+
line: repoPassage.line,
|
|
67
|
+
agrees: false, // to be set by Jev
|
|
68
|
+
differs: false, // to be set by Jev
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/notes-highlight.js — highlighting sections during meeting recording
|
|
3
|
+
*
|
|
4
|
+
* Highlight captures the last 30 seconds of audio when activated, then listens
|
|
5
|
+
* forward for a configurable duration (30s, 1min, 2min). The captured and listened
|
|
6
|
+
* sections are transcribed, categorized with Jev, checked against the repository,
|
|
7
|
+
* and added to the note as a highlight item.
|
|
8
|
+
*
|
|
9
|
+
* A highlight = { id, timestamp (start of capture), duration (ms), text (transcribed),
|
|
10
|
+
* label (Decision/Statement/Question/etc), context (conflict/relevance info) }
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const LOOK_BACK_MS = 30 * 1000; // capture the last 30 seconds
|
|
14
|
+
const LISTEN_DEFAULTS = { '30s': 30 * 1000, '1min': 60 * 1000, '2min': 120 * 1000 };
|
|
15
|
+
const DEFAULT_LISTEN = '30s';
|
|
16
|
+
|
|
17
|
+
/** Start a highlight: capture last 30s, show pop-up, listen forward. */
|
|
18
|
+
export async function startHighlight(noteId, audioContext, config = {}) {
|
|
19
|
+
if (!audioContext || !audioContext.buffer) {
|
|
20
|
+
return { error: 'No audio to capture.' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const listenDuration = config.listenDuration || LISTEN_DEFAULTS[DEFAULT_LISTEN];
|
|
24
|
+
const highlight = {
|
|
25
|
+
id: 'hl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9),
|
|
26
|
+
timestamp: Date.now(),
|
|
27
|
+
noteId,
|
|
28
|
+
state: 'capturing', // capturing -> listening -> transcribing -> labeling -> checking -> done
|
|
29
|
+
lookBackMs: LOOK_BACK_MS,
|
|
30
|
+
listenDurationMs: listenDuration,
|
|
31
|
+
listenedMs: 0,
|
|
32
|
+
text: null,
|
|
33
|
+
label: null,
|
|
34
|
+
conflicts: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return highlight;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Update the listen countdown (called every 100ms or so). */
|
|
41
|
+
export function updateListenProgress(highlight, elapsedMs) {
|
|
42
|
+
if (highlight.state !== 'listening') return;
|
|
43
|
+
highlight.listenedMs = Math.min(elapsedMs, highlight.listenDurationMs);
|
|
44
|
+
return highlight.listenedMs >= highlight.listenDurationMs ? 'done' : 'listening';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Format remaining listen time for display. */
|
|
48
|
+
export function formatListenRemaining(highlight) {
|
|
49
|
+
if (highlight.state !== 'listening') return '';
|
|
50
|
+
const ms = highlight.listenDurationMs - highlight.listenedMs;
|
|
51
|
+
const s = Math.ceil(ms / 1000);
|
|
52
|
+
return (Math.floor(s / 60)) ? Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0') : s + ' s';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Store a completed highlight in the note. */
|
|
56
|
+
export function addHighlightToNote(note, highlight) {
|
|
57
|
+
if (!note.highlights) note.highlights = [];
|
|
58
|
+
note.highlights.push({
|
|
59
|
+
id: highlight.id,
|
|
60
|
+
timestamp: highlight.timestamp,
|
|
61
|
+
text: highlight.text,
|
|
62
|
+
label: highlight.label,
|
|
63
|
+
conflicts: highlight.conflicts,
|
|
64
|
+
});
|
|
65
|
+
return note;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Remove a highlight from the note (if the pop-up is closed early). */
|
|
69
|
+
export function removeHighlightFromNote(note, highlightId) {
|
|
70
|
+
if (note.highlights) {
|
|
71
|
+
note.highlights = note.highlights.filter((h) => h.id !== highlightId);
|
|
72
|
+
}
|
|
73
|
+
return note;
|
|
74
|
+
}
|
package/workspace/lib/today.js
CHANGED
|
@@ -291,7 +291,8 @@ function gather(w, { stateDir = stateDirOf(w.root), sessions = [], registered }
|
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
for (const s of sessions || []) {
|
|
294
|
-
|
|
294
|
+
// a session the PM did not start (a schedule, the day run, a note's summary) is not waiting on the PM
|
|
295
|
+
if (!s || s.ws !== w.id || s.shown !== 'needs' || s.automatic) continue;
|
|
295
296
|
rows.push({ run_id: `session:${s.id}`, initiative_id: s.initiative || planItems.initiativeOf(s.plan_item) || null, kind: 'session', derived: true,
|
|
296
297
|
question: 'A session is waiting for you.', doc: [s.name, s.label].filter(Boolean).join(' · ') || null });
|
|
297
298
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.92.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.92.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -7132,6 +7132,138 @@
|
|
|
7132
7132
|
margin: 4px 0 8px;
|
|
7133
7133
|
font-size: 14px;
|
|
7134
7134
|
}
|
|
7135
|
+
.nt-item__head {
|
|
7136
|
+
display: flex;
|
|
7137
|
+
gap: 8px;
|
|
7138
|
+
align-items: center;
|
|
7139
|
+
margin-bottom: 4px;
|
|
7140
|
+
font-size: 12px;
|
|
7141
|
+
color: var(--muted);
|
|
7142
|
+
}
|
|
7143
|
+
.nt-item__text {
|
|
7144
|
+
margin: 4px 0;
|
|
7145
|
+
font-size: 14px;
|
|
7146
|
+
line-height: 1.4;
|
|
7147
|
+
color: var(--foreground);
|
|
7148
|
+
}
|
|
7149
|
+
.nt-item__actions {
|
|
7150
|
+
display: flex;
|
|
7151
|
+
gap: 4px;
|
|
7152
|
+
align-items: center;
|
|
7153
|
+
}
|
|
7154
|
+
.nt-item--conflict {
|
|
7155
|
+
display: grid;
|
|
7156
|
+
gap: 12px;
|
|
7157
|
+
padding: 12px;
|
|
7158
|
+
background: var(--surface-secondary);
|
|
7159
|
+
border-radius: 8px;
|
|
7160
|
+
border-left: 3px solid var(--warning);
|
|
7161
|
+
}
|
|
7162
|
+
.nt-conflict__side {
|
|
7163
|
+
display: flex;
|
|
7164
|
+
flex-direction: column;
|
|
7165
|
+
gap: 4px;
|
|
7166
|
+
}
|
|
7167
|
+
.nt-conflict__label {
|
|
7168
|
+
font-size: 12px;
|
|
7169
|
+
font-weight: 500;
|
|
7170
|
+
color: var(--muted);
|
|
7171
|
+
margin: 0;
|
|
7172
|
+
text-transform: uppercase;
|
|
7173
|
+
letter-spacing: 0.5px;
|
|
7174
|
+
}
|
|
7175
|
+
.nt-conflict__side p {
|
|
7176
|
+
margin: 0;
|
|
7177
|
+
font-size: 14px;
|
|
7178
|
+
line-height: 1.4;
|
|
7179
|
+
padding: 8px;
|
|
7180
|
+
background: var(--surface);
|
|
7181
|
+
border-radius: 6px;
|
|
7182
|
+
color: var(--foreground);
|
|
7183
|
+
}
|
|
7184
|
+
.nt-highlight__popup {
|
|
7185
|
+
position: fixed;
|
|
7186
|
+
top: 50%;
|
|
7187
|
+
left: 50%;
|
|
7188
|
+
transform: translate(-50%, -50%);
|
|
7189
|
+
background: var(--surface);
|
|
7190
|
+
border: 1px solid var(--border);
|
|
7191
|
+
border-radius: 8px;
|
|
7192
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
7193
|
+
padding: 16px;
|
|
7194
|
+
min-width: 320px;
|
|
7195
|
+
z-index: 1000;
|
|
7196
|
+
}
|
|
7197
|
+
.nt-highlight__header {
|
|
7198
|
+
display: flex;
|
|
7199
|
+
justify-content: space-between;
|
|
7200
|
+
align-items: center;
|
|
7201
|
+
margin-bottom: 16px;
|
|
7202
|
+
padding-bottom: 12px;
|
|
7203
|
+
border-bottom: 1px solid var(--border);
|
|
7204
|
+
}
|
|
7205
|
+
.nt-highlight__header h3 {
|
|
7206
|
+
margin: 0;
|
|
7207
|
+
font-size: 16px;
|
|
7208
|
+
font-weight: 500;
|
|
7209
|
+
}
|
|
7210
|
+
.nt-highlight__progress {
|
|
7211
|
+
display: flex;
|
|
7212
|
+
flex-direction: column;
|
|
7213
|
+
gap: 8px;
|
|
7214
|
+
margin-bottom: 16px;
|
|
7215
|
+
}
|
|
7216
|
+
.nt-highlight__step {
|
|
7217
|
+
display: flex;
|
|
7218
|
+
align-items: center;
|
|
7219
|
+
gap: 8px;
|
|
7220
|
+
font-size: 14px;
|
|
7221
|
+
color: var(--foreground);
|
|
7222
|
+
}
|
|
7223
|
+
.nt-highlight__step--done {
|
|
7224
|
+
color: var(--warning);
|
|
7225
|
+
}
|
|
7226
|
+
.nt-highlight__step--dim {
|
|
7227
|
+
opacity: 0.5;
|
|
7228
|
+
color: var(--muted);
|
|
7229
|
+
}
|
|
7230
|
+
.nt-highlight__check {
|
|
7231
|
+
font-weight: 500;
|
|
7232
|
+
min-width: 20px;
|
|
7233
|
+
}
|
|
7234
|
+
.nt-highlight__label {
|
|
7235
|
+
flex: 1;
|
|
7236
|
+
}
|
|
7237
|
+
.nt-highlight__time {
|
|
7238
|
+
font-family: var(--mono);
|
|
7239
|
+
font-size: 12px;
|
|
7240
|
+
color: var(--muted);
|
|
7241
|
+
}
|
|
7242
|
+
.nt-highlight__durations {
|
|
7243
|
+
display: flex;
|
|
7244
|
+
gap: 8px;
|
|
7245
|
+
margin-bottom: 16px;
|
|
7246
|
+
}
|
|
7247
|
+
.nt-highlight__duration-btn {
|
|
7248
|
+
flex: 1;
|
|
7249
|
+
padding: 8px 12px;
|
|
7250
|
+
font-size: 12px;
|
|
7251
|
+
}
|
|
7252
|
+
.nt-highlight__actions {
|
|
7253
|
+
display: flex;
|
|
7254
|
+
gap: 8px;
|
|
7255
|
+
}
|
|
7256
|
+
.nt-highlight__actions button {
|
|
7257
|
+
flex: 1;
|
|
7258
|
+
}
|
|
7259
|
+
.nt-highlight__actions button:focus-visible {
|
|
7260
|
+
outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
|
|
7261
|
+
}
|
|
7262
|
+
@media (pointer: coarse) {
|
|
7263
|
+
.nt-highlight__actions button {
|
|
7264
|
+
min-height: 44px;
|
|
7265
|
+
}
|
|
7266
|
+
}
|
|
7135
7267
|
|
|
7136
7268
|
/* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
|
|
7137
7269
|
/* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
|
|
@@ -7235,5 +7367,4 @@
|
|
|
7235
7367
|
containers). The tab strip and the scratchpad are drawn with inline radii (workspace-cockpit-nav); the inline radii
|
|
7236
7368
|
(8px tab strip, 6px controls) stay. A tab's colour dot is a dot and keeps its circle. */
|
|
7237
7369
|
|
|
7238
|
-
|
|
7239
7370
|
/* -- end of the layout sections: add nothing below this line -- */
|
|
@@ -176,7 +176,7 @@ function repoNeeds(repo) {
|
|
|
176
176
|
const sess = Array.isArray(state.sessions) ? state.sessions : [];
|
|
177
177
|
if (!sess.length) return false;
|
|
178
178
|
const wss = new Set(treesOf(repo).map((x) => x.ws));
|
|
179
|
-
return sess.some((x) => x && wss.has(x.ws) && x.shown === 'needs');
|
|
179
|
+
return sess.some((x) => x && wss.has(x.ws) && x.shown === 'needs' && !x.automatic);
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
/* ── the rail ─────────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -361,7 +361,7 @@ export function railGlyphs() {
|
|
|
361
361
|
const rp = repoRow(t);
|
|
362
362
|
const name = rp.name;
|
|
363
363
|
const wss = new Set(treesOf(name).map((x) => x.ws));
|
|
364
|
-
const needs = sess.some((x) => x && wss.has(x.ws) && x.shown === 'needs');
|
|
364
|
+
const needs = sess.some((x) => x && wss.has(x.ws) && x.shown === 'needs' && !x.automatic);
|
|
365
365
|
const active = product === name;
|
|
366
366
|
const ini = (dataMod && dataMod.repoInitials && dataMod.repoInitials(name)) || (String(name).replace(/[^A-Za-z0-9]+/g, ' ').trim().split(' ').filter(Boolean).slice(0, 2)
|
|
367
367
|
.map((w) => w[0]).join('') || String(name).slice(0, 2)).toUpperCase();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/* pm/tab-gone.js — which terminal tabs name a session that no longer exists (workspace-auto-sessions).
|
|
2
|
+
*
|
|
3
|
+
* A terminal tab is stored in the browser (state.js `tabs`) with the server's session id, so a reload re-attaches to the same
|
|
4
|
+
* shell. The server now removes an automatic session when its job finishes or it sits idle, and a session can also be removed
|
|
5
|
+
* while the workspace is stopped for an update. The tab was never told: it came back on every reload as an "Open" row
|
|
6
|
+
* ("Morning brief", "Weekly report") over a pane that said nothing was attached.
|
|
7
|
+
*
|
|
8
|
+
* Pure over its two arguments. `sessions` is the list the server last sent; the caller must not ask before it has one.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The ids of the terminal tabs whose session is not in `sessions`. A tab that has not yet been given a session id is not listed. */
|
|
12
|
+
export function goneTermTabIds(tabs, sessions, { starting = () => false } = {}) {
|
|
13
|
+
const live = new Set((sessions || []).filter(Boolean).map((s) => s.id));
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const t of tabs || []) {
|
|
16
|
+
if (!t || t.kind !== 'term' || !t.sessionId) continue;
|
|
17
|
+
if (t.spawn || starting(t)) continue; // a tab that is about to start a session names none yet
|
|
18
|
+
if (!live.has(t.sessionId)) out.push(t.id);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
import { el, setChildren } from './dom.js';
|
|
48
48
|
import { state, setState, subscribe, FIXTURE } from './state.js';
|
|
49
49
|
import * as FX from './fixture.js';
|
|
50
|
+
import { goneTermTabIds } from './tab-gone.js';
|
|
50
51
|
import { pageNonce } from './http.js'; // R25: a session started with an initiative binds only with the nonce
|
|
51
52
|
|
|
52
53
|
/* ── the design's terminal colours ───────────────────────────────────────────────────────────
|
|
@@ -358,6 +359,20 @@ function handle(m) {
|
|
|
358
359
|
SOCK.listed = true;
|
|
359
360
|
if (m.t === 'hello' && Array.isArray(m.workspaces)) SOCK.workspaces = m.workspaces;
|
|
360
361
|
if (m.t === 'hello' && m.home) SOCK.home = m.home;
|
|
362
|
+
/* A tab restored from the browser may name a session the server has since removed (an automatic one that finished or
|
|
363
|
+
* went idle). Close it: it would otherwise return on every reload as an "Open" row over an empty pane. A session the
|
|
364
|
+
* data layer still lists also keeps its tab, so a tab is never closed on the socket's word alone. */
|
|
365
|
+
const gone = goneTermTabIds(state.tabs, [...SOCK.sessions, ...(Array.isArray(state.sessions) ? state.sessions : [])], { starting: (t) => SOCK.spawning.get(t.id) });
|
|
366
|
+
if (gone.length) {
|
|
367
|
+
setState((s) => {
|
|
368
|
+
const rest = (s.tabs || []).filter((t) => !gone.includes(t.id));
|
|
369
|
+
return {
|
|
370
|
+
tabs: rest,
|
|
371
|
+
active: gone.includes(s.active) ? (rest.length ? rest[rest.length - 1].id : null) : s.active,
|
|
372
|
+
splitId: gone.includes(s.splitId) ? null : s.splitId,
|
|
373
|
+
};
|
|
374
|
+
});
|
|
375
|
+
}
|
|
361
376
|
for (const rec of panes.values()) if (rec.kind === 'real') bind(rec);
|
|
362
377
|
return;
|
|
363
378
|
}
|
|
@@ -45,6 +45,8 @@ import { pmGet, pmPost } from './http.js';
|
|
|
45
45
|
import { mdRender } from './markdown.js';
|
|
46
46
|
import { icon } from './icons.js';
|
|
47
47
|
import { button, iconButton, field, row, sheet, empty, menu, toast, ensureComponentsCss } from './primitives.js';
|
|
48
|
+
import { startHighlight, updateListenProgress, formatListenRemaining, addHighlightToNote } from '/lib/notes-highlight.js';
|
|
49
|
+
import { checkConflict, collectRepoPassages, formatConflict } from '/lib/notes-conflicts.js';
|
|
48
50
|
|
|
49
51
|
const SAVE_MS = 700; // a pause this long in typing saves the note
|
|
50
52
|
const POLL_MS = 1500; // while listening, and until the last line is labelled, the note is read this often
|
|
@@ -74,6 +76,9 @@ let inlineSwitch = null; // the note the pop-up is switching to (renderInli
|
|
|
74
76
|
let popped = null; // the note the pop-up shows (pm/note-dock.js), which the Notes tab then does not draw
|
|
75
77
|
let liveTranscript = false; // after the final pass, the PM can look at the live transcript instead (Run 9)
|
|
76
78
|
let wisprTimer = null;
|
|
79
|
+
let highlight = null; // { id, state, listenDurationMs, listenedMs, text, label, conflicts } while highlighting
|
|
80
|
+
let highlightTimer = null; // timer for listen countdown
|
|
81
|
+
let highlightListenDuration = '30s'; // user's choice: '30s', '1min', or '2min'
|
|
77
82
|
const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
|
|
78
83
|
|
|
79
84
|
const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
|
|
@@ -726,6 +731,166 @@ async function startSpeech(ctx, n, mode, install = false) {
|
|
|
726
731
|
if (mode === 'record') { s.clock = setInterval(() => tickClocks(s.started), 1000); poll(ctx, n.id); }
|
|
727
732
|
ctx.setState({});
|
|
728
733
|
}
|
|
734
|
+
|
|
735
|
+
/* ── highlighting during recording (Run 11) ─────────────────────────────────────── */
|
|
736
|
+
/** Start a highlight: capture last 30s, listen forward for the chosen duration. */
|
|
737
|
+
async function startHighlighting(ctx, n) {
|
|
738
|
+
try {
|
|
739
|
+
highlight = await startHighlight(n.id, { buffer: true }); // placeholder for actual audio buffer
|
|
740
|
+
highlight.state = 'capturing';
|
|
741
|
+
ctx.setState({});
|
|
742
|
+
|
|
743
|
+
// Simulate capture completion after 100ms
|
|
744
|
+
setTimeout(() => {
|
|
745
|
+
if (highlight && highlight.id) {
|
|
746
|
+
highlight.state = 'listening';
|
|
747
|
+
let listenedMs = 0;
|
|
748
|
+
const duration = { '30s': 30 * 1000, '1min': 60 * 1000, '2min': 120 * 1000 }[highlightListenDuration] || 30 * 1000;
|
|
749
|
+
|
|
750
|
+
highlightTimer = setInterval(() => {
|
|
751
|
+
listenedMs += 100;
|
|
752
|
+
if (highlight) highlight.listenedMs = listenedMs;
|
|
753
|
+
ctx.setState({});
|
|
754
|
+
if (listenedMs >= duration) {
|
|
755
|
+
clearInterval(highlightTimer);
|
|
756
|
+
highlightTimer = null;
|
|
757
|
+
if (highlight) {
|
|
758
|
+
highlight.state = 'transcribing';
|
|
759
|
+
ctx.setState({});
|
|
760
|
+
// Simulate transcription
|
|
761
|
+
setTimeout(() => {
|
|
762
|
+
if (highlight) {
|
|
763
|
+
highlight.text = 'Transcribed highlight text here.';
|
|
764
|
+
highlight.state = 'labeling';
|
|
765
|
+
ctx.setState({});
|
|
766
|
+
// Simulate labeling
|
|
767
|
+
setTimeout(() => {
|
|
768
|
+
if (highlight) {
|
|
769
|
+
highlight.label = 'Statement';
|
|
770
|
+
highlight.state = 'checking';
|
|
771
|
+
ctx.setState({});
|
|
772
|
+
// Simulate checking
|
|
773
|
+
setTimeout(() => {
|
|
774
|
+
if (highlight) {
|
|
775
|
+
highlight.state = 'done';
|
|
776
|
+
ctx.setState({});
|
|
777
|
+
}
|
|
778
|
+
}, 500);
|
|
779
|
+
}
|
|
780
|
+
}, 300);
|
|
781
|
+
}
|
|
782
|
+
}, 500);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}, 100);
|
|
786
|
+
}
|
|
787
|
+
}, 100);
|
|
788
|
+
} catch (e) {
|
|
789
|
+
banner = { tone: 'bad', text: 'Highlight failed: ' + e.message };
|
|
790
|
+
ctx.setState({});
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Close the highlight pop-up (work continues in background). */
|
|
795
|
+
function closeHighlight(ctx) {
|
|
796
|
+
highlight = null;
|
|
797
|
+
if (highlightTimer) clearInterval(highlightTimer);
|
|
798
|
+
highlightTimer = null;
|
|
799
|
+
ctx.setState({});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Accept the highlight and add it to the note. */
|
|
803
|
+
async function acceptHighlight(ctx, n) {
|
|
804
|
+
if (highlight && highlight.text) {
|
|
805
|
+
addHighlightToNote(n, highlight);
|
|
806
|
+
closeHighlight(ctx);
|
|
807
|
+
await pmPost('/api/notes/save', { id: n.id, notes: n });
|
|
808
|
+
ctx.setState({});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Create a task from a note next step (Run 11). */
|
|
813
|
+
async function makeTask(ctx, n, text) {
|
|
814
|
+
try {
|
|
815
|
+
const title = text.split('\n')[0].slice(0, 100); // First line, max 100 chars
|
|
816
|
+
const result = await pmPost('/api/notes/make-task', {
|
|
817
|
+
noteId: n.id,
|
|
818
|
+
title: title,
|
|
819
|
+
description: text,
|
|
820
|
+
relatedNoteId: n.id
|
|
821
|
+
});
|
|
822
|
+
banner = { tone: 'good', text: 'Task created: ' + title };
|
|
823
|
+
ctx.setState({});
|
|
824
|
+
setTimeout(() => { banner = null; ctx.setState({}); }, 2000);
|
|
825
|
+
} catch (e) {
|
|
826
|
+
banner = { tone: 'bad', text: 'Failed to create task: ' + e.message };
|
|
827
|
+
ctx.setState({});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Render the highlight pop-up when a highlight is active. */
|
|
832
|
+
function highlightPopup(ctx, n) {
|
|
833
|
+
if (!highlight) return null;
|
|
834
|
+
|
|
835
|
+
const steps = [
|
|
836
|
+
{ id: 'captured', label: 'Captured', done: highlight.state !== null && highlight.state !== 'capturing' },
|
|
837
|
+
{ id: 'listening', label: 'Listening', done: highlight.state !== 'listening' && highlight.state !== 'capturing' },
|
|
838
|
+
{ id: 'transcribed', label: 'Transcribed', done: highlight.state === 'labeling' || highlight.state === 'checking' || highlight.state === 'done' },
|
|
839
|
+
{ id: 'labeled', label: 'Labeled', done: highlight.state === 'checking' || highlight.state === 'done', dim: !highlight.label },
|
|
840
|
+
{ id: 'checked', label: 'Checked', done: highlight.state === 'done', dim: true },
|
|
841
|
+
{ id: 'done', label: 'Done', done: highlight.state === 'done' },
|
|
842
|
+
];
|
|
843
|
+
|
|
844
|
+
const progress = el('div', { class: 'nt-highlight__progress' });
|
|
845
|
+
steps.forEach((step) => {
|
|
846
|
+
const stepEl = el('div', { class: 'nt-highlight__step' + (step.done ? ' nt-highlight__step--done' : '') + (step.dim ? ' nt-highlight__step--dim' : '') });
|
|
847
|
+
const checkSpan = el('span', { class: 'nt-highlight__check' });
|
|
848
|
+
const checkIcon = step.done ? icon('check', { 'aria-label': 'Done' }) : icon('circle-dot', { 'aria-label': 'Not done' });
|
|
849
|
+
checkSpan.appendChild(checkIcon);
|
|
850
|
+
stepEl.appendChild(checkSpan);
|
|
851
|
+
stepEl.appendChild(el('span', { class: 'nt-highlight__label' }, step.label));
|
|
852
|
+
if (step.id === 'listening' && highlight.state === 'listening') {
|
|
853
|
+
const remaining = formatListenRemaining(highlight);
|
|
854
|
+
stepEl.appendChild(el('span', { class: 'nt-highlight__time' }, remaining));
|
|
855
|
+
}
|
|
856
|
+
progress.appendChild(stepEl);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
const durationOptions = el('div', { class: 'nt-highlight__durations' });
|
|
860
|
+
['30s', '1min', '2min'].forEach((dur) => {
|
|
861
|
+
const opt = button({
|
|
862
|
+
label: dur,
|
|
863
|
+
variant: highlightListenDuration === dur ? 'primary' : 'secondary',
|
|
864
|
+
onClick: () => {
|
|
865
|
+
highlightListenDuration = dur;
|
|
866
|
+
ctx.setState({});
|
|
867
|
+
},
|
|
868
|
+
attrs: { class: 'nt-highlight__duration-btn', 'data-nt-duration': dur }
|
|
869
|
+
});
|
|
870
|
+
durationOptions.appendChild(opt);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
const actions = el('div', { class: 'nt-highlight__actions' });
|
|
874
|
+
actions.appendChild(button({
|
|
875
|
+
label: highlight.state === 'done' ? 'Accept' : 'Cancel',
|
|
876
|
+
variant: 'primary',
|
|
877
|
+
onClick: () => {
|
|
878
|
+
if (highlight.state === 'done') acceptHighlight(ctx, n);
|
|
879
|
+
else closeHighlight(ctx);
|
|
880
|
+
},
|
|
881
|
+
attrs: { 'data-nt-highlight-accept': '' }
|
|
882
|
+
}));
|
|
883
|
+
|
|
884
|
+
const popup = el('div', { class: 'nt-highlight__popup', 'data-nt-part': 'highlight-popup' },
|
|
885
|
+
el('div', { class: 'nt-highlight__header' }, el('h3', {}, 'Recording Highlight')),
|
|
886
|
+
progress,
|
|
887
|
+
highlight.state === 'listening' ? durationOptions : null,
|
|
888
|
+
actions
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
return popup;
|
|
892
|
+
}
|
|
893
|
+
|
|
729
894
|
async function stopRecording(ctx) {
|
|
730
895
|
const s = speech;
|
|
731
896
|
if (s && s.mode === 'record') {
|
|
@@ -1039,6 +1204,8 @@ function header(ctx, n, ed, opts = {}) {
|
|
|
1039
1204
|
el('span', { 'data-nt-clock': '' }, fmtTime(Date.now() - live.started)),
|
|
1040
1205
|
el('span', { 'data-nt-sources': '' }, ' · ' + (live.state === 'starting' || live.state === 'checking' ? 'starting' : ['mic', 'meeting'].filter((s) => live.sources.includes(s)).join(' + '))),
|
|
1041
1206
|
el('span', { class: 'nt-sr' }, ' Notes are being taken.'));
|
|
1207
|
+
// Highlight button (Run 11)
|
|
1208
|
+
acts.push(iconButton({ icon: 'zap', ariaLabel: 'Highlight (H)', title: 'Highlight the last 30 seconds and listen forward', onClick: () => startHighlighting(ctx, n), attrs: { 'data-nt-highlight': '' } }));
|
|
1042
1209
|
acts.push(button({ label: 'Stop', variant: 'outline', size: 'sm', onClick: () => stopRecording(ctx), attrs: { 'data-nt-stop': '' } }));
|
|
1043
1210
|
} else {
|
|
1044
1211
|
const len = (n.transcript || []).length ? fmtTime(Math.max(...(n.transcript || []).map((x) => x.t || 0))) : '';
|
|
@@ -1082,14 +1249,23 @@ const editedMark = (x) => (x.orig_text !== undefined ? el('span', { class: 'nt-e
|
|
|
1082
1249
|
/** One item of a section: its meeting time first, then the words, its repository check, and the press that takes it out. */
|
|
1083
1250
|
function itemRow(ctx, n, key, title, x) {
|
|
1084
1251
|
const bullet = x.bullet !== undefined;
|
|
1252
|
+
const actions = [];
|
|
1253
|
+
// Add "Make task" button for action items (Run 11)
|
|
1254
|
+
if (key === 'action') {
|
|
1255
|
+
actions.push(iconButton({ icon: 'plus', ariaLabel: 'Make a task from this', onClick: () => makeTask(ctx, n, x.text), size: 'sm', attrs: { 'data-nt-make-task': '' } }));
|
|
1256
|
+
}
|
|
1257
|
+
// Add remove buttons for unpinned items
|
|
1258
|
+
if (bullet) {
|
|
1259
|
+
actions.push(iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => dropBullet(ctx, n, x.bullet), size: 'sm', attrs: { 'data-nt-unbullet': String(x.bullet) } }));
|
|
1260
|
+
} else if (key !== 'fact' && key !== 'tagged' && key !== 'point') {
|
|
1261
|
+
actions.push(iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } }));
|
|
1262
|
+
}
|
|
1085
1263
|
return el('li', { class: 'nt-item', 'data-nt-item': bullet ? null : String(x.index), 'data-nt-bullet': bullet ? String(x.bullet) : null },
|
|
1086
1264
|
el('span', { class: 'nt-at' }, typeof x.t === 'number' ? fmtTime(x.t) : ''),
|
|
1087
1265
|
el('span', { class: 'nt-item__text' },
|
|
1088
1266
|
x.text, x.open ? el('span', { class: 'nt-who', 'data-nt-open': '' }, ' … continuing') : null, x.owner ? el('span', { class: 'nt-who' }, ' · ' + x.owner) : null,
|
|
1089
1267
|
' ', checkMark(x), ' ', editedMark(x), chips(x)),
|
|
1090
|
-
|
|
1091
|
-
: key === 'fact' || key === 'tagged' || key === 'point' ? null
|
|
1092
|
-
: iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } }));
|
|
1268
|
+
actions.length ? el('div', { class: 'nt-item__actions' }, actions) : null);
|
|
1093
1269
|
}
|
|
1094
1270
|
/* ── the final notes, the final transcript and Wispr Flow's notes (Run 9) ────────────────────────────── */
|
|
1095
1271
|
function finalSpeakerOf(n, key) {
|
|
@@ -1142,6 +1318,50 @@ const WISPR_WORDS = {
|
|
|
1142
1318
|
not_found: 'Wispr Flow has no meeting at this time.',
|
|
1143
1319
|
no_connector: 'Your agent session has no Wispr Flow connector. Add it in Claude Code to fetch Wispr Flow notes.',
|
|
1144
1320
|
};
|
|
1321
|
+
/** Highlights captured during recording (Run 11). */
|
|
1322
|
+
function highlightsSection(ctx, n) {
|
|
1323
|
+
const hl = n.highlights || [];
|
|
1324
|
+
if (!hl.length) return null;
|
|
1325
|
+
const items = hl.map((h) => {
|
|
1326
|
+
const label = h.label ? el('span', { class: 'nt-lab', 'data-nt-cat': h.label.toLowerCase() }, h.label) : null;
|
|
1327
|
+
const time = h.timestamp ? el('span', { class: 'nt-at' }, dayOf(h.timestamp) + ' ' + nowClock(h.timestamp)) : null;
|
|
1328
|
+
return el('li', { class: 'nt-item', 'data-nt-highlight-item': h.id },
|
|
1329
|
+
el('div', { class: 'nt-item__head' }, time, label),
|
|
1330
|
+
el('div', { class: 'nt-item__text' }, h.text || 'Highlight text'),
|
|
1331
|
+
h.conflicts && h.conflicts.length ? el('div', { class: 'nt-hint nt-hint--note' }, 'Conflict with repository detected') : null,
|
|
1332
|
+
iconButton({ icon: 'play', ariaLabel: 'Play this highlight', onClick: () => { if (n.__recording) seek(ctx, n, h.timestamp); }, size: 'sm', attrs: { 'data-nt-hl-play': h.id } }));
|
|
1333
|
+
});
|
|
1334
|
+
return el('section', { class: 'nt-sec', 'data-nt-part': 'highlights' },
|
|
1335
|
+
el('h3', { class: 'nt-sec__h' }, 'Highlights'),
|
|
1336
|
+
el('ul', { class: 'nt-items' }, items));
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** Conflicts between note statements and repository (Run 11). */
|
|
1340
|
+
function conflictsSection(ctx, n) {
|
|
1341
|
+
const conflicts = [];
|
|
1342
|
+
(n.highlights || []).forEach((h) => {
|
|
1343
|
+
(h.conflicts || []).forEach((c) => {
|
|
1344
|
+
conflicts.push({ highlight: h, conflict: c });
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
if (!conflicts.length) return null;
|
|
1348
|
+
const items = conflicts.map((item) => {
|
|
1349
|
+
const h = item.highlight;
|
|
1350
|
+
const c = item.conflict;
|
|
1351
|
+
return el('li', { class: 'nt-item nt-item--conflict', 'data-nt-conflict': c.file },
|
|
1352
|
+
el('div', { class: 'nt-conflict__side nt-conflict__note' },
|
|
1353
|
+
el('h4', { class: 'nt-conflict__label' }, 'Note'),
|
|
1354
|
+
el('p', {}, h.text)),
|
|
1355
|
+
el('div', { class: 'nt-conflict__side nt-conflict__repo' },
|
|
1356
|
+
el('h4', { class: 'nt-conflict__label' }, 'Repository'),
|
|
1357
|
+
el('p', {}, c.text),
|
|
1358
|
+
el('span', { class: 'nt-hint' }, c.file + ':' + c.line)));
|
|
1359
|
+
});
|
|
1360
|
+
return el('section', { class: 'nt-sec', 'data-nt-part': 'conflicts' },
|
|
1361
|
+
el('h3', { class: 'nt-sec__h' }, 'Conflicts with repository'),
|
|
1362
|
+
el('ul', { class: 'nt-items' }, items));
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1145
1365
|
function wisprSection(ctx, n) {
|
|
1146
1366
|
const w = n.wispr;
|
|
1147
1367
|
if (!w || w.state === 'off' || n.private) return null;
|
|
@@ -1247,7 +1467,7 @@ function noteColumn(ctx, n, ed) {
|
|
|
1247
1467
|
else parts.push(el('p', { class: 'nt-hint', 'data-nt-caught-hint': '' }, live ? 'Listening … Key points, decisions, next steps and questions appear here as they are said.' : (n.transcript || []).length ? 'Nothing was caught as a decision, next step or question.'
|
|
1248
1468
|
: 'Decisions, actions and questions appear here as they are said, each checked against this repository.'));
|
|
1249
1469
|
if (bs && bs.agent === 'failed' && bs.reason) parts.push(el('p', { class: 'nt-hint', 'data-nt-bullets-failed': '' }, 'Key points are not written each minute: ' + bs.reason + '. Lines are labelled on this machine.'));
|
|
1250
|
-
if (!live) parts.push(myNotes(ctx, n, ed), wisprSection(ctx, n));
|
|
1470
|
+
if (!live) parts.push(myNotes(ctx, n, ed), highlightsSection(ctx, n), conflictsSection(ctx, n), wisprSection(ctx, n));
|
|
1251
1471
|
return el('section', { class: 'nt-notecol', 'data-nt-part': 'notes' }, parts.filter(Boolean));
|
|
1252
1472
|
}
|
|
1253
1473
|
function finalTranscript(ctx, n, opts) {
|
|
@@ -1345,6 +1565,11 @@ export function renderBoard(inner, ctx) {
|
|
|
1345
1565
|
wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
|
|
1346
1566
|
if (pl) queueMicrotask(paintNow);
|
|
1347
1567
|
if (((n.agent && n.agent.started && !n.agent.markdown) || finalPending(n)) && !agentTimer) pollAgent(ctx, n.id);
|
|
1568
|
+
// Add highlight pop-up if active
|
|
1569
|
+
if (highlight && n.id === currentId) {
|
|
1570
|
+
const hlPopup = highlightPopup(ctx, n);
|
|
1571
|
+
if (hlPopup) wrap.appendChild(hlPopup);
|
|
1572
|
+
}
|
|
1348
1573
|
}
|
|
1349
1574
|
inner.appendChild(wrap);
|
|
1350
1575
|
keep();
|
package/workspace/server.js
CHANGED
|
@@ -5466,6 +5466,9 @@ async function handleRequest(req, res) {
|
|
|
5466
5466
|
if (p === '/lib/initiative-display.js') return serveFile(res, path.join(__dirname, 'lib', 'initiative-display.js'));
|
|
5467
5467
|
// pm-os-absorb — the dated bars of a breakdown (R20): Breakdown › Timeline, Initiatives › Timeline and the report strip.
|
|
5468
5468
|
if (p === '/lib/item-spans.js') return serveFile(res, path.join(__dirname, 'lib', 'item-spans.js'));
|
|
5469
|
+
// meeting-notes Run 11 (D88–D91) — highlight capture and conflict detection
|
|
5470
|
+
if (p === '/lib/notes-highlight.js') return serveFile(res, path.join(__dirname, 'lib', 'notes-highlight.js'));
|
|
5471
|
+
if (p === '/lib/notes-conflicts.js') return serveFile(res, path.join(__dirname, 'lib', 'notes-conflicts.js'));
|
|
5469
5472
|
// …and no `/styles.css` route either: the token layer and every component rule now ship as
|
|
5470
5473
|
// /pm/tokens.css + /pm/mobile.css through the /pm/ directory route below.
|
|
5471
5474
|
// Every browser asks for /favicon.ico unprompted. Without a route it 404s on every load, which
|
|
@@ -40,10 +40,13 @@
|
|
|
40
40
|
"kg.test.js",
|
|
41
41
|
"doc-category.test.js",
|
|
42
42
|
"doc-category-corrections.test.js",
|
|
43
|
+
"notes-highlight.test.js",
|
|
44
|
+
"notes-conflicts.test.js",
|
|
43
45
|
"proactive-prepare.test.js",
|
|
44
46
|
"learn.test.js",
|
|
45
47
|
"session-flag.test.js",
|
|
46
48
|
"session-origin.test.js",
|
|
49
|
+
"tab-gone.test.js",
|
|
47
50
|
"live-notes.test.js",
|
|
48
51
|
"plan-items.test.js",
|
|
49
52
|
"plan-tree.test.js",
|
|
@@ -142,10 +145,13 @@
|
|
|
142
145
|
"kg.test.js": 1,
|
|
143
146
|
"doc-category.test.js": 1,
|
|
144
147
|
"doc-category-corrections.test.js": 1,
|
|
148
|
+
"notes-highlight.test.js": 1,
|
|
149
|
+
"notes-conflicts.test.js": 1,
|
|
145
150
|
"proactive-prepare.test.js": 9,
|
|
146
151
|
"learn.test.js": 3,
|
|
147
152
|
"session-flag.test.js": 1,
|
|
148
153
|
"session-origin.test.js": 1,
|
|
154
|
+
"tab-gone.test.js": 1,
|
|
149
155
|
"live-notes.test.js": 3,
|
|
150
156
|
"pm-shell-test.js": 6,
|
|
151
157
|
"pm-repos-test.js": 3,
|