create-pmos 0.55.0 → 0.55.2

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.
@@ -2120,6 +2120,144 @@
2120
2120
  }
2121
2121
  }
2122
2122
 
2123
+ /* 0.55.1 — the dock's sessions, grouped by initiative (dock-sessions.js). The side list, then the strip on top. */
2124
+ .fr-ds-group {
2125
+ display: flex;
2126
+ flex-direction: column;
2127
+ gap: 2px;
2128
+ padding-top: 8px;
2129
+ }
2130
+ .fr-ds-head {
2131
+ display: flex;
2132
+ align-items: center;
2133
+ gap: 4px;
2134
+ min-height: 28px;
2135
+ padding-left: 6px;
2136
+ }
2137
+ .fr-ds-head__title {
2138
+ flex: 1;
2139
+ min-width: 0;
2140
+ margin: 0;
2141
+ overflow: hidden;
2142
+ color: var(--muted-foreground);
2143
+ font-size: 11px;
2144
+ font-weight: 600;
2145
+ text-overflow: ellipsis;
2146
+ white-space: nowrap;
2147
+ }
2148
+ .fr-ds-row {
2149
+ gap: 2px;
2150
+ padding: 2px 0 2px 6px;
2151
+ border: 1px solid transparent;
2152
+ background: transparent;
2153
+ }
2154
+ .fr-ds-row[data-front="true"] {
2155
+ border-color: var(--border);
2156
+ background: var(--card);
2157
+ }
2158
+ .fr-ds-confirm {
2159
+ display: flex;
2160
+ flex-wrap: wrap;
2161
+ align-items: center;
2162
+ gap: 4px;
2163
+ padding: 6px;
2164
+ border: 1px solid var(--destructive);
2165
+ }
2166
+ .fr-ds-confirm__text {
2167
+ flex: 1 1 100%;
2168
+ min-width: 0;
2169
+ font-size: 12px;
2170
+ }
2171
+ .fr-ds-confirm--strip .fr-ds-confirm__text {
2172
+ flex: none;
2173
+ }
2174
+ .fr-ds-confirm--strip {
2175
+ flex: none;
2176
+ flex-wrap: nowrap;
2177
+ align-self: center;
2178
+ padding: 0 6px;
2179
+ }
2180
+ .fr-ds-foot {
2181
+ display: flex;
2182
+ justify-content: flex-end;
2183
+ gap: 4px;
2184
+ margin-top: auto;
2185
+ padding-top: 8px;
2186
+ border-top: 1px solid var(--border);
2187
+ }
2188
+ .fr-ds-note {
2189
+ margin: 0;
2190
+ padding: 8px 6px;
2191
+ color: var(--muted-foreground);
2192
+ font-size: 12px;
2193
+ }
2194
+ .fr-ds-error {
2195
+ margin: 0;
2196
+ color: var(--destructive);
2197
+ font-size: 12px;
2198
+ }
2199
+ .fr-ds-error:empty {
2200
+ display: none;
2201
+ }
2202
+ .fr-ds-on {
2203
+ display: flex;
2204
+ flex-direction: column;
2205
+ gap: 12px;
2206
+ }
2207
+ .fr-ds-starts {
2208
+ display: flex;
2209
+ flex-direction: column;
2210
+ }
2211
+ .fr-ds-starts[aria-busy="true"] {
2212
+ opacity: 0.6;
2213
+ pointer-events: none;
2214
+ }
2215
+ .fr-ds-starts__head {
2216
+ margin: 8px 0 4px;
2217
+ color: var(--muted-foreground);
2218
+ font-size: 12px;
2219
+ font-weight: 600;
2220
+ }
2221
+ .fr-ds-groupbox {
2222
+ flex: none;
2223
+ align-self: center;
2224
+ }
2225
+ .fr-ds-select {
2226
+ max-width: 160px;
2227
+ min-height: 28px;
2228
+ font-size: 12px;
2229
+ }
2230
+ .fr-ds-chip {
2231
+ flex: none;
2232
+ align-self: center;
2233
+ max-width: 160px;
2234
+ overflow: hidden;
2235
+ text-overflow: ellipsis;
2236
+ white-space: nowrap;
2237
+ }
2238
+ .fr-ds-push {
2239
+ margin-left: auto;
2240
+ align-self: center;
2241
+ }
2242
+ .fr-ds-tool {
2243
+ align-self: center;
2244
+ }
2245
+ .fr-ds-select:focus-visible,
2246
+ .fr-ds-chip:focus-visible {
2247
+ outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
2248
+ outline-offset: 1px;
2249
+ }
2250
+ @media (pointer: coarse) {
2251
+ .fr-ds-head,
2252
+ .fr-ds-select,
2253
+ .fr-ds-chip {
2254
+ min-height: 44px;
2255
+ }
2256
+ .fr-ds-row {
2257
+ min-height: 48px;
2258
+ }
2259
+ }
2260
+
2123
2261
  /* -- layouts · .td- Today (Run 3, slice 3B) -- */
