@aiwuk/team 0.0.0-stage → 5.0.3
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/CHANGELOG.md +156 -0
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/README.zh-CN.md +150 -0
- package/access.js +93 -0
- package/activity-summary.js +36 -0
- package/allocation.js +34 -0
- package/board-dispatch.js +141 -0
- package/brand.js +41 -0
- package/client/00-core-zh.js +284 -0
- package/client/01-i18n-en.js +265 -0
- package/client/02-styles.js +336 -0
- package/client/02b-stage-styles.js +65 -0
- package/client/03-shared.js +82 -0
- package/client/03b-collapsible-list.js +34 -0
- package/client/03c-config-panel.js +605 -0
- package/client/04-tool-cards.js +110 -0
- package/client/04a-dispatch-confirm.js +162 -0
- package/client/04b-more-cards.js +78 -0
- package/client/04c-model-picker.js +68 -0
- package/client/04d-confirm-dock.js +40 -0
- package/client/05-policy.js +217 -0
- package/client/06-usage.js +195 -0
- package/client/06b-model-source.js +67 -0
- package/client/07-board-parts.js +312 -0
- package/client/07a-role-theme.js +25 -0
- package/client/08-artifacts-report.js +375 -0
- package/client/08a-task-control.js +126 -0
- package/client/08b-stage-sprites.js +97 -0
- package/client/08c-stage-colors.js +55 -0
- package/client/08c-stage-scene.js +238 -0
- package/client/08d-stage-adapter.js +65 -0
- package/client/08e-stage-activity.js +83 -0
- package/client/09-team-board.js +181 -0
- package/client/09b-role-view.js +95 -0
- package/client/09c-model-tabs.js +20 -0
- package/client/10-overview.js +364 -0
- package/client/10b-onboarding.js +121 -0
- package/client/11-register.js +249 -0
- package/client.js +5053 -0
- package/config-projection.js +40 -0
- package/config.js +411 -0
- package/cordis.patch.yml +110 -0
- package/defaults.js +222 -0
- package/dispatch-confirm.js +293 -0
- package/dispatch-routes-store.js +31 -0
- package/failover.js +85 -0
- package/file-limits.js +119 -0
- package/free-worker.mjs +125 -0
- package/http-guard.js +14 -0
- package/i18n/roles.en.json +23 -0
- package/i18n/roles.zh.json +23 -0
- package/i18n/server.en.json +659 -0
- package/i18n/server.zh.json +659 -0
- package/i18n.js +34 -0
- package/icon.svg +7 -0
- package/index.js +111 -0
- package/integrations-config.js +301 -0
- package/integrations-messages.js +62 -0
- package/integrations.js +17 -0
- package/lead-route.js +30 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/long-context.js +30 -0
- package/managed-config-route.js +49 -0
- package/managed-ref.js +38 -0
- package/managed-resolve.js +47 -0
- package/member-task-control.js +242 -0
- package/memory-backends.js +89 -0
- package/memory-embed.js +70 -0
- package/memory-hub.js +150 -0
- package/memory-openviking.js +101 -0
- package/memory-redact.js +34 -0
- package/memory-store.js +219 -0
- package/memory-sync-queue.js +28 -0
- package/model-policy.js +85 -0
- package/model-prefs.js +104 -0
- package/observability-api.js +76 -0
- package/observability-projection.js +141 -0
- package/onboarding.js +47 -0
- package/opencode.js +74 -0
- package/package.json +153 -4
- package/plan-gate.js +92 -0
- package/preset-guard.js +47 -0
- package/request-evidence.js +41 -0
- package/roles.js +69 -0
- package/roster-defaults.js +70 -0
- package/route-health.js +39 -0
- package/runtime-observability.js +78 -0
- package/runtime-view.js +21 -0
- package/scripts/memory-cli.mjs +138 -0
- package/search-engines.js +119 -0
- package/search.js +164 -0
- package/session-policy.js +59 -0
- package/session-rotation.js +206 -0
- package/settings-schema.js +40 -0
- package/shared/managed-config.js +84 -0
- package/single-model.js +44 -0
- package/skills/project-role-theme/SKILL.md +46 -0
- package/task-bridge.js +32 -0
- package/task-control-api.js +36 -0
- package/task-control-audit.js +60 -0
- package/team.js +101 -0
- package/teammate-context.js +120 -0
- package/teammate-wait.js +18 -0
- package/template.js +46 -0
- package/theme-plugin.js +172 -0
- package/theme.js +115 -0
- package/token-discipline.js +116 -0
- package/token-metrics.js +37 -0
- package/tool-names.js +10 -0
- package/tool-schema.js +23 -0
- package/transcribe.js +104 -0
- package/ui.js +113 -0
- package/usage.js +92 -0
- package/workflow.js +246 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// ---------- capability_dispatch_plan ----------
|
|
2
|
+
function DispatchPlanView(props) {
|
|
3
|
+
const t = props.t;
|
|
4
|
+
const s = stage(props);
|
|
5
|
+
const plan = s.data ?? s.args;
|
|
6
|
+
const mode = plan.mode;
|
|
7
|
+
const assignments = Array.isArray(plan.assignments) ? plan.assignments : [];
|
|
8
|
+
const summary = s.state === 'error' ? firstLine(cleanError(s.output))
|
|
9
|
+
: mode === 'delegate' ? assignments.map(a => a.displayName ?? a.role).filter(Boolean).join(t('common.listSep'))
|
|
10
|
+
: plan.reason ?? '';
|
|
11
|
+
const sections = [];
|
|
12
|
+
if (s.state === 'error') sections.push(errorBody(s, t));
|
|
13
|
+
else if (s.state === 'ok' && !s.data) sections.push(rawBody(s, t));
|
|
14
|
+
else {
|
|
15
|
+
if (plan.analysis) sections.push(section(t('plan.analysis'), h('div', { className: 'ctm-text' }, plan.analysis)));
|
|
16
|
+
if (plan.boardError) sections.push(section(null, h('p', { className: 'ctm-sub ctm-warn' }, plan.boardError)));
|
|
17
|
+
if (plan.reason) sections.push(section(t('plan.reason'), h('div', { className: 'ctm-text' }, plan.reason)));
|
|
18
|
+
if (assignments.length) sections.push(section(t('plan.assignments'), h('div', { className: 'ctm-grid' }, ...assignments.flatMap((a, i) => [
|
|
19
|
+
h('div', { className: 'ctm-who', key: `w${i}` }, h('span', { className: 'ctm-name' }, a.displayName ?? a.role),
|
|
20
|
+
h('span', { className: 'ctm-sub' }, [a.displayName ? a.role : null, a.capability, a.model].filter(Boolean).join(' · ')),
|
|
21
|
+
Array.isArray(a.fallbacks) && a.fallbacks.length ? h('span', { className: 'ctm-sub' }, fmt(t('plan.fallbacks'), { list: a.fallbacks.join(t('common.listSep')) })) : null),
|
|
22
|
+
h('div', { className: 'ctm-text', key: `t${i}` }, a.task,
|
|
23
|
+
Array.isArray(a.boardTasks) && a.boardTasks.length ? h('ul', { className: 'ctm-plan-board' }, ...a.boardTasks.map(bt => h('li', { key: bt.id },
|
|
24
|
+
h('code', null, bt.id), ' ', bt.subject,
|
|
25
|
+
bt.writeScopes?.length ? h('span', { className: 'ctm-sub' }, ' · ', fmt(t('plan.boardScope'), { list: bt.writeScopes.join(t('common.listSep')) })) : null,
|
|
26
|
+
bt.blockedBy?.length ? h('span', { className: 'ctm-sub' }, ' · ', fmt(t('plan.boardAfter'), { list: bt.blockedBy.join(t('common.listSep')) })) : null))) : null),
|
|
27
|
+
]))));
|
|
28
|
+
const facts = [];
|
|
29
|
+
if (plan.freeTools) facts.push(chip(t('free.tools'), t(`tools.${plan.freeTools}`), 'tools'));
|
|
30
|
+
if (plan.directBudget !== undefined) facts.push(h('span', { className: 'ctm-sub', key: 'budget' }, plan.directBudget === null ? t('plan.unlimited') : fmt(t('plan.budget'), { n: plan.directBudget })));
|
|
31
|
+
if (plan.next) sections.push(section(t('plan.next'), facts.length ? h('div', { className: 'ctm-chips' }, ...facts) : null, h('div', { className: 'ctm-text ctm-sub' }, plan.next)));
|
|
32
|
+
}
|
|
33
|
+
const body = sections.filter(Boolean);
|
|
34
|
+
const card = h(Card, { tool: 'capability_dispatch_plan', icon: ICONS.plan, title: t('plan.title'), summary, state: s.state, t, inspect: props.inspect,
|
|
35
|
+
badges: [mode && t(`mode.${mode}`) !== `mode.${mode}` ? badge(t(`mode.${mode}`), mode === 'delegate' ? 'brand' : undefined, 'mode') : null,
|
|
36
|
+
plan.confirmed?.by === 'user' ? badge(t('confirm.confirmedBy'), 'success', 'confirmed') : null],
|
|
37
|
+
body: body.length ? body : null });
|
|
38
|
+
// While the call waits for the user, the confirmation card opens in the dock above the input box (this row is
|
|
39
|
+
// folded into the turn's steps); without a dock it sits right under this row, already open.
|
|
40
|
+
if (s.state !== 'running') return card;
|
|
41
|
+
const direct = mode === 'direct' && s.args.userRequested === true;
|
|
42
|
+
if (!direct && mode !== 'delegate') return card;
|
|
43
|
+
if (dockedSessions.has(props.sessionId)) return h('div', { className: 'ctm-dc-wrap' }, card, h('p', { className: 'ctm-sub ctm-dc-pointer' }, t('confirm.docked')));
|
|
44
|
+
return h('div', { className: 'ctm-dc-wrap' }, card, h(direct ? DirectConfirmCard : DispatchConfirmCard, { sessionId: props.sessionId, callId: props.callId, t }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------- capability_free_task ----------
|
|
48
|
+
function FreeTaskView(props) {
|
|
49
|
+
const t = props.t;
|
|
50
|
+
const s = stage(props);
|
|
51
|
+
const r = s.data;
|
|
52
|
+
const subject = s.args.subject || firstLine(s.args.prompt);
|
|
53
|
+
const changed = Array.isArray(r?.changedFiles) ? r.changedFiles : [];
|
|
54
|
+
const summary = s.state === 'error' ? firstLine(cleanError(s.output)) : subject;
|
|
55
|
+
const badges = [];
|
|
56
|
+
if (r?.model) badges.push(badge(String(r.model).replace(/^opencode\//, ''), 'success', 'model'));
|
|
57
|
+
if (changed.length) badges.push(badge(fmt(t('free.changedCount'), { n: changed.length }), 'brand', 'changed'));
|
|
58
|
+
if (r?.skipped?.length) badges.push(badge(`${t('free.skipped')} ${r.skipped.length}`, 'warn', 'skipped'));
|
|
59
|
+
const sections = [];
|
|
60
|
+
if (r) {
|
|
61
|
+
const calls = Array.isArray(r.toolCalls) ? r.toolCalls.length : 0;
|
|
62
|
+
sections.push(section(null, h('div', { className: 'ctm-chips' },
|
|
63
|
+
chip(t('free.model'), r.model, 'm'),
|
|
64
|
+
r.tools ? chip(t('free.tools'), t(`tools.${r.tools}`), 'tl') : null,
|
|
65
|
+
r.taskId ? chip(t('free.task'), `#${r.taskId}`, 'tk') : null,
|
|
66
|
+
calls ? chip('', fmt(t('free.calls'), { n: calls }), 'c') : null)));
|
|
67
|
+
if (r.skipped?.length) sections.push(section(t('free.skipped'), h('ul', { className: 'ctm-list ctm-warn' }, ...r.skipped.map((x, i) => h('li', { key: i, className: 'ctm-sub ctm-warn' }, x)))));
|
|
68
|
+
if (changed.length) sections.push(section(t('free.changed'), h('ul', { className: 'ctm-list' }, ...changed.map(file => h('li', { key: file },
|
|
69
|
+
h('button', { type: 'button', className: 'ctm-link', onClick: () => props.openFile?.(file) }, file))))));
|
|
70
|
+
if (r.text) sections.push(section(t('free.result'), h('pre', { className: 'ctm-pre' }, r.text)));
|
|
71
|
+
} else if (s.state === 'error') sections.push(errorBody(s, t));
|
|
72
|
+
else if (s.state === 'ok') sections.push(rawBody(s, t));
|
|
73
|
+
if (s.args.prompt) sections.push(section(t('free.prompt'), h('pre', { className: 'ctm-pre ctm-sub', 'data-short': '' }, s.args.prompt)));
|
|
74
|
+
const body = sections.filter(Boolean);
|
|
75
|
+
return h(Card, { tool: 'capability_free_task', icon: ICONS.free, title: t('free.title'), summary, state: s.state, t, inspect: props.inspect, badges, body: body.length ? body : null });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------- capability_team_read / capability_team_save ----------
|
|
79
|
+
function rolesGrid(roles, t) {
|
|
80
|
+
return h('div', { className: 'ctm-grid3' }, ...roles.flatMap((r, i) => [
|
|
81
|
+
h('div', { className: 'ctm-who', key: `n${i}` }, h('span', { className: 'ctm-name' }, r.displayName ?? r.id), h('span', { className: 'ctm-sub' }, r.id)),
|
|
82
|
+
h('span', { className: 'ctm-sub', key: `c${i}` }, [r.capability, r.tier].filter(Boolean).join(' · ')),
|
|
83
|
+
h('div', { className: 'ctm-who', key: `m${i}` },
|
|
84
|
+
h('span', null, r.provider ? `${r.provider}/${r.model}` : t('team.unassigned')),
|
|
85
|
+
r.singleModel ? h('span', { className: 'ctm-badge', 'data-tone': 'warn', 'data-single-model': '' }, t('roles.singleModel')) : null,
|
|
86
|
+
Array.isArray(r.fallbacks) && r.fallbacks.length ? h('span', { className: 'ctm-sub' }, fmt(t('team.fallbacks'), { n: r.fallbacks.length })) : null),
|
|
87
|
+
]));
|
|
88
|
+
}
|
|
89
|
+
function teamView(kind) {
|
|
90
|
+
return function TeamView(props) {
|
|
91
|
+
const t = props.t;
|
|
92
|
+
const s = stage(props);
|
|
93
|
+
const d = s.data;
|
|
94
|
+
const roles = Array.isArray(d?.roles) ? d.roles : [];
|
|
95
|
+
const summary = s.state === 'error' ? firstLine(cleanError(s.output))
|
|
96
|
+
: !d ? ''
|
|
97
|
+
: kind === 'save' ? fmt(t('team.saved'), { rev: d.revision })
|
|
98
|
+
: fmt(t('team.summary'), { name: d.effective?.name ?? '', rev: d.revision, n: roles.length });
|
|
99
|
+
const sections = [];
|
|
100
|
+
if (d) {
|
|
101
|
+
if (roles.length) sections.push(section(t('team.roles'), rolesGrid(roles, t)));
|
|
102
|
+
if (d.coolingRoutes?.length) sections.push(section(t('team.cooling'), h('div', { className: 'ctm-text ctm-warn' }, d.coolingRoutes.map(c => typeof c === 'string' ? c : JSON.stringify(c)).join('\n'))));
|
|
103
|
+
if (d.project) sections.push(section(t('team.project'), h('span', { className: 'ctm-sub' }, d.project)));
|
|
104
|
+
} else sections.push(errorBody(s, t) ?? rawBody(s, t));
|
|
105
|
+
const body = sections.filter(Boolean);
|
|
106
|
+
return h(Card, { tool: `capability_team_${kind}`, icon: ICONS.team, title: t(kind === 'save' ? 'team.saveTitle' : 'team.readTitle'), summary,
|
|
107
|
+
summaryTone: kind === 'save' && d ? '' : undefined, state: s.state, t, inspect: props.inspect, body: body.length ? body : null,
|
|
108
|
+
badges: kind === 'save' && d?.saved ? [badge('✓', 'success', 'ok')] : [] });
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// ---------- dispatch confirmation card ----------
|
|
2
|
+
// Shown under a running capability_dispatch_plan call: the user checks each role's model and up to three
|
|
3
|
+
// fallbacks, edits them, and confirms or cancels. Answers go to api/capability-team.dispatch-confirm;
|
|
4
|
+
// the tool keeps waiting until then, so nobody is dispatched before the user decides.
|
|
5
|
+
const CONFIRM_PATH = 'api/capability-team.dispatch-confirm';
|
|
6
|
+
const CONFIRM_HEARTBEAT_MS = 5000; // keep polling while pending so the tool knows the card is still on screen
|
|
7
|
+
const rkey = r => (r ? `${r.provider}/${r.model}` : '');
|
|
8
|
+
function usePendingConfirm(sessionId, callId, reload) {
|
|
9
|
+
const [view, setView] = React.useState(null); // null loading · {status:'stale'|'gone'} · pending view
|
|
10
|
+
React.useEffect(() => {
|
|
11
|
+
if (!sessionId || !callId) return undefined;
|
|
12
|
+
const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
13
|
+
let tries = 0, timer = null, live = true, wasPending = false;
|
|
14
|
+
const again = ms => { timer = setTimeout(load, ms); };
|
|
15
|
+
const load = async () => {
|
|
16
|
+
timer = null;
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(apiUrl(CONFIRM_PATH, { sessionId, callId }), { signal: ctrl?.signal });
|
|
19
|
+
const body = res.ok ? await res.json() : null;
|
|
20
|
+
if (!live) return;
|
|
21
|
+
// Pending: show it once (refresh only when the native question box takes over), keep polling as a heartbeat.
|
|
22
|
+
if (body?.status === 'pending') { setView(v => (wasPending && v?.status === 'pending' && v.native === body.native ? v : body)); wasPending = true; again(CONFIRM_HEARTBEAT_MS); return; }
|
|
23
|
+
// It was pending and is gone now: answered here or in the question box, or ended on the server.
|
|
24
|
+
if (wasPending) { setView(v => (v?.status === 'pending' ? { status: 'gone' } : v)); return; }
|
|
25
|
+
// The tool registers its request right after it starts; retry briefly before giving up.
|
|
26
|
+
if (++tries < 6) { again(500); return; }
|
|
27
|
+
// The call is still running but has no request: the plugin reloaded or the request expired.
|
|
28
|
+
setView({ status: 'stale' });
|
|
29
|
+
} catch (e) {
|
|
30
|
+
if (!live || e?.name === 'AbortError') return;
|
|
31
|
+
if (wasPending) again(CONFIRM_HEARTBEAT_MS); else setView({ status: 'stale' });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
load();
|
|
35
|
+
return () => { live = false; ctrl?.abort(); if (timer) clearTimeout(timer); };
|
|
36
|
+
}, [sessionId, callId, reload]);
|
|
37
|
+
return [view, setView];
|
|
38
|
+
}
|
|
39
|
+
// Shown when the Lead asks to finish the user's request itself: allowing lifts its step limit until the next message.
|
|
40
|
+
function DirectConfirmCard({ sessionId, callId, t }) {
|
|
41
|
+
const [view] = usePendingConfirm(sessionId, callId, 0);
|
|
42
|
+
const [busy, setBusy] = React.useState(false);
|
|
43
|
+
const [error, setError] = React.useState(null);
|
|
44
|
+
const [sent, setSent] = React.useState(null);
|
|
45
|
+
if (sent) return h('div', { className: 'ctm-dc', 'data-state': 'sent' }, h('span', { className: 'ctm-sub' }, t(sent === 'cancel' ? 'direct.cancelled' : 'direct.sent')));
|
|
46
|
+
if (!view) return h('div', { className: 'ctm-dc' }, h('span', { className: 'ctm-sub' }, t('direct.loading')));
|
|
47
|
+
if (view.status === 'stale') return h('div', { className: 'ctm-dc', 'data-state': 'stale' }, h('p', { className: 'ctm-sub ctm-warn', role: 'alert' }, t('direct.stale')));
|
|
48
|
+
if (view.status !== 'pending') return null;
|
|
49
|
+
if (view.native) return h('div', { className: 'ctm-dc', 'data-state': 'native' }, h('span', { className: 'ctm-sub' }, t('direct.native')));
|
|
50
|
+
const submit = async decision => {
|
|
51
|
+
setBusy(true); setError(null);
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(apiUrl(CONFIRM_PATH), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, callId, decision }) });
|
|
54
|
+
if (res.ok) setSent(decision); else setError(t('confirm.error.other'));
|
|
55
|
+
} catch { setError(t('confirm.error.other')); }
|
|
56
|
+
finally { setBusy(false); }
|
|
57
|
+
};
|
|
58
|
+
return h('div', { className: 'ctm-dc', role: 'group', 'aria-label': t('direct.title') },
|
|
59
|
+
h('div', { className: 'ctm-dc-head' },
|
|
60
|
+
h('strong', { className: 'ctm-dc-title' }, t('direct.title')),
|
|
61
|
+
h('span', { className: 'ctm-sub' }, t('direct.hint'))),
|
|
62
|
+
view.reason ? h('p', { className: 'ctm-text' }, view.reason) : null,
|
|
63
|
+
h('div', { className: 'ctm-dc-foot' },
|
|
64
|
+
error ? h('p', { className: 'ctm-sub ctm-warn', role: 'alert' }, error) : null,
|
|
65
|
+
h('div', { className: 'ctm-dc-buttons' },
|
|
66
|
+
h('button', { type: 'button', className: 'ctm-btn', disabled: busy, onClick: () => submit('cancel') }, t('direct.deny')),
|
|
67
|
+
h('button', { type: 'button', className: 'ctm-btn', 'data-primary': '', disabled: busy, onClick: () => submit('confirm') }, t('direct.allow')))));
|
|
68
|
+
}
|
|
69
|
+
function DispatchConfirmCard({ sessionId, callId, t }) {
|
|
70
|
+
const [reload, setReload] = React.useState(0);
|
|
71
|
+
const [view, setView] = usePendingConfirm(sessionId, callId, reload);
|
|
72
|
+
const [edits, setEdits] = React.useState({}); // role → { primary, fallbacks }
|
|
73
|
+
const [scope, setScope] = React.useState('task');
|
|
74
|
+
const [busy, setBusy] = React.useState(false);
|
|
75
|
+
const [error, setError] = React.useState(null);
|
|
76
|
+
const [sent, setSent] = React.useState(null);
|
|
77
|
+
const [expanded, setExpanded] = React.useState({}); // role → task text shown in full
|
|
78
|
+
if (sent) return h('div', { className: 'ctm-dc', 'data-state': 'sent' }, h('span', { className: 'ctm-sub' }, t(sent === 'cancel' ? 'confirm.cancelled' : 'confirm.sent')));
|
|
79
|
+
if (!view) return h('div', { className: 'ctm-dc' }, h('span', { className: 'ctm-sub' }, t('confirm.loading')));
|
|
80
|
+
if (view.status === 'stale') return h('div', { className: 'ctm-dc', 'data-state': 'stale' }, h('p', { className: 'ctm-sub ctm-warn', role: 'alert' }, t('confirm.stale')));
|
|
81
|
+
if (view.status !== 'pending') return null; // Answered in the question box or ended on the server.
|
|
82
|
+
if (view.native) return h('div', { className: 'ctm-dc', 'data-state': 'native' }, h('span', { className: 'ctm-sub' }, t('confirm.native')));
|
|
83
|
+
const max = view.maxFallbacks ?? 3;
|
|
84
|
+
const current = r => edits[r.role] ?? { primary: rkey(r.primary), fallbacks: r.fallbacks.map(rkey) };
|
|
85
|
+
const isCrossTier = (r, route) => r.candidates.some(candidate => rkey(candidate) === route && candidate.sameTier === false);
|
|
86
|
+
const hasCrossTier = view.routes.some(r => [current(r).primary, ...current(r).fallbacks].some(route => isCrossTier(r, route)));
|
|
87
|
+
const effectiveScope = hasCrossTier ? 'task' : scope;
|
|
88
|
+
const update = (r, change) => { setEdits(prev => ({ ...prev, [r.role]: { ...current(r), ...change } })); setScope('task'); };
|
|
89
|
+
const move = (list, i, d) => { const next = [...list]; const j = i + d; if (j < 0 || j >= next.length) return list; [next[i], next[j]] = [next[j], next[i]]; return next; };
|
|
90
|
+
const submit = async decision => {
|
|
91
|
+
setBusy(true); setError(null);
|
|
92
|
+
try {
|
|
93
|
+
const routes = view.routes.map(r => ({ role: r.role, ...current(r) }));
|
|
94
|
+
const res = await fetch(apiUrl(CONFIRM_PATH), { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
95
|
+
body: JSON.stringify({ sessionId, callId, decision, scope: effectiveScope, catalogVersion: view.catalogVersion, routes }) });
|
|
96
|
+
if (res.ok) { setSent(decision); return; }
|
|
97
|
+
const body = await res.json().catch(() => ({}));
|
|
98
|
+
if (res.status === 409) { setError(t('confirm.error.conflict')); setEdits({}); setView(null); setReload(n => n + 1); return; }
|
|
99
|
+
setError(res.status === 422 ? `${t('confirm.error.invalid')} ${String(body.message ?? '')}` : t('confirm.error.other'));
|
|
100
|
+
} catch { setError(t('confirm.error.other')); }
|
|
101
|
+
finally { setBusy(false); }
|
|
102
|
+
};
|
|
103
|
+
const changedCaps = view.routes.filter(r => { const c = current(r); return c.primary !== rkey(r.primary) || c.fallbacks.join() !== r.fallbacks.map(rkey).join(); }).map(r => r.capability);
|
|
104
|
+
const row = r => {
|
|
105
|
+
const c = current(r);
|
|
106
|
+
const options = [...new Set([c.primary, ...r.candidates.map(rkey)])].filter(Boolean);
|
|
107
|
+
const recommended = r.candidates.filter(candidate => candidate.sameTier).map(rkey);
|
|
108
|
+
const spare = options.filter(k => k !== c.primary && !c.fallbacks.includes(k));
|
|
109
|
+
const who = r.displayName ?? r.role;
|
|
110
|
+
const long = r.task && (r.task.length > 120 || r.task.includes('\n'));
|
|
111
|
+
const iconButton = (glyph, labelKey, f, disabledWhen, onClick) => h('button', { type: 'button', className: 'ctm-dc-icon', disabled: busy || disabledWhen,
|
|
112
|
+
'aria-label': `${t(labelKey)} ${f}`, title: t(labelKey), onClick }, glyph);
|
|
113
|
+
return h('section', { key: r.role, className: 'ctm-dc-role', 'aria-label': who },
|
|
114
|
+
h('header', { className: 'ctm-dc-role-head' },
|
|
115
|
+
h('span', { className: 'ctm-dc-avatar', 'aria-hidden': true }, Array.from(who)[0] ?? '?'),
|
|
116
|
+
h('div', { className: 'ctm-dc-who' },
|
|
117
|
+
h('span', { className: 'ctm-name' }, who),
|
|
118
|
+
h('span', { className: 'ctm-dc-meta' },
|
|
119
|
+
h('code', null, r.role), h('span', null, r.capability), badge(label(t, `tier.${r.tier}`, r.tier), 'brand', 'tier'),
|
|
120
|
+
r.learned?.habit ? badge(fmt(t('confirm.habit'), { n: r.learned.habit.count, of: r.learned.habit.of }), 'success', 'habit') : null))),
|
|
121
|
+
r.task ? h('div', { className: 'ctm-dc-task' },
|
|
122
|
+
h('p', { className: 'ctm-text', 'data-clamped': expanded[r.role] ? undefined : '' }, r.task),
|
|
123
|
+
long ? h('button', { type: 'button', className: 'ctm-dc-link', onClick: () => setExpanded(prev => ({ ...prev, [r.role]: !prev[r.role] })) },
|
|
124
|
+
t(expanded[r.role] ? 'confirm.collapse' : 'confirm.expand')) : null) : null,
|
|
125
|
+
h('div', { className: 'ctm-dc-grid' },
|
|
126
|
+
h('span', { className: 'ctm-label ctm-dc-key' }, t('confirm.primary'), isCrossTier(r, c.primary) ? badge(t('confirm.crossTier'), 'warn', 'cross-tier') : null),
|
|
127
|
+
h(ModelPicker, { value: c.primary, options, recommended, disabled: busy, t, label: `${who} ${t('confirm.primary')}`, placeholder: t('picker.search'),
|
|
128
|
+
onChange: k => update(r, { primary: k, fallbacks: c.fallbacks.filter(f => f !== k) }) }),
|
|
129
|
+
h('span', { className: 'ctm-label ctm-dc-key' }, t('confirm.fallbacks'), h('span', { className: 'ctm-dc-count' }, `${c.fallbacks.length}/${max}`)),
|
|
130
|
+
h('div', { className: 'ctm-dc-fallbacks' },
|
|
131
|
+
c.fallbacks.length ? h('ol', { className: 'ctm-dc-list' }, ...c.fallbacks.map((f, i) => h('li', { key: f, className: 'ctm-dc-item' },
|
|
132
|
+
h('span', { className: 'ctm-dc-index', 'aria-hidden': true }, String(i + 1)),
|
|
133
|
+
h('span', { className: 'ctm-dc-route', title: f }, f, isCrossTier(r, f) ? badge(t('confirm.crossTierFallback'), 'warn', 'cross-tier') : null, r.learned?.unhealthy?.[f] ? badge(fmt(t('confirm.failing'), { n: r.learned.unhealthy[f] }), 'warn', 'bad') : null),
|
|
134
|
+
h('span', { className: 'ctm-dc-actions' },
|
|
135
|
+
iconButton('↑', 'confirm.up', f, i === 0, () => update(r, { fallbacks: move(c.fallbacks, i, -1) })),
|
|
136
|
+
iconButton('↓', 'confirm.down', f, i === c.fallbacks.length - 1, () => update(r, { fallbacks: move(c.fallbacks, i, 1) })),
|
|
137
|
+
iconButton('×', 'confirm.remove', f, false, () => update(r, { fallbacks: c.fallbacks.filter(x => x !== f) }))))))
|
|
138
|
+
: h('span', { className: 'ctm-sub' }, t('confirm.noFallbacks')),
|
|
139
|
+
c.fallbacks.length < max && spare.length
|
|
140
|
+
? h(ModelPicker, { value: '', options: spare, recommended, disabled: busy, t, label: t('confirm.addFallback'), placeholder: t('confirm.addFallback'),
|
|
141
|
+
onChange: k => update(r, { fallbacks: [...c.fallbacks, k] }) })
|
|
142
|
+
: c.fallbacks.length >= max ? h('span', { className: 'ctm-sub' }, fmt(t('confirm.max'), { n: max })) : null)),
|
|
143
|
+
r.learned?.unhealthy?.[c.primary] ? h('p', { className: 'ctm-sub ctm-warn' }, fmt(t('confirm.primaryFailing'), { route: c.primary, n: r.learned.unhealthy[c.primary] })) : null,
|
|
144
|
+
r.learned?.habit?.settled && scope === 'task' ? h('p', { className: 'ctm-sub' }, t('confirm.habitSettled')) : null);
|
|
145
|
+
};
|
|
146
|
+
return h('div', { className: 'ctm-dc', role: 'group', 'aria-label': t('confirm.title') },
|
|
147
|
+
h('div', { className: 'ctm-dc-head' },
|
|
148
|
+
h('strong', { className: 'ctm-dc-title' }, t('confirm.title')),
|
|
149
|
+
h('span', { className: 'ctm-sub' }, t('confirm.hint'))),
|
|
150
|
+
h('div', { className: 'ctm-dc-roles' }, ...view.routes.map(row)),
|
|
151
|
+
h('div', { className: 'ctm-dc-foot' },
|
|
152
|
+
h('div', { className: 'ctm-dc-scope', role: 'radiogroup', 'aria-label': t('confirm.scope') },
|
|
153
|
+
h('span', { className: 'ctm-label' }, t('confirm.scope')),
|
|
154
|
+
h('div', { className: 'ctm-dc-seg' }, ...['task', 'project'].map(s => h('button', { key: s, type: 'button', role: 'radio', 'aria-checked': effectiveScope === s,
|
|
155
|
+
className: 'ctm-dc-segbtn', disabled: busy || (s === 'project' && hasCrossTier), onClick: () => setScope(s) }, t(`confirm.scope.${s}`)))),
|
|
156
|
+
hasCrossTier ? h('span', { className: 'ctm-sub ctm-warn' }, t('confirm.crossTierTaskOnly')) : null,
|
|
157
|
+
effectiveScope === 'project' && changedCaps.length ? h('span', { className: 'ctm-sub ctm-warn' }, fmt(t('confirm.scope.projectHint'), { list: [...new Set(changedCaps)].join(t('common.listSep')) })) : null),
|
|
158
|
+
error ? h('p', { className: 'ctm-sub ctm-warn', role: 'alert' }, error) : null,
|
|
159
|
+
h('div', { className: 'ctm-dc-buttons' },
|
|
160
|
+
h('button', { type: 'button', className: 'ctm-btn', disabled: busy, onClick: () => submit('cancel') }, t('confirm.cancel')),
|
|
161
|
+
h('button', { type: 'button', className: 'ctm-btn', 'data-primary': '', disabled: busy, onClick: () => submit('confirm') }, t('confirm.ok')))));
|
|
162
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ---------- cards for the remaining JSON tools ----------
|
|
2
|
+
// Every capability-team tool that answers with JSON gets a card, so the conversation never shows raw JSON.
|
|
3
|
+
// `valueView` renders any leftover structure as labelled rows and chips instead of a JSON dump.
|
|
4
|
+
function valueView(v, depth = 0) {
|
|
5
|
+
if (v === null || v === undefined || v === '') return h('span', { className: 'ctm-sub' }, '—');
|
|
6
|
+
if (typeof v !== 'object') return h('span', { className: 'ctm-text' }, String(v));
|
|
7
|
+
if (Array.isArray(v)) {
|
|
8
|
+
if (!v.length) return h('span', { className: 'ctm-sub' }, '—');
|
|
9
|
+
if (v.every(x => x === null || typeof x !== 'object')) return h('div', { className: 'ctm-chips' }, ...v.slice(0, 40).map((x, i) => h('span', { key: i, className: 'ctm-chip' }, String(x))));
|
|
10
|
+
if (depth >= 2) return h('span', { className: 'ctm-sub' }, `${v.length} ×`);
|
|
11
|
+
return h('div', { className: 'ctm-mc-list' }, ...v.slice(0, 20).map((x, i) => h('div', { key: i, className: 'ctm-mc-item' }, valueView(x, depth + 1))));
|
|
12
|
+
}
|
|
13
|
+
const entries = Object.entries(v).filter(([, x]) => x !== undefined);
|
|
14
|
+
if (depth >= 2) return h('span', { className: 'ctm-sub' }, entries.map(([k, x]) => `${k}: ${typeof x === 'object' && x ? '…' : String(x)}`).join(' · '));
|
|
15
|
+
return h('div', { className: 'ctm-grid ctm-ov-kv' }, ...entries.flatMap(([k, x]) => [
|
|
16
|
+
h('span', { key: `k${k}`, className: 'ctm-sub' }, k), h('div', { key: `v${k}`, className: 'ctm-text' }, valueView(x, depth + 1))]));
|
|
17
|
+
}
|
|
18
|
+
const routeLabel = r => (r ? `${r.provider ? r.provider + '/' : ''}${r.model ?? ''}` : '');
|
|
19
|
+
/** One card shape: title, a one-line summary from the data, and a body built from it. */
|
|
20
|
+
function dataCard(tool, icon, titleKey, summarize, bodyOf) {
|
|
21
|
+
return function DataCard(props) {
|
|
22
|
+
const t = props.t;
|
|
23
|
+
const s = stage(props);
|
|
24
|
+
const d = s.data;
|
|
25
|
+
const summary = s.state === 'error' ? firstLine(cleanError(s.output)) : d ? summarize(d, t, s.args) : s.state === 'ok' ? firstLine(s.output) : '';
|
|
26
|
+
const body = s.state === 'error' ? errorBody(s, t) : d ? bodyOf(d, t) : rawBody(s, t);
|
|
27
|
+
return h(Card, { tool, icon, title: t(titleKey), summary, state: s.state, t, inspect: props.inspect, body: body ?? null });
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const CatalogView = dataCard('capability_model_catalog', ICONS.team, 'cards.catalog',
|
|
31
|
+
(d, t) => fmt(t('cards.catalog.summary'), { n: d.entries?.length ?? 0, p: new Set((d.entries ?? []).map(e => e.provider)).size }),
|
|
32
|
+
(d, t) => {
|
|
33
|
+
const byProvider = new Map();
|
|
34
|
+
for (const e of d.entries ?? []) { if (!byProvider.has(e.provider)) byProvider.set(e.provider, []); byProvider.get(e.provider).push(e); }
|
|
35
|
+
return [...[...byProvider].map(([p, list]) => section(`${p} · ${list.length}`, h('div', { className: 'ctm-chips' },
|
|
36
|
+
...list.slice(0, 60).map(e => h('span', { key: e.model, className: 'ctm-chip', title: e.tiers?.join(', ') ?? '' }, e.tiers?.length ? `${e.model} · ${e.tiers.map(x => label(t, `tier.${x}`, x)).join('/')}` : e.model))))),
|
|
37
|
+
...(d.errors?.length ? [section(t('cards.catalog.errors'), h('div', { className: 'ctm-text ctm-warn' }, d.errors.map(e => `${e.provider}: ${e.error}`).join('\n')))] : [])];
|
|
38
|
+
});
|
|
39
|
+
const leaseSummary = (d, t) => [d.roleId, d.status && label(t, `control.lease.status.${d.status}`, d.status),
|
|
40
|
+
d.estimatedSteps ? fmt(t('control.steps.label'), { used: d.startsUsed ?? 0, est: d.estimatedSteps }) : null,
|
|
41
|
+
d.override ? fmt(t('control.override.badge'), { from: routeLabel(d.override.from), to: routeLabel(d.override) }) : null].filter(Boolean).join(' · ');
|
|
42
|
+
const leaseBody = (d, t) => section(null, valueView({ [t('cards.lease.role')]: d.roleId, [t('cards.lease.status')]: d.status, [t('cards.lease.judgment')]: d.judgment,
|
|
43
|
+
[t('cards.lease.steps')]: d.estimatedSteps ? `${d.startsUsed ?? 0} / ${d.estimatedSteps}` : undefined,
|
|
44
|
+
[t('control.override.title')]: d.override ? `${routeLabel(d.override.from)} → ${routeLabel(d.override)}` : t('control.override.none'),
|
|
45
|
+
[t('cards.lease.ends')]: d.expiresAt ? timeOfDay(d.expiresAt) : undefined, [t('cards.lease.endReason')]: d.endReason ?? undefined }));
|
|
46
|
+
const BudgetView = dataCard('capability_task_budget', ICONS.plan, 'cards.budget', leaseSummary, leaseBody);
|
|
47
|
+
const OverrideView = dataCard('capability_member_override', ICONS.plan, 'cards.override', leaseSummary, leaseBody);
|
|
48
|
+
const ExportView = dataCard('capability_team_export', ICONS.team, 'cards.export',
|
|
49
|
+
(d, t) => fmt(t('cards.export.summary'), { n: Object.keys(d.roles ?? {}).length, c: Object.keys(d.capabilities ?? {}).length }),
|
|
50
|
+
d => [section(null, valueView({ format: d.format, version: d.version, name: d.name })),
|
|
51
|
+
d.roles ? section('roles', valueView(Object.fromEntries(Object.entries(d.roles).map(([id, r]) => [id, `${r.displayName ?? id} · ${r.capability ?? ''}`])))) : null].filter(Boolean));
|
|
52
|
+
const SearchView = dataCard('omni_search', ICONS.free, 'cards.search',
|
|
53
|
+
(d, t, args) => fmt(t('cards.search.summary'), { q: d.query ?? args.query ?? '', n: d.results?.length ?? 0 }),
|
|
54
|
+
d => section(d.engine ?? d.provider ?? null, h('ol', { className: 'ctm-mc-results' }, ...(d.results ?? []).slice(0, 20).map((r, i) => h('li', { key: i },
|
|
55
|
+
h('span', { className: 'ctm-name' }, r.title ?? r.url), r.url ? h('span', { className: 'ctm-sub' }, r.url) : null,
|
|
56
|
+
r.snippet || r.content ? h('span', { className: 'ctm-sub' }, firstLine(r.snippet ?? r.content)) : null)))));
|
|
57
|
+
const FetchView = dataCard('omni_fetch', ICONS.free, 'cards.fetch',
|
|
58
|
+
(d, t, args) => d.title ?? d.url ?? args.url ?? '',
|
|
59
|
+
d => section(d.url ?? null, h('div', { className: 'ctm-text' }, String(d.content ?? d.text ?? d.markdown ?? '').slice(0, 1200))));
|
|
60
|
+
const HandoffView = dataCard('capability_handoff', ICONS.plan, 'cards.handoff',
|
|
61
|
+
(d, t) => fmt(t('cards.handoff.summary'), { name: d.member ?? '', n: d.facts?.openTasks ?? 0 }),
|
|
62
|
+
(d, t) => section(null, valueView({ [t('cards.handoff.diff')]: d.facts?.diffStat ? '✓' : '—', [t('cards.handoff.test')]: d.facts?.lastTest ?? '—' })));
|
|
63
|
+
const RotateView = dataCard('capability_rotate_member', ICONS.team, 'cards.rotate',
|
|
64
|
+
(d, t) => (d.rotated ? fmt(t('cards.rotate.summary'), { from: d.from, to: d.to }) : d.reason ?? ''),
|
|
65
|
+
d => [d.next ? section(null, h('pre', { className: 'ctm-pre', 'data-short': '' }, d.next)) : null,
|
|
66
|
+
d.prompt ? section('prompt', h('div', { className: 'ctm-text ctm-sub' }, d.prompt)) : null].filter(Boolean));
|
|
67
|
+
const MemorySearchView = dataCard('capability_memory_search', ICONS.plan, 'cards.memorySearch',
|
|
68
|
+
(d, t, args) => fmt(t('cards.memorySearch.summary'), { q: args.query ?? '', n: d.results?.length ?? 0, mode: t(`cards.memory.mode.${d.mode}`) }),
|
|
69
|
+
(d, t) => [d.note ? h('p', { className: 'ctm-sub ctm-warn' }, d.note) : null,
|
|
70
|
+
section(null, h('ol', { className: 'ctm-mc-results' }, ...(d.results ?? []).map(r => h('li', { key: r.id },
|
|
71
|
+
h('span', { className: 'ctm-sub' }, `${label(t, `cards.memory.kind.${r.kind}`, r.kind)} · ${r.scope}${r.via?.length ? ` · ${r.via.join('+')}` : ''}`),
|
|
72
|
+
h('span', { className: 'ctm-text' }, r.text)))))].filter(Boolean));
|
|
73
|
+
const MemoryWriteView = dataCard('capability_memory_write', ICONS.plan, 'cards.memoryWrite',
|
|
74
|
+
(d, t, args) => `${label(t, `cards.memory.kind.${d.kind}`, d.kind)} · ${d.merged ? t('cards.memoryWrite.merged') : t('cards.memoryWrite.saved')}${firstLine(args.text) ? ` · ${firstLine(args.text)}` : ''}`,
|
|
75
|
+
() => null);
|
|
76
|
+
const PrefsView = dataCard('capability_model_prefs', ICONS.plan, 'cards.prefs',
|
|
77
|
+
(d, t) => (d.cleared ? t('cards.prefs.cleared') : fmt(t('cards.prefs.summary'), { n: Object.keys(d.habits ?? {}).length })),
|
|
78
|
+
(d, t) => (d.habits ? section(null, valueView(Object.fromEntries(Object.entries(d.habits).map(([cap, hb]) => [cap, `${hb.primary}${hb.fallbacks?.length ? ` → ${hb.fallbacks.join(' → ')}` : ''} · ${hb.count}/${hb.of}`])))) : null));
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ---------- searchable model picker ----------
|
|
2
|
+
// A combobox over "provider/model" routes: type to filter (every space-separated word must match),
|
|
3
|
+
// arrow keys to move, Enter to pick, Escape to close. Options are grouped by provider.
|
|
4
|
+
const splitKey = key => { const i = key.indexOf('/'); return i > 0 ? [key.slice(0, i), key.slice(i + 1)] : ['', key]; };
|
|
5
|
+
/** Routes whose "provider/model" contains every word of the query (case-insensitive). */
|
|
6
|
+
function filterRoutes(options, query) {
|
|
7
|
+
const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
8
|
+
return words.length ? options.filter(k => words.every(w => k.toLowerCase().includes(w))) : options;
|
|
9
|
+
}
|
|
10
|
+
let pickerSeq = 0;
|
|
11
|
+
function ModelPicker({ value, options, recommended = [], onChange, placeholder, label: aria, disabled, t }) {
|
|
12
|
+
const [open, setOpen] = React.useState(false);
|
|
13
|
+
const [query, setQuery] = React.useState('');
|
|
14
|
+
const [active, setActive] = React.useState(0);
|
|
15
|
+
const idRef = React.useRef(null);
|
|
16
|
+
const inputRef = React.useRef(null);
|
|
17
|
+
const [place, setPlace] = React.useState(null); // fixed popup position inside the scrolling confirmation dock
|
|
18
|
+
// In the dock (a scroll box above the input) an absolute popup would be clipped: pin it to the viewport
|
|
19
|
+
// instead, span the dock card's full width, and open it upward when there is more room above the field than below it.
|
|
20
|
+
const measure = () => {
|
|
21
|
+
const el = inputRef.current;
|
|
22
|
+
const dock = el?.getBoundingClientRect ? el.closest?.('.ctm-dock') : null;
|
|
23
|
+
if (!dock) return null;
|
|
24
|
+
const r = el.getBoundingClientRect(), below = window.innerHeight - r.bottom;
|
|
25
|
+
const d = dock.getBoundingClientRect?.() ?? r;
|
|
26
|
+
const base = { position: 'fixed', left: d.left, width: d.width, right: 'auto' };
|
|
27
|
+
return below < 280 && r.top > below ? { ...base, top: 'auto', bottom: window.innerHeight - r.top + 4 } : { ...base, top: r.bottom + 4 };
|
|
28
|
+
};
|
|
29
|
+
const show = () => { if (!open) setPlace(measure()); setOpen(true); };
|
|
30
|
+
if (!idRef.current) idRef.current = `ctm-mp-${++pickerSeq}`;
|
|
31
|
+
const id = idRef.current;
|
|
32
|
+
const ordered = [...options.filter(key => recommended.includes(key)), ...options.filter(key => !recommended.includes(key)).sort((a, b) => splitKey(a)[0].localeCompare(splitKey(b)[0]))];
|
|
33
|
+
const shown = filterRoutes(ordered, query);
|
|
34
|
+
const close = () => { setOpen(false); setQuery(''); setActive(0); setPlace(null); };
|
|
35
|
+
const pick = key => { if (key) onChange(key); close(); };
|
|
36
|
+
const onKeyDown = event => {
|
|
37
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); show(); setActive(i => Math.min(i + 1, shown.length - 1)); }
|
|
38
|
+
else if (event.key === 'ArrowUp') { event.preventDefault(); setActive(i => Math.max(i - 1, 0)); }
|
|
39
|
+
else if (event.key === 'Enter') { event.preventDefault(); if (open) pick(shown[active]); else show(); }
|
|
40
|
+
else if (event.key === 'Escape') { if (open) { event.preventDefault(); close(); } }
|
|
41
|
+
else if (event.key === 'Tab') close();
|
|
42
|
+
};
|
|
43
|
+
const groups = [];
|
|
44
|
+
shown.forEach((key, index) => {
|
|
45
|
+
const provider = recommended.includes(key) ? t('picker.sameTier') : splitKey(key)[0];
|
|
46
|
+
if (!groups.length || groups[groups.length - 1].provider !== provider) groups.push({ provider, items: [] });
|
|
47
|
+
groups[groups.length - 1].items.push({ key, index });
|
|
48
|
+
});
|
|
49
|
+
return h('div', { className: 'ctm-mp', 'data-open': open ? '' : undefined },
|
|
50
|
+
h('input', { ref: inputRef, type: 'text', className: 'ctm-mp-input', role: 'combobox', 'aria-label': aria, 'aria-expanded': open, 'aria-controls': `${id}-list`,
|
|
51
|
+
'aria-autocomplete': 'list', 'aria-activedescendant': open && shown[active] ? `${id}-${active}` : undefined, disabled, spellCheck: false,
|
|
52
|
+
value: open ? query : value ?? '', placeholder: open ? (value || placeholder) : placeholder,
|
|
53
|
+
onFocus: show, onClick: show, onBlur: close,
|
|
54
|
+
onChange: e => { setQuery(e.target.value); setActive(0); show(); }, onKeyDown }),
|
|
55
|
+
h('span', { className: 'ctm-mp-caret', 'aria-hidden': true }, '▾'),
|
|
56
|
+
open ? h('div', { className: 'ctm-mp-pop', style: place ?? undefined },
|
|
57
|
+
h('div', { className: 'ctm-mp-count ctm-sub' }, fmt(t('picker.count'), { n: shown.length, total: options.length })),
|
|
58
|
+
shown.length
|
|
59
|
+
? h('ul', { id: `${id}-list`, role: 'listbox', className: 'ctm-mp-list' }, ...groups.flatMap(g => [
|
|
60
|
+
h('li', { key: `g:${g.provider}`, role: 'presentation', className: 'ctm-mp-group' }, g.provider || t('picker.noProvider')),
|
|
61
|
+
...g.items.map(({ key, index }) => h('li', { key, id: `${id}-${index}`, role: 'option', 'aria-selected': key === value,
|
|
62
|
+
className: 'ctm-mp-option', 'data-active': index === active ? '' : undefined,
|
|
63
|
+
// mousedown keeps focus in the input, so blur does not close the list before the click lands
|
|
64
|
+
onMouseDown: e => { e.preventDefault(); pick(key); }, onMouseEnter: () => setActive(index) },
|
|
65
|
+
h('span', { className: 'ctm-mp-model' }, key), key === value ? h('span', { className: 'ctm-mp-check', 'aria-hidden': true }, '✓') : null)),
|
|
66
|
+
]))
|
|
67
|
+
: h('div', { className: 'ctm-mp-empty ctm-sub' }, t('picker.empty'))) : null);
|
|
68
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ---------- confirmation dock above the composer ----------
|
|
2
|
+
// A plan call's own row sits inside the turn's folded steps, so a card there is easy to miss. While the Lead
|
|
3
|
+
// session runs, the dock asks the server for its latest pending confirmation and shows the card right above
|
|
4
|
+
// the input box; the plan row then only points here. Sessions that are not Leads answer 404 and stop polling.
|
|
5
|
+
const DOCK_POLL_MS = 2000; // well inside the server's 8 s grace, so the native question box stays a fallback
|
|
6
|
+
const dockedSessions = new Set(); // sessions whose conversation shows the dock; plan rows render a pointer there
|
|
7
|
+
function ConfirmDock({ sessionId, useSession, t }) {
|
|
8
|
+
const running = typeof useSession === 'function' ? useSession(s => !!s?.running) : true;
|
|
9
|
+
const [pending, setPending] = React.useState(null); // { callId, kind } of the latest pending request
|
|
10
|
+
React.useEffect(() => {
|
|
11
|
+
if (!sessionId) return undefined;
|
|
12
|
+
dockedSessions.add(sessionId);
|
|
13
|
+
return () => { dockedSessions.delete(sessionId); };
|
|
14
|
+
}, [sessionId]);
|
|
15
|
+
React.useEffect(() => {
|
|
16
|
+
if (!sessionId || !running) { setPending(null); return undefined; }
|
|
17
|
+
const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
18
|
+
let live = true, timer = null;
|
|
19
|
+
const poll = async () => {
|
|
20
|
+
timer = null;
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(apiUrl(CONFIRM_PATH, { sessionId }), { signal: ctrl?.signal });
|
|
23
|
+
if (!live) return;
|
|
24
|
+
if (res.status === 404) { setPending(null); return; } // not a capability-team Lead: nothing to confirm here
|
|
25
|
+
const body = res.ok ? await res.json() : null;
|
|
26
|
+
if (!live) return;
|
|
27
|
+
const next = body?.status === 'pending' && body.callId ? { callId: body.callId, kind: body.kind === 'direct' ? 'direct' : 'dispatch' } : null;
|
|
28
|
+
setPending(prev => (prev?.callId === next?.callId && prev?.kind === next?.kind ? prev : next));
|
|
29
|
+
} catch (e) { if (!live || e?.name === 'AbortError') return; }
|
|
30
|
+
if (live) timer = setTimeout(poll, DOCK_POLL_MS);
|
|
31
|
+
};
|
|
32
|
+
poll();
|
|
33
|
+
return () => { live = false; ctrl?.abort(); if (timer) clearTimeout(timer); };
|
|
34
|
+
}, [sessionId, running]);
|
|
35
|
+
if (!pending) return null;
|
|
36
|
+
const Card = pending.kind === 'direct' ? DirectConfirmCard : DispatchConfirmCard;
|
|
37
|
+
return h('div', { className: 'ctm-dock', 'data-kind': pending.kind,
|
|
38
|
+
// A pinned picker popup would drift from its field while the dock scrolls: close it by dropping focus.
|
|
39
|
+
onScroll: e => { const f = typeof document !== 'undefined' ? document.activeElement : null; if (f && e.currentTarget.contains(f) && f.classList?.contains('ctm-mp-input')) f.blur(); } }, h(Card, { key: pending.callId, sessionId, callId: pending.callId, t }));
|
|
40
|
+
}
|