@artooi/ag-ui-web-component 0.31.1 → 0.33.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 (46) hide show
  1. package/CHANGELOG.md +197 -1
  2. package/README.md +182 -23
  3. package/dist/ag-ui-web-component.bundle.js +352 -128
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +34 -7
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  8. package/dist/core/agui_client.d.ts +25 -0
  9. package/dist/core/agui_client.d.ts.map +1 -1
  10. package/dist/index.js +1367 -388
  11. package/dist/index.js.map +4 -4
  12. package/dist/ui/clamp_launcher.d.ts +15 -0
  13. package/dist/ui/clamp_launcher.d.ts.map +1 -0
  14. package/dist/ui/copy_payload.d.ts +30 -0
  15. package/dist/ui/copy_payload.d.ts.map +1 -0
  16. package/dist/ui/launcher_drag.d.ts +43 -0
  17. package/dist/ui/launcher_drag.d.ts.map +1 -0
  18. package/dist/ui/launcher_placement.d.ts +64 -0
  19. package/dist/ui/launcher_placement.d.ts.map +1 -0
  20. package/dist/ui/message_actions.d.ts +30 -2
  21. package/dist/ui/message_actions.d.ts.map +1 -1
  22. package/dist/ui/resize_handle.d.ts +32 -26
  23. package/dist/ui/resize_handle.d.ts.map +1 -1
  24. package/dist/ui/styles.d.ts +1 -1
  25. package/dist/ui/styles.d.ts.map +1 -1
  26. package/dist/ui/subagent_panel.d.ts +9 -0
  27. package/dist/ui/subagent_panel.d.ts.map +1 -1
  28. package/dist/ui/subagent_update.d.ts +9 -0
  29. package/dist/ui/subagent_update.d.ts.map +1 -1
  30. package/dist/ui/ui_strings.d.ts +19 -0
  31. package/dist/ui/ui_strings.d.ts.map +1 -1
  32. package/package.json +3 -3
  33. package/src/constants.ts +38 -7
  34. package/src/core/ag_ui_chat.ts +570 -24
  35. package/src/core/agui_client.ts +41 -0
  36. package/src/ui/clamp_launcher.ts +20 -0
  37. package/src/ui/copy_payload.ts +156 -0
  38. package/src/ui/launcher_drag.ts +182 -0
  39. package/src/ui/launcher_placement.ts +143 -0
  40. package/src/ui/message_actions.ts +93 -19
  41. package/src/ui/resize_handle.ts +109 -73
  42. package/src/ui/styles.ts +283 -81
  43. package/src/ui/subagent_panel.ts +14 -0
  44. package/src/ui/subagent_update.ts +9 -0
  45. package/src/ui/ui_strings.ts +22 -0
  46. package/src/version.ts +1 -1
@@ -1,3 +1,4 @@
1
+ import { ICON_COPY, ICON_THUMB_DOWN, ICON_THUMB_UP } from "../constants.js";
1
2
  import type { UiStrings } from "./ui_strings.js";
2
3
 
3
4
  /** How long a button shows its confirmation before reverting. */
