create-pmos 0.18.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +21 -1
- package/workspace/lib/four-panel.js +259 -24
- package/workspace/lib/tree-share.js +171 -13
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +4 -4
- package/workspace/public/index.html +6 -0
- package/workspace/public/pm/app.js +44 -7
- package/workspace/public/pm/data.js +42 -2
- package/workspace/public/pm/fixture.js +24 -1
- package/workspace/public/pm/panel-detail.js +226 -20
- package/workspace/public/pm/panel-tabs.js +192 -4
- package/workspace/public/pm/state.js +6 -0
- package/workspace/public/pm/tab-doc.js +5 -5
- package/workspace/public/pm/tab-task.js +1 -1
- package/workspace/public/pm/tab-term.js +17 -1
- package/workspace/server.js +87 -7
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Who shares a working tree with whom,
|
|
2
|
+
// Who shares a working tree with whom, who is DECLARED to be driving it, and — since
|
|
3
|
+
// workspace-session-choice — what each of those sessions is actually DOING and what ending it
|
|
4
|
+
// would cost.
|
|
3
5
|
//
|
|
4
6
|
// The premise, stated once so nothing below has to hedge: PMOS hosts real PTYs and the agent CLI
|
|
5
7
|
// inside one is untouched (D62). There is nothing between an agent and the filesystem, so this
|
|
@@ -21,6 +23,23 @@
|
|
|
21
23
|
//
|
|
22
24
|
// Pure functions over plain rows, on purpose: the server owns the git shell-outs and the session
|
|
23
25
|
// registry, and the rules are the part worth testing without either.
|
|
26
|
+
//
|
|
27
|
+
// ── ONE module, TWO consumers ────────────────────────────────────────────────────────────────
|
|
28
|
+
// This file is now a UMD, for the same reason lib/session-state.js is: the SERVER computes the
|
|
29
|
+
// co-tenancy sentence at spawn, and the BROWSER computes the same descriptions again when the PM
|
|
30
|
+
// clicks `+ session` into an occupied tree. A second copy of "what does ending this cost?" would
|
|
31
|
+
// be free to drift, and the two surfaces would end up holding different opinions about one
|
|
32
|
+
// session — the exact failure the session-state table was extracted to stop.
|
|
33
|
+
//
|
|
34
|
+
// What this module deliberately does NOT do is name a session's STATE. The vocabulary (working ·
|
|
35
|
+
// quiet · needs you · maybe blocked · exited) belongs to lib/session-state.js and arrives here
|
|
36
|
+
// already worded, on the row, as `shown` + `label`. Re-deriving a word here would be that second
|
|
37
|
+
// copy in a new costume.
|
|
38
|
+
(function (root, factory) {
|
|
39
|
+
const api = factory();
|
|
40
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
41
|
+
else root.PMOSTreeShare = api;
|
|
42
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
24
43
|
|
|
25
44
|
/** Live = a session the PM could still be driving. `exited` is a corpse and collides with nobody. */
|
|
26
45
|
function isLive(s) { return !!s && s.state !== 'exited'; }
|
|
@@ -73,18 +92,138 @@ function nameList(rows, cap = 3) {
|
|
|
73
92
|
return `${names.slice(0, cap).join(', ')} and ${names.length - cap} more`;
|
|
74
93
|
}
|
|
75
94
|
|
|
95
|
+
/** The same list, each pane carrying the word beside it — `label` when the caller supplied one. */
|
|
96
|
+
function statedList(rows, cap = 3) {
|
|
97
|
+
const parts = (rows || []).map((s) => (s && s.label ? `“${s.name}” (${s.label})` : `“${s.name}”`));
|
|
98
|
+
if (parts.length <= cap) return parts.join(', ');
|
|
99
|
+
return `${parts.slice(0, cap).join(', ')} and ${parts.length - cap} more`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A terse age, in the voice the sidebar already uses. `null` in, `null` out — never "0s". */
|
|
103
|
+
function ago(ms) {
|
|
104
|
+
if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
|
|
105
|
+
if (ms < 60000) return `${Math.max(1, Math.round(ms / 1000))}s`;
|
|
106
|
+
if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
|
|
107
|
+
return `${Math.round(ms / 360000) / 10}h`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* WHAT ENDING THIS SESSION WOULD COST — read only from what the workspace can actually observe.
|
|
112
|
+
*
|
|
113
|
+
* The PM's question, asked in as many words, is "is it safe to end?". The honest answer has three
|
|
114
|
+
* shapes and only one of them is a yes, so the levels are named for the EVIDENCE rather than for a
|
|
115
|
+
* recommendation:
|
|
116
|
+
*
|
|
117
|
+
* 'in-flight' — bytes are arriving right now. Ending it cuts a turn that is running. Not safe,
|
|
118
|
+
* and this one PMOS is sure of.
|
|
119
|
+
* 'settled' — the turn finished and the session is waiting on the human (`needs`). This is the
|
|
120
|
+
* only state where "nothing is in flight" is CORROBORATED — session-state reaches
|
|
121
|
+
* `needs` only from a transcript with no unresolved tool call, over a quiet clock.
|
|
122
|
+
* 'unknown' — quiet, or a tool call open with nothing moving. The workspace cannot tell a
|
|
123
|
+
* finished turn from a permission prompt from a hung tool, and this is the state it
|
|
124
|
+
* must not launder into a yes. Saying "probably idle" is the whole point: an
|
|
125
|
+
* over-confident "safe" here is the watermelon this repo has a word for.
|
|
126
|
+
* 'gone' — already exited; there is nothing left to end.
|
|
127
|
+
*
|
|
128
|
+
* `shown` is the presented state from lib/session-state.js. An unrecognised value falls to
|
|
129
|
+
* 'unknown', because a state this module has never heard of is precisely not a state it may
|
|
130
|
+
* declare safe.
|
|
131
|
+
*/
|
|
132
|
+
function endRisk(shown, quietMs) {
|
|
133
|
+
const age = ago(quietMs);
|
|
134
|
+
if (shown === 'exited') {
|
|
135
|
+
return { level: 'gone', why: 'already exited — there is nothing left to end' };
|
|
136
|
+
}
|
|
137
|
+
if (shown === 'working') {
|
|
138
|
+
return { level: 'in-flight', why: 'output is arriving right now — ending it cuts a turn that is still running' };
|
|
139
|
+
}
|
|
140
|
+
if (shown === 'needs') {
|
|
141
|
+
return {
|
|
142
|
+
level: 'settled',
|
|
143
|
+
why: 'its turn finished and it is waiting on you — ending it costs the scrollback, not work in flight',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (shown === 'blocked') {
|
|
147
|
+
return {
|
|
148
|
+
level: 'unknown',
|
|
149
|
+
why: `a tool call has been open${age ? ` ${age}` : ''} with nothing moving — PMOS cannot tell a permission prompt from a stuck tool, so ending it may or may not discard work`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
level: 'unknown',
|
|
154
|
+
why: `nothing has moved${age ? ` for ${age}` : ''}, and PMOS cannot see whether the turn finished — read this as “probably idle”, not “safe”`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One co-tenant, described for a human who is deciding between joining it and starting another.
|
|
160
|
+
*
|
|
161
|
+
* Everything here is either copied off the row or derived by `endRisk`; nothing is invented. In
|
|
162
|
+
* particular `doing` is the agent's own last words as the sidebar already shows them, and it stays
|
|
163
|
+
* null when the workspace could not read them rather than becoming a cheerful placeholder.
|
|
164
|
+
*/
|
|
165
|
+
function describeTenant(row, writerId) {
|
|
166
|
+
const s = row || {};
|
|
167
|
+
const shown = s.shown || (s.state === 'exited' ? 'exited' : 'working');
|
|
168
|
+
return {
|
|
169
|
+
id: s.id || null,
|
|
170
|
+
ws: s.ws || null,
|
|
171
|
+
name: s.name || '(unnamed)',
|
|
172
|
+
agent: s.agent || null,
|
|
173
|
+
shown,
|
|
174
|
+
label: s.label || shown,
|
|
175
|
+
doing: s.doing || null,
|
|
176
|
+
branch: s.branch || null,
|
|
177
|
+
writer: !!(writerId && s.id === writerId),
|
|
178
|
+
risk: endRisk(shown, s.quietMs),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The whole answer to "you are about to start a session in a tree that is already busy".
|
|
184
|
+
*
|
|
185
|
+
* Returned as DATA rather than as a paragraph, because the two consumers need different shapes of
|
|
186
|
+
* it: the server writes a sentence into the pane's scrollback, and the browser draws a chooser with
|
|
187
|
+
* a button per peer. Both read the same `peers`, so a peer the chooser offers and a peer the pane
|
|
188
|
+
* line names can never be different sets.
|
|
189
|
+
*/
|
|
190
|
+
function coTenancyReport(mates, opts) {
|
|
191
|
+
const o = opts || {};
|
|
192
|
+
const peers = (mates || []).map((m) => describeTenant(m, o.writerId));
|
|
193
|
+
const n = peers.length;
|
|
194
|
+
return {
|
|
195
|
+
tree: o.tree || null,
|
|
196
|
+
branch: o.branch || null,
|
|
197
|
+
peers,
|
|
198
|
+
headline: n
|
|
199
|
+
? `${n} session${n === 1 ? '' : 's'} already running in this working tree`
|
|
200
|
+
: 'nothing else is running in this working tree',
|
|
201
|
+
// Stated once, here, so every surface that renders this report carries the same limit.
|
|
202
|
+
shared: 'you share one branch, one index and one set of files with '
|
|
203
|
+
+ (n === 1 ? 'it' : 'them'),
|
|
204
|
+
advisory: 'Claims are advisory: PMOS hosts your agent’s terminal, it does not sit between it and the files.',
|
|
205
|
+
escape: 'To work in parallel for real, start the session on a new branch with its own folder (⑂).',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
76
209
|
/**
|
|
77
210
|
* The sentence a session gets when it starts in a tree somebody is already working in.
|
|
78
211
|
*
|
|
79
|
-
* It names the panes
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
212
|
+
* It names the panes AND what each of them is doing — a name alone tells the PM which pane, but not
|
|
213
|
+
* whether walking over to it is a good idea. `label` is the sidebar's own word for the session
|
|
214
|
+
* (working · quiet 12m · needs you · maybe blocked), passed in rather than re-derived, so this
|
|
215
|
+
* sentence and the sidebar cannot disagree. Rows with no label degrade to bare names rather than
|
|
216
|
+
* inventing a state.
|
|
217
|
+
*
|
|
218
|
+
* It states the ONE fact that makes this matter — a shared branch, index and file set — and names
|
|
219
|
+
* the way out that already exists, because a warning with no alternative is just a nag. Returns
|
|
220
|
+
* null when there is nobody there, so the caller never has to test for emptiness.
|
|
83
221
|
*/
|
|
84
222
|
function coTenancyNotice(mates, opts) {
|
|
85
223
|
const rows = mates || [];
|
|
86
224
|
if (!rows.length) return null;
|
|
87
225
|
const n = rows.length;
|
|
226
|
+
const rep = coTenancyReport(rows, opts);
|
|
88
227
|
const role = opts && opts.role === 'writer' ? 'writer' : 'reader';
|
|
89
228
|
const held = opts && opts.writerName;
|
|
90
229
|
const who = role === 'writer'
|
|
@@ -94,23 +233,40 @@ function coTenancyNotice(mates, opts) {
|
|
|
94
233
|
: held
|
|
95
234
|
? `“${held}” holds the writer claim`
|
|
96
235
|
: 'no session holds the writer claim';
|
|
97
|
-
return `${
|
|
98
|
-
+
|
|
99
|
-
+
|
|
100
|
-
+ '
|
|
236
|
+
return `${rep.headline} (${statedList(rows)}) — ${rep.shared}. ${who}. `
|
|
237
|
+
+ `${rep.advisory} ${rep.escape} `
|
|
238
|
+
+ `To join ${n === 1 ? 'it' : 'one of them'} instead of starting another, open it from + session — `
|
|
239
|
+
+ 'the chooser says what each one is doing and what ending it would cost.';
|
|
101
240
|
}
|
|
102
241
|
|
|
103
242
|
/**
|
|
104
243
|
* The same fact, written into the pane's own scrollback at spawn.
|
|
105
244
|
*
|
|
106
245
|
* The notice frame is a transient line in whichever client was listening; this one is part of the
|
|
107
|
-
* session's record, so re-opening the tab tomorrow still shows what it was told. Dim,
|
|
108
|
-
*
|
|
246
|
+
* session's record, so re-opening the tab tomorrow still shows what it was told. Dim, and it never
|
|
247
|
+
* claims anything was prevented.
|
|
248
|
+
*
|
|
249
|
+
* It is no longer ONE line. The sentence is followed by a row per peer carrying that peer's state
|
|
250
|
+
* and what ending it would cost — the same `peers` the chooser draws, in the same order, because
|
|
251
|
+
* the PM who scrolls back to this tomorrow is asking the identical question they asked at the
|
|
252
|
+
* chooser and deserves the identical answer. Bounded at four rows so a twelve-session tree cannot
|
|
253
|
+
* push the shell's first prompt off the screen; the sentence above already says the true count.
|
|
109
254
|
*/
|
|
255
|
+
const ADVISORY_ROWS = 4;
|
|
110
256
|
function paneAdvisory(mates, opts) {
|
|
111
257
|
const line = coTenancyNotice(mates, opts);
|
|
112
258
|
if (!line) return null;
|
|
113
|
-
|
|
259
|
+
const rep = coTenancyReport(mates, opts);
|
|
260
|
+
const shown = rep.peers.slice(0, ADVISORY_ROWS);
|
|
261
|
+
const rows = shown.map((p) => {
|
|
262
|
+
const who = `${p.name}${p.writer ? ' (writer)' : ''}`;
|
|
263
|
+
const doing = p.doing ? ` · ${p.doing}` : '';
|
|
264
|
+
return `\x1b[38;5;244m · ${who} — ${p.label}${doing}; ${p.risk.why}\x1b[0m\r\n`;
|
|
265
|
+
});
|
|
266
|
+
const more = rep.peers.length > shown.length
|
|
267
|
+
? [`\x1b[38;5;244m · …and ${rep.peers.length - shown.length} more, listed in + session\x1b[0m\r\n`]
|
|
268
|
+
: [];
|
|
269
|
+
return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n` + rows.join('') + more.join('');
|
|
114
270
|
}
|
|
115
271
|
|
|
116
272
|
/**
|
|
@@ -126,4 +282,6 @@ function joinNotices(...parts) {
|
|
|
126
282
|
return kept.length ? kept.join(' · ') : null;
|
|
127
283
|
}
|
|
128
284
|
|
|
129
|
-
|
|
285
|
+
return { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices,
|
|
286
|
+
nameList, statedList, ago, endRisk, describeTenant, coTenancyReport, ADVISORY_ROWS };
|
|
287
|
+
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.20.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "PMOS Terminal Workspace
|
|
5
|
+
"description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
8
|
"pmos": "bin/pmos.js"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
12
|
"test": "node test.js && npm run test:modules",
|
|
13
|
-
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js",
|
|
13
|
+
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"playwright-core": "^1.61.1",
|
|
30
30
|
"ws": "^8.18.0"
|
|
31
31
|
},
|
|
32
|
-
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs
|
|
32
|
+
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs \u2014 it just stops measuring pixels, and says so.",
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"pixelmatch": "^7.1.0",
|
|
35
35
|
"pngjs": "^7.0.0"
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
holding different opinions about one session. Loaded before the module graph so a panel can
|
|
30
30
|
bind from window.PMOSSessionState rather than re-typing a word. -->
|
|
31
31
|
<script src="/lib/session-state.js"></script>
|
|
32
|
+
<!-- ONE module, TWO consumers, again (workspace-session-choice). Who shares a working tree, and
|
|
33
|
+
what ending one of those sessions would cost, is computed by the SERVER when it writes the
|
|
34
|
+
advisory into a new pane's scrollback and by the BROWSER when it draws the + session chooser.
|
|
35
|
+
Both read this file, so the sentence in the terminal and the sentence in the chooser cannot
|
|
36
|
+
drift apart. Classic script (a UMD setting window.PMOSTreeShare), like the two above. -->
|
|
37
|
+
<script src="/lib/tree-share.js"></script>
|
|
32
38
|
<!-- The phone's navigation reducer. DOM-free, loaded as a classic script (it is a UMD that sets
|
|
33
39
|
window.PMOSMobileNav) and required by test.js, so the whole screen domain is walkable in node
|
|
34
40
|
rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not
|
|
@@ -83,9 +83,35 @@ function runningCount() {
|
|
|
83
83
|
|
|
84
84
|
/* ── the shell, built once ─────────────────────────────────────────────────────────────────── */
|
|
85
85
|
|
|
86
|
+
/* ── Why every track and every column here is `minmax(0, …)` / `min-height:0` ─────────────────
|
|
87
|
+
*
|
|
88
|
+
* The shell is 100vh and nothing in it is supposed to scroll the PAGE — a panel scrolls, the app
|
|
89
|
+
* never does. That held right up until a long document was opened in panel 4, and then the whole
|
|
90
|
+
* user interface scrolled: the top bar slid away, the status bar came up from below, and the doc
|
|
91
|
+
* pane's own `overflow-y:auto` never engaged.
|
|
92
|
+
*
|
|
93
|
+
* The cause is one CSS default, in two guises, and it is worth naming precisely because it is
|
|
94
|
+
* invisible in a style string that looks correct:
|
|
95
|
+
*
|
|
96
|
+
* • `grid-template-rows: 44px 1fr 26px` — `1fr` is shorthand for `minmax(auto, 1fr)`, and that
|
|
97
|
+
* `auto` MINIMUM is the item's min-content height. A tall document therefore does not overflow
|
|
98
|
+
* the middle row; it ENLARGES it, past the 100vh the row was supposed to be trapped inside.
|
|
99
|
+
* • a grid item / flex item with `min-height: auto` cannot shrink below its content either, so
|
|
100
|
+
* even a correctly-sized track hands the overflow straight through to the next box down.
|
|
101
|
+
*
|
|
102
|
+
* Overflow that is `visible` still contributes to the SCROLLABLE overflow region of every ancestor
|
|
103
|
+
* that does not clip it, all the way up to the viewport — which is exactly the reported symptom:
|
|
104
|
+
* the page scrolls, not the pane. So the fix is the whole chain, not one box. Each ancestor between
|
|
105
|
+
* the viewport and a scrolling pane declares a zero minimum, and the root clips as the backstop so
|
|
106
|
+
* a future panel that forgets cannot reach the page again.
|
|
107
|
+
*
|
|
108
|
+
* The design prototype had this right (`grid-template-rows:44px minmax(0,1fr) 28px`, see
|
|
109
|
+
* prototype/workspace-layout-variants/*.html); the port dropped the `minmax(0, …)` and read
|
|
110
|
+
* identically until something tall was rendered into it.
|
|
111
|
+
*/
|
|
86
112
|
const root = el('div', {
|
|
87
113
|
'data-pm-theme': state.theme,
|
|
88
|
-
style: 'display:grid;grid-template-rows:44px 1fr 26px;height:100vh;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
|
|
114
|
+
style: 'display:grid;grid-template-rows:44px minmax(0,1fr) 26px;height:100vh;overflow:hidden;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
|
|
89
115
|
});
|
|
90
116
|
|
|
91
117
|
const themeBtn = el('button', {
|
|
@@ -113,7 +139,14 @@ const statusBar = el('div', {
|
|
|
113
139
|
el('span', { style: 'margin-left:auto' }, 'sessions survive detach · ⌘K search · Esc closes tab'),
|
|
114
140
|
);
|
|
115
141
|
|
|
116
|
-
|
|
142
|
+
/* The body grid's own geometry, in one place so the initial build and every repaint below cannot
|
|
143
|
+
* disagree. The implicit ROW is stated explicitly as `minmax(0,1fr)` for the same reason the root's
|
|
144
|
+
* middle track is: an auto row is floored at its items' min-content height, and one tall panel
|
|
145
|
+
* would push the grid taller than the box it was given. */
|
|
146
|
+
const BODY_GRID_STYLE = () =>
|
|
147
|
+
'display:grid;grid-template-columns:' + gridCols() + ';grid-template-rows:minmax(0,1fr);min-height:0;overflow:hidden';
|
|
148
|
+
|
|
149
|
+
const bodyGrid = el('div', { style: BODY_GRID_STYLE() });
|
|
117
150
|
|
|
118
151
|
/** The collapsed 36px strip: a full-height button with a rotated vertical-rl label. */
|
|
119
152
|
function makeRail(flag) {
|
|
@@ -131,10 +164,14 @@ const rail1 = makeRail('col1');
|
|
|
131
164
|
const rail2 = makeRail('col2');
|
|
132
165
|
rail0.label.textContent = 'repositories ⟩';
|
|
133
166
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
167
|
+
/* `min-height:0` on all four: a grid item's automatic minimum is its content, so a panel taller
|
|
168
|
+
* than the viewport would otherwise stretch the row it sits in rather than scroll inside it. The
|
|
169
|
+
* two that already carry `overflow-y:auto` get it for free under the spec — stated anyway, because
|
|
170
|
+
* "this one is safe because of a rule elsewhere in the same line" is how the next edit breaks it. */
|
|
171
|
+
const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto' });
|
|
172
|
+
const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0' });
|
|
173
|
+
const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
|
|
174
|
+
const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
|
|
138
175
|
|
|
139
176
|
setChildren(root, [topBar, bodyGrid, statusBar]);
|
|
140
177
|
|
|
@@ -172,7 +209,7 @@ function render() {
|
|
|
172
209
|
rail1.label.textContent = modeTitle() + ' ⟩';
|
|
173
210
|
rail2.label.textContent = detailTitle() + ' ⟩';
|
|
174
211
|
|
|
175
|
-
bodyGrid.setAttribute('style',
|
|
212
|
+
bodyGrid.setAttribute('style', BODY_GRID_STYLE());
|
|
176
213
|
|
|
177
214
|
// On a phone there are no rails to collapse into and no second column to fold away: the four
|
|
178
215
|
// hosts all stay mounted, one is DISPLAYED (pm/mobile.css picks it from `data-mpanel`), and the
|
|
@@ -277,9 +277,13 @@ function evalFor(ladderSteps) {
|
|
|
277
277
|
|
|
278
278
|
/** A live session, in the shape panel 2 and panel 4 read. `shown` is the server's own derived state. */
|
|
279
279
|
function sessionRow(s) {
|
|
280
|
-
|
|
280
|
+
// `s.label` is the SERVER's rendering of the presented state — one join (lib/session-state's
|
|
281
|
+
// `label`), one clock, one sentence. This used to be re-derived here from `shown` + `quietMs`,
|
|
282
|
+
// which is how the sidebar and the pane could describe one session with two different words. The
|
|
283
|
+
// local derivation survives only as a fallback for a server that predates the field.
|
|
284
|
+
const label = s.label || (s.shown === 'quiet' && Number.isFinite(s.quietMs)
|
|
281
285
|
? 'quiet ' + Math.max(1, Math.round(s.quietMs / 60000)) + 'm'
|
|
282
|
-
: (s.shown === 'needs' ? 'needs you' : s.shown);
|
|
286
|
+
: (s.shown === 'needs' ? 'needs you' : s.shown));
|
|
283
287
|
return { name: s.name, st: s.shown, label, profile: s.profile_id || null, id: s.id };
|
|
284
288
|
}
|
|
285
289
|
|
|
@@ -441,10 +445,34 @@ function groupsFromSpine(spine) {
|
|
|
441
445
|
const products = new Map(); // product name → {name, ws, root, count, board, records}
|
|
442
446
|
let productOrder = [];
|
|
443
447
|
|
|
448
|
+
/* workspace id → the working-tree top it sits in (null for a folder with no git).
|
|
449
|
+
*
|
|
450
|
+
* This exists so panel 4 can answer "who else is already in the tree I am about to start a session
|
|
451
|
+
* in?" BEFORE it starts one. The session rows carry `tree` too, but only for trees that already
|
|
452
|
+
* have a session — which is one session too late for a chooser that runs on the FIRST click. The
|
|
453
|
+
* server derives this (`git rev-parse --show-toplevel`, via `repoIdentity`) and publishes it on
|
|
454
|
+
* /api/workspaces; it is read here and never re-derived. */
|
|
455
|
+
const wsTree = new Map();
|
|
456
|
+
|
|
457
|
+
/** The working tree a workspace id sits in, or null — for a folder with no git, or an unknown id. */
|
|
458
|
+
export function treeOfWorkspace(id) {
|
|
459
|
+
return (id && wsTree.get(id)) || null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The branch that tree is on, or null. Shown beside the tree so a chooser names what is shared. */
|
|
463
|
+
const wsBranch = new Map();
|
|
464
|
+
export function branchOfWorkspace(id) {
|
|
465
|
+
return (id && wsBranch.get(id)) || null;
|
|
466
|
+
}
|
|
467
|
+
|
|
444
468
|
/** Group registered working trees into products: several worktrees of one repo share a `repo_key`. */
|
|
445
469
|
function foldWorkspaces(rows) {
|
|
446
470
|
const byRepo = new Map();
|
|
447
471
|
for (const w of rows || []) {
|
|
472
|
+
// Recorded per REGISTERED id, before the fold — folding is by repository, and the whole point
|
|
473
|
+
// of the tree key is that two registered ids can share one tree while a repo's two worktrees do
|
|
474
|
+
// not. Reading it off the folded row would lose exactly that distinction.
|
|
475
|
+
if (w && w.id) { wsTree.set(w.id, w.tree || null); wsBranch.set(w.id, w.branch || null); }
|
|
448
476
|
const key = w.repo_key || w.root;
|
|
449
477
|
if (!byRepo.has(key)) {
|
|
450
478
|
byRepo.set(key, {
|
|
@@ -941,6 +969,18 @@ export async function recordArtifactLink({ artifact_path, slug, kind, target })
|
|
|
941
969
|
return out;
|
|
942
970
|
}
|
|
943
971
|
|
|
972
|
+
/**
|
|
973
|
+
* panel-detail.js — `lookupTask({kind, subject, hint, profile})`.
|
|
974
|
+
*
|
|
975
|
+
* Asks the server to compose the task a SESSION will run to resolve a reference. It returns text.
|
|
976
|
+
* Nothing here contacts Jira, Confluence or Google, and nothing here can: the workspace holds no
|
|
977
|
+
* credential (D53 — the agent layer acts with the PM's own MCP, PMOS records the result).
|
|
978
|
+
*/
|
|
979
|
+
export async function lookupTask({ kind, subject, hint, profile }) {
|
|
980
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode composes no task' };
|
|
981
|
+
return postJson('/api/initiative/lookup-task', { kind, subject, hint, profile });
|
|
982
|
+
}
|
|
983
|
+
|
|
944
984
|
/** panel-detail.js — `recordInitiativeLink({slug, kind, label, path, st})` → `initiative-links.jsonl`. */
|
|
945
985
|
export async function recordInitiativeLink({ slug, kind, label, path }) {
|
|
946
986
|
if (FIXTURE) return { ok: true };
|
|
@@ -790,7 +790,30 @@ export const artDiffText = dest => 'repo file is the source. read ' + (dest ? de
|
|
|
790
790
|
export const artSyncPushed = dest => ({ color: 'var(--success)', text: 'pushed · repo → ' + dest.kind + ' · in sync 12:41' });
|
|
791
791
|
export const artSyncPulled = dest => ({ color: 'var(--warning)', text: 'pulled · ' + dest.kind + ' → repo · file updated' });
|
|
792
792
|
|
|
793
|
-
|
|
793
|
+
/* addLink (lines 1757–1766) — PROTOTYPE TRANSCRIPTION ONLY. It has no caller, and must not gain one.
|
|
794
|
+
*
|
|
795
|
+
* This is the helper the whole workspace-external-linking initiative was opened about. It returns
|
|
796
|
+
* `{kind:'jira epic', label:'ATL-501 · <slug>', path:'jira://ATL-501', st:'connected'}` — a Jira key
|
|
797
|
+
* counted up from the constant 501, a label built from the initiative slug, and the word `connected`
|
|
798
|
+
* on a row nothing had read. panel-detail.js called it on every + click and wrote the result to
|
|
799
|
+
* `initiative-links.jsonl`, so PMOS accumulated references to epics that do not exist, rendered in
|
|
800
|
+
* the same visual grammar it uses for facts.
|
|
801
|
+
*
|
|
802
|
+
* WHY IT IS STILL HERE, WHICH IS A REAL TENSION AND NOT AN OVERSIGHT. The initiative's rubric asks
|
|
803
|
+
* for the fabricating helpers to be REMOVED rather than bypassed, and deleting this function is the
|
|
804
|
+
* literal reading. But this module is a TRANSCRIPTION of the prototype, and fixture-parity.test.mjs
|
|
805
|
+
* exists to pin it line-for-line — deleting the function deletes that suite's assertion, and the
|
|
806
|
+
* same rubric forbids weakening an assertion to make a change pass. Both rules are right and they
|
|
807
|
+
* point opposite ways.
|
|
808
|
+
*
|
|
809
|
+
* The property that actually matters is not this function's existence; it is that nothing in real
|
|
810
|
+
* mode can reach it. So it stays, as the pinned record of what the prototype drew, and the guarantee
|
|
811
|
+
* is enforced mechanically instead of by deletion: pm-detail-test.js asserts that no module outside
|
|
812
|
+
* this file references `makeLink`. If a later change wires it back up, that test fails and names it.
|
|
813
|
+
*
|
|
814
|
+
* The seeded rows below (`ATL-482` and its siblings) are the same case and are correct: a prototype
|
|
815
|
+
* draws specimens. They are rendered in FIXTURE mode only and never reach the real store.
|
|
816
|
+
*/
|
|
794
817
|
export function makeLink(slug, src, existing = []) {
|
|
795
818
|
if (src === 'jira') {
|
|
796
819
|
const num = 501 + existing.filter(x => x.kind === 'jira epic').length;
|