@artooi/ag-ui-web-component 0.9.0 → 0.11.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +104 -7
  2. package/README.md +89 -7
  3. package/dist/ag-ui-web-component.bundle.js +168 -47
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +39 -1
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +28 -1
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/attachment.d.ts +5 -0
  10. package/dist/core/attachment.d.ts.map +1 -1
  11. package/dist/core/conversation_store.d.ts +8 -0
  12. package/dist/core/conversation_store.d.ts.map +1 -1
  13. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  14. package/dist/core/upload_attachment.d.ts +8 -2
  15. package/dist/core/upload_attachment.d.ts.map +1 -1
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +887 -128
  19. package/dist/index.js.map +4 -4
  20. package/dist/ui/approval_card.d.ts +51 -0
  21. package/dist/ui/approval_card.d.ts.map +1 -0
  22. package/dist/ui/attachment_chips.d.ts.map +1 -1
  23. package/dist/ui/attachment_tray.d.ts +7 -1
  24. package/dist/ui/attachment_tray.d.ts.map +1 -1
  25. package/dist/ui/question_card.d.ts +52 -0
  26. package/dist/ui/question_card.d.ts.map +1 -0
  27. package/dist/ui/relative_time.d.ts +5 -3
  28. package/dist/ui/relative_time.d.ts.map +1 -1
  29. package/dist/ui/skills_menu.d.ts.map +1 -1
  30. package/dist/ui/styles.d.ts +1 -1
  31. package/dist/ui/styles.d.ts.map +1 -1
  32. package/dist/ui/thoughts_block.d.ts +2 -2
  33. package/dist/ui/thoughts_block.d.ts.map +1 -1
  34. package/dist/ui/thread_drawer.d.ts.map +1 -1
  35. package/dist/ui/tool_call_card.d.ts.map +1 -1
  36. package/dist/ui/ui_strings.d.ts +16 -0
  37. package/dist/ui/ui_strings.d.ts.map +1 -1
  38. package/dist/ui/voice_input.d.ts +9 -1
  39. package/dist/ui/voice_input.d.ts.map +1 -1
  40. package/dist/version.d.ts.map +1 -1
  41. package/package.json +4 -4
  42. package/src/core/ag_ui_chat.ts +251 -14
  43. package/src/core/agui_client.ts +95 -9
  44. package/src/core/attachment.ts +21 -1
  45. package/src/core/conversation_store.ts +84 -18
  46. package/src/core/remote_conversation_store.ts +24 -3
  47. package/src/core/upload_attachment.ts +8 -1
  48. package/src/index.ts +14 -0
  49. package/src/ui/approval_card.ts +119 -0
  50. package/src/ui/attachment_chips.ts +5 -0
  51. package/src/ui/attachment_tray.ts +50 -5
  52. package/src/ui/question_card.ts +216 -0
  53. package/src/ui/relative_time.ts +8 -3
  54. package/src/ui/skills_menu.ts +6 -0
  55. package/src/ui/styles.ts +130 -9
  56. package/src/ui/thoughts_block.ts +3 -2
  57. package/src/ui/thread_drawer.ts +94 -9
  58. package/src/ui/tool_call_card.ts +6 -0
  59. package/src/ui/ui_strings.ts +30 -0
  60. package/src/ui/voice_input.ts +21 -1
  61. package/src/version.ts +1 -1
