create-pmos 0.495.0 → 0.497.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 +57 -33
- package/payload/AGENTS.md +2 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/workflow-state.js +7 -19
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/adler-moments-test.js +3 -1
- package/workspace/board-programs-test.js +6 -6
- package/workspace/board-shared-ui-test.js +2 -2
- package/workspace/briefing-premium-adddoc-test.js +1 -48
- package/workspace/briefing-premium-board-test.js +18 -19
- package/workspace/changelog.json +56 -0
- package/workspace/guide/adler.md +1 -1
- package/workspace/guide/briefing-premium.md +1 -0
- package/workspace/guide/claude-proposes.md +6 -2
- package/workspace/guide/data-freshness.md +7 -1
- package/workspace/guide/home.md +1 -1
- package/workspace/guide/published-board.md +1 -1
- package/workspace/guide/workflows.md +4 -3
- package/workspace/lib/board-premium.js +17 -2
- package/workspace/lib/engine/workflow-state.js +7 -19
- package/workspace/lib/initiative-chat.js +4 -1
- package/workspace/lib/proposals.js +18 -4
- package/workspace/lib/routes-board-premium.js +2 -2
- package/workspace/lib/routes-proposals.js +14 -2
- package/workspace/lib/split-merge-suggest.js +37 -3
- package/workspace/lib/split-merge.js +10 -4
- package/workspace/lib/task-cli.js +5 -1
- package/workspace/lib/vendor/scripts/workflow-state.js +7 -19
- package/workspace/lib/workflow-cli.js +70 -15
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-board-actions-test.js +7 -9
- package/workspace/premium-core-doc-issue-test.js +9 -38
- package/workspace/public/pm/chat-turn-end.js +19 -0
- package/workspace/public/pm/cozy-board.css +17 -5
- package/workspace/public/pm/home-app.js +21 -5
- package/workspace/public/pm/home-chat.js +5 -0
- package/workspace/public/pm/home-ini.js +7 -0
- package/workspace/public/pm/home-today.js +11 -4
- package/workspace/public/pm/load-shapes.css +57 -0
- package/workspace/public/pm/load-shapes.js +57 -0
- package/workspace/public/pm/premium-board.js +82 -57
- package/workspace/public/pm/readonly.js +3 -3
- package/workspace/public/pm/split-merge.js +3 -2
- package/workspace/public/premium-board.html +7 -10
- package/workspace/public/premium.html +1 -0
- package/workspace/split-merge-ui-test.js +1 -1
- package/workspace/test-lists/chat-turn-end.json +9 -0
- package/workspace/test-lists/page-open-speed.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/* workflow-cli.js — `pmos workflow …`: what an agent writes about workflows (D99, initiative workflows-per-initiative; canvas WfADraft, WfACond).
|
|
3
3
|
*
|
|
4
|
-
* pmos workflow list the
|
|
5
|
-
*
|
|
4
|
+
* pmos workflow list every workflow the PM can pick, Product feature first: id, name, stages. This repository's
|
|
5
|
+
* library, then the home workspace's (Settings › Workflows saves there) and every other open one
|
|
6
|
+
* pmos workflow show <id> one workflow's stages, numbered, with their category, output and when they run (any library above)
|
|
6
7
|
* pmos workflow draft --file <path|-> [--note <text>]
|
|
7
8
|
* writes THE draft the New workflow chat shows in the Viewer (one wf-draft line). The JSON is one workflow { name, icon?, decide?,
|
|
8
9
|
* stages: [...] }, checked as a save is, so the page never shows a draft that Create would refuse. A new draft replaces the one before.
|
|
@@ -23,7 +24,14 @@
|
|
|
23
24
|
* WHO WRITES. These write through lib/workflows-store.js and lib/routes-boards.js, never over HTTP. An agent may draft, judge, and move or
|
|
24
25
|
* switch an initiative when the PM asks; it never saves, copies or archives a workflow: Create, Copy and Archive are the PM's presses on the
|
|
25
26
|
* Workflows page. Who wrote a line is the call path (lib/actor.js), never an
|
|
26
|
-
* argument. Exit codes: 0 done, 1 refused, 2 usage. Nothing is sent.
|
|
27
|
+
* argument. Exit codes: 0 done, 1 refused, 2 usage. Nothing is sent.
|
|
28
|
+
*
|
|
29
|
+
* THE LIBRARY IS NOT IN THE REPOSITORY'S FILES (batch 24 item 8). Settings › Workflows saves to the HOME workspace's workflows.jsonl, and
|
|
30
|
+
* a product repository holds only the workflows its initiatives were switched to (lib/routes-boards.js switchWorkflow adopts them, #1089).
|
|
31
|
+
* An initiative chat in a product repository searched the repo, found no "Simpler Feature" and fell back to the feature LANE (D56) — a
|
|
32
|
+
* different thing from a D99 workflow. So list, show and switch read the other workspaces too: PMOS_SELF_ROOT (the launcher's home), the
|
|
33
|
+
* PMOS checkout this CLI runs from when it is PMOS-self, and every repository in the server's registry (workspaces.json). A caller that
|
|
34
|
+
* passes its own env (a test) gets only the homes it passes. */
|
|
27
35
|
const fs = require('fs');
|
|
28
36
|
const path = require('path');
|
|
29
37
|
const { execFileSync } = require('child_process');
|
|
@@ -32,12 +40,14 @@ const driver = require('./driver.js');
|
|
|
32
40
|
const WS = require('./workflows-store.js');
|
|
33
41
|
const { repoScript } = require('./repo-script.js');
|
|
34
42
|
const WF = require(repoScript('workflows.js'));
|
|
43
|
+
const stateRoot = require('./state-root.js');
|
|
35
44
|
|
|
36
45
|
const COMMANDS = { workflow: ['list', 'show', 'draft', 'show-draft', 'judge', 'of', 'move', 'switch'] };
|
|
37
46
|
const USAGE = [
|
|
38
47
|
'usage: pmos workflow list|show|draft|show-draft|judge|of|move|switch — the workflows each kind of initiative goes through (D99)',
|
|
39
|
-
' pmos workflow list the
|
|
48
|
+
' pmos workflow list every workflow the PM can pick: this repository\'s and the ones saved in Settings',
|
|
40
49
|
' pmos workflow show <id> one workflow, its stages numbered',
|
|
50
|
+
' The workflows are not files in the repository: never search the files for one, and never use a lane (feature, chore…) instead.',
|
|
41
51
|
' pmos workflow draft --file <path|-> [--note <text>]',
|
|
42
52
|
' Write the draft the New workflow chat shows beside it. The PM presses Create workflow; you never save one.',
|
|
43
53
|
' pmos workflow show-draft the draft now open',
|
|
@@ -81,10 +91,51 @@ function readStdin(stdin) {
|
|
|
81
91
|
if (typeof stdin === 'string') return stdin;
|
|
82
92
|
try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
|
|
83
93
|
}
|
|
94
|
+
/** The roots of the other workspaces whose libraries an agent reads: home first, then the server's registry. */
|
|
95
|
+
function homeRoots(env) {
|
|
96
|
+
const out = [];
|
|
97
|
+
const dir = (p) => { try { return !!p && fs.statSync(p).isDirectory(); } catch { return false; } };
|
|
98
|
+
if (env.PMOS_SELF_ROOT && dir(env.PMOS_SELF_ROOT)) out.push(path.resolve(env.PMOS_SELF_ROOT));
|
|
99
|
+
const self = path.resolve(__dirname, '..', '..');
|
|
100
|
+
if (!/[\\/]node_modules[\\/]/.test(__dirname) && (fs.existsSync(path.join(self, 'DECISIONS.md')) || fs.existsSync(path.join(self, 'AGENTS.md')))) out.push(self);
|
|
101
|
+
let reg = [];
|
|
102
|
+
try { reg = JSON.parse(fs.readFileSync(path.join(stateRoot.defaultRoot({ env }), 'workspaces.json'), 'utf8')); } catch { reg = []; }
|
|
103
|
+
if (Array.isArray(reg)) for (const x of reg) if (x && typeof x.root === 'string' && dir(x.root)) out.push(path.resolve(x.root));
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/** The other workspaces, as { id, name, root, stateDir }: not this repository, each once, only those with a state folder. */
|
|
107
|
+
function othersOf(root, homes) {
|
|
108
|
+
const seen = new Set([path.resolve(root)]);
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const r of homes || []) {
|
|
111
|
+
const abs = path.resolve(r);
|
|
112
|
+
if (seen.has(abs)) continue;
|
|
113
|
+
seen.add(abs);
|
|
114
|
+
const d = driver.stateDirOf(abs);
|
|
115
|
+
if (d) out.push({ id: driver.wsIdOf(abs), name: path.basename(abs), root: abs, stateDir: d });
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** Every workflow the PM can pick: this library's, then each other workspace's that this one lacks (not archived there), with where it is. */
|
|
120
|
+
function allWorkflows(stateDir, others) {
|
|
121
|
+
const own = WF.list(WS.library(stateDir));
|
|
122
|
+
const ids = new Set(own.map((w) => w.id));
|
|
123
|
+
const more = [];
|
|
124
|
+
for (const o of others) {
|
|
125
|
+
for (const w of WF.list(WS.library(o.stateDir))) if (!ids.has(w.id) && !w.archived) { ids.add(w.id); more.push({ ...w, from: o.name }); }
|
|
126
|
+
}
|
|
127
|
+
return [...own, ...more];
|
|
128
|
+
}
|
|
129
|
+
function workflowById(stateDir, others, id) {
|
|
130
|
+
const own = WS.library(stateDir).byId.get(id);
|
|
131
|
+
if (own) return own;
|
|
132
|
+
for (const o of others) { const w = WS.library(o.stateDir).byId.get(id); if (w) return { ...w, from: o.name }; }
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
84
135
|
const WHEN_WORDS = (s) => (s.when && s.when.mode === 'only' ? 'only when ' + s.when.text : s.when && s.when.mode === 'optional' ? 'optional' : 'always');
|
|
85
136
|
const stageLine = (s, i) => `${String(i + 1).padStart(2)}. ${s.milestone ? '◆ ' : ''}${s.name} [${s.key}] · ${WF.CAT_LABEL[s.cat]} · ${s.output && s.output.name ? s.output.name + ' (' + s.output.kind + ')' : s.output.kind} · ${WHEN_WORDS(s)}`;
|
|
86
137
|
|
|
87
|
-
async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isTTY, env, stdin } = {}) {
|
|
138
|
+
async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isTTY, env, stdin, homes } = {}) {
|
|
88
139
|
const args = argv[0] === 'workflow' ? argv.slice(1) : argv.slice();
|
|
89
140
|
const verb = args[0];
|
|
90
141
|
const { pos, flags } = parse(args.slice(1));
|
|
@@ -102,16 +153,17 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
102
153
|
if (!stateDir) return fail('this repository has no PMOS state folder (pmos/state, state or .pmos)');
|
|
103
154
|
const e = env === undefined ? process.env : env;
|
|
104
155
|
const actor = actorLib.fromCli({ isTTY: isTTY === undefined ? !!(process.stdin.isTTY && process.stdout.isTTY) : isTTY, env: e });
|
|
156
|
+
const others = othersOf(root, homes !== undefined ? homes : env === undefined ? homeRoots(e) : []);
|
|
105
157
|
|
|
106
158
|
if (verb === 'list') {
|
|
107
|
-
const all =
|
|
108
|
-
if (json) { say(JSON.stringify({ ok: true, workflows: all.map((w) => ({ id: w.id, name: w.name, stages: w.stages.length, archived: !!w.archived, default: !!w.default })) })); return 0; }
|
|
109
|
-
for (const w of all) say(`${w.id} · ${w.name} · ${w.stages.length} stage${w.stages.length === 1 ? '' : 's'}${w.archived ? ' · archived' : ''}${w.default ? ' · default for new initiatives' : ''}`);
|
|
159
|
+
const all = allWorkflows(stateDir, others);
|
|
160
|
+
if (json) { say(JSON.stringify({ ok: true, workflows: all.map((w) => ({ id: w.id, name: w.name, stages: w.stages.length, archived: !!w.archived, default: !!w.default, ...(w.from ? { from: w.from } : {}) })) })); return 0; }
|
|
161
|
+
for (const w of all) say(`${w.id} · ${w.name} · ${w.stages.length} stage${w.stages.length === 1 ? '' : 's'}${w.archived ? ' · archived' : ''}${w.default ? ' · default for new initiatives' : ''}${w.from ? ' · saved in ' + w.from : ''}`);
|
|
110
162
|
return 0;
|
|
111
163
|
}
|
|
112
164
|
if (verb === 'show') {
|
|
113
165
|
const id = pos[0]; if (!id) { say('usage: pmos workflow show <id>'); return 2; }
|
|
114
|
-
const w =
|
|
166
|
+
const w = workflowById(stateDir, others, id);
|
|
115
167
|
if (!w) return fail(`no workflow ${id} — pmos workflow list names them`);
|
|
116
168
|
if (json) { say(JSON.stringify({ ok: true, workflow: w })); return 0; }
|
|
117
169
|
say(`${w.name} (${w.id}) · ${w.decide === 'confirm' ? 'PMOS suggests, the PM confirms' : 'PMOS decides and tells why'}`);
|
|
@@ -140,7 +192,7 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
140
192
|
else say(`✓ drafted ${r.draft.name} with ${r.draft.stages.length} stages. The Workflows page shows it beside the chat; the PM presses Create workflow. Nothing was sent.`);
|
|
141
193
|
return 0;
|
|
142
194
|
}
|
|
143
|
-
if (verb === 'of' || verb === 'move' || verb === 'switch') return onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor });
|
|
195
|
+
if (verb === 'of' || verb === 'move' || verb === 'switch') return onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor, others });
|
|
144
196
|
// judge
|
|
145
197
|
if (!flags.board || !flags.id || !flags.stage) { say('usage: pmos workflow judge --board <id> --id <initiative> --stage <key> --runs yes|no|unsure --why <reason> [--cite <where>]'); return 2; }
|
|
146
198
|
let r;
|
|
@@ -158,10 +210,13 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
158
210
|
|
|
159
211
|
/* ── an initiative's own workflow: of, move, switch ── */
|
|
160
212
|
const MARK = { done: '✓', current: '▶', skipped: '–', next: ' ' };
|
|
161
|
-
/** The Board's routes outside HTTP: this repository as the
|
|
162
|
-
|
|
213
|
+
/** The Board's routes outside HTTP: this repository as the workspace, its state folder, and the PMOS checkout as the self board. The other
|
|
214
|
+
* workspaces are listed too, so a switch to a workflow saved in the home library finds it and adopts it here, as the page's switch does. */
|
|
215
|
+
function boardsCtx(root, stateDir, others = []) {
|
|
163
216
|
const w = { id: driver.wsIdOf(root), name: path.basename(root), root };
|
|
164
|
-
|
|
217
|
+
const list = [w, ...others.map((o) => ({ id: o.id, name: o.name, root: o.root }))];
|
|
218
|
+
const dirOf = (x) => { if (!x || x.id === w.id) return stateDir; const o = others.find((y) => y.id === x.id); return o ? o.stateDir : null; };
|
|
219
|
+
return { w, ctx: { getWs: () => w, workspaceList: () => list, stateDirFor: dirOf, selfRoot: path.resolve(__dirname, '..', '..') } };
|
|
165
220
|
}
|
|
166
221
|
/** The initiative's row, reading the records now when nothing is cached (a CLI process starts with an empty cache). */
|
|
167
222
|
function rowOf(ctx, w, id) {
|
|
@@ -184,7 +239,7 @@ function ofLines(r) {
|
|
|
184
239
|
}
|
|
185
240
|
const ofJson = (r) => ({ initiative: { id: r.row.id, title: r.row.title }, board: { id: r.board.id, name: r.board.name }, wf: r.row.wf, stage: r.row.wfStage, stages: r.row.wfStages, ended: r.row.ended });
|
|
186
241
|
|
|
187
|
-
function onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor }) {
|
|
242
|
+
function onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor, others }) {
|
|
188
243
|
const id = pos[0];
|
|
189
244
|
const usage = { of: 'pmos workflow of <initiative>', move: 'pmos workflow move <initiative> --stage <key|name|number> [--why <text>]', switch: 'pmos workflow switch <initiative> --to <workflow id> [--stage <key|name>] [--why <text>]' }[verb];
|
|
190
245
|
if (!id) { say('usage: ' + usage); return 2; }
|
|
@@ -193,7 +248,7 @@ function onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor
|
|
|
193
248
|
for (const k of ['board', 'runs', 'cite', 'file', 'note', 'undo']) if (k in flags) return fail(`--${k} is not an option of ${verb}`, 2);
|
|
194
249
|
if (verb === 'of') for (const k of ['stage', 'to', 'why']) if (k in flags) return fail(`--${k} is not an option of of`, 2);
|
|
195
250
|
if (verb === 'move' && 'to' in flags) return fail('--to names a workflow: use pmos workflow switch. Move takes --stage', 2);
|
|
196
|
-
const { w, ctx } = boardsCtx(root, stateDir);
|
|
251
|
+
const { w, ctx } = boardsCtx(root, stateDir, others);
|
|
197
252
|
const before = rowOf(ctx, w, id);
|
|
198
253
|
if (!before.ok) return fail(before.error);
|
|
199
254
|
if (verb === 'of') {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.497.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.497.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -18,6 +18,8 @@ const F = require('./fixtures/board-premium-facts.js');
|
|
|
18
18
|
|
|
19
19
|
const BASE = T.BASE;
|
|
20
20
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
/* the one pill for what needs a look (batch 24 item 7, canvas BbB): its rows are pressed after the pill opens them */
|
|
22
|
+
const needOpen = async (pg) => { if (await pg.$('#bdNeedMenu[hidden]')) await pg.click('#chNeed'); };
|
|
21
23
|
const until = async (fn, ms = 20000, step = 150) => { const t0 = Date.now(); for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); } };
|
|
22
24
|
const results = [];
|
|
23
25
|
const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 500) : ''}`); };
|
|
@@ -99,7 +101,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
|
|
|
99
101
|
const spLife = async (id, label) => { await spOpen(id); await p.click(`.hi-pop [data-sp-life]:has(> span:text-is("${label}"))`); };
|
|
100
102
|
const closeAll = async () => { for (let i = 0; i < 4; i++) { if (await p.evaluate(() => (!document.querySelector('#czViewer') || document.querySelector('#czViewer').hidden) && (!document.querySelector('#bdPop') || document.querySelector('#bdPop').hidden))) return; await p.keyboard.press('Escape'); await sleep(100); } };
|
|
101
103
|
await until(() => p.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 30000);
|
|
102
|
-
await p.click('#chAsks');
|
|
104
|
+
await needOpen(p); await p.click('#chAsks');
|
|
103
105
|
ok('A2 the New asks popover lists both asks with Accept and Decline (BdAsks)', await until(() => p.evaluate(() => document.querySelectorAll('#bdPop .bd-acard[data-ask]').length === 2 && document.querySelectorAll('#bdPop [data-askact="accept"]').length === 2 && document.querySelectorAll('#bdPop [data-askact="decline"]').length === 2)), await p.evaluate(() => (document.querySelector('#bdPop') || {}).textContent));
|
|
104
106
|
ok('A2 an ask shows its source and the product with the reason, not an invented name, and no "Waiting for design" tag', await p.evaluate(() => { const t = document.querySelector('#bdPop').textContent; return /Email from Rina/.test(t) && /Why: came in through Email from Rina/.test(t) && !/demo/i.test(t) && !document.querySelector('#bdPop .bd-wtag'); }));
|
|
105
107
|
|
|
@@ -121,15 +123,13 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
|
|
|
121
123
|
await p.click('#czViewer [data-grill="later"]');
|
|
122
124
|
ok('T3 Later keeps the open questions as the next step', await until(() => lines('triage-answers.jsonl').some((l) => l.kind === 'later')) && !!(await toastIs(/Kept as the next step/)));
|
|
123
125
|
await until(() => p.evaluate(() => document.querySelector('#chAsks') && /\b1\b/.test(document.querySelector('#chAsks').textContent)), 30000);
|
|
124
|
-
await p.click('#chAsks');
|
|
126
|
+
await needOpen(p); await p.click('#chAsks');
|
|
125
127
|
ok('T4 the accepted ask leaves the popover', await until(() => p.evaluate((id) => !document.querySelector(`#bdPop [data-askact="accept"][data-id="${id}"]`) && document.querySelectorAll('#bdPop .bd-acard[data-ask]').length === 1, a1.body.ask.id)));
|
|
126
128
|
await p.click(`#bdPop [data-askact="decline"][data-id="${a2.body.ask.id}"]`);
|
|
127
129
|
ok('T5 Decline asks why not, with the chips', !!(await popOn(/Why not.*Out of scope.*Already planned.*Not now.*Other/)) && !P.posts.some((x) => x.path === '/api/boards/asks/decline'));
|
|
128
130
|
await p.click('#bdPop [data-whynot="Not now"]'); await p.fill('#bdPop input[name="why"]', 'Not tied to a goal this quarter'); await p.click('#bdPop [data-askact="decline-go"]');
|
|
129
131
|
ok('T6 Decline records the reason in the board ledger', !!(await toastIs(/Ask declined/)) && lines('boards.jsonl').some((l) => l.kind === 'ask-decline' && l.why === 'Not now: Not tied to a goal this quarter'), JSON.stringify(lines('boards.jsonl').filter((l) => l.kind === 'ask-decline')));
|
|
130
|
-
await until(() => p.evaluate(() =>
|
|
131
|
-
await p.click('#chAsks');
|
|
132
|
-
ok('T7 the popover says nothing is left to triage', !!(await popOn(/No new ask waits for triage/)));
|
|
132
|
+
ok('T7 with nothing left to triage the asks row leaves the pill (batch 24 item 7: zero is silence)', !!(await until(() => p.evaluate(() => !document.querySelector('#chAsks')), 30000)));
|
|
133
133
|
await closeAll();
|
|
134
134
|
|
|
135
135
|
/* N New initiative */
|
|
@@ -281,9 +281,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
|
|
|
281
281
|
await S.page.route('**/api/board-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: m, as_of: Date.now() }) }));
|
|
282
282
|
await S.page.reload();
|
|
283
283
|
await until(() => S.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0));
|
|
284
|
-
await S.page.
|
|
285
|
-
ok('S1 the PMOS-self board names the router in its asks popover and hides New initiative', await until(() => S.page.evaluate(() => /pmos-router/.test((document.querySelector('#bdPop') || {}).textContent || '') && document.querySelector('#newIni').hidden)));
|
|
286
|
-
await S.page.keyboard.press('Escape');
|
|
284
|
+
ok('S1 the PMOS-self board has no asks row (it has no ask source; zero is silence, batch 24 item 7) and hides New initiative', await until(() => S.page.evaluate(() => !document.querySelector('#chAsks') && document.querySelector('#newIni').hidden)));
|
|
287
285
|
await S.page.click('#ibBody .ini2'); await until(() => S.page.evaluate(() => !!document.querySelector('#czViewer:not([hidden]) [data-iv-stagepick]'))); await S.page.click('#czViewer [data-iv-stagepick]');
|
|
288
286
|
const s2 = await until(() => S.page.evaluate(() => { const el = document.querySelector('.hi-pop'); return el ? { go: [...el.querySelectorAll('[data-sp-go]:not([disabled])')].length, all: el.querySelectorAll('[data-sp-go]').length, auto: !!el.querySelector('[data-sp-auto]'), life: [...el.querySelectorAll('[data-sp-life]')].map((x) => x.dataset.spLife) } : null; }));
|
|
289
287
|
ok('S2 its stage picker moves no stage and changes no workflow, and keeps Put on hold', !!s2 && s2.all > 1 && s2.go <= 1 && s2.life.includes('hold') && !s2.life.includes('wontDo') && !s2.life.includes('archive'), JSON.stringify(s2));
|
|
@@ -305,7 +303,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
|
|
|
305
303
|
{
|
|
306
304
|
const S = await open('/premium-board.html', { w: 390, h: 844 });
|
|
307
305
|
await until(() => S.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 30000);
|
|
308
|
-
const f = await S.page.evaluate(() => ({ wide: document.documentElement.scrollWidth > innerWidth + 1, small: [...document.querySelectorAll('#ibBody .ini2, #
|
|
306
|
+
const f = await S.page.evaluate(() => ({ wide: document.documentElement.scrollWidth > innerWidth + 1, small: [...document.querySelectorAll('#ibBody .ini2, #chNeed, #newIni')].filter((b) => b.offsetParent && (b.getBoundingClientRect().width < 44 || b.getBoundingClientRect().height < 44)).map((b) => b.className + ':' + Math.round(b.getBoundingClientRect().width) + 'x' + Math.round(b.getBoundingClientRect().height)) }));
|
|
309
307
|
ok('F1 at 390 px there is no sideways scroll and every new control is at least 44 px', !f.wide && !f.small.length, JSON.stringify(f));
|
|
310
308
|
await S.page.click('#ibBody .ini2'); await until(() => S.page.evaluate(() => !!document.querySelector('#czViewer:not([hidden]) [data-iv-stagepick]'))); await S.page.click('#czViewer [data-iv-stagepick]');
|
|
311
309
|
const mi = await until(() => S.page.evaluate(() => { const L = [...document.querySelectorAll('.hi-pop .hi-mi')]; return L.length ? L.every((b) => b.getBoundingClientRect().height >= 44) && document.documentElement.scrollWidth <= innerWidth + 1 : null; }));
|
|
@@ -87,28 +87,14 @@ const BODY = '## Purpose\n\nWhy this board exists.\n\n## Scope\n\nWhat it covers
|
|
|
87
87
|
const H = await T.pressHeaders(BASE);
|
|
88
88
|
const nonce = H['x-pmos-nonce'];
|
|
89
89
|
|
|
90
|
-
/* B
|
|
91
|
-
|
|
92
|
-
const bToast = (p, re) => until(() => p.evaluate((s) => { const t = document.querySelector('#bdToast'); return t && !t.hidden && new RegExp(s).test(t.textContent) ? t.textContent : null; }, re.source));
|
|
93
|
-
const P = await open('/premium-board.html'), p = P.page;
|
|
94
|
-
await until(() => p.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 60000);
|
|
95
|
-
await p.click('#tabDocs');
|
|
96
|
-
ok('B1 a board document is a row whose title opens the reader', await until(() => p.evaluate((k) => !!document.querySelector(`#docBody [data-opendoc="core:${k}"]`), bkey), 60000));
|
|
97
|
-
ok('B1 every document row has a title that opens it', await p.evaluate(() => [...document.querySelectorAll('#docBody .bd-dt[data-docrow]')].every((r) => !!r.querySelector('[data-opendoc]'))));
|
|
98
|
-
await p.click(`#docBody [data-opendoc="core:${bkey}"]`);
|
|
99
|
-
ok('B2 the reader shows the file\'s text and path, and the bar says nothing is sent', await until(() => p.evaluate((pt) => { const o = document.querySelector('#czViewer'); return !o.hidden && /Why this board exists/.test(o.textContent) && o.textContent.includes(pt) && !!o.querySelector('[data-issue-bar="draft"]') && /Nothing is sent/.test(o.querySelector('[data-issue-bar]').textContent) && !!o.querySelector('[data-act="issue"]'); }, d1.path)), await p.evaluate(() => document.querySelector('#czViewer').textContent));
|
|
100
|
-
ok('B2 no old dialog opens', await p.evaluate(() => !!!document.querySelector('#dlg.on')));
|
|
90
|
+
/* B a board's own core document. Batch 24 item 7 took the Documents tab off the Board (Search's Documents filter reaches the file), so no Board press
|
|
91
|
+
issues it; the issue route still takes a board scope */
|
|
101
92
|
const n0 = ledger().filter((l) => l.ev === 'issue').length;
|
|
102
|
-
await
|
|
103
|
-
ok('
|
|
104
|
-
const sent = P.posts.filter((x) => x.path === '/api/core-docs/issue');
|
|
105
|
-
ok('B4 it posted the workspace, the scope, the key and the slot, and nothing else, with the launch nonce', sent.length === 1 && Object.keys(sent[0].body).sort().join() === 'key,scope,slot,ws' && sent[0].body.scope === bscope && sent[0].body.key === bkey && sent[0].body.slot === d1.slot && sent[0].body.ws === B.ws && sent[0].headers['x-pmos-nonce'] === nonce && !!nonce, JSON.stringify(sent));
|
|
93
|
+
const bres = await fetch(BASE + '/api/core-docs/issue', { method: 'POST', headers: H, body: JSON.stringify({ ws: B.ws, scope: bscope, key: bkey, slot: d1.slot }) });
|
|
94
|
+
ok('B4 the issue route takes a board scope with the launch nonce', bres.status === 200, String(bres.status));
|
|
106
95
|
const il = ledger().filter((l) => l.ev === 'issue');
|
|
107
|
-
ok('B5 the ledger has one new issue line for that draft
|
|
96
|
+
ok('B5 the ledger has one new issue line for that draft', il.length === n0 + 1 && il[il.length - 1].slot === d1.slot && il[il.length - 1].scope === bscope, JSON.stringify(il));
|
|
108
97
|
ok('B5 the file now says state: issued', /^state: issued$/m.test(fs.readFileSync(path.join(team, d1.path), 'utf8')));
|
|
109
|
-
ok('B6 the reader says it is issued, the Issue it press is gone, and the row reads Up to date', await until(() => p.evaluate(() => { const o = document.querySelector('#czViewer'); return !o.hidden && !!o.querySelector('[data-issue-bar="done"]') && !o.querySelector('[data-act="issue"]'); }), 30000) && await until(() => p.evaluate((k) => { const r = document.querySelector(`#docBody [data-docrow="core:${k}"]`); return r && /Up to date/.test(r.textContent); }, bkey), 30000), await p.evaluate(() => document.querySelector('#czViewer').textContent + ' // ' + document.querySelector('#docBody').textContent));
|
|
110
|
-
await p.screenshot({ path: path.join(R.base, 'board-issued.png') });
|
|
111
|
-
await P.ctx.close();
|
|
112
98
|
|
|
113
99
|
/* I the initiative: a layout inside Home (premium-agreed-design). The Board's expand link lands there; the card's Overview lists the core documents (its Documents view),
|
|
114
100
|
and a draft opens a minimal reader in the Viewer (Waiting for design) with the Issue it bar, Read and Edit */
|
|
@@ -163,33 +149,18 @@ const BODY = '## Purpose\n\nWhy this board exists.\n\n## Scope\n\nWhat it covers
|
|
|
163
149
|
await jp.screenshot({ path: path.join(R.base, 'ini-issued.png') });
|
|
164
150
|
|
|
165
151
|
/* P the POST allow-list and the refusals */
|
|
166
|
-
const all =
|
|
152
|
+
const all = I.posts.concat(J.posts);
|
|
167
153
|
const other = all.filter((x) => !ALLOWED.test(x.path) && x.path !== '/api/loose-props/rows'); // what Home shows loose, sent when it changed so Claude can propose a clean-up (batch 13 item 3); it changes no work
|
|
168
|
-
ok('P1 the pages post only to the issue route (and the conversation note)', all.length >=
|
|
154
|
+
ok('P1 the pages post only to the issue route (and the conversation note)', all.length >= 2 && !other.length, JSON.stringify(other));
|
|
169
155
|
const sentAll = all.filter((x) => x.path === '/api/core-docs/issue');
|
|
170
|
-
ok('P2 every issue press carried the nonce and no writer field', sentAll.length >=
|
|
156
|
+
ok('P2 every issue press carried the nonce and no writer field', sentAll.length >= 2 && sentAll.every((x) => x.headers['x-pmos-nonce'] === nonce && !['by', 'via', 'at', 'actor', 'pm', 'nonce'].some((k) => k in x.body)));
|
|
171
157
|
const bare = await fetch(BASE + '/api/core-docs/issue', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: BASE }, body: JSON.stringify({ ws: B.ws, scope: bscope, key: bkey, slot: d1.slot }) });
|
|
172
158
|
ok('P3 a press without the launch nonce is refused', bare.status === 403);
|
|
173
159
|
const agent = await fetch(BASE + '/api/core-docs/issue', { method: 'POST', headers: { ...H, 'x-pmos-session': 's1' }, body: JSON.stringify({ ws: B.ws, scope: bscope, key: bkey, slot: d1.slot }) });
|
|
174
160
|
ok('P4 D98: an agent session is not refused as an agent (the press is checked like the PM\'s)', agent.status !== 403, String(agent.status));
|
|
175
|
-
ok('P5 no script error on any page', !
|
|
161
|
+
ok('P5 no script error on any page', !I.errors.length && !J.errors.length, I.errors.concat(J.errors).join(' | '));
|
|
176
162
|
await J.ctx.close();
|
|
177
163
|
|
|
178
|
-
/* F floors and fidelity: a fresh draft, at 390 and 1280, light and dark */
|
|
179
|
-
const d4 = CD.writeDraft(team, { scope: bscope, key: (board2.model.docs.rows.filter((d) => d.act && d.act.kind === 'draft' && d.st === 'missing' && d.key !== bkey)[0] || {}).key, markdown: BODY, by: 'agent:test', now: Date.now() });
|
|
180
|
-
const k4 = d4.path.split('/').slice(-2)[0];
|
|
181
|
-
for (const [w, h, theme] of [[390, 844, 'light'], [390, 844, 'dark'], [1280, 900, 'dark']]) {
|
|
182
|
-
const S = await open('/premium-board.html', { w, h, theme });
|
|
183
|
-
await until(() => S.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 60000);
|
|
184
|
-
await S.page.click(w <= 720 ? '#chDocs' : '#tabDocs');
|
|
185
|
-
await until(() => S.page.evaluate((k) => !!document.querySelector(`#docBody [data-opendoc="core:${k}"]`), k4), 60000);
|
|
186
|
-
await S.page.click(`#docBody [data-opendoc="core:${k4}"]`);
|
|
187
|
-
await until(() => S.page.evaluate(() => !!document.querySelector('#czViewer [data-act="issue"]')));
|
|
188
|
-
const f = await S.page.evaluate(() => ({ wide: document.documentElement.scrollWidth > innerWidth + 1, small: [...document.querySelectorAll('#czViewer [data-act="issue"]')].filter((b) => b.getBoundingClientRect().height < (innerWidth <= 720 ? 44 : 34)).map((b) => b.textContent) }));
|
|
189
|
-
ok(`F1 ${w} px ${theme}: no sideways scroll and the bar's button is 44 px on a phone`, !f.wide && !f.small.length, JSON.stringify(f));
|
|
190
|
-
await S.page.screenshot({ path: path.join(R.base, `bar-${w}-${theme}.png`) });
|
|
191
|
-
await S.ctx.close();
|
|
192
|
-
}
|
|
193
164
|
if (process.env.KEEP_SHOTS) { for (const f of fs.readdirSync(R.base).filter((x) => /\.png$/.test(x))) fs.copyFileSync(path.join(R.base, f), path.join(process.env.KEEP_SHOTS, f)); }
|
|
194
165
|
finish();
|
|
195
166
|
})();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* pm/chat-turn-end.js — tells when a chat turn ended, so the page beside the chat reads its records again (batch 25 item 3: "When chat
|
|
2
|
+
* said it has made changes to the doc or content shown on the viewer, the content on the viewer should automatically update without
|
|
3
|
+
* the user needing to refresh"). home-chat.js takes a snapshot on each read of the conversation; the host re-reads what the Viewer shows
|
|
4
|
+
* when a turn ends.
|
|
5
|
+
*
|
|
6
|
+
* snapOf(key, busy, messages) { key, busy, agents }: the conversation's place, whether Claude is still at work, and how many of its
|
|
7
|
+
* messages are not the PM's.
|
|
8
|
+
* turnEnded(prev, cur) true once per finished turn: the same conversation, not at work now, and either at work on the last read
|
|
9
|
+
* or with more of Claude's messages than then (a turn that started and finished between two reads).
|
|
10
|
+
* The first read of a conversation is never a finished turn: opening a chat does not re-read the page. */
|
|
11
|
+
export function snapOf(key, busy, messages) {
|
|
12
|
+
const agents = (Array.isArray(messages) ? messages : []).filter((m) => m && m.role !== 'user').length;
|
|
13
|
+
return { key: String(key || ''), busy: !!busy, agents };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function turnEnded(prev, cur) {
|
|
17
|
+
if (!prev || !cur || !cur.key || prev.key !== cur.key || cur.busy) return false;
|
|
18
|
+
return prev.busy || cur.agents > prev.agents;
|
|
19
|
+
}
|
|
@@ -115,9 +115,22 @@ body.bd-page main.bd-main { position: relative; max-width: none; margin: 16px; p
|
|
|
115
115
|
.bd-chip em { font-style: normal; font-weight: 600; color: var(--cz-ink3); margin-left: 2px; }
|
|
116
116
|
.bd-chip:disabled { opacity: .6; }
|
|
117
117
|
.bd-sp { margin-left: auto; }
|
|
118
|
-
|
|
119
|
-
.bd-
|
|
120
|
-
.bd-
|
|
118
|
+
/* the calmer top bar (batch 24 item 7, canvas BbB): one pill for what needs a look, the legend behind (i), Jira as Adler's face */
|
|
119
|
+
.bd-chip.bd-need { background: var(--cz-well); border-color: transparent; gap: 10px; padding: 0 10px 0 12px; font-weight: 800; color: var(--cz-ink); }
|
|
120
|
+
.bd-chip.bd-need > span { display: inline-flex; align-items: center; gap: 5px; }
|
|
121
|
+
.bd-chip.bd-need.on { background: var(--cz-bluebg); color: var(--cz-blueink); }
|
|
122
|
+
.bd-needm { min-width: 220px; }
|
|
123
|
+
.bd-needm .bd-mlbl, .bd-lgdm .bd-mlbl { font: 800 var(--fs-1) var(--cz-sans); color: var(--cz-ink3); padding: 6px 10px 4px; letter-spacing: .5px; text-transform: uppercase; white-space: nowrap; }
|
|
124
|
+
.bd-needm .bd-nr { display: flex; align-items: center; gap: 10px; }
|
|
125
|
+
.bd-needm .bd-nr .k { margin-left: auto; color: var(--cz-ink3); font-weight: 700; }
|
|
126
|
+
.bd-needm .bd-nr.on { background: var(--cz-bluebg); color: var(--cz-blueink); }
|
|
127
|
+
.bd-needm .cz-dot { width: 7px; height: 7px; }
|
|
128
|
+
.bd-lgdm { min-width: 220px; padding: 6px 12px 10px; }
|
|
129
|
+
.bd-lgdm .tg-lgd { position: static; flex-direction: column; gap: 9px; padding: 4px 0 0; background: none; font-weight: 600; color: var(--cz-ink2); white-space: nowrap; }
|
|
130
|
+
.bd-chip.bd-jf { width: 36px; padding: 0; justify-content: center; border-color: transparent; background: transparent; position: relative; }
|
|
131
|
+
.bd-chip.bd-jf:hover { background: var(--cz-well); }
|
|
132
|
+
.bd-chip.bd-jf svg.ad-svg { width: 24px; height: 24px; }
|
|
133
|
+
.bd-pip { position: absolute; right: 3px; top: 3px; width: 9px; height: 9px; border-radius: 50%; background: var(--cz-risk); border: 2px solid var(--cz-card); }
|
|
121
134
|
|
|
122
135
|
/* ── the "Waiting for design" card, where the shell has none yet; messages ── */
|
|
123
136
|
.bd-wait { border: 1px dashed var(--cz-hair); border-radius: 18px; padding: 22px; color: var(--cz-ink3); font-size: var(--fs-2); }
|
|
@@ -1195,7 +1208,6 @@ html[data-theme="dark"] body.bd-page { --bd-prog: rgb(180,170,240); --bd-progbg:
|
|
|
1195
1208
|
.sc .cur .eta { top: 19px; }
|
|
1196
1209
|
.sc .eta.late { color: var(--cz-late); }
|
|
1197
1210
|
.bd-kb.lanes .bd-kstrip span { margin-top: 58px; }
|
|
1198
|
-
.bd-chipset .tg-lgd { position: static; padding: 0; background: none; font-weight: 600; white-space: nowrap; }
|
|
1199
1211
|
/* the phone (BdPhone): a program is a head card with its next milestone; its initiatives and the product heads follow as cards */
|
|
1200
1212
|
@media (max-width: 720px) {
|
|
1201
1213
|
.bt-pg { display: grid; grid-template-columns: minmax(0, 1fr); border: 0; border-radius: 14px; padding: 8px 6px 10px 4px; min-height: 0; }
|
|
@@ -1218,7 +1230,7 @@ html[data-theme="dark"] body.bd-page { --bd-prog: rgb(180,170,240); --bd-progbg:
|
|
|
1218
1230
|
.bd-spw .bd-spk svg { width: 14px; height: 14px; }
|
|
1219
1231
|
body.bd-spick .bd-hd .bd-title { display: none; }
|
|
1220
1232
|
body.bd-spick .bd-hd .bd-tools { margin-left: auto; }
|
|
1221
|
-
@media (max-width: 720px) { .bd-spw .bd-spk, .bd-spw .bd-grpm button { min-height: 44px; } }
|
|
1233
|
+
@media (max-width: 720px) { .bd-spw .bd-spk, .bd-spw .bd-grpm button, .bd-needm .bd-nr { min-height: 44px; } .bd-chip.bd-jf { width: 44px; } .bd-chip.bd-jf::before { display: none; } }
|
|
1222
1234
|
/* programs first on every span (PgTimeline's rule): the program row brackets its initiatives' bars and carries its milestones */
|
|
1223
1235
|
.sp-pg { background: var(--bd-progbg); min-height: 56px; }
|
|
1224
1236
|
.sp-pg .sp-n { flex-direction: row; align-items: center; justify-content: flex-start; }
|
|
@@ -47,8 +47,9 @@ import * as YD from './home-day.js';
|
|
|
47
47
|
import * as AD from './cozy-adler.js'; // Adler, Claude's face (batch 16 item 2)
|
|
48
48
|
import * as OKL from './okr-list.js'; // the product page's Goals line (batch 16 item 6, GsProduct)
|
|
49
49
|
import { canDictate, dictate } from './home-dictate.js';
|
|
50
|
-
import { mountIni, iniList, iniOverview, iniPage, iniAddTask } from './home-ini.js'; // the initiative's page, its stage move, Tasks and Core documents (premium-uncovered-features, HbC)
|
|
51
|
-
import * as MEN from './home-mentions.js';
|
|
50
|
+
import { mountIni, iniList, iniOverview, iniPage, iniAddTask, iniFresh } from './home-ini.js'; // the initiative's page, its stage move, Tasks and Core documents (premium-uncovered-features, HbC)
|
|
51
|
+
import * as MEN from './home-mentions.js';
|
|
52
|
+
import * as LS from './load-shapes.js'; // the last view, the grey shapes and the held Viewer column while Home reads (page-open-speed) // @Mentions, the permanent row at the top of Home and its Viewer (Viewer kit, XvANew)
|
|
52
53
|
import { mountRecRow, clockOf as recClock } from './home-rec-row.js';
|
|
53
54
|
import { mountSesName } from './home-ses-name.js'; // session names A: tap the session's name to rename it (canvas XmsNames)
|
|
54
55
|
import * as SM from './split-merge.js'; // split one initiative in two, or merge two: Claude suggests, the PM approves (batch 11 item 14)
|
|
@@ -173,13 +174,14 @@ async function getRetry(path, tries = 20) {
|
|
|
173
174
|
}
|
|
174
175
|
return { ok: false, error: 'still reading' };
|
|
175
176
|
}
|
|
177
|
+
const LAST_KEY = (WS || '') + '|' + (BOARD || ''); // the last view Home drew, by its product and board (SpA)
|
|
176
178
|
let pickGen = 0; // bumped by every saved pick, so a read that started before the save does not put the old pick back
|
|
177
179
|
async function load() {
|
|
178
180
|
const gen = pickGen;
|
|
179
181
|
const [brief, board, ses, desk, today, sch, work, day, rev, progs, , pick] = await Promise.all([
|
|
180
182
|
getRetry('/api/briefing-premium' + wsQ()), getRetry('/api/board-premium' + wsQ() + boardQ(WS ? '&' : '?')), pmGet('/api/sessions'), pmGet('/api/home-desk' + wsQ()),
|
|
181
183
|
pmGet('/api/brief-page' + wsQ()), pmGet('/api/schedules' + wsQ()), pmGet('/api/my-work-premium' + wsQ()), pmGet('/api/phone-day'), pmGet('/api/home-review' + wsQ()),
|
|
182
|
-
pmGet('/api/programs'), MEN.load(menCtx()), PP.readPick(), SO.load(),
|
|
184
|
+
pmGet('/api/programs'), MEN.load(menCtx()).then((x) => { S.menIn = true; if (!S.ready) drawList(); return x; }), PP.readPick(), SO.load(),
|
|
183
185
|
]);
|
|
184
186
|
const D = S.D;
|
|
185
187
|
D.brief = brief && brief.ok && brief.model ? brief.model : null;
|
|
@@ -345,7 +347,8 @@ function homeListHtml() {
|
|
|
345
347
|
S.lo = { sel, ses: S.ses, restOpen: (k) => S.rest.has(k), pin: S.pin, phone: false }; // the folded rail's card draws Home's rows with these
|
|
346
348
|
return TD.listHtml(M, todayModel(), {
|
|
347
349
|
sel, selHead: S.head, ses: S.ses, pOpen, here: isPhone() ? null : TD.groupOfOpen(M, sel) || S.head, restOpen: (k) => S.rest.has(k), cap: S.cap, pin: S.pin, avHtml, ready: S.ready, err: S.D.err.length ? S.D.err[0] : '',
|
|
348
|
-
pick: S.D.pick, tones: pickTones(), review: R, mic: canDictate(), phone: isPhone(), day: S.day, news: S.news && S.news.ok ? S.news.count : 0, top: S.ready ? RECROW.html() + MEN.rowHtml(S.D, { sel: S.sel }) + (S.impact ? IMP.rowHtml(S.impact.c, { esc, sel: S.sel === 'impact' }) : '') : '',
|
|
350
|
+
pick: S.D.pick, tones: pickTones(), review: R, mic: canDictate(), phone: isPhone(), day: S.day, news: S.news && S.news.ok ? S.news.count : 0, top: S.ready ? RECROW.html() + MEN.rowHtml(S.D, { sel: S.sel }) + (S.impact ? IMP.rowHtml(S.impact.c, { esc, sel: S.sel === 'impact' }) : '') : S.menIn ? '<div class="ls-stale" inert>' + LS.inert(MEN.rowHtml(S.D, {})) + '</div>' : LS.menShape(), // Mentions on its own clock, seen but not pressed until Home is fresh (SpG)
|
|
351
|
+
last: S.ready ? '' : LS.last(LAST_KEY), keep: S.ready && S.filter === 'today' && !S.ini ? (b) => LS.keepLast(LAST_KEY, b) : null,
|
|
349
352
|
imFace: IM.faceHtml(S.im, { joy: S.imJoy }), grill: S.ready ? grillNews() : null,
|
|
350
353
|
});
|
|
351
354
|
}
|
|
@@ -753,7 +756,15 @@ const chat = mountChat($('#hmChat'), {
|
|
|
753
756
|
desk: () => !isPhone(),
|
|
754
757
|
reopenViewer: () => reopenViewer(),
|
|
755
758
|
head: (t) => (isPhone() ? '' : TD.chatHeadHtml(chatHead(t))), // the phone names it in its top bar (#hmTop)
|
|
759
|
+
// a chat turn finished: read the records again, so the list and the Viewer show what Claude changed with no refresh (batch 25 item 3).
|
|
760
|
+
// load() keeps the open folds (ini-view.js FOLD) and the Viewer's scroll (home-ini.js redrawView); the initiative's layout is read again too
|
|
761
|
+
turnEnded: () => turnEnded(),
|
|
756
762
|
});
|
|
763
|
+
let turnT = null;
|
|
764
|
+
function turnEnded() {
|
|
765
|
+
clearTimeout(turnT);
|
|
766
|
+
turnT = setTimeout(async () => { await load(); iniFresh(); }, 400); // two chats that finish together read once
|
|
767
|
+
}
|
|
757
768
|
/* claude-onboarding-build: set up a project as a conversation in this chat's place (cozy-onboard.js). It opens on ?onboard=1, shows the
|
|
758
769
|
* finished project's next steps on ?onboarded=1, and opens by itself on a first run: setup not finished and no registered folder holds an
|
|
759
770
|
* initiative. A named workspace does not stop it: the installer opens its own empty ~/PMOS folder by name (bug 2026-11-02, Windows). */
|
|
@@ -1312,7 +1323,10 @@ addEventListener('pp:changed', (e) => { if (WS) { location.assign('/premium.html
|
|
|
1312
1323
|
|
|
1313
1324
|
/* ── the Viewer: needs rows, the weekly update, the overview, files, people, routines ── */
|
|
1314
1325
|
let lastViewer = null;
|
|
1315
|
-
VW.onChange((d) => { if (d.open) lastViewer = d.id; chat.refresh(); document.body.classList.toggle('hm-vw-open', d.open); });
|
|
1326
|
+
VW.onChange((d) => { if (d.open) { lastViewer = d.id; LS.holdViewer($('.hm-vw'), false); } chat.refresh(); document.body.classList.toggle('hm-vw-open', d.open); });
|
|
1327
|
+
// the Viewer's column held from the first frame when Home opened it last time (SpJ): the chat has its final width at once
|
|
1328
|
+
const VW_HELD = 'pmos.home.vwheld';
|
|
1329
|
+
if (!isPhone() && store.get(VW_HELD) === '1' && !VW.isOpen()) LS.holdViewer($('.hm-vw'), true);
|
|
1316
1330
|
function reopenViewer() {
|
|
1317
1331
|
if (/^vw:/.test(lastViewer || '')) { import('./premium-viewer.js').then((M) => { if (!M.vwReopen()) { lastViewer = null; reopenViewer(); } }); return; } // the last file, chart or report shown
|
|
1318
1332
|
const r = S.sel ? rowById(S.sel) : null;
|
|
@@ -1991,6 +2005,8 @@ load().then(() => {
|
|
|
1991
2005
|
if (say) setTimeout(() => { const ta = $('#hmIn'); if (ta) { ta.value = say; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); ta.setSelectionRange(say.length, say.length); } }, 0);
|
|
1992
2006
|
if (mt) openMeeting({ note: mt });
|
|
1993
2007
|
if (/^(prop|men|pulse|scan|rep|src)$/.test(P.get('around') || '')) MORE.openAround(P.get('around')); // ?around=<row>: that Around you view (home-around.js)
|
|
2008
|
+
// the Viewer opens now or not at all: the held column goes either way, and the next open holds it only if it opened (SpJ)
|
|
2009
|
+
setTimeout(() => { if (!isPhone()) store.set(VW_HELD, VW.isOpen() ? '1' : null); LS.holdViewer($('.hm-vw'), false); }, 0);
|
|
1994
2010
|
poll();
|
|
1995
2011
|
});
|
|
1996
2012
|
addEventListener('resize', () => { if (!isPhone() && S.sheet) sheet(false); drawList(); });
|
|
@@ -33,6 +33,7 @@ import { splitFiles, filesHtml, sizeText, MAX_BYTES, MAX_FILES, setAttachSink }
|
|
|
33
33
|
import * as VWC from './cozy-viewer.js';
|
|
34
34
|
import { canDictate, dictate } from './home-dictate.js';
|
|
35
35
|
import { adler } from './cozy-adler.js';
|
|
36
|
+
import { snapOf, turnEnded } from './chat-turn-end.js'; // a finished turn re-reads what the Viewer shows (batch 25 item 3)
|
|
36
37
|
/* The views a chat opens go in the Viewer: on a page with no dialog the old viewer's views draw in the cozy card (premium-viewer.js). */
|
|
37
38
|
const views = {
|
|
38
39
|
ctx: () => import('./premium-vw-ctx.js'), file: () => import('./premium-vw-file.js'), attach: () => import('./premium-attach.js'),
|
|
@@ -92,7 +93,11 @@ export function mountChat(el, host) {
|
|
|
92
93
|
C.th = th && th.ok ? th : null; C.conv = conv && conv.ok ? conv : null;
|
|
93
94
|
if (host.touch && sid() && C.touched !== sid()) { C.touched = sid(); host.touch(C.touched); } // the Live pill: the PM opened it now
|
|
94
95
|
C.sess = host.session ? host.session(t.sid || (C.th && C.th.session)) : null;
|
|
96
|
+
// a turn that finished: Claude may have changed what the Viewer shows (tasks, documents), so the host reads it again (batch 25 item 3)
|
|
97
|
+
const snap = snapOf(t.key || t.sid, running() || !!(C.th && C.th.working), C.conv && C.conv.messages);
|
|
98
|
+
const ended = turnEnded(C.turn, snap); C.turn = snap;
|
|
95
99
|
draw();
|
|
100
|
+
if (ended && host.turnEnded) host.turnEnded(t);
|
|
96
101
|
clearTimeout(C.timer);
|
|
97
102
|
const live = (C.th && (C.th.session || C.th.working)) || (t.sid && C.conv && C.conv.state !== 'ended');
|
|
98
103
|
C.timer = setTimeout(() => { if (!document.hidden) read(); else C.timer = setTimeout(read, IDLE_POLL); }, live ? POLL : IDLE_POLL);
|
|
@@ -201,6 +201,13 @@ function ask(anchor, label, value, go, o = {}) { // one line of words, inline
|
|
|
201
201
|
i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const v = i.value.trim(); if (v) { closePop(); go(v); } } });
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/** The open initiative's records changed elsewhere (a chat turn finished, batch 25 item 3): read its layout again, as a press does.
|
|
205
|
+
* The page redraws in place; the folds and the scroll stay. */
|
|
206
|
+
export function iniFresh() {
|
|
207
|
+
if (!X.ctx) return;
|
|
208
|
+
const id = X.ctx.S.ini, M = curM();
|
|
209
|
+
if (M) readL(id, M, true);
|
|
210
|
+
}
|
|
204
211
|
export function iniList(sc) {
|
|
205
212
|
if (!X.ctx || !sc) return;
|
|
206
213
|
sc.querySelectorAll('[data-hi-slot]').forEach((n) => n.remove());
|
|
@@ -18,7 +18,8 @@ import { gripHtml, sugHtml } from './prog-order.js'; // a program's order: the
|
|
|
18
18
|
import * as SO from './sort-order.js'; // the sort button and ONE order with the Board (batch 14 item 8, canvas SoAHomePhone)
|
|
19
19
|
import { prodKey } from './order-model.js';
|
|
20
20
|
import * as CW from './ctx-wedge.js'; // how full a session's context is: the pie, and Claude's ✦ Compact (batch 13 item 8, XctxA1Desk)
|
|
21
|
-
import * as AD from './cozy-adler.js';
|
|
21
|
+
import * as AD from './cozy-adler.js';
|
|
22
|
+
import * as LS from './load-shapes.js'; // the ring, the grey shapes and the last view while Home reads (page-open-speed, SpA SpG SpM) // Adler greets on Home (batch 16 item 2, canvas AdH1): the M face, honest when the read failed
|
|
22
23
|
|
|
23
24
|
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
24
25
|
export const P = {
|
|
@@ -321,8 +322,11 @@ export function listHtml(M, T, o) {
|
|
|
321
322
|
// the blue dot after "Good morning": something is new for you, in Your day's New for you fold (feedback batch 16 item 5b, FmA4Desk)
|
|
322
323
|
const nw = o.news > 0 ? '. ' + o.news + ' new for you' : '';
|
|
323
324
|
// Your impact (batch 16 item 7, GmA1Home): with it on, the greeting's face is its own press, Adler with the week's leaves (pm/impact.js faceHtml)
|
|
324
|
-
|
|
325
|
-
|
|
325
|
+
// Adler beside the greeting from the first frame (SpM): thinking while Home reads, handled when it is in, honest if the read failed.
|
|
326
|
+
// While Home reads, a hollow ring follows the greeting (SpA); a fresh page shows no sign of having been checked
|
|
327
|
+
const own = o.imFace && !o.err && o.ready;
|
|
328
|
+
const face = own ? '' : AD.adler(o.err ? 'honest' : o.ready ? 'handled' : 'thinking', 36, { label: false, cls: 'hv-ad' });
|
|
329
|
+
const head = `<div class="hm-hd hv-bar">${own ? o.imFace : ''}<button type="button" class="hv-hi" ${o.ready ? 'data-hm-day' : 'aria-disabled="true"'} aria-expanded="${!!o.day}" aria-label="${esc(M.word || 'Today')}${nw}. Open your day">${face}<b>${esc(M.word || 'Today')}</b>${o.ready ? '' : LS.ringHtml()}${o.news > 0 ? '<i class="hv-nd" aria-hidden="true"></i>' : ''}${svg(P.chevD, 18)}</button><span class="hv-acts">`
|
|
326
330
|
// the product chip, always (it is also the way to add one): the ticked products' tiles, or All (canvas XhpA2States)
|
|
327
331
|
+ (o.pick ? chipHtml(o.pick, { id: 'hmPick', attrs: 'data-hm-pick', label: 'Products on Home', tones: o.tones }) : '')
|
|
328
332
|
+ (o.phone ? '' : `<button type="button" class="ut-ib hv-ib${flt ? ' on' : ''}" data-hm-flt aria-haspopup="menu" aria-label="Show: Today, Kept or Done${flt ? '. Now ' + esc(flt.label) : ''}" title="Show: Today, Kept or Done">${svg(P.filter, 18)}</button>`)
|
|
@@ -336,7 +340,9 @@ export function listHtml(M, T, o) {
|
|
|
336
340
|
// the Today card only while the day has a moment; the plain day (its meetings and load) is in Your day
|
|
337
341
|
const card = MOMENT.has(T.mode) ? todayHtml(T, { greet: M.greet, sub: '', phone: o.phone }) : '';
|
|
338
342
|
// start-up and errors (canvas AdU9, AdU6): Adler wears his glasses while Home reads, and says a failure plainly; the greeting's face hides
|
|
339
|
-
|
|
343
|
+
// start-up (SpA, SpG): the list Home drew last, drawn at once and inert until the fresh read is in; with none kept, grey rows that claim
|
|
344
|
+
// nothing. o.top is Mentions (or its grey shape) on its own clock. Errors (canvas AdU6): Adler says a failure plainly
|
|
345
|
+
if (!o.ready) return head + card + `<p class="vh" role="status">Reading your work.</p><div class="hv-body">${o.top || ''}${o.last ? `<div class="ls-stale" inert>${o.last}</div>` : LS.listShapes()}</div>`;
|
|
340
346
|
const errLine = `<div class="hm-none hm-ad" role="status">${AD.adler('honest', 48, { label: false })}<p>Home could not read the repository: ${esc(o.err)}.</p></div>`;
|
|
341
347
|
if (o.err && !M.groups.some((g) => (g.kind === 'product' && g.count) || (g.rows && g.rows.length))) return head + card + errLine;
|
|
342
348
|
// the repository's read failed but sessions or notes still answered: say so above what did come back, never an empty product as if calm
|
|
@@ -353,6 +359,7 @@ export function listHtml(M, T, o) {
|
|
|
353
359
|
if (o.cap) body += `<form class="hm-cap" id="hmCapF"><label class="vh" for="hmCap">Add a task to your own list</label><input id="hmCap" autocomplete="off" maxlength="250" placeholder="A task for your own list. Esc to cancel">`
|
|
354
360
|
+ (o.mic ? `<button type="button" class="ut-ib" data-hm-capmic aria-label="Say it instead" title="Say it instead">${svg(P.mic, 16)}</button>` : '') + '</form>';
|
|
355
361
|
}
|
|
362
|
+
if (o.keep && !o.err && !M.list && !o.cap) o.keep(body); // the last view, for the next open (SpA)
|
|
356
363
|
return head + card + `<div class="hv-body">${o.top || ''}${body}</div>` + (o.phone && o.ses && !M.list ? sesSheetHtml(M, o) : ''); // o.top: the pinned recording row and Mentions (home-rec-row.js, home-mentions.js)
|
|
357
364
|
}
|
|
358
365
|
/** Your day (HvAOpen): a sheet over the list from "Good morning". The date, the day's meetings and their load (a press opens the calendar,
|