2124
2262
  .td-board {
2125
2263
  display: flex;
@@ -2172,6 +2310,7 @@
2172
2310
  }
2173
2311
  .td-day__hello { flex: 0 1 220px; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 14px; color: var(--foreground); white-space: nowrap; }
2174
2312
  .td-day__dim { color: var(--muted-foreground); }
2313
+ .td-day__warn { color: var(--warning); }
2175
2314
  .td-day__mono { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 12px; }
2176
2315
  .td-day__steps { display: flex; flex-wrap: wrap; gap: 2px; list-style: none; margin: 0; padding: 0; }
2177
2316
  .td-day__ts { gap: 6px; padding: 0 6px; letter-spacing: 0.04em; color: var(--muted-foreground); }
@@ -1321,6 +1321,14 @@ export async function bindSessionInitiative(id, initiative) {
1321
1321
  return out || { ok: false, error: 'the workspace server did not answer' };
1322
1322
  }
1323
1323
 
1324
+ /** 0.55.1 — the dock's ⋯ sheet: rename a session and set its type of work (`workType` null clears it). Labels only. */
1325
+ export async function setSessionMeta(id, patch) {
1326
+ if (FIXTURE) return { ok: false, error: 'fixture mode has no server' };
1327
+ const out = await postJson('/api/session/meta', { id, ...(patch || {}) });
1328
+ if (out && out.ok) setTimeout(pollSessions, 200);
1329
+ return out || { ok: false, error: 'the workspace server did not answer' };
1330
+ }
1331
+
1324
1332
  /* ── the scratchpad ─────────────────────────────────────────────────────────────────────────────
1325
1333
  * The pad is PER SESSION, and the server has always stored it that way: `/api/scratchpad` is keyed
1326
1334
  * by the server-issued session id (workspace-scoped, one file per session, deleted with it). What
@@ -0,0 +1,373 @@
1
+ /* pm/dock-sessions.js — the dock's sessions, grouped by initiative (0.55.1).
2
+ *
3
+ * The dock used to list tabs. A tab says nothing about what its session is for, so the PM could not tell which agent was
4
+ * working on which initiative, and ending a session, naming it or starting one on a branch took a trip to a separate sheet.
5
+ * This module is the dock's list of the repository's SESSIONS:
6
+ *
7
+ * railNodes(repo, otherRows) the side list: one group per initiative, then "Not linked", then "Scheduled", then the
8
+ * open tabs that are not sessions (documents, skills, views) under "Open"
9
+ * stripNodes(repo, otherNodes) the strip on top: a group select, the group's sessions as chips, + and ✕
10
+ * listedSessionIds(repo) the sessions the list shows, so the caller does not draw their tabs twice
11
+ * openNewSessionSheet(repo, {initiative})
12
+ * the one new-session sheet: name, initiative, where it runs, and how it starts
13
+ *
14
+ * The initiative of a session is the one it recorded (spawn or the PM's word), else the one its branch names
15
+ * (initiative/<id>). A session started by a schedule is in "Scheduled" whatever it is for. Ending a session asks in the row;
16
+ * when it was the last live session on its own working copy, the toast offers to remove that copy (the branch is kept; the
17
+ * server refuses unsaved work and says why). Live mode only.
18
+ */
19
+ import { el } from './dom.js';
20
+ import { state, setState } from './state.js';
21
+ import { boardOf } from './board-read.js';
22
+ import { button, iconButton, row, field, sheet, toast } from './primitives.js';
23
+ import { COPY } from './frame-copy.js';
24
+ import { sessionDefaults, loadSkills } from './dock.js';
25
+
26
+ let dataMod = null; let termMod = null; let iniMod = null; let tabsMod = null;
27
+ import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
28
+ import('./tab-term.js').then((m) => { termMod = m; }).catch(() => { termMod = null; });
29
+ import('./panel-initiatives.js').then((m) => { iniMod = m; }).catch(() => { iniMod = null; });
30
+ import('./panel-tabs.js').then((m) => { tabsMod = m; }).catch(() => { tabsMod = null; });
31
+
32
+ const C = () => COPY.dock.ds;
33
+ export const WORK_TYPES = Object.freeze(['research', 'prototype', 'prd', 'build', 'review', 'report', 'general']);
34
+ const isEnded = (s) => s.state === 'exited' || s.shown === 'exited';
35
+ const typeLabel = (t) => (t && C().types[t]) || null;
36
+
37
+ /** The type of work a skill starts, read from its id, name and label. Anything unrecognised is general work. */
38
+ export function typeOfSkill(sk) {
39
+ const k = [sk && sk.id, sk && sk.skill, sk && sk.label].filter(Boolean).join(' ').toLowerCase();
40
+ if (/triage|review/.test(k)) return 'review';
41
+ if (/requirement|prd|spec/.test(k)) return 'prd';
42
+ if (/research|discover/.test(k)) return 'research';
43
+ if (/proto/.test(k)) return 'prototype';
44
+ if (/build|implement|code/.test(k)) return 'build';
45
+ if (/report|brief|biweekly/.test(k)) return 'report';
46
+ return 'general';
47
+ }
48
+
49
+ /* ── reading the sessions ─────────────────────────────────────────────────────────────────────────────────────────── */
50
+
51
+ const treesOf = (repo) => (dataMod && typeof dataMod.productTrees === 'function' && repo ? dataMod.productTrees(repo) : []);
52
+ const titleOf = (slug) => { const r = state.records && state.records[slug]; return (r && r.tag) || slug; };
53
+
54
+ function initiativeOf(s, trees) {
55
+ if (s.initiative) return s.initiative;
56
+ const t = trees.find((x) => x.ws === s.ws);
57
+ return (t && t.initiative) || null;
58
+ }
59
+
60
+ /** Every session that runs in one of this repository's working trees, in the server's order. */
61
+ function sessionsOfRepo(repo, trees) {
62
+ const in_ = new Set(trees.map((t) => t.ws));
63
+ return (Array.isArray(state.sessions) ? state.sessions : []).filter((s) => s && in_.has(s.ws));
64
+ }
65
+
66
+ /** [{ key, title, initiative, scheduled, sessions }]: initiatives in board order, then Not linked, then Scheduled. */
67
+ export function groupsOf(repo) {
68
+ const trees = treesOf(repo);
69
+ const order = ((boardOf() || {})[repo] || []).flatMap((g) => g.slugs || []);
70
+ const rank = (slug) => { const i = order.indexOf(slug); return i < 0 ? order.length : i; };
71
+ const by = new Map(); const none = []; const sched = [];
72
+ for (const s of sessionsOfRepo(repo, trees)) {
73
+ if (String(s.origin || '').startsWith('schedule:')) { sched.push(s); continue; }
74
+ const i = initiativeOf(s, trees);
75
+ if (!i) { none.push(s); continue; }
76
+ if (!by.has(i)) by.set(i, []);
77
+ by.get(i).push(s);
78
+ }
79
+ const out = [...by.keys()].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
80
+ .map((i) => ({ key: 'i:' + i, title: titleOf(i), initiative: i, scheduled: false, sessions: by.get(i) }));
81
+ if (none.length) out.push({ key: 'none', title: C().notLinked, initiative: null, scheduled: false, sessions: none });
82
+ if (sched.length) out.push({ key: 'sched', title: C().scheduled, initiative: null, scheduled: true, sessions: sched });
83
+ return out;
84
+ }
85
+
86
+ export function listedSessionIds(repo) {
87
+ return new Set(groupsOf(repo).flatMap((g) => g.sessions.map((s) => s.id)));
88
+ }
89
+
90
+ const frontTab = () => (state.tabs || []).find((t) => t.id === state.active) || null;
91
+ function frontSessionId() {
92
+ const t = frontTab();
93
+ return t && t.kind === 'term' ? t.sessionId || null : null;
94
+ }
95
+
96
+ /** Bring a session to the front: its open tab when it has one, else a new tab on that exact process. */
97
+ export function openSession(s) {
98
+ const t = (state.tabs || []).find((x) => x.kind === 'term' && x.sessionId === s.id);
99
+ if (t) { setState({ active: t.id }); return; }
100
+ if (iniMod) iniMod.openTerm(null, { id: s.id, name: s.name, st: isEnded(s) ? 'exited' : 'working', profile: s.profile_id || null });
101
+ }
102
+
103
+ function metaOf(s, trees) {
104
+ const parts = [typeLabel(s.work_type) || C().types.general];
105
+ if (isEnded(s)) parts.push(C().ended);
106
+ const t = trees.find((x) => x.ws === s.ws);
107
+ // the branch is named when the session is not on main, unless it is the initiative's own (the group already says that)
108
+ const own = t && t.initiative && t.initiative === initiativeOf(s, trees);
109
+ if (t && !t.main && !own) parts.push('⑂ ' + (t.branch || 'detached'));
110
+ return parts.join(' · ');
111
+ }
112
+
113
+ /* ── ending a session ─────────────────────────────────────────────────────────────────────────────────────────────── */
114
+
115
+ async function endSession(repo, s) {
116
+ if (!dataMod) return;
117
+ // Which tabs are on this session, read BEFORE asking: once the server drops it, a tab re-joins by name and stops saying.
118
+ const tabIds = tabsMod && typeof tabsMod.tabIdsOfSession === 'function' ? tabsMod.tabIdsOfSession(s.id) : [];
119
+ const t = treesOf(repo).find((x) => x.ws === s.ws) || null;
120
+ const othersLive = (state.sessions || []).some((x) => x && x.id !== s.id && x.ws === s.ws && !isEnded(x));
121
+ let out = await dataMod.removeSessionById(s.id);
122
+ // "asked it to end, not exited yet": the process is going. Ask again a few times rather than make the PM press twice;
123
+ // a session that is gone by then ("no such session") has ended.
124
+ for (let i = 0; i < 5 && out && out.ok !== false && out.removed === false; i++) {
125
+ await new Promise((r) => setTimeout(r, 600));
126
+ const again = await dataMod.removeSessionById(s.id);
127
+ out = again && again.ok === false && /no such session/.test(again.error || '') ? { ok: true, removed: true } : again;
128
+ }
129
+ setState({ dsEnding: null });
130
+ if (!out || out.ok === false) { toast({ text: (out && out.error) || C().endFailed, tone: 'error' }); return; }
131
+ if (out.removed !== false && tabsMod && typeof tabsMod.closeTabsById === 'function') tabsMod.closeTabsById(tabIds);
132
+ if (t && !t.main && !othersLive) {
133
+ toast({ text: C().endedTree(s.name, t.branch || 'detached'), actionLabel: C().removeCopy, timeout: 12000, onAction: () => removeCopy(t) });
134
+ } else {
135
+ toast({ text: out.removed === false ? (out.note || C().endedOne(s.name)) : C().endedOne(s.name) });
136
+ }
137
+ }
138
+
139
+ async function removeCopy(t) {
140
+ const out = dataMod ? await dataMod.removeWorktree(t.ws, t.dir) : null;
141
+ if (out && out.ok !== false) {
142
+ toast({ text: C().copyRemoved(t.branch || 'detached') });
143
+ if (typeof dataMod.refreshTrees === 'function') dataMod.refreshTrees();
144
+ } else {
145
+ toast({ text: C().copyKept((out && out.error) || C().refused), tone: 'error', timeout: 10000 });
146
+ }
147
+ }
148
+
149
+ function confirmNode(repo, s, cls) {
150
+ return el('div', { class: cls, role: 'group', 'aria-label': C().endAsk(s.name), 'data-ds-confirm': s.id },
151
+ el('span', { class: 'fr-ds-confirm__text' }, C().endAsk(s.name)),
152
+ button({ label: C().endYes, variant: 'danger', size: 'sm', onClick: () => endSession(repo, s), attrs: { 'data-ds': 'end-yes' } }),
153
+ button({ label: C().cancel, variant: 'ghost', size: 'sm', onClick: () => setState({ dsEnding: null }), attrs: { 'data-ds': 'end-no' } }));
154
+ }
155
+
156
+ /* ── the ⋯ sheet: name, initiative, type of work ──────────────────────────────────────────────────────────────────── */
157
+
158
+ function initiativeOptions(repo) {
159
+ const slugs = [...new Set(((boardOf() || {})[repo] || []).flatMap((g) => g.slugs || []))];
160
+ return slugs.map((slug) => ({ value: slug, label: titleOf(slug) === slug ? slug : titleOf(slug) + ' (' + slug + ')' }));
161
+ }
162
+
163
+ function retitleTabs(id, name) {
164
+ setState((st) => ({ tabs: (st.tabs || []).map((t) => (t.kind === 'term' && t.sessionId === id ? { ...t, title: name } : t)) }));
165
+ }
166
+
167
+ export function openMetaSheet(repo, s) {
168
+ const opts = initiativeOptions(repo);
169
+ if (s.initiative && !opts.some((o) => o.value === s.initiative)) opts.unshift({ value: s.initiative, label: s.initiative });
170
+ const nameF = field({ label: C().name, value: s.name, attrs: { 'data-ds': 'meta-name' } });
171
+ const iniF = field({ label: C().initiative, kind: 'select', value: s.initiative || '', options: [{ value: '', label: C().noInitiative }, ...opts], attrs: { 'data-ds': 'meta-initiative' } });
172
+ const typeF = field({ label: C().type, kind: 'select', value: s.work_type || '', attrs: { 'data-ds': 'meta-type' },
173
+ options: [{ value: '', label: C().noType }, ...WORK_TYPES.map((w) => ({ value: w, label: C().types[w] }))] });
174
+ const err = el('p', { class: 'fr-ds-error', role: 'alert' });
175
+ let busy = false;
176
+ const save = async () => {
177
+ if (busy || !dataMod) return;
178
+ busy = true; err.textContent = '';
179
+ const patch = {};
180
+ const nm = nameF.control.value.trim();
181
+ if (nm && nm !== s.name) patch.name = nm;
182
+ const wt = typeF.control.value || null;
183
+ if (wt !== (s.work_type || null)) patch.workType = wt;
184
+ if (Object.keys(patch).length) {
185
+ const o = await dataMod.setSessionMeta(s.id, patch);
186
+ if (!o || o.ok === false) { err.textContent = (o && o.error) || C().saveFailed; busy = false; return; }
187
+ if (patch.name) retitleTabs(s.id, o.name || patch.name);
188
+ }
189
+ const ni = iniF.control.value || null;
190
+ if (ni !== (s.initiative || null)) {
191
+ const o = await dataMod.bindSessionInitiative(s.id, ni);
192
+ if (!o || o.ok === false) { err.textContent = (o && o.error) || C().saveFailed; busy = false; return; }
193
+ }
194
+ sh.close();
195
+ };
196
+ const sh = sheet({
197
+ title: C().metaTitle, sub: s.name, body: [nameF, iniF, typeF, err], attrs: { 'data-ds': 'meta' },
198
+ actions: [
199
+ button({ label: C().cancel, variant: 'ghost', onClick: () => sh.close() }),
200
+ button({ label: C().save, variant: 'primary', onClick: save, attrs: { 'data-ds': 'meta-save' } }),
201
+ ],
202
+ });
203
+ return sh;
204
+ }
205
+
206
+ /* ── the new-session sheet ────────────────────────────────────────────────────────────────────────────────────────── */
207
+
208
+ function uniqueName(base) {
209
+ const taken = new Set((state.sessions || []).filter((s) => s && !isEnded(s)).map((s) => s.name));
210
+ if (!taken.has(base)) return base;
211
+ for (let i = 2; ; i += 1) if (!taken.has(base + ' ' + i)) return base + ' ' + i;
212
+ }
213
+
214
+ /**
215
+ * One sheet, one list. "On" defaults to Automatic: the server's start policy picks the tree (the initiative's own working
216
+ * copy on initiative/<id>, made the first time, or the main tree when there is no initiative). An existing tree or a new
217
+ * branch can be chosen instead. A press on Blank session or on a skill starts it at once.
218
+ */
219
+ export function openNewSessionSheet(repo, preset) {
220
+ const trees = treesOf(repo);
221
+ const main = trees.find((t) => t.main) || trees[0] || null;
222
+ const ws = main ? main.ws : (dataMod && dataMod.wsOf ? dataMod.wsOf(repo) : null);
223
+ const d = sessionDefaults();
224
+ const m = { initiative: (preset && preset.initiative) || '', on: 'auto', busy: false };
225
+
226
+ const nameF = field({ label: C().name, placeholder: C().namePh, attrs: { 'data-ds': 'new-name' } });
227
+ const opts = initiativeOptions(repo);
228
+ if (m.initiative && !opts.some((o) => o.value === m.initiative)) opts.unshift({ value: m.initiative, label: m.initiative });
229
+ const forF = field({ label: C().forLabel, kind: 'select', value: m.initiative, attrs: { 'data-ds': 'new-for' },
230
+ options: [{ value: '', label: C().noInitiative }, ...opts], onCommit: (v) => { m.initiative = v; paintOn(); } });
231
+ const branchF = field({ label: C().branchName, placeholder: 'feature/…', attrs: { 'data-ds': 'new-branch' } });
232
+ const onHost = el('div', { class: 'fr-ds-on' });
233
+ const err = el('p', { class: 'fr-ds-error', role: 'alert' });
234
+ const list = el('div', { class: 'fr-ds-starts', 'data-ds': 'starts' });
235
+
236
+ const autoWhere = () => {
237
+ if (!m.initiative) return C().mainTree(main && main.branch);
238
+ const b = 'initiative/' + m.initiative;
239
+ return trees.some((t) => t.branch === b) ? '⑂ ' + b : C().newFolder(b);
240
+ };
241
+ function paintOn() {
242
+ const options = [{ value: 'auto', label: C().auto(autoWhere()) },
243
+ ...trees.map((t) => ({ value: 'ws:' + t.ws, label: t.main ? C().mainTree(t.branch) : '⑂ ' + (t.branch || 'detached') })),
244
+ { value: 'new', label: C().newBranch }];
245
+ const onF = field({ label: C().on, kind: 'select', value: m.on, options, attrs: { 'data-ds': 'new-on' }, onCommit: (v) => { m.on = v; paintOn(); } });
246
+ onHost.replaceChildren(onF, m.on === 'new' ? branchF : '');
247
+ }
248
+
249
+ async function go(sk) {
250
+ if (m.busy) return;
251
+ if (!termMod || !ws) { err.textContent = C().refused; return; }
252
+ err.textContent = '';
253
+ const typed = nameF.control.value.trim();
254
+ const base = (sk ? (sk.label || sk.skill || sk.id) : (d.cli === 'claude' ? 'Claude' : 'Shell')) + (m.initiative ? ' · ' + m.initiative : '');
255
+ const name = typed || uniqueName(base);
256
+ const common = {
257
+ name, agent: sk ? 'claude' : d.cli, initiative: m.initiative || null, profile: null,
258
+ skill: sk ? sk.id : null, workType: sk ? typeOfSkill(sk) : 'general',
259
+ tier: !sk && d.cli === 'claude' && d.tier !== 'auto' ? d.tier : null,
260
+ };
261
+ let opts;
262
+ if (m.on === 'auto') opts = { ...common, start: true, ws };
263
+ else if (m.on === 'new') {
264
+ const b = branchF.control.value.trim();
265
+ if (!b) { err.textContent = C().needBranch; return; }
266
+ opts = { ...common, ws, branch: b, createBranch: true, isolate: true };
267
+ } else opts = { ...common, ws: m.on.slice(3), branch: null, createBranch: false, isolate: null };
268
+ m.busy = true; list.setAttribute('aria-busy', 'true');
269
+ const res = await termMod.requestSpawn(opts);
270
+ m.busy = false; list.removeAttribute('aria-busy');
271
+ if (!res || !res.ok) { err.textContent = (res && res.error) || C().refused; return; }
272
+ sh.close();
273
+ if (iniMod) iniMod.openTerm(null, { id: res.id, name, st: 'working', profile: null });
274
+ if (dataMod && typeof dataMod.startSession === 'function') dataMod.startSession(null, null);
275
+ // a session that shares its working tree with a running one is told so: their edits land in the same files
276
+ if (res.notice && /already running/.test(res.notice)) toast({ text: res.notice, timeout: 12000 });
277
+ }
278
+
279
+ function paintStarts(got) {
280
+ const rows = [row({ title: C().blank, meta: [C().blankMeta(d.cli, d.tier)], onOpen: () => go(null), attrs: { class: 'fr-ds-start', 'data-ds-start': 'blank' } })];
281
+ for (const sk of (got && got.list) || []) {
282
+ rows.push(row({ title: sk.label || sk.skill || sk.id, meta: [C().types[typeOfSkill(sk)]], onOpen: () => go(sk),
283
+ attrs: { class: 'fr-ds-start', 'data-ds-start': sk.id } }));
284
+ }
285
+ const note = !got ? C().skillsLoading : (got.error ? COPY.dock.skillsUnread : (got.list && got.list.length ? null : COPY.dock.noSkills));
286
+ list.replaceChildren(el('h3', { class: 'fr-ds-starts__head' }, C().startHead), ...rows, note ? el('p', { class: 'fr-ds-note' }, note) : '');
287
+ }
288
+
289
+ paintOn();
290
+ paintStarts(null);
291
+ loadSkills(ws).then((got) => paintStarts(got || { list: [], error: true }), () => paintStarts({ list: [], error: true }));
292
+ const sh = sheet({
293
+ title: m.initiative ? C().newFor(titleOf(m.initiative)) : C().newTitle, sub: C().newSub,
294
+ body: [nameF, forF, onHost, err, list], attrs: { 'data-ds': 'new' },
295
+ });
296
+ return sh;
297
+ }
298
+
299
+ /* ── the side list ────────────────────────────────────────────────────────────────────────────────────────────────── */
300
+
301
+ function sessionRow(repo, s, trees, front) {
302
+ if (state.dsEnding === s.id) return confirmNode(repo, s, 'fr-ds-confirm');
303
+ return row({
304
+ title: s.name, meta: [metaOf(s, trees)], onOpen: () => openSession(s), openLabel: C().open(s.name),
305
+ trail: [
306
+ iconButton({ icon: 'ellipsis', ariaLabel: C().more(s.name), onClick: () => openMetaSheet(repo, s), attrs: { 'data-ds': 'more' } }),
307
+ iconButton({ icon: 'x', ariaLabel: C().end(s.name), onClick: () => setState({ dsEnding: s.id }), attrs: { 'data-ds': 'end' } }),
308
+ ],
309
+ attrs: { class: 'fr-ds-row', 'data-ds-session': s.id, 'data-front': front ? 'true' : 'false' },
310
+ });
311
+ }
312
+
313
+ function groupHead(repo, g) {
314
+ return el('div', { class: 'fr-ds-head', 'data-ds-group': g.key },
315
+ el('h3', { class: 'fr-ds-head__title', title: g.initiative || g.title }, g.title),
316
+ g.scheduled ? '' : iconButton({ icon: 'plus', ariaLabel: g.initiative ? C().newFor(g.title) : C().newTitle,
317
+ onClick: () => openNewSessionSheet(repo, { initiative: g.initiative }), attrs: { 'data-ds': 'group-new' } }));
318
+ }
319
+
320
+ /** The side list's nodes: the session groups, then "Open" with the caller's rows for the tabs that are not sessions. */
321
+ export function railNodes(repo, otherRows) {
322
+ const trees = treesOf(repo);
323
+ const front = frontSessionId();
324
+ const out = [];
325
+ const groups = groupsOf(repo);
326
+ for (const g of groups) {
327
+ out.push(el('section', { class: 'fr-ds-group', 'data-ds-section': g.key }, groupHead(repo, g),
328
+ ...g.sessions.map((s) => sessionRow(repo, s, trees, s.id === front))));
329
+ }
330
+ if (otherRows && otherRows.length) {
331
+ out.push(el('section', { class: 'fr-ds-group', 'data-ds-section': 'open' },
332
+ el('div', { class: 'fr-ds-head' }, el('h3', { class: 'fr-ds-head__title' }, C().openTabs)), ...otherRows));
333
+ }
334
+ if (!groups.length && !(otherRows && otherRows.length)) out.push(el('p', { class: 'fr-ds-note' }, C().none));
335
+ return out;
336
+ }
337
+
338
+ /* ── the strip on top ─────────────────────────────────────────────────────────────────────────────────────────────── */
339
+
340
+ /** The strip's nodes: the group select, the chips of that group, then + and ✕. `otherNodes` are the non-session tabs. */
341
+ export function stripNodes(repo, otherNodes) {
342
+ const trees = treesOf(repo);
343
+ const groups = groupsOf(repo).map((g) => ({ key: g.key, title: g.title, sessions: g.sessions, initiative: g.initiative }));
344
+ if (otherNodes && otherNodes.length) groups.push({ key: 'open', title: C().openTabs, sessions: null });
345
+ const front = frontSessionId();
346
+ const ft = frontTab();
347
+ const frontKey = front ? (groups.find((g) => g.sessions && g.sessions.some((s) => s.id === front)) || {}).key : (ft ? 'open' : null);
348
+ const sel = groups.find((g) => g.key === state.dsGroup) ? state.dsGroup : (groups.find((g) => g.key === frontKey) ? frontKey : (groups[0] && groups[0].key));
349
+ const g = groups.find((x) => x.key === sel) || null;
350
+ const ending = front && state.dsEnding === front ? (state.sessions || []).find((s) => s && s.id === front) : null;
351
+
352
+ const out = [];
353
+ if (groups.length) {
354
+ out.push(el('div', { class: 'fr-ds-groupbox' }, field({ label: C().group, hideLabel: true, kind: 'select', value: sel || '', attrs: { 'data-ds': 'group', class: 'fr-ds-select' },
355
+ options: groups.map((x) => ({ value: x.key, label: x.title + ' (' + (x.sessions ? x.sessions.length : otherNodes.length) + ')' })),
356
+ onCommit: (v) => setState({ dsGroup: v, dsEnding: null }) })));
357
+ } else out.push(el('span', { class: 'fr-ds-note' }, C().none));
358
+ if (ending) out.push(confirmNode(repo, ending, 'fr-ds-confirm fr-ds-confirm--strip'));
359
+ else if (g && g.sessions) {
360
+ for (const s of g.sessions) {
361
+ const on = s.id === front;
362
+ out.push(button({ label: s.name, variant: on ? 'outline' : 'ghost', size: 'sm', onClick: () => openSession(s),
363
+ attrs: { class: 'fr-ds-chip', 'data-ds-session': s.id, 'aria-pressed': on ? 'true' : 'false', title: s.name + ' · ' + metaOf(s, trees) } }));
364
+ }
365
+ } else if (g) out.push(...otherNodes);
366
+ out.push(iconButton({ icon: 'plus', ariaLabel: g && g.initiative ? C().newFor(g.title) : C().newTitle,
367
+ onClick: () => openNewSessionSheet(repo, { initiative: g && g.initiative }), attrs: { 'data-ds': 'strip-new', 'data-fr': 'session', class: 'fr-ds-push' } }));
368
+ out.push(iconButton({ icon: 'x', ariaLabel: C().endFront, attrs: { 'data-ds': 'strip-end', class: 'fr-ds-tool' }, onClick: () => {
369
+ if (front) setState({ dsEnding: front, dsGroup: frontKey || null });
370
+ else if (ft && tabsMod) tabsMod.closeTab(ft.id);
371
+ } }));
372
+ return out;
373
+ }
@@ -11,9 +11,8 @@
11
11
  * foldButton({ fold }) the control in the open dock's strip that folds it back to the rail
