@artooi/ag-ui-web-component 0.45.0 → 0.47.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 (91) hide show
  1. package/CHANGELOG.md +146 -1
  2. package/README.md +191 -25
  3. package/dist/ag-ui-web-component.bundle.js +399 -157
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +14 -1
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +47 -4
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/chat_registry.d.ts +23 -0
  10. package/dist/core/chat_registry.d.ts.map +1 -0
  11. package/dist/core/events/run_finished_detail.d.ts +6 -0
  12. package/dist/core/events/run_finished_detail.d.ts.map +1 -1
  13. package/dist/core/events/thread_detail.d.ts +27 -0
  14. package/dist/core/events/thread_detail.d.ts.map +1 -0
  15. package/dist/core/run_handlers.d.ts +6 -1
  16. package/dist/core/run_handlers.d.ts.map +1 -1
  17. package/dist/dom/animations.d.ts +21 -6
  18. package/dist/dom/animations.d.ts.map +1 -1
  19. package/dist/dom/highlight_overlay.d.ts +7 -1
  20. package/dist/dom/highlight_overlay.d.ts.map +1 -1
  21. package/dist/dom/ring_color.d.ts +14 -0
  22. package/dist/dom/ring_color.d.ts.map +1 -0
  23. package/dist/index.d.ts +3 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +857 -86
  26. package/dist/index.js.map +4 -4
  27. package/dist/tools/tool_catalog.d.ts +5 -0
  28. package/dist/tools/tool_catalog.d.ts.map +1 -1
  29. package/dist/tools/tool_dispatch.d.ts.map +1 -1
  30. package/dist/ui/history/conversation_history.d.ts +10 -1
  31. package/dist/ui/history/conversation_history.d.ts.map +1 -1
  32. package/dist/ui/interrupts/confirmation_styles.d.ts.map +1 -1
  33. package/dist/ui/placement/panel_placement.d.ts +6 -0
  34. package/dist/ui/placement/panel_placement.d.ts.map +1 -1
  35. package/dist/ui/placement/small_viewport_styles.d.ts.map +1 -1
  36. package/dist/ui/placement/surface_for.d.ts +12 -1
  37. package/dist/ui/placement/surface_for.d.ts.map +1 -1
  38. package/dist/ui/progress/thoughts_styles.d.ts.map +1 -1
  39. package/dist/ui/progress/tool_call_card.d.ts +8 -0
  40. package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
  41. package/dist/ui/progress/tool_group_styles.d.ts +2 -0
  42. package/dist/ui/progress/tool_group_styles.d.ts.map +1 -0
  43. package/dist/ui/progress/tool_step_group.d.ts +47 -0
  44. package/dist/ui/progress/tool_step_group.d.ts.map +1 -0
  45. package/dist/ui/progress/tool_step_styles.d.ts.map +1 -1
  46. package/dist/ui/shell/token_styles.d.ts.map +1 -1
  47. package/dist/ui/styles.d.ts.map +1 -1
  48. package/dist/ui/themes/ledger_styles.d.ts.map +1 -1
  49. package/dist/ui/themes/soft_styles.d.ts.map +1 -1
  50. package/dist/ui/transcript/groups_tool_steps.d.ts +13 -0
  51. package/dist/ui/transcript/groups_tool_steps.d.ts.map +1 -0
  52. package/dist/ui/transcript/message_styles.d.ts.map +1 -1
  53. package/dist/ui/transcript/transcript.d.ts +16 -1
  54. package/dist/ui/transcript/transcript.d.ts.map +1 -1
  55. package/dist/ui/ui_strings.d.ts +18 -0
  56. package/dist/ui/ui_strings.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/constants.ts +15 -1
  59. package/src/core/ag_ui_chat.ts +122 -11
  60. package/src/core/chat_registry.ts +53 -0
  61. package/src/core/events/run_finished_detail.ts +6 -0
  62. package/src/core/events/thread_detail.ts +26 -0
  63. package/src/core/run_handlers.ts +9 -4
  64. package/src/dom/animations.ts +30 -31
  65. package/src/dom/highlight_overlay.ts +9 -5
  66. package/src/dom/ring_color.ts +85 -0
  67. package/src/index.ts +3 -0
  68. package/src/tools/tool_catalog.ts +23 -10
  69. package/src/tools/tool_dispatch.ts +26 -11
  70. package/src/ui/history/conversation_history.ts +28 -4
  71. package/src/ui/interrupts/confirmation_styles.ts +0 -2
  72. package/src/ui/placement/embedded_styles.ts +2 -2
  73. package/src/ui/placement/panel_placement.ts +34 -9
  74. package/src/ui/placement/preset_styles.ts +1 -1
  75. package/src/ui/placement/sidebar_styles.ts +1 -1
  76. package/src/ui/placement/small_viewport_styles.ts +5 -0
  77. package/src/ui/placement/surface_for.ts +12 -1
  78. package/src/ui/progress/thoughts_styles.ts +0 -2
  79. package/src/ui/progress/tool_call_card.ts +29 -8
  80. package/src/ui/progress/tool_group_styles.ts +232 -0
  81. package/src/ui/progress/tool_step_group.ts +272 -0
  82. package/src/ui/progress/tool_step_styles.ts +0 -2
  83. package/src/ui/shell/token_styles.ts +4 -2
  84. package/src/ui/styles.ts +5 -0
  85. package/src/ui/themes/ledger_styles.ts +15 -0
  86. package/src/ui/themes/soft_styles.ts +20 -0
  87. package/src/ui/transcript/groups_tool_steps.ts +16 -0
  88. package/src/ui/transcript/message_styles.ts +0 -2
  89. package/src/ui/transcript/transcript.ts +109 -3
  90. package/src/ui/ui_strings.ts +31 -0
  91. package/src/version.ts +1 -1