@@ -0,0 +1,216 @@
1
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
+
3
+ /** What the inline question card asks (the `ask_user` frontend tool's args). */
4
+ export interface QuestionRequest {
5
+ /** The question shown to the user. */
6
+ question: string;
7
+ /**
8
+ * Preset choices rendered as radios. When empty/omitted the card is a plain
9
+ * free-text prompt.
10
+ */
11
+ options?: readonly string[];
12
+ /**
13
+ * Whether the user may type a custom answer. With `options`, adds an "other"
14
+ * radio revealing a text field; without options the card is free-text anyway.
15
+ */
16
+ allowCustom?: boolean;
17
+ }
18
+
19
+ /** Options for {@link requestQuestion}. */
20
+ export interface QuestionOptions {
21
+ /**
22
+ * Aborting this signal resolves the card with an empty answer (fields
23
+ * disabled) — the hook a Stop control uses to dismiss an open question when
24
+ * the user cancels the whole run.
25
+ */
26
+ signal?: AbortSignal;
27
+ /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
28
+ strings?: UiStrings;
29
+ }
30
+
31
+ /**
32
+ * A fully custom renderer for the `ask_user` question, set via
33
+ * `AgUiChat.askUserRenderer`. Receives the parsed {@link QuestionRequest} and an
34
+ * `AbortSignal` that fires when the run is stopped, and resolves with the user's
35
+ * answer (an empty string signals "no answer", e.g. on abort). When provided it
36
+ * **replaces** the built-in {@link requestQuestion} card entirely — the host owns
37
+ * the DOM, so it can render a native modal, a framework component, or anything
38
+ * else. See the `strings` / `::part()` seams for styling the built-in card
39
+ * instead of replacing it.
40
+ */
41
+ export type QuestionRenderer = (
42
+ request: QuestionRequest,
43
+ options: { signal: AbortSignal },
44
+ ) => Promise<string>;
45
+
46
+ /** A single custom-answer text field. */
47
+ function answerInput(placeholder: string): HTMLInputElement {
48
+ const input = document.createElement("input");
49
+ input.type = "text";
50
+ input.className = "question-input";
51
+ input.setAttribute("part", "question-input");
52
+ input.placeholder = placeholder;
53
+ return input;
54
+ }
55
+
56
+ /**
57
+ * Append an inline **question** card to ``host`` and resolve with the user's
58
+ * answer — the browser half of the built-in `ask_user` frontend tool.
59
+ *
60
+ * Unlike the confirmation/approval cards (which resolve a yes/no), this collects
61
+ * a typed answer: a radio pick from ``options``, or free text (when
62
+ * ``allowCustom`` or no ``options`` are given). The card stays in the transcript
63
+ * as a resolved record (controls disabled, `data-resolved` set) rather than
64
+ * vanishing. A Stop while it is open resolves it with an empty string.
65
+ */
66
+ export function requestQuestion(
67
+ host: Node & ParentNode,
68
+ request: QuestionRequest,
69
+ options: QuestionOptions = {},
70
+ ): Promise<string> {
71
+ const strings = options.strings ?? DEFAULT_UI_STRINGS;
72
+ const choices = request.options ?? [];
73
+ const hasChoices = choices.length > 0;
74
+ // Free text is offered when there are no preset choices, or when custom
75
+ // answers are explicitly allowed alongside them (via an "other" radio).
76
+ const allowsText = !hasChoices || request.allowCustom === true;
77
+
78
+ return new Promise<string>((resolve) => {
79
+ const card = document.createElement("div");
80
+ card.className = "question";
81
+ card.setAttribute("part", "question");
82
+ card.setAttribute("role", "group");
83
+ card.setAttribute("aria-label", strings.askUserAction);
84
+
85
+ const body = document.createElement("div");
86
+ body.className = "question-body";
87
+ body.setAttribute("part", "question-body");
88
+ body.textContent = request.question;
89
+
90
+ const form = document.createElement("div");
91
+ form.className = "question-options";
92
+ form.setAttribute("part", "question-options");
93
+
94
+ // `name` scopes the radio group to this card so multiple open cards don't
95
+ // interfere; a per-card token keeps it unique without module state.
96
+ const group = `q-${choices.length}-${request.question.length}`;
97
+ const radios: HTMLInputElement[] = [];
98
+ for (const choice of choices) {
99
+ const label = document.createElement("label");
100
+ label.className = "question-choice";
101
+ label.setAttribute("part", "question-choice");
102
+ const radio = document.createElement("input");
103
+ radio.type = "radio";
104
+ radio.name = group;
105
+ radio.value = choice;
106
+ radio.setAttribute("part", "question-radio");
107
+ const text = document.createElement("span");
108
+ text.setAttribute("part", "question-choice-text");
109
+ text.textContent = choice;
110
+ label.append(radio, text);
111
+ form.appendChild(label);
112
+ radios.push(radio);
113
+ }
114
+
115
+ // The "other" radio (only alongside choices) toggles the free-text field.
116
+ let otherRadio: HTMLInputElement | null = null;
117
+ let input: HTMLInputElement | null = null;
118
+ if (allowsText) {
119
+ input = answerInput(strings.answerPlaceholder);
120
+ if (hasChoices) {
121
+ const label = document.createElement("label");
122
+ label.className = "question-choice";
123
+ label.setAttribute("part", "question-choice");
124
+ otherRadio = document.createElement("input");
125
+ otherRadio.type = "radio";
126
+ otherRadio.name = group;
127
+ otherRadio.value = "";
128
+ otherRadio.setAttribute("part", "question-radio");
129
+ const text = document.createElement("span");
130
+ text.setAttribute("part", "question-choice-text");
131
+ text.textContent = strings.otherOption;
132
+ label.append(otherRadio, text);
133
+ form.appendChild(label);
134
+ input.disabled = true;
135
+ }
136
+ form.appendChild(input);
137
+ }
138
+
139
+ const actions = document.createElement("div");
140
+ actions.className = "question-actions";
141
+ actions.setAttribute("part", "question-actions");
142
+ const submit = document.createElement("button");
143
+ submit.type = "button";
144
+ submit.className = "question-btn";
145
+ submit.setAttribute("part", "question-button");
146
+ submit.textContent = strings.submit;
147
+ actions.appendChild(submit);
148
+
149
+ let settled = false;
150
+ const answerFor = (): string | null => {
151
+ const picked = radios.find((r) => r.checked);
152
+ if (picked !== undefined) {
153
+ return picked.value;
154
+ }
155
+ if (input !== null && (otherRadio === null || otherRadio.checked)) {
156
+ const typed = input.value.trim();
157
+ return typed === "" ? null : typed;
158
+ }
159
+ return null;
160
+ };
161
+ const refresh = (): void => {
162
+ if (input !== null && otherRadio !== null) {
163
+ input.disabled = !otherRadio.checked;
164
+ }
165
+ submit.disabled = answerFor() === null;
166
+ };
167
+ const close = (answer: string): void => {
168
+ if (settled) {
169
+ return;
170
+ }
171
+ settled = true;
172
+ submit.disabled = true;
173
+ for (const radio of radios) {
174
+ radio.disabled = true;
175
+ }
176
+ if (otherRadio !== null) {
177
+ otherRadio.disabled = true;
178
+ }
179
+ if (input !== null) {
180
+ input.disabled = true;
181
+ }
182
+ card.setAttribute("data-resolved", answer === "" ? "cancelled" : "answered");
183
+ resolve(answer);
184
+ };
185
+
186
+ for (const radio of [...radios, ...(otherRadio !== null ? [otherRadio] : [])]) {
187
+ radio.addEventListener("change", refresh);
188
+ }
189
+ input?.addEventListener("input", refresh);
190
+ input?.addEventListener("keydown", (event) => {
191
+ if (event.key === "Enter") {
192
+ event.preventDefault();
193
+ const answer = answerFor();
194
+ if (answer !== null) {
195
+ close(answer);
196
+ }
197
+ }
198
+ });
199
+ submit.addEventListener("click", () => {
200
+ const answer = answerFor();
201
+ if (answer !== null) {
202
+ close(answer);
203
+ }
204
+ });
205
+ options.signal?.addEventListener("abort", () => close(""), { once: true });
206
+
207
+ card.append(body, form, actions);
208
+ host.appendChild(card);
209
+ if (options.signal?.aborted === true) {
210
+ close("");
211
+ return;
212
+ }
213
+ refresh();
214
+ (hasChoices ? radios[0] : input)?.focus();
215
+ });
216
+ }
@@ -6,15 +6,20 @@ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
6
6
  *