12
12
  * focusButton({ toggle }) / paintFocus(b, on)
13
13
  * Focus: the dock takes the whole window, and Exit focus gives the page back (0.53.1)
14
- * sessionMenuButton({ newSession, withOptions, openSkill, ws })
15
- * "+ Session": New session, Session with options, then the skills to start
16
- * from (R10: Skills start from the dock's +session menu; the Skills view goes)
14
+ * sessionMenuButton({ open, ws }) "+ Session": opens the new-session sheet (dock-sessions.js): a blank
15
+ * session or a skill to start from (R10; one list since 0.55.1)
17
16
  * sessionDefaults() / setSessionDefaults(patch)
18
17
  * the default CLI and model that Settings sets and the session menu applies
19
18
  *
@@ -24,7 +23,8 @@
24
23
  import { el } from './dom.js';
25
24
  import { FIXTURE } from './state.js';
26
25
  import { pmGet } from './http.js';
27
- import { button, iconButton, menu } from './primitives.js';
26
+ import { button, iconButton } from './primitives.js';
27
+ import { icon } from './icons.js';
28
28
  import { COPY } from './frame-copy.js';
29
29
 
30
30
  /* ── the default CLI and model ────────────────────────────────────────────────────────────────────────────────────── */
@@ -103,23 +103,21 @@ const endDrag = () => { if (drag) { drag.g.removeAttribute('data-dragging'); dra
103
103
  window.addEventListener('pointerup', endDrag);
104
104
  window.addEventListener('pointercancel', endDrag);
105
105
 
106
- const focusLabels = new WeakMap(); // the Focus control -> its label node
106
+ /* 0.55.1: icon only (the PM asked for no label): maximize-2 to focus, minimize-2 to leave; the name is the aria-label. */
107
107
  export function focusButton({ toggle } = {}) {
108
- const label = el('span', null, COPY.dock.focus);
109
- const b = button({ label, variant: 'ghost', size: 'sm',
110
- attrs: { class: 'fr-dock-focus', 'data-fr': 'dock-focus', 'aria-pressed': 'false', title: COPY.dock.focusTitle, 'aria-keyshortcuts': 'Meta+Shift+J Control+Shift+J' },
111
- onClick: () => toggle && toggle() });
112
- focusLabels.set(b, label);
113
- return b;
108
+ return iconButton({ icon: 'maximize-2', ariaLabel: COPY.dock.focusTitle, onClick: () => toggle && toggle(),
109
+ attrs: { class: 'fr-dock-focus', 'data-fr': 'dock-focus', 'aria-pressed': 'false', 'aria-keyshortcuts': 'Meta+Shift+J Control+Shift+J' } });
114
110
  }
115
111
  /** Show the Focus control's state: pressed while the dock has the whole window. */
116
112
  export function paintFocus(b, on) {
117
113
  if (!b) return;
118
- const label = focusLabels.get(b);
119
- const text = on ? COPY.dock.unfocus : COPY.dock.focus;
120
- if (label && label.textContent !== text) label.textContent = text;
114
+ const name = on ? 'minimize-2' : 'maximize-2';
115
+ const cur = b.querySelector('[data-icon]');
116
+ if (!cur || cur.getAttribute('data-icon') !== name) b.replaceChildren(icon(name));
121
117
  b.setAttribute('aria-pressed', on ? 'true' : 'false');
122
- b.title = on ? COPY.dock.unfocusTitle : COPY.dock.focusTitle;
118
+ const label = on ? COPY.dock.unfocusTitle : COPY.dock.focusTitle;
119
+ b.setAttribute('aria-label', label);
120
+ b.title = label;
123
121
  }
124
122
 
125
123
  export function foldButton({ fold } = {}) {
@@ -131,7 +129,7 @@ export function foldButton({ fold } = {}) {
131
129
  const skillsByWs = new Map(); // ws -> { at, list | null, error }
132
130
  const SKILLS_TTL = 60000;
133
131
 
134
- function loadSkills(ws) {
132
+ export function loadSkills(ws) {
135
133
  const key = ws || '';
136
134
  const have = skillsByWs.get(key);
137
135
  if (have && (have.pending || Date.now() - have.at < SKILLS_TTL)) return have.pending || Promise.resolve(have);
@@ -148,28 +146,14 @@ function loadSkills(ws) {
148
146
  }
149
147
 
150
148
  /**
151
- * The dock's "+ Session" control. A press opens a menu: New session (the one-click start, or the options form when the
152
- * default CLI is not the shell, because only the form can start another CLI), Session with options, and one item per
153
- * skill of the repository. A skill opens in its own tab in the dock, where it is read and started.
149
+ * The dock's "+ Session" control (0.55.1: one press, one sheet). The sheet (dock-sessions.js) holds the name, the
150
+ * initiative, where it runs, and the list to start from: a blank session or one of the repository's skills.
154
151
  */
155
- export function sessionMenuButton({ newSession, withOptions, openSkill, ws } = {}) {
152
+ export function sessionMenuButton({ open, ws } = {}) {
156
153
  if (!FIXTURE) loadSkills(ws);
157
- const b = button({
158
- label: COPY.dock.session, variant: 'outline', size: 'sm', icon: 'plus', iconEnd: 'chevron-down',
159
- attrs: { class: 'fr-session', 'data-fr': 'session', 'aria-haspopup': 'menu', 'aria-label': COPY.dock.sessionMenu },
160
- onClick: async () => {
161
- const got = FIXTURE ? null : await loadSkills(ws);
162
- const d = sessionDefaults();
163
- const items = [
164
- { label: COPY.dock.newSession, onSelect: () => (d.cli === 'shell' ? newSession && newSession() : withOptions && withOptions(d)) },
165
- { label: COPY.dock.withOptions, onSelect: () => withOptions && withOptions(d) },
166
- { sep: true },
167
- ];
168
- const list = got && got.list ? got.list : [];
169
- if (!list.length) items.push({ label: got && got.error ? COPY.dock.skillsUnread : COPY.dock.noSkills, onSelect: () => {} });
170
- for (const sk of list) items.push({ label: COPY.dock.skillsHead + ': ' + (sk.label || sk.skill || sk.id), onSelect: () => openSkill && openSkill(sk) });
171
- menu({ anchor: b, items, ariaLabel: COPY.dock.sessionMenu });
172
- },
154
+ return button({
155
+ label: COPY.dock.session, variant: 'outline', size: 'sm', icon: 'plus',
156
+ attrs: { class: 'fr-session', 'data-fr': 'session', 'aria-haspopup': 'dialog', 'aria-label': COPY.dock.sessionMenu },
157
+ onClick: () => open && open(),
173
158
  });
174
- return b;
175
159
  }