@adea-ai/ui 0.63.3 → 0.64.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 (44) hide show
  1. package/README.md +46 -0
  2. package/dist/NOTICE +54 -0
  3. package/dist/components/conversation/busy-send-button.d.ts +28 -0
  4. package/dist/components/conversation/busy-send-button.d.ts.map +1 -0
  5. package/dist/components/conversation/busy-send-button.js +184 -0
  6. package/dist/components/conversation/busy-send-button.js.map +1 -0
  7. package/dist/components/conversation/conversation-surface.d.ts +5 -1
  8. package/dist/components/conversation/conversation-surface.d.ts.map +1 -1
  9. package/dist/components/conversation/conversation-surface.js +42 -62
  10. package/dist/components/conversation/conversation-surface.js.map +1 -1
  11. package/dist/components/conversation/index.d.ts +1 -0
  12. package/dist/components/conversation/index.d.ts.map +1 -1
  13. package/dist/components/conversation/index.js +2 -1
  14. package/dist/components/conversation/scroll-follow-core.d.ts +181 -0
  15. package/dist/components/conversation/scroll-follow-core.d.ts.map +1 -0
  16. package/dist/components/conversation/scroll-follow-core.js +139 -0
  17. package/dist/components/conversation/scroll-follow-core.js.map +1 -0
  18. package/dist/components/conversation/scroll-follow.d.ts +13 -0
  19. package/dist/components/conversation/scroll-follow.d.ts.map +1 -0
  20. package/dist/components/conversation/scroll-follow.js +102 -0
  21. package/dist/components/conversation/scroll-follow.js.map +1 -0
  22. package/dist/index.js +2 -1
  23. package/dist/lib/tokens.d.ts +3 -0
  24. package/dist/lib/tokens.d.ts.map +1 -1
  25. package/dist/lib/tokens.js +3 -0
  26. package/dist/lib/tokens.js.map +1 -1
  27. package/dist/r/conversation.json +17 -0
  28. package/dist/r/registry.json +17 -0
  29. package/dist/r/src/components/conversation/busy-send-button.tsx +161 -0
  30. package/dist/r/src/components/conversation/conversation-surface.tsx +45 -48
  31. package/dist/r/src/components/conversation/index.ts +1 -0
  32. package/dist/r/src/components/conversation/scroll-follow-core.ts +314 -0
  33. package/dist/r/src/components/conversation/scroll-follow.ts +133 -0
  34. package/dist/r/src/lib/tokens.ts +3 -0
  35. package/dist/r/src/styles/theme.css +9 -0
  36. package/package.json +1 -1
  37. package/registry.json +17 -0
  38. package/src/components/conversation/busy-send-button.tsx +161 -0
  39. package/src/components/conversation/conversation-surface.tsx +45 -48
  40. package/src/components/conversation/index.ts +1 -0
  41. package/src/components/conversation/scroll-follow-core.ts +314 -0
  42. package/src/components/conversation/scroll-follow.ts +133 -0
  43. package/src/lib/tokens.ts +3 -0
  44. package/src/styles/theme.css +9 -0
package/README.md CHANGED
@@ -153,6 +153,52 @@ The toolbar switches theme, accent, typeface and density live, which is the
153
153
  fastest way to see how a component behaves across the catalogue rather than in
154
154
  one theme.
155
155
 
156
+ ## Busy composer action
157
+
158
+ Square icon controls use the control-height tokens through the Tailwind `--size`
159
+ mapping; `--spacing-control-*` remains the separate inline-padding ladder.
160
+ Text controls, icon buttons and toggles share the same height in both densities.
161
+ Dimensions remain rem-based and follow the consumer's root font size. The browser
162
+ geometry lane checks all six rungs, heights and padding under both themes/densities.
163
+
164
+ `BusySendButton` translates KiroCrew's split fire/mode-picker composition. Pass
165
+ controlled `mode`, `onModeChange` and `onFire`. `disabled` prevents firing while
166
+ leaving mode choice available before typing; `selectionDisabled` gates choosing.
167
+ Inject `unavailable` reasons for unsupported or unauthorized modes: rows remain
168
+ visible, and the selected action cannot fire. Supply `alternateActionHint` only
169
+ when the host implements the described keyboard chord. The component adds no
170
+ keyboard chord, preference storage, runtime command or queue. Persistence and
171
+ same-session preference synchronization remain application-owned.
172
+
173
+ Kobalte owns the menu and focus lifecycle. Its Tab prevention lacks row cycling,
174
+ so a menu-scoped donor translation cycles enabled rows without a document listener.
175
+ Stories cover before-typing, working,
176
+ unavailable and read-only states. `bun run test:components` checks the isolated
177
+ Solid composition headlessly in Chromium and WebKit. Full Chat composition,
178
+ packed distribution and real consumer/native acceptance remain separate gates.
179
+
180
+ ## Transcript follow
181
+
182
+ `ConversationSurface` follows streamed text, earlier-row growth, turn collapse
183
+ and pane resizing using the KiroCrew plain-scroller decision contract. A
184
+ deliberate upward scroll releases follow, including small moves near the bottom.
185
+ Returning to the bottom or choosing **Jump to latest** re-arms it; the action
186
+ does not estimate unread messages from pixel distance. Instant pins keep
187
+ self-scroll attribution synchronized. Keyboard jumping returns focus to the
188
+ transcript when its jump control disappears; automatic pins never move focus.
189
+
190
+ `threshold` controls jump-button visibility (80px by default), independently of
191
+ follow intent. `follow={false}` is fully inert: positioning belongs to the host,
192
+ with no observer or mount pin. Re-enabling it explicitly re-arms at the bottom;
193
+ it is not an automatic restore-and-resume flag. An optional `resetKey` also
194
+ re-arms follow when that identity changes. Canonical
195
+ session identity, durable restoration and virtualized transcript windows remain
196
+ application-owned. `ref` and `onScroll` still reach the inner scroller.
197
+
198
+ `bun run test:components` checks the built component in Chromium and WebKit.
199
+ The `SurfaceFollowing` story exercises streaming, collapse and pane resizing;
200
+ source/license/test provenance is recorded in `NOTICE`.
201
+
156
202
  ## Composer keyboard behavior
157
203
 
158
204
  `MessageComposer` preserves native IME candidate commits and suppresses sending
package/dist/NOTICE CHANGED
@@ -49,6 +49,60 @@ Origin UI / coss (AGPL-3.0-or-later) was consulted for visual ideas only. No
49
49
  code, class string, or asset from that project is reproduced here, because its
50
50
  licence is incompatible with this distribution.
51
51
 
