@aiwuk/team 0.0.0-stage → 5.0.8

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.
Files changed (117) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/LICENSE +21 -0
  3. package/README.md +149 -2
  4. package/README.zh-CN.md +150 -0
  5. package/access.js +93 -0
  6. package/activity-summary.js +36 -0
  7. package/allocation.js +34 -0
  8. package/board-dispatch.js +141 -0
  9. package/brand.js +41 -0
  10. package/client/00-core-zh.js +284 -0
  11. package/client/01-i18n-en.js +265 -0
  12. package/client/02-styles.js +336 -0
  13. package/client/02b-stage-styles.js +65 -0
  14. package/client/03-shared.js +82 -0
  15. package/client/03b-collapsible-list.js +34 -0
  16. package/client/03c-config-panel.js +605 -0
  17. package/client/04-tool-cards.js +110 -0
  18. package/client/04a-dispatch-confirm.js +162 -0
  19. package/client/04b-more-cards.js +78 -0
  20. package/client/04c-model-picker.js +68 -0
  21. package/client/04d-confirm-dock.js +40 -0
  22. package/client/05-policy.js +217 -0
  23. package/client/06-usage.js +195 -0
  24. package/client/06b-model-source.js +67 -0
  25. package/client/07-board-parts.js +312 -0
  26. package/client/07a-role-theme.js +25 -0
  27. package/client/08-artifacts-report.js +375 -0
  28. package/client/08a-task-control.js +126 -0
  29. package/client/08b-stage-sprites.js +97 -0
  30. package/client/08c-stage-colors.js +55 -0
  31. package/client/08c-stage-scene.js +238 -0
  32. package/client/08d-stage-adapter.js +65 -0
  33. package/client/08e-stage-activity.js +83 -0
  34. package/client/09-team-board.js +181 -0
  35. package/client/09b-role-view.js +95 -0
  36. package/client/09c-model-tabs.js +20 -0
  37. package/client/10-overview.js +364 -0
  38. package/client/10b-onboarding.js +121 -0
  39. package/client/11-register.js +249 -0
  40. package/client.js +5053 -0
  41. package/config-projection.js +40 -0
  42. package/config.js +411 -0
  43. package/cordis.patch.yml +110 -0
  44. package/defaults.js +222 -0
  45. package/dispatch-confirm.js +293 -0
  46. package/dispatch-routes-store.js +31 -0
  47. package/failover.js +85 -0
  48. package/file-limits.js +119 -0
  49. package/free-worker.mjs +125 -0
  50. package/http-guard.js +14 -0
  51. package/i18n/roles.en.json +23 -0
  52. package/i18n/roles.zh.json +23 -0
  53. package/i18n/server.en.json +659 -0
  54. package/i18n/server.zh.json +659 -0
  55. package/i18n.js +34 -0
  56. package/icon.svg +7 -0
  57. package/index.js +113 -0
  58. package/integrations-config.js +301 -0
  59. package/integrations-messages.js +62 -0
  60. package/integrations.js +17 -0
  61. package/lead-route.js +30 -0
  62. package/locale/en.json +6 -0
  63. package/locale/zh.json +6 -0
  64. package/long-context.js +30 -0
  65. package/managed-config-route.js +49 -0
  66. package/managed-ref.js +38 -0
  67. package/managed-resolve.js +47 -0
  68. package/member-models.js +84 -0
  69. package/member-task-control.js +242 -0
  70. package/memory-backends.js +89 -0
  71. package/memory-embed.js +70 -0
  72. package/memory-hub.js +140 -0
  73. package/memory-openviking.js +101 -0
  74. package/memory-redact.js +34 -0
  75. package/memory-store.js +270 -0
  76. package/memory-sync-queue.js +28 -0
  77. package/model-policy.js +85 -0
  78. package/model-prefs.js +104 -0
  79. package/observability-api.js +76 -0
  80. package/observability-projection.js +116 -0
  81. package/onboarding.js +47 -0
  82. package/opencode.js +74 -0
  83. package/package.json +155 -4
  84. package/plan-gate.js +92 -0
  85. package/preset-guard.js +47 -0
  86. package/request-evidence.js +41 -0
  87. package/roles.js +105 -0
  88. package/roster-defaults.js +70 -0
  89. package/route-health.js +39 -0
  90. package/runtime-observability.js +78 -0
  91. package/runtime-view.js +21 -0
  92. package/scripts/memory-cli.mjs +138 -0
  93. package/search-engines.js +119 -0
  94. package/search.js +164 -0
  95. package/session-policy.js +59 -0
  96. package/session-rotation.js +206 -0
  97. package/settings-schema.js +40 -0
  98. package/shared/managed-config.js +84 -0
  99. package/single-model.js +44 -0
  100. package/skills/project-role-theme/SKILL.md +46 -0
  101. package/task-bridge.js +32 -0
  102. package/task-control-api.js +36 -0
  103. package/task-control-audit.js +60 -0
  104. package/team.js +101 -0
  105. package/teammate-context.js +120 -0
  106. package/teammate-wait.js +18 -0
  107. package/template.js +46 -0
  108. package/theme-plugin.js +172 -0
  109. package/theme.js +115 -0
  110. package/token-discipline.js +116 -0
  111. package/token-metrics.js +37 -0
  112. package/tool-names.js +10 -0
  113. package/tool-schema.js +23 -0
  114. package/transcribe.js +104 -0
  115. package/ui.js +113 -0
  116. package/usage.js +96 -0
  117. package/workflow.js +246 -0
