@artooi/ag-ui-web-component 0.38.0 → 0.40.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 (142) hide show
  1. package/CHANGELOG.md +515 -1
  2. package/README.md +232 -56
  3. package/dist/ag-ui-web-component.bundle.js +370 -139
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +17 -14
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/activity_registry.d.ts +46 -0
  8. package/dist/core/activity_registry.d.ts.map +1 -0
  9. package/dist/core/ag_ui_chat.d.ts +23 -10
  10. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  11. package/dist/core/agui_client.d.ts +28 -0
  12. package/dist/core/agui_client.d.ts.map +1 -1
  13. package/dist/core/client_seed.d.ts +27 -0
  14. package/dist/core/client_seed.d.ts.map +1 -0
  15. package/dist/core/read_max_tool_rounds.d.ts +9 -0
  16. package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
  17. package/dist/core/run_handlers.d.ts +80 -0
  18. package/dist/core/run_handlers.d.ts.map +1 -0
  19. package/dist/core/storage_scope.d.ts +110 -0
  20. package/dist/core/storage_scope.d.ts.map +1 -0
  21. package/dist/core/tool_outcome.d.ts +2 -3
  22. package/dist/core/tool_outcome.d.ts.map +1 -1
  23. package/dist/core/utils.d.ts +46 -0
  24. package/dist/core/utils.d.ts.map +1 -1
  25. package/dist/index.js +16268 -14747
  26. package/dist/index.js.map +4 -4
  27. package/dist/skills/skill_catalog.d.ts +67 -0
  28. package/dist/skills/skill_catalog.d.ts.map +1 -0
  29. package/dist/skills/skill_name_from.d.ts +12 -0
  30. package/dist/skills/skill_name_from.d.ts.map +1 -0
  31. package/dist/tools/tool_catalog.d.ts +79 -0
  32. package/dist/tools/tool_catalog.d.ts.map +1 -0
  33. package/dist/tools/tool_dispatch.d.ts +104 -0
  34. package/dist/tools/tool_dispatch.d.ts.map +1 -0
  35. package/dist/ui/composer/attachment_tray.d.ts.map +1 -1
  36. package/dist/ui/composer/auto_grow.d.ts +10 -0
  37. package/dist/ui/composer/auto_grow.d.ts.map +1 -0
  38. package/dist/ui/composer/composer_attachments.d.ts +57 -0
  39. package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
  40. package/dist/ui/composer/composer_voice.d.ts +48 -0
  41. package/dist/ui/composer/composer_voice.d.ts.map +1 -0
  42. package/dist/ui/composer/voice_input.d.ts.map +1 -1
  43. package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
  44. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
  45. package/dist/ui/fill_ui_string.d.ts +32 -0
  46. package/dist/ui/fill_ui_string.d.ts.map +1 -0
  47. package/dist/ui/history/conversation_history.d.ts +203 -0
  48. package/dist/ui/history/conversation_history.d.ts.map +1 -0
  49. package/dist/ui/history/relative_time.d.ts.map +1 -1
  50. package/dist/ui/history/thread_drawer.d.ts +0 -14
  51. package/dist/ui/history/thread_drawer.d.ts.map +1 -1
  52. package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -1
  53. package/dist/ui/interrupts/pending_decision.d.ts +23 -0
  54. package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
  55. package/dist/ui/placement/is_collapsible_placement.d.ts +18 -0
  56. package/dist/ui/placement/is_collapsible_placement.d.ts.map +1 -0
  57. package/dist/ui/placement/is_draggable_placement.d.ts +9 -0
  58. package/dist/ui/placement/is_draggable_placement.d.ts.map +1 -0
  59. package/dist/ui/placement/launcher_drag.d.ts +6 -0
  60. package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
  61. package/dist/ui/placement/panel_placement.d.ts +119 -0
  62. package/dist/ui/placement/panel_placement.d.ts.map +1 -0
  63. package/dist/ui/progress/run_announcer.d.ts +41 -0
  64. package/dist/ui/progress/run_announcer.d.ts.map +1 -0
  65. package/dist/ui/progress/subagent_progress.d.ts +58 -0
  66. package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
  67. package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
  68. package/dist/ui/shell/adopt_styles.d.ts +23 -0
  69. package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
  70. package/dist/ui/shell/glyph_slot.d.ts +7 -0
  71. package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
  72. package/dist/ui/shell/header_button.d.ts +10 -0
  73. package/dist/ui/shell/header_button.d.ts.map +1 -0
  74. package/dist/ui/shell/icon_element.d.ts +12 -0
  75. package/dist/ui/shell/icon_element.d.ts.map +1 -0
  76. package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
  77. package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
  78. package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
  79. package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
  80. package/dist/ui/styles.d.ts +1 -1
  81. package/dist/ui/styles.d.ts.map +1 -1
  82. package/dist/ui/transcript/answer_actions.d.ts +39 -0
  83. package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
  84. package/dist/ui/transcript/answer_stream.d.ts +56 -0
  85. package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
  86. package/dist/ui/transcript/render_or_warn.d.ts +16 -0
  87. package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
  88. package/dist/ui/transcript/starter_chips.d.ts +21 -0
  89. package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
  90. package/dist/ui/transcript/transcript.d.ts +171 -0
  91. package/dist/ui/transcript/transcript.d.ts.map +1 -0
  92. package/dist/ui/ui_strings.d.ts +29 -1
  93. package/dist/ui/ui_strings.d.ts.map +1 -1
  94. package/package.json +1 -1
  95. package/src/constants.ts +17 -15
  96. package/src/core/activity_registry.ts +133 -0
  97. package/src/core/ag_ui_chat.ts +945 -4419
  98. package/src/core/agui_client.ts +172 -16
  99. package/src/core/client_seed.ts +27 -0
  100. package/src/core/read_max_tool_rounds.ts +13 -0
  101. package/src/core/remote_conversation_store.ts +1 -1
  102. package/src/core/run_handlers.ts +501 -0
  103. package/src/core/storage_scope.ts +279 -0
  104. package/src/core/tool_outcome.ts +5 -3
  105. package/src/core/utils.ts +109 -0
  106. package/src/skills/skill_catalog.ts +166 -0
  107. package/src/skills/skill_name_from.ts +19 -0
  108. package/src/tools/tool_catalog.ts +354 -0
  109. package/src/tools/tool_dispatch.ts +521 -0
  110. package/src/ui/composer/attachment_tray.ts +6 -5
  111. package/src/ui/composer/auto_grow.ts +12 -0
  112. package/src/ui/composer/composer_attachments.ts +324 -0
  113. package/src/ui/composer/composer_voice.ts +93 -0
  114. package/src/ui/composer/voice_input.ts +4 -4
  115. package/src/ui/excerpts/transcript_quote_offer.ts +198 -0
  116. package/src/ui/fill_ui_string.ts +41 -0
  117. package/src/ui/history/conversation_history.ts +699 -0
  118. package/src/ui/history/relative_time.ts +5 -4
  119. package/src/ui/history/thread_drawer.ts +3 -50
  120. package/src/ui/interrupts/confirmation_card.ts +4 -2
  121. package/src/ui/interrupts/pending_decision.ts +34 -0
  122. package/src/ui/placement/is_collapsible_placement.ts +19 -0
  123. package/src/ui/placement/is_draggable_placement.ts +23 -0
  124. package/src/ui/placement/launcher_drag.ts +104 -89
  125. package/src/ui/placement/panel_placement.ts +1130 -0
  126. package/src/ui/progress/run_announcer.ts +67 -0
  127. package/src/ui/progress/subagent_progress.ts +201 -0
  128. package/src/ui/progress/tool_call_card.ts +2 -0
  129. package/src/ui/shell/adopt_styles.ts +28 -0
  130. package/src/ui/shell/glyph_slot.ts +12 -0
  131. package/src/ui/shell/header_button.ts +21 -0
  132. package/src/ui/shell/icon_element.ts +33 -0
  133. package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
  134. package/src/ui/shell/read_launcher_icon_url.ts +9 -0
  135. package/src/ui/styles.ts +301 -70
  136. package/src/ui/transcript/answer_actions.ts +139 -0
  137. package/src/ui/transcript/answer_stream.ts +123 -0
  138. package/src/ui/transcript/render_or_warn.ts +22 -0
  139. package/src/ui/transcript/starter_chips.ts +42 -0
  140. package/src/ui/transcript/transcript.ts +429 -0
  141. package/src/ui/ui_strings.ts +38 -1
  142. package/src/version.ts +1 -1
