@adea-ai/ui 0.67.0 → 0.67.2
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/README.md +8 -3
- package/dist/components/conversation/conversation-surface.d.ts +8 -0
- package/dist/components/conversation/conversation-surface.d.ts.map +1 -1
- package/dist/components/conversation/conversation-surface.js +5 -1
- package/dist/components/conversation/conversation-surface.js.map +1 -1
- package/dist/components/conversation/index.d.ts +1 -1
- package/dist/components/conversation/index.d.ts.map +1 -1
- package/dist/components/conversation/scroll-follow.d.ts +3 -0
- package/dist/components/conversation/scroll-follow.d.ts.map +1 -1
- package/dist/components/conversation/scroll-follow.js +25 -3
- package/dist/components/conversation/scroll-follow.js.map +1 -1
- package/dist/r/src/components/conversation/conversation-surface.tsx +10 -0
- package/dist/r/src/components/conversation/index.ts +5 -1
- package/dist/r/src/components/conversation/scroll-follow.ts +34 -3
- package/package.json +1 -1
- package/src/components/conversation/conversation-surface.tsx +10 -0
- package/src/components/conversation/index.ts +5 -1
- package/src/components/conversation/scroll-follow.ts +34 -3
package/README.md
CHANGED
|
@@ -180,9 +180,14 @@ transcript when its jump control disappears; automatic pins never move focus.
|
|
|
180
180
|
|
|
181
181
|
`threshold` controls jump-button visibility (80px by default), independently of
|
|
182
182
|
follow intent. `follow={false}` is fully inert: positioning belongs to the host,
|
|
183
|
-
with no observer or mount pin. Re-enabling it explicitly re-arms at the bottom
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
with no observer or mount pin. Re-enabling it explicitly re-arms at the bottom
|
|
184
|
+
unless the host supplies `initialReadingPosition: { top, following }`. That
|
|
185
|
+
snapshot is consumed only on mount, identity reset or follow re-enable; ordinary
|
|
186
|
+
snapshot prop changes never reposition the reader. A parked snapshot releases
|
|
187
|
+
follow until an explicit jump or native return to the bottom. The
|
|
188
|
+
`onReadingPositionChange` callback reports native offset and actual follow intent,
|
|
189
|
+
including small upward moves inside the jump visibility threshold. An optional
|
|
190
|
+
`resetKey` resets follow or consumes the current identity's snapshot. Canonical
|
|
186
191
|
session identity, durable restoration and virtualized transcript windows remain
|
|
187
192
|
application-owned. `ref` and `onScroll` still reach the inner scroller.
|
|
188
193
|
|
|
@@ -15,6 +15,10 @@ import type { ComponentProps, JSX } from 'solid-js';
|
|
|
15
15
|
* than dragging the pane behind it — which in a desktop shell means the whole
|
|
16
16
|
* window moves.
|
|
17
17
|
*/
|
|
18
|
+
export type ConversationReadingPosition = Readonly<{
|
|
19
|
+
top: number;
|
|
20
|
+
following: boolean;
|
|
21
|
+
}>;
|
|
18
22
|
export type ConversationSurfaceProps = ComponentProps<'div'> & {
|
|
19
23
|
/** How far from the bottom still counts as "at the bottom", in px. */
|
|
20
24
|
threshold?: number;
|
|
@@ -22,6 +26,10 @@ export type ConversationSurfaceProps = ComponentProps<'div'> & {
|
|
|
22
26
|
follow?: boolean;
|
|
23
27
|
/** Optional conversation identity: changing it re-arms follow at the bottom. */
|
|
24
28
|
resetKey?: string;
|
|
29
|
+
/** Host snapshot consumed on mount/identity reset, never on ordinary prop updates. */
|
|
30
|
+
initialReadingPosition?: ConversationReadingPosition;
|
|
31
|
+
/** Actual follow intent and native offset; storage/identity remain host-owned. */
|
|
32
|
+
onReadingPositionChange?: (position: ConversationReadingPosition) => void;
|
|
25
33
|
/** Rendered above the transcript when there is nothing in it. */
|
|
26
34
|
empty?: JSX.Element;
|
|
27
35
|
/** 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;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,
|
|
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,2BAA2B,GAAG,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAAC,CAAA;AAEvF,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,sFAAsF;IACtF,sBAAsB,CAAC,EAAE,2BAA2B,CAAA;IACpD,kFAAkF;IAClF,uBAAuB,CAAC,EAAE,CAAC,QAAQ,EAAE,2BAA2B,KAAK,IAAI,CAAA;IACzE,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,eAuElE"}
|
|
@@ -14,6 +14,8 @@ function ConversationSurface(props) {
|
|
|
14
14
|
"threshold",
|
|
15
15
|
"follow",
|
|
16
16
|
"resetKey",
|
|
17
|
+
"initialReadingPosition",
|
|
18
|
+
"onReadingPositionChange",
|
|
17
19
|
"empty",
|
|
18
20
|
"header",
|
|
19
21
|
"children",
|
|
@@ -24,7 +26,9 @@ function ConversationSurface(props) {
|
|
|
24
26
|
const follow = createScrollFollow({
|
|
25
27
|
enabled: () => local.follow !== false,
|
|
26
28
|
resetKey: () => local.resetKey,
|
|
27
|
-
threshold: () => local.threshold ?? 80
|
|
29
|
+
threshold: () => local.threshold ?? 80,
|
|
30
|
+
initialPosition: () => local.initialReadingPosition,
|
|
31
|
+
onPositionChange: (position) => local.onReadingPositionChange?.(position)
|
|
28
32
|
});
|
|
29
33
|
const onScroll = (event) => {
|
|
30
34
|
follow.onScroll();
|
|
@@ -1 +1 @@
|
|
|
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":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"conversation-surface.js","names":["ArrowDown","ComponentProps","JSX","Show","splitProps","cn","Button","createScrollFollow","ConversationReadingPosition","Readonly","top","following","ConversationSurfaceProps","threshold","follow","resetKey","initialReadingPosition","onReadingPositionChange","position","empty","Element","header","ConversationSurface","props","local","rest","enabled","initialPosition","onPositionChange","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 ConversationReadingPosition = Readonly<{ top: number; following: boolean }>\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 /** Host snapshot consumed on mount/identity reset, never on ordinary prop updates. */\n initialReadingPosition?: ConversationReadingPosition\n /** Actual follow intent and native offset; storage/identity remain host-owned. */\n onReadingPositionChange?: (position: ConversationReadingPosition) => void\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 'initialReadingPosition',\n 'onReadingPositionChange',\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 initialPosition: () => local.initialReadingPosition,\n onPositionChange: (position) => local.onReadingPositionChange?.(position),\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":";;;;;;;;;;AA0CA,SAAgBsB,oBAAoBC,OAAiC;CACnE,MAAM,CAACC,OAAOC,QAAQrB,WAAWmB,OAAO;EACtC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CAAU,CACX;CAID,MAAMT,SAASP,mBAAmB;EAChCmB,eAAeF,MAAMV,WAAW;EAChCC,gBAAgBS,MAAMT;EACtBF,iBAAiBW,MAAMX,aAAa;EACpCc,uBAAuBH,MAAMR;EAC7BY,mBAAmBV,aAAaM,MAAMP,0BAA0BC,QAAQ;CAC1E,CAAC;CACD,MAAMW,YAA0DI,UAAU;EACxEnB,OAAOe,SAAS;EAChB,MAAMK,UAAUV,MAAMK;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;GAChB5B,OAAO6B,aAAaD,OAAO;GAC3B,IAAI,OAAOlB,MAAMoB,QAAQ,YAAYpB,MAAMoB,IAAIF,OAAO;EACxD,GAACL,KAAA;EAAAQ,OAAAR,OAAAS,WAAA,EAAA,IAEDC,WAAQ;GAAA,OAAEvB,MAAMuB,YAAY;EAAC,EAAA,GAEzBtB,IAAI,GAAA,OAAA,IAAA;EAAA,IAAAuB,QAEElC,OAAOmC;EAAW,OAAAD,UAAA,aAAAP,IAAAO,OAAAT,KAAA,IAAlBzB,OAAOmC,cAAWV;EAAAW,OAAAX,OAAAY,gBACzBhD,MAAI;GAAA,IAACiD,OAAI;IAAA,OAAE5B,MAAMH;GAAM;GAAA,IAAAgC,WAAA;IAAA,OAAG7B,MAAMH;GAAM;EAAA,CAAA,GAAA,IAAA;EAAA6B,OAAAX,OAAAY,gBACtChD,MAAI;GAAA,IAACiD,OAAI;IAAA,OAAE5B,MAAM6B;GAAQ;GAAA,IAAEC,WAAQ;IAAA,cAAA;KAAA,IAAAC,QAAAC,QAAA;KAAAN,OAAAK,aAAoB/B,MAAML,KAAK;KAAA,OAAAoC;IAAA,EAAA,CAAA;GAAA;GAAA,IAAAF,WAAA;IAAA,OAChE7B,MAAM6B;GAAQ;EAAA,CAAA,GAAA,IAAA;EAAAH,OAAAf,MAAAgB,gBAKpBhD,MAAI;GAAA,IAACiD,OAAI;IAAA,OAAE,CAACtC,OAAO2C,SAAS;GAAC;GAAA,IAAAJ,WAAA;IAAA,IAAAK,QAAAC,OAAA;IAAAT,OAAAQ,OAAAP,gBAEzB7C,QAAM;KACLsD,MAAI;KACJC,MAAI;KACJC,SAAO;KAAA,SAAA;KAAA,IAEPC,UAAO;MAAA,OAAEjD,OAAOkD;KAAI;KAAA,IAAAX,WAAA;MAAA,OAAA,CAAAF,gBAEnBnD,WAAS,CAAA,CAAA,GAAA,gBAAA;KAAA;IAAA,CAAA,CAAA;IAAA,OAAA0D;GAAA;EAAA,CAAA,GAAA,IAAA;EAAAO,aAAAC,UAAA/B,MA7BT9B,GAAG,yCAAyCmB,MAAM2C,KAAK,CAAC,CAAA;EAAA,OAAAhC;CAAA,EAAA,CAAA;AAoCrE"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { ChatComposer, type ChatComposerProps, type ChatComposerControl } from './chat-composer';
|
|
2
2
|
export { BusySendButton, type BusySendMode, type BusySendButtonProps } from './busy-send-button';
|
|
3
3
|
export { ConversationAvatar, type ConversationAvatarKind, type ConversationAvatarProps, } from './conversation-avatar';
|
|
4
|
-
export { ConversationSurface, type ConversationSurfaceProps } from './conversation-surface';
|
|
4
|
+
export { ConversationSurface, type ConversationSurfaceProps, type ConversationReadingPosition, } from './conversation-surface';
|
|
5
5
|
export { ComposerAttachmentButton, ComposerHint, MessageComposer, type MessageComposerProps, } from './message-composer';
|
|
6
6
|
export { MessageBody, MessageDayDivider, MessageGroup, MessageRow, type MessageRowProps, } from './message-row';
|
|
7
7
|
export { AttachmentCard, ThreadPanel, type AttachmentCardProps, type ThreadPanelProps, } from './thread-panel';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,KAAK,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAChG,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,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,KAAK,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAChG,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,EACL,mBAAmB,EACnB,KAAK,wBAAwB,EAC7B,KAAK,2BAA2B,GACjC,MAAM,wBAAwB,CAAA;AAC/B,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;AAEvB,OAAO,EACL,UAAU,EACV,SAAS,EACT,eAAe,EACf,WAAW,EACX,YAAY,EACZ,OAAO,EACP,WAAW,EACX,gBAAgB,EAChB,kBAAkB,EAClB,cAAc,EACd,uBAAuB,EACvB,YAAY,EACZ,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,KAAK,UAAU,GAChB,MAAM,gBAAgB,CAAA"}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import type { ConversationReadingPosition } from './conversation-surface';
|
|
1
2
|
/** Internal plain-scroller binding; host-owned restoration can disable follow. */
|
|
2
3
|
export declare function createScrollFollow(options: {
|
|
3
4
|
enabled: () => boolean;
|
|
4
5
|
resetKey: () => string | undefined;
|
|
5
6
|
threshold: () => number;
|
|
7
|
+
initialPosition?: () => ConversationReadingPosition | undefined;
|
|
8
|
+
onPositionChange?: (position: ConversationReadingPosition) => void;
|
|
6
9
|
}): {
|
|
7
10
|
atBottom: import("solid-js").Accessor<boolean>;
|
|
8
11
|
onScroll: () => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scroll-follow.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/scroll-follow.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"scroll-follow.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/scroll-follow.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAA;AAezE,kFAAkF;AAClF,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAC1C,OAAO,EAAE,MAAM,OAAO,CAAA;IACtB,QAAQ,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAClC,SAAS,EAAE,MAAM,MAAM,CAAA;IACvB,eAAe,CAAC,EAAE,MAAM,2BAA2B,GAAG,SAAS,CAAA;IAC/D,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,2BAA2B,KAAK,IAAI,CAAA;CACnE;;;;4BAsH2B,cAAc;2BAGf,cAAc;EAIxC"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { bottomTarget, computeAtBottom, evaluateAutoPin, isSelfScroll, resolveUserScrollStick } from "./scroll-follow-core.js";
|
|
2
|
-
import { createEffect, createSignal, onCleanup } from "solid-js";
|
|
2
|
+
import { createEffect, createSignal, onCleanup, untrack } from "solid-js";
|
|
3
3
|
//#region src/components/conversation/scroll-follow.ts
|
|
4
4
|
var geometry = (element) => ({
|
|
5
5
|
scrollTop: element.scrollTop,
|
|
@@ -16,6 +16,13 @@ function createScrollFollow(options) {
|
|
|
16
16
|
let lastWriteClientHeight = -1;
|
|
17
17
|
let previousTop = -1;
|
|
18
18
|
let lastScrollClientHeight = 0;
|
|
19
|
+
const reportPosition = () => {
|
|
20
|
+
if (scroller) untrack(() => options.onPositionChange?.({
|
|
21
|
+
top: scroller.scrollTop,
|
|
22
|
+
following: options.enabled() && stick
|
|
23
|
+
}));
|
|
24
|
+
};
|
|
25
|
+
onCleanup(reportPosition);
|
|
19
26
|
const writePin = (element, target) => {
|
|
20
27
|
element.scrollTop = target;
|
|
21
28
|
lastWriteTop = target;
|
|
@@ -38,9 +45,14 @@ function createScrollFollow(options) {
|
|
|
38
45
|
lastWriteClientHeight = geom.clientHeight;
|
|
39
46
|
}
|
|
40
47
|
setAtBottom(computeAtBottom(geometry(scroller), options.threshold()));
|
|
48
|
+
reportPosition();
|
|
41
49
|
};
|
|
42
50
|
const onScroll = () => {
|
|
43
|
-
if (!scroller
|
|
51
|
+
if (!scroller) return;
|
|
52
|
+
if (!options.enabled()) {
|
|
53
|
+
reportPosition();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
44
56
|
const geom = geometry(scroller);
|
|
45
57
|
setAtBottom(computeAtBottom(geom, options.threshold()));
|
|
46
58
|
if (!isSelfScroll(geom.scrollTop, lastWriteTop)) {
|
|
@@ -59,17 +71,20 @@ function createScrollFollow(options) {
|
|
|
59
71
|
}
|
|
60
72
|
previousTop = geom.scrollTop;
|
|
61
73
|
lastScrollClientHeight = geom.clientHeight;
|
|
74
|
+
reportPosition();
|
|
62
75
|
};
|
|
63
76
|
const jump = () => {
|
|
64
77
|
if (!scroller || !options.enabled()) return;
|
|
65
78
|
stick = true;
|
|
66
79
|
writePin(scroller, bottomTarget(geometry(scroller)));
|
|
67
80
|
setAtBottom(true);
|
|
81
|
+
reportPosition();
|
|
68
82
|
scroller.focus({ preventScroll: true });
|
|
69
83
|
};
|
|
70
84
|
createEffect(() => {
|
|
71
85
|
options.resetKey();
|
|
72
86
|
const enabled = options.enabled();
|
|
87
|
+
const initial = untrack(() => options.initialPosition?.());
|
|
73
88
|
stick = true;
|
|
74
89
|
lastWriteTop = -1;
|
|
75
90
|
lastWriteClientHeight = -1;
|
|
@@ -77,7 +92,14 @@ function createScrollFollow(options) {
|
|
|
77
92
|
lastScrollClientHeight = 0;
|
|
78
93
|
setAtBottom(true);
|
|
79
94
|
if (!enabled || !scroller) return;
|
|
80
|
-
|
|
95
|
+
if (initial && Number.isFinite(initial.top) && initial.top >= 0) {
|
|
96
|
+
stick = initial.following;
|
|
97
|
+
writePin(scroller, stick ? bottomTarget(geometry(scroller)) : initial.top);
|
|
98
|
+
previousTop = scroller.scrollTop;
|
|
99
|
+
lastScrollClientHeight = scroller.clientHeight;
|
|
100
|
+
} else writePin(scroller, bottomTarget(geometry(scroller)));
|
|
101
|
+
setAtBottom(computeAtBottom(geometry(scroller), options.threshold()));
|
|
102
|
+
reportPosition();
|
|
81
103
|
if (typeof ResizeObserver === "undefined") return;
|
|
82
104
|
const observer = new ResizeObserver(pinAuto);
|
|
83
105
|
observer.observe(scroller);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scroll-follow.js","names":[],"sources":["../../../src/components/conversation/scroll-follow.ts"],"sourcesContent":["/*\n * Substantially translated from KiroCrew website/src/app-sdk/useChatScrollFollow.ts,\n * pinned at 283e136c0f902e965a535a7c9548c57c7504fed0.\n * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\n * This product includes software developed at Amazon.com, Inc. (https://www.amazon.com/).\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0\n * Unless required by applicable law or agreed to in writing, software distributed\n * under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR\n * CONDITIONS OF ANY KIND, either express or implied. See the License for the\n * specific language governing permissions and limitations under the License.\n * Solid owner effects replace React refs/effects; native geometry and the\n * content/viewport observer contract are retained. No session store is copied.\n */\nimport { createEffect, createSignal, onCleanup } from 'solid-js'\nimport {\n bottomTarget,\n computeAtBottom,\n evaluateAutoPin,\n isSelfScroll,\n resolveUserScrollStick,\n} from './scroll-follow-core'\n\nconst geometry = (element: HTMLDivElement) => ({\n scrollTop: element.scrollTop,\n scrollHeight: element.scrollHeight,\n clientHeight: element.clientHeight,\n})\n\n/** Internal plain-scroller binding; host-owned restoration can disable follow. */\nexport function createScrollFollow(options: {\n enabled: () => boolean\n resetKey: () => string | undefined\n threshold: () => number\n}) {\n const [atBottom, setAtBottom] = createSignal(true)\n let scroller: HTMLDivElement | undefined\n let content: HTMLDivElement | undefined\n let stick = true\n let lastWriteTop = -1\n let lastWriteClientHeight = -1\n let previousTop = -1\n let lastScrollClientHeight = 0\n\n const writePin = (element: HTMLDivElement, target: number) => {\n // Instant writes keep the self-scroll reference synchronized. A smooth\n // animation would produce intermediate positions that resemble user input.\n element.scrollTop = target\n lastWriteTop = target\n lastWriteClientHeight = element.clientHeight\n previousTop = target\n }\n const pinAuto = () => {\n if (!scroller || !options.enabled()) return\n const geom = geometry(scroller)\n const result = evaluateAutoPin({\n stick,\n geom,\n lastWriteTop,\n viewportShrink: lastWriteClientHeight >= 0 ? lastWriteClientHeight - geom.clientHeight : 0,\n })\n stick = result.stick\n if (result.pin) writePin(scroller, result.target)\n else if (result.stick) {\n lastWriteTop = result.target\n lastWriteClientHeight = geom.clientHeight\n }\n setAtBottom(computeAtBottom(geometry(scroller), options.threshold()))\n }\n const onScroll = () => {\n if (!scroller
|
|
1
|
+
{"version":3,"file":"scroll-follow.js","names":[],"sources":["../../../src/components/conversation/scroll-follow.ts"],"sourcesContent":["/*\n * Substantially translated from KiroCrew website/src/app-sdk/useChatScrollFollow.ts,\n * pinned at 283e136c0f902e965a535a7c9548c57c7504fed0.\n * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\n * This product includes software developed at Amazon.com, Inc. (https://www.amazon.com/).\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0\n * Unless required by applicable law or agreed to in writing, software distributed\n * under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR\n * CONDITIONS OF ANY KIND, either express or implied. See the License for the\n * specific language governing permissions and limitations under the License.\n * Solid owner effects replace React refs/effects; native geometry and the\n * content/viewport observer contract are retained. No session store is copied.\n */\nimport { createEffect, createSignal, onCleanup, untrack } from 'solid-js'\nimport type { ConversationReadingPosition } from './conversation-surface'\nimport {\n bottomTarget,\n computeAtBottom,\n evaluateAutoPin,\n isSelfScroll,\n resolveUserScrollStick,\n} from './scroll-follow-core'\n\nconst geometry = (element: HTMLDivElement) => ({\n scrollTop: element.scrollTop,\n scrollHeight: element.scrollHeight,\n clientHeight: element.clientHeight,\n})\n\n/** Internal plain-scroller binding; host-owned restoration can disable follow. */\nexport function createScrollFollow(options: {\n enabled: () => boolean\n resetKey: () => string | undefined\n threshold: () => number\n initialPosition?: () => ConversationReadingPosition | undefined\n onPositionChange?: (position: ConversationReadingPosition) => void\n}) {\n const [atBottom, setAtBottom] = createSignal(true)\n let scroller: HTMLDivElement | undefined\n let content: HTMLDivElement | undefined\n let stick = true\n let lastWriteTop = -1\n let lastWriteClientHeight = -1\n let previousTop = -1\n let lastScrollClientHeight = 0\n const reportPosition = () => {\n if (scroller)\n untrack(() =>\n options.onPositionChange?.({\n top: scroller!.scrollTop,\n following: options.enabled() && stick,\n })\n )\n }\n onCleanup(reportPosition)\n\n const writePin = (element: HTMLDivElement, target: number) => {\n // Instant writes keep the self-scroll reference synchronized. A smooth\n // animation would produce intermediate positions that resemble user input.\n element.scrollTop = target\n lastWriteTop = target\n lastWriteClientHeight = element.clientHeight\n previousTop = target\n }\n const pinAuto = () => {\n if (!scroller || !options.enabled()) return\n const geom = geometry(scroller)\n const result = evaluateAutoPin({\n stick,\n geom,\n lastWriteTop,\n viewportShrink: lastWriteClientHeight >= 0 ? lastWriteClientHeight - geom.clientHeight : 0,\n })\n stick = result.stick\n if (result.pin) writePin(scroller, result.target)\n else if (result.stick) {\n lastWriteTop = result.target\n lastWriteClientHeight = geom.clientHeight\n }\n setAtBottom(computeAtBottom(geometry(scroller), options.threshold()))\n reportPosition()\n }\n const onScroll = () => {\n if (!scroller) return\n if (!options.enabled()) {\n reportPosition()\n return\n }\n const geom = geometry(scroller)\n setAtBottom(computeAtBottom(geom, options.threshold()))\n if (!isSelfScroll(geom.scrollTop, lastWriteTop)) {\n stick = resolveUserScrollStick({\n stick,\n followOutput: true,\n scrollTop: geom.scrollTop,\n prevScrollTop: previousTop,\n geom,\n viewportGrowth: lastScrollClientHeight > 0 ? geom.clientHeight - lastScrollClientHeight : 0,\n })\n if (!stick) {\n lastWriteTop = -1\n lastWriteClientHeight = -1\n }\n }\n previousTop = geom.scrollTop\n // This baseline belongs only to scroll events, not observer callbacks.\n // Advancing it during resize would erase the viewport-clamp evidence.\n lastScrollClientHeight = geom.clientHeight\n reportPosition()\n }\n const jump = () => {\n if (!scroller || !options.enabled()) return\n stick = true\n writePin(scroller, bottomTarget(geometry(scroller)))\n setAtBottom(true)\n reportPosition()\n // The jump control disappears at the bottom. Return its keyboard focus to\n // the transcript instead of leaving the reader on a detached button.\n scroller.focus({ preventScroll: true })\n }\n\n createEffect(() => {\n options.resetKey()\n const enabled = options.enabled()\n // Capture before any reporting callback can replace the host snapshot.\n const initial = untrack(() => options.initialPosition?.())\n stick = true\n lastWriteTop = -1\n lastWriteClientHeight = -1\n previousTop = -1\n lastScrollClientHeight = 0\n setAtBottom(true)\n if (!enabled || !scroller) return\n // Snapshot updates must not become another positioning owner. Consume the\n // host's snapshot only at entry/reset, then native reader intent owns follow.\n if (initial && Number.isFinite(initial.top) && initial.top >= 0) {\n stick = initial.following\n writePin(scroller, stick ? bottomTarget(geometry(scroller)) : initial.top)\n previousTop = scroller.scrollTop\n lastScrollClientHeight = scroller.clientHeight\n } else writePin(scroller, bottomTarget(geometry(scroller)))\n setAtBottom(computeAtBottom(geometry(scroller), options.threshold()))\n reportPosition()\n if (typeof ResizeObserver === 'undefined') return\n const observer = new ResizeObserver(pinAuto)\n observer.observe(scroller)\n if (content) observer.observe(content)\n onCleanup(() => observer.disconnect())\n })\n\n return {\n atBottom,\n onScroll,\n jump,\n bindScroller: (element: HTMLDivElement) => {\n scroller = element\n },\n bindContent: (element: HTMLDivElement) => {\n content = element\n },\n }\n}\n"],"mappings":"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"}
|
|
@@ -21,6 +21,8 @@ import { createScrollFollow } from './scroll-follow'
|
|
|
21
21
|
* than dragging the pane behind it — which in a desktop shell means the whole
|
|
22
22
|
* window moves.
|
|
23
23
|
*/
|
|
24
|
+
export type ConversationReadingPosition = Readonly<{ top: number; following: boolean }>
|
|
25
|
+
|
|
24
26
|
export type ConversationSurfaceProps = ComponentProps<'div'> & {
|
|
25
27
|
/** How far from the bottom still counts as "at the bottom", in px. */
|
|
26
28
|
threshold?: number
|
|
@@ -28,6 +30,10 @@ export type ConversationSurfaceProps = ComponentProps<'div'> & {
|
|
|
28
30
|
follow?: boolean
|
|
29
31
|
/** Optional conversation identity: changing it re-arms follow at the bottom. */
|
|
30
32
|
resetKey?: string
|
|
33
|
+
/** Host snapshot consumed on mount/identity reset, never on ordinary prop updates. */
|
|
34
|
+
initialReadingPosition?: ConversationReadingPosition
|
|
35
|
+
/** Actual follow intent and native offset; storage/identity remain host-owned. */
|
|
36
|
+
onReadingPositionChange?: (position: ConversationReadingPosition) => void
|
|
31
37
|
/** Rendered above the transcript when there is nothing in it. */
|
|
32
38
|
empty?: JSX.Element
|
|
33
39
|
/** A header that scrolls with the transcript, e.g. a day divider or a banner. */
|
|
@@ -40,6 +46,8 @@ export function ConversationSurface(props: ConversationSurfaceProps) {
|
|
|
40
46
|
'threshold',
|
|
41
47
|
'follow',
|
|
42
48
|
'resetKey',
|
|
49
|
+
'initialReadingPosition',
|
|
50
|
+
'onReadingPositionChange',
|
|
43
51
|
'empty',
|
|
44
52
|
'header',
|
|
45
53
|
'children',
|
|
@@ -54,6 +62,8 @@ export function ConversationSurface(props: ConversationSurfaceProps) {
|
|
|
54
62
|
enabled: () => local.follow !== false,
|
|
55
63
|
resetKey: () => local.resetKey,
|
|
56
64
|
threshold: () => local.threshold ?? 80,
|
|
65
|
+
initialPosition: () => local.initialReadingPosition,
|
|
66
|
+
onPositionChange: (position) => local.onReadingPositionChange?.(position),
|
|
57
67
|
})
|
|
58
68
|
const onScroll: JSX.EventHandlerUnion<HTMLDivElement, Event> = (event) => {
|
|
59
69
|
follow.onScroll()
|
|
@@ -5,7 +5,11 @@ export {
|
|
|
5
5
|
type ConversationAvatarKind,
|
|
6
6
|
type ConversationAvatarProps,
|
|
7
7
|
} from './conversation-avatar'
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
ConversationSurface,
|
|
10
|
+
type ConversationSurfaceProps,
|
|
11
|
+
type ConversationReadingPosition,
|
|
12
|
+
} from './conversation-surface'
|
|
9
13
|
export {
|
|
10
14
|
ComposerAttachmentButton,
|
|
11
15
|
ComposerHint,
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* Solid owner effects replace React refs/effects; native geometry and the
|
|
14
14
|
* content/viewport observer contract are retained. No session store is copied.
|
|
15
15
|
*/
|
|
16
|
-
import { createEffect, createSignal, onCleanup } from 'solid-js'
|
|
16
|
+
import { createEffect, createSignal, onCleanup, untrack } from 'solid-js'
|
|
17
|
+
import type { ConversationReadingPosition } from './conversation-surface'
|
|
17
18
|
import {
|
|
18
19
|
bottomTarget,
|
|
19
20
|
computeAtBottom,
|
|
@@ -33,6 +34,8 @@ export function createScrollFollow(options: {
|
|
|
33
34
|
enabled: () => boolean
|
|
34
35
|
resetKey: () => string | undefined
|
|
35
36
|
threshold: () => number
|
|
37
|
+
initialPosition?: () => ConversationReadingPosition | undefined
|
|
38
|
+
onPositionChange?: (position: ConversationReadingPosition) => void
|
|
36
39
|
}) {
|
|
37
40
|
const [atBottom, setAtBottom] = createSignal(true)
|
|
38
41
|
let scroller: HTMLDivElement | undefined
|
|
@@ -42,6 +45,16 @@ export function createScrollFollow(options: {
|
|
|
42
45
|
let lastWriteClientHeight = -1
|
|
43
46
|
let previousTop = -1
|
|
44
47
|
let lastScrollClientHeight = 0
|
|
48
|
+
const reportPosition = () => {
|
|
49
|
+
if (scroller)
|
|
50
|
+
untrack(() =>
|
|
51
|
+
options.onPositionChange?.({
|
|
52
|
+
top: scroller!.scrollTop,
|
|
53
|
+
following: options.enabled() && stick,
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
onCleanup(reportPosition)
|
|
45
58
|
|
|
46
59
|
const writePin = (element: HTMLDivElement, target: number) => {
|
|
47
60
|
// Instant writes keep the self-scroll reference synchronized. A smooth
|
|
@@ -67,9 +80,14 @@ export function createScrollFollow(options: {
|
|
|
67
80
|
lastWriteClientHeight = geom.clientHeight
|
|
68
81
|
}
|
|
69
82
|
setAtBottom(computeAtBottom(geometry(scroller), options.threshold()))
|
|
83
|
+
reportPosition()
|
|
70
84
|
}
|
|
71
85
|
const onScroll = () => {
|
|
72
|
-
if (!scroller
|
|
86
|
+
if (!scroller) return
|
|
87
|
+
if (!options.enabled()) {
|
|
88
|
+
reportPosition()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
73
91
|
const geom = geometry(scroller)
|
|
74
92
|
setAtBottom(computeAtBottom(geom, options.threshold()))
|
|
75
93
|
if (!isSelfScroll(geom.scrollTop, lastWriteTop)) {
|
|
@@ -90,12 +108,14 @@ export function createScrollFollow(options: {
|
|
|
90
108
|
// This baseline belongs only to scroll events, not observer callbacks.
|
|
91
109
|
// Advancing it during resize would erase the viewport-clamp evidence.
|
|
92
110
|
lastScrollClientHeight = geom.clientHeight
|
|
111
|
+
reportPosition()
|
|
93
112
|
}
|
|
94
113
|
const jump = () => {
|
|
95
114
|
if (!scroller || !options.enabled()) return
|
|
96
115
|
stick = true
|
|
97
116
|
writePin(scroller, bottomTarget(geometry(scroller)))
|
|
98
117
|
setAtBottom(true)
|
|
118
|
+
reportPosition()
|
|
99
119
|
// The jump control disappears at the bottom. Return its keyboard focus to
|
|
100
120
|
// the transcript instead of leaving the reader on a detached button.
|
|
101
121
|
scroller.focus({ preventScroll: true })
|
|
@@ -104,6 +124,8 @@ export function createScrollFollow(options: {
|
|
|
104
124
|
createEffect(() => {
|
|
105
125
|
options.resetKey()
|
|
106
126
|
const enabled = options.enabled()
|
|
127
|
+
// Capture before any reporting callback can replace the host snapshot.
|
|
128
|
+
const initial = untrack(() => options.initialPosition?.())
|
|
107
129
|
stick = true
|
|
108
130
|
lastWriteTop = -1
|
|
109
131
|
lastWriteClientHeight = -1
|
|
@@ -111,7 +133,16 @@ export function createScrollFollow(options: {
|
|
|
111
133
|
lastScrollClientHeight = 0
|
|
112
134
|
setAtBottom(true)
|
|
113
135
|
if (!enabled || !scroller) return
|
|
114
|
-
|
|
136
|
+
// Snapshot updates must not become another positioning owner. Consume the
|
|
137
|
+
// host's snapshot only at entry/reset, then native reader intent owns follow.
|
|
138
|
+
if (initial && Number.isFinite(initial.top) && initial.top >= 0) {
|
|
139
|
+
stick = initial.following
|
|
140
|
+
writePin(scroller, stick ? bottomTarget(geometry(scroller)) : initial.top)
|
|
141
|
+
previousTop = scroller.scrollTop
|
|
142
|
+
lastScrollClientHeight = scroller.clientHeight
|
|
143
|
+
} else writePin(scroller, bottomTarget(geometry(scroller)))
|
|
144
|
+
setAtBottom(computeAtBottom(geometry(scroller), options.threshold()))
|
|
145
|
+
reportPosition()
|
|
115
146
|
if (typeof ResizeObserver === 'undefined') return
|
|
116
147
|
const observer = new ResizeObserver(pinAuto)
|
|
117
148
|
observer.observe(scroller)
|
package/package.json
CHANGED
|
@@ -21,6 +21,8 @@ import { createScrollFollow } from './scroll-follow'
|
|
|
21
21
|
* than dragging the pane behind it — which in a desktop shell means the whole
|
|
22
22
|
* window moves.
|
|
23
23
|
*/
|
|
24
|
+
export type ConversationReadingPosition = Readonly<{ top: number; following: boolean }>
|
|
25
|
+
|
|
24
26
|
export type ConversationSurfaceProps = ComponentProps<'div'> & {
|
|
25
27
|
/** How far from the bottom still counts as "at the bottom", in px. */
|
|
26
28
|
threshold?: number
|
|
@@ -28,6 +30,10 @@ export type ConversationSurfaceProps = ComponentProps<'div'> & {
|
|
|
28
30
|
follow?: boolean
|
|
29
31
|
/** Optional conversation identity: changing it re-arms follow at the bottom. */
|
|
30
32
|
resetKey?: string
|
|
33
|
+
/** Host snapshot consumed on mount/identity reset, never on ordinary prop updates. */
|
|
34
|
+
initialReadingPosition?: ConversationReadingPosition
|
|
35
|
+
/** Actual follow intent and native offset; storage/identity remain host-owned. */
|
|
36
|
+
onReadingPositionChange?: (position: ConversationReadingPosition) => void
|
|
31
37
|
/** Rendered above the transcript when there is nothing in it. */
|
|
32
38
|
empty?: JSX.Element
|
|
33
39
|
/** A header that scrolls with the transcript, e.g. a day divider or a banner. */
|
|
@@ -40,6 +46,8 @@ export function ConversationSurface(props: ConversationSurfaceProps) {
|
|
|
40
46
|
'threshold',
|
|
41
47
|
'follow',
|
|
42
48
|
'resetKey',
|
|
49
|
+
'initialReadingPosition',
|
|
50
|
+
'onReadingPositionChange',
|
|
43
51
|
'empty',
|
|
44
52
|
'header',
|
|
45
53
|
'children',
|
|
@@ -54,6 +62,8 @@ export function ConversationSurface(props: ConversationSurfaceProps) {
|
|
|
54
62
|
enabled: () => local.follow !== false,
|
|
55
63
|
resetKey: () => local.resetKey,
|
|
56
64
|
threshold: () => local.threshold ?? 80,
|
|
65
|
+
initialPosition: () => local.initialReadingPosition,
|
|
66
|
+
onPositionChange: (position) => local.onReadingPositionChange?.(position),
|
|
57
67
|
})
|
|
58
68
|
const onScroll: JSX.EventHandlerUnion<HTMLDivElement, Event> = (event) => {
|
|
59
69
|
follow.onScroll()
|
|
@@ -5,7 +5,11 @@ export {
|
|
|
5
5
|
type ConversationAvatarKind,
|
|
6
6
|
type ConversationAvatarProps,
|
|
7
7
|
} from './conversation-avatar'
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
ConversationSurface,
|
|
10
|
+
type ConversationSurfaceProps,
|
|
11
|
+
type ConversationReadingPosition,
|
|
12
|
+
} from './conversation-surface'
|
|
9
13
|
export {
|
|
10
14
|
ComposerAttachmentButton,
|
|
11
15
|
ComposerHint,
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* Solid owner effects replace React refs/effects; native geometry and the
|
|
14
14
|
* content/viewport observer contract are retained. No session store is copied.
|
|
15
15
|
*/
|
|
16
|
-
import { createEffect, createSignal, onCleanup } from 'solid-js'
|
|
16
|
+
import { createEffect, createSignal, onCleanup, untrack } from 'solid-js'
|
|
17
|
+
import type { ConversationReadingPosition } from './conversation-surface'
|
|
17
18
|
import {
|
|
18
19
|
bottomTarget,
|
|
19
20
|
computeAtBottom,
|
|
@@ -33,6 +34,8 @@ export function createScrollFollow(options: {
|
|
|
33
34
|
enabled: () => boolean
|
|
34
35
|
resetKey: () => string | undefined
|
|
35
36
|
threshold: () => number
|
|
37
|
+
initialPosition?: () => ConversationReadingPosition | undefined
|
|
38
|
+
onPositionChange?: (position: ConversationReadingPosition) => void
|
|
36
39
|
}) {
|
|
37
40
|
const [atBottom, setAtBottom] = createSignal(true)
|
|
38
41
|
let scroller: HTMLDivElement | undefined
|
|
@@ -42,6 +45,16 @@ export function createScrollFollow(options: {
|
|
|
42
45
|
let lastWriteClientHeight = -1
|
|
43
46
|
let previousTop = -1
|
|
44
47
|
let lastScrollClientHeight = 0
|
|
48
|
+
const reportPosition = () => {
|
|
49
|
+
if (scroller)
|
|
50
|
+
untrack(() =>
|
|
51
|
+
options.onPositionChange?.({
|
|
52
|
+
top: scroller!.scrollTop,
|
|
53
|
+
following: options.enabled() && stick,
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
onCleanup(reportPosition)
|
|
45
58
|
|
|
46
59
|
const writePin = (element: HTMLDivElement, target: number) => {
|
|
47
60
|
// Instant writes keep the self-scroll reference synchronized. A smooth
|
|
@@ -67,9 +80,14 @@ export function createScrollFollow(options: {
|
|
|
67
80
|
lastWriteClientHeight = geom.clientHeight
|
|
68
81
|
}
|
|
69
82
|
setAtBottom(computeAtBottom(geometry(scroller), options.threshold()))
|
|
83
|
+
reportPosition()
|
|
70
84
|
}
|
|
71
85
|
const onScroll = () => {
|
|
72
|
-
if (!scroller
|
|
86
|
+
if (!scroller) return
|
|
87
|
+
if (!options.enabled()) {
|
|
88
|
+
reportPosition()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
73
91
|
const geom = geometry(scroller)
|
|
74
92
|
setAtBottom(computeAtBottom(geom, options.threshold()))
|
|
75
93
|
if (!isSelfScroll(geom.scrollTop, lastWriteTop)) {
|
|
@@ -90,12 +108,14 @@ export function createScrollFollow(options: {
|
|
|
90
108
|
// This baseline belongs only to scroll events, not observer callbacks.
|
|
91
109
|
// Advancing it during resize would erase the viewport-clamp evidence.
|
|
92
110
|
lastScrollClientHeight = geom.clientHeight
|
|
111
|
+
reportPosition()
|
|
93
112
|
}
|
|
94
113
|
const jump = () => {
|
|
95
114
|
if (!scroller || !options.enabled()) return
|
|
96
115
|
stick = true
|
|
97
116
|
writePin(scroller, bottomTarget(geometry(scroller)))
|
|
98
117
|
setAtBottom(true)
|
|
118
|
+
reportPosition()
|
|
99
119
|
// The jump control disappears at the bottom. Return its keyboard focus to
|
|
100
120
|
// the transcript instead of leaving the reader on a detached button.
|
|
101
121
|
scroller.focus({ preventScroll: true })
|
|
@@ -104,6 +124,8 @@ export function createScrollFollow(options: {
|
|
|
104
124
|
createEffect(() => {
|
|
105
125
|
options.resetKey()
|
|
106
126
|
const enabled = options.enabled()
|
|
127
|
+
// Capture before any reporting callback can replace the host snapshot.
|
|
128
|
+
const initial = untrack(() => options.initialPosition?.())
|
|
107
129
|
stick = true
|
|
108
130
|
lastWriteTop = -1
|
|
109
131
|
lastWriteClientHeight = -1
|
|
@@ -111,7 +133,16 @@ export function createScrollFollow(options: {
|
|
|
111
133
|
lastScrollClientHeight = 0
|
|
112
134
|
setAtBottom(true)
|
|
113
135
|
if (!enabled || !scroller) return
|
|
114
|
-
|
|
136
|
+
// Snapshot updates must not become another positioning owner. Consume the
|
|
137
|
+
// host's snapshot only at entry/reset, then native reader intent owns follow.
|
|
138
|
+
if (initial && Number.isFinite(initial.top) && initial.top >= 0) {
|
|
139
|
+
stick = initial.following
|
|
140
|
+
writePin(scroller, stick ? bottomTarget(geometry(scroller)) : initial.top)
|
|
141
|
+
previousTop = scroller.scrollTop
|
|
142
|
+
lastScrollClientHeight = scroller.clientHeight
|
|
143
|
+
} else writePin(scroller, bottomTarget(geometry(scroller)))
|
|
144
|
+
setAtBottom(computeAtBottom(geometry(scroller), options.threshold()))
|
|
145
|
+
reportPosition()
|
|
115
146
|
if (typeof ResizeObserver === 'undefined') return
|
|
116
147
|
const observer = new ResizeObserver(pinAuto)
|
|
117
148
|
observer.observe(scroller)
|