create-pmos 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,507 @@
1
+ /* pm/panel-newsess.js — the session pre-flight: the questions asked BEFORE a PTY exists.
2
+ *
3
+ * ── What this is, and what it is not ────────────────────────────────────────────────────────
4
+ * It is NOT a resurrection of `app.js`'s composer. That file's imperative `$()`/`innerHTML` style
5
+ * is retired; this is `el()`/`setState()` and design tokens like every other four-panel module.
6
+ * What is ported is the composer's INTENT — the five questions it asked and, more importantly, the
7
+ * three places it told the truth about what it could not read.
8
+ *
9
+ * It is also NOT the primary way to start a session. `workspace-repo-first`'s accepted PRD is
10
+ * explicit: one click is primary and the composer is *"demoted, not deleted"*. `+ session` keeps
11
+ * its one-click behaviour and its co-tenancy chooser; this is the `⋯ options` path beside it, for
12
+ * the cases the one-click rule does not cover — a branch off a tag, a colleague's branch, an
13
+ * existing worktree, a specific profile for this session only.
14
+ *
15
+ * ── Why it renders in panel 4 and not panel 3 ───────────────────────────────────────────────
16
+ * It occupies the same slot as `startPromptBlock` (panel-tabs.js): above the panes, below the tab
17
+ * bar. Two reasons, and the second is the load-bearing one:
18
+ * 1. It is about the click that was just made in that bar, so it belongs beside it.
19
+ * 2. Every open pane hosts a live xterm, and a terminal that is re-parented by an unrelated
20
+ * render silently loses its grid. Rendering here leaves every pane exactly where it was;
21
+ * taking over the panes row would move them.
22
+ *
23
+ * ── The one property everything else exists to serve ────────────────────────────────────────
24
+ * A REFUSAL STARTS NOTHING AND SHOWS NOTHING. `onStart` awaits `requestSpawn`, and only a resolved
25
+ * `{ok:true}` writes an `extraSess` row or opens a tab. On `{ok:false}` the form stays open with
26
+ * the server's own sentence in it — no tab, no pane, no xterm, no row. The defect this replaces
27
+ * did the opposite: the click built the tab, the pane's own render asked for the PTY, and the
28
+ * refusal was written as a dimmed line INSIDE a terminal belonging to a session that was never
29
+ * started.
30
+ */
31
+
32
+ import { el } from './dom.js';
33
+ import { state, setState, FIXTURE } from './state.js';
34
+ import * as FX from './fixture.js';
35
+ import { openTerm as openTermTab } from './panel-initiatives.js';
36
+
37
+ /* Both are another module's file, loaded lazily for the reason panel-tabs.js gives: a static import
38
+ * of a file that fails to load takes this whole module down with it, and a panel that cannot draw
39
+ * is a worse failure than a form that cannot start a session yet. */
40
+ let dataMod = null;
41
+ import('./data.js').then((m) => { dataMod = m; setState({}); }).catch(() => { dataMod = null; });
42
+ let termMod = null;
43
+ import('./tab-term.js').then((m) => { termMod = m; }).catch(() => { termMod = null; });
44
+
45
+ /** One interpolated value in its own span — the convention every panel module renders values with. */
46
+ const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
47
+
48
+ /* ── styles: design tokens only, no raw hex (workspace-ci's token-hygiene check) ────────────── */
49
+
50
+ const WRAP = 'flex:none;max-height:56vh;overflow-y:auto;display:flex;flex-direction:column;gap:9px;'
51
+ + 'padding:12px 14px;border-bottom:1px solid var(--border);background:var(--card)';
52
+ const TITLE = 'font-size:12.5px;color:var(--foreground)';
53
+ const SUB = 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5';
54
+ const LABEL = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;'
55
+ + 'color:var(--muted-foreground)';
56
+ const ROW = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap';
57
+ const INPUT = 'font-family:var(--mono);font-size:11.5px;padding:5px 8px;min-height:30px;'
58
+ + 'background:var(--secondary);border:1px solid var(--input);color:var(--foreground);min-width:200px';
59
+ const CHIP = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
60
+ + 'background:var(--secondary);border:1px solid var(--border);color:var(--foreground)';
61
+ const CHIP_ON = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
62
+ + 'background:var(--accent);border:1px solid var(--ring);color:var(--foreground)';
63
+ const CHIP_DEAD = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
64
+ + 'background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground);opacity:.55';
65
+ const CHIP_BAD = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
66
+ + 'background:var(--secondary);border:1px solid var(--destructive);color:var(--destructive);opacity:.75';
67
+ const HOVER = 'border-color:var(--ring)';
68
+ /* `--brand` is the token layer's one accent; there is no `--primary` in tokens.css and inventing a
69
+ * name would be a token that resolves to nothing in both themes. */
70
+ const PRIMARY = 'font-family:var(--mono);font-size:11px;padding:6px 14px;min-height:32px;'
71
+ + 'background:var(--brand-wash);border:1px solid var(--brand);color:var(--foreground)';
72
+ const PRIMARY_OFF = 'font-family:var(--mono);font-size:11px;padding:6px 14px;min-height:32px;'
73
+ + 'background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground)';
74
+ const ERR = 'font-size:11.5px;line-height:1.5;color:var(--destructive);'
75
+ + 'border:1px solid var(--destructive);padding:7px 9px;overflow-wrap:anywhere';
76
+ const WARN = 'font-size:11px;line-height:1.5;color:var(--warning);overflow-wrap:anywhere';
77
+ const MUTED_MONO = 'font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);overflow-wrap:anywhere';
78
+
79
+ /* ── open / close ──────────────────────────────────────────────────────────────────────────── */
80
+
81
+ /**
82
+ * Open the pre-flight for `slug`. NOTHING is started and nothing is written to `extraSess` — that
83
+ * is the entire difference from `newSession()`, which files the row and opens the tab up front.
84
+ *
85
+ * The name is seeded rather than imposed: `<slug> <n>` beats `agent <n>` because the tab bar shows
86
+ * it and "agent 3" says nothing about which of three initiatives it is on. `n` counts past every
87
+ * name the window can see, the same way `nextSessionName` does, so re-opening the form after a
88
+ * reload does not propose a name that is already taken.
89
+ */
90
+ export function openNewSession(slug, seed) {
91
+ if (!slug) return;
92
+ const s = seed || {};
93
+ setState({
94
+ newSess: { slug },
95
+ startPrompt: null, endConfirm: null,
96
+ newSessName: s.name || proposeName(slug),
97
+ newSessAgent: s.agent || 'shell',
98
+ newSessBranch: '', newSessBranchNew: '', newSessBranchMode: false,
99
+ newSessIsolate: null, newSessError: null, newSessBusy: false,
100
+ // Cleared on the way IN as well as on the way out: a form that opened still showing the profile
101
+ // a previous, abandoned question had chosen would be proposing an answer nobody gave.
102
+ preflightProfile: s.profile === undefined ? null : s.profile,
103
+ });
104
+ refreshReads();
105
+ }
106
+
107
+ /**
108
+ * Close it, discarding what was typed. A cancelled question leaves no residue — and `preflightProfile`
109
+ * is part of "no residue", not an exception to it.
110
+ *
111
+ * It used to survive the close, in a slot `+ session` also read, so a profile picked here and then
112
+ * cancelled was filed onto the next one-click start and sent to the server as a spawn instruction.
113
+ * The server refuses a descriptor it cannot resolve, so the click opened a terminal and wrote a
114
+ * refusal into it for a session that was never started. Clearing it here is half the repair; the
115
+ * other half is that `+ session` no longer reads this at all (panel-tabs.js).
116
+ */
117
+ export function closeNewSession() {
118
+ setState({ newSess: null, newSessError: null, newSessBusy: false, preflightProfile: null });
119
+ }
120
+
121
+ export const isNewSessionOpen = () => !!state.newSess;
122
+
123
+ /** The next free `<slug> <n>`, counted against every name this window can see. */
124
+ function proposeName(slug) {
125
+ const re = new RegExp('^' + slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ' (\\d+)$');
126
+ let max = 0;
127
+ const bump = (name) => { const m = re.exec(String(name || '')); if (m) max = Math.max(max, Number(m[1])); };
128
+ for (const x of (state.sessions || [])) bump(x && x.name);
129
+ for (const k in (state.extraSess || {})) for (const x of (state.extraSess[k] || [])) bump(x && x.name);
130
+ for (const t of (state.tabs || [])) if (t && t.kind === 'term') bump(t.sess);
131
+ return slug + ' ' + (max + 1);
132
+ }
133
+
134
+ /* ── the two server reads ──────────────────────────────────────────────────────────────────── */
135
+
136
+ /* Fired when the form opens, and again when the server says a branch moved. Both reads are
137
+ * independent and each writes only its own key, so a repository with no git still gets its profile
138
+ * chips and a machine with no profile directory still gets its branch picker. */
139
+ function refreshReads() {
140
+ if (FIXTURE || !dataMod) return;
141
+ const ws = state.wsId || null;
142
+ if (typeof dataMod.loadBranches === 'function') {
143
+ dataMod.loadBranches(ws).then((r) => setState({ newSessBranches: r || null })).catch(() => {});
144
+ }
145
+ if (typeof dataMod.loadProfiles === 'function') {
146
+ dataMod.loadProfiles().then((r) => {
147
+ setState({ profiles: r || null });
148
+ // Preselect from the server's remembered preference — never from a hardcoded name. A
149
+ // preference naming a descriptor that no longer reads preselects NOTHING rather than
150
+ // substituting another, which is the same rule the server applies to the preference itself.
151
+ const pref = r && r.prefs ? r.prefs[state.newSessAgent] : null;
152
+ const live = (r && Array.isArray(r.profiles) ? r.profiles : []).map((p) => p && p.id);
153
+ if (pref && live.includes(pref) && !state.preflightProfile) setState({ preflightProfile: pref });
154
+ }).catch(() => {});
155
+ }
156
+ }
157
+
158
+ /* The `{t:'branch'}` broadcast lands in `state.branchInfo` (tab-term.js). When it names the
159
+ * workspace this form is asking about, the picker's own read is stale and is taken again — this is
160
+ * what stopped the retired composer's picker going stale after a checkout made in a terminal. */
161
+ let lastBranchAt = 0;
162
+ function absorbBranchBroadcast() {
163
+ const b = state.branchInfo;
164
+ if (!b || !state.newSess) return;
165
+ if (b.at === lastBranchAt) return;
166
+ lastBranchAt = b.at;
167
+ if (b.ws && state.wsId && b.ws !== state.wsId) return;
168
+ // The broadcast carries a full branchInfo payload, so it is usable directly; the re-read is for
169
+ // the fields a broadcast may not carry (worktree occupancy).
170
+ setState({ newSessBranches: { ok: true, ...b } });
171
+ refreshReads();
172
+ }
173
+
174
+ /* ── the branch picker ─────────────────────────────────────────────────────────────────────── */
175
+
176
+ /**
177
+ * The picker, or an honest line saying why there is none. Three outcomes, and they are three
178
+ * different sentences on purpose — a folder with no git is not the same event as git failing, and
179
+ * neither is the same as "the read has not come back yet".
180
+ */
181
+ function branchSection() {
182
+ const info = state.newSessBranches;
183
+ const out = [];
184
+ out.push(el('div', { style: LABEL }, 'branch'));
185
+
186
+ if (FIXTURE) {
187
+ out.push(el('div', { style: SUB }, 'fixture mode reads no repository — a session started here applies no branch'));
188
+ return out;
189
+ }
190
+ if (!info) {
191
+ out.push(el('div', { style: SUB }, 'reading the repository…'));
192
+ return out;
193
+ }
194
+ if (info.ok === false) {
195
+ /* `code` distinguishes the two. A folder that is not a repository is a normal, calm fact and
196
+ * the control is simply absent. A git error is NOT calm: it is said in warn colour with git's
197
+ * own words, because "no branch control" would otherwise read as "no git here" and the PM would
198
+ * never learn their repository is unreadable. */
199
+ const notRepo = info.code === 'not-a-repo' || /not a git repository/i.test(info.error || '');
200
+ out.push(notRepo
201
+ ? el('div', { style: SUB }, 'this repository is not a git working tree — a session starts here with no branch applied')
202
+ : el('div', { style: WARN }, iv('the branch list could not be read — ' + (info.error || 'git gave no reason'))));
203
+ return out;
204
+ }
205
+
206
+ const current = info.branch || null;
207
+ const branches = Array.isArray(info.branches) ? info.branches : [];
208
+ /* A branch checked out in ANOTHER working tree is a branch git will refuse to check out here.
209
+ * Offering it and letting the server say no would be technically honest and practically rude:
210
+ * the PM would meet the refusal after the click. It is shown, named with where it is, and
211
+ * disabled. */
212
+ const elsewhere = new Map();
213
+ for (const w of (Array.isArray(info.worktrees) ? info.worktrees : [])) {
214
+ if (!w || !w.branch || w.branch === current) continue;
215
+ if (!elsewhere.has(w.branch)) elsewhere.set(w.branch, w.dir || 'another working tree');
216
+ }
217
+
218
+ const pick = (v) => setState({ newSessBranch: v, newSessBranchMode: false, newSessError: null });
219
+ const chips = [
220
+ // ALWAYS first and ALWAYS the default: starting a session must never require a git decision.
221
+ el('button', {
222
+ onClick: () => pick(''),
223
+ style: state.newSessBranch === '' && !state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
224
+ }, iv('stay on current' + (current ? ' (' + current + ')' : ''))),
225
+ ];
226
+ for (const b of branches) {
227
+ if (b === current) continue;
228
+ const taken = elsewhere.get(b);
229
+ chips.push(taken
230
+ ? el('button', {
231
+ disabled: true, title: 'git will not check this out twice — it is already in ' + taken,
232
+ style: CHIP_DEAD,
233
+ }, iv(b + ' — in ' + shortPath(taken)))
234
+ : el('button', {
235
+ onClick: () => pick(b),
236
+ style: state.newSessBranch === b && !state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
237
+ }, iv(b)));
238
+ }
239
+ chips.push(el('button', {
240
+ onClick: () => setState({ newSessBranchMode: true, newSessBranch: '', newSessError: null }),
241
+ style: state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
242
+ }, '+ new branch…'));
243
+
244
+ out.push(el('div', { style: ROW }, chips));
245
+
246
+ if (state.newSessBranchMode) {
247
+ out.push(el('div', { style: ROW },
248
+ el('input', {
249
+ value: state.newSessBranchNew, placeholder: 'initiative/my-branch', style: INPUT,
250
+ // Written straight onto the state object WITHOUT a render: a repaint between keystrokes
251
+ // rebuilds the input and takes the caret with it. Same contract as panel-detail's newField.
252
+ onInput: (e) => { state.newSessBranchNew = e.target.value; },
253
+ onChange: () => setState({}),
254
+ }),
255
+ ));
256
+ }
257
+
258
+ // Isolation is only a question once a branch is actually being changed. Asked about a session
259
+ // that is staying put, it would be a checkbox with no meaning.
260
+ if (state.newSessBranchMode || state.newSessBranch) {
261
+ const on = isolateNow();
262
+ out.push(el('div', { style: ROW },
263
+ el('button', {
264
+ onClick: () => setState({ newSessIsolate: !on }),
265
+ style: on ? CHIP_ON : CHIP, hover: HOVER,
266
+ }, iv((on ? '☑' : '☐') + ' its own folder (worktree)')),
267
+ el('span', { style: SUB }, iv(on
268
+ ? 'the branch is checked out in a new folder — sessions already running are untouched'
269
+ : 'this repository is switched to that branch — every session already in it moves too')),
270
+ ));
271
+ }
272
+ return out;
273
+ }
274
+
275
+ const shortPath = (p) => { const s = String(p || ''); const i = s.lastIndexOf('/'); return i < 0 ? s : s.slice(i + 1); };
276
+
277
+ /** What the isolate checkbox currently reads — the PM's choice if they made one, else the server's
278
+ * own rule (protect other sessions when there are other sessions to protect), computed here so the
279
+ * box shows what will actually be sent rather than an unchecked default that means something else. */
280
+ function isolateNow() {
281
+ if (state.newSessIsolate !== null && state.newSessIsolate !== undefined) return state.newSessIsolate;
282
+ const ws = state.wsId;
283
+ const live = (state.sessions || []).filter((s) => s && s.ws === ws && s.state !== 'exited');
284
+ return live.length > 0;
285
+ }
286
+
287
+ /* ── the profile chips ─────────────────────────────────────────────────────────────────────── */
288
+
289
+ /**
290
+ * Ported from `renderProfileChips` (app.js:1655) for its three honesty branches, which are the
291
+ * whole reason this is not just a list:
292
+ * · a FAILED read offers only `no profile`, and says why in the server's own words;
293
+ * · a BROKEN descriptor is named, marked, and NOT offered — while every good chip beside it
294
+ * still is, because one bad file must not turn the surface into a stated absence;
295
+ * · `absent` ("the directory does not exist") reads differently from a read that found none.
296
+ */
297
+ function profileSection() {
298
+ const out = [el('div', { style: LABEL }, 'profile')];
299
+ const p = state.profiles;
300
+
301
+ const chipFor = (id, label) => el('button', {
302
+ onClick: () => setState({ preflightProfile: id, newSessError: null }),
303
+ style: state.preflightProfile === id ? CHIP_ON : CHIP, hover: HOVER,
304
+ }, iv(label || id));
305
+
306
+ // `no profile` is a REAL choice, not the absence of one: it is sent as `profile: null`, which the
307
+ // server distinguishes from a client that forgot the field.
308
+ const none = chipFor(null, 'no profile');
309
+
310
+ if (FIXTURE) {
311
+ const list = (FX.PROFILES || []).map((x) => x && x.id).filter(Boolean);
312
+ out.push(el('div', { style: ROW }, none, list.map((id) => chipFor(id))));
313
+ return out;
314
+ }
315
+ if (!p) { out.push(el('div', { style: SUB }, 'reading the profile descriptors…')); return out; }
316
+
317
+ if (p.ok === false || p.error) {
318
+ out.push(el('div', { style: ROW }, none));
319
+ out.push(el('div', { style: WARN },
320
+ iv('the profile descriptors could not be read — ' + (p.error || 'no reason given')
321
+ + '. Only “no profile” is offered; PMOS will not name a descriptor it did not read.')));
322
+ return out;
323
+ }
324
+
325
+ const good = Array.isArray(p.profiles) ? p.profiles : [];
326
+ const broken = Array.isArray(p.broken) ? p.broken : [];
327
+ out.push(el('div', { style: ROW },
328
+ none,
329
+ good.map((x) => chipFor(x.id, x.id)),
330
+ // Shown and withheld in the same breath. An unusable descriptor that simply vanished would
331
+ // leave the PM wondering where their profile went.
332
+ broken.map((b) => el('button', { disabled: true, style: CHIP_BAD, title: b.error },
333
+ iv((b.file || '?').replace(/\.json$/, '') + ' — unusable'))),
334
+ ));
335
+
336
+ if (p.absent) {
337
+ out.push(el('div', { style: SUB }, iv('no profile directory at ' + (p.dir || '?')
338
+ + ' — nothing was read, so nothing is offered but “no profile”')));
339
+ } else if (!good.length && !broken.length) {
340
+ out.push(el('div', { style: SUB }, iv((p.dir || 'the profile directory') + ' was read and holds no descriptors')));
341
+ }
342
+ if (broken.length) {
343
+ out.push(el('div', { style: WARN },
344
+ iv(broken.length + ' descriptor' + (broken.length === 1 ? '' : 's') + ' could not be parsed and '
345
+ + (broken.length === 1 ? 'is' : 'are') + ' not offered: '
346
+ + broken.map((b) => b.file + ' (' + b.error + ')').join(' · '))));
347
+ }
348
+ return out;
349
+ }
350
+
351
+ /* ── starting ──────────────────────────────────────────────────────────────────────────────── */
352
+
353
+ /**
354
+ * THE INVERSION. Ask first; open only on an answer that said yes.
355
+ *
356
+ * Every early exit and every failure path here leaves the surface exactly as it found it. There is
357
+ * deliberately no `finally` that opens a tab, no `catch` that assumes success, and no best-effort
358
+ * write to `extraSess` before the await — the previous implementation's `try { … } catch {}` around
359
+ * the real start, with the tab already open, is the shape being removed.
360
+ */
361
+ async function onStart() {
362
+ if (state.newSessBusy) return; // one request per press; the button also disables
363
+ const open = state.newSess;
364
+ if (!open) return;
365
+ const slug = open.slug;
366
+ const name = String(state.newSessName || '').trim() || proposeName(slug);
367
+ const createBranch = !!state.newSessBranchMode;
368
+ const branch = createBranch ? String(state.newSessBranchNew || '').trim() : (state.newSessBranch || null);
369
+
370
+ if (createBranch && !branch) {
371
+ setState({ newSessError: 'name the new branch, or choose “stay on current”' });
372
+ return;
373
+ }
374
+
375
+ /* FIXTURE MODE FILES THE ROW AND OPENS THE TAB WITH NO REQUEST AT ALL.
376
+ *
377
+ * That is not a shortcut around R1, it is R1's premise: in fixture mode there is no server, so
378
+ * "did the server accept?" is not a question that exists to be answered honestly. The offline
379
+ * demo path must make no network call — it is what lets no-network.test.js and the design
380
+ * comparator trust a fixture run — and a form that could never complete there would be a dead
381
+ * control in the only mode the comparator ever sees. The refusal contract is a REAL-MODE
382
+ * contract and is asserted in real mode (pm-term-test.js).
383
+ */
384
+ if (FIXTURE) {
385
+ const sess = { name, st: 'working', label: 'working', profile: state.preflightProfile || null,
386
+ branch: branch || null };
387
+ setState((s) => ({
388
+ // The question is over, so its answer is discarded with it — see closeNewSession.
389
+ newSess: null, newSessBusy: false, newSessError: null, preflightProfile: null,
390
+ extraSess: { ...s.extraSess, [slug]: [...(s.extraSess[slug] || []), sess] },
391
+ }), () => openTermTab(slug, sess));
392
+ return;
393
+ }
394
+
395
+ if (!termMod || typeof termMod.requestSpawn !== 'function') {
396
+ setState({ newSessError: 'the terminal module has not loaded — nothing was started' });
397
+ return;
398
+ }
399
+
400
+ setState({ newSessBusy: true, newSessError: null });
401
+ const res = await termMod.requestSpawn({
402
+ name,
403
+ agent: state.newSessAgent || 'shell',
404
+ ws: state.wsId || null,
405
+ branch: branch || null,
406
+ createBranch,
407
+ // Sent explicitly, ALWAYS, and only when a branch is in play — the server defaults an omitted
408
+ // value from liveSessionCount, and its own comment asks the client to state what the PM saw.
409
+ isolate: branch ? isolateNow() : null,
410
+ profile: state.preflightProfile === undefined ? null : state.preflightProfile,
411
+ });
412
+
413
+ if (!res || !res.ok) {
414
+ /* THE REFUSAL PATH. The form stays open, the server's sentence is shown in it, and NOTHING was
415
+ * created: no tab, no pane, no xterm, no extraSess row. This is the assertion the rubric makes
416
+ * a veto. */
417
+ setState({ newSessBusy: false, newSessError: (res && res.error) || 'the workspace server refused, without saying why' });
418
+ return;
419
+ }
420
+
421
+ /* Accepted. Only now does anything appear. The session record carries the SERVER's id, so the tab
422
+ * opened from it attaches to precisely this PTY — `bind()` returns at its first line and never
423
+ * reaches the fallback spawn, so one press cannot produce two sessions. */
424
+ const sess = {
425
+ name, st: 'working', label: 'working',
426
+ profile: state.preflightProfile || null,
427
+ branch: branch || null,
428
+ id: res.id,
429
+ };
430
+ setState((s) => ({
431
+ // `sess` above already carries the profile; the slot is cleared with the question it answered,
432
+ // so the next `+ session` cannot inherit it. See closeNewSession.
433
+ newSess: null, newSessBusy: false, newSessError: null, preflightProfile: null,
434
+ extraSess: { ...s.extraSess, [slug]: [...(s.extraSess[slug] || []), sess] },
435
+ }), () => {
436
+ openTermTab(slug, sess);
437
+ // The server's non-fatal truth about what it just did — a worktree's path, "not a git
438
+ // repository, so no branch was applied". It travelled with the acceptance and is written into
439
+ // the pane it is about, rather than being dropped because the request succeeded.
440
+ if (res.notice && termMod && typeof termMod.noteOnTab === 'function') {
441
+ termMod.noteOnTab('term:' + slug + ':' + name, 'pmos: ' + res.notice);
442
+ }
443
+ });
444
+ if (dataMod && typeof dataMod.startSession === 'function') {
445
+ try { dataMod.startSession(slug, sess); } catch { /* the poll is a refresh, not the start */ }
446
+ }
447
+ }
448
+
449
+ /* ── render ────────────────────────────────────────────────────────────────────────────────── */
450
+
451
+ /** The pre-flight block, or null when it is closed. Called from panel-tabs.js's render. */
452
+ export function newSessionBlock() {
453
+ if (!state.newSess) return null;
454
+ absorbBranchBroadcast();
455
+ const slug = state.newSess.slug;
456
+ const busy = !!state.newSessBusy;
457
+
458
+ return el('div', { style: WRAP },
459
+ el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
460
+ el('span', { style: TITLE }, 'start a session — more options'),
461
+ el('span', { style: MUTED_MONO }, iv(slug)),
462
+ ),
463
+ el('div', { style: SUB },
464
+ 'What this session runs, and where. + session does all of this in one click on the rule; '
465
+ + 'this is for the times the rule is not what you want.'),
466
+
467
+ el('div', { style: LABEL }, 'name'),
468
+ el('div', { style: ROW },
469
+ el('input', {
470
+ value: state.newSessName, placeholder: slug + ' 1', style: INPUT, disabled: busy,
471
+ onInput: (e) => { state.newSessName = e.target.value; },
472
+ onChange: () => setState({}),
473
+ }),
474
+ ),
475
+
476
+ el('div', { style: LABEL }, 'agent'),
477
+ el('div', { style: ROW },
478
+ ['shell', 'claude'].map((a) => el('button', {
479
+ onClick: () => setState({ newSessAgent: a, newSessError: null }),
480
+ style: state.newSessAgent === a ? CHIP_ON : CHIP, hover: HOVER,
481
+ }, iv(a))),
482
+ el('span', { style: SUB }, 'a CLI that is not on PATH is refused by the server, and says so'),
483
+ ),
484
+
485
+ branchSection(),
486
+ profileSection(),
487
+
488
+ // The refusal, where the question was asked. It is rendered ABOVE the button so the PM reads it
489
+ // before pressing again, and it survives until something changes.
490
+ state.newSessError ? el('div', { style: ERR }, iv(state.newSessError)) : null,
491
+
492
+ el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding-top:2px' },
493
+ el('button', {
494
+ onClick: () => { if (!busy) onStart(); },
495
+ disabled: busy, style: busy ? PRIMARY_OFF : PRIMARY, hover: busy ? null : HOVER,
496
+ }, busy ? 'starting…' : 'start session'),
497
+ el('button', { onClick: () => closeNewSession(), style: CHIP, hover: HOVER, disabled: busy }, 'cancel'),
498
+ ),
499
+
500
+ el('div', { style: SUB },
501
+ 'Nothing appears until the server says it started. If it refuses — an unreadable profile, a '
502
+ + 'branch git will not check out — you will read the reason here and no session, tab or '
503
+ + 'terminal will have been created.'),
504
+ );
505
+ }
506
+
507
+ export default newSessionBlock;
@@ -245,6 +245,36 @@ export function onAddProduct() { setDetail({ kind: 'addprod' }); }
245
245
  * Fill panel 1. `host` IS the prototype's open-panel container (app.js owns its style), so this
