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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +501 -1
  2. package/README.md +232 -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
package/src/ui/styles.ts CHANGED
@@ -48,6 +48,11 @@ export const STYLES = `
48
48
  /* Message action row: the control box and the mark inside it. The box has a
49
49
  floor of 24px so it stays a reliable target at every density. */
50
50
  --_action-size: var(--ag-ui-action-size, 28px);
51
+ /* The header's own controls. Sized rather than left to their contents: a row
52
+ of buttons each as wide as the glyph inside it comes out uneven, and uneven
53
+ buttons two pixels apart read as one smudge rather than five controls. */
54
+ --_header-btn-size: var(--ag-ui-header-btn-size, 30px);
55
+ --_header-gap: var(--ag-ui-header-gap, 4px);
51
56
  --_action-icon-size: var(--ag-ui-action-icon-size, 15px);
52
57
  --_tooltip-bg: var(--ag-ui-tooltip-bg, #1f2430);
53
58
  --_tooltip-fg: var(--ag-ui-tooltip-fg, #f5f6fa);
@@ -142,6 +147,68 @@ export const STYLES = `
142
147
  --_msg-pad: var(--ag-ui-msg-pad, 8px 12px);
143
148
  --_msg-radius: var(--ag-ui-msg-radius, 14px);
144
149
 
150
+ /* Edges of the viewport the host has already spent, and that a fixed
151
+ placement must therefore stay out of: a sticky nav bar, a docked toolbar,
152
+ a device's safe area. Four longhands rather than one shorthand because a
153
+ custom property is a token stream and CSS cannot index one -- the height
154
+ arithmetic below needs the vertical pair on its own.
155
+
156
+ Every fixed placement derives from these, and the heights subtract them in
157
+ one place. A host that reserved its chrome by restating --ag-ui-inset per
158
+ placement had to keep --ag-ui-height in step by hand, and forgetting it
159
+ overflowed the panel past the bottom of the screen with nothing to say so.
160
+
161
+ These take env(safe-area-inset-*) verbatim, which is what a full-bleed
162
+ placement wants on a device with a notch. */
163
+ --_viewport-inset-top: var(--ag-ui-viewport-inset-top, 0px);
164
+ --_viewport-inset-right: var(--ag-ui-viewport-inset-right, 0px);
165
+ --_viewport-inset-bottom: var(--ag-ui-viewport-inset-bottom, 0px);
166
+ --_viewport-inset-left: var(--ag-ui-viewport-inset-left, 0px);
167
+ /* What is left of the viewport once the host's chrome is taken out, on both
168
+ axes, so a placement never composes this itself and no host can subtract
169
+ an edge from the position and forget it in the height.
170
+
171
+ Settable in their own right, and that is not only convention: an on-screen
172
+ keyboard changes no viewport-percentage length -- not vh, not dvh, not svh
173
+ -- so a full-bleed panel on a phone has to be told the height rather than
174
+ deriving it. The value to publish there is the visual viewport's. */
175
+ --_viewport-height: var(--ag-ui-viewport-height, var(--_visual-viewport-height));
176
+ /* The measured height of the part of the screen the user can actually see,
177
+ written by the element from the visual viewport and falling back to the
178
+ layout viewport where nothing has measured yet.
179
+
180
+ This is the on-screen keyboard, and it needs measuring because no CSS
181
+ length describes it: an on-screen keyboard has no effect on any
182
+ viewport-percentage unit, so 100vh, 100dvh and 100svh are all the same
183
+ number with the keyboard up as without it. A full-bleed panel sized from
184
+ any of them puts its composer behind the keyboard the user is typing into.
185
+
186
+ Separate from the token above so a host that states the usable height
187
+ outright still wins: the element writes this one inline, and an inline
188
+ value would otherwise outrank the host's own rule. */
189
+ --_visual-viewport-height: var(
190
+ --ag-ui-visual-viewport-height,
191
+ calc(100vh - var(--_viewport-inset-top) - var(--_viewport-inset-bottom))
192
+ );
193
+ /* How much of the layout viewport is hidden below the visible one, measured
194
+ and written by the element alongside the height above.
195
+
196
+ A shorter panel is not enough on its own for anything anchored to the
197
+ bottom. A floating widget is positioned against the layout viewport, so
198
+ with a keyboard up its bottom edge -- and the launcher that lives at that
199
+ corner -- sits behind the keyboard however tall the panel is. This is what
200
+ lifts it clear. */
201
+ /* Two tokens, for the same reason the height above has two: the element
202
+ writes the measurement inline, and a host that states its own value needs
203
+ a knob that outranks that write rather than one the next write replaces.
204
+ A host wanting no keyboard lift at all sets --ag-ui-keyboard-inset: 0px. */
205
+ --_keyboard-inset: var(--ag-ui-keyboard-inset, var(--_visual-viewport-inset-bottom));
206
+ --_visual-viewport-inset-bottom: var(--ag-ui-visual-viewport-inset-bottom, 0px);
207
+ --_viewport-width: var(
208
+ --ag-ui-viewport-width,
209
+ calc(100vw - var(--_viewport-inset-left) - var(--_viewport-inset-right))
210
+ );
211
+
145
212
  /* Layout — override from outside to dock the widget anywhere.
146
213
  Set --ag-ui-position: static (and place this element in your own
147
214
  grid/flex layout) to embed it in the page flow instead of floating. */
