@artooi/ag-ui-web-component 0.16.0 → 0.18.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.
@@ -0,0 +1,82 @@
1
+ import type { UiStrings } from "./ui_strings.js";
2
+
3
+ /** How long the button shows its "copied" confirmation before reverting. */
4
+ const CONFIRM_MS = 1500;
5
+
6
+ /**
7
+ * Give every fenced code block in `root` a copy button.
8
+ *
9
+ * An agent that answers with code is answering with something the reader means
10
+ * to *use*, and selecting it by hand out of a scrolling transcript — inside a
11
+ * shadow root, in a narrow sidebar — is the one interaction the chat surface
12
+ * made harder than the page around it.
13
+ *
14
+ * Applied to **finished** bubbles only. The streaming bubble reassigns its
15
+ * `innerHTML` on every delta, so a button attached mid-stream would be
16
+ * discarded and rebuilt for each one; waiting until the turn ends costs
17
+ * nothing and does that work once.
18
+ *
19
+ * Idempotent: a bubble already processed is skipped, so a re-render or a second
20
+ * call cannot stack buttons.
21
+ */
22
+ export function attachCopyButtons(root: ParentNode, strings: UiStrings): void {
23
+ for (const pre of Array.from(root.querySelectorAll("pre"))) {
24
+ const code = pre.querySelector("code");
25
+ if (code === null || pre.querySelector(".code-copy") !== null) {
26
+ continue;
27
+ }
28
+ pre.classList.add("has-copy");
29
+ pre.append(button(code, strings));
30
+ }
31
+ }
32
+
33
+ function button(code: Element, strings: UiStrings): HTMLButtonElement {
34
+ // Never null: `textContent` is nullable only on documents and doctypes,
35
+ // and this is always the `code` element `querySelector` just returned.
36
+ const text = code.textContent as string;
37
+ const copy = document.createElement("button");
38
+ copy.type = "button";
39
+ copy.className = "code-copy";
40
+ copy.setAttribute("part", "code-copy");
41
+ copy.textContent = strings.copyCode;
42
+ copy.title = strings.copyCode;
43
+ copy.setAttribute("aria-label", strings.copyCode);
44
+ copy.addEventListener("click", () => {
45
+ void writeText(text).then((ok) => {
46
+ // Say which of the two happened. A button that always claims success is
47
+ // worse than no button: the reader pastes stale clipboard content and
48
+ // finds out somewhere else entirely.
49
+ confirm(copy, ok ? strings.copied : strings.copyFailed, strings);
50
+ });
51
+ });
52
+ return copy;
53
+ }
54
+
55
+ /**
56
+ * Copy `text`, reporting whether it landed.
57
+ *
58
+ * The Clipboard API needs a secure context and a user gesture, and is simply
59
+ * absent in some embeddings, so its absence is an ordinary outcome rather than
60
+ * an error worth throwing at the host page.
61
+ */
62
+ async function writeText(text: string): Promise<boolean> {
63
+ const clipboard = navigator.clipboard;
64
+ if (clipboard === undefined) {
65
+ return false;
66
+ }
67
+ try {
68
+ await clipboard.writeText(text);
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ function confirm(copy: HTMLButtonElement, message: string, strings: UiStrings): void {
76
+ copy.textContent = message;
77
+ copy.dataset["state"] = message === strings.copied ? "copied" : "failed";
78
+ setTimeout(() => {
79
+ copy.textContent = strings.copyCode;
80
+ delete copy.dataset["state"];
81
+ }, CONFIRM_MS);
82
+ }
@@ -102,6 +102,11 @@ export class AttachmentTray {
102
102
  return this.#items.some((item) => item.status === ATTACHMENT_STATUS.UPLOADING);
103
103
  }
104
104
 
105
+ /** How many chips are still uploading — the count a send-time notice names. */
106
+ pendingCount(): number {
107
+ return this.#items.filter((item) => item.status === ATTACHMENT_STATUS.UPLOADING).length;
108
+ }
109
+
105
110
  /** Whether the tray holds no chips. */
106
111
  isEmpty(): boolean {
107
112
  return this.#items.length === 0;
@@ -26,6 +26,8 @@ export class CheckpointMenu {
26
26
  readonly #onPick: (runId: string, verb: CheckpointVerb) => void;
27
27
  readonly #list: HTMLDivElement;
28
28
  readonly #heading: HTMLSpanElement;
29
+ /** What had focus before the panel opened, restored on close. */
30
+ #lastFocused: HTMLElement | null = null;
29
31
  #strings: UiStrings;
30
32
  #runs: readonly RunRow[] = [];
31
33
 
@@ -41,6 +43,10 @@ export class CheckpointMenu {
41
43
  this.element.setAttribute("part", "checkpoints");
42
44
  this.element.setAttribute("role", "dialog");
43
45
  this.element.setAttribute("aria-label", strings.checkpoints);
46
+ // Focusable as a fallback: with no continuable runs the panel holds no
47
+ // controls at all, and a dialog that cannot take focus strands the
48
+ // keyboard user outside it with no way back except Tab-ing blind.
49
+ this.element.tabIndex = -1;
44
50
  this.element.hidden = true;
45
51
 
46
52
  const header = document.createElement("div");
@@ -48,6 +54,7 @@ export class CheckpointMenu {
48
54
  header.setAttribute("part", "checkpoints-header");
49
55
  this.#heading = document.createElement("span");
50
56
  this.#heading.className = "checkpoints-title";
57
+ this.#heading.setAttribute("part", "checkpoints-title");
51
58
  this.#heading.textContent = strings.checkpoints;
52
59
  header.append(this.#heading);
53
60
 
@@ -56,12 +63,7 @@ export class CheckpointMenu {
56
63
  this.#list.setAttribute("part", "checkpoints-list");
57
64
 
58
65
  this.element.append(header, this.#list);
59
- this.element.addEventListener("keydown", (event) => {
60
- if (event.key === "Escape") {
61
- event.stopPropagation();
62
- this.close();
63
- }
64
- });
66
+ this.element.addEventListener("keydown", (event) => this.#onKeydown(event));
65
67
  }
66
68
 
67
69
  /** Replace the rows. The host passes only `continuable` runs. */
@@ -79,11 +81,61 @@ export class CheckpointMenu {
79
81
  }
80
82
 
81
83
  open(): void {
84
+ if (this.open_) {
85
+ return;
86
+ }
87
+ // Remember what had focus so it is restored on close, then move focus into
88
+ // the panel so a keyboard user lands inside the dialog rather than being
89
+ // left behind it. The thread drawer already does this; a dialog that takes
90
+ // no focus is one a keyboard user cannot reach and a screen reader does not
91
+ // announce, however correct its role and label.
92
+ this.#lastFocused = this.#activeElement() as HTMLElement | null;
82
93
  this.element.hidden = false;
94
+ (this.#focusables()[0] ?? this.element).focus();
83
95
  }
84
96
 
85
97
  close(): void {
98
+ if (!this.open_) {
99
+ return;
100
+ }
86
101
  this.element.hidden = true;
102
+ this.#lastFocused?.focus();
103
+ this.#lastFocused = null;
104
+ }
105
+
106
+ /** The focused element within this panel's root (shadow-aware). */
107
+ #activeElement(): Element | null {
108
+ return (this.element.getRootNode() as Document | ShadowRoot).activeElement;
109
+ }
110
+
111
+ /** The panel's tabbable controls, in document order. */
112
+ #focusables(): HTMLElement[] {
113
+ return Array.from(this.element.querySelectorAll<HTMLElement>("button, [tabindex]")).filter(
114
+ (el) => !el.hidden,
115
+ );
116
+ }
117
+
118
+ /** Escape closes; Tab is trapped so focus cannot wander behind the dialog. */
119
+ #onKeydown(event: KeyboardEvent): void {
120
+ if (event.key === "Escape") {
121
+ event.stopPropagation();
122
+ this.close();
123
+ return;
124
+ }
125
+ if (event.key !== "Tab") {
126
+ return;
127
+ }
128
+ const focusables = this.#focusables();
129
+ const first = focusables[0];
130
+ const last = focusables[focusables.length - 1];
131
+ const active = this.#activeElement();
132
+ if (event.shiftKey && active === first) {
133
+ event.preventDefault();
134
+ last?.focus();
135
+ } else if (!event.shiftKey && active === last) {
136
+ event.preventDefault();
137
+ first?.focus();
138
+ }
87
139
  }
88
140
 
89
141
  get open_(): boolean {
@@ -112,6 +164,7 @@ export class CheckpointMenu {
112
164
 
113
165
  const label = document.createElement("span");
114
166
  label.className = "checkpoint-label";
167
+ label.setAttribute("part", "checkpoint-label");
115
168
  // A run id is opaque to a person, so the time is the identifying detail;
116
169
  // the id rides `title` for anyone who needs to correlate with server logs.
117
170
  label.textContent =
package/src/ui/styles.ts CHANGED
@@ -11,6 +11,7 @@ export const STYLES = `
11
11
  --ag-ui-user-bg: #4f46e5;
12
12
  --ag-ui-user-fg: #ffffff;
13
13
  --ag-ui-assistant-bg: #f1f1f6;
14
+ --ag-ui-hover: #e7e7ee;
14
15
  --ag-ui-input-bg: var(--ag-ui-bg);
15
16
  --ag-ui-tool-bg: var(--ag-ui-assistant-bg);
16
17
  --ag-ui-tool-fg: var(--ag-ui-accent);
@@ -80,6 +81,7 @@ export const STYLES = `
80
81
  --ag-ui-bg: #15151f;
81
82
  --ag-ui-fg: #e8e8f2;
82
83
  --ag-ui-assistant-bg: #25253a;
84
+ --ag-ui-hover: #303049;
83
85
  --ag-ui-header-bg: #1f1f30;
84
86
  --ag-ui-header-fg: #e8e8f2;
85
87
  --ag-ui-border: #33334a;
@@ -92,6 +94,7 @@ export const STYLES = `
92
94
  --ag-ui-bg: #15151f;
93
95
  --ag-ui-fg: #e8e8f2;
94
96
  --ag-ui-assistant-bg: #25253a;
97
+ --ag-ui-hover: #303049;
95
98
  --ag-ui-header-bg: #1f1f30;
96
99
  --ag-ui-header-fg: #e8e8f2;
97
100
  --ag-ui-border: #33334a;
@@ -107,6 +110,7 @@ export const STYLES = `
107
110
  --ag-ui-accent: #3fb950;
108
111
  --ag-ui-user-bg: #238636;
109
112
  --ag-ui-assistant-bg: #161b22;
113
+ --ag-ui-hover: #21262d;
110
114
  --ag-ui-header-bg: #010409;
111
115
  --ag-ui-header-fg: #c9d1d9;
112
116
  --ag-ui-border: #30363d;
@@ -508,12 +512,86 @@ export const STYLES = `
508
512
  padding: 0;
509
513
  }
510
514
 
515
+ /* The copy button sits inside the block, so it scrolls with wide code rather
516
+ than floating over the bubble. Positioning is on the pre; the button only
517
+ appears once one has been attached. */
518
+ .message--assistant pre.has-copy {
519
+ position: relative;
520
+ }
521
+
522
+ .code-copy {
523
+ position: absolute;
524
+ top: 4px;
525
+ right: 4px;
526
+ padding: 2px 8px;
527
+ font: inherit;
528
+ font-size: 0.75em;
529
+ line-height: 1.6;
530
+ color: var(--ag-ui-muted);
531
+ background: var(--ag-ui-surface);
532
+ border: 1px solid var(--ag-ui-border);
533
+ border-radius: 4px;
534
+ cursor: pointer;
535
+ opacity: 0;
536
+ transition: opacity 0.12s ease;
537
+ }
538
+
539
+ /* Revealed on hover or keyboard focus. Focus matters as much as hover here:
540
+ hidden-until-hover is invisible to a keyboard user otherwise. */
541
+ .message--assistant pre.has-copy:hover .code-copy,
542
+ .code-copy:focus-visible {
543
+ opacity: 1;
544
+ }
545
+
546
+ .code-copy:hover {
547
+ color: var(--ag-ui-text);
548
+ background: var(--ag-ui-hover);
549
+ }
550
+
551
+ .code-copy[data-state="copied"] {
552
+ opacity: 1;
553
+ color: var(--ag-ui-text);
554
+ }
555
+
556
+ .code-copy[data-state="failed"] {
557
+ opacity: 1;
558
+ color: var(--ag-ui-danger, var(--ag-ui-text));
559
+ }
560
+
511
561
  .message--assistant blockquote {
512
562
  padding-left: 10px;
513
563
  border-left: 3px solid var(--ag-ui-border);
514
564
  color: var(--ag-ui-muted);
515
565
  }
516
566
 
567
+ /* Markdown tables. table/thead/tbody/tr/th/td are all in
568
+ the sanitizer's ALLOWED_TAGS, so an agent emitting one renders it — and until
569
+ now rendered it entirely unstyled, overflowing the bubble. A wide table
570
+ scrolls inside its own box rather than stretching the message: the bubble is
571
+ width-constrained, so without this the columns either crush or push the
572
+ layout sideways. */
573
+ .message--assistant table {
574
+ display: block;
575
+ width: fit-content;
576
+ max-width: 100%;
577
+ overflow-x: auto;
578
+ border-collapse: collapse;
579
+ font-size: 0.95em;
580
+ }
581
+
582
+ .message--assistant th,
583
+ .message--assistant td {
584
+ padding: 6px 10px;
585
+ border: 1px solid var(--ag-ui-border);
586
+ text-align: left;
587
+ vertical-align: top;
588
+ }
589
+
590
+ .message--assistant th {
591
+ background: var(--ag-ui-hover);
592
+ font-weight: 600;
593
+ }
594
+
517
595
  .pending {
518
596
  align-self: flex-start;
519
597
  display: inline-flex;
@@ -1264,9 +1342,9 @@ export const STYLES = `
1264
1342
  flex-direction: column;
1265
1343
  gap: 0.25rem;
1266
1344
  padding: 0.5rem;
1267
- border: 1px solid var(--agui-border, #d7d7dc);
1345
+ border: 1px solid var(--ag-ui-border);
1268
1346
  border-radius: 0.5rem;
1269
- background: var(--agui-surface, #fff);
1347
+ background: var(--ag-ui-assistant-bg);
1270
1348
  box-shadow: 0 6px 24px rgb(0 0 0 / 12%);
1271
1349
  max-height: 60%;
1272
1350
  overflow-y: auto;
@@ -1297,7 +1375,7 @@ export const STYLES = `
1297
1375
  }
1298
1376
 
1299
1377
  .checkpoint-row:hover {
1300
- background: var(--agui-hover, #f3f3f5);
1378
+ background: var(--ag-ui-hover);
1301
1379
  }
1302
1380
 
1303
1381
  .checkpoint-label {
@@ -1312,7 +1390,7 @@ export const STYLES = `
1312
1390
  font-size: 0.6875rem;
1313
1391
  padding: 0 0.375rem;
1314
1392
  border-radius: 999px;
1315
- background: var(--agui-hover, #f3f3f5);
1393
+ background: var(--ag-ui-hover);
1316
1394
  opacity: 0.8;
1317
1395
  }
1318
1396
 
@@ -1321,14 +1399,14 @@ export const STYLES = `
1321
1399
  font-size: 0.75rem;
1322
1400
  cursor: pointer;
1323
1401
  padding: 0.125rem 0.5rem;
1324
- border: 1px solid var(--agui-border, #d7d7dc);
1402
+ border: 1px solid var(--ag-ui-border);
1325
1403
  border-radius: 0.375rem;
1326
1404
  background: transparent;
1327
1405
  color: inherit;
1328
1406
  }
1329
1407
 
1330
1408
  .checkpoint-action:hover {
1331
- background: var(--agui-hover, #f3f3f5);
1409
+ background: var(--ag-ui-hover);
1332
1410
  }
1333
1411
 
1334
1412
  .drawer-backdrop {
@@ -57,6 +57,8 @@ export interface UiStrings {
57
57
  runInterrupted: string;
58
58
  /** Tool-result content (and card text) when the page moved mid-run. */
59
59
  pageMoved: string;
60
+ /** Notice when Send ran while a file was still uploading. Token: `{n}`. */
61
+ attachmentsStillUploading: string;
60
62
 
61
63
  // ── Composer ────────────────────────────────────────────────────────────────
62
64
  /** `aria-label` of the message textarea. */
@@ -166,6 +168,12 @@ export interface UiStrings {
166
168
  /** Hours ago. Token: `{n}`. */
167
169
  hoursAgo: string;
168
170
  /** Title of the checkpoint panel. */
171
+ /** Label on a code block's copy button. */
172
+ copyCode: string;
173
+ /** Shown on the copy button after the code reached the clipboard. */
174
+ copied: string;
175
+ /** Shown when the clipboard was unavailable or refused the write. */
176
+ copyFailed: string;
169
177
  checkpoints: string;
170
178
  /** Empty state when no run can be continued. */
171
179
  noCheckpoints: string;
@@ -189,6 +197,9 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
189
197
  collapse: "Collapse",
190
198
  expand: "Expand",
191
199
  toggleTheme: "Toggle theme",
200
+ copyCode: "Copy",
201
+ copied: "Copied",
202
+ copyFailed: "Copy failed",
192
203
  checkpoints: "Continue a run",
193
204
  noCheckpoints: "Nothing to continue yet.",
194
205
  resumeRun: "Resume",
@@ -208,6 +219,8 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
208
219
  runInterrupted: "The previous response didn’t finish — the page changed before it arrived.",
209
220
  pageMoved:
210
221
  "The page changed since you last looked at it. Call read_page to see the current page, then retry.",
222
+ attachmentsStillUploading:
223
+ "{n} file still uploading — it was not sent with this message and is still attached.",
211
224
  skillNeeds: "“{title}” needs: {fields}",
212
225
 
213
226
  message: "Message",
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.16.0";
1
+ export const VERSION: string = "0.18.0";