@artooi/ag-ui-web-component 0.47.0 → 0.49.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 (60) hide show
  1. package/CHANGELOG.md +205 -1
  2. package/README.md +235 -16
  3. package/dist/ag-ui-web-component.bundle.js +647 -181
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/constants.d.ts +27 -2
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/core/ag_ui_chat.d.ts +44 -5
  8. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  9. package/dist/core/events/bar_height_detail.d.ts +6 -0
  10. package/dist/core/events/bar_height_detail.d.ts.map +1 -0
  11. package/dist/dom/highlight_overlay.d.ts +2 -1
  12. package/dist/dom/highlight_overlay.d.ts.map +1 -1
  13. package/dist/dom/ring_color.d.ts +8 -0
  14. package/dist/dom/ring_color.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1184 -69
  18. package/dist/index.js.map +4 -4
  19. package/dist/tools/chat_surface_tools.d.ts +8 -1
  20. package/dist/tools/chat_surface_tools.d.ts.map +1 -1
  21. package/dist/ui/history/conversation_history.d.ts +16 -3
  22. package/dist/ui/history/conversation_history.d.ts.map +1 -1
  23. package/dist/ui/placement/collapse_styles.d.ts.map +1 -1
  24. package/dist/ui/placement/dock_styles.d.ts +2 -0
  25. package/dist/ui/placement/dock_styles.d.ts.map +1 -0
  26. package/dist/ui/placement/panel_placement.d.ts +16 -0
  27. package/dist/ui/placement/panel_placement.d.ts.map +1 -1
  28. package/dist/ui/placement/small_viewport_styles.d.ts.map +1 -1
  29. package/dist/ui/placement/surface_for.d.ts +32 -19
  30. package/dist/ui/placement/surface_for.d.ts.map +1 -1
  31. package/dist/ui/progress/tool_group_styles.d.ts.map +1 -1
  32. package/dist/ui/progress/tool_step_styles.d.ts.map +1 -1
  33. package/dist/ui/shell/build_launcher.d.ts +15 -4
  34. package/dist/ui/shell/build_launcher.d.ts.map +1 -1
  35. package/dist/ui/shell/token_styles.d.ts.map +1 -1
  36. package/dist/ui/styles.d.ts.map +1 -1
  37. package/dist/ui/themes/quiet_styles.d.ts.map +1 -1
  38. package/dist/ui/themes/scheme_styles.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/constants.ts +30 -2
  41. package/src/core/ag_ui_chat.ts +335 -28
  42. package/src/core/events/bar_height_detail.ts +5 -0
  43. package/src/dom/highlight_overlay.ts +2 -1
  44. package/src/dom/ring_color.ts +141 -4
  45. package/src/index.ts +4 -0
  46. package/src/tools/chat_surface_tools.ts +13 -5
  47. package/src/ui/history/conversation_history.ts +51 -7
  48. package/src/ui/placement/collapse_styles.ts +248 -2
  49. package/src/ui/placement/dock_styles.ts +95 -0
  50. package/src/ui/placement/panel_placement.ts +300 -16
  51. package/src/ui/placement/small_viewport_styles.ts +9 -0
  52. package/src/ui/placement/surface_for.ts +58 -22
  53. package/src/ui/progress/tool_group_styles.ts +52 -2
  54. package/src/ui/progress/tool_step_styles.ts +5 -3
  55. package/src/ui/shell/build_launcher.ts +32 -5
  56. package/src/ui/shell/token_styles.ts +58 -1
  57. package/src/ui/styles.ts +4 -0
  58. package/src/ui/themes/quiet_styles.ts +19 -3
  59. package/src/ui/themes/scheme_styles.ts +10 -1
  60. package/src/version.ts +1 -1
@@ -25,6 +25,14 @@ const PACKAGE_ACCENT = "#4f46e5";
25
25
  * host that themes `--ag-ui-accent` on its page keeps the rings it has; the
26
26
  * ring token sits above both so a host can keep the ring apart from the accent.
27
27
  *
