@artooi/ag-ui-web-component 0.23.0 → 0.24.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 (124) hide show
  1. package/CHANGELOG.md +149 -36
  2. package/README.md +140 -9
  3. package/dist/ag-ui-web-component.bundle.js +113 -122
  4. package/dist/ag-ui-web-component.bundle.js.map +3 -3
  5. package/dist/constants.d.ts +71 -85
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +105 -127
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/agui_client.d.ts +24 -30
  10. package/dist/core/agui_client.d.ts.map +1 -1
  11. package/dist/core/attachment.d.ts +9 -14
  12. package/dist/core/attachment.d.ts.map +1 -1
  13. package/dist/core/conversation_store.d.ts +38 -27
  14. package/dist/core/conversation_store.d.ts.map +1 -1
  15. package/dist/core/create_http_agent.d.ts +13 -15
  16. package/dist/core/create_http_agent.d.ts.map +1 -1
  17. package/dist/core/remote_conversation_store.d.ts +10 -9
  18. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  19. package/dist/core/run_index.d.ts +11 -20
  20. package/dist/core/run_index.d.ts.map +1 -1
  21. package/dist/core/transcribe_audio.d.ts +8 -8
  22. package/dist/core/transcribe_audio.d.ts.map +1 -1
  23. package/dist/core/upload_attachment.d.ts +15 -18
  24. package/dist/core/upload_attachment.d.ts.map +1 -1
  25. package/dist/core/utils.d.ts +4 -6
  26. package/dist/core/utils.d.ts.map +1 -1
  27. package/dist/dom/animations.d.ts +22 -30
  28. package/dist/dom/animations.d.ts.map +1 -1
  29. package/dist/dom/dom_driver.d.ts +7 -7
  30. package/dist/dom/native_setter.d.ts +2 -2
  31. package/dist/dom/native_setter.d.ts.map +1 -1
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +357 -383
  35. package/dist/index.js.map +2 -2
  36. package/dist/skills/fill_template.d.ts +4 -5
  37. package/dist/skills/fill_template.d.ts.map +1 -1
  38. package/dist/skills/parse_skills.d.ts.map +1 -1
  39. package/dist/skills/skill.d.ts +7 -8
  40. package/dist/skills/skill.d.ts.map +1 -1
  41. package/dist/tools/client_tool_registry.d.ts +2 -2
  42. package/dist/tools/page_action_tools.d.ts +7 -10
  43. package/dist/tools/page_action_tools.d.ts.map +1 -1
  44. package/dist/tools/page_state.d.ts +5 -8
  45. package/dist/tools/page_state.d.ts.map +1 -1
  46. package/dist/tools/route_map.d.ts +7 -10
  47. package/dist/tools/route_map.d.ts.map +1 -1
  48. package/dist/ui/approval_card.d.ts +15 -20
  49. package/dist/ui/approval_card.d.ts.map +1 -1
  50. package/dist/ui/attach_copy_buttons.d.ts +4 -10
  51. package/dist/ui/attach_copy_buttons.d.ts.map +1 -1
  52. package/dist/ui/attachment_chips.d.ts +7 -10
  53. package/dist/ui/attachment_chips.d.ts.map +1 -1
  54. package/dist/ui/attachment_tray.d.ts +6 -6
  55. package/dist/ui/checkpoint_menu.d.ts +7 -8
  56. package/dist/ui/checkpoint_menu.d.ts.map +1 -1
  57. package/dist/ui/confirmation_card.d.ts +10 -15
  58. package/dist/ui/confirmation_card.d.ts.map +1 -1
  59. package/dist/ui/question_card.d.ts +12 -15
  60. package/dist/ui/question_card.d.ts.map +1 -1
  61. package/dist/ui/relative_time.d.ts +5 -7
  62. package/dist/ui/relative_time.d.ts.map +1 -1
  63. package/dist/ui/render_markdown.d.ts +8 -8
  64. package/dist/ui/render_markdown.d.ts.map +1 -1
  65. package/dist/ui/resize_handle.d.ts +21 -34
  66. package/dist/ui/resize_handle.d.ts.map +1 -1
  67. package/dist/ui/run_notice.d.ts +5 -7
  68. package/dist/ui/run_notice.d.ts.map +1 -1
  69. package/dist/ui/skills_menu.d.ts +4 -5
  70. package/dist/ui/skills_menu.d.ts.map +1 -1
  71. package/dist/ui/styles.d.ts +1 -1
  72. package/dist/ui/styles.d.ts.map +1 -1
  73. package/dist/ui/thoughts_block.d.ts +9 -11
  74. package/dist/ui/thoughts_block.d.ts.map +1 -1
  75. package/dist/ui/thread_drawer.d.ts +6 -5
  76. package/dist/ui/thread_drawer.d.ts.map +1 -1
  77. package/dist/ui/tool_call_card.d.ts +17 -25
  78. package/dist/ui/tool_call_card.d.ts.map +1 -1
  79. package/dist/ui/ui_strings.d.ts +6 -12
  80. package/dist/ui/ui_strings.d.ts.map +1 -1
  81. package/dist/ui/voice_input.d.ts +10 -11
  82. package/dist/ui/voice_input.d.ts.map +1 -1
  83. package/package.json +1 -1
  84. package/src/constants.ts +74 -87
  85. package/src/core/ag_ui_chat.ts +313 -269
  86. package/src/core/agui_client.ts +60 -71
  87. package/src/core/attachment.ts +9 -14
  88. package/src/core/conversation_store.ts +55 -33
  89. package/src/core/create_http_agent.ts +18 -22
  90. package/src/core/remote_conversation_store.ts +28 -15
  91. package/src/core/run_index.ts +14 -23
  92. package/src/core/transcribe_audio.ts +9 -10
  93. package/src/core/upload_attachment.ts +18 -21
  94. package/src/core/utils.ts +4 -6
  95. package/src/dom/animations.ts +33 -43
  96. package/src/dom/dom_driver.ts +7 -7
  97. package/src/dom/native_setter.ts +11 -12
  98. package/src/index.ts +3 -0
  99. package/src/skills/fill_template.ts +4 -5
  100. package/src/skills/parse_skills.ts +3 -4
  101. package/src/skills/skill.ts +7 -8
  102. package/src/tools/client_tool_registry.ts +2 -2
  103. package/src/tools/page_action_tools.ts +12 -15
  104. package/src/tools/page_state.ts +5 -8
  105. package/src/tools/route_map.ts +15 -19
  106. package/src/ui/approval_card.ts +15 -20
  107. package/src/ui/attach_copy_buttons.ts +9 -18
  108. package/src/ui/attachment_chips.ts +7 -10
  109. package/src/ui/attachment_tray.ts +6 -6
  110. package/src/ui/checkpoint_menu.ts +7 -8
  111. package/src/ui/confirmation_card.ts +10 -15
  112. package/src/ui/question_card.ts +12 -15
  113. package/src/ui/relative_time.ts +5 -7
  114. package/src/ui/render_markdown.ts +25 -51
  115. package/src/ui/resize_handle.ts +25 -38
  116. package/src/ui/run_notice.ts +9 -12
  117. package/src/ui/skills_menu.ts +4 -5
  118. package/src/ui/styles.ts +92 -101
  119. package/src/ui/thoughts_block.ts +11 -13
  120. package/src/ui/thread_drawer.ts +6 -5
  121. package/src/ui/tool_call_card.ts +22 -32
  122. package/src/ui/ui_strings.ts +6 -12
  123. package/src/ui/voice_input.ts +10 -11
  124. package/src/version.ts +1 -1
