@artooi/ag-ui-web-component 0.34.0 → 0.35.1

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 (58) hide show
  1. package/CHANGELOG.md +517 -1
  2. package/README.md +243 -13
  3. package/dist/ag-ui-web-component.bundle.js +614 -96
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +76 -3
  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 +1886 -320
  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/clamp_launcher.d.ts +10 -5
  22. package/dist/ui/clamp_launcher.d.ts.map +1 -1
  23. package/dist/ui/clamp_panel.d.ts +2 -2
  24. package/dist/ui/clamp_panel.d.ts.map +1 -1
  25. package/dist/ui/launcher_drag.d.ts +2 -2
  26. package/dist/ui/launcher_drag.d.ts.map +1 -1
  27. package/dist/ui/launcher_placement.d.ts +14 -1
  28. package/dist/ui/launcher_placement.d.ts.map +1 -1
  29. package/dist/ui/panel_drag.d.ts.map +1 -1
  30. package/dist/ui/place_widget.d.ts +9 -1
  31. package/dist/ui/place_widget.d.ts.map +1 -1
  32. package/dist/ui/run_notice.d.ts +14 -3
  33. package/dist/ui/run_notice.d.ts.map +1 -1
  34. package/dist/ui/styles.d.ts +1 -1
  35. package/dist/ui/styles.d.ts.map +1 -1
  36. package/dist/ui/thread_drawer.d.ts +21 -0
  37. package/dist/ui/thread_drawer.d.ts.map +1 -1
  38. package/dist/ui/ui_strings.d.ts +16 -0
  39. package/dist/ui/ui_strings.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/constants.ts +82 -3
  42. package/src/core/ag_ui_chat.ts +965 -55
  43. package/src/dom/animations.ts +30 -0
  44. package/src/dom/highlight_overlay.ts +256 -0
  45. package/src/index.ts +12 -0
  46. package/src/tools/chat_surface_tools.ts +207 -0
  47. package/src/tools/page_action_tools.ts +2 -0
  48. package/src/ui/clamp_launcher.ts +25 -7
  49. package/src/ui/clamp_panel.ts +10 -4
  50. package/src/ui/launcher_drag.ts +11 -2
  51. package/src/ui/launcher_placement.ts +34 -8
  52. package/src/ui/panel_drag.ts +4 -0
  53. package/src/ui/place_widget.ts +11 -3
  54. package/src/ui/run_notice.ts +32 -3
  55. package/src/ui/styles.ts +563 -45
  56. package/src/ui/thread_drawer.ts +138 -8
  57. package/src/ui/ui_strings.ts +24 -0
  58. 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";
@@ -67,6 +82,7 @@ import {
67
82
  type Extent,
68
83
  type LauncherBox,
69
84
  launcherPlacement,
85
+ type ViewportBox,
70
86
  } from "../ui/launcher_placement.js";
