@artooi/ag-ui-web-component 0.21.0 → 0.23.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.
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  var ELEMENT_TAG = "ag-ui-chat";
3
3
  var SUBMIT_EVENT = "ag-ui-submit";
4
4
  var TOGGLE_EVENT = "ag-ui-toggle";
5
+ var UNREAD_EVENT = "ag-ui-unread";
5
6
  var STATE_EVENT = "ag-ui-state";
6
7
  var ATTACHMENT_EVENT = "ag-ui-attachments";
7
8
  var MESSAGE_ROLE = {
@@ -34,6 +35,15 @@ var TOOL_DISPLAY = {
34
35
  };
35
36
  var COMPACTION_ACTIVITY_TYPE = "compaction";
36
37
  var LOAD_CAPABILITY_TOOL = "load_capability";
38
+ var ICON_SEND = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19.5V5m-6.5 6.5L12 5l6.5 6.5"/></svg>`;
39
+ var ICON_STOP = `<svg class="glyph glyph--solid" viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2.5"/></svg>`;
40
+ var ICON_ATTACH = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="M17 8.5V15a5 5 0 0 1-10 0V7a3 3 0 0 1 6 0v7.5a1 1 0 0 1-2 0V8.5"/></svg>`;
41
+ var ICON_VOICE = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V7a3 3 0 0 1 3-3z"/><path d="M5 11v1a7 7 0 0 0 14 0v-1"/><path d="M12 19v3"/></svg>`;
42
+ var ICON_LAUNCHER = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v8a2.5 2.5 0 0 1-2.5 2.5H9l-5 4z"/></svg>`;
43
+ var ICON_FILE = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>`;
44
+ var ICON_FILE_IMAGE = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><circle cx="9" cy="10" r="1.5"/><path d="M4.5 17.5 9 13.5l3.5 3 3-2.5 4.5 4"/></svg>`;
45
+ var ICON_FILE_PDF = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><rect class="glyph--solid" x="7.5" y="13.5" width="9" height="4.5" rx="1"/></svg>`;
46
+ var ICON_FILE_TEXT = `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M8.5 13.5h7M8.5 17h4.5"/></svg>`;
37
47
 
38
48
  // src/skills/fill_template.ts
39
49
  var PLACEHOLDER_RE = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
@@ -509,6 +519,7 @@ var DEFAULT_UI_STRINGS = {
509
519
  newChat: "New chat",
510
520
  collapse: "Collapse",
511
521
  expand: "Expand",
522
+ expandUnread: "Expand \u2014 {count} unread",
512
523
  toggleTheme: "Toggle theme",
513
524
  copyCode: "Copy",
514
525
  copied: "Copied",
@@ -716,7 +727,7 @@ function renderChip(ref) {
716
727
  const icon = document.createElement("span");
717
728
  icon.className = "attachment-chip-icon";
718
729
  icon.setAttribute("part", "attachment-chip-icon");
719
- icon.textContent = iconFor(ref.mime);
730
+ icon.innerHTML = iconFor(ref.mime);
720
731
  icon.setAttribute("aria-hidden", "true");
721
732
  const name = document.createElement("span");
722
733
  name.className = "attachment-chip-name";
@@ -732,15 +743,15 @@ function renderChip(ref) {
732
743
  }
733
744
  function iconFor(mime) {
734
745
  if (mime.startsWith("image/")) {
735
- return "\u{1F5BC}";
746
+ return ICON_FILE_IMAGE;
736
747
  }
737
748
  if (mime === "application/pdf") {
738
- return "\u{1F4D5}";
749
+ return ICON_FILE_PDF;
739
750
  }
740
751
  if (mime.startsWith("text/")) {
741
- return "\u{1F4C4}";
752
+ return ICON_FILE_TEXT;
742
753
  }
743
- return "\u{1F4CE}";
754
+ return ICON_FILE;
744
755
  }
745
756
  function formatBytes(bytes) {
746
757
  if (bytes < 1024) {
@@ -906,7 +917,7 @@ var AttachmentTray = class {
906
917
  const icon = document.createElement("span");
907
918
  icon.className = "attachment-chip-icon";
908
919
  icon.setAttribute("part", "attachment-chip-icon");
909
- icon.textContent = iconFor(item.file.type);
920
+ icon.innerHTML = iconFor(item.file.type);
910
921
  icon.setAttribute("aria-hidden", "true");
911
922
  const name = document.createElement("span");
912
923
  name.className = "attachment-chip-name";
@@ -4532,6 +4543,36 @@ var STYLES = `
4532
4543
  --_icon-size: var(--ag-ui-icon-size, 22px);
4533
4544
  --_icon-radius: var(--ag-ui-icon-radius, 4px);
4534
4545
 
4546
+ /* Composer \u2014 one bordered surface holding the field and its tool row. */
4547
+ --_composer-radius: var(--ag-ui-composer-radius, 14px);
4548
+ --_composer-max-height: var(--ag-ui-composer-max-height, 40vh);
4549
+ --_tool-btn-size: var(--ag-ui-tool-btn-size, 30px);
4550
+ --_send-size: var(--ag-ui-send-size, 30px);
4551
+ --_glyph-size: var(--ag-ui-glyph-size, 18px);
4552
+ --_glyph-stroke: var(--ag-ui-glyph-stroke, 1.75);
4553
+
4554
+ /* Unread badge on the launcher. */
4555
+ --_badge-bg: var(--ag-ui-badge-bg, var(--_danger));
4556
+ --_badge-fg: var(--ag-ui-badge-fg, #ffffff);
4557
+ --_badge-size: var(--ag-ui-badge-size, 18px);
4558
+ --_badge-font-size: var(--ag-ui-badge-font-size, 11px);
4559
+
4560
+ /* The floating launcher a collapsed widget shrinks to. */
4561
+ --_launcher-size: var(--ag-ui-launcher-size, 56px);
4562
+ --_launcher-bg: var(--ag-ui-launcher-bg, var(--_header-bg));
4563
+ --_launcher-fg: var(--ag-ui-launcher-fg, var(--_header-fg));
4564
+ --_launcher-radius: var(--ag-ui-launcher-radius, 50%);
4565
+ --_launcher-icon-size: var(--ag-ui-launcher-icon-size, 26px);
4566
+ --_launcher-inset: var(--ag-ui-launcher-inset, auto 0 0 auto);
4567
+
4568
+ /* Motion. One duration and two curves drive every collapse, expand and
4569
+ slide-over, so the whole widget accelerates and settles as one thing.
4570
+ The default curve is a long-tailed ease-out (a fast start that decelerates
4571
+ into place); the pop curve overshoots slightly, for something arriving. */
4572
+ --_motion: var(--ag-ui-motion, 0.28s);
4573
+ --_ease: var(--ag-ui-ease, cubic-bezier(0.32, 0.72, 0, 1));
4574
+ --_ease-pop: var(--ag-ui-ease-pop, cubic-bezier(0.34, 1.36, 0.64, 1));
4575
+
4535
4576
  /* Spacing \u2014 the density preset overrides these. */
4536
4577
  --_space: var(--ag-ui-space, 10px);
4537
4578
  --_pad: var(--ag-ui-pad, 16px);
@@ -4696,56 +4737,161 @@ var STYLES = `
4696
4737
  --_height: var(--ag-ui-height, 100vh);
4697
4738
  --_max-height: var(--ag-ui-max-height, 100vh);
4698
4739
  --_radius: var(--ag-ui-radius, 0);
4699
- transition: width 0.28s ease;
4740
+ transition: width var(--_motion) var(--_ease);
4700
4741
  }
4701
4742
 
4702
4743
  :host([placement="sidebar"][data-side="left"]) {
4703
4744
  --_inset: var(--ag-ui-inset, 0 auto 0 0);
4704
4745
  }
4705
4746
 
4747
+ /* The docked panel is pinned to the edge it docks against rather than filling
4748
+ the host as a flex child. Collapsing shrinks the host to the rail width, and
4749
+ a flex child would be squashed to 52px on the way out instead of sliding out
4750
+ at full width. */
4706
4751
  :host([placement="sidebar"]) .chat {
4707
- transition: transform 0.28s ease;
4752
+ position: absolute;
4753
+ inset: 0 0 0 auto;
4754
+ width: var(--_width);
4755
+ transform-origin: right center;
4756
+ }
4757
+
4758
+ :host([placement="sidebar"][data-side="left"]) .chat {
4759
+ inset: 0 auto 0 0;
4760
+ transform-origin: left center;
4708
4761
  }
4709
4762
 
4710
- /* Collapsed sidebar: shrink the host to the rail width, hide the panel, and
4711
- reveal the rail. Higher specificity than the generic collapse rules, so it
4712
- wins regardless of source order. */
4763
+ /* Collapsed sidebar: shrink the host to the rail width and slide the panel out
4764
+ through the edge it docks against. Higher specificity than the generic
4765
+ collapse rules, so it wins regardless of source order. */
4713
4766
  :host([placement="sidebar"][collapsed]) {
4714
4767
  width: var(--_rail-width);
4715
4768
  height: 100vh;
4716
4769
  max-height: 100vh;
4717
4770
  bottom: 0;
4771
+ pointer-events: auto;
4718
4772
  }
4719
4773
 
4720
4774
  :host([placement="sidebar"][collapsed]) .chat {
4721
- display: none;
4775
+ transform: translateX(100%);
4722
4776
  }
4723
4777
 
4724
- /* The rail is a sibling of the panel (so it survives the panel being hidden);
4725
- shown only for a collapsed sidebar. */
4726
- .rail {
4727
- display: none;
4778
+ :host([placement="sidebar"][data-side="left"][collapsed]) .chat {
4779
+ transform: translateX(-100%);
4780
+ }
4781
+
4782
+ @media (prefers-reduced-motion: reduce) {
4783
+ :host([placement="sidebar"]),
4784
+ :host([placement="sidebar"]) .chat {
4785
+ transition: none;
4786
+ }
4787
+ }
4788
+
4789
+ /* \u2500\u2500 Launcher \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4790
+ What a collapsed widget shrinks to: a round floating button by default, the
4791
+ sidebar's slim edge rail under that placement. A sibling of the panel, so it
4792
+ survives the panel being hidden, and the only part of a collapsed widget
4793
+ that takes pointer events. */
4794
+ .launcher {
4795
+ display: flex;
4796
+ position: absolute;
4797
+ inset: var(--_launcher-inset);
4798
+ align-items: center;
4799
+ justify-content: center;
4800
+ width: var(--_launcher-size);
4801
+ height: var(--_launcher-size);
4802
+ padding: 0;
4728
4803
  border: none;
4804
+ border-radius: var(--_launcher-radius);
4805
+ background: var(--_launcher-bg);
4806
+ color: var(--_launcher-fg);
4807
+ box-shadow: var(--_shadow);
4729
4808
  font: inherit;
4809
+ cursor: pointer;
4810
+ pointer-events: auto;
4811
+ opacity: 0;
4812
+ transform: scale(0.4);
4813
+ visibility: hidden;
4814
+ transition:
4815
+ opacity var(--_motion) var(--_ease),
4816
+ transform var(--_motion) var(--_ease-pop),
4817
+ visibility var(--_motion) var(--_ease);
4818
+ }
4819
+
4820
+ /* The launcher grows out of the corner the panel shrank into.
4821
+ It is laid out at rest rather than display:none, which is what lets it
4822
+ animate in *and* out: an element that was not rendered has no before-change
4823
+ style to transition from, and one whose display flips to none cannot
4824
+ transition at all. visibility keeps it unpaintable, untabbable and
4825
+ unclickable in between -- the expanded panel's own controls sit under it and
4826
+ must stay reachable. */
4827
+ :host([collapsed]) .launcher {
4828
+ opacity: 1;
4829
+ transform: none;
4830
+ visibility: visible;
4730
4831
  }
4731
4832
 
4732
- :host([placement="sidebar"][collapsed]) .rail {
4733
- display: flex;
4833
+ :host([collapsed]) .launcher:hover {
4834
+ transform: scale(1.06);
4835
+ }
4836
+
4837
+ :host([collapsed]) .launcher:active {
4838
+ transform: scale(0.94);
4839
+ }
4840
+
4841
+ .launcher .icon-holder {
4842
+ width: var(--_launcher-icon-size);
4843
+ height: var(--_launcher-icon-size);
4844
+ }
4845
+
4846
+ /* The unread badge rides the launcher's top-right corner. The ring in the
4847
+ widget's own background is what separates it from the launcher underneath,
4848
+ whatever colour the host themes either one. */
4849
+ .launcher-badge {
4734
4850
  position: absolute;
4851
+ top: -2px;
4852
+ right: -2px;
4853
+ display: inline-flex;
4854
+ align-items: center;
4855
+ justify-content: center;
4856
+ box-sizing: border-box;
4857
+ min-width: var(--_badge-size);
4858
+ height: var(--_badge-size);
4859
+ padding: 0 5px;
4860
+ border-radius: 999px;
4861
+ background: var(--_badge-bg);
4862
+ color: var(--_badge-fg);
4863
+ font-size: var(--_badge-font-size);
4864
+ font-weight: 600;
4865
+ line-height: 1;
4866
+ box-shadow: 0 0 0 2px var(--_bg);
4867
+ }
4868
+
4869
+ .launcher-badge[hidden] {
4870
+ display: none;
4871
+ }
4872
+
4873
+ /* The sidebar collapses to an edge rail instead: full height, square, flush
4874
+ against the dock. It slides in with the panel rather than popping. */
4875
+ :host([placement="sidebar"][collapsed]) .launcher {
4735
4876
  inset: 0;
4877
+ width: auto;
4878
+ height: auto;
4736
4879
  align-items: flex-start;
4737
- justify-content: center;
4738
4880
  padding-top: 16px;
4739
4881
  border: 1px solid var(--_border);
4882
+ border-radius: 0;
4740
4883
  background: var(--_header-bg);
4741
4884
  color: var(--_header-fg);
4742
- cursor: pointer;
4885
+ box-shadow: none;
4886
+ transform: none;
4743
4887
  }
4744
4888
 
4889
+ /* Every transition in this file is timed by --_motion, so collapsing it to a
4890
+ frame is the whole reduced-motion story: states still change (and display
4891
+ still flips at the end of its discrete transition), nothing travels. */
4745
4892
  @media (prefers-reduced-motion: reduce) {
4746
- :host([placement="sidebar"]),
4747
- :host([placement="sidebar"]) .chat {
4748
- transition: none;
4893
+ :host {
4894
+ --_motion: var(--ag-ui-motion, 0.001s);
4749
4895
  }
4750
4896
  }
4751
4897
 
@@ -4836,27 +4982,78 @@ var STYLES = `
4836
4982
  background: rgba(255, 255, 255, 0.18);
4837
4983
  }
4838
4984
 
4839
- /* Collapsed: keep just the header bar \u2014 hide the whole body, and let the host
4840
- shrink to the header. (A host can also fully hide the element itself.) */
4985
+ /* \u2500\u2500 Collapse \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4986
+ Collapsing shrinks the widget to the round floating launcher: the panel
4987
+ scales down toward the launcher's corner and fades out, the launcher pops in
4988
+ from that same point. Both halves are transform and opacity only, so the
4989
+ morph runs on the compositor and never reflows the host page.
4990
+
4991
+ The host box keeps its expanded size. Animating it would animate layout, and
4992
+ a dragged --ag-ui-width would then fight the launcher's own size. Nothing
4993
+ paints there once the panel is gone, so the box only has to stop swallowing
4994
+ clicks: pointer events go to none, and the launcher takes them back.
4995
+
4996
+ Two placements collapse to something else and restore what they need:
4997
+ "sidebar" slides to its rail (below), while "embedded" and "page" keep the
4998
+ original header bar \u2014 embedded is laid out by the host page, where a
4999
+ floating circle would escape the layout, and page is a full-screen route
5000
+ with no corner to float in. */
4841
5001
  :host([collapsed]) {
5002
+ pointer-events: none;
5003
+ }
5004
+
5005
+ :host([collapsed]) .chat {
5006
+ opacity: 0;
5007
+ transform: scale(0.94);
5008
+ visibility: hidden;
5009
+ }
5010
+
5011
+ /* visibility is what keeps the panel out of the tab order and the a11y tree at
5012
+ rest without display:none killing the transition. It interpolates so that any
5013
+ progress below 1 still counts as visible: the panel stays on screen for the
5014
+ whole collapse and flips hidden exactly at the end, and on expand it is
5015
+ visible from the first frame. */
5016
+ .chat {
5017
+ transform-origin: bottom right;
5018
+ transition:
5019
+ opacity var(--_motion) var(--_ease),
5020
+ transform var(--_motion) var(--_ease),
5021
+ visibility var(--_motion) var(--_ease);
5022
+ }
5023
+
5024
+ :host([placement="bottom-left"]) .chat {
5025
+ transform-origin: bottom left;
5026
+ }
5027
+
5028
+ /* The two in-flow placements keep the original collapse: hide the body, let
5029
+ the host shrink to the header bar. */
5030
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) {
4842
5031
  height: auto;
4843
5032
  max-height: none;
5033
+ pointer-events: auto;
4844
5034
  }
4845
5035
 
4846
- :host([collapsed]) .messages,
4847
- :host([collapsed]) .input-row,
4848
- :host([collapsed]) .skill-chips,
4849
- :host([collapsed]) .skill-palette,
4850
- :host([collapsed]) .skill-hint {
4851
- display: none;
5036
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .chat {
5037
+ opacity: 1;
5038
+ transform: none;
5039
+ visibility: visible;
4852
5040
  }
4853
5041
 
4854
- /* Edge-docked placements pin top *and* bottom, which would otherwise keep the
4855
- panel full-height when collapsed; unpin the bottom so it shrinks to the
4856
- header. (Floating/bottom-left pin only the bottom, so they already shrink.) */
4857
- :host([collapsed][placement="side"]),
4858
- :host([collapsed][placement="full"]) {
4859
- bottom: auto;
5042
+ /* These two keep the header bar, so the launcher must stay out of the way \u2014
5043
+ an embedded host is position: static, which would otherwise leave an
5044
+ absolutely-positioned circle to escape the layout entirely and land against
5045
+ whatever the page happens to position. */
5046
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .launcher {
5047
+ visibility: hidden;
5048
+ opacity: 0;
5049
+ }
5050
+
5051
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .messages,
5052
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .input-row,
5053
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-chips,
5054
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-palette,
5055
+ :host([collapsed]:is([placement="embedded"], [placement="page"])) .skill-hint {
5056
+ display: none;
4860
5057
  }
4861
5058
 
4862
5059
  .messages {
@@ -5529,102 +5726,187 @@ var STYLES = `
5529
5726
  opacity: 0.35;
5530
5727
  }
5531
5728
 
5729
+ /* \u2500\u2500 Composer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5730
+ One surface owns the border, the background and the focus ring; the field and
5731
+ its tool row sit inside it. The row used to be four siblings stretched to the
5732
+ textarea's height, which gave a paperclip the same visual weight as the field
5733
+ and turned Send into a full-height slab. */
5532
5734
  .input-row {
5533
5735
  display: flex;
5534
- gap: 8px;
5535
5736
  padding: 12px;
5536
5737
  border-top: 1px solid var(--_border);
5537
5738
  }
5538
5739
 
5539
- .input {
5740
+ .composer {
5540
5741
  flex: 1;
5541
- resize: none;
5742
+ min-width: 0;
5743
+ display: flex;
5744
+ flex-direction: column;
5745
+ gap: 4px;
5746
+ padding: 6px 6px 6px 10px;
5542
5747
  background: var(--_input-bg);
5543
5748
  border: 1px solid var(--_border);
5544
- border-radius: 8px;
5545
- padding: 8px 10px;
5749
+ border-radius: var(--_composer-radius);
5750
+ transition: border-color var(--_motion) var(--_ease);
5751
+ }
5752
+
5753
+ .composer:focus-within {
5754
+ border-color: var(--_accent);
5755
+ }
5756
+
5757
+ /* The field grows with its content (sized by #autoGrow) up to the ceiling,
5758
+ then scrolls. border-box keeps that measurement stable: with content-box the
5759
+ padding would be added to every scrollHeight read and the field would creep
5760
+ taller on each keystroke. */
5761
+ .input {
5762
+ box-sizing: border-box;
5763
+ resize: none;
5764
+ max-height: var(--_composer-max-height);
5765
+ overflow-y: auto;
5766
+ padding: 6px 4px 2px;
5767
+ background: transparent;
5768
+ border: none;
5546
5769
  font: inherit;
5547
5770
  color: inherit;
5548
5771
  outline: none;
5549
5772
  }
5550
5773
 
5551
- .input:focus {
5552
- border-color: var(--_accent);
5774
+ .composer-tools {
5775
+ display: flex;
5776
+ align-items: center;
5777
+ gap: 2px;
5553
5778
  }
5554
5779
 
5555
- .send {
5780
+ /* Icon buttons: quiet at rest, so the field is what the eye lands on. */
5781
+ .attach-btn,
5782
+ .voice-btn {
5783
+ display: inline-flex;
5784
+ align-items: center;
5785
+ justify-content: center;
5786
+ width: var(--_tool-btn-size);
5787
+ height: var(--_tool-btn-size);
5788
+ padding: 0;
5556
5789
  border: none;
5557
5790
  border-radius: 8px;
5558
- padding: 0 16px;
5791
+ background: transparent;
5792
+ color: var(--_muted);
5793
+ font: inherit;
5794
+ line-height: 1;
5795
+ cursor: pointer;
5796
+ transition:
5797
+ background-color var(--_motion) var(--_ease),
5798
+ color var(--_motion) var(--_ease);
5799
+ }
5800
+
5801
+ .attach-btn:hover,
5802
+ .voice-btn:hover {
5803
+ background: var(--_hover);
5804
+ color: var(--_fg);
5805
+ }
5806
+
5807
+ .attach-btn:disabled,
5808
+ .voice-btn:disabled {
5809
+ cursor: default;
5810
+ opacity: 0.6;
5811
+ }
5812
+
5813
+ /* Send closes the row on the right: a circle, the only filled control in the
5814
+ composer, so "the thing that acts" reads at a glance. */
5815
+ .send {
5816
+ margin-left: auto;
5817
+ display: inline-flex;
5818
+ align-items: center;
5819
+ justify-content: center;
5820
+ flex: none;
5821
+ width: var(--_send-size);
5822
+ height: var(--_send-size);
5823
+ padding: 0;
5824
+ border: none;
5825
+ border-radius: 50%;
5559
5826
  background: var(--_accent);
5560
5827
  color: #ffffff;
5561
5828
  font: inherit;
5562
- font-weight: 600;
5829
+ line-height: 1;
5563
5830
  cursor: pointer;
5831
+ transition:
5832
+ background-color var(--_motion) var(--_ease),
5833
+ transform var(--_motion) var(--_ease-pop);
5834
+ }
5835
+
5836
+ .send:hover {
5837
+ transform: scale(1.08);
5838
+ }
5839
+
5840
+ .send:active {
5841
+ transform: scale(0.92);
5564
5842
  }
5565
5843
 
5566
5844
  .send:disabled {
5567
5845
  opacity: 0.5;
5568
5846
  cursor: default;
5847
+ transform: none;
5569
5848
  }
5570
5849
 
5571
- /* The composer button doubles as the Stop control while a run is in flight. */
5850
+ /* The composer button doubles as the Stop control while a run is in flight \u2014
5851
+ same circle, different glyph, so nothing moves when a run starts. */
5572
5852
  .send[data-state="running"] {
5573
5853
  background: var(--_muted);
5574
5854
  }
5575
5855
 
5576
- /* \u2500\u2500 File attachments \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
5577
- /* The \u{1F4CE} picker button sits left of the input; hidden until data-attachments-url. */
5578
- .attach-btn {
5579
- border: 1px solid var(--_border);
5580
- border-radius: 8px;
5581
- padding: 0 10px;
5582
- background: var(--_input-bg);
5583
- color: inherit;
5584
- font: inherit;
5585
- cursor: pointer;
5586
- }
5587
-
5588
- .attach-btn:hover {
5589
- border-color: var(--_accent);
5856
+ .send[data-state="idle"] .send-stop,
5857
+ .send[data-state="running"] .send-send {
5858
+ display: none;
5590
5859
  }
5591
5860
 
5592
- .attach-input {
5593
- display: none;
5861
+ /* Glyphs \u2014 one class, painted from the button's own colour so every state
5862
+ (hover, recording, running) carries the icon with it. */
5863
+ .glyph {
5864
+ width: var(--_glyph-size);
5865
+ height: var(--_glyph-size);
5866
+ fill: none;
5867
+ stroke: currentColor;
5868
+ stroke-width: var(--_glyph-stroke);
5869
+ stroke-linecap: round;
5870
+ stroke-linejoin: round;
5594
5871
  }
5595
5872
 
5596
- /* The \u{1F3A4} mic button; shown only once #wireVoice mounts it. */
5597
- .voice-slot {
5598
- display: contents;
5873
+ .glyph--solid {
5874
+ fill: currentColor;
5875
+ stroke: none;
5599
5876
  }
5600
5877
 
5601
- .voice-btn {
5602
- border: 1px solid var(--_border);
5603
- border-radius: 8px;
5604
- padding: 0 10px;
5605
- background: var(--_input-bg);
5606
- color: inherit;
5607
- font: inherit;
5608
- cursor: pointer;
5878
+ /* In an icon holder (header brand, launcher) the glyph takes the holder's size
5879
+ rather than the composer's. */
5880
+ .icon-holder .glyph {
5881
+ width: 100%;
5882
+ height: 100%;
5609
5883
  }
5610
5884
 
5611
- .voice-btn:hover {
5612
- border-color: var(--_accent);
5885
+ /* \u2500\u2500 File attachments \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
5886
+ /* The picker button sits in the composer's tool row; hidden until
5887
+ data-attachments-url. */
5888
+ .attach-input {
5889
+ display: none;
5613
5890
  }
5614
5891
 
5615
- .voice-btn:disabled {
5616
- cursor: default;
5617
- opacity: 0.6;
5892
+ /* The mic button's mount point; filled only once #wireVoice mounts the
5893
+ control. */
5894
+ .voice-slot {
5895
+ display: contents;
5618
5896
  }
5619
5897
 
5620
5898
  /* Recording: a red tint + a gentle pulse so it's clearly "live". */
5621
5899
  .voice-btn[data-state="recording"] {
5622
- border-color: var(--_danger);
5623
5900
  background: var(--_danger);
5624
5901
  color: #ffffff;
5625
5902
  animation: ag-ui-voice-pulse 1.2s ease-in-out infinite;
5626
5903
  }
5627
5904
 
5905
+ .voice-btn[data-state="recording"]:hover {
5906
+ background: var(--_danger);
5907
+ color: #ffffff;
5908
+ }
5909
+
5628
5910
  @keyframes ag-ui-voice-pulse {
5629
5911
  0%, 100% { opacity: 1; }
5630
5912
  50% { opacity: 0.6; }
@@ -5641,11 +5923,25 @@ var STYLES = `
5641
5923
  display: contents;
5642
5924
  }
5643
5925
 
5926
+ /* The padding is the only separation the tray gets: its slot is display:
5927
+ contents, so the tray is a direct child of the .chat column, and that column
5928
+ sets no gap. The bottom value keeps a chip off the composer's top edge.
5929
+ The inline value here is only the default: the page placement overrides
5930
+ padding-inline further down to compute its reading-column gutter, and wins
5931
+ on specificity whichever way this declaration is written. Read the two
5932
+ together before changing either. */
5644
5933
  .attachment-tray {
5645
5934
  display: flex;
5646
5935
  flex-wrap: wrap;
5647
5936
  gap: 6px;
5648
- padding: 8px 12px 0;
5937
+ padding: 8px 12px;
5938
+ }
5939
+
5940
+ /* The tray sets the hidden property while empty, and an author display beats
5941
+ the UA stylesheet's rule for it, so without this the empty tray keeps
5942
+ laying out and its padding is permanent dead space above the composer. */
5943
+ .attachment-tray[hidden] {
5944
+ display: none;
5649
5945
  }
5650
5946
 
5651
5947
  .attachment-chips {
@@ -5681,15 +5977,29 @@ var STYLES = `
5681
5977
  overflow-wrap: anywhere;
5682
5978
  }
5683
5979
 
5980
+ /* A chip carries its own text colour because it carries its own background.
5981
+ The same chip renders in two places with opposite inherited colours: in the
5982
+ composer tray it inherits the panel's, but on a sent user bubble it inherits
5983
+ the user foreground, which is white on the stock light theme. Against the
5984
+ assistant surface underneath that was a 1.13:1 filename \u2014 invisible, and only
5985
+ the size stayed legible because it sets its own muted colour. Pair the colour
5986
+ with the background it belongs to, and both placements read the same.
5987
+ Overridden below for an errored chip, which must keep its red. */
5684
5988
  .attachment-chip {
5685
5989
  display: inline-flex;
5686
5990
  align-items: center;
5687
5991
  gap: 6px;
5992
+ /* Without this the 100% below caps the content box, so the chip still
5993
+ overflows its container by its own padding and border. Invisible while a
5994
+ character cap kept names short; routine once the container is what bounds
5995
+ the name. */
5996
+ box-sizing: border-box;
5688
5997
  max-width: 100%;
5689
5998
  padding: 4px 8px;
5690
5999
  border: 1px solid var(--_border);
5691
6000
  border-radius: 999px;
5692
6001
  background: var(--_assistant-bg);
6002
+ color: var(--_text);
5693
6003
  font-size: 0.85em;
5694
6004
  position: relative;
5695
6005
  }
@@ -5699,11 +6009,31 @@ var STYLES = `
5699
6009
  color: var(--_danger);
5700
6010
  }
5701
6011
 
6012
+ /* The type mark. Painted from currentColor, so it carries the chip's state
6013
+ with it and turns red along with an errored one; muted by opacity rather
6014
+ than a colour, which is what keeps that true. Sized from the chip's own text
6015
+ rather than --ag-ui-glyph-size, the way an icon holder's glyph takes the
6016
+ holder's size: the composer's 18px buttons would make a chip button-height. */
6017
+ .attachment-chip-icon {
6018
+ display: inline-flex;
6019
+ flex: none;
6020
+ opacity: 0.75;
6021
+ }
6022
+
6023
+ .attachment-chip-icon .glyph {
6024
+ width: 1.25em;
6025
+ height: 1.25em;
6026
+ }
6027
+
6028
+ /* No character cap: the chip is already max-width 100%, so its container is
6029
+ what bounds the name, and a fixed cap only truncated names the chip had room
6030
+ for. min-width: 0 is what lets the flex item shrink past its content, so the
6031
+ ellipsis appears at the container edge instead of the chip overflowing. */
5702
6032
  .attachment-chip-name {
5703
6033
  overflow: hidden;
5704
6034
  text-overflow: ellipsis;
5705
6035
  white-space: nowrap;
5706
- max-width: 14ch;
6036
+ min-width: 0;
5707
6037
  }
5708
6038
 
5709
6039
  .attachment-chip-size {
@@ -6015,16 +6345,35 @@ var STYLES = `
6015
6345
  color: var(--_danger);
6016
6346
  }
6017
6347
 
6018
- /* Chat-history drawer \u2014 a slide-over within the chat panel. */
6348
+ /* Chat-history drawer \u2014 a slide-over within the chat panel.
6349
+ The hidden attribute stays the single source of truth for open/closed (no
6350
+ JS-driven animation state): display is transitioned discretely, so on close
6351
+ the drawer stays displayed for the whole exit and is removed only at the end,
6352
+ which is what lets the backdrop fade and the panel slide out. */
6019
6353
  .drawer {
6020
6354
  position: absolute;
6021
6355
  inset: 0;
6022
6356
  z-index: 5;
6023
6357
  display: flex;
6358
+ transition: visibility var(--_motion) var(--_ease);
6024
6359
  }
6025
6360
 
6361
+ /* Closed. The overlay keeps its box (display, not none) so the backdrop and
6362
+ panel inside it stay rendered and can transition both ways; visibility is
6363
+ what takes the whole subtree out of the tab order, the a11y tree and hit
6364
+ testing at rest, and it holds off until the slide has finished. */
6026
6365
  .drawer[hidden] {
6027
- display: none;
6366
+ display: flex;
6367
+ visibility: hidden;
6368
+ pointer-events: none;
6369
+ }
6370
+
6371
+ .drawer[hidden] .drawer-backdrop {
6372
+ opacity: 0;
6373
+ }
6374
+
6375
+ .drawer[hidden] .drawer-panel {
6376
+ transform: translateX(-100%);
6028
6377
  }
6029
6378
 
6030
6379
  .checkpoints {
@@ -6042,10 +6391,21 @@ var STYLES = `
6042
6391
  box-shadow: 0 6px 24px rgb(0 0 0 / 12%);
6043
6392
  max-height: 60%;
6044
6393
  overflow-y: auto;
6394
+ transform-origin: top center;
6395
+ transition:
6396
+ opacity var(--_motion) var(--_ease),
6397
+ transform var(--_motion) var(--_ease),
6398
+ visibility var(--_motion) var(--_ease);
6045
6399
  }
6046
6400
 
6401
+ /* Same idiom as the drawer: laid out at rest, hidden by visibility, so the
6402
+ popover can animate open and closed. */
6047
6403
  .checkpoints[hidden] {
6048
- display: none;
6404
+ display: flex;
6405
+ visibility: hidden;
6406
+ pointer-events: none;
6407
+ opacity: 0;
6408
+ transform: scale(0.96) translateY(-6px);
6049
6409
  }
6050
6410
 
6051
6411
  .checkpoints-title {
@@ -6107,6 +6467,8 @@ var STYLES = `
6107
6467
  position: absolute;
6108
6468
  inset: 0;
6109
6469
  background: rgba(20, 20, 50, 0.32);
6470
+ opacity: 1;
6471
+ transition: opacity var(--_motion) var(--_ease);
6110
6472
  }
6111
6473
 
6112
6474
  .drawer-panel {
@@ -6119,6 +6481,8 @@ var STYLES = `
6119
6481
  border-right: 1px solid var(--_border);
6120
6482
  box-shadow: var(--_shadow);
6121
6483
  overflow: hidden;
6484
+ transform: none;
6485
+ transition: transform var(--_motion) var(--_ease);
6122
6486
  }
6123
6487
 
6124
6488
  .drawer-header {
@@ -6748,7 +7112,10 @@ var VoiceInput = class {
6748
7112
  this.element.type = "button";
6749
7113
  this.element.className = "voice-btn";
6750
7114
  this.element.setAttribute("part", "voice-button");
6751
- this.element.textContent = "\u{1F3A4}";
7115
+ const glyph = document.createElement("slot");
7116
+ glyph.name = "icon-voice";
7117
+ glyph.innerHTML = ICON_VOICE;
7118
+ this.element.append(glyph);
6752
7119
  this.#setState("idle");
6753
7120
  this.element.addEventListener("click", () => {
6754
7121
  void this.toggle();
@@ -7742,14 +8109,17 @@ var AgUiChat = class extends HTMLElement {
7742
8109
  ...this.#toolRegistry.tools()
7743
8110
  ];
7744
8111
  /**
7745
- * Per-run context provider. Defaults to the compact page map (when a
7746
- * {@link getPageMap} provider is set and {@link autoInjectPageMap} is on)
7747
- * plus a one-line manifest of the files attached to the message being sent,
7748
- * so the agent knows which `read_attachment` ids are available.
8112
+ * Per-run context provider. Defaults to the compact page map, when a
8113
+ * {@link getPageMap} provider is set and {@link autoInjectPageMap} is on.
8114
+ *
8115
+ * It used to append a one-line manifest of the message's attachments too.
8116
+ * The server now derives that manifest from the refs riding the messages, so
8117
+ * the client's copy only duplicated it, on exactly the turn a file was
8118
+ * attached. Attachments still reach the agent; they reach it through the
8119
+ * message, which is where they were already.
7749
8120
  */
7750
8121
  getContext = () => [
7751
- ...createPageMapContext(this.getPageMap, this.autoInjectPageMap),
7752
- ...this.#attachmentContext()
8122
+ ...createPageMapContext(this.getPageMap, this.autoInjectPageMap)
7753
8123
  ];
7754
8124
  /**
7755
8125
  * Navigable routes the agent can jump to via the built-in `route.*` tools.
@@ -7865,8 +8235,12 @@ var AgUiChat = class extends HTMLElement {
7865
8235
  #attachSlot;
7866
8236
  /** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
7867
8237
  #themeToggle;
7868
- /** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
7869
- #rail;
8238
+ /** What the collapsed widget shrinks to: the floating launcher, or the sidebar rail. */
8239
+ #launcher;
8240
+ /** The launcher's unread badge; hidden at zero, and when the host opts out. */
8241
+ #badge;
8242
+ // Answers that finished while the widget was collapsed. Expanding clears it.
8243
+ #unread = 0;
7870
8244
  /** Empty-state region at the top of the message list; hidden once anything renders. */
7871
8245
  #emptyWrap;
7872
8246
  /** Upload tray; created on connect only when `data-attachments-url` is set. */
@@ -7877,8 +8251,6 @@ var AgUiChat = class extends HTMLElement {
7877
8251
  #voice = null;
7878
8252
  /** Whether the element is currently in the DOM; gates the connect-time warning. */
7879
8253
  #connected = false;
7880
- /** Refs attached to the message currently being sent (the context manifest). */
7881
- #runAttachments = [];
7882
8254
  #client = null;
7883
8255
  // Seed for the next client. Once one exists it owns the live value (the
7884
8256
  // agent applies STATE_SNAPSHOT / STATE_DELTA into it), so this is only the
@@ -7939,7 +8311,8 @@ var AgUiChat = class extends HTMLElement {
7939
8311
  this.#attachSlot = document.createElement("div");
7940
8312
  this.#voiceSlot = document.createElement("span");
7941
8313
  this.#themeToggle = document.createElement("button");
7942
- this.#rail = document.createElement("button");
8314
+ this.#launcher = document.createElement("button");
8315
+ this.#badge = document.createElement("span");
7943
8316
  this.#emptyWrap = document.createElement("div");
7944
8317
  this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
7945
8318
  this.#drawer = new ThreadDrawer({
@@ -8002,6 +8375,7 @@ var AgUiChat = class extends HTMLElement {
8002
8375
  return;
8003
8376
  }
8004
8377
  this.#input.value = "";
8378
+ this.#autoGrow();
8005
8379
  const endpoint = verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId);
8006
8380
  const agent = this.agentFactory({
8007
8381
  endpoint,
@@ -8332,7 +8706,7 @@ var AgUiChat = class extends HTMLElement {
8332
8706
  if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
8333
8707
  this.setAttribute("collapsed", "");
8334
8708
  }
8335
- this.#syncRail();
8709
+ this.#syncLauncher();
8336
8710
  this.#initSkills();
8337
8711
  if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
8338
8712
  this.conversationStore = new SessionStorageStore(this.#storageNs);
@@ -8563,22 +8937,6 @@ var AgUiChat = class extends HTMLElement {
8563
8937
  }
8564
8938
  });
8565
8939
  }
8566
- /** The one-line manifest of the message's attachments, for the run context. */
8567
- #attachmentContext() {
8568
- if (this.#runAttachments.length === 0) {
8569
- return [];
8570
- }
8571
- const lines = this.#runAttachments.map(
8572
- (ref) => `- ${ref.name} (id: ${ref.id}, ${ref.mime || "unknown type"}, ${ref.size} bytes)`
8573
- );
8574
- return [
8575
- {
8576
- description: "Files the user attached to this message",
8577
- value: `${lines.join("\n")}
8578
- Use the read_attachment tool with an id to read a file's contents.`
8579
- }
8580
- ];
8581
- }
8582
8940
  /**
8583
8941
  * When `data-threads-url` is set, route thread enumeration / load / rename /
8584
8942
  * delete through that server endpoint (wrapping the current store as the
@@ -8690,12 +9048,14 @@ Use the read_attachment tool with an id to read a file's contents.`
8690
9048
  this.#skillHint.textContent = this.#strings.skillNeeds.replace("{title}", skill.title).replace("{fields}", missing.join(", "));
8691
9049
  this.#skillHint.hidden = false;
8692
9050
  this.#input.value = text2;
9051
+ this.#autoGrow();
8693
9052
  this.#input.focus();
8694
9053
  this.#selectFirstPlaceholder(text2);
8695
9054
  return;
8696
9055
  }
8697
9056
  this.#skillHint.hidden = true;
8698
9057
  this.#input.value = text2;
9058
+ this.#autoGrow();
8699
9059
  if (skill.sendImmediately === false) {
8700
9060
  this.#input.focus();
8701
9061
  return;
@@ -8733,7 +9093,7 @@ Use the read_attachment tool with an id to read a file's contents.`
8733
9093
  this.removeAttribute("collapsed");
8734
9094
  }
8735
9095
  sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
8736
- this.#syncRail();
9096
+ this.#setUnread(0);
8737
9097
  this.dispatchEvent(
8738
9098
  new CustomEvent(TOGGLE_EVENT, {
8739
9099
  detail: { collapsed },
@@ -8742,6 +9102,15 @@ Use the read_attachment tool with an id to read a file's contents.`
8742
9102
  })
8743
9103
  );
8744
9104
  }
9105
+ /**
9106
+ * Answers that finished while the widget was collapsed, and that the user has
9107
+ * therefore not seen. Expanding (or {@link newChat}) clears it. The launcher's
9108
+ * badge renders this; {@link UNREAD_EVENT} announces every change, so a host
9109
+ * chrome can render its own instead.
9110
+ */
9111
+ get unread() {
9112
+ return this.#unread;
9113
+ }
8745
9114
  /** Flip the collapsed state. Bound to the built-in header toggle. */
8746
9115
  toggleCollapsed() {
8747
9116
  this.setCollapsed(!this.collapsed);
@@ -8935,6 +9304,7 @@ Use the read_attachment tool with an id to read a file's contents.`
8935
9304
  this.#resetState();
8936
9305
  this.#threadId = this.conversationStore.threadId();
8937
9306
  this.#setRunning(false);
9307
+ this.#setUnread(0);
8938
9308
  }
8939
9309
  /** Drop the in-memory run + transcript, leaving the thread id untouched. */
8940
9310
  #resetState() {
@@ -8946,7 +9316,6 @@ Use the read_attachment tool with an id to read a file's contents.`
8946
9316
  this.#toolCards.clear();
8947
9317
  this.#serverSettled.clear();
8948
9318
  this.#initialMessages = [];
8949
- this.#runAttachments = [];
8950
9319
  this.#attachTray?.clear();
8951
9320
  this.#messages.replaceChildren(this.#emptyWrap);
8952
9321
  this.#updateEmptyState();
@@ -9233,17 +9602,27 @@ Use the read_attachment tool with an id to read a file's contents.`
9233
9602
  const inputRow = document.createElement("div");
9234
9603
  inputRow.className = "input-row";
9235
9604
  inputRow.setAttribute("part", "composer");
9605
+ const composer = document.createElement("div");
9606
+ composer.className = "composer";
9607
+ composer.setAttribute("part", "composer-surface");
9608
+ const tools = document.createElement("div");
9609
+ tools.className = "composer-tools";
9610
+ tools.setAttribute("part", "composer-tools");
9236
9611
  this.#input.className = "input";
9237
9612
  this.#input.setAttribute("part", "input");
9238
9613
  this.#input.setAttribute("aria-label", this.#strings.message);
9239
- this.#input.rows = 2;
9614
+ this.#input.rows = 1;
9240
9615
  this.#input.placeholder = this.#strings.inputPlaceholder;
9241
9616
  this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
9242
9617
  this.#input.addEventListener("input", () => this.#onInput());
9243
9618
  this.#send.className = "send";
9244
9619
  this.#send.type = "button";
9245
9620
  this.#send.setAttribute("part", "send");
9246
- this.#send.textContent = this.#strings.send;
9621
+ this.#send.append(
9622
+ this.#glyphSlot("icon-send", "send-send", ICON_SEND),
9623
+ this.#glyphSlot("icon-stop", "send-stop", ICON_STOP)
9624
+ );
9625
+ this.#send.title = this.#strings.send;
9247
9626
  this.#send.setAttribute("aria-label", this.#strings.send);
9248
9627
  this.#send.dataset["state"] = "idle";
9249
9628
  this.#send.addEventListener("click", () => {
@@ -9259,7 +9638,7 @@ Use the read_attachment tool with an id to read a file's contents.`
9259
9638
  this.#attachButton.className = "attach-btn";
9260
9639
  this.#attachButton.type = "button";
9261
9640
  this.#attachButton.setAttribute("part", "attach-button");
9262
- this.#attachButton.textContent = "\u{1F4CE}";
9641
+ this.#attachButton.append(this.#glyphSlot("icon-attach", "attach-glyph", ICON_ATTACH));
9263
9642
  this.#attachButton.title = this.#strings.attachFiles;
9264
9643
  this.#attachButton.setAttribute("aria-label", this.#strings.attachFiles);
9265
9644
  this.#attachButton.hidden = true;
@@ -9273,7 +9652,9 @@ Use the read_attachment tool with an id to read a file's contents.`
9273
9652
  this.#voiceSlot.className = "voice-slot";
9274
9653
  const footer = document.createElement("slot");
9275
9654
  footer.name = "footer";
9276
- inputRow.append(this.#attachButton, this.#voiceSlot, this.#input, this.#send, this.#fileInput);
9655
+ tools.append(this.#attachButton, this.#voiceSlot, this.#send);
9656
+ composer.append(this.#input, tools);
9657
+ inputRow.append(composer, this.#fileInput);
9277
9658
  this.#chat.append(
9278
9659
  header,
9279
9660
  this.#messages,
@@ -9286,12 +9667,19 @@ Use the read_attachment tool with an id to read a file's contents.`
9286
9667
  this.#drawer.element,
9287
9668
  this.#checkpoints.element
9288
9669
  );
9289
- this.#rail.className = "rail";
9290
- this.#rail.type = "button";
9291
- this.#rail.setAttribute("part", "launcher");
9292
- this.#rail.setAttribute("aria-label", this.#strings.expand);
9293
- this.#rail.append(this.#iconElement("launcher", "launcher-icon", "\u{1F4AC}"));
9294
- this.#rail.addEventListener("click", () => this.setCollapsed(false));
9670
+ this.#launcher.className = "launcher";
9671
+ this.#launcher.type = "button";
9672
+ this.#launcher.setAttribute("part", "launcher");
9673
+ this.#launcher.setAttribute("aria-label", this.#strings.expand);
9674
+ this.#badge.className = "launcher-badge";
9675
+ this.#badge.setAttribute("part", "launcher-badge");
9676
+ this.#badge.setAttribute("aria-hidden", "true");
9677
+ this.#badge.hidden = true;
9678
+ this.#launcher.append(
9679
+ this.#iconElement("launcher", "launcher-icon", ICON_LAUNCHER, this.#launcherIconUrl()),
9680
+ this.#badge
9681
+ );
9682
+ this.#launcher.addEventListener("click", () => this.setCollapsed(false));
9295
9683
  this.#chat.append(
9296
9684
  createResizeHandle({
9297
9685
  axis: () => this.#resizeAxis(),
@@ -9305,7 +9693,7 @@ Use the read_attachment tool with an id to read a file's contents.`
9305
9693
  label: this.#strings.resizePanel
9306
9694
  })
9307
9695
  );
9308
- this.#root.append(style, this.#chat, this.#rail);
9696
+ this.#root.append(style, this.#chat, this.#launcher);
9309
9697
  }
9310
9698
  /**
9311
9699
  * Build a header control button: a named slot a host can project markup into,
@@ -9332,16 +9720,37 @@ Use the read_attachment tool with an id to read a file's contents.`
9332
9720
  return button2;
9333
9721
  }
9334
9722
  /**
9335
- * An icon holder wrapping a `<slot>` so a host can project custom markup; with
9336
- * a `data-icon-url` `<img>` as the slot's fallback, or a glyph when given.
9723
+ * A `<slot>` a host can project its own mark into, falling back to one of the
9724
+ * built-in glyphs. The markup is an author-written constant, never user or
9725
+ * server data, so it is assigned directly rather than sanitised.
9726
+ */
9727
+ #glyphSlot(slotName, className, markup) {
9728
+ const slot = document.createElement("slot");
9729
+ slot.name = slotName;
9730
+ slot.className = className;
9731
+ slot.innerHTML = markup;
9732
+ return slot;
9733
+ }
9734
+ /**
9735
+ * The launcher's own image URL. `data-launcher-icon-url` lets the collapsed
9736
+ * button carry a different mark from the header's — a product logo reads at
9737
+ * 22px in a header bar but rarely at 26px in a circle — and falls back to the
9738
+ * header icon so a single `data-icon-url` still feeds both.
9337
9739
  */
9338
- #iconElement(slotName, part, fallbackGlyph) {
9740
+ #launcherIconUrl() {
9741
+ return this.getAttribute("data-launcher-icon-url") ?? this.getAttribute("data-icon-url");
9742
+ }
9743
+ /**
9744
+ * An icon holder wrapping a `<slot>` so a host can project custom markup;
9745
+ * with an `<img>` as the slot's fallback when an icon URL is configured, or
9746
+ * the given glyph markup when it is not.
9747
+ */
9748
+ #iconElement(slotName, part, fallbackGlyph, iconUrl = this.getAttribute("data-icon-url")) {
9339
9749
  const holder = document.createElement("span");
9340
9750
  holder.className = "icon-holder";
9341
9751
  holder.setAttribute("part", part);
9342
9752
  const slot = document.createElement("slot");
9343
9753
  slot.name = slotName;
9344
- const iconUrl = this.getAttribute("data-icon-url");
9345
9754
  if (iconUrl !== null) {
9346
9755
  const img = document.createElement("img");
9347
9756
  img.className = "icon-img";
@@ -9349,14 +9758,63 @@ Use the read_attachment tool with an id to read a file's contents.`
9349
9758
  img.alt = "";
9350
9759
  slot.append(img);
9351
9760
  } else if (fallbackGlyph !== null) {
9352
- slot.append(document.createTextNode(fallbackGlyph));
9761
+ slot.innerHTML = fallbackGlyph;
9353
9762
  }
9354
9763
  holder.append(slot);
9355
9764
  return holder;
9356
9765
  }
9357
- /** Reflect the collapsed state on the rail's `aria-expanded`. */
9358
- #syncRail() {
9359
- this.#rail.setAttribute("aria-expanded", String(!this.collapsed));
9766
+ /**
9767
+ * Reflect the collapsed state and the unread count on the launcher.
9768
+ *
9769
+ * The count is also the launcher's accessible name: a badge that only exists
9770
+ * as a coloured dot says nothing to a screen reader, and "Expand" alone would
9771
+ * be a lie once answers are waiting behind it.
9772
+ */
9773
+ #syncLauncher() {
9774
+ this.#launcher.setAttribute("aria-expanded", String(!this.collapsed));
9775
+ const unread = this.#unread;
9776
+ this.#badge.textContent = unread > 9 ? "9+" : String(unread);
9777
+ this.#badge.hidden = unread === 0 || !this.#badgeEnabled();
9778
+ const label = this.#badge.hidden ? this.#strings.expand : this.#strings.expandUnread.replace("{count}", String(unread));
9779
+ this.#launcher.setAttribute("aria-label", label);
9780
+ this.#launcher.title = label;
9781
+ }
9782
+ /**
9783
+ * The unread badge, unlike every other affordance here, is on by default:
9784
+ * a collapsed widget is the one state where an answer can arrive with nothing
9785
+ * on screen to say so. `data-unread-badge="false"` turns it off for a host
9786
+ * that drives its own chrome from the `ag-ui-unread` event.
9787
+ */
9788
+ #badgeEnabled() {
9789
+ return this.getAttribute("data-unread-badge") !== "false";
9790
+ }
9791
+ /**
9792
+ * Set the unread count, repaint the badge, and tell the host.
9793
+ *
9794
+ * The count is kept whether or not the badge renders it, so `unread` stays
9795
+ * truthful for a host chrome and switching the badge on mid-session doesn't
9796
+ * start from a number that was never counted.
9797
+ */
9798
+ #setUnread(count) {
9799
+ this.#unread = count;
9800
+ this.#syncLauncher();
9801
+ this.dispatchEvent(
9802
+ new CustomEvent(UNREAD_EVENT, {
9803
+ detail: { unread: count },
9804
+ bubbles: true,
9805
+ composed: true
9806
+ })
9807
+ );
9808
+ }
9809
+ /**
9810
+ * Count an answer the user cannot have seen: one that finished while the
9811
+ * widget was collapsed. Expanding is what marks them read.
9812
+ */
9813
+ #noteUnread() {
9814
+ if (!this.collapsed) {
9815
+ return;
9816
+ }
9817
+ this.#setUnread(this.#unread + 1);
9360
9818
  }
9361
9819
  /** Hide the empty-state region once the message list holds anything else. */
9362
9820
  #updateEmptyState() {
@@ -9366,6 +9824,7 @@ Use the read_attachment tool with an id to read a file's contents.`
9366
9824
  #onInput() {
9367
9825
  this.#skillsMenu.onInput(this.#input.value);
9368
9826
  this.#skillHint.hidden = true;
9827
+ this.#autoGrow();
9369
9828
  }
9370
9829
  #onKeydown(event) {
9371
9830
  if (this.#skillsMenu.onKeydown(event)) {
@@ -9392,14 +9851,32 @@ Use the read_attachment tool with an id to read a file's contents.`
9392
9851
  this.#confirmAbort?.abort();
9393
9852
  this.#client?.cancel();
9394
9853
  }
9395
- /** Swap the composer button between Send (idle) and Stop (running). */
9854
+ /**
9855
+ * Swap the composer button between Send (idle) and Stop (running).
9856
+ *
9857
+ * The glyph is swapped by CSS from `data-state` — both are mounted — so this
9858
+ * only has to move the accessible name, which is the button's whole label now
9859
+ * that it carries no text.
9860
+ */
9396
9861
  #setRunning(running) {
9397
9862
  this.#running = running;
9398
9863
  const label = running ? this.#strings.stop : this.#strings.send;
9399
- this.#send.textContent = label;
9864
+ this.#send.title = label;
9400
9865
  this.#send.setAttribute("aria-label", label);
9401
9866
  this.#send.dataset["state"] = running ? "running" : "idle";
9402
9867
  }
9868
+ /**
9869
+ * Size the field to its content: one row when empty, growing with what is
9870
+ * typed until the CSS ceiling takes over and it scrolls.
9871
+ *
9872
+ * Resetting to `auto` first is what makes it shrink again — `scrollHeight`
9873
+ * never reports less than the current height, so measuring without the reset
9874
+ * would ratchet the composer taller and never back down.
9875
+ */
9876
+ #autoGrow() {
9877
+ this.#input.style.height = "auto";
9878
+ this.#input.style.height = `${this.#input.scrollHeight}px`;
9879
+ }
9403
9880
  async #submit() {
9404
9881
  if (this.#running) {
9405
9882
  return;
@@ -9410,6 +9887,7 @@ Use the read_attachment tool with an id to read a file's contents.`
9410
9887
  return;
9411
9888
  }
9412
9889
  this.#input.value = "";
9890
+ this.#autoGrow();
9413
9891
  if (this.#attachTray?.hasPending() === true) {
9414
9892
  this.#appendNotice(
9415
9893
  "\u{1F4CE}",
@@ -9449,7 +9927,6 @@ Use the read_attachment tool with an id to read a file's contents.`
9449
9927
  if (attachments.length > 0) {
9450
9928
  bubble.appendChild(renderAttachmentChips(attachments));
9451
9929
  }
9452
- this.#runAttachments = attachments;
9453
9930
  this.dispatchEvent(
9454
9931
  new CustomEvent(SUBMIT_EVENT, {
9455
9932
  detail: { content, attachments },
@@ -9678,6 +10155,7 @@ Use the read_attachment tool with an id to read a file's contents.`
9678
10155
  }
9679
10156
  attachCopyButtons(bubble, this.#strings);
9680
10157
  this.#streamingBubble = null;
10158
+ this.#noteUnread();
9681
10159
  },
9682
10160
  onToolCall: (call) => {
9683
10161
  this.#hidePending();
@@ -9726,7 +10204,6 @@ Use the read_attachment tool with an id to read a file's contents.`
9726
10204
  this.#hidePending();
9727
10205
  this.#setRunning(false);
9728
10206
  this.#streamingBubble = null;
9729
- this.#runAttachments = [];
9730
10207
  for (const card of this.#toolCards.values()) {
9731
10208
  if (!card.settled) {
9732
10209
  card.settle(TOOL_CALL_STATUS.DONE, this.#strings.noResult);
@@ -9904,7 +10381,7 @@ function setControlValue(el, value) {
9904
10381
  }
9905
10382
 
9906
10383
  // src/version.ts
9907
- var VERSION = "0.21.0";
10384
+ var VERSION = "0.23.0";
9908
10385
  export {
9909
10386
  ATTACHMENT_EVENT,
9910
10387
  AgUiChat,
@@ -9928,6 +10405,7 @@ export {
9928
10405
  TOOL_CALL_STATUS,
9929
10406
  TOOL_DISPLAY,
9930
10407
  ToolCallCard,
10408
+ UNREAD_EVENT,
9931
10409
  VERSION,
9932
10410
  X_CONFIRM_KEY,
9933
10411
  X_DESTRUCTIVE_KEY,