@@ -0,0 +1,85 @@
1
+ import { ChatRegistration } from "../core/chat_registry.js";
2
+
3
+ /** The public ring token, read from the target and then from the chat. */
4
+ const RING_TOKEN = "--ag-ui-highlight-ring-color";
5
+
6
+ /** The accent a host themes its page with, read from the target. */
7
+ const ACCENT_TOKEN = "--ag-ui-accent";
8
+
9
+ /**
10
+ * The accent the chat actually resolved, its theme's default included. Read
11
+ * through the private alias because the public token is unset on a page that
12
+ * themed nothing, which is exactly the page this exists for.
13
+ */
14
+ const CHAT_ACCENT = "--_accent";
15
+
16
+ /** The package indigo, for a page with no chat and no theming at all. */
17
+ const PACKAGE_ACCENT = "#4f46e5";
18
+
19
+ /**
20
+ * The colour a highlight ring is drawn in when the caller passed none.
21
+ *
22
+ * In order: `--ag-ui-highlight-ring-color` on the target, the same token on
23
+ * the chat, the target's `--ag-ui-accent`, the chat's resolved accent, the
24
+ * package indigo. The target's accent stays above the chat's own default so a
25
+ * host that themes `--ag-ui-accent` on its page keeps the rings it has; the
26
+ * ring token sits above both so a host can keep the ring apart from the accent.
27
+ *
28
+ * `chat` defaults to the most recently connected `<ag-ui-chat>`, the common
29
+ * page with one widget. A page with two passes the one the ring speaks for.
30
+ */
31
+ export function ringColor(target: HTMLElement, chat?: HTMLElement): string {
32
+ const onTarget = readToken(target, RING_TOKEN);
33
+ if (onTarget !== "") {
34
+ return onTarget;
35
+ }
36
+ const owner = chat ?? ChatRegistration.latest();
37
+ const onChat = owner === null ? "" : resolveOn(owner, RING_TOKEN);
38
+ if (onChat !== "") {
39
+ return onChat;
40
+ }
41
+ const accent = readToken(target, ACCENT_TOKEN);
42
+ if (accent !== "") {
43
+ return accent;
44
+ }
45
+ const chatAccent = owner === null ? "" : resolveOn(owner, CHAT_ACCENT);
46
+ return chatAccent === "" ? PACKAGE_ACCENT : chatAccent;
47
+ }
48
+
49
+ function readToken(el: HTMLElement, name: string): string {
50
+ return window.getComputedStyle(el).getPropertyValue(name).trim();
51
+ }
52
+
53
+ /**
54
+ * The custom property `name` on `chat`, as the colour it computes to there, or
55
+ * `""` when it is unset or not a colour.
56
+ *
57
+ * Resolved inside the chat rather than handed to the target, because the
58
+ * chat's colours are `light-dark()` pairs and a custom property keeps them
59
+ * unresolved: written onto an element in the page, the pair would pick its
60
+ * half by the page's colour scheme, so a dark widget on a light page would ring
61
+ * in its light-mode accent. A probe inside the shadow tree inherits the chat's
62
+ * scheme, and its computed `color` is the resolved value.
63
+ *
64
+ * An element with no open shadow root (a host passing something other than a
65
+ * chat) is read as it is, unresolved; that is the best it offers.
66
+ */
67
+ function resolveOn(chat: HTMLElement, name: string): string {
68
+ const value = readToken(chat, name);
69
+ const root = chat.shadowRoot;
70
+ if (value === "" || root === null) {
71
+ return value;
72
+ }
73
+ const probe = document.createElement("span");
74
+ probe.hidden = true;
75
+ probe.style.color = value;
76
+ // An unparseable value leaves the declaration empty, and the probe would
77
+ // then report the colour it inherits instead of saying it has none.
78
+ if (probe.style.color === "") {
79
+ return "";
80
+ }
81
+ root.appendChild(probe);
82
+ const resolved = window.getComputedStyle(probe).color;
83
+ probe.remove();
84
+ return resolved;
85
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  STATE_EVENT,
18
18
  SUBMIT_EVENT,
19
19
  SUGGESTIONS_ACTIVITY_TYPE,
20
+ THREAD_EVENT,
20
21
  TOGGLE_EVENT,
21
22
  TOOL_CALL_STATUS,
22
23
  TOOL_DISPLAY,
@@ -63,6 +64,7 @@ export type { InvalidateDetail } from "./core/events/invalidate_detail.js";
63
64
  export type { RunFinishedDetail } from "./core/events/run_finished_detail.js";
64
65
  export type { StateDetail } from "./core/events/state_detail.js";
65
66
  export type { SubmitDetail } from "./core/events/submit_detail.js";
67
+ export type { ThreadDetail } from "./core/events/thread_detail.js";
66
68
  export type { ToggleDetail } from "./core/events/toggle_detail.js";
67
69
  export type { ToolRun } from "./core/events/tool_run.js";
68
70
  export type { UnreadDetail } from "./core/events/unread_detail.js";
@@ -89,6 +91,7 @@ export {
89
91
  type PressOptions,
90
92
  prefersReducedMotion,
91
93
  pressThenClick,
94
+ type RingOwnerOptions,
92
95
  type ScrollOptions,
93
96
  type SelectOptions,
94
97
  scrollIntoCenterView,
@@ -36,6 +36,11 @@ export interface ToolCatalogHost {
36
36
  readonly askUserRenderer: () => QuestionRenderer | null;
37
37
  /** The decision a run is suspended on, which a Stop abandons. */
38
38
  readonly decision: PendingDecision;
39
+ /**
40
+ * Mark the card of ``callId`` as asking the user, or no longer. The call id is
41
+ * absent for a handler run outside a tool call, which has no card to mark.
42
+ */
43
+ readonly setAsking: (callId: string | undefined, asking: boolean) => void;
39
44
  /** The current answer group, opening one if none is open. */
40
45
  readonly ensureGroup: () => HTMLDivElement;
41
46
  /** The resolved string table. */
@@ -293,17 +298,25 @@ export class ToolCatalog {
293
298
  // A host-supplied renderer takes full control of the UI.
294
299
  const renderer = this.#host.askUserRenderer();
295
300
  let answer: string;
296
- if (renderer === null) {
297
- answer = await builtIn();
298
- } else {
299
- // Awaited here rather than inside a helper, so an answering renderer
300
- // takes exactly as many turns to be heard as it always did.
301
- try {
302
- // Called on the element, as `this.askUserRenderer(...)` always was.
303
- answer = await renderer.call(this.#host.element, request, { signal });
304
- } catch (error) {
305
- answer = await this.#afterRendererFailed(error, signal, callId, builtIn);
301
+ // The card says it is asking for as long as a question is open on it, an
302
+ // answer, a Stop and a renderer that failed alike, so the step group's row
303
+ // does not read as running while the run waits on a person.
304
+ this.#host.setAsking(callId, true);
305
+ try {
306
+ if (renderer === null) {
307
+ answer = await builtIn();
308
+ } else {
309
+ // Awaited here rather than inside a helper, so an answering renderer
310
+ // takes exactly as many turns to be heard as it always did.
311
+ try {
312
+ // Called on the element, as `this.askUserRenderer(...)` always was.
313
+ answer = await renderer.call(this.#host.element, request, { signal });
314
+ } catch (error) {
315
+ answer = await this.#afterRendererFailed(error, signal, callId, builtIn);
316
+ }
306
317
  }
318
+ } finally {
319
+ this.#host.setAsking(callId, false);
307
320
  }
308
321
  this.#host.decision.close();
309
322
  this.#host.updateEmptyState();
@@ -166,6 +166,7 @@ export class ToolDispatch {
166
166
  // transcript places itself against its own card, and by the time it runs the
167
167
  // card is no longer reachable by id.
168
168
  this.#host.transcript.setCardElement(call.id, card.element);
169
+ this.#host.transcript.holdRunning(call.id, card);
169
170
  // Scoped out of this round's catalog ⇒ not a frontend tool of ours, for
170
171
  // this round. A host that offers `delete_record` only on the page where
171
172
  // deleting makes sense has said something about *this* run, and a call
@@ -254,17 +255,26 @@ export class ToolDispatch {
254
255
  // to the message list made it a sibling *after* the group, so anything
255
256
  // that streamed afterwards rendered above it and the prompt drifted to
256
257
  // the foot of the turn no matter when it was asked.
257
- const decision = requestConfirmation(this.#host.transcript.ensureGroup(), request, {
258
- signal,
259
- strings: this.#host.strings(),
260
- // Offered only where it can be honoured -- see `#confirmationRule`.
261
- ...(rule === "destructive"
262
- ? { onAlwaysAllow: () => this.#sessionApproved.add(call.name) }
263
- : {}),
264
- });
265
- this.#host.transcript.updateEmptyState();
266
- this.#host.transcript.follow();
267
- const accepted = await decision;
258
+ //
259
+ // The card says it is asking for as long as the prompt is open, however it
260
+ // ends, so a step group's row reads "waiting for you" rather than running.
261
+ card.setAsking(true);
262
+ let accepted: boolean;
263
+ try {
264
+ const decision = requestConfirmation(this.#host.transcript.ensureGroup(), request, {
265
+ signal,
266
+ strings: this.#host.strings(),
267
+ // Offered only where it can be honoured -- see `#confirmationRule`.
268
+ ...(rule === "destructive"
269
+ ? { onAlwaysAllow: () => this.#sessionApproved.add(call.name) }
270
+ : {}),
271
+ });
272
+ this.#host.transcript.updateEmptyState();
273
+ this.#host.transcript.follow();
274
+ accepted = await decision;
275
+ } finally {
276
+ card.setAsking(false);
277
+ }
268
278
  this.#host.decision.close();
269
279
  // A handover while the card was open declines it, as any Stop does, but
270
280
  // the transcript the decline would be drawn into is the next principal's
@@ -458,6 +468,11 @@ export class ToolDispatch {
458
468
  request.args = card.args;
459
469
  }
460
470
  card?.mark(TOOL_CALL_STATUS.DEFERRED);
471
+ // Before either renderer runs: the built-in card focuses its approve
472
+ // button as it is drawn, and a folded step group has no box to focus in.
473
+ if (card !== undefined) {
474
+ this.#host.transcript.revealStep(card);
475
+ }
461
476
  // The built-in card renders into the gated call's own card, falling back
462
477
  // to the answer group when the interrupt names no call we hold one for.
463
478
  const builtIn = (): Promise<boolean> =>
@@ -9,6 +9,7 @@ import type {
9
9
  ClientConversationStore,
10
10
  NavigationCheckpoint,
11
11
  } from "../../core/conversation_store.js";
12
+ import type { ThreadDetail } from "../../core/events/thread_detail.js";
12
13
  import type { MessageRole } from "../../core/message_role.js";
13
14
  import { RunIndex } from "../../core/run_index.js";
14
15
  import { toolStatusFromOutcome } from "../../core/tool_outcome.js";
@@ -119,6 +120,11 @@ export interface ConversationHistoryHost {
119
120
  readonly resetState: () => void;
120
121
  /** Swap the composer between Send and Stop, which the composer owns. */
121
122
  readonly setRunning: (running: boolean) => void;
123
+ /**
124
+ * Say the conversation on screen changed, once its transcript is replaced.
125
+ * The element fills in the thread it replaced, which it last reported.
126
+ */
127
+ readonly threadChanged: (threadId: string, reason: ThreadDetail["reason"]) => void;
122
128
  }
123
129
 
124
130
  /**
@@ -335,7 +341,7 @@ export class ConversationHistory {
335
341
  this.#host.conversationStore().setActiveThread(threadId);
336
342
  this.#threadId = threadId;
337
343
  this.#host.setRunning(false);
338
- await this.rehydrate();
344
+ await this.rehydrate("switch");
339
345
  }
340
346
 
341
347
  /** Rename a thread, then reload the list that shows its title. */
@@ -355,6 +361,8 @@ export class ConversationHistory {
355
361
  this.#host.resetState();
356
362
  this.adoptActiveThread();
357
363
  this.#host.setRunning(false);
364
+ // No replay to wait for: the thread that took its place is a fresh one.
365
+ this.#host.threadChanged(this.#threadId, "delete");
358
366
  }
359
367
  void this.refreshDrawer();
360
368
  }
@@ -628,8 +636,11 @@ export class ConversationHistory {
628
636
  * for the run it would have resumed -- unless what replaced it is a restore
629
637
  * of the same conversation, still current, which answers for that run
630
638
  * instead; see `#forgetStopped`.
639
+ *
640
+ * `reason` is what the thread event this restore ends with reports as why
641
+ * the conversation changed. A restore that is stopped reports nothing.
631
642
  */
632
- async rehydrate(): Promise<void> {
643
+ async rehydrate(reason: ThreadDetail["reason"]): Promise<void> {
633
644
  // Claimed before awaiting, and checked by `#restoring` wherever the restore
634
645
  // has waited or run host code. With a slow remote store, picking thread B
635
646
  // then C interleaved both replays into one transcript, and New chat drew
@@ -727,11 +738,24 @@ export class ConversationHistory {
727
738
  }
728
739
  }
729
740
  }
741
+ if (checkpoint === null) {
742
+ this.#noticeIfRunUnfinished(messages);
743
+ }
744
+ // Said once the transcript is the restored one, and only by a restore
745
+ // still current: one that was stopped returned above, so two quick
746
+ // switches report the second alone. Before a resume, because the resume
747
+ // is a run, and waiting for it reported the change a whole answer late.
748
+ this.#host.threadChanged(threadId, reason);
730
749
  if (checkpoint !== null) {
750
+ // A listener is host code, and can stop the restore as a replayed
751
+ // renderer can. Resuming then answered the call into the conversation
752
+ // that replaced it, and left the checkpoint for a later visit to resume.
753
+ if (!this.#restoring(generation)) {
754
+ this.#forgetStopped(store, threadId, checkpoint);
755
+ return;
756
+ }
731
757
  await this.#resumeFrom(checkpoint, generation);
732
- return;
733
758
  }
734
- this.#noticeIfRunUnfinished(messages);
735
759
  } finally {
736
760
  // The resumed run starting has let go of the hold already, as a send's
737
761
  // does, and a request that then fails still started one: the client
@@ -28,8 +28,6 @@ export const CONFIRMATION_STYLES: string = `
28
28
  padding: 8px 10px;
29
29
  max-height: 140px;
30
30
  overflow: auto;
31
- /* Scrolling past the end of this must not scroll the page behind it. */
32
- overscroll-behavior: contain;
33
31
  font-size: 12px;
34
32
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
35
33
  background: var(--_assistant-bg);
@@ -4,8 +4,8 @@ export const EMBEDDED_STYLES: string = `
4
4
  the widget lives in the host's own layout (fixes overlay/z-index clashes). */
5
5
  :host([placement="embedded"]) {
6
6
  --_position: var(--ag-ui-position, static);
7
- --_width: var(--ag-ui-width, 100%);
8
- --_height: var(--ag-ui-height, 100%);
7
+ --_width: var(--_drag-width, var(--ag-ui-width, 100%));
8
+ --_height: var(--_drag-height, var(--ag-ui-height, 100%));
9
9
  --_max-width: var(--ag-ui-max-width, 100%);
10
10
  --_max-height: var(--ag-ui-max-height, 100%);
11
11
  --_shadow: var(--ag-ui-shadow, none);
@@ -27,6 +27,17 @@ import { surfaceFor } from "./surface_for.js";
27
27
  /** Per-tab persistence key for a dragged panel size. */
28
28
  const SIZE_KEY = "ag-ui-chat:size";
29
29
 
30
+ /**
31
+ * The private properties a dragged size lives in. They are not the public
32
+ * `--ag-ui-width` / `--ag-ui-height` tokens, which belong to the host: an
33
+ * inline write there outranked every host rule and was read back by the
34
+ * small-viewport block, so a size dragged in a wide window leaked into the
35
+ * phone shape. The placement rules read these before the host token; the
36
+ * small-viewport block does not read them at all.
37
+ */
38
+ const DRAG_WIDTH = "--_drag-width";
39
+ const DRAG_HEIGHT = "--_drag-height";
40
+
30
41
  /** Per-tab persistence key for a dragged launcher position. */
31
42
  const LAUNCHER_KEY = "ag-ui-chat:launcher";
32
43
 
@@ -189,6 +200,20 @@ export class PanelPlacement {
189
200
  this.#applySize(this.#readSize());
190
201
  }
191
202
 
203
+ /**
204
+ * Drop the dragged size and its stored copy, so the placement's own size and
205
+ * the host's tokens apply again. For a host entering a sized state of its
206
+ * own, which a user's drag would otherwise outrank.
207
+ */
208
+ resetSize(): void {
209
+ this.#host.element.style.removeProperty(DRAG_WIDTH);
210
+ this.#host.element.style.removeProperty(DRAG_HEIGHT);
211
+ this.#host.clearPreference(SIZE_KEY);
212
+ // The anchor was measured against the dragged box; the resting one may
213
+ // pin different edges.
214
+ this.syncResizeAnchor();
215
+ }
216
+
192
217
  /**
193
218
  * A zero-sized box carrying the host's viewport insets as padding, so they
194
219
  * can be read back as used pixel lengths. See the `.viewport-probe` rule for
@@ -414,8 +439,8 @@ export class PanelPlacement {
414
439
  */
415
440
  #measureAnchor(): ResizeAnchor {
416
441
  const before = this.#host.element.getBoundingClientRect();
417
- const width = this.#host.element.style.getPropertyValue("--ag-ui-width");
418
- const height = this.#host.element.style.getPropertyValue("--ag-ui-height");
442
+ const width = this.#host.element.style.getPropertyValue(DRAG_WIDTH);
443
+ const height = this.#host.element.style.getPropertyValue(DRAG_HEIGHT);
419
444
  // Shrink first. Growing is the obvious probe and cannot answer the question
420
445
  // at a size already resting against max-width or max-height: the box does
421
446
  // not change, no edge moves, and every clamped axis then reads as pinned on
@@ -431,8 +456,8 @@ export class PanelPlacement {
431
456
  const grown = shrunk.x === null || shrunk.y === null ? this.#probeAnchor(before, 1) : shrunk;
432
457
  // Restore exactly what was there, including "nothing" — leaving a probe
433
458
  // value behind would pin a panel that had been sizing itself.
434
- this.#restoreProperty("--ag-ui-width", width);
435
- this.#restoreProperty("--ag-ui-height", height);
459
+ this.#restoreProperty(DRAG_WIDTH, width);
460
+ this.#restoreProperty(DRAG_HEIGHT, height);
436
461
  return {
437
462
  // Neither direction moved it: the axis cannot be resized at all, so the
438
463
  // floating default is the best answer available and is the one the
@@ -497,7 +522,7 @@ export class PanelPlacement {
497
522
  /**
498
523
  * Write a dragged size onto the host, on the axes this placement leaves free.
499
524
  *
500
- * Writing the custom property rather than inline `width` / `height` does not
525
+ * Writing a custom property rather than inline `width` / `height` does not
501
526
  * by itself leave placement in charge: an inline custom property still
502
527
  * outranks a `:host([placement=…])` rule setting the same property, so a
503
528
  * height dragged while floating would cap a docked sidebar asking for
@@ -518,10 +543,10 @@ export class PanelPlacement {
518
543
  // symmetry nobody has asked for. The limit that matters, staying inside
519
544
  // what the host left free, is enforced above.
520
545
  if (size.width !== undefined) {
521
- this.#host.element.style.setProperty("--ag-ui-width", `${size.width}px`);
546
+ this.#host.element.style.setProperty(DRAG_WIDTH, `${size.width}px`);
522
547
  }
523
548
  if (size.height !== undefined && axis === "both") {
524
- this.#host.element.style.setProperty("--ag-ui-height", `${size.height}px`);
549
+ this.#host.element.style.setProperty(DRAG_HEIGHT, `${size.height}px`);
525
550
  }
526
551
  }
527
552
 
@@ -535,10 +560,10 @@ export class PanelPlacement {
535
560
  releaseOwnedAxes(): void {
536
561
  const axis = this.#resizeAxis();
537
562
  if (axis !== "both") {
538
- this.#host.element.style.removeProperty("--ag-ui-height");
563
+ this.#host.element.style.removeProperty(DRAG_HEIGHT);
539
564
  }
540
565
  if (axis === "none") {
541
- this.#host.element.style.removeProperty("--ag-ui-width");
566
+ this.#host.element.style.removeProperty(DRAG_WIDTH);
542
567
  }
543
568
  }
544
569
 
@@ -12,7 +12,7 @@ export const PRESET_STYLES: string = `
12
12
 
13
13
  :host([placement="side"]) {
14
14
  --_inset: var(--ag-ui-inset, calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
15
- --_width: var(--ag-ui-width, 420px);
15
+ --_width: var(--_drag-width, var(--ag-ui-width, 420px));
16
16
  --_height: var(--ag-ui-height, var(--_viewport-height));
17
17
  --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
18
18
  --_panel-radius: var(--ag-ui-panel-radius, 0);
@@ -7,7 +7,7 @@ export const SIDEBAR_STYLES: string = `
7
7
  host-managed push instead. */
8
8
  :host([placement="sidebar"]) {
9
9
  --_inset: var(--ag-ui-inset, calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
10
- --_width: var(--ag-ui-width, 420px);
10
+ --_width: var(--_drag-width, var(--ag-ui-width, 420px));
11
11
  --_height: var(--ag-ui-height, var(--_viewport-height));
12
12
  --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
13
13
  --_panel-radius: var(--ag-ui-panel-radius, 0);
@@ -17,6 +17,11 @@ export const SMALL_VIEWPORT_STYLES: string = `
17
17
  fine-pointered and small, and conflating the two gets both wrong. What the
18
18
  pointer decides is which controls make sense, further down.
19
19
 
20
+ The size the user dragged is deliberately not read here. The desktop
21
+ placements read it before the host token, and this block restates the sizes
22
+ without it, so a persisted or freshly dragged size cannot change the shape
23
+ of a phone, and a window narrowed after a drag goes full-bleed.
24
+
20
25
  Two placements are left alone. "page" is already this shape. "embedded" sits
21
26
  in a box the host sized and placed, and taking that over would break the app
22
27
  shell it was embedded into -- the host is the only party that knows whether
@@ -3,6 +3,13 @@ import type { ResizeAxis } from "./resize_handle.js";
3
3
  /**
4
4
  * What a collapsed panel shrinks to.
5
5
  *
6
+ * Only `null` versus not-`null` is read today: every caller asks whether the
7
+ * surface can collapse at all, never which target it collapses to. The three
8
+ * names below therefore describe the surface rather than drive it, and nothing
9
+ * relies on them (changing one fails only the test of this record). The
10
+ * bottom-bar collapse is expected to read or replace them, and a reader that
11
+ * does should come with a test outside this record's own.
12
+ *
6
13
  * - `launcher` -- the round button in a corner.
7
14
  * - `rail` -- the slim full-height edge the sidebar docks against; the same
8
15
  * launcher element, shaped by the stylesheet.
@@ -29,7 +36,11 @@ export type CollapseTarget = "launcher" | "rail" | "header" | null;
29
36
  * under this directory); this is the part of a surface that code decides.
30
37
  */
31
38
  export interface Surface {
32
- /** What collapsing shrinks the panel to; `null` when it cannot collapse. */
39
+ /**
40
+ * What collapsing shrinks the panel to; `null` when it cannot collapse. Only
41
+ * the `null` test is authoritative: the non-null names are descriptive (see
42
+ * {@link CollapseTarget}), and nothing branches on which one a surface has.
43
+ */
33
44
  readonly collapseTo: CollapseTarget;
34
45
  /**
35
46
  * Whether a gesture may move the launcher. The rest have nowhere to put it:
@@ -48,8 +48,6 @@ export const THOUGHTS_STYLES: string = `
48
48
  border-left: 2px solid var(--_border);
49
49
  max-height: 220px;
50
50
  overflow: auto;
51
- /* Scrolling past the end of this must not scroll the page behind it. */
52
- overscroll-behavior: contain;
53
51
  white-space: pre-wrap;
54
52
  word-break: break-word;
55
53
  font-family: inherit;
@@ -197,9 +197,7 @@ export class ToolCallCard {
197
197
 
198
198
  this.element = document.createElement("div");
199
199
  this.element.className = "tool-call";
200
- this.element.setAttribute("part", "tool-card");
201
200
  this.element.setAttribute("data-tool-name", name);
202
- this.element.setAttribute("data-status", TOOL_CALL_STATUS.PENDING);
203
201
  this.element.setAttribute("data-expanded", "false");
204
202
 
205
203
  const head = document.createElement("div");
@@ -221,8 +219,7 @@ export class ToolCallCard {
221
219
 
222
220
  this.#status = document.createElement("span");
223
221
  this.#status.className = "tool-call-status";
224
- this.#status.setAttribute("part", "tool-card-status");
225
- this.#status.textContent = statusLabels(strings)[TOOL_CALL_STATUS.PENDING];
222
+ this.#setStatus(TOOL_CALL_STATUS.PENDING);
226
223
 
227
224
  this.#decision = document.createElement("span");
228
225
  this.#decision.className = "tool-call-decision";
@@ -283,8 +280,18 @@ export class ToolCallCard {
283
280
  if (this.#settled) {
284
281
  return;
285
282
  }
286
- this.element.setAttribute("data-status", status);
287
- this.#status.textContent = statusLabels(this.#strings)[status];
283
+ this.#setStatus(status);
284
+ }
285
+
286
+ /**
287
+ * Say whether a prompt is open on this call, a question it asked or a
288
+ * confirmation it needs. A flag of its own beside the status, which stays
289
+ * `pending`: the call is still running as far as the status goes, and a card
290
+ * display mode that reads the status draws it exactly as before. The step
291
+ * group reads the flag to tell a call waiting on a person from one working.
292
+ */
293
+ setAsking(asking: boolean): void {
294
+ this.element.toggleAttribute("data-asking", asking);
288
295
  }
289
296
 
290
297
  /**
@@ -315,8 +322,7 @@ export class ToolCallCard {
315
322
  return;
316
323
  }
317
324
  this.#settled = true;
318
- this.element.setAttribute("data-status", status);
319
- this.#status.textContent = statusLabels(this.#strings)[status];
325
+ this.#setStatus(status);
320
326
  this.#resultLabel.textContent = resultLabels(this.#strings)[status];
321
327
  this.#renderPayload(
322
328
  this.#resultBody,
@@ -326,6 +332,21 @@ export class ToolCallCard {
326
332
  this.#resultSection.hidden = false;
327
333
  }
328
334
 
335
+ /**
336
+ * Write the status everywhere it is read from, in one place so they cannot
337
+ * disagree: `data-status` for the shadow CSS, the pill's text, and the part
338
+ * lists. `::part()` takes no attribute selector, so the card and its pill also
339
+ * name the status as a part (`tool-card-error`, `tool-card-status-error`), the
340
+ * modifier convention a run notice uses, and a host styles failures alone.
341
+ * Setting the whole list replaces the previous modifier.
342
+ */
343
+ #setStatus(status: ToolCallStatus): void {
344
+ this.element.setAttribute("data-status", status);
345
+ this.element.setAttribute("part", `tool-card tool-card-${status}`);
346
+ this.#status.setAttribute("part", `tool-card-status tool-card-status-${status}`);
347
+ this.#status.textContent = statusLabels(this.#strings)[status];
348
+ }
349
+
329
350
  /**
330
351
  * Fill one body region, offering it to the host formatter first.
331
352
  *