71
87
  import {
72
88
  attachMessageActions,
@@ -630,7 +646,7 @@ export class AgUiChat extends HTMLElement {
630
646
  * Resolve a `scroll_to` / `drag_and_drop` target string to a host-page
631
647
  * element (or `null`). Defaults to a CSS-selector lookup; override to map
632
648
  * page-map element ids. The page-action tools are opt-in via the
633
- * `data-page-actions` attribute (`"scroll"` / `"drag"`).
649
+ * `data-page-actions` attribute (`"scroll"` / `"drag"` / `"chat"`).
634
650
  */
635
651
  resolvePageTarget: ResolvePageTarget = (target) => document.querySelector<HTMLElement>(target);
636
652
 
@@ -755,6 +771,12 @@ export class AgUiChat extends HTMLElement {
755
771
  readonly #root: ShadowRoot;
756
772
  /** Screen-reader-only status region -- see {@link AgUiChat.#announce}. */
757
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");
758
780
  /** Return-to-foot affordance, shown only once something has been missed. */
759
781
  readonly #jumpButton = document.createElement("button");
760
782
  /**
@@ -816,6 +838,8 @@ export class AgUiChat extends HTMLElement {
816
838
  readonly #launcher: HTMLButtonElement;
817
839
  /** The launcher's unread badge; hidden at zero, and when the host opts out. */
818
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");
819
843
  // Answers that finished while the widget was collapsed. Expanding clears it.
820
844
  #unread = 0;
821
845
  /** Empty-state region at the top of the message list; hidden once anything renders. */
@@ -829,6 +853,14 @@ export class AgUiChat extends HTMLElement {
829
853
  * position -- see #applyLauncherPlacement for what that costs the host.
830
854
  */
831
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;
832
864
 
833
865
  /**
834
866
  * Where the user dragged the *panel*, in viewport coordinates, or null while
@@ -864,8 +896,78 @@ export class AgUiChat extends HTMLElement {
864
896
  * a field so `removeEventListener` on disconnect gets the same reference.
865
897
  */
866
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
+ }
867
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
+ }
868
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
+ }
869
971
  /** Mic button mount point (input row); the control mounts on connect when enabled. */
870
972
  readonly #voiceSlot: HTMLSpanElement;
871
973
  /** Voice-input control; created on connect when transcription is available. */
@@ -1000,6 +1102,9 @@ export class AgUiChat extends HTMLElement {
1000
1102
  onDelete: (threadId) => {
1001
1103
  this.#deleteThread(threadId);
1002
1104
  },
1105
+ onVisibility: () => {
1106
+ this.#syncThreadsState();
1107
+ },
1003
1108
  });
1004
1109
  this.#checkpoints = new CheckpointMenu((runId, verb) => {
1005
1110
  void this.#continueRun(runId, verb);
@@ -1105,6 +1210,13 @@ export class AgUiChat extends HTMLElement {
1105
1210
  // Position is owned the same way a size is: a placement that places
1106
1211
  // itself takes back a launcher the user had dragged somewhere else.
1107
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
+ }
1108
1220
  // Placement also moves the panel, so the edges its layout holds still
1109
1221
  // change with it. Deferred a frame so the new rules have applied.
1110
1222
  requestAnimationFrame(() => this.#syncResizeAnchor());
@@ -1114,6 +1226,7 @@ export class AgUiChat extends HTMLElement {
1114
1226
  // `#strings` is the resolved table once connected, the English defaults
1115
1227
  // before then.
1116
1228
  this.#title.textContent = value ?? this.#strings.title;
1229
+ this.#railLabel.textContent = this.#title.textContent;
1117
1230
  return;
1118
1231
  }
1119
1232
  if (name === "user-key") {
@@ -1252,7 +1365,10 @@ export class AgUiChat extends HTMLElement {
1252
1365
  .map((token) => token.trim())
1253
1366
  .filter((token) => token !== ""),
1254
1367
  );
1255
- 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
+ ];
1256
1372
  }
1257
1373
 
1258
1374
  /** All built-in (route + page + page-action + ask_user) frontend tools. */