7
7
  * `now` is injectable so callers (and tests) can pin the reference point; it
8
8
  * defaults to the current time. A timestamp in the future (clock skew) reads as
9
- * `"just now"`. The unit words come from {@link UiStrings} (the `{n}` token is
10
- * filled in here) so a localized host translates them; the bucketing stays
11
- * integer-rounded and locale-neutral.
9
+ * `"just now"`. A non-finite timestamp an unparseable or missing `updated_at`
10
+ * that arrived as `NaN` has no meaningful age, so it falls back to `justNow`
11
+ * rather than rendering `"NaNw ago"` or `"~2950w ago"`. The unit words come from
12
+ * {@link UiStrings} (the `{n}` token is filled in here) so a localized host
13
+ * translates them; the bucketing stays integer-rounded and locale-neutral.
12
14
  */
13
15
  export function relativeTime(
14
16
  timestamp: number,
15
17
  now: number = Date.now(),
16
18
  strings: UiStrings = DEFAULT_UI_STRINGS,
17
19
  ): string {
20
+ if (!Number.isFinite(timestamp)) {
21
+ return strings.justNow;
22
+ }
18
23
  const seconds = Math.round((now - timestamp) / 1000);
19
24
  if (seconds < 60) {
20
25
  return strings.justNow;
@@ -24,9 +24,11 @@ export class SkillsMenu {
24
24
  this.#onPick = onPick;
25
25
  this.chips = document.createElement("div");
26
26
  this.chips.className = "skill-chips";
27
+ this.chips.setAttribute("part", "skill-chips");
27
28
  this.chips.hidden = true;
28
29
  this.palette = document.createElement("div");
29
30
  this.palette.className = "skill-palette";
31
+ this.palette.setAttribute("part", "skill-palette");
30
32
  this.palette.setAttribute("role", "listbox");
31
33
  this.palette.hidden = true;
32
34
  }
@@ -137,6 +139,7 @@ export class SkillsMenu {
137
139
  const button = document.createElement("button");
138
140
  button.type = "button";
139
141
  button.className = "skill-chip";
142
+ button.setAttribute("part", "skill-chip");
140
143
  button.textContent = skill.title;
141
144
  button.addEventListener("click", () => this.#pick(skill));
142
145
  this.chips.appendChild(button);
@@ -149,17 +152,20 @@ export class SkillsMenu {
149
152
  const item = document.createElement("button");
150
153
  item.type = "button";
151
154
  item.className = "skill-item";
155
+ item.setAttribute("part", "skill-item");
152
156
  item.setAttribute("role", "option");
153
157
  item.setAttribute("aria-selected", index === this.#activeIndex ? "true" : "false");
154
158
 
155
159
  const title = document.createElement("span");
156
160
  title.className = "skill-item-title";
161
+ title.setAttribute("part", "skill-item-title");
157
162
  title.textContent = skill.title;
158
163
  item.appendChild(title);
159
164
 
160
165
  if (skill.description !== undefined) {
161
166
  const desc = document.createElement("span");
162
167
  desc.className = "skill-item-desc";
168
+ desc.setAttribute("part", "skill-item-desc");
163
169
  desc.textContent = skill.description;
164
170
  item.appendChild(desc);
165
171
  }
package/src/ui/styles.ts CHANGED
@@ -146,7 +146,7 @@ export const STYLES = `
146
146
  --ag-ui-radius: 0;
147
147
  }
148
148
 
149
- /* Page (PAGE-1): full-bleed background with a centred reading column. Unlike
149
+ /* Page: full-bleed background with a centred reading column. Unlike
150
150
  "full" (edge-to-edge, left-aligned messages) the content sits in a column
151
151
  capped at --ag-ui-content-max-width. The column is produced by symmetric auto
152
152
  padding on the scroll area + composer (no per-row wrapper), so user pills
@@ -188,7 +188,7 @@ export const STYLES = `
188
188
  max-width: 100%;
189
189
  }
190
190
 
191
- /* Sidebar (CUST-3): a full-height docked panel that slides open/closed and
191
+ /* Sidebar: a full-height docked panel that slides open/closed and
192
192
  collapses to a slim icon rail (not the floating launcher). Docked right by
193
193
  default; data-side="left" docks it left. Overlay by default — set
194
194
  --ag-ui-position: static (and place this element in your own layout) for a
@@ -298,7 +298,7 @@ export const STYLES = `
298
298
  white-space: nowrap;
299
299
  }
300
300
 
301
- /* Header / launcher icon holder (CUST-2): a slot, with a data-icon-url <img>
301
+ /* Header / launcher icon holder: a slot, with a data-icon-url <img>
302
302
  fallback, sized via --ag-ui-icon-size. */
303
303
  .icon-holder {
304
304
  display: inline-flex;
@@ -372,7 +372,7 @@ export const STYLES = `
372
372
  gap: var(--ag-ui-space);
373
373
  }
374
374
 
375
- /* Empty-state region (CUST-1 slot): centred while it's the only thing in the
375
+ /* Empty-state region (slot): centred while it's the only thing in the
376
376
  list, hidden as soon as a message, card, or pending indicator renders. */
377
377
  .empty {
378
378
  margin: auto;
@@ -384,7 +384,7 @@ export const STYLES = `
384
384
  display: none;
385
385
  }
386
386
 
387
- /* ── Answer group / well (WELL-1) ─────────────────────────────────────────
387
+ /* ── Answer group / well ─────────────────────────────────────────
388
388
  One .answer per assistant turn wraps the streamed text, its tool cards,
389
389
  and the pending indicator so a whole answer reads (and can be boxed) as one
390
390
  unit. A flex column on the message-list gap, stretched to the list width so
@@ -553,7 +553,7 @@ export const STYLES = `
553
553
  }
554
554
  }
555
555
 
556
- /* ── Thoughts region (THINK-1) ────────────────────────────────────────────
556
+ /* ── Thoughts region ────────────────────────────────────────────
557
557
  A muted, collapsible chain-of-thought at the top of the answer group: open
558
558
  while the model reasons, folded once the answer text starts. */
559
559
  .thoughts {
@@ -646,7 +646,7 @@ export const STYLES = `
646
646
  word-break: break-word;
647
647
  }
648
648
 
649
- /* Leading status icon (CARD-1). Empty in the DOM — the glyph/spinner is drawn
649
+ /* Leading status icon. Empty in the DOM — the glyph/spinner is drawn
650
650
  here from the card's data-status, so it stays themeable. */
651
651
  .tool-call-icon {
652
652
  flex: none;
@@ -694,7 +694,7 @@ export const STYLES = `
694
694
  }
695
695
  }
696
696
 
697
- /* Inline display mode (CARD-1): the lightest card — drop the box chrome so the
697
+ /* Inline display mode: the lightest card — drop the box chrome so the
698
698
  status row reads as one line of the answer; the result toggle still expands
699
699
  below it. */
700
700
  .tool-call[data-display="inline"] {
@@ -824,7 +824,7 @@ export const STYLES = `
824
824
  display: none;
825
825
  }
826
826
 
827
- /* The 🎤 mic button (VOICE-1); shown only once #wireVoice mounts it. */
827
+ /* The 🎤 mic button; shown only once #wireVoice mounts it. */
828
828
  .voice-slot {
829
829
  display: contents;
830
830
  }
@@ -1028,6 +1028,127 @@ export const STYLES = `
1028
1028
  color: #ffffff;
1029
1029
  }
1030
1030
 
1031
+ /* Approval card — the server-side-tool gate (approve/deny an interrupt). */
1032
+ .approval {
1033
+ align-self: stretch;
1034
+ box-sizing: border-box;
1035
+ display: flex;
1036
+ flex-direction: column;
1037
+ gap: 8px;
1038
+ padding: 12px;
1039
+ background: var(--ag-ui-bg);
1040
+ border: 1px solid var(--ag-ui-accent);
1041
+ border-radius: 10px;
1042
+ }
1043
+
1044
+ .approval[data-resolved] {
1045
+ opacity: 0.7;
1046
+ border-color: var(--ag-ui-border);
1047
+ }
1048
+
1049
+ .approval-body {
1050
+ font-weight: 600;
1051
+ }
1052
+
1053
+ .approval-actions {
1054
+ display: flex;
1055
+ gap: 8px;
1056
+ justify-content: flex-end;
1057
+ }
1058
+
1059
+ .approval-btn {
1060
+ border: 1px solid var(--ag-ui-border);
1061
+ border-radius: 8px;
1062
+ padding: 8px 14px;
1063
+ font: inherit;
1064
+ font-weight: 600;
1065
+ cursor: pointer;
1066
+ background: var(--ag-ui-bg);
1067
+ color: var(--ag-ui-fg);
1068
+ }
1069
+
1070
+ .approval-btn:disabled {
1071
+ cursor: default;
1072
+ opacity: 0.6;
1073
+ }
1074
+
1075
+ .approval-btn--approve {
1076
+ border-color: var(--ag-ui-accent);
1077
+ background: var(--ag-ui-accent);
1078
+ color: #ffffff;
1079
+ }
1080
+
1081
+ /* Question card — the built-in ask_user prompt (radios and/or free text). */
1082
+ .question {
1083
+ align-self: stretch;
1084
+ box-sizing: border-box;
1085
+ display: flex;
1086
+ flex-direction: column;
1087
+ gap: 8px;
1088
+ padding: 12px;
1089
+ background: var(--ag-ui-bg);
1090
+ border: 1px solid var(--ag-ui-accent);
1091
+ border-radius: 10px;
1092
+ }
1093
+
1094
+ .question[data-resolved] {
1095
+ opacity: 0.7;
1096
+ border-color: var(--ag-ui-border);
1097
+ }
1098
+
1099
+ .question-body {
1100
+ font-weight: 600;
1101
+ }
1102
+
1103
+ .question-options {
1104
+ display: flex;
1105
+ flex-direction: column;
1106
+ gap: 6px;
1107
+ }
1108
+
1109
+ .question-choice {
1110
+ display: flex;
1111
+ align-items: center;
1112
+ gap: 8px;
1113
+ cursor: pointer;
1114
+ }
1115
+
1116
+ .question-input {
1117
+ box-sizing: border-box;
1118
+ width: 100%;
1119
+ padding: 8px 10px;
1120
+ font: inherit;
1121
+ color: var(--ag-ui-fg);
1122
+ background: var(--ag-ui-bg);
1123
+ border: 1px solid var(--ag-ui-border);
1124
+ border-radius: 8px;
1125
+ }
1126
+
1127
+ .question-input:disabled {
1128
+ opacity: 0.6;
1129
+ }
1130
+
1131
+ .question-actions {
1132
+ display: flex;
1133
+ justify-content: flex-end;
1134
+ }
1135
+
1136
+ .question-btn {
1137
+ border: 1px solid var(--ag-ui-accent);
1138
+ border-radius: 8px;
1139
+ padding: 8px 14px;
1140
+ font: inherit;
1141
+ font-weight: 600;
1142
+ cursor: pointer;
1143
+ background: var(--ag-ui-accent);
1144
+ color: #ffffff;
1145
+ }
1146
+
1147
+ .question-btn:disabled {
1148
+ cursor: default;
1149
+ opacity: 0.6;
1150
+ }
1151
+
1031
1152
  /* Skills — chips row + the /-command palette, above the input. */
1032
1153
  .skill-chips {
1033
1154
  display: flex;
@@ -2,9 +2,9 @@ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
2
 
3
3
  /**
4
4
  * A muted, collapsible "thinking" region for a reasoning model's streamed
5
- * chain-of-thought (THINK-1).
5
+ * chain-of-thought.
6
6
  *
7
- * Lives at the top of the current answer group (the WELL-1 turn container): it
7
+ * Lives at the top of the current answer group (the turn container): it
8
8
  * opens expanded while the model reasons — {@link stream} replaces its body with
9
9
  * the running reasoning buffer — and {@link collapse} folds it away once the
10
10
  * answer's first text token arrives, so the thoughts don't crowd the answer.
@@ -42,6 +42,7 @@ export class ThoughtsBlock {
42
42
 
43
43
  this.#label = document.createElement("span");
44
44
  this.#label.className = "thoughts-label";
45
+ this.#label.setAttribute("part", "thoughts-label");
45
46
  this.#label.textContent = strings.thinking;
46
47
  this.#toggle.append(this.#label);
47
48