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.
@@ -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
- function metaOf(s, trees) {
104
- const parts = [typeLabel(s.work_type) || C().types.general];
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: [metaOf(s, trees)], onOpen: () => openSession(s), openLabel: C().open(s.name),
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
- function groupHead(repo, g) {
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
- el('h3', { class: 'fr-ds-head__title', title: g.initiative || g.title }, g.title),
316
- g.scheduled ? '' : iconButton({ icon: 'plus', ariaLabel: g.initiative ? C().newFor(g.title) : C().newTitle,
317
- onClick: () => openNewSessionSheet(repo, { initiative: g.initiative }), attrs: { 'data-ds': 'group-new' } }));
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
- out.push(el('section', { class: 'fr-ds-group', 'data-ds-section': g.key }, groupHead(repo, g),
328
- ...g.sessions.map((s) => sessionRow(repo, s, trees, s.id === front))));
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)), ...otherRows));
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 35 symbols live in one vendored sprite, /vendor/lucide-sprite.js, generated from lucide-static 1.52.0 (see
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) => cockpitTabNode(t, act, splitTab, true))),
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) => cockpitTabNode(t, act, splitTab))),
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