@artooi/ag-ui-web-component 0.33.1 → 0.35.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 (62) hide show
  1. package/CHANGELOG.md +563 -1
  2. package/README.md +279 -16
  3. package/dist/ag-ui-web-component.bundle.js +676 -101
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +80 -0
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +37 -2
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/dom/animations.d.ts +14 -0
  10. package/dist/dom/animations.d.ts.map +1 -1
  11. package/dist/dom/highlight_overlay.d.ts +47 -0
  12. package/dist/dom/highlight_overlay.d.ts.map +1 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2288 -388
  16. package/dist/index.js.map +4 -4
  17. package/dist/tools/chat_surface_tools.d.ts +96 -0
  18. package/dist/tools/chat_surface_tools.d.ts.map +1 -0
  19. package/dist/tools/page_action_tools.d.ts +2 -0
  20. package/dist/tools/page_action_tools.d.ts.map +1 -1
  21. package/dist/ui/chart_block.d.ts +18 -0
  22. package/dist/ui/chart_block.d.ts.map +1 -1
  23. package/dist/ui/clamp_launcher.d.ts +10 -5
  24. package/dist/ui/clamp_launcher.d.ts.map +1 -1
  25. package/dist/ui/clamp_panel.d.ts +13 -0
  26. package/dist/ui/clamp_panel.d.ts.map +1 -0
  27. package/dist/ui/launcher_drag.d.ts +2 -2
  28. package/dist/ui/launcher_drag.d.ts.map +1 -1
  29. package/dist/ui/launcher_placement.d.ts +14 -1
  30. package/dist/ui/launcher_placement.d.ts.map +1 -1
  31. package/dist/ui/panel_drag.d.ts +40 -0
  32. package/dist/ui/panel_drag.d.ts.map +1 -0
  33. package/dist/ui/place_widget.d.ts +31 -0
  34. package/dist/ui/place_widget.d.ts.map +1 -0
  35. package/dist/ui/run_notice.d.ts +14 -3
  36. package/dist/ui/run_notice.d.ts.map +1 -1
  37. package/dist/ui/styles.d.ts +1 -1
  38. package/dist/ui/styles.d.ts.map +1 -1
  39. package/dist/ui/thread_drawer.d.ts +21 -0
  40. package/dist/ui/thread_drawer.d.ts.map +1 -1
  41. package/dist/ui/ui_strings.d.ts +16 -0
  42. package/dist/ui/ui_strings.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/constants.ts +87 -0
  45. package/src/core/ag_ui_chat.ts +1146 -38
  46. package/src/dom/animations.ts +30 -0
  47. package/src/dom/highlight_overlay.ts +256 -0
  48. package/src/index.ts +12 -0
  49. package/src/tools/chat_surface_tools.ts +207 -0
  50. package/src/tools/page_action_tools.ts +2 -0
  51. package/src/ui/chart_block.ts +222 -57
  52. package/src/ui/clamp_launcher.ts +25 -7
  53. package/src/ui/clamp_panel.ts +30 -0
  54. package/src/ui/launcher_drag.ts +11 -2
  55. package/src/ui/launcher_placement.ts +50 -60
  56. package/src/ui/panel_drag.ts +138 -0
  57. package/src/ui/place_widget.ts +64 -0
  58. package/src/ui/run_notice.ts +32 -3
  59. package/src/ui/styles.ts +622 -47
  60. package/src/ui/thread_drawer.ts +138 -8
  61. package/src/ui/ui_strings.ts +24 -0
  62. package/src/version.ts +1 -1
