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.
- package/bin/create-pmos.js +3 -1
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/REMOTE.md +19 -0
- package/workspace/bin/pmos.js +90 -9
- package/workspace/guide/index.md +1 -1
- package/workspace/guide/layout.md +9 -5
- package/workspace/guide/proactive-engine.md +1 -0
- package/workspace/guide/today.md +2 -0
- package/workspace/lib/brief.js +28 -4
- package/workspace/lib/phone-cli.js +94 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +1 -1
- package/workspace/public/pm/components.css +139 -0
- package/workspace/public/pm/data.js +8 -0
- package/workspace/public/pm/dock-sessions.js +373 -0
- package/workspace/public/pm/dock.js +21 -37
- package/workspace/public/pm/frame-copy.js +50 -0
- package/workspace/public/pm/icons.js +1 -1
- package/workspace/public/pm/panel-tabs.js +46 -11
- package/workspace/public/pm/tab-term.js +13 -1
- package/workspace/public/pm/today-copy.js +3 -0
- package/workspace/public/pm/today-day.js +11 -5
- package/workspace/public/vendor/lucide-sprite.js +8 -2
- package/workspace/server.js +86 -10
- package/workspace/skills/morning-brief.skill +3 -1
- package/workspace/test-lists/workspace-phone-command.json +5 -0
- package/workspace/third-party/README.md +1 -1
|
@@ -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({
|
|
15
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
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
|
-
|
|
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.
|
|
152
|
-
*
|
|
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({
|
|
152
|
+
export function sessionMenuButton({ open, ws } = {}) {
|
|
156
153
|
if (!FIXTURE) loadSkills(ws);
|
|
157
|
-
|
|
158
|
-
label: COPY.dock.session, variant: 'outline', size: 'sm', icon: 'plus',
|
|
159
|
-
attrs: { class: 'fr-session', 'data-fr': 'session', 'aria-haspopup': '
|
|
160
|
-
onClick:
|
|
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
|
}
|