@@ -18,6 +19,17 @@ export interface MessageActionsOptions {
18
19
  * disagree.
19
20
  */
20
21
  text?: () => string;
22
+ /**
23
+ * The rich flavour to copy alongside `text`, as an HTML fragment. Absent
24
+ * means the clipboard gets plain text only, which is what this did before
25
+ * the option existed.
26
+ *
27
+ * It is what makes a copied table paste as a table: a spreadsheet or a chat
28
+ * client reads `text/html` when it is offered and falls back to the plain
29
+ * flavour when it is not, so the two are the same content at two fidelities
30
+ * rather than a choice the caller has to make.
31
+ */
32
+ html?: () => string;
21
33
  /**
22
34
  * Report a rating for this message. Absent means no feedback buttons.
23
35
  *
@@ -53,7 +65,7 @@ export function attachMessageActions(bubble: HTMLElement, options: MessageAction
53
65
  const bar = messageActionBar(bubble, options.strings);
54
66
  const text = options.text;
55
67
  if (text !== undefined) {
56
- bar.appendChild(copyButton(options.strings, text));
68
+ bar.appendChild(copyButton(options.strings, text, options.html));
57
69
  }
58
70
  if (options.onFeedback !== undefined) {
59
71
  bar.append(
@@ -98,38 +110,102 @@ function existingBar(bubble: HTMLElement): HTMLElement | null {
98
110
  return next?.classList.contains("message-actions") === true ? (next as HTMLElement) : null;
99
111
  }
100
112
 
101
- /** Build one action button, labelled for screen readers rather than by glyph. */
113
+ /**
114
+ * Build one action button, labelled for screen readers rather than by glyph.
115
+ *
116
+ * `icon` is icon markup rather than a text glyph, because the text glyphs these
117
+ * carried are the obscure end of the character set -- the copy mark in
118
+ * particular has no font behind it on most systems, so it rendered as a mark
119
+ * nobody could name on a control small enough that nobody could hit it either.
120
+ *
121
+ * The label is carried three ways, and each has a reader the others miss:
122
+ * `aria-label` for assistive technology, `title` for the browser's own
123
+ * tooltip, and `data-tooltip` for the one this component draws. The last is
124
+ * not redundant with the second -- a `title` never appears on keyboard focus,
125
+ * so without it a keyboard user has no way to see what the control does.
126
+ *
127
+ * The icon sits in its own part, so a host can restyle or replace it. A slot
128
+ * would be the better channel and cannot be used here: these repeat once per
129
+ * message, and a named slot can only be filled once.
130
+ */
102
131
  export function messageActionButton(
103
132
  modifier: string,
104
133
  label: string,
105
- glyph: string,
134
+ icon: string,
106
135
  ): HTMLButtonElement {
107
136
  const button = document.createElement("button");
108
137
  button.type = "button";
109
138
  button.className = `message-action message-action--${modifier}`;
110
139
  button.setAttribute("part", `message-action message-action-${modifier}`);
140
+ setLabel(button, label);
141
+ const holder = document.createElement("span");
142
+ holder.className = "message-action-icon";
143
+ holder.setAttribute("part", `message-action-icon message-action-icon-${modifier}`);
144
+ holder.setAttribute("aria-hidden", "true");
145
+ // Author-written markup from constants, never message content.
146
+ holder.innerHTML = icon;
147
+ button.appendChild(holder);
148
+ return button;
149
+ }
150
+
151
+ /** Put `label` on every channel that names this button. */
152
+ function setLabel(button: HTMLButtonElement, label: string): void {
111
153
  button.title = label;
112
154
  button.setAttribute("aria-label", label);
113
- const icon = document.createElement("span");
114
- icon.setAttribute("aria-hidden", "true");
115
- icon.textContent = glyph;
116
- button.appendChild(icon);
117
- return button;
155
+ button.dataset["tooltip"] = label;
118
156
  }
119
157
 
120
- function copyButton(strings: UiStrings, text: () => string): HTMLButtonElement {
121
- const button = messageActionButton("copy", strings.copyMessage, "⎘");
158
+ function copyButton(
159
+ strings: UiStrings,
160
+ text: () => string,
161
+ html: (() => string) | undefined,
162
+ ): HTMLButtonElement {
163
+ const button = messageActionButton("copy", strings.copyMessage, ICON_COPY);
122
164
  button.addEventListener("click", () => {
123
- void navigator.clipboard.writeText(text()).then(
124
- () => flash(button, strings.copied, strings.copyMessage),
165
+ void write(text(), html?.()).then((ok) => {
125
166
  // A denied clipboard permission is the common case, not an exception:
126
167
  // say so on the button rather than throwing into an unhandled rejection.
127
- () => flash(button, strings.copyFailed, strings.copyMessage),
128
- );
168
+ flash(button, ok ? strings.copied : strings.copyFailed, strings.copyMessage);
169
+ });
129
170
  });
130
171
  return button;
131
172
  }
132
173
 
174
+ /**
175
+ * Put the message on the clipboard, richest flavour first.
176
+ *
177
+ * Writing both flavours needs `ClipboardItem`, which not every engine that has
178
+ * `writeText` also has -- and even where the constructor exists the write can
179
+ * be refused. Neither is a failure worth reporting as one while the plain text
180
+ * would still have landed, so both fall through to `writeText` and only that
181
+ * decides what the button says.
182
+ */
183
+ async function write(text: string, html: string | undefined): Promise<boolean> {
184
+ const clipboard = navigator.clipboard;
185
+ if (clipboard === undefined) {
186
+ return false;
187
+ }
188
+ if (html !== undefined && typeof ClipboardItem === "function") {
189
+ try {
190
+ await clipboard.write([
191
+ new ClipboardItem({
192
+ "text/plain": new Blob([text], { type: "text/plain" }),
193
+ "text/html": new Blob([html], { type: "text/html" }),
194
+ }),
195
+ ]);
196
+ return true;
197
+ } catch {
198
+ // Fall through to the plain flavour.
199
+ }
200
+ }
201
+ try {
202
+ await clipboard.writeText(text);
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
133
209
  function feedbackButton(
134
210
  rating: "up" | "down",
135
211
  label: string,
@@ -138,7 +214,7 @@ function feedbackButton(
138
214
  const button = messageActionButton(
139
215
  rating === "up" ? "up" : "down",
140
216
  label,
141
- rating === "up" ? "\u{1F44D}" : "\u{1F44E}",
217
+ rating === "up" ? ICON_THUMB_UP : ICON_THUMB_DOWN,
142
218
  );
143
219
  button.addEventListener("click", () => {
144
220
  // Pressed rather than removed: the rating is a standing statement about the
@@ -159,12 +235,10 @@ function feedbackButton(
159
235
  * cannot happen and cannot be covered.
160
236
  */
161
237
  function flash(button: HTMLButtonElement, message: string, label: string): void {
162
- button.title = message;
163
- button.setAttribute("aria-label", message);
238
+ setLabel(button, message);
164
239
  button.classList.add("message-action--confirmed");
165
240
  setTimeout(() => {
166
- button.title = label;
167
- button.setAttribute("aria-label", label);
241
+ setLabel(button, label);
168
242
  button.classList.remove("message-action--confirmed");
169
243
  }, CONFIRM_MS);
170
244
  }
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Which edges a grip drags. An axis left out is an axis the grip does not
3
+ * move: the left-edge grip is `{ x: "left" }`, the top-right corner is
4
+ * `{ x: "right", y: "top" }`.
5
+ */
6
+ export interface ResizeGrip {
7
+ readonly x?: "left" | "right";
8
+ readonly y?: "top" | "bottom";
9
+ }
10
+
1
11
  /** Which edges the layout holds still while the panel changes size. */
2
12
  export interface ResizeAnchor {
3
13
  /** The horizontal edge that does not move. */
@@ -8,7 +18,7 @@ export interface ResizeAnchor {
8
18
 
9
19
  /**
10
20
  * What the current placement allows: both axes, width only, or nothing. Which
11
- * corner the grip sits on is separate, and is measured rather than assumed.
21
+ * edges a given grip drags is separate, and fixed when the grip is built.
12
22
  */
13
23
  export type ResizeAxis = "none" | "width" | "both";
14
24
 
@@ -18,7 +28,7 @@ export interface ResizeSize {
18
28
  readonly height?: number;
19
29
  }
20
30
 
21
- /** The panel's position on screen at the moment a drag starts. */
31
+ /** The panel's position on screen, in viewport coordinates. */
22
32
  export interface PanelRect {
23
33
  readonly left: number;
24
34
  readonly top: number;
@@ -34,23 +44,16 @@ export interface ResizeOptions {
34
44
  * would survive the host switching to a docked or full-bleed layout.
35
45
  */
36
46
  readonly axis: () => ResizeAxis;
37
- /**
38
- * Which edges the layout is holding still, measured at the moment of the drag
39
- * rather than derived from `placement`: a floating panel is pinned
40
- * bottom-right, while an embedded one goes wherever the host's CSS puts it,
41
- * so `placement` alone cannot answer the question.
42
- */
43
- readonly anchor: () => ResizeAnchor;
44
47
  /** The panel's current bounding box. */
45
48
  readonly rect: () => PanelRect;
46
- /** Apply a size (the host writes the custom properties). */
47
- readonly apply: (size: ResizeSize) => void;
49
+ /** Apply a box (the host decides what that costs in properties). */
50
+ readonly apply: (box: PanelRect) => void;
48
51
  /**
49
52
  * Called once per completed resize, for persistence: on `pointerup` for a
50
53
  * drag, and when the key comes up (or focus leaves the handle) for a key
51
54
  * press. Never per pointer move, and never per key repeat.
52
55
  */
53
- readonly commit: (size: ResizeSize) => void;
56
+ readonly commit: (box: PanelRect) => void;
54
57
  /** Accessible label. */
55
58
  readonly label: string;
56
59
  }
@@ -59,68 +62,63 @@ export interface ResizeOptions {
59
62
  const MIN_WIDTH = 280;
60
63
  const MIN_HEIGHT = 240;
61
64
 
65
+ /** Keyboard step, and the larger one Shift asks for. */
66
+ const STEP = 16;
67
+ const COARSE_STEP = 64;
68
+
62
69
  /**
63
- * A drag handle that resizes the chat panel.
70
+ * A drag handle that resizes the chat panel from one edge or corner.
64
71
  *
65
- * Two rules keep it correct, and both are easy to break invisibly:
72
+ * The rule that keeps it correct is that **the edge a grip does not drag is the
73
+ * one that stays put**. That is the whole model, and it is what lets the same
74
+ * code serve all eight grips: the left-edge grip moves the left edge and holds
75
+ * the right, the right-edge grip does the reverse, a corner does both axes.
66
76
  *
67
- * - The new size is measured from the edge that is *not* moving, never from a
68
- * delta, and that edge is measured rather than assumed. Getting it wrong is
69
- * very visible: the panel shrinks when dragged outward and travels by its
70
- * opposite corner.
71
- * - It writes the `--ag-ui-width` / `--ag-ui-height` custom properties, not
72
- * inline `width` / `height`. The placement rules set those same properties,
73
- * so an inline dimension would outrank and fight them — a sidebar would keep
74
- * its dragged width after switching to fullscreen. Writing the property
75
- * leaves placement the final say.
77
+ * Which edge the *layout* pins is a different question and is deliberately not
78
+ * asked here. It matters only to the host, which has to rewrite its own
79
+ * position when a grip drags the very edge the layout was holding still --
80
+ * dragging the pinned edge of a panel is a move as much as a resize. The
81
+ * handle reports the box it wants; what that costs in CSS properties is the
82
+ * host's problem.
76
83
  *
77
- * Axes and anchor are both read per interaction, so a runtime `placement`
78
- * change takes effect at once.
84
+ * An earlier version took the anchor and derived the direction from it, which
85
+ * is where the asymmetries lived: whether an arrow key grew or shrank the
86
+ * panel depended on which corner the single grip had been placed on. With the
87
+ * grip stated outright, an arrow simply moves the edge it names.
79
88
  */
80
- export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
89
+ export function createResizeHandle(grip: ResizeGrip, options: ResizeOptions): HTMLDivElement {
81
90
  const handle = document.createElement("div");
82
- handle.className = "resize-handle";
83
- handle.setAttribute("part", "resize-handle");
91
+ handle.className = `resize-handle resize-handle--${gripName(grip)}`;
92
+ handle.setAttribute("part", `resize-handle resize-handle-${gripName(grip)}`);
93
+ // A separator with an orientation: an edge grip splits along one axis, and a
94
+ // corner has no single one to report.
84
95
  handle.setAttribute("role", "separator");
96
+ if (grip.x === undefined) {
97
+ handle.setAttribute("aria-orientation", "horizontal");
98
+ } else if (grip.y === undefined) {
99
+ handle.setAttribute("aria-orientation", "vertical");
100
+ }
85
101
  handle.setAttribute("aria-label", options.label);
86
102
  handle.tabIndex = 0;
87
103
 
88
- /** The size implied by a pointer at (x, y), given which edges are pinned. */
89
- const sizeAt = (
90
- axis: ResizeAxis,
91
- anchor: ResizeAnchor,
92
- rect: PanelRect,
93
- x: number,
94
- y: number,
95
- ): ResizeSize => {
96
- const width = anchor.x === "right" ? rect.right - x : x - rect.left;
97
- const clamped: ResizeSize = { width: Math.max(MIN_WIDTH, width) };
98
- if (axis !== "both") {
99
- return clamped;
100
- }
101
- const height = anchor.y === "bottom" ? rect.bottom - y : y - rect.top;
102
- return { ...clamped, height: Math.max(MIN_HEIGHT, height) };
103
- };
104
-
105
104
  handle.addEventListener("pointerdown", (event: PointerEvent) => {
106
105
  const axis = options.axis();
107
- if (axis === "none") {
106
+ if (axis === "none" || !movable(grip, axis)) {
108
107
  return;
109
108
  }
110
- // Captured once: the pinned edges cannot move during the drag, and reading
111
- // them live would chase the panel as it resizes.
112
- const anchor = options.anchor();
109
+ // Captured once: the edges this grip is not dragging cannot move during
110
+ // the drag, and reading them live would chase the panel as it resizes.
113
111
  const rect = options.rect();
114
112
 
115
113
  const onMove = (move: PointerEvent): void => {
116
- options.apply(sizeAt(axis, anchor, rect, move.clientX, move.clientY));
114
+ options.apply(boxAt(grip, axis, rect, move.clientX, move.clientY));
117
115
  };
118
116
 
119
117
  const onUp = (up: PointerEvent): void => {
120
118
  window.removeEventListener("pointermove", onMove);
121
119
  window.removeEventListener("pointerup", onUp);
122
120
  handle.removeAttribute("data-dragging");
123
- options.commit(sizeAt(axis, anchor, rect, up.clientX, up.clientY));
121
+ options.commit(boxAt(grip, axis, rect, up.clientX, up.clientY));
124
122
  };
125
123
 
126
124
  handle.setAttribute("data-dragging", "true");
@@ -131,49 +129,44 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
131
129
  event.preventDefault();
132
130
  });
133
131
 
134
- // The size the current key gesture has applied but not yet persisted. The
132
+ // The box the current key gesture has applied but not yet persisted. The
135
133
  // pointer path can commit inline because a drag has one unambiguous end;
136
134
  // a key press does not, so the gesture's result is held here until it does.
137
- let pending: ResizeSize | null = null;
135
+ let pending: PanelRect | null = null;
138
136
 
139
137
  /** End a key gesture: persist what it applied, once. */
140
138
  const settle = (): void => {
141
139
  if (pending === null) {
142
140
  return;
143
141
  }
144
- const size = pending;
142
+ const box = pending;
145
143
  pending = null;
146
- options.commit(size);
144
+ options.commit(box);
147
145
  };
148
146
 
149
147
  // Keyboard parity: a pointer-only resize is unreachable without a mouse, and
150
- // this control has no equivalent elsewhere in the UI.
148
+ // this control has no equivalent elsewhere in the UI. An arrow moves the edge
149
+ // this grip names, in the direction it names -- so the same key grows one
150
+ // side's grip and shrinks the opposite one, which is what a pointer does too.
151
151
  handle.addEventListener("keydown", (event: KeyboardEvent) => {
152
152
  const axis = options.axis();
153
- if (axis === "none") {
153
+ if (axis === "none" || !movable(grip, axis)) {
154
154
  return;
155
155
  }
156
- const anchor = options.anchor();
156
+ const step = event.shiftKey ? COARSE_STEP : STEP;
157
157
  const rect = options.rect();
158
- const step = event.shiftKey ? 64 : 16;
159
- // An arrow moves the grip, so whether it grows or shrinks depends on which
160
- // side the grip is on — the asymmetry the pointer path also handles.
161
- const outward = anchor.x === "right" ? -1 : 1;
162
- const width = rect.right - rect.left;
163
- const height = rect.bottom - rect.top;
164
- let next: ResizeSize | null = null;
165
- if (event.key === "ArrowLeft") {
166
- next = { width: Math.max(MIN_WIDTH, width - step * outward) };
167
- } else if (event.key === "ArrowRight") {
168
- next = { width: Math.max(MIN_WIDTH, width + step * outward) };
169
- } else if (axis === "both" && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
170
- const grow = event.key === (anchor.y === "bottom" ? "ArrowUp" : "ArrowDown");
171
- next = { height: Math.max(MIN_HEIGHT, height + (grow ? step : -step)) };
158
+ const delta = ARROWS[event.key];
159
+ if (delta === undefined) {
160
+ return;
172
161
  }
173
- if (next === null) {
162
+ // An arrow across this grip's fixed axis has no edge to move.
163
+ if ((delta.x !== 0 && grip.x === undefined) || (delta.y !== 0 && grip.y === undefined)) {
174
164
  return;
175
165
  }
176
166
  event.preventDefault();
167
+ const x = (grip.x === "left" ? rect.left : rect.right) + delta.x * step;
168
+ const y = (grip.y === "top" ? rect.top : rect.bottom) + delta.y * step;
169
+ const next = boxAt(grip, axis, rect, x, y);
177
170
  // Live feedback per key event, persistence only when the gesture ends:
178
171
  // `commit` promises one call per completed resize, and a held arrow key
179
172
  // repeats at the OS rate (20-30 events a second), so committing here would
@@ -191,3 +184,46 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
191
184
 
192
185
  return handle;
193
186
  }
187
+
188
+ /** Which way each arrow key pushes the edge under it. */
189
+ const ARROWS: Record<string, { x: number; y: number } | undefined> = {
190
+ ArrowLeft: { x: -1, y: 0 },
191
+ ArrowRight: { x: 1, y: 0 },
192
+ ArrowUp: { x: 0, y: -1 },
193
+ ArrowDown: { x: 0, y: 1 },
194
+ };
195
+
196
+ /** The hyphenated name of a grip, for its class and part. */
197
+ export function gripName(grip: ResizeGrip): string {
198
+ return [grip.y, grip.x].filter((side) => side !== undefined).join("-");
199
+ }
200
+
201
+ /** Whether the current placement leaves this grip anything to move. */
202
+ function movable(grip: ResizeGrip, axis: ResizeAxis): boolean {
203
+ // A docked panel owns its height, so a grip that only moves a horizontal
204
+ // edge has nothing to do and must not pretend otherwise.
205
+ return axis === "both" || grip.x !== undefined;
206
+ }
207
+
208
+ /**
209
+ * The box a pointer at (x, y) implies for this grip.
210
+ *
211
+ * Each axis is clamped by pushing the *dragged* edge back to the minimum,
212
+ * never by moving the edge that is supposed to be standing still: clamping the
213
+ * wrong one is how a panel dragged past its minimum starts travelling.
214
+ */
215
+ function boxAt(
216
+ grip: ResizeGrip,
217
+ axis: ResizeAxis,
218
+ rect: PanelRect,
219
+ x: number,
220
+ y: number,
221
+ ): PanelRect {
222
+ const left = grip.x === "left" ? Math.min(x, rect.right - MIN_WIDTH) : rect.left;
223
+ const right = grip.x === "right" ? Math.max(x, rect.left + MIN_WIDTH) : rect.right;
224
+ // A placement that owns its height leaves the vertical edges where they are.
225
+ const vertical = axis === "both";
226
+ const top = vertical && grip.y === "top" ? Math.min(y, rect.bottom - MIN_HEIGHT) : rect.top;
227
+ const bottom = vertical && grip.y === "bottom" ? Math.max(y, rect.top + MIN_HEIGHT) : rect.bottom;
228
+ return { left, top, right, bottom };
229
+ }