@artooi/ag-ui-web-component 0.39.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 (46) hide show
  1. package/CHANGELOG.md +155 -1
  2. package/README.md +70 -12
  3. package/dist/ag-ui-web-component.bundle.js +168 -45
  4. package/dist/ag-ui-web-component.bundle.js.map +3 -3
  5. package/dist/core/ag_ui_chat.d.ts +7 -3
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +12 -0
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/client_seed.d.ts +14 -2
  10. package/dist/core/client_seed.d.ts.map +1 -1
  11. package/dist/index.js +636 -231
  12. package/dist/index.js.map +2 -2
  13. package/dist/tools/tool_catalog.d.ts.map +1 -1
  14. package/dist/ui/composer/composer_attachments.d.ts +6 -1
  15. package/dist/ui/composer/composer_attachments.d.ts.map +1 -1
  16. package/dist/ui/composer/composer_voice.d.ts +3 -0
  17. package/dist/ui/composer/composer_voice.d.ts.map +1 -1
  18. package/dist/ui/excerpts/transcript_quote_offer.d.ts +7 -2
  19. package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -1
  20. package/dist/ui/history/conversation_history.d.ts +29 -3
  21. package/dist/ui/history/conversation_history.d.ts.map +1 -1
  22. package/dist/ui/placement/launcher_drag.d.ts +6 -0
  23. package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
  24. package/dist/ui/placement/panel_placement.d.ts +1 -1
  25. package/dist/ui/placement/panel_placement.d.ts.map +1 -1
  26. package/dist/ui/styles.d.ts +1 -1
  27. package/dist/ui/styles.d.ts.map +1 -1
  28. package/dist/ui/transcript/transcript.d.ts +3 -2
  29. package/dist/ui/transcript/transcript.d.ts.map +1 -1
  30. package/dist/ui/ui_strings.d.ts +2 -0
  31. package/dist/ui/ui_strings.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/core/ag_ui_chat.ts +217 -64
  34. package/src/core/agui_client.ts +26 -14
  35. package/src/core/client_seed.ts +14 -2
  36. package/src/tools/tool_catalog.ts +63 -12
  37. package/src/ui/composer/composer_attachments.ts +64 -41
  38. package/src/ui/composer/composer_voice.ts +5 -0
  39. package/src/ui/excerpts/transcript_quote_offer.ts +26 -13
  40. package/src/ui/history/conversation_history.ts +125 -17
  41. package/src/ui/placement/launcher_drag.ts +104 -89
  42. package/src/ui/placement/panel_placement.ts +27 -3
  43. package/src/ui/styles.ts +137 -14
  44. package/src/ui/transcript/transcript.ts +10 -5
  45. package/src/ui/ui_strings.ts +3 -0
  46. package/src/version.ts +1 -1
@@ -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
  }
