@nxisai/agent-harness 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +32 -7
- package/dist/handoff/tools.d.ts.map +1 -1
- package/dist/handoff/tools.js +11 -4
- package/dist/handoff/tools.js.map +1 -1
- package/dist/harness/harness.d.ts +16 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +70 -10
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/recommend.d.ts +46 -0
- package/dist/harness/recommend.d.ts.map +1 -0
- package/dist/harness/recommend.js +144 -0
- package/dist/harness/recommend.js.map +1 -0
- package/dist/harness/records.d.ts +70 -0
- package/dist/harness/records.d.ts.map +1 -0
- package/dist/harness/records.js +209 -0
- package/dist/harness/records.js.map +1 -0
- package/dist/harness/renderer.d.ts +31 -1
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.d.ts +39 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +82 -4
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts +27 -0
- package/dist/harness/tools/site-actions.d.ts.map +1 -1
- package/dist/harness/tools/site-actions.js +47 -0
- package/dist/harness/tools/site-actions.js.map +1 -1
- package/dist/harness/tools/site-pages.js +1 -1
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +5 -2
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/ui/action-card.d.ts +141 -0
- package/dist/ui/action-card.d.ts.map +1 -0
- package/dist/ui/action-card.js +384 -0
- package/dist/ui/action-card.js.map +1 -0
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +615 -88
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/result-pick.d.ts +52 -0
- package/dist/ui/result-pick.d.ts.map +1 -0
- package/dist/ui/result-pick.js +196 -0
- package/dist/ui/result-pick.js.map +1 -0
- package/dist/ui/tailwind.generated.d.ts +1 -1
- package/dist/ui/tailwind.generated.d.ts.map +1 -1
- package/dist/ui/tailwind.generated.js +1 -1
- package/dist/ui/tailwind.generated.js.map +1 -1
- package/package.json +1 -1
- package/src/handoff/tools.ts +11 -7
- package/src/harness/harness.ts +82 -17
- package/src/harness/recommend.ts +194 -0
- package/src/harness/records.ts +237 -0
- package/src/harness/renderer.ts +34 -1
- package/src/harness/tools/action-tools.ts +92 -4
- package/src/harness/tools/site-actions.ts +58 -0
- package/src/harness/tools/site-pages.ts +1 -1
- package/src/harness/tools/source.ts +5 -2
- package/src/index.ts +4 -1
- package/src/ui/action-card.ts +496 -0
- package/src/ui/agent-fullscreen.ts +655 -78
- package/src/ui/result-pick.ts +233 -0
- package/src/ui/tailwind.generated.ts +1 -1
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The action card: how the agent asks for, confirms and sends a form or a
|
|
3
|
+
* handoff, as one message in the conversation.
|
|
4
|
+
*
|
|
5
|
+
* A write goes through several prompts in the harness (ask for what is
|
|
6
|
+
* missing, confirm, run, report), and each used to draw its own card. Here
|
|
7
|
+
* one card carries the whole request: who it is for, what the agent already
|
|
8
|
+
* has, what is still needed, a sentence saying what will happen, then the
|
|
9
|
+
* steps as they run and the outcome. It is the assistant's message, so it
|
|
10
|
+
* reads as the assistant speaking rather than a form dropped into the chat.
|
|
11
|
+
*
|
|
12
|
+
* The card is a {@link ActionItem}: plain data, saved with the conversation
|
|
13
|
+
* and redrawn from it. What a live card can do (continue, send, decline) is
|
|
14
|
+
* passed in as {@link ActionHandlers}; a card drawn without them, after a
|
|
15
|
+
* reload, shows how things stood and offers nothing to press.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
import type { Slot } from '../harness/slots.js';
|
|
20
|
+
import type { JsonSchema } from '../types.js';
|
|
21
|
+
|
|
22
|
+
/** Where a request stands. */
|
|
23
|
+
export type ActionStage =
|
|
24
|
+
/** Some details are still needed, or the visitor asked to change them. */
|
|
25
|
+
| 'collect'
|
|
26
|
+
/** Everything is known; waiting for the visitor to send. */
|
|
27
|
+
| 'review'
|
|
28
|
+
/** Sent; the steps are running. */
|
|
29
|
+
| 'working'
|
|
30
|
+
/** Sent, and the form or the target took it. */
|
|
31
|
+
| 'done'
|
|
32
|
+
/** Left for the page that submits it. That page finishes the request. */
|
|
33
|
+
| 'left'
|
|
34
|
+
/** It did not go through. */
|
|
35
|
+
| 'failed'
|
|
36
|
+
/** The visitor said not now, or the prompt was replaced. */
|
|
37
|
+
| 'declined';
|
|
38
|
+
|
|
39
|
+
/** How a field is edited. */
|
|
40
|
+
export type ActionFieldKind = 'text' | 'long' | 'email' | 'tel' | 'date' | 'number' | 'choice' | 'select' | 'check';
|
|
41
|
+
|
|
42
|
+
/** One field of the request. */
|
|
43
|
+
export interface ActionField {
|
|
44
|
+
name: string;
|
|
45
|
+
label: string;
|
|
46
|
+
/** The value as typed, always a string; see {@link actionValues} for the typed form. */
|
|
47
|
+
value: string;
|
|
48
|
+
kind: ActionFieldKind;
|
|
49
|
+
options?: { value: string; label: string }[];
|
|
50
|
+
required: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* `known`: the agent had it (from the chat, a saved profile, the page).
|
|
53
|
+
* `needed`: still to be given. `hidden`: sent but not shown, such as the
|
|
54
|
+
* business the card is already about.
|
|
55
|
+
*/
|
|
56
|
+
group: 'known' | 'needed' | 'hidden';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Who the request goes to. */
|
|
60
|
+
export interface ActionIdentity {
|
|
61
|
+
name: string;
|
|
62
|
+
/** One line under the name: "Plumber · Royal Oak · ★ 4.9 (287)", "Quote form". */
|
|
63
|
+
sub?: string;
|
|
64
|
+
/** Availability, when it is known: "Replies in ~5 min", "Away until 8am". */
|
|
65
|
+
status?: string;
|
|
66
|
+
tone?: 'ok' | 'away';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One step of a running request, as the card lists it. */
|
|
70
|
+
export interface ActionStep {
|
|
71
|
+
label: string;
|
|
72
|
+
status: 'active' | 'done' | 'failed';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A request as the conversation keeps it. */
|
|
76
|
+
export interface ActionItem {
|
|
77
|
+
kind: 'action';
|
|
78
|
+
/** The journey this request is, so its steps find their card. */
|
|
79
|
+
id: string;
|
|
80
|
+
/** The tool it calls. */
|
|
81
|
+
tool: string;
|
|
82
|
+
/** A form, or a handoff to an agent or a person. */
|
|
83
|
+
variant: 'form' | 'handoff-agent' | 'handoff-person';
|
|
84
|
+
/** What it is, in words: "quote form". */
|
|
85
|
+
noun: string;
|
|
86
|
+
identity: ActionIdentity;
|
|
87
|
+
fields: ActionField[];
|
|
88
|
+
stage: ActionStage;
|
|
89
|
+
/** What will happen, in one sentence, shown when reviewing. */
|
|
90
|
+
sentence: string;
|
|
91
|
+
/** A line under the sentence: where it is sent from, when that is another page. */
|
|
92
|
+
note?: string;
|
|
93
|
+
/** What the primary button says when sending. */
|
|
94
|
+
sendLabel: string;
|
|
95
|
+
/** The steps once sent. */
|
|
96
|
+
steps?: ActionStep[];
|
|
97
|
+
/** The outcome in words: a confirmation, or why it failed. */
|
|
98
|
+
message?: string;
|
|
99
|
+
/** The disclosure is open. */
|
|
100
|
+
open?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What a live card can do. Absent on a card redrawn from history. */
|
|
104
|
+
export interface ActionHandlers {
|
|
105
|
+
/** Collecting is done: the values, typed. */
|
|
106
|
+
continue(values: Record<string, unknown>): void;
|
|
107
|
+
/** Review asked to change something. */
|
|
108
|
+
change(): void;
|
|
109
|
+
/** Send, with the values as they now stand. */
|
|
110
|
+
send(values: Record<string, unknown>): void;
|
|
111
|
+
/** Not now. */
|
|
112
|
+
decline(): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Fields ──────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/** `full_name` → `Full name`. */
|
|
118
|
+
export function fieldLabel(name: string): string {
|
|
119
|
+
const words = name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim().toLowerCase();
|
|
120
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const LONG = /description|details|message|summary|notes?|comments?|problem|issue|project|question/i;
|
|
124
|
+
|
|
125
|
+
function kindOf(name: string, schema: JsonSchema, value: string): Pick<ActionField, 'kind' | 'options'> {
|
|
126
|
+
// "Select…" and "Choose a service…" are how a form asks, not answers.
|
|
127
|
+
const values = (schema.enum ?? schema.items?.enum)?.filter((v) => !/^\s*(select|choose|pick)\b/i.test(String(v)) && !/(…|\.\.\.)\s*$/.test(String(v)));
|
|
128
|
+
if (values?.length) {
|
|
129
|
+
const options = values.map((v) => ({ value: String(v), label: fieldLabel(String(v)) }));
|
|
130
|
+
return { kind: options.length <= 4 ? 'choice' : 'select', options };
|
|
131
|
+
}
|
|
132
|
+
if (schema.type === 'boolean') return { kind: 'check' };
|
|
133
|
+
if (schema.type === 'integer' || schema.type === 'number') return { kind: 'number' };
|
|
134
|
+
if (schema.format === 'email' || /e-?mail/i.test(name)) return { kind: 'email' };
|
|
135
|
+
if (/phone|tel\b|mobile/i.test(name)) return { kind: 'tel' };
|
|
136
|
+
if (schema.format === 'date' || /(^|_)date($|_)/i.test(name)) return { kind: 'date' };
|
|
137
|
+
if (LONG.test(name) || value.length > 60) return { kind: 'long' };
|
|
138
|
+
return { kind: 'text' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The card's fields, from the harness's resolved slots.
|
|
143
|
+
*
|
|
144
|
+
* @param slots - Every slot of the tool.
|
|
145
|
+
* @param values - The values known so far.
|
|
146
|
+
* @param opts.needed - Slots still to be given.
|
|
147
|
+
* @param opts.hidden - Slots sent but not shown.
|
|
148
|
+
* @param opts.labels - Labels to use instead of the field's name.
|
|
149
|
+
*/
|
|
150
|
+
export function fieldsFromSlots(
|
|
151
|
+
slots: Slot[],
|
|
152
|
+
values: Record<string, unknown>,
|
|
153
|
+
opts: { needed?: Set<string>; hidden?: Set<string>; labels?: Record<string, string> } = {},
|
|
154
|
+
): ActionField[] {
|
|
155
|
+
return slots.map((slot) => {
|
|
156
|
+
const raw = values[slot.name];
|
|
157
|
+
const value = raw === undefined || raw === null ? '' : typeof raw === 'boolean' ? (raw ? 'true' : '') : String(raw);
|
|
158
|
+
const group: ActionField['group'] = opts.hidden?.has(slot.name)
|
|
159
|
+
? 'hidden'
|
|
160
|
+
: opts.needed?.has(slot.name) || value === ''
|
|
161
|
+
? 'needed'
|
|
162
|
+
: 'known';
|
|
163
|
+
return {
|
|
164
|
+
name: slot.name,
|
|
165
|
+
label: opts.labels?.[slot.name] ?? fieldLabel(slot.name),
|
|
166
|
+
value,
|
|
167
|
+
required: slot.required,
|
|
168
|
+
group,
|
|
169
|
+
...kindOf(slot.name, slot.schema ?? {}, value),
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The values to send, typed as the schema wants them. Empty optional fields are left out. */
|
|
175
|
+
export function actionValues(item: Pick<ActionItem, 'fields'>): Record<string, unknown> {
|
|
176
|
+
const out: Record<string, unknown> = {};
|
|
177
|
+
for (const f of item.fields) {
|
|
178
|
+
if (f.kind === 'check') {
|
|
179
|
+
out[f.name] = f.value === 'true';
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (f.value.trim() === '') continue;
|
|
183
|
+
out[f.name] = f.kind === 'number' && !Number.isNaN(Number(f.value)) ? Number(f.value) : f.value;
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
189
|
+
|
|
190
|
+
/** Fields that stop the request from going: required and empty, or not an email address. */
|
|
191
|
+
export function missingFields(item: Pick<ActionItem, 'fields'>): ActionField[] {
|
|
192
|
+
return item.fields.filter(
|
|
193
|
+
(f) => f.group !== 'hidden' && ((f.required && f.kind !== 'check' && f.value.trim() === '') || (f.kind === 'email' && f.value.trim() !== '' && !EMAIL.test(f.value.trim()))),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Up to two letters for a name: "Hatzel & Sons Plumbing" → "HS". */
|
|
198
|
+
export function initials(name: string): string {
|
|
199
|
+
const words = name.split(/\s+/).filter((w) => /^[\p{L}\p{N}]/u.test(w));
|
|
200
|
+
return (words.slice(0, 2).map((w) => w[0]).join('') || '?').toUpperCase();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Drawing ─────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
type Child = Node | string | null | undefined | false;
|
|
206
|
+
|
|
207
|
+
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string | boolean | undefined> = {}, ...children: Child[]): HTMLElementTagNameMap[K] {
|
|
208
|
+
const el = document.createElement(tag);
|
|
209
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
210
|
+
if (v === undefined || v === false) continue;
|
|
211
|
+
if (k === 'class') el.className = String(v);
|
|
212
|
+
else el.setAttribute(k, v === true ? '' : v);
|
|
213
|
+
}
|
|
214
|
+
for (const c of children) if (c) el.append(c);
|
|
215
|
+
return el;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const CHECK = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5L20 7"/></svg>';
|
|
219
|
+
const LEAVE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 3h7v7M10 14 21 3M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>';
|
|
220
|
+
|
|
221
|
+
function icon(svg: string, cls: string): HTMLElement {
|
|
222
|
+
const span = h('span', { class: cls });
|
|
223
|
+
span.innerHTML = svg;
|
|
224
|
+
return span;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let uid = 0;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Draw a card.
|
|
231
|
+
*
|
|
232
|
+
* @param item - The request. Edits to its fields are written back to it.
|
|
233
|
+
* @param handlers - What a live card can do; null for a card from history.
|
|
234
|
+
* @param onChange - Called after an edit, so the conversation can be saved.
|
|
235
|
+
*/
|
|
236
|
+
export function actionCard(item: ActionItem, handlers: ActionHandlers | null, onChange: () => void): HTMLElement {
|
|
237
|
+
const id = `ac${++uid}`;
|
|
238
|
+
const live = !!handlers;
|
|
239
|
+
// A card from history that never got past asking was left behind: say so, and offer nothing.
|
|
240
|
+
const stage: ActionStage = !live && (item.stage === 'collect' || item.stage === 'review') ? 'declined' : item.stage;
|
|
241
|
+
const card = h('div', { class: `msg assistant action-card is-${stage}`, 'data-action': item.id });
|
|
242
|
+
|
|
243
|
+
card.append(identityRow(item, stage));
|
|
244
|
+
|
|
245
|
+
const visible = item.fields.filter((f) => f.group !== 'hidden');
|
|
246
|
+
const known = visible.filter((f) => f.group === 'known');
|
|
247
|
+
const needed = visible.filter((f) => f.group === 'needed');
|
|
248
|
+
const person = item.variant === 'handoff-person';
|
|
249
|
+
const handoff = item.variant !== 'form';
|
|
250
|
+
const disclosureLabel = handoff ? 'What they’ll see' : stage === 'done' ? 'What was sent' : 'What they’ll get';
|
|
251
|
+
|
|
252
|
+
const disclosure = (editable: boolean): HTMLElement | null => {
|
|
253
|
+
const shown = editable ? known : visible.filter((f) => f.value.trim() !== '');
|
|
254
|
+
if (!shown.length) return null;
|
|
255
|
+
const panel = h('div', { class: 'ac-panel', id: `${id}-panel` });
|
|
256
|
+
if (editable) shown.forEach((f) => panel.append(control(f, id, onChange, () => refresh())));
|
|
257
|
+
else {
|
|
258
|
+
const dl = h('dl', { class: 'ac-list' });
|
|
259
|
+
shown.forEach((f) => dl.append(h('dt', {}, f.label), h('dd', {}, f.kind === 'check' ? (f.value === 'true' ? 'Yes' : 'No') : f.value)));
|
|
260
|
+
panel.append(dl);
|
|
261
|
+
}
|
|
262
|
+
panel.hidden = !item.open;
|
|
263
|
+
const toggle = h('button', { type: 'button', class: 'ac-toggle', 'aria-expanded': String(!!item.open), 'aria-controls': `${id}-panel` }, `${disclosureLabel} ›`);
|
|
264
|
+
toggle.addEventListener('click', () => {
|
|
265
|
+
item.open = !item.open;
|
|
266
|
+
panel.hidden = !item.open;
|
|
267
|
+
toggle.setAttribute('aria-expanded', String(!!item.open));
|
|
268
|
+
onChange();
|
|
269
|
+
});
|
|
270
|
+
return h('div', { class: 'ac-disclosure' }, toggle, panel);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Enables the primary button as fields are filled, without redrawing what is being typed in.
|
|
274
|
+
let refresh = (): void => {};
|
|
275
|
+
|
|
276
|
+
if (stage === 'collect') {
|
|
277
|
+
if (item.sentence && handoff) card.append(h('p', { class: 'ac-sentence' }, item.sentence));
|
|
278
|
+
if (handoff) card.append(disclosure(true) as Node);
|
|
279
|
+
else if (known.length) {
|
|
280
|
+
const group = h('div', { class: 'ac-group' }, h('div', { class: 'ac-eyebrow' }, 'What I have · tap to change'));
|
|
281
|
+
known.forEach((f) => group.append(control(f, id, onChange, () => refresh())));
|
|
282
|
+
card.append(group);
|
|
283
|
+
}
|
|
284
|
+
if (needed.length) {
|
|
285
|
+
const group = h('div', { class: 'ac-group' }, h('div', { class: 'ac-eyebrow' }, person ? 'So they can reply' : 'Still needed'));
|
|
286
|
+
needed.forEach((f) => group.append(control(f, id, onChange, () => refresh())));
|
|
287
|
+
card.append(group);
|
|
288
|
+
}
|
|
289
|
+
const primary = h('button', { type: 'button', class: 'ac-btn primary' });
|
|
290
|
+
refresh = () => {
|
|
291
|
+
const gaps = missingFields(item);
|
|
292
|
+
primary.disabled = gaps.length > 0;
|
|
293
|
+
primary.textContent = gaps.length ? `Add ${list(gaps.map(askFor))}` : handoff ? item.sendLabel : 'Review';
|
|
294
|
+
};
|
|
295
|
+
refresh();
|
|
296
|
+
primary.addEventListener('click', () => {
|
|
297
|
+
if (missingFields(item).length) return;
|
|
298
|
+
handlers?.continue(actionValues(item));
|
|
299
|
+
});
|
|
300
|
+
card.append(actions(primary, notNow(handlers)));
|
|
301
|
+
} else if (stage === 'review') {
|
|
302
|
+
card.append(h('p', { class: 'ac-sentence' }, item.sentence));
|
|
303
|
+
const d = disclosure(false);
|
|
304
|
+
if (d) {
|
|
305
|
+
const change = h('button', { type: 'button', class: 'ac-link' }, 'Change');
|
|
306
|
+
change.addEventListener('click', () => handlers?.change());
|
|
307
|
+
d.querySelector('.ac-toggle')!.after(change);
|
|
308
|
+
card.append(d);
|
|
309
|
+
}
|
|
310
|
+
if (item.note) card.append(h('p', { class: 'ac-note' }, icon(LEAVE, 'ac-note-icon'), item.note));
|
|
311
|
+
const primary = h('button', { type: 'button', class: 'ac-btn primary' }, item.sendLabel);
|
|
312
|
+
primary.addEventListener('click', () => {
|
|
313
|
+
primary.disabled = true;
|
|
314
|
+
handlers?.send(actionValues(item));
|
|
315
|
+
});
|
|
316
|
+
card.append(actions(primary, notNow(handlers)));
|
|
317
|
+
} else if (stage === 'working') {
|
|
318
|
+
const steps = item.steps?.length ? item.steps : [{ label: 'Sending', status: 'active' as const }];
|
|
319
|
+
const ol = h('ol', { class: 'ac-steps', 'aria-live': 'polite' });
|
|
320
|
+
steps.forEach((s) => ol.append(h('li', { class: `is-${s.status}` }, marker(s.status), h('span', {}, s.label))));
|
|
321
|
+
card.append(ol);
|
|
322
|
+
} else if (stage === 'done') {
|
|
323
|
+
card.append(h('div', { class: 'ac-outcome ok' }, icon(CHECK, 'ac-tick'), h('strong', {}, handoff ? `Handed to ${item.identity.name.replace(/^The\s/, 'the ')}` : 'Sent')));
|
|
324
|
+
// The form's own words when it had any; otherwise who replies, and where.
|
|
325
|
+
const email = item.fields.find((f) => f.kind === 'email' && f.value.trim())?.value.trim();
|
|
326
|
+
const reply = item.message ?? (!handoff && email ? `${item.identity.name} will reply to ${email}.` : undefined);
|
|
327
|
+
if (reply) card.append(h('p', { class: 'ac-sentence' }, reply));
|
|
328
|
+
if (!handoff) card.append(disclosure(false) as Node);
|
|
329
|
+
} else if (stage === 'left') {
|
|
330
|
+
card.append(h('p', { class: 'ac-note' }, icon(LEAVE, 'ac-note-icon'), item.message ?? `Opened ${item.identity.name}’s page to send it from there.`));
|
|
331
|
+
} else if (stage === 'failed') {
|
|
332
|
+
card.append(h('div', { class: 'ac-outcome bad' }, h('span', { class: 'ac-bang', 'aria-hidden': 'true' }, '!'), h('strong', {}, 'It didn’t go through')));
|
|
333
|
+
if (item.message) card.append(h('p', { class: 'ac-sentence' }, item.message));
|
|
334
|
+
if (live) {
|
|
335
|
+
const again = h('button', { type: 'button', class: 'ac-btn primary' }, 'Change and try again');
|
|
336
|
+
again.addEventListener('click', () => handlers!.change());
|
|
337
|
+
card.append(actions(again, notNow(handlers)));
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
card.append(h('p', { class: 'ac-muted' }, 'Not sent.'));
|
|
341
|
+
}
|
|
342
|
+
return card;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function identityRow(item: ActionItem, stage: ActionStage): HTMLElement {
|
|
346
|
+
const who = item.identity;
|
|
347
|
+
const text = h('div', { class: 'ac-who' }, h('strong', {}, who.name));
|
|
348
|
+
if (who.sub) text.append(h('span', {}, who.sub));
|
|
349
|
+
// Availability matters while deciding; afterwards the outcome says what happened.
|
|
350
|
+
if (who.status && (stage === 'collect' || stage === 'review')) {
|
|
351
|
+
text.append(h('span', { class: `ac-status ${who.tone ?? ''}` }, h('i', { 'aria-hidden': 'true' }), who.status));
|
|
352
|
+
}
|
|
353
|
+
return h('div', { class: 'ac-id' }, h('div', { class: `ac-avatar ${item.variant === 'form' ? 'square' : ''}`, 'aria-hidden': 'true' }, initials(who.name)), text);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function marker(status: ActionStep['status']): HTMLElement {
|
|
357
|
+
if (status === 'done') return icon(CHECK, 'ac-mark done');
|
|
358
|
+
if (status === 'failed') return h('span', { class: 'ac-mark failed', 'aria-hidden': 'true' }, '!');
|
|
359
|
+
return h('span', { class: 'ac-mark active', 'aria-hidden': 'true' });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function actions(primary: HTMLElement, secondary: HTMLElement | null): HTMLElement {
|
|
363
|
+
return h('div', { class: 'ac-actions' }, primary, secondary);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function notNow(handlers: ActionHandlers | null): HTMLElement | null {
|
|
367
|
+
if (!handlers) return null;
|
|
368
|
+
const b = h('button', { type: 'button', class: 'ac-btn' }, 'Not now');
|
|
369
|
+
b.addEventListener('click', () => handlers.decline());
|
|
370
|
+
return b;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A missing field as the button asks for it: "your email", not "where should they reply?". */
|
|
374
|
+
function askFor(f: ActionField): string {
|
|
375
|
+
if (f.kind === 'email') return 'your email';
|
|
376
|
+
if (f.kind === 'tel') return 'your phone number';
|
|
377
|
+
if (/^(full_?)?name$/i.test(f.name)) return 'your name';
|
|
378
|
+
return f.label.replace(/\?$/, '').toLowerCase();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function list(words: string[]): string {
|
|
382
|
+
// "your name and email", not "your name and your email".
|
|
383
|
+
words = words.map((w, i) => (i > 0 && words[0].startsWith('your ') ? w.replace(/^your /, '') : w));
|
|
384
|
+
if (words.length <= 1) return words[0] ?? '';
|
|
385
|
+
return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function control(f: ActionField, id: string, onChange: () => void, onInput: () => void): HTMLElement {
|
|
389
|
+
const fid = `${id}-${f.name}`;
|
|
390
|
+
const set = (v: string) => {
|
|
391
|
+
f.value = v;
|
|
392
|
+
onInput();
|
|
393
|
+
onChange();
|
|
394
|
+
};
|
|
395
|
+
const label = h('span', { class: 'ac-label' }, f.label, !f.required && f.group === 'needed' ? h('em', {}, ' (optional)') : null);
|
|
396
|
+
|
|
397
|
+
if (f.kind === 'choice' && f.options) {
|
|
398
|
+
const group = h('div', { class: 'ac-choices', role: 'group', 'aria-labelledby': `${fid}-l` });
|
|
399
|
+
const buttons = f.options.map((o) => {
|
|
400
|
+
const b = h('button', { type: 'button', class: 'ac-choice', 'aria-pressed': String(o.value === f.value) }, o.label);
|
|
401
|
+
b.addEventListener('click', () => {
|
|
402
|
+
set(o.value);
|
|
403
|
+
buttons.forEach((x, i) => x.setAttribute('aria-pressed', String(f.options![i].value === f.value)));
|
|
404
|
+
});
|
|
405
|
+
return b;
|
|
406
|
+
});
|
|
407
|
+
buttons.forEach((b) => group.append(b));
|
|
408
|
+
label.id = `${fid}-l`;
|
|
409
|
+
return h('div', { class: 'ac-field' }, label, group);
|
|
410
|
+
}
|
|
411
|
+
if (f.kind === 'check') {
|
|
412
|
+
const box = h('input', { type: 'checkbox', id: fid }) as HTMLInputElement;
|
|
413
|
+
box.checked = f.value === 'true';
|
|
414
|
+
box.addEventListener('change', () => set(box.checked ? 'true' : ''));
|
|
415
|
+
return h('label', { class: 'ac-field check', for: fid }, box, label);
|
|
416
|
+
}
|
|
417
|
+
let input: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
|
418
|
+
if (f.kind === 'select' && f.options) {
|
|
419
|
+
const select = h('select', { id: fid }) as HTMLSelectElement;
|
|
420
|
+
select.append(h('option', { value: '' }, 'Choose…'));
|
|
421
|
+
f.options.forEach((o) => select.append(h('option', { value: o.value }, o.label)));
|
|
422
|
+
select.value = f.value;
|
|
423
|
+
input = select;
|
|
424
|
+
} else if (f.kind === 'long') {
|
|
425
|
+
input = h('textarea', { id: fid, rows: '2' }) as HTMLTextAreaElement;
|
|
426
|
+
input.value = f.value;
|
|
427
|
+
} else {
|
|
428
|
+
const type = f.kind === 'email' ? 'email' : f.kind === 'tel' ? 'tel' : f.kind === 'date' ? 'date' : f.kind === 'number' ? 'number' : 'text';
|
|
429
|
+
const auto = f.kind === 'email' ? 'email' : f.kind === 'tel' ? 'tel' : /(^|_)name$/i.test(f.name) ? 'name' : undefined;
|
|
430
|
+
input = h('input', { id: fid, type, autocomplete: auto }) as HTMLInputElement;
|
|
431
|
+
input.value = f.value;
|
|
432
|
+
}
|
|
433
|
+
if (f.required) input.setAttribute('aria-required', 'true');
|
|
434
|
+
input.addEventListener('input', () => set(input.value));
|
|
435
|
+
input.addEventListener('change', () => set(input.value));
|
|
436
|
+
return h('label', { class: 'ac-field', for: fid }, label, input);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The card's styles, on the widget's own tokens. */
|
|
440
|
+
export const ACTION_CARD_CSS = `
|
|
441
|
+
.action-card { align-self: flex-start; width: min(100%, 420px); box-sizing: border-box; display: flex; flex-direction: column; gap: 12px; padding: 12px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 16px 16px 16px 4px; font-size: 14px; line-height: 1.5; color: var(--ink); white-space: normal; }
|
|
442
|
+
.action-card p { margin: 0; }
|
|
443
|
+
.ac-id { display: flex; align-items: center; gap: 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; }
|
|
444
|
+
.ac-avatar { width: 34px; height: 34px; flex-shrink: 0; border-radius: 50%; background: var(--blue-strong); color: #fff; font-size: 12px; font-weight: 700; display: grid; place-items: center; letter-spacing: .02em; }
|
|
445
|
+
.ac-avatar.square { border-radius: 10px; background: var(--ink); }
|
|
446
|
+
.ac-who { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
447
|
+
.ac-who strong { font-size: 14px; font-weight: 600; }
|
|
448
|
+
.ac-who span { font-size: 12.5px; color: var(--ink-2); }
|
|
449
|
+
.ac-status { display: inline-flex; align-items: center; gap: 6px; }
|
|
450
|
+
.ac-status i { width: 7px; height: 7px; border-radius: 50%; background: var(--green-strong); flex-shrink: 0; }
|
|
451
|
+
.ac-status.away i { background: #D97706; }
|
|
452
|
+
.ac-sentence { color: var(--ink); }
|
|
453
|
+
.ac-note { display: flex; gap: 6px; align-items: flex-start; font-size: 12.5px; color: var(--ink-2); }
|
|
454
|
+
.ac-note-icon { flex-shrink: 0; margin-top: 2px; display: inline-flex; }
|
|
455
|
+
.ac-muted { color: var(--ink-3); font-size: 13px; }
|
|
456
|
+
.action-card.is-declined .ac-id { opacity: .7; }
|
|
457
|
+
.ac-eyebrow { font-family: var(--mono); font-size: 10.5px; font-weight: 500; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); }
|
|
458
|
+
.ac-group { display: flex; flex-direction: column; gap: 10px; }
|
|
459
|
+
.ac-field { display: flex; flex-direction: column; gap: 4px; }
|
|
460
|
+
.ac-field.check { flex-direction: row; align-items: center; gap: 8px; }
|
|
461
|
+
.ac-label { font-size: 12.5px; font-weight: 600; color: var(--ink-2); }
|
|
462
|
+
.ac-label em { font-style: normal; font-weight: 400; color: var(--ink-3); }
|
|
463
|
+
.ac-field input:not([type=checkbox]), .ac-field textarea, .ac-field select { box-sizing: border-box; width: 100%; min-height: 40px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 8px 12px; font: inherit; font-size: 14px; color: var(--ink); background: var(--bg); }
|
|
464
|
+
.ac-field textarea { resize: vertical; line-height: 1.45; }
|
|
465
|
+
.ac-field input:focus, .ac-field textarea:focus, .ac-field select:focus { outline: 2px solid var(--blue); outline-offset: -1px; border-color: transparent; }
|
|
466
|
+
.ac-choices { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
467
|
+
.ac-choice { min-height: 36px; padding: 0 12px; border-radius: var(--radius-pill); border: 1px solid var(--border-strong); background: var(--bg); color: var(--ink-2); font: 600 13px var(--sans); cursor: pointer; }
|
|
468
|
+
.ac-choice[aria-pressed=true] { border: 1.5px solid var(--blue); background: var(--blue-tint); color: var(--blue-strong); }
|
|
469
|
+
.ac-disclosure { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
|
|
470
|
+
.ac-toggle, .ac-link { border: 0; background: none; padding: 0; font: 600 13px var(--sans); color: var(--blue-strong); cursor: pointer; }
|
|
471
|
+
.ac-link { color: var(--ink-2); text-decoration: underline; text-underline-offset: 2px; }
|
|
472
|
+
.ac-panel { flex-basis: 100%; display: flex; flex-direction: column; gap: 10px; border-top: 1px solid var(--border); padding-top: 8px; margin-top: 4px; }
|
|
473
|
+
.ac-list { display: grid; grid-template-columns: minmax(80px, max-content) minmax(0, 1fr); gap: 6px 12px; margin: 0; font-size: 13px; }
|
|
474
|
+
.ac-list dt { color: var(--ink-3); }
|
|
475
|
+
.ac-list dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
476
|
+
.ac-actions { display: flex; gap: 8px; }
|
|
477
|
+
.ac-btn { min-height: 42px; padding: 0 14px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: var(--bg); color: var(--ink); font: 600 14px var(--sans); cursor: pointer; }
|
|
478
|
+
.ac-btn.primary { flex-grow: 1; border: 0; background: var(--blue); color: #fff; }
|
|
479
|
+
.ac-btn.primary:hover:not(:disabled) { background: var(--blue-strong); }
|
|
480
|
+
.ac-btn.primary:disabled { background: var(--surface-2); color: var(--ink-3); cursor: not-allowed; }
|
|
481
|
+
.ac-btn:focus-visible, .ac-toggle:focus-visible, .ac-link:focus-visible, .ac-choice:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
|
482
|
+
.ac-steps { list-style: none; margin: 0; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
|
|
483
|
+
.ac-steps li { display: flex; align-items: center; gap: 10px; font-size: 13.5px; }
|
|
484
|
+
.ac-steps li.is-active { font-weight: 600; }
|
|
485
|
+
.ac-mark { width: 18px; height: 18px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; box-sizing: border-box; }
|
|
486
|
+
.ac-mark.done { background: var(--green-strong); color: #fff; }
|
|
487
|
+
.ac-mark.failed { background: var(--red); color: #fff; font-size: 11px; font-weight: 700; }
|
|
488
|
+
.ac-mark.active { border: 2px solid var(--blue); border-right-color: transparent; animation: ac-spin .9s linear infinite; width: 14px; height: 14px; margin: 0 2px; }
|
|
489
|
+
@keyframes ac-spin { to { transform: rotate(360deg); } }
|
|
490
|
+
@media (prefers-reduced-motion: reduce) { .ac-mark.active { animation: none; } }
|
|
491
|
+
.ac-outcome { display: flex; align-items: center; gap: 10px; font-size: 14.5px; }
|
|
492
|
+
.ac-tick { width: 22px; height: 22px; border-radius: 50%; background: var(--green-strong); color: #fff; display: grid; place-items: center; flex-shrink: 0; }
|
|
493
|
+
.ac-bang { width: 22px; height: 22px; border-radius: 50%; background: var(--red); color: #fff; display: grid; place-items: center; font-weight: 700; flex-shrink: 0; }
|
|
494
|
+
.action-card.is-done { border-color: #BFE5CD; }
|
|
495
|
+
.action-card.is-failed { border-color: #F3C4C4; }
|
|
496
|
+
`;
|