create-pmos 0.46.0 → 0.48.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/package.json +1 -1
- package/payload/AGENTS-HUMAN.md +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +13 -3
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
- package/payload/mods/pmos-cockpit/hooks/lib/handover-run.mjs +33 -0
- package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +189 -18
- package/payload/mods/pmos-cockpit/types/index.d.ts +16 -1
- package/payload/okf/core/concepts/frontmatter-format.md +6 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/pmos-log.sh +18 -8
- package/payload/scripts/pmos-model.js +233 -2
- package/payload/scripts/status-facts.js +204 -0
- package/payload/scripts/workflow-state.js +52 -0
- package/payload/skills/SKILL-FORMAT.md +1 -0
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +3 -3
- package/workspace/bin/live-notes.js +133 -0
- package/workspace/design-parity.js +328 -21
- package/workspace/doc-category-bench.js +165 -0
- package/workspace/fixtures/doc-category-labelled.json +167 -0
- package/workspace/guide/initiatives-and-next-steps.md +16 -2
- package/workspace/guide/knowledge-graph.md +89 -2
- package/workspace/guide/layout.md +30 -2
- package/workspace/guide/proactive-engine.md +26 -4
- package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
- package/workspace/guide/statuses.md +1 -0
- package/workspace/guide/validate-with-typesafe.md +17 -1
- package/workspace/lib/claims.js +51 -2
- package/workspace/lib/doc-category-corrections.js +113 -0
- package/workspace/lib/doc-category-writeback.js +110 -0
- package/workspace/lib/doc-category.js +225 -0
- package/workspace/lib/engine/pmos-model.js +233 -2
- package/workspace/lib/engine/status-facts.js +204 -0
- package/workspace/lib/engine/workflow-state.js +52 -0
- package/workspace/lib/jev-categorize.js +399 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-route.js +129 -0
- package/workspace/lib/kg-cli.js +5 -2
- package/workspace/lib/kg-dangling-baseline.json +1 -0
- package/workspace/lib/kg-dangling-rules.json +1 -0
- package/workspace/lib/kg.js +26 -9
- package/workspace/lib/learn.js +282 -0
- package/workspace/lib/live-notes.js +890 -0
- package/workspace/lib/proactive-cli.js +49 -1
- package/workspace/lib/proactive.js +8 -7
- package/workspace/lib/typesafe-settings.js +10 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +5 -4
- package/workspace/public/pm/app.js +148 -79
- package/workspace/public/pm/data.js +58 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +267 -21
- package/workspace/public/pm/panel-cockpit.js +636 -0
- package/workspace/public/pm/panel-detail.js +210 -8
- package/workspace/public/pm/panel-initiatives.js +9 -9
- package/workspace/public/pm/panel-newsess.js +145 -86
- package/workspace/public/pm/panel-repos.js +34 -17
- package/workspace/public/pm/panel-tabs.js +145 -27
- package/workspace/public/pm/state.js +45 -4
- package/workspace/public/pm/tab-doc.js +244 -16
- package/workspace/public/pm/tab-term.js +11 -1
- package/workspace/public/pm/tokens.css +4 -3
- package/workspace/public/pm/view-addrepo.js +207 -0
- package/workspace/public/pm/view-files.js +197 -0
- package/workspace/public/pm/view-guide.js +141 -0
- package/workspace/public/pm/view-knowledge.js +225 -0
- package/workspace/public/pm/view-memory.js +122 -0
- package/workspace/public/pm/view-portfolio.js +153 -0
- package/workspace/public/pm/view-reports.js +190 -0
- package/workspace/public/pm/view-scheduled.js +161 -0
- package/workspace/public/pm/view-skills.js +216 -0
- package/workspace/public/pm/view-today.js +168 -0
- package/workspace/public/pm/view-validate.js +171 -0
- package/workspace/server.js +394 -10
- package/workspace/skills/live-notes.skill +58 -0
- package/workspace/skills/morning-brief.skill +1 -0
|
@@ -16,13 +16,14 @@
|
|
|
16
16
|
* pmos proactive prepare plan --initiative ID [--repo PATH]
|
|
17
17
|
* pmos proactive prepare queue --initiative ID --item ITEM --sha HASH [--repo PATH]
|
|
18
18
|
* pmos proactive prepare status | cancel ID | run
|
|
19
|
+
* pmos proactive learn [status] | run | lessons | approve ID | reject ID | retire ID | restore TRIGGER | reset
|
|
19
20
|
*/
|
|
20
21
|
const fs = require('fs');
|
|
21
22
|
const path = require('path');
|
|
22
23
|
const { execFileSync } = require('child_process');
|
|
23
24
|
const { parseArgs } = require('./schedule-cli');
|
|
24
25
|
|
|
25
|
-
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'prepare'] };
|
|
26
|
+
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'prepare', 'learn'] };
|
|
26
27
|
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
|
|
27
28
|
const onOff = (v, what) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : { err: `${what} takes on or off` });
|
|
28
29
|
|
|
@@ -141,6 +142,53 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
|
|
|
141
142
|
for (const e of r.entries) say(`${e.id} ${e.status} ${e.label}${e.reason ? ' — ' + e.reason : ''}`);
|
|
142
143
|
return 0;
|
|
143
144
|
}
|
|
145
|
+
if (verb === 'learn') {
|
|
146
|
+
const sub = pos[0] || 'status';
|
|
147
|
+
const rate = (x, what) => (x.rate === null ? `${what}: not enough evidence (${x.n} of ${x.min} needed)` : `${what}: ${Math.round(x.rate * 100)}% (${x.n} counted)`);
|
|
148
|
+
const decide = async (decision, past) => {
|
|
149
|
+
if (!pos[1]) return fail(`usage: pmos proactive learn ${decision} ID`);
|
|
150
|
+
const r = await call('POST', '/api/proactive/learn/lesson', { id: pos[1], decision, confirm: true });
|
|
151
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing changed.`);
|
|
152
|
+
say(`${past} ${r.id}`); return 0;
|
|
153
|
+
};
|
|
154
|
+
if (sub === 'approve') return decide('approve', 'approved');
|
|
155
|
+
if (sub === 'reject') return decide('reject', 'rejected');
|
|
156
|
+
if (sub === 'retire') return decide('retire', 'retired');
|
|
157
|
+
if (sub === 'restore') {
|
|
158
|
+
if (!pos[1]) return fail('usage: pmos proactive learn restore TRIGGER');
|
|
159
|
+
const r = await call('POST', '/api/proactive/learn/restore', { kind: pos[1], confirm: true });
|
|
160
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing changed.`);
|
|
161
|
+
say(`restored ${r.kind} — it comes back on Today and stays until 10 new ratings of it have arrived`); return 0;
|
|
162
|
+
}
|
|
163
|
+
if (sub === 'reset') {
|
|
164
|
+
const r = await call('POST', '/api/proactive/learn/reset', { confirm: true });
|
|
165
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing changed.`);
|
|
166
|
+
say('reset — earlier feedback is ignored from now on. It is not deleted.'); return 0;
|
|
167
|
+
}
|
|
168
|
+
if (sub === 'run') {
|
|
169
|
+
const r = await call('POST', '/api/proactive/learn/run', {});
|
|
170
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}`);
|
|
171
|
+
say(`weekly review taken — ${r.proposed.length} lesson(s) proposed${r.snapshot.narrowed.length ? ', narrowed: ' + r.snapshot.narrowed.join(', ') : ''}`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
const r = await call('GET', '/api/proactive/learn');
|
|
175
|
+
if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
|
|
176
|
+
if (sub === 'lessons') {
|
|
177
|
+
if (r.paused) { say('Learning is paused: no lesson applies.'); return 0; }
|
|
178
|
+
if (!r.approved.length) say('No approved lessons.');
|
|
179
|
+
for (const l of r.approved) say(`- ${l.text}`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
if (sub !== 'status') return fail('usage: pmos proactive learn [status|run|lessons|approve ID|reject ID|retire ID|restore TRIGGER|reset]');
|
|
183
|
+
say(`learning ${r.paused ? 'paused' : 'on'} · ${r.evidenceCount} piece(s) of evidence${r.resetAt ? ' since ' + r.resetAt : ''}`);
|
|
184
|
+
const ro = r.rollup;
|
|
185
|
+
say(`last ${ro.days} days — ${rate(ro.hit, 'hit rate')} · ${rate(ro.asIs, 'used as is')} · ${rate(ro.corrections, 'corrections per prepared run')} · ${ro.missed} reported missed`);
|
|
186
|
+
say('watermelon check: ' + ro.watermelon.note + (ro.watermelon.reasons.length ? ' ' + ro.watermelon.reasons.join('; ') + '.' : ''));
|
|
187
|
+
for (const c of r.cells.filter((x) => x.delta)) say(`weight ${c.dim} ${c.key}: ${c.delta > 0 ? '+' : ''}${c.delta} (${c.n} events, net ${c.sum})`);
|
|
188
|
+
for (const n of r.narrowed) say(`narrowed: ${n.kind} — useful ${Math.round(n.rate * 100)}% over ${n.n} ratings in ${n.days} days (floor ${Math.round(n.floor * 100)}%). Restore with: pmos proactive learn restore ${n.kind}`);
|
|
189
|
+
for (const l of r.lessons.filter((x) => x.status === 'proposed' || x.status === 'approved')) say(`${l.id} ${l.status} ${l.text}`);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
144
192
|
if (verb === 'brief') {
|
|
145
193
|
const sub = pos[0] || 'show';
|
|
146
194
|
if (sub === 'submit') {
|
|
@@ -39,14 +39,14 @@ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().sl
|
|
|
39
39
|
const days = (ms) => Math.floor(ms / DAY);
|
|
40
40
|
|
|
41
41
|
function cand(kind, key, p) {
|
|
42
|
-
return { id: idOf(key), key, kind, owner: OWNER[kind], initiative_id: p.initiative_id || null, ws: p.ws || null, repo: p.repo || null,
|
|
42
|
+
return { id: idOf(key), key, kind, owner: OWNER[kind], initiative_id: p.initiative_id || null, ws: p.ws || null, repo: p.repo || null, lane: p.lane || null,
|
|
43
43
|
title: clip(p.title, 120), why: clip(p.why, 200), evidence: clip(p.evidence, 240), confidence: p.confidence, since: p.since == null ? null : p.since,
|
|
44
44
|
open: p.open || null };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* @param {{now:number, settings:object, repos:Array, nextStepRuns:Array}} ctx
|
|
49
|
-
* repos[]: {name, ws, error?, initiatives:[{id,title,active,lastRun,hasRuns}], needs:[{initiative_id,question,run_id}],
|
|
49
|
+
* repos[]: {name, ws, error?, initiatives:[{id,title,active,lane,lastRun,hasRuns}], needs:[{initiative_id,question,run_id}],
|
|
50
50
|
* returned:[{initiative_id,note,at}], schedules:[{id,name,lastStatus,lastRun}]}
|
|
51
51
|
* nextStepRuns[]: {session_id, initiative_id, ws, key, ended, reviewed, at}
|
|
52
52
|
* @returns {{candidates:Array, unreadable:Array<{repo:string,error:string}>}}
|
|
@@ -61,7 +61,7 @@ function detect(ctx) {
|
|
|
61
61
|
for (const n of r.needs || []) {
|
|
62
62
|
const ini = byId.get(n.initiative_id);
|
|
63
63
|
out.push(cand('needs', `needs:${r.ws}:${n.run_id || n.initiative_id + ':' + clip(n.question, 60)}`, {
|
|
64
|
-
initiative_id: n.initiative_id, ws: r.ws, repo: r.name, title: ini ? ini.title || ini.id : (n.initiative_id || 'an open item'),
|
|
64
|
+
initiative_id: n.initiative_id, ws: r.ws, repo: r.name, lane: ini ? ini.lane : null, title: ini ? ini.title || ini.id : (n.initiative_id || 'an open item'),
|
|
65
65
|
why: 'An open item is waiting on you.', evidence: n.question, confidence: 0.95, since: null,
|
|
66
66
|
open: n.initiative_id ? { type: 'initiative', initiative_id: n.initiative_id, ws: r.ws } : null }));
|
|
67
67
|
}
|
|
@@ -71,7 +71,7 @@ function detect(ctx) {
|
|
|
71
71
|
const ini = byId.get(x.initiative_id);
|
|
72
72
|
if (ini && !ini.active) continue;
|
|
73
73
|
out.push(cand('returned', `returned:${r.ws}:${x.initiative_id}:${x.at || ''}`, {
|
|
74
|
-
initiative_id: x.initiative_id, ws: r.ws, repo: r.name, title: ini ? ini.title || ini.id : x.initiative_id,
|
|
74
|
+
initiative_id: x.initiative_id, ws: r.ws, repo: r.name, lane: ini ? ini.lane : null, title: ini ? ini.title || ini.id : x.initiative_id,
|
|
75
75
|
why: 'You returned this at the Acceptance Gate and it has not been accepted since.', evidence: x.note || 'returned', confidence: 0.95, since: x.at ? Date.parse(x.at) || null : null,
|
|
76
76
|
open: { type: 'initiative', initiative_id: x.initiative_id, ws: r.ws } }));
|
|
77
77
|
}
|
|
@@ -93,7 +93,7 @@ function detect(ctx) {
|
|
|
93
93
|
if (idle < limit) continue;
|
|
94
94
|
const d = days(idle);
|
|
95
95
|
out.push(cand('stalled', `stalled:${r.ws}:${i.id}`, {
|
|
96
|
-
initiative_id: i.id, ws: r.ws, repo: r.name, title: i.title || i.id,
|
|
96
|
+
initiative_id: i.id, ws: r.ws, repo: r.name, lane: i.lane, title: i.title || i.id,
|
|
97
97
|
why: `In progress, with no agent run for ${d} days.`, evidence: `last run ${new Date(i.lastRun).toISOString().slice(0, 10)}; the threshold is ${st.stalledDays || DEFAULTS.stalledDays} days`,
|
|
98
98
|
confidence: Math.min(0.85, +(0.5 + 0.05 * (d - (st.stalledDays || DEFAULTS.stalledDays))).toFixed(2)), since: i.lastRun,
|
|
99
99
|
open: { type: 'initiative', initiative_id: i.id, ws: r.ws } }));
|
|
@@ -112,9 +112,10 @@ function detect(ctx) {
|
|
|
112
112
|
return { candidates: out, unreadable };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
/** `adj` is optional (slice D4): c → {delta, why[]}. Without it the order is the fixed one, unchanged. */
|
|
116
|
+
function rank(cands, now, adj) {
|
|
116
117
|
const score = (c) => BASE[c.kind] + (c.since != null && now != null ? Math.min(30, Math.max(0, days(now - c.since))) : 0);
|
|
117
|
-
return cands.map((c) => ({ ...c, rank: score(c) })
|
|
118
|
+
return cands.map((c) => { const a = typeof adj === 'function' ? adj(c) : null; const d = a && a.delta ? a.delta : 0; return { ...c, rank: score(c) + d, adjust: d, adjust_why: a && a.why ? a.why.join('; ') : '' }; }).sort((a, b) => b.rank - a.rank || (a.id < b.id ? -1 : 1))
|
|
118
119
|
.map((c, i) => ({ ...c, n: i + 1 }));
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -97,16 +97,24 @@ function getRepo(stateDir, root) {
|
|
|
97
97
|
// jev-claim-checks (D80): also ask Jev about changed claim pairs after each check. Default OFF, separate from
|
|
98
98
|
// `consent` and `watch`; a request needs all of key, consent, watch and this.
|
|
99
99
|
jev: !!(r && r.jev === true),
|
|
100
|
+
// jev-doc-categorizer (D82): ask Jev to categorize documents the local rules leave undetermined. Default OFF, separate
|
|
101
|
+
// from every other switch; a request needs key, consent and this.
|
|
102
|
+
categorize: !!(r && r.categorize === true),
|
|
103
|
+
// status-from-artifacts Run 2 (D83): also send the documents tied to an initiative for two status questions
|
|
104
|
+
// (is it a stub; does a tied note contradict a requirement). Default OFF, and only read when `jev` is on too.
|
|
105
|
+
status_read: !!(r && r.status_read === true),
|
|
106
|
+
// prompt-model-routing: classify prompts with Jev to choose a model tier. Default OFF; a request needs key, consent and this.
|
|
107
|
+
route: !!(r && r.route === true),
|
|
100
108
|
};
|
|
101
109
|
}
|
|
102
110
|
|
|
103
|
-
function setRepo(stateDir, root, { consent, exclude, watch, jev }) {
|
|
111
|
+
function setRepo(stateDir, root, { consent, exclude, watch, jev, categorize, route, status_read: statusRead }) {
|
|
104
112
|
const cur = read(stateDir);
|
|
105
113
|
const prev = getRepo(stateDir, root);
|
|
106
114
|
const ex = Array.isArray(exclude)
|
|
107
115
|
? exclude.map((x) => String(x).trim().replace(/^\/+/, '')).filter((x) => x && !x.includes('..') && x.length <= 200).slice(0, MAX_EXCLUDES)
|
|
108
116
|
: prev.exclude;
|
|
109
|
-
const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex, watch: typeof watch === 'boolean' ? watch : prev.watch, jev: typeof jev === 'boolean' ? jev : prev.jev } };
|
|
117
|
+
const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex, watch: typeof watch === 'boolean' ? watch : prev.watch, jev: typeof jev === 'boolean' ? jev : prev.jev, categorize: typeof categorize === 'boolean' ? categorize : prev.categorize, route: typeof route === 'boolean' ? route : prev.route, status_read: typeof statusRead === 'boolean' ? statusRead : prev.status_read } };
|
|
110
118
|
return write(stateDir, { key: cur.key, repos });
|
|
111
119
|
}
|
|
112
120
|
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
12
|
"test": "node test.js && npm run test:modules",
|
|
13
|
-
"test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js && node proactive-prepare.test.js && node session-flag.test.js",
|
|
13
|
+
"test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node status-facts-panel.test.js && node jev-claims.test.js && node jev-categorize.test.js && node jev-status.test.js && node jev-route.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js && node doc-category.test.js && node doc-category-corrections.test.js && node proactive-prepare.test.js && node learn.test.js && node session-flag.test.js && node live-notes.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
|
-
"test:parity": "node design-parity.js --
|
|
15
|
+
"test:parity": "node design-parity.js --board=WsA",
|
|
16
|
+
"test:parity:four-panel": "node design-parity.js --states",
|
|
16
17
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
17
18
|
"doctor": "node doctor.js",
|
|
18
|
-
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js && node pm-r25-test.js",
|
|
19
|
+
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js && node pm-r25-test.js && node pm-r26-test.js && node pm-doc-category-test.js && node pm-r27-test.js && node pm-r28-test.js && node pm-r29-today-test.js && node pm-r29-portfolio-test.js && node pm-r29-knowledge-test.js && node pm-r29-memory-test.js && node pm-r29-skills-scheduled-test.js && node pm-r29-validate-guide-test.js && node pm-r29-reports-addrepo-test.js && node pm-r29-filespad-test.js && node pm-r29-newsession-alignment-test.js",
|
|
19
20
|
"test:offline": "node offline-tarball.test.js"
|
|
20
21
|
},
|
|
21
22
|
"engines": {
|
|
@@ -22,12 +22,17 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { el, clear, setChildren } from './dom.js';
|
|
25
|
-
import { state, setState, subscribe, flush, FIXTURE } from './state.js';
|
|
25
|
+
import { state, setState, subscribe, flush, FIXTURE, LEGACY_LAYOUT_FLAG } from './state.js';
|
|
26
26
|
/* The phone surface (≤760px). It owns its own chrome and decides which single panel is visible;
|
|
27
27
|
* the shell's only part in it is to keep all four hosts mounted below the breakpoint and to hand
|
|
28
28
|
* the module a repaint. See pm/mobile.js. */
|
|
29
29
|
import { isMobile, mountMobile, applyMobile } from './mobile.js';
|
|
30
30
|
|
|
31
|
+
/* The LEGACY layout is the three-column shell of column-layout: repositories with the list under them, the detail, the tabs, and
|
|
32
|
+
* a list that a mode (skills, memory …) replaces. A phone still draws it until Run 4 of workspace-cockpit-nav, and `?layout=legacy`
|
|
33
|
+
* draws it on a desktop so the suites of the panels it uses keep guarding them. It is not a setting: nothing offers it. */
|
|
34
|
+
const legacyLayout = () => LEGACY_LAYOUT_FLAG || isMobile();
|
|
35
|
+
|
|
31
36
|
/* ── derived labels (the prototype's two title maps, lines 1409 / 1699) ─────────────────────── */
|
|
32
37
|
|
|
33
38
|
const MODE_TITLE = {
|
|
@@ -35,30 +40,38 @@ const MODE_TITLE = {
|
|
|
35
40
|
knowledge: 'knowledge', agent: 'agents', memory: 'memory', connect: 'connections', focus: 'focus',
|
|
36
41
|
portfolio: 'portfolio', guide: 'guide', validate: 'validate', today: 'today',
|
|
37
42
|
};
|
|
43
|
+
const modeTitle = () => MODE_TITLE[state.mode] || 'initiatives';
|
|
38
44
|
const DETAIL_TITLE = {
|
|
39
45
|
init: 'initiative', flow: 'workflow', sched: 'scheduled task', skill: 'skill', know: 'decision',
|
|
40
46
|
agent: 'agent', mem: 'memory', prof: 'claude profile', intg: 'connection',
|
|
41
47
|
addprod: 'add product', new: 'file new',
|
|
42
48
|
};
|
|
43
49
|
|
|
44
|
-
const modeTitle = () => MODE_TITLE[state.mode] || 'initiatives';
|
|
45
50
|
const detailTitle = () => DETAIL_TITLE[(state.detail && state.detail.kind) || ''] || 'detail';
|
|
46
51
|
|
|
47
52
|
/* Panels 1 and 2 are resizable: a drag handle on each right edge. The width is clamped, remembered
|
|
48
53
|
* (state.js PERSIST) and reset by a double-click. Before this the columns were a fixed 210px / 280px and a
|
|
49
54
|
* long repository or branch name was simply cut off. */
|
|
50
|
-
const
|
|
55
|
+
const W_LEGACY = { min: 220, max: 560, def: 280 };
|
|
56
|
+
/* The cockpit's list is 288px by default (the mockup's) and is resized and clamped the same way. */
|
|
57
|
+
const W_COCKPIT = { min: 220, max: 560, def: 288 };
|
|
58
|
+
const W0 = { get min() { return W_LEGACY.min; }, get max() { return W_LEGACY.max; }, get def() { return legacyLayout() ? W_LEGACY.def : W_COCKPIT.def; } };
|
|
51
59
|
const clampW = (v, r) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(r.max, Math.max(r.min, Math.round(v))) : r.def);
|
|
52
60
|
const col0w = () => clampW(state.col0W, W0);
|
|
53
61
|
|
|
54
|
-
/*
|
|
62
|
+
/* workspace-cockpit-nav: on a desktop the body is FOUR columns — the 64px repository rail (a 65px track: the mockup draws the rail
|
|
63
|
+
* 64px wide with its 1px border outside that width, so the list starts at x=65), the list (288px), the detail (about
|
|
64
|
+
* 470px) and the tabs (the rest) — and the hosts keep the order they had. The rail is always drawn; `col1` folds the list to a 36px
|
|
65
|
+
* strip and `col2` folds the detail. The three-column shell below is what a PHONE still draws until Run 4.
|
|
66
|
+
*
|
|
67
|
+
* THREE columns (column-layout): navigation, detail, tabs. Navigation is the repository list with the list of the
|
|
55
68
|
* current mode UNDER it (initiatives, or memory, skills, knowledge …). The detail opens in the second column, the
|
|
56
69
|
* tabs fill the rest. The DOM still holds four hosts in the same order — repos, list, detail, tabs — and the grid
|
|
57
70
|
* puts the list host under the repositories host (see placeHosts). Folded away, the navigation column is a 36px
|
|
58
71
|
* rail; the list alone folds to a one-line bar under the repositories. */
|
|
59
|
-
const gridCols = () =>
|
|
60
|
-
(state.col0 ? col0w() + 'px' : '36px') + ' ' +
|
|
61
|
-
(state.col2 ? 'minmax(420px,
|
|
72
|
+
const gridCols = (legacy, focus) => focus ? 'minmax(0,1fr) 360px' : legacy
|
|
73
|
+
? (state.col0 ? col0w() + 'px' : '36px') + ' ' + (state.col2 ? 'minmax(420px,560px)' : '36px') + ' minmax(0,1fr)'
|
|
74
|
+
: '65px ' + (state.col1 ? col0w() + 'px' : '36px') + ' ' + (state.col2 ? 'minmax(420px,470px)' : '36px') + ' minmax(0,1fr)';
|
|
62
75
|
|
|
63
76
|
/** The right-hand 7px of a column is its drag handle. It is a hit-test on the host, not an extra element:
|
|
64
77
|
* the body grid is exactly four tracks and the panels repaint their own children. `key` is the state
|
|
@@ -142,43 +155,58 @@ function runningCount() {
|
|
|
142
155
|
*/
|
|
143
156
|
const root = el('div', {
|
|
144
157
|
'data-pm-theme': state.theme,
|
|
145
|
-
style: 'display:grid;grid-template-rows:
|
|
158
|
+
style: 'display:grid;grid-template-rows:48px minmax(0,1fr) 28px;height:100vh;overflow:hidden;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
|
|
146
159
|
});
|
|
147
160
|
|
|
161
|
+
/* The legacy layout has no rail to hang the menu from, so its theme control stays where it was: in the top bar. */
|
|
148
162
|
const themeBtn = el('button', {
|
|
149
163
|
onClick: () => setState((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),
|
|
150
|
-
style: '
|
|
151
|
-
hover: 'border-color:
|
|
164
|
+
style: 'font-size:12px;color:var(--foreground);padding:5px 12px;border:1px solid var(--input);min-height:30px',
|
|
165
|
+
hover: 'border-color:var(--muted-foreground)',
|
|
152
166
|
}, 'theme · dark');
|
|
167
|
+
const crumb = el('span', { 'data-top': 'crumb', style: 'display:flex;align-items:center;gap:8px;color:var(--muted-foreground);min-width:0;overflow:hidden;white-space:nowrap' });
|
|
168
|
+
const jump = el('button', {
|
|
169
|
+
onClick: () => { if (legacyLayout()) focusSearch(); else setState({ paletteOpen: true, menuOpen: false }); },
|
|
170
|
+
'data-top': 'jump', 'aria-label': 'jump to anything',
|
|
171
|
+
style: 'margin-left:auto;display:flex;align-items:center;gap:8px;box-sizing:content-box;border:1px solid var(--input);border-radius:8px;height:32px;padding:0 12px;min-width:300px;color:var(--muted-foreground);font:13px var(--sans);line-height:1.45',
|
|
172
|
+
hover: 'background:var(--accent)',
|
|
173
|
+
html: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>Jump to anything<span style="margin-left:auto;font:11px var(--mono)">⌘K</span>',
|
|
174
|
+
});
|
|
175
|
+
const liveBadge = el('span', { style: 'font:500 11px var(--sans);border:1px solid transparent;border-radius:999px;padding:0 8px;line-height:18px;white-space:nowrap;background:color-mix(in oklab, var(--success) 16%, transparent);color:var(--success)' }, '● live · local');
|
|
153
176
|
|
|
154
177
|
const topBar = el('div', {
|
|
155
|
-
|
|
178
|
+
'data-shell': 'top',
|
|
179
|
+
// its fill is set per layout in render(): none on the cockpit (as on the mockup), --background on the legacy shell
|
|
180
|
+
style: 'display:flex;align-items:center;gap:14px;padding:0 16px;border-bottom:1px solid var(--border);color:var(--foreground);min-width:0;line-height:1.45',
|
|
156
181
|
},
|
|
157
|
-
el('span', { style: '
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
el('span', { style: 'font-size:9px' }, '●'), 'LIVE · local'),
|
|
162
|
-
themeBtn,
|
|
182
|
+
el('span', { style: 'font-weight:600;font-size:15px' }, 'PMOS'),
|
|
183
|
+
crumb,
|
|
184
|
+
jump,
|
|
185
|
+
liveBadge,
|
|
163
186
|
);
|
|
164
187
|
|
|
165
|
-
const statusLeft = el('span', null, 'local ·
|
|
188
|
+
const statusLeft = el('span', null, 'local · 0 sessions running');
|
|
166
189
|
const statusBar = el('div', {
|
|
167
|
-
|
|
190
|
+
'data-shell': 'status',
|
|
191
|
+
style: 'display:flex;align-items:center;justify-content:space-between;padding:0 14px;color:var(--muted-foreground);font:11px var(--mono);border-top:1px solid var(--border)',
|
|
168
192
|
},
|
|
169
193
|
statusLeft,
|
|
170
|
-
el('span',
|
|
194
|
+
el('span', null, '⌘K jump · Esc closes tab'),
|
|
171
195
|
);
|
|
196
|
+
/* The rail's menu is drawn into this host, a sibling of the body so it can float over all four columns. */
|
|
197
|
+
const menuHost = el('div', { 'data-shell': 'menu', style: 'display:contents' });
|
|
198
|
+
const paletteHost = el('div', { id: 'pm-palette', 'data-shell': 'palette', style: 'display:contents' });
|
|
172
199
|
|
|
173
200
|
/* The body grid's own geometry, in one place so the initial build and every repaint below cannot
|
|
174
201
|
* disagree. The implicit ROW is stated explicitly as `minmax(0,1fr)` for the same reason the root's
|
|
175
202
|
* middle track is: an auto row is floored at its items' min-content height, and one tall panel
|
|
176
203
|
* would push the grid taller than the box it was given. */
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
204
|
+
// A cockpit desktop is one row of four columns; the legacy layout is two rows (repositories, then the list under them) on a desktop
|
|
205
|
+
// and one on a phone, where the hosts are shown one at a time.
|
|
206
|
+
const BODY_GRID_STYLE = (legacy, phone, focus) =>
|
|
207
|
+
'display:grid;grid-template-columns:' + gridCols(legacy, focus) + ';grid-template-rows:' + (legacy && !phone ? 'auto minmax(0,1fr)' : 'minmax(0,1fr)') + ';min-height:0;overflow:hidden';
|
|
180
208
|
|
|
181
|
-
const bodyGrid = el('div', { style: BODY_GRID_STYLE(false) });
|
|
209
|
+
const bodyGrid = el('div', { 'data-shell': 'body', style: BODY_GRID_STYLE(false, false) });
|
|
182
210
|
|
|
183
211
|
/** The collapsed 36px strip: a full-height button with a rotated vertical-rl label. */
|
|
184
212
|
function makeRail(flag) {
|
|
@@ -191,9 +219,10 @@ function makeRail(flag) {
|
|
|
191
219
|
return { btn, label };
|
|
192
220
|
}
|
|
193
221
|
|
|
194
|
-
const rail0 = makeRail('col0');
|
|
195
|
-
const
|
|
196
|
-
|
|
222
|
+
const rail0 = makeRail('col0'); // the phone's folded navigation strip (legacy layout)
|
|
223
|
+
const rail1 = makeRail('col1'); // a desktop's folded list
|
|
224
|
+
const rail2 = makeRail('col2'); // a folded detail
|
|
225
|
+
/* The list under the repositories, folded: one line, not a column (legacy layout). */
|
|
197
226
|
const listBar = el('button', {
|
|
198
227
|
onClick: () => setState((s) => ({ col1: !s.col1 })),
|
|
199
228
|
title: 'show the list',
|
|
@@ -202,8 +231,7 @@ const listBar = el('button', {
|
|
|
202
231
|
});
|
|
203
232
|
rail0.label.textContent = 'repositories ⟩';
|
|
204
233
|
/* overhaul-r1 R4: in live mode the collapsed repositories rail also carries one glyph per repository
|
|
205
|
-
* (panel-repos.js#railGlyphs)
|
|
206
|
-
* toggle keeps its label and stays the rest of the strip. */
|
|
234
|
+
* (panel-repos.js#railGlyphs). Only the phone's legacy layout folds the navigation column now; a desktop's rail never folds. */
|
|
207
235
|
const rail0Host = el('div', {
|
|
208
236
|
role: 'button', tabindex: '0', title: 'show the repositories panel',
|
|
209
237
|
onClick: () => setState((s) => ({ col0: !s.col0 })),
|
|
@@ -213,27 +241,29 @@ const rail0Host = el('div', {
|
|
|
213
241
|
});
|
|
214
242
|
|
|
215
243
|
/* `min-height:0` on all four: a grid item's automatic minimum is its content, so a panel taller
|
|
216
|
-
* than the viewport would otherwise stretch the row it sits in rather than scroll inside it.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
244
|
+
* than the viewport would otherwise stretch the row it sits in rather than scroll inside it. */
|
|
245
|
+
const HOST_STYLE = {
|
|
246
|
+
repos: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto',
|
|
247
|
+
inits: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0',
|
|
248
|
+
initsDesk: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;gap:12px;padding:12px;box-sizing:border-box;overflow:hidden;min-width:0;min-height:0',
|
|
249
|
+
};
|
|
250
|
+
const hostRepos = el('div', { id: 'pm-rail', style: HOST_STYLE.repos });
|
|
251
|
+
const hostInits = el('div', { id: 'pm-list', style: HOST_STYLE.initsDesk });
|
|
222
252
|
makeEdgeDrag(hostInits, 'col0W', W0, col0w);
|
|
223
|
-
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' });
|
|
224
|
-
const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
|
|
253
|
+
const hostDetail = el('div', { id: 'pm-detail', style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
|
|
254
|
+
const hostTabs = el('div', { id: 'pm-tabs', style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
|
|
225
255
|
|
|
226
|
-
setChildren(root, [topBar, bodyGrid, statusBar]);
|
|
256
|
+
setChildren(root, [topBar, bodyGrid, statusBar, menuHost, paletteHost]);
|
|
227
257
|
|
|
228
258
|
/* ── panel modules, loaded at boot and tolerated when absent ────────────────────────────────── */
|
|
229
259
|
|
|
230
|
-
const mods = { repos: null, inits: null, detail: null, tabs: null, data: null, fixture: null };
|
|
260
|
+
const mods = { repos: null, inits: null, detail: null, tabs: null, data: null, fixture: null, cockpit: null };
|
|
231
261
|
|
|
232
262
|
async function loadModules() {
|
|
233
263
|
const want = [
|
|
234
264
|
['repos', './panel-repos.js'], ['inits', './panel-initiatives.js'],
|
|
235
265
|
['detail', './panel-detail.js'], ['tabs', './panel-tabs.js'],
|
|
236
|
-
['data', './data.js'], ['fixture', './fixture.js'],
|
|
266
|
+
['data', './data.js'], ['fixture', './fixture.js'], ['cockpit', './panel-cockpit.js'],
|
|
237
267
|
];
|
|
238
268
|
await Promise.all(want.map(async ([key, path]) => {
|
|
239
269
|
try { mods[key] = await import(path); } catch { mods[key] = null; }
|
|
@@ -249,10 +279,7 @@ function paint(mod, name, host) {
|
|
|
249
279
|
|
|
250
280
|
/* ── render ────────────────────────────────────────────────────────────────────────────────── */
|
|
251
281
|
|
|
252
|
-
/* The
|
|
253
|
-
* label, and its text is still exactly `repositories ⟩` — the glyphs draw their initials from a data
|
|
254
|
-
* attribute (tokens.css) and name themselves through aria-label, and CSS `order` lifts them above
|
|
255
|
-
* the label. A glyph click stops at the glyph: it selects, it does not expand. */
|
|
282
|
+
/* The phone's folded navigation strip (legacy layout): one control that re-opens the panel, its first child the rotated label. */
|
|
256
283
|
function railRepos() {
|
|
257
284
|
const glyphs = mods.repos && typeof mods.repos.railGlyphs === 'function' ? mods.repos.railGlyphs() : [];
|
|
258
285
|
if (!glyphs.length) {
|
|
@@ -265,15 +292,15 @@ function railRepos() {
|
|
|
265
292
|
return rail0Host;
|
|
266
293
|
}
|
|
267
294
|
|
|
268
|
-
/* Where each host sits in the grid.
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
function placeHosts(phone, kids) {
|
|
295
|
+
/* Where each host sits in the LEGACY grid. Column 1 holds the repositories (row 1) and the list under them (row 2); the detail and the
|
|
296
|
+
* tabs span both rows. The repositories block is capped so a long list of repositories cannot push the list off the screen. A folded
|
|
297
|
+
* column is a rail spanning both rows. On a phone the placement is cleared and mobile.css decides which single host is shown. On a
|
|
298
|
+
* cockpit desktop every host has its own column and nothing is placed. */
|
|
299
|
+
function placeHosts(legacy, phone, kids) {
|
|
273
300
|
const cell = (el0, col, row) => { if (el0) { el0.style.gridColumn = col; el0.style.gridRow = row; } };
|
|
274
301
|
const clear0 = (el0) => { el0.style.gridColumn = ''; el0.style.gridRow = ''; el0.style.maxHeight = ''; };
|
|
275
|
-
for (const h of [hostRepos, hostInits, hostDetail, hostTabs, listBar, rail0.btn, rail0Host, rail2.btn]) clear0(h);
|
|
276
|
-
if (phone) return;
|
|
302
|
+
for (const h of [hostRepos, hostInits, hostDetail, hostTabs, listBar, rail0.btn, rail0Host, rail1.btn, rail2.btn]) clear0(h);
|
|
303
|
+
if (!legacy || phone) return;
|
|
277
304
|
if (state.col0) {
|
|
278
305
|
cell(hostRepos, '1', '1');
|
|
279
306
|
hostRepos.style.maxHeight = 'calc((100vh - 70px) * .42)';
|
|
@@ -286,44 +313,76 @@ function placeHosts(phone, kids) {
|
|
|
286
313
|
cell(hostTabs, '3', '1 / 3');
|
|
287
314
|
}
|
|
288
315
|
|
|
316
|
+
let lastLegacy = null;
|
|
289
317
|
function render() {
|
|
290
318
|
root.setAttribute('data-pm-theme', state.theme);
|
|
291
|
-
|
|
319
|
+
const phone = isMobile();
|
|
320
|
+
const legacy = legacyLayout();
|
|
321
|
+
// the mockup's body line-height is 1.45; the legacy shell's panels are drawn for 1.5
|
|
322
|
+
root.style.lineHeight = legacy ? '1.5' : '1.45';
|
|
323
|
+
// the cockpit's bars are the mockup's 48px and 28px with their 1px rule outside that height (as its rail is 64px plus its rule);
|
|
324
|
+
// the legacy shell keeps 48px and 28px rule included. A phone's stylesheet replaces the rows altogether.
|
|
325
|
+
root.style.gridTemplateRows = legacy ? '48px minmax(0,1fr) 28px' : '49px minmax(0,1fr) 29px';
|
|
326
|
+
// the cockpit's bars have no fill of their own (the root's --background shows through, as on the mockup); the legacy shell's do
|
|
327
|
+
topBar.style.background = legacy ? 'var(--background)' : '';
|
|
328
|
+
statusBar.style.background = legacy ? 'var(--background)' : '';
|
|
329
|
+
// A cockpit desktop never shows a list mode in the list: it opens as a view tab (panel-cockpit.js)
|
|
330
|
+
if (!legacy && mods.cockpit && typeof mods.cockpit.absorbMode === 'function' && mods.cockpit.absorbMode()) return;
|
|
331
|
+
|
|
292
332
|
// overhaul-r1 R2.3: the active tab's line of work, when it is not the main tree
|
|
293
333
|
const actTab = (state.tabs || []).find((t) => t.id === state.active);
|
|
294
334
|
const br = actTab && mods.tabs && typeof mods.tabs.tabBranch === 'function' ? mods.tabs.tabBranch(actTab, true) : null;
|
|
295
|
-
statusLeft.textContent = 'local ·
|
|
335
|
+
statusLeft.textContent = 'local · ' + runningCount() + ' sessions running' + (br ? ' · ⎇ ' + br : '');
|
|
336
|
+
const parts = mods.cockpit && typeof mods.cockpit.breadcrumb === 'function' ? mods.cockpit.breadcrumb() : [];
|
|
337
|
+
setChildren(crumb, parts);
|
|
338
|
+
themeBtn.textContent = state.theme === 'dark' ? 'theme · dark' : 'theme · light';
|
|
339
|
+
if (legacy && themeBtn.parentNode !== topBar) topBar.appendChild(themeBtn);
|
|
340
|
+
if (!legacy && themeBtn.parentNode === topBar) topBar.removeChild(themeBtn);
|
|
296
341
|
|
|
297
|
-
//
|
|
298
|
-
// a panel away can still see what they folded.
|
|
342
|
+
// Folded strips name whatever is behind them, so a PM who folded a panel away can still see what they folded.
|
|
299
343
|
listBar.textContent = '▸ ' + modeTitle();
|
|
344
|
+
rail1.label.textContent = 'initiatives ⟩';
|
|
300
345
|
rail2.label.textContent = detailTitle() + ' ⟩';
|
|
301
346
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
347
|
+
if (lastLegacy !== legacy) {
|
|
348
|
+
lastLegacy = legacy;
|
|
349
|
+
hostInits.setAttribute('style', legacy ? HOST_STYLE.inits + ';border-top:1px solid var(--border)' : HOST_STYLE.initsDesk);
|
|
350
|
+
// the cockpit's rail is the mockup's centred column; the legacy repositories panel draws its own rows edge to edge
|
|
351
|
+
hostRepos.style.alignItems = legacy ? '' : 'center';
|
|
352
|
+
hostRepos.style.gap = legacy ? '' : '10px';
|
|
353
|
+
hostRepos.style.padding = legacy ? '' : '12px 0';
|
|
354
|
+
}
|
|
355
|
+
const focus = !legacy && !phone && !!state.layoutFocus;
|
|
356
|
+
bodyGrid.setAttribute('style', BODY_GRID_STYLE(legacy, phone, focus));
|
|
357
|
+
|
|
358
|
+
// On a phone there are no rails to collapse into and no second column to fold away: the four hosts all stay mounted, one is
|
|
359
|
+
// DISPLAYED (pm/mobile.css picks it from `data-mpanel`), and the PM's desktop arrangement is left exactly as they set it. Mounting
|
|
360
|
+
// all four keeps a live terminal alive: a host dropped from this list would be removed from the document.
|
|
361
|
+
let kids;
|
|
362
|
+
if (phone) kids = [hostRepos, hostInits, hostDetail, hostTabs];
|
|
363
|
+
else if (legacy) {
|
|
364
|
+
const listHost = state.col1 ? hostInits : listBar;
|
|
365
|
+
kids = [state.col0 ? hostRepos : railRepos(), state.col0 ? listHost : null, state.col2 ? hostDetail : rail2.btn, hostTabs].filter(Boolean);
|
|
366
|
+
} else if (focus) kids = [hostDetail, hostTabs]; // Focus: the tabs and a 360px detail; hostTabs is never moved, only the others come and go
|
|
367
|
+
else kids = [hostRepos, state.col1 ? hostInits : rail1.btn, state.col2 ? hostDetail : rail2.btn, hostTabs];
|
|
319
368
|
setChildren(bodyGrid, kids);
|
|
320
|
-
placeHosts(phone, kids);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (
|
|
324
|
-
|
|
369
|
+
placeHosts(legacy, phone, kids);
|
|
370
|
+
if (focus) { hostTabs.style.gridColumn = '1'; hostTabs.style.gridRow = '1'; hostDetail.style.gridColumn = '2'; hostDetail.style.gridRow = '1'; }
|
|
371
|
+
|
|
372
|
+
if (legacy) {
|
|
373
|
+
if (phone || state.col0) paint(mods.repos, 'renderRepos', hostRepos);
|
|
374
|
+
if (phone || (state.col0 && state.col1)) paint(mods.inits, 'renderInitiatives', hostInits);
|
|
375
|
+
if (phone || state.col2) paint(mods.detail, 'renderDetail', hostDetail);
|
|
376
|
+
} else if (focus) {
|
|
377
|
+
paint(mods.detail, 'renderDetail', hostDetail);
|
|
378
|
+
} else {
|
|
379
|
+
paint(mods.cockpit, 'renderRail', hostRepos);
|
|
380
|
+
if (state.col1) paint(mods.cockpit, 'renderWorkList', hostInits);
|
|
381
|
+
if (state.col2) paint(mods.detail, 'renderDetail', hostDetail);
|
|
382
|
+
}
|
|
325
383
|
paint(mods.tabs, 'renderTabs', hostTabs);
|
|
326
|
-
|
|
384
|
+
paint(mods.cockpit, 'renderMenu', menuHost);
|
|
385
|
+
paint(mods.cockpit, 'renderPalette', paletteHost);
|
|
327
386
|
applyMobile();
|
|
328
387
|
}
|
|
329
388
|
|
|
@@ -338,9 +397,19 @@ function focusSearch() {
|
|
|
338
397
|
function onKeyDown(e) {
|
|
339
398
|
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
|
340
399
|
e.preventDefault();
|
|
341
|
-
|
|
400
|
+
// the cockpit's ⌘K is the palette; the legacy layout (and a phone) keep focusing the list's filter
|
|
401
|
+
if (legacyLayout()) setState({ col1: true, menuOpen: false }, focusSearch);
|
|
402
|
+
else setState({ paletteOpen: !state.paletteOpen, menuOpen: false });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !legacyLayout()) {
|
|
406
|
+
e.preventDefault();
|
|
407
|
+
if (mods.cockpit && typeof mods.cockpit.toggleFocus === 'function') mods.cockpit.toggleFocus();
|
|
342
408
|
return;
|
|
343
409
|
}
|
|
410
|
+
if (e.key === 'Escape' && state.paletteOpen) { setState({ paletteOpen: false }); return; }
|
|
411
|
+
if (e.key === 'Escape' && state.menuOpen) { setState({ menuOpen: false }); return; }
|
|
412
|
+
if (e.key === 'Escape' && state.layoutFocus) { setState({ layoutFocus: false }); return; }
|
|
344
413
|
if (e.key === 'Escape' && !/INPUT|TEXTAREA/.test(e.target && e.target.tagName)) {
|
|
345
414
|
// Verbatim from the prototype: Esc closes the ACTIVE tab, the last remaining tab takes over,
|
|
346
415
|
// and a split pointing at the closed tab is dropped rather than left dangling.
|