create-pmos 0.8.0 → 0.9.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/README.md +24 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/skills/pmos-router.skill +22 -0
- package/payload/skills-native/pmos-router/SKILL.md +22 -0
- package/workspace/README.md +28 -0
- package/workspace/lib/console-state.js +56 -1
- package/workspace/lib/git-info.js +197 -25
- package/workspace/lib/session-state.js +139 -0
- package/workspace/lib/transcript-read.js +28 -6
- package/workspace/public/app.js +314 -58
- package/workspace/public/index.html +24 -3
- package/workspace/public/styles.css +875 -572
- package/workspace/public/vendor/ibm-plex-mono-400.woff2 +0 -0
- package/workspace/public/vendor/ibm-plex-mono-700.woff2 +0 -0
- package/workspace/public/vendor/space-grotesk-var.woff2 +0 -0
- package/workspace/server.js +338 -39
package/workspace/public/app.js
CHANGED
|
@@ -28,6 +28,9 @@ const state = {
|
|
|
28
28
|
layoutId: null, // the one currently applied
|
|
29
29
|
layoutSaveT: null, // debounce handle for the write-back
|
|
30
30
|
layoutDirty: false, // an adjustment not yet on disk — flushed on pagehide
|
|
31
|
+
scratchKey: null, // whose scratchpad is on screen — 'session:<id>' or 'ws:<id>'
|
|
32
|
+
scratchT: null, // debounce handle for the pad's write-back (like layoutSaveT)
|
|
33
|
+
scratchPending: null, // {session,ws,text} typed but not yet written — flushed on a switch
|
|
31
34
|
terms: new Map(), // id -> {term, fit, el, g}
|
|
32
35
|
attached: new Set(), // ids attached ON THIS CONNECTION (cleared on reconnect)
|
|
33
36
|
needs: [],
|
|
@@ -38,6 +41,7 @@ const state = {
|
|
|
38
41
|
fsDir: null, // the in-app folder browser's current directory
|
|
39
42
|
ctrlArmed: false, // mobile control bar: Ctrl is a one-shot modifier for the next key
|
|
40
43
|
branch: null, // {ok,current,branches[],detached,dirty} for the selected workspace
|
|
44
|
+
worktrees: [], // the selected workspace's repo's working trees, with removability
|
|
41
45
|
newBranchMode: false, // new-session form: creating a branch vs picking an existing one
|
|
42
46
|
sessSig: null, // structural fingerprint of the last session push (see structSig)
|
|
43
47
|
};
|
|
@@ -151,8 +155,13 @@ function handle(m) {
|
|
|
151
155
|
// server pushes a fresh 'sessions'; jump to the new one
|
|
152
156
|
selectSession(m.id);
|
|
153
157
|
} else if (m.t === 'branch') {
|
|
154
|
-
// a checkout changed the repo's branch — refresh the chip if it's the workspace on screen
|
|
155
|
-
|
|
158
|
+
// a checkout changed the repo's branch — refresh the chip if it's the workspace on screen,
|
|
159
|
+
// and the picker too when the form is open (a stale branch list is how a PM picks wrong)
|
|
160
|
+
if (m.ws === state.wsId) { state.branch = m; renderBranch(); loadWorktrees(); if (!$('newForm').hidden) fillBranchPicker(); }
|
|
161
|
+
} else if (m.t === 'notice') {
|
|
162
|
+
// something non-fatal but worth knowing: where a worktree landed, or that a branch was
|
|
163
|
+
// ignored because the workspace has no git. Never a blocked session, never a silent one.
|
|
164
|
+
flash(m.notice, 12000);
|
|
156
165
|
} else if (m.t === 'error') {
|
|
157
166
|
// a rejected action (e.g. a failed branch checkout that must NOT spawn) — say so, don't swallow
|
|
158
167
|
flash(m.error || 'the server rejected that action');
|
|
@@ -169,6 +178,9 @@ async function loadBranch() {
|
|
|
169
178
|
try { const r = await fetch('/api/git/branches?ws=' + encodeURIComponent(id || '')); info = await r.json(); } catch {}
|
|
170
179
|
if (id !== state.wsId) return; // a slower fetch for a workspace we've since left must not paint
|
|
171
180
|
state.branch = info; renderBranch();
|
|
181
|
+
// Refill the open form ONLY while the PM has not chosen anything yet — a refresh landing a beat
|
|
182
|
+
// after a quick selection must never silently reset it back to "stay on current".
|
|
183
|
+
if (!$('newForm').hidden && !$('newBranch').value && !state.newBranchMode) fillBranchPicker();
|
|
172
184
|
}
|
|
173
185
|
function renderBranch() {
|
|
174
186
|
const el = $('branchChip'); if (!el) return;
|
|
@@ -176,8 +188,12 @@ function renderBranch() {
|
|
|
176
188
|
if (!b || !b.ok) { el.hidden = true; el.textContent = ''; el.title = ''; return; }
|
|
177
189
|
const label = b.detached ? 'detached HEAD' : (b.current || '');
|
|
178
190
|
el.hidden = !label;
|
|
179
|
-
|
|
180
|
-
|
|
191
|
+
// `⑂` marks a workspace that IS an isolated worktree — the PM should be able to see at a glance
|
|
192
|
+
// that this window's branch is its own, not the shared one.
|
|
193
|
+
el.textContent = (b.linked ? '⑂ ' : '⎇ ') + label + (b.dirty ? '*' : '');
|
|
194
|
+
const where = b.linked ? 'this workspace has its own working tree — switching here moves only its sessions'
|
|
195
|
+
: 'shared working tree — switching here moves every session in this repo';
|
|
196
|
+
el.title = (b.dirty ? 'uncommitted changes · ' : '') + where;
|
|
181
197
|
el.classList.toggle('dirty', !!b.dirty);
|
|
182
198
|
}
|
|
183
199
|
|
|
@@ -196,8 +212,10 @@ async function selectWorkspace(id, opts = {}) {
|
|
|
196
212
|
const pick = mine.find((s) => s.id === remembered) || mine.find((s) => s.state !== 'exited') || mine[0];
|
|
197
213
|
if (pick) selectSession(pick.id); else clearActive();
|
|
198
214
|
// the arrangement belongs to the repo (R7) — a different workspace brings its own layouts
|
|
199
|
-
|
|
200
|
-
|
|
215
|
+
// the scratchpad follows the ACTIVE TAB, and selectSession/clearActive above have already
|
|
216
|
+
// settled which that is — syncActive() reloaded it, so there is nothing to do here
|
|
217
|
+
if (changed) { state.branch = null; renderBranch(); state.worktrees = []; renderWorktrees(); loadLayouts(); }
|
|
218
|
+
await Promise.all([loadLibrary(), loadNeeds(), loadBranch(), loadWorktrees()]);
|
|
201
219
|
}
|
|
202
220
|
function renderWorkspaces() {
|
|
203
221
|
const html = state.workspaces.map((w) => {
|
|
@@ -235,16 +253,21 @@ async function addWorkspace(root) {
|
|
|
235
253
|
return true;
|
|
236
254
|
} catch { flash('could not reach the server'); return false; }
|
|
237
255
|
}
|
|
238
|
-
|
|
256
|
+
// `ms` is longer for messages carrying something to READ — a worktree's path is useless if it
|
|
257
|
+
// vanishes before the PM can copy it out.
|
|
258
|
+
function flash(msg, ms = 4000) {
|
|
239
259
|
const el = $('wsFoot'); const prev = el.textContent;
|
|
240
260
|
el.textContent = msg;
|
|
241
|
-
setTimeout(() => { el.textContent = prev; },
|
|
261
|
+
setTimeout(() => { el.textContent = prev; }, ms);
|
|
242
262
|
}
|
|
243
263
|
|
|
244
|
-
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
264
|
+
// The glyph and the word both come from the SHARED join module (/lib/session-state.js), never
|
|
265
|
+
// re-typed here. `quiet` still means no output for a while and still NEVER means "waiting on you"
|
|
266
|
+
// (harness-tower R1) — the stronger words exist only where the transcript corroborates the
|
|
267
|
+
// silence, which is a different cell of the table, not a relabelling of this one.
|
|
268
|
+
const SS = window.PMOSSessionState;
|
|
269
|
+
const GLYPH = SS.GLYPH;
|
|
270
|
+
const STATE_WORD = SS.WORD;
|
|
248
271
|
// The threshold the server derives `quiet` from, sent in `hello`. It used to be re-typed here as a
|
|
249
272
|
// literal "mirrors harness-parse.QUIET_AFTER_MS" — two copies of one number, free to drift apart and
|
|
250
273
|
// give the sidebar and the server different opinions about the same session. The literal survives
|
|
@@ -257,37 +280,42 @@ function shortDur(ms) {
|
|
|
257
280
|
const min = Math.round(sec / 60);
|
|
258
281
|
return min < 60 ? min + 'm' : Math.round(min / 60) + 'h';
|
|
259
282
|
}
|
|
260
|
-
//
|
|
261
|
-
// on lifecycle events; a periodic full re-render would
|
|
262
|
-
// input (the defect the pre-promotion grilling caught), so
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (
|
|
283
|
+
// The CADENCE clock, derived client-side too so a row can change WITHOUT a server push. The server
|
|
284
|
+
// only pushes on lifecycle events plus the liveness heartbeat; a periodic full re-render would
|
|
285
|
+
// destroy an open drill-in and the rename input (the defect the pre-promotion grilling caught), so
|
|
286
|
+
// the ticker below repaints chips only. This is one of the join's two inputs — never the answer.
|
|
287
|
+
function cadenceState(s) {
|
|
288
|
+
if (s.state === 'exited') return { cadence: 'exited', quietMs: null };
|
|
289
|
+
if (!s.lastOutputAt) return { cadence: 'working', quietMs: null };
|
|
266
290
|
const q = Math.max(0, Date.now() - s.lastOutputAt);
|
|
267
|
-
return q >= QUIET_AFTER_MS ? {
|
|
268
|
-
}
|
|
269
|
-
// The open call's
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
// is
|
|
275
|
-
|
|
276
|
-
|
|
291
|
+
return q >= QUIET_AFTER_MS ? { cadence: 'quiet', quietMs: q } : { cadence: 'working', quietMs: q };
|
|
292
|
+
}
|
|
293
|
+
// The open call's verb + AGE. `working · Bash 4m` is the first honest signal for HUNG the workspace
|
|
294
|
+
// has; the weak claim's `· Bash 6m` is the same fact once the bytes stop, and is the whole content
|
|
295
|
+
// of that claim. WHICH states may carry it is the shared module's call (CARRIES_CALL_AGE), not a
|
|
296
|
+
// condition re-typed here — as the words themselves are its call, never literals in this file.
|
|
297
|
+
//
|
|
298
|
+
// The honesty bound is structural, not a check bolted on: the age rides states that require a LIVE
|
|
299
|
+
// cadence or an explicitly-suspect one, and `needs` is not among them — so a crashed session's
|
|
300
|
+
// dangling tool_use (a permanent phantom) can never dress itself as a live age on the strong claim.
|
|
301
|
+
// `sinceTs` comes from the transcript record; we tick it client-side like the quiet clock.
|
|
302
|
+
function openCallOf(s) {
|
|
277
303
|
const oc = s.hx && s.hx.openCall;
|
|
278
|
-
if (!oc || !oc.sinceTs) return
|
|
304
|
+
if (!oc || !oc.sinceTs) return null;
|
|
279
305
|
const ms = Date.parse(oc.sinceTs);
|
|
280
|
-
if (!Number.isFinite(ms)) return
|
|
306
|
+
if (!Number.isFinite(ms)) return null;
|
|
281
307
|
const age = Date.now() - ms;
|
|
282
|
-
if (age < 0) return
|
|
283
|
-
return
|
|
308
|
+
if (age < 0) return null;
|
|
309
|
+
return { verb: oc.verb, ageMs: age };
|
|
284
310
|
}
|
|
311
|
+
// THE JOIN, client side — the same module the server uses (R6). Two inputs: the cadence clock above
|
|
312
|
+
// and the transcript's answer to whose turn it is. Neither the table nor the words are duplicated
|
|
313
|
+
// here; if they were, the sidebar and the server could drift into different opinions about one
|
|
314
|
+
// session, which is exactly what shipping QUIET_AFTER_MS from `hello` was done to prevent.
|
|
285
315
|
function stateLabel(s) {
|
|
286
|
-
const {
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
if (shown === 'working') return { shown, text: `${w}${callAge(s, shown)}` };
|
|
290
|
-
return { shown, text: w };
|
|
316
|
+
const { cadence, quietMs } = cadenceState(s);
|
|
317
|
+
const shown = SS.presented(cadence, s.hx && s.hx.turn);
|
|
318
|
+
return { shown, text: SS.label(shown, { quietMs, call: openCallOf(s), fmtDur: shortDur }) };
|
|
291
319
|
}
|
|
292
320
|
function renderSessions() {
|
|
293
321
|
const mine = mySessions();
|
|
@@ -529,6 +557,7 @@ function syncActiveState() {
|
|
|
529
557
|
});
|
|
530
558
|
updateExitedOverlay();
|
|
531
559
|
refreshScratchSend(); // the scratchpad's send button follows the active session's liveness
|
|
560
|
+
renderScratchWho(); // …and its label follows the active tab's name, including a rename
|
|
532
561
|
}
|
|
533
562
|
|
|
534
563
|
// ── renaming a session (R11): inline, in both surfaces, live or exited. The name chosen at
|
|
@@ -584,7 +613,7 @@ const GROUP_TABS = ['stabs', 'stabs1'];
|
|
|
584
613
|
function groupOf(id) { const t = state.terms.get(id); return t ? t.g : 0; }
|
|
585
614
|
function groupHost(g) { return $(GROUP_HOST[g] || GROUP_HOST[0]); }
|
|
586
615
|
function splitOn() { return !$('group1').hidden; }
|
|
587
|
-
function syncActive() { state.active = state.groups[state.focusG].activeId; }
|
|
616
|
+
function syncActive() { state.active = state.groups[state.focusG].activeId; syncScratchpad(); }
|
|
588
617
|
|
|
589
618
|
function ensureTerm(id, g) {
|
|
590
619
|
const want = typeof g === 'number' ? g : 0;
|
|
@@ -646,7 +675,7 @@ function selectSession(id, g) {
|
|
|
646
675
|
const s = state.sessions.find((x) => x.id === id);
|
|
647
676
|
if (!s) return;
|
|
648
677
|
// selecting a session from the palette may cross workspaces — follow it there
|
|
649
|
-
if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); state.branch = null; renderBranch(); loadBranch();
|
|
678
|
+
if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); state.branch = null; renderBranch(); loadBranch(); loadLayouts(); }
|
|
650
679
|
// Which group shows it: the one asked for; else the group already showing it (so a click in the
|
|
651
680
|
// sidebar focuses where it lives rather than yanking it across); else the focused group.
|
|
652
681
|
let want = typeof g === 'number' ? g : state.groups.findIndex((gr) => gr.activeId === id);
|
|
@@ -1078,6 +1107,8 @@ function flushLayouts() {
|
|
|
1078
1107
|
} catch {}
|
|
1079
1108
|
}
|
|
1080
1109
|
window.addEventListener('pagehide', flushLayouts);
|
|
1110
|
+
// a pad typed into and closed within the debounce window is still the PM's text — write it
|
|
1111
|
+
window.addEventListener('pagehide', () => flushScratchpad());
|
|
1081
1112
|
document.addEventListener('visibilitychange', () => { if (document.hidden) flushLayouts(); });
|
|
1082
1113
|
|
|
1083
1114
|
// ── the layout menu ──
|
|
@@ -1598,7 +1629,8 @@ function openNewForm() {
|
|
|
1598
1629
|
// pre-filled but editable — a default that reads better than "shell" in a list of shells
|
|
1599
1630
|
const n = mySessions().length + 1;
|
|
1600
1631
|
$('newName').value = `${w ? w.name : 'session'} ${n}`;
|
|
1601
|
-
fillBranchPicker(); //
|
|
1632
|
+
fillBranchPicker(); // paint immediately from what we already know…
|
|
1633
|
+
loadBranch(); // …then re-fetch: branches made in a terminal must show up here
|
|
1602
1634
|
const inp = $('newName');
|
|
1603
1635
|
setTimeout(() => { inp.focus(); inp.select(); }, 0);
|
|
1604
1636
|
}
|
|
@@ -1606,29 +1638,148 @@ function closeNewForm() {
|
|
|
1606
1638
|
$('newForm').hidden = true;
|
|
1607
1639
|
$('newBtn').setAttribute('aria-expanded', 'false');
|
|
1608
1640
|
}
|
|
1609
|
-
// Populate the branch control from state.branch. A
|
|
1610
|
-
//
|
|
1641
|
+
// Populate the branch control from state.branch. A workspace with no git hides the whole control
|
|
1642
|
+
// and says so once, plainly — a silently missing control reads as "broken", and a PM working in a
|
|
1643
|
+
// tenant product repo that has no git should never be asked a branch question at all. The default
|
|
1644
|
+
// is always "stay on current": starting a session must never require a git decision.
|
|
1611
1645
|
function fillBranchPicker() {
|
|
1612
|
-
const wrap = $('newBranchWrap'), sel = $('newBranch');
|
|
1646
|
+
const wrap = $('newBranchWrap'), sel = $('newBranch'), nogit = $('newNoGit');
|
|
1613
1647
|
const b = state.branch;
|
|
1614
|
-
if (!b || !b.ok) {
|
|
1648
|
+
if (!b || !b.ok) {
|
|
1649
|
+
wrap.hidden = true;
|
|
1650
|
+
// `no-git` is an ordinary folder, not a fault: one muted line, no warning colour. A real git
|
|
1651
|
+
// error (unreadable HEAD, git missing) says what it was instead of pretending all is well.
|
|
1652
|
+
nogit.hidden = !b || !b.code;
|
|
1653
|
+
nogit.textContent = b && b.code === 'git-error'
|
|
1654
|
+
? `git could not read this folder (${b.error}) — the session starts here, with no branch applied`
|
|
1655
|
+
: 'no git in this folder — nothing to branch, the session just starts here';
|
|
1656
|
+
nogit.classList.toggle('warn', !!b && b.code === 'git-error');
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
nogit.hidden = true;
|
|
1615
1660
|
wrap.hidden = false;
|
|
1616
1661
|
const cur = b.detached ? '' : (b.current || '');
|
|
1662
|
+
// a branch checked out in another worktree cannot be checked out again — git refuses, so the
|
|
1663
|
+
// picker says where it lives rather than letting the PM discover it as an error
|
|
1664
|
+
const elsewhere = new Map((b.worktrees || [])
|
|
1665
|
+
.filter((w) => w.branch && w.branch !== cur).map((w) => [w.branch, w.dir]));
|
|
1617
1666
|
const opts = [`<option value="">stay on current${cur ? ' (' + esc(cur) + ')' : ''}</option>`]
|
|
1618
|
-
.concat((b.branches || []).map((n) =>
|
|
1667
|
+
.concat((b.branches || []).map((n) => {
|
|
1668
|
+
const where = elsewhere.get(n);
|
|
1669
|
+
const tail = n === cur ? ' — current' : (where ? ' — open in ' + shortPath(where) : '');
|
|
1670
|
+
return `<option value="${esc(n)}"${n === cur ? ' disabled' : ''}>${esc(n)}${esc(tail)}</option>`;
|
|
1671
|
+
}))
|
|
1619
1672
|
.concat([`<option value="__new__">+ new branch…</option>`]);
|
|
1620
1673
|
sel.innerHTML = opts.join('');
|
|
1621
1674
|
sel.value = '';
|
|
1675
|
+
// Isolation protects OTHER sessions — so default it on only when there are others to protect.
|
|
1676
|
+
// Alone in a workspace a plain checkout disturbs nobody and leaves no folder behind, which is
|
|
1677
|
+
// what stops a directory accumulating per branch. Either way the PM can flip it.
|
|
1678
|
+
$('newIsolate').checked = mySessions().some((s) => s.state !== 'exited');
|
|
1622
1679
|
setNewBranchMode(false);
|
|
1623
1680
|
}
|
|
1681
|
+
// last two segments of a path — enough to recognise, short enough for a <option> or a hint line
|
|
1682
|
+
function shortPath(p) { const parts = String(p).split('/').filter(Boolean); return parts.slice(-2).join('/'); }
|
|
1683
|
+
|
|
1684
|
+
// ── worktree housekeeping (R7): isolation buys "your other sessions keep running" at the price of
|
|
1685
|
+
// a folder per branch. That price is only fair if the folders are visible and reclaimable from
|
|
1686
|
+
// here — a delete is never a guess, so a tree that is busy, dirty, or holds commits nothing else
|
|
1687
|
+
// holds is refused with the reason spelled out, not silently greyed. ──
|
|
1688
|
+
async function loadWorktrees() {
|
|
1689
|
+
const id = state.wsId;
|
|
1690
|
+
let r = { ok: false };
|
|
1691
|
+
try { r = await (await fetch('/api/git/worktrees?ws=' + encodeURIComponent(id || ''))).json(); } catch {}
|
|
1692
|
+
if (id !== state.wsId) return;
|
|
1693
|
+
state.worktrees = r.ok ? (r.worktrees || []) : [];
|
|
1694
|
+
renderWorktrees();
|
|
1695
|
+
}
|
|
1696
|
+
function renderWorktrees() {
|
|
1697
|
+
const box = $('wtBox'); if (!box) return;
|
|
1698
|
+
const list = (state.worktrees || []).filter((t) => !t.main);
|
|
1699
|
+
box.hidden = list.length === 0; // a repo with one working tree has nothing to manage
|
|
1700
|
+
if (!list.length) return;
|
|
1701
|
+
$('wtSummary').textContent = `⑂ ${list.length} worktree${list.length === 1 ? '' : 's'}`;
|
|
1702
|
+
$('wtList').innerHTML = list.map((t) => `
|
|
1703
|
+
<div class="wtrow" data-dir="${esc(t.dir)}">
|
|
1704
|
+
<span class="wtb" title="${esc(t.dir)}">${esc(t.branch || 'detached')}</span>
|
|
1705
|
+
<button type="button" data-rm="${esc(t.dir)}"${t.removable ? '' : ' disabled'}
|
|
1706
|
+
title="${esc(t.removable ? 'Remove ' + t.dir : t.reasons.join('; '))}">remove</button>
|
|
1707
|
+
${t.removable ? '' : `<span class="wtwhy">kept — ${esc(t.reasons.join('; '))}</span>`}
|
|
1708
|
+
</div>`).join('');
|
|
1709
|
+
$('wtList').querySelectorAll('[data-rm]').forEach((b) => { b.onclick = () => removeWorktree(b.dataset.rm); });
|
|
1710
|
+
}
|
|
1711
|
+
async function removeWorktree(dir) {
|
|
1712
|
+
// The server re-checks every hazard; this confirm exists because a folder disappearing from
|
|
1713
|
+
// under the PM should be something they said yes to, not a side effect of a mis-click.
|
|
1714
|
+
if (!confirm(`Remove the worktree at\n${dir}\n\nThe branch itself is kept — only the folder goes.`)) return;
|
|
1715
|
+
let r = { ok: false, error: 'request failed' };
|
|
1716
|
+
try {
|
|
1717
|
+
r = await (await fetch('/api/git/worktrees/remove', { method: 'POST',
|
|
1718
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1719
|
+
body: JSON.stringify({ ws: state.wsId, dir }) })).json();
|
|
1720
|
+
} catch {}
|
|
1721
|
+
flash(r.ok ? (r.pruned ? 'cleared git’s record of a folder that was already gone' : 'worktree removed — the branch is still there')
|
|
1722
|
+
: (r.error || 'the worktree was not removed'), r.ok ? 6000 : 12000);
|
|
1723
|
+
loadWorktrees();
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1624
1726
|
function setNewBranchMode(on) {
|
|
1625
1727
|
state.newBranchMode = on;
|
|
1626
1728
|
$('newBranchName').hidden = !on;
|
|
1627
1729
|
if (on) setTimeout(() => $('newBranchName').focus(), 0);
|
|
1730
|
+
renderBranchHint();
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// The one line that decides whether the PM trusts this control. It changes with the choice, because
|
|
1734
|
+
// the three choices do genuinely different things:
|
|
1735
|
+
// stay → nothing happens to git at all
|
|
1736
|
+
// isolate (default)→ the branch gets its own folder; running sessions are untouched
|
|
1737
|
+
// switch this repo → every session in this repo moves, and so does every other workspace on it
|
|
1738
|
+
function renderBranchHint() {
|
|
1739
|
+
const hint = $('newBranchHint'), iso = $('newIsolate'), isoWrap = $('newIsolateWrap');
|
|
1740
|
+
const b = state.branch; if (!hint || !b || !b.ok) return;
|
|
1741
|
+
const sel = $('newBranch');
|
|
1742
|
+
const picked = sel.value === '__new__' ? ($('newBranchName').value.trim() || 'the new branch') : sel.value;
|
|
1743
|
+
const cur = b.detached ? 'a detached HEAD' : (b.current || 'the current branch');
|
|
1744
|
+
|
|
1745
|
+
if (!sel.value) { // "stay on current" — no git action, so no warning
|
|
1746
|
+
isoWrap.hidden = true;
|
|
1747
|
+
hint.hidden = false; hint.classList.remove('warn');
|
|
1748
|
+
hint.textContent = `runs on ${cur} — nothing is checked out, nothing else moves`;
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
isoWrap.hidden = false;
|
|
1752
|
+
hint.hidden = false;
|
|
1753
|
+
if (iso.checked) {
|
|
1754
|
+
const dir = worktreeHint(picked);
|
|
1755
|
+
hint.classList.remove('warn');
|
|
1756
|
+
hint.textContent = `${picked} gets its own folder${dir ? ' at ' + dir : ''} — every running session stays on its own branch`;
|
|
1757
|
+
} else {
|
|
1758
|
+
// How alarming this is depends entirely on who is here. Warning about "every session" when
|
|
1759
|
+
// the PM is the only one running teaches them to ignore the warning that matters.
|
|
1760
|
+
const others = mySessions().filter((s) => s.state !== 'exited').length;
|
|
1761
|
+
const extra = b.subdir ? ` (the repo is ${shortPath(b.repoRoot || '')}, this workspace is only ${b.subdir}/ inside it)` : '';
|
|
1762
|
+
hint.classList.toggle('warn', others > 0);
|
|
1763
|
+
hint.textContent = others > 0
|
|
1764
|
+
? `switches this whole repo to ${picked} — ${others} running session${others === 1 ? '' : 's'} here move${others === 1 ? 's' : ''} with it${extra}`
|
|
1765
|
+
: `checks out ${picked} here — nothing else is running in this workspace, and no new folder is made${extra}`;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
// Mirror of the server's worktreePath, for the hint only — the server is the authority and reports
|
|
1769
|
+
// the real path back in its notice. Kept in sync deliberately: a PM deciding where work lands
|
|
1770
|
+
// should see the answer before committing to it, not after.
|
|
1771
|
+
function worktreeHint(branch) {
|
|
1772
|
+
const b = state.branch;
|
|
1773
|
+
if (!b || !b.ok || !b.repoRoot || !branch || branch === 'the new branch') return '';
|
|
1774
|
+
const parts = String(b.repoRoot).split('/').filter(Boolean);
|
|
1775
|
+
const name = parts[parts.length - 1] || 'repo';
|
|
1776
|
+
return `../${name}-worktrees/${branch.replace(/\//g, '-')}`;
|
|
1628
1777
|
}
|
|
1629
1778
|
$('newBtn').onclick = () => ($('newForm').hidden ? openNewForm() : closeNewForm());
|
|
1630
1779
|
$('newCancel').onclick = closeNewForm;
|
|
1631
1780
|
$('newBranch').onchange = (e) => setNewBranchMode(e.target.value === '__new__');
|
|
1781
|
+
$('newIsolate').onchange = renderBranchHint;
|
|
1782
|
+
$('newBranchName').oninput = renderBranchHint; // the hint names the folder as the PM types it
|
|
1632
1783
|
$('newAgents').querySelectorAll('button').forEach((b) => b.onclick = () => {
|
|
1633
1784
|
state.newAgent = b.dataset.agent;
|
|
1634
1785
|
$('newAgents').querySelectorAll('button').forEach((x) => x.setAttribute('aria-checked', String(x === b)));
|
|
@@ -1637,7 +1788,9 @@ $('newForm').addEventListener('submit', (e) => {
|
|
|
1637
1788
|
e.preventDefault();
|
|
1638
1789
|
const name = $('newName').value.trim();
|
|
1639
1790
|
const msg = { t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId };
|
|
1640
|
-
// branch: either create a new one, or check out an existing pick. Empty = stay put (no
|
|
1791
|
+
// branch: either create a new one, or check out an existing pick. Empty = stay put (no git call
|
|
1792
|
+
// at all) — and that is what a hidden control means too, so a non-git workspace can never have a
|
|
1793
|
+
// stale pick from a previous repo smuggled onto it.
|
|
1641
1794
|
const sel = $('newBranch');
|
|
1642
1795
|
if (!$('newBranchWrap').hidden && sel) {
|
|
1643
1796
|
if (sel.value === '__new__') {
|
|
@@ -1647,29 +1800,75 @@ $('newForm').addEventListener('submit', (e) => {
|
|
|
1647
1800
|
} else if (sel.value) {
|
|
1648
1801
|
msg.branch = sel.value;
|
|
1649
1802
|
}
|
|
1803
|
+
// isolate → the branch gets its own worktree and running sessions are left alone. Only
|
|
1804
|
+
// meaningful when a branch was actually chosen.
|
|
1805
|
+
if (msg.branch) msg.isolate = !!$('newIsolate').checked;
|
|
1650
1806
|
}
|
|
1651
1807
|
send(msg);
|
|
1652
1808
|
closeNewForm();
|
|
1653
1809
|
});
|
|
1654
1810
|
|
|
1655
|
-
// ── scratchpad (R4):
|
|
1656
|
-
|
|
1811
|
+
// ── scratchpad (R4): PER-TAB staging text; copy or send-to-terminal without auto-running. The pad
|
|
1812
|
+
// belongs to the session it is staged against, not to the workspace: moving between tabs brings
|
|
1813
|
+
// each tab's own text, and typing in one never overwrites another's. ──
|
|
1814
|
+
// The debounce handle and the not-yet-written text live on `state` (like layoutSaveT): syncActive()
|
|
1815
|
+
// can run before this section of the module has evaluated, and module-level `let`s would be in
|
|
1816
|
+
// their temporal dead zone at that point — which blanked the whole app on first paint.
|
|
1657
1817
|
function setScratchStatus(s) { const el = $('scratchStatus'); if (el) el.textContent = s; }
|
|
1818
|
+
// Which pad is on screen: the active tab's, or — when the workspace has no tab at all — the
|
|
1819
|
+
// workspace's own. Encoded as one key so a switch is a simple inequality.
|
|
1820
|
+
function scratchKey() { return state.active ? 'session:' + state.active : 'ws:' + (state.wsId || ''); }
|
|
1821
|
+
function renderScratchWho() {
|
|
1822
|
+
const el = $('scratchWho'); if (!el) return;
|
|
1823
|
+
const s = state.sessions.find((x) => x.id === state.active);
|
|
1824
|
+
el.textContent = s ? s.name : '';
|
|
1825
|
+
el.title = s ? `staged for “${s.name}” — each tab keeps its own scratchpad` : '';
|
|
1826
|
+
}
|
|
1658
1827
|
async function loadScratchpad() {
|
|
1659
|
-
const
|
|
1828
|
+
const key = scratchKey();
|
|
1829
|
+
state.scratchKey = key;
|
|
1830
|
+
const q = state.active
|
|
1831
|
+
? 'session=' + encodeURIComponent(state.active)
|
|
1832
|
+
: 'ws=' + encodeURIComponent(state.wsId || '');
|
|
1660
1833
|
let text = '';
|
|
1661
|
-
try { const r = await fetch('/api/scratchpad?
|
|
1662
|
-
if (
|
|
1834
|
+
try { const r = await fetch('/api/scratchpad?' + q); const j = await r.json(); if (j && j.ok) text = j.text || ''; } catch {}
|
|
1835
|
+
if (key !== scratchKey()) return; // a slower fetch for a tab we've since left must not paint
|
|
1663
1836
|
$('scratchText').value = text; setScratchStatus('');
|
|
1837
|
+
renderScratchWho();
|
|
1838
|
+
}
|
|
1839
|
+
// Called on every active-session change. It flushes whatever the PM had just typed into the tab
|
|
1840
|
+
// they are LEAVING before loading the one they are arriving at — without this, the debounce could
|
|
1841
|
+
// still be holding text that would land after the new tab's pad had already painted.
|
|
1842
|
+
function syncScratchpad() {
|
|
1843
|
+
const key = scratchKey();
|
|
1844
|
+
if (key === state.scratchKey) { renderScratchWho(); return; }
|
|
1845
|
+
flushScratchpad();
|
|
1846
|
+
loadScratchpad();
|
|
1847
|
+
}
|
|
1848
|
+
function postScratchpad(p) {
|
|
1849
|
+
return fetch('/api/scratchpad', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1850
|
+
body: JSON.stringify(p.session ? { session: p.session, text: p.text } : { ws: p.ws, text: p.text }) });
|
|
1851
|
+
}
|
|
1852
|
+
function flushScratchpad() {
|
|
1853
|
+
clearTimeout(state.scratchT);
|
|
1854
|
+
const p = state.scratchPending;
|
|
1855
|
+
state.scratchPending = null;
|
|
1856
|
+
if (!p) return;
|
|
1857
|
+
postScratchpad(p).catch(() => {}); // fire-and-forget: the switch must not wait on the network
|
|
1664
1858
|
}
|
|
1665
1859
|
function saveScratchpad() {
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1860
|
+
// The target is captured NOW, with the text — a save that lands after a tab switch must still
|
|
1861
|
+
// write to the tab it was typed in.
|
|
1862
|
+
state.scratchPending = { session: state.active, ws: state.wsId, text: $('scratchText').value };
|
|
1863
|
+
clearTimeout(state.scratchT);
|
|
1864
|
+
state.scratchT = setTimeout(async () => {
|
|
1865
|
+
const p = state.scratchPending;
|
|
1866
|
+
state.scratchPending = null;
|
|
1867
|
+
if (!p) return;
|
|
1669
1868
|
setScratchStatus('saving…');
|
|
1670
1869
|
try {
|
|
1671
|
-
const
|
|
1672
|
-
|
|
1870
|
+
const j = await (await postScratchpad(p)).json();
|
|
1871
|
+
if (p.session && p.session !== state.active) return; // a pad we've since left: saved, quietly
|
|
1673
1872
|
setScratchStatus(j && j.ok ? 'saved' : 'save failed'); // a broken save is never a silent green
|
|
1674
1873
|
} catch { setScratchStatus('save failed'); }
|
|
1675
1874
|
}, 500);
|
|
@@ -1936,6 +2135,7 @@ const SURFACES = [
|
|
|
1936
2135
|
{ name: 'report', label: 'weekly report', phase: 'report' },
|
|
1937
2136
|
// Read-only console surfaces (D64 Track B): file-mode state, honest-by-construction (a broken
|
|
1938
2137
|
// read renders RED, never a green empty). Sourced from /api/console, not the deliverable contract.
|
|
2138
|
+
{ name: 'workflow', label: 'workflow', phase: 'plan', console: true },
|
|
1939
2139
|
{ name: 'needs', label: 'needs you', phase: 'deliver', console: true },
|
|
1940
2140
|
{ name: 'radar', label: 'radar', phase: 'measure', console: true },
|
|
1941
2141
|
{ name: 'metrics', label: 'metrics', phase: 'measure', console: true },
|
|
@@ -1988,7 +2188,7 @@ function sfHead(active, ok) {
|
|
|
1988
2188
|
function wireSurface() {
|
|
1989
2189
|
document.querySelectorAll('[data-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.surface));
|
|
1990
2190
|
document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
|
|
1991
|
-
$('docBody').querySelectorAll('.sf-art[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
|
|
2191
|
+
$('docBody').querySelectorAll('.sf-art[data-doc],.wf-id[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
|
|
1992
2192
|
}
|
|
1993
2193
|
function surfaceBody(name, payload) {
|
|
1994
2194
|
if (!payload || !payload.ok) return sfCantProduce(name, payload && payload.contract);
|
|
@@ -2077,6 +2277,7 @@ function consoleBody(name, p) {
|
|
|
2077
2277
|
if (name === 'needs') return csNeeds(p.data);
|
|
2078
2278
|
if (name === 'radar') return csRadar(p.data, p.source);
|
|
2079
2279
|
if (name === 'metrics') return csMetrics(p.data, p.source);
|
|
2280
|
+
if (name === 'workflow') return csWorkflow(p.data);
|
|
2080
2281
|
return csError(name, 'unknown console surface');
|
|
2081
2282
|
}
|
|
2082
2283
|
function csError(name, why) {
|
|
@@ -2089,7 +2290,9 @@ function csError(name, why) {
|
|
|
2089
2290
|
function csEmpty(name) {
|
|
2090
2291
|
const msg = name === 'needs'
|
|
2091
2292
|
? 'Nothing needs your decision right now. No open Acceptance Gate, no overdue outcome check.'
|
|
2092
|
-
:
|
|
2293
|
+
: name === 'workflow'
|
|
2294
|
+
? 'No initiative records in this repo — the read succeeded and there is genuinely nothing in flight.'
|
|
2295
|
+
: `No ${esc(name)} data yet — the state read succeeded and there is genuinely nothing to show.`;
|
|
2093
2296
|
return `<div class="cs-empty"><span class="dot" aria-hidden="true"></span><b>Nothing awaits</b>
|
|
2094
2297
|
<p>${msg}</p><p class="cs-empty-note">This is a real, verified empty — not a failed read.</p></div>`;
|
|
2095
2298
|
}
|
|
@@ -2103,6 +2306,59 @@ function csNeeds(d) {
|
|
|
2103
2306
|
<div class="m">${esc(n.kind)}${n.initiative_id ? ' · ' + esc(n.initiative_id) : ''}${n.derived ? ' · derived' : ''}${n.run_id ? ' · ' + esc(n.run_id) : ''}</div></div>
|
|
2104
2307
|
</div>`).join('')}</div>`;
|
|
2105
2308
|
}
|
|
2309
|
+
// The workflow ladder — the same steps, glyphs and next-actions the CLI and the Initiative Gate
|
|
2310
|
+
// print, from the same engine. Parity is the point: a panel that disagreed with CI would be worse
|
|
2311
|
+
// than no panel, because the PM would trust the one that is about to fail them.
|
|
2312
|
+
const WF_GLYPH = { done: '✓', current: '●', blocked: '✗', pending: '·', na: '─', unknown: '?' };
|
|
2313
|
+
const WF_STAGES = ['intake', 'spec', 'contract', 'build', 'eval', 'accepted'];
|
|
2314
|
+
function csWorkflow(d) {
|
|
2315
|
+
const live = (d && d.live) || [];
|
|
2316
|
+
const bad = (d && d.unreadable) || [];
|
|
2317
|
+
// "Open" is named for what it is derived from — no recorded acceptance — never as "in flight".
|
|
2318
|
+
// Most of these are finished work whose acceptance was never logged, and the count IS the finding.
|
|
2319
|
+
const head = `<div class="cs-h"><span class="dot ${d.blocked ? 'warn' : 'ok'}" aria-hidden="true"></span>
|
|
2320
|
+
<b>${d.total} without a recorded acceptance${d.blocked ? ` · ${d.blocked} blocked` : ''}</b>
|
|
2321
|
+
<span class="src">derived — no status field to keep updated${d.accepted ? ` · ${d.accepted} accepted` : ''}</span></div>`;
|
|
2322
|
+
// A degraded read is stated, never absorbed: without history the order rules are unknown, and an
|
|
2323
|
+
// unknown that renders like a tick is exactly the false green this surface exists to prevent.
|
|
2324
|
+
const degraded = d.degraded
|
|
2325
|
+
? `<div class="wf-degraded" role="alert"><b>Order checks degraded</b> — ${esc(d.degraded)}.
|
|
2326
|
+
Steps that depend on commit order show <span class="mono">?</span>, not a tick.</div>` : '';
|
|
2327
|
+
const unreadable = bad.length
|
|
2328
|
+
? `<div class="wf-degraded" role="alert"><b>${bad.length} record${bad.length === 1 ? '' : 's'} unreadable</b> —
|
|
2329
|
+
${bad.map((b) => esc(b.id)).join(', ')}. Shown here rather than dropped from the count.</div>` : '';
|
|
2330
|
+
if (!live.length) {
|
|
2331
|
+
return `<div class="cs-workflow">${head}${degraded}${unreadable}
|
|
2332
|
+
<div class="cs-none">Every initiative in this repo has a recorded acceptance.</div></div>`;
|
|
2333
|
+
}
|
|
2334
|
+
// Never a silent cap: a truncated list that does not say so reads as "that is everything".
|
|
2335
|
+
const more = d.hidden
|
|
2336
|
+
? `<div class="wf-more">+ ${d.hidden} more without a recorded acceptance, not shown —
|
|
2337
|
+
<span class="mono">node scripts/workflow-state.js --all</span></div>` : '';
|
|
2338
|
+
return `<div class="cs-workflow">${head}${degraded}${unreadable}
|
|
2339
|
+
${live.map((r) => {
|
|
2340
|
+
const at = Math.max(0, WF_STAGES.indexOf(r.stage));
|
|
2341
|
+
const rail = WF_STAGES.map((s, i) =>
|
|
2342
|
+
`<span class="wf-st ${i < at ? 'past' : i === at ? 'at' : ''}">${esc(s)}</span>`).join('<i class="wf-sep" aria-hidden="true"></i>');
|
|
2343
|
+
return `<div class="wf-card" data-blocked="${r.blockers.length ? '1' : '0'}">
|
|
2344
|
+
<div class="wf-top">
|
|
2345
|
+
<button class="wf-id" data-doc="${esc(r.record)}" title="open the initiative record">${esc(r.id)}</button>
|
|
2346
|
+
<span class="wf-lane">${esc(r.lane)}</span>
|
|
2347
|
+
${r.in_change ? '<span class="wf-here">this change</span>' : ''}
|
|
2348
|
+
<span class="wf-anchor" title="${esc(r.anchor || '')}">${esc((r.anchor || 'unanchored').split(/[(—]/)[0].trim())}</span>
|
|
2349
|
+
</div>
|
|
2350
|
+
<div class="wf-rail">${rail}</div>
|
|
2351
|
+
<div class="wf-steps">${r.steps.map((s) => `
|
|
2352
|
+
<div class="wf-step" data-st="${esc(s.status)}">
|
|
2353
|
+
<span class="wf-g" aria-hidden="true">${WF_GLYPH[s.status] || '?'}</span>
|
|
2354
|
+
<span class="wf-k">${esc(s.key)}</span>
|
|
2355
|
+
<span class="wf-e">${esc(s.evidence || '')}${s.rule && s.status === 'blocked' ? ` <span class="wf-rule">${esc(s.rule)}</span>` : ''}</span>
|
|
2356
|
+
</div>${s.next ? `<div class="wf-next" data-st="${esc(s.status)}">→ ${esc(s.next)}</div>` : ''}`).join('')}</div>
|
|
2357
|
+
${r.next ? `<div class="wf-do"><b>next</b> ${esc(r.next.action || '')}</div>`
|
|
2358
|
+
: `<div class="wf-do ok"><b>nothing blocking</b> — the ladder is complete</div>`}
|
|
2359
|
+
</div>`;
|
|
2360
|
+
}).join('')}${more}</div>`;
|
|
2361
|
+
}
|
|
2106
2362
|
function csRadar(d, source) {
|
|
2107
2363
|
const g = d.eval_gate || {};
|
|
2108
2364
|
const cal = d.calibration;
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
6
|
<title>PMOS Terminal Workspace</title>
|
|
7
|
+
<!-- The terminal's face is preloaded, not left to be discovered. An @font-face is only fetched when
|
|
8
|
+
something first RENDERS in it, which for Plex Mono is about the same moment app.js constructs
|
|
9
|
+
the first xterm — and xterm measures its cell from whatever face is resolved at construction.
|
|
10
|
+
Starting the fetch here, during head parse, means the real metrics are in place before the
|
|
11
|
+
first terminal exists. app.js also re-fits on document.fonts.ready as the backstop. -->
|
|
12
|
+
<link rel="preload" href="/vendor/ibm-plex-mono-400.woff2" as="font" type="font/woff2" crossorigin />
|
|
13
|
+
<link rel="preload" href="/vendor/ibm-plex-mono-700.woff2" as="font" type="font/woff2" crossorigin />
|
|
7
14
|
<link rel="stylesheet" href="/vendor/xterm.css" />
|
|
8
15
|
<link rel="stylesheet" href="/styles.css" />
|
|
9
16
|
</head>
|
|
@@ -65,6 +72,12 @@
|
|
|
65
72
|
</div>
|
|
66
73
|
<p class="hint">or just run <code>pmos</code> inside that repo</p>
|
|
67
74
|
</form>
|
|
75
|
+
<!-- Worktrees the workspace made are folders the PM has to live with, so the app that
|
|
76
|
+
creates them is the app that clears them. Hidden entirely until there is one. -->
|
|
77
|
+
<details class="wtbox" id="wtBox" hidden>
|
|
78
|
+
<summary id="wtSummary">worktrees</summary>
|
|
79
|
+
<div id="wtList"></div>
|
|
80
|
+
</details>
|
|
68
81
|
</div>
|
|
69
82
|
</section>
|
|
70
83
|
|
|
@@ -83,11 +96,16 @@
|
|
|
83
96
|
<button type="button" data-agent="codex" role="radio" aria-checked="false">codex</button>
|
|
84
97
|
</div>
|
|
85
98
|
<div class="brwrap" id="newBranchWrap" hidden>
|
|
86
|
-
<label class="lbl" for="newBranch">branch</label>
|
|
99
|
+
<label class="lbl" for="newBranch">branch <span class="opt">— optional</span></label>
|
|
87
100
|
<select id="newBranch" aria-label="Branch to run this session on"></select>
|
|
88
101
|
<input id="newBranchName" type="text" hidden placeholder="new-branch-name" aria-label="New branch name" autocomplete="off" spellcheck="false" />
|
|
89
|
-
<
|
|
102
|
+
<label class="chk" id="newIsolateWrap" hidden>
|
|
103
|
+
<input type="checkbox" id="newIsolate" checked />
|
|
104
|
+
<span>give it its own folder — leave running sessions alone</span>
|
|
105
|
+
</label>
|
|
106
|
+
<p class="hint" id="newBranchHint"></p>
|
|
90
107
|
</div>
|
|
108
|
+
<p class="hint" id="newNoGit" hidden>no git in this folder — nothing to branch, the session just starts here</p>
|
|
91
109
|
<div class="row">
|
|
92
110
|
<button type="submit" class="go">start session</button>
|
|
93
111
|
<button type="button" class="quiet" id="newCancel">cancel</button>
|
|
@@ -144,12 +162,14 @@
|
|
|
144
162
|
|
|
145
163
|
<!-- Scratchpad: a collapsible drawer at the BOTTOM of the terminal column. Folded it is a thin
|
|
146
164
|
bar; open it takes a bounded slice below the terminal — it never overlays the tabs, the
|
|
147
|
-
terminal, or the document rail. Persists
|
|
165
|
+
terminal, or the document rail. Persists PER TAB: each session keeps its own staging
|
|
166
|
+
text, and the label below says which tab you are staging for. -->
|
|
148
167
|
<section class="scratch" id="scratch" data-open="false" aria-label="Scratchpad">
|
|
149
168
|
<div class="scratch-head">
|
|
150
169
|
<button type="button" class="scratch-toggle" id="scratchToggle" aria-expanded="false" aria-controls="scratchText">
|
|
151
170
|
<span class="chev" aria-hidden="true">▴</span> scratchpad
|
|
152
171
|
</button>
|
|
172
|
+
<span class="scratch-who" id="scratchWho"></span>
|
|
153
173
|
<span class="scratch-status" id="scratchStatus" role="status"></span>
|
|
154
174
|
<span class="scratch-actions">
|
|
155
175
|
<button type="button" class="mini" id="scratchCopy" title="Copy to clipboard">copy</button>
|
|
@@ -260,6 +280,7 @@
|
|
|
260
280
|
<script src="/vendor/addon-web-links.js"></script>
|
|
261
281
|
<script src="/lib/preview-kind.js"></script>
|
|
262
282
|
<script src="/lib/layout.js"></script>
|
|
283
|
+
<script src="/lib/session-state.js"></script>
|
|
263
284
|
<!-- The editor computes its diff with the SAME module the server saves through, so what you see
|
|
264
285
|
while editing and what the server writes can never disagree. -->
|
|
265
286
|
<script src="/lib/apply-change.js"></script>
|