52
+ Busy composer action translated from KiroCrew
53
+ --------------------------------------------
54
+ Original: website/src/components/BusySendButton.tsx
55
+ Also translated: website/src/hooks/useMenuKeyboard.ts (enabled-row Tab cycle)
56
+ Revision: 283e136c0f902e965a535a7c9548c57c7504fed0
57
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
58
+ This product includes software developed at Amazon.com, Inc.
59
+ Apache License, Version 2.0 (see LICENSE).
60
+ Destination: packages/ui/src/components/conversation/busy-send-button.tsx
61
+
62
+ The split fire/mode-picker structure, descriptions, independent disabled-fire
63
+ state, selected radio treatment and conditional alternate-action hint are
64
+ substantially translated to Solid and the shared design system. Kobalte owns
65
+ portal positioning, arrow navigation, dismissal and focus restoration. Its
66
+ Tab prevention lacks row cycling, so the donor cycle is translated as a scoped
67
+ menu handler over enabled rows; no document listener or manual focus trap.
68
+ Donor per-slot storage/migration/broadcast, i18n/platform lookup, React runtime,
69
+ manual portal and document listeners are not copied. Applications inject mode,
70
+ action and unavailable reasons. No runtime queue or force-reset authority added.
71
+ Nearest tests: BusySendButton.menuKeyboard.test.tsx and
72
+ BusySendButton.slotScope.test.tsx; controlled mode replaces donor storage tests,
73
+ while keyboard/focus expectations are exercised in Chromium and WebKit.
74
+
75
+ ## Plain transcript follow translated from KiroCrew
76
+
77
+ `packages/ui/src/components/conversation/scroll-follow-core.ts`,
78
+ `scroll-follow.ts` and ConversationSurface substantially translate the plain
79
+ scroller lifecycle in `website/src/app-sdk/useChatScrollFollow.ts` and its pure
80
+ `website/src/hooks/virtualizer/FollowController.ts` decisions, pinned at
81
+ `283e136c0f902e965a535a7c9548c57c7504fed0`.
82
+
83
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
84
+ This product includes software developed at Amazon.com, Inc.
85
+ (https://www.amazon.com/). Licensed under the Apache License, Version 2.0.
86
+
87
+ Retained: separate follow intent and jump visibility, synchronous scroll-up race
88
+ fencing, direction-aware re-engagement, self-scroll attribution, DPR tolerance,
89
+ content growth/collapse and viewport shrink/growth distinctions, instant pins,
90
+ explicit disabled/reset behavior and content/viewport observer cleanup. Solid
91
+ ownership replaces React refs/effects. Applications retain identity, durable
92
+ scroll restoration and virtualized row measurement/anchor authority.
93
+
94
+ The jump label follows `website/src/app-sdk/ChatScrollChrome.tsx`'s action-only
95
+ contract; pixel distance cannot establish an unread message count. The existing
96
+ shared button appearance and configurable 80px visibility default are retained.
97
+ Keyboard jumping returns focus to the focusable transcript as its jump control
98
+ disappears; this hardens a donor edge case without moving focus during automatic
99
+ pins. No donor edge-fade styling, React runtime, virtualizer, asset, font or session
100
+ store is copied. Tests translate applicable cases from
101
+ `website/src/test/useChatScrollFollow.test.tsx` and `FollowController.test.ts`;
102
+ Bun/browser integration and deterministic bounded grids replace React/jsdom,
103
+ Vitest and fast-check without adding dependencies. The full Chat product
104
+ composition and application adoption remain separate gates.
105
+
52
106
  ## Composer IME input ownership translated from KiroCrew
53
107
 
54
108
  `packages/ui/src/components/conversation/ime-guard.ts` and its MessageComposer
@@ -0,0 +1,28 @@
1
+ export type BusySendMode = 'steer' | 'queue';
2
+ export type BusySendButtonProps = {
3
+ mode: BusySendMode;
4
+ onModeChange: (mode: BusySendMode) => void;
5
+ /** Executes only the selected action. The host owns the draft and command. */
6
+ onFire: () => void;
7
+ /** An empty draft or pending delivery disables fire, but not choosing a mode. */
8
+ disabled?: boolean;
9
+ /** Disable mode selection when the host has no authority to change preferences. */
10
+ selectionDisabled?: boolean;
11
+ /** Host capability/authority failures: unavailable rows remain visible. */
12
+ unavailable?: Partial<Record<BusySendMode, string>>;
13
+ /** Supply only when the host implements this alternate-action keyboard chord. */
14
+ alternateActionHint?: string;
15
+ };
16
+ /**
17
+ * Kiro's split action/menu composition, with app-owned mode and availability.
18
+ * Picking a mode never fires it. Persistence, migration and same-session
19
+ * broadcasts belong to the consumer; this component has no storage or global listeners.
20
+ * Kobalte replaces the donor manual portal/positioning and document listeners:
21
+ * arrows wrap and selection/Escape return focus. Kobalte 0.13.14 prevents Tab
22
+ * without moving focus, so a menu-scoped translation of the donor Tab cycle
23
+ * fills that gap. It discovers enabled radio rows only; no document trap.
24
+ * No force-reset timer or runtime authority is translated from the adjacent
25
+ * donor stop flow.
26
+ */
27
+ export declare function BusySendButton(props: BusySendButtonProps): import("solid-js").JSX.Element;
28
+ //# sourceMappingURL=busy-send-button.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"busy-send-button.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/busy-send-button.tsx"],"names":[],"mappings":"AAmBA,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,CAAA;AAC5C,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,YAAY,CAAA;IAClB,YAAY,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI,CAAA;IAC1C,8EAA8E;IAC9E,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,iFAAiF;IACjF,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,2EAA2E;IAC3E,WAAW,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC,CAAA;IACnD,iFAAiF;IACjF,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AASD;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,kCA2GxD"}
@@ -0,0 +1,184 @@
1
+ import { Button } from "../ui/button/button.js";
2
+ import { ButtonGroup } from "../ui/button-group/button-group.js";
3
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu/dropdown-menu.js";
4
+ import { createComponent, insert, setAttribute, template } from "solid-js/web";
5
+ import { ArrowUpFromLine, ChevronDown, Target } from "lucide-solid";
6
+ import { For, Show, createUniqueId } from "solid-js";
7
+ //#region src/components/conversation/busy-send-button.tsx
8
+ var _tmpl$ = /*#__PURE__*/ template(`<p class="text-muted-foreground px-2 py-1 text-xs">`);
9
+ var _tmpl$2 = /*#__PURE__*/ template(`<div data-slot=busy-send-button class="flex w-fit flex-col gap-1">`);
10
+ var _tmpl$3 = /*#__PURE__*/ template(`<div class="flex min-w-0 flex-col gap-1"><span></span><span class="text-muted-foreground text-xs">`);
11
+ var _tmpl$4 = /*#__PURE__*/ template(`<span class="text-muted-foreground text-xs">`);
12
+ var _tmpl$5 = /*#__PURE__*/ template(`<p class="text-muted-foreground text-xs">`);
13
+ var modes = ["steer", "queue"];
14
+ var labels = {
15
+ steer: "Steer",
16
+ queue: "Queue"
17
+ };
18
+ var descriptions = {
19
+ steer: "Act on this right away",
20
+ queue: "Run after the current work finishes"
21
+ };
22
+ /**
23
+ * Kiro's split action/menu composition, with app-owned mode and availability.
24
+ * Picking a mode never fires it. Persistence, migration and same-session
25
+ * broadcasts belong to the consumer; this component has no storage or global listeners.
26
+ * Kobalte replaces the donor manual portal/positioning and document listeners:
27
+ * arrows wrap and selection/Escape return focus. Kobalte 0.13.14 prevents Tab
28
+ * without moving focus, so a menu-scoped translation of the donor Tab cycle
29
+ * fills that gap. It discovers enabled radio rows only; no document trap.
30
+ * No force-reset timer or runtime authority is translated from the adjacent
31
+ * donor stop flow.
32
+ */
33
+ function BusySendButton(props) {
34
+ const reasonId = createUniqueId();
35
+ const reason = () => props.unavailable?.[props.mode];
36
+ const variant = () => props.mode === "steer" ? "default" : "secondary";
37
+ return (() => {
38
+ var _el$ = _tmpl$2();
39
+ insert(_el$, createComponent(DropdownMenu, {
40
+ placement: "top-end",
41
+ modal: false,
42
+ get children() {
43
+ return [createComponent(ButtonGroup, {
44
+ label: "Send while busy",
45
+ get children() {
46
+ return [createComponent(Button, {
47
+ type: "button",
48
+ size: "icon-sm",
49
+ get variant() {
50
+ return variant();
51
+ },
52
+ get ["aria-label"]() {
53
+ return props.mode === "steer" ? "Steer" : "Queue message";
54
+ },
55
+ get ["aria-describedby"]() {
56
+ return reason() ? reasonId : void 0;
57
+ },
58
+ get disabled() {
59
+ return props.disabled || !!reason();
60
+ },
61
+ onClick: () => {
62
+ if (!props.disabled && !reason()) props.onFire();
63
+ },
64
+ get children() {
65
+ return createComponent(Show, {
66
+ get when() {
67
+ return props.mode === "steer";
68
+ },
69
+ get fallback() {
70
+ return createComponent(ArrowUpFromLine, {});
71
+ },
72
+ get children() {
73
+ return createComponent(Target, {});
74
+ }
75
+ });
76
+ }
77
+ }), createComponent(DropdownMenuTrigger, {
78
+ as: Button,
79
+ type: "button",
80
+ size: "icon-sm",
81
+ get variant() {
82
+ return variant();
83
+ },
84
+ "aria-label": "Send options",
85
+ get disabled() {
86
+ return props.selectionDisabled;
87
+ },
88
+ get children() {
89
+ return createComponent(ChevronDown, {});
90
+ }
91
+ })];
92
+ }
93
+ }), createComponent(DropdownMenuContent, {
94
+ hideArrow: true,
95
+ get children() {
96
+ return [createComponent(DropdownMenuRadioGroup, {
97
+ "on:keydown": (event) => {
98
+ if (event.key !== "Tab" || event.isComposing || !(event.currentTarget instanceof HTMLElement)) return;
99
+ const rows = Array.from(event.currentTarget.querySelectorAll("[role=\"menuitemradio\"]:not([aria-disabled=\"true\"])"));
100
+ if (!rows.length) return;
101
+ event.preventDefault();
102
+ event.stopPropagation();
103
+ const active = document.activeElement;
104
+ const index = active instanceof HTMLElement ? rows.indexOf(active) : -1;
105
+ rows[index < 0 ? event.shiftKey ? rows.length - 1 : 0 : (index + (event.shiftKey ? -1 : 1) + rows.length) % rows.length]?.focus();
106
+ },
107
+ get value() {
108
+ return props.mode;
109
+ },
110
+ onChange: (value) => {
111
+ if ((value === "steer" || value === "queue") && !props.selectionDisabled && !props.unavailable?.[value]) props.onModeChange(value);
112
+ },
113
+ get children() {
114
+ return createComponent(For, {
115
+ each: modes,
116
+ children: (mode) => createComponent(DropdownMenuRadioItem, {
117
+ value: mode,
118
+ closeOnSelect: true,
119
+ get disabled() {
120
+ return props.selectionDisabled || !!props.unavailable?.[mode];
121
+ },
122
+ get children() {
123
+ return [createComponent(Show, {
124
+ when: mode === "steer",
125
+ get fallback() {
126
+ return createComponent(ArrowUpFromLine, {});
127
+ },
128
+ get children() {
129
+ return createComponent(Target, {});
130
+ }
131
+ }), (() => {
132
+ var _el$3 = _tmpl$3(), _el$4 = _el$3.firstChild, _el$5 = _el$4.nextSibling;
133
+ insert(_el$4, () => labels[mode]);
134
+ insert(_el$5, () => descriptions[mode]);
135
+ insert(_el$3, createComponent(Show, {
136
+ get when() {
137
+ return props.unavailable?.[mode];
138
+ },
139
+ children: (message) => (() => {
140
+ var _el$6 = _tmpl$4();
141
+ insert(_el$6, message);
142
+ return _el$6;
143
+ })()
144
+ }), null);
145
+ return _el$3;
146
+ })()];
147
+ }
148
+ })
149
+ });
150
+ }
151
+ }), createComponent(Show, {
152
+ get when() {
153
+ return props.alternateActionHint;
154
+ },
155
+ get children() {
156
+ return [createComponent(DropdownMenuSeparator, {}), (() => {
157
+ var _el$2 = _tmpl$();
158
+ insert(_el$2, () => props.alternateActionHint);
159
+ return _el$2;
160
+ })()];
161
+ }
162
+ })];
163
+ }
164
+ })];
165
+ }
166
+ }), null);
167
+ insert(_el$, createComponent(Show, {
168
+ get when() {
169
+ return reason();
170
+ },
171
+ children: (message) => (() => {
172
+ var _el$7 = _tmpl$5();
173
+ setAttribute(_el$7, "id", reasonId);
174
+ insert(_el$7, message);
175
+ return _el$7;
176
+ })()
177
+ }), null);
178
+ return _el$;
179
+ })();
180
+ }
181
+ //#endregion
182
+ export { BusySendButton };
183
+
184
+ //# sourceMappingURL=busy-send-button.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"busy-send-button.js","names":["ArrowUpFromLine","ChevronDown","Target","For","Show","createUniqueId","Button","ButtonGroup","DropdownMenu","DropdownMenuContent","DropdownMenuRadioGroup","DropdownMenuRadioItem","DropdownMenuSeparator","DropdownMenuTrigger","BusySendMode","BusySendButtonProps","mode","onModeChange","onFire","disabled","selectionDisabled","unavailable","Partial","Record","alternateActionHint","modes","labels","steer","queue","descriptions","BusySendButton","props","reasonId","reason","variant","const","_el$","_tmpl$2","_$insert","_$createComponent","placement","modal","children","label","type","size","aria-label","aria-describedby","undefined","onClick","when","fallback","as","hideArrow","on:keydown","event","KeyboardEvent","key","isComposing","currentTarget","HTMLElement","rows","Array","from","querySelectorAll","length","preventDefault","stopPropagation","active","document","activeElement","index","indexOf","next","shiftKey","focus","value","onChange","each","closeOnSelect","_el$3","_tmpl$3","_el$4","firstChild","_el$5","nextSibling","message","_el$6","_tmpl$4","_el$2","_tmpl$","_el$7","_tmpl$5","_$setAttribute","_$createComponent"],"sources":["../../../src/components/conversation/busy-send-button.tsx"],"sourcesContent":["/*\n * Substantially translated from KiroCrew website/src/components/BusySendButton.tsx\n * at 283e136c0f902e965a535a7c9548c57c7504fed0.\n * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\n * Licensed under Apache-2.0; see LICENSE and NOTICE.\n */\nimport { ArrowUpFromLine, ChevronDown, Target } from 'lucide-solid'\nimport { For, Show, createUniqueId } from 'solid-js'\nimport { Button } from '../ui/button/button'\nimport { ButtonGroup } from '../ui/button-group/button-group'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from '../ui/dropdown-menu/dropdown-menu'\n\nexport type BusySendMode = 'steer' | 'queue'\nexport type BusySendButtonProps = {\n mode: BusySendMode\n onModeChange: (mode: BusySendMode) => void\n /** Executes only the selected action. The host owns the draft and command. */\n onFire: () => void\n /** An empty draft or pending delivery disables fire, but not choosing a mode. */\n disabled?: boolean\n /** Disable mode selection when the host has no authority to change preferences. */\n selectionDisabled?: boolean\n /** Host capability/authority failures: unavailable rows remain visible. */\n unavailable?: Partial<Record<BusySendMode, string>>\n /** Supply only when the host implements this alternate-action keyboard chord. */\n alternateActionHint?: string\n}\n\nconst modes: BusySendMode[] = ['steer', 'queue']\nconst labels = { steer: 'Steer', queue: 'Queue' }\nconst descriptions = {\n steer: 'Act on this right away',\n queue: 'Run after the current work finishes',\n}\n\n/**\n * Kiro's split action/menu composition, with app-owned mode and availability.\n * Picking a mode never fires it. Persistence, migration and same-session\n * broadcasts belong to the consumer; this component has no storage or global listeners.\n * Kobalte replaces the donor manual portal/positioning and document listeners:\n * arrows wrap and selection/Escape return focus. Kobalte 0.13.14 prevents Tab\n * without moving focus, so a menu-scoped translation of the donor Tab cycle\n * fills that gap. It discovers enabled radio rows only; no document trap.\n * No force-reset timer or runtime authority is translated from the adjacent\n * donor stop flow.\n */\nexport function BusySendButton(props: BusySendButtonProps) {\n const reasonId = createUniqueId()\n const reason = () => props.unavailable?.[props.mode]\n const variant = () => (props.mode === 'steer' ? ('default' as const) : ('secondary' as const))\n return (\n <div data-slot=\"busy-send-button\" class=\"flex w-fit flex-col gap-1\">\n <DropdownMenu placement=\"top-end\" modal={false}>\n <ButtonGroup label=\"Send while busy\">\n <Button\n type=\"button\"\n size=\"icon-sm\"\n variant={variant()}\n aria-label={props.mode === 'steer' ? 'Steer' : 'Queue message'}\n aria-describedby={reason() ? reasonId : undefined}\n disabled={props.disabled || !!reason()}\n onClick={() => {\n if (!props.disabled && !reason()) props.onFire()\n }}\n >\n <Show when={props.mode === 'steer'} fallback={<ArrowUpFromLine />}>\n <Target />\n </Show>\n </Button>\n <DropdownMenuTrigger\n as={Button}\n type=\"button\"\n size=\"icon-sm\"\n variant={variant()}\n aria-label=\"Send options\"\n disabled={props.selectionDisabled}\n >\n <ChevronDown />\n </DropdownMenuTrigger>\n </ButtonGroup>\n <DropdownMenuContent hideArrow>\n <DropdownMenuRadioGroup\n on:keydown={(event: KeyboardEvent) => {\n if (\n event.key !== 'Tab' ||\n event.isComposing ||\n !(event.currentTarget instanceof HTMLElement)\n )\n return\n const rows = Array.from(\n event.currentTarget.querySelectorAll<HTMLElement>(\n '[role=\"menuitemradio\"]:not([aria-disabled=\"true\"])'\n )\n )\n if (!rows.length) return\n event.preventDefault()\n event.stopPropagation()\n const active = document.activeElement\n const index = active instanceof HTMLElement ? rows.indexOf(active) : -1\n const next =\n index < 0\n ? event.shiftKey\n ? rows.length - 1\n : 0\n : (index + (event.shiftKey ? -1 : 1) + rows.length) % rows.length\n rows[next]?.focus()\n }}\n value={props.mode}\n onChange={(value) => {\n if (\n (value === 'steer' || value === 'queue') &&\n !props.selectionDisabled &&\n !props.unavailable?.[value]\n )\n props.onModeChange(value)\n }}\n >\n <For each={modes}>\n {(mode) => (\n <DropdownMenuRadioItem\n value={mode}\n closeOnSelect\n disabled={props.selectionDisabled || !!props.unavailable?.[mode]}\n >\n <Show when={mode === 'steer'} fallback={<ArrowUpFromLine />}>\n <Target />\n </Show>\n <div class=\"flex min-w-0 flex-col gap-1\">\n <span>{labels[mode]}</span>\n <span class=\"text-muted-foreground text-xs\">{descriptions[mode]}</span>\n <Show when={props.unavailable?.[mode]}>\n {(message) => <span class=\"text-muted-foreground text-xs\">{message()}</span>}\n </Show>\n </div>\n </DropdownMenuRadioItem>\n )}\n </For>\n </DropdownMenuRadioGroup>\n <Show when={props.alternateActionHint}>\n <DropdownMenuSeparator />\n <p class=\"text-muted-foreground px-2 py-1 text-xs\">{props.alternateActionHint}</p>\n </Show>\n </DropdownMenuContent>\n </DropdownMenu>\n <Show when={reason()}>\n {(message) => (\n <p id={reasonId} class=\"text-muted-foreground text-xs\">\n {message()}\n </p>\n )}\n </Show>\n </div>\n )\n}\n"],"mappings":"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"}
@@ -10,7 +10,7 @@ import type { ComponentProps, JSX } from 'solid-js';
10
10
  * to the end by an arriving message, and that is the single most common way a
11
11
  * chat surface is made unusable.
12
12
  * - **Offer a way back.** Once the reader is not at the bottom, a control appears
13
- * that returns them and says how much they have missed.
13
+ * that returns them. Pixel distance cannot prove an unread message count.
14
14
  * - **`overscroll-contain`.** The transcript scrolls to its end and stops, rather
15
15
  * than dragging the pane behind it — which in a desktop shell means the whole
16
16
  * window moves.
@@ -18,6 +18,10 @@ import type { ComponentProps, JSX } from 'solid-js';
18
18
  export type ConversationSurfaceProps = ComponentProps<'div'> & {
19
19
  /** How far from the bottom still counts as "at the bottom", in px. */
20
20
  threshold?: number;
21
+ /** Disable automatic positioning while the host owns scroll restoration. */
22
+ follow?: boolean;
23
+ /** Optional conversation identity: changing it re-arms follow at the bottom. */
24
+ resetKey?: string;
21
25
  /** Rendered above the transcript when there is nothing in it. */
22
26
  empty?: JSX.Element;
23
27
  /** A header that scrolls with the transcript, e.g. a day divider or a banner. */
@@ -1 +1 @@
1
- {"version":3,"file":"conversation-surface.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/conversation-surface.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;AAKnD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,wBAAwB,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG;IAC7D,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,iEAAiE;IACjE,KAAK,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;IACnB,iFAAiF;IACjF,MAAM,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;CACrB,CAAA;AAOD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,eAsElE"}
1
+ {"version":3,"file":"conversation-surface.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/conversation-surface.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;AAMnD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,wBAAwB,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG;IAC7D,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,iEAAiE;IACjE,KAAK,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;IACnB,iFAAiF;IACjF,MAAM,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;CACrB,CAAA;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,eAmElE"}
@@ -1,63 +1,50 @@
1
1
  import { cn } from "../../lib/utils.js";
2
2
  import { Button } from "../ui/button/button.js";
3
- import { className, createComponent, effect, insert, memo, spread, template, use } from "solid-js/web";
3
+ import { createScrollFollow } from "./scroll-follow.js";
4
+ import { className, createComponent, effect, insert, mergeProps, spread, template, use } from "solid-js/web";
4
5
  import { ArrowDown } from "lucide-solid";
5
- import { Show, createSignal, onCleanup, splitProps } from "solid-js";
6
+ import { Show, splitProps } from "solid-js";
6
7
  //#region src/components/conversation/conversation-surface.tsx
7
8
  var _tmpl$ = /*#__PURE__*/ template(`<div class="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center">`);
8
- var _tmpl$2 = /*#__PURE__*/ template(`<div data-slot=conversation-surface><div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">`);
9
+ var _tmpl$2 = /*#__PURE__*/ template(`<div data-slot=conversation-surface><div class="min-h-0 flex-1 overflow-y-auto overscroll-contain"><div data-slot=conversation-content>`);
9
10
  var _tmpl$3 = /*#__PURE__*/ template(`<div class=p-4>`);
10
- /** How far the reader is from the end of the transcript, in px. */
11
- function distanceFromBottom(element) {
12
- return element.scrollHeight - element.scrollTop - element.clientHeight;
13
- }
14
11
  function ConversationSurface(props) {
15
12
  const [local, rest] = splitProps(props, [
16
13
  "class",
17
14
  "threshold",
15
+ "follow",
16
+ "resetKey",
18
17
  "empty",
19
18
  "header",
20
- "children"
19
+ "children",
20
+ "onScroll",
21
+ "ref",
22
+ "tabindex"
21
23
  ]);
22
- const [atBottom, setAtBottom] = createSignal(true);
23
- const [missed, setMissed] = createSignal(0);
24
- let scroller;
25
- const onScroll = () => {
26
- if (!scroller) return;
27
- const distance = distanceFromBottom(scroller);
28
- const threshold = local.threshold ?? 80;
29
- setAtBottom(distance <= threshold);
30
- setMissed(distance <= threshold ? 0 : Math.round(distance / 96));
31
- };
32
- const jumpToLatest = () => {
33
- scroller?.scrollTo({
34
- top: scroller.scrollHeight,
35
- behavior: "smooth"
36
- });
37
- setAtBottom(true);
38
- setMissed(0);
39
- };
40
- const observer = () => {
41
- if (!scroller) return;
42
- const mutation = new MutationObserver(() => {
43
- if (atBottom()) scroller?.scrollTo({ top: scroller.scrollHeight });
44
- else onScroll();
45
- });
46
- mutation.observe(scroller, {
47
- childList: true,
48
- subtree: true
49
- });
50
- onCleanup(() => mutation.disconnect());
24
+ const follow = createScrollFollow({
25
+ enabled: () => local.follow !== false,
26
+ resetKey: () => local.resetKey,
27
+ threshold: () => local.threshold ?? 80
28
+ });
29
+ const onScroll = (event) => {
30
+ follow.onScroll();
31
+ const handler = local.onScroll;
32
+ if (typeof handler === "function") handler(event);
33
+ else if (handler) handler[0](handler[1], event);
51
34
  };
52
35
  return (() => {
53
- var _el$ = _tmpl$2(), _el$2 = _el$.firstChild;
36
+ var _el$ = _tmpl$2(), _el$2 = _el$.firstChild, _el$3 = _el$2.firstChild;
54
37
  _el$2.addEventListener("scroll", onScroll);
55
38
  use((element) => {
56
- scroller = element;
57
- observer();
39
+ follow.bindScroller(element);
40
+ if (typeof local.ref === "function") local.ref(element);
58
41
  }, _el$2);
59
- spread(_el$2, rest, false, true);
60
- insert(_el$2, createComponent(Show, {
42
+ spread(_el$2, mergeProps({ get tabindex() {
43
+ return local.tabindex ?? 0;
44
+ } }, rest), false, true);
45
+ var _ref$ = follow.bindContent;
46
+ typeof _ref$ === "function" ? use(_ref$, _el$3) : follow.bindContent = _el$3;
47
+ insert(_el$3, createComponent(Show, {
61
48
  get when() {
62
49
  return local.header;
63
50
  },
@@ -65,15 +52,15 @@ function ConversationSurface(props) {
65
52
  return local.header;
66
53
  }
67
54
  }), null);
68
- insert(_el$2, createComponent(Show, {
55
+ insert(_el$3, createComponent(Show, {
69
56
  get when() {
70
57
  return local.children;
71
58
  },
72
59
  get fallback() {
73
60
  return (() => {
74
- var _el$4 = _tmpl$3();
75
- insert(_el$4, () => local.empty);
76
- return _el$4;
61
+ var _el$5 = _tmpl$3();
62
+ insert(_el$5, () => local.empty);
63
+ return _el$5;
77
64
  })();
78
65
  },
79
66
  get children() {
@@ -82,30 +69,23 @@ function ConversationSurface(props) {
82
69
  }), null);
83
70
  insert(_el$, createComponent(Show, {
84
71
  get when() {
85
- return !atBottom();
72
+ return !follow.atBottom();
86
73
  },
87
74
  get children() {
88
- var _el$3 = _tmpl$();
89
- insert(_el$3, createComponent(Button, {
75
+ var _el$4 = _tmpl$();
76
+ insert(_el$4, createComponent(Button, {
77
+ type: "button",
90
78
  size: "sm",
91
79
  variant: "secondary",
92
80
  "class": "pointer-events-auto gap-1.5 shadow-md",
93
- onClick: jumpToLatest,
81
+ get onClick() {
82
+ return follow.jump;
83
+ },
94
84
  get children() {
95
- return [createComponent(ArrowDown, {}), createComponent(Show, {
96
- get when() {
97
- return missed() > 0;
98
- },
99
- get fallback() {
100
- return "Jump to latest";
101
- },
102
- get children() {
103
- return [memo(() => missed()), " new"];
104
- }
105
- })];
85
+ return [createComponent(ArrowDown, {}), "Jump to latest"];
106
86
  }
107
87
  }));
108
- return _el$3;
88
+ return _el$4;
109
89
  }
110
90
  }), null);
111
91
  effect(() => className(_el$, cn("relative flex min-h-0 flex-1 flex-col", local.class)));
@@ -1 +1 @@
1
- {"version":3,"file":"conversation-surface.js","names":["ArrowDown","ComponentProps","JSX","Show","createSignal","onCleanup","splitProps","cn","Button","ConversationSurfaceProps","threshold","empty","Element","header","distanceFromBottom","element","HTMLDivElement","scrollHeight","scrollTop","clientHeight","ConversationSurface","props","local","rest","atBottom","setAtBottom","missed","setMissed","scroller","onScroll","distance","Math","round","jumpToLatest","scrollTo","top","behavior","observer","mutation","MutationObserver","observe","childList","subtree","disconnect","_el$","_tmpl$2","_el$2","firstChild","addEventListener","_$use","_$spread","_$insert","_$createComponent","when","children","fallback","_el$4","_tmpl$3","_el$3","_tmpl$","size","variant","onClick","_$memo","_$effect","_$className","class","_$createComponent","_$memo","_$className"],"sources":["../../../src/components/conversation/conversation-surface.tsx"],"sourcesContent":["import { ArrowDown } from 'lucide-solid'\nimport type { ComponentProps, JSX } from 'solid-js'\nimport { Show, createSignal, onCleanup, splitProps } from 'solid-js'\nimport { cn } from '#lib/utils'\nimport { Button } from '../ui/button/button'\n\n/**\n * ConversationSurface.\n *\n * The scrolling transcript. Its job is small and it is the difference between a\n * transcript that stays readable as it grows and one that fights the reader:\n *\n * - **Stick to the bottom.** New messages scroll into view only when the reader is\n * already at the bottom. Someone reading back through history must not be yanked\n * to the end by an arriving message, and that is the single most common way a\n * chat surface is made unusable.\n * - **Offer a way back.** Once the reader is not at the bottom, a control appears\n * that returns them and says how much they have missed.\n * - **`overscroll-contain`.** The transcript scrolls to its end and stops, rather\n * than dragging the pane behind it — which in a desktop shell means the whole\n * window moves.\n */\nexport type ConversationSurfaceProps = ComponentProps<'div'> & {\n /** How far from the bottom still counts as \"at the bottom\", in px. */\n threshold?: number\n /** Rendered above the transcript when there is nothing in it. */\n empty?: JSX.Element\n /** A header that scrolls with the transcript, e.g. a day divider or a banner. */\n header?: JSX.Element\n}\n\n/** How far the reader is from the end of the transcript, in px. */\nfunction distanceFromBottom(element: HTMLDivElement): number {\n return element.scrollHeight - element.scrollTop - element.clientHeight\n}\n\nexport function ConversationSurface(props: ConversationSurfaceProps) {\n const [local, rest] = splitProps(props, ['class', 'threshold', 'empty', 'header', 'children'])\n\n const [atBottom, setAtBottom] = createSignal(true)\n const [missed, setMissed] = createSignal(0)\n let scroller: HTMLDivElement | undefined\n\n const onScroll = () => {\n if (!scroller) return\n const distance = distanceFromBottom(scroller)\n const threshold = local.threshold ?? 80\n setAtBottom(distance <= threshold)\n setMissed(distance <= threshold ? 0 : Math.round(distance / 96))\n }\n\n const jumpToLatest = () => {\n scroller?.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' })\n setAtBottom(true)\n setMissed(0)\n }\n\n // An arriving message while the reader is at the bottom should follow it. While\n // they are not, it must not.\n const observer = () => {\n if (!scroller) return\n const mutation = new MutationObserver(() => {\n if (atBottom()) scroller?.scrollTo({ top: scroller.scrollHeight })\n else onScroll()\n })\n mutation.observe(scroller, { childList: true, subtree: true })\n onCleanup(() => mutation.disconnect())\n }\n\n return (\n <div\n data-slot=\"conversation-surface\"\n class={cn('relative flex min-h-0 flex-1 flex-col', local.class)}\n >\n <div\n ref={(element) => {\n scroller = element\n observer()\n }}\n onScroll={onScroll}\n class=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\"\n {...rest}\n >\n <Show when={local.header}>{local.header}</Show>\n <Show when={local.children} fallback={<div class=\"p-4\">{local.empty}</div>}>\n {local.children}\n </Show>\n </div>\n\n <Show when={!atBottom()}>\n <div class=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n <Button\n size=\"sm\"\n variant=\"secondary\"\n class=\"pointer-events-auto gap-1.5 shadow-md\"\n onClick={jumpToLatest}\n >\n <ArrowDown />\n <Show when={missed() > 0} fallback={<>Jump to latest</>}>\n {missed()} new\n </Show>\n </Button>\n </div>\n </Show>\n </div>\n )\n}\n"],"mappings":";;;;;;;;;;AAgCA,SAASc,mBAAmBC,SAAiC;CAC3D,OAAOA,QAAQE,eAAeF,QAAQG,YAAYH,QAAQI;AAC5D;AAEA,SAAgBC,oBAAoBC,OAAiC;CACnE,MAAM,CAACC,OAAOC,QAAQjB,WAAWe,OAAO;EAAC;EAAS;EAAa;EAAS;EAAU;CAAU,CAAC;CAE7F,MAAM,CAACG,UAAUC,eAAerB,aAAa,IAAI;CACjD,MAAM,CAACsB,QAAQC,aAAavB,aAAa,CAAC;CAC1C,IAAIwB;CAEJ,MAAMC,iBAAiB;EACrB,IAAI,CAACD,UAAU;EACf,MAAME,WAAWhB,mBAAmBc,QAAQ;EAC5C,MAAMlB,YAAYY,MAAMZ,aAAa;EACrCe,YAAYK,YAAYpB,SAAS;EACjCiB,UAAUG,YAAYpB,YAAY,IAAIqB,KAAKC,MAAMF,WAAW,EAAE,CAAC;CACjE;CAEA,MAAMG,qBAAqB;EACzBL,UAAUM,SAAS;GAAEC,KAAKP,SAASX;GAAcmB,UAAU;EAAS,CAAC;EACrEX,YAAY,IAAI;EAChBE,UAAU,CAAC;CACb;CAIA,MAAMU,iBAAiB;EACrB,IAAI,CAACT,UAAU;EACf,MAAMU,WAAW,IAAIC,uBAAuB;GAC1C,IAAIf,SAAS,GAAGI,UAAUM,SAAS,EAAEC,KAAKP,SAASX,aAAa,CAAC;QAC5DY,SAAS;EAChB,CAAC;EACDS,SAASE,QAAQZ,UAAU;GAAEa,WAAW;GAAMC,SAAS;EAAK,CAAC;EAC7DrC,gBAAgBiC,SAASK,WAAW,CAAC;CACvC;CAEA,cAAA;EAAA,IAAAC,OAAAC,QAAA,GAAAC,QAAAF,KAAAG;EAAAD,MAAAE,iBAAA,UAUgBnB,QAAQ;EAAAoB,KAJZlC,YAAY;GAChBa,WAAWb;GACXsB,SAAS;EACX,GAACS,KAAA;EAAAI,OAAAJ,OAGGvB,MAAI,OAAA,IAAA;EAAA4B,OAAAL,OAAAM,gBAEPjD,MAAI;GAAA,IAACkD,OAAI;IAAA,OAAE/B,MAAMT;GAAM;GAAA,IAAAyC,WAAA;IAAA,OAAGhC,MAAMT;GAAM;EAAA,CAAA,GAAA,IAAA;EAAAsC,OAAAL,OAAAM,gBACtCjD,MAAI;GAAA,IAACkD,OAAI;IAAA,OAAE/B,MAAMgC;GAAQ;GAAA,IAAEC,WAAQ;IAAA,cAAA;KAAA,IAAAC,QAAAC,QAAA;KAAAN,OAAAK,aAAoBlC,MAAMX,KAAK;KAAA,OAAA6C;IAAA,EAAA,CAAA;GAAA;GAAA,IAAAF,WAAA;IAAA,OAChEhC,MAAMgC;GAAQ;EAAA,CAAA,GAAA,IAAA;EAAAH,OAAAP,MAAAQ,gBAIlBjD,MAAI;GAAA,IAACkD,OAAI;IAAA,OAAE,CAAC7B,SAAS;GAAC;GAAA,IAAA8B,WAAA;IAAA,IAAAI,QAAAC,OAAA;IAAAR,OAAAO,OAAAN,gBAElB5C,QAAM;KACLoD,MAAI;KACJC,SAAO;KAAA,SAAA;KAEPC,SAAS7B;KAAY,IAAAqB,WAAA;MAAA,OAAA,CAAAF,gBAEpBpD,WAAS,CAAA,CAAA,GAAAoD,gBACTjD,MAAI;OAAA,IAACkD,OAAI;QAAA,OAAE3B,OAAO,IAAI;OAAC;OAAA,IAAE6B,WAAQ;QAAA,OAAA;OAAA;OAAA,IAAAD,WAAA;QAAA,OAAA,CAAAS,WAC/BrC,OAAO,CAAC,GAAA,MAAA;OAAA;MAAA,CAAA,CAAA;KAAA;IAAA,CAAA,CAAA;IAAA,OAAAgC;GAAA;EAAA,CAAA,GAAA,IAAA;EAAAM,aAAAC,UAAArB,MA3BVrC,GAAG,yCAAyCe,MAAM4C,KAAK,CAAC,CAAA;EAAA,OAAAtB;CAAA,EAAA,CAAA;AAkCrE"}
1
+ {"version":3,"file":"conversation-surface.js","names":["ArrowDown","ComponentProps","JSX","Show","splitProps","cn","Button","createScrollFollow","ConversationSurfaceProps","threshold","follow","resetKey","empty","Element","header","ConversationSurface","props","local","rest","enabled","onScroll","EventHandlerUnion","HTMLDivElement","Event","event","handler","_el$","_tmpl$2","_el$2","firstChild","_el$3","addEventListener","_$use","element","bindScroller","ref","_$spread","_$mergeProps","tabindex","_ref$","bindContent","_$insert","_$createComponent","when","children","fallback","_el$5","_tmpl$3","atBottom","_el$4","_tmpl$","type","size","variant","onClick","jump","_$effect","_$className","class","_$mergeProps","_$use","_$createComponent","_$className"],"sources":["../../../src/components/conversation/conversation-surface.tsx"],"sourcesContent":["import { ArrowDown } from 'lucide-solid'\nimport type { ComponentProps, JSX } from 'solid-js'\nimport { Show, splitProps } from 'solid-js'\nimport { cn } from '#lib/utils'\nimport { Button } from '../ui/button/button'\nimport { createScrollFollow } from './scroll-follow'\n\n/**\n * ConversationSurface.\n *\n * The scrolling transcript. Its job is small and it is the difference between a\n * transcript that stays readable as it grows and one that fights the reader:\n *\n * - **Stick to the bottom.** New messages scroll into view only when the reader is\n * already at the bottom. Someone reading back through history must not be yanked\n * to the end by an arriving message, and that is the single most common way a\n * chat surface is made unusable.\n * - **Offer a way back.** Once the reader is not at the bottom, a control appears\n * that returns them. Pixel distance cannot prove an unread message count.\n * - **`overscroll-contain`.** The transcript scrolls to its end and stops, rather\n * than dragging the pane behind it — which in a desktop shell means the whole\n * window moves.\n */\nexport type ConversationSurfaceProps = ComponentProps<'div'> & {\n /** How far from the bottom still counts as \"at the bottom\", in px. */\n threshold?: number\n /** Disable automatic positioning while the host owns scroll restoration. */\n follow?: boolean\n /** Optional conversation identity: changing it re-arms follow at the bottom. */\n resetKey?: string\n /** Rendered above the transcript when there is nothing in it. */\n empty?: JSX.Element\n /** A header that scrolls with the transcript, e.g. a day divider or a banner. */\n header?: JSX.Element\n}\n\nexport function ConversationSurface(props: ConversationSurfaceProps) {\n const [local, rest] = splitProps(props, [\n 'class',\n 'threshold',\n 'follow',\n 'resetKey',\n 'empty',\n 'header',\n 'children',\n 'onScroll',\n 'ref',\n 'tabindex',\n ])\n // Kobalte provides the control's semantics; transcript follow is a native\n // geometry contract. KiroCrew's plain-scroller controller handles streaming,\n // content collapse and pane resizing without treating them as user consent.\n const follow = createScrollFollow({\n enabled: () => local.follow !== false,\n resetKey: () => local.resetKey,\n threshold: () => local.threshold ?? 80,\n })\n const onScroll: JSX.EventHandlerUnion<HTMLDivElement, Event> = (event) => {\n follow.onScroll()\n const handler = local.onScroll\n if (typeof handler === 'function') handler(event)\n else if (handler) handler[0](handler[1], event)\n }\n\n return (\n <div\n data-slot=\"conversation-surface\"\n class={cn('relative flex min-h-0 flex-1 flex-col', local.class)}\n >\n <div\n ref={(element) => {\n follow.bindScroller(element)\n if (typeof local.ref === 'function') local.ref(element)\n }}\n onScroll={onScroll}\n tabindex={local.tabindex ?? 0}\n class=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\"\n {...rest}\n >\n <div ref={follow.bindContent} data-slot=\"conversation-content\">\n <Show when={local.header}>{local.header}</Show>\n <Show when={local.children} fallback={<div class=\"p-4\">{local.empty}</div>}>\n {local.children}\n </Show>\n </div>\n </div>\n\n <Show when={!follow.atBottom()}>\n <div class=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"secondary\"\n class=\"pointer-events-auto gap-1.5 shadow-md\"\n onClick={follow.jump}\n >\n <ArrowDown />\n Jump to latest\n </Button>\n </div>\n </Show>\n </div>\n )\n}\n"],"mappings":";;;;;;;;;;AAoCA,SAAgBe,oBAAoBC,OAAiC;CACnE,MAAM,CAACC,OAAOC,QAAQd,WAAWY,OAAO;EACtC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CAAU,CACX;CAID,MAAMN,SAASH,mBAAmB;EAChCY,eAAeF,MAAMP,WAAW;EAChCC,gBAAgBM,MAAMN;EACtBF,iBAAiBQ,MAAMR,aAAa;CACtC,CAAC;CACD,MAAMW,YAA0DI,UAAU;EACxEd,OAAOU,SAAS;EAChB,MAAMK,UAAUR,MAAMG;EACtB,IAAI,OAAOK,YAAY,YAAYA,QAAQD,KAAK;OAC3C,IAAIC,SAASA,QAAQ,EAAE,CAACA,QAAQ,IAAID,KAAK;CAChD;CAEA,cAAA;EAAA,IAAAE,OAAAC,QAAA,GAAAC,QAAAF,KAAAG,YAAAC,QAAAF,MAAAC;EAAAD,MAAAG,iBAAA,UAUgBX,QAAQ;EAAAY,KAJZC,YAAY;GAChBvB,OAAOwB,aAAaD,OAAO;GAC3B,IAAI,OAAOhB,MAAMkB,QAAQ,YAAYlB,MAAMkB,IAAIF,OAAO;EACxD,GAACL,KAAA;EAAAQ,OAAAR,OAAAS,WAAA,EAAA,IAEDC,WAAQ;GAAA,OAAErB,MAAMqB,YAAY;EAAC,EAAA,GAEzBpB,IAAI,GAAA,OAAA,IAAA;EAAA,IAAAqB,QAEE7B,OAAO8B;EAAW,OAAAD,UAAA,aAAAP,IAAAO,OAAAT,KAAA,IAAlBpB,OAAO8B,cAAWV;EAAAW,OAAAX,OAAAY,gBACzBvC,MAAI;GAAA,IAACwC,OAAI;IAAA,OAAE1B,MAAMH;GAAM;GAAA,IAAA8B,WAAA;IAAA,OAAG3B,MAAMH;GAAM;EAAA,CAAA,GAAA,IAAA;EAAA2B,OAAAX,OAAAY,gBACtCvC,MAAI;GAAA,IAACwC,OAAI;IAAA,OAAE1B,MAAM2B;GAAQ;GAAA,IAAEC,WAAQ;IAAA,cAAA;KAAA,IAAAC,QAAAC,QAAA;KAAAN,OAAAK,aAAoB7B,MAAML,KAAK;KAAA,OAAAkC;IAAA,EAAA,CAAA;GAAA;GAAA,IAAAF,WAAA;IAAA,OAChE3B,MAAM2B;GAAQ;EAAA,CAAA,GAAA,IAAA;EAAAH,OAAAf,MAAAgB,gBAKpBvC,MAAI;GAAA,IAACwC,OAAI;IAAA,OAAE,CAACjC,OAAOsC,SAAS;GAAC;GAAA,IAAAJ,WAAA;IAAA,IAAAK,QAAAC,OAAA;IAAAT,OAAAQ,OAAAP,gBAEzBpC,QAAM;KACL6C,MAAI;KACJC,MAAI;KACJC,SAAO;KAAA,SAAA;KAAA,IAEPC,UAAO;MAAA,OAAE5C,OAAO6C;KAAI;KAAA,IAAAX,WAAA;MAAA,OAAA,CAAAF,gBAEnB1C,WAAS,CAAA,CAAA,GAAA,gBAAA;KAAA;IAAA,CAAA,CAAA;IAAA,OAAAiD;GAAA;EAAA,CAAA,GAAA,IAAA;EAAAO,aAAAC,UAAA/B,MA7BTrB,GAAG,yCAAyCY,MAAMyC,KAAK,CAAC,CAAA;EAAA,OAAAhC;CAAA,EAAA,CAAA;AAoCrE"}
@@ -1,3 +1,4 @@
1
+ export { BusySendButton, type BusySendMode, type BusySendButtonProps } from './busy-send-button';
1
2
  export { ConversationAvatar, type ConversationAvatarKind, type ConversationAvatarProps, } from './conversation-avatar';
2
3
  export { ConversationSurface, type ConversationSurfaceProps } from './conversation-surface';
3
4
  export { ComposerAttachmentButton, ComposerHint, MessageComposer, type MessageComposerProps, } from './message-composer';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,GAC7B,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,mBAAmB,EAAE,KAAK,wBAAwB,EAAE,MAAM,wBAAwB,CAAA;AAC3F,OAAO,EACL,wBAAwB,EACxB,YAAY,EACZ,eAAe,EACf,KAAK,oBAAoB,GAC1B,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,KAAK,eAAe,GACrB,MAAM,eAAe,CAAA;AACtB,OAAO,EACL,cAAc,EACd,WAAW,EACX,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,GACtB,MAAM,gBAAgB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,KAAK,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AAChG,OAAO,EACL,kBAAkB,EAClB,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,GAC7B,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,mBAAmB,EAAE,KAAK,wBAAwB,EAAE,MAAM,wBAAwB,CAAA;AAC3F,OAAO,EACL,wBAAwB,EACxB,YAAY,EACZ,eAAe,EACf,KAAK,oBAAoB,GAC1B,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,KAAK,eAAe,GACrB,MAAM,eAAe,CAAA;AACtB,OAAO,EACL,cAAc,EACd,WAAW,EACX,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,GACtB,MAAM,gBAAgB,CAAA"}
@@ -1,6 +1,7 @@
1
+ import { BusySendButton } from "./busy-send-button.js";
1
2
  import { ConversationAvatar } from "./conversation-avatar.js";
2
3
  import { ConversationSurface } from "./conversation-surface.js";
3
4
  import { ComposerAttachmentButton, ComposerHint, MessageComposer } from "./message-composer.js";
4
5
  import { MessageBody, MessageDayDivider, MessageGroup, MessageRow } from "./message-row.js";
5
6
  import { AttachmentCard, ThreadPanel } from "./thread-panel.js";
6
- export { AttachmentCard, ComposerAttachmentButton, ComposerHint, ConversationAvatar, ConversationSurface, MessageBody, MessageComposer, MessageDayDivider, MessageGroup, MessageRow, ThreadPanel };
7
+ export { AttachmentCard, BusySendButton, ComposerAttachmentButton, ComposerHint, ConversationAvatar, ConversationSurface, MessageBody, MessageComposer, MessageDayDivider, MessageGroup, MessageRow, ThreadPanel };