@@ -149,14 +216,49 @@ export const STYLES = `
149
216
  --_z-index: var(--ag-ui-z-index, 2147483000);
150
217
  --_width: var(--ag-ui-width, 380px);
151
218
  --_height: var(--ag-ui-height, 560px);
152
- --_inset: var(--ag-ui-inset, auto 24px 24px auto);
153
- --_max-width: var(--ag-ui-max-width, calc(100vw - 48px));
154
- --_max-height: var(--ag-ui-max-height, calc(100vh - 48px));
219
+ /* The gutter a resting floating panel keeps between itself and the edge of
220
+ the box the host left free. One number, because the inset below spends it
221
+ and the cap beneath has to know it was spent. */
222
+ --_edge-gutter: var(--ag-ui-edge-gutter, 24px);
223
+ --_inset: var(
224
+ --ag-ui-inset,
225
+ auto calc(var(--_edge-gutter) + var(--_viewport-inset-right))
226
+ calc(var(--_edge-gutter) + var(--_viewport-inset-bottom) + var(--_keyboard-inset)) auto
227
+ );
228
+ /* The cap is what the host left free, less the *one* gutter the inset above
229
+ spends on the anchored edge -- not two, and not none.
230
+
231
+ Two was the first answer and it was felt on one axis only. The arithmetic
232
+ says why: the default panel is 560 tall against a cap of the viewport
233
+ minus 48, which on an 800px screen with a header reserved is 72px of
234
+ headroom, so the height reaches its cap almost immediately; the default
235
+ width is 380 against a cap near 1230, which is 850px nothing ever reaches.
236
+ Once the size is capped a grip cannot grow the panel, so a pull on the
237
+ anchored edge is written as position instead and the panel travels, and a
238
+ pull on the free edge stops a whole gutter short of an edge a drag can
239
+ reach.
240
+
241
+ None was the correction, and it overshot: the panel is anchored bottom-
242
+ right with a gutter already spent there, so a cap of the full usable
243
+ height puts the far edge exactly one gutter outside it. On an 800px screen
244
+ with nothing reserved that is a top of -24 -- the header, and every
245
+ control in it, off the top of the window. Reserve a 120px header and it
246
+ sits inside that instead, which is the one thing the reservation exists to
247
+ prevent.
248
+
249
+ One is the fixed point of both complaints. A resting panel grown to the
250
+ cap runs from the usable near edge to its gutter on the far one, so the
251
+ near edge is reachable by a resize exactly as it is by a drag, and neither
252
+ can put any part of the panel outside the box. */
253
+ --_max-width: var(--ag-ui-max-width, calc(var(--_viewport-width) - var(--_edge-gutter)));
254
+ --_max-height: var(--ag-ui-max-height, calc(var(--_viewport-height) - var(--_edge-gutter)));
155
255
  /* Reading-column width for placement="page" (full-bleed, centred content). */
156
256
  --_content-max-width: var(--ag-ui-content-max-width, 820px);
157
257
  /* Slim rail the sidebar placement collapses to. Only that placement reads
158
258
  it, but it is declared here so every alias has a default in one place. */
159
259
  --_rail-width: var(--ag-ui-rail-width, 52px);
260
+ /* Width of the docked conversation list on a full-page chat. */
261
+ --_threads-rail-width: var(--ag-ui-threads-rail-width, 280px);
160
262
 
161
263
  position: var(--_position);
162
264
  inset: var(--_inset);
@@ -227,23 +329,30 @@ export const STYLES = `
227
329
 
228
330
  /* ── Placement presets ──────────────────────────────────────────────────── */
229
331
  :host([placement="bottom-left"]) {
230
- --_inset: var(--ag-ui-inset, auto auto 24px 24px);
332
+ --_inset: var(
333
+ --ag-ui-inset,
334
+ auto auto calc(var(--_edge-gutter) + var(--_viewport-inset-bottom) + var(--_keyboard-inset))
335
+ calc(var(--_edge-gutter) + var(--_viewport-inset-left))
336
+ );
231
337
  }
232
338
 
233
339
  :host([placement="side"]) {
234
- --_inset: var(--ag-ui-inset, 0 0 0 auto);
340
+ --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
235
341
  --_width: var(--ag-ui-width, 420px);
236
- --_height: var(--ag-ui-height, 100vh);
237
- --_max-height: var(--ag-ui-max-height, 100vh);
342
+ --_height: var(--ag-ui-height, var(--_viewport-height));
343
+ --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
238
344
  --_radius: var(--ag-ui-radius, 0);
239
345
  }
240
346
 
241
347
  :host([placement="full"]) {
242
- --_inset: var(--ag-ui-inset, 0);
243
- --_width: var(--ag-ui-width, 100vw);
244
- --_height: var(--ag-ui-height, 100vh);
245
- --_max-width: var(--ag-ui-max-width, 100vw);
246
- --_max-height: var(--ag-ui-max-height, 100vh);
348
+ --_inset: var(
349
+ --ag-ui-inset,
350
+ var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) var(--_viewport-inset-left)
351
+ );
352
+ --_width: var(--ag-ui-width, var(--_viewport-width));
353
+ --_height: var(--ag-ui-height, var(--_viewport-height));
354
+ --_max-width: var(--ag-ui-max-width, var(--_viewport-width));
355
+ --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
247
356
  --_radius: var(--ag-ui-radius, 0);