@@ -7,9 +7,8 @@ export interface Route {
7
7
  readonly id: string;
8
8
  /**
9
9
  * The URL path to navigate to. May contain `:name` placeholders for dynamic
10
- * segments (e.g. `/admin/shop/book/:pk/change/`); the agent fills them via
11
- * the `navigate_to_route` `params` argument. Real apps are mostly
12
- * parameterised — few pages have a truly static path.
10
+ * segments (e.g. `/admin/shop/book/:pk/change/`), which the agent fills via
11
+ * `navigate_to_route`'s `params` argument.
13
12
  */
14
13
  readonly path: string;
15
14
  /** Human label shown to the agent. */
@@ -33,20 +32,19 @@ const PATH_PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g;
33
32
 
34
33
  /** The `:name` path-parameter names declared in a path template, in order. */
35
34
  function pathParamNames(path: string): string[] {
36
- // Slicing the ":" off the whole match rather than reading the capture group:
37
- // the group is mandatory, so under `noUncheckedIndexedAccess` indexing it
38
- // forces an `undefined` guard on a case the regex cannot produce an
39
- // unreachable branch no test can ever cover.
35
+ // Slice the ":" off the whole match rather than read the capture group: under
36
+ // `noUncheckedIndexedAccess`, indexing the group forces an `undefined` guard
37
+ // on a case the regex cannot produce, which no test can cover.
40
38
  return [...path.matchAll(PATH_PARAM_RE)].map((match) => match[0].slice(1));
41
39
  }
42
40
 
43
41
  /**
44
- * Substitute `:name` placeholders in ``path`` from ``params``.
42
+ * Substitute `:name` placeholders in `path` from `params`.
45
43
  *
46
- * Returns the concrete path plus the params *not* consumed by a placeholder, so
47
- * the caller can append those as a query string. Throws (referencing
48
- * ``routeId``) when a declared path param is missing or empty — a half-filled
49
- * path must never be navigated to.
44
+ * Returns the concrete path plus the params no placeholder consumed, for the
45
+ * caller to append as a query string. Throws (naming `routeId`) when a declared
46
+ * path param is missing or empty — a half-filled path must never be navigated
47
+ * to.
50
48
  */
51
49
  function fillPath(
52
50
  routeId: string,
@@ -79,13 +77,11 @@ function withQuery(path: string, params: Record<string, unknown>): string {
79
77
  * The built-in `route.*` tools, bound to live getters so a host can set
80
78
  * `routeMap` / `navigate` before or after mount.
81
79
  *
82
- * `list_routes` is read-only and advertises each route's dynamic
83
- * `pathParams` so the model knows what to supply. `navigate_to_route` is marked
84
- * `x-navigates` so an MPA reload checkpoints + resumes; it substitutes path
85
- * params into the template and appends any remaining params as a query string.
86
- * When the host supplies a `navigate(path)` callback (an SPA), the element
87
- * routes client-side instead and the run loop simply continues — see
88
- * `AgUiChat`'s execute path.
80
+ * `list_routes` is read-only and advertises each route's `pathParams` so the
81
+ * model knows what to supply. `navigate_to_route` fills those params, appends
82
+ * the rest as a query string, and is stamped `x-navigates` so an MPA reload
83
+ * checkpoints and resumes; with a host `navigate(path)` callback (an SPA) it
84
+ * routes client-side and the run loop continues instead.
89
85
  */
90
86
  export function createRouteTools(
91
87
  getRouteMap: () => RouteMap,
@@ -25,9 +25,8 @@ function actionButton(modifier: string, label: string): HTMLButtonElement {
25
25
  /** Options for {@link requestApproval}. */
26
26
  export interface ApprovalOptions {
27
27
  /**
28
- * Aborting this signal resolves the card as **denied** (buttons disabled,
29
- * `data-resolved="denied"`)the hook a Stop control uses to dismiss a
30
- * pending approval when the user cancels the whole run.
28
+ * Aborting this signal resolves the card as denied, with buttons disabled and
29
+ * `data-resolved="denied"` — how a Stop control dismisses a pending approval.
31
30
  */
32
31
  signal?: AbortSignal;
33
32
  /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
@@ -36,13 +35,11 @@ export interface ApprovalOptions {
36
35
 
37
36
  /**
38
37
  * A fully custom renderer for a server-side-tool approval, set via
39
- * `AgUiChat.approvalRenderer`. Receives the {@link ApprovalRequest} (the
40
- * interrupt's message + tool name) and an `AbortSignal` that fires when the run
41
- * is stopped, and resolves `true` to approve or `false` to deny. When provided
42
- * it **replaces** the built-in {@link requestApproval} card entirely the host
43
- * owns the DOM, so it can render a native modal, a framework component, or
44
- * anything else. See the `strings` / `::part()` seams for styling the built-in
45
- * card instead of replacing it.
38
+ * `AgUiChat.approvalRenderer`. Receives the {@link ApprovalRequest} and an
39
+ * `AbortSignal` that fires when the run is stopped, and resolves `true` to
40
+ * approve or `false` to deny. Replaces the built-in {@link requestApproval}
41
+ * card entirely, the host owning the DOM; to restyle the built-in card instead,
42
+ * use the `strings` and `::part()` seams.
46
43
  */
47
44
  export type ApprovalRenderer = (
48
45
  request: ApprovalRequest,
@@ -50,17 +47,15 @@ export type ApprovalRenderer = (
50
47
  ) => Promise<boolean>;
51
48
 
52
49
  /**
53
- * Append an inline **approval** card to ``host`` and resolve when the user
54
- * decides whether a gated *server-side* tool may run.
50
+ * Append an inline approval card to `host` and resolve `true` to run a gated
51
+ * server-side tool or `false` to deny it.
55
52
  *
56
- * The server-side approval gate is distinct from the client-tool confirmation
57
- * card ({@link requestConfirmation}): a destructive server tool defers instead
58
- * of executing, and the run finishes on an AG-UI *interrupt* the client answers
59
- * with `resume[]`. This card is the browser half of that loop it reads
60
- * naturally after the tool-call card whose execution it gates, and resolves
61
- * ``true`` to approve (run the tool) or ``false`` to deny. The card stays in the
62
- * transcript as a resolved record (buttons disabled, `data-resolved` set)
63
- * rather than vanishing.
53
+ * Distinct from the client-tool confirmation card
54
+ * ({@link requestConfirmation}): a gated server tool defers instead of
55
+ * executing, so the run finishes on an AG-UI interrupt the client answers with
56
+ * `resume[]`, and this is the browser half of that loop. Unlike the
57
+ * confirmation card, this one stays in the transcript as a resolved record,
58
+ * buttons disabled and `data-resolved` set.
64
59
  */
65
60
  export function requestApproval(
66
61
  host: Node & ParentNode,
@@ -6,17 +6,11 @@ const CONFIRM_MS = 1500;
6
6
  /**
7
7
  * Give every fenced code block in `root` a copy button.
8
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.
9
+ * Call only on finished bubbles: the streaming bubble reassigns its `innerHTML`
10
+ * on every delta, so a button attached mid-stream is discarded and rebuilt for
11
+ * each one.
13
12
  *
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
13
+ * Idempotent a bubble already processed is skipped, so a re-render or a second
20
14
  * call cannot stack buttons.
21
15
  */
22
16
  export function attachCopyButtons(root: ParentNode, strings: UiStrings): void {
@@ -43,9 +37,8 @@ function button(code: Element, strings: UiStrings): HTMLButtonElement {
43
37
  copy.setAttribute("aria-label", strings.copyCode);
44
38
  copy.addEventListener("click", () => {
45
39
  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.
40
+ // Report the real outcome: a button that always claims success leaves the
41
+ // reader pasting stale clipboard content.
49
42
  confirm(copy, ok ? strings.copied : strings.copyFailed, strings);
50
43
  });
51
44
  });
@@ -53,11 +46,9 @@ function button(code: Element, strings: UiStrings): HTMLButtonElement {
53
46
  }
54
47
 
55
48
  /**
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.
49
+ * Copy `text`, reporting whether it landed. The Clipboard API needs a secure
50
+ * context and a user gesture and is absent in some embeddings, so failure is an
51
+ * ordinary outcome rather than an error to throw at the host page.
61
52
  */
62
53
  async function writeText(text: string): Promise<boolean> {
63
54
  const clipboard = navigator.clipboard;
@@ -2,10 +2,10 @@ import { ICON_FILE, ICON_FILE_IMAGE, ICON_FILE_PDF, ICON_FILE_TEXT } from "../co
2
2
  import type { AttachmentRef } from "../core/attachment.js";
3
3
 
4
4
  /**
5
- * Render the read-only attachment chips shown on a sent user message bubble
6
- * (and on restored history) — one chip per ref with a type icon, the filename,
7
- * and a human size. Static by design: no progress, no remove (that lives in the
8
- * composer tray); a restored bubble re-renders these with no animation.
5
+ * Render the read-only attachment chips on a sent user message bubble and on
6
+ * restored history — one chip per ref with a type icon, filename, and human
7
+ * size. Static: no progress and no remove, both of which live in the composer
8
+ * tray.
9
9
  */
10
10
  export function renderAttachmentChips(refs: readonly AttachmentRef[]): HTMLDivElement {
11
11
  const list = document.createElement("div");
@@ -45,12 +45,9 @@ function renderChip(ref: AttachmentRef): HTMLDivElement {
45
45
 
46
46
  /**
47
47
  * A coarse type mark for a chip — image, PDF, text document, or generic file —
48
- * as the inline SVG markup of one of the built-in glyphs.
49
- *
50
- * Returns markup rather than a character because chips sit beside the
51
- * composer's SVG send, attach and mic buttons: emoji render at a different
52
- * optical weight, vary by platform, and take neither the glyph size nor
53
- * `currentColor`, so the two never matched.
48
+ * as inline SVG markup rather than a character, so it matches the composer's
49
+ * SVG buttons in weight and takes the glyph size and `currentColor`, which
50
+ * emoji do not.
54
51
  *
55
52
  * The MIME string only selects among author-written constants and is never
56
53
  * interpolated into one, so the result is safe to assign as markup.
@@ -36,13 +36,13 @@ interface TrayItem {
36
36
 
37
37
  /**
38
38
  * The composer's pending-attachments tray: a chip per picked file with a
39
- * progress bar while it uploads, settling to a ready chip (holding the durable
40
- * ref) or an error chip (with retry). A *stateful view* in the spirit of
41
- * {@link ThreadDrawer} — the host appends {@link element}, calls {@link add} on
42
- * pick/drop, reads {@link readyRefs} when the user sends, and clears it.
39
+ * progress bar while it uploads, settling to a ready chip holding the durable
40
+ * ref or an error chip with retry. A stateful view like {@link ThreadDrawer}:
41
+ * the host appends {@link element}, calls {@link add} on pick or drop, reads
42
+ * {@link readyRefs} on send, and clears it.
43
43
  *
44
- * Client-side size/type guards reject a bad file into an error chip without
45
- * uploading instant feedback, but the server is the authority.
44
+ * Client-side size and type guards reject a bad file into an error chip without
45
+ * uploading, for instant feedback, but the server stays the authority.
46
46
  */
47
47
  export class AttachmentTray {
48
48
  /** The tray root; append above the input row. Hidden while empty. */
@@ -8,16 +8,15 @@ export type CheckpointVerb = "resume" | "fork";
8
8
  /**
9
9
  * The checkpoint panel: continuable runs, each offering **resume** or **fork**.
10
10
  *
11
- * A separate surface from the thread drawer on purpose they are different
12
- * axes. A thread is a conversation you switch *to*; a checkpoint is a run you
13
- * continue *from*, and one thread can hold many. Folding them into one list
14
- * would make "resume" look like "open", which it isn't: resuming starts a new
11
+ * Deliberately separate from the thread drawer: a thread is a conversation you
12
+ * switch to, a checkpoint is a run you continue from, and one thread holds
13
+ * many. One list would make "resume" read as "open", when resuming starts a new
15
14
  * run seeded from a snapshot.
16
15
  *
17
- * Only rows the server marked `continuable` are worth offering, so the host
18
- * feeds those; a run with no snapshot would resume from nothing. Pure DOM in
19
- * the spirit of {@link SkillsMenu} the host appends {@link element}, toggles
20
- * it, feeds rows via {@link setRuns}, and acts on {@link onPick}.
16
+ * The host feeds only rows the server marked `continuable`, a run without a
17
+ * snapshot having nothing to resume from. Pure DOM, like {@link SkillsMenu}:
18
+ * append {@link element}, toggle it, feed {@link setRuns}, act on
19
+ * {@link onPick}.
21
20
  */
22
21
  export class CheckpointMenu {
23
22
  /** The panel root. Append to the chat shell; hidden until opened. */
@@ -24,9 +24,9 @@ function actionButton(modifier: string, label: string): HTMLButtonElement {
24
24
  /** Options for {@link requestConfirmation}. */
25
25
  export interface ConfirmationOptions {
26
26
  /**
27
- * Aborting this signal resolves the card as declined (buttons disabled,
28
- * `data-resolved="declined"`)the hook a Stop control uses to dismiss a
29
- * pending confirmation when the user cancels the whole run.
27
+ * Aborting this signal resolves the card as declined, with buttons disabled
28
+ * and `data-resolved="declined"` — how a Stop control dismisses a pending
29
+ * confirmation.
30
30
  */
31
31
  signal?: AbortSignal;
32
32
  /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
@@ -34,20 +34,15 @@ export interface ConfirmationOptions {
34
34
  }
35
35
 
36
36
  /**
37
- * Append an inline confirmation card to ``host`` (the chat message list) and
38
- * resolve when the user decides.
37
+ * Append an inline confirmation card to `host` (the chat message list) and
38
+ * resolve `true` on confirm, `false` on cancel.
39
39
  *
40
- * Unlike a modal overlay, the card lives in the transcript right where the
41
- * action is — it reads naturally after the assistant's explanation and never
42
- * steals focus from the page. Resolves ``true`` on confirm, ``false`` on
43
- * cancel.
40
+ * The card lives in the transcript rather than in a modal overlay, so it reads
41
+ * after the assistant's explanation and never steals focus from the page.
44
42
  *
45
- * **Answering it removes it.** A prompt and a record are two different objects:
46
- * the prompt owns the user's attention until it is answered, and the record of
47
- * what was decided belongs in the transcript, in order, scrolling with
48
- * everything else. The tool card this gates is that record — it settles to
49
- * `done` or `declined` and carries the decision. Leaving the spent form in
50
- * place made an answered question read as outstanding.
43
+ * Answering it removes it: the record of the decision belongs to the tool card
44
+ * this gates, which settles to `done` or `declined` and carries it. A spent
45
+ * form left in place reads as still outstanding.
51
46
  */
52
47
  export function requestConfirmation(
53
48
  host: Node & ParentNode,
@@ -19,9 +19,8 @@ export interface QuestionRequest {
19
19
  /** Options for {@link requestQuestion}. */
20
20
  export interface QuestionOptions {
21
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.
22
+ * Aborting this signal resolves the card with an empty answer and disabled
23
+ * fieldshow a Stop control dismisses an open question.
25
24
  */
26
25
  signal?: AbortSignal;
27
26
  /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
@@ -32,11 +31,9 @@ export interface QuestionOptions {
32
31
  * A fully custom renderer for the `ask_user` question, set via
33
32
  * `AgUiChat.askUserRenderer`. Receives the parsed {@link QuestionRequest} and an
34
33
  * `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.
34
+ * answer; an empty string means no answer. Replaces the built-in
35
+ * {@link requestQuestion} card entirely — see {@link ApprovalRenderer} for the
36
+ * same seam on approvals.
40
37
  */
41
38
  export type QuestionRenderer = (
42
39
  request: QuestionRequest,
@@ -54,14 +51,14 @@ function answerInput(placeholder: string): HTMLInputElement {
54
51
  }
55
52
 
56
53
  /**
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.
54
+ * Append an inline question card to `host` and resolve with the user's answer —
55
+ * the browser half of the built-in `ask_user` frontend tool.
59
56
  *
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.
57
+ * Collects a typed answer rather than the yes/no the confirmation and approval
58
+ * cards return: a radio pick from `options`, or free text when `allowCustom` is
59
+ * set or no `options` are given. Stays in the transcript as a resolved record,
60
+ * controls disabled and `data-resolved` set; a Stop while it is open resolves
61
+ * it with an empty string.
65
62
  */
66
63
  export function requestQuestion(
67
64
  host: Node & ParentNode,
@@ -4,13 +4,11 @@ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
4
4
  * A compact relative timestamp for a thread row — e.g. `"just now"`, `"5m ago"`,
5
5
  * `"3h ago"`, `"2d ago"`, `"4w ago"`.
6
6
  *
7
- * `now` is injectable so callers (and tests) can pin the reference point; it
8
- * defaults to the current time. A timestamp in the future (clock skew) reads as
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.
7
+ * `now` is injectable so callers and tests can pin the reference point. A
8
+ * future timestamp (clock skew) and a non-finite one (an unparseable or missing
9
+ * `updated_at`, arriving as `NaN`) both read as `justNow`, rather than
10
+ * rendering a nonsense age. Unit words come from {@link UiStrings} with `{n}`
11
+ * filled in here; the bucketing stays integer-rounded and locale-neutral.
14
12
  */
15
13
  export function relativeTime(
16
14
  timestamp: number,
@@ -1,52 +1,26 @@
1
- // DOMPurify is exercised in a **real browser** by the `chromium` project in
2
- // vitest.config.ts, not by the happy-dom suite. That is deliberate: 3.4.8+ do
3
- // not sanitise under happy-dom at all `<script>` and `<img>` pass straight
4
- // through — so a happy-dom-only suite can go green while this module ships no
5
- // sanitisation whatsoever.
6
- //
7
- // **Root cause, upstream, and both are refusing to move.** DOMPurify 3.4.8
8
- // changed one line, reading the tag name through
9
- // `lookupGetter(Node.prototype, "nodeName")` instead of `currentNode.nodeName`
10
- // (cure53/DOMPurify 3.4.7...3.4.8). happy-dom defines an *own* `nodeName`
11
- // getter on **both** `Node.prototype` (returning `""`) and `Element.prototype`,
12
- // so grabbing the base one defeats the dispatch and every element resolves to
13
- // `tagName === ""`. Not in ALLOWED_TAGS, so the wrapper is stripped and its
14
- // children are re-inserted as clones the NodeIterator never revisits — which is
15
- // why nested payloads come back *entirely* unsanitised. Real browsers and jsdom
16
- // define `nodeName` only on `Node.prototype` and are unaffected.
17
- //
18
- // happy-dom bug: capricorn86/happy-dom#2182 (open)
19
- // working fix: capricorn86/happy-dom#2183 (closed, never merged)
20
- // DOMPurify's answer: cure53/DOMPurify#1457, #1496 (closed, wontfix —
21
- // "happy-dom is not supported")
22
- //
23
- // ⚠ DOMPurify's README names happy-dom as **not safe**: combining them "will
24
- // likely lead to XSS". jsdom is the only non-browser DOM it supports. So the
25
- // old exact pin at 3.4.7 was never the safety it looked like — cure53 notes it
26
- // "doesn't really work, it just appears so". Running these assertions in
27
- // Chromium is the fix; the pin was a placebo.
28
- //
29
- // ⚠ If you ever move these assertions back under happy-dom to make them faster,
30
- // you remove the only check that this module does anything at all.
1
+ // This module's sanitisation is verified only by the `chromium` project in
2
+ // vitest.config.ts, and must stay there. DOMPurify 3.4.8+ silently sanitises
3
+ // nothing under happy-dom (every element resolves to an empty tag name), so a
4
+ // happy-dom-only suite goes green while this module strips nothing at all.
5
+ // Moving those assertions back under happy-dom for speed removes the only check
6
+ // that this module does anything. CLAUDE.md records the upstream root cause.
31
7
  import DOMPurify from "dompurify";
32
8
  import { Marked } from "marked";
33
9
 
34
- // Local parser instance so configuration never leaks into the shared `marked`
35
- // singleton (a host app's deduped copy keeps its own options). GitHub-flavoured
36
- // markdown with single-newline line breaks (chat-like). Constructed once at
37
- // module scope — configured here and never mutated afterwards; per-call
38
- // construction would re-pay setup on every streaming re-render.
10
+ // A local parser instance, so configuration never leaks into the shared
11
+ // `marked` singleton a host app's deduped copy would share. Built once at module
12
+ // scope and never mutated; per-call construction would re-pay setup on every
13
+ // streaming re-render.
39
14
  const parser = new Marked({ gfm: true, breaks: true });
40
15
 
41
- // Conservative allowlist for assistant chat content: inline emphasis, code,
42
- // lists, quotes, headings, links, and tables. Deliberately excludes `iframe`,
43
- // `style`, and any scripting rendering untrusted model/tool output as HTML
44
- // is an XSS surface, so the sanitiser is the load-bearing safety net.
16
+ // Conservative allowlist for assistant chat content. Rendering untrusted
17
+ // model/tool output as HTML is an XSS surface and this sanitiser is the only
18
+ // thing standing in front of it, so `iframe`, `style` and all scripting stay
19
+ // out.
45
20
  //
46
- // `img` is excluded by default: a model-controlled `<img src="https://...">`
47
- // is fetched by the browser with **no user interaction**, which turns any
48
- // prompt-injected page data into a zero-click exfiltration channel. Hosts
49
- // that trust their content can opt back in via `allowImages`.
21
+ // `img` is excluded by default: a model-controlled image URL is fetched with no
22
+ // user interaction, turning prompt-injected page data into a zero-click
23
+ // exfiltration channel. Hosts that trust their content opt in via `allowImages`.
50
24
  const ALLOWED_TAGS = [
51
25
  "a",
52
26
  "p",
@@ -89,9 +63,9 @@ const ALLOWED_ATTR_WITH_IMAGES = [...ALLOWED_ATTR, "src", "alt", "width", "heigh
89
63
  /** Options for {@link renderMarkdown}. */
90
64
  export interface RenderMarkdownOptions {
91
65
  /**
92
- * Permit `<img>` tags (and their `src`/`alt`/`width`/`height` attributes)
93
- * in the sanitised output. **Off by default** see the allowlist note on
94
- * the exfiltration risk. Only enable for trusted content sources.
66
+ * Permit `<img>` tags (and their `src`/`alt`/`width`/`height` attributes) in
67
+ * the sanitised output. Off by default; see the exfiltration note on the
68
+ * allowlist above. Only enable for trusted content sources.
95
69
  */
96
70
  readonly allowImages?: boolean;
97
71
  }
@@ -99,13 +73,13 @@ export interface RenderMarkdownOptions {
99
73
  /**
100
74
  * Render markdown (and any embedded raw HTML) to a sanitised HTML string.
101
75
  *
102
- * Both markdown syntax and literal HTML flow through one path: `marked` emits
103
- * HTML, then DOMPurify strips everything outside {@link ALLOWED_TAGS} /
104
- * {@link ALLOWED_ATTR} (scripts, event handlers, `javascript:` URLs, etc.).
105
- * Links are hardened with `target="_blank"` + `rel="noopener noreferrer"`.
76
+ * Markdown syntax and literal HTML share one path: `marked` emits HTML, then
77
+ * DOMPurify strips everything outside {@link ALLOWED_TAGS} /
78
+ * {@link ALLOWED_ATTR} scripts, event handlers, `javascript:` URLs. Links are
79
+ * hardened with `target="_blank"` and `rel="noopener noreferrer"`.
106
80
  *
107
81
  * The result is trimmed so a single-paragraph message round-trips to clean
108
- * `textContent` (no trailing newline from the wrapping `<p>`).
82
+ * `textContent`, without the wrapping paragraph's trailing newline.
109
83
  */
110
84
  export function renderMarkdown(text: string, options?: RenderMarkdownOptions): string {
111
85
  const allowImages = options?.allowImages === true;
@@ -7,10 +7,8 @@ export interface ResizeAnchor {
7
7
  }
8
8
 
9
9
  /**
10
- * What the current placement allows: both axes, width only, or nothing.
11
- *
12
- * Which *corner* the grip sits on is not part of this — that follows the host's
13
- * layout, which the component measures rather than assumes.
10
+ * 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.
14
12
  */
15
13
  export type ResizeAxis = "none" | "width" | "both";
16
14
 
@@ -31,22 +29,16 @@ export interface PanelRect {
31
29
  /** What the handle needs from its host to do its job. */
32
30
  export interface ResizeOptions {
33
31
  /**
34
- * Which axes the current placement allows, read **per interaction**.
35
- *
36
- * A getter rather than a value because `placement` is a live attribute: read
37
- * once at construction, a handle built while floating kept its axes after the
38
- * host switched to a docked or full-bleed layout.
32
+ * Which axes the current placement allows, read per interaction. A getter
33
+ * because `placement` is a live attribute, and a value read at construction
34
+ * would survive the host switching to a docked or full-bleed layout.
39
35
  */
40
36
  readonly axis: () => ResizeAxis;
41
37
  /**
42
- * Which edges the layout is holding still, measured at the moment of the
43
- * drag.
44
- *
45
- * **Measured, not derived from `placement`.** A floating panel is pinned
46
- * bottom-right and an embedded one goes wherever the host's own CSS puts it —
47
- * the demo playground drops it in a right-aligned flex slot, so "embedded"
48
- * alone says nothing. Guessing produced a panel that shrank when dragged
49
- * outward and travelled by its opposite corner.
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.
50
42
  */
51
43
  readonly anchor: () => ResizeAnchor;
52
44
  /** The panel's current bounding box. */
@@ -66,25 +58,20 @@ const MIN_HEIGHT = 240;
66
58
  /**
67
59
  * A drag handle that resizes the chat panel.
68
60
  *
69
- * The size was previously fixed by whatever the host set `--ag-ui-width` /
70
- * `--ag-ui-height` to: themeable by the page, immovable by the person reading a
71
- * long answer in a 380px column.
72
- *
73
- * **The new size is measured from the edge that is not moving, never from a
74
- * delta**, and which edge that is is **measured rather than assumed**. A
75
- * floating panel is pinned bottom-right; an embedded one goes wherever the
76
- * host's CSS puts it, so `placement` does not answer the question. Getting it
77
- * wrong is very visible: the panel shrinks when dragged outward and travels by
78
- * its opposite corner.
61
+ * Two rules keep it correct, and both are easy to break invisibly:
79
62
  *
80
- * **It writes the custom properties rather than inline `width` / `height`.**
81
- * The placement rules set those same properties, so an inline dimension would
82
- * fight them a sidebar would keep a dragged width after switching to
83
- * fullscreen. Writing the property means placement still has the final say.
63
+ * - The new size is measured from the edge that is *not* moving, never from a
64
+ * delta, and that edge is measured rather than assumed. Getting it wrong is
65
+ * very visible: the panel shrinks when dragged outward and travels by its
66
+ * opposite corner.
67
+ * - It writes the `--ag-ui-width` / `--ag-ui-height` custom properties, not
68
+ * inline `width` / `height`. The placement rules set those same properties,
69
+ * so an inline dimension would outrank and fight them — a sidebar would keep
70
+ * its dragged width after switching to fullscreen. Writing the property
71
+ * leaves placement the final say.
84
72
  *
85
- * The axes are read per interaction, so switching `placement` at runtime takes
86
- * effect immediately rather than leaving whichever ones the element happened to
87
- * mount with.
73
+ * Axes and anchor are both read per interaction, so a runtime `placement`
74
+ * change takes effect at once.
88
75
  */
89
76
  export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
90
77
  const handle = document.createElement("div");
@@ -116,7 +103,7 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
116
103
  if (axis === "none") {
117
104
  return;
118
105
  }
119
- // Captured once: the fixed edges cannot move during the drag, and reading
106
+ // Captured once: the pinned edges cannot move during the drag, and reading
120
107
  // them live would chase the panel as it resizes.
121
108
  const anchor = options.anchor();
122
109
  const rect = options.rect();
@@ -140,7 +127,7 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
140
127
  event.preventDefault();
141
128
  });
142
129
 
143
- // Keyboard parity. A pointer-only resize is unreachable without a mouse, and
130
+ // Keyboard parity: a pointer-only resize is unreachable without a mouse, and
144
131
  // this control has no equivalent elsewhere in the UI.
145
132
  handle.addEventListener("keydown", (event: KeyboardEvent) => {
146
133
  const axis = options.axis();
@@ -150,8 +137,8 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
150
137
  const anchor = options.anchor();
151
138
  const rect = options.rect();
152
139
  const step = event.shiftKey ? 64 : 16;
153
- // An arrow moves the grip, and whether that grows or shrinks depends on
154
- // which side the grip is on — the same asymmetry the pointer path handles.
140
+ // An arrow moves the grip, so whether it grows or shrinks depends on which
141
+ // side the grip is on — the asymmetry the pointer path also handles.
155
142
  const outward = anchor.x === "right" ? -1 : 1;
156
143
  const width = rect.right - rect.left;
157
144
  const height = rect.bottom - rect.top;
@@ -1,28 +1,25 @@
1
1
  /**
2
- * A muted one-line notice about something the *run* did, rendered inline in the
3
- * transcript between turns.
2
+ * A muted one-line notice about something the run did (history condensed, a
3
+ * skill loaded), rendered inline between turns.
4
4
  *
5
- * Distinct from a tool card (which reports work the agent asked for and is
6
- * settleable) and from an error (which is a failure). A notice is ambient: the
7
- * agent condensed earlier turns, or loaded a skill. It never settles, never
8
- * takes an action, and carries no controls — so it stays visually quiet and out
9
- * of the way of the conversation it annotates.
5
+ * Distinct from a tool card, which reports work the agent asked for and
6
+ * settles, and from an error, which is a failure. A notice never settles, takes
7
+ * no action, and carries no controls.
10
8
  */
11
9
  export function renderRunNotice(icon: string, text: string, kind: string): HTMLDivElement {
12
10
  const notice = document.createElement("div");
13
11
  notice.className = `run-notice run-notice--${kind}`;
14
12
  notice.setAttribute("part", `run-notice run-notice-${kind}`);
15
- // A status role, not an alert: this is informational and must not interrupt a
16
- // screen reader mid-sentence. Polite announcements land after the current
17
- // utterance, which is right for an annotation about turns already spoken.
13
+ // status, not alert: informational, so it must not interrupt a screen reader
14
+ // mid-sentence. Polite announcements land after the current utterance.
18
15
  notice.setAttribute("role", "status");
19
16
 
20
17
  const glyph = document.createElement("span");
21
18
  glyph.className = "run-notice-icon";
22
19
  glyph.setAttribute("part", "run-notice-icon");
23
20
  glyph.textContent = icon;
24
- // Decorative: the adjacent text already says what happened, and a screen
25
- // reader announcing the emoji's name would just add noise.
21
+ // Decorative: the adjacent text already says what happened, so announcing the
22
+ // glyph's name would only add noise.
26
23
  glyph.setAttribute("aria-hidden", "true");
27
24
 
28
25
  const label = document.createElement("span");