@@ -149,8 +149,9 @@ export class PanelPlacement {
149
149
  * away and unclickable behind the open panel, so a drag there would move
150
150
  * something nobody can see.
151
151
  */
152
- enableLauncherDrag(): void {
152
+ enableLauncherDrag(signal: AbortSignal): void {
153
153
  enableLauncherDrag(this.#host.launcher, {
154
+ signal,
154
155
  enabled: () => this.#host.collapsed() && this.#launcherDraggable(),
155
156
  rect: () => this.#launcherBox(),
156
157
  viewport: () => this.#viewport(),
@@ -652,9 +653,22 @@ export class PanelPlacement {
652
653
  if (visual === null || visual === undefined) {
653
654
  return;
654
655
  }
655
- if (Math.abs(visual.height - window.innerHeight) < 1) {
656
+ // The layout viewport from the box a fixed element is laid out against,
657
+ // for the reasons #screen gives -- and one more that only a phone shows.
658
+ // While iOS Safari has the visible area panned down to show a focused
659
+ // field, innerHeight reads the layout height less the pan, and goes on
660
+ // reading it until a window scroll the element never hears about. Measured
661
+ // from that, the band below came out as nothing, and at the deepest pan the
662
+ // browser makes -- the one that shows a composer docked at the foot of the
663
+ // screen -- the shorter number equals the visible height, so the check
664
+ // below took every measurement back and put the panel at the layout top at
665
+ // full height, its header above the screen. clientHeight held the layout
666
+ // height throughout.
667
+ const layout = this.#screen().height;
668
+ if (Math.abs(visual.height - layout) < 1) {
656
669
  this.#host.element.style.removeProperty("--ag-ui-visual-viewport-height");
657
670
  this.#host.element.style.removeProperty("--ag-ui-visual-viewport-inset-bottom");
671
+ this.#host.element.style.removeProperty("--ag-ui-visual-viewport-inset-top");
658
672
  return;
659
673
  }
660
674
  this.#host.element.style.setProperty(
@@ -667,11 +681,21 @@ export class PanelPlacement {
667
681
  // the launcher at that corner stay behind the keyboard until this lifts
668
682
  // them. Never negative -- a visual viewport panned up past the layout one
669
683
  // would otherwise pull the panel down off the screen.
670
- const hidden = window.innerHeight - visual.height - visual.offsetTop;
684
+ const hidden = layout - visual.height - visual.offsetTop;
671
685
  this.#host.element.style.setProperty(
672
686
  "--ag-ui-visual-viewport-inset-bottom",
673
687
  `${Math.max(0, Math.round(hidden))}px`,
674
688
  );
689
+ // And what is hidden above it. To show a field the keyboard would cover, a
690
+ // browser pans the visual viewport down the layout one, and a fixed panel
691
+ // anchored at the top stays at the layout top: sized to the visible height,
692
+ // it then shows only its lower part, from the pan down, with its header off
693
+ // the screen and page background under it. Never negative, for the same
694
+ // reason as the band below.
695
+ this.#host.element.style.setProperty(
696
+ "--ag-ui-visual-viewport-inset-top",
697
+ `${Math.max(0, Math.round(visual.offsetTop))}px`,
698
+ );
675
699
  }
676
700
 
677
701
  /**
package/src/ui/styles.ts CHANGED
@@ -180,7 +180,24 @@ export const STYLES = `
180
180
  keyboard changes no viewport-percentage length -- not vh, not dvh, not svh
181
181
  -- so a full-bleed panel on a phone has to be told the height rather than
182
182
  deriving it. The value to publish there is the visual viewport's. */
183
- --_viewport-height: var(--ag-ui-viewport-height, var(--_visual-viewport-height));
183
+ --_viewport-height: var(
184
+ --ag-ui-viewport-height,
185
+ calc(
186
+ min(var(--_visual-viewport-inset-top) + var(--_visual-viewport-height), 100dvh - var(--_viewport-inset-bottom)) -
187
+ var(--_viewport-inset-top) - var(--_keyboard-inset-top)
188
+ )
189
+ );
190
+ /* That default is the host's box cut to the part of the screen the user can
191
+ see: from the top the panel starts at, the host's reserved top moved down
192
+ by the keyboard inset below, to whichever is further up, the bottom of the
193
+ visible area or the host's reserved bottom. Where nothing has measured, the
194
+ visible area is the layout viewport with no pan, and this is the host's
195
+ box exactly.
196
+
197
+ Cut rather than replaced. A measured height used as the whole answer kept
198
+ a bar the host reserved at the top in the position and out of the height,
199
+ so the panel ran that far past the bottom of the visible area, with its
200
+ composer behind the keyboard. */
184
201
  /* The measured height of the part of the screen the user can actually see,
185
202
  written by the element from the visual viewport and falling back to the
186
203
  layout viewport where nothing has measured yet.
@@ -193,11 +210,15 @@ export const STYLES = `
193
210
 
194
211
  Separate from the token above so a host that states the usable height
195
212
  outright still wins: the element writes this one inline, and an inline
196
- value would otherwise outrank the host's own rule. */
197
- --_visual-viewport-height: var(
198
- --ag-ui-visual-viewport-height,
199
- calc(100vh - var(--_viewport-inset-top) - var(--_viewport-inset-bottom))
200
- );
213
+ value would otherwise outrank the host's own rule.
214
+
215
+ The fallback is dvh rather than vh because the browser's own bars do move
216
+ it, where a keyboard does not. iOS Safari resolves vh to the viewport with
217
+ its bars collapsed, so on a page that does not scroll them away a panel
218
+ sized from it ran under the address bar by the bars' height -- 40px on
219
+ the phone measured -- with a docked composer underneath. dvh is the
220
+ viewport with the bars as they are. */
221
+ --_visual-viewport-height: var(--ag-ui-visual-viewport-height, 100dvh);
201
222
  /* How much of the layout viewport is hidden below the visible one, measured
202
223
  and written by the element alongside the height above.
203
224
 
@@ -212,6 +233,24 @@ export const STYLES = `
212
233
  A host wanting no keyboard lift at all sets --ag-ui-keyboard-inset: 0px. */
213
234
  --_keyboard-inset: var(--ag-ui-keyboard-inset, var(--_visual-viewport-inset-bottom));
214
235
  --_visual-viewport-inset-bottom: var(--ag-ui-visual-viewport-inset-bottom, 0px);
236
+ /* How far a panel anchored at the top moves down for a keyboard, below the
237
+ top the host reserved. With the same two-token shape as the lift above;
238
+ 0px keeps the panel at the host's top.
239
+
240
+ To show the field being typed into, a browser pans the visible area down
241
+ the layout viewport, and a fixed element stays against the layout top, so
242
+ a panel that did not move showed only its lower part, from the pan down,
243
+ with its header off the screen and an empty band under it. It moves by
244
+ what the pan goes past the host's reserved top, not by the whole pan: the
245
+ pan scrolls a bar reserved there away with the rest of the page rather
246
+ than pushing it down, and adding the two put the panel a whole bar below
247
+ the visible area. A corner panel anchored at the bottom does not use this,
248
+ because the band below already accounts for the pan. */
249
+ --_keyboard-inset-top: var(--ag-ui-keyboard-inset-top, max(0px, var(--_visual-viewport-inset-top) - var(--_viewport-inset-top)));
250
+ /* How much of the layout viewport is hidden above the visible one: how far
251
+ the browser panned. Measured and written by the element with the two
252
+ above. */
253
+ --_visual-viewport-inset-top: var(--ag-ui-visual-viewport-inset-top, 0px);
215
254
  --_viewport-width: var(
216
255
  --ag-ui-viewport-width,
217
256
  calc(100vw - var(--_viewport-inset-left) - var(--_viewport-inset-right))
@@ -348,7 +387,7 @@ export const STYLES = `
348
387
  }
349
388
 
350
389
  :host([placement="side"]) {
351
- --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
390
+ --_inset: var(--ag-ui-inset, calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
352
391
  --_width: var(--ag-ui-width, 420px);
353
392
  --_height: var(--ag-ui-height, var(--_viewport-height));
354
393
  --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
@@ -358,7 +397,8 @@ export const STYLES = `
358
397
  :host([placement="full"]) {
359
398
  --_inset: var(
360
399
  --ag-ui-inset,
361
- var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) var(--_viewport-inset-left)
400
+ calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right)
401
+ var(--_viewport-inset-bottom) var(--_viewport-inset-left)
362
402
  );
363
403
  --_width: var(--ag-ui-width, var(--_viewport-width));
364
404
  --_height: var(--ag-ui-height, var(--_viewport-height));
@@ -375,7 +415,8 @@ export const STYLES = `
375
415
  :host([placement="page"]) {
376
416
  --_inset: var(
377
417
  --ag-ui-inset,
378
- var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) var(--_viewport-inset-left)
418
+ calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right)
419
+ var(--_viewport-inset-bottom) var(--_viewport-inset-left)
379
420
  );
380
421
  --_width: var(--ag-ui-width, var(--_viewport-width));
381
422
  --_height: var(--ag-ui-height, var(--_viewport-height));
@@ -441,7 +482,7 @@ export const STYLES = `
441
482
  :host([placement=""]:not([data-small-viewport="off"])) {
442
483
  --_inset: var(
443
484
  --ag-ui-inset,
444
- var(--_viewport-inset-top) var(--_viewport-inset-right)
485
+ calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right)
445
486
  calc(var(--_viewport-inset-bottom) + var(--_keyboard-inset))
446
487
  var(--_viewport-inset-left)
447
488
  );
@@ -471,7 +512,7 @@ export const STYLES = `
471
512
  --ag-ui-position: static (and place this element in your own layout) for a
472
513
  host-managed push instead. */
473
514
  :host([placement="sidebar"]) {
474
- --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
515
+ --_inset: var(--ag-ui-inset, calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
475
516
  --_width: var(--ag-ui-width, 420px);
476
517
  --_height: var(--ag-ui-height, var(--_viewport-height));
477
518
  --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
@@ -480,7 +521,11 @@ export const STYLES = `
480
521
  }
481
522
 
482
523
  :host([placement="sidebar"][data-side="left"]) {
483
- --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) auto var(--_viewport-inset-bottom) var(--_viewport-inset-left));
524
+ --_inset: var(
525
+ --ag-ui-inset,
526
+ calc(var(--_viewport-inset-top) + var(--_keyboard-inset-top)) auto var(--_viewport-inset-bottom)
527
+ var(--_viewport-inset-left)
528
+ );
484
529
  }
485
530
 
486
531
  /* The docked panel is pinned to the edge it docks against rather than filling
@@ -1237,6 +1282,64 @@ export const STYLES = `
1237
1282
  border-block-start-color: transparent;
1238
1283
  }
1239
1284
 
1285
+ /* On a phone the composer stays at the foot and the greeting takes the room
1286
+ above it, which is the other way round from everything above.
1287
+
1288
+ Centring is a shape for a screen with room to spare: the composer reads as
1289
+ the one thing on the page, and whichever way the rows grow there is space
1290
+ left on both sides of them. A phone has no space to spare, and the keyboard
1291
+ is what takes it -- opening one leaves a 426px-tall visible area with the
1292
+ composer halfway up it and an empty band underneath, which is the half of
1293
+ the screen a thumb is already resting on. Docked, the field sits directly
1294
+ over the keyboard, where every chat on the platform puts it, and the
1295
+ greeting keeps the rest.
1296
+
1297
+ Both rules restate the selector they override, with the breakpoint's own
1298
+ opt-out added: this is part of the shape a host keeps its desktop layout
1299
+ instead of, so it goes the same way as the rest of that shape. Restating
1300
+ rather than adding conditions to the originals keeps each of those rules
1301
+ readable as one decision, and source order settles the pair. */
1302
+ @media (max-width: 600px) {
1303
+ :host([data-empty]:not([data-restoring]):not([data-small-viewport="off"])) .chat::after {
1304
+ flex-grow: 0;
1305
+ }
1306
+
1307
+ /* The empty region fills the transcript instead of being centred in it as a
1308
+ block, so the two things in it can go to different places: the prompts the
1309
+ host offers to the foot, against the composer, and the greeting to the
1310
+ middle of what they leave.
1311
+
1312
+ A prompt chip is a way into the conversation. Against the field it starts,
1313
+ it reads as one; under the greeting halfway up the panel, with the field
1314
+ at the foot, it reads as decoration next to something else. Nothing here
1315
+ moves them on a screen where the composer is still centred: there the
1316
+ whole region hangs at the foot of the upper half, directly over the
1317
+ composer, and the prompts are already against it.
1318
+
1319
+ Auto block margins on the greeting are what centre it, and they take the
1320
+ free space whether or not there are prompts below to leave any, so a host
1321
+ that offers none gets a greeting in the middle of the transcript rather
1322
+ than one clinging to the composer.
1323
+ The region declares a display here, which outranks the rule collapsing a
1324
+ hidden one, so it says it is not hidden itself. Nothing sets the two
1325
+ apart today -- the transcript writes the hidden property and the host's
1326
+ data-empty from one expression -- and that is exactly why: a rule holding
1327
+ because of how a method in another file happens to be written is a rule
1328
+ holding by luck. */
1329
+ :host([placement="page"][data-empty]:not([data-greeting="off"]):not([data-restoring]):not([data-small-viewport="off"])) .empty:not([hidden]),
1330
+ :host([placement="embedded"][data-greeting][data-empty]:not([data-greeting="off"]):not([data-restoring]):not([data-small-viewport="off"])) .empty:not([hidden]) {
1331
+ margin: 0;
1332
+ flex: 1;
1333
+ display: flex;
1334
+ flex-direction: column;
1335
+ }
1336
+
1337
+ :host([placement="page"][data-empty]:not([data-greeting="off"]):not([data-restoring]):not([data-small-viewport="off"])) .greeting,
1338
+ :host([placement="embedded"][data-greeting][data-empty]:not([data-greeting="off"]):not([data-restoring]):not([data-small-viewport="off"])) .greeting {
1339
+ margin-block: auto;
1340
+ }
1341
+ }
1342
+
1240
1343
  /* Nor does the greeting paint while a restore is in flight. Hidden rather than
1241
1344
  removed, so it keeps its place and nothing around it moves when it shows. */
1242
1345
  :host([data-restoring]) .greeting {
@@ -1720,6 +1823,17 @@ export const STYLES = `
1720
1823
  gap: 3px;
1721
1824
  }
1722
1825
 
1826
+ /* A call with no arguments drops the region rather than framing an empty
1827
+ object, and a settled card drops the result region until it has one -- both
1828
+ by setting the hidden property, and an author display beats the user-agent
1829
+ rule for that attribute. Without this the region kept laying out: 42px of
1830
+ card holding the ARGUMENTS heading over nothing, on every call the agent
1831
+ made with no arguments, in the display mode that shows arguments by
1832
+ default. */
1833
+ .tool-call-section[hidden] {
1834
+ display: none;
1835
+ }
1836
+
1723
1837
  /* The heading that tells the two payloads apart. Without it the arguments and
1724
1838
  the result were one run of text and a reader had to guess the boundary. */
1725
1839
  .tool-call-section-label {
@@ -2390,8 +2504,8 @@ export const STYLES = `
2390
2504
  display: none;
2391
2505
  }
2392
2506
 
2393
- /* The mic button's mount point; filled only once #wireVoice mounts the
2394
- control. */
2507
+ /* The mic button's mount point; filled only once ComposerVoice.wire mounts
2508
+ the control, which it skips unless transcription is configured. */
2395
2509
  .voice-slot {
2396
2510
  display: contents;
2397
2511
  }
@@ -3068,6 +3182,15 @@ export const STYLES = `
3068
3182
  padding: 10px 12px;
3069
3183
  }
3070
3184
 
3185
+ /* The row is hidden whenever the host offers no skills, which is most elements
3186
+ most of the time, and an author display beats the user-agent rule for the
3187
+ hidden attribute. Without this it kept its padding: 20px of panel between
3188
+ the transcript and the composer, under every placement, reading as the gap
3189
+ under whatever the transcript ends with. */
3190
+ .skill-chips[hidden] {
3191
+ display: none;
3192
+ }
3193
+
3071
3194
  .skill-chip {
3072
3195
  border: 1px solid var(--_border);
3073
3196
  border-radius: 999px;
@@ -82,12 +82,17 @@ export class Transcript {
82
82
  /**
83
83
  * Start following the foot of the list, and wire the jump button to it.
84
84
  * Called while rendering rather than at construction: the viewport has to
85
- * exist and the observer has to have something to observe.
85
+ * exist and the observer has to have something to observe. The button
86
+ * outlives a connection, so its listener goes under `signal`.
86
87
  */
87
- mountScroller(jumpButton: HTMLButtonElement): void {
88
- jumpButton.addEventListener("click", () => {
89
- this.jump();
90
- });
88
+ mountScroller(jumpButton: HTMLButtonElement, signal: AbortSignal): void {
89
+ jumpButton.addEventListener(
90
+ "click",
91
+ () => {
92
+ this.jump();
93
+ },
94
+ { signal },
95
+ );
91
96
  this.#scroller = createStickToBottom({
92
97
  viewport: this.#host.messages,
93
98
  onMissedContent: (missed) => {
@@ -118,6 +118,8 @@ export interface UiStrings {
118
118
  notConnected: string;
119
119
  /** Composer hint when a run continuation was picked with an empty composer. */
120
120
  continueNeedsTurn: string;
121
+ /** Composer hint when a run continuation was picked while a run is in flight. */
122
+ continueWhileRunning: string;
121
123
 
122
124
  // ── Composer ────────────────────────────────────────────────────────────────
123
125
  /** `aria-label` of the message textarea. */
@@ -385,6 +387,7 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
385
387
  "{n} file still uploading — it was not sent with this message and is still attached.",
386
388
  notConnected: "This chat isn’t connected to an agent, so the message wasn’t sent.",
387
389
  continueNeedsTurn: "Type the next turn in the composer first, then pick a run to continue.",
390
+ continueWhileRunning: "Wait for the current answer or stop it, then pick a run to continue.",
388
391
  skillNeeds: "“{title}” needs {fields} — fill it in below, then send.",
389
392
 
390
393
  message: "Message",
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.39.0";
1
+ export const VERSION: string = "0.40.0";