248
357
  }
249
358
 
@@ -253,11 +362,14 @@ export const STYLES = `
253
362
  composer rather than a per-row wrapper, so user pills still right-align and
254
363
  the assistant well spans the column. */
255
364
  :host([placement="page"]) {
256
- --_inset: var(--ag-ui-inset, 0);
257
- --_width: var(--ag-ui-width, 100vw);
258
- --_height: var(--ag-ui-height, 100vh);
259
- --_max-width: var(--ag-ui-max-width, 100vw);
260
- --_max-height: var(--ag-ui-max-height, 100vh);
365
+ --_inset: var(
366
+ --ag-ui-inset,
367
+ var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) var(--_viewport-inset-left)
368
+ );
369
+ --_width: var(--ag-ui-width, var(--_viewport-width));
370
+ --_height: var(--ag-ui-height, var(--_viewport-height));
371
+ --_max-width: var(--ag-ui-max-width, var(--_viewport-width));
372
+ --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
261
373
  --_radius: var(--ag-ui-radius, 0);
262
374
  }
263
375
 
@@ -288,28 +400,100 @@ export const STYLES = `
288
400
  max-width: 100%;
289
401
  }
290
402
 
403
+ /* Small viewports: one shape, reached from whichever placement the host chose.
404
+
405
+ A phone is not an eighth placement, it is an override that collapses the
406
+ others onto one of them. The host picked a placement for the desktop it was
407
+ designing; a 380x560 panel with a 24px margin is not a smaller version of
408
+ that decision, it is most of the screen with a frame drawn round it.
409
+
410
+ The breakpoint is a literal because a custom property cannot be read in a
411
+ media query -- which is exactly why there is an opt-out. Everything the block
412
+ sets is a token a host can re-state, but the *trigger* is unreachable, so a
413
+ host whose layout wants a different threshold, or none, sets
414
+ data-small-viewport="off" and keeps its desktop shape at every width. 600px is above every common phone in portrait and below every
415
+ tablet in landscape, and it is a width rather than a pointer test on purpose:
416
+ a touch laptop is coarse-pointered and wide, a narrow desktop window is
417
+ fine-pointered and small, and conflating the two gets both wrong. What the
418
+ pointer decides is which controls make sense, further down.
419
+
420
+ Two placements are left alone. "page" is already this shape. "embedded" sits
421
+ in a box the host sized and placed, and taking that over would break the app
422
+ shell it was embedded into -- the host is the only party that knows whether
423
+ its column should become the whole screen. */
424
+ @media (max-width: 600px) {
425
+ :host([placement="floating"]:not([data-small-viewport="off"])),
426
+ :host([placement="bottom-left"]:not([data-small-viewport="off"])),
427
+ :host([placement="sidebar"]:not([data-small-viewport="off"])),
428
+ :host([placement="side"]:not([data-small-viewport="off"])),
429
+ :host(:not([placement]):not([data-small-viewport="off"])),
430
+ :host([placement=""]:not([data-small-viewport="off"])) {
431
+ --_inset: var(
432
+ --ag-ui-inset,
433
+ var(--_viewport-inset-top) var(--_viewport-inset-right)
434
+ calc(var(--_viewport-inset-bottom) + var(--_keyboard-inset))
435
+ var(--_viewport-inset-left)
436
+ );
437
+ --_width: var(--ag-ui-width, var(--_viewport-width));
438
+ --_height: var(--ag-ui-height, var(--_viewport-height));
439
+ --_max-width: var(--ag-ui-max-width, var(--_viewport-width));
440
+ --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
441
+ --_radius: var(--ag-ui-radius, 0);
442
+ --_shadow: var(--ag-ui-shadow, none);
443
+ }
444
+
445
+ /* Nothing to resize once the panel is the screen, and the grips would sit
446
+ under the thumbs holding the phone. */
447
+ :host([placement="floating"]:not([data-small-viewport="off"])) .resize-handle,
448
+ :host([placement="bottom-left"]:not([data-small-viewport="off"])) .resize-handle,
449
+ :host([placement="sidebar"]:not([data-small-viewport="off"])) .resize-handle,
450
+ :host([placement="side"]:not([data-small-viewport="off"])) .resize-handle,
451
+ :host(:not([placement]):not([data-small-viewport="off"])) .resize-handle,
452
+ :host([placement=""]:not([data-small-viewport="off"])) .resize-handle {
453
+ display: none;
454
+ }
455
+ }
456
+
291
457
  /* Sidebar: a full-height docked panel that slides open/closed and
292
458
  collapses to a slim icon rail (not the floating launcher). Docked right by
293
459
  default; data-side="left" docks it left. Overlay by default — set
294
460
  --ag-ui-position: static (and place this element in your own layout) for a
295
461
  host-managed push instead. */
296
462
  :host([placement="sidebar"]) {
297
- --_inset: var(--ag-ui-inset, 0 0 0 auto);
463
+ --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) var(--_viewport-inset-right) var(--_viewport-inset-bottom) auto);
298
464
  --_width: var(--ag-ui-width, 420px);