@@ -7,25 +7,31 @@ import {
7
7
  COMPACTION_ACTIVITY_TYPE,
8
8
  CUSTOM_AGENT_EVENT,
9
9
  DEFAULT_ATTACHMENT_MAX_BYTES,
10
+ EDGE_MARGIN,
10
11
  FEEDBACK_EVENT,
11
12
  ICON_ATTACH,
12
13
  ICON_LAUNCHER,
14
+ ICON_MOON,
13
15
  ICON_RETRY,
14
16
  ICON_SEND,
15
17
  ICON_STOP,
18
+ ICON_SUN,
16
19
  INVALIDATE_CUSTOM_NAME,
17
20
  INVALIDATE_EVENT,
18
21
  LOAD_CAPABILITY_TOOL,
19
22
  MAX_TOOL_ROUNDS,
20
23
  MESSAGE_ACTIONS,
21
24
  MESSAGE_ROLE,
25
+ PASTE_ATTACH_CHARS,
22
26
  READ_PAGE_TOOL,
23
27
  RUN_FINISHED_EVENT,
28
+ SCREEN_EDGE_MARGIN,
24
29
  STATE_EVENT,
25
30
  SUBAGENT_CUSTOM_NAME,
26
31
  SUBAGENT_PHASE,
27
32
  SUBMIT_EVENT,
28
33
  SUGGESTIONS_ACTIVITY_TYPE,
34
+ THREADS_DOCK_MIN_WIDTH,
29
35
  TOGGLE_EVENT,
30
36
  TOOL_CALL_STATUS,
31
37
  TOOL_DISPLAY,
@@ -36,11 +42,20 @@ import {
36
42
  import { fillTemplate } from "../skills/fill_template.js";
37
43
  import { parseSkills } from "../skills/parse_skills.js";
38
44
  import type { Skill } from "../skills/skill.js";
45
+ import {
46
+ type ChatCorner,
47
+ type ChatSurfaceReport,
48
+ createChatSurfaceTools,
49
+ } from "../tools/chat_surface_tools.js";
39
50
  import type { ChartRenderer } from "../tools/client_tool_registry.js";
40
51
  import { type ClientTool, ClientToolRegistry } from "../tools/client_tool_registry.js";
41
52
  import { isDestructive } from "../tools/is_destructive.js";
42
53
  import { isNavigates } from "../tools/is_navigates.js";
43
- import { createPageActionTools, type ResolvePageTarget } from "../tools/page_action_tools.js";
54
+ import {
55
+ createPageActionTools,
56
+ PAGE_ACTIONS,
57
+ type ResolvePageTarget,
58
+ } from "../tools/page_action_tools.js";
44
59
  import { createPageMapContext, type PageMap } from "../tools/page_map.js";
45
60
  import { createPageStateTools, type PageState } from "../tools/page_state.js";
46
61
  import { parseToolCatalog, type ToolCatalogEntry } from "../tools/parse_tool_catalog.js";
@@ -58,6 +73,7 @@ import { chartSpecFrom } from "../ui/chart_spec_from.js";
58
73
  import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
59
74
  import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
60
75
  import { clampLauncher } from "../ui/clamp_launcher.js";
76
+ import { clampPanel } from "../ui/clamp_panel.js";
61
77
  import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
62
78
  import { copyPayload } from "../ui/copy_payload.js";
63
79
  import { enableLauncherDrag } from "../ui/launcher_drag.js";
@@ -66,6 +82,7 @@ import {
66
82
  type Extent,
67
83
  type LauncherBox,
68
84
  launcherPlacement,
85
+ type ViewportBox,
69
86
  } from "../ui/launcher_placement.js";
70
87
  import {
71
88
  attachMessageActions,
@@ -73,6 +90,8 @@ import {
73
90
  messageActionButton,
74
91
  } from "../ui/message_actions.js";
75
92
  import { attachQuoteOffer, type PageQuoteOffer } from "../ui/page_quote_offer.js";
93
+ import { enablePanelDrag } from "../ui/panel_drag.js";
94
+ import { placeWidget } from "../ui/place_widget.js";
76
95
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
77
96
  import {
78
97
  type QuestionRenderer,
@@ -316,6 +335,15 @@ const THEME_KEY = "ag-ui-chat:theme";
316
335
  /** Per-tab persistence key for a dragged launcher position. */
317
336
  const LAUNCHER_KEY = "ag-ui-chat:launcher";
318
337
 
338
+ /** A stored `{ left, top }` pair, or null for anything that is not one. */
339
+ function asPoint(value: unknown): { readonly left: number; readonly top: number } | null {
340
+ if (typeof value !== "object" || value === null) {
341
+ return null;
342
+ }
343
+ const { left, top } = value as { left?: unknown; top?: unknown };
344
+ return typeof left === "number" && typeof top === "number" ? { left, top } : null;
345
+ }
346
+
319
347
  /**
320
348
  * Placements whose launcher can be dragged. The rest have nowhere to put it:
321
349
  * a sidebar collapses to a full-height edge rail, "embedded" and "page" hide
@@ -618,7 +646,7 @@ export class AgUiChat extends HTMLElement {
618
646
  * Resolve a `scroll_to` / `drag_and_drop` target string to a host-page
619
647
  * element (or `null`). Defaults to a CSS-selector lookup; override to map
620
648
  * page-map element ids. The page-action tools are opt-in via the
621
- * `data-page-actions` attribute (`"scroll"` / `"drag"`).
649
+ * `data-page-actions` attribute (`"scroll"` / `"drag"` / `"chat"`).
622
650
  */
623
651
  resolvePageTarget: ResolvePageTarget = (target) => document.querySelector<HTMLElement>(target);
624
652
 
@@ -743,6 +771,12 @@ export class AgUiChat extends HTMLElement {
743
771
  readonly #root: ShadowRoot;
744
772
  /** Screen-reader-only status region -- see {@link AgUiChat.#announce}. */
745
773
  readonly #announcer = document.createElement("div");
774
+ /**
775
+ * A zero-sized box carrying the host's viewport insets as padding, so they
776
+ * can be read back as used pixel lengths. See the `.viewport-probe` rule for
777
+ * why a custom property cannot be read directly.
778
+ */
779
+ readonly #viewportProbe = document.createElement("div");
746
780
  /** Return-to-foot affordance, shown only once something has been missed. */
747
781
  readonly #jumpButton = document.createElement("button");
748
782
  /**
@@ -804,6 +838,8 @@ export class AgUiChat extends HTMLElement {
804
838
  readonly #launcher: HTMLButtonElement;
805
839
  /** The launcher's unread badge; hidden at zero, and when the host opts out. */
806
840
  readonly #badge: HTMLSpanElement;
841
+ /** The rail's vertical caption. Rendered only by the sidebar's edge rail. */
842
+ readonly #railLabel: HTMLSpanElement = document.createElement("span");
807
843
  // Answers that finished while the widget was collapsed. Expanding clears it.
808
844
  #unread = 0;
809
845
  /** Empty-state region at the top of the message list; hidden once anything renders. */
@@ -817,6 +853,27 @@ export class AgUiChat extends HTMLElement {
817
853
  * position -- see #applyLauncherPlacement for what that costs the host.
818
854
  */
819
855
  #launcherPos: { readonly left: number; readonly top: number } | null = null;
856
+ /** What the user has sent this session, newest first, for arrow-key recall. */
857
+ readonly #sentDrafts: string[] = [];
858
+ /** Typed while a run was in flight, oldest first; sent when it settles. */
859
+ readonly #queued: string[] = [];
860
+ /** The row those show up in, above the composer. */
861
+ readonly #queuedRow: HTMLDivElement = document.createElement("div");
862
+ /** How far back the composer has been walked; null while the user is typing. */
863
+ #recallIndex: number | null = null;
864
+
865
+ /**
866
+ * Where the user dragged the *panel*, in viewport coordinates, or null while
867
+ * its position is still derived from the launcher's.
868
+ *
869
+ * The two gestures state different things and are restored differently. A
870
+ * launcher drag says where the bubble goes and leaves the panel to open into
871
+ * whatever space the viewport has, so it is re-derived every time -- which is
872
+ * what lets a widget re-decide its direction when the window changes under
873
+ * it. A header drag states the panel's own position, and re-deriving that
874
+ * from the launcher would move the panel the user just placed.
875
+ */
876
+ #panelPos: { readonly left: number; readonly top: number } | null = null;
820
877
 
821
878
  /**
822
879
  * The corner the panel opens away from, once this element is placing itself.
@@ -839,8 +896,78 @@ export class AgUiChat extends HTMLElement {
839
896
  * a field so `removeEventListener` on disconnect gets the same reference.
840
897
  */
841
898
  readonly #onViewportResize = (): void => {
899
+ this.#publishVisualViewport();
900
+ // Not while a gesture owns the position. Restoring re-applies the *stored*
901
+ // position, and mid-drag that value is the one from before the drag began
902
+ // -- so it puts the widget back where it was, the next pointer move puts it
903
+ // where the finger is, and the two fight for as long as the viewport keeps
904
+ // changing.
905
+ //
906
+ // Which on a phone can be most of the drag: the visual viewport resizes and
907
+ // scrolls whenever the browser's own chrome collapses, and that is driven
908
+ // by the gesture in progress. This was not the cause of the jumping that
909
+ // sent me looking -- that was an inset measured from the wrong box -- so it
910
+ // is a guard against a fight that had not been observed rather than a fix
911
+ // for one that had.
912
+ if (this.#dragging()) {
913
+ return;
914
+ }
842
915
  this.#restoreLauncherPosition();
916
+ // Docking is decided by width, so a resize can cross the threshold with
917
+ // the drawer already open. Without this the rail keeps a narrow
918
+ // transcript's width, the focus trap stays off, and the backdrop that
919
+ // would dismiss it is still display:none. Only while it is open: the two
920
+ // are re-decided on the way in, and a closed drawer has no layout to fix.
921
+ if (this.#drawer.isOpen()) {
922
+ this.#drawer.setModal(!this.#threadsDock());
923
+ this.#syncThreadsState();
924
+ }
843
925
  };
926
+
927
+ /**
928
+ * Hold a resized box inside the part of the screen the host left free.
929
+ *
930
+ * Each edge on its own, unlike the drag's clamp: a drag moves a box of fixed
931
+ * size, so pushing it back in is right, while a resize is anchored on the
932
+ * opposite edge and pushing it back would move the edge the user is not
933
+ * touching. Bounding each edge instead leaves the grip stopped at the limit
934
+ * -- the gesture keeps going and the panel simply stops growing, which is
935
+ * what dragging already does.
936
+ *
937
+ * The minimum size is the grip's own concern and is applied before this, so
938
+ * a panel that cannot fit the space is left at its minimum and overflowing
939
+ * rather than collapsed to nothing.
940
+ */
941
+ #withinViewport(box: PanelRect): PanelRect {
942
+ const viewport = this.#viewport();
943
+ // The same bound a drag stops at, so a grip pulled to the edge and a panel
944
+ // dragged to it come to rest on the same line. The inner Math.max/min pair
945
+ // keeps an already-inverted box from turning inside out.
946
+ const left = viewport.left + SCREEN_EDGE_MARGIN;
947
+ const top = viewport.top + SCREEN_EDGE_MARGIN;
948
+ const right = viewport.left + viewport.width - SCREEN_EDGE_MARGIN;
949
+ const bottom = viewport.top + viewport.height - SCREEN_EDGE_MARGIN;
950
+ return {
951
+ left: Math.min(Math.max(box.left, left), box.right),
952
+ top: Math.min(Math.max(box.top, top), box.bottom),
953
+ right: Math.max(Math.min(box.right, right), box.left),
954
+ bottom: Math.max(Math.min(box.bottom, bottom), box.top),
955
+ };
956
+ }
957
+
958
+ /**
959
+ * Whether a pointer or key gesture is currently placing the widget.
960
+ *
961
+ * Read from the stamp the drag helpers already set, rather than tracked
962
+ * separately: one source of truth, and it clears on `pointercancel` as well
963
+ * as `pointerup`, which is the end a touch gesture usually gets.
964
+ */
965
+ #dragging(): boolean {
966
+ return (
967
+ this.#launcher.hasAttribute("data-dragging") ||
968
+ this.#root.querySelector(".header[data-dragging]") !== null
969
+ );
970
+ }
844
971
  /** Mic button mount point (input row); the control mounts on connect when enabled. */
845
972
  readonly #voiceSlot: HTMLSpanElement;
846
973
  /** Voice-input control; created on connect when transcription is available. */
@@ -975,6 +1102,9 @@ export class AgUiChat extends HTMLElement {
975
1102
  onDelete: (threadId) => {
976
1103
  this.#deleteThread(threadId);
977
1104
  },
1105
+ onVisibility: () => {
1106
+ this.#syncThreadsState();
1107
+ },
978
1108
  });
979
1109
  this.#checkpoints = new CheckpointMenu((runId, verb) => {
980
1110
  void this.#continueRun(runId, verb);
@@ -1080,6 +1210,13 @@ export class AgUiChat extends HTMLElement {
1080
1210
  // Position is owned the same way a size is: a placement that places
1081
1211
  // itself takes back a launcher the user had dragged somewhere else.
1082
1212
  this.#releaseLauncherPosition();
1213
+ // Switching into a placement with no collapsed state has to release it,
1214
+ // not just stop offering it: the control is gone from the header the
1215
+ // moment the attribute changes, so a panel collapsed under the previous
1216
+ // placement would have no way back.
1217
+ if (!this.#collapsible() && this.collapsed) {
1218
+ this.setCollapsed(false);
1219
+ }
1083
1220
  // Placement also moves the panel, so the edges its layout holds still
1084
1221
  // change with it. Deferred a frame so the new rules have applied.
1085
1222
  requestAnimationFrame(() => this.#syncResizeAnchor());
@@ -1089,6 +1226,7 @@ export class AgUiChat extends HTMLElement {
1089
1226
  // `#strings` is the resolved table once connected, the English defaults
1090
1227
  // before then.
1091
1228
  this.#title.textContent = value ?? this.#strings.title;
1229
+ this.#railLabel.textContent = this.#title.textContent;
1092
1230
  return;
1093
1231
  }
1094
1232
  if (name === "user-key") {
@@ -1227,7 +1365,10 @@ export class AgUiChat extends HTMLElement {
1227
1365
  .map((token) => token.trim())
1228
1366
  .filter((token) => token !== ""),
1229
1367
  );
1230
- return createPageActionTools(enabled, (target) => this.resolvePageTarget(target));
1368
+ return [
1369
+ ...createPageActionTools(enabled, (target) => this.resolvePageTarget(target)),
1370
+ ...(enabled.has(PAGE_ACTIONS.CHAT) ? createChatSurfaceTools(this) : []),
1371
+ ];
1231
1372
  }
1232
1373
 
1233
1374
  /** All built-in (route + page + page-action + ask_user) frontend tools. */
@@ -1518,7 +1659,7 @@ export class AgUiChat extends HTMLElement {
1518
1659
  // Restore a theme the built-in toggle persisted last visit (opt-in only, so
1519
1660
  // it never overrides a host that drives `theme` itself).
1520
1661
  if (this.getAttribute("data-theme-toggle") !== null) {
1521
- const saved = this.#readScopedItem(THEME_KEY);
1662
+ const saved = this.#readPreference(THEME_KEY);
1522
1663
  if (saved !== null) {
1523
1664
  this.setAttribute("theme", saved);
1524
1665
  }
@@ -1526,7 +1667,11 @@ export class AgUiChat extends HTMLElement {
1526
1667
  this.#render();
1527
1668
  this.#drawer.setStrings(this.#strings);
1528
1669
  this.#checkpoints.setStrings(this.#strings);
1529
- if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
1670
+ // Gated on the placement, not just on the stored value: the key is
1671
+ // namespaced per instance but not per placement, so a tab that collapsed a
1672
+ // floating panel and later loaded the same instance as a page would restore
1673
+ // a state that placement has no way out of.
1674
+ if (this.#collapsible() && this.#startsCollapsed()) {
1530
1675
  this.setAttribute("collapsed", "");
1531
1676
  }
1532
1677
  this.#syncLauncher();
@@ -1542,6 +1687,12 @@ export class AgUiChat extends HTMLElement {
1542
1687
  this.conversationStore = this.#builtinStore;
1543
1688
  }
1544
1689
  window.addEventListener("resize", this.#onViewportResize);
1690
+ // The visual viewport changes without the window resizing -- a keyboard
1691
+ // opening, a pinch-zoom, the URL bar collapsing -- and `scroll` is what
1692
+ // fires when it is panned rather than resized.
1693
+ window.visualViewport?.addEventListener("resize", this.#onViewportResize);
1694
+ window.visualViewport?.addEventListener("scroll", this.#onViewportResize);
1695
+ this.#publishVisualViewport();
1545
1696
  this.#wireThreadStore();
1546
1697
  this.#wireAttachments();
1547
1698
  this.#wireVoice();
@@ -1615,6 +1766,8 @@ export class AgUiChat extends HTMLElement {
1615
1766
  disconnectedCallback(): void {
1616
1767
  this.#connected = false;
1617
1768
  window.removeEventListener("resize", this.#onViewportResize);
1769
+ window.visualViewport?.removeEventListener("resize", this.#onViewportResize);
1770
+ window.visualViewport?.removeEventListener("scroll", this.#onViewportResize);
1618
1771
  // Give the namespace back. A disconnect is not necessarily a farewell — a
1619
1772
  // DOM move and a framework re-render both look like one — and an element
1620
1773
  // that could not reclaim its own namespace on the way back in would lose
@@ -1699,7 +1852,7 @@ export class AgUiChat extends HTMLElement {
1699
1852
  this.#fileInput.accept = accept;
1700
1853
  this.#attachButton.hidden = false;
1701
1854
  this.#enableDragAndDrop();
1702
- this.#enablePaste();
1855
+ this.#enablePaste(tray);
1703
1856
  }
1704
1857
 
1705
1858
  /** The built-in multipart upload handler for `data-attachments-url`, or `null`. */
@@ -1967,6 +2120,65 @@ export class AgUiChat extends HTMLElement {
1967
2120
  });
1968
2121
  }
1969
2122
 
2123
+ /**
2124
+ * Turn a very long text paste into an attachment instead of a wall of text.
2125
+ *
2126
+ * A composer capped at `40vh` is not where forty thousand characters go: the
2127
+ * user cannot read what they pasted, cannot edit around it, and sends one
2128
+ * enormous turn. As a file it stays whole, the model still receives it, and
2129
+ * the box is left for the question about it.
2130
+ *
2131
+ * Only where the host has configured uploads -- and structurally so, rather
2132
+ * than by a check here: the paste listener is wired inside the attachment
2133
+ * setup, so with no tray there is no listener at all and an ordinary paste is
2134
+ * untouched. Quietly dropping a paste for being long would be far worse than
2135
+ * an awkward composer. The tray is passed rather than read off the field for
2136
+ * the same reason its `onChange` hook is: it can only be called from one that
2137
+ * exists, so taking it as an argument removes a null check no caller can
2138
+ * reach.
2139
+ *
2140
+ * Nothing is lost by removing the chip: the text is still on the clipboard,
2141
+ * so pasting again brings it back. That is why this needs no undo of its own.
2142
+ */
2143
+ #pasteLongTextAsFile(event: ClipboardEvent, clipboard: DataTransfer, tray: AttachmentTray): void {
2144
+ const threshold = this.#pasteAttachThreshold();
2145
+ const text = clipboard.getData("text/plain");
2146
+ if (threshold === null || text.length < threshold) {
2147
+ return;
2148
+ }
2149
+ event.preventDefault();
2150
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
2151
+ tray.add(new File([text], `pasted-${stamp}.txt`, { type: "text/plain" }));
2152
+ }
2153
+
2154
+ /**
2155
+ * How long a pasted string has to be before it becomes a file, or `null` to
2156
+ * leave every paste in the composer.
2157
+ *
2158
+ * One attribute with three answers rather than three attributes: absent is
2159
+ * the default, `off` refuses, and a number states the threshold. A value that
2160
+ * is neither says so, because a typo silently meaning "off" is the failure
2161
+ * this whole release keeps finding.
2162
+ */
2163
+ #pasteAttachThreshold(): number | null {
2164
+ const raw = this.getAttribute("data-paste-attach");
2165
+ if (raw === null) {
2166
+ return PASTE_ATTACH_CHARS;
2167
+ }
2168
+ if (raw === "off") {
2169
+ return null;
2170
+ }
2171
+ const stated = Number.parseInt(raw, 10);
2172
+ if (Number.isNaN(stated) || stated <= 0) {
2173
+ console.warn(
2174
+ `<ag-ui-chat>: data-paste-attach="${raw}" is neither "off" nor a positive ` +
2175
+ `number of characters, so the default of ${PASTE_ATTACH_CHARS} is used.`,
2176
+ );
2177
+ return PASTE_ATTACH_CHARS;
2178
+ }
2179
+ return stated;
2180
+ }
2181
+
1970
2182
  /**
1971
2183
  * Accept files pasted into the composer.
1972
2184
  *
@@ -1980,7 +2192,7 @@ export class AgUiChat extends HTMLElement {
1980
2192
  * clipboard, and swallowing the words someone meant to paste in order to
1981
2193
  * attach a picture they did not is the worse of the two failures.
1982
2194
  */
1983
- #enablePaste(): void {
2195
+ #enablePaste(tray: AttachmentTray): void {
1984
2196
  this.#chat.addEventListener("paste", (event: ClipboardEvent) => {
1985
2197
  // Nullish rather than a null check: the property is typed as nullable,
1986
2198
  // and an engine that fires a plain Event for a paste leaves it absent
@@ -1991,6 +2203,7 @@ export class AgUiChat extends HTMLElement {
1991
2203
  }
1992
2204
  const files = Array.from(clipboard.files);
1993
2205
  if (files.length === 0) {
2206
+ this.#pasteLongTextAsFile(event, clipboard, tray);
1994
2207
  return;
1995
2208
  }
1996
2209
  if (clipboard.getData("text/plain") === "") {
@@ -2176,7 +2389,27 @@ export class AgUiChat extends HTMLElement {
2176
2389
  * per-tab, and emit a {@link TOGGLE_EVENT} so a host can mirror the state in
2177
2390
  * its own chrome.
2178
2391
  */
2179
- setCollapsed(collapsed: boolean): void {
2392
+ setCollapsed(collapsed: boolean, options: { readonly announce?: boolean } = {}): void {
2393
+ if (collapsed && !this.#collapsible()) {
2394
+ return;
2395
+ }
2396
+ // Announced before the state changes, so the notice is written into a
2397
+ // transcript the user can still see -- and only when the agent did it,
2398
+ // rather than when the user pressed the control themselves.
2399
+ //
2400
+ // Collapsing only. Expanding announces nothing because the panel arriving
2401
+ // is the announcement, and a notice about something visibly happening is
2402
+ // noise; there is also nothing to undo that the collapse control does not
2403
+ // already do.
2404
+ if (options.announce === true && collapsed && !this.collapsed) {
2405
+ // No undo beside this one. The notice is written into the transcript and
2406
+ // the transcript is then hidden by the very collapse it describes, so
2407
+ // the only way to read it is to expand the panel -- which is what the
2408
+ // undo would have done. By the time the button can be seen it has
2409
+ // nothing left to do, and a control that is always a no-op is worse than
2410
+ // no control. Same reasoning as the expand case just below.
2411
+ this.#announceSurfaceChange(this.#strings.chatMinimised, null);
2412
+ }
2180
2413
  if (!collapsed) {
2181
2414
  // Re-decide which way to open before opening: the viewport may have
2182
2415
  // changed since the launcher was dropped, and this corner is what the
@@ -2211,6 +2444,194 @@ export class AgUiChat extends HTMLElement {
2211
2444
  return this.#unread;
2212
2445
  }
2213
2446
 
2447
+ /**
2448
+ * Whether to mount collapsed.
2449
+ *
2450
+ * A stored choice always wins -- in either direction, so a user who opened
2451
+ * the panel finds it open. With nothing stored, the corner placements start
2452
+ * collapsed: they are the two that have a launcher, and a launcher is the
2453
+ * resting state of every corner chat in the field. Mounting open put a
2454
+ * 380x560 panel over the host page's own bottom-right corner on a visitor's
2455
+ * first load, uninvited.
2456
+ *
2457
+ * The placements that place themselves are unchanged. A host that docks a
2458
+ * sidebar has already decided the widget belongs on screen, and one that
2459
+ * embeds it in its own layout has given it a box to fill.
2460
+ *
2461
+ * `data-start-open` restores the previous behaviour for a host that wants
2462
+ * the panel up immediately.
2463
+ */
2464
+ #startsCollapsed(): boolean {
2465
+ const stored = this.#readScopedItem(COLLAPSED_KEY);
2466
+ if (stored !== null) {
2467
+ return stored === "1";
2468
+ }
2469
+ return (
2470
+ DRAGGABLE_PLACEMENTS.has(this.getAttribute("placement")) &&
2471
+ !this.hasAttribute("data-start-open")
2472
+ );
2473
+ }
2474
+
2475
+ /**
2476
+ * Whether this placement has a collapsed state at all.
2477
+ *
2478
+ * `page` does not. It is a dedicated route rather than a panel sitting on
2479
+ * someone else's page, so there is no "away" for it to go to: collapsing it
2480
+ * left a strip of application chrome fixed over a route that no longer had an
2481
+ * owner. It is also the placement that hides the launcher, so the usual way
2482
+ * back does not exist here.
2483
+ *
2484
+ * The header hides its collapse control under this placement, but a control
2485
+ * removed from the UI is not a state removed from the model -- the property,
2486
+ * the attribute and a value restored from storage all still reach it. This is
2487
+ * what the reachable paths are gated on; the stylesheet covers the one path
2488
+ * that never passes through here, an attribute written straight onto the
2489
+ * element.
2490
+ */
2491
+ #collapsible(): boolean {
2492
+ return this.getAttribute("placement") !== "page";
2493
+ }
2494
+
2495
+ /**
2496
+ * Describe the panel to whoever is asking -- in practice the agent, through
2497
+ * the opt-in chat-surface tools.
2498
+ *
2499
+ * `movable` folds two separate reasons into the one answer a caller needs:
2500
+ * a placement that places itself owns its position, and a panel that fills
2501
+ * the screen has nowhere to go. Reporting them apart would make every caller
2502
+ * re-derive the same conjunction.
2503
+ */
2504
+ describeSurface(): ChatSurfaceReport {
2505
+ const box = this.getBoundingClientRect();
2506
+ const viewport = this.#viewport();
2507
+ const fullBleed = box.width >= viewport.width - 1 && box.height >= viewport.height - 1;
2508
+ return {
2509
+ placement: this.getAttribute("placement"),
2510
+ collapsed: this.collapsed,
2511
+ collapsible: this.#collapsible(),
2512
+ movable: this.#launcherDraggable() && !fullBleed,
2513
+ draggable: this.#launcherDraggable(),
2514
+ fullBleed,
2515
+ box: {
2516
+ left: Math.round(box.left),
2517
+ top: Math.round(box.top),
2518
+ width: Math.round(box.width),
2519
+ height: Math.round(box.height),
2520
+ },
2521
+ viewport: {
2522
+ left: Math.round(viewport.left),
2523
+ top: Math.round(viewport.top),
2524
+ width: Math.round(viewport.width),
2525
+ height: Math.round(viewport.height),
2526
+ },
2527
+ };
2528
+ }
2529
+
2530
+ /**
2531
+ * Send the panel to a corner, and report whether it went.
2532
+ *
2533
+ * A third claimant on the axes a placement and a user drag already share, and
2534
+ * it takes them the same way the drag does rather than inventing a second
2535
+ * mechanism: the same commit path, so the launcher travels with the panel,
2536
+ * the corner it opens from is re-picked, and switching placement hands
2537
+ * everything back. What it must not do is claim a move it did not make --
2538
+ * hence the boolean, and hence {@link describeSurface} existing so a caller
2539
+ * can ask first.
2540
+ */
2541
+ moveTo(corner: ChatCorner, options: { readonly announce?: boolean } = {}): boolean {
2542
+ if (!this.#launcherDraggable()) {
2543
+ return false;
2544
+ }
2545
+ const restore = options.announce === true ? this.#captureGeometry() : null;
2546
+ const viewport = this.#viewport();
2547
+ const box = this.getBoundingClientRect();
2548
+ if (box.width >= viewport.width - 1 && box.height >= viewport.height - 1) {
2549
+ return false;
2550
+ }
2551
+ const [edgeY, edgeX] = corner.split("-");
2552
+ // Every term is an absolute screen coordinate, because that is what the
2553
+ // clamps and the insets both speak. The usable box carries an origin, so
2554
+ // its near edge is `viewport.left`, not zero, and its far edge is
2555
+ // `viewport.left + viewport.width` -- a margin applied to the extents
2556
+ // alone would send the agent's own move under the chrome the host
2557
+ // reserved, which is the failure the usable box exists to prevent.
2558
+ const nearX = viewport.left + EDGE_MARGIN;
2559
+ const nearY = viewport.top + EDGE_MARGIN;
2560
+ const left =
2561
+ edgeX === "left"
2562
+ ? nearX
2563
+ : Math.max(nearX, viewport.left + viewport.width - EDGE_MARGIN - box.width);
2564
+ const top =
2565
+ edgeY === "top"
2566
+ ? nearY
2567
+ : Math.max(nearY, viewport.top + viewport.height - EDGE_MARGIN - box.height);
2568
+ const host = { left, top, right: left + box.width, bottom: top + box.height };
2569
+ // Both axes measured, rather than one read twice: a host can restyle the
2570
+ // launcher as a pill, and squaring it here would put it off the corner.
2571
+ const launcherWidth = this.#launcher.offsetWidth;
2572
+ const launcherHeight = this.#launcher.offsetHeight;
2573
+ this.#placePanelAndLauncher(host, {
2574
+ left: edgeX === "left" ? host.left : host.right - launcherWidth,
2575
+ top: edgeY === "top" ? host.top : host.bottom - launcherHeight,
2576
+ width: launcherWidth,
2577
+ height: launcherHeight,
2578
+ });
2579
+ this.#storeLauncherPosition();
2580
+ if (restore !== null) {
2581
+ this.#announceSurfaceChange(this.#strings.chatMoved, restore);
2582
+ }
2583
+ return true;
2584
+ }
2585
+
2586
+ /**
2587
+ * Snapshot the panel's stated position, and return a function that puts it
2588
+ * back.
2589
+ *
2590
+ * Both insets and the expand corner, because they are one decision: the
2591
+ * corner is what the panel grows from, so restoring a position without it
2592
+ * puts the box back and animates it out of the wrong side. Absent values are
2593
+ * captured as absent and removed on the way back, rather than written as
2594
+ * empty strings that would outrank the placement.
2595
+ */
2596
+ #captureGeometry(): () => void {
2597
+ const inset = this.style.getPropertyValue("--ag-ui-inset");
2598
+ const launcherInset = this.style.getPropertyValue("--ag-ui-launcher-inset");
2599
+ const corner = this.getAttribute("data-expand-corner");
2600
+ const launcherPos = this.#launcherPos;
2601
+ const panelPos = this.#panelPos;
2602
+ const expandCorner = this.#expandCorner;
2603
+ return () => {
2604
+ const put = (name: string, value: string): void => {
2605
+ if (value === "") {
2606
+ this.style.removeProperty(name);
2607
+ } else {
2608
+ this.style.setProperty(name, value);
2609
+ }
2610
+ };
2611
+ put("--ag-ui-inset", inset);
2612
+ put("--ag-ui-launcher-inset", launcherInset);
2613
+ if (corner === null) {
2614
+ this.removeAttribute("data-expand-corner");
2615
+ } else {
2616
+ this.setAttribute("data-expand-corner", corner);
2617
+ }
2618
+ this.#launcherPos = launcherPos;
2619
+ this.#panelPos = panelPos;
2620
+ this.#expandCorner = expandCorner;
2621
+ // Erased rather than rewritten when there was nothing to go back to.
2622
+ // #storeLauncherPosition returns early for a null position, which would
2623
+ // leave the move this is undoing sitting in storage -- and since that
2624
+ // store outlives the tab, the next resize or reload would quietly put
2625
+ // the panel back in the corner the user had just rejected.
2626
+ if (launcherPos === null) {
2627
+ this.#clearPreference(LAUNCHER_KEY);
2628
+ } else {
2629
+ this.#storeLauncherPosition();
2630
+ }
2631
+ this.#syncResizeAnchor();
2632
+ };
2633
+ }
2634
+
2214
2635
  /** Flip the collapsed state. Bound to the built-in header toggle. */
2215
2636
  toggleCollapsed(): void {
2216
2637
  this.setCollapsed(!this.collapsed);
@@ -2225,7 +2646,7 @@ export class AgUiChat extends HTMLElement {
2225
2646
  toggleTheme(): void {
2226
2647
  const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
2227
2648
  this.setAttribute("theme", next);
2228
- writeStoredItem(this.#storageKey(THEME_KEY), next);
2649
+ this.#writePreference(THEME_KEY, next);
2229
2650
  this.#syncThemeGlyph();
2230
2651
  }
2231
2652
 
@@ -2356,6 +2777,13 @@ export class AgUiChat extends HTMLElement {
2356
2777
  if (axis === "none") {
2357
2778
  return;
2358
2779
  }
2780
+ // The placement's max-width and max-height are left alone, which means a
2781
+ // grip pushed against the edge the placement is *not* anchored to stops one
2782
+ // gutter short of the screen. Moving the cap with the size fixes that and
2783
+ // shifts several resting sizes by a pixel or two, because the cap and the
2784
+ // size are not measured from the same box -- not worth the churn for a
2785
+ // symmetry nobody has asked for. The limit that matters, staying inside
2786
+ // what the host left free, is enforced above.
2359
2787
  if (size.width !== undefined) {
2360
2788
  this.style.setProperty("--ag-ui-width", `${size.width}px`);
2361
2789
  }
@@ -2393,9 +2821,113 @@ export class AgUiChat extends HTMLElement {
2393
2821
  );
2394
2822
  }
2395
2823
 
2396
- /** The viewport the launcher and the panel both have to fit inside. */
2397
- #viewport(): Extent {
2398
- return { width: window.innerWidth, height: window.innerHeight };
2824
+ /**
2825
+ * The viewport the launcher and the panel both have to fit inside.
2826
+ *
2827
+ * The *visual* viewport, not the layout one, because they come apart exactly
2828
+ * when this matters. An on-screen keyboard shrinks the visual viewport and
2829
+ * leaves the layout viewport alone, so clamping against `innerHeight` parks
2830
+ * the launcher behind the keyboard and decides which corner to open into
2831
+ * using space that is not on the screen. Pinch-zoom does the same on both
2832
+ * axes.
2833
+ *
2834
+ * Falls back where the API is absent, which keeps this working in the
2835
+ * happy-dom project as well as in an old browser.
2836
+ */
2837
+ #viewport(): ViewportBox {
2838
+ const visual = window.visualViewport;
2839
+ const width = visual?.width ?? window.innerWidth;
2840
+ const height = visual?.height ?? window.innerHeight;
2841
+ // And minus whatever the host reserved for its own chrome. Without this a
2842
+ // panel is clamped against the whole screen and settles happily underneath
2843
+ // a sticky header, where it cannot be reached -- and where collapsing it,
2844
+ // the one thing a user tries, hides it completely rather than rescuing it.
2845
+ // Read as padding off the probe, not as custom properties off this
2846
+ // element. `getPropertyValue` on an unregistered custom property returns
2847
+ // the token stream rather than a length, so a host stating `4rem` reserves
2848
+ // four pixels here and sixty-four in the stylesheet, and one stating
2849
+ // `calc(56px + env(safe-area-inset-top))` -- which is the natural spelling
2850
+ // of what the token's own documentation recommends -- parses as NaN and
2851
+ // takes the panel's whole inset down with it.
2852
+ const style = getComputedStyle(this.#viewportProbe);
2853
+ const edge = (name: string): number => {
2854
+ const value = Number.parseFloat(style.getPropertyValue(name));
2855
+ // A detached or not-yet-rendered probe resolves to nothing at all, and
2856
+ // reserving NaN is worse than reserving zero in every case.
2857
+ return Number.isFinite(value) ? value : 0;
2858
+ };
2859
+ const left = edge("padding-left");
2860
+ const top = edge("padding-top");
2861
+ return {
2862
+ left,
2863
+ top,
2864
+ width: Math.max(0, width - left - edge("padding-right")),
2865
+ height: Math.max(0, height - top - edge("padding-bottom")),
2866
+ };
2867
+ }
2868
+
2869
+ /**
2870
+ * The whole viewport, before anything the host reserved is taken out of it.
2871
+ *
2872
+ * Distinct from {@link #viewport} on purpose, and the two must not be
2873
+ * swapped. The usable box decides where the widget may rest; this is what a
2874
+ * CSS `inset` on a fixed element is measured from, because that is what the
2875
+ * browser measures it from.
2876
+ */
2877
+ #screen(): Extent {
2878
+ // The *layout* viewport, and `clientWidth`/`clientHeight` rather than
2879
+ // `innerWidth`/`innerHeight`, because those two disagree by the width of a
2880
+ // classic scrollbar and it is the smaller one a fixed element is laid out
2881
+ // against. Reading the visual viewport here would be the same mistake one
2882
+ // level up as clamping against the whole screen was one level down: a
2883
+ // keyboard shrinks the visual viewport without moving the box CSS measures
2884
+ // an inset from, so a panel the clamp had just held inside the visible
2885
+ // band would be written back out behind the keyboard.
2886
+ //
2887
+ // The zero checks are for a detached or not-yet-laid-out document, where
2888
+ // `clientWidth` is 0 and no viewport ever is.
2889
+ const root = document.documentElement;
2890
+ return {
2891
+ width: root.clientWidth || window.innerWidth,
2892
+ height: root.clientHeight || window.innerHeight,
2893
+ };
2894
+ }
2895
+
2896
+ /**
2897
+ * Publish the measured viewport height so the stylesheet can size a
2898
+ * full-bleed panel to what the user can see.
2899
+ *
2900
+ * No CSS length carries this. An on-screen keyboard has no effect on any
2901
+ * viewport-percentage unit, so a panel sized from `100dvh` puts its composer
2902
+ * behind the keyboard being typed into. Written inline, and read through a
2903
+ * token the host's own `--ag-ui-viewport-height` still outranks.
2904
+ *
2905
+ * Removed rather than frozen when the two viewports agree, so a desktop that
2906
+ * never diverges carries no inline override at all and the declared fallback
2907
+ * stays in charge.
2908
+ */
2909
+ #publishVisualViewport(): void {
2910
+ const visual = window.visualViewport;
2911
+ if (visual === null || visual === undefined) {
2912
+ return;
2913
+ }
2914
+ if (Math.abs(visual.height - window.innerHeight) < 1) {
2915
+ this.style.removeProperty("--ag-ui-visual-viewport-height");
2916
+ this.style.removeProperty("--ag-ui-visual-viewport-inset-bottom");
2917
+ return;
2918
+ }
2919
+ this.style.setProperty("--ag-ui-visual-viewport-height", `${Math.round(visual.height)}px`);
2920
+ // What is hidden below the visible area, which is where a keyboard is. A
2921
+ // shorter panel does not help anything anchored to the bottom: a floating
2922
+ // widget is positioned against the layout viewport, so its bottom edge and
2923
+ // the launcher at that corner stay behind the keyboard until this lifts
2924
+ // them. Never negative -- a visual viewport panned up past the layout one
2925
+ // would otherwise pull the panel down off the screen.
2926
+ const hidden = window.innerHeight - visual.height - visual.offsetTop;
2927
+ this.style.setProperty(
2928
+ "--ag-ui-visual-viewport-inset-bottom",
2929
+ `${Math.max(0, Math.round(hidden))}px`,
2930
+ );
2399
2931
  }
2400
2932
 
2401
2933
  /**
@@ -2444,6 +2976,10 @@ export class AgUiChat extends HTMLElement {
2444
2976
  return;
2445
2977
  }
2446
2978
  this.#launcherPos = at;
2979
+ // Dropping the bubble hands the panel's position back to the placement.
2980
+ // Keeping a stated one would pin the panel where it was dragged and leave
2981
+ // the launcher deriving nothing, which is the gesture doing half its job.
2982
+ this.#panelPos = null;
2447
2983
  // The host box keeps its expanded size while collapsed, so its own rect is
2448
2984
  // the panel's size in either state and needs no separate bookkeeping.
2449
2985
  const panel = this.getBoundingClientRect();
@@ -2451,6 +2987,7 @@ export class AgUiChat extends HTMLElement {
2451
2987
  this.#launcherBox(),
2452
2988
  { width: panel.width, height: panel.height },
2453
2989
  this.#viewport(),
2990
+ this.#screen(),
2454
2991
  );
2455
2992
  this.style.setProperty("--ag-ui-inset", placement.hostInset);
2456
2993
  this.style.setProperty("--ag-ui-launcher-inset", placement.launcherInset);
@@ -2471,13 +3008,184 @@ export class AgUiChat extends HTMLElement {
2471
3008
  this.#storeLauncherPosition();
2472
3009
  }
2473
3010
 
2474
- /** Write the current launcher position, if this element owns one. */
3011
+ /**
3012
+ * Move the panel live during a header drag, without persisting.
3013
+ *
3014
+ * Only the host box is written, and that is the whole trick: the launcher is
3015
+ * positioned *inside* that box, so leaving its own inset alone carries it
3016
+ * along by exactly the distance the panel travelled -- which is what a person
3017
+ * dragging a window expects of the thing it collapses into. Placing it on the
3018
+ * panel's pinned corner instead, as an earlier version did, sent it leaping
3019
+ * across the panel the moment the drag re-picked that corner.
3020
+ *
3021
+ * The corner is therefore held for the length of the gesture. Both insets are
3022
+ * measured from it, and rewriting one of them from a new corner while the
3023
+ * other still names the old one would move the launcher for no reason.
3024
+ */
3025
+ #movePanel(box: PanelRect, from: PanelRect): { held: PanelRect; launcher: LauncherBox | null } {
3026
+ if (!this.#launcherDraggable()) {
3027
+ return { held: box, launcher: null };
3028
+ }
3029
+ // Where the launcher rests, recorded before the first move writes anything.
3030
+ // From here on the DOM shows it mid-gesture, so this is the last moment it
3031
+ // can be read rather than derived.
3032
+ if (this.#launcherPos === null) {
3033
+ const resting = this.#launcherBox();
3034
+ this.#launcherPos = { left: resting.left, top: resting.top };
3035
+ }
3036
+ // The launcher as it was when the gesture began. Held for the whole drag:
3037
+ // every move measures from here, so the two halves cannot drift apart.
3038
+ const start = this.#launcherPos;
3039
+ // The screen-edge bound, not the resting gutter. The 24px margin is where
3040
+ // a placement rests one, not a rule about where a person may put it, and
3041
+ // enforcing it against a drag is what made the panel feel stuck short of
3042
+ // every edge on all four sides at once. Zero was the correction and it
3043
+ // went too far the other way: it welded the panel to the boundary while
3044
+ // the launcher -- same shadow, same rounded edge -- was held 8px off it,
3045
+ // and it disagreed with the restore below, so a panel dragged flush leapt
3046
+ // inward the next time the viewport changed.
3047
+ const held = clampPanel(box, this.#viewport(), SCREEN_EDGE_MARGIN);
3048
+ const corner = this.#expandCorner ?? this.#anchor;
3049
+ // The usable box decides where the panel may rest, above; the screen is
3050
+ // what these insets are measured from, because that is what the browser
3051
+ // measures a fixed element's inset from. Using the usable box here made a
3052
+ // right or bottom short by whatever the host had reserved, and the panel
3053
+ // jumped by that much the first time a gesture wrote one.
3054
+ const screen = this.#screen();
3055
+ this.style.setProperty(
3056
+ "--ag-ui-inset",
3057
+ [
3058
+ corner.y === "top" ? `${Math.round(held.top)}px` : "auto",
3059
+ corner.x === "right" ? `${Math.round(screen.width - held.right)}px` : "auto",
3060
+ corner.y === "bottom" ? `${Math.round(screen.height - held.bottom)}px` : "auto",
3061
+ corner.x === "left" ? `${Math.round(held.left)}px` : "auto",
3062
+ ].join(" "),
3063
+ );
3064
+ this.#panelPos = { left: held.left, top: held.top };
3065
+
3066
+ // Where the launcher has ended up, derived rather than read. During a
3067
+ // header drag it rides inside the host box with its own inset untouched,
3068
+ // so the DOM shows it moving while `#launcherPos` still holds where it
3069
+ // started -- reading it back mid-gesture returns the stale value, and
3070
+ // adding the panel's travel to that a second time on release is a jump.
3071
+ // Measured from the box the press started on, so a long drag cannot
3072
+ // accumulate the rounding each move writes.
3073
+ const carried = {
3074
+ ...this.#launcherBox(),
3075
+ left: start.left + (held.left - from.left),
3076
+ top: start.top + (held.top - from.top),
3077
+ };
3078
+ // A bubble carried into an edge the host reserved is one nobody can press.
3079
+ // Clamping it live rather than at the end is what makes releasing the drag
3080
+ // change nothing: leaving it until then parked it under a nav bar for the
3081
+ // whole gesture and hopped it out on pointerup.
3082
+ const launcher = { ...carried, ...clampLauncher(carried, this.#viewport()) };
3083
+ this.style.setProperty(
3084
+ "--ag-ui-launcher-inset",
3085
+ placeWidget(held, launcher, corner, this.#screen()).launcherInset,
3086
+ );
3087
+ return { held, launcher };
3088
+ }
3089
+
3090
+ /**
3091
+ * Finish a header drag: settle where both halves ended up, and remember it.
3092
+ *
3093
+ * The launcher travels the distance the panel actually travelled, which is
3094
+ * the clamped distance rather than the pointer's -- a panel held against the
3095
+ * viewport margin stops, and so does the bubble attached to it. Measured from
3096
+ * the box the press started on, so a long drag cannot accumulate the rounding
3097
+ * each move writes into the inset.
3098
+ *
3099
+ * Only now is the corner re-picked, from where the launcher has ended up, so
3100
+ * the panel opens into clear space next time. Re-picking it moves nothing:
3101
+ * both insets are rewritten from positions that are already decided.
3102
+ */
3103
+ #commitPanel(box: PanelRect, from: PanelRect): void {
3104
+ // Exactly what the last move applied, rather than the same sum computed
3105
+ // again. Recomputing it is how the two came apart: releasing the drag
3106
+ // moved the bubble by the panel's whole travel a second time.
3107
+ const { held, launcher } = this.#movePanel(box, from);
3108
+ if (launcher === null) {
3109
+ return;
3110
+ }
3111
+ this.#placePanelAndLauncher(held, launcher);
3112
+ this.#storeLauncherPosition();
3113
+ }
3114
+
3115
+ /**
3116
+ * Write both insets for a panel and launcher that are already positioned,
3117
+ * re-picking the corner they are measured from.
3118
+ */
3119
+ #placePanelAndLauncher(host: PanelRect, launcher: LauncherBox): void {
3120
+ const viewport = this.#viewport();
3121
+ const screen = this.#screen();
3122
+ const size = { width: host.right - host.left, height: host.bottom - host.top };
3123
+ const { corner } = launcherPlacement(launcher, size, viewport, screen);
3124
+ // The screen again, not the usable box: these are CSS insets on a fixed
3125
+ // element and the browser measures them from the real edges.
3126
+ const insets = placeWidget(host, launcher, corner, screen);
3127
+ this.style.setProperty("--ag-ui-inset", insets.hostInset);
3128
+ this.style.setProperty("--ag-ui-launcher-inset", insets.launcherInset);
3129
+ this.#launcherPos = { left: launcher.left, top: launcher.top };
3130
+ this.#panelPos = { left: host.left, top: host.top };
3131
+ this.#expandCorner = corner;
3132
+ this.setAttribute("data-expand-corner", `${corner.y}-${corner.x}`);
3133
+ this.#syncResizeAnchor();
3134
+ }
3135
+
3136
+ /**
3137
+ * Re-apply a panel position the user stated, against the current viewport.
3138
+ *
3139
+ * The launcher keeps its offset from the panel through the clamp -- it was
3140
+ * put where it is relative to the panel, and a viewport that has since shrunk
3141
+ * is no reason to move one without the other -- and is then held on screen in
3142
+ * its own right.
3143
+ */
3144
+ #restorePanelPosition(at: { readonly left: number; readonly top: number }): void {
3145
+ if (!this.#launcherDraggable()) {
3146
+ return;
3147
+ }
3148
+ const rect = this.getBoundingClientRect();
3149
+ // The same bound the drag itself used. Taking the default here instead is
3150
+ // what made a panel dragged to an edge jump a whole resting gutter inward
3151
+ // on the next resize, reload or expand -- re-placing a position the user
3152
+ // had stated, against a limit they had never been shown.
3153
+ const held = clampPanel(
3154
+ { left: at.left, top: at.top, right: at.left + rect.width, bottom: at.top + rect.height },
3155
+ this.#viewport(),
3156
+ SCREEN_EDGE_MARGIN,
3157
+ );
3158
+ const launcher = this.#launcherBox();
3159
+ const carried = {
3160
+ ...launcher,
3161
+ left: launcher.left + (held.left - at.left),
3162
+ top: launcher.top + (held.top - at.top),
3163
+ };
3164
+ this.#placePanelAndLauncher(held, {
3165
+ ...carried,
3166
+ ...clampLauncher(carried, this.#viewport()),
3167
+ });
3168
+ }
3169
+
3170
+ /**
3171
+ * Write the current position, if this element owns one.
3172
+ *
3173
+ * The panel's own position rides along only when the user stated it, because
3174
+ * its presence is what tells a restore which of the two gestures to honour:
3175
+ * with it, the panel goes back where it was put; without it, the panel is
3176
+ * re-derived from the launcher and opens into whatever room the viewport has
3177
+ * now.
3178
+ */
2475
3179
  #storeLauncherPosition(): void {
2476
3180
  const position = this.#launcherPos;
2477
3181
  if (position === null) {
2478
3182
  return;
2479
3183
  }
2480
- writeStoredItem(this.#storageKey(LAUNCHER_KEY), JSON.stringify(position));
3184
+ const panel = this.#panelPos;
3185
+ this.#writePreference(
3186
+ LAUNCHER_KEY,
3187
+ JSON.stringify(panel === null ? position : { ...position, panel }),
3188
+ );
2481
3189
  }
2482
3190
 
2483
3191
  /**
@@ -2487,19 +3195,33 @@ export class AgUiChat extends HTMLElement {
2487
3195
  * unreachable -- it is the only way back to a collapsed conversation.
2488
3196
  */
2489
3197
  #restoreLauncherPosition(): void {
2490
- const stored = this.#launcherPos ?? this.#readLauncherPosition();
2491
- if (stored === null) {
3198
+ const stored = this.#readLauncherPosition();
3199
+ const launcher = this.#launcherPos ?? stored;
3200
+ if (launcher === null) {
3201
+ return;
3202
+ }
3203
+ const panel = this.#panelPos ?? stored?.panel ?? null;
3204
+ if (panel !== null) {
3205
+ // Stated rather than derived: the launcher is only read here to keep the
3206
+ // offset the two were left with, so it has to be seeded before the panel
3207
+ // is placed around it.
3208
+ this.#launcherPos = { left: launcher.left, top: launcher.top };
3209
+ this.#restorePanelPosition(panel);
2492
3210
  return;
2493
3211
  }
2494
3212
  const box = this.#launcherBox();
2495
3213
  this.#applyLauncherPlacement(
2496
- clampLauncher({ ...box, left: stored.left, top: stored.top }, this.#viewport()),
3214
+ clampLauncher({ ...box, left: launcher.left, top: launcher.top }, this.#viewport()),
2497
3215
  );
2498
3216
  }
2499
3217
 
2500
- /** The persisted launcher position for this instance, or null. */
2501
- #readLauncherPosition(): { readonly left: number; readonly top: number } | null {
2502
- const raw = this.#readScopedItem(LAUNCHER_KEY);
3218
+ /** The persisted position for this instance, or null. */
3219
+ #readLauncherPosition(): {
3220
+ readonly left: number;
3221
+ readonly top: number;
3222
+ readonly panel?: { readonly left: number; readonly top: number };
3223
+ } | null {
3224
+ const raw = this.#readPreference(LAUNCHER_KEY);
2503
3225
  if (raw === null) {
2504
3226
  return null;
2505
3227
  }
@@ -2508,8 +3230,15 @@ export class AgUiChat extends HTMLElement {
2508
3230
  if (typeof parsed !== "object" || parsed === null) {
2509
3231
  return null;
2510
3232
  }
2511
- const { left, top } = parsed as { left?: unknown; top?: unknown };
2512
- return typeof left === "number" && typeof top === "number" ? { left, top } : null;
3233
+ const { left, top, panel } = parsed as { left?: unknown; top?: unknown; panel?: unknown };
3234
+ if (typeof left !== "number" || typeof top !== "number") {
3235
+ return null;
3236
+ }
3237
+ // A record written before the panel could be dragged has no panel half,
3238
+ // and one written by a launcher drag never will -- both restore by
3239
+ // deriving the panel, which is what they meant.
3240
+ const at = asPoint(panel);
3241
+ return at === null ? { left, top } : { left, top, panel: at };
2513
3242
  } catch {
2514
3243
  // A corrupt entry is not worth failing a mount over; fall back to the
2515
3244
  // placement's own corner.
@@ -2527,6 +3256,7 @@ export class AgUiChat extends HTMLElement {
2527
3256
  return;
2528
3257
  }
2529
3258
  this.#launcherPos = null;
3259
+ this.#panelPos = null;
2530
3260
  this.#expandCorner = null;
2531
3261
  this.style.removeProperty("--ag-ui-inset");
2532
3262
  this.style.removeProperty("--ag-ui-launcher-inset");
@@ -2547,20 +3277,24 @@ export class AgUiChat extends HTMLElement {
2547
3277
  * host positioning the panel with its own rule keeps that rule until someone
2548
3278
  * drags the edge it was holding.
2549
3279
  */
2550
- #applyResize(grip: ResizeGrip, box: PanelRect): void {
3280
+ #applyResize(grip: ResizeGrip, box: PanelRect): PanelRect {
3281
+ box = this.#withinViewport(box);
2551
3282
  this.#applySize({ width: box.right - box.left, height: box.bottom - box.top });
2552
3283
  if (grip.x !== this.#anchor.x && grip.y !== this.#anchor.y) {
2553
- return;
3284
+ return box;
2554
3285
  }
2555
- const viewport = this.#viewport();
3286
+ // The whole screen, not the usable box: a CSS inset on a fixed element is
3287
+ // measured from the real edges, so expressing a right or bottom against a
3288
+ // box the host has inset comes out short by exactly that inset.
3289
+ const screen = this.#screen();
2556
3290
  const anchor = this.#anchor;
2557
3291
  const side = (value: number): string => `${Math.round(value)}px`;
2558
3292
  this.style.setProperty(
2559
3293
  "--ag-ui-inset",
2560
3294
  [
2561
3295
  anchor.y === "top" ? side(box.top) : "auto",
2562
- anchor.x === "right" ? side(viewport.width - box.right) : "auto",
2563
- anchor.y === "bottom" ? side(viewport.height - box.bottom) : "auto",
3296
+ anchor.x === "right" ? side(screen.width - box.right) : "auto",
3297
+ anchor.y === "bottom" ? side(screen.height - box.bottom) : "auto",
2564
3298
  anchor.x === "left" ? side(box.left) : "auto",
2565
3299
  ].join(" "),
2566
3300
  );
@@ -2575,12 +3309,21 @@ export class AgUiChat extends HTMLElement {
2575
3309
  top: anchor.y === "top" ? box.top : box.bottom - size,
2576
3310
  };
2577
3311
  }
3312
+ // A stated panel position is a claim about this box, so it moves with it.
3313
+ if (this.#panelPos !== null) {
3314
+ this.#panelPos = { left: box.left, top: box.top };
3315
+ }
3316
+ return box;
2578
3317
  }
2579
3318
 
2580
3319
  /** Finish a resize: keep the box, remember it, and re-read the pinned edges. */
2581
3320
  #commitResize(grip: ResizeGrip, box: PanelRect): void {
2582
- this.#applyResize(grip, box);
2583
- this.#persistSize({ width: box.right - box.left, height: box.bottom - box.top });
3321
+ // The bounded box, not the one the pointer asked for. Persisting the raw
3322
+ // one would store a size the panel never had and restore it on the next
3323
+ // mount, which is the same disagreement between apply and commit that made
3324
+ // the header drag jump on release.
3325
+ const held = this.#applyResize(grip, box);
3326
+ this.#persistSize({ width: held.right - held.left, height: held.bottom - held.top });
2584
3327
  this.#storeLauncherPosition();
2585
3328
  // Re-stamp after the drag: a host whose layout changed underneath us would
2586
3329
  // otherwise keep the tab-reachable grip in the old corner, which reads as
@@ -2614,12 +3357,12 @@ export class AgUiChat extends HTMLElement {
2614
3357
  /** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
2615
3358
  #persistSize(size: ResizeSize): void {
2616
3359
  const stored = { ...this.#readSize(), ...size };
2617
- writeStoredItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
3360
+ this.#writePreference(SIZE_KEY, JSON.stringify(stored));
2618
3361
  }
2619
3362
 
2620
3363
  /** The persisted size for this instance, or an empty record. */
2621
3364
  #readSize(): ResizeSize {
2622
- const raw = this.#readScopedItem(SIZE_KEY);
3365
+ const raw = this.#readPreference(SIZE_KEY);
2623
3366
  if (raw === null) {
2624
3367
  return {};
2625
3368
  }
@@ -2775,10 +3518,91 @@ export class AgUiChat extends HTMLElement {
2775
3518
  return sessionStorage.getItem(base);
2776
3519
  }
2777
3520
 
3521
+ /**
3522
+ * Read a layout preference: where the widget sits, how big it is, which
3523
+ * theme it wears.
3524
+ *
3525
+ * These live in `localStorage` rather than beside the transcript, because a
3526
+ * layout preference is not a conversation. The transcript is deliberately
3527
+ * per-tab -- two tabs are two conversations, and closing the tab ends it --
3528
+ * and everything else inherited that scoping without earning it. A user who
3529
+ * dragged the panel clear of their own UI did it again in the next tab, and
3530
+ * again after every restart.
3531
+ *
3532
+ * Whether the widget is *currently open* stays per-tab with the transcript.
3533
+ * It is a statement about this tab rather than a preference: carrying it
3534
+ * across would pop the panel open on every new tab because it was opened
3535
+ * once, somewhere else.
3536
+ *
3537
+ * Falls back to the session value it used to be written to, so an existing
3538
+ * position survives the upgrade rather than resetting once.
3539
+ */
3540
+ #readPreference(base: string): string | null {
3541
+ try {
3542
+ const stored = localStorage.getItem(this.#storageKey(base));
3543
+ if (stored !== null) {
3544
+ return stored;
3545
+ }
3546
+ } catch {
3547
+ // Fall through to the per-tab copy below.
3548
+ }
3549
+ return this.#readScopedItem(base);
3550
+ }
3551
+
3552
+ /**
3553
+ * Persist a layout preference as durably as this browser allows: to
3554
+ * `localStorage` so it outlives the tab, and to the per-tab store as well.
3555
+ *
3556
+ * The second write is not redundancy for its own sake. A privacy mode can
3557
+ * deny `localStorage` while allowing `sessionStorage`, and losing the
3558
+ * durable copy should degrade to the per-tab behaviour this replaced rather
3559
+ * than to no persistence at all. The read above prefers the durable copy, so
3560
+ * a tab that has both cannot be shadowed by its own stale one.
3561
+ *
3562
+ * Neither write is worth an exception. Losing where the panel sat is not
3563
+ * worth a warning either -- unlike the transcript, which says so once,
3564
+ * because losing that loses the conversation on the next reload.
3565
+ */
3566
+ #writePreference(base: string, value: string): void {
3567
+ const key = this.#storageKey(base);
3568
+ try {
3569
+ localStorage.setItem(key, value);
3570
+ } catch {
3571
+ // Quota, or a store that denies writes.
3572
+ }
3573
+ writeStoredItem(key, value);
3574
+ }
3575
+
3576
+ /**
3577
+ * Drop a layout preference from both stores.
3578
+ *
3579
+ * The mirror of {@link AgUiChat.#writePreference}, and it has to clear both
3580
+ * for the same reason that writes both: leaving either copy behind means the
3581
+ * value comes back on the next read.
3582
+ */
3583
+ #clearPreference(base: string): void {
3584
+ const key = this.#storageKey(base);
3585
+ try {
3586
+ localStorage.removeItem(key);
3587
+ } catch {
3588
+ // A store that denies access; the per-tab copy below still goes.
3589
+ }
3590
+ try {
3591
+ sessionStorage.removeItem(key);
3592
+ } catch {
3593
+ // Nothing left to do: the value was never persisted in the first place.
3594
+ }
3595
+ }
3596
+
2778
3597
  /** Reflect the current theme on the toggle: show the destination's glyph. */
2779
3598
  #syncThemeGlyph(): void {
2780
3599
  const dark = this.getAttribute("theme") === "dark";
2781
- this.#themeToggle.textContent = dark ? "☀️" : "🌙";
3600
+ // Replaced wholesale rather than toggling a class: the two marks are
3601
+ // different paths, not one path in two states, and the slot has to keep
3602
+ // working so a host can still supply its own.
3603
+ this.#themeToggle.replaceChildren(
3604
+ this.#iconElement("theme", "theme-icon", dark ? ICON_SUN : ICON_MOON, null),
3605
+ );
2782
3606
  }
2783
3607
 
2784
3608
  /**
@@ -2797,7 +3621,42 @@ export class AgUiChat extends HTMLElement {
2797
3621
  // open *underneath* a popover still floating over it.
2798
3622
  this.#checkpoints.close();
2799
3623
  void this.#refreshDrawer();
3624
+ this.#drawer.setModal(!this.#threadsDock());
2800
3625
  this.#drawer.open();
3626
+ this.#syncThreadsState();
3627
+ }
3628
+
3629
+ /**
3630
+ * Whether the conversation list docks beside the transcript rather than
3631
+ * covering it.
3632
+ *
3633
+ * Only the full-page placement, and only where there is room. A dedicated
3634
+ * route is the one surface with width to spare -- everywhere else the panel
3635
+ * is a few hundred pixels wide, and a list docked into that leaves a column
3636
+ * of transcript too narrow to read. The width is the panel's own rather than
3637
+ * the window's, because an embedded host can give a full-page-sized box to
3638
+ * something that is not a page.
3639
+ */
3640
+ #threadsDock(): boolean {
3641
+ return (
3642
+ this.getAttribute("placement") === "page" &&
3643
+ this.getBoundingClientRect().width >= THREADS_DOCK_MIN_WIDTH
3644
+ );
3645
+ }
3646
+
3647
+ /**
3648
+ * Stamp whether the list is showing, and how.
3649
+ *
3650
+ * On the host rather than inside the shell because the transcript has to move
3651
+ * over for a docked list, and the drawer is the last child of the panel -- CSS
3652
+ * cannot select backwards from it to the rows it needs to shift.
3653
+ */
3654
+ #syncThreadsState(): void {
3655
+ if (this.#drawer.isOpen() && this.#threadsDock()) {
3656
+ this.setAttribute("data-threads-docked", "");
3657
+ } else {
3658
+ this.removeAttribute("data-threads-docked");
3659
+ }
2801
3660
  }
2802
3661
 
2803
3662
  /**
@@ -2814,6 +3673,17 @@ export class AgUiChat extends HTMLElement {
2814
3673
  this.#checkpoints.open();
2815
3674
  }
2816
3675
 
3676
+ /**
3677
+ * Close the conversation list, if it is open.
3678
+ *
3679
+ * No state sync here: the drawer reports every close through `onVisibility`,
3680
+ * including the four that never reach this method, and doing it twice would
3681
+ * be a second place to keep right.
3682
+ */
3683
+ closeThreads(): void {
3684
+ this.#drawer.close();
3685
+ }
3686
+
2817
3687
  /** Close the checkpoints panel, if it is open. */
2818
3688
  closeCheckpoints(): void {
2819
3689
  this.#checkpoints.close();
@@ -2862,6 +3732,13 @@ export class AgUiChat extends HTMLElement {
2862
3732
  this.#client = null;
2863
3733
  this.#clearTranscript();
2864
3734
  this.#initialMessages = [];
3735
+ // The composer's own history goes with the conversation it was typed
3736
+ // into. The path that makes this more than tidiness is the `user-key`
3737
+ // rescope, which purges storage and wipes the transcript precisely so the
3738
+ // previous principal's words are not visible to the next one -- and would
3739
+ // otherwise leave every one of them a single ArrowUp away.
3740
+ this.#sentDrafts.length = 0;
3741
+ this.#recallIndex = null;
2865
3742
  }
2866
3743
 
2867
3744
  /**
@@ -3289,6 +4166,16 @@ export class AgUiChat extends HTMLElement {
3289
4166
  controls.append(collapse);
3290
4167
  header.append(title, headerActions, controls);
3291
4168
 
4169
+ // A panel is a window and a header is its title bar. Only while open: a
4170
+ // collapsed widget has no header on screen, and the launcher is the handle
4171
+ // then.
4172
+ enablePanelDrag(header, {
4173
+ enabled: () => !this.collapsed && this.#launcherDraggable(),
4174
+ rect: () => this.getBoundingClientRect(),
4175
+ apply: (box, from) => this.#movePanel(box, from),
4176
+ commit: (box, from) => this.#commitPanel(box, from),
4177
+ });
4178
+
3292
4179
  this.#messages.className = "messages";
3293
4180
  this.#messages.setAttribute("part", "messages");
3294
4181
  this.#messages.setAttribute("role", "log");
@@ -3359,7 +4246,19 @@ export class AgUiChat extends HTMLElement {
3359
4246
  this.#emptyWrap.setAttribute("part", "empty");
3360
4247
  const emptySlot = document.createElement("slot");
3361
4248
  emptySlot.name = "empty";
4249
+ // Fallback content, so a host that slots its own gets exactly that and
4250
+ // nothing of ours: the starters live *inside* the slot rather than beside
4251
+ // it, which is the difference between an offer and an imposition.
4252
+ const starters = this.#starterChips();
4253
+ if (starters !== null) {
4254
+ emptySlot.append(starters);
4255
+ }
3362
4256
  this.#emptyWrap.append(emptySlot);
4257
+ this.#queuedRow.className = "queued";
4258
+ this.#queuedRow.setAttribute("part", "queued");
4259
+ this.#queuedRow.setAttribute("role", "group");
4260
+ this.#queuedRow.setAttribute("aria-label", this.#strings.queued);
4261
+ this.#queuedRow.hidden = true;
3363
4262
  this.#messages.append(this.#emptyWrap);
3364
4263
 
3365
4264
  const inputRow = document.createElement("div");
@@ -3454,6 +4353,7 @@ export class AgUiChat extends HTMLElement {
3454
4353
  this.#skillsMenu.palette,
3455
4354
  this.#skillsMenu.chips,
3456
4355
  this.#skillHint,
4356
+ this.#queuedRow,
3457
4357
  this.#attachSlot,
3458
4358
  inputRow,
3459
4359
  footer,
@@ -3493,8 +4393,18 @@ export class AgUiChat extends HTMLElement {
3493
4393
  // decoration to a screen reader rather than a second, context-free number.
3494
4394
  this.#badge.setAttribute("aria-hidden", "true");
3495
4395
  this.#badge.hidden = true;
4396
+ // Only the edge rail shows this. A full-height column carrying one small
4397
+ // icon reads as a coloured stripe rather than a way back into a
4398
+ // conversation -- it is the widest collapsed state there is and the one
4399
+ // that says least about itself. Written here and hidden in CSS everywhere
4400
+ // else, because the launcher is one element shaped by placement.
4401
+ this.#railLabel.className = "rail-label";
4402
+ this.#railLabel.setAttribute("part", "rail-label");
4403
+ this.#railLabel.setAttribute("aria-hidden", "true");
4404
+ this.#railLabel.textContent = this.getAttribute("title-text") ?? this.#strings.title;
3496
4405
  this.#launcher.append(
3497
4406
  this.#iconElement("launcher", "launcher-icon", ICON_LAUNCHER, this.#launcherIconUrl()),
4407
+ this.#railLabel,
3498
4408
  this.#badge,
3499
4409
  );
3500
4410
  this.#launcher.addEventListener("click", () => this.setCollapsed(false));
@@ -3529,7 +4439,9 @@ export class AgUiChat extends HTMLElement {
3529
4439
  }
3530
4440
  this.#focusableGrip();
3531
4441
  this.#adoptStyles();
3532
- this.#root.append(this.#announcer, this.#chat, this.#launcher);
4442
+ this.#viewportProbe.className = "viewport-probe";
4443
+ this.#viewportProbe.setAttribute("aria-hidden", "true");
4444
+ this.#root.append(this.#viewportProbe, this.#announcer, this.#chat, this.#launcher);
3533
4445
  }
3534
4446
 
3535
4447
  /**
@@ -3728,6 +4640,39 @@ export class AgUiChat extends HTMLElement {
3728
4640
  }
3729
4641
 
3730
4642
  /** Hide the empty-state region once the message list holds anything else. */
4643
+ /**
4644
+ * The prompts offered on an empty transcript, from `data-starters`.
4645
+ *
4646
+ * Different from the suggestion chips a run pushes, which are follow-ups to
4647
+ * something already said. These answer the blank-page question instead, and
4648
+ * they are the host's rather than the model's -- only the host knows what its
4649
+ * page is for. Shares the renderer, the count and the length limit, because
4650
+ * two rows of prompt chips that behaved differently would be the harder
4651
+ * thing to explain.
4652
+ *
4653
+ * Read once at connect: it is content for a state the widget is in before
4654
+ * anything happens, and a host that wants it to change has `slot="empty"`.
4655
+ */
4656
+ #starterChips(): HTMLElement | null {
4657
+ const raw = this.getAttribute("data-starters");
4658
+ if (raw === null) {
4659
+ return null;
4660
+ }
4661
+ let parsed: unknown;
4662
+ try {
4663
+ parsed = JSON.parse(raw);
4664
+ } catch {
4665
+ console.warn(
4666
+ "<ag-ui-chat>: data-starters is not valid JSON, so no starters are shown. " +
4667
+ "It takes an array of strings, e.g. data-starters='[\"Summarise this page\"]'.",
4668
+ );
4669
+ return null;
4670
+ }
4671
+ return renderSuggestionChips({ prompts: parsed }, this.#strings, (prompt) => {
4672
+ void this.sendMessage(prompt);
4673
+ });
4674
+ }
4675
+
3731
4676
  #updateEmptyState(): void {
3732
4677
  this.#emptyWrap.hidden = this.#messages.childElementCount > 1;
3733
4678
  }
@@ -3737,6 +4682,10 @@ export class AgUiChat extends HTMLElement {
3737
4682
  this.#skillsMenu.onInput(this.#input.value);
3738
4683
  this.#skillHint.hidden = true;
3739
4684
  this.#autoGrow();
4685
+ // Typing puts the composer back in the user's hands: the next ArrowUp
4686
+ // starts from the newest turn again rather than continuing a walk through
4687
+ // history the user has since edited.
4688
+ this.#recallIndex = null;
3740
4689
  }
3741
4690
 
3742
4691
  #onKeydown(event: KeyboardEvent): void {
@@ -3755,7 +4704,51 @@ export class AgUiChat extends HTMLElement {
3755
4704
  if (event.key === "Enter" && !event.shiftKey) {
3756
4705
  event.preventDefault();
3757
4706
  void this.#submit();
4707
+ return;
3758
4708
  }
4709
+ this.#recallHistory(event);
4710
+ }
4711
+
4712
+ /**
4713
+ * Walk back through what the user has already sent, on the arrow keys.
4714
+ *
4715
+ * Only from an empty composer, and only with the palette closed -- which the
4716
+ * caller has already established, since the palette consumes arrows while it
4717
+ * is open. Both conditions matter: arrows inside text are how you move the
4718
+ * caret, and taking them would break editing to add a shortcut.
4719
+ *
4720
+ * The drafts are the user's own turns in this conversation, newest first,
4721
+ * which is what every shell and every coding agent means by this. Arrowing
4722
+ * forward past the newest empties the composer again rather than sticking on
4723
+ * it, so the way out is the same key that got you in.
4724
+ */
4725
+ #recallHistory(event: KeyboardEvent): void {
4726
+ const back = event.key === "ArrowUp";
4727
+ if ((!back && event.key !== "ArrowDown") || this.#skillsMenu.isOpen()) {
4728
+ return;
4729
+ }
4730
+ const drafts = this.#sentDrafts;
4731
+ if (drafts.length === 0) {
4732
+ return;
4733
+ }
4734
+ // An empty composer is the only safe entry: anything typed is the user's,
4735
+ // and replacing it with a past turn would lose it without asking.
4736
+ if (this.#recallIndex === null && (!back || this.#input.value !== "")) {
4737
+ return;
4738
+ }
4739
+ const next = this.#recallIndex === null ? 0 : this.#recallIndex + (back ? 1 : -1);
4740
+ if (next >= drafts.length) {
4741
+ return;
4742
+ }
4743
+ event.preventDefault();
4744
+ this.#recallIndex = next < 0 ? null : next;
4745
+ // Asserted rather than defaulted: `next` was bounded on both sides two
4746
+ // lines up, so a fallback here would be a branch no test can reach
4747
+ // honestly -- and an unreachable default is worse than an assertion,
4748
+ // because it looks like a case somebody thought about.
4749
+ this.#input.value = next < 0 ? "" : (drafts[next] as string);
4750
+ this.#input.setSelectionRange(this.#input.value.length, this.#input.value.length);
4751
+ this.#autoGrow();
3759
4752
  }
3760
4753
 
3761
4754
  /**
@@ -3765,6 +4758,26 @@ export class AgUiChat extends HTMLElement {
3765
4758
  * observes the disconnect).
3766
4759
  */
3767
4760
  #cancelRun(): void {
4761
+ // Stopping discards what was waiting. Sending messages into a conversation
4762
+ // the user has just stopped is the opposite of what stopping meant, and it
4763
+ // would arrive after they had already turned away.
4764
+ //
4765
+ // Not sending it is not the same as destroying it, though. A queued
4766
+ // message left the composer the moment it was queued, so dropping it here
4767
+ // would take a paragraph the user typed and leave it nowhere -- not on
4768
+ // screen, not in the composer, not recallable. It goes to the front of the
4769
+ // recall history instead, so ArrowUp gets it back. In queue order, which
4770
+ // puts the one typed last first.
4771
+ //
4772
+ // This path is also reached from `disconnectedCallback`, where a DOM move
4773
+ // and a framework re-render both look like a farewell and neither is one.
4774
+ for (const text of this.#queued) {
4775
+ if (this.#sentDrafts[0] !== text) {
4776
+ this.#sentDrafts.unshift(text);
4777
+ }
4778
+ }
4779
+ this.#queued.length = 0;
4780
+ this.#renderQueued();
3768
4781
  this.#confirmAbort?.abort();
3769
4782
  this.#client?.cancel();
3770
4783
  }
@@ -3777,11 +4790,56 @@ export class AgUiChat extends HTMLElement {
3777
4790
  * that it carries no text.
3778
4791
  */
3779
4792
  #setRunning(running: boolean): void {
4793
+ const settled = this.#running && !running;
3780
4794
  this.#running = running;
3781
4795
  const label = running ? this.#strings.stop : this.#strings.send;
3782
4796
  this.#send.title = label;
3783
4797
  this.#send.setAttribute("aria-label", label);
3784
4798
  this.#send.dataset["state"] = running ? "running" : "idle";
4799
+ if (settled) {
4800
+ this.#flushQueued();
4801
+ }
4802
+ }
4803
+
4804
+ /**
4805
+ * Send the next message that was typed while the run was going.
4806
+ *
4807
+ * One at a time, through the same path as anything else: each queued turn
4808
+ * starts a run of its own, and the next is sent when *that* one settles. Any
4809
+ * other shape would be a second sender racing the guard above.
4810
+ */
4811
+ #flushQueued(): void {
4812
+ const next = this.#queued.shift();
4813
+ this.#renderQueued();
4814
+ if (next !== undefined) {
4815
+ void this.sendMessage(next);
4816
+ }
4817
+ }
4818
+
4819
+ /**
4820
+ * Draw what is waiting, as chips that can be taken back.
4821
+ *
4822
+ * Visible and removable, because a message the user typed and cannot see is
4823
+ * a message they will type again -- and one they changed their mind about
4824
+ * has to be retractable before it is sent on their behalf.
4825
+ */
4826
+ #renderQueued(): void {
4827
+ this.#queuedRow.replaceChildren();
4828
+ this.#queuedRow.hidden = this.#queued.length === 0;
4829
+ for (const [index, text] of this.#queued.entries()) {
4830
+ const chip = document.createElement("button");
4831
+ chip.type = "button";
4832
+ chip.className = "queued-chip";
4833
+ chip.setAttribute("part", "queued-chip");
4834
+ chip.textContent = text;
4835
+ chip.title = this.#strings.removeQueued.replace("{text}", text);
4836
+ chip.setAttribute("aria-label", chip.title);
4837
+ chip.addEventListener("click", () => {
4838
+ this.#queued.splice(index, 1);
4839
+ this.#renderQueued();
4840
+ });
4841
+ this.#queuedRow.appendChild(chip);
4842
+ }
3785
4843
  }
