@adia-ai/web-modules 0.8.24 → 0.8.26

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.
@@ -0,0 +1,162 @@
1
+ # Edit this file; run `npm run build:components` to regenerate a2ui.json.
2
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
3
+ name: UIAgentAdmin
4
+ tag: agent-admin-ui
5
+ status: stable
6
+ component: AgentAdmin
7
+ category: container
8
+ version: 1
9
+ description: |
10
+ L4 admin composite (CHAT-HARNESS.md §Components row L4 + §Interfaces 5,
11
+ gh#600 / WCH-5) — persona roster + settings pane + a chat pane, wired to
12
+ the store-swap law: a genuine persona switch (a new `SettingsStore`
13
+ object) resets the conversation, disposes open generative-UI surfaces,
14
+ and mints a fresh session; re-selecting the already-active persona
15
+ (same memoized store reference) resets nothing.
16
+
17
+ Behavior-only orchestrator (mirrors `<chat-shell>`) — stamps no HTML of
18
+ its own. Bespoke light-DOM children: `<admin-roster-ui>`,
19
+ `<admin-settings-ui>`, `<chat-shell>` in EXTERNAL mode (no `proxy-url` —
20
+ this composite drives it via `wireAgentEvents`).
21
+
22
+ Transport-blind (CHAT-HARNESS law 1): the `runTurn` JS property is
23
+ `(session, text) → AsyncIterable<AgentEvent>`; packaged default
24
+ `undefined` falls back to a deterministic, keyless stub that compiles
25
+ the active persona FRESH every turn (`readLivePersona` +
26
+ `compilePersona`) and visibly cites what it read — the live-apply
27
+ probe: edit an entry's content in `<admin-settings-ui>`, the next
28
+ turn's stub output cites the new text immediately, no propagation
29
+ channel.
30
+
31
+ Lives in `packages/web-modules/agent-admin/` alongside
32
+ `<admin-roster-ui>` and `<admin-settings-ui>`.
33
+
34
+ composes:
35
+ - admin-roster-ui
36
+ - admin-settings-ui
37
+ - chat-shell
38
+
39
+ props:
40
+ personas:
41
+ description: |
42
+ Full array of `Persona` objects (from `@adia-ai/persona`) this admin
43
+ surface manages. JSON-attribute or JS-property; the composite derives
44
+ `<admin-roster-ui>`'s summary list from it automatically.
45
+ type: array
46
+ default: []
47
+ required: true
48
+ dynamic: true
49
+ active-id:
50
+ description: |
51
+ The currently-active persona's id. Reflects; setting it programmatically
52
+ (or via `<admin-roster-ui>`'s `persona-change`) runs `setActivePersona`.
53
+ type: string
54
+ default: ""
55
+ reflect: true
56
+ dynamic: true
57
+ renderer:
58
+ description: |
59
+ Optional generative-UI renderer (WCH-4's surface-host registry seam)
60
+ — JS property only, host-injected (private `@genui/renderer` packages
61
+ never ship inside this composite). Omitted, surface events are safely
62
+ dropped.
63
+ type: object
64
+ dynamic: true
65
+ runTurn:
66
+ description: |
67
+ Optional transport override — `(session, text) => AsyncIterable<AgentEvent>`.
68
+ JS property only. Omitted, the composite's own deterministic stub
69
+ drives every turn (see description).
70
+ type: object
71
+ dynamic: true
72
+
73
+ events:
74
+ persona-activated:
75
+ description: A persona finished activating (fired for both a genuine swap and a same-store reconnect).
76
+ detail:
77
+ personaId:
78
+ type: string
79
+ reset:
80
+ type: boolean
81
+ description: True for a genuine store swap (conversation/surfaces/session reset); false for a same-store reconnect.
82
+ persona-imported:
83
+ description: An imported persona (from `<admin-roster-ui>`'s `persona-import`) was appended and activated.
84
+ detail:
85
+ persona:
86
+ type: object
87
+
88
+ slots: {}
89
+
90
+ states:
91
+ - name: idle
92
+ description: Default; one persona active, chat pane external-driven.
93
+
94
+ traits: []
95
+
96
+ tokens:
97
+ --agent-admin-roster-w:
98
+ description: Roster column width.
99
+ default: var(--a-space-12)
100
+ --agent-admin-settings-w:
101
+ description: Settings column width.
102
+ default: calc(var(--a-space-12) * 1.6)
103
+ --agent-admin-border:
104
+ description: Column divider border.
105
+ default: var(--a-border-subtle)
106
+
107
+ a2ui:
108
+ rules:
109
+ - rule: |
110
+ Compose `<agent-admin-ui>` with bespoke `<admin-roster-ui>` +
111
+ `<admin-settings-ui>` + `<chat-shell>` children (chat-shell WITHOUT
112
+ `proxy-url` — external mode; this composite drives it). Set
113
+ `personas` as a JS property.
114
+ reason: Behavior-only orchestrator; the consumer authors the bespoke children.
115
+ - rule: |
116
+ Never bind `<chat-shell proxy-url>` inside `<agent-admin-ui>` — the
117
+ composite drives the chat pane itself via `wireAgentEvents`; a
118
+ `proxy-url` would double-drive turns.
119
+ reason: One transport per turn (CHAT-HARNESS law 1).
120
+
121
+ anti_patterns:
122
+ - wrong: |
123
+ {"component": "AgentAdmin", "personas": [{"id": "a", "label": "A", "seedVersion": 1, "entries": []}]}
124
+ why: Renders nothing without its bespoke children — `<agent-admin-ui>` needs `<admin-roster-ui>` + `<admin-settings-ui>` + `<chat-shell>` authored inside it.
125
+ fix: |
126
+ <agent-admin-ui id="admin">
127
+ <admin-roster-ui></admin-roster-ui>
128
+ <admin-settings-ui></admin-settings-ui>
129
+ <chat-shell><chat-header></chat-header><chat-thread></chat-thread><chat-composer></chat-composer></chat-shell>
130
+ </agent-admin-ui>
131
+
132
+ examples:
133
+ - name: composed-shell
134
+ description: Full agent-admin composition (JS-driven; `personas` with full Entry arrays isn't practically hand-authored as static a2ui JSON).
135
+ a2ui: |
136
+ [{"id": "admin", "component": "AgentAdmin", "children": [
137
+ {"id": "roster", "component": "AdminRoster"},
138
+ {"id": "settings", "component": "AdminSettings"},
139
+ {"id": "chat", "component": "ChatShell"}
140
+ ]}]
141
+
142
+ keywords:
143
+ - admin
144
+ - agent
145
+ - persona
146
+ - roster
147
+ - settings
148
+ - chat
149
+ - store-swap
150
+
151
+ synonyms:
152
+ admin:
153
+ - agent-admin
154
+ - persona-admin
155
+ persona:
156
+ - agent
157
+ - profile
158
+
159
+ related:
160
+ - AdminRoster
161
+ - AdminSettings
162
+ - ChatShell
@@ -0,0 +1,95 @@
1
+ /**
2
+ * demo-seeds — the three personas `playgrounds/agent-admin` ships with.
3
+ *
4
+ * Shared between the playground (imported at a browser-absolute path) and
5
+ * `demo-seeds.test.js` (modality-neutral lint, CHAT-HARNESS law 4 — a
6
+ * persona's prose surfaces INTENT only, never dialect specifics: "A2UI",
7
+ * "envelope", component tag names like "chart-ui" are all disallowed).
8
+ */
9
+
10
+ import { definePersona, seedEntries } from '@adia-ai/persona';
11
+
12
+ export const supportPersona = definePersona({
13
+ id: 'support',
14
+ label: 'Support agent',
15
+ category: 'Customer',
16
+ seedVersion: 1,
17
+ entries: seedEntries([
18
+ {
19
+ id: 'identity',
20
+ kind: 'prompt-section',
21
+ label: 'Identity',
22
+ description: 'Who this agent is.',
23
+ content: 'You are a customer support agent for a software product. You help people solve problems with their account and their orders.',
24
+ },
25
+ {
26
+ id: 'tone',
27
+ kind: 'prompt-section',
28
+ label: 'Tone',
29
+ description: 'How this agent talks.',
30
+ content: 'Be concise, patient, and friendly. Confirm what the person needs before offering a fix.',
31
+ },
32
+ {
33
+ id: 'lookup-order',
34
+ kind: 'tool',
35
+ label: 'Look up an order',
36
+ description: 'Find an order by its confirmation number.',
37
+ content: 'Given a confirmation number, return the order status and its items.',
38
+ },
39
+ ]),
40
+ });
41
+
42
+ export const writerPersona = definePersona({
43
+ id: 'writer',
44
+ label: 'Writer',
45
+ category: 'Content',
46
+ seedVersion: 1,
47
+ entries: seedEntries([
48
+ {
49
+ id: 'identity',
50
+ kind: 'prompt-section',
51
+ label: 'Identity',
52
+ description: 'Who this agent is.',
53
+ content: 'You are a writing assistant. You help people draft, tighten, and proofread short pieces of text.',
54
+ },
55
+ {
56
+ id: 'style',
57
+ kind: 'prompt-section',
58
+ label: 'Style',
59
+ description: 'The house voice.',
60
+ content: 'Prefer short sentences and plain words. Avoid jargon unless the person uses it first.',
61
+ },
62
+ {
63
+ id: 'editing-checklist',
64
+ kind: 'skill',
65
+ label: 'Editing checklist',
66
+ description: 'A repeatable pass over a draft.',
67
+ content: 'Check for a clear opening line, one idea per paragraph, and a direct closing.',
68
+ },
69
+ ]),
70
+ });
71
+
72
+ export const researcherPersona = definePersona({
73
+ id: 'researcher',
74
+ label: 'Researcher',
75
+ category: 'Content',
76
+ seedVersion: 1,
77
+ entries: seedEntries([
78
+ {
79
+ id: 'identity',
80
+ kind: 'prompt-section',
81
+ label: 'Identity',
82
+ description: 'Who this agent is.',
83
+ content: 'You are a research assistant. You gather facts, summarize sources, and flag open questions.',
84
+ },
85
+ {
86
+ id: 'method',
87
+ kind: 'workflow',
88
+ label: 'Research method',
89
+ description: 'How this agent works through a question.',
90
+ content: 'Break the question into parts, look for a source for each part, then summarize what is and is not settled.',
91
+ },
92
+ ]),
93
+ });
94
+
95
+ export const DEMO_PERSONAS = [supportPersona, writerPersona, researcherPersona];
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @adia-ai/web-modules/agent-admin — cluster barrel.
3
+ *
4
+ * L4 admin composite family (CHAT-HARNESS.md §Components row L4, gh#600 /
5
+ * WCH-5) over `@adia-ai/persona`'s L2 store contract: `<admin-roster-ui>`
6
+ * (persona switch, exactly-one-active), `<admin-settings-ui>` (settings
7
+ * pane over one bound store), `<agent-admin-ui>` (the composition —
8
+ * roster + settings + a chat pane, store-swap law, transport-blind).
9
+ */
10
+ export { UIAdminRoster } from './admin-roster/admin-roster.js';
11
+ export { UIAdminSettings } from './admin-settings/admin-settings.js';
12
+ export { UIAgentAdmin } from './agent-admin/agent-admin.js';
13
+ export { readLivePersona, entryStoreKey } from './live-persona.js';
@@ -0,0 +1,53 @@
1
+ /**
2
+ * live-persona — the L4 fresh-read overlay (CHAT-HARNESS law 7: "Live-apply
3
+ * is by fresh read at turn time — no propagation channel").
4
+ *
5
+ * `admin-settings-ui` writes edits straight through a per-persona
6
+ * `SettingsStore` (`@adia-ai/persona`'s `personaStore`) — it never mutates
7
+ * the `Persona` object in memory. `readLivePersona` is the ONE place that
8
+ * re-reads the store and overlays it onto the base persona's entries; the
9
+ * agent-admin composition calls it fresh, immediately before every
10
+ * `compilePersona()`, so the very next turn always compiles whatever is
11
+ * currently in the store — there is no cache, no subscription forwarding
12
+ * content into a second copy of the persona.
13
+ *
14
+ * Key convention MIRRORS `@adia-ai/persona`'s `store.ts` (`entry.<id>.<field>`
15
+ * for `enabled` / `order`) — store.ts doesn't export that helper, so it's
16
+ * kept in lock-step here by hand. `content` is a THIRD field this module
17
+ * owns (persona/store.ts never seeds or reads it) — entry authoring
18
+ * (M-scope 2) persists edited prompt-section/skill/etc. text under the
19
+ * same `entry.<id>.content` key.
20
+ */
21
+
22
+ /** @param {string} entryId @param {'enabled'|'order'|'content'} field */
23
+ export function entryStoreKey(entryId, field) {
24
+ return `entry.${entryId}.${field}`;
25
+ }
26
+
27
+ /**
28
+ * Overlay `store`'s current values onto `persona.entries`, entry by entry.
29
+ * A field the store has never seen falls back to the persona's own seed
30
+ * value — same precedence `personaStore` documents for enabled/order.
31
+ * Never mutates `persona`; always returns a fresh object + fresh entries
32
+ * array so callers can't accidentally hold a stale reference across turns.
33
+ *
34
+ * @param {import('@adia-ai/persona').Persona} persona
35
+ * @param {import('@adia-ai/persona').SettingsStore} store
36
+ * @returns {import('@adia-ai/persona').Persona}
37
+ */
38
+ export function readLivePersona(persona, store) {
39
+ if (!persona) return persona;
40
+ if (!store) return persona;
41
+ const entries = persona.entries.map((entry) => {
42
+ const enabled = store.get(entryStoreKey(entry.id, 'enabled'));
43
+ const order = store.get(entryStoreKey(entry.id, 'order'));
44
+ const content = store.get(entryStoreKey(entry.id, 'content'));
45
+ return {
46
+ ...entry,
47
+ enabled: typeof enabled === 'boolean' ? enabled : entry.enabled,
48
+ order: typeof order === 'number' ? order : entry.order,
49
+ content: typeof content === 'string' ? content : entry.content,
50
+ };
51
+ });
52
+ return { ...persona, entries };
53
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The L3 surface-host registry over genui-system `attachSurface`
3
+ * (CHAT-HARNESS.md §Interfaces 4).
4
+ */
5
+
6
+ /** Renderer seam — only the subset of `@genui/renderer`'s `Renderer` this
7
+ * module calls. The real `Renderer` type satisfies this structurally. */
8
+ export interface SurfaceRegistryRenderer {
9
+ handleFrame(raw: string): void;
10
+ readonly surfaces: ReadonlyMap<string, unknown>;
11
+ attachSurface(surfaceId: string, root: Element): { detach(): void; [key: string]: unknown };
12
+ }
13
+
14
+ export interface SurfaceHost {
15
+ surfaceId: string;
16
+ root: Element;
17
+ handle: { detach(): void; [key: string]: unknown };
18
+ bubbleEl: Element;
19
+ status: 'open' | 'closed';
20
+ }
21
+
22
+ export interface SurfaceRegistry {
23
+ /** Fresh id ⇒ mount into `turnBubbleEl`; known id ⇒ return the original host. */
24
+ route(surfaceId: string, turnBubbleEl: Element): SurfaceHost;
25
+ /** Protocol-level delete + non-removable "Closed." annotation. Idempotent. */
26
+ close(surfaceId: string): SurfaceHost | undefined;
27
+ /** Apply one wire envelope line for `surfaceId`, routing/closing as needed. */
28
+ feed(surfaceId: string, line: string, turnBubbleEl: Element | null): SurfaceHost | undefined;
29
+ get(surfaceId: string): SurfaceHost | undefined;
30
+ disposeAll(): void;
31
+ readonly hosts: ReadonlyMap<string, SurfaceHost>;
32
+ }
33
+
34
+ export function createSurfaceRegistry(opts: {
35
+ renderer: SurfaceRegistryRenderer;
36
+ doc?: Document;
37
+ }): SurfaceRegistry;
38
+
39
+ /** CHAT-HARNESS law 3 — code-owned progress labels, never model text. */
40
+ export const PROGRESS_LABELS: Record<string, string>;
41
+
42
+ export function wireAgentEvents(
43
+ chatShell: Element & {
44
+ appendChunk?(text: string): void;
45
+ appendMessage?(msg: { role: string; content?: string; render?: boolean }): Element;
46
+ startStreaming?(): void;
47
+ stopStreaming?(): void;
48
+ streaming?: boolean;
49
+ },
50
+ registry: SurfaceRegistry,
51
+ opts: {
52
+ events: AsyncIterable<{ type: string; [key: string]: unknown }>;
53
+ statusEl?: { textContent: string } | null;
54
+ isProgressStage?: (stage: unknown) => boolean;
55
+ },
56
+ ): Promise<void>;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * chat-surfaces — the L3 surface-host registry over genui-system
3
+ * `attachSurface` (CHAT-HARNESS.md §Interfaces 4, gh#599 / WCH-4).
4
+ *
5
+ * `<chat-thread>` bookkeeping: `Map<surfaceId, host>`.
6
+ * - A FRESH surfaceId mounts a new host inline in the CREATING turn's
7
+ * bubble (the `chatShell.appendMessage()` element passed in as
8
+ * `turnBubbleEl`).
9
+ * - A KNOWN surfaceId (open OR closed) always routes back to that
10
+ * ORIGINAL host — persistent surface identity across turns. A later
11
+ * turn that updates surface S renders into S's original bubble, it
12
+ * never mounts a second one.
13
+ * - `close(surfaceId)` is a PROTOCOL-level delete (a real `deleteSurface`
14
+ * envelope through the renderer — the same path a server-sent delete
15
+ * rides, `SurfaceStore#deleteSurface`, idempotent) plus a visible,
16
+ * NON-REMOVABLE "Closed." annotation appended to the original bubble.
17
+ * History is never silently erased — the annotation stays even if the
18
+ * bubble is re-rendered around it (callers must APPEND, never replace).
19
+ *
20
+ * Dependency-injected renderer (CHAT-HARNESS law 1 — one-method transport,
21
+ * packaged seam undefined): this module never imports `@genui/renderer`
22
+ * itself. `@genui/*` are private, unpublished workspace packages — a real
23
+ * `@adia-ai/web-modules` npm consumer could never resolve a bare `@genui/…`
24
+ * specifier. The HOST (a demo page, a consumer app) builds the renderer
25
+ * (`createRenderer` + its own `WidgetAdapter`) and hands it in; this module
26
+ * only ever calls the five methods `Renderer` exposes.
27
+ */
28
+
29
+ const CLOSED_ANNOTATION_TEXT = 'Closed.';
30
+ const PROTOCOL_VERSION = 'v1.0';
31
+
32
+ /**
33
+ * @param {object} opts
34
+ * @param {{handleFrame(raw: string): void, surfaces: ReadonlyMap<string, unknown>, attachSurface(surfaceId: string, root: Element): {detach(): void}}} opts.renderer
35
+ * @param {Document} [opts.doc]
36
+ */
37
+ export function createSurfaceRegistry({ renderer, doc = document }) {
38
+ if (!renderer) throw new Error('createSurfaceRegistry: opts.renderer is required');
39
+
40
+ /** @type {Map<string, {surfaceId: string, root: Element, handle: object, bubbleEl: Element, status: 'open'|'closed'}>} */
41
+ const hosts = new Map();
42
+
43
+ /**
44
+ * Fresh id ⇒ mount a new host into `turnBubbleEl`. Known id (open or
45
+ * closed) ⇒ return the ORIGINAL host untouched — never re-mounts, never
46
+ * moves to a different bubble.
47
+ */
48
+ function route(surfaceId, turnBubbleEl) {
49
+ const existing = hosts.get(surfaceId);
50
+ if (existing) return existing;
51
+
52
+ if (!turnBubbleEl) {
53
+ throw new Error(`createSurfaceRegistry.route: no bubble element for fresh surfaceId "${surfaceId}"`);
54
+ }
55
+
56
+ // Island posture: the root is the ONLY thing this registry adds to the
57
+ // bubble — no class/style/chrome on the bubble itself.
58
+ const root = doc.createElement('div');
59
+ root.setAttribute('data-surface-root', surfaceId);
60
+ turnBubbleEl.appendChild(root);
61
+
62
+ const handle = renderer.attachSurface(surfaceId, root);
63
+ const host = { surfaceId, root, handle, bubbleEl: turnBubbleEl, status: 'open' };
64
+ hosts.set(surfaceId, host);
65
+ return host;
66
+ }
67
+
68
+ function annotateClosed(host) {
69
+ const note = doc.createElement('div');
70
+ note.setAttribute('data-surface-closed', host.surfaceId);
71
+ // No removal affordance is ever wired to this element — it is
72
+ // permanent, visible history (CHAT-HARNESS §Interfaces 4).
73
+ note.textContent = CLOSED_ANNOTATION_TEXT;
74
+ host.bubbleEl.appendChild(note);
75
+ }
76
+
77
+ /**
78
+ * Protocol-level delete: rides `deleteSurface` through the renderer
79
+ * (idempotent — deleting an already-closed or unknown id is a no-op on
80
+ * the renderer side), marks the host closed, and appends the Closed
81
+ * annotation. Returns the host record (or undefined for an id this
82
+ * registry never routed).
83
+ */
84
+ function close(surfaceId) {
85
+ const host = hosts.get(surfaceId);
86
+ if (!host) return undefined;
87
+ if (host.status === 'closed') return host;
88
+
89
+ renderer.handleFrame(JSON.stringify({
90
+ version: PROTOCOL_VERSION,
91
+ deleteSurface: { surfaceId },
92
+ }));
93
+ host.status = 'closed';
94
+ annotateClosed(host);
95
+ return host;
96
+ }
97
+
98
+ /**
99
+ * Feed one 'surface' AgentEvent line for `surfaceId` into the renderer,
100
+ * routing/mounting/closing as needed. `line` is one raw wire envelope
101
+ * (createSurface / updateComponents / updateDataModel / deleteSurface).
102
+ *
103
+ * - `deleteSurface` line ⇒ apply it, then close() bookkeeping (no
104
+ * second envelope is sent — the line already IS the delete).
105
+ * - anything else ⇒ apply it, then route() if the surface came up
106
+ * live. A fresh id whose createSurface line failed validation never
107
+ * becomes live in `renderer.surfaces`, so nothing is mounted for it.
108
+ */
109
+ function feed(surfaceId, line, turnBubbleEl) {
110
+ let envelope = null;
111
+ try {
112
+ envelope = JSON.parse(line);
113
+ } catch {
114
+ // Malformed line — let the renderer's own diagnostic path handle it.
115
+ }
116
+
117
+ if (envelope && Object.hasOwn(envelope, 'deleteSurface')) {
118
+ renderer.handleFrame(line);
119
+ const host = hosts.get(surfaceId);
120
+ if (host && host.status !== 'closed') {
121
+ host.status = 'closed';
122
+ annotateClosed(host);
123
+ }
124
+ return host;
125
+ }
126
+
127
+ renderer.handleFrame(line);
128
+ if (renderer.surfaces.has(surfaceId)) {
129
+ return route(surfaceId, turnBubbleEl);
130
+ }
131
+ return hosts.get(surfaceId);
132
+ }
133
+
134
+ function get(surfaceId) {
135
+ return hosts.get(surfaceId);
136
+ }
137
+
138
+ /** Page/component teardown — detaches every open host's rendering from
139
+ * its Root (island cleanup) without sending any protocol delete; this
140
+ * is NOT the same operation as `close()` and leaves no annotation. */
141
+ function disposeAll() {
142
+ for (const host of hosts.values()) {
143
+ host.handle.detach();
144
+ }
145
+ hosts.clear();
146
+ }
147
+
148
+ return { route, close, feed, get, disposeAll, hosts };
149
+ }
150
+
151
+ /** CHAT-HARNESS law 3 — labels rendered from a CODE-OWNED table, never
152
+ * model text. Keyed on `@adia-ai/agent`'s closed `ProgressStage` vocabulary
153
+ * (`PROGRESS_STAGES`); a stage this table has no entry for renders nothing
154
+ * rather than a raw enum value. */
155
+ export const PROGRESS_LABELS = {
156
+ sent: 'Sending…',
157
+ started: 'Thinking…',
158
+ reasoning: 'Reasoning…',
159
+ content: 'Writing…',
160
+ validating: 'Validating…',
161
+ retry: 'Retrying…',
162
+ tool: 'Using a tool…',
163
+ done: 'Done',
164
+ };
165
+
166
+ /**
167
+ * Consumes ONE `AsyncIterable<AgentEvent>` for the turn already opened on
168
+ * `chatShell` (the caller has already appended the user message and an
169
+ * empty assistant draft, and called `chatShell.startStreaming()` — mirrors
170
+ * `playgrounds/agent-chat`'s existing submit handler). Transport-blind
171
+ * (CHAT-HARNESS law 1): this function never fetches, never opens a
172
+ * socket — it only folds the event stream it's handed.
173
+ *
174
+ * @param {import('../chat-shell/chat-shell.js').ChatShell} chatShell
175
+ * @param {ReturnType<typeof createSurfaceRegistry>} registry
176
+ * @param {object} opts
177
+ * @param {AsyncIterable<object>} opts.events — the AgentEvent stream
178
+ * @param {{textContent: string}} [opts.statusEl] — rendered from PROGRESS_LABELS
179
+ * @param {(stage: unknown) => boolean} [opts.isProgressStage] — closed-vocabulary
180
+ * guard; defaults to accepting any key PROGRESS_LABELS has (out-of-vocab drop).
181
+ */
182
+ export async function wireAgentEvents(chatShell, registry, opts) {
183
+ const { events, statusEl = null, isProgressStage = (s) => Object.hasOwn(PROGRESS_LABELS, s) } = opts ?? {};
184
+ if (!events) throw new Error('wireAgentEvents: opts.events (AsyncIterable<AgentEvent>) is required');
185
+
186
+ const currentBubble = () => {
187
+ const thread = chatShell.querySelector?.('chat-thread');
188
+ return thread?.querySelector('[data-role="assistant"]:last-child [data-bubble]') ?? null;
189
+ };
190
+
191
+ for await (const event of events) {
192
+ switch (event.type) {
193
+ case 'text':
194
+ chatShell.appendChunk?.(event.text);
195
+ break;
196
+
197
+ case 'progress': {
198
+ if (!isProgressStage(event.stage)) break; // closed-vocab drop (law 3)
199
+ if (statusEl) statusEl.textContent = PROGRESS_LABELS[event.stage] ?? '';
200
+ break;
201
+ }
202
+
203
+ case 'surface': {
204
+ const bubble = currentBubble();
205
+ registry.feed(event.surfaceId, event.line, bubble);
206
+ break;
207
+ }
208
+
209
+ case 'tool_use':
210
+ chatShell.appendMessage?.({
211
+ role: 'assistant',
212
+ content: `🔧 \`${event.name}(${JSON.stringify(event.input)})\``,
213
+ render: true,
214
+ });
215
+ chatShell.appendMessage?.({ role: 'assistant', content: '' });
216
+ break;
217
+
218
+ case 'tool_result':
219
+ chatShell.appendMessage?.({
220
+ role: event.isError ? 'error' : 'assistant',
221
+ content: `↩ \`${event.name}\` → ${String(event.output).slice(0, 300)}`,
222
+ render: true,
223
+ });
224
+ chatShell.appendMessage?.({ role: 'assistant', content: '' });
225
+ break;
226
+
227
+ case 'error':
228
+ chatShell.appendMessage?.({ role: 'error', content: event.error?.message ?? String(event.error) });
229
+ chatShell.stopStreaming?.();
230
+ break;
231
+
232
+ case 'done':
233
+ chatShell.stopStreaming?.();
234
+ break;
235
+
236
+ default:
237
+ break;
238
+ }
239
+ }
240
+
241
+ if (chatShell.streaming) chatShell.stopStreaming?.();
242
+ }
package/chat/index.js CHANGED
@@ -2,3 +2,4 @@ export { ChatShell } from './chat-shell/chat-shell.js';
2
2
  export { ChatThread } from './chat-thread/chat-thread.js';
3
3
  export { ChatComposer } from './chat-composer/chat-composer.js';
4
4
  export { ChatSidebar } from './chat-sidebar/chat-sidebar.js';
5
+ export { createSurfaceRegistry, wireAgentEvents, PROGRESS_LABELS } from './chat-surfaces/chat-surfaces.js';