299
- --_height: var(--ag-ui-height, 100vh);
300
- --_max-height: var(--ag-ui-max-height, 100vh);
465
+ --_height: var(--ag-ui-height, var(--_viewport-height));
466
+ --_max-height: var(--ag-ui-max-height, var(--_viewport-height));
301
467
  --_radius: var(--ag-ui-radius, 0);
302
468
  transition: width var(--_motion) var(--_ease);
303
469
  }
304
470
 
305
471
  :host([placement="sidebar"][data-side="left"]) {
306
- --_inset: var(--ag-ui-inset, 0 auto 0 0);
472
+ --_inset: var(--ag-ui-inset, var(--_viewport-inset-top) auto var(--_viewport-inset-bottom) var(--_viewport-inset-left));
307
473
  }
308
474
 
309
475
  /* The docked panel is pinned to the edge it docks against rather than filling
310
476
  the host as a flex child. Collapsing shrinks the host to the rail width, and
311
477
  a flex child would be squashed to 52px on the way out instead of sliding out
312
478
  at full width. */
479
+ /* The panel is taken out of flow so the collapse can slide it out at full
480
+ width; see the note above. That makes the host its containing block, and the
481
+ host is only one by accident: it is position: fixed by default. A host that
482
+ takes the documented route to a pushed layout instead of an overlay sets
483
+ --ag-ui-position: static, and a static element establishes nothing -- so the
484
+ panel resolved against the initial containing block, landed at the document
485
+ origin, and scrolled away with the page. Docked left it pinned to the
486
+ document's left edge rather than the host's.
487
+
488
+ It looked correct wherever it was first tried, because a full-height column
489
+ at the top of an unscrolled document is exactly where those two answers
490
+ coincide. Containment makes the host a containing block whatever its
491
+ position, so the panel stays in the box the host was given. It is a no-op in
492
+ the default case, which is already positioned. */
493
+ :host([placement="sidebar"]) {
494
+ contain: layout;
495
+ }
496
+
313
497
  :host([placement="sidebar"]) .chat {
314
498
  position: absolute;
315
499
  inset: 0 0 0 auto;
@@ -325,11 +509,15 @@ export const STYLES = `
325
509
  /* Collapsed sidebar: shrink the host to the rail width and slide the panel out
326
510
  through the edge it docks against. Higher specificity than the generic
327
511
  collapse rules, so it wins regardless of source order. */
512
+ /* The rail is the full height of what the host left, not of the screen. It
513
+ said 100vh and pinned its own bottom, which put it under any chrome the host
514
+ had reserved -- and the icon lives at the top of the rail, so the one control
515
+ that reopens the panel was the first thing to disappear behind a sticky
516
+ header. */
328
517
  :host([placement="sidebar"][collapsed]) {
329
518
  width: var(--_rail-width);
330
- height: 100vh;
331
- max-height: 100vh;
332
- bottom: 0;
519
+ height: var(--_viewport-height);
520
+ max-height: var(--_viewport-height);
333
521
  pointer-events: auto;
334
522
  }
335
523
 
@@ -445,17 +633,83 @@ export const STYLES = `
445
633
 
446
634
  /* The sidebar collapses to an edge rail instead: full height, square, flush
447
635
  against the dock. It slides in with the panel rather than popping. */
636
+ /* The edge rail. It reads as the docked edge of a panel rather than a coloured
637
+ stripe: the surface the panel is made of, a border on the side it docks
638
+ against, and the accent kept for the icon -- a full-height slab of accent is
639
+ the loudest thing on the page and says the least about itself.
640
+
641
+ Content sits at the top rather than centred, because a control floating in
642
+ the middle of a screen-high column has nothing to belong to. */
448
643
  :host([placement="sidebar"][collapsed]) .launcher {
449
644
  inset: 0;
450
645
  width: auto;
451
646
  height: auto;
452
- align-items: flex-start;
453
- padding-top: 16px;
454
- border: 1px solid var(--_border);
647
+ flex-direction: column;
648
+ align-items: center;
649
+ justify-content: flex-start;
650
+ gap: 12px;
651
+ padding-top: 14px;
652
+ border: 0;
653
+ border-inline-start: 1px solid var(--_border);
455
654
  border-radius: 0;
655
+ background: var(--_bg);
656
+ color: var(--_fg);
657
+ box-shadow: none;
658
+ transform: none;
659
+ }
660
+
661
+ :host([placement="sidebar"][data-side="left"][collapsed]) .launcher {
662
+ border-inline-start: 0;
663
+ border-inline-end: 1px solid var(--_border);
664
+ }
665
+
666
+ /* The icon keeps the accent, so there is one obvious thing to press.
667
+
668
+ The glyph is sized here rather than left to fill the holder. A glyph in an
669
+ icon holder takes the holder's whole box by default, which is right where the
670
+ holder is only a box and wrong the moment it becomes a filled circle: the
671
+ mark then runs edge to edge and reads as a square crammed into a disc. The
672
+ proportion is the floating launcher's own -- a 26px glyph in a 56px bubble --
673
+ so the two collapsed states look like the same widget. */
674
+ :host([placement="sidebar"][collapsed]) .launcher .icon-holder {
675
+ display: inline-flex;
676
+ align-items: center;
677
+ justify-content: center;
678
+ width: 34px;
679
+ height: 34px;
680
+ border-radius: 50%;
456
681
  background: var(--_header-bg);
457
682
  color: var(--_header-fg);
458
- box-shadow: none;
683
+ }
684
+
685
+ :host([placement="sidebar"][collapsed]) .launcher .icon-holder .glyph,
686
+ :host([placement="sidebar"][collapsed]) .launcher .icon-holder .icon-img {
687
+ width: 16px;
688
+ height: 16px;
689
+ }
690
+
691
+ /* Written down the rail, which is the only direction it fits. Reading upward
692
+ is the convention for a right-hand edge and matches how a docked panel's
693
+ label is set everywhere it appears. */
694
+ .rail-label {
695
+ display: none;
696
+ }
697
+
698
+ :host([placement="sidebar"][collapsed]) .rail-label {
699
+ display: block;
700
+ writing-mode: vertical-rl;
701
+ transform: rotate(180deg);
702
+ max-height: calc(100% - 96px);
703
+ overflow: hidden;
704
+ font-size: 0.9em;
705
+ font-weight: 600;
706
+ letter-spacing: 0.02em;
707
+ white-space: nowrap;
708
+ text-overflow: ellipsis;
709
+ }
710
+
711
+ :host([placement="sidebar"][data-side="left"][collapsed]) .rail-label {
712
+ writing-mode: vertical-rl;
459
713
  transform: none;
460
714
  }
461
715
 
@@ -569,17 +823,24 @@ export const STYLES = `
569
823
 
570
824
  .header-controls {
571
825
  display: flex;
572
- gap: 2px;
826
+ gap: var(--_header-gap);
573
827
  flex: none;
574
828
  }
575
829
 
576
830
  .header-btn {
831
+ display: inline-flex;
832
+ flex: 0 0 auto;
833
+ align-items: center;
834
+ justify-content: center;
835
+ box-sizing: border-box;
836
+ width: var(--_header-btn-size);
837
+ height: var(--_header-btn-size);
577
838
  border: none;
578
839
  background: transparent;
579
840
  color: inherit;
580
841
  font: inherit;
581
842
  line-height: 1;
582
- padding: 4px 7px;
843
+ padding: 0;
583
844
  border-radius: 6px;
584
845
  cursor: pointer;
585
846
  opacity: 0.85;
@@ -601,9 +862,9 @@ export const STYLES = `
601
862
  Nothing paints there once the panel is gone, so the box only has to stop
602
863
  swallowing clicks: pointer events go to none and the launcher takes them.
603
864
 
604
- Two placements collapse differently: "sidebar" slides to its rail (below),
605
- while "embedded" and "page" keep the header bar, having no corner for a
606
- floating circle that would escape the host's layout. */
865
+ Two placements do not use it: "sidebar" slides to its rail (below), and
866
+ "embedded" keeps its header bar, having no corner for a floating circle that
867
+ would escape the host's layout. "page" has no collapsed state at all. */
607
868
  :host([collapsed]) {
608
869
  pointer-events: none;
609
870
  /* A collapsed host has to be allowed to shrink, and in the layout hosts
@@ -665,37 +926,107 @@ export const STYLES = `
665
926
  transform-origin: bottom right;
666
927
  }
667
928
 
668
- /* The two in-flow placements keep the original collapse: hide the body, let
669
- the host shrink to the header bar. */
670
- :host([collapsed]:is([placement="embedded"], [placement="page"])) {
929
+ /* The in-flow placement keeps the original collapse: hide the body, let the
930
+ host shrink to the header bar. */
931
+ :host([collapsed][placement="embedded"]) {
671
932
  height: auto;
672
933
  max-height: none;
673
934
  pointer-events: auto;
674
935
  }
675
936
 
676
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .chat {
937
+ :host([collapsed][placement="embedded"]) .chat {
677
938
  opacity: 1;
678
939
  transform: none;
679
940
  visibility: visible;
680
941
  }
681
942
 
682
- /* These two keep the header bar, so the launcher must stay out of the way: an
943
+ /* It keeps the header bar, so the launcher must stay out of the way: an
683
944
  embedded host is position: static, which would let an absolutely-positioned
684
945
  circle escape the layout and land against whatever the page positions. */
685
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .launcher {
946
+ :host([collapsed][placement="embedded"]) .launcher {
686
947
  visibility: hidden;
687
948
  opacity: 0;
688
949
  }
689
950
 
690
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages-wrap,
691
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages,
692
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .input-row,
693
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-chips,
694
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-palette,
695
- :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-hint {
951
+ :host([collapsed][placement="embedded"]) .messages-wrap,
952
+ :host([collapsed][placement="embedded"]) .messages,
953
+ :host([collapsed][placement="embedded"]) .input-row,
954
+ :host([collapsed][placement="embedded"]) .skill-chips,
955
+ :host([collapsed][placement="embedded"]) .skill-palette,
956
+ :host([collapsed][placement="embedded"]) .skill-hint {
957
+ display: none;
958
+ }
959
+
960
+ /* A docked conversation list: beside the transcript rather than over it.
961
+
962
+ A full-page chat is the one surface with width to spare, and covering the
963
+ conversation to show the list of conversations is the wrong trade there --
964
+ it hides the thing you are trying to get back to. Everywhere else the panel
965
+ is a few hundred pixels wide and a docked list would leave a column of
966
+ transcript narrower than the messages in it, so this is the only placement
967
+ that gets it.
968
+
969
+ The transcript is moved by padding on the shell rather than by making the
970
+ list a flex sibling: the drawer is the last child of the panel, and no
971
+ selector reaches backwards from it to the rows that have to shift. That is
972
+ also why the state is stamped on the host. */
973
+ :host([data-threads-docked]) .drawer {
974
+ /* Not a scrim: the page behind it is still the user's to work in. */
975
+ pointer-events: none;
976
+ }
977
+
978
+ :host([data-threads-docked]) .drawer-backdrop {
696
979
  display: none;
697
980
  }
698
981
 
982
+ :host([data-threads-docked]) .drawer-panel {
983
+ width: var(--_threads-rail-width);
984
+ pointer-events: auto;
985
+ border-inline-end: 1px solid var(--_border);
986
+ box-shadow: none;
987
+ }
988
+
989
+ :host([data-threads-docked]) .messages,
990
+ :host([data-threads-docked]) .input-row,
991
+ :host([data-threads-docked]) .skill-chips,
992
+ :host([data-threads-docked]) .attachment-tray {
993
+ padding-inline-start: calc(
994
+ var(--_threads-rail-width) + max(var(--_pad), (100% - var(--_content-max-width)) / 2)
995
+ );
996
+ }
997
+
998
+ :host([data-threads-docked]) .header {
999
+ padding-inline-start: calc(var(--_threads-rail-width) + var(--_pad));
1000
+ }
1001
+
1002
+ /* The page placement has no collapsed state, so it offers no control for one.
1003
+ A dedicated route has no "away" to go to: shrinking it left a strip of
1004
+ application chrome fixed over a route that no longer had an owner, and this
1005
+ is the one placement where the launcher that would bring it back is hidden.
1006
+
1007
+ Hiding the control is only half of it. The state has another way in -- the
1008
+ attribute can be written straight onto the element, and a value stored under
1009
+ a different placement is restored on connect -- and the JS guards that catch
1010
+ those cannot see an attribute set directly. So the placement neutralises the
1011
+ state here as well: with the collapse rules above no longer naming "page", an
1012
+ unguarded collapsed attribute would otherwise fall through to the generic
1013
+ rule that scales the panel away and drops pointer events, leaving nothing on
1014
+ screen and no launcher to press. */
1015
+ :host([placement="page"]) .header-btn--collapse {
1016
+ display: none;
1017
+ }
1018
+
1019
+ :host([placement="page"][collapsed]) {
1020
+ pointer-events: auto;
1021
+ align-self: auto;
1022
+ }
1023
+
1024
+ :host([placement="page"][collapsed]) .chat {
1025
+ opacity: 1;
1026
+ transform: none;
1027
+ visibility: visible;
1028
+ }
1029
+
699
1030
  /* Jump-to-latest: shown only once the reader has scrolled away *and* missed
700
1031
  something. Anchored to the panel rather than the list so it does not scroll
701
1032
  with the content it is offering to scroll to. */
@@ -783,6 +1114,28 @@ export const STYLES = `
783
1114
 
784
1115
  The 1px box with clip-path, rather than width/height 0, is the shape that
785
1116
  survives: a zero-sized element is dropped from the tree by some engines. */
1117
+ /* A used-value reader for the four viewport-inset tokens, and the only
1118
+ reliable one. getComputedStyle().getPropertyValue() on an unregistered
1119
+ custom property hands back the substituted token stream, not a length: it
1120
+ returns "4rem" verbatim, and "calc(56px + env(safe-area-inset-top))" as
1121
+ "calc(56px + 0px)" -- which parses as NaN and takes the whole inset with it.
1122
+ Padding is a real property, so the same tokens come back resolved to px.
1123
+
1124
+ visibility rather than display: none, because a box that generates no
1125
+ layout has no used values to read. Zero-sized, absolutely positioned and
1126
+ inert, so it costs nothing but the read. */
1127
+ .viewport-probe {
1128
+ position: absolute;
1129
+ top: 0;
1130
+ left: 0;
1131
+ width: 0;
1132
+ height: 0;
1133
+ visibility: hidden;
1134
+ pointer-events: none;
1135
+ padding: var(--_viewport-inset-top) var(--_viewport-inset-right)
1136
+ var(--_viewport-inset-bottom) var(--_viewport-inset-left);
1137
+ }
1138
+
786
1139
  .sr-only {
787
1140
  position: absolute;
788
1141
  width: 1px;
@@ -798,6 +1151,8 @@ export const STYLES = `
798
1151
  .messages {
799
1152
  flex: 1;
800
1153
  overflow-y: auto;
1154
+ /* Scrolling past the end of this must not scroll the page behind it. */
1155
+ overscroll-behavior: contain;
801
1156
  /* The browser's own scroll anchoring competes with the scroller for the same
802
1157
  job and wins unpredictably -- it can hold the view still exactly when we
803
1158
  want to follow. Turned off so following is decided in one place. Safari
@@ -950,6 +1305,8 @@ export const STYLES = `
950
1305
  .message--assistant pre {
951
1306
  padding: 8px 10px;
952
1307
  overflow: auto;
1308
+ /* Scrolling past the end of this must not scroll the page behind it. */
1309
+ overscroll-behavior: contain;
953
1310
  background: var(--_bg);
954
1311
  border: 1px solid var(--_border);
955
1312
  border-radius: 6px;
@@ -1128,6 +1485,8 @@ export const STYLES = `
1128
1485
  border-left: 2px solid var(--_border);
1129
1486
  max-height: 220px;
1130
1487
  overflow: auto;
1488
+ /* Scrolling past the end of this must not scroll the page behind it. */
1489
+ overscroll-behavior: contain;
1131
1490
  white-space: pre-wrap;
1132
1491
  word-break: break-word;
1133
1492
  font-family: inherit;
@@ -1318,6 +1677,8 @@ export const STYLES = `
1318
1677
  padding: 6px 8px;
1319
1678
  max-height: 160px;
1320
1679
  overflow: auto;
1680
+ /* Scrolling past the end of this must not scroll the page behind it. */
1681
+ overscroll-behavior: contain;
1321
1682
  background: var(--_bg);
1322
1683
  border: 1px solid var(--_border);
1323
1684
  border-radius: 6px;
@@ -1812,6 +2173,8 @@ export const STYLES = `
1812
2173
  resize: none;
1813
2174
  max-height: var(--_composer-max-height);
1814
2175
  overflow-y: auto;
2176
+ /* Scrolling past the end of this must not scroll the page behind it. */
2177
+ overscroll-behavior: contain;
1815
2178
  padding: 6px 4px 2px;
1816
2179
  background: transparent;
1817
2180
  border: none;
@@ -2096,6 +2459,30 @@ export const STYLES = `
2096
2459
  opacity: 0.75;
2097
2460
  }
2098
2461
 
2462
+ /* The one control a notice may carry. Quiet, because it reports something
2463
+ already done rather than asking for a decision. */
2464
+ .run-notice-undo {
2465
+ flex: 0 0 auto;
2466
+ margin-inline-start: auto;
2467
+ padding: 2px 8px;
2468
+ border: 1px solid var(--_border);
2469
+ border-radius: var(--_radius);
2470
+ background: var(--_bg);
2471
+ color: var(--_accent);
2472
+ font: inherit;
2473
+ font-size: 0.9em;
2474
+ cursor: pointer;
2475
+ }
2476
+
2477
+ .run-notice-undo:hover:not(:disabled) {
2478
+ background: var(--_hover);
2479
+ }
2480
+
2481
+ .run-notice-undo:disabled {
2482
+ opacity: 0.5;
2483
+ cursor: default;
2484
+ }
2485
+
2099
2486
  .run-notice-text {
2100
2487
  min-width: 0;
2101
2488
  overflow-wrap: anywhere;
@@ -2240,6 +2627,8 @@ export const STYLES = `
2240
2627
  padding: 8px 10px;
2241
2628
  max-height: 140px;
2242
2629
  overflow: auto;
2630
+ /* Scrolling past the end of this must not scroll the page behind it. */
2631
+ overscroll-behavior: contain;
2243
2632
  font-size: 12px;
2244
2633
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
2245
2634
  background: var(--_assistant-bg);
@@ -2428,6 +2817,43 @@ export const STYLES = `
2428
2817
  }
2429
2818
  }
2430
2819
 
2820
+ /* Touch. Separate from the width breakpoint above on purpose: width decides
2821
+ the layout, the pointer decides which controls make sense, and a touch
2822
+ laptop is coarse-pointered and wide while a narrow desktop window is
2823
+ fine-pointered and small. */
2824
+ @media (pointer: coarse) {
2825
+ /* A 6px edge strip is not a control, it is a trap that eats a scroll. The
2826
+ corners are 14px, still under the 24px floor a target is meant to clear,
2827
+ and the panel has other ways to be resized on a device that has a pointer
2828
+ precise enough to grab one. */
2829
+ .resize-handle {
2830
+ display: none;
2831
+ }
2832
+
2833
+ /* Platform guidance is 44pt on iOS and 48dp on Android. These sit at 28-30px,
2834
+ which clears the WCAG minimum and misses both. */
2835
+ :host {
2836
+ --_action-size: var(--ag-ui-action-size, 44px);
2837
+ --_tool-btn-size: var(--ag-ui-tool-btn-size, 44px);
2838
+ --_send-size: var(--ag-ui-send-size, 44px);
2839
+ /* Missed the first time these were raised, which left the header -- the
2840
+ widget's primary controls, and the only way to reach history, a new chat
2841
+ or the collapse -- at half the size of everything beside it. */
2842
+ --_header-btn-size: var(--ag-ui-header-btn-size, 44px);
2843
+ --_header-gap: var(--ag-ui-header-gap, 6px);
2844
+ }
2845
+
2846
+ /* iOS Safari zooms the page when a control under 16px takes focus, which
2847
+ drags the whole fixed panel with it and leaves the user pinching back out
2848
+ of a chat they only wanted to type into. The composer inherits the widget
2849
+ font, which is 14px by default and 13px at compact density, so it has to
2850
+ say 16 outright -- and only ever upward, so a host that has deliberately
2851
+ set something larger keeps it. */
2852
+ .input {
2853
+ font-size: max(16px, var(--_font-size));
2854
+ }
2855
+ }
2856
+
2431
2857
  .message-action[aria-pressed="true"] {
2432
2858
  opacity: 1;
2433
2859
  color: var(--_accent);
@@ -2616,6 +3042,8 @@ export const STYLES = `
2616
3042
  flex-direction: column;
2617
3043
  max-height: 220px;
2618
3044
  overflow: auto;
3045
+ /* Scrolling past the end of this must not scroll the page behind it. */
3046
+ overscroll-behavior: contain;
2619
3047
  background: var(--_bg);
2620
3048
  border: 1px solid var(--_border);
2621
3049
  border-radius: 8px;
@@ -2704,6 +3132,8 @@ export const STYLES = `
2704
3132
  box-shadow: 0 6px 24px rgb(0 0 0 / 12%);
2705
3133
  max-height: 60%;
2706
3134
  overflow-y: auto;
3135
+ /* Scrolling past the end of this must not scroll the page behind it. */
3136
+ overscroll-behavior: contain;
2707
3137
  transform-origin: top center;
2708
3138
  transition:
2709
3139
  opacity var(--_motion) var(--_ease),
@@ -2879,10 +3309,20 @@ export const STYLES = `
2879
3309
  }
2880
3310
 
2881
3311
  .drawer-title {
3312
+ /* Takes the slack so the two controls group at the trailing edge. With
3313
+ space-between and three items the middle one floats, which reads as the
3314
+ title and the close button being a pair with New chat wedged between
3315
+ them. Truncates rather than pushing them off the row. */
3316
+ flex: 1 1 auto;
3317
+ min-width: 0;
3318
+ overflow: hidden;
3319
+ text-overflow: ellipsis;
3320
+ white-space: nowrap;
2882
3321
  font-weight: 600;
2883
3322
  }
2884
3323
 
2885
3324
  .drawer-new {
3325
+ flex: 0 0 auto;
2886
3326
  border: 1px solid var(--_border);
2887
3327
  border-radius: var(--_radius);
2888
3328
  background: var(--_bg);
@@ -2893,10 +3333,63 @@ export const STYLES = `
2893
3333
  cursor: pointer;
2894
3334
  }
2895
3335
 
3336
+ /* Narrows the list. Sits under the header rather than in it: the header's two
3337
+ controls act on the conversation, and a field that filters what is below it
3338
+ belongs with what it filters. */
3339
+ /* What is waiting for the run to finish. Above the composer, beside the
3340
+ attachment tray, because both are things already handed over and not yet
3341
+ sent. Each chip is a button: pressing it takes that message back. */
3342
+ .queued {
3343
+ display: flex;
3344
+ flex-wrap: wrap;
3345
+ gap: var(--_space);
3346
+ padding: 0 var(--_pad) var(--_space);
3347
+ }
3348
+
3349
+ .queued-chip {
3350
+ max-width: 100%;
3351
+ overflow: hidden;
3352
+ padding: 4px 10px;
3353
+ border: 1px dashed var(--_border);
3354
+ border-radius: var(--_msg-radius);
3355
+ background: var(--_bg);
3356
+ color: var(--_muted);
3357
+ font: inherit;
3358
+ font-size: 0.9em;
3359
+ text-overflow: ellipsis;
3360
+ white-space: nowrap;
3361
+ cursor: pointer;
3362
+ }
3363
+
3364
+ .queued-chip:hover {
3365
+ border-style: solid;
3366
+ color: var(--_fg);
3367
+ }
3368
+
3369
+ .drawer-filter {
3370
+ flex: 0 0 auto;
3371
+ box-sizing: border-box;
3372
+ width: calc(100% - var(--_pad) * 2);
3373
+ margin: var(--_space) var(--_pad) 0;
3374
+ padding: 6px 10px;
3375
+ border: 1px solid var(--_border);
3376
+ border-radius: var(--_radius);
3377
+ background: var(--_input-bg);
3378
+ color: var(--_fg);
3379
+ font: inherit;
3380
+ font-size: 0.9em;
3381
+ }
3382
+
3383
+ .drawer-filter::placeholder {
3384
+ color: var(--_muted);
3385
+ }
3386
+
2896
3387
  .drawer-list {
2897
3388
  flex: 1;
2898
3389
  min-height: 0;
2899
3390
  overflow-y: auto;
3391
+ /* Scrolling past the end of this must not scroll the page behind it. */
3392
+ overscroll-behavior: contain;
2900
3393
  }
2901
3394
 
2902
3395
  .drawer-empty {
@@ -3017,6 +3510,31 @@ export const STYLES = `
3017
3510
  background: none;
3018
3511
  }
3019
3512
 
3513
+ /* The way back out of the list. It sits beside New chat, which is the control
3514
+ it must not be mistaken for: one returns you to the conversation you were
3515
+ reading, the other replaces it. */
3516
+ .drawer-close {
3517
+ flex: 0 0 auto;
3518
+ display: inline-flex;
3519
+ align-items: center;
3520
+ justify-content: center;
3521
+ width: 26px;
3522
+ height: 26px;
3523
+ padding: 0;
3524
+ border: 0;
3525
+ border-radius: 6px;
3526
+ background: none;
3527
+ color: inherit;
3528
+ font: inherit;
3529
+ font-size: 18px;
3530
+ line-height: 1;
3531
+ cursor: pointer;
3532
+ }
3533
+
3534
+ .drawer-close:hover {
3535
+ background: var(--_hover);
3536
+ }
3537
+
3020
3538
  :host([placement="embedded"]) .drawer-panel {
3021
3539
  width: 100%;
3022
3540
  border-right: none;