create-pmos 0.176.0 → 0.178.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/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-briefing-test.js +1 -0
- package/workspace/c3-phone-test.js +143 -0
- package/workspace/c3-quiet-test.js +2 -1
- package/workspace/c3-reports-test.js +1 -0
- package/workspace/c3-sessions-panel-test.js +12 -3
- package/workspace/c3-work-test.js +1 -0
- package/workspace/guide/console3-sessions.md +2 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/phone-companion.md +47 -0
- package/workspace/lib/c3-full-console.js +15 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-phone.js +14 -0
- package/workspace/public/pm/c3-phone-model.js +55 -0
- package/workspace/public/pm/c3-phone.css +49 -0
- package/workspace/public/pm/c3-phone.js +269 -0
- package/workspace/public/pm/c3-session-term.js +15 -1
- package/workspace/public/pm/c3-shell.js +19 -2
- package/workspace/test-files.json +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.178.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-13T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T01:59:58Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-13T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T01:59:58Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-13T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T01:59:58Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -43,6 +43,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
|
43
43
|
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
|
|
44
44
|
await T.waitForServer(BASE);
|
|
45
45
|
browser = await chromium.launch({ executablePath: exe });
|
|
46
|
+
require('./lib/c3-full-console').fullConsole(browser); // the desktop layout at phone width (c3-phone-companion)
|
|
46
47
|
|
|
47
48
|
const open = async (ctxOpts) => {
|
|
48
49
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ...(ctxOpts || {}) });
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* c3-phone-test.js — the phone companion (initiative c3-phone-companion; PRD done-criteria 1 to 6 and 8).
|
|
4
|
+
* A real server and a real Chromium drive /c3.html in demo mode (clock frozen at Thu 8 Oct 2026 08:12; ?at=HH:MM moves the companion's clock).
|
|
5
|
+
*
|
|
6
|
+
* phone at 400 wide the companion shows and the desktop grid does not
|
|
7
|
+
* moments 08:12 morning; 10:10 before Checkout sync; 10:45 in the meeting with the Record offer
|
|
8
|
+
* done Done on the waiting item records a write to /api/pm-items/done
|
|
9
|
+
* tell text sent from the ask bar records a capture and clears the bar
|
|
10
|
+
* record the red button shows the recording line; Stop brings the bar back
|
|
11
|
+
* debrief 11:25 shows the decisions and actions of the recorded Checkout sync; a meeting with no recording asks you to tell PMOS
|
|
12
|
+
* desk Full console shows the desktop grid and survives a reload; More > Phone companion brings it back
|
|
13
|
+
* desktop at 1280 wide no companion is drawn
|
|
14
|
+
* No script error may be raised.
|
|
15
|
+
*/
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { spawn } = require('child_process');
|
|
18
|
+
const { findBrowser } = require('./lib/find-browser');
|
|
19
|
+
const T = require('./test-run').configure({ name: 'c3-phone' });
|
|
20
|
+
|
|
21
|
+
const BASE = T.BASE;
|
|
22
|
+
const results = [];
|
|
23
|
+
const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 400) : ''}`); };
|
|
24
|
+
let srv = null; let browser = null;
|
|
25
|
+
const finish = async (code) => {
|
|
26
|
+
if (browser) await browser.close().catch(() => {});
|
|
27
|
+
if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
|
|
28
|
+
T.killTmux();
|
|
29
|
+
const bad = results.filter((x) => !x.cond).length;
|
|
30
|
+
console.log(`\n${results.length - bad}/${results.length} c3-phone assertions passed`);
|
|
31
|
+
process.exit(code !== undefined ? code : (bad ? 1 : 0));
|
|
32
|
+
};
|
|
33
|
+
process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
34
|
+
|
|
35
|
+
async function waitFor(page, fn, arg, ms = 30000) {
|
|
36
|
+
try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
const shown = (s) => { const n = document.querySelector(s); return !!n && getComputedStyle(n).display !== 'none' && n.getBoundingClientRect().height > 0; };
|
|
39
|
+
const writes = (p) => (window.__c3demoWrites || []).filter((w) => w.path === p || w.url === p || String(w.path || w.url || '').startsWith(p)).length;
|
|
40
|
+
|
|
41
|
+
(async () => {
|
|
42
|
+
const exe = findBrowser();
|
|
43
|
+
if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the c3-phone suite cannot run'); return finish(1); }
|
|
44
|
+
let chromium;
|
|
45
|
+
try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the c3-phone suite cannot run'); return finish(1); }
|
|
46
|
+
|
|
47
|
+
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
|
|
48
|
+
await T.waitForServer(BASE);
|
|
49
|
+
browser = await chromium.launch({ executablePath: exe });
|
|
50
|
+
const errors = [];
|
|
51
|
+
const ctxOf = async (w) => browser.newContext({ viewport: { width: w, height: 860 } });
|
|
52
|
+
const open = async (ctx, url, ready) => {
|
|
53
|
+
const page = await ctx.newPage();
|
|
54
|
+
page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
|
|
55
|
+
await page.goto(BASE + url, { waitUntil: 'load' });
|
|
56
|
+
await waitFor(page, ready || (() => !!document.querySelector('[data-c3ph-moment]')));
|
|
57
|
+
return page;
|
|
58
|
+
};
|
|
59
|
+
const moment = (page) => page.evaluate(() => document.querySelector('[data-c3ph]')?.dataset.moment || '');
|
|
60
|
+
|
|
61
|
+
/* ── phone, morning ── */
|
|
62
|
+
const pctx = await ctxOf(400);
|
|
63
|
+
let page = await open(pctx, '/c3.html?demo=1');
|
|
64
|
+
ok('phone: the companion shows at 400 wide', await page.evaluate(shown, '[data-c3ph]'));
|
|
65
|
+
ok('phone: the desktop grid is hidden', !(await page.evaluate(shown, '.c3grid')));
|
|
66
|
+
ok('phone: no sideways scroll', await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
|
67
|
+
ok('moments: 08:12 is morning', (await moment(page)) === 'morning', await moment(page));
|
|
68
|
+
ok('phone: the ask bar and the red Record button are in the bottom bar', await page.evaluate(() => !!document.querySelector('[data-c3ph-foot] [data-c3ph-input]') && !!document.querySelector('[data-c3ph-foot] [data-c3ph-record]')));
|
|
69
|
+
|
|
70
|
+
/* ── done on the waiting item ── */
|
|
71
|
+
const hasWaiting = await waitFor(page, () => !!document.querySelector('[data-c3ph-waiting] [data-c3ph-done]'), null, 10000);
|
|
72
|
+
ok('done: a waiting card with Done shows', hasWaiting);
|
|
73
|
+
if (hasWaiting) {
|
|
74
|
+
const before = await page.evaluate(writes, '/api/pm-items/done');
|
|
75
|
+
await page.click('[data-c3ph-waiting] [data-c3ph-done]');
|
|
76
|
+
ok('done: Done records a write to /api/pm-items/done', await waitFor(page, (n) => ((window.__c3demoWrites || []).filter((w) => String(w.path || w.url || '').startsWith('/api/pm-items/done')).length) > n, before, 5000));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ── tell PMOS ── */
|
|
80
|
+
await page.fill('[data-c3ph-input]', 'Ask Maya about the beta date');
|
|
81
|
+
await page.press('[data-c3ph-input]', 'Enter');
|
|
82
|
+
ok('tell: the text is filed as a capture', await waitFor(page, () => (window.__c3demoWrites || []).some((w) => w.path === '/api/capture' && w.body && /beta date/.test(w.body.text)), null, 5000));
|
|
83
|
+
ok('tell: the bar is cleared', (await page.inputValue('[data-c3ph-input]')) === '');
|
|
84
|
+
|
|
85
|
+
/* ── record ── */
|
|
86
|
+
await page.click('[data-c3ph-record]');
|
|
87
|
+
ok('record: the recording line shows with Stop', await waitFor(page, () => !!document.querySelector('[data-c3ph-recbar] [data-c3ph-stop]'), null, 8000));
|
|
88
|
+
ok('record: the ask bar gives way while recording', !(await page.evaluate(() => !!document.querySelector('[data-c3ph-foot] [data-c3ph-input]'))));
|
|
89
|
+
if (await page.$('[data-c3ph-stop]')) await page.click('[data-c3ph-stop]');
|
|
90
|
+
ok('record: Stop brings the bar back', await waitFor(page, () => !!document.querySelector('[data-c3ph-foot] [data-c3ph-input]') && !document.querySelector('[data-c3ph-recbar]'), null, 8000));
|
|
91
|
+
await page.close();
|
|
92
|
+
|
|
93
|
+
/* ── moments by the clock ── */
|
|
94
|
+
page = await open(pctx, '/c3.html?demo=1&at=10:10');
|
|
95
|
+
ok('moments: 10:10 is before Checkout sync', (await moment(page)) === 'before' && /Checkout sync/.test(await page.textContent('[data-c3ph-moment]')), await moment(page));
|
|
96
|
+
await page.close();
|
|
97
|
+
page = await open(pctx, '/c3.html?demo=1&at=10:45');
|
|
98
|
+
ok('moments: 10:45 is in the meeting', (await moment(page)) === 'meeting', await moment(page));
|
|
99
|
+
ok('moments: the meeting card offers to record it', await page.evaluate(() => !!document.querySelector('[data-c3ph-moment] [data-c3ph-rec-meeting]') && !!document.querySelector('[data-c3ph-moment] [data-c3ph-norec]')));
|
|
100
|
+
ok('moments: button icons in the moment card stay small', await page.evaluate(() => [...document.querySelectorAll('[data-c3ph-moment] .pmc-btn svg')].every((s) => s.getBoundingClientRect().width <= 20)));
|
|
101
|
+
await page.click('[data-c3ph-rec-meeting]');
|
|
102
|
+
ok('moments: Record starts recording Checkout sync', await waitFor(page, () => /Checkout sync/.test(document.querySelector('[data-c3ph-recbar]')?.textContent || ''), null, 8000));
|
|
103
|
+
ok('moments: while recording, the card opens the meeting companion', await waitFor(page, () => !!document.querySelector('[data-c3ph-moment] [data-c3ph-focus]'), null, 5000));
|
|
104
|
+
if (await page.$('[data-c3ph-stop]')) await page.click('[data-c3ph-stop]');
|
|
105
|
+
|
|
106
|
+
await page.close();
|
|
107
|
+
|
|
108
|
+
/* ── just ended: the debrief from the recording's notes, as on the desktop ── */
|
|
109
|
+
page = await open(pctx, '/c3.html?demo=1&at=11:25');
|
|
110
|
+
ok('debrief: 11:25 is just after Checkout sync', (await moment(page)) === 'after', await moment(page));
|
|
111
|
+
ok('debrief: the card shows the decisions and actions from the recording', await waitFor(page, () => { const c = document.querySelector('[data-c3ph-moment="after"]'); return c && c.dataset.c3phDebriefState === 'ready' && !!c.querySelector('[data-c3ph-debrief="decisions"]') && !!c.querySelector('[data-c3ph-debrief="actions"]') && /31 Oct/.test(c.textContent); }, null, 8000));
|
|
112
|
+
await page.close();
|
|
113
|
+
page = await open(pctx, '/c3.html?demo=1&at=17:10');
|
|
114
|
+
ok('debrief: a meeting with no recording asks you to tell PMOS', await waitFor(page, () => { const c = document.querySelector('[data-c3ph-moment="after"]'); return c && c.dataset.c3phDebriefState === 'none' && !!c.querySelector('[data-c3ph-tellafter]'); }, null, 8000));
|
|
115
|
+
await page.close();
|
|
116
|
+
page = await open(pctx, '/c3.html?demo=1&at=10:45');
|
|
117
|
+
|
|
118
|
+
/* ── full console and back ── */
|
|
119
|
+
await page.click('[data-c3ph-desk]');
|
|
120
|
+
ok('desk: Full console shows the desktop grid', await waitFor(page, () => { const g = document.querySelector('.c3grid'); return !!g && getComputedStyle(g).display !== 'none' && !document.querySelector('[data-c3ph]'); }, null, 5000));
|
|
121
|
+
await page.reload({ waitUntil: 'load' });
|
|
122
|
+
await waitFor(page, () => !!document.querySelector('nav.c3tabs [role="tab"]'));
|
|
123
|
+
ok('desk: the choice survives a reload', await page.evaluate(() => !document.querySelector('[data-c3ph]')));
|
|
124
|
+
const more = await page.$('[data-c3pg="more"]');
|
|
125
|
+
if (more) {
|
|
126
|
+
await more.click();
|
|
127
|
+
const item = await page.$('[data-a="phcomp"]');
|
|
128
|
+
ok('desk: More offers Phone companion', !!item);
|
|
129
|
+
if (item) { await item.click(); ok('desk: Phone companion brings the companion back', await waitFor(page, () => !!document.querySelector('[data-c3ph-moment]'), null, 5000)); }
|
|
130
|
+
} else ok('desk: the More button is found', false);
|
|
131
|
+
await page.close();
|
|
132
|
+
await pctx.close();
|
|
133
|
+
|
|
134
|
+
/* ── desktop ── */
|
|
135
|
+
const dctx = await ctxOf(1280);
|
|
136
|
+
page = await open(dctx, '/c3.html?demo=1', () => !!document.querySelector('nav.c3tabs [role="tab"]'));
|
|
137
|
+
ok('desktop: no companion at 1280 wide', await page.evaluate(() => !document.querySelector('[data-c3ph]') && !document.querySelector('.c3.c3-phone-on')));
|
|
138
|
+
ok('desktop: the grid shows', await page.evaluate(shown, '.c3grid'));
|
|
139
|
+
await dctx.close();
|
|
140
|
+
|
|
141
|
+
ok('no script error', errors.length === 0, errors.join(' | '));
|
|
142
|
+
finish();
|
|
143
|
+
})();
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* more Backlog, Releases, Docs and Sprints show only after More; each still opens
|
|
10
10
|
* calendar one Layers button shows and hides the layer chips
|
|
11
11
|
* reports no deck is drawn
|
|
12
|
-
* phone at 400 wide nothing changes: the title shows and no dot is drawn (
|
|
12
|
+
* phone at 400 wide with Full console chosen, nothing changes: the title shows and no dot is drawn (c3-phone-test.js tests the companion)
|
|
13
13
|
* No script error may be raised.
|
|
14
14
|
*/
|
|
15
15
|
const path = require('path');
|
|
@@ -45,6 +45,7 @@ const shown = (s) => { const n = document.querySelector(s); return !!n && getCom
|
|
|
45
45
|
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
|
|
46
46
|
await T.waitForServer(BASE);
|
|
47
47
|
browser = await chromium.launch({ executablePath: exe });
|
|
48
|
+
require('./lib/c3-full-console').fullConsole(browser); // the desktop layout at phone width (c3-phone-companion)
|
|
48
49
|
const errors = [];
|
|
49
50
|
const open = async (w, url) => {
|
|
50
51
|
const ctx = await browser.newContext({ viewport: { width: w, height: 860 } });
|
|
@@ -91,6 +91,7 @@ const DEF_P = ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'm
|
|
|
91
91
|
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
|
|
92
92
|
await T.waitForServer(BASE);
|
|
93
93
|
browser = await chromium.launch({ executablePath: exe });
|
|
94
|
+
require('./lib/c3-full-console').fullConsole(browser); // the desktop layout at phone width (c3-phone-companion)
|
|
94
95
|
|
|
95
96
|
const errors = [];
|
|
96
97
|
const watch = (page) => {
|
|
@@ -30,6 +30,12 @@ const finish = async (code) => {
|
|
|
30
30
|
process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
31
31
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
32
|
async function until(fn, ms = 8000) { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(150); } return null; }
|
|
33
|
+
/* the Sessions panel: the Sessions button opens a running last session's terminal, whose "All sessions" opens the panel */
|
|
34
|
+
async function openPanel(page) {
|
|
35
|
+
await page.click('[data-c3pg="agent-button"]');
|
|
36
|
+
const got = await page.waitForSelector('[data-c3t="host"], [data-c3-all-sessions]', { timeout: 15000 }).catch(() => null);
|
|
37
|
+
if (got && await page.$('[data-c3-all-sessions]')) await page.click('[data-c3-all-sessions]');
|
|
38
|
+
}
|
|
33
39
|
async function waitFor(page, fn, arg, ms = 15000) { try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; } }
|
|
34
40
|
const get = (p) => fetch(BASE + p).then((r) => r.json());
|
|
35
41
|
// the agent CLI's home for this run: the conversation test writes a session record and a transcript here, as claude would
|
|
@@ -175,8 +181,11 @@ function startShell(name) {
|
|
|
175
181
|
await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
|
|
176
182
|
|
|
177
183
|
/* ── D: End asks first, then ends ── */
|
|
184
|
+
// Sessions opens the terminal of the session opened last while it runs (PM, 2026-10-13); its "All sessions" opens the panel
|
|
178
185
|
await page.click('[data-c3pg="agent-button"]');
|
|
179
|
-
ok('D0 the
|
|
186
|
+
ok('D0 Sessions opens the terminal of the session opened last', await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id));
|
|
187
|
+
await page.click('[data-c3-all-sessions]');
|
|
188
|
+
ok('D0a All sessions in the terminal sheet opens the panel', await waitFor(page, (s) => !!document.querySelector(s), sel) && !(await page.$('[data-c3-session-term]')));
|
|
180
189
|
ok('D0b it opens on the session opened last: its row is open and marked', await waitFor(page, (i) => { const li = document.querySelector(`[data-c3t="mine"] [data-run="${i}"]`); const end = document.querySelector(`[data-end-session="${i}"]`); return !!li && li.hasAttribute('data-last-open') && !!end && end.offsetParent !== null; }, id)
|
|
181
190
|
&& await page.evaluate((i) => localStorage.getItem('pmos.workspace.c3.last-session') === i, id));
|
|
182
191
|
if (!(await page.isVisible(`[data-end-session="${id}"]`).catch(() => false))) await page.click(sel + ' .nb');
|
|
@@ -232,7 +241,7 @@ function startShell(name) {
|
|
|
232
241
|
|
|
233
242
|
/* ── F: New session starts on console3 and opens its terminal here (c3-new-session; it went to the classic app before) ── */
|
|
234
243
|
const before = new Set(((await get('/api/sessions')).sessions || []).map((s) => s.id));
|
|
235
|
-
if (!(await page.isVisible('[data-c3t="new"]').catch(() => false))) await page
|
|
244
|
+
if (!(await page.isVisible('[data-c3t="new"]').catch(() => false))) await openPanel(page);
|
|
236
245
|
ok('F1 Started by me has a New button', await waitFor(page, () => !!document.querySelector('[data-c3t="new"]')));
|
|
237
246
|
await page.click('[data-c3t="new"]');
|
|
238
247
|
ok('F2 New opens the new-session sheet on this page', await waitFor(page, () => !!document.querySelector('[data-c3ns="form"]')) && page.url() === url0, page.url());
|
|
@@ -261,7 +270,7 @@ function startShell(name) {
|
|
|
261
270
|
|
|
262
271
|
/* ── P: a past session can be removed, also one the server knows only from its record on disk ── */
|
|
263
272
|
if (await page.isVisible('[data-shell="sheet"] [aria-label="Close"]').catch(() => false)) await page.click('[data-shell="sheet"] [aria-label="Close"]');
|
|
264
|
-
if (!(await page.isVisible('[data-c3t="past"]').catch(() => false))) await page
|
|
273
|
+
if (!(await page.isVisible('[data-c3t="past"]').catch(() => false))) await openPanel(page);
|
|
265
274
|
const pastSel = '[data-c3t="past"] [data-run="sess-9901"]';
|
|
266
275
|
ok('P1 the ended session is listed in Past', await waitFor(page, (s) => !!document.querySelector(s), pastSel));
|
|
267
276
|
if (!(await page.isVisible('[data-end-session="sess-9901"]').catch(() => false))) await page.click(pastSel + ' .nb');
|
|
@@ -60,6 +60,7 @@ const W = '[data-pm-work]';
|
|
|
60
60
|
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
|
|
61
61
|
await T.waitForServer(BASE);
|
|
62
62
|
browser = await chromium.launch({ executablePath: exe });
|
|
63
|
+
require('./lib/c3-full-console').fullConsole(browser); // the desktop layout at phone width (c3-phone-companion)
|
|
63
64
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
|
64
65
|
const page = await ctx.newPage();
|
|
65
66
|
const errors = [];
|
|
@@ -9,6 +9,8 @@ timestamp: 2026-10-13
|
|
|
9
9
|
|
|
10
10
|
Press the **Sessions** button in the console3 header to open the panel. It is the bolt icon with no label. Point at it to see its name. When a session needs you, a count shows on it. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
|
|
11
11
|
|
|
12
|
+
When the session that you opened last is still running, the **Sessions** button opens its terminal directly. To see all your sessions, press **All sessions** at the top of the terminal sheet. If that session has ended, the button opens the panel.
|
|
13
|
+
|
|
12
14
|
The panel opens on the session that you opened last. Its row is open, so you can read the conversation and send a message without searching for it. A session counts as opened when you open its row or its terminal.
|
|
13
15
|
|
|
14
16
|
## The dock
|
package/workspace/guide/index.md
CHANGED
|
@@ -48,6 +48,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
48
48
|
- [People](people.md): the people you work with in a repository, how they get there, and `pmos people`.
|
|
49
49
|
- [Docs](docs.md): whether each linked document is the same as, ahead of or behind its Google Docs and Confluence copy.
|
|
50
50
|
- [Calendar popup](calendar-popup.md): open the full calendar over any page of the console with the calendar button or the C key, and close it.
|
|
51
|
+
- [The phone companion](phone-companion.md): what the console shows at phone width: one moment of your day, your items one at a time, Tell PMOS and Record.
|
|
51
52
|
- [Layout](layout.md): the frame, the keys and the rule for where a list or a detail opens.
|
|
52
53
|
- [The Calendar tab of the console page](console-calendar.md): the zooms, the layer chips, the markers, the context of a meeting and free time.
|
|
53
54
|
- [Statuses](statuses.md): what each status means, where it comes from, and what you can change.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "Guide: the phone companion"
|
|
4
|
+
description: "At phone width the console shows a companion: one moment of your day, your items one at a time, Tell PMOS and Record."
|
|
5
|
+
timestamp: 2026-10-13
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Guide: the phone companion
|
|
9
|
+
|
|
10
|
+
When the console (`/`) is 600 pixels wide or less, for example on a phone, it shows the companion and not the desktop pages. The companion is not a small copy of the desktop. It shows the one thing that is useful now, and it keeps decisions for your computer.
|
|
11
|
+
|
|
12
|
+
## The moment card
|
|
13
|
+
|
|
14
|
+
The top card shows where you are in your day and why it is useful now. PMOS chooses the moment from today's meetings and the clock:
|
|
15
|
+
|
|
16
|
+
| Moment | When | What the card shows |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| Your day | Before noon, with no meeting in the next 30 minutes | The next meeting, the number of your open items and the number of decisions that wait for your computer |
|
|
19
|
+
| Before you walk in | The next meeting starts in 30 minutes or less | What is prepared for it, and the items to close first, each with **Done** |
|
|
20
|
+
| In the meeting | A meeting is in progress | **Record this meeting?** with **Record** and **Not this one**. When it records, **Open the meeting companion** opens the meeting focus page. |
|
|
21
|
+
| Just ended | A meeting ended less than 20 minutes ago | **What came out of it?** If you recorded the meeting, the card shows the summary, the decisions, the actions and the open questions from its notes, the same notes as on the Notes page. While PMOS writes the notes, the card says so. If you did not record it, **Tell PMOS** puts the meeting name in the bar so you can type the result. |
|
|
22
|
+
| Free time | 15 minutes or more before the next meeting | The items that fit in the time: overdue items first, then items due today, at most two. Each has **Done** and **Later today**. |
|
|
23
|
+
| Wrapping up | From 17:00, or after noon with no meeting left | What is still open and what waits for your computer |
|
|
24
|
+
|
|
25
|
+
When the moment changes, a short message shows at the top for 6 seconds. The message shows only while the page is open. The companion does not send notifications.
|
|
26
|
+
|
|
27
|
+
## Waiting on you
|
|
28
|
+
|
|
29
|
+
Under the moment card, your own items show one at a time ("1 of 4"). **Done** marks the item done; the message that follows has **Undo**. **Later today** shows the item again later. **Next** shows the next item. Under the card, **Later today** lists the rest of today's meetings.
|
|
30
|
+
|
|
31
|
+
## Tell PMOS
|
|
32
|
+
|
|
33
|
+
Type a note, a to-do or an idea in the bar at the bottom and press Enter. PMOS puts it in your Work inbox. The bar does not answer questions. The small microphone puts the cursor in the bar; use the microphone of your phone's keyboard to speak. No audio goes to PMOS from this button.
|
|
34
|
+
|
|
35
|
+
## Record
|
|
36
|
+
|
|
37
|
+
The red button records a conversation or a meeting with the same recorder as **Record** on the desktop. While it records, the bar shows the name, the time and **Stop**.
|
|
38
|
+
|
|
39
|
+
## The full console
|
|
40
|
+
|
|
41
|
+
**Full console** at the top shows the desktop pages on this device, and the device keeps this choice. To go back, open **More** and choose **Phone companion**. In a window wider than 600 pixels the console always shows the desktop pages.
|
|
42
|
+
|
|
43
|
+
## Limits
|
|
44
|
+
|
|
45
|
+
- No notification on the lock screen, and no spoken brief.
|
|
46
|
+
- No recommendation for a decision. Decisions with options wait for your computer.
|
|
47
|
+
- To make tasks from the notes of a meeting, use the Notes page on your computer.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/c3-full-console.js — for browser suites that test the console's desktop layout at phone width.
|
|
3
|
+
* Since c3-phone-companion, console3 at 600px or less shows the phone companion. The desktop layout stays reachable there with
|
|
4
|
+
* "Full console", which stores pmos.c3.phone = 'desk' on the device. fullConsole(browser) makes every new context start with
|
|
5
|
+
* that choice, so a suite that resizes to 400 wide still tests the desktop layout. c3-phone-test.js tests the companion itself. */
|
|
6
|
+
function fullConsole(browser) {
|
|
7
|
+
const make = browser.newContext.bind(browser);
|
|
8
|
+
browser.newContext = async (...args) => {
|
|
9
|
+
const ctx = await make(...args);
|
|
10
|
+
await ctx.addInitScript(() => { try { localStorage.setItem('pmos.c3.phone', 'desk'); } catch { /* no storage */ } });
|
|
11
|
+
return ctx;
|
|
12
|
+
};
|
|
13
|
+
return browser;
|
|
14
|
+
}
|
|
15
|
+
module.exports = { fullConsole };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.178.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.178.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* pm/c3-demo-phone.js — demo only (?demo=1): the notes of the recorded Checkout sync, for the phone companion's "Just ended"
|
|
2
|
+
* card (c3-phone.js). The shapes are the real ones: a GET /api/notes row and the note of GET /api/notes/one with final_notes. */
|
|
3
|
+
const NOTE = {
|
|
4
|
+
id: 'note-demo-checkout', title: 'Checkout sync', private: false, state: 'ended', updated_at: '2026-10-08T11:16:00Z',
|
|
5
|
+
meeting: { kind: 'event', event_id: 'm2', title: 'Checkout sync' },
|
|
6
|
+
final_notes: {
|
|
7
|
+
overview: 'The v2 date moves to 31 Oct because the sandbox keys are late. Beta stays on 30 Oct for the two partners already set up.',
|
|
8
|
+
decisions: [{ text: 'Checkout v2 ships on 31 Oct, not 24 Oct', why: 'Sandbox keys arrive two days late' }, { text: 'Beta stays on 30 Oct for the two partners already set up' }],
|
|
9
|
+
actions: [{ text: 'Send Priya the revised Checkout v2 date', owner: 'Wawan', due: '9 Oct' }, { text: 'Ask Dara for the sandbox keys date in writing', owner: 'Dara', due: '10 Oct' }],
|
|
10
|
+
questions: [{ text: 'Does the 31 Oct date move the IDPF 3.2 release?' }],
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
export const demoNoteFor = (m) => (m && (m.id === 'm2' || m.title === NOTE.title) ? NOTE : null);
|
|
14
|
+
export const demoNote = (id) => (id === NOTE.id ? { ok: true, note: NOTE } : null);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* pm/c3-phone-model.js — which moment of the day the phone companion is in, and what it brings (initiative c3-phone-companion).
|
|
2
|
+
* Pure: it reads today's meetings and the PM's own open items (GET /api/brief-page) and a clock, and returns one moment. No DOM, no fetch.
|
|
3
|
+
*
|
|
4
|
+
* momentOf({ today, needs, now }) → { kind, meeting?, next?, minutes?, fits?, later }
|
|
5
|
+
* kind 'meeting' a meeting is under way (start ≤ now < end) meeting = that one
|
|
6
|
+
* 'before' the next meeting starts within BEFORE_MIN minutes meeting = the next one, minutes = until it starts
|
|
7
|
+
* 'after' a meeting ended less than AFTER_MIN minutes ago, nothing under way meeting = the one that ended
|
|
8
|
+
* 'gap' a free stretch of GAP_MIN minutes or more before the next meeting minutes = free minutes, fits = items that fit
|
|
9
|
+
* 'morning' before noon with no meeting under way or near next = the next meeting, or null
|
|
10
|
+
* 'wrap' from WRAP_HOUR on, or no meeting left today after noon next = null
|
|
11
|
+
* later the meetings still to come after the one the moment is about (time order), for "I'll bring up later".
|
|
12
|
+
* fitsIn(needs, minutes) the own items worth doing in a free stretch: overdue first (most late first), then due today; at most two.
|
|
13
|
+
* The meeting rows are the brief page's today[] rows: { id, title, start, end, people, initiative, prepared, before }. */
|
|
14
|
+
|
|
15
|
+
export const BEFORE_MIN = 30;
|
|
16
|
+
export const AFTER_MIN = 20;
|
|
17
|
+
export const GAP_MIN = 15;
|
|
18
|
+
export const WRAP_HOUR = 17;
|
|
19
|
+
|
|
20
|
+
const ms = (iso) => Date.parse(iso);
|
|
21
|
+
const ok = (m) => m && m.id && !Number.isNaN(ms(m.start)) && !Number.isNaN(ms(m.end || m.start));
|
|
22
|
+
|
|
23
|
+
export function fitsIn(needs, minutes) {
|
|
24
|
+
if (!(minutes >= GAP_MIN)) return [];
|
|
25
|
+
const own = (needs || []).filter((n) => n && (n.kind === 'overdue' || n.kind === 'due-today'));
|
|
26
|
+
own.sort((a, b) => (a.kind === b.kind ? (b.days_late || 0) - (a.days_late || 0) : a.kind === 'overdue' ? -1 : 1));
|
|
27
|
+
return own.slice(0, 2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function momentOf({ today, needs, now } = {}) {
|
|
31
|
+
const t = (now instanceof Date ? now : new Date(now || Date.now())).getTime();
|
|
32
|
+
const hour = new Date(t).getHours();
|
|
33
|
+
const rows = (today || []).filter(ok).slice().sort((a, b) => ms(a.start) - ms(b.start));
|
|
34
|
+
const end = (m) => ms(m.end || m.start);
|
|
35
|
+
const cur = rows.find((m) => ms(m.start) <= t && t < end(m));
|
|
36
|
+
const upcoming = rows.filter((m) => ms(m.start) > t);
|
|
37
|
+
const next = upcoming[0] || null;
|
|
38
|
+
const laterThan = (m) => upcoming.filter((x) => x !== m);
|
|
39
|
+
if (cur) return { kind: 'meeting', meeting: cur, minutes: Math.round((end(cur) - t) / 60000), later: upcoming };
|
|
40
|
+
const until = next ? Math.round((ms(next.start) - t) / 60000) : null;
|
|
41
|
+
if (next && until <= BEFORE_MIN) return { kind: 'before', meeting: next, minutes: until, later: laterThan(next) };
|
|
42
|
+
const ended = rows.filter((m) => end(m) <= t && t - end(m) < AFTER_MIN * 60000).pop();
|
|
43
|
+
if (ended) return { kind: 'after', meeting: ended, later: upcoming };
|
|
44
|
+
if (hour >= WRAP_HOUR || (!next && hour >= 12)) return { kind: 'wrap', next: null, later: upcoming };
|
|
45
|
+
if (next && until >= GAP_MIN && rows.some((m) => end(m) <= t)) return { kind: 'gap', next, minutes: until, fits: fitsIn(needs, until), later: upcoming };
|
|
46
|
+
if (hour < 12) return { kind: 'morning', next, later: upcoming };
|
|
47
|
+
return { kind: 'gap', next, minutes: until, fits: fitsIn(needs, until || 0), later: upcoming };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** "in 25 min", "in 1 h 5 min" */
|
|
51
|
+
export function inText(minutes) {
|
|
52
|
+
const m = Math.max(0, Math.round(minutes || 0));
|
|
53
|
+
if (m < 60) return 'in ' + m + ' min';
|
|
54
|
+
return 'in ' + Math.floor(m / 60) + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '');
|
|
55
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* pm/c3-phone.css — the phone companion (c3-phone-companion, PM-approved 2026-10-12, design version 4). Linked once by c3-phone.js.
|
|
2
|
+
* In phone mode the root carries .c3-phone-on: the desktop frame (header, tabs, panes, dock) is not drawn and the companion fills the
|
|
3
|
+
* screen. One moment card, one waiting card, a short "later today" list, and a bottom bar with "Tell PMOS" and the red Record button.
|
|
4
|
+
* Everything is scoped under .c3 and uses tokens only. */
|
|
5
|
+
.c3.c3-phone-on .c3grid{display:none !important}
|
|
6
|
+
.c3.c3-phone-on{min-height:100dvh}
|
|
7
|
+
.c3 .c3ph{display:flex;flex-direction:column;min-height:100dvh;background:var(--background)}
|
|
8
|
+
.c3 .c3ph-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:calc(10px + env(safe-area-inset-top)) 16px 10px;position:sticky;top:0;background:var(--background);z-index:2}
|
|
9
|
+
.c3 .c3ph-hd b{font-size:var(--fs-4)}
|
|
10
|
+
.c3 .c3ph-hdr{display:flex;align-items:center;gap:8px}
|
|
11
|
+
.c3 .c3ph-dotn{display:inline-flex;align-items:center;gap:4px;font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
|
|
12
|
+
.c3 .c3ph-body{flex:1;display:grid;gap:12px;align-content:start;padding:0 12px 16px}
|
|
13
|
+
.c3 .c3ph-card{display:grid;gap:8px;padding:14px}
|
|
14
|
+
.c3 .c3ph-card.lead{border-color:var(--acc)}
|
|
15
|
+
.c3 .c3ph-why,.c3 .c3ph-step{font:var(--fs-1) var(--mono);color:var(--muted-foreground);display:flex;align-items:center;gap:6px}
|
|
16
|
+
.c3 .c3ph-why{color:var(--acc)}
|
|
17
|
+
.c3 .c3ph-big{font-size:var(--fs-5);font-weight:600;line-height:1.3}
|
|
18
|
+
.c3 .c3ph-tt{font-size:var(--fs-3);line-height:1.4}
|
|
19
|
+
.c3 .c3ph-line{font-size:var(--fs-2);color:var(--muted-foreground);line-height:1.45}
|
|
20
|
+
.c3 .c3ph-rec-why{font-size:var(--fs-2);line-height:1.45;background:var(--muted);border-radius:8px;padding:8px 10px}
|
|
21
|
+
.c3 .c3ph-prep{display:flex;gap:6px;align-items:flex-start;font-size:var(--fs-2);line-height:1.45}
|
|
22
|
+
.c3 .c3ph-before,.c3 .c3ph-fit{display:grid;gap:6px;padding-top:8px;border-top:1px solid var(--border);font-size:var(--fs-2)}
|
|
23
|
+
.c3 .c3ph-before{grid-template-columns:1fr auto;align-items:center}
|
|
24
|
+
.c3 .c3ph-acts{display:grid;gap:8px}
|
|
25
|
+
.c3 .c3ph-acts.two{grid-template-columns:1fr 1fr}
|
|
26
|
+
.c3 .c3ph-b{min-height:44px;justify-content:center}
|
|
27
|
+
.c3 .c3ph-cnt{display:flex;justify-content:space-between;font-weight:600;font-size:var(--fs-2)}
|
|
28
|
+
.c3 .c3ph-cnt .dim{font-weight:400;font-family:var(--mono)}
|
|
29
|
+
.c3 .c3ph-later{display:grid;gap:4px;font-size:var(--fs-2);color:var(--muted-foreground);padding:0 4px}
|
|
30
|
+
.c3 .c3ph-later > div{display:flex;gap:10px}
|
|
31
|
+
.c3 .c3ph-later .mono{font-family:var(--mono);min-width:42px}
|
|
32
|
+
.c3 .c3ph-ft{position:sticky;bottom:0;display:flex;align-items:center;gap:10px;padding:8px 12px calc(12px + env(safe-area-inset-bottom));border-top:1px solid var(--border);background:var(--card);z-index:2}
|
|
33
|
+
.c3 .c3ph-ask{flex:1;display:flex;align-items:center;gap:8px;border:1px solid var(--border);border-radius:8px;padding:0 4px 0 12px;min-height:44px;color:var(--muted-foreground);background:var(--background)}
|
|
34
|
+
.c3 .c3ph-ask input.c3ph-in{flex:1;min-width:0;border:0;background:transparent;font-family:inherit;font-size:var(--fs-3);color:var(--foreground);padding:10px 0;outline:none;border-radius:0}
|
|
35
|
+
.c3 .c3ph-ask:focus-within{border-color:var(--acc)}
|
|
36
|
+
.c3 .c3ph-recdot{width:44px;height:44px;border:2px solid var(--destructive);display:grid;place-items:center;flex:none;padding:0}
|
|
37
|
+
.c3 .c3ph-reddot{width:18px;height:18px;border-radius:50%;background:var(--destructive)}
|
|
38
|
+
.c3 .c3ph-recbar{flex:1;display:flex;align-items:center;gap:10px;min-height:44px}
|
|
39
|
+
.c3 .c3ph-rt{flex:1;min-width:0;display:grid}
|
|
40
|
+
.c3 .c3ph-rt b{color:var(--destructive);font-size:var(--fs-3)}
|
|
41
|
+
.c3 .c3ph-rt .dim{font-size:var(--fs-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
42
|
+
.c3 .c3ph-dot{width:12px;height:12px;border-radius:50%;background:var(--destructive);flex:none;animation:c3phblink 1.2s infinite}
|
|
43
|
+
.c3 .c3ph-dot.paused{animation:none;opacity:.5}
|
|
44
|
+
@keyframes c3phblink{50%{opacity:.3}}
|
|
45
|
+
@media (prefers-reduced-motion:reduce){.c3 .c3ph-dot{animation:none}}
|
|
46
|
+
.c3 .c3ph-bnhost{position:sticky;top:52px;z-index:3;padding:0 12px}
|
|
47
|
+
.c3 .c3ph-banner{display:grid;gap:2px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;box-shadow:var(--sh3);cursor:pointer}
|
|
48
|
+
/* the focus page in its compact form fills the phone */
|
|
49
|
+
.c3.c3-phone-on .c3focus.fc-pop{position:fixed;inset:0;z-index:20}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/* pm/c3-phone.js — the phone companion (initiative c3-phone-companion). Below 601px wide, console3 shows this instead of a small copy of
|
|
2
|
+
* the desktop pages. Approved design: version 4 of the mockup named in planning/initiatives/c3-phone-companion.md.
|
|
3
|
+
*
|
|
4
|
+
* It has no tabs and no inbox to visit. It brings the one thing that matters now (c3-phone-model.js momentOf), then the PM's own items one
|
|
5
|
+
* at a time, then what comes later today. Two controls sit at the bottom: "Tell PMOS" and the red Record button.
|
|
6
|
+
* moment card meeting under way: "Record this meeting?" (Record / Not this one); recording: the meeting companion (c3-focus.js, compact)
|
|
7
|
+
* before a meeting: when it starts, who, what is prepared, the "before you go" items with Done
|
|
8
|
+
* just ended: tell PMOS what came out of it a free gap: the own items that fit, with Done and Later today
|
|
9
|
+
* morning: the next meeting and how much waits wrap: what is still open, what waits for the desk
|
|
10
|
+
* waiting card the own items from GET /api/brief-page needs, one at a time: Done (POST /api/pm-items/done, Undo: /reopen),
|
|
11
|
+
* Later today and Tomorrow (POST /api/pm-items/notyet). Decisions are not answered on the phone: they wait for the desk.
|
|
12
|
+
* Tell PMOS a line of text, filed by POST /api/capture { ws, text }: it lands in the Work inbox, where triage files it. The small mic
|
|
13
|
+
* points to the keyboard's own dictation; this page does not send audio anywhere.
|
|
14
|
+
* Record the red button records a conversation or a meeting, the same recorder as the desktop Record control (note-dock.js).
|
|
15
|
+
* While it records, the bar shows the clock and Stop. In demo mode it drives the simulated recorder and saves nothing.
|
|
16
|
+
* "Full console" switches this device to the desktop layout (kept on the device); the desktop More menu has "Phone companion" to come back.
|
|
17
|
+
* The moment is worked out again every minute; when it changes, a short banner says so. ?at=HH:MM sets the clock in demo mode only. */
|
|
18
|
+
import { el } from './dom.js';
|
|
19
|
+
import { button, iconButton } from './primitives.js';
|
|
20
|
+
import { pmPost, pmGet } from './http.js';
|
|
21
|
+
import { ensureC3Css, c3ic } from './pm-work-view.js';
|
|
22
|
+
import { onBriefPage, loadBriefPage } from './c3-brief-data.js';
|
|
23
|
+
import { onBriefing, loadBriefing } from './shell-ticker.js';
|
|
24
|
+
import { toast } from './c3-toast.js';
|
|
25
|
+
import { momentOf, inText } from './c3-phone-model.js';
|
|
26
|
+
|
|
27
|
+
const CSS_ID = 'c3-phone-css';
|
|
28
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
29
|
+
const hm = (iso) => { const d = new Date(iso); return p2(d.getHours()) + ':' + p2(d.getMinutes()); };
|
|
30
|
+
const first = (n) => String(n || '').trim().split(/\s+/)[0];
|
|
31
|
+
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
32
|
+
const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
33
|
+
const clockOf = (msec) => { const s = Math.max(0, Math.floor(msec / 1000)); return Math.floor(s / 60) + ':' + p2(s % 60); };
|
|
34
|
+
|
|
35
|
+
function ensureCss() {
|
|
36
|
+
if (document.getElementById(CSS_ID)) return;
|
|
37
|
+
document.head.appendChild(el('link', { id: CSS_ID, rel: 'stylesheet', href: '/pm/c3-phone.css' }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Is this device in phone mode? Phone width, and the PM has not chosen the full console on it. */
|
|
41
|
+
export function phoneWanted(store) {
|
|
42
|
+
if (typeof matchMedia !== 'function' || !matchMedia('(max-width:600px)').matches) return false;
|
|
43
|
+
return !(store && store.get('pmos.c3.phone') === 'desk');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function mountPhone(root, ctx = {}) {
|
|
47
|
+
ensureC3Css(); ensureCss();
|
|
48
|
+
const demo = !!ctx.demo;
|
|
49
|
+
const q = new URLSearchParams(location.search);
|
|
50
|
+
const at = demo && /^\d{1,2}:\d{2}$/.test(q.get('at') || '') ? q.get('at').split(':').map(Number) : null;
|
|
51
|
+
const now = () => { const d = new Date(); if (at) d.setHours(at[0], at[1], 0, 0); return d; };
|
|
52
|
+
const S = { page: null, briefing: null, gone: new Set(), skipped: new Set(), noRec: new Set(), debrief: {}, kindKey: '', banner: null, rec: null, recKey: '' };
|
|
53
|
+
|
|
54
|
+
const host = el('div', { class: 'c3ph', 'data-c3ph': '', role: 'main', 'aria-label': 'PMOS companion' });
|
|
55
|
+
const head = el('div', { class: 'c3ph-hd' });
|
|
56
|
+
const body = el('div', { class: 'c3ph-body', 'data-c3ph-body': '' });
|
|
57
|
+
const bannerHost = el('div', { class: 'c3ph-bnhost' });
|
|
58
|
+
const input = el('input', { type: 'text', class: 'c3ph-in', 'data-c3ph-input': '', maxlength: '400', autocomplete: 'off', placeholder: 'Tell PMOS: a note, a to-do, an idea', 'aria-label': 'Tell PMOS: a note, a to-do or an idea' });
|
|
59
|
+
const form = el('form', { class: 'c3ph-ask', 'data-c3ph-ask': '' }, c3ic('spark', 'sm'), input,
|
|
60
|
+
iconButton({ icon: 'mic', ariaLabel: 'Dictate', size: 'sm', attrs: { 'data-c3ph-dictate': '' }, onClick: () => { input.focus(); toast('Use the mic on your keyboard to talk. PMOS files what you say.'); } }));
|
|
61
|
+
const recBtn = button({ label: '', variant: 'outline', attrs: { class: 'c3ph-recdot', 'data-c3ph-record': '', 'aria-label': 'Record a conversation or meeting', title: 'Record a conversation or meeting' }, onClick: () => startRec(null) });
|
|
62
|
+
recBtn.replaceChildren(el('span', { class: 'c3ph-reddot', 'aria-hidden': 'true' }));
|
|
63
|
+
const foot = el('div', { class: 'c3ph-ft', 'data-c3ph-foot': '' });
|
|
64
|
+
host.append(head, bannerHost, body, foot);
|
|
65
|
+
root.append(host);
|
|
66
|
+
|
|
67
|
+
/* ── recording: the desktop recorder (note-dock.js), or the demo's simulated one ── */
|
|
68
|
+
let dock = null;
|
|
69
|
+
const recLib = demo ? import('./shell-recording.js').then((m) => ({ state: () => m.simDock.recordState(), now: () => m.simDock.now(), start: (t) => m.simStart(t), stop: () => m.simDock.stopNow() }))
|
|
70
|
+
: import('./note-dock.js').then((m) => { m.mountDock && m.mountDock(); return { state: () => m.recordState(), now: () => Date.now(), start: (t, ev) => (ev ? m.recordEvent(ev) : m.recordNew()), stop: () => m.stopNow() }; });
|
|
71
|
+
recLib.then((d) => { dock = d; syncRec(); }).catch(() => { recBtn.hidden = true; });
|
|
72
|
+
function recState() { try { return dock ? dock.state() : { mode: 'idle' }; } catch { return { mode: 'idle' }; } }
|
|
73
|
+
function startRec(m) {
|
|
74
|
+
if (!dock) { toast('The recorder is not ready yet.'); return; }
|
|
75
|
+
if (recState().mode !== 'idle') { toast('Stop the recording first.'); return; }
|
|
76
|
+
Promise.resolve(dock.start(m ? m.title : 'Conversation', m || null)).then(() => { syncRec(); draw(); });
|
|
77
|
+
}
|
|
78
|
+
function syncRec() {
|
|
79
|
+
const st = recState();
|
|
80
|
+
const key = st.mode + ':' + (st.id || '') + ':' + (st.title || '');
|
|
81
|
+
if (key !== S.recKey) {
|
|
82
|
+
S.recKey = key;
|
|
83
|
+
if (st.mode === 'idle') foot.replaceChildren(form, recBtn);
|
|
84
|
+
else {
|
|
85
|
+
S.clock = el('span', { 'data-c3ph-clock': '' });
|
|
86
|
+
foot.replaceChildren(el('div', { class: 'c3ph-recbar', 'data-c3ph-recbar': st.mode, role: 'status' },
|
|
87
|
+
el('span', { class: 'c3ph-dot' + (st.mode === 'paused' ? ' paused' : '') }),
|
|
88
|
+
el('div', { class: 'c3ph-rt' }, el('b', null, st.mode === 'paused' ? 'Paused' : 'Recording'), el('span', { class: 'dim' }, (st.title || 'Conversation') + ' · ', S.clock)),
|
|
89
|
+
button({ label: 'Stop', variant: 'outline', size: 'sm', icon: 'square', attrs: { 'data-c3ph-stop': '' }, onClick: () => Promise.resolve(dock.stop()).then(() => { syncRec(); draw(); }) })));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (S.clock) S.clock.textContent = st.mode === 'recording' && st.started ? clockOf(dock.now() - st.started) : st.mode === 'paused' ? 'paused' : '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ── writes ── */
|
|
96
|
+
const post = (path, b) => pmPost(path, b).catch(() => ({ ok: false }));
|
|
97
|
+
function itemDone(n) {
|
|
98
|
+
S.gone.add(n.id); draw();
|
|
99
|
+
post('/api/pm-items/done', { id: n.id }).then((r) => { if (r && r.ok === false) { S.gone.delete(n.id); draw(); toast('That did not go through.'); } });
|
|
100
|
+
toast('Done: ' + n.headline, () => { S.gone.delete(n.id); draw(); post('/api/pm-items/reopen', { id: n.id }); });
|
|
101
|
+
}
|
|
102
|
+
function itemLater(n, when) {
|
|
103
|
+
S.gone.add(n.id); draw();
|
|
104
|
+
post('/api/pm-items/notyet', { id: n.id, when }).then((r) => { if (r && r.ok === false) { S.gone.delete(n.id); draw(); toast('That did not go through.'); } });
|
|
105
|
+
toast(when === 'tomorrow' ? 'Back tomorrow' : 'Back later today');
|
|
106
|
+
}
|
|
107
|
+
form.addEventListener('submit', (e) => {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
const text = input.value.trim(); if (!text) return;
|
|
110
|
+
input.value = ''; input.blur();
|
|
111
|
+
if (demo) { (window.__c3demoWrites = window.__c3demoWrites || []).push({ path: '/api/capture', body: { ws: ctx.ws, text } }); toast('Filed in your Work inbox. (Demo: nothing is saved.)'); return; }
|
|
112
|
+
post('/api/capture', { ws: ctx.ws, text }).then((r) => toast(r && r.ok !== false ? 'Filed in your Work inbox. PMOS sorts it.' : 'That was not filed' + (r && r.error ? ': ' + r.error : '.')));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/* ── the debrief of a meeting that just ended: the notes of its recording (GET /api/notes, /api/notes/one), as on the desktop ── */
|
|
116
|
+
function debriefOf(m) {
|
|
117
|
+
const k = m.id;
|
|
118
|
+
const have = S.debrief[k];
|
|
119
|
+
if (have && ((have.state === 'ready' && have.parts.from === 'final') || Date.now() - have.at < 30000)) return have;
|
|
120
|
+
if (!have || have.state !== 'loading') {
|
|
121
|
+
S.debrief[k] = { state: have ? have.state : 'loading', parts: have && have.parts, at: Date.now() };
|
|
122
|
+
readDebrief(m).then((d) => { S.debrief[k] = { ...d, at: Date.now() }; draw(); }, () => { S.debrief[k] = { state: 'none', at: Date.now() }; draw(); });
|
|
123
|
+
}
|
|
124
|
+
return S.debrief[k];
|
|
125
|
+
}
|
|
126
|
+
async function readDebrief(m) {
|
|
127
|
+
const [links, dn] = await Promise.all([demo ? import('./c3-demo-phone.js') : import('./notes-links.js'), import('./dest-notes.js')]);
|
|
128
|
+
let row;
|
|
129
|
+
if (demo) row = links.demoNoteFor(m);
|
|
130
|
+
else { await links.refreshNotes(); row = links.notesFor(m).slice().sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)))[0]; }
|
|
131
|
+
if (!row) return { state: 'none' };
|
|
132
|
+
if (row.private) return { state: 'private' };
|
|
133
|
+
const one = demo ? links.demoNote(row.id) : await pmGet('/api/notes/one?id=' + encodeURIComponent(row.id));
|
|
134
|
+
const n = one && one.note;
|
|
135
|
+
if (!n) return { state: 'none' };
|
|
136
|
+
const parts = dn.partsOf(n);
|
|
137
|
+
if (!parts.overview && !parts.decisions.length && !parts.actions.length && !parts.questions.length) return { state: 'writing' };
|
|
138
|
+
return { state: 'ready', parts };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ── drawing ── */
|
|
142
|
+
const why = (t) => el('span', { class: 'c3ph-why' }, c3ic('spark', 'sm'), t);
|
|
143
|
+
const big = (t) => el('div', { class: 'c3ph-big' }, t);
|
|
144
|
+
const line = (t, attrs) => el('div', { class: 'c3ph-line', ...(attrs || {}) }, t);
|
|
145
|
+
const acts = (...b) => el('div', { class: 'c3ph-acts' + (b.filter(Boolean).length === 2 ? ' two' : '') }, ...b.filter(Boolean));
|
|
146
|
+
const card = (hero, attrs, ...kids) => el('section', { class: 'card c3ph-card' + (hero ? ' lead' : ''), ...(attrs || {}) }, ...kids);
|
|
147
|
+
const btn = (label, variant, icon, attrs, onClick) => button({ label, variant, icon, attrs: { ...(attrs || {}), class: 'c3ph-b' }, onClick });
|
|
148
|
+
const who = (m) => (m.people && m.people.length ? m.people.map(first).join(', ') : '');
|
|
149
|
+
const own = () => ((S.page && S.page.needs) || []).filter((n) => n && n.id && !S.gone.has(n.id));
|
|
150
|
+
const decisions = () => ((S.briefing && S.briefing.needs) || []).filter((n) => n && (n.card || (n.opts || n.options || []).length) && !n.itemId).length;
|
|
151
|
+
|
|
152
|
+
function itemCard(n, hero) {
|
|
153
|
+
const late = n.kind === 'overdue' ? (n.days_late === 1 ? '1 day late' : n.days_late + ' days late') : n.kind === 'due-today' ? 'Due today' : n.person ? 'Talk to ' + first(n.person) : 'Outside PMOS';
|
|
154
|
+
return [el('span', { class: 'c3ph-step' }, late.toUpperCase()), el('b', { class: 'c3ph-tt' }, n.headline),
|
|
155
|
+
n.evidence && n.evidence.text ? line('Looks done: ' + n.evidence.text + '.') : null,
|
|
156
|
+
acts(btn(n.evidence ? 'Confirm done' : 'Done', hero ? 'primary' : 'outline', 'check', { 'data-c3ph-done': n.id }, () => itemDone(n)),
|
|
157
|
+
btn('Later today', 'ghost', 'clock', { 'data-c3ph-later': n.id }, () => itemLater(n, 'later-today')))];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function momentCard(mo) {
|
|
161
|
+
const m = mo.meeting;
|
|
162
|
+
if (mo.kind === 'meeting') {
|
|
163
|
+
const st = recState();
|
|
164
|
+
const recording = st.mode !== 'idle';
|
|
165
|
+
const open = btn('Open the meeting companion', recording ? 'primary' : 'outline', 'target', { 'data-c3ph-focus': m.id }, () => import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.id, start: m.start }, pop: true })).catch(() => toast('The meeting companion did not open.')));
|
|
166
|
+
if (recording) return card(true, { 'data-c3ph-moment': 'meeting' }, why('Under way · ends ' + inText(mo.minutes)), big(m.title), line('Recording. PMOS follows the talk and writes the notes.'), acts(open));
|
|
167
|
+
if (S.noRec.has(m.id)) return card(true, { 'data-c3ph-moment': 'meeting' }, why('Under way · ends ' + inText(mo.minutes)), big(m.title), line('Not recording. PMOS follows the agenda and what you mark.'), acts(open, btn('Record from now', 'ghost', 'mic', { 'data-c3ph-rec-meeting': m.id }, () => startRec(m))));
|
|
168
|
+
return card(true, { 'data-c3ph-moment': 'meeting' }, why('Starting now' + (who(m) ? ' · ' + who(m) : '')), big('Record ' + m.title + '?'),
|
|
169
|
+
el('div', { class: 'c3ph-rec-why' }, 'Recording lets PMOS follow the talk, bring up what you need at the right topic, and write the notes for you.'),
|
|
170
|
+
acts(btn('Record', 'primary', 'mic', { 'data-c3ph-rec-meeting': m.id }, () => startRec(m)), btn('Not this one', 'ghost', null, { 'data-c3ph-norec': m.id }, () => { S.noRec.add(m.id); draw(); })));
|
|
171
|
+
}
|
|
172
|
+
if (mo.kind === 'before') {
|
|
173
|
+
const before = (m.before || []).filter((x) => !S.gone.has(x.id));
|
|
174
|
+
return card(true, { 'data-c3ph-moment': 'before' }, why('Starts ' + inText(mo.minutes) + ' · ' + hm(m.start)), big(m.title),
|
|
175
|
+
who(m) || m.initiative ? line([who(m), m.initiative].filter(Boolean).join(' · ')) : null,
|
|
176
|
+
...(m.prepared || []).map((x) => el('div', { class: 'c3ph-prep' }, c3ic(x.kind === 'prep' ? 'spark' : 'doc', 'sm'), x.text)),
|
|
177
|
+
...before.map((x) => el('div', { class: 'c3ph-before', 'data-c3ph-before': x.id }, el('span', null, 'Before you go: ' + x.title),
|
|
178
|
+
btn('Done', 'outline', 'check', { 'data-c3ph-done': x.id }, () => itemDone({ id: x.id, headline: x.title })))),
|
|
179
|
+
!(m.prepared || []).length && !before.length ? line('Nothing is prepared for it.') : null,
|
|
180
|
+
line('When it starts, PMOS asks to record it.', { 'data-c3ph-recnote': '' }));
|
|
181
|
+
}
|
|
182
|
+
if (mo.kind === 'after') {
|
|
183
|
+
const db = debriefOf(m);
|
|
184
|
+
const head = [why('Just ended · ' + hm(m.end || m.start)), big('What came out of ' + m.title + '?')];
|
|
185
|
+
const tell = btn(db.state === 'ready' ? 'Add something' : 'Tell PMOS', db.state === 'ready' ? 'outline' : 'primary', 'notebook-pen', { 'data-c3ph-tellafter': m.id }, () => { input.value = 'From ' + m.title + ': '; input.focus(); });
|
|
186
|
+
if (db.state === 'ready') {
|
|
187
|
+
const p = db.parts;
|
|
188
|
+
const list = (label, rows, fmt) => (rows.length ? el('div', { class: 'c3ph-fit', 'data-c3ph-debrief': label.toLowerCase() }, el('b', null, label + ' · ' + rows.length), ...rows.slice(0, 3).map((x) => el('div', { class: 'c3ph-line' }, fmt(x)))) : null);
|
|
189
|
+
return card(true, { 'data-c3ph-moment': 'after', 'data-c3ph-debrief-state': 'ready' }, ...head,
|
|
190
|
+
p.overview ? line(p.overview.length > 220 ? p.overview.slice(0, 217) + '…' : p.overview) : null,
|
|
191
|
+
list('Decisions', p.decisions, (x) => x.text),
|
|
192
|
+
list('Actions', p.actions, (x) => [x.text, x.owner ? ' · ' + x.owner : '', x.due ? ' · due ' + x.due : ''].join('')),
|
|
193
|
+
list('Open questions', p.questions, (x) => x.text),
|
|
194
|
+
line(p.from === 'final' ? 'From the recording. Make tasks from it on your computer.' : 'Caught while recording. The final notes follow.'),
|
|
195
|
+
acts(tell));
|
|
196
|
+
}
|
|
197
|
+
return card(true, { 'data-c3ph-moment': 'after', 'data-c3ph-debrief-state': db.state }, ...head,
|
|
198
|
+
line(db.state === 'writing' ? 'PMOS is writing the notes from the recording. Decisions and actions show here when they are ready.'
|
|
199
|
+
: db.state === 'private' ? 'The notes of this meeting are private. Open them on your computer.'
|
|
200
|
+
: db.state === 'loading' ? 'Looking for the notes of this meeting.'
|
|
201
|
+
: 'Not recorded. Tell PMOS the decisions and actions. They land in your Work inbox.'),
|
|
202
|
+
db.state === 'writing' || db.state === 'private' ? null : acts(tell));
|
|
203
|
+
}
|
|
204
|
+
if (mo.kind === 'gap') {
|
|
205
|
+
const fits = (mo.fits || []).filter((n) => !S.gone.has(n.id));
|
|
206
|
+
return card(true, { 'data-c3ph-moment': 'gap' }, why(mo.next ? 'Until ' + mo.next.title + ' at ' + hm(mo.next.start) : 'No more meetings today'),
|
|
207
|
+
big(mo.minutes ? mo.minutes + ' free minutes' : 'A free stretch'),
|
|
208
|
+
...(fits.length ? fits.map((n) => el('div', { class: 'c3ph-fit', 'data-c3ph-fit': n.id }, ...itemCard(n, false))) : [line('Nothing of yours is due. A good moment to prepare the next meeting.')]));
|
|
209
|
+
}
|
|
210
|
+
if (mo.kind === 'morning') {
|
|
211
|
+
const o = own();
|
|
212
|
+
return card(true, { 'data-c3ph-moment': 'morning' }, why('Your day'), big(mo.next ? mo.next.title + ' at ' + hm(mo.next.start) : 'No meetings today'),
|
|
213
|
+
line([mo.next ? 'First up, ' + inText((Date.parse(mo.next.start) - now().getTime()) / 60000) + '.' : null, o.length ? o.length + (o.length === 1 ? ' thing of yours waits.' : ' things of yours wait.') : 'Nothing of yours is due.', decisions() ? decisions() + (decisions() === 1 ? ' decision waits' : ' decisions wait') + ' for your desk.' : null].filter(Boolean).join(' ')));
|
|
214
|
+
}
|
|
215
|
+
const o = own();
|
|
216
|
+
return card(true, { 'data-c3ph-moment': 'wrap' }, why('Wrapping up'), big(o.length ? o.length + ' still open today' : 'Nothing left for today'),
|
|
217
|
+
line(decisions() ? decisions() + (decisions() === 1 ? ' decision waits' : ' decisions wait') + ' for your desk. It opens first on your Briefing.' : 'Nothing waits for your desk.'));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function waitingCard(mo) {
|
|
221
|
+
const skipIds = new Set(((mo.kind === 'gap' && mo.fits) || []).map((n) => n.id));
|
|
222
|
+
const list = own().filter((n) => !skipIds.has(n.id) && !S.skipped.has(n.id));
|
|
223
|
+
if (!list.length) return null;
|
|
224
|
+
const n = list[0];
|
|
225
|
+
return card(false, { 'data-c3ph-waiting': n.id }, el('div', { class: 'c3ph-cnt' }, el('span', null, 'Waiting on you'), el('span', { class: 'dim' }, '1 of ' + list.length)),
|
|
226
|
+
...itemCard(n, false), list.length > 1 ? button({ label: 'Next', variant: 'ghost', size: 'sm', iconEnd: 'chevron-right', attrs: { 'data-c3ph-skip': n.id }, onClick: () => { S.skipped.add(n.id); draw(); } }) : null);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function laterList(mo) {
|
|
230
|
+
const rows = (mo.later || []).slice(0, 3);
|
|
231
|
+
if (!rows.length) return null;
|
|
232
|
+
return el('div', { class: 'c3ph-later', 'data-c3ph-later-list': '' }, el('span', { class: 'c3ph-step' }, 'LATER TODAY'),
|
|
233
|
+
...rows.map((m) => el('div', { 'data-c3ph-later-row': m.id }, el('span', { class: 'mono' }, hm(m.start)), el('span', null, m.title))));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function headLine() {
|
|
237
|
+
const d = now();
|
|
238
|
+
const n = own().length;
|
|
239
|
+
head.replaceChildren(el('b', null, WD[d.getDay()] + ' ' + d.getDate() + ' ' + MO[d.getMonth()]),
|
|
240
|
+
el('span', { class: 'c3ph-hdr' }, n ? el('span', { class: 'c3ph-dotn', 'data-c3ph-count': String(n) }, c3ic('inbox', 'sm'), n + ' waiting') : null,
|
|
241
|
+
button({ label: 'Full console', variant: 'ghost', size: 'sm', attrs: { 'data-c3ph-desk': '' }, onClick: () => ctx.onDesk && ctx.onDesk() })));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const BANNER = { meeting: (m) => m.title + ' has started', before: (m, mo) => m.title + ' starts ' + inText(mo.minutes), after: (m) => m.title + ' just ended', gap: (m, mo) => (mo.minutes || 0) + ' free minutes', wrap: () => 'Time to wrap up', morning: () => 'Good morning' };
|
|
245
|
+
function draw() {
|
|
246
|
+
if (!S.page) { body.replaceChildren(card(false, { 'data-c3ph-state': 'loading' }, line('Reading your day.'))); headLine(); return; }
|
|
247
|
+
if (S.page.ok === false) { body.replaceChildren(card(false, { 'data-c3ph-state': 'error' }, line('Your day could not be read: ' + (S.page.error || 'unreadable')))); headLine(); return; }
|
|
248
|
+
const mo = momentOf({ today: S.page.today || [], needs: own(), now: now() });
|
|
249
|
+
const key = mo.kind + ':' + ((mo.meeting && mo.meeting.id) || '');
|
|
250
|
+
if (S.kindKey && key !== S.kindKey) {
|
|
251
|
+
const t = BANNER[mo.kind](mo.meeting || mo.next || {}, mo);
|
|
252
|
+
bannerHost.replaceChildren(el('div', { class: 'c3ph-banner', role: 'status', 'data-c3ph-banner': mo.kind, onclick: () => bannerHost.replaceChildren() }, el('span', { class: 'c3ph-step' }, 'PMOS'), el('b', null, t)));
|
|
253
|
+
clearTimeout(S.banner); S.banner = setTimeout(() => bannerHost.replaceChildren(), 6000);
|
|
254
|
+
}
|
|
255
|
+
S.kindKey = key;
|
|
256
|
+
host.dataset.moment = mo.kind;
|
|
257
|
+
headLine();
|
|
258
|
+
body.replaceChildren(momentCard(mo), waitingCard(mo) || el('span'), laterList(mo) || el('span'));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const off1 = onBriefPage((j) => { S.page = j; draw(); });
|
|
262
|
+
const off2 = onBriefing((b) => { S.briefing = b; if (S.page) draw(); });
|
|
263
|
+
loadBriefPage(); loadBriefing();
|
|
264
|
+
draw();
|
|
265
|
+
const t1 = setInterval(syncRec, 1000);
|
|
266
|
+
const t2 = setInterval(draw, 60000);
|
|
267
|
+
const t3 = setInterval(() => loadBriefPage(), 5 * 60000);
|
|
268
|
+
return { host, refresh: draw, dispose() { clearInterval(t1); clearInterval(t2); clearInterval(t3); off1(); off2(); host.remove(); } };
|
|
269
|
+
}
|
|
@@ -23,6 +23,17 @@ export const PAD_H_KEY = 'pmos.workspace.c3.term-pad-h';
|
|
|
23
23
|
export const LAST_SESSION_KEY = 'pmos.workspace.c3.last-session';
|
|
24
24
|
export const rememberSession = (id) => { if (id) store.set(LAST_SESSION_KEY, String(id)); };
|
|
25
25
|
export const lastSession = () => store.get(LAST_SESSION_KEY) || null;
|
|
26
|
+
|
|
27
|
+
/** The header's Sessions button: the terminal of the session opened last, when it still runs; else the Sessions panel (c3-terminal.js).
|
|
28
|
+
* The terminal sheet has "All sessions", which opens the panel. */
|
|
29
|
+
export async function openLastSession() {
|
|
30
|
+
const id = lastSession();
|
|
31
|
+
const r = id ? await pmGet('/api/sessions').catch(() => null) : null;
|
|
32
|
+
const s = r && Array.isArray(r.sessions) ? r.sessions.find((x) => x.id === id && x.state !== 'exited' && x.shown !== 'exited') : null;
|
|
33
|
+
if (s) return openSessionTerm({ id: s.id, name: s.name });
|
|
34
|
+
const m = await import('./c3-terminal.js');
|
|
35
|
+
return m.openTerminalPage();
|
|
36
|
+
}
|
|
26
37
|
const PAD_ROWS = 4; // the default height
|
|
27
38
|
/** The pad's height in rows: at least 2, and at most what leaves the terminal its room in a column `room` px high with `lh` px rows. Pure, for Node. */
|
|
28
39
|
export const clampPad = (rows, room, lh) => Math.max(2, Math.min(Math.round(rows), Math.max(2, Math.floor((room - 200) / (lh || 18)))));
|
|
@@ -138,10 +149,13 @@ export async function openSessionTerm({ id, name } = {}) {
|
|
|
138
149
|
let size = sizeOf(store.get(SIZE_KEY));
|
|
139
150
|
const sizeBtns = SIZES.map(([k, label, title]) => c3btn({ 'aria-pressed': String(k === size), 'data-c3-size': k, title, 'aria-label': title }, () => setSize(k), label));
|
|
140
151
|
const seg = el('div', { class: 'seg c3t-size', role: 'group', 'aria-label': 'Terminal size', 'data-c3-size-seg': '' }, ...sizeBtns);
|
|
152
|
+
// the way back to every session: the Sessions panel, in place of this sheet
|
|
153
|
+
const all = c3btn({ class: 'chip2 lk', 'data-c3-all-sessions': '', title: 'All sessions: the Sessions panel' }, () => { sheet.close(); import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
|
|
154
|
+
c3ic('menu', 'sm'), 'All sessions');
|
|
141
155
|
const cls = (k) => 'tsz-' + k;
|
|
142
156
|
// Esc and Tab belong to the shell while the terminal has focus (Esc interrupts an agent); the close button and a press outside still close.
|
|
143
157
|
// The scratchpad is outside the terminal host, so its keys are the page's own.
|
|
144
|
-
const sheet = openSheet({ title: name || id, icon: 'term', wide: true, body: wrap, actions: seg, cls: cls(size),
|
|
158
|
+
const sheet = openSheet({ title: name || id, icon: 'term', wide: true, body: wrap, actions: [all, seg], cls: cls(size),
|
|
145
159
|
onClose: () => { pad.dispose(); term.disposeTerm(tab); }, passKeys: (e) => host.contains(e.target) });
|
|
146
160
|
function setSize(k) {
|
|
147
161
|
sheet.sheet.classList.remove(cls(size)); size = sizeOf(k); sheet.sheet.classList.add(cls(size));
|
|
@@ -50,6 +50,7 @@ import { freshnessLine } from './freshness-line.js';
|
|
|
50
50
|
import { mountInitiatives } from './c3-initiatives.js';
|
|
51
51
|
import { mountSettings, gearIcon, applyLook } from './c3-settings.js';
|
|
52
52
|
import { mountQuiet } from './c3-quiet.js';
|
|
53
|
+
import { mountPhone, phoneWanted } from './c3-phone.js';
|
|
53
54
|
|
|
54
55
|
/* In demo mode each tab's deck asks for its own cards (the mockup draws a different deck per tab); real mode sends no scope. Reports always sends its own (repTab). */
|
|
55
56
|
const sc = (name) => (DEMO ? name : undefined);
|
|
@@ -107,8 +108,9 @@ function header() {
|
|
|
107
108
|
S.ttl = el('h1', { 'data-c3pg': 'title' });
|
|
108
109
|
S.sub = el('div', { class: 'sub', 'data-c3pg': 'date' }, dateLine());
|
|
109
110
|
S.termBdg = el('span', { class: 'bdg', hidden: true, 'data-c3pg': 'term-count' }, '0');
|
|
110
|
-
/* the AGENT button (the bolt glyph, as the mockup's Sessions pill)
|
|
111
|
-
|
|
111
|
+
/* the AGENT button (the bolt glyph, as the mockup's Sessions pill), with the needs-you badge: the terminal of the session opened last while it runs
|
|
112
|
+
* (PM, 2026-10-13), else the Terminal page, the full agent surfaces; the terminal sheet's "All sessions" opens that page. It is an icon only (PM, 2026-10-12: no "Sessions" label); its tooltip and aria-label name it. Ctrl J and the More menu fold and open the dock. */
|
|
113
|
+
S.termBtn = button({ variant: 'ghost', label: [ic('bolt', 'sm'), S.termBdg], onClick: () => { import('./c3-session-term.js').then((m) => m.openLastSession()); },
|
|
112
114
|
attrs: { class: 'psess', title: 'Agent surfaces: sessions, results to accept, questions for you', 'aria-label': 'Agent surfaces: sessions, results to accept, questions for you', 'aria-haspopup': 'dialog', 'data-c3pg': 'agent-button' } }); // the mockup's header button (agent-surfaces.html #act)
|
|
113
115
|
S.tools = el('div', { class: 'tools', 'data-c3pg': 'tools' });
|
|
114
116
|
if (S.demoMod) S.tools.append(S.demoMod.demoPill());
|
|
@@ -138,6 +140,7 @@ function toggleMore() {
|
|
|
138
140
|
item('alert', 'Incident day', () => toggleIncident(), S.incident, S.demo ? { 'data-a': 'inc' } : { 'data-a': 'inc', disabled: true, title: 'No incident feed is connected, so this page shows no incident.' }),
|
|
139
141
|
item('spark', 'Agent playbooks', () => openPlaybooks(), false, { 'data-a': 'pbopen' }),
|
|
140
142
|
item('phone', 'Phone preview', () => togglePreview(), !!S.preview, { 'data-a': 'phone' }),
|
|
143
|
+
S.root.classList.contains('ph') && store.get('pmos.c3.phone') === 'desk' ? item('phone', 'Phone companion', () => { store.set('pmos.c3.phone', ''); syncPhone(); }, false, { 'data-a': 'phcomp' }) : null,
|
|
141
144
|
item(S.theme === 'dark' ? 'sun' : 'moon', S.theme === 'dark' ? 'Light theme' : 'Dark theme', () => setTheme(S.theme === 'dark' ? 'light' : 'dark')),
|
|
142
145
|
((b) => { b.firstChild.replaceWith(gearIcon('sm')); return b; })(item('info', 'Settings', () => showTab('settings', true), false, { 'data-a': 'settings' })),
|
|
143
146
|
item('term', S.dockOpen ? 'Fold the dock' : 'Open the dock', () => { S.dockOpen = !S.dockOpen; keepDock(); syncDock(); }, false, { 'data-a': 'dockt' }, 'Ctrl J'),
|
|
@@ -150,6 +153,18 @@ function toggleMore() {
|
|
|
150
153
|
document.addEventListener('keydown', S.menuOff); document.addEventListener('pointerdown', S.menuOff);
|
|
151
154
|
const first = S.menu.querySelector('.c3pg-mi:not([disabled])'); if (first) first.focus();
|
|
152
155
|
}
|
|
156
|
+
/* The phone companion (c3-phone.js): at 600px or less, unless the PM chose the full console on this device, it fills the page and the
|
|
157
|
+
* desktop grid is hidden. "Full console" stores that choice; the More menu's "Phone companion" clears it. */
|
|
158
|
+
function syncPhone() {
|
|
159
|
+
const want = phoneWanted(store);
|
|
160
|
+
if (want && !S.phone) {
|
|
161
|
+
S.root.classList.add('c3-phone-on');
|
|
162
|
+
S.phone = mountPhone(S.root, { ws: S.ws, demo: S.demo, onDesk: () => { store.set('pmos.c3.phone', 'desk'); syncPhone(); } });
|
|
163
|
+
} else if (!want && S.phone) {
|
|
164
|
+
S.phone.dispose(); S.phone = null;
|
|
165
|
+
S.root.classList.remove('c3-phone-on');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
153
168
|
function closeMore() {
|
|
154
169
|
if (!S.menu) return;
|
|
155
170
|
document.removeEventListener('keydown', S.menuOff); document.removeEventListener('pointerdown', S.menuOff);
|
|
@@ -607,6 +622,8 @@ async function boot() {
|
|
|
607
622
|
mountDaypart(root, root.querySelector('.c3top'));
|
|
608
623
|
mountAmbient(root); // the ambient motion canvas, once, behind the grid
|
|
609
624
|
mountQuiet(root); // the quiet layout: two rows above the content, no teaching sentences (c3-quiet-pages)
|
|
625
|
+
syncPhone(); // at phone width the companion replaces the desktop frame (c3-phone-companion)
|
|
626
|
+
if (typeof matchMedia === 'function') matchMedia('(max-width:600px)').addEventListener('change', syncPhone);
|
|
610
627
|
|
|
611
628
|
S.dock.setWorkspaces(S.workspaces, S.ws);
|
|
612
629
|
mountBell(S.bellHost, { go: (_, t) => goTarget(t) });
|
|
@@ -78,7 +78,8 @@
|
|
|
78
78
|
"briefing-icons.test.js",
|
|
79
79
|
"needs-overdue.test.js",
|
|
80
80
|
"briefing-scope.test.js",
|
|
81
|
-
"settings-store.test.js"
|
|
81
|
+
"settings-store.test.js",
|
|
82
|
+
"c3-phone-model.test.js"
|
|
82
83
|
],
|
|
83
84
|
"panels": [
|
|
84
85
|
"pm-shell-test.js",
|
|
@@ -145,7 +146,8 @@
|
|
|
145
146
|
"c3-scan-test.js",
|
|
146
147
|
"c3-sessions-panel-test.js",
|
|
147
148
|
"c3-updates-test.js",
|
|
148
|
-
"c3-quiet-test.js"
|
|
149
|
+
"c3-quiet-test.js",
|
|
150
|
+
"c3-phone-test.js"
|
|
149
151
|
],
|
|
150
152
|
"ui": [
|
|
151
153
|
"ui-test.js"
|
|
@@ -287,7 +289,9 @@
|
|
|
287
289
|
"c3-settings-test.js": 25,
|
|
288
290
|
"c3-sessions-panel-test.js": 20,
|
|
289
291
|
"c3-updates-test.js": 20,
|
|
290
|
-
"c3-quiet-test.js": 30
|
|
292
|
+
"c3-quiet-test.js": 30,
|
|
293
|
+
"c3-phone-model.test.js": 1,
|
|
294
|
+
"c3-phone-test.js": 20
|
|
291
295
|
},
|
|
292
296
|
"_comment_seconds": "Measured wall time of each suite run alone (4-CPU sandbox, 2026-10-05). Only the ORDER uses it: the slowest suites start first, and --shard deals suites to the least-loaded shard so the shards finish together. A wrong or missing number never skips a suite; a missing one counts as 10."
|
|
293
297
|
}
|