create-pmos 0.67.0 → 0.68.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/layout.md +36 -9
- package/workspace/lib/sync.js +32 -4
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +610 -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/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/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).
|
|
@@ -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
|
}
|
|
@@ -25,6 +25,7 @@ export const SEL = Object.freeze({
|
|
|
25
25
|
sprite: '.pmc-sprite',
|
|
26
26
|
icon: 'svg.pmc-icon',
|
|
27
27
|
btn: '.pmc-btn',
|
|
28
|
+
btnLabel: '.pmc-btn__label',
|
|
28
29
|
primary: '.pmc-btn--primary',
|
|
29
30
|
row: '.pmc-row',
|
|
30
31
|
owed: '.pmc-row--owed',
|
|
@@ -256,16 +257,17 @@ function metaLine(cls, meta, signals) {
|
|
|
256
257
|
|
|
257
258
|
/**
|
|
258
259
|
* row({ title, titleIcon?, meta?: string[], signals?: Node[] (2 at most), lead?: Node[], trail?: Node[], onOpen?, openLabel?,
|
|
259
|
-
* owed?: boolean, done?: boolean, attrs? })
|
|
260
|
+
* owed?: boolean, done?: boolean, compact?: boolean, attrs? })
|
|
261
|
+
* `compact` keeps the title and the meta line on one line each, cut with an ellipsis: for a narrow list such as the dock's.
|
|
260
262
|
* One title, one meta line holding at most two signals. With `onOpen` the title and meta are one button (the row's
|
|
261
263
|
* focus target); without it they are plain text.
|
|
262
264
|
*/
|
|
263
|
-
export function row({ title, titleIcon, meta, signals, lead, trail, onOpen, openLabel, owed, done, proposed, attrs, openAttrs } = {}) {
|
|
265
|
+
export function row({ title, titleIcon, meta, signals, lead, trail, onOpen, openLabel, owed, done, proposed, compact, attrs, openAttrs } = {}) {
|
|
264
266
|
const body = [titleLine('pmc-row__title', title, titleIcon), metaLine('pmc-row__meta', meta, signals)];
|
|
265
267
|
const main = onOpen
|
|
266
268
|
? el('button', { type: 'button', class: 'pmc-row__open', 'aria-label': openLabel || null, onClick: onOpen, ...(openAttrs || {}) }, body)
|
|
267
269
|
: el('span', { class: 'pmc-row__main' }, body);
|
|
268
|
-
return el('div', withClass(cx('pmc-row', owed && 'pmc-row--owed', done && 'pmc-row--done', proposed && 'pmc-row--proposed'), attrs),
|
|
270
|
+
return el('div', withClass(cx('pmc-row', owed && 'pmc-row--owed', done && 'pmc-row--done', proposed && 'pmc-row--proposed', compact && 'pmc-row--compact'), attrs),
|
|
269
271
|
lead && lead.length ? el('span', { class: 'pmc-row__lead' }, lead) : null,
|
|
270
272
|
main,
|
|
271
273
|
trail && trail.length ? el('span', { class: 'pmc-row__trail' }, trail) : null);
|
|
@@ -325,7 +327,8 @@ let openMenu = null;
|
|
|
325
327
|
let lastMenuClose = { anchor: null, at: 0 };
|
|
326
328
|
|
|
327
329
|
/**
|
|
328
|
-
* menu({ anchor, items: [{ label, onSelect, danger?, sep? }], ariaLabel }): opens a popover menu under `anchor`.
|
|
330
|
+
* menu({ anchor, items: [{ label, onSelect, danger?, sep?, icon?, attrs? }], ariaLabel }): opens a popover menu under `anchor`.
|
|
331
|
+
* An item's `attrs` are data hooks for the suites (a class or a role is the menu's own).
|
|
329
332
|
* Arrow keys move, Enter or Space chooses, Escape closes and returns focus to the anchor. Pressing the anchor again
|
|
330
333
|
* closes it.
|
|
331
334
|
*/
|
|
@@ -349,6 +352,7 @@ export function menu({ anchor, items, ariaLabel } = {}) {
|
|
|
349
352
|
for (const it of items || []) {
|
|
350
353
|
if (it.sep) { node.appendChild(el('div', { class: 'pmc-menu__sep', role: 'separator' })); continue; }
|
|
351
354
|
const b = el('button', {
|
|
355
|
+
...(it.attrs || {}),
|
|
352
356
|
type: 'button', role: 'menuitem', tabindex: '-1', class: cx('pmc-menu__item', it.danger && 'pmc-menu__item--danger'),
|
|
353
357
|
onClick: (e) => { e.stopPropagation(); close(false); if (it.onSelect) it.onSelect(); },
|
|
354
358
|
}, it.icon ? icon(it.icon) : null, el('span', null, it.label));
|
|
@@ -129,6 +129,7 @@ function initialBase() {
|
|
|
129
129
|
newSessIsolate: null, newSessError: null, newSessBusy: false,
|
|
130
130
|
newSessBranches: null, preflightProfile: null, profiles: null, branchInfo: null,
|
|
131
131
|
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
132
|
+
dsFolded: {}, // the dock's session groups the PM folded (true) or opened (false), by group key — persisted
|
|
132
133
|
tabsVertical: false, // the cockpit's tabs as a vertical list instead of a strip — persisted, per browser
|
|
133
134
|
/* The scratchpad drawer's height in px (panel-tabs.js). 96 is the prototype's literal and stays
|
|
134
135
|
* the default; the PM can drag it, and the drag is clamped there rather than here — this store
|
|
@@ -238,6 +239,8 @@ const PERSIST = {
|
|
|
238
239
|
scratchOpen: isBool,
|
|
239
240
|
// the cockpit's tabs as a vertical list (true) or the horizontal strip (false, the default)
|
|
240
241
|
tabsVertical: isBool,
|
|
242
|
+
// the dock's session groups, folded (true) or open (false) by the PM; a group with no entry takes its default
|
|
243
|
+
dsFolded: isFlagMap,
|
|
241
244
|
/* A restored height is re-validated on the way IN, not merely on the way out: a corrupted or
|
|
242
245
|
* hand-edited record must not be able to render a drawer with no textarea in it, or one taller
|
|
243
246
|
* than any window it could be restored into. The upper bound here is deliberately generous (the
|