28
+ * Only the chat's own accent is adjusted, because it follows the widget's
29
+ * scheme rather than the page's: a dark widget's near-white accent on a light
30
+ * page all but disappears. It is held to 3:1 against the surface behind the
31
+ * target (see {@link surfaceBehind}): the accent as resolved if it holds, else
32
+ * the other half of the chat's light-dark pair, else the accent stepped toward
33
+ * black or white. Every value above it is a choice someone made, and is drawn
34
+ * as given.
35
+ *
28
36
  * `chat` defaults to the most recently connected `<ag-ui-chat>`, the common
29
37
  * page with one widget. A page with two passes the one the ring speaks for.
30
38
  */
@@ -42,8 +50,130 @@ export function ringColor(target: HTMLElement, chat?: HTMLElement): string {
42
50
  if (accent !== "") {
43
51
  return accent;
44
52
  }
45
- const chatAccent = owner === null ? "" : resolveOn(owner, CHAT_ACCENT);
46
- return chatAccent === "" ? PACKAGE_ACCENT : chatAccent;
53
+ if (owner === null) {
54
+ return PACKAGE_ACCENT;
55
+ }
56
+ const chatAccent = resolveOn(owner, CHAT_ACCENT);
57
+ return chatAccent === "" ? PACKAGE_ACCENT : legible(owner, chatAccent, target);
58
+ }
59
+
60
+ /** The contrast a ring needs against what it is drawn on (WCAG 2.4.13). */
61
+ const RING_CONTRAST = 3;
62
+
63
+ /** How far each step moves a failing accent toward black or white. */
64
+ const STEP = 0.1;
65
+
66
+ type Rgba = readonly [number, number, number, number];
67
+
68
+ /**
69
+ * `accent`, or the nearest variant of it that holds {@link RING_CONTRAST}
70
+ * against the surface behind `target`.
71
+ *
72
+ * Colours are compared as sRGB bytes painted onto a one-pixel canvas, the one
73
+ * reading that turns a resolved color-mix() or oklch() into numbers. Where
74
+ * there is no 2d context to paint on, nothing can be measured, and the accent
75
+ * is drawn as it was.
76
+ */
77
+ function legible(chat: HTMLElement, accent: string, target: HTMLElement): string {
78
+ const paint = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
79
+ if (paint === null) {
80
+ return accent;
81
+ }
82
+ const bytes = (color: string): Rgba => {
83
+ paint.clearRect(0, 0, 1, 1);
84
+ paint.fillStyle = "rgb(0 0 0 / 0)";
85
+ paint.fillStyle = color;
86
+ paint.fillRect(0, 0, 1, 1);
87
+ const [r = 0, g = 0, b = 0, a = 0] = paint.getImageData(0, 0, 1, 1).data;
88
+ return [r, g, b, a / 255];
89
+ };
90
+ const surface = surfaceBehind(target, bytes);
91
+ const holds = (color: Rgba): boolean => contrast(over(color, surface), surface) >= RING_CONTRAST;
92
+ const own = [accent, resolveOn(chat, CHAT_ACCENT, "light"), resolveOn(chat, CHAT_ACCENT, "dark")];
93
+ const fit = own.find((candidate) => holds(bytes(candidate)));
94
+ if (fit !== undefined) {
95
+ return fit;
96
+ }
97
+ // Black or white, whichever the surface sets off better, holds at least
98
+ // 4.5:1 against any surface, so the walk always ends by the last step.
99
+ const from = over(bytes(accent), surface);
100
+ const black: Rgba = [0, 0, 0, 1];
101
+ const white: Rgba = [255, 255, 255, 1];
102
+ const toward = contrast(black, surface) > contrast(white, surface) ? black : white;
103
+ let step = 1;
104
+ while (!holds(mix(from, toward, step * STEP))) {
105
+ step += 1;
106
+ }
107
+ const [r, g, b] = mix(from, toward, step * STEP);
108
+ return `rgb(${r}, ${g}, ${b})`;
109
+ }
110
+
111
+ /**
112
+ * The colour of the surface a ring around `target` is drawn on, as opaque
113
+ * sRGB bytes.
114
+ *
115
+ * The ring sits outside the target's border box (the flash outline is offset
116
+ * from it, the overlay leaves a gap), so the target's own fill is not under
117
+ * it: a white field on a dark page is ringed on the dark page. The surface is
118
+ * every background from the target's parent up, composited until one is
119
+ * opaque, over the page canvas, which follows the root's colour scheme. A
120
+ * background image is not read; its colour beneath is.
121
+ */
122
+ function surfaceBehind(target: HTMLElement, bytes: (color: string) => Rgba): Rgba {
123
+ const layers: Rgba[] = [];
124
+ for (let el = parentOf(target); el !== null; el = parentOf(el)) {
125
+ const layer = bytes(window.getComputedStyle(el).backgroundColor);
126
+ layers.push(layer);
127
+ if (layer[3] === 1) {
128
+ break;
129
+ }
130
+ }
131
+ const probe = document.createElement("span");
132
+ probe.hidden = true;
133
+ probe.style.color = "Canvas";
134
+ document.documentElement.appendChild(probe);
135
+ let surface = bytes(window.getComputedStyle(probe).color);
136
+ probe.remove();
137
+ for (const layer of layers.reverse()) {
138
+ surface = over(layer, surface);
139
+ }
140
+ return surface;
141
+ }
142
+
143
+ /** The element above `el`, crossing out of a shadow root to its host. */
144
+ function parentOf(el: Element): Element | null {
145
+ if (el.parentElement !== null) {
146
+ return el.parentElement;
147
+ }
148
+ const root = el.getRootNode();
149
+ return root instanceof ShadowRoot ? root.host : null;
150
+ }
151
+
152
+ /** `top` painted over the opaque `under`. */
153
+ function over(top: Rgba, under: Rgba): Rgba {
154
+ return mix(under, top, top[3]);
155
+ }
156
+
157
+ /** `from` moved `share` of the way to `to`, as opaque bytes. */
158
+ function mix(from: Rgba, to: Rgba, share: number): Rgba {
159
+ const channel = (index: 0 | 1 | 2): number =>
160
+ Math.round(from[index] + (to[index] - from[index]) * share);
161
+ return [channel(0), channel(1), channel(2), 1];
162
+ }
163
+
164
+ /** WCAG relative luminance of opaque sRGB bytes. */
165
+ function luminance([r, g, b]: Rgba): number {
166
+ const linear = (channel: number): number => {
167
+ const c = channel / 255;
168
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
169
+ };
170
+ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
171
+ }
172
+
173
+ /** WCAG contrast ratio between two opaque colours. */
174
+ function contrast(a: Rgba, b: Rgba): number {
175
+ const [la, lb] = [luminance(a), luminance(b)];
176
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
47
177
  }