@@ -1,3 +1,4 @@
1
+ import { fillUiString } from "../fill_ui_string.js";
1
2
  import { DEFAULT_UI_STRINGS, type UiStrings } from "../ui_strings.js";
2
3
 
3
4
  /**
@@ -24,17 +25,17 @@ export function relativeTime(
24
25
  }
25
26
  const minutes = Math.round(seconds / 60);
26
27
  if (minutes < 60) {
27
- return strings.minutesAgo.replace("{n}", String(minutes));
28
+ return fillUiString(strings.minutesAgo, { n: minutes });
28
29
  }
29
30
  const hours = Math.round(minutes / 60);
30
31
  if (hours < 24) {
31
- return strings.hoursAgo.replace("{n}", String(hours));
32
+ return fillUiString(strings.hoursAgo, { n: hours });
32
33
  }
33
34
  const days = Math.round(hours / 24);
34
35
  if (days < 7) {
35
- return strings.daysAgo.replace("{n}", String(days));
36
+ return fillUiString(strings.daysAgo, { n: days });
36
37
  }
37
- return strings.weeksAgo.replace("{n}", String(Math.round(days / 7)));
38
+ return fillUiString(strings.weeksAgo, { n: Math.round(days / 7) });
38
39
  }
39
40
 
40
41
  /**
@@ -20,15 +20,6 @@ export interface ThreadDrawerCallbacks {
20
20
  readonly onRename: (threadId: string, title: string) => void;
21
21
  /** A row was deleted (after the inline confirm). */