3786
4844
 
3787
4845
  /**
@@ -3803,15 +4861,37 @@ export class AgUiChat extends HTMLElement {
3803
4861
  // Enter has no such guard; without this it would start a second concurrent
3804
4862
  // SSE run that orphans the first (unabortable) and lets the second run's
3805
4863
  // settle sweep corrupt the first's still-pending tool cards.
3806
- if (this.#running) {
3807
- return;
3808
- }
3809
4864
  const content = this.#input.value.trim();
3810
4865
  const attachments = this.#attachTray?.readyRefs() ?? [];
3811
4866
  // Allow an attachments-only message (no typed text), but nothing empty.
3812
4867
  if (content === "" && attachments.length === 0) {
3813
4868
  return;
3814
4869
  }
4870
+ // A second run cannot start while one is in flight: it would orphan the
4871
+ // first, which is unabortable, and the second's settle sweep would corrupt
4872
+ // the first's still-pending tool cards. That is why this was a dead key --
4873
+ // Enter during a run did nothing at all, silently.
4874
+ //
4875
+ // Queueing keeps the guard and gives the key something to do. Text only:
4876
+ // an attachment is settled state the tray is holding and the composer has
4877
+ // no second copy of, so parking it here would mean deciding what happens
4878
+ // when the user then removes the chip.
4879
+ if (this.#running) {
4880
+ if (content !== "") {
4881
+ this.#queued.push(content);
4882
+ this.#renderQueued();
4883
+ this.#input.value = "";
4884
+ this.#autoGrow();
4885
+ }
4886
+ return;
4887
+ }
4888
+ // Recorded before the box is cleared, newest first, so the arrow keys walk
4889
+ // back through it. A repeat of the last one is not a second entry: the
4890
+ // point is to reach what was said, not how often.
4891
+ if (content !== "" && this.#sentDrafts[0] !== content) {
4892
+ this.#sentDrafts.unshift(content);
4893
+ }
4894
+ this.#recallIndex = null;
3815
4895
  this.#input.value = "";
3816
4896
  this.#autoGrow();
3817
4897
  // A file still uploading does not ride along — `readyRefs()` returns only
@@ -4844,12 +5924,40 @@ export class AgUiChat extends HTMLElement {
4844
5924
  return true;
4845
5925
  }
4846
5926
 
4847
- #appendNotice(icon: string, text: string, kind: string): void {
4848
- this.#ensureGroup().appendChild(renderRunNotice(icon, text, kind));
5927
+ /**
5928
+ * An inline notice about something the run did.
5929
+ *
5930
+ * `undo` is offered only where the agent rearranged the user's own window --
5931
+ * see {@link renderRunNotice} for why a notice may carry that one control and
5932
+ * nothing else.
5933
+ */
5934
+ #appendNotice(
5935
+ icon: string,
5936
+ text: string,
5937
+ kind: string,
5938
+ undo?: { readonly label: string; readonly onActivate: () => void },
5939
+ ): void {
5940
+ this.#ensureGroup().appendChild(renderRunNotice(icon, text, kind, undo));
4849
5941
  this.#updateEmptyState();
4850
5942
  this.#scroller.follow();
4851
5943
  }
4852
5944
 
5945
+ /**
5946
+ * Say that the agent rearranged the user's window, and offer the way back.
5947
+ *
5948
+ * Only on the agent's path. A host calling {@link moveTo} is arranging its
5949
+ * own page and does not need telling what it just did; an agent doing it
5950
+ * mid-conversation is the case where a panel appears to move on its own.
5951
+ */
5952
+ #announceSurfaceChange(text: string, undo: (() => void) | null): void {
5953
+ this.#appendNotice(
5954
+ "⤢",
5955
+ text,
5956
+ "surface",
5957
+ undo === null ? undefined : { label: this.#strings.undo, onActivate: undo },
5958
+ );
5959
+ }
5960
+
4853
5961
  /**
4854
5962
  * Turn on chart rendering, by whichever route this consumer wants.
4855
5963
  *