create-pmos 0.15.0 → 0.16.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 +38 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +44 -12
- package/workspace/design-parity.js +358 -0
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/mobile-nav.js +32 -2
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +13 -5
- package/workspace/public/index.html +79 -409
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +877 -0
- package/workspace/public/pm/dom.js +116 -0
- package/workspace/public/pm/fixture.js +979 -0
- package/workspace/public/pm/mobile.css +200 -0
- package/workspace/public/pm/mobile.js +403 -0
- package/workspace/public/pm/modes.js +1076 -0
- package/workspace/public/pm/panel-detail.js +648 -0
- package/workspace/public/pm/panel-initiatives.js +426 -0
- package/workspace/public/pm/panel-repos.js +460 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +164 -0
- package/workspace/public/pm/tab-doc.js +279 -0
- package/workspace/public/pm/tab-task.js +83 -0
- package/workspace/public/pm/tab-term.js +560 -0
- package/workspace/public/pm/tokens.css +73 -0
- package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
- package/workspace/server.js +158 -8
- package/workspace/focus.test.js +0 -650
- package/workspace/handoff.test.js +0 -443
- package/workspace/profiles.test.js +0 -362
- package/workspace/public/app.js +0 -5560
- package/workspace/public/styles.css +0 -2258
- package/workspace/repo-scan.test.js +0 -327
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/* pm/panel-tabs.js — panel 4: the tab bar, the panes, the scratchpad and the bottom bar.
|
|
2
|
+
*
|
|
3
|
+
* A port of the prototype's third column (template lines 844–997, derivation 1962–2126). Every
|
|
4
|
+
* inline style below is the prototype's string, copied character for character; the interpolated
|
|
5
|
+
* values are wrapped in their own `<span>` because that is what the prototype's runtime emits for
|
|
6
|
+
* `{{ … }}` (verified against the reference render: `<span class="sc-interp">value</span>`), and a
|
|
7
|
+
* text node where the design has an element moves the comparator's geometry.
|
|
8
|
+
*
|
|
9
|
+
* ── What this module owns ───────────────────────────────────────────────────────────────────
|
|
10
|
+
* the tab bar · + session · split · the empty state · which pane hosts exist and where they sit
|
|
11
|
+
* · the scratchpad · the bottom bar (scratchpad toggle, active-tab title, copy)
|
|
12
|
+
* The BODY of a tab belongs to its own module — `mountDoc` (tab-doc.js), `mountTask`
|
|
13
|
+
* (tab-task.js), `mountTerm` (tab-term.js). This file never renders a tab's contents.
|
|
14
|
+
*
|
|
15
|
+
* ── Why the pane hosts are cached ───────────────────────────────────────────────────────────
|
|
16
|
+
* A term pane hosts a live xterm attached to a real PTY. Rebuilding that element on an unrelated
|
|
17
|
+
* state change silently loses the terminal's grid — "the quiet PTY regression" the PRD names. So
|
|
18
|
+
* one host element is created per tab id, kept for as long as the tab exists, and merely
|
|
19
|
+
* re-ordered by `setChildren`. It is disposed only when the tab is actually closed.
|
|
20
|
+
*
|
|
21
|
+
* ── Scoping ────────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
* Tabs are per repository (prototype lines 1963–1966): a tab whose slug belongs to a repository is
|
|
23
|
+
* visible only in that repository; a tab whose slug is not on any board (a SKILL.md, a decision
|
|
24
|
+
* page, a doc opened with `slug:null`) is visible everywhere. That is a derivation, recomputed at
|
|
25
|
+
* read time, never a stored flag — the `repo` field a tab carries is only the fallback for a slug
|
|
26
|
+
* the board no longer knows about.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { el, setChildren } from './dom.js';
|
|
30
|
+
import { state, setState, FIXTURE } from './state.js';
|
|
31
|
+
import * as FX from './fixture.js';
|
|
32
|
+
import { openTab, openTerm as openTermTab, openDoc as openDocTab } from './panel-initiatives.js';
|
|
33
|
+
import { mountDoc, disposeDoc, docText } from './tab-doc.js';
|
|
34
|
+
import { mountTask } from './tab-task.js';
|
|
35
|
+
|
|
36
|
+
/* tab-term.js is loaded LAZILY and on purpose: it is another module's file, a static import of a
|
|
37
|
+
* file that is not on disk yet takes the whole panel down with it, and an empty terminal pane is a
|
|
38
|
+
* far smaller failure than a dead panel 4. When it lands, the panel repaints itself. */
|
|
39
|
+
let termMod = null;
|
|
40
|
+
import('./tab-term.js')
|
|
41
|
+
.then((m) => { termMod = m; setState({}); })
|
|
42
|
+
.catch(() => { termMod = null; });
|
|
43
|
+
|
|
44
|
+
/** `{{ expr }}` — one interpolated value in its own span, the way the prototype's runtime emits it. */
|
|
45
|
+
const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
|
|
46
|
+
|
|
47
|
+
/* ── the board, read exactly the way panels 2 and 3 read it ─────────────────────────────────── */
|
|
48
|
+
|
|
49
|
+
const boardOf = () =>
|
|
50
|
+
(state.board && typeof state.board === 'object' ? state.board : (FIXTURE ? FX.ALLGROUPS : {}));
|
|
51
|
+
|
|
52
|
+
function currentProduct() {
|
|
53
|
+
if (state.product) return state.product;
|
|
54
|
+
const keys = Object.keys(boardOf());
|
|
55
|
+
return keys.length ? keys[0] : 'workspace';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The selected initiative (prototype line 1397) — the slug `+ session` starts a session on. */
|
|
59
|
+
function selSlug() {
|
|
60
|
+
if (state.sel) return state.sel;
|
|
61
|
+
const product = currentProduct();
|
|
62
|
+
if (FIXTURE) return FX.defaultSlug(product);
|
|
63
|
+
for (const g of (boardOf()[product] || [])) if (g.slugs && g.slugs.length) return g.slugs[0];
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** slugProduct (lines 1963–1965) — which repository owns each initiative slug. */
|
|
68
|
+
function slugProductOf() {
|
|
69
|
+
const map = {};
|
|
70
|
+
const board = boardOf();
|
|
71
|
+
Object.keys(board).forEach((prod) => {
|
|
72
|
+
(board[prod] || []).forEach((g) => (g.slugs || []).forEach((s) => { map[s] = prod; }));
|
|
73
|
+
});
|
|
74
|
+
Object.keys(state.addedInits || {}).forEach((key) => {
|
|
75
|
+
const prod = key.split('|')[0];
|
|
76
|
+
(state.addedInits[key] || []).forEach((s) => { map[s] = prod; });
|
|
77
|
+
});
|
|
78
|
+
return map;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The tabs visible in the current repository (line 1966). */
|
|
82
|
+
function visibleTabs() {
|
|
83
|
+
const product = currentProduct();
|
|
84
|
+
const map = slugProductOf();
|
|
85
|
+
return (state.tabs || []).filter((t) => {
|
|
86
|
+
const sp = map[t.slug] !== undefined ? map[t.slug] : t.repo;
|
|
87
|
+
return sp === undefined || sp === product;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ── the openers ───────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
* `openTab` / `openTerm` / `openDoc` have exactly ONE implementation, in panel-initiatives.js,
|
|
93
|
+
* and are re-exported here so a caller can reach them from the panel that owns tabs. Their
|
|
94
|
+
* argument order is the prototype's (`openDoc(slug, path, role)`, `openTerm(slug, sess)`), which
|
|
95
|
+
* is what every existing call site in this repo passes.
|
|
96
|
+
*/
|
|
97
|
+
export { openTab };
|
|
98
|
+
|
|
99
|
+
/** openDoc (line 1400): focus the tab for `path`, opening it first if it is not already there. */
|
|
100
|
+
export function openDoc(slug, path, role) {
|
|
101
|
+
return openDocTab(slug, path, role);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* openTerm (line 1399). Two shapes:
|
|
106
|
+
* openTerm(slug, sess) — attach a tab to a session that already exists;
|
|
107
|
+
* openTerm(slug) — start a NEW session on that initiative and open it (the `+ session`
|
|
108
|
+
* path), defaulting to the currently selected initiative.
|
|
109
|
+
*/
|
|
110
|
+
export function openTerm(slug, sess) {
|
|
111
|
+
if (sess === undefined) return newSession(typeof slug === 'string' ? slug : undefined);
|
|
112
|
+
return openTermTab(slug, sess);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Open a task tab. Accepts the finished tab record panel 3 builds (`openTask(tab)`), or a task
|
|
117
|
+
* plus its initiative (`openTask(task, 'workspace-pm-spine')`), in which case the tab id is the
|
|
118
|
+
* prototype's `task:<slug>:<phase>#<index>` (line 1750).
|
|
119
|
+
*/
|
|
120
|
+
export function openTask(task, initiative) {
|
|
121
|
+
if (!task) return;
|
|
122
|
+
if (initiative === undefined && task.id) return openTab(task);
|
|
123
|
+
const slug = typeof initiative === 'string' ? initiative
|
|
124
|
+
: (initiative && (initiative.slug || initiative.id)) || task.slug;
|
|
125
|
+
const idx = task.index != null ? task.index : 0;
|
|
126
|
+
return openTab({
|
|
127
|
+
id: task.id || FX.taskTabId(slug, task.phase, idx),
|
|
128
|
+
kind: 'task', role: 'TASK', title: task.title, slug, repo: slugProductOf()[slug],
|
|
129
|
+
phase: task.phase, status: task.status, instruction: task.instruction, input: task.input,
|
|
130
|
+
expect: task.expect, evalNote: task.evalNote, output: task.output,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Close one tab (lines 1971–1974): the last remaining tab takes over, a dangling split is dropped. */
|
|
135
|
+
export function closeTab(id) {
|
|
136
|
+
setState((s) => {
|
|
137
|
+
const rest = s.tabs.filter((t) => t.id !== id);
|
|
138
|
+
return {
|
|
139
|
+
tabs: rest,
|
|
140
|
+
active: s.active === id ? (rest.length ? rest[rest.length - 1].id : null) : s.active,
|
|
141
|
+
splitId: s.splitId === id ? null : s.splitId,
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* The data layer's optional session-start hook. In fixture mode nothing is ever started; in real
|
|
147
|
+
* mode the PTY is data.js's to create, and panel 4's part is only to show the tab for it. */
|
|
148
|
+
let dataMod = null;
|
|
149
|
+
import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `+ session` (line 2087): file a new session against the selected initiative and open its tab.
|
|
153
|
+
* The session lands in `extraSess` — the prototype's own list of sessions started in this window —
|
|
154
|
+
* and the terminal tab is opened in the callback, i.e. after the render that added it.
|
|
155
|
+
*/
|
|
156
|
+
export function newSession(slug) {
|
|
157
|
+
const target = slug || selSlug();
|
|
158
|
+
if (!target) return;
|
|
159
|
+
const n = state.startN;
|
|
160
|
+
const sess = { name: 'agent ' + n, st: 'working', label: 'working', profile: state.newSessProfile };
|
|
161
|
+
setState((s) => ({
|
|
162
|
+
startN: n + 1,
|
|
163
|
+
extraSess: { ...s.extraSess, [target]: [...(s.extraSess[target] || []), sess] },
|
|
164
|
+
}), () => openTermTab(target, sess));
|
|
165
|
+
if (!FIXTURE && dataMod && typeof dataMod.startSession === 'function') {
|
|
166
|
+
try { dataMod.startSession(target, sess); } catch { /* the tab still opens */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `⫽ split` / `⫽ unsplit` (lines 2088–2092). */
|
|
171
|
+
export function toggleSplit() {
|
|
172
|
+
setState((s) => {
|
|
173
|
+
if (s.splitId) return { splitId: null };
|
|
174
|
+
const other = s.tabs.find((t) => t.id !== s.active);
|
|
175
|
+
return { splitId: other ? other.id : null };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ── persistent chrome ─────────────────────────────────────────────────────────────────────── */
|
|
180
|
+
|
|
181
|
+
const BAR_STYLE = 'display:flex;gap:2px;align-items:stretch;padding:6px 8px 0;border-bottom:1px solid oklch(1 0 0 / 10%);min-height:48px;min-width:0;overflow-x:auto';
|
|
182
|
+
const CHIP_STYLE = 'flex:none;align-self:center;font-family:var(--mono);font-size:11px;color:oklch(0.85 0.01 214);padding:5px 11px;min-height:30px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);white-space:nowrap;margin-left:4px';
|
|
183
|
+
const SPLIT_STYLE = 'flex:none;margin-left:auto;align-self:center;font-family:var(--mono);font-size:11px;color:oklch(0.85 0.01 214);padding:5px 12px;min-height:30px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);white-space:nowrap';
|
|
184
|
+
const CHIP_HOVER = 'border-color:oklch(0.56 0.021 213.5)';
|
|
185
|
+
|
|
186
|
+
const tabBar = el('div', { style: BAR_STYLE });
|
|
187
|
+
|
|
188
|
+
const newSessionBtn = el('button', {
|
|
189
|
+
onClick: () => newSession(), style: CHIP_STYLE, hover: CHIP_HOVER,
|
|
190
|
+
}, '+ session');
|
|
191
|
+
|
|
192
|
+
const splitLabel = iv('⫽ split');
|
|
193
|
+
const splitBtn = el('button', {
|
|
194
|
+
onClick: () => toggleSplit(), style: SPLIT_STYLE, hover: CHIP_HOVER,
|
|
195
|
+
}, splitLabel);
|
|
196
|
+
|
|
197
|
+
const emptyState = el('div', {
|
|
198
|
+
style: 'flex:1;display:grid;place-items:center;color:oklch(0.55 0.02 213);font-size:12.5px;padding:20px;text-align:center',
|
|
199
|
+
}, "nothing open — click a task's output or an artifact, or + session above");
|
|
200
|
+
|
|
201
|
+
const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0' });
|
|
202
|
+
|
|
203
|
+
/* ── the scratchpad (lines 983–991) ──────────────────────────────────────────────────────────
|
|
204
|
+
* One textarea, created once. Its text is written back as a CHILD TEXT NODE as well as a value,
|
|
205
|
+
* because that is what the prototype's runtime does and the comparator reads text nodes — but only
|
|
206
|
+
* when the two differ, so a repaint mid-sentence never moves the PM's caret.
|
|
207
|
+
*/
|
|
208
|
+
const scratchTa = el('textarea', {
|
|
209
|
+
placeholder: 'scratchpad — survives tab switches, dies with the page',
|
|
210
|
+
style: 'height:96px;background:oklch(0.11 0.004 228.8);color:#c4ccce;padding:10px 14px;font-family:var(--mono);font-size:12px',
|
|
211
|
+
onInput: (e) => setState({ scratchText: e.target.value }),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const scratchCopyLabel = iv('⧉ copy');
|
|
215
|
+
const scratchCopyBtn = el('button', {
|
|
216
|
+
onClick: () => {
|
|
217
|
+
copyToClipboard(state.scratchText);
|
|
218
|
+
setState({ scratchCopied: true });
|
|
219
|
+
setTimeout(() => setState({ scratchCopied: false }), 1200);
|
|
220
|
+
},
|
|
221
|
+
style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.85 0.01 214);padding:4px 10px;min-height:26px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%)',
|
|
222
|
+
hover: CHIP_HOVER,
|
|
223
|
+
}, scratchCopyLabel);
|
|
224
|
+
|
|
225
|
+
const scratchSendLabel = iv('open a terminal to send');
|
|
226
|
+
const scratchSendBtn = el('button', {
|
|
227
|
+
onClick: () => sendScratchToTerminal(),
|
|
228
|
+
style: 'font-family:var(--mono);font-size:10.5px;color:' + FX.SCRATCH_SEND_COLOR.off + ';padding:4px 10px;min-height:26px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%)',
|
|
229
|
+
hover: CHIP_HOVER,
|
|
230
|
+
}, scratchSendLabel);
|
|
231
|
+
|
|
232
|
+
const scratchBlock = el('div', {
|
|
233
|
+
style: 'display:flex;flex-direction:column;border-top:1px solid oklch(1 0 0 / 10%)',
|
|
234
|
+
},
|
|
235
|
+
scratchTa,
|
|
236
|
+
el('div', { style: 'display:flex;gap:6px;padding:5px 12px 7px;background:oklch(0.11 0.004 228.8)' },
|
|
237
|
+
scratchCopyBtn, scratchSendBtn),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
/* ── the bottom bar (lines 992–996) ─────────────────────────────────────────────────────────── */
|
|
241
|
+
|
|
242
|
+
const scratchToggleLabel = iv('▲ scratchpad');
|
|
243
|
+
const colTitleLabel = iv('scratchpad');
|
|
244
|
+
const copyLabel = iv('copy');
|
|
245
|
+
|
|
246
|
+
const bottomBar = el('div', {
|
|
247
|
+
style: 'display:flex;gap:10px;align-items:center;padding:5px 12px;border-top:1px solid oklch(1 0 0 / 10%);font-size:11px;color:oklch(0.6 0.02 213)',
|
|
248
|
+
},
|
|
249
|
+
el('button', {
|
|
250
|
+
onClick: () => setState((s) => ({ scratchOpen: !s.scratchOpen })),
|
|
251
|
+
style: 'flex:none;color:oklch(0.72 0.014 214);font-size:11px;padding:3px 6px;white-space:nowrap',
|
|
252
|
+
hover: 'color:oklch(0.987 0.002 197.1)',
|
|
253
|
+
}, scratchToggleLabel),
|
|
254
|
+
el('span', {
|
|
255
|
+
style: 'font-family:var(--mono);color:#c4ccce;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
|
|
256
|
+
}, colTitleLabel),
|
|
257
|
+
el('button', {
|
|
258
|
+
onClick: () => copyActive(),
|
|
259
|
+
style: 'flex:none;margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--brand);padding:3px 10px;min-height:26px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);white-space:nowrap',
|
|
260
|
+
hover: CHIP_HOVER,
|
|
261
|
+
}, copyLabel),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
/* ── clipboard + scratchpad actions (lines 2098–2118) ───────────────────────────────────────── */
|
|
265
|
+
|
|
266
|
+
function copyToClipboard(txt) {
|
|
267
|
+
try { if (navigator.clipboard) navigator.clipboard.writeText(txt); } catch { /* denied */ }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The active tab, or null when the active id belongs to another repository. */
|
|
271
|
+
function activeTab() {
|
|
272
|
+
return visibleTabs().find((t) => t.id === state.active) || null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** `⇥ send to terminal` (line 2104): one echoed prompt line per non-empty scratchpad line. */
|
|
276
|
+
function sendScratchToTerminal() {
|
|
277
|
+
const act = activeTab();
|
|
278
|
+
if (!act || act.kind !== 'term') return;
|
|
279
|
+
const v = (state.scratchText || '').trim();
|
|
280
|
+
if (!v) return;
|
|
281
|
+
const lines = FX.scratchLines(v);
|
|
282
|
+
setState((s) => ({ termHist: { ...s.termHist, [act.id]: [...(s.termHist[act.id] || []), ...lines] } }));
|
|
283
|
+
/* A real PTY is not a scrollback list: when a terminal module is present it gets the same text
|
|
284
|
+
* as keystrokes, so the paste lands in the shell rather than only in the transcript. */
|
|
285
|
+
if (!FIXTURE && termMod && typeof termMod.sendToTerm === 'function') {
|
|
286
|
+
try { termMod.sendToTerm(act, v + '\n'); } catch { /* the transcript still shows it */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** `copy` (line 2111): the doc's text, or the attach command, or the scratchpad. */
|
|
291
|
+
function copyActive() {
|
|
292
|
+
const act = activeTab();
|
|
293
|
+
let txt = '';
|
|
294
|
+
if (act && act.kind === 'doc') txt = state.docEdits[act.path] ?? docText(act);
|
|
295
|
+
else if (act && act.kind === 'term') txt = FX.termCopyText(act);
|
|
296
|
+
else txt = state.scratchText;
|
|
297
|
+
copyToClipboard(txt);
|
|
298
|
+
setState({ copied: true });
|
|
299
|
+
setTimeout(() => setState({ copied: false }), 1200);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ── pane hosts ────────────────────────────────────────────────────────────────────────────── */
|
|
303
|
+
|
|
304
|
+
const PANE_STYLE = 'flex:1;min-width:0;display:flex;flex-direction:column;border-right:1px solid oklch(1 0 0 / 10%)';
|
|
305
|
+
const paneHosts = new Map(); // tab id -> the pane element, kept for the tab's whole life
|
|
306
|
+
|
|
307
|
+
function paneFor(tab) {
|
|
308
|
+
let host = paneHosts.get(tab.id);
|
|
309
|
+
if (!host) { host = el('div', { style: PANE_STYLE }); paneHosts.set(tab.id, host); }
|
|
310
|
+
if (tab.kind === 'term') {
|
|
311
|
+
if (termMod && typeof termMod.mountTerm === 'function') termMod.mountTerm(host, tab);
|
|
312
|
+
} else if (tab.kind === 'task') {
|
|
313
|
+
mountTask(host, tab);
|
|
314
|
+
} else {
|
|
315
|
+
mountDoc(host, tab);
|
|
316
|
+
}
|
|
317
|
+
return host;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Drop the host of every tab that is no longer open, letting its module release what it holds. */
|
|
321
|
+
function reapClosedPanes() {
|
|
322
|
+
const live = new Set((state.tabs || []).map((t) => t.id));
|
|
323
|
+
for (const [id, host] of [...paneHosts]) {
|
|
324
|
+
if (live.has(id)) continue;
|
|
325
|
+
const tab = { id };
|
|
326
|
+
if (termMod && typeof termMod.disposeTerm === 'function') { try { termMod.disposeTerm(tab); } catch { /* ignore */ } }
|
|
327
|
+
try { disposeDoc(id); } catch { /* ignore */ }
|
|
328
|
+
if (host.parentNode) host.parentNode.removeChild(host);
|
|
329
|
+
paneHosts.delete(id);
|
|
330
|
+
const slot = offSlots.get(id);
|
|
331
|
+
if (slot) { if (slot.parentNode) slot.parentNode.removeChild(slot); offSlots.delete(id); }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* ── an open session whose tab is not the one on screen ───────────────────────────────────────
|
|
336
|
+
*
|
|
337
|
+
* `+ session` starts a REAL PTY, and a term tab is the only view of it. Until this existed a pane
|
|
338
|
+
* was built ONLY for the tab being displayed, which meant: start two sessions in a row and the
|
|
339
|
+
* first one had no terminal, was never bound to a session, and therefore never spawned a shell at
|
|
340
|
+
* all — the tab bar showed two sessions running and the server had one. Opening the tab later
|
|
341
|
+
* started it then, so the failure looked like a slow start rather than a session that was never
|
|
342
|
+
* begun. The same hole swallows a session the PM starts and then switches away from.
|
|
343
|
+
*
|
|
344
|
+
* So every open term tab is mounted, and the ones not on screen live in an OFF-SCREEN SLOT: a real
|
|
345
|
+
* box, the size of the pane they would occupy, parked far outside the viewport. Not `display:none`
|
|
346
|
+
* and not detached, because both measure zero and a terminal fitted to a zero box is the 1×1
|
|
347
|
+
* terminal this module's refit rules exist to prevent — the grid a hidden session's shell is
|
|
348
|
+
* drawing into has to be the grid it will still be drawing into when the PM looks at it.
|
|
349
|
+
*
|
|
350
|
+
* Real mode only. Fixture mode opens no socket, starts no PTY and must render the prototype's DOM
|
|
351
|
+
* exactly, so nothing is parked and nothing extra exists on the page.
|
|
352
|
+
*/
|
|
353
|
+
const OFF_STAGE_STYLE = 'position:fixed;left:-100000px;top:0;width:0;height:0;pointer-events:none';
|
|
354
|
+
const offSlots = new Map(); // tab id -> the off-screen box its host is parked in
|
|
355
|
+
const lastPaneBox = { w: 720, h: 420 };
|
|
356
|
+
let offStage = null;
|
|
357
|
+
|
|
358
|
+
function offSlotFor(id, w, h) {
|
|
359
|
+
if (!offStage) {
|
|
360
|
+
offStage = el('div', { style: OFF_STAGE_STYLE, 'aria-hidden': 'true' });
|
|
361
|
+
document.body.appendChild(offStage);
|
|
362
|
+
}
|
|
363
|
+
let slot = offSlots.get(id);
|
|
364
|
+
if (!slot) { slot = el('div', { style: 'display:flex' }); offSlots.set(id, slot); offStage.appendChild(slot); }
|
|
365
|
+
const want = 'display:flex;width:' + w + 'px;height:' + h + 'px';
|
|
366
|
+
if (slot.getAttribute('style') !== want) slot.setAttribute('style', want);
|
|
367
|
+
return slot;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Keep every open term tab live. `shown` is the set of tab ids panel 4 is displaying — those hosts
|
|
372
|
+
* belong to the panes row and are left alone.
|
|
373
|
+
*/
|
|
374
|
+
function keepSessionsLive(shown) {
|
|
375
|
+
if (FIXTURE || !termMod || typeof termMod.mountTerm !== 'function') return;
|
|
376
|
+
const parked = (state.tabs || []).filter((t) => t.kind === 'term' && !shown.has(t.id));
|
|
377
|
+
if (!parked.length) return;
|
|
378
|
+
/* The slot is the size of the box panel 4 gives ONE pane. Measured from the panes row, so a
|
|
379
|
+
* parked terminal is fitted to what it will actually get; when the row has no box yet (booting,
|
|
380
|
+
* or a phone showing another panel) the last known size stands in, and an 80×24 default only for
|
|
381
|
+
* the very first one — a stale-but-plausible grid beats a zero one. */
|
|
382
|
+
const box = panesRow.getBoundingClientRect();
|
|
383
|
+
if (box.width > 1 && box.height > 1) { lastPaneBox.w = Math.round(box.width); lastPaneBox.h = Math.round(box.height); }
|
|
384
|
+
for (const t of parked) {
|
|
385
|
+
const host = paneFor(t);
|
|
386
|
+
const slot = offSlotFor(t.id, lastPaneBox.w, lastPaneBox.h);
|
|
387
|
+
if (host.parentNode !== slot) slot.appendChild(host);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/* ── the tab bar ───────────────────────────────────────────────────────────────────────────── */
|
|
392
|
+
|
|
393
|
+
function tabNode(t, act, splitTab) {
|
|
394
|
+
const selected = (act && act.id === t.id) || (splitTab && splitTab.id === t.id);
|
|
395
|
+
const bg = selected ? FX.TAB_COLORS.activeBg : 'transparent';
|
|
396
|
+
const bc = selected ? FX.TAB_COLORS.activeBorder : FX.TAB_COLORS.idleBorder;
|
|
397
|
+
const color = act && act.id === t.id ? FX.TAB_COLORS.activeText : FX.TAB_COLORS.idleText;
|
|
398
|
+
const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
|
|
399
|
+
const meta = t.kind === 'term' ? 'session' : (t.kind === 'task' ? 'task' : t.role);
|
|
400
|
+
|
|
401
|
+
return el('div', {
|
|
402
|
+
style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
|
|
403
|
+
+ ';border:1px solid ' + bc + ';border-bottom:0',
|
|
404
|
+
},
|
|
405
|
+
el('button', {
|
|
406
|
+
onClick: () => setState({ active: t.id }),
|
|
407
|
+
style: 'display:flex;gap:7px;align-items:center;min-height:38px',
|
|
408
|
+
},
|
|
409
|
+
el('span', { style: 'font-size:9px;color:' + dotColor }, '●'),
|
|
410
|
+
el('span', { style: 'display:flex;flex-direction:column;min-width:0;align-items:flex-start' },
|
|
411
|
+
el('span', {
|
|
412
|
+
style: 'font-family:var(--mono);font-size:11.5px;color:' + color
|
|
413
|
+
+ ';max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.3',
|
|
414
|
+
}, iv(t.title)),
|
|
415
|
+
el('span', {
|
|
416
|
+
style: 'font-family:var(--mono);font-size:9px;color:oklch(0.52 0.02 213);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.2',
|
|
417
|
+
}, iv(t.slug), ' · ', iv(meta)),
|
|
418
|
+
),
|
|
419
|
+
),
|
|
420
|
+
el('button', {
|
|
421
|
+
onClick: () => closeTab(t.id),
|
|
422
|
+
style: 'color:oklch(0.6 0.02 213);font-size:12px;padding:2px 3px;align-self:flex-start;margin-top:6px',
|
|
423
|
+
hover: 'color:oklch(0.987 0.002 197.1)',
|
|
424
|
+
}, '×'),
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/* ── render ────────────────────────────────────────────────────────────────────────────────── */
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Paint panel 4 into `host` — the persistent panel container app.js owns. The children are the
|
|
432
|
+
* prototype's, in the prototype's order: tab bar · (empty state) · panes · (scratchpad) · bottom
|
|
433
|
+
* bar. The panes row renders even when nothing is open, exactly as the design does.
|
|
434
|
+
*/
|
|
435
|
+
export function renderTabs(host) {
|
|
436
|
+
const visTabs = visibleTabs();
|
|
437
|
+
const act = visTabs.find((t) => t.id === state.active) || null;
|
|
438
|
+
const splitTab = state.splitId
|
|
439
|
+
? (visTabs.find((t) => t.id === state.splitId && t.id !== (act && act.id)) || null)
|
|
440
|
+
: null;
|
|
441
|
+
|
|
442
|
+
// ── tab bar ──
|
|
443
|
+
const canSplit = visTabs.length >= 2 || !!state.splitId;
|
|
444
|
+
splitLabel.textContent = state.splitId ? '⫽ unsplit' : '⫽ split';
|
|
445
|
+
setChildren(tabBar, [
|
|
446
|
+
...visTabs.map((t) => tabNode(t, act, splitTab)),
|
|
447
|
+
newSessionBtn,
|
|
448
|
+
canSplit ? splitBtn : null,
|
|
449
|
+
]);
|
|
450
|
+
|
|
451
|
+
// ── panes ──
|
|
452
|
+
reapClosedPanes();
|
|
453
|
+
const panes = [act ? paneFor(act) : null, splitTab ? paneFor(splitTab) : null].filter(Boolean);
|
|
454
|
+
setChildren(panesRow, panes);
|
|
455
|
+
// …and the sessions that are open but not on screen keep running, off screen (see above).
|
|
456
|
+
keepSessionsLive(new Set([act && act.id, splitTab && splitTab.id].filter(Boolean)));
|
|
457
|
+
|
|
458
|
+
// ── scratchpad ──
|
|
459
|
+
/* `defaultValue` IS the textarea's child text node — the rendered text lives in the DOM, not only
|
|
460
|
+
* in a property, which is how the prototype's runtime leaves it. Writing it costs a typed-in box
|
|
461
|
+
* nothing; `value` is written only when it differs, so the caret stays where the PM put it. */
|
|
462
|
+
const scratchText = state.scratchText || '';
|
|
463
|
+
if (scratchTa.defaultValue !== scratchText) scratchTa.defaultValue = scratchText;
|
|
464
|
+
if (scratchTa.value !== scratchText) scratchTa.value = scratchText;
|
|
465
|
+
scratchCopyLabel.textContent = state.scratchCopied ? 'copied ✓' : '⧉ copy';
|
|
466
|
+
const canSend = !!(act && act.kind === 'term');
|
|
467
|
+
scratchSendLabel.textContent = canSend ? '⇥ send to terminal' : 'open a terminal to send';
|
|
468
|
+
scratchSendBtn.setAttribute('style',
|
|
469
|
+
'font-family:var(--mono);font-size:10.5px;color:'
|
|
470
|
+
+ (canSend ? FX.SCRATCH_SEND_COLOR.on : FX.SCRATCH_SEND_COLOR.off)
|
|
471
|
+
+ ';padding:4px 10px;min-height:26px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%)');
|
|
472
|
+
|
|
473
|
+
// ── bottom bar ──
|
|
474
|
+
scratchToggleLabel.textContent = state.scratchOpen ? '▼ scratchpad' : '▲ scratchpad';
|
|
475
|
+
colTitleLabel.textContent = act ? act.title : 'scratchpad';
|
|
476
|
+
copyLabel.textContent = state.copied ? 'copied ✓' : 'copy';
|
|
477
|
+
|
|
478
|
+
setChildren(host, [
|
|
479
|
+
tabBar,
|
|
480
|
+
visTabs.length === 0 ? emptyState : null,
|
|
481
|
+
panesRow,
|
|
482
|
+
state.scratchOpen ? scratchBlock : null,
|
|
483
|
+
bottomBar,
|
|
484
|
+
]);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export default renderTabs;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/* pm/state.js — the single mutable state object for the four-panel workspace.
|
|
2
|
+
*
|
|
3
|
+
* One object, one `setState`, one batched render. The keys are the prototype's keys, verbatim,
|
|
4
|
+
* because every panel module is a port of a subtree that already reads them by name — renaming a
|
|
5
|
+
* field here would make each port a translation instead of a copy.
|
|
6
|
+
*
|
|
7
|
+
* Two rules the rest of the surface depends on:
|
|
8
|
+
* 1. `setState` coalesces. Several patches inside one event handler produce exactly ONE render,
|
|
9
|
+
* the way the prototype's React setState did. A callback runs AFTER that render.
|
|
10
|
+
* 2. Nothing here is derived. Anything computable from these fields is computed at read time by
|
|
11
|
+
* the panel that needs it (D33) — a second copy of a derivable value is a board that lies.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/* Fixture mode is decided from the URL, not from another module: the persistence bypass below is
|
|
15
|
+
* the whole reason the comparator can trust a run. Reading it here keeps state.js loadable on its
|
|
16
|
+
* own and stops a data-layer import from deciding whether the PM's layout is written to disk. */
|
|
17
|
+
export const FIXTURE = (() => {
|
|
18
|
+
try { return new URLSearchParams(location.search).get('fixture') === 'proto'; }
|
|
19
|
+
catch { return false; }
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
const STORE_KEY = 'pmos.workspace.pm.v1';
|
|
23
|
+
|
|
24
|
+
/** The initial state — the prototype's `state = {…}` block, key for key. */
|
|
25
|
+
function initial() {
|
|
26
|
+
return {
|
|
27
|
+
theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, settingsOpen: false,
|
|
28
|
+
product: null, newSessProfile: 'claude-work', intgSynced: {},
|
|
29
|
+
addSrc: 'github', addRepo: 'github.com/acme/atlas-mobile', addPath: '~/code/notes-cli',
|
|
30
|
+
addLog: [], addedProducts: [], extraKnow: {},
|
|
31
|
+
newType: 'init', newName: '', newSlug: '', newAnchor: null, newFlow: 'adlc-feature',
|
|
32
|
+
userGroups: {}, userKrs: {}, addedInits: {}, userInits: {}, selTask: null, collapsedPhases: {},
|
|
33
|
+
detail: { kind: 'init' },
|
|
34
|
+
tabs: [], active: null, splitId: null,
|
|
35
|
+
initLinks: {}, artLinks: {}, artSyncOpen: {}, artSyncState: {},
|
|
36
|
+
extraSess: {}, termHist: {}, startN: 1, docEdits: {}, docModes: {},
|
|
37
|
+
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
38
|
+
flowsMap: null, schedList: null, agentList: null, memory: null,
|
|
39
|
+
savedDocs: {}, scratchCopied: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* ── persistence ──────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
* Only the PM's *arrangement* is persisted — never content, never tabs, never a live session.
|
|
45
|
+
* Each key carries its own validator. A stored value that fails its validator is DROPPED and the
|
|
46
|
+
* rest of the record is still applied: "layout amnesia" in the PRD's failure-mode list means a
|
|
47
|
+
* stale record from an older build throwing, or silently wiping a layout the PM is working in.
|
|
48
|
+
* An unknown key is simply not read, so an older or newer schema costs nothing.
|
|
49
|
+
*/
|
|
50
|
+
const isBool = (v) => typeof v === 'boolean';
|
|
51
|
+
const isSlug = (v) => v === null || typeof v === 'string';
|
|
52
|
+
const isFlagMap = (v) =>
|
|
53
|
+
!!v && typeof v === 'object' && !Array.isArray(v) &&
|
|
54
|
+
Object.keys(v).every((k) => typeof k === 'string' && typeof v[k] === 'boolean');
|
|
55
|
+
|
|
56
|
+
const PERSIST = {
|
|
57
|
+
theme: (v) => v === 'dark' || v === 'light',
|
|
58
|
+
col0: isBool, col1: isBool, col2: isBool,
|
|
59
|
+
settingsOpen: isBool,
|
|
60
|
+
product: isSlug,
|
|
61
|
+
sel: isSlug,
|
|
62
|
+
collapsedGroups: isFlagMap,
|
|
63
|
+
collapsedPhases: isFlagMap,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The exact set of keys written to localStorage, in write order. */
|
|
67
|
+
export const persistKeys = Object.keys(PERSIST);
|
|
68
|
+
|
|
69
|
+
function restore(target) {
|
|
70
|
+
if (FIXTURE) return target; // the fixture render must be deterministic
|
|
71
|
+
let raw = null;
|
|
72
|
+
try { raw = localStorage.getItem(STORE_KEY); } catch { return target; }
|
|
73
|
+
if (!raw) return target;
|
|
74
|
+
let saved = null;
|
|
75
|
+
try { saved = JSON.parse(raw); } catch { return target; }
|
|
76
|
+
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return target;
|
|
77
|
+
for (const k of persistKeys) {
|
|
78
|
+
if (!Object.prototype.hasOwnProperty.call(saved, k)) continue;
|
|
79
|
+
let ok = false;
|
|
80
|
+
try { ok = PERSIST[k](saved[k]); } catch { ok = false; }
|
|
81
|
+
if (ok) target[k] = saved[k];
|
|
82
|
+
}
|
|
83
|
+
return target;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function persist() {
|
|
87
|
+
if (FIXTURE) return;
|
|
88
|
+
const out = {};
|
|
89
|
+
for (const k of persistKeys) out[k] = state[k];
|
|
90
|
+
try { localStorage.setItem(STORE_KEY, JSON.stringify(out)); } catch { /* quota / private mode */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ── the store ────────────────────────────────────────────────────────────────────────────── */
|
|
94
|
+
|
|
95
|
+
export const state = restore(initial());
|
|
96
|
+
|
|
97
|
+
/* ── the write log ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
* How many times each field has been ASSIGNED, whatever the value it was assigned. This is not
|
|
99
|
+
* derived state and not a second copy of anything: it is a fact about the writes, and it exists
|
|
100
|
+
* because one surface genuinely needs to tell a REQUEST from a CHANGE.
|
|
101
|
+
*
|
|
102
|
+
* "Show me this tab" is `setState({active: id})`. When the tab was already the active one, the
|
|
103
|
+
* value does not move, so a reader watching values sees nothing happen — which is correct for a
|
|
104
|
+
* desktop, where the tab is already on screen, and wrong for the phone, where one panel is visible
|
|
105
|
+
* at a time and the PM may be standing on the board looking at nothing of the sort. The phone reads
|
|
106
|
+
* this counter alongside the value; nothing else needs it, and no panel had to learn that a phone
|
|
107
|
+
* exists for it to work.
|
|
108
|
+
*/
|
|
109
|
+
const writes = Object.create(null);
|
|
110
|
+
|
|
111
|
+
/** How many times `key` has been written. Only ever increases; the value itself is meaningless. */
|
|
112
|
+
export function writeCount(key) { return writes[key] || 0; }
|
|
113
|
+
|
|
114
|
+
const subs = new Set();
|
|
115
|
+
let queued = false;
|
|
116
|
+
let callbacks = [];
|
|
117
|
+
|
|
118
|
+
/** Register a render function. Returns its own unsubscribe. */
|
|
119
|
+
export function subscribe(fn) {
|
|
120
|
+
subs.add(fn);
|
|
121
|
+
return () => subs.delete(fn);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Merge a patch (or the result of `fn(state)`) into the state and schedule one render.
|
|
126
|
+
* A function form returning `null` is a no-op patch — the prototype relies on that for Esc with
|
|
127
|
+
* no active tab. `cb` runs after the render that includes this patch.
|
|
128
|
+
*/
|
|
129
|
+
export function setState(patchOrFn, cb) {
|
|
130
|
+
const patch = typeof patchOrFn === 'function' ? patchOrFn(state) : patchOrFn;
|
|
131
|
+
if (patch) {
|
|
132
|
+
for (const k in patch) writes[k] = (writes[k] || 0) + 1;
|
|
133
|
+
Object.assign(state, patch);
|
|
134
|
+
}
|
|
135
|
+
if (typeof cb === 'function') callbacks.push(cb);
|
|
136
|
+
if (queued) return;
|
|
137
|
+
queued = true;
|
|
138
|
+
// The microtask re-checks `queued` instead of calling flush() unconditionally. Without that
|
|
139
|
+
// re-check, an explicit flush() — which a caller uses when it must READ the DOM it just caused —
|
|
140
|
+
// leaves this microtask still pending, and it fires afterwards as a SECOND render of a state
|
|
141
|
+
// nothing changed. That second render is not free: paint() rebuilds a panel's subtree, so the
|
|
142
|
+
// element the first render just focused is detached and re-appended, and the focus is silently
|
|
143
|
+
// lost. It cost the shell's ⌘K contract exactly that way — focus() ran, succeeded, and was undone
|
|
144
|
+
// one microtask later. A stale re-render also churns the container a live PTY sits in, which is
|
|
145
|
+
// the same class of quiet damage the persistent panel-4 host exists to prevent.
|
|
146
|
+
queueMicrotask(() => { if (queued) flush(); });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Render now, synchronously — for a caller that must read the DOM it just caused. */
|
|
150
|
+
export function flush() {
|
|
151
|
+
queued = false;
|
|
152
|
+
persist();
|
|
153
|
+
for (const fn of [...subs]) fn(state);
|
|
154
|
+
const run = callbacks;
|
|
155
|
+
callbacks = [];
|
|
156
|
+
for (const cb of run) cb(state);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Reset to the seeded state and forget the stored arrangement. Used by the browser suite. */
|
|
160
|
+
export function resetState() {
|
|
161
|
+
Object.assign(state, initial());
|
|
162
|
+
try { localStorage.removeItem(STORE_KEY); } catch { /* ignore */ }
|
|
163
|
+
setState({});
|
|
164
|
+
}
|