22
22
  readonly onDelete: (threadId: string) => void;
23
- /**
24
- * The list opened or closed.
25
- *
26
- * There are five ways out of it -- the close control, the backdrop, Escape,
27
- * picking a row and starting a new chat -- and only two of them go through
28
- * the host. A docked list moves the transcript over, so the host has to know
29
- * about all five or the transcript stays shifted around nothing.
30
- */
31
- readonly onVisibility?: (open: boolean) => void;
32
23
  }
33
24
 
34
25
  /**
@@ -62,17 +53,6 @@ export class ThreadDrawer {
62
53
  #filter: HTMLInputElement;
63
54
  /** The current filter text, lowercased once rather than per row. */
64
55
  #query = "";
65
- /**
66
- * Whether the list covers the conversation, or sits beside it.
67
- *
68
- * Everything a dialog implies follows from this and nothing else does: the
69
- * modal role, the focus trap, and taking focus on open. A list docked beside
70
- * a full-page conversation is a region of that page -- telling assistive
71
- * technology to ignore the rest of the document while it is showing would be
72
- * a lie, and stealing focus into it would interrupt someone mid-sentence to
73
- * announce a list they can already see.
74
- */
75
- #modal = true;
76
56
 
77
57
  /** The element focused before the drawer opened, restored on close. */
78
58
  #lastFocused: HTMLElement | null = null;
@@ -186,21 +166,6 @@ export class ThreadDrawer {
186
166
  this.#renderList();
187
167
  }
188
168
 
189
- /**
190
- * Whether the list is a dialog over the conversation or a rail beside it.
191
- * The host decides, because only the placement knows whether there is room.
192
- */
193
- setModal(modal: boolean): void {
194
- this.#modal = modal;
195
- if (modal) {
196
- this.#panel.setAttribute("role", "dialog");
197
- this.#panel.setAttribute("aria-modal", "true");
198
- } else {
199
- this.#panel.setAttribute("role", "region");
200
- this.#panel.removeAttribute("aria-modal");
201
- }
202
- }
203
-
204
169
  isOpen(): boolean {
205
170
  return !this.element.hidden;
206
171
  }
