create-pmos 0.67.1 → 0.69.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/command-palette.md +1 -1
- package/workspace/guide/initiatives-screen.md +1 -1
- package/workspace/guide/layout.md +35 -8
- package/workspace/guide/notes.md +12 -1
- package/workspace/guide/today.md +3 -1
- package/workspace/lib/notes-links.js +56 -0
- package/workspace/lib/notes.js +87 -5
- package/workspace/lib/routes-notes.js +47 -6
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +697 -3
- package/workspace/public/pm/doc-editor.js +473 -0
- package/workspace/public/pm/doc-rail.js +161 -0
- package/workspace/public/pm/doc-remote.js +180 -0
- package/workspace/public/pm/dock-sessions.js +57 -13
- package/workspace/public/pm/frame-copy.js +2 -0
- package/workspace/public/pm/icons.js +1 -1
- package/workspace/public/pm/initiative-sheet.js +10 -2
- package/workspace/public/pm/initiatives-copy.js +1 -0
- package/workspace/public/pm/notes-links.js +110 -0
- package/workspace/public/pm/panel-tabs.js +85 -3
- package/workspace/public/pm/primitives.js +8 -4
- package/workspace/public/pm/state.js +3 -0
- package/workspace/public/pm/tab-doc.js +175 -170
- package/workspace/public/pm/today-agenda.js +6 -1
- package/workspace/public/pm/today-day.js +6 -0
- package/workspace/public/pm/view-notes.js +113 -6
- package/workspace/public/vendor/lucide-sprite.js +15 -2
- package/workspace/replaced-tests.json +43 -0
- package/workspace/server.js +30 -0
- package/workspace/test-lists/workspace-tabs-declutter.json +13 -0
- package/workspace/third-party/README.md +1 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/* pm/doc-remote.js — the document's Confluence (or Google Docs) copy, in the document tab's bar (workspace-tabs-declutter).
|
|
2
|
+
*
|
|
3
|
+
* The PM asked for Confluence sync where the document is open, not only on the Docs page. Nothing new is decided here:
|
|
4
|
+
* the state comes from the same store the Docs page reads (`/api/docs`, lib/doc-sync-store.js), and every act is the
|
|
5
|
+
* Docs page's act on the same route: Use … version (held five seconds behind Undo, then one doc-sync session that
|
|
6
|
+
* writes one side with the PM's own connector), Compare (one doc-check session that only reads), and Add copy. Nothing
|
|
7
|
+
* syncs by itself.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { el } from './dom.js';
|
|
11
|
+
import { button, field, sheet, toast } from './primitives.js';
|
|
12
|
+
import { pmGet, pmPost } from './http.js';
|
|
13
|
+
import { setState } from './state.js';
|
|
14
|
+
|
|
15
|
+
const LABEL = { google: 'Google Docs', confluence: 'Confluence' };
|
|
16
|
+
const UNDO_MS = 5000;
|
|
17
|
+
const FRESH_MS = 30000;
|
|
18
|
+
|
|
19
|
+
const views = new Map(); // ws -> { at, state: 'loading' | 'ok' | 'error', data }
|
|
20
|
+
const pending = new Map(); // ws|path|kind -> { use, timer }
|
|
21
|
+
|
|
22
|
+
function read(ws, force) {
|
|
23
|
+
const cur = views.get(ws);
|
|
24
|
+
if (cur && !force && (cur.state === 'loading' || Date.now() - cur.at < FRESH_MS)) return cur;
|
|
25
|
+
const next = { at: Date.now(), state: cur && cur.data ? 'ok' : 'loading', data: cur ? cur.data : null };
|
|
26
|
+
views.set(ws, next);
|
|
27
|
+
pmGet('/api/docs?ws=' + encodeURIComponent(ws)).then((r) => {
|
|
28
|
+
views.set(ws, r && r.ok ? { at: Date.now(), state: 'ok', data: r } : { at: Date.now(), state: 'error', data: null, error: (r && r.error) || 'refused' });
|
|
29
|
+
setState({});
|
|
30
|
+
}, () => { views.set(ws, { at: Date.now(), state: 'error', data: null }); setState({}); });
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read the store again after something may have moved it (a save, a sync, a compare). */
|
|
35
|
+
export function refreshCopies(ws) { read(ws, true); }
|
|
36
|
+
|
|
37
|
+
const docOf = (ws, rel) => {
|
|
38
|
+
const v = views.get(ws);
|
|
39
|
+
return v && v.data && Array.isArray(v.data.docs) ? v.data.docs.find((d) => d.path === rel) || null : null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The copies of one file, Confluence first. Exported for the Connections rail. */
|
|
43
|
+
export function copiesOf(ws, rel) {
|
|
44
|
+
read(ws, false);
|
|
45
|
+
const d = docOf(ws, rel);
|
|
46
|
+
return d ? [...d.links].sort((a, b) => (a.kind === 'confluence' ? -1 : 0) - (b.kind === 'confluence' ? -1 : 0)) : [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One sentence for a copy's state, in the Docs page's words. */
|
|
50
|
+
export function wordsOf(link, ws, rel) {
|
|
51
|
+
const L = link.label || LABEL[link.kind] || link.kind;
|
|
52
|
+
const p = pending.get(ws + '|' + rel + '|' + link.kind);
|
|
53
|
+
const sy = link.syncing || (p ? { use: p.use } : null);
|
|
54
|
+
if (sy) return { tone: 'busy', text: sy.use === 'pmos' ? 'Updating ' + L + '…' : 'Updating PMOS from ' + L + '…' };
|
|
55
|
+
if (link.sync_failed) return { tone: 'bad', text: 'Sync failed' };
|
|
56
|
+
switch (link.signal) {
|
|
57
|
+
case 'same': return { tone: 'same', text: 'Same as ' + L };
|
|
58
|
+
case 'ahead': return { tone: 'warn', text: 'PMOS is newer' };
|
|
59
|
+
case 'behind': return { tone: 'warn', text: L + ' is newer' };
|
|
60
|
+
case 'diverged': return { tone: 'bad', text: 'Edited on both sides' };
|
|
61
|
+
case 'unchecked': return { tone: 'none', text: L + ': not compared yet' };
|
|
62
|
+
case 'unreadable': return { tone: 'bad', text: "Can't open in " + L };
|
|
63
|
+
case 'missing-remote': return { tone: 'bad', text: 'Not found in ' + L };
|
|
64
|
+
case 'missing-repo': return { tone: 'bad', text: 'Not in PMOS any more' };
|
|
65
|
+
default: return { tone: 'none', text: L };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function when(iso) {
|
|
70
|
+
const t = new Date(iso || '');
|
|
71
|
+
if (Number.isNaN(t.getTime())) return '';
|
|
72
|
+
const days = Math.round((new Date().setHours(0, 0, 0, 0) - new Date(t).setHours(0, 0, 0, 0)) / 86400000);
|
|
73
|
+
const hm = t.toTimeString().slice(0, 5);
|
|
74
|
+
if (days === 0) return 'today ' + hm;
|
|
75
|
+
if (days === 1) return 'yesterday ' + hm;
|
|
76
|
+
return t.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
77
|
+
}
|
|
78
|
+
const edit = (by, at) => [by, at ? when(at) : null].filter(Boolean).join(', ');
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The bar's control for one file: its first copy's state, or "add a copy". Null when the store cannot be read (the
|
|
82
|
+
* bar then shows nothing rather than a guess) or the file is not one the store can track.
|
|
83
|
+
*/
|
|
84
|
+
export function copyChip(ws, rel) {
|
|
85
|
+
if (!ws || !rel) return null;
|
|
86
|
+
const v = read(ws, false);
|
|
87
|
+
if (v.state === 'error' || (v.state === 'loading' && !v.data)) return null;
|
|
88
|
+
const links = copiesOf(ws, rel);
|
|
89
|
+
if (!links.length) {
|
|
90
|
+
return button({ label: 'add a copy', variant: 'ghost', size: 'sm', icon: 'cloud',
|
|
91
|
+
attrs: { class: 'fr-doc-bar__copy', 'data-doc': 'copy', 'data-tone': 'none', title: 'Track this file in Confluence or Google Docs' },
|
|
92
|
+
onClick: () => openCopy(ws, rel) });
|
|
93
|
+
}
|
|
94
|
+
const w = wordsOf(links[0], ws, rel);
|
|
95
|
+
return button({ label: w.text + (links.length > 1 ? ' +' + (links.length - 1) : ''), variant: 'outline', size: 'sm', icon: 'cloud',
|
|
96
|
+
attrs: { class: 'fr-doc-bar__copy', 'data-doc': 'copy', 'data-tone': w.tone, title: 'The copy of this file: which side is newer, and use either version' },
|
|
97
|
+
onClick: () => openCopy(ws, rel) });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The details and the acts, in a sheet. */
|
|
101
|
+
export function openCopy(ws, rel) {
|
|
102
|
+
read(ws, true);
|
|
103
|
+
const links = copiesOf(ws, rel);
|
|
104
|
+
const d = docOf(ws, rel);
|
|
105
|
+
let close = null;
|
|
106
|
+
const body = [];
|
|
107
|
+
if (!links.length) {
|
|
108
|
+
let target = '';
|
|
109
|
+
body.push(el('p', { class: 'fr-copy__p' }, 'This file has no copy yet. Paste the address of a Confluence page or a Google Doc. Nothing syncs by itself: a copy changes only when you press a button.'));
|
|
110
|
+
body.push(field({ label: 'Address of the copy', placeholder: 'https://…atlassian.net/wiki/…', attrs: { 'data-copy': 'target' },
|
|
111
|
+
onCommit: (val) => { target = val; } }));
|
|
112
|
+
const s = sheet({ title: 'Add a copy', sub: rel, body,
|
|
113
|
+
actions: [button({ label: 'add copy', variant: 'primary', attrs: { 'data-copy': 'add' }, onClick: async () => {
|
|
114
|
+
const input = document.querySelector('[data-copy="target"] input, input[data-copy="target"]');
|
|
115
|
+
const t = String((input && input.value) || target).trim();
|
|
116
|
+
const kind = /atlassian|confluence|\/wiki\/|pageId=/i.test(t) ? 'confluence' : (/google|docs\.|drive\./i.test(t) ? 'google' : null);
|
|
117
|
+
if (!kind) { toast({ text: 'Paste the address of a Confluence page or a Google Doc.', tone: 'error' }); return; }
|
|
118
|
+
const r = await pmPost('/api/docs/link', { ws, path: rel, kind, target: t });
|
|
119
|
+
if (!r || !r.ok) { toast({ text: (r && r.error) || 'The copy was not added.', tone: 'error' }); return; }
|
|
120
|
+
toast({ text: 'Copy added. Compare to see which side is newer.', timeout: 4000 });
|
|
121
|
+
refreshCopies(ws);
|
|
122
|
+
if (close) close();
|
|
123
|
+
} })] });
|
|
124
|
+
close = s && s.close;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const link of links) {
|
|
128
|
+
const L = link.label || LABEL[link.kind] || link.kind;
|
|
129
|
+
const w = wordsOf(link, ws, rel);
|
|
130
|
+
const busy = w.tone === 'busy';
|
|
131
|
+
const use = (u) => () => { useVersion(ws, rel, link, u); if (close) close(); };
|
|
132
|
+
body.push(el('section', { class: 'fr-copy', 'data-copy-kind': link.kind },
|
|
133
|
+
el('div', { class: 'fr-copy__state', 'data-tone': w.tone }, el('span', { class: 'fr-copy__dot' }), w.text,
|
|
134
|
+
link.basis === 'date' ? el('span', { class: 'fr-copy__by' }, ' (by date)') : null),
|
|
135
|
+
el('dl', { class: 'fr-copy__sides' },
|
|
136
|
+
el('dt', null, 'PMOS'), el('dd', null, el('span', { class: 'fr-copy__where' }, rel),
|
|
137
|
+
el('span', { class: 'fr-copy__edit' }, edit(d && d.repo && d.repo.modified_by, d && d.repo && d.repo.modified_at) || 'Last edit unknown')),
|
|
138
|
+
el('dt', null, L), el('dd', null,
|
|
139
|
+
link.url ? el('a', { class: 'fr-copy__where', href: link.url, target: '_blank', rel: 'noopener noreferrer' }, link.title || link.target) : el('span', { class: 'fr-copy__where' }, link.target),
|
|
140
|
+
el('span', { class: 'fr-copy__edit' }, edit(link.remote_modified_by, link.remote_modified_at) || 'Last edit: read at the next compare'))),
|
|
141
|
+
link.note && link.signal !== 'same' && link.signal !== 'unchecked' ? el('p', { class: 'fr-copy__diff', 'data-copy': 'diff' }, 'First difference: ' + link.note) : null,
|
|
142
|
+
link.sync_failed ? el('p', { class: 'fr-copy__diff' }, 'The last sync wrote nothing: ' + (link.sync_failed.reason || 'no reason given')) : null,
|
|
143
|
+
el('div', { class: 'fr-copy__acts' },
|
|
144
|
+
!busy && (link.signal === 'ahead' || link.signal === 'diverged')
|
|
145
|
+
? button({ label: 'use PMOS version', variant: link.signal === 'ahead' ? 'primary' : 'outline', size: 'sm', attrs: { 'data-copy': 'use-pmos' }, onClick: use('pmos') }) : null,
|
|
146
|
+
!busy && (link.signal === 'behind' || link.signal === 'diverged')
|
|
147
|
+
? button({ label: 'use ' + L + ' version', variant: link.signal === 'behind' ? 'primary' : 'outline', size: 'sm', attrs: { 'data-copy': 'use-copy' }, onClick: use('copy') }) : null)));
|
|
148
|
+
}
|
|
149
|
+
const v = views.get(ws);
|
|
150
|
+
const open = v && v.data && (v.data.open || v.data.session);
|
|
151
|
+
body.push(el('p', { class: 'fr-copy__p' }, 'A sync writes one side only, through your own connector, and never creates, moves or deletes a page. Unsaved edits here are not part of it: save first.'));
|
|
152
|
+
const s = sheet({ title: 'Copies of this file', sub: rel, body,
|
|
153
|
+
actions: [
|
|
154
|
+
button({ label: open ? 'comparing…' : 'compare again', variant: 'outline', icon: 'refresh-cw', disabled: !!open, attrs: { 'data-copy': 'compare' }, onClick: async () => {
|
|
155
|
+
const r = await pmPost('/api/docs/check', { ws });
|
|
156
|
+
toast(r && r.ok ? { text: 'Compare started. It reads every copy and changes nothing.', timeout: 4000 } : { text: (r && r.error) || 'The compare could not start.', tone: 'error' });
|
|
157
|
+
refreshCopies(ws);
|
|
158
|
+
if (close) close();
|
|
159
|
+
} }),
|
|
160
|
+
button({ label: 'open Docs', variant: 'ghost', attrs: { 'data-copy': 'docs' }, onClick: () => { import('./registry.js').then((m) => m.openDestination('docs')); if (close) close(); } }),
|
|
161
|
+
] });
|
|
162
|
+
close = s && s.close;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function useVersion(ws, rel, link, use) {
|
|
166
|
+
const key = ws + '|' + rel + '|' + link.kind;
|
|
167
|
+
if (pending.has(key)) return;
|
|
168
|
+
const L = link.label || LABEL[link.kind] || link.kind;
|
|
169
|
+
const p = { use, timer: null };
|
|
170
|
+
p.timer = setTimeout(async () => {
|
|
171
|
+
pending.delete(key);
|
|
172
|
+
const r = await pmPost('/api/docs/sync', { ws, path: rel, kind: link.kind, use });
|
|
173
|
+
if (!r || !r.ok) toast({ text: (r && r.error) || 'The sync could not start.', tone: 'error' });
|
|
174
|
+
refreshCopies(ws);
|
|
175
|
+
}, UNDO_MS);
|
|
176
|
+
pending.set(key, p);
|
|
177
|
+
toast({ text: use === 'pmos' ? 'Updating ' + L + ' with the PMOS version.' : 'Updating PMOS with the ' + L + ' version.', actionLabel: 'Undo', timeout: UNDO_MS,
|
|
178
|
+
onAction: () => { clearTimeout(p.timer); pending.delete(key); setState({}); toast({ text: 'Nothing was changed.', timeout: 3000 }); } });
|
|
179
|
+
setState({});
|
|
180
|
+
}
|
|
@@ -100,9 +100,15 @@ export function openSession(s) {
|
|
|
100
100
|
if (iniMod) iniMod.openTerm(null, { id: s.id, name: s.name, st: isEnded(s) ? 'exited' : 'working', profile: s.profile_id || null });
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
/* The row's second line says only what tells two sessions apart: its state when it is not plainly working, its type of
|
|
104
|
+
* work when one was named (General is every unnamed session, so it said nothing), and a branch off main. The strip's
|
|
105
|
+
* tooltip keeps the full line. */
|
|
106
|
+
function metaOf(s, trees, { brief = false } = {}) {
|
|
107
|
+
const parts = [];
|
|
105
108
|
if (isEnded(s)) parts.push(C().ended);
|
|
109
|
+
else if (brief && s.shown && s.shown !== 'working') parts.push(stateWord(s.shown));
|
|
110
|
+
const ty = typeLabel(s.work_type);
|
|
111
|
+
if (!brief || (s.work_type && s.work_type !== 'general')) parts.push(ty || C().types.general);
|
|
106
112
|
const t = trees.find((x) => x.ws === s.ws);
|
|
107
113
|
// the branch is named when the session is not on main, unless it is the initiative's own (the group already says that)
|
|
108
114
|
const own = t && t.initiative && t.initiative === initiativeOf(s, trees);
|
|
@@ -110,6 +116,18 @@ function metaOf(s, trees) {
|
|
|
110
116
|
return parts.join(' · ');
|
|
111
117
|
}
|
|
112
118
|
|
|
119
|
+
/* The state glyph and its word come from the one table the server also reads (lib/session-state.js). The shape carries
|
|
120
|
+
* the state; the colour is the token for its tone (components.css, by data-state), and the word is in the row's
|
|
121
|
+
* accessible name. An ended session is the hollow circle. */
|
|
122
|
+
const SS = () => (typeof window !== 'undefined' && window.PMOSSessionState) || null;
|
|
123
|
+
const stateOf = (s) => (isEnded(s) ? 'exited' : (s.shown || 'working'));
|
|
124
|
+
function stateWord(st) { const t = SS(); return (t && t.WORD && t.WORD[st]) || st; }
|
|
125
|
+
function stateGlyph(s) {
|
|
126
|
+
const st = stateOf(s);
|
|
127
|
+
const t = SS();
|
|
128
|
+
return el('span', { class: 'fr-ds-glyph', 'data-state': st, 'aria-hidden': 'true' }, (t && t.GLYPH && t.GLYPH[st]) || '');
|
|
129
|
+
}
|
|
130
|
+
|
|
113
131
|
/* ── ending a session ─────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
114
132
|
|
|
115
133
|
async function endSession(repo, s) {
|
|
@@ -300,21 +318,42 @@ export function openNewSessionSheet(repo, preset) {
|
|
|
300
318
|
|
|
301
319
|
function sessionRow(repo, s, trees, front) {
|
|
302
320
|
if (state.dsEnding === s.id) return confirmNode(repo, s, 'fr-ds-confirm');
|
|
321
|
+
const meta = metaOf(s, trees, { brief: true });
|
|
303
322
|
return row({
|
|
304
|
-
title: s.name, meta: [
|
|
323
|
+
title: s.name, meta: meta ? [meta] : [], lead: [stateGlyph(s)], compact: true, onOpen: () => openSession(s),
|
|
324
|
+
openLabel: C().open(s.name) + ' · ' + stateWord(stateOf(s)),
|
|
305
325
|
trail: [
|
|
306
|
-
iconButton({ icon: 'ellipsis', ariaLabel: C().more(s.name), onClick: () => openMetaSheet(repo, s), attrs: { 'data-ds': 'more' } }),
|
|
307
|
-
iconButton({ icon: 'x', ariaLabel: C().end(s.name), onClick: () => setState({ dsEnding: s.id }), attrs: { 'data-ds': 'end' } }),
|
|
326
|
+
iconButton({ icon: 'ellipsis', ariaLabel: C().more(s.name), size: 'sm', onClick: () => openMetaSheet(repo, s), attrs: { 'data-ds': 'more', class: 'fr-ds-act' } }),
|
|
327
|
+
iconButton({ icon: 'x', ariaLabel: C().end(s.name), size: 'sm', onClick: () => setState({ dsEnding: s.id }), attrs: { 'data-ds': 'end', class: 'fr-ds-act' } }),
|
|
308
328
|
],
|
|
309
|
-
attrs: { class: 'fr-ds-row', 'data-ds-session': s.id, 'data-front': front ? 'true' : 'false' },
|
|
329
|
+
attrs: { class: 'fr-ds-row' + (isEnded(s) ? ' fr-ds-row--ended' : ''), 'data-ds-session': s.id, 'data-front': front ? 'true' : 'false' },
|
|
310
330
|
});
|
|
311
331
|
}
|
|
312
332
|
|
|
313
|
-
|
|
333
|
+
/* ── folding a group ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
334
|
+
* A group's head is a disclosure: its name and how many sessions it holds. The PM's choice is kept per group key (with
|
|
335
|
+
* the layout, so it survives a reload). With no choice made, a group is open, except Scheduled while none of its runs is
|
|
336
|
+
* live: a schedule leaves one ended session per run, and those rows filled the list above the sessions in use. */
|
|
337
|
+
export function groupFolded(g) {
|
|
338
|
+
const m = state.dsFolded || {};
|
|
339
|
+
if (Object.prototype.hasOwnProperty.call(m, g.key)) return !!m[g.key];
|
|
340
|
+
return !!g.scheduled && g.sessions.every(isEnded);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function groupHead(repo, g, folded) {
|
|
344
|
+
const live = g.sessions.filter((s) => !isEnded(s)).length;
|
|
345
|
+
const count = live && live !== g.sessions.length ? live + ' of ' + g.sessions.length : String(g.sessions.length);
|
|
346
|
+
const toggle = button({
|
|
347
|
+
label: g.title, variant: 'ghost', size: 'sm', icon: folded ? 'chevron-right' : 'chevron-down',
|
|
348
|
+
onClick: () => setState((st) => ({ dsFolded: { ...(st.dsFolded || {}), [g.key]: !folded } })),
|
|
349
|
+
attrs: { class: 'fr-ds-head__toggle', 'data-ds': 'fold', 'aria-expanded': folded ? 'false' : 'true',
|
|
350
|
+
'aria-label': C().foldLabel(g.title, g.sessions.length, folded), title: g.initiative || g.title },
|
|
351
|
+
});
|
|
314
352
|
return el('div', { class: 'fr-ds-head', 'data-ds-group': g.key },
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
353
|
+
toggle,
|
|
354
|
+
el('span', { class: 'fr-ds-head__count', 'aria-hidden': 'true' }, count),
|
|
355
|
+
g.scheduled ? '' : iconButton({ icon: 'plus', ariaLabel: g.initiative ? C().newFor(g.title) : C().newTitle, size: 'sm',
|
|
356
|
+
onClick: () => openNewSessionSheet(repo, { initiative: g.initiative }), attrs: { 'data-ds': 'group-new', class: 'fr-ds-head__new' } }));
|
|
318
357
|
}
|
|
319
358
|
|
|
320
359
|
/** The side list's nodes: the session groups, then "Open" with the caller's rows for the tabs that are not sessions. */
|
|
@@ -324,12 +363,17 @@ export function railNodes(repo, otherRows) {
|
|
|
324
363
|
const out = [];
|
|
325
364
|
const groups = groupsOf(repo);
|
|
326
365
|
for (const g of groups) {
|
|
327
|
-
|
|
328
|
-
|
|
366
|
+
// the session in front is never folded away: its group opens to show it
|
|
367
|
+
const folded = groupFolded(g) && !g.sessions.some((s) => s.id === front);
|
|
368
|
+
// live sessions first, ended ones after them, each in the server's order
|
|
369
|
+
const ordered = [...g.sessions.filter((s) => !isEnded(s)), ...g.sessions.filter(isEnded)];
|
|
370
|
+
out.push(el('section', { class: 'fr-ds-group', 'data-ds-section': g.key, 'data-folded': folded ? 'true' : 'false' }, groupHead(repo, g, folded),
|
|
371
|
+
...(folded ? [] : ordered.map((s) => sessionRow(repo, s, trees, s.id === front)))));
|
|
329
372
|
}
|
|
330
373
|
if (otherRows && otherRows.length) {
|
|
331
374
|
out.push(el('section', { class: 'fr-ds-group', 'data-ds-section': 'open' },
|
|
332
|
-
el('div', { class: 'fr-ds-head' }, el('h3', { class: 'fr-ds-head__title' }, C().openTabs)
|
|
375
|
+
el('div', { class: 'fr-ds-head fr-ds-head--plain' }, el('h3', { class: 'fr-ds-head__title' }, C().openTabs),
|
|
376
|
+
el('span', { class: 'fr-ds-head__count', 'aria-hidden': 'true' }, String(otherRows.length))), ...otherRows));
|
|
333
377
|
}
|
|
334
378
|
if (!groups.length && !(otherRows && otherRows.length)) out.push(el('p', { class: 'fr-ds-note' }, C().none));
|
|
335
379
|
return out;
|
|
@@ -61,6 +61,7 @@ export const COPY = {
|
|
|
61
61
|
sideTabs: 'Show the sessions as a list at the side',
|
|
62
62
|
topTabs: 'Show the sessions as a strip on top',
|
|
63
63
|
// 0.55.1 — the dock's session list (dock-sessions.js)
|
|
64
|
+
openTab: (title, what) => 'Open ' + title + ' · ' + what,
|
|
64
65
|
ds: {
|
|
65
66
|
notLinked: 'Not linked',
|
|
66
67
|
scheduled: 'Scheduled',
|
|
@@ -68,6 +69,7 @@ export const COPY = {
|
|
|
68
69
|
none: 'No sessions yet. Press Session to start one.',
|
|
69
70
|
group: 'Show the sessions of',
|
|
70
71
|
ended: 'ended',
|
|
72
|
+
foldLabel: (t, n, folded) => (folded ? 'Show ' : 'Hide ') + t + ', ' + n + ' session' + (n === 1 ? '' : 's'),
|
|
71
73
|
open: (n) => 'Open ' + n,
|
|
72
74
|
more: (n) => 'Name, initiative and type of work of ' + n,
|
|
73
75
|
end: (n) => 'End ' + n,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* pm/icons.js — the lucide icons the Breakdown uses (D86 point 6).
|
|
2
2
|
*
|
|
3
|
-
* The
|
|
3
|
+
* The 48 symbols live in one vendored sprite, /vendor/lucide-sprite.js, generated from lucide-static 1.52.0 (see
|
|
4
4
|
* workspace/third-party/README.md). `ensureSprite()` loads it once and puts it in the overlay layer; `icon(name)` returns
|
|
5
5
|
* an <svg> that points at one symbol. Icons are decoration only: every one is aria-hidden, and a control that shows only
|
|
6
6
|
* an icon carries its own aria-label (primitives.js `iconButton` refuses to build one without).
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* the graph is doc-graph.js, initiative docs-library-graph), History,
|
|
8
8
|
* Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira),
|
|
9
9
|
* People (people-link.js, initiative people-directory: who owns its items or is named in interactions about it)
|
|
10
|
+
* Notes (notes-links.js, initiative meeting-notes Run 4: the meeting notes linked to it, each with its summary)
|
|
10
11
|
* ⋯ horizon, check-by, lifecycle and the anchor (both PM-only: the server's guard answers), Copy CLI, Export breakdown
|
|
11
12
|
* Nothing on this sheet says "plan". Everything it shows is read from GET /api/initiatives/<id>; every write goes through
|
|
12
13
|
* initiatives-api.js. The Breakdown tab mounts with a tab id of the form `initiative:<slug>`, so it never meets a panel-4
|
|
@@ -22,6 +23,7 @@ import { icon, ensureSprite } from './icons.js';
|
|
|
22
23
|
import { mountBreakdown, unmountBreakdown } from './panel-breakdown.js';
|
|
23
24
|
import { mountJira, unmountJira } from './sheet-jira.js';
|
|
24
25
|
import { initiativePeople } from './people-link.js';
|
|
26
|
+
import { notesOfInitiative, noteRows, onNotes, refreshNotes } from './notes-links.js';
|
|
25
27
|
import { openDoc } from './panel-initiatives.js';
|
|
26
28
|
import { getLibrary, initiativeGraph } from './doc-graph.js';
|
|
27
29
|
import { openNewSession } from './panel-newsess.js';
|
|
@@ -83,7 +85,7 @@ export function openSheet(repo, slug) {
|
|
|
83
85
|
const s = sheet({
|
|
84
86
|
mode: 'auto', title: slug, sub: '', body: [crumb, stepperHost, waitingHost, tabsHost, panel],
|
|
85
87
|
attrs: { class: 'in-sheet', 'data-in-sheet': 'initiative', 'data-in-id': slug },
|
|
86
|
-
onClose: () => { closed = true; unmountBreakdown(bdHost); unmountJira(panel); if (current === s) current = null; },
|
|
88
|
+
onClose: () => { closed = true; unmountBreakdown(bdHost); unmountJira(panel); offNotes(); if (current === s) current = null; },
|
|
87
89
|
});
|
|
88
90
|
current = s;
|
|
89
91
|
|
|
@@ -244,10 +246,14 @@ export function openSheet(repo, slug) {
|
|
|
244
246
|
);
|
|
245
247
|
}
|
|
246
248
|
|
|
249
|
+
const notesHere = () => notesOfInitiative(slug).filter((n) => !n.initiative.ws || !ws || n.initiative.ws === ws);
|
|
250
|
+
const offNotes = onNotes(() => { if (!closed && tab === 'notes') drawPanel(); });
|
|
251
|
+
refreshNotes();
|
|
252
|
+
|
|
247
253
|
function drawTabs() {
|
|
248
254
|
tabsHost.replaceChildren(tabs({
|
|
249
255
|
variant: 'segmented', full: true, ariaLabel: COPY.sheet.tabs, value: tab,
|
|
250
|
-
items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }, { id: 'jira', label: COPY.sheet.jira }, { id: 'people', label: COPY.sheet.people }],
|
|
256
|
+
items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }, { id: 'jira', label: COPY.sheet.jira }, { id: 'people', label: COPY.sheet.people }, { id: 'notes', label: COPY.sheet.notes }],
|
|
251
257
|
onChange: (id) => {
|
|
252
258
|
if (id === tab) return;
|
|
253
259
|
if (tab === 'breakdown') unmountBreakdown(bdHost);
|
|
@@ -270,6 +276,8 @@ export function openSheet(repo, slug) {
|
|
|
270
276
|
}
|
|
271
277
|
if (tab === 'jira') { mountJira(panel, { ws, slug }); return; }
|
|
272
278
|
if (tab === 'people') { panel.replaceChildren(initiativePeople(ws, slug, { onOpen: () => s.close() })); return; }
|
|
279
|
+
// meeting-notes Run 4: the meeting notes linked to this initiative, newest first, each with its summary
|
|
280
|
+
if (tab === 'notes') { panel.replaceChildren(el('div', { 'data-in-notes': '' }, noteRows(notesHere(), { onOpen: () => s.close() }))); return; }
|
|
273
281
|
if (tab === 'files') {
|
|
274
282
|
const files = data.files || [];
|
|
275
283
|
// Grouped by where the tie came from (scan-consistency R3, scan-documents S3): the record's own paths, the documents
|
|
@@ -135,6 +135,7 @@ export const COPY = {
|
|
|
135
135
|
history: 'History',
|
|
136
136
|
jira: 'Jira tickets',
|
|
137
137
|
people: 'People',
|
|
138
|
+
notes: 'Notes',
|
|
138
139
|
waitingYou: (act) => `Waiting on you: ${act}`,
|
|
139
140
|
waitingAgent: (act) => `Waiting on agent: ${act}`,
|
|
140
141
|
nothingOwed: 'Nothing is owed.',
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/* pm/notes-links.js — a meeting note where its meeting and its initiative are shown (meeting-notes Run 4).
|
|
2
|
+
*
|
|
3
|
+
* notesFor(e) → the note rows linked to a calendar event (same id, or same start minute and title)
|
|
4
|
+
* notesOfInitiative(id) → the note rows linked to an initiative, newest first
|
|
5
|
+
* notesBlock(e, { ws, repo, initiative }) → the "Meeting notes" block of an event's detail: the summary and Open notes,
|
|
6
|
+
* or Take notes; Take notes on an event that already has a note opens that note
|
|
7
|
+
* noteRows(rows, { onOpen }) → the rows of an initiative's Notes tab; onOpen runs before the note opens
|
|
8
|
+
* openNote(id) → opens the Notes tab on that note
|
|
9
|
+
* onNotes(fn) · refreshNotes() → redraw when the list is read
|
|
10
|
+
*
|
|
11
|
+
* One read feeds it: GET /api/notes (a row carries the meeting, the initiative and up to three summary lines, never the body;
|
|
12
|
+
* a private note carries its title only). Take notes writes POST /api/notes/new { meeting, initiative }.
|
|
13
|
+
*/
|
|
14
|
+
import { el } from './dom.js';
|
|
15
|
+
import { button } from './primitives.js';
|
|
16
|
+
import { pmGet, pmPost } from './http.js';
|
|
17
|
+
|
|
18
|
+
const READ_MS = 15000; // the list is read again at most this often while a calendar or an initiative is drawn
|
|
19
|
+
let rows = null; let readAt = 0; let reading = null; let failed = false;
|
|
20
|
+
const subs = new Set();
|
|
21
|
+
|
|
22
|
+
export function onNotes(fn) { subs.add(fn); return () => subs.delete(fn); }
|
|
23
|
+
export function refreshNotes() {
|
|
24
|
+
if (reading) return reading;
|
|
25
|
+
reading = pmGet('/api/notes').then((r) => {
|
|
26
|
+
reading = null; readAt = Date.now();
|
|
27
|
+
if (r && r.ok) { const sig = JSON.stringify(r.notes); const was = JSON.stringify(rows); rows = r.notes; failed = false; if (sig !== was) for (const f of subs) f(); }
|
|
28
|
+
else failed = true;
|
|
29
|
+
});
|
|
30
|
+
return reading;
|
|
31
|
+
}
|
|
32
|
+
function list() {
|
|
33
|
+
if (!reading && Date.now() - readAt > READ_MS) refreshNotes();
|
|
34
|
+
return rows || [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const minuteOf = (s) => { const t = Date.parse(s); return Number.isFinite(t) ? Math.floor(t / 60000) : null; };
|
|
38
|
+
const normTitle = (t) => String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
39
|
+
/** Whether a note's meeting is this event. The same rule as lib/notes.js sameEvent. Pure, for the suite. */
|
|
40
|
+
export function sameEvent(meeting, e) {
|
|
41
|
+
if (!meeting || meeting.kind !== 'event' || !e) return false;
|
|
42
|
+
if (meeting.event_id && e.id && meeting.event_id === e.id) return true;
|
|
43
|
+
const a = minuteOf(meeting.start); const b = minuteOf(e.start);
|
|
44
|
+
return a !== null && a === b && normTitle(meeting.title) === normTitle(e.title);
|
|
45
|
+
}
|
|
46
|
+
export const notesFor = (e) => list().filter((n) => sameEvent(n.meeting, e));
|
|
47
|
+
export const notesOfInitiative = (id) => list().filter((n) => n.initiative && n.initiative.id === id);
|
|
48
|
+
export const notesKnown = () => rows !== null || failed;
|
|
49
|
+
|
|
50
|
+
/** The meeting link a calendar event gives a note. */
|
|
51
|
+
export function meetingOf(e) {
|
|
52
|
+
return { kind: 'event', event_id: /^[A-Za-z0-9_-]{1,40}$/.test(String(e.id || '')) ? String(e.id) : null, src: e.src === 'memory' ? 'memory' : 'brief',
|
|
53
|
+
start: e.start, end: e.end || null, title: e.title };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function openNote(id) {
|
|
57
|
+
const [v, c] = await Promise.all([import('./view-notes.js'), import('./panel-cockpit.js')]);
|
|
58
|
+
v.openNote(id);
|
|
59
|
+
c.openView('notes');
|
|
60
|
+
}
|
|
61
|
+
async function takeNotes(e, { ws, repo, initiative } = {}, btn) {
|
|
62
|
+
if (btn) btn.disabled = true;
|
|
63
|
+
const ini = initiative && initiative.id ? { id: initiative.id, ws: ws || null, repo: repo || null, title: initiative.title || null } : undefined;
|
|
64
|
+
const r = await pmPost('/api/notes/new', { meeting: meetingOf(e), ...(ini ? { initiative: ini } : {}) });
|
|
65
|
+
if (btn) btn.disabled = false;
|
|
66
|
+
if (!r || !r.ok) { const { toast } = await import('./primitives.js'); toast({ text: (r && r.error) || 'The note was not made.', tone: 'destructive' }); return; }
|
|
67
|
+
refreshNotes();
|
|
68
|
+
openNote(r.note.id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const dayOf = (iso) => { try { return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); } catch { return ''; } };
|
|
72
|
+
const clock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
|
|
73
|
+
|
|
74
|
+
/** The Meeting notes block of an event's detail. */
|
|
75
|
+
export function notesBlock(e, opts = {}) {
|
|
76
|
+
const mine = notesFor(e);
|
|
77
|
+
const ended = !!e.ended;
|
|
78
|
+
if (mine.length) {
|
|
79
|
+
const n = mine[0];
|
|
80
|
+
const lines = n.summary_lines || [];
|
|
81
|
+
return el('div', { class: 'td-day__notes', 'data-notes-block': 'linked', 'data-notes-id': n.id },
|
|
82
|
+
el('h4', null, 'Meeting notes'),
|
|
83
|
+
n.private ? el('p', { class: 'td-day__note' }, 'This note is private.')
|
|
84
|
+
: lines.length ? el('ul', { class: 'nt-lsum' }, ...lines.map((l) => el('li', null, l)))
|
|
85
|
+
: el('p', { class: 'td-day__note' }, n.segments ? 'No summary yet. Open the note and press Summarize.' : 'No summary yet.'),
|
|
86
|
+
el('div', { class: 'td-day__acts' }, button({ label: 'Open notes', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => openNote(n.id) })));
|
|
87
|
+
}
|
|
88
|
+
if (!notesKnown()) return null;
|
|
89
|
+
const take = button({ label: 'Take notes', variant: ended ? undefined : 'primary', size: 'sm', attrs: { 'data-notes-take': e.id || '' }, onClick: () => takeNotes(e, opts, take) });
|
|
90
|
+
return el('div', { class: 'td-day__notes', 'data-notes-block': 'none' },
|
|
91
|
+
el('h4', null, 'Meeting notes'),
|
|
92
|
+
el('p', { class: 'td-day__note' }, ended ? 'No notes were taken in PMOS for this meeting.' : 'Take notes here when the meeting starts. The note is linked to this event' + (opts.initiative ? ' and its initiative.' : '.')),
|
|
93
|
+
el('div', { class: 'td-day__acts' }, take));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The rows of an initiative's Notes tab. */
|
|
97
|
+
export function noteRows(found, { onOpen } = {}) {
|
|
98
|
+
if (!found.length) return el('p', { class: 'td-day__note', 'data-notes-empty': '' }, 'No meeting notes are linked to this initiative yet. Link one from the note\'s Initiative line.');
|
|
99
|
+
const sorted = found.slice().sort((a, b) => String((b.meeting && b.meeting.start) || b.created_at).localeCompare(String((a.meeting && a.meeting.start) || a.created_at)));
|
|
100
|
+
return el('div', { class: 'nt-lrows' }, ...sorted.map((n) => {
|
|
101
|
+
const at = n.meeting && n.meeting.kind === 'event' ? n.meeting.start : n.created_at;
|
|
102
|
+
const where = n.meeting && n.meeting.kind === 'event' ? 'calendar' : 'not on the calendar';
|
|
103
|
+
const lines = n.summary_lines || [];
|
|
104
|
+
return el('div', { class: 'nt-lrow', 'data-notes-row': n.id },
|
|
105
|
+
el('div', { class: 'nt-lrow__main' }, el('b', null, n.title || 'Untitled note'),
|
|
106
|
+
el('div', { class: 'nt-lrow__meta' }, dayOf(at) + ', ' + clock(at) + ' · ' + where + (n.private ? ' · private' : '')),
|
|
107
|
+
lines.length ? el('ul', { class: 'nt-lsum' }, ...lines.map((l) => el('li', null, l))) : el('p', { class: 'td-day__note' }, 'No summary yet.')),
|
|
108
|
+
button({ label: 'Open', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => { if (onOpen) onOpen(); openNote(n.id); } }));
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
@@ -35,7 +35,7 @@ import { mountDoc, disposeDoc, docText } from './tab-doc.js';
|
|
|
35
35
|
import { mountWeb, promptWeb } from './tab-web.js';
|
|
36
36
|
import { newSessionBlock, openNewSession } from './panel-newsess.js';
|
|
37
37
|
import { boardOf, currentProduct } from './board-read.js';
|
|
38
|
-
import { field, button, iconButton, toast, ensureComponentsCss } from './primitives.js';
|
|
38
|
+
import { field, button, iconButton, row, toast, ensureComponentsCss, SEL } from './primitives.js';
|
|
39
39
|
import { COPY } from './frame-copy.js';
|
|
40
40
|
import { COPY as PAL } from './palette-copy.js';
|
|
41
41
|
import { askChoice } from './dialogs.js';
|
|
@@ -673,6 +673,36 @@ const scratchBlock = el('div', {
|
|
|
673
673
|
scratchSendBtn, scratchCopyBtn, scratchClearBtn, scratchNote),
|
|
674
674
|
);
|
|
675
675
|
|
|
676
|
+
/* ── the scratchpad in the frame (workspace-tabs-declutter) ─────────────────────────────────────────────────────────
|
|
677
|
+
* One header: the bottom bar's toggle names the pad and opens and closes it, so the drawer draws no label row and no ▾
|
|
678
|
+
* of its own. Its three actions are primitives; Send is the one primary while a terminal is in front, and the note says
|
|
679
|
+
* once what keeps the pad. The textarea stays a direct child of the drawer (the height clamp measures through it). */
|
|
680
|
+
const padSendF = button({ label: 'send to terminal', variant: 'primary', size: 'sm', onClick: () => sendScratchToTerminal(), attrs: { 'data-scratch': 'send' } });
|
|
681
|
+
const padCopyF = button({ label: 'copy', variant: 'ghost', size: 'sm', attrs: { 'data-scratch': 'copy' }, onClick: () => {
|
|
682
|
+
copyToClipboard(state.scratchText);
|
|
683
|
+
setState({ scratchCopied: true });
|
|
684
|
+
setTimeout(() => setState({ scratchCopied: false }), 1200);
|
|
685
|
+
} });
|
|
686
|
+
const padClearF = button({ label: 'clear', variant: 'ghost', size: 'sm', onClick: () => { if (state.scratchText) onScratchInput(''); }, attrs: { 'data-scratch': 'clear' } });
|
|
687
|
+
const padNoteF = el('span', { class: 'fr-pad__note', 'data-scratch': 'note' });
|
|
688
|
+
const padRowF = el('div', { class: 'fr-pad__row' }, padSendF, padCopyF, padClearF, padNoteF);
|
|
689
|
+
const padToggleF = button({ label: 'scratchpad', variant: 'ghost', size: 'sm', icon: 'chevron-down',
|
|
690
|
+
onClick: () => setState((s) => ({ scratchOpen: !s.scratchOpen })), attrs: { 'data-scratch': 'toggle', class: 'fr-pad__toggle' } });
|
|
691
|
+
const bottomCopyF = button({ label: 'copy', variant: 'ghost', size: 'sm', onClick: () => copyActive(), attrs: { 'data-tabs': 'copy' } });
|
|
692
|
+
|
|
693
|
+
function paintFramePad(act) {
|
|
694
|
+
const canSend = !!(act && act.kind === 'term');
|
|
695
|
+
const text = state.scratchText || '';
|
|
696
|
+
padSendF.disabled = !canSend || !text.trim();
|
|
697
|
+
padSendF.title = canSend ? 'Type the pad into this session and press Enter' : 'Open a terminal to send the pad to it';
|
|
698
|
+
padClearF.disabled = !text;
|
|
699
|
+
padCopyF.disabled = !text;
|
|
700
|
+
padCopyF.querySelector(SEL.btnLabel).textContent = state.scratchCopied ? 'copied' : 'copy';
|
|
701
|
+
padNoteF.textContent = canSend ? 'Kept for this session.' : 'Kept for this repository.';
|
|
702
|
+
setChildren(scratchBlock, [scratchGrip, scratchTa, padRowF]);
|
|
703
|
+
scratchBlock.setAttribute('data-frame', '');
|
|
704
|
+
}
|
|
705
|
+
|
|
676
706
|
/**
|
|
677
707
|
* Paint the scratchpad drawer and the bottom bar's scratchpad toggle for the active tab `act`.
|
|
678
708
|
* renderTabs calls it on one line, so the scratchpad (3C) and the tab strip (3A) are separate hunks.
|
|
@@ -705,6 +735,7 @@ function renderScratch(act) {
|
|
|
705
735
|
scratchNote.textContent = FIXTURE ? 'Kept until the page closes.'
|
|
706
736
|
: (canSend ? 'Kept for this session. Send also presses Enter.' : 'Kept for this repository. Open a terminal to send.');
|
|
707
737
|
scratchToggleLabel.textContent = state.scratchOpen ? '▼ scratchpad' : '▲ scratchpad';
|
|
738
|
+
if (!FIXTURE && inFrame() && !isMobile()) paintFramePad(act);
|
|
708
739
|
if (!FIXTURE) paintBottom(act);
|
|
709
740
|
}
|
|
710
741
|
|
|
@@ -760,6 +791,21 @@ function paintBottom(act) {
|
|
|
760
791
|
if (composeInput.control.value !== text) composeInput.control.value = text;
|
|
761
792
|
composeSend.disabled = !text.trim();
|
|
762
793
|
}
|
|
794
|
+
if (!compose && inFrame() && !isMobile()) {
|
|
795
|
+
/* The frame's bar: the scratchpad toggle (its chevron points the way the drawer will move), the tab in front, and a
|
|
796
|
+
* copy that says what it copies. */
|
|
797
|
+
const open = !!state.scratchOpen;
|
|
798
|
+
padToggleF.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
799
|
+
const ic = padToggleF.querySelector('[data-icon]');
|
|
800
|
+
const want = open ? 'chevron-down' : 'chevron-right';
|
|
801
|
+
if (ic && ic.getAttribute('data-icon') !== want) ic.replaceWith(icon(want));
|
|
802
|
+
const what = act && act.kind === 'doc' ? 'copy document' : (act && act.kind === 'term' ? 'copy attach command' : null);
|
|
803
|
+
if (what) bottomCopyF.querySelector(SEL.btnLabel).textContent = state.copied ? 'copied' : what;
|
|
804
|
+
bottomTitle.textContent = act ? act.title : '';
|
|
805
|
+
setChildren(bottomBar, [padToggleF, bottomTitle, what ? bottomCopyF : null]);
|
|
806
|
+
bottomBar.hidden = false;
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
763
809
|
setChildren(bottomBar, compose ? [composeLine] : (act ? [bottomToggle, bottomTitle, bottomCopy] : [bottomToggle, bottomTitle]));
|
|
764
810
|
// A phone draws no scratchpad drawer: its pad is the compose line of a terminal. With no terminal in front the bar would
|
|
765
811
|
// offer a toggle that opens nothing, so on a phone it is shown for the compose line only.
|
|
@@ -1221,6 +1267,42 @@ function cockpitTabRow(t, front) {
|
|
|
1221
1267
|
);
|
|
1222
1268
|
}
|
|
1223
1269
|
|
|
1270
|
+
/* ── a tab in the dock's side list (the frame, live mode) ─────────────────────────────────────────────────────────────
|
|
1271
|
+
* The tabs that are not sessions (documents, views, web pages) sit under "Open" in the same row as a session: the
|
|
1272
|
+
* design system's row, its title in the body face, a second line that says what it is (the document's role, or the kind
|
|
1273
|
+
* of tab) and its line of work when that is not main, and one close control. The server's session id and the initiative
|
|
1274
|
+
* slug stay in the tooltip; on screen they told two rows apart no better than the title did. */
|
|
1275
|
+
const KIND_WORD = { term: 'Terminal', view: 'View', web: 'Web page', skill: 'Skill', transcript: 'Conversation', breakdown: 'Breakdown', dest: 'Page' };
|
|
1276
|
+
function dockTabRow(t, front) {
|
|
1277
|
+
const br = tabBranch(t);
|
|
1278
|
+
const what = t.kind === 'doc' ? (t.role || 'Document') : (KIND_WORD[t.kind] || t.kind);
|
|
1279
|
+
const r = row({
|
|
1280
|
+
title: t.title, meta: [what, br ? '⑂ ' + br : null], compact: true, onOpen: () => setState({ active: t.id }),
|
|
1281
|
+
openLabel: COPY.dock.openTab(t.title, what),
|
|
1282
|
+
trail: [iconButton({ icon: 'x', ariaLabel: 'close ' + t.title, size: 'sm', onClick: () => closeTab(t.id), attrs: { 'data-tab-close': '', class: 'fr-ds-act' } })],
|
|
1283
|
+
attrs: { class: 'fr-ds-row fr-ds-tab', 'data-tab': t.id, 'data-tab-active': front ? '1' : '0', 'data-tab-row': '', 'data-front': front ? 'true' : 'false',
|
|
1284
|
+
'data-kind': t.kind, title: t.title + (t.slug ? '\n' + t.slug : '') },
|
|
1285
|
+
});
|
|
1286
|
+
const meta = r.querySelector(SEL.rowMeta);
|
|
1287
|
+
if (meta) {
|
|
1288
|
+
meta.setAttribute('data-tab-sub', '');
|
|
1289
|
+
// the branch keeps its hook for the suites (R2.2): it is the meta line's last part
|
|
1290
|
+
if (br && meta.lastElementChild) meta.lastElementChild.setAttribute('data-r1', 'tab-branch');
|
|
1291
|
+
}
|
|
1292
|
+
return r;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/* …and the same tab in the strip on top: a chip like a session's, with its own close control. The second line moves to the
|
|
1296
|
+
* tooltip; the chip keeps the strip's floor and the 96px floor a crowded strip shrinks to. */
|
|
1297
|
+
function dockTabChip(t, front) {
|
|
1298
|
+
const br = tabBranch(t);
|
|
1299
|
+
const what = t.kind === 'doc' ? (t.role || 'Document') : (KIND_WORD[t.kind] || t.kind);
|
|
1300
|
+
return el('div', { class: 'fr-ds-tabchip', 'data-tab': t.id, 'data-tab-active': front ? '1' : '0', 'data-kind': t.kind },
|
|
1301
|
+
button({ label: t.title, variant: front ? 'outline' : 'ghost', size: 'sm', onClick: () => setState({ active: t.id }),
|
|
1302
|
+
attrs: { class: 'fr-ds-chip', 'aria-pressed': front ? 'true' : 'false', title: t.title + ' · ' + what + (br ? ' · ⑂ ' + br : '') } }),
|
|
1303
|
+
iconButton({ icon: 'x', ariaLabel: 'close ' + t.title, size: 'sm', onClick: () => closeTab(t.id), attrs: { 'data-tab-close': '' } }));
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1224
1306
|
function tabNode(t, act, splitTab) {
|
|
1225
1307
|
const selected = (act && act.id === t.id) || (splitTab && splitTab.id === t.id);
|
|
1226
1308
|
const bg = selected ? FX.TAB_COLORS.activeBg : 'transparent';
|
|
@@ -1349,10 +1431,10 @@ export function renderTabs(host) {
|
|
|
1349
1431
|
const others = notListed(dsMod.listedSessionIds(ds));
|
|
1350
1432
|
setChildren(tabBar, vertical ? [
|
|
1351
1433
|
fr.session,
|
|
1352
|
-
...dsMod.railNodes(ds, others.map((t) =>
|
|
1434
|
+
...dsMod.railNodes(ds, others.map((t) => dockTabRow(t, (act && act.id === t.id) || (splitTab && splitTab.id === t.id)))),
|
|
1353
1435
|
el('div', { 'data-tabs': 'vcontrols', class: 'fr-ds-foot' }, state.splitId ? splitBtn : null, fr.orient, fr.focus, fr.fold),
|
|
1354
1436
|
] : [
|
|
1355
|
-
...dsMod.stripNodes(ds, others.map((t) =>
|
|
1437
|
+
...dsMod.stripNodes(ds, others.map((t) => dockTabChip(t, (act && act.id === t.id) || (splitTab && splitTab.id === t.id)))),
|
|
1356
1438
|
state.splitId ? splitBtn : null, fr.orient, fr.focus, fr.fold,
|
|
1357
1439
|
]);
|
|
1358
1440
|
}
|