48
178
 
49
179
  function readToken(el: HTMLElement, name: string): string {
@@ -62,9 +192,11 @@ function readToken(el: HTMLElement, name: string): string {
62
192
  * scheme, and its computed `color` is the resolved value.
63
193
  *
64
194
  * An element with no open shadow root (a host passing something other than a
65
- * chat) is read as it is, unresolved; that is the best it offers.
195
+ * chat) is read as it is, unresolved; that is the best it offers. `scheme`
196
+ * forces one half of the pair, which is how a ring finds the chat's other
197
+ * accent when its own scheme's is too faint against the page.
66
198
  */
67
- function resolveOn(chat: HTMLElement, name: string): string {
199
+ function resolveOn(chat: HTMLElement, name: string, scheme?: "light" | "dark"): string {
68
200
  const value = readToken(chat, name);
69
201
  const root = chat.shadowRoot;
70
202
  if (value === "" || root === null) {
@@ -72,6 +204,11 @@ function resolveOn(chat: HTMLElement, name: string): string {
72
204
  }
73
205
  const probe = document.createElement("span");
74
206
  probe.hidden = true;
207
+ // A scheme named here picks that half of a light-dark() pair instead of
208
+ // the one the chat's own scheme picks.
209
+ if (scheme !== undefined) {
210
+ probe.style.colorScheme = scheme;
211
+ }
75
212
  probe.style.color = value;
76
213
  // An unparseable value leaves the declaration empty, and the probe would
77
214
  // then report the colour it inherits instead of saying it has none.
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  export {
4
4
  ATTACHMENT_EVENT,
5
+ BAR_HEIGHT_EVENT,
5
6
  CHART_ACTIVITY_TYPE,
6
7
  COMPACTION_ACTIVITY_TYPE,
7
8
  CUSTOM_AGENT_EVENT,
@@ -21,7 +22,9 @@ export {
21
22
  TOGGLE_EVENT,
22
23
  TOOL_CALL_STATUS,
23
24
  TOOL_DISPLAY,
25
+ TOOL_GROUP_DISPLAY,
24
26
  TOOL_OUTCOME,
27
+ type ToolGroupDisplayMode,
25
28
  UNREAD_EVENT,
26
29
  X_CONFIRM_KEY,
27
30
  X_DESTRUCTIVE_KEY,
@@ -58,6 +61,7 @@ export {
58
61
  } from "./core/create_http_agent.js";
59
62
  export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
60
63
  export type { AttachmentsDetail } from "./core/events/attachments_detail.js";
64
+ export type { BarHeightDetail } from "./core/events/bar_height_detail.js";
61
65
  export type { CustomAgentDetail } from "./core/events/custom_agent_detail.js";
62
66
  export type { FeedbackDetail } from "./core/events/feedback_detail.js";
63
67
  export type { InvalidateDetail } from "./core/events/invalidate_detail.js";
@@ -1,4 +1,5 @@
1
1
  import { X_SUMMARY_KEY } from "../constants.js";
2
+ import type { CollapseTarget } from "../ui/placement/surface_for.js";
2
3
  import type { ClientTool } from "./client_tool_registry.js";
3
4
 
4
5
  /** Every corner the panel can be sent to, in the order the schema states them. */
@@ -11,7 +12,13 @@ export type ChatCorner = (typeof CHAT_CORNERS)[number];
11
12
  export interface ChatSurfaceReport {
12
13
  /** The placement in force, or `null` when the host set none. */
13
14
  readonly placement: string | null;
14
- /** Whether the panel is currently at its launcher. */
15
+ /**
16
+ * What the panel collapses to: `launcher`, `rail`, `header`, `bar` (the
17
+ * bottom bar it rises from), `none` (hidden, brought back by the host's own
18
+ * control), or `null` when it cannot collapse.
19
+ */
20
+ readonly collapseTo: CollapseTarget;
21
+ /** Whether the panel is currently collapsed. */
15
22
  readonly collapsed: boolean;
16
23
  /** Whether this placement has a collapsed state at all. */
17
24
  readonly collapsible: boolean;
@@ -166,9 +173,10 @@ export function createChatSurfaceTools(surface: ChatSurface): ClientTool[] {
166
173
  {
167
174
  name: "minimise_chat",
168
175
  description:
169
- "Collapse your own panel to its launcher, so the user can see the whole page. " +
170
- "The launcher stays visible and reopens it. Answers `minimised: false` when the " +
171
- "placement has no collapsed state, which is the case for a full-page chat.",
176
+ "Collapse your own panel, so the user can see the whole page. The user reopens " +
177
+ "it from where it collapsed to (a launcher, a rail, a bottom bar or the page's " +
178
+ "own control). Answers `minimised: false` when the placement has no collapsed " +
179
+ "state, which is the case for a full-page chat.",
172
180
  parameters: {
173
181
  type: "object",
174
182
  properties: {},
@@ -180,7 +188,7 @@ export function createChatSurfaceTools(surface: ChatSurface): ClientTool[] {
180
188
  if (!report.collapsible) {
181
189
  return {
182
190
  minimised: false,
183
- reason: "this placement has no collapsed state, so there is no launcher to return to",
191
+ reason: "this placement has no collapsed state, so there is nothing to minimise to",
184
192
  };
185
193
  }
186
194
  surface.setCollapsed(true, { announce: true });
@@ -216,6 +216,8 @@ export class ConversationHistory {
216
216
  * Also what tells a pick refused for a load from one refused for a run.
217
217
  */
218
218
  #loading: (() => void) | null = null;
219
+ /** The thread switch under way and the promise that settles it, or `null`. */
220
+ #switching: { readonly threadId: string; readonly done: Promise<void> } | null = null;
219
221
 
220
222
  constructor(host: ConversationHistoryHost) {
221
223
  this.#host = host;
@@ -331,17 +333,56 @@ export class ConversationHistory {
331
333
  this.#host.checkpoints.open();
332
334
  }
333
335
 
334
- /** Switch the active conversation to an existing thread and replay it. */
335
- async switchThread(threadId: string): Promise<void> {
336
+ /**
337
+ * Switch the active conversation to an existing thread and replay it.
338
+ *
339
+ * A call for the thread a switch is already loading is handed that switch's
340
+ * own promise, so it settles when the thread is drawn rather than at once,
341
+ * before the replay.
342
+ *
343
+ * A store that rejects the load leaves the element on the thread it was on:
344
+ * the switch has already cleared the transcript, so it points back at the
345
+ * old thread and draws it again, saying nothing, and then rejects with the
346
+ * store's error. Left as it was, the element named a thread it was not
347
+ * showing and never reported it.
348
+ */
349
+ switchThread(threadId: string): Promise<void> {
336
350
  if (threadId === this.#threadId) {
337
- return;
351
+ return this.#switching?.threadId === threadId ? this.#switching.done : Promise.resolve();
338
352
  }
353
+ const previous = this.#threadId;
354
+ const done = this.#switchTo(threadId, previous).finally(() => {
355
+ if (this.#switching === entry) {
356
+ this.#switching = null;
357
+ }
358
+ });
359
+ const entry = { threadId, done };
360
+ this.#switching = entry;
361
+ return done;
362
+ }
363
+
364
+ async #switchTo(threadId: string, previous: string): Promise<void> {
339
365
  this.#host.cancelRun();
340
366
  this.#host.resetState();
341
367
  this.#host.conversationStore().setActiveThread(threadId);
342
368
  this.#threadId = threadId;
343
369
  this.#host.setRunning(false);
344
- await this.rehydrate("switch");
370
+ try {
371
+ await this.rehydrate("switch");
372
+ } catch (error) {
373
+ // Only while this switch is still the one on screen: a newer switch, a
374
+ // new chat or a delete has moved the thread on, and undoing that would
375
+ // put the old one back over it.
376
+ if (this.#threadId === threadId) {
377
+ this.#threadId = previous;
378
+ this.#host.conversationStore().setActiveThread(previous);
379
+ // Drawn again without a word: the thread on screen is the one the last
380
+ // event reported, so there is nothing to tell a host. A store that
381
+ // cannot answer for it either leaves the screen as it is.
382
+ await this.rehydrate(null).catch(() => undefined);
383
+ }
384
+ throw error;
385
+ }
345
386
  }
346
387
 
347
388
  /** Rename a thread, then reload the list that shows its title. */
@@ -638,9 +679,10 @@ export class ConversationHistory {
638
679
  * instead; see `#forgetStopped`.
639
680
  *
640
681
  * `reason` is what the thread event this restore ends with reports as why
641
- * the conversation changed. A restore that is stopped reports nothing.
682
+ * the conversation changed. A restore that is stopped reports nothing, and
683
+ * neither does one given `null`, which draws the thread already reported.
642
684
  */
643
- async rehydrate(reason: ThreadDetail["reason"]): Promise<void> {
685
+ async rehydrate(reason: ThreadDetail["reason"] | null): Promise<void> {
644
686
  // Claimed before awaiting, and checked by `#restoring` wherever the restore
645
687
  // has waited or run host code. With a slow remote store, picking thread B
646
688
  // then C interleaved both replays into one transcript, and New chat drew
@@ -745,7 +787,9 @@ export class ConversationHistory {
745
787
  // still current: one that was stopped returned above, so two quick
746
788
  // switches report the second alone. Before a resume, because the resume
747
789
  // is a run, and waiting for it reported the change a whole answer late.
748
- this.#host.threadChanged(threadId, reason);
790
+ if (reason !== null) {
791
+ this.#host.threadChanged(threadId, reason);
792
+ }
749
793
  if (checkpoint !== null) {
750
794
  // A listener is host code, and can stop the restore as a replayed
751
795
  // renderer can. Resuming then answered the call into the conversation
@@ -1,5 +1,6 @@
1
- // Collapsing and expanding: the animation toward the launcher, and the
2
- // in-flow and page placements that collapse differently or not at all.
1
+ // Collapsing and expanding: the animation toward the launcher, the in-flow
2
+ // and page placements that collapse differently or not at all, and the two
3
+ // collapse-to targets: the bottom bar, and the host's own (hidden here).
3
4
  export const COLLAPSE_STYLES: string = `
4
5
  /* ── Collapse ───────────────────────────────────────────────────────────────
5
6
  Collapsing shrinks the widget to the round floating launcher: the panel
@@ -101,12 +102,18 @@ export const COLLAPSE_STYLES: string = `
101
102
  :host([collapsed][placement="embedded"]) .messages-wrap,
102
103
  :host([collapsed][placement="embedded"]) .messages,
103
104
  :host([collapsed][placement="embedded"]) .input-row,
105
+ :host([collapsed][placement="embedded"]) slot[name="above-composer"],
104
106
  :host([collapsed][placement="embedded"]) .skill-chips,
105
107
  :host([collapsed][placement="embedded"]) .skill-palette,
106
108
  :host([collapsed][placement="embedded"]) .skill-hint {
107
109
  display: none;
108
110
  }
109
111
 
112
+ /* The above-composer slot is in the list above because it is the host's content
113
+ against the composer: a bare slot has no box of its own, so hiding the input
114
+ row alone left the slotted row showing under the header bar. Its sibling slot
115
+ below the composer is a footer and is left to the host. */
116
+
110
117
  /* The page placement has no collapsed state, so it offers no control for one.
111
118
  A dedicated route has no "away" to go to: shrinking it left a strip of
112
119
  application chrome fixed over a route that no longer had an owner, and this
@@ -134,4 +141,243 @@ export const COLLAPSE_STYLES: string = `
134
141
  transform: none;
135
142
  visibility: visible;
136
143
  }
144
+
145
+ /* ── Bottom bar (collapse-to="bar") ──────────────────────────────────────────
146
+ The round launcher drawn as a strip along the bottom edge: the title, the
147
+ unread badge and a running mark, with a history button beside it. The
148
+ wrapper draws no box anywhere else, so the launcher lays out against the
149
+ host as it always has; under the bar it is fixed to the bottom of the
150
+ viewport, outside the host's box, which sits where the panel opens.
151
+
152
+ Here rather than with the launcher because it restyles the launcher's
153
+ collapsed and pressed states, which this module follows in the cascade.
154
+ The strip stays drawn while the panel is open on a wide viewport, as the
155
+ way to minimise it; the phone block hides it then. */
156
+ .dock-bar {
157
+ display: contents;
158
+ }
159
+
160
+ .bar-history {
161
+ display: none;
162
+ }
163
+
164
+ :host([data-collapsed-to="bar"]) .dock-bar {
165
+ position: fixed;
166
+ inset: auto var(--_viewport-inset-right) var(--_viewport-inset-bottom) var(--_viewport-inset-left);
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: flex-end;
170
+ gap: 4px;
171
+ box-sizing: border-box;
172
+ min-height: 40px;
173
+ padding: 4px 8px;
174
+ border-top: 1px solid var(--_border);
175
+ background: var(--_bg);
176
+ color: var(--_fg);
177
+ pointer-events: auto;
178
+ }
179
+
180
+ /* A left-hand chat keeps its side: its controls and its panel at the left end. */
181
+ :host([data-collapsed-to="bar"][placement="bottom-left"]) .dock-bar {
182
+ justify-content: flex-start;
183
+ }
184
+
185
+ /* The launcher as the strip's title button. Its resting state hides it behind
186
+ an open panel; the bar keeps it on screen, so every part of that state is
187
+ restated, and the hover and press scales are cancelled at the weight of the
188
+ rules that set them. The title truncates rather than taking a fixed width,
189
+ leaving room beside it for whatever a later release adds to the row. */
190
+ :host([data-collapsed-to="bar"]) .launcher {
191
+ position: static;
192
+ inset: auto;
193
+ flex: 0 1 auto;
194
+ justify-content: flex-start;
195
+ gap: 8px;
196
+ width: auto;
197
+ min-width: 0;
198
+ max-width: 100%;
199
+ height: auto;
200
+ min-height: 32px;
201
+ padding: 4px 10px;
202
+ border-radius: 8px;
203
+ background: transparent;
204
+ color: var(--_fg);
205
+ box-shadow: none;
206
+ touch-action: manipulation;
207
+ opacity: 1;
208
+ transform: none;
209
+ visibility: visible;
210
+ }
211
+
212
+ :host([data-collapsed-to="bar"]) .launcher:hover {
213
+ background: var(--_wash);
214
+ }
215
+
216
+ /* Bare at rest, like the history button beside it. The themes frame the
217
+ round launcher with a border or a shadow at the weight of the rule above
218
+ and later in the cascade, so the child combinator on the bar is there for
219
+ weight, not scope: the launcher is always the bar's direct child. */
220
+ :host([data-collapsed-to="bar"]) .dock-bar > .launcher {
221
+ border: 0;
222
+ box-shadow: none;
223
+ }
224
+
225
+ :host([data-collapsed-to="bar"][collapsed]) .launcher:hover,
226
+ :host([data-collapsed-to="bar"][collapsed]) .launcher:active {
227
+ transform: none;
228
+ }
229
+
230
+ :host([data-collapsed-to="bar"]) .launcher .icon-holder {
231
+ flex: none;
232
+ width: 18px;
233
+ height: 18px;
234
+ }
235
+
236
+ :host([data-collapsed-to="bar"]) .rail-label {
237
+ display: block;
238
+ min-width: 0;
239
+ overflow: hidden;
240
+ font-size: 0.9em;
241
+ font-weight: 600;
242
+ white-space: nowrap;
243
+ text-overflow: ellipsis;
244
+ }
245
+
246
+ :host([data-collapsed-to="bar"]) .launcher-badge {
247
+ position: static;
248
+ flex: none;
249
+ box-shadow: none;
250
+ }
251
+
252
+ /* The running mark: the element already reflects a run as data-running, so
253
+ the bar shows one without a state of its own. */
254
+ :host([data-collapsed-to="bar"][data-running]) .launcher::after {
255
+ content: "";
256
+ flex: none;
257
+ width: 8px;
258
+ height: 8px;
259
+ border-radius: 50%;
260
+ background: var(--_accent);
261
+ animation: ag-ui-dock-running 1.2s ease-in-out infinite;
262
+ }
263
+
264
+ @keyframes ag-ui-dock-running {
265
+ 50% {
266
+ opacity: 0.35;
267
+ }
268
+ }
269
+
270
+ @media (prefers-reduced-motion: reduce) {
271
+ :host([data-collapsed-to="bar"][data-running]) .launcher::after {
272
+ animation: none;
273
+ }
274
+ }
275
+
276
+ :host([data-collapsed-to="bar"]) .bar-history {
277
+ display: inline-flex;
278
+ flex: none;
279
+ align-items: center;
280
+ justify-content: center;
281
+ width: 32px;
282
+ height: 32px;
283
+ padding: 0;
284
+ border: 0;
285
+ border-radius: 8px;
286
+ background: transparent;
287
+ color: var(--_muted);
288
+ font: inherit;
289
+ cursor: pointer;
290
+ }
291
+
292
+ :host([data-collapsed-to="bar"]) .bar-history:hover {
293
+ background: var(--_wash);
294
+ color: var(--_fg);
295
+ }
296
+
297
+ /* Both declare a display of their own, so each needs its own hidden rule. */
298
+ :host([data-collapsed-to]) .dock-bar[hidden],
299
+ :host([data-collapsed-to]) .bar-history[hidden] {
300
+ display: none;
301
+ }
302
+
303
+ /* History from the bar: the panel's own drawer, moved beside the bar while
304
+ the panel is away, drawn as a popover standing on the bar's end. There is
305
+ no backdrop; a press outside the bar closes it, as Escape does.
306
+
307
+ Its outer edge keeps the panel's gutter, so the popover and the panel line
308
+ up on the side they share. The bar is already inset by the host's reserved
309
+ edges, so the gutter alone is the whole offset from it, and the width cap
310
+ leaves the gutter free on both sides. A phone keeps a narrow margin
311
+ instead, in the block below: the panel there is the whole screen, so there
312
+ is no edge to line up with. */
313
+ :host([data-collapsed-to="bar"]) .dock-bar > .drawer {
314
+ inset: auto var(--_edge-gutter) calc(100% + 8px) auto;
315
+ width: min(320px, calc(var(--_viewport-width) - 2 * var(--_edge-gutter)));
316
+ height: min(440px, calc(var(--_viewport-height) - var(--_dock-bottom, 0px) - 16px));
317
+ border-radius: var(--_panel-radius);
318
+ box-shadow: var(--_shadow);
319
+ overflow: hidden;
320
+ }
321
+
322
+ :host([data-collapsed-to="bar"][placement="bottom-left"]) .dock-bar > .drawer {
323
+ inset: auto auto calc(100% + 8px) var(--_edge-gutter);
324
+ }
325
+
326
+ @media (max-width: 600px) {
327
+ :host([data-collapsed-to="bar"]:not([data-small-viewport="off"])) .dock-bar > .drawer {
328
+ inset: auto 8px calc(100% + 8px) auto;
329
+ width: min(320px, calc(100vw - 16px));
330
+ }
331
+
332
+ :host([data-collapsed-to="bar"][placement="bottom-left"]:not([data-small-viewport="off"])) .dock-bar > .drawer {
333
+ inset: auto auto calc(100% + 8px) 8px;
334
+ }
335
+ }
336
+
337
+ :host([data-collapsed-to="bar"]) .dock-bar > .drawer .drawer-backdrop {
338
+ display: none;
339
+ }
340
+
341
+ /* The popover closes in one step. The in-panel drawer slides its panel out
342
+ and holds the overlay's visibility until the slide ends; here the overlay
343
+ is the popover's own box, rounded and clipped, so the slide moved the list
344
+ inside a box that stayed put (read as a shrink) and the box went only once
345
+ the slide had finished. This outweighs the drawer's own hidden rule, so the
346
+ box hides at once, and whatever the list inside does is out of sight. The
347
+ way in needs no rule: the drawer is moved into the bar as it opens, and a
348
+ moved element starts no transition. Nothing is left for reduced motion. */
349
+ :host([data-collapsed-to="bar"]) .dock-bar > .drawer {
350
+ transition: none;
351
+ }
352
+
353
+ /* Border-box, so the list's own border sits inside the popover's clipped box
354
+ rather than 2px past its bottom edge. */
355
+ :host([data-collapsed-to="bar"]) .dock-bar > .drawer .drawer-panel {
356
+ box-sizing: border-box;
357
+ width: 100%;
358
+ border: 1px solid var(--_border);
359
+ border-radius: inherit;
360
+ box-shadow: none;
361
+ }
362
+
363
+ /* ── The host's own bar (collapse-to="none") ─────────────────────────────────
364
+ Collapsed is hidden: no launcher, no rail, no header bar. The host's control
365
+ calls the public API, and the panel rises from its anchor (the dock module)
366
+ or from the placement's corner. Each placement's own collapsed rule is
367
+ overridden at its weight or above, later in the cascade. */
368
+ :host([data-collapsed-to="none"][collapsed]) {
369
+ pointer-events: none;
370
+ }
371
+
372
+ :host([data-collapsed-to="none"][collapsed]) .launcher {
373
+ display: none;
374
+ }
375
+
376
+ :host([data-collapsed-to="none"][collapsed][placement="sidebar"]) {
377
+ width: 0;
378
+ }
379
+
380
+ :host([data-collapsed-to="none"][collapsed][placement="embedded"]) {
381
+ display: none;
382
+ }
137
383
  `;