@@ -213,10 +178,7 @@ export class ThreadDrawer {
213
178
  // the panel (its first control) so keyboard users land inside the dialog.
214
179
  this.#lastFocused = this.#activeElement() as HTMLElement | null;
215
180
  this.element.hidden = false;
216
- if (this.#modal) {
217
- this.#newButton.focus();
218
- }
219
- this.#callbacks.onVisibility?.(true);
181
+ this.#newButton.focus();
220
182
  }
221
183
 
222
184
  close(): void {
@@ -224,13 +186,8 @@ export class ThreadDrawer {
224
186
  return;
225
187
  }
226
188
  this.element.hidden = true;
227
- // Only if it was taken. Restoring focus that was never moved would pull it
228
- // back from wherever the user has since put it.
229
- if (this.#modal) {
230
- this.#lastFocused?.focus();
231
- }
189
+ this.#lastFocused?.focus();
232
190
  this.#lastFocused = null;
233
- this.#callbacks.onVisibility?.(false);
234
191
  }
235
192
 
236
193
  toggle(): void {
@@ -253,11 +210,7 @@ export class ThreadDrawer {
253
210
  this.close();
254
211
  return;
255
212
  }
256
- // Escape still closes a docked rail -- it is a way out, not a modal
257
- // convention -- but nothing beyond this point applies to one. Tabbing out
258
- // of a list that sits beside the conversation should reach the
259
- // conversation, which is the whole difference.
260
- if (event.key !== "Tab" || !this.#modal) {
213
+ if (event.key !== "Tab") {
261
214
  return;
262
215
  }
263
216
  const focusables = Array.from(
@@ -1,3 +1,4 @@
1
+ import { fillUiString } from "../fill_ui_string.js";
1
2
  import { DEFAULT_UI_STRINGS, type UiStrings } from "../ui_strings.js";
2
3
 
3
4
  /** What the inline confirmation card displays. */
@@ -77,7 +78,8 @@ export function requestConfirmation(
77
78
  const body = document.createElement("div");
78
79
  body.className = "confirm-body";
79
80
  body.setAttribute("part", "confirm-body");
80
- body.textContent = request.message ?? strings.confirmRun.replace("{tool}", request.toolName);
81
+ body.textContent =
82
+ request.message ?? fillUiString(strings.confirmRun, { tool: request.toolName });
81
83
 
82
84
  const args = document.createElement("pre");
83
85
  args.className = "confirm-args";
@@ -94,7 +96,7 @@ export function requestConfirmation(
94
96
  const always =
95
97
  options.onAlwaysAllow === undefined
96
98
  ? null
97
- : actionButton("always", strings.confirmAlways.replace("{tool}", request.toolName));
99
+ : actionButton("always", fillUiString(strings.confirmAlways, { tool: request.toolName }));
98
100
  const confirm = actionButton("confirm", strings.confirm);
99
101
 
100
102
  let settled = false;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The decision a run is suspended on, and the one way to abandon it.
3
+ *
4
+ * A run pauses on a person in three places -- a client-side confirmation, a
5
+ * batch of server interrupts, and the `ask_user` question -- and a Stop has to
6
+ * reach whichever of them is open. Each used to assign the element's own abort
7
+ * controller directly, so four places wrote one field and nothing said which
8
+ * of them owned it. Holding it here gives it one owner: a wait opens, the Stop
9
+ * aborts what is open, and the wait closes when it is answered.
10
+ *
11
+ * Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
12
+ * instance.
13
+ */
14
+ export class PendingDecision {
15
+ // Aborting this dismisses (declines) an open confirmation card when the run
16
+ // is cancelled while the card awaits a decision. One controller per wait.
17
+ #controller: AbortController | null = null;
18
+
19
+ /** Start waiting on a person; aborting the returned signal abandons the wait. */
20
+ open(): AbortSignal {
21
+ this.#controller = new AbortController();
22
+ return this.#controller.signal;
23
+ }
24
+
25
+ /** The wait was answered; a later Stop has nothing of this wait's to abandon. */
26
+ close(): void {
27
+ this.#controller = null;
28
+ }
29
+
30
+ /** Abandon whatever wait is open, resolving it as declined. A no-op when none is. */
31
+ abort(): void {
32
+ this.#controller?.abort();
33
+ }
34
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Whether `placement` has a collapsed state at all.
3
+ *
4
+ * `page` does not. It is a dedicated route rather than a panel sitting on
5
+ * someone else's page, so there is no "away" for it to go to: collapsing it
6
+ * left a strip of application chrome fixed over a route that no longer had an
7
+ * owner. It is also the placement that hides the launcher, so the usual way
8
+ * back does not exist here.
9
+ *
10
+ * The header hides its collapse control under this placement, but a control
11
+ * removed from the UI is not a state removed from the model -- the property,
12
+ * the attribute and a value restored from storage all still reach it. This is
13
+ * what the reachable paths are gated on; the stylesheet covers the one path
14
+ * that never passes through here, an attribute written straight onto the
15
+ * element.
16
+ */
17
+ export function isCollapsiblePlacement(placement: string | null): boolean {
18
+ return placement !== "page";
19
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Placements whose launcher can be dragged. The rest have nowhere to put it:
3
+ * a sidebar collapses to a full-height edge rail, "embedded" and "page" hide
4
+ * the launcher entirely and keep their header bar, and a full-bleed panel
5
+ * covers the screen it would be opening into.
6
+ */
7
+ const DRAGGABLE_PLACEMENTS: ReadonlySet<string | null> = new Set([
8
+ null,
9
+ "",
10
+ "floating",
11
+ "bottom-left",
12
+ ]);
13
+
14
+ /**
15
+ * Whether `placement` is one whose launcher can be dragged.
16
+ *
17
+ * Asked by two owners: the placement, deciding whether a gesture may move the
18
+ * widget, and the element, deciding whether a first mount starts collapsed --
19
+ * the corner placements are the two with a launcher to collapse to.
20
+ */
21
+ export function isDraggablePlacement(placement: string | null): boolean {
22
+ return DRAGGABLE_PLACEMENTS.has(placement);
23
+ }
@@ -21,6 +21,12 @@ export interface LauncherDragOptions {
21
21
  * per pointer move, and never per key repeat.
22
22
  */
23
23
  readonly commit: (left: number, top: number) => void;
24
+ /**
25
+ * Ends the launcher's own listeners when aborted. The launcher outlives the
26
+ * element's connection to the document and this is called on every connect,
27
+ * so without it a re-inserted element moved one arrow press two steps.
28
+ */
29
+ readonly signal: AbortSignal;
24
30
  }
25
31
 
26
32
  /**
@@ -55,6 +61,7 @@ const COARSE_STEP = 64;
55
61
  */
56
62
  export function enableLauncherDrag(launcher: HTMLElement, options: LauncherDragOptions): void {
57
63
  let suppressClick = false;
64
+ const { signal } = options;
58
65
 
59
66
  launcher.addEventListener(
60
67
  "click",
@@ -70,72 +77,76 @@ export function enableLauncherDrag(launcher: HTMLElement, options: LauncherDragO
70
77
  event.stopPropagation();
71
78
  event.preventDefault();
72
79
  },
73
- true,
80
+ { capture: true, signal },
74
81
  );
75
82
 
76
- launcher.addEventListener("pointerdown", (event: PointerEvent) => {
77
- // Any suppression left armed by a drag that ended elsewhere dies here,
78
- // before it can reach a click belonging to this new gesture.
79
- suppressClick = false;
80
- if (!options.enabled()) {
81
- return;
82
- }
83
- const start = options.rect();
84
- const originX = event.clientX;
85
- const originY = event.clientY;
86
- let dragging = false;
87
-
88
- const onMove = (move: PointerEvent): void => {
89
- const dx = move.clientX - originX;
90
- const dy = move.clientY - originY;
91
- if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD) {
83
+ launcher.addEventListener(
84
+ "pointerdown",
85
+ (event: PointerEvent) => {
86
+ // Any suppression left armed by a drag that ended elsewhere dies here,
87
+ // before it can reach a click belonging to this new gesture.
88
+ suppressClick = false;
89
+ if (!options.enabled()) {
92
90
  return;
93
91
  }
94
- dragging = true;
95
- launcher.setAttribute("data-dragging", "true");
96
- // Measured from the box the press started on, never from the live one:
97
- // reading it each move would chase the launcher as it moves and the
98
- // travel would compound.
99
- const next = clampLauncher(
100
- { ...start, left: start.left + dx, top: start.top + dy },
101
- options.viewport(),
102
- );
103
- options.apply(next.left, next.top);
104
- };
92
+ const start = options.rect();
93
+ const originX = event.clientX;
94
+ const originY = event.clientY;
95
+ let dragging = false;
105
96
 
106
- const onUp = (up: PointerEvent): void => {
107
- window.removeEventListener("pointermove", onMove);
108
- window.removeEventListener("pointerup", onUp);
109
- window.removeEventListener("pointercancel", onUp);
110
- if (!dragging) {
111
- return;
112
- }
113
- launcher.removeAttribute("data-dragging");
114
- suppressClick = true;
115
- const next = clampLauncher(
116
- {
117
- ...start,
118
- left: start.left + (up.clientX - originX),
119
- top: start.top + (up.clientY - originY),
120
- },
121
- options.viewport(),
122
- );
123
- options.commit(next.left, next.top);
124
- };
97
+ const onMove = (move: PointerEvent): void => {
98
+ const dx = move.clientX - originX;
99
+ const dy = move.clientY - originY;
100
+ if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD) {
101
+ return;
102
+ }
103
+ dragging = true;
104
+ launcher.setAttribute("data-dragging", "true");
105
+ // Measured from the box the press started on, never from the live one:
106
+ // reading it each move would chase the launcher as it moves and the
107
+ // travel would compound.
108
+ const next = clampLauncher(
109
+ { ...start, left: start.left + dx, top: start.top + dy },
110
+ options.viewport(),
111
+ );
112
+ options.apply(next.left, next.top);
113
+ };
125
114
 
126
- // Listeners on `window`, not the launcher: a fast drag outruns the pointer
127
- // and would otherwise strand it mid-move with no pointerup.
128
- //
129
- // pointercancel matters on touch, where it is routine rather than
130
- // exceptional: the browser takes the pointer back for a scroll or a system
131
- // gesture and never sends pointerup. Without this the move listeners stay
132
- // attached and the drag stamp never clears, which leaves the launcher
133
- // following a finger that has stopped and the element believing a gesture
134
- // is still in flight.
135
- window.addEventListener("pointermove", onMove);
136
- window.addEventListener("pointerup", onUp);
137
- window.addEventListener("pointercancel", onUp);
138
- });
115
+ const onUp = (up: PointerEvent): void => {
116
+ window.removeEventListener("pointermove", onMove);
117
+ window.removeEventListener("pointerup", onUp);
118
+ window.removeEventListener("pointercancel", onUp);
119
+ if (!dragging) {
120
+ return;
121
+ }
122
+ launcher.removeAttribute("data-dragging");
123
+ suppressClick = true;
124
+ const next = clampLauncher(
125
+ {
126
+ ...start,
127
+ left: start.left + (up.clientX - originX),
128
+ top: start.top + (up.clientY - originY),
129
+ },
130
+ options.viewport(),
131
+ );
132
+ options.commit(next.left, next.top);
133
+ };
134
+
135
+ // Listeners on `window`, not the launcher: a fast drag outruns the pointer
136
+ // and would otherwise strand it mid-move with no pointerup.
137
+ //
138
+ // pointercancel matters on touch, where it is routine rather than
139
+ // exceptional: the browser takes the pointer back for a scroll or a system
140
+ // gesture and never sends pointerup. Without this the move listeners stay
141
+ // attached and the drag stamp never clears, which leaves the launcher
142
+ // following a finger that has stopped and the element believing a gesture
143
+ // is still in flight.
144
+ window.addEventListener("pointermove", onMove);
145
+ window.addEventListener("pointerup", onUp);
146
+ window.addEventListener("pointercancel", onUp);
147
+ },
148
+ { signal },
149
+ );
139
150
 
140
151
  // The position this key gesture has applied but not yet persisted. The
141
152
  // pointer path can commit inline because a drag has one unambiguous end; a
@@ -155,37 +166,41 @@ export function enableLauncherDrag(launcher: HTMLElement, options: LauncherDragO
155
166
  // Keyboard parity: a pointer-only move is unreachable without a mouse, and
156
167
  // the launcher has no equivalent control elsewhere. Arrow keys are free on a
157
168
  // button, so Enter and Space still expand.
158
- launcher.addEventListener("keydown", (event: KeyboardEvent) => {
159
- if (!options.enabled()) {
160
- return;
161
- }
162
- const step = event.shiftKey ? COARSE_STEP : STEP;
163
- const rect = options.rect();
164
- let moved: { left: number; top: number } | null = null;
165
- if (event.key === "ArrowLeft") {
166
- moved = { left: rect.left - step, top: rect.top };
167
- } else if (event.key === "ArrowRight") {
168
- moved = { left: rect.left + step, top: rect.top };
169
- } else if (event.key === "ArrowUp") {
170
- moved = { left: rect.left, top: rect.top - step };
171
- } else if (event.key === "ArrowDown") {
172
- moved = { left: rect.left, top: rect.top + step };
173
- }
174
- if (moved === null) {
175
- return;
176
- }
177
- event.preventDefault();
178
- const next = clampLauncher({ ...rect, ...moved }, options.viewport());
179
- // Live feedback per key event, persistence only when the gesture ends: a
180
- // held arrow repeats at the OS rate, and committing here would put that
181
- // many storage writes behind a single press.
182
- options.apply(next.left, next.top);
183
- pending = next;
184
- });
169
+ launcher.addEventListener(
170
+ "keydown",
171
+ (event: KeyboardEvent) => {
172
+ if (!options.enabled()) {
173
+ return;
174
+ }
175
+ const step = event.shiftKey ? COARSE_STEP : STEP;
176
+ const rect = options.rect();
177
+ let moved: { left: number; top: number } | null = null;
178
+ if (event.key === "ArrowLeft") {
179
+ moved = { left: rect.left - step, top: rect.top };
180
+ } else if (event.key === "ArrowRight") {
181
+ moved = { left: rect.left + step, top: rect.top };
182
+ } else if (event.key === "ArrowUp") {
183
+ moved = { left: rect.left, top: rect.top - step };
184
+ } else if (event.key === "ArrowDown") {
185
+ moved = { left: rect.left, top: rect.top + step };
186
+ }
187
+ if (moved === null) {
188
+ return;
189
+ }
190
+ event.preventDefault();
191
+ const next = clampLauncher({ ...rect, ...moved }, options.viewport());
192
+ // Live feedback per key event, persistence only when the gesture ends: a
193
+ // held arrow repeats at the OS rate, and committing here would put that
194
+ // many storage writes behind a single press.
195
+ options.apply(next.left, next.top);
196
+ pending = next;
197
+ },
198
+ { signal },
199
+ );
185
200
 
186
201
  // The key coming up ends the gesture, mirroring `pointerup`. `blur` closes
187
202
  // one whose keyup never arrives here -- focus moved on mid-press -- because a
188
203
  // position applied but never committed is a move the host silently forgets.
189
- launcher.addEventListener("keyup", settle);
190
- launcher.addEventListener("blur", settle);
204
+ launcher.addEventListener("keyup", settle, { signal });
205
+ launcher.addEventListener("blur", settle, { signal });
191
206
  }