create-pmos 0.521.0 → 0.523.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/bin/create-pmos.js +106 -42
- package/package.json +39 -47
- 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/bin/pmos.js +2 -1
- package/workspace/board-publish-ui-test.js +1 -1
- package/workspace/changelog.json +38 -0
- package/workspace/guide/home.md +2 -2
- package/workspace/guide/published-board.md +15 -3
- package/workspace/lib/board-pack.js +44 -1
- package/workspace/lib/board-publish-choices.js +22 -6
- package/workspace/lib/board-publish-parts.js +95 -0
- package/workspace/lib/boards-host.js +3 -3
- package/workspace/lib/boards-received.js +105 -0
- package/workspace/lib/connector-read.js +19 -0
- package/workspace/lib/headless.js +2 -2
- package/workspace/lib/kit-update.js +51 -21
- package/workspace/lib/project-onboard.js +7 -16
- package/workspace/lib/refresh-run.js +2 -1
- package/workspace/lib/routes-board-publish.js +42 -12
- package/workspace/lib/routes-onboard.js +17 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/bd-publish.css +3 -0
- package/workspace/public/pm/bd-publish.js +39 -9
- package/workspace/public/pm/bd-shared.css +5 -0
- package/workspace/public/pm/bd-shared.js +72 -10
- package/workspace/public/pm/chat-term.js +22 -0
- package/workspace/public/pm/cozy-board.css +10 -0
- package/workspace/public/pm/cozy-home.css +3 -0
- package/workspace/public/pm/home-chat.js +3 -0
- package/workspace/public/pm/premium-board.js +8 -2
- package/workspace/public/pm/premium-shell.js +2 -1
- package/workspace/public/pm/readonly-boot.js +4 -1
- package/workspace/public/pm/readonly.js +40 -5
- package/workspace/public/pm/term-view.js +39 -8
package/workspace/package.json
CHANGED
|
@@ -47,3 +47,6 @@
|
|
|
47
47
|
.bppop :focus-visible{outline:2px solid var(--ember);outline-offset:2px}
|
|
48
48
|
#bdPublish{position:relative}#bdPublish .bpn{position:absolute;top:2px;right:0;min-width:16px;padding:0 5px;border-radius:99px;background:var(--solid);color:var(--on-solid);font:var(--fw-strong) var(--fs-badge)/var(--lh-badge) var(--mono);text-align:center;line-height:16px}
|
|
49
49
|
@media (max-width:640px){.bppop{left:0 !important;right:0;bottom:0;top:auto !important;width:auto;max-width:none;border-radius:16px 16px 0 0;border-width:1px 0 0;box-shadow:0 -8px 30px rgb(0 0 0 / .18)}.bppop .bpbody{max-height:85vh;padding:12px 12px calc(18px + env(safe-area-inset-bottom,0px))}.bppop .bpcaret{display:none}}
|
|
50
|
+
/* several picks (batch 27 item 1, ShA): Products and Programs as chip groups, and the link's name made from the picks */
|
|
51
|
+
.bppop .bpcap{font:var(--fw-strong) var(--fs-meta)/var(--lh-meta) var(--sans,inherit);color:var(--ink-3);padding:6px 8px 0}
|
|
52
|
+
.bppop .bpname{font-weight:var(--fw-strong);color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* POST /api/board-publish, which is a page press: only a press here publishes, never an agent session.
|
|
5
5
|
*
|
|
6
6
|
* No host "Add the board host": one field and a save icon. Nothing is sent before that.
|
|
7
|
-
* Ready What to share (
|
|
7
|
+
* Ready What to share: Products (the board on screen first) and Programs (each says which products it reaches) as chips, in pick
|
|
8
|
+
* order; several picks are one link with a tab per pick and a name made from the picks, the pencil renames it (batch 27 item 1) · Team (optional) · Password (on by default, generated; a new one, or off) · Ends in 1, 7 or 30 days
|
|
8
9
|
* · the host with a pencil to change it · the send-up icon publishes
|
|
9
10
|
* Link ready the link and the password, one copy icon, shown once (the workspace does not keep the password)
|
|
10
11
|
* Open links not listed here any more: one row "N open links" opens the Shared tab, where they are managed. Zero is silence.
|
|
@@ -27,13 +28,32 @@ const OK = svg('<path d="M5 12l5 5 9-10"/>');
|
|
|
27
28
|
const UP = svg('<path d="M12 15V4M7.5 8.5L12 4l4.5 4.5M5 14v5h14v-5"/>');
|
|
28
29
|
const COPY = svg('<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/>');
|
|
29
30
|
const MORE = svg('<path d="M9 6l6 6-6 6"/>');
|
|
31
|
+
const TAG = svg('<path d="M3 12V4h8l10 10-8 8zM7.5 8.5h.01"/>');
|
|
30
32
|
/** An icon-only button: the word is its tooltip and its label. */
|
|
31
33
|
const ib = (icon, label, attrs, cls = '') => `<button type="button" class="bpib${cls ? ' ' + cls : ''}" aria-label="${esc(label)}" title="${esc(label)}" ${attrs}>${icon}</button>`;
|
|
32
34
|
const SOON_MS = 36 * 3600 * 1000;
|
|
33
35
|
|
|
34
36
|
export const COUNT = (links) => (Array.isArray(links) ? links.length : 0);
|
|
35
37
|
const keyOf = (k, id) => k + ':' + id;
|
|
36
|
-
|
|
38
|
+
/** A product's key holds its ws and board (batch 27 item 1): a pick of another product publishes that product's board. */
|
|
39
|
+
const prodKey = (p) => (p.ws ? p.ws + '|' + p.id : p.id);
|
|
40
|
+
const chipKey = (kind, c) => keyOf(kind, kind === 'product' ? prodKey(c) : c.id);
|
|
41
|
+
const chip = (S, kind, c, extra = '') => `<button type="button" class="bpchip" data-bp-sel="${esc(chipKey(kind, c))}" aria-pressed="${S.sel.has(chipKey(kind, c))}">${esc(c.label)}${extra ? ` <small>${esc(extra)}</small>` : ''}</button>`;
|
|
42
|
+
/** The link's name made from the picks, in pick order: three names, then "+ N more" (lib/board-pack.js nameOf). */
|
|
43
|
+
export function autoName(S) {
|
|
44
|
+
const c = S.choices || { products: [], programs: [], initiatives: [] };
|
|
45
|
+
const labels = [...S.sel].map((k) => { const i = k.indexOf(':'); const kind = k.slice(0, i), id = k.slice(i + 1);
|
|
46
|
+
const list = kind === 'product' ? c.products : kind === 'program' ? c.programs : c.initiatives;
|
|
47
|
+
const x = (list || []).find((y) => (kind === 'product' ? prodKey(y) : y.id) === id); return x ? x.label : id; });
|
|
48
|
+
return labels.length > 3 ? labels.slice(0, 3).join(' + ') + ' + ' + (labels.length - 3) + ' more' : labels.join(' + ');
|
|
49
|
+
}
|
|
50
|
+
/** The picks as the publish route takes them, in pick order (lib/board-publish-parts.js). */
|
|
51
|
+
export function partsOf(S, ctx) {
|
|
52
|
+
return [...S.sel].map((k) => { const i = k.indexOf(':'); const kind = k.slice(0, i), id = k.slice(i + 1);
|
|
53
|
+
if (kind === 'product') { const j = id.indexOf('|'); return j < 0 ? { kind, ws: ctx.ws, board: id } : { kind, ws: id.slice(0, j), board: id.slice(j + 1) }; }
|
|
54
|
+
if (kind === 'program') return { kind, id };
|
|
55
|
+
return { kind, id, ws: ctx.ws, board: ctx.board || ctx.ws }; });
|
|
56
|
+
}
|
|
37
57
|
|
|
38
58
|
/** The popover's inside. S = { phase, host, days, choices, sel:Set, team, pw, pass, err, busy, made, links, iniOpen, copied, now } */
|
|
39
59
|
export function publishHtml(S) {
|
|
@@ -55,10 +75,17 @@ export function publishHtml(S) {
|
|
|
55
75
|
}
|
|
56
76
|
const c = S.choices || { products: [], programs: [], initiatives: [] };
|
|
57
77
|
const picked = c.initiatives.filter((i) => S.sel.has(keyOf('initiative', i.id)));
|
|
58
|
-
const
|
|
78
|
+
const prods = c.products.map((p) => chip(S, 'product', p, p.n ? String(p.n) : '')).join('');
|
|
79
|
+
const progs = c.programs.map((p) => chip(S, 'program', p, p.reach || '')).join('');
|
|
80
|
+
const inis = picked.map((i) => chip(S, 'initiative', i, 'initiative')).join('')
|
|
59
81
|
+ (c.initiatives.length ? `<button type="button" class="bpchip add" data-bp="ini" aria-expanded="${!!S.iniOpen}">${PLUS}Initiative</button>` : '');
|
|
60
82
|
const menu = S.iniOpen ? `<div class="bpmenu" role="menu">${c.initiatives.filter((i) => !S.sel.has(keyOf('initiative', i.id))).map((i) => `<button type="button" role="menuitem" data-bp-add="${esc(i.id)}">${esc(i.label)}</button>`).join('') || '<p class="bpnote">Every initiative is picked.</p>'}</div>` : '';
|
|
61
|
-
|
|
83
|
+
// several picks: one link, one tab per pick; its name is made from the picks, and the pencil renames it (batch 27 item 1, ShA)
|
|
84
|
+
const name = S.sel.size > 1 ? (S.naming
|
|
85
|
+
? `<div class="bpfield"><div class="bpinp2">${TAG}<input id="bpName" type="text" maxlength="120" autocomplete="off" aria-label="The link's name" value="${esc(S.name || autoName(S))}">${ib(OK, 'Keep this name', 'data-bp="named"', 'gh sm')}</div></div>`
|
|
86
|
+
: `<div class="bpfield"><div class="bpinp2" title="The link's name. Made from your picks; the pencil changes it.">${TAG}<span class="bpg bpname">${esc(S.name || autoName(S))}</span>${ib(PEN, 'Rename the link', 'data-bp="rename"', 'gh sm')}</div></div>`) : '';
|
|
87
|
+
return `<div class="bpeye">What to share</div>${c.products.length > 1 || c.programs.length ? '<div class="bpcap">Products</div>' : ''}<div class="bpchips">${prods}</div>`
|
|
88
|
+
+ (progs ? `<div class="bpcap">Programs</div><div class="bpchips">${progs}</div>` : '') + `<div class="bpchips">${inis}</div>${menu}` + name
|
|
62
89
|
+ `<div class="bpfield"><div class="bpinp2">${TEAM}<input id="bpTeam" type="text" maxlength="40" autocomplete="off" aria-label="Team" placeholder="Team (optional)" value="${esc(S.team)}"></div></div>`
|
|
63
90
|
+ `<div class="bpfield"><div class="bpinp2">${LOCK}${S.pw ? `<input id="bpPass" type="text" spellcheck="false" autocomplete="off" aria-label="Password" value="${esc(S.pass)}">${ib(DICE, 'New password', 'data-bp="newpw"', 'gh sm')}` : '<span class="bpg bpoff">No password</span>'}<button type="button" class="bpsw" role="switch" aria-checked="${S.pw}" aria-label="Password" title="Password" data-bp="pw"></button></div></div>`
|
|
64
91
|
+ `<div class="bpline2"><span class="bpnote">Ends in</span><div class="bpseg" role="group" aria-label="Link ends after (days)">${[1, 7, 30].map((d) => `<button type="button" data-bp-days="${d}" aria-pressed="${S.days === d}" aria-label="${d === 1 ? '1 day' : d + ' days'}">${d}</button>`).join('')}</div><span class="bpnote">days</span></div>`
|
|
@@ -110,7 +137,8 @@ export function open(btn, ctx) {
|
|
|
110
137
|
const onKey = (e) => { if (e.key === 'Escape' && m.isConnected) { e.stopPropagation(); close(true); } };
|
|
111
138
|
const onOut = (e) => { if (m.isConnected && !m.contains(e.target) && !btn.contains(e.target)) close(false); };
|
|
112
139
|
const draw = (focus) => {
|
|
113
|
-
const keep = { team: m.querySelector('#bpTeam'), pass: m.querySelector('#bpPass'), host: m.querySelector('#bpHost') };
|
|
140
|
+
const keep = { team: m.querySelector('#bpTeam'), pass: m.querySelector('#bpPass'), host: m.querySelector('#bpHost'), name: m.querySelector('#bpName') };
|
|
141
|
+
if (keep.name) S.name = keep.name.value.trim() === autoName(S) ? '' : keep.name.value.trim();
|
|
114
142
|
if (keep.team) S.team = keep.team.value; if (keep.pass) S.pass = keep.pass.value; if (keep.host) S.hostIn = keep.host.value;
|
|
115
143
|
body.innerHTML = publishHtml(S); place();
|
|
116
144
|
const f = focus && m.querySelector(focus); if (f) f.focus();
|
|
@@ -120,7 +148,7 @@ export function open(btn, ctx) {
|
|
|
120
148
|
const r = await pmGet(q).catch(() => null);
|
|
121
149
|
if (!r || !r.ok) { S.phase = 'form'; S.err = (r && r.error) || 'The workspace did not answer. Try again.'; return draw(); }
|
|
122
150
|
S.phase = 'form'; S.host = r.host; S.links = r.links || []; S.choices = r.choices; if (!S.pass) S.pass = r.password || ''; S.now = Date.now();
|
|
123
|
-
if (!S.sel.size && S.choices && S.choices.products[0]) S.sel.add(
|
|
151
|
+
if (!S.sel.size && S.choices && S.choices.products[0]) S.sel.add(chipKey('product', S.choices.products[0]));
|
|
124
152
|
draw(); refreshCount(btn);
|
|
125
153
|
};
|
|
126
154
|
m.onclick = async (e) => {
|
|
@@ -138,6 +166,8 @@ export function open(btn, ctx) {
|
|
|
138
166
|
if (!r || !r.ok) { S.err = (r && r.error) || 'The address was not saved.'; return draw('#bpHost'); }
|
|
139
167
|
S.phase = 'loading'; draw(); return load();
|
|
140
168
|
}
|
|
169
|
+
if (act === 'rename') { S.naming = true; return draw('#bpName'); }
|
|
170
|
+
if (act === 'named') { S.naming = false; return draw('[data-bp="rename"]'); }
|
|
141
171
|
if (act === 'ini') { S.iniOpen = !S.iniOpen; return draw('[data-bp="ini"]'); }
|
|
142
172
|
if (act === 'pw') { S.pw = !S.pw; return draw('[data-bp="pw"]'); }
|
|
143
173
|
if (act === 'newpw') { const r = await pmGet('/api/board-publish').catch(() => null); if (r && r.ok) S.pass = r.password; return draw('#bpPass'); }
|
|
@@ -148,10 +178,10 @@ export function open(btn, ctx) {
|
|
|
148
178
|
}
|
|
149
179
|
if (act === 'make') {
|
|
150
180
|
draw(); if (S.busy) return; S.busy = true; S.err = ''; draw();
|
|
151
|
-
const
|
|
152
|
-
const r = await pmPost('/api/board-publish', { ws: ctx.ws, board: ctx.board || ctx.ws,
|
|
181
|
+
const parts = partsOf(S, ctx); const several = S.sel.size > 1;
|
|
182
|
+
const r = await pmPost('/api/board-publish', { ws: ctx.ws, board: ctx.board || ctx.ws, parts, name: several ? S.name || '' : '', team: S.team.trim(), password: S.pw ? S.pass : '', days: S.days, view: ctx.view }).catch(() => null);
|
|
153
183
|
S.busy = false;
|
|
154
|
-
if (r && r.ok) { const l = (await pmGet('/api/board-publish').catch(() => null)) || {}; S.links = l.links || S.links; S.made = { url: r.url, pass: S.pw ? S.pass : '', ends: r.ends, team: S.team.trim(), name: (S.links.find((x) => x.id === r.id) || {}).name || 'Board' }; S.pass = ''; S.err = ''; draw('[data-bp="copy"]'); return refreshCount(btn); }
|
|
184
|
+
if (r && r.ok) { const l = (await pmGet('/api/board-publish').catch(() => null)) || {}; S.links = l.links || S.links; S.made = { url: r.url, pass: S.pw ? S.pass : '', ends: r.ends, team: S.team.trim(), name: r.name || (S.links.find((x) => x.id === r.id) || {}).name || 'Board' }; S.pass = ''; S.err = ''; draw('[data-bp="copy"]'); return refreshCount(btn); }
|
|
155
185
|
S.err = (r && r.error) || 'The workspace did not answer. Try again.'; return draw();
|
|
156
186
|
}
|
|
157
187
|
};
|
|
@@ -92,3 +92,8 @@
|
|
|
92
92
|
#cShared .bdsh-pwpop::before { display: none; }
|
|
93
93
|
#cShared .bdsh-first { margin: 8px auto; }
|
|
94
94
|
}
|
|
95
|
+
/* Shared with you (batch 27 item 3, ShX X2 and X3): the paste field on top, then one quiet row per board someone shared with you */
|
|
96
|
+
#cShared .bdsh-paste { display: grid; gap: 8px; margin-bottom: 12px; }
|
|
97
|
+
#cShared .bdsh-col > .bdsh-cap { display: block; margin: 12px 0 4px; }
|
|
98
|
+
#cShared .bdsh-pill { display: inline-flex; align-items: center; height: 22px; padding: 0 9px; border-radius: 999px; background: var(--cz-well); font-size: var(--fs-badge); font-weight: var(--fw-strong); color: var(--cz-ink2); flex: none; }
|
|
99
|
+
#cShared .bdsh-pill.w { background: var(--cz-riskbg); color: var(--cz-risk); }
|
|
@@ -53,7 +53,7 @@ export function css() {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/** The number of open copies: yours, plus each readable team's, without counting your own twice. */
|
|
56
|
-
export function totalOf(S) { return rowsOf(S).filter((r) => !r.ended).length; }
|
|
56
|
+
export function totalOf(S) { return rowsOf(S).filter((r) => !r.ended).length + (S.withYou || []).filter((r) => r.state === 'open').length; }
|
|
57
57
|
|
|
58
58
|
function rowsOf(S, now = S.now || Date.now()) {
|
|
59
59
|
const mine = new Set((S.yours || []).map((l) => l.id));
|
|
@@ -113,34 +113,70 @@ function firstRun(S) {
|
|
|
113
113
|
+ `<div class="bdsh-form">${joinForm(J)}</div><p class="bdsh-meta bdsh-fine">${svg(I.lock, 14)} Kept on this computer only.</p></div>`;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** The paste field (batch 27 item 3, ShX X2): a board someone shared with you is kept here. Its password is asked once, when the board needs one. */
|
|
117
|
+
function pasteHtml(S) {
|
|
118
|
+
const A = S.add || {};
|
|
119
|
+
const go = ib('plus', A.busy ? 'Adding…' : 'Add this board', `data-sh="add"${A.busy ? ' disabled' : ''}`, 's pri');
|
|
120
|
+
return `<div class="bdsh-paste"><div class="bdsh-fld${A.err ? ' bad' : ''}">${svg(I.link, 18)}${field('shLink', 'Paste a shared board link', A.link)}${A.needPw ? '' : go}</div>`
|
|
121
|
+
+ (A.needPw ? `<div class="bdsh-fld">${svg(I.lock, 18)}${field('shLinkPw', 'The board\'s password', '', 'password')}${go}</div>` : '')
|
|
122
|
+
+ (A.err ? `<p class="bdsh-err" role="alert"><i class="cz-dot red"></i>${esc(A.err)}</p>` : '') + '</div>';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const RSTATE = { ended: ['Ended', ''], off: ['Off', ''], password: ['Password', 'w'], locked: ['Locked', 'w'], unreachable: ['Not read', ''] };
|
|
126
|
+
/** One board shared with you (ShX X2, X3). Healthy: its name opens it, the meta says who and when it ends, nothing more (zero is silence).
|
|
127
|
+
* Ended, turned off or a new password: one quiet line with the reason and an ✕ that forgets it. */
|
|
128
|
+
function recvCard(S, r) {
|
|
129
|
+
const now = S.now || Date.now(); const left = r.ends ? Date.parse(r.ends) - now : NaN;
|
|
130
|
+
const soon = r.state === 'open' && left > 0 && left < 7 * 86400000;
|
|
131
|
+
const days = Math.max(1, Math.ceil(left / 86400000));
|
|
132
|
+
const st = RSTATE[r.state];
|
|
133
|
+
const nm = r.state === 'open' ? `<button type="button" class="bdsh-nm bd-dlink" data-sh-visit="${esc(r.id)}">${esc(r.name || 'Shared board')}</button>`
|
|
134
|
+
: r.state === 'password' ? `<button type="button" class="bdsh-nm bd-dlink" data-sh-repw="${esc(r.id)}" title="Type the new password">${esc(r.name || 'Shared board')}</button>`
|
|
135
|
+
: `<span class="bdsh-nm bdsh-plain">${esc(r.name || 'Shared board')}</span>`;
|
|
136
|
+
const why = r.state === 'ended' ? 'ended ' + day(r.ends) : r.state === 'off' ? 'turned off' + (r.by ? ' by ' + r.by : '') : r.state === 'password' ? 'Needs a new password' : r.error || '';
|
|
137
|
+
const meta = `<span class="bdsh-meta">${[r.state === 'open' || r.state === 'ended' || r.state === 'off' ? r.by : '', r.state === 'open' && r.ends ? 'ends ' + day(r.ends) : why].filter(Boolean).map(esc).join(' · ')}</span>`;
|
|
138
|
+
const pill = st ? `<span class="bdsh-pill${st[1] ? ' ' + st[1] : ''}">${esc(st[0])}</span>` : soon ? `<span class="bdsh-pill w">Ends in ${days} ${days === 1 ? 'day' : 'days'}</span>` : '';
|
|
139
|
+
const act = r.state === 'open' ? ib('open', 'Open in a new tab', `data-sh-visit="${esc(r.id)}"`, 'gh') : ib('x', 'Forget this board', `data-sh-forget="${esc(r.id)}"`, 's gh');
|
|
140
|
+
return `<div class="bdsh-card${r.state === 'open' ? '' : ' bdsh-gone'}" data-sh-recv="${esc(r.id)}"><span class="bdsh-tile" aria-hidden="true">${svg(I.board)}</span>`
|
|
141
|
+
+ `<div class="bdsh-txt">${nm}${meta}</div>${pill}<span class="bdsh-act${r.state === 'open' ? ' open' : ''}">${act}</span></div>`;
|
|
142
|
+
}
|
|
143
|
+
function withYouHtml(S) {
|
|
144
|
+
const W = S.withYou || []; if (!W.length) return '';
|
|
145
|
+
const order = { open: 0, password: 1, locked: 1, unreachable: 2, off: 3, ended: 3 };
|
|
146
|
+
return `<span class="bdsh-cap">Shared with you</span><div class="bdsh-list" role="list" aria-label="Shared with you">${W.slice().sort((a, b) => (order[a.state] || 0) - (order[b.state] || 0)).map((r) => recvCard(S, r)).join('')}</div>`;
|
|
147
|
+
}
|
|
148
|
+
|
|
116
149
|
/** The tab's inside. S = { phase, yours, teams:[{team,host,ok,links|kind,error}], names, readAt, now, sel, join, off, leave, pw, endedOpen } */
|
|
117
150
|
export function viewHtml(S) {
|
|
118
151
|
if (S.phase === 'loading') return '<p class="bdsh-meta bdsh-pad">Reading the teams…</p>';
|
|
119
152
|
const teams = S.teams || []; const rows = rowsOf(S);
|
|
120
|
-
|
|
153
|
+
const W = S.withYou || [];
|
|
154
|
+
if (!teams.length && !(S.yours || []).length && !W.length) return `<div class="bdsh-col">${pasteHtml(S)}${firstRun(S)}</div>`;
|
|
121
155
|
const count = (f) => rows.filter((r) => !r.ended && f(r)).length;
|
|
122
|
-
const sel = S.sel === 'mine' && (S.yours || []).length ? 'mine' : teams.some((t) => t.team === S.sel) ? S.sel : 'all';
|
|
156
|
+
const sel = S.sel === 'mine' && (S.yours || []).length ? 'mine' : S.sel === 'with' && W.length ? 'with' : teams.some((t) => t.team === S.sel) ? S.sel : 'all';
|
|
123
157
|
const chip = (k, label, n) => `<button type="button" class="bd-chip bdsh-chip${sel === k ? ' on' : ''}" data-sh-sel="${esc(k)}" aria-pressed="${sel === k}">${esc(label)}${n ? ' ' + n : ''}</button>`;
|
|
124
158
|
const J = S.join || {};
|
|
125
159
|
const plus = `<span class="bdsh-plusw">${ib('plus', 'Join a team', `data-sh="${J.open ? 'closejoin' : 'openjoin'}" aria-haspopup="dialog" aria-expanded="${!!J.open}"`, 's plus')}`
|
|
126
160
|
+ (J.open ? `<div class="bdsh-pop bdsh-joinpop" role="dialog" aria-label="Join a team"><div class="bdsh-ph"><b>Join a team</b>${ib('x', 'Close', 'data-sh="closejoin"', 's gh')}</div>${joinForm(J)}<p class="bdsh-meta bdsh-fine">${svg(I.lock, 14)} Kept on this computer only.</p></div>` : '') + '</span>';
|
|
127
|
-
const
|
|
161
|
+
const openW = W.filter((r) => r.state === 'open').length;
|
|
162
|
+
const chipsHtml = chip('all', 'All', count(() => true) + openW) + (W.length ? chip('with', 'With you', openW) : '') + ((S.yours || []).length ? chip('mine', 'Yours', count((r) => r.mine)) : '') + teams.map((t) => chip(t.team, t.team, t.ok ? count((r) => r.team === t.team) : 0)).join('') + plus;
|
|
128
163
|
const failing = teams.filter((t) => !t.ok && (sel === 'all' || sel === t.team));
|
|
129
164
|
const banners = failing.map((t) => `<div class="bdsh-ban" role="alert"><i class="cz-dot red"></i><span class="bdsh-bt"><b>${esc(t.team)}</b> cannot be read. ${esc(t.error)}</span>`
|
|
130
165
|
+ (t.kind === 'password' ? ib('key', 'Paste a new invite link', `data-sh="repaste" data-sh-team="${esc(t.team)}"`, 's') : ib('refresh', 'Refresh', `data-sh="refresh" data-sh-team="${esc(t.team)}"`, 's')) + '</div>').join('');
|
|
131
|
-
const shown = rows.filter((r) => (sel === 'all' ? true : sel === 'mine' ? r.mine : r.team === sel)).sort((a, b) => (b.mine ? 1 : 0) - (a.mine ? 1 : 0) || String(b.made).localeCompare(String(a.made)));
|
|
166
|
+
const shown = rows.filter((r) => (sel === 'all' ? true : sel === 'with' ? false : sel === 'mine' ? r.mine : r.team === sel)).sort((a, b) => (b.mine ? 1 : 0) - (a.mine ? 1 : 0) || String(b.made).localeCompare(String(a.made)));
|
|
132
167
|
const live = shown.filter((r) => !r.ended); const ended = shown.filter((r) => r.ended);
|
|
133
168
|
const teamSel = teams.find((t) => t.team === sel);
|
|
134
169
|
const leaving = teamSel && S.leave === teamSel.team;
|
|
135
170
|
const teamLine = teamSel ? `<div class="bdsh-tl"><span class="bdsh-meta">${leaving ? 'Leave ' + esc(teamSel.team) + '? It only forgets the team\'s password on this computer. Copies you published stay.' : esc(teamSel.team)}</span>`
|
|
136
171
|
+ (leaving ? ib('x', 'Stay in the team', 'data-sh="stay"', 's gh') + ib('leave', 'Sure, leave ' + teamSel.team, `data-sh-leave="${esc(teamSel.team)}"`, 's danger')
|
|
137
172
|
: ib('key', 'Paste a new invite link', `data-sh="repaste" data-sh-team="${esc(teamSel.team)}"`, 's gh') + ib('leave', 'Leave team', `data-sh-leave="${esc(teamSel.team)}"`, 's gh')) + '</div>' : '';
|
|
138
|
-
const
|
|
173
|
+
const withYou = sel === 'all' || sel === 'with' ? withYouHtml(S) : '';
|
|
174
|
+
const empty = !live.length && !ended.length && !failing.length && !withYou ? `<p class="bdsh-meta bdsh-pad">${sel === 'all' ? 'Nothing is shared yet.' : sel === 'mine' ? 'You have no open links.' : 'Nobody has shared a board with ' + esc(sel) + ' yet. Press the share button on the Board to publish one.'}</p>` : '';
|
|
139
175
|
const fold = ended.length ? `<button type="button" class="bdsh-fold" data-sh="ended" aria-expanded="${!!S.endedOpen}">${svg(I.more, 16)}<span>${ended.length} ended</span></button>` + (S.endedOpen ? ended.map((r) => card(S, r)).join('') : '') : '';
|
|
140
176
|
const hosts = [...new Set(teams.filter((t) => t.ok).map((t) => t.host))];
|
|
141
177
|
const readLine = S.readAt ? `<p class="bdsh-meta bdsh-read"><i class="cz-dot ok"></i><span>Read${hosts.length ? ' from ' + hosts.map(esc).join(', ') : ''} ${agoText(S)}. Press a board to open its copy in a new tab.</span>${ib('refresh', 'Refresh', 'data-sh="refresh"', 's gh')}</p>` : '';
|
|
142
|
-
return `<div class="bdsh-col"
|
|
143
|
-
+ (live.length ? `<div class="bdsh-list" role="list" aria-label="Shared boards">${live.map((r) => card(S, r)).join('')}</div>` : '') + fold + empty + readLine + '</div>';
|
|
178
|
+
return `<div class="bdsh-col">${pasteHtml(S)}<div class="bdsh-top" role="group" aria-label="Shared">${chipsHtml}</div>` + banners + teamLine + withYou
|
|
179
|
+
+ (live.length && withYou ? '<span class="bdsh-cap">Boards</span>' : '') + (live.length ? `<div class="bdsh-list" role="list" aria-label="Shared boards">${live.map((r) => card(S, r)).join('')}</div>` : '') + fold + empty + readLine + '</div>';
|
|
144
180
|
}
|
|
145
181
|
|
|
146
182
|
export async function loadCount(cb) {
|
|
@@ -157,18 +193,21 @@ function toast(msg) {
|
|
|
157
193
|
|
|
158
194
|
export function show(el, opts = {}) {
|
|
159
195
|
css();
|
|
160
|
-
|
|
196
|
+
if (opts.paste && el.__sh) el.__sh.add = { link: opts.paste };
|
|
197
|
+
const S = el.__sh || (el.__sh = { phase: 'loading', withYou: [], add: {}, yours: [], teams: [], names: [], readAt: '', now: Date.now(), sel: 'all', join: { open: false, typed: false }, off: null, leave: null, pw: null, endedOpen: false });
|
|
161
198
|
const draw = (focus) => {
|
|
162
199
|
const keep = { invite: el.querySelector('#shInvite'), host: el.querySelector('#shHost'), team: el.querySelector('#shTeam'), pass: el.querySelector('#shPass') };
|
|
200
|
+
const lk = el.querySelector('#shLink'); if (lk) S.add.link = lk.value;
|
|
163
201
|
if (keep.invite) S.join.invite = keep.invite.value; if (keep.host) S.join.host = keep.host.value; if (keep.team) S.join.team = keep.team.value; if (keep.pass) S.join.password = keep.pass.value;
|
|
164
202
|
el.innerHTML = '<h2 class="vh">Shared</h2><div id="shBody">' + viewHtml(S) + '</div>';
|
|
165
203
|
const f = focus && el.querySelector(focus); if (f) f.focus();
|
|
166
204
|
};
|
|
205
|
+
if (opts.paste && !S.add.link) S.add = { link: opts.paste };
|
|
167
206
|
const read = async (keepPhase) => {
|
|
168
207
|
if (!keepPhase) { S.phase = 'loading'; draw(); }
|
|
169
208
|
const r = await pmGet('/api/board-publish/shared').catch(() => null);
|
|
170
209
|
S.phase = 'ready'; S.now = Date.now();
|
|
171
|
-
if (r && r.ok) { S.yours = r.yours || []; S.teams = r.teams || []; S.names = S.teams.map((t) => t.team); S.readAt = r.readAt; } else toast((r && r.error) || 'The workspace did not answer. Try again.');
|
|
210
|
+
if (r && r.ok) { S.yours = r.yours || []; S.withYou = r.withYou || []; S.teams = r.teams || []; S.names = S.teams.map((t) => t.team); S.readAt = r.readAt; } else toast((r && r.error) || 'The workspace did not answer. Try again.');
|
|
172
211
|
if (opts.onCount) opts.onCount(totalOf(S)); draw();
|
|
173
212
|
};
|
|
174
213
|
if (!el.__shBound) {
|
|
@@ -183,6 +222,14 @@ export function show(el, opts = {}) {
|
|
|
183
222
|
const b = e.target.closest('button'); if (!b) return;
|
|
184
223
|
if (b.dataset.shSel) { S.sel = b.dataset.shSel; S.leave = null; S.off = null; S.pw = null; return draw(`[data-sh-sel="${CSS.escape(S.sel)}"]`); }
|
|
185
224
|
const a = b.dataset.sh;
|
|
225
|
+
if (a === 'add') {
|
|
226
|
+
const link = ((el.querySelector('#shLink') || {}).value || '').trim(); const pwEl = el.querySelector('#shLinkPw');
|
|
227
|
+
S.add = Object.assign({}, S.add, { link, busy: true, err: '' }); draw();
|
|
228
|
+
const r = await pmPost('/api/board-publish/receive', { link, password: pwEl ? pwEl.value : undefined }).catch(() => null); S.add.busy = false;
|
|
229
|
+
if (r && r.ok) { S.add = {}; S.sel = 'all'; toast('Added ' + ((r.board && r.board.name) || 'the board') + '. Its password stays on this computer.'); return read(true); }
|
|
230
|
+
if (r && r.kind === 'password') { S.add.needPw = true; S.add.err = r.error; return draw('#shLinkPw'); }
|
|
231
|
+
S.add.err = (r && r.error) || 'The workspace did not answer. Try again.'; return draw('#shLink');
|
|
232
|
+
}
|
|
186
233
|
if (a === 'refresh') { S.leave = null; S.pw = null; return read(false); }
|
|
187
234
|
if (a === 'openjoin') { S.join = { open: true, typed: false }; return draw('#shInvite'); }
|
|
188
235
|
if (a === 'closejoin') { S.join = { open: false, typed: false }; return draw('.bdsh-ib.plus'); }
|
|
@@ -229,6 +276,21 @@ export function show(el, opts = {}) {
|
|
|
229
276
|
const r = await pmPost('/api/board-publish/off', { id: S.off }).catch(() => null); S.off = null;
|
|
230
277
|
toast(r && r.ok ? 'Turned off. The copy is deleted from the host.' : (r && r.error) || 'The link could not be turned off.'); return read(true);
|
|
231
278
|
}
|
|
279
|
+
if (b.dataset.shForget) {
|
|
280
|
+
const r = await pmPost('/api/board-publish/forget', { id: b.dataset.shForget }).catch(() => null);
|
|
281
|
+
toast(r && r.ok ? 'Forgotten on this computer.' : (r && r.error) || 'Not saved.'); return read(true);
|
|
282
|
+
}
|
|
283
|
+
if (b.dataset.shRepw) {
|
|
284
|
+
const row = (S.withYou || []).find((x) => x.id === b.dataset.shRepw);
|
|
285
|
+
S.add = { link: row ? row.url : '', needPw: true, err: '' }; return draw('#shLinkPw');
|
|
286
|
+
}
|
|
287
|
+
if (b.dataset.shVisit) {
|
|
288
|
+
const w = window.open('about:blank', '_blank'); if (w) { try { w.opener = null; } catch { /* none */ } }
|
|
289
|
+
const row = (S.withYou || []).find((x) => x.id === b.dataset.shVisit);
|
|
290
|
+
const r = await pmPost('/api/board-publish/visit', { id: b.dataset.shVisit }).catch(() => null);
|
|
291
|
+
if (r && r.ok && w) { w.location.href = r.url; toast('Opened ' + ((row && row.name) || 'the board') + ' in a new tab.'); } else { if (w) w.close(); toast((r && r.error) || 'The workspace did not answer. Try again.'); if (r && r.kind) read(true); }
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
232
294
|
if (b.dataset.shOpen) {
|
|
233
295
|
// the tab is opened by the press itself (a popup blocker allows that), then pointed at the one-time address
|
|
234
296
|
const w = window.open('about:blank', '_blank'); if (w) { try { w.opener = null; } catch { /* none */ } }
|
|
@@ -10,3 +10,25 @@ export function termTarget({ live = null, command = null, ws = null } = {}) {
|
|
|
10
10
|
if (command && typeof command === 'string') return { copy: command, label: 'Copy the terminal command' };
|
|
11
11
|
return null;
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
/* What the terminal answers by itself (batch 27 item 5, Wawan 2026-11-04: "When open terminal, the response starts in terminal and never comes
|
|
15
|
+
* back to the chat"). Attaching replays the session's screen into xterm.js, and xterm.js answers every query in it: device attributes, mode
|
|
16
|
+
* reports, the cursor position, colours, focus in and out. Sent on as input, those answers reached Claude Code as a burst of keys, which it
|
|
17
|
+
* reads as a paste in its input box ("paste again to expand"), under whatever the chat typed next. The session's terminal is tmux, which has
|
|
18
|
+
* answered those queries already, so they are not the PM's keys and none of them goes to the session. */
|
|
19
|
+
const AUTO = [
|
|
20
|
+
/\x1b\[[?>=]?[\d;]*c/g, // device attributes (DA1, DA2, DA3)
|
|
21
|
+
/\x1b\[\??[\d;]*\$y/g, // a mode report (DECRQM)
|
|
22
|
+
/\x1b\[\d+;\d+R/g, // the cursor position (DSR 6)
|
|
23
|
+
/\x1b\[[0-3]n/g, // the terminal status (DSR 5)
|
|
24
|
+
/\x1b\[\?\d*u/g, // the keyboard protocol flags
|
|
25
|
+
/\x1b\[[IO]/g, // focus in and out
|
|
26
|
+
/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, // an OSC answer (a colour, the title)
|
|
27
|
+
/\x1bP[\s\S]*?\x1b\\/g, // a DCS answer (DECRQSS, XTGETTCAP)
|
|
28
|
+
];
|
|
29
|
+
/** The part of xterm.js data the PM typed: d with the terminal's own answers taken out ('' when nothing is left). */
|
|
30
|
+
export function typedOnly(d) {
|
|
31
|
+
let s = String(d == null ? '' : d);
|
|
32
|
+
for (const re of AUTO) s = s.replace(re, '');
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
@@ -1475,6 +1475,16 @@ span.tg-ng { cursor: default; }
|
|
|
1475
1475
|
.ro [hidden] { display: none !important; }
|
|
1476
1476
|
.ro-by { margin: 14px 18px 0; font-size: var(--fs-meta); color: var(--cz-ink3); line-height: var(--lh-meta); }
|
|
1477
1477
|
.ro-by b { color: var(--cz-ink); font-weight: var(--fw-strong); }
|
|
1478
|
+
/* the shared-by line ends with two quiet icons, Open in my PMOS and Get PMOS; the tabs of a link of several picks sit under it (batch 27 items 1 to 3) */
|
|
1479
|
+
.ro-by { display: flex; align-items: center; gap: 8px; }
|
|
1480
|
+
.ro-by .ro-txt { flex: 1; min-width: 0; }
|
|
1481
|
+
.ro-ways { display: inline-flex; gap: 4px; flex: none; }
|
|
1482
|
+
.ro-way { width: 36px; height: 36px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: var(--cz-ink2); background: var(--cz-well); }
|
|
1483
|
+
.ro-way:hover { color: var(--cz-ink); }
|
|
1484
|
+
.ro-way:focus-visible, .ro-tab:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
|
|
1485
|
+
.ro-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 18px 0; }
|
|
1486
|
+
.ro-tab { height: 34px; padding: 0 14px; border-radius: 999px; display: inline-flex; align-items: center; font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink3); text-decoration: none; }
|
|
1487
|
+
.ro-tab.on { background: var(--cz-well); color: var(--cz-ink); }
|
|
1478
1488
|
.ro-back { color: inherit; font-weight: var(--fw-strong); text-decoration: none; }
|
|
1479
1489
|
.ro-back:hover { text-decoration: underline; }
|
|
1480
1490
|
/* with a viewer open the Timeline keeps the initiative's name: no Stage, Status, Start or Next milestone */
|
|
@@ -486,6 +486,9 @@ form.hm-box textarea { min-height: 48px; border: 0; background: none; resize: no
|
|
|
486
486
|
.hv-xt { height: 100%; box-sizing: border-box; padding: 12px 16px; overflow: auto; background: var(--cz-well); } /* term-view.js: the session's terminal drawn by xterm.js, read only */
|
|
487
487
|
.hv-xt .xterm { display: inline-block; zoom: .8; } /* xterm's 15px default drawn at 12px */
|
|
488
488
|
.hv-xt .xterm-viewport { background: transparent !important; }
|
|
489
|
+
/* the live terminal fills the whole card and fits its grid to it (term-view.js, batch 27 item 6): xterm's own type size and no zoom, so the fit measures true (as the sign-in terminal) */
|
|
490
|
+
.hv-xt.hv-xt-live { overflow: hidden; padding: 10px 12px; }
|
|
491
|
+
.hv-xt.hv-xt-live .xterm { display: block; zoom: 1; width: 100%; height: 100%; }
|
|
489
492
|
|
|
490
493
|
/* ── the parts other chat modules draw inside a message: context and report chips (premium-vw-ctx, report-chat), the /goal card (goal-chat),
|
|
491
494
|
* Claude's question card (chat-ask), attachments (premium-attach) ── */
|
|
@@ -103,6 +103,9 @@ export function mountChat(el, host) {
|
|
|
103
103
|
else if (th && th.ok && th.history && (th.history.messages || []).length) conv = { ok: true, state: 'ended', messages: th.history.messages };
|
|
104
104
|
} else if (t.sid) conv = await host.pmGet('/api/sessions/conversation?id=' + encodeURIComponent(t.sid)).catch(() => null);
|
|
105
105
|
if (my !== C.seq || C.t !== t) return;
|
|
106
|
+
// the next read is set before anything is drawn, so a drawing error never stops the chat reading (batch 27 item 5: answers stopped
|
|
107
|
+
// reaching the chat for good after the terminal opened); a later read() clears it and sets its own
|
|
108
|
+
clearTimeout(C.timer); C.timer = setTimeout(read, POLL);
|
|
106
109
|
C.th = th && th.ok ? th : null; C.conv = conv && conv.ok ? conv : null;
|
|
107
110
|
if (host.touch && sid() && C.touched !== sid()) { C.touched = sid(); host.touch(C.touched); } // the Live pill: the PM opened it now
|
|
108
111
|
C.sess = host.session ? host.session(t.sid || (C.th && C.th.session)) : null;
|
|
@@ -899,12 +899,13 @@ $('#bdBell').addEventListener('click', (e) => { if (M) Acts.open.bell(e.currentT
|
|
|
899
899
|
$('#bdPublish').addEventListener('click', (e) => { const btn = e.currentTarget; if (M && !ALL && M.board.ws) import('./bd-publish.js').then((P) => P.open(btn, { ws: M.board.ws, board: M.board.id || M.board.ws, view })); });
|
|
900
900
|
|
|
901
901
|
/* ── tabs: Initiatives and Documents (Documents has no frame yet: a plain list) ──────────────────────────────────────────────────────── */
|
|
902
|
+
let SH_PASTE = ''; // ?shared=<link>: Open in my PMOS on a shared board (batch 27 item 3) lands on Shared with the link in the paste field
|
|
902
903
|
function setTab(t) {
|
|
903
904
|
tab = t;
|
|
904
905
|
$('#tabIni').setAttribute('aria-selected', String(t === 'ini')); $('#tabShared').setAttribute('aria-selected', String(t === 'shared'));
|
|
905
906
|
$('#cIni').hidden = t !== 'ini'; $('#cShared').hidden = t !== 'shared';
|
|
906
907
|
// Shared (initiative board-publish, slice 4): reads each joined team's host now, on opening the tab and on Refresh, and never in between
|
|
907
|
-
if (t === 'shared') { closePanel(); import('./bd-shared.js').then((S) => { SH_FULL = true; S.show($('#cShared'), { onCount: setSharedCount }); }); }
|
|
908
|
+
if (t === 'shared') { closePanel(); import('./bd-shared.js').then((S) => { SH_FULL = true; const paste = SH_PASTE; SH_PASTE = ''; S.show($('#cShared'), { onCount: setSharedCount, paste }); }); }
|
|
908
909
|
}
|
|
909
910
|
$('#tabIni').onclick = () => setTab('ini');
|
|
910
911
|
$('#tabShared').onclick = () => setTab('shared');
|
|
@@ -1219,6 +1220,11 @@ PO.mount({ programs: () => PGS, stateOf: (p) => progState(p), say,
|
|
|
1219
1220
|
SO.mount({ redraw: () => { if (M) drawInit(); }, say });
|
|
1220
1221
|
SO.load().then(() => { if (M) drawInit(); }); // the PM's sort and own order (look.order), shared with Home
|
|
1221
1222
|
PP.mount({ M: () => M, programs: () => PGS, openIni: (id, from) => openIni((M.rows || []).find((x) => x.id === id && x.track), from), close: closePanel, reload: loadPrograms, say }); // the program panel (PgProgram)
|
|
1222
|
-
|
|
1223
|
+
// Open in my PMOS (batch 27 item 3): the link fills the paste field and nothing is added before the PM presses +; the address forgets it at once
|
|
1224
|
+
if (Q0.get('shared') && !window.PMOS_READONLY) {
|
|
1225
|
+
SH_PASTE = String(Q0.get('shared')).slice(0, 400);
|
|
1226
|
+
try { const q = new URLSearchParams(location.search); q.delete('shared'); const s2 = q.toString(); history.replaceState(null, '', location.pathname + (s2 ? '?' + s2 : '')); } catch { /* none */ }
|
|
1227
|
+
setTab('shared');
|
|
1228
|
+
} else setTab('ini');
|
|
1223
1229
|
load();
|
|
1224
1230
|
loadPrograms();
|
|
@@ -596,7 +596,8 @@ export async function loadThread(key) {
|
|
|
596
596
|
const ws = new URLSearchParams(location.search).get('ws');
|
|
597
597
|
let th = null;
|
|
598
598
|
try { const r = await fetch('/api/briefing-premium/thread?key=' + encodeURIComponent(key) + (ws ? '&ws=' + encodeURIComponent(ws) : ''), { credentials: 'same-origin' }); if (r.ok) th = await r.json(); } catch { th = null; }
|
|
599
|
-
|
|
599
|
+
// one read that failed (the server busy, a dropped connection) tries again, so the open chat never stops taking answers (batch 27 item 5)
|
|
600
|
+
if (!th || !th.ok) { if (S.key === key && $('#dlg').classList.contains('on')) threadTimer = setTimeout(() => loadThread(key), 4000); return; }
|
|
600
601
|
let conv = null;
|
|
601
602
|
if (th.session) conv = await pmGet('/api/sessions/conversation?id=' + encodeURIComponent(th.session)).catch(() => null);
|
|
602
603
|
else if (th.history && (th.history.messages || []).length) conv = { ok: true, state: 'ended', messages: th.history.messages }; // item-chat-history
|
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
window.PMOS_READONLY = pack;
|
|
16
16
|
var R = pack.responses;
|
|
17
17
|
if (!q.get('ws')) {
|
|
18
|
+
// a pack of several picks (batch 27 item 1) opens on its first tab, a program's on its page; else on the pack's one board
|
|
19
|
+
var t0 = Array.isArray(pack.parts) && pack.parts[0];
|
|
18
20
|
var bk = Object.keys(R).filter(function (k) { return /^\/api\/board-premium\?/.test(k); })[0]; var bm = bk && /ws=([^&]+)&board=([^&]+)/.exec(bk);
|
|
19
|
-
|
|
21
|
+
var first = t0 && t0.ws ? '?ws=' + encodeURIComponent(t0.ws) + '&board=' + encodeURIComponent(t0.board || t0.ws) + (t0.program ? '&program=' + encodeURIComponent(t0.program) : '') : bm ? '?ws=' + bm[1] + '&board=' + bm[2] : '';
|
|
22
|
+
if (first) { try { history.replaceState(null, '', location.pathname + first + '&pack=' + encodeURIComponent(file)); } catch (e) { /* none */ } }
|
|
20
23
|
}
|
|
21
24
|
var norm = function (a) { var l = []; new URLSearchParams(a.search).forEach(function (v, k) { l.push(k + '=' + v); }); l.sort(); return a.pathname + '?' + l.join('&'); };
|
|
22
25
|
var byNorm = {}; Object.keys(R).forEach(function (k) { byNorm[norm(new URL(k, location.href))] = R[k]; });
|
|
@@ -27,8 +27,8 @@ export const VIEW_CONTROLS = [
|
|
|
27
27
|
// the one program page (batch 21 item 2, RdProgO): open the program, its tabs, its folds, the period of its goals, an initiative of it
|
|
28
28
|
'button.tg-pgo', 'button.bt-pgo', 'button.bd-lho', '#czViewer .pv-tab', '#czViewer [data-czs-fold]', '#czViewer .pp-nd .tw', '#czViewer [data-pp="ms"]', '#czViewer [data-pp="itasks"]',
|
|
29
29
|
'#czViewer [data-db="fold"]', '#czViewer [data-pp="period"]', '#czViewer [data-pp="per"]', '#czViewer [data-pp="early"]', '#czViewer [data-pp="ini"]',
|
|
30
|
-
// the line above the Board
|
|
31
|
-
'.ro-back',
|
|
30
|
+
// the line above the Board: the way back to the hub, a tab per pick (batch 27 item 1), Open in my PMOS and Get PMOS (batch 27 items 2 and 3)
|
|
31
|
+
'.ro-back', '.ro-tab', '.ro-way',
|
|
32
32
|
].join(',');
|
|
33
33
|
|
|
34
34
|
/** The Board's static toolbar and chips: hidden, so the page's own script keeps the elements it holds. */
|
|
@@ -85,13 +85,48 @@ function refuse(e) {
|
|
|
85
85
|
const esc = (t) => String(t == null ? '' : t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
86
86
|
const KIND = { product: 'Product', program: 'Program', initiative: 'Initiative', multi: 'Selection' };
|
|
87
87
|
|
|
88
|
-
/**
|
|
88
|
+
/** Where the workspace runs on a viewer's own computer (its default address): Open in my PMOS goes there with the link, never the password. */
|
|
89
|
+
export const MY_PMOS = 'http://127.0.0.1:7433/premium-board';
|
|
90
|
+
export const GET_PMOS = 'https://pm-os.app/welcome';
|
|
91
|
+
const GO = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 7l5 5-5 5M4 12h11M20 4v16"/></svg>';
|
|
92
|
+
const GET = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7.5 10.5L12 15l4.5-4.5M5 19h14"/></svg>';
|
|
93
|
+
|
|
94
|
+
/** The link a teammate was sent, when this page is a copy on the host (/b/<id>/pack.json). A preview in the publisher's own workspace has none. */
|
|
95
|
+
export function linkOf(loc) {
|
|
96
|
+
const pk = new URLSearchParams(loc.search).get('pack') || ''; const m = /^\/b\/([A-Za-z0-9_-]+)\/pack\.json$/.exec(pk);
|
|
97
|
+
return m ? loc.origin + '/b/' + m[1] : '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The tabs of a pack of several picks (batch 27 item 1, ShA): one per pick, in pick order; one pick draws none (zero is silence).
|
|
101
|
+
* A tab is a link to the same copy on another board (a program's tab opens its page): the page reads the pack again, from the browser's cache. */
|
|
102
|
+
export function tabsHtml(pack, loc) {
|
|
103
|
+
const parts = (pack && Array.isArray(pack.parts) ? pack.parts : []).filter((t) => t && t.ws);
|
|
104
|
+
if (parts.length < 2) return '';
|
|
105
|
+
const q = new URLSearchParams(loc.search); const ws = q.get('ws') || parts[0].ws; const prog = q.get('program') || '';
|
|
106
|
+
const exact = parts.findIndex((t) => t.ws === ws && (t.program || '') === prog);
|
|
107
|
+
const on = exact >= 0 ? exact : parts.findIndex((t) => t.ws === ws);
|
|
108
|
+
const pk = q.get('pack') || '';
|
|
109
|
+
return '<nav class="ro-tabs" aria-label="Boards in this link">' + parts.map((t, i) => {
|
|
110
|
+
const href = '?ws=' + encodeURIComponent(t.ws) + '&board=' + encodeURIComponent(t.board || t.ws) + (t.program ? '&program=' + encodeURIComponent(t.program) : '') + '&pack=' + encodeURIComponent(pk);
|
|
111
|
+
return '<a class="ro-tab' + (i === on ? ' on' : '') + '" href="' + esc(href) + '"' + (i === on ? ' aria-current="page"' : '') + '>' + esc(t.label) + '</a>';
|
|
112
|
+
}).join('') + '</nav>';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The two quiet icons at the end of the line (batch 27 items 2 and 3, ShX X1): Open in my PMOS (for a viewer who runs it) and Get PMOS. */
|
|
116
|
+
export function waysHtml(link) {
|
|
117
|
+
return (link ? '<a class="ro-way" href="' + esc(MY_PMOS + '?shared=' + encodeURIComponent(link)) + '" target="_blank" rel="noopener noreferrer" aria-label="Open in my PMOS" title="Open in my PMOS (if you have it)">' + GO + '</a>' : '')
|
|
118
|
+
+ '<a class="ro-way" href="' + GET_PMOS + '" target="_blank" rel="noopener" aria-label="Get PMOS" title="Get PMOS for yourself">' + GET + '</a>';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The one line that says what this copy is, the way back to the team's hub (R8), and back to PMOS; then the tabs when the link holds several. */
|
|
89
122
|
function banner() {
|
|
90
123
|
const main = document.getElementById('stage'); if (!main || !PACK || document.querySelector('.ro-by')) return;
|
|
91
124
|
const s = PACK.scope || {}; const d = document.createElement('div'); d.className = 'ro-by';
|
|
92
|
-
d.innerHTML = (PACK.hub ? '<a class="ro-back" href="' + esc(PACK.hub) + '">← Shared boards</a> · ' : '') + '<b>' + esc(s.label) + '</b> · ' + esc(KIND[s.kind] || '') + ' · a read-only copy of ' + esc(PACK.at)
|
|
93
|
-
+ ' · shared by ' + esc(PACK.by) + ' · the link ends ' + esc(PACK.ends);
|
|
125
|
+
d.innerHTML = '<span class="ro-txt">' + (PACK.hub ? '<a class="ro-back" href="' + esc(PACK.hub) + '">← Shared boards</a> · ' : '') + '<b>' + esc(s.label) + '</b> · ' + esc(KIND[s.kind] || '') + ' · a read-only copy of ' + esc(PACK.at)
|
|
126
|
+
+ ' · shared by ' + esc(PACK.by) + ' · the link ends ' + esc(PACK.ends) + '</span><span class="ro-ways">' + waysHtml(linkOf(location)) + '</span>';
|
|
94
127
|
main.insertBefore(d, main.firstChild);
|
|
128
|
+
const tabs = tabsHtml(PACK, location);
|
|
129
|
+
if (tabs) d.insertAdjacentHTML('afterend', tabs);
|
|
95
130
|
}
|
|
96
131
|
|
|
97
132
|
function start() {
|
|
@@ -4,13 +4,28 @@
|
|
|
4
4
|
* to the session over the page's WebSocket, as the sign-in terminal does (cozy-claude-setup.js signInTerminal), so keys typed in it reach the
|
|
5
5
|
* session — a first-run prompt, a pasted sign-in code, an answer to Claude. Before, it was read only, and a new user whose Claude stopped at its
|
|
6
6
|
* theme or trust-this-folder screen could not answer it.
|
|
7
|
+
* It fills the whole card and fits its grid to it, again whenever the card or the window changes size (batch 27 item 6), and only the keys
|
|
8
|
+
* the PM types reach the session: xterm.js's own answers to the queries in the replayed screen do not (chat-term.js typedOnly, item 5).
|
|
7
9
|
* An ENDED session, or a page where the socket cannot attach, keeps the read-only view: GET /api/sessions/transcript?id=<sid>&raw=1 every 2 s,
|
|
8
10
|
* drawn in xterm.js the size of the session's own, after the first read only what was printed since (since + gen). When xterm.js cannot load,
|
|
9
11
|
* it falls back to the plain text (GET …&max=12000, ANSI taken out). */
|
|
10
12
|
import { open, current, setKind } from './cozy-viewer.js';
|
|
11
13
|
import { pmGet, pageNonce } from './http.js';
|
|
14
|
+
import { typedOnly } from './chat-term.js';
|
|
12
15
|
|
|
13
|
-
let xtermP = null;
|
|
16
|
+
let xtermP = null, fitP = null;
|
|
17
|
+
/** window.FitAddon from /vendor/addon-fit.js, loaded once. Resolves null when it cannot load: the live terminal then keeps the session's grid. */
|
|
18
|
+
function loadFit() {
|
|
19
|
+
if (window.FitAddon) return Promise.resolve(window.FitAddon);
|
|
20
|
+
if (fitP) return fitP;
|
|
21
|
+
fitP = new Promise((done) => {
|
|
22
|
+
const s = document.createElement('script'); s.src = '/vendor/addon-fit.js';
|
|
23
|
+
s.onload = () => done(window.FitAddon || null);
|
|
24
|
+
s.onerror = () => { fitP = null; done(null); };
|
|
25
|
+
document.head.appendChild(s);
|
|
26
|
+
});
|
|
27
|
+
return fitP;
|
|
28
|
+
}
|
|
14
29
|
/** window.Terminal from /vendor/xterm.js, loaded once with its stylesheet. Resolves null when it cannot load. */
|
|
15
30
|
function loadXterm() {
|
|
16
31
|
if (window.Terminal) return Promise.resolve(window.Terminal);
|
|
@@ -39,14 +54,15 @@ function look(el) {
|
|
|
39
54
|
|
|
40
55
|
/** The live terminal: attach over the WebSocket and send what the PM types. Resolves true when it attached to a running session,
|
|
41
56
|
* false when the session has ended or the socket did not answer (the caller then shows the read-only view). */
|
|
42
|
-
function attachLive(Terminal, body, sid, live, keep) {
|
|
57
|
+
function attachLive(Terminal, Fit, body, sid, live, keep) {
|
|
43
58
|
return new Promise((done) => {
|
|
59
|
+
let ro = null;
|
|
44
60
|
let sock;
|
|
45
61
|
try { sock = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/?pmos-nonce=' + encodeURIComponent(pageNonce())); } catch { done(false); return; }
|
|
46
62
|
const send = (d) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify(d)); };
|
|
47
63
|
let term = null, settled = false;
|
|
48
64
|
const settle = (v) => { if (!settled) { settled = true; clearTimeout(t); done(v); } };
|
|
49
|
-
const close = () => { try { send({ t: 'detach', id: sid }); sock.close(); } catch { /* gone */ } if (term) { try { term.dispose(); } catch { /* gone */ } term = null; } };
|
|
65
|
+
const close = () => { if (ro) { ro.disconnect(); ro = null; } try { send({ t: 'detach', id: sid }); sock.close(); } catch { /* gone */ } if (term) { try { term.dispose(); } catch { /* gone */ } term = null; } };
|
|
50
66
|
const t = setTimeout(() => { close(); settle(false); }, 1500); // a session the server does not know never answers
|
|
51
67
|
sock.addEventListener('open', () => send({ t: 'attach', id: sid }));
|
|
52
68
|
sock.addEventListener('error', () => { close(); settle(false); });
|
|
@@ -60,14 +76,29 @@ function attachLive(Terminal, body, sid, live, keep) {
|
|
|
60
76
|
term = new Terminal({ cols: m.cols || 80, rows: m.rows || 24, cursorBlink: true, scrollback: 5000, fontFamily: L.fontFamily, lineHeight: 1.25, theme: { ...L.theme, cursor: L.theme.foreground } });
|
|
61
77
|
term.open(host);
|
|
62
78
|
if (m.buf) term.write(m.buf, () => term && term.scrollToBottom());
|
|
63
|
-
|
|
64
|
-
|
|
79
|
+
// only the keys the PM types reach the session: the terminal's own answers to the queries in the replayed screen stay here (chat-term.js typedOnly)
|
|
80
|
+
term.onData((d) => { const k = typedOnly(d); if (k) send({ t: 'input', id: sid, data: k }); });
|
|
81
|
+
// the terminal fills the whole card and fits its lines to it, again on every resize (batch 27 item 6, Wawan 2026-11-04: "Terminal
|
|
82
|
+
// should occupy the whole width and height"). The session has one grid, so the agent redraws at the card's size.
|
|
83
|
+
if (Fit) {
|
|
84
|
+
const fit = new Fit.FitAddon(); term.loadAddon(fit);
|
|
85
|
+
let t0 = null, sent = { cols: term.cols, rows: term.rows };
|
|
86
|
+
const refit = () => {
|
|
87
|
+
if (!term || !host.isConnected) return;
|
|
88
|
+
try { fit.fit(); } catch { return; }
|
|
89
|
+
if (term.cols !== sent.cols || term.rows !== sent.rows) { sent = { cols: term.cols, rows: term.rows }; send({ t: 'resize', id: sid, cols: term.cols, rows: term.rows }); }
|
|
90
|
+
};
|
|
91
|
+
refit();
|
|
92
|
+
ro = new ResizeObserver(() => { clearTimeout(t0); t0 = setTimeout(refit, 80); });
|
|
93
|
+
ro.observe(host);
|
|
94
|
+
}
|
|
65
95
|
keep(close);
|
|
66
96
|
term.focus();
|
|
67
97
|
settle(true);
|
|
68
98
|
}
|
|
69
99
|
if (m.t === 'data' && m.id === sid && term) { const b = term.buffer.active, atEnd = b.viewportY >= b.baseY; term.write(m.data, () => { if (atEnd && term) term.scrollToBottom(); }); }
|
|
70
|
-
|
|
100
|
+
// another screen resized the session: this card takes the size back at its next fit (a resize of the card or the window)
|
|
101
|
+
if (m.t === 'size' && m.id === sid && term && !ro) { try { term.resize(m.cols, m.rows); } catch { /* keep */ } }
|
|
71
102
|
if (m.t === 'error' && !term) { close(); settle(false); }
|
|
72
103
|
});
|
|
73
104
|
});
|
|
@@ -117,9 +148,9 @@ export function openTermView(sid, o = {}) {
|
|
|
117
148
|
};
|
|
118
149
|
|
|
119
150
|
(async () => {
|
|
120
|
-
const Terminal = await loadXterm();
|
|
151
|
+
const [Terminal, Fit] = await Promise.all([loadXterm(), loadFit()]);
|
|
121
152
|
if (!Terminal) plain = true;
|
|
122
|
-
else if (await attachLive(Terminal, body, sid, live, (c) => { closeLive = c; })) return; // a running session: the real terminal
|
|
153
|
+
else if (await attachLive(Terminal, Fit, body, sid, live, (c) => { closeLive = c; })) return; // a running session: the real terminal
|
|
123
154
|
if (!live()) return;
|
|
124
155
|
// an ended session (or no socket): the read-only view, and the card says so
|
|
125
156
|
setKind(id, 'Terminal · read only');
|