@@ -0,0 +1,55 @@
1
+ // ---------- office stage: theme colours for the canvas ----------
2
+ /**
3
+ * The pixel canvas cannot paint with `var()` or `color-mix()` strings, and host tokens may be defined with either.
4
+ * So each stage colour is resolved through a hidden probe element: its `color` is set to `var(--token, fallback)`
5
+ * and the browser's computed value (a concrete rgb or color(srgb ...) value) is read back. The fallback (a CSS system-colour keyword) is only used when
6
+ * the token is missing from the host stylesheet.
7
+ */
8
+ const STAGE_COLOR_TOKENS = {
9
+ floorA: ['--ctm-stage-floor-a', 'Canvas'], floorB: ['--ctm-stage-floor-b', 'Canvas'], grout: ['--ctm-stage-grout', 'GrayText'],
10
+ wall: ['--ctm-stage-wall', 'GrayText'], desk: ['--ctm-stage-desk', 'GrayText'], deskTop: ['--ctm-stage-desk-top', 'GrayText'],
11
+ select: ['--ctm-stage-select', 'Highlight'],
12
+ };
13
+ const RGBA_FN = 'rgba'; // assembled so no colour literal appears in source (tests/theme-tokens.test.mjs)
14
+ /** `color(srgb r g b / a)` (what browsers return for color-mix) → an rgba string; every other value is returned trimmed. */
15
+ function stageNormalizeColor(value) {
16
+ const text = String(value ?? '').trim();
17
+ const m = /^color\(\s*srgb\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)(?:\s*\/\s*(-?[\d.]+%?))?\s*\)$/i.exec(text);
18
+ if (!m) return text;
19
+ const channel = s => Math.round(Math.max(0, Math.min(1, s.endsWith('%') ? parseFloat(s) / 100 : parseFloat(s))) * 255);
20
+ const alpha = m[4] === undefined ? 1 : Math.max(0, Math.min(1, m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4])));
21
+ return `${RGBA_FN}(${channel(m[1])}, ${channel(m[2])}, ${channel(m[3])}, ${+alpha.toFixed(3)})`;
22
+ }
23
+ /** Resolve every stage colour through `probe` (a hidden element inside the stage, so it inherits the custom properties). */
24
+ function stageResolveColors(probe, getStyle) {
25
+ const out = {};
26
+ for (const [key, [name, fallback]] of Object.entries(STAGE_COLOR_TOKENS)) {
27
+ probe.style.color = '';
28
+ probe.style.color = `var(${name}, ${fallback})`;
29
+ out[key] = stageNormalizeColor(getStyle(probe).color) || stageNormalizeColor(fallback);
30
+ }
31
+ return out;
32
+ }
33
+ /** Bridge from the host's `theme/change` event to every live scene; `stageTheme.bind(ctx)` is called once from plugin apply. */
34
+ const stageTheme = {
35
+ listeners: new Set(),
36
+ notify() { for (const fn of [...this.listeners]) fn(); },
37
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); },
38
+ bind(ctx) {
39
+ if (typeof ctx?.on !== 'function') return () => {};
40
+ const off = ctx.on('theme/change', () => this.notify());
41
+ return typeof off === 'function' ? off : () => {};
42
+ },
43
+ };
44
+ /** Re-run `onChange` when the host swaps palettes: `theme/change` events plus attribute changes on body/html (dark flag, theme source, token overrides). */
45
+ function stageWatchTheme(onChange) {
46
+ const offBus = stageTheme.subscribe(onChange);
47
+ let observer = null;
48
+ if (typeof MutationObserver === 'function' && typeof document !== 'undefined') {
49
+ observer = new MutationObserver(onChange);
50
+ for (const target of [document.body, document.documentElement]) {
51
+ if (target) observer.observe(target, { attributes: true, attributeFilter: ['data-ds-dark-theme', 'data-ds-theme-source', 'style'] });
52
+ }
53
+ }
54
+ return () => { offBus(); observer?.disconnect(); };
55
+ }
@@ -0,0 +1,238 @@
1
+ // ---------- office stage: layout, DOM plates and the render loop (imperative; React only owns the host element) ----------
2
+ /**
3
+ * Sprites arrive from `stageSprites` (08d) and are diffed in `sync`: new members walk in from the door, and the
4
+ * one-shot effects from `stageEvents` (08e) fly letters between Lead and teammates, celebrate finished tasks and
5
+ * flash changed routes. Poses follow each sprite's `activity`. The first frame is painted synchronously because a
6
+ * hidden side pane never fires requestAnimationFrame.
7
+ */
8
+ class OfficeScene {
9
+ constructor(stage, canvas, overlay, { t, onOpen, onSelect }) {
10
+ this.stage = stage; this.canvas = canvas; this.overlay = overlay; this.ctx = canvas.getContext('2d'); this.t = t; this.onOpen = onOpen; this.onSelect = onSelect;
11
+ this.sprites = new Map(); this.plates = new Map(); this.icons = new Map(); this.bubbles = new Map(); this.couriers = []; this.prevList = []; this.tick = 0;
12
+ this.onCanvasClick = e => { const id = this.hit(e); if (id) this.select(this.selected === id ? null : id); };
13
+ canvas.addEventListener?.('click', this.onCanvasClick);
14
+ this.w = 0; this.h = 0; this.S = 3; this.plateW = 120; this.selected = null; this.synced = false; this.dirty = true; this.visible = true;
15
+ this.reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
16
+ this.px = (x, y, w, h, c) => { this.ctx.fillStyle = c; this.ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h)); };
17
+ if (typeof ResizeObserver === 'function') { this.ro = new ResizeObserver(() => this.resize()); this.ro.observe(stage); }
18
+ this.visibleAt = 0; // on-screen check is a cheap rect read once a second (IntersectionObserver is unreliable inside some embedded panes)
19
+ this.onVisibility = () => { this.last = 0; if (!document.hidden) this.draw(); };
20
+ document.addEventListener('visibilitychange', this.onVisibility);
21
+ this.colors = null; this.unwatchTheme = stageWatchTheme(() => this.retheme());
22
+ this.last = 0; this.schedule();
23
+ }
24
+ /** Canvas colours are resolved to concrete rgb through a probe element (see 08c-stage-colors.js) and cached until the theme changes. */
25
+ stageColors() {
26
+ if (this.colors) return this.colors;
27
+ this.probe ??= this.el('i');
28
+ this.probe.setAttribute('aria-hidden', 'true'); Object.assign(this.probe.style, { position: 'absolute', width: '0', height: '0', visibility: 'hidden', pointerEvents: 'none' });
29
+ if (!this.probe.isConnected) this.stage.append(this.probe);
30
+ return (this.colors = stageResolveColors(this.probe, getComputedStyle));
31
+ }
32
+ /** `theme/change` fires before the host flips the body attribute, so re-resolve once more after it settles. */
33
+ retheme() {
34
+ const redraw = () => { this.colors = null; this.dirty = true; this.draw(); };
35
+ redraw(); clearTimeout(this.themeTimer); this.themeTimer = setTimeout(redraw, 80);
36
+ }
37
+ /** rAF when the host delivers frames; a timer fallback keeps motion going (at low fps) in panes that stall rAF while visible. */
38
+ schedule() {
39
+ cancelAnimationFrame(this.raf); clearTimeout(this.timer);
40
+ this.raf = requestAnimationFrame(now => this.loop(now));
41
+ this.timer = setTimeout(() => this.loop(performance.now()), this.busy() && this.visible && !document.hidden ? 120 : 1000);
42
+ }
43
+ el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
44
+ destroy() {
45
+ cancelAnimationFrame(this.raf); clearTimeout(this.timer); clearTimeout(this.themeTimer); this.ro?.disconnect(); document.removeEventListener('visibilitychange', this.onVisibility);
46
+ this.unwatchTheme?.(); this.probe?.remove();
47
+ this.canvas.removeEventListener?.('click', this.onCanvasClick);
48
+ for (const id of [...this.sprites.keys()]) this.remove(id);
49
+ }
50
+
51
+ /* ---------- diff from the adapter ---------- */
52
+ sync(list) {
53
+ const seen = new Set();
54
+ for (const data of list) {
55
+ seen.add(data.id);
56
+ const prev = this.sprites.get(data.id);
57
+ if (!prev) { this.add(data, this.synced && data.status !== 'inactive'); continue; }
58
+ const was = prev.data; prev.data = data;
59
+ if (was.status === 'inactive' && data.status !== 'inactive' && !this.reduced) this.walkIn(prev);
60
+ else if (prev.phase !== 'walking') prev.phase = phaseOf(data);
61
+ if (was.activity !== data.activity) prev.gesture = null;
62
+ }
63
+ if (this.synced) for (const ev of stageEvents(this.prevList, list)) this.effect(ev);
64
+ for (const data of list) this.renderPlate(data.id);
65
+ for (const id of [...this.sprites.keys()]) if (!seen.has(id)) this.remove(id);
66
+ this.prevList = list; this.synced = true; this.dirty = true; this.positionOverlay(); this.draw();
67
+ }
68
+ effect(ev) {
69
+ if (ev.type === 'flash') this.flash(ev.id);
70
+ else if (ev.type === 'mail') this.courier(ev.from, ev.to);
71
+ else if (ev.type === 'celebrate') { const s = this.sprites.get(ev.id); if (s) { s.celebrate = this.reduced ? 0 : 1.6; s.cheer = 2.4; } }
72
+ else if (ev.type === 'sweat') this.dirty = true;
73
+ }
74
+ add(data, enter) {
75
+ const s = { data, palette: stagePalette(data.id, data.capability), x: 0, y: 0, home: { x: 0, y: 0 }, facing: 1, phase: phaseOf(data), path: [], frame: 0, ft: 0, flash: 0, celebrate: 0, cheer: 0, gesture: null };
76
+ this.sprites.set(data.id, s);
77
+ const plate = this.el('div', 'ctm-stage-plate'); plate.dataset.id = data.id; plate.tabIndex = 0; plate.setAttribute('role', 'button');
78
+ plate.addEventListener('click', () => this.select(this.selected === data.id ? null : data.id));
79
+ plate.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); plate.click(); } });
80
+ this.overlay.append(plate); this.plates.set(data.id, plate);
81
+ const icon = this.el('div', 'ctm-stage-icon'); icon.hidden = true; this.overlay.append(icon); this.icons.set(data.id, icon);
82
+ const bubble = this.el('div', 'ctm-stage-bubble'); bubble.hidden = true; this.overlay.append(bubble); this.bubbles.set(data.id, bubble);
83
+ this.resize(); if (enter && !this.reduced) this.walkIn(s); this.renderPlate(data.id);
84
+ }
85
+ remove(id) {
86
+ this.sprites.delete(id); for (const map of [this.plates, this.icons, this.bubbles]) { map.get(id)?.remove(); map.delete(id); }
87
+ if (this.selected === id) this.select(null); this.resize();
88
+ }
89
+ /** `notify=false` mirrors the React-held selection without echoing it back. */
90
+ select(id, notify = true) {
91
+ const next = id && this.sprites.has(id) ? id : null, changed = next !== this.selected;
92
+ this.selected = next; for (const [k, p] of this.plates) p.setAttribute('aria-selected', String(k === this.selected));
93
+ if (changed && notify) this.onSelect?.(next);
94
+ if (changed) this.dirty = true;
95
+ }
96
+ /** Canvas click → the member whose desk/body box contains the point (people are drawn over their desks). */
97
+ hit(e) {
98
+ const r = this.canvas.getBoundingClientRect?.(); if (!r) return null;
99
+ const x = e.clientX - r.left, y = e.clientY - r.top, S = this.S;
100
+ for (const [id, s] of this.sprites) if (Math.abs(x - s.home.x) <= 12 * S && y >= s.home.y - 18 * S && y <= s.home.y + 8 * S) return id;
101
+ return null;
102
+ }
103
+ walkIn(s) { s.x = -12 * this.S; s.y = s.home.y + 9 * this.S; s.facing = 1; s.path = [{ x: s.home.x, y: s.y }, { x: s.home.x, y: s.home.y }]; s.phase = 'walking'; }
104
+ /** Fly a letter between two desks; `to == null` means "to the team" (fans out to every seated teammate). */
105
+ courier(fromId, toId) {
106
+ if (this.reduced) return;
107
+ const from = this.sprites.get(fromId); if (!from) return;
108
+ const targets = toId ? [this.sprites.get(toId)].filter(Boolean) : [...this.sprites.values()].filter(s => !s.data.lead && s.data.status !== 'inactive').slice(0, 6);
109
+ for (const to of targets) {
110
+ const dest = { x: to.home.x - 10 * this.S, y: to.home.y + 2 * this.S };
111
+ this.couriers.push({ x: from.home.x, y: from.home.y, from: { ...from.home }, to: dest, reply: !from.data.lead, t: 0, dur: .9 + Math.hypot(dest.x - from.home.x, dest.y - from.home.y) / 420 });
112
+ }
113
+ }
114
+ flash(id) { const b = this.plates.get(id)?.querySelector('.ctm-model-badge'); if (b) { b.removeAttribute('data-flash'); void b.offsetWidth; b.dataset.flash = ''; } }
115
+
116
+ /* ---------- layout ---------- */
117
+ resize() {
118
+ // Measure the CSS height without our own floor, so growing the stage for a tall roster never oscillates.
119
+ this.stage.style.minHeight = '';
120
+ const r = this.stage.getBoundingClientRect(); if (!r.width || !r.height) return;
121
+ const base = Math.round(r.height), all = [...this.sprites.values()], others = all.filter(s => !s.data.lead);
122
+ this.w = Math.round(r.width);
123
+ const cols = this.w < 520 ? 3 : this.w < 1250 ? 6 : 7, rows = 1 + Math.ceil(others.length / cols), plateH = 26, bottom = 14;
124
+ const fit = S => 10 * S + 8 + bottom + rows * (22 * S + plateH) + (rows - 1) * 2;
125
+ let S = this.w < 520 ? 2 : this.w < 980 ? 3 : 4; while (S > 2 && fit(S) > base) S--;
126
+ this.S = S;
127
+ const need = fit(S); this.h = Math.max(base, need);
128
+ if (need > base) this.stage.style.minHeight = need + 'px'; // tiny panes: grow a few px rather than overlap rows
129
+ const dpr = Math.min(3, window.devicePixelRatio || 1);
130
+ this.canvas.width = Math.round(this.w * dpr); this.canvas.height = Math.round(this.h * dpr);
131
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.ctx.imageSmoothingEnabled = false;
132
+ const top = 10 * S + 8, contentH = 22 * S + plateH, cellH = rows > 1 ? (this.h - top - bottom - contentH) / (rows - 1) : 0;
133
+ const spread = Math.min(1, Math.max(.6, this.w / (cols * 150))), cellW = this.w / cols * spread, left = (this.w - cellW * cols) / 2;
134
+ this.plateW = Math.min(this.w >= 1100 ? 160 : 150, Math.floor(cellW * .85));
135
+ const place = (s, cx, cy) => { s.home = { x: Math.round(cx), y: Math.round(cy) }; if (s.phase !== 'walking') { s.x = s.home.x; s.y = s.home.y; } };
136
+ for (const s of all.filter(x => x.data.lead)) place(s, this.w / 2, top + 16 * S);
137
+ others.forEach((s, i) => {
138
+ const row = Math.floor(i / cols), inRow = Math.min(cols, others.length - row * cols), offset = left + (cols - inRow) * cellW / 2;
139
+ place(s, offset + (i % cols + .5) * cellW, top + 16 * S + cellH * (row + 1));
140
+ });
141
+ for (const p of this.plates.values()) p.style.setProperty('--pw', this.plateW + 'px');
142
+ this.dirty = true; this.positionOverlay(); this.draw();
143
+ }
144
+ positionOverlay() {
145
+ const S = this.S, headY = 16 * S, half = this.plateW / 2 + 2;
146
+ for (const [id, s] of this.sprites) {
147
+ const p = this.plates.get(id), i = this.icons.get(id), b = this.bubbles.get(id);
148
+ const px = Math.max(half, Math.min(this.w - half, s.home.x));
149
+ p.style.left = px + 'px'; p.style.top = (s.home.y + 7 * S) + 'px';
150
+ i.style.left = s.x + 'px'; i.style.top = (s.y - headY - 2) + 'px';
151
+ b.style.left = Math.max(70, Math.min(this.w - 70, s.x)) + 'px'; b.style.top = (s.y - headY - (s.data.icon ? 20 : 4)) + 'px';
152
+ }
153
+ }
154
+
155
+ /* ---------- DOM plates ---------- */
156
+ renderPlate(id) {
157
+ const s = this.sprites.get(id), p = this.plates.get(id); if (!s || !p) return;
158
+ const d = s.data, t = this.t; p.innerHTML = ''; p.dataset.status = d.status;
159
+ const name = this.el('div', 'ctm-stage-plate-name');
160
+ const dot = this.el('i', 'ctm-stage-dot'); dot.dataset.s = d.status; dot.title = d.statusLabel;
161
+ name.append(dot, this.el('span', '', d.name));
162
+ const act = this.el('div', 'ctm-stage-plate-act', [d.activityLabel, d.parallel > 1 ? fmt(t('stage.parallel'), { n: d.parallel }) : ''].filter(Boolean).join(' · '));
163
+ act.dataset.act = d.activity ?? '';
164
+ if (d.ctxRatio != null) { const m = this.el('div', 'ctm-stage-meter'); m.dataset.tone = d.ctxRatio >= .9 ? 'error' : d.ctxRatio >= .75 ? 'warn' : ''; m.style.setProperty('--p', Math.round(d.ctxRatio * 100) + '%'); m.title = `${t('live.contextPressure')} ${Math.round(d.ctxRatio * 100)}%`; m.append(this.el('i')); name.append(m); }
165
+ const more = this.el('div', 'ctm-stage-plate-more');
166
+ more.append(this.el('small', 'ctm-sub', [d.capability, d.statusLabel].filter(Boolean).join(' · ')));
167
+ if (d.route) { const b = this.el('span', 'ctm-model-badge', d.routeAlias); b.dataset.grade = d.grade; b.title = d.route; more.append(b); }
168
+ more.append(this.el('div', 'ctm-stage-plate-task', d.task ? `#${d.task.id} ${d.task.subject ?? ''}` : t('live.noTask')));
169
+ if (d.openable && this.onOpen) { const btn = this.el('button', 'ctm-btn ctm-stage-open', t('stage.open')); btn.type = 'button'; btn.addEventListener('click', e => { e.stopPropagation(); this.onOpen(d.memberId); }); more.append(btn); }
170
+ p.append(name, act, more); p.dataset.act = d.activity ?? '';
171
+ p.title = `${d.name} · ${d.activityLabel ?? d.statusLabel}${d.route ? ' · ' + d.route : ''}`; p.setAttribute('aria-label', p.title);
172
+ this.renderIcon(id); this.renderBubble(id, Date.now());
173
+ }
174
+ renderIcon(id) {
175
+ const s = this.sprites.get(id), icon = this.icons.get(id); if (!s || !icon) return;
176
+ const glyph = s.cheer > 0 ? '✅' : s.data.icon ?? ''; icon.textContent = glyph; icon.hidden = !glyph; icon.dataset.act = s.cheer > 0 ? 'done' : s.data.activity ?? '';
177
+ }
178
+ renderBubble(id, now) {
179
+ const s = this.sprites.get(id), bubble = this.bubbles.get(id); if (!s || !bubble) return;
180
+ const text = s.cheer > 0 ? this.t('stage.act.doneBubble') : stageBubbleText(s.data, now, this.t);
181
+ if (bubble.textContent !== text) { bubble.textContent = text; bubble.title = text; }
182
+ bubble.hidden = !text; bubble.dataset.act = s.cheer > 0 ? 'done' : s.data.activity ?? '';
183
+ }
184
+
185
+ /* ---------- loop ---------- */
186
+ busy() {
187
+ return this.couriers.length > 0 || [...this.sprites.values()].some(s => s.phase === 'walking' || s.celebrate > 0 || s.cheer > 0
188
+ || s.data.status === 'running' || STAGE_BUSY_ACTS.has(s.data.activity));
189
+ }
190
+ loop(now) {
191
+ this.schedule();
192
+ if (!this.last) this.last = now;
193
+ // Reduced motion: still repaint state changes and the elapsed clock, but only a few frames per second.
194
+ const idle = !this.busy(), minFrame = this.reduced ? 500 : idle ? 66 : 0; if (now - this.last < minFrame) return;
195
+ const dt = Math.min(.05, (now - this.last) / 1000); this.last = now;
196
+ if (now - this.visibleAt > 1000) { const r = this.stage.getBoundingClientRect(); this.visible = r.width > 0 && r.bottom > 0 && r.top < (window.innerHeight || Infinity); this.visibleAt = now; }
197
+ if ((document.hidden || !this.visible) && !this.dirty) return; // hidden or scrolled-away hosts still get one repaint per change
198
+ this.update(dt); this.draw();
199
+ }
200
+ update(dt) {
201
+ const wall = Date.now(), second = Math.floor(wall / 1000), tickNow = second !== this.tick; this.tick = second;
202
+ for (const [id, s] of this.sprites) {
203
+ s.ft += dt; const rate = s.phase === 'walking' ? .12 : this.reduced ? 1 : STAGE_FRAME_RATE[s.data.activity] ?? .5;
204
+ if (s.ft > rate) { s.ft = 0; s.frame = (s.frame + 1) % 4; }
205
+ const cheered = s.cheer > 0;
206
+ if (s.celebrate > 0) s.celebrate = Math.max(0, s.celebrate - dt);
207
+ if (s.cheer > 0) s.cheer = Math.max(0, s.cheer - dt);
208
+ if (cheered && !s.cheer) { this.renderIcon(id); this.renderBubble(id, wall); }
209
+ s.gesture = s.data.activity === 'idle' && !this.reduced ? stageIdleGesture(id, wall) : null;
210
+ if (tickNow && s.data.bubbleSince != null) this.renderBubble(id, wall);
211
+ if (s.phase === 'walking' && s.path.length) {
212
+ const p = s.path[0], dx = p.x - s.x, dy = p.y - s.y, dist = Math.hypot(dx, dy), step = 70 * dt;
213
+ if (Math.abs(dx) > .5) s.facing = dx > 0 ? 1 : -1;
214
+ if (dist <= step) { s.x = p.x; s.y = p.y; s.path.shift(); if (!s.path.length) s.phase = phaseOf(s.data); } else { s.x += dx / dist * step; s.y += dy / dist * step; }
215
+ }
216
+ }
217
+ for (const c of this.couriers) { c.t += dt / c.dur; const k = Math.min(1, c.t), e = k < .5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; c.x = c.from.x + (c.to.x - c.from.x) * e; c.y = c.from.y + (c.to.y - c.from.y) * e - Math.sin(k * Math.PI) * 24; }
218
+ this.couriers = this.couriers.filter(c => c.t < 1);
219
+ if (this.busy()) this.positionOverlay();
220
+ }
221
+ draw() {
222
+ const { ctx, w, h, S } = this; if (!w) return;
223
+ const c = this.stageColors();
224
+ stageDrawRoom(ctx, this.px, w, h, S, { floorA: c.floorA, floorB: c.floorB, grout: c.grout, wall: c.wall });
225
+ const desk = c.desk, deskTop = c.deskTop, ents = [];
226
+ for (const [id, s] of this.sprites) {
227
+ if (id === this.selected) ents.push({ y: -1, f: () => { const x = s.home.x - 14 * S, y = s.home.y - 19 * S; this.px(x, y, 28 * S, S, c.select); this.px(x, s.home.y + 9 * S, 28 * S, S, c.select); } });
228
+ ents.push({ y: s.home.y + 3 * S, f: () => stageDrawDesk(this.px, s, S, desk, deskTop) });
229
+ if (s.data.status !== 'inactive') ents.push({ y: s.y + (s.phase === 'walking' ? 6 * S : 0), f: () => stageDrawPerson(this.px, s, S) });
230
+ }
231
+ for (const c of this.couriers) ents.push({ y: 1e9, f: () => stageDrawCourier(this.px, c, S) });
232
+ ents.sort((a, b) => a.y - b.y).forEach(e => e.f());
233
+ this.dirty = false;
234
+ }
235
+ }
236
+ function phaseOf(data) { return data.status === 'running' ? 'typing' : 'idle'; }
237
+ /** Seconds per animation frame by activity: fast typing while writing, slow breathing when idle or dozing. */
238
+ const STAGE_FRAME_RATE = { writing: .12, terminal: .18, searching: .25, browsing: .3, messaging: .2, board: .25, working: .22, reading: .7, thinking: .4, waiting: .8, failed: .6, arriving: .3, idle: .5, offline: 1 };
@@ -0,0 +1,65 @@
1
+ // ---------- office stage: boardCards + live projections → sprite data, and the React host component ----------
2
+ /**
3
+ * Pure mapping, one entry per board card. Status/task/model come from boardCards (the same source as the member
4
+ * detail panel); the fine-grained activity, bubble and context pressure come from the member's own live projections.
5
+ */
6
+ function stageSprites(cards, { live, t = k => k, currentMemberId = null, canOpen = false, since } = {}) {
7
+ return (cards ?? []).map(card => {
8
+ const memberId = card.member?.id ?? null;
9
+ const proj = memberId && live?.projections ? live.projections(memberId) : undefined;
10
+ const act = proj?.capabilityTeamActivity, cur = act?.schemaVersion === 1 ? act.current : null;
11
+ const activity = stageActivityOf(card.status, act);
12
+ const toolAct = !['thinking', 'idle', 'offline', 'failed', 'arriving'].includes(activity);
13
+ const cp = proj?.contextPressure, projected = cp?.projectedTokens ?? cp?.pressureTokens;
14
+ const ctxRatio = typeof projected === 'number' && typeof cp?.contextWindow === 'number' && cp.contextWindow > 0 ? Math.min(1, Math.max(0, projected / cp.contextWindow)) : null;
15
+ const badge = card.model ? modelBadgeInfo(card.model, t) : null;
16
+ const task = card.task ? { id: card.task.id, subject: card.task.subject, status: card.task.status } : null;
17
+ const runSince = card.status === 'running' && memberId ? since?.get?.(memberId) ?? null : null;
18
+ const bubble = stageBubbleBase(activity, { cur, task, since: runSince, t });
19
+ return {
20
+ id: card.id, memberId, name: card.name, capability: card.role?.capability ?? (card.lead ? 'orchestrator' : ''), lead: !!card.lead,
21
+ status: card.status, statusLabel: label(t, `live.${card.status}`, card.status),
22
+ activity, activityLabel: label(t, `stage.act.${activity}`, activity), actKey: toolAct ? (cur?.id ?? cur?.startedAt ?? cur?.tool ?? '') : '',
23
+ task, route: card.model ?? null, routeAlias: badge?.alias ?? card.model ?? '', grade: badge?.grade ?? 'default',
24
+ icon: STAGE_ACT_GLYPH[activity] ?? '',
25
+ bubble: bubble.text, bubbleSince: bubble.since ?? null,
26
+ parallel: card.status === 'running' && typeof act?.activeCount === 'number' ? act.activeCount : 0,
27
+ ctxRatio,
28
+ openable: !!(canOpen && !card.lead && memberId && memberId !== currentMemberId && card.status !== 'provisioning' && card.status !== 'failed'),
29
+ };
30
+ });
31
+ }
32
+ const STAGE_COLLAPSED_KEY = 'dsh-capability-team-stage-collapsed';
33
+ /**
34
+ * React host for the pixel office. It renders the header and an empty host div; OfficeScene owns everything inside
35
+ * the host (canvas + DOM plates) so React never reconciles those nodes. Without a DOM (unit tests, SSR) it renders
36
+ * the header only.
37
+ */
38
+ function OfficeStage({ cards, live, t, open, currentMemberId, since, selected = null, onSelect }) {
39
+ const readCollapsed = () => { try { return window.localStorage?.getItem(STAGE_COLLAPSED_KEY) === '1'; } catch { return false; } };
40
+ const [collapsed, setCollapsed] = React.useState(readCollapsed());
41
+ const host = React.useRef(null), sceneRef = React.useRef(null), selectRef = React.useRef(onSelect);
42
+ selectRef.current = onSelect; // the scene outlives renders, so it always calls the latest callback
43
+ const sprites = stageSprites(cards, { live, t, currentMemberId, canOpen: !!open, since });
44
+ const seated = sprites.filter(s => s.status !== 'inactive').length;
45
+ const toggle = () => setCollapsed(prev => { const next = !prev; try { window.localStorage?.setItem(STAGE_COLLAPSED_KEY, next ? '1' : '0'); } catch { /* storage unavailable */ } return next; });
46
+ React.useEffect(() => {
47
+ const el = host.current;
48
+ if (!el || typeof document === 'undefined' || typeof requestAnimationFrame !== 'function') { sceneRef.current?.destroy(); sceneRef.current = null; return; }
49
+ if (!sceneRef.current) { sceneRef.current = new OfficeScene(el, el.querySelector('canvas'), el.querySelector('.ctm-stage-overlay'), { t, onOpen: open ? id => open(id) : null, onSelect: id => selectRef.current?.(id) }); }
50
+ sceneRef.current.t = t; sceneRef.current.sync(sprites); sceneRef.current.select(selected, false);
51
+ });
52
+ React.useEffect(() => () => { sceneRef.current?.destroy(); sceneRef.current = null; }, []);
53
+ return h('section', { className: 'ctm-stage', 'aria-label': t('stage.title'), ...(collapsed ? { 'data-collapsed': '' } : {}) },
54
+ h('div', { className: 'ctm-stage-head' },
55
+ h('span', { className: 'ctm-label' }, t('stage.title')),
56
+ h('span', { className: 'ctm-sub' }, fmt(t('stage.summary'), { n: seated, m: sprites.length })),
57
+ h('button', { type: 'button', className: 'ctm-btn ctm-stage-toggle', 'aria-expanded': !collapsed, onClick: toggle }, t(collapsed ? 'stage.expand' : 'stage.collapse'))),
58
+ collapsed ? null : h('div', { className: 'ctm-stage-office', ref: host },
59
+ h('canvas', { className: 'ctm-stage-canvas', 'aria-hidden': true }),
60
+ h('div', { className: 'ctm-stage-overlay' }),
61
+ sprites.length ? null : h('p', { className: 'ctm-stage-empty ctm-sub' }, t('stage.empty'))));
62
+ }
63
+ // test hooks for the pure helpers
64
+ Object.assign(OfficeStage, { sprites: stageSprites, palette: stagePalette, activityOf: stageActivityOf, bubbleText: stageBubbleText,
65
+ events: stageEvents, idleGesture: stageIdleGesture, toolGlyph: stageToolGlyph });
@@ -0,0 +1,83 @@
1
+ // ---------- office stage: pure live-activity derivation (no DOM; unit-tested through OfficeStage.* hooks) ----------
2
+ /** Tool name → activity. First match wins, so `web_search` is browsing and `read_image` is reading. */
3
+ const STAGE_TOOL_ACTS = [
4
+ ['send_message', 'messaging'], ['spawn', 'messaging'], ['team_task', 'board'], ['wait_agent', 'waiting'],
5
+ ['web', 'browsing'], ['fetch', 'browsing'], ['browse', 'browsing'], ['omni', 'browsing'],
6
+ ['read', 'reading'], ['write', 'writing'], ['edit', 'writing'],
7
+ ['bash', 'terminal'], ['pwsh', 'terminal'], ['shell', 'terminal'], ['exec', 'terminal'],
8
+ ['grep', 'searching'], ['glob', 'searching'], ['search', 'searching'],
9
+ ];
10
+ const STAGE_ACT_GLYPH = { thinking: '💭', reading: '📖', writing: '✍️', terminal: '⌨️', searching: '🔍', browsing: '🌐',
11
+ messaging: '📨', board: '📋', waiting: '⏳', failed: '❗', working: '⚙️', arriving: '🚪', idle: '', offline: '' };
12
+ /** Activities that keep the render loop at full rate (anything animated beyond the idle gestures). */
13
+ const STAGE_BUSY_ACTS = new Set(['thinking', 'reading', 'writing', 'terminal', 'searching', 'browsing', 'messaging', 'board', 'waiting', 'working', 'arriving']);
14
+ function stageToolAct(tool) {
15
+ const name = String(tool ?? '').toLowerCase(); if (!name) return 'working';
16
+ return STAGE_TOOL_ACTS.find(([key]) => name.includes(key))?.[1] ?? 'working';
17
+ }
18
+ function stageToolGlyph(tool) { return tool ? STAGE_ACT_GLYPH[stageToolAct(tool)] : ''; }
19
+ /**
20
+ * One fine-grained activity per member from the pushed status + capabilityTeamActivity projection:
21
+ * offline (no session) / arriving (provisioning) / failed / tool category while a call runs / thinking while the
22
+ * model generates (phase model, settled-but-still-running, or no projection yet) / idle.
23
+ */
24
+ function stageActivityOf(status, act) {
25
+ if (status === 'inactive') return 'offline';
26
+ if (status === 'failed') return 'failed';
27
+ if (status === 'provisioning') return 'arriving';
28
+ if (status !== 'running') return 'idle';
29
+ const cur = act?.schemaVersion === 1 ? act.current : null;
30
+ if (act?.schemaVersion === 1 && act.phase === 'tool' && cur?.status === 'running') return stageToolAct(cur.tool);
31
+ return 'thinking';
32
+ }
33
+ const stageClock = ms => { const s = Math.max(0, Math.floor(ms / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
34
+ /** Bubble = base text + elapsed (recomputed by the scene once a second) + parallel count. */
35
+ function stageBubbleText(d, now, t = k => k) {
36
+ if (!d?.bubble) return '';
37
+ const parts = [d.bubble];
38
+ if (typeof d.bubbleSince === 'number' && now >= d.bubbleSince) parts.push(`· ${stageClock(now - d.bubbleSince)}`);
39
+ if (d.parallel > 1) parts.push(fmt(t('stage.parallel'), { n: d.parallel }));
40
+ return parts.join(' ');
41
+ }
42
+ /** Base bubble text and the start time it counts from, per activity. Idle/offline members stay quiet. */
43
+ function stageBubbleBase(activity, { cur, task, since, t }) {
44
+ const taskText = task ? `#${task.id} ${task.subject ?? ''}`.trim() : '';
45
+ if (activity === 'thinking') return { text: [t('stage.act.thinkingBubble'), taskText].filter(Boolean).join(' '), since };
46
+ if (activity === 'failed') return { text: t('stage.act.failedBubble'), since: null };
47
+ if (activity === 'arriving') return { text: t('stage.act.arriving'), since: null };
48
+ if (activity === 'idle' || activity === 'offline') return { text: '', since: null };
49
+ const tool = [cur?.summary || label(t, `stage.act.${activity}`, cur?.tool ?? ''), cur?.target ? `(${cur.target})` : ''].filter(Boolean).join(' ');
50
+ return { text: tool || t(`stage.act.${activity}`), since: typeof cur?.startedAt === 'number' ? cur.startedAt : since };
51
+ }
52
+ /**
53
+ * Deterministic idle gesture: every 4 s window each member has a hash-picked chance to stretch or sip coffee,
54
+ * so idle desks are alive without randomness (and tests can pin the result).
55
+ */
56
+ function stageIdleGesture(id, nowMs) {
57
+ const slot = Math.floor(nowMs / 4000), n = stageHash(`${id}:${slot}`) % 7;
58
+ return n === 0 ? 'stretch' : n === 1 ? 'coffee' : null;
59
+ }
60
+ /**
61
+ * Diff two sprite lists into one-shot effects. Mail flies Lead → teammate when a task turns in_progress for that
62
+ * teammate or the teammate wakes up while the Lead is messaging; teammate → Lead when the teammate starts a
63
+ * send_message call. Losing an in-progress task (done/removed) celebrates; a changed route flashes the badge.
64
+ */
65
+ function stageEvents(prev, next) {
66
+ const before = new Map((prev ?? []).map(d => [d.id, d])), out = [];
67
+ const lead = (next ?? []).find(d => d.lead), leadWas = lead ? before.get(lead.id) : null;
68
+ const leadMessaging = lead && (lead.activity === 'messaging' || leadWas?.activity === 'messaging');
69
+ for (const d of next ?? []) {
70
+ const was = before.get(d.id); if (!was) continue;
71
+ if (was.route !== d.route && d.route) out.push({ type: 'flash', id: d.id });
72
+ if (d.ctxRatio >= .9 && !(was.ctxRatio >= .9)) out.push({ type: 'sweat', id: d.id });
73
+ if (d.lead || !lead) continue;
74
+ const newTask = d.task?.status === 'in_progress' && (was.task?.id !== d.task.id || was.task?.status !== 'in_progress');
75
+ const woke = leadMessaging && d.status === 'running' && was.status !== 'running';
76
+ if (newTask || woke) out.push({ type: 'mail', from: lead.id, to: d.id });
77
+ if (d.activity === 'messaging' && (was.activity !== 'messaging' || was.actKey !== d.actKey)) out.push({ type: 'mail', from: d.id, to: lead.id });
78
+ if (was.task?.status === 'in_progress' && d.task?.id !== was.task.id) out.push({ type: 'celebrate', id: d.id });
79
+ }
80
+ if (lead && leadWas && lead.activity === 'messaging' && (leadWas.activity !== 'messaging' || leadWas.actKey !== lead.actKey)
81
+ && !out.some(e => e.type === 'mail' && e.from === lead.id)) out.push({ type: 'mail', from: lead.id, to: null });
82
+ return out;
83
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Team workbench: the pixel office plus a member detail panel. Selection lives here (React) — OfficeScene reports
3
+ * clicks through `onSelect`; `selectedId` lets a host (or a test) pin the selection from outside.
4
+ */
5
+ function TeamBoard({ roles, activity, allocations, since, t, open, live, usage, runtimeState, currentMemberId, leadSessionId, selectedId }) {
6
+ const cards = boardCards(localizeRoles(roles, t), activity, allocations);
7
+ const running = cards.filter(c => c.status === 'running').length;
8
+ const [picked, setPicked] = React.useState({ stageSelected: null });
9
+ const selected = selectedId !== undefined ? selectedId : (picked?.stageSelected ?? null);
10
+ const chosen = selected ? cards.find(c => c.id === selected) ?? null : null;
11
+ const openable = c => Boolean(open && !c.lead && c.member?.id && (!currentMemberId || c.member.id !== currentMemberId) && c.status !== 'provisioning' && c.status !== 'failed');
12
+ const tasks = activity?.tasks ?? [];
13
+ const taskRevSig = tasks.map(tk => {
14
+ const b = Array.isArray(tk.blockedBy) ? tk.blockedBy.join(',') : (tk.blockedBy ?? '');
15
+ return `${tk.id}:${tk.revision ?? ''}:${tk.status ?? ''}:${tk.subject ?? ''}:${tk.ownerName ?? ''}:${b}`;
16
+ }).join('|');
17
+ const depsView = React.useMemo ? React.useMemo(() => taskDependencyView(tasks, t), [taskRevSig, t]) : taskDependencyView(tasks, t);
18
+
19
+ // Local 1Hz cooldown clock: run only with cooldowns; stop when hidden and recalculate when visible
20
+ const [now, setNow] = React.useState(Date.now());
21
+ const hasCooling = Boolean(runtimeState?.data?.members?.some(m => Array.isArray(m.cooling) && m.cooling.some(c => (c.until ?? 0) > now)));
22
+ React.useEffect(() => {
23
+ let timer = null;
24
+ const stop = () => {
25
+ if (timer) {
26
+ clearInterval(timer);
27
+ timer = null;
28
+ }
29
+ };
30
+ const start = () => {
31
+ if (timer) return;
32
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
33
+ if (!hasCooling) return;
34
+ timer = setInterval(() => {
35
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
36
+ stop();
37
+ return;
38
+ }
39
+ setNow(Date.now());
40
+ }, 1000);
41
+ };
42
+
43
+ const onVis = () => {
44
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
45
+ setNow(Date.now());
46
+ start();
47
+ } else {
48
+ stop();
49
+ }
50
+ };
51
+
52
+ start();
53
+ if (typeof document !== 'undefined' && document.addEventListener) {
54
+ document.addEventListener('visibilitychange', onVis);
55
+ }
56
+ return () => {
57
+ stop();
58
+ if (typeof document !== 'undefined' && document.removeEventListener) {
59
+ document.removeEventListener('visibilitychange', onVis);
60
+ }
61
+ };
62
+ }, [hasCooling]);
63
+
64
+ const coolingBadges = memberSessionId => {
65
+ if (!memberSessionId || !runtimeState?.data?.members) return null;
66
+ const m = runtimeState.data.members.find(rm => rm.sessionId === memberSessionId);
67
+ if (!m || !Array.isArray(m.cooling) || !m.cooling.length) return null;
68
+ const seenRoutes = new Set();
69
+ const badges = [];
70
+ for (const c of m.cooling) {
71
+ const rKey = typeof c.route === 'object' && c.route ? `${c.route.provider ?? ''}/${c.route.model ?? ''}` : String(c.route ?? '');
72
+ if (seenRoutes.has(rKey)) continue;
73
+ seenRoutes.add(rKey);
74
+ const remainingSec = Math.max(0, Math.ceil(((c.until ?? 0) - now) / 1000));
75
+ if (remainingSec > 0) {
76
+ badges.push(h('span', { key: rKey, className: 'ctm-badge', 'data-tone': 'cooling', title: `${rKey}: ${c.until}` },
77
+ fmt(t('runtime.cooling'), { s: remainingSec })));
78
+ }
79
+ }
80
+ return badges.length ? h('div', { className: 'ctm-chips' }, ...badges) : null;
81
+ };
82
+
83
+ const switchNotices = memberSessionId => {
84
+ if (!memberSessionId || !runtimeState?.data?.members) return null;
85
+ const m = runtimeState.data.members.find(rm => rm.sessionId === memberSessionId);
86
+ if (!m || !Array.isArray(m.switches) || !m.switches.length) return null;
87
+ const seenIds = new Set();
88
+ const uniqueSwitches = [];
89
+ for (const s of m.switches) {
90
+ const sid = s.id ?? `${s.turn}:${s.step}:${s.attempt}`;
91
+ if (seenIds.has(sid)) continue;
92
+ seenIds.add(sid);
93
+ uniqueSwitches.push(s);
94
+ }
95
+ const latest = uniqueSwitches[0];
96
+ if (!latest) return null;
97
+ const rFrom = typeof latest.from === 'object' && latest.from ? `${latest.from.provider ? latest.from.provider + '/' : ''}${latest.from.model ?? ''}` : String(latest.from ?? '');
98
+ const rTo = typeof latest.to === 'object' && latest.to ? `${latest.to.provider ? latest.to.provider + '/' : ''}${latest.to.model ?? ''}` : String(latest.to ?? '');
99
+ const reasonText = label(t, `runtime.reason.${latest.reason}`, latest.reason);
100
+ const detailParts = [latest.code, latest.status].filter(Boolean);
101
+ const detailText = detailParts.length ? ` · ${detailParts.join('/')}` : '';
102
+ const noticeText = t('runtime.switched', { from: rFrom, to: rTo, reason: reasonText, detail: detailText });
103
+ const finalText = noticeText.startsWith('runtime.switched') ? fmt(en['runtime.switched'], { from: rFrom, to: rTo, reason: reasonText, detail: detailText }) : noticeText;
104
+ return h('div', { className: 'ctm-bd-switch', title: finalText }, finalText);
105
+ };
106
+ const card = c => {
107
+ // Item 9: when there is a configured model AND the last actually-requested model differs from it, show
108
+ // both badges instead of collapsing to one route. When there is no configured model to contrast against
109
+ // (e.g. no role/allocation data loaded yet), fall back to a single badge for whichever model is known —
110
+ // this keeps the pre-existing single-badge rendering intact for that case.
111
+ const showDual = !!c.configuredModel && c.modelDiffers;
112
+ const singleModel = showDual ? null : (c.lastRequestModel ?? c.configuredModel);
113
+ const singleBadge = singleModel ? modelBadgeInfo(singleModel, t) : null;
114
+ const singleIsLive = singleModel === c.lastRequestModel && c.liveModel;
115
+ const configuredBadge = showDual ? modelBadgeInfo(c.configuredModel, t) : null;
116
+ const lastRequestBadge = showDual ? modelBadgeInfo(c.lastRequestModel, t) : null;
117
+ // The warning marker replaces the plain "not in config" tag, so the card says it once.
118
+ const lastRequestTag = c.lastRequestIsFallback ? t('live.fallback') : c.routeWarn ? null : t('live.notInConfig');
119
+ const warn = c.routeWarn ? routeWarnBadge(t) : null;
120
+
121
+ return h('div', { key: c.id, className: 'ctm-bd-card', 'data-status': c.status, 'data-card': c.id, ...(c.delegated ? { 'data-delegated': '' } : {}),
122
+ ...(currentMemberId && c.member?.id === currentMemberId ? { 'data-current': '' } : {}),
123
+ ...(openable(c) ? { role: 'button', tabIndex: 0, title: t('live.open'), 'data-open': '', onClick: () => open(c.member.id),
124
+ onKeyDown: event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(c.member.id); } } } : {}) },
125
+ openable(c) ? h('button', {
126
+ type: 'button', className: 'ctm-bd-open-btn', title: t('live.open'), 'aria-label': `${t('live.open')}: ${c.name}`,
127
+ onClick: event => { event.stopPropagation(); open(c.member.id); },
128
+ onKeyDown: event => {
129
+ if (event.key === 'Enter' || event.key === ' ') {
130
+ event.preventDefault();
131
+ event.stopPropagation();
132
+ open(c.member.id);
133
+ }
134
+ },
135
+ }, t('live.openBtn')) : null,
136
+ h('div', { className: 'ctm-bd-top' },
137
+ h('span', { className: 'ctm-bd-avatar', 'aria-hidden': true }, Array.from(c.name)[0] ?? '?'),
138
+ h('div', { className: 'ctm-who' }, h('span', { className: 'ctm-name' }, c.name), h('span', { className: 'ctm-sub' }, c.role?.capability ?? c.id)),
139
+ h('span', { className: 'ctm-bd-state', 'data-tone': LIVE[c.status] },
140
+ h('i', { className: 'ctm-bd-dot', 'aria-hidden': true }), label(t, `live.${c.status}`, c.status),
141
+ c.status === 'running' && since.get(c.member?.id) ? h('span', { className: 'ctm-bd-time' }, elapsed(Date.now() - since.get(c.member.id))) : null)),
142
+ statusBubble(c, t, live),
143
+ singleBadge ? h('div', { className: 'ctm-bd-model', title: `${singleModel} (${t(singleIsLive ? (c.member?.modelFrom === 'pick' ? 'live.leadPick' : 'live.liveModel') : 'live.configModel')})` },
144
+ h('span', { className: 'ctm-ov-hop ctm-model-badge', 'data-grade': singleBadge.grade, ...(singleIsLive ? { 'data-primary': '' } : {}) },
145
+ singleBadge.gradeLabel ? h('span', { className: 'ctm-badge-rank' }, singleBadge.gradeLabel) : null,
146
+ h('span', { className: 'ctm-badge-alias' }, singleBadge.alias),
147
+ singleIsLive ? null : h('span', { className: 'ctm-sub', 'data-config-model': '' }, t('live.configTag'))),
148
+ singleIsLive ? warn : null) : null,
149
+ configuredBadge ? h('div', { className: 'ctm-bd-model', title: `${c.configuredModel} (${t('live.configModel')})` },
150
+ h('span', { className: 'ctm-ov-hop ctm-model-badge', 'data-grade': configuredBadge.grade },
151
+ configuredBadge.gradeLabel ? h('span', { className: 'ctm-badge-rank' }, configuredBadge.gradeLabel) : null,
152
+ h('span', { className: 'ctm-badge-alias' }, configuredBadge.alias))) : null,
153
+ lastRequestBadge ? h('div', { className: 'ctm-bd-model', 'data-last-request': '', title: `${c.lastRequestModel} (${t('live.liveModel')})` },
154
+ h('span', { className: 'ctm-ov-hop ctm-model-badge', 'data-grade': lastRequestBadge.grade, 'data-primary': '' },
155
+ h('span', { className: 'ctm-badge-rank' }, t('live.lastRequest')),
156
+ h('span', { className: 'ctm-badge-alias' }, lastRequestBadge.alias),
157
+ lastRequestTag ? h('span', { className: 'ctm-sub' }, lastRequestTag) : null),
158
+ warn) : null,
159
+ cardMeters(c, live, usage, t),
160
+ coolingBadges(c.member?.id),
161
+ switchNotices(c.member?.id),
162
+ leadSessionId && c.member && !c.lead && runtimeState?.data?.taskControl
163
+ ? h(TaskControl, { leadSessionId, roleId: c.id, name: c.name, configured: c.configuredModel, control: runtimeState.data.taskControl, t }) : null,
164
+ c.member?.diagnostics?.length ? h('div', { className: 'ctm-sub ctm-warn' }, c.member.diagnostics.join(t('common.listSep'))) : null,
165
+ h('div', { className: 'ctm-bd-bar', 'aria-hidden': true }));
166
+ };
167
+ return h('div', { className: 'ctm-bd' },
168
+ h('div', { className: 'ctm-bd-head' },
169
+ h('span', { className: 'ctm-label' }, t('live.title')),
170
+ running ? badge(fmt(t('live.runningCount'), { n: running }), 'brand', 'run') : null,
171
+ activity?.at ? h('span', { className: 'ctm-sub ctm-bd-at' }, h('i', { className: 'ctm-bd-pulse', 'aria-hidden': true }), t('live.polling')) : null),
172
+ activity?.unavailable ? h('p', { className: 'ctm-ov-note ctm-warn' }, activity.unavailable) : null,
173
+ h(OfficeStage, { cards, live, t, open, currentMemberId, since, selected: chosen?.id ?? null, onSelect: id => setPicked({ stageSelected: id ?? null }) }),
174
+ cards.length ? h('section', { className: 'ctm-stage-detail', 'aria-live': 'polite', 'aria-label': t('stage.detail') },
175
+ chosen ? h('div', { className: 'ctm-stage-detail-head' },
176
+ h('span', { className: 'ctm-label' }, t('stage.detail')),
177
+ h('button', { type: 'button', className: 'ctm-btn', onClick: () => setPicked({ stageSelected: null }) }, t('stage.detailClose'))) : null,
178
+ chosen ? card(chosen) : h('p', { className: 'ctm-sub ctm-stage-detail-hint' }, t('stage.detailHint'))) : null,
179
+ depsView);
180
+ }
181
+ TeamBoard.detail = (props, id) => TeamBoard({ ...props, selectedId: id }); // test/host hook: board with one member's detail open