@@ -1543,7 +1659,7 @@ export class AgUiChat extends HTMLElement {
1543
1659
  // Restore a theme the built-in toggle persisted last visit (opt-in only, so
1544
1660
  // it never overrides a host that drives `theme` itself).
1545
1661
  if (this.getAttribute("data-theme-toggle") !== null) {
1546
- const saved = this.#readScopedItem(THEME_KEY);
1662
+ const saved = this.#readPreference(THEME_KEY);
1547
1663
  if (saved !== null) {
1548
1664
  this.setAttribute("theme", saved);
1549
1665
  }
@@ -1551,7 +1667,11 @@ export class AgUiChat extends HTMLElement {
1551
1667
  this.#render();
1552
1668
  this.#drawer.setStrings(this.#strings);
1553
1669
  this.#checkpoints.setStrings(this.#strings);
1554
- 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()) {
1555
1675
  this.setAttribute("collapsed", "");
1556
1676
  }
1557
1677
  this.#syncLauncher();
@@ -1567,6 +1687,12 @@ export class AgUiChat extends HTMLElement {
1567
1687
  this.conversationStore = this.#builtinStore;
1568
1688
  }
1569
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();
1570
1696
  this.#wireThreadStore();
1571
1697
  this.#wireAttachments();
1572
1698
  this.#wireVoice();
@@ -1640,6 +1766,8 @@ export class AgUiChat extends HTMLElement {
1640
1766
  disconnectedCallback(): void {
1641
1767
  this.#connected = false;
1642
1768
  window.removeEventListener("resize", this.#onViewportResize);
1769
+ window.visualViewport?.removeEventListener("resize", this.#onViewportResize);
1770
+ window.visualViewport?.removeEventListener("scroll", this.#onViewportResize);
1643
1771
  // Give the namespace back. A disconnect is not necessarily a farewell — a
1644
1772
  // DOM move and a framework re-render both look like one — and an element
1645
1773
  // that could not reclaim its own namespace on the way back in would lose
@@ -1724,7 +1852,7 @@ export class AgUiChat extends HTMLElement {
1724
1852
  this.#fileInput.accept = accept;
1725
1853
  this.#attachButton.hidden = false;
1726
1854
  this.#enableDragAndDrop();
1727
- this.#enablePaste();
1855
+ this.#enablePaste(tray);
1728
1856
  }
1729
1857
 
1730
1858
  /** The built-in multipart upload handler for `data-attachments-url`, or `null`. */
@@ -1992,6 +2120,65 @@ export class AgUiChat extends HTMLElement {
1992
2120
  });
1993
2121
  }
1994
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
+
1995
2182
  /**
1996
2183
  * Accept files pasted into the composer.
1997
2184
  *
@@ -2005,7 +2192,7 @@ export class AgUiChat extends HTMLElement {
2005
2192
  * clipboard, and swallowing the words someone meant to paste in order to
2006
2193
  * attach a picture they did not is the worse of the two failures.
2007
2194
  */
2008
- #enablePaste(): void {
2195
+ #enablePaste(tray: AttachmentTray): void {
2009
2196
  this.#chat.addEventListener("paste", (event: ClipboardEvent) => {
2010
2197
  // Nullish rather than a null check: the property is typed as nullable,
2011
2198
  // and an engine that fires a plain Event for a paste leaves it absent
@@ -2016,6 +2203,7 @@ export class AgUiChat extends HTMLElement {
2016
2203
  }
2017
2204
  const files = Array.from(clipboard.files);
2018
2205
  if (files.length === 0) {
2206
+ this.#pasteLongTextAsFile(event, clipboard, tray);
2019
2207
  return;
2020
2208
  }
2021
2209
  if (clipboard.getData("text/plain") === "") {
@@ -2201,7 +2389,27 @@ export class AgUiChat extends HTMLElement {
2201
2389
  * per-tab, and emit a {@link TOGGLE_EVENT} so a host can mirror the state in
2202
2390
  * its own chrome.
2203
2391
  */
2204
- 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
+ }
2205
2413
  if (!collapsed) {
2206
2414
  // Re-decide which way to open before opening: the viewport may have
2207
2415
  // changed since the launcher was dropped, and this corner is what the
@@ -2236,6 +2444,194 @@ export class AgUiChat extends HTMLElement {
2236
2444
  return this.#unread;
2237
2445
  }
2238
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
+
2239
2635
  /** Flip the collapsed state. Bound to the built-in header toggle. */
2240
2636
  toggleCollapsed(): void {
2241
2637
  this.setCollapsed(!this.collapsed);
@@ -2250,7 +2646,7 @@ export class AgUiChat extends HTMLElement {
2250
2646
  toggleTheme(): void {
2251
2647
  const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
2252
2648
  this.setAttribute("theme", next);
2253
- writeStoredItem(this.#storageKey(THEME_KEY), next);
2649
+ this.#writePreference(THEME_KEY, next);
2254
2650
  this.#syncThemeGlyph();
2255
2651
  }
2256
2652
 
@@ -2381,6 +2777,13 @@ export class AgUiChat extends HTMLElement {
2381
2777
  if (axis === "none") {
2382
2778
  return;
2383
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.
2384
2787
  if (size.width !== undefined) {
2385
2788
  this.style.setProperty("--ag-ui-width", `${size.width}px`);
2386
2789
  }
@@ -2418,9 +2821,113 @@ export class AgUiChat extends HTMLElement {
2418
2821
  );
2419
2822
  }
2420
2823
 
2421
- /** The viewport the launcher and the panel both have to fit inside. */
2422
- #viewport(): Extent {
2423
- 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
+ );
2424
2931
  }
2425
2932
 
2426
2933
  /**
@@ -2480,6 +2987,7 @@ export class AgUiChat extends HTMLElement {
2480
2987
  this.#launcherBox(),
2481
2988
  { width: panel.width, height: panel.height },
2482
2989
  this.#viewport(),
2990
+ this.#screen(),
2483
2991
  );
2484
2992
  this.style.setProperty("--ag-ui-inset", placement.hostInset);
2485
2993
  this.style.setProperty("--ag-ui-launcher-inset", placement.launcherInset);
@@ -2514,9 +3022,9 @@ export class AgUiChat extends HTMLElement {
2514
3022
  * measured from it, and rewriting one of them from a new corner while the
2515
3023
  * other still names the old one would move the launcher for no reason.
2516
3024
  */
2517
- #movePanel(box: PanelRect): PanelRect {
3025
+ #movePanel(box: PanelRect, from: PanelRect): { held: PanelRect; launcher: LauncherBox | null } {
2518
3026
  if (!this.#launcherDraggable()) {
2519
- return box;
3027
+ return { held: box, launcher: null };
2520
3028
  }
2521
3029
  // Where the launcher rests, recorded before the first move writes anything.
2522
3030
  // From here on the DOM shows it mid-gesture, so this is the last moment it
@@ -2525,20 +3033,58 @@ export class AgUiChat extends HTMLElement {
2525
3033
  const resting = this.#launcherBox();
2526
3034
  this.#launcherPos = { left: resting.left, top: resting.top };
2527
3035
  }
2528
- const held = clampPanel(box, this.#viewport());
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);
2529
3048
  const corner = this.#expandCorner ?? this.#anchor;
2530
- const viewport = this.#viewport();
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();
2531
3055
  this.style.setProperty(
2532
3056
  "--ag-ui-inset",
2533
3057
  [
2534
3058
  corner.y === "top" ? `${Math.round(held.top)}px` : "auto",
2535
- corner.x === "right" ? `${Math.round(viewport.width - held.right)}px` : "auto",
2536
- corner.y === "bottom" ? `${Math.round(viewport.height - held.bottom)}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",
2537
3061
  corner.x === "left" ? `${Math.round(held.left)}px` : "auto",
2538
3062
  ].join(" "),
2539
3063
  );
2540
3064
  this.#panelPos = { left: held.left, top: held.top };
2541
- return held;
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 };
2542
3088
  }
2543
3089
 
2544
3090
  /**
@@ -2555,22 +3101,14 @@ export class AgUiChat extends HTMLElement {
2555
3101
  * both insets are rewritten from positions that are already decided.
2556
3102
  */
2557
3103
  #commitPanel(box: PanelRect, from: PanelRect): void {
2558
- const held = this.#movePanel(box);
2559
- const start = this.#launcherPos;
2560
- if (!this.#launcherDraggable() || start === null) {
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) {
2561
3109
  return;
2562
3110
  }
2563
- const carried = {
2564
- ...this.#launcherBox(),
2565
- left: start.left + (held.left - from.left),
2566
- top: start.top + (held.top - from.top),
2567
- };
2568
- // The screen is the last word: a bubble dragged past the edge is one nobody
2569
- // can click, and it is the only way back to a collapsed conversation.
2570
- this.#placePanelAndLauncher(held, {
2571
- ...carried,
2572
- ...clampLauncher(carried, this.#viewport()),
2573
- });
3111
+ this.#placePanelAndLauncher(held, launcher);
2574
3112
  this.#storeLauncherPosition();
2575
3113
  }
2576
3114
 
@@ -2580,9 +3118,12 @@ export class AgUiChat extends HTMLElement {
2580
3118
  */
2581
3119
  #placePanelAndLauncher(host: PanelRect, launcher: LauncherBox): void {
2582
3120
  const viewport = this.#viewport();
3121
+ const screen = this.#screen();
2583
3122
  const size = { width: host.right - host.left, height: host.bottom - host.top };
2584
- const { corner } = launcherPlacement(launcher, size, viewport);
2585
- const insets = placeWidget(host, launcher, corner, viewport);
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);
2586
3127
  this.style.setProperty("--ag-ui-inset", insets.hostInset);
2587
3128
  this.style.setProperty("--ag-ui-launcher-inset", insets.launcherInset);
2588
3129
  this.#launcherPos = { left: launcher.left, top: launcher.top };
@@ -2605,9 +3146,14 @@ export class AgUiChat extends HTMLElement {
2605
3146
  return;
2606
3147
  }
2607
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.
2608
3153
  const held = clampPanel(
2609
3154
  { left: at.left, top: at.top, right: at.left + rect.width, bottom: at.top + rect.height },
2610
3155
  this.#viewport(),
3156
+ SCREEN_EDGE_MARGIN,
2611
3157
  );
2612
3158
  const launcher = this.#launcherBox();
2613
3159
  const carried = {
@@ -2636,8 +3182,8 @@ export class AgUiChat extends HTMLElement {
2636
3182
  return;
2637
3183
  }
2638
3184
  const panel = this.#panelPos;
2639
- writeStoredItem(
2640
- this.#storageKey(LAUNCHER_KEY),
3185
+ this.#writePreference(
3186
+ LAUNCHER_KEY,
2641
3187
  JSON.stringify(panel === null ? position : { ...position, panel }),
2642
3188
  );
2643
3189
  }
@@ -2675,7 +3221,7 @@ export class AgUiChat extends HTMLElement {
2675
3221
  readonly top: number;
2676
3222
  readonly panel?: { readonly left: number; readonly top: number };
2677
3223
  } | null {
2678
- const raw = this.#readScopedItem(LAUNCHER_KEY);
3224
+ const raw = this.#readPreference(LAUNCHER_KEY);
2679
3225
  if (raw === null) {
2680
3226
  return null;
2681
3227
  }
@@ -2731,20 +3277,24 @@ export class AgUiChat extends HTMLElement {
2731
3277
  * host positioning the panel with its own rule keeps that rule until someone
2732
3278
  * drags the edge it was holding.
2733
3279
  */
2734
- #applyResize(grip: ResizeGrip, box: PanelRect): void {
3280
+ #applyResize(grip: ResizeGrip, box: PanelRect): PanelRect {
3281
+ box = this.#withinViewport(box);
2735
3282
  this.#applySize({ width: box.right - box.left, height: box.bottom - box.top });
2736
3283
  if (grip.x !== this.#anchor.x && grip.y !== this.#anchor.y) {
2737
- return;
3284
+ return box;
2738
3285
  }
2739
- 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();
2740
3290
  const anchor = this.#anchor;
2741
3291
  const side = (value: number): string => `${Math.round(value)}px`;
2742
3292
  this.style.setProperty(
2743
3293
  "--ag-ui-inset",
2744
3294
  [
2745
3295
  anchor.y === "top" ? side(box.top) : "auto",
2746
- anchor.x === "right" ? side(viewport.width - box.right) : "auto",
2747
- 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",
2748
3298
  anchor.x === "left" ? side(box.left) : "auto",
2749
3299
  ].join(" "),
2750
3300
  );
@@ -2763,12 +3313,17 @@ export class AgUiChat extends HTMLElement {
2763
3313
  if (this.#panelPos !== null) {
2764
3314
  this.#panelPos = { left: box.left, top: box.top };
2765
3315
  }
3316
+ return box;
2766
3317
  }
2767
3318
 
2768
3319
  /** Finish a resize: keep the box, remember it, and re-read the pinned edges. */
2769
3320
  #commitResize(grip: ResizeGrip, box: PanelRect): void {
2770
- this.#applyResize(grip, box);
2771
- 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 });
2772
3327
  this.#storeLauncherPosition();
2773
3328
  // Re-stamp after the drag: a host whose layout changed underneath us would
2774
3329
  // otherwise keep the tab-reachable grip in the old corner, which reads as
@@ -2802,12 +3357,12 @@ export class AgUiChat extends HTMLElement {
2802
3357
  /** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
2803
3358
  #persistSize(size: ResizeSize): void {
2804
3359
  const stored = { ...this.#readSize(), ...size };
2805
- writeStoredItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
3360
+ this.#writePreference(SIZE_KEY, JSON.stringify(stored));
2806
3361
  }
2807
3362
 
2808
3363
  /** The persisted size for this instance, or an empty record. */
2809
3364
  #readSize(): ResizeSize {
2810
- const raw = this.#readScopedItem(SIZE_KEY);
3365
+ const raw = this.#readPreference(SIZE_KEY);
2811
3366
  if (raw === null) {
2812
3367
  return {};
2813
3368
  }
@@ -2963,10 +3518,91 @@ export class AgUiChat extends HTMLElement {
2963
3518
  return sessionStorage.getItem(base);
2964
3519
  }
2965
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
+
2966
3597
  /** Reflect the current theme on the toggle: show the destination's glyph. */
2967
3598
  #syncThemeGlyph(): void {
2968
3599
  const dark = this.getAttribute("theme") === "dark";
2969
- 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
+ );
2970
3606
  }
2971
3607
 
2972
3608
  /**
@@ -2985,7 +3621,42 @@ export class AgUiChat extends HTMLElement {
2985
3621
  // open *underneath* a popover still floating over it.
2986
3622
  this.#checkpoints.close();
2987
3623
  void this.#refreshDrawer();
3624
+ this.#drawer.setModal(!this.#threadsDock());
2988
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
+ }
2989
3660
  }
2990
3661
 
2991
3662
  /**
@@ -3002,6 +3673,17 @@ export class AgUiChat extends HTMLElement {
3002
3673
  this.#checkpoints.open();
3003
3674
  }
3004
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
+
3005
3687
  /** Close the checkpoints panel, if it is open. */
3006
3688
  closeCheckpoints(): void {
3007
3689
  this.#checkpoints.close();
@@ -3050,6 +3732,13 @@ export class AgUiChat extends HTMLElement {
3050
3732
  this.#client = null;
3051
3733
  this.#clearTranscript();
3052
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;
3053
3742
  }
3054
3743
 
3055
3744
  /**
@@ -3483,7 +4172,7 @@ export class AgUiChat extends HTMLElement {
3483
4172
  enablePanelDrag(header, {
3484
4173
  enabled: () => !this.collapsed && this.#launcherDraggable(),
3485
4174
  rect: () => this.getBoundingClientRect(),
3486
- apply: (box) => this.#movePanel(box),
4175
+ apply: (box, from) => this.#movePanel(box, from),
3487
4176
  commit: (box, from) => this.#commitPanel(box, from),
3488
4177
  });
3489
4178
 
@@ -3557,7 +4246,19 @@ export class AgUiChat extends HTMLElement {
3557
4246
  this.#emptyWrap.setAttribute("part", "empty");
3558
4247
  const emptySlot = document.createElement("slot");
3559
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
+ }
3560
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;
3561
4262
  this.#messages.append(this.#emptyWrap);
3562
4263
 
3563
4264
  const inputRow = document.createElement("div");
@@ -3652,6 +4353,7 @@ export class AgUiChat extends HTMLElement {
3652
4353
  this.#skillsMenu.palette,
3653
4354
  this.#skillsMenu.chips,
3654
4355
  this.#skillHint,
4356
+ this.#queuedRow,
3655
4357
  this.#attachSlot,
3656
4358
  inputRow,
3657
4359
  footer,
@@ -3691,8 +4393,18 @@ export class AgUiChat extends HTMLElement {
3691
4393
  // decoration to a screen reader rather than a second, context-free number.
3692
4394
  this.#badge.setAttribute("aria-hidden", "true");
3693
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;
3694
4405
  this.#launcher.append(
3695
4406
  this.#iconElement("launcher", "launcher-icon", ICON_LAUNCHER, this.#launcherIconUrl()),
4407
+ this.#railLabel,
3696
4408
  this.#badge,
3697
4409
  );
3698
4410
  this.#launcher.addEventListener("click", () => this.setCollapsed(false));
@@ -3727,7 +4439,9 @@ export class AgUiChat extends HTMLElement {
3727
4439
  }
3728
4440
  this.#focusableGrip();
3729
4441
  this.#adoptStyles();
3730
- 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);
3731
4445
  }
3732
4446
 
3733
4447
  /**
@@ -3926,6 +4640,39 @@ export class AgUiChat extends HTMLElement {
3926
4640
  }
3927
4641
 
3928
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
+
3929
4676
  #updateEmptyState(): void {
3930
4677
  this.#emptyWrap.hidden = this.#messages.childElementCount > 1;
3931
4678
  }
@@ -3935,6 +4682,10 @@ export class AgUiChat extends HTMLElement {
3935
4682
  this.#skillsMenu.onInput(this.#input.value);
3936
4683
  this.#skillHint.hidden = true;
3937
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;
3938
4689
  }
3939
4690
 
3940
4691
  #onKeydown(event: KeyboardEvent): void {
@@ -3953,7 +4704,51 @@ export class AgUiChat extends HTMLElement {
3953
4704
  if (event.key === "Enter" && !event.shiftKey) {
3954
4705
  event.preventDefault();
3955
4706
  void this.#submit();
4707
+ return;
3956
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();
3957
4752
  }
3958
4753
 
3959
4754
  /**
@@ -3963,6 +4758,26 @@ export class AgUiChat extends HTMLElement {
3963
4758
  * observes the disconnect).
3964
4759
  */
3965
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();
3966
4781
  this.#confirmAbort?.abort();
3967
4782
  this.#client?.cancel();
3968
4783
  }
@@ -3975,11 +4790,56 @@ export class AgUiChat extends HTMLElement {
3975
4790
  * that it carries no text.
3976
4791
  */
3977
4792
  #setRunning(running: boolean): void {
4793
+ const settled = this.#running && !running;
3978
4794
  this.#running = running;
3979
4795
  const label = running ? this.#strings.stop : this.#strings.send;
3980
4796
  this.#send.title = label;
3981
4797
  this.#send.setAttribute("aria-label", label);
3982
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
+ }
3983
4843
  }
3984
4844
 
3985
4845
  /**
@@ -4001,15 +4861,37 @@ export class AgUiChat extends HTMLElement {
4001
4861
  // Enter has no such guard; without this it would start a second concurrent
4002
4862
  // SSE run that orphans the first (unabortable) and lets the second run's
4003
4863
  // settle sweep corrupt the first's still-pending tool cards.
4004
- if (this.#running) {
4005
- return;
4006
- }
4007
4864
  const content = this.#input.value.trim();
4008
4865
  const attachments = this.#attachTray?.readyRefs() ?? [];
4009
4866
  // Allow an attachments-only message (no typed text), but nothing empty.
4010
4867
  if (content === "" && attachments.length === 0) {
4011
4868
  return;
4012
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;
4013
4895
  this.#input.value = "";
4014
4896
  this.#autoGrow();
4015
4897
  // A file still uploading does not ride along — `readyRefs()` returns only
@@ -5042,12 +5924,40 @@ export class AgUiChat extends HTMLElement {
5042
5924
  return true;
5043
5925
  }
5044
5926
 
5045
- #appendNotice(icon: string, text: string, kind: string): void {
5046
- 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));
5047
5941
  this.#updateEmptyState();
5048
5942
  this.#scroller.follow();
5049
5943
  }
5050
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
+
5051
5961
  /**
5052
5962
  * Turn on chart rendering, by whichever route this consumer wants.
5053
5963
  *