246
246
  * appends the panel's three parts as direct children and never wraps them.
247
247
  */
248
+ /* ── AN UNSETTLED STATE ROOT, ON SCREEN ──────────────────────────────────────────────────────
249
+ * `state_health` arrives on /api/workspaces (and on /api/skills, /api/schedules and every
250
+ * /api/console payload). When it is `{ok:false}` the workspace could not move the PM's runtime
251
+ * state out of the checkout, and anything the per-user directory does not hold would otherwise
252
+ * render as a confident empty list — which is how the PM came to see zero skills and zero
253
+ * schedules while forty-five scheduled runs stopped firing.
254
+ *
255
+ * IT BRANCHES ON `ok`, NEVER ON `kind`. The server discriminates the reason — `unmigrated` (two
256
+ * populated directories), `failed` (the filesystem refused the move, with the errno and the cause
257
+ * in words), `unreadable` (one of the two directories could not be listed, so whether it holds
258
+ * state is unknown), `unusable-root` (the state root could not be created at all), `unknown` (an
259
+ * outcome that build did not recognise) — and puts the whole explanation in `error`. Reading only
260
+ * `ok` and `error` here is what makes a kind added later visible on this surface with no client
261
+ * change, which is the failure mode that produced this banner's second round: a `kind` the client
262
+ * had never heard of drawing nothing at all. Two kinds have since been added with no edit here,
263
+ * and state-isolation's F14/U19 assert each of them on a real screen.
264
+ *
265
+ * So it is drawn here, at the top of panel 1, in `--destructive`, on every paint until it is
266
+ * resolved. It is deliberately NOT dismissible: the condition is still true after the click.
267
+ * Nothing renders when the state is settled — including in fixture mode, which has no data layer
268
+ * and therefore never sets the key, so the comparator sees the prototype's markup unchanged. */
269
+ function stateHealthBanner() {
270
+ const h = state.stateHealth;
271
+ if (!h || h.ok !== false) return null;
272
+ return el('div', {
273
+ 'data-state-health': String(h.kind || 'unknown'),
274
+ style: 'padding:9px 14px;border-bottom:1px solid var(--border);font-size:11px;line-height:1.5;color:var(--destructive)',
275
+ }, ip(h.error || 'the workspace runtime state is not settled — it could not be moved out of the checkout'));
276
+ }
277
+
248
278
  export function renderRepos(host) {
249
279
  const tuples = repoTuples();
250
280
  const product = activeProduct(tuples);
@@ -262,6 +292,7 @@ export function renderRepos(host) {
262
292
  );
263
293
 
264
294
  const scroll = el('div', { style: 'flex:1;overflow-y:auto;min-height:0' },
295
+ stateHealthBanner(),
265
296
  tuples.map((t) => repoButton(t, product)),
266
297
  el('button', {
267
298
  onClick: onAddProduct,
@@ -32,6 +32,7 @@ import * as FX from './fixture.js';
32
32
  import { openTab, openTerm as openTermTab, openDoc as openDocTab } from './panel-initiatives.js';
33
33
  import { mountDoc, disposeDoc, docText } from './tab-doc.js';
34
34
  import { mountTask } from './tab-task.js';
35
+ import { newSessionBlock, openNewSession } from './panel-newsess.js';
35
36
 
36
37
  /* tab-term.js is loaded LAZILY and on purpose: it is another module's file, a static import of a
37
38
  * file that is not on disk yet takes the whole panel down with it, and an empty terminal pane is a
@@ -239,7 +240,18 @@ export function newSession(slug, opts) {
239
240
  /* `spawn` is carried on the session and thence onto the tab: it is an INSTRUCTION to start a PTY,
240
241
  * and it is the only thing that authorises one. Without it the terminal resolves by name, which
241
242
  * is a question about what already exists, not a request for something new. */
242
- const sess = { name, st: 'working', label: 'working', profile: state.newSessProfile, spawn: true };
243
+ /* NO PROFILE, and stated rather than left to a slot. This control asks the PM nothing — that is
244
+ * what makes it one click — so the only honest thing it can say about a profile is that none was
245
+ * chosen, which the server reads as "no overlay" rather than as a client that forgot the field.
246
+ *
247
+ * It used to read `state.newSessProfile`, the slot the `⋯ options` pre-flight also wrote. Opening
248
+ * that form wrote the server's remembered preference into it and every chip wrote the PM's choice
249
+ * into it, and cancelling cleared neither — so a question the PM had abandoned came back as an
250
+ * instruction on the next one-click start, travelled to the server on the tab, and was refused
251
+ * whenever the descriptor could not be resolved. The click then opened a terminal and wrote the
252
+ * refusal inside it, for a session that never existed. A profile is a per-session question, and
253
+ * the control that asks it is the only one that may answer it. */
254
+ const sess = { name, st: 'working', label: 'working', profile: null, spawn: true };
243
255
  setState((s) => ({
244
256
  startN: n + 1,
245
257
  startPrompt: null,
@@ -292,6 +304,21 @@ const newSessionBtn = el('button', {
292
304
  onClick: () => newSession(), style: CHIP_STYLE, hover: CHIP_HOVER,
293
305
  }, '+ session');
294
306
 
307
+ /* `⋯ options` — the demoted composer, restored (workspace-session-preflight).
308
+ *
309
+ * `workspace-repo-first`'s accepted PRD says the six-question composer "remains reachable as *more
310
+ * options* … it is demoted, not deleted". The four-panel rebuild deleted it, and start-policy.test
311
+ * has been asserting in a comment that `spawnWithBranch` is "still reachable as more options" —
312
+ * reachable from the server, by no client. This chip is the client half.
313
+ *
314
+ * `+ session` above is untouched: same handler, same text, same CHIP_STYLE, same co-tenancy
315
+ * chooser. One click stays the primary path, which is what that PRD decided. */
316
+ const newSessionOptsBtn = el('button', {
317
+ onClick: () => openNewSession(selSlug()),
318
+ style: CHIP_STYLE, hover: CHIP_HOVER,
319
+ title: 'choose the branch, the agent and the profile before the session starts',
320
+ }, '⋯ options');
321
+
295
322
  const splitLabel = iv('⫽ split');
296
323
  const splitBtn = el('button', {
297
324
  onClick: () => toggleSplit(), style: SPLIT_STYLE, hover: CHIP_HOVER,
@@ -769,6 +796,7 @@ export function renderTabs(host) {
769
796
  setChildren(tabBar, [
770
797
  ...visTabs.map((t) => tabNode(t, act, splitTab)),
771
798
  newSessionBtn,
799
+ newSessionOptsBtn,
772
800
  canSplit ? splitBtn : null,
773
801
  ]);
774
802
 
@@ -806,7 +834,11 @@ export function renderTabs(host) {
806
834
  // was. Replacing the panes with it would re-parent a terminal, which is the quiet regression
807
835
  // the persistent pane hosts exist to prevent.
808
836
  startPromptBlock(),
809
- visTabs.length === 0 && !state.startPrompt ? emptyState : null,
837
+ // The pre-flight sits in the same slot and for the same reason: it is about the click just made
838
+ // in the bar above, and putting it here leaves every open pane — and every live PTY — exactly
839
+ // where it was. Replacing the panes with it would re-parent a terminal.
840
+ newSessionBlock(),
841
+ visTabs.length === 0 && !state.startPrompt && !state.newSess ? emptyState : null,
810
842
  panesRow,
811
843
  state.scratchOpen ? scratchBlock : null,
812
844
  bottomBar,