@adea-ai/ui 0.63.3 → 0.63.4
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 +22 -0
- package/dist/NOTICE +31 -0
- package/dist/components/conversation/conversation-surface.d.ts +5 -1
- package/dist/components/conversation/conversation-surface.d.ts.map +1 -1
- package/dist/components/conversation/conversation-surface.js +42 -62
- package/dist/components/conversation/conversation-surface.js.map +1 -1
- package/dist/components/conversation/scroll-follow-core.d.ts +181 -0
- package/dist/components/conversation/scroll-follow-core.d.ts.map +1 -0
- package/dist/components/conversation/scroll-follow-core.js +139 -0
- package/dist/components/conversation/scroll-follow-core.js.map +1 -0
- package/dist/components/conversation/scroll-follow.d.ts +13 -0
- package/dist/components/conversation/scroll-follow.d.ts.map +1 -0
- package/dist/components/conversation/scroll-follow.js +102 -0
- package/dist/components/conversation/scroll-follow.js.map +1 -0
- package/dist/r/conversation.json +10 -0
- package/dist/r/registry.json +10 -0
- package/dist/r/src/components/conversation/conversation-surface.tsx +45 -48
- package/dist/r/src/components/conversation/scroll-follow-core.ts +314 -0
- package/dist/r/src/components/conversation/scroll-follow.ts +133 -0
- package/package.json +1 -1
- package/registry.json +10 -0
- package/src/components/conversation/conversation-surface.tsx +45 -48
- package/src/components/conversation/scroll-follow-core.ts +314 -0
- package/src/components/conversation/scroll-follow.ts +133 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scroll-follow-core.js","names":[],"sources":["../../../src/components/conversation/scroll-follow-core.ts"],"sourcesContent":["/*\n * Substantially translated from KiroCrew website/src/hooks/virtualizer/FollowController.ts\n * (plain-scroller decision units), pinned at\n * 283e136c0f902e965a535a7c9548c57c7504fed0.\n * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\n * This product includes software developed at Amazon.com, Inc.\n * (https://www.amazon.com/).\n *\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 of the License at\n * http://www.apache.org/licenses/LICENSE-2.0\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n *\n * Pure plain-scroller decisions retained from FollowController; virtual row\n * measurement, persisted anchor resolution and restore settling are excluded.\n */\n/** Default distance (px) from the bottom within which `isAtBottom` is true. */\nexport const DEFAULT_BOTTOM_THRESHOLD = 100\n\n/**\n * Tolerance (px) for treating a scroll position as \"the same\" as a value we\n * wrote programmatically. Covers sub-pixel rounding and 1px momentum overshoot.\n * Must stay small so a deliberate user scroll of even a few px is still seen as\n * a user scroll.\n */\nexport const SELF_SCROLL_EPSILON = 2\n\n/**\n * \"At the bottom\" tolerance (px) for deciding whether an auto-pin still has\n * work to do. A flat 0.5 is UNDER one device pixel at fractional device-pixel\n * ratios (0.67 CSS px at 150% zoom, 0.8 at 125%): the scroller's resting\n * maximum scrollTop lands on a fractional value, so `|scrollTop - target|`\n * stays just above 0.5 even when the viewport is visually pinned to the\n * bottom — making the pin re-fire on every ResizeObserver tick. Scaling the\n * epsilon to the device pixel (never below 1 CSS px) absorbs that fractional\n * resting error. `devicePixelRatio` is read defensively so a jsdom / SSR\n * environment that leaves it undefined falls back to 1 (→ 1.5px).\n */\nexport function atBottomEpsilon(): number {\n const dpr =\n typeof window !== 'undefined' &&\n typeof window.devicePixelRatio === 'number' &&\n window.devicePixelRatio > 0\n ? window.devicePixelRatio\n : 1\n return Math.max(1, 1 / dpr + 0.5)\n}\n\n/** Live scroll geometry snapshot read from the scroller element. */\nexport interface ScrollGeom {\n scrollTop: number\n scrollHeight: number\n clientHeight: number\n}\n\n/** scrollTop that places the viewport exactly at the bottom (never negative). */\nexport function bottomTarget(geom: ScrollGeom): number {\n return Math.max(0, geom.scrollHeight - geom.clientHeight)\n}\n\n/** Pixels between the current scroll position and the bottom. */\nexport function distanceFromBottom(geom: ScrollGeom): number {\n return geom.scrollHeight - geom.scrollTop - geom.clientHeight\n}\n\n/** Whether the scroller is within `threshold` px of the bottom. */\nexport function computeAtBottom(geom: ScrollGeom, threshold: number): boolean {\n return distanceFromBottom(geom) <= threshold\n}\n\n/**\n * Recognise a `scroll` event caused by our own programmatic write rather than\n * by the user. `lastWriteTop < 0` means \"we have not written this session\", so\n * any scroll is treated as the user's.\n */\nexport function isSelfScroll(\n scrollTop: number,\n lastWriteTop: number,\n epsilon: number = SELF_SCROLL_EPSILON\n): boolean {\n return lastWriteTop >= 0 && Math.abs(scrollTop - lastWriteTop) <= epsilon\n}\n\n/**\n * Distance (px) from the true bottom within which a user scroll RE-ENGAGES\n * follow. Deliberately much tighter than DEFAULT_BOTTOM_THRESHOLD: that 100px\n * band drives the jump-to-bottom pill's visibility, and reusing it for follow\n * meant a deliberate 3-99px scroll-up kept `stick` armed — the next content\n * change then yanked the reader back to the bottom. Re-engaging only when the\n * user has returned essentially to the bottom keeps \"scrolled up to read\"\n * positions belonging to the user.\n */\nexport const FOLLOW_REENGAGE_PX = 16\n\n/**\n * Direction-aware `stick` decision for a *user-initiated* scroll (self-scrolls\n * filtered out by the caller via `isSelfScroll`):\n *\n * 1. At the true bottom (within the DPR-aware epsilon) → follow. This also\n * absorbs the layout engine's clamp: a mid-stream content SHRINK drops\n * scrollTop (which reads as an upward move) but lands exactly at the new\n * bottom — releasing there froze streaming follow for the rest of the\n * turn.\n * 2. Any other upward move → release, regardless of distance from the\n * bottom. The scroll position now belongs to the user; only returning to\n * the bottom (3) re-engages.\n * 3. A genuine DOWNWARD move that arrives within FOLLOW_REENGAGE_PX of the\n * bottom → re-engage. A neutral event inside the band does NOT: that is\n * how content collapsing under a still reader re-armed follow.\n * 4. Otherwise (downward/neutral, still away from the bottom) → keep the\n * previous state.\n *\n * `prevScrollTop < 0` means \"no prior observation this session\". Direction is\n * unknowable then, so the decision is position-only and CONSERVATIVE: follow\n * only within the re-engage band. Keeping a stale `stick` on an unattributable\n * away-from-bottom scroll is how a reader gets yanked.\n */\nexport function resolveUserScrollStick(args: {\n stick: boolean\n followOutput: boolean\n scrollTop: number\n prevScrollTop: number\n geom: ScrollGeom\n /** Change in the scroller's own height since the previous scroll event.\n *\n * Positive = the viewport GREW (the composer shrank under a deletion, the\n * keyboard closed). That growth lowers the maximum scrollTop, so the engine\n * clamps any reader parked closer to the bottom than the growth — with no\n * application write anywhere. The clamp then arrives here as an ordinary\n * scroll event sitting at distance ~0, which rule 1 below used to read as\n * \"the reader came back to the bottom\" and re-arm follow for someone who\n * never touched the scroller. The next turn to start then took them to the\n * end. Rule 1 exists to absorb a CONTENT-shrink clamp mid-stream, and content\n * shrink moves `scrollHeight`, not `clientHeight` — so the two are\n * distinguishable, and this is the delta that tells them apart. */\n viewportGrowth?: number\n}): boolean {\n const { stick, followOutput, scrollTop, prevScrollTop, geom } = args\n if (!followOutput) return false\n const dist = distanceFromBottom(geom)\n // A viewport growth large enough to explain the reader's arrival at the bottom\n // is the engine's clamp, not the reader. Leave `stick` exactly as it was.\n // A native clamp only ever LOWERS scrollTop, so a downward move concurrent with\n // the growth is the user's own and must still re-engage follow. Without the\n // direction term a reader who deliberately scrolls down while the keyboard\n // closes is refused their re-engagement.\n const clampedByViewport =\n (args.viewportGrowth ?? 0) > atBottomEpsilon() && scrollTop <= prevScrollTop + atBottomEpsilon()\n if (dist <= atBottomEpsilon()) return clampedByViewport ? stick : true\n if (prevScrollTop < 0) return dist <= FOLLOW_REENGAGE_PX\n if (scrollTop < prevScrollTop - 0.5) return false\n // Re-engagement requires a genuine DOWNWARD move, not merely a non-upward\n // event that finds the reader inside the band. A neutral event (identical\n // scrollTop -- the tail of an iOS momentum run, or any scroll fired while the\n // reader is at rest) used to satisfy this, so a reader sitting mid-transcript\n // could be re-armed by CONTENT rather than by their own hand: when rows\n // outside the window reprice smaller than their estimates, the remaining\n // content collapses under them and the bottom band arrives at the reader\n // instead of the reader arriving at it. Follow re-engaged, and the next pin\n // took them to the end -- reported as scrolling along and suddenly landing at\n // the bottom. Distance alone cannot tell those apart; the direction of the\n // reader's own move can.\n if (scrollTop > prevScrollTop + 0.5 && dist <= FOLLOW_REENGAGE_PX) return true\n return stick\n}\n\n/** Result of an automatic (RO / append) pin evaluation. */\nexport interface AutoPinResult {\n /** Whether to write `el.scrollTop = target` now. */\n pin: boolean\n /** Next value for `stick` (released to false if the user scrolled up). */\n stick: boolean\n /** The bottom scrollTop the caller should write when `pin` is true. */\n target: number\n}\n\n/**\n * Decide an automatic pin at the moment content changed (RO callback / append\n * layout effect / its follow-up rAF), reading LIVE geometry.\n *\n * - Not sticking → never pin.\n * - Sticking but the user has scrolled up since our last write\n * (`scrollTop < lastWriteTop - epsilon`) → release stick, don't pin.\n * This is the synchronous, race-proof guard.\n * - Otherwise → pin to the bottom (only actually move if not already there).\n *\n * `lastWriteTop < 0` disables the scroll-up guard (used right after a slot\n * switch, before we have written anything this session).\n *\n * `viewportShrink` (px, default 0) is how much the SCROLLER'S OWN BOX has\n * shrunk since that reference was recorded — chrome mounting below the\n * transcript (a queue band, an attachment strip, a tip card), often\n * spring-animated over several frames. Our own shrink inflates\n * `distanceFromBottom` with no user input, so without this allowance the\n * distance guard reads it as \"meaningfully away from the bottom\". Paired with\n * a content SHRINK in the same commit window — a tail-row remount clamping\n * scrollTop below `lastWriteTop` — that produced a full user-scroll-up\n * signature out of two of our own layout changes: follow released mid\n * animation and the content settled a card-height low. Judging the distance\n * against the box we were last a bottom FOR keeps the guard measuring the\n * user's move rather than our own. Only the shrink's own pixels are forgiven,\n * so a genuine drag inside the same tick still releases.\n */\nexport function evaluateAutoPin(args: {\n stick: boolean\n geom: ScrollGeom\n lastWriteTop: number\n epsilon?: number\n viewportShrink?: number\n /** Is a turn actually producing output right now?\n *\n * Follow means \"keep me at the end of a LIVE turn\". With nothing running there\n * is no output to follow, so a reader sitting above the bottom is not\n * following — and an automatic pin there is a yank with no cause, reported\n * from a phone as the transcript springing back after scrolling up about a\n * hundred pixels with nothing streaming.\n *\n * Defaults to `true` = assume a run is live, which keeps the behaviour of a\n * caller that has no run signal to give (the app-SDK chat surface). The chat\n * transcript passes the real thing. */\n runActive?: boolean\n /** Is an anchor restore currently OWNING the scroll position?\n *\n * A restore places the reader at an absolute offset and then re-lands it as\n * measurements arrive. An automatic pin during that window is a second owner\n * writing the same scroller, and the two fight: captured on a phone as\n * `WRITE autopin 3091->4245` answered by `WRITE settle 4245->3091`, twice in\n * 120ms, 1,154px each way. The settle won those rounds, but only because its\n * budget had not expired yet -- which is why the same switch sometimes landed\n * at the bottom and sometimes did not.\n *\n * Released rather than merely skipped, for the reason the idle branch below\n * gives: skipping leaves follow armed, so the next growth yanks the reader\n * from wherever the restore just put them. */\n restoreGate?: boolean\n /** Has hardware input -- wheel / touch / pointer / a scrolling key -- reached\n * the scroller since we last placed the reader at the bottom?\n *\n * False means the reader has done nothing, so a gap that opened while they\n * rest on our last write was opened by content -- a row settling from its\n * estimate, a code-block stand-in swapping for the highlighted block, the\n * top spacer repricing -- and is a gap WE owe them, not one they chose.\n *\n * The idle rule below cannot tell those apart from distance alone, and it\n * errs toward release, which was invisible wherever the browser's native\n * scroll anchoring quietly carried the reader through the growth. WebKit has\n * no scroll anchoring at all, so on an iPhone every entry into an idle\n * session paid the whole post-pin reprice as a displacement and then had\n * follow released on top of it: the transcript opened a viewport or more\n * above the end with nothing streaming to bring it back.\n *\n * Defaults to `true` = assume the reader may have moved, which is the\n * release-leaning legacy behaviour for a caller that has no input signal. */\n readerMovedSinceWrite?: boolean\n}): AutoPinResult {\n const { stick, geom, lastWriteTop } = args\n const epsilon = args.epsilon ?? SELF_SCROLL_EPSILON\n const viewportShrink = Math.max(0, args.viewportShrink ?? 0)\n const runActive = args.runActive ?? true\n const readerMovedSinceWrite = args.readerMovedSinceWrite ?? true\n const target = bottomTarget(geom)\n if (args.restoreGate) return { pin: false, stick: false, target }\n if (!stick) return { pin: false, stick: false, target }\n // The reader is resting exactly where we last put them and has given no input\n // since, so any gap is content settling under them -- carry them back, live\n // turn or not. Both conditions are load-bearing. Position alone would read our\n // own write as consent for a reader who wheeled up and happened to stop on it;\n // input alone would drag back a programmatic reveal -- a search hit, a pinned\n // prompt, find-in-page -- whose scroll event has not dispatched yet when a\n // height commit lands, since none of those touch the scroller's input\n // listeners. A reveal moves scrollTop off our write; a reprice does not.\n const restingOnOurWrite = lastWriteTop >= 0 && Math.abs(geom.scrollTop - lastWriteTop) <= epsilon\n if (!readerMovedSinceWrite && restingOnOurWrite) {\n return { pin: distanceFromBottom(geom) > atBottomEpsilon(), stick: true, target }\n }\n // Idle: release rather than merely skip the pin. Skipping would leave follow\n // armed, so the next turn to start would yank this reader to the bottom from\n // wherever they had settled — the same defect one event later.\n //\n // But distance alone cannot say WHO opened that gap, and the two causes want\n // opposite answers: a reader who scrolled up should be released, while a\n // reader the CONTENT moved away from should be carried back.\n if (!runActive && distanceFromBottom(geom) > atBottomEpsilon()) {\n // Released. The question this branch cannot answer from geometry -- did the\n // reader open this gap, or did the content -- is answered ABOVE by\n // `readerMovedSinceWrite`: reaching here means input or an unexplained\n // scroll has been seen since our last positioning, so a reader who is now\n // above the bottom while nothing runs is one who left it. Reading our own\n // last write as consent would be an automatic action authorizing itself;\n // the only evidence that the reader never moved is the absence of input,\n // and that is what the branch above requires.\n return { pin: false, stick: false, target }\n }\n // Release only on a genuine user scroll-UP: scrollTop dropped below our last\n // write AND we are now meaningfully away from the bottom. A pure content\n // SHRINK mid-stream (a partial markdown line re-parsing, a code fence opening\n // and reclassifying the block) clamps scrollTop below lastWriteTop too, but\n // leaves us still AT the new bottom (distance ~0). Without the distance guard\n // that shrink looked like a scroll-up and froze streaming follow — once\n // released, nothing re-armed stick for the rest of the response.\n if (\n lastWriteTop >= 0 &&\n geom.scrollTop < lastWriteTop - epsilon &&\n distanceFromBottom(geom) - viewportShrink > epsilon\n ) {\n return { pin: false, stick: false, target }\n }\n return { pin: Math.abs(geom.scrollTop - target) > atBottomEpsilon(), stick: true, target }\n}\n"],"mappings":";;;;;;;;;;;AA2CA,SAAgB,kBAA0B;CACxC,MAAM,MACJ,OAAO,WAAW,eAClB,OAAO,OAAO,qBAAqB,YACnC,OAAO,mBAAmB,IACtB,OAAO,mBACP;CACN,OAAO,KAAK,IAAI,GAAG,IAAI,MAAM,EAAG;AAClC;;AAUA,SAAgB,aAAa,MAA0B;CACrD,OAAO,KAAK,IAAI,GAAG,KAAK,eAAe,KAAK,YAAY;AAC1D;;AAGA,SAAgB,mBAAmB,MAA0B;CAC3D,OAAO,KAAK,eAAe,KAAK,YAAY,KAAK;AACnD;;AAGA,SAAgB,gBAAgB,MAAkB,WAA4B;CAC5E,OAAO,mBAAmB,IAAI,KAAK;AACrC;;;;;;AAOA,SAAgB,aACd,WACA,cACA,UAAA,GACS;CACT,OAAO,gBAAgB,KAAK,KAAK,IAAI,YAAY,YAAY,KAAK;AACpE;;;;;;;;;;;;;;;;;;;;;;;;AAoCA,SAAgB,uBAAuB,MAmB3B;CACV,MAAM,EAAE,OAAO,cAAc,WAAW,eAAe,SAAS;CAChE,IAAI,CAAC,cAAc,OAAO;CAC1B,MAAM,OAAO,mBAAmB,IAAI;CAOpC,MAAM,qBACH,KAAK,kBAAkB,KAAK,gBAAgB,KAAK,aAAa,gBAAgB,gBAAgB;CACjG,IAAI,QAAQ,gBAAgB,GAAG,OAAO,oBAAoB,QAAQ;CAClE,IAAI,gBAAgB,GAAG,OAAO,QAAA;CAC9B,IAAI,YAAY,gBAAgB,IAAK,OAAO;CAY5C,IAAI,YAAY,gBAAgB,MAAO,QAAA,IAA4B,OAAO;CAC1E,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuCA,SAAgB,gBAAgB,MAmDd;CAChB,MAAM,EAAE,OAAO,MAAM,iBAAiB;CACtC,MAAM,UAAU,KAAK,WAAA;CACrB,MAAM,iBAAiB,KAAK,IAAI,GAAG,KAAK,kBAAkB,CAAC;CAC3D,MAAM,YAAY,KAAK,aAAa;CACpC,MAAM,wBAAwB,KAAK,yBAAyB;CAC5D,MAAM,SAAS,aAAa,IAAI;CAChC,IAAI,KAAK,aAAa,OAAO;EAAE,KAAK;EAAO,OAAO;EAAO;CAAO;CAChE,IAAI,CAAC,OAAO,OAAO;EAAE,KAAK;EAAO,OAAO;EAAO;CAAO;CAStD,MAAM,oBAAoB,gBAAgB,KAAK,KAAK,IAAI,KAAK,YAAY,YAAY,KAAK;CAC1F,IAAI,CAAC,yBAAyB,mBAC5B,OAAO;EAAE,KAAK,mBAAmB,IAAI,IAAI,gBAAgB;EAAG,OAAO;EAAM;CAAO;CASlF,IAAI,CAAC,aAAa,mBAAmB,IAAI,IAAI,gBAAgB,GAS3D,OAAO;EAAE,KAAK;EAAO,OAAO;EAAO;CAAO;CAS5C,IACE,gBAAgB,KAChB,KAAK,YAAY,eAAe,WAChC,mBAAmB,IAAI,IAAI,iBAAiB,SAE5C,OAAO;EAAE,KAAK;EAAO,OAAO;EAAO;CAAO;CAE5C,OAAO;EAAE,KAAK,KAAK,IAAI,KAAK,YAAY,MAAM,IAAI,gBAAgB;EAAG,OAAO;EAAM;CAAO;AAC3F"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Internal plain-scroller binding; host-owned restoration can disable follow. */
|
|
2
|
+
export declare function createScrollFollow(options: {
|
|
3
|
+
enabled: () => boolean;
|
|
4
|
+
resetKey: () => string | undefined;
|
|
5
|
+
threshold: () => number;
|
|
6
|
+
}): {
|
|
7
|
+
atBottom: import("solid-js").Accessor<boolean>;
|
|
8
|
+
onScroll: () => void;
|
|
9
|
+
jump: () => void;
|
|
10
|
+
bindScroller: (element: HTMLDivElement) => void;
|
|
11
|
+
bindContent: (element: HTMLDivElement) => void;
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=scroll-follow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scroll-follow.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/scroll-follow.ts"],"names":[],"mappings":"AA8BA,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;CACxB;;;;4BA0F2B,cAAc;2BAGf,cAAc;EAIxC"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { bottomTarget, computeAtBottom, evaluateAutoPin, isSelfScroll, resolveUserScrollStick } from "./scroll-follow-core.js";
|
|
2
|
+
import { createEffect, createSignal, onCleanup } from "solid-js";
|
|
3
|
+
//#region src/components/conversation/scroll-follow.ts
|
|
4
|
+
var geometry = (element) => ({
|
|
5
|
+
scrollTop: element.scrollTop,
|
|
6
|
+
scrollHeight: element.scrollHeight,
|
|
7
|
+
clientHeight: element.clientHeight
|
|
8
|
+
});
|
|
9
|
+
/** Internal plain-scroller binding; host-owned restoration can disable follow. */
|
|
10
|
+
function createScrollFollow(options) {
|
|
11
|
+
const [atBottom, setAtBottom] = createSignal(true);
|
|
12
|
+
let scroller;
|
|
13
|
+
let content;
|
|
14
|
+
let stick = true;
|
|
15
|
+
let lastWriteTop = -1;
|
|
16
|
+
let lastWriteClientHeight = -1;
|
|
17
|
+
let previousTop = -1;
|
|
18
|
+
let lastScrollClientHeight = 0;
|
|
19
|
+
const writePin = (element, target) => {
|
|
20
|
+
element.scrollTop = target;
|
|
21
|
+
lastWriteTop = target;
|
|
22
|
+
lastWriteClientHeight = element.clientHeight;
|
|
23
|
+
previousTop = target;
|
|
24
|
+
};
|
|
25
|
+
const pinAuto = () => {
|
|
26
|
+
if (!scroller || !options.enabled()) return;
|
|
27
|
+
const geom = geometry(scroller);
|
|
28
|
+
const result = evaluateAutoPin({
|
|
29
|
+
stick,
|
|
30
|
+
geom,
|
|
31
|
+
lastWriteTop,
|
|
32
|
+
viewportShrink: lastWriteClientHeight >= 0 ? lastWriteClientHeight - geom.clientHeight : 0
|
|
33
|
+
});
|
|
34
|
+
stick = result.stick;
|
|
35
|
+
if (result.pin) writePin(scroller, result.target);
|
|
36
|
+
else if (result.stick) {
|
|
37
|
+
lastWriteTop = result.target;
|
|
38
|
+
lastWriteClientHeight = geom.clientHeight;
|
|
39
|
+
}
|
|
40
|
+
setAtBottom(computeAtBottom(geometry(scroller), options.threshold()));
|
|
41
|
+
};
|
|
42
|
+
const onScroll = () => {
|
|
43
|
+
if (!scroller || !options.enabled()) return;
|
|
44
|
+
const geom = geometry(scroller);
|
|
45
|
+
setAtBottom(computeAtBottom(geom, options.threshold()));
|
|
46
|
+
if (!isSelfScroll(geom.scrollTop, lastWriteTop)) {
|
|
47
|
+
stick = resolveUserScrollStick({
|
|
48
|
+
stick,
|
|
49
|
+
followOutput: true,
|
|
50
|
+
scrollTop: geom.scrollTop,
|
|
51
|
+
prevScrollTop: previousTop,
|
|
52
|
+
geom,
|
|
53
|
+
viewportGrowth: lastScrollClientHeight > 0 ? geom.clientHeight - lastScrollClientHeight : 0
|
|
54
|
+
});
|
|
55
|
+
if (!stick) {
|
|
56
|
+
lastWriteTop = -1;
|
|
57
|
+
lastWriteClientHeight = -1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
previousTop = geom.scrollTop;
|
|
61
|
+
lastScrollClientHeight = geom.clientHeight;
|
|
62
|
+
};
|
|
63
|
+
const jump = () => {
|
|
64
|
+
if (!scroller || !options.enabled()) return;
|
|
65
|
+
stick = true;
|
|
66
|
+
writePin(scroller, bottomTarget(geometry(scroller)));
|
|
67
|
+
setAtBottom(true);
|
|
68
|
+
scroller.focus({ preventScroll: true });
|
|
69
|
+
};
|
|
70
|
+
createEffect(() => {
|
|
71
|
+
options.resetKey();
|
|
72
|
+
const enabled = options.enabled();
|
|
73
|
+
stick = true;
|
|
74
|
+
lastWriteTop = -1;
|
|
75
|
+
lastWriteClientHeight = -1;
|
|
76
|
+
previousTop = -1;
|
|
77
|
+
lastScrollClientHeight = 0;
|
|
78
|
+
setAtBottom(true);
|
|
79
|
+
if (!enabled || !scroller) return;
|
|
80
|
+
writePin(scroller, bottomTarget(geometry(scroller)));
|
|
81
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
82
|
+
const observer = new ResizeObserver(pinAuto);
|
|
83
|
+
observer.observe(scroller);
|
|
84
|
+
if (content) observer.observe(content);
|
|
85
|
+
onCleanup(() => observer.disconnect());
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
atBottom,
|
|
89
|
+
onScroll,
|
|
90
|
+
jump,
|
|
91
|
+
bindScroller: (element) => {
|
|
92
|
+
scroller = element;
|
|
93
|
+
},
|
|
94
|
+
bindContent: (element) => {
|
|
95
|
+
content = element;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
export { createScrollFollow };
|
|
101
|
+
|
|
102
|
+
//# sourceMappingURL=scroll-follow.js.map
|
|
@@ -0,0 +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 || !options.enabled()) return\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 }\n const jump = () => {\n if (!scroller || !options.enabled()) return\n stick = true\n writePin(scroller, bottomTarget(geometry(scroller)))\n setAtBottom(true)\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 stick = true\n lastWriteTop = -1\n lastWriteClientHeight = -1\n previousTop = -1\n lastScrollClientHeight = 0\n setAtBottom(true)\n if (!enabled || !scroller) return\n writePin(scroller, bottomTarget(geometry(scroller)))\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":";;;AAwBA,IAAM,YAAY,aAA6B;CAC7C,WAAW,QAAQ;CACnB,cAAc,QAAQ;CACtB,cAAc,QAAQ;AACxB;;AAGA,SAAgB,mBAAmB,SAIhC;CACD,MAAM,CAAC,UAAU,eAAe,aAAa,IAAI;CACjD,IAAI;CACJ,IAAI;CACJ,IAAI,QAAQ;CACZ,IAAI,eAAe;CACnB,IAAI,wBAAwB;CAC5B,IAAI,cAAc;CAClB,IAAI,yBAAyB;CAE7B,MAAM,YAAY,SAAyB,WAAmB;EAG5D,QAAQ,YAAY;EACpB,eAAe;EACf,wBAAwB,QAAQ;EAChC,cAAc;CAChB;CACA,MAAM,gBAAgB;EACpB,IAAI,CAAC,YAAY,CAAC,QAAQ,QAAQ,GAAG;EACrC,MAAM,OAAO,SAAS,QAAQ;EAC9B,MAAM,SAAS,gBAAgB;GAC7B;GACA;GACA;GACA,gBAAgB,yBAAyB,IAAI,wBAAwB,KAAK,eAAe;EAC3F,CAAC;EACD,QAAQ,OAAO;EACf,IAAI,OAAO,KAAK,SAAS,UAAU,OAAO,MAAM;OAC3C,IAAI,OAAO,OAAO;GACrB,eAAe,OAAO;GACtB,wBAAwB,KAAK;EAC/B;EACA,YAAY,gBAAgB,SAAS,QAAQ,GAAG,QAAQ,UAAU,CAAC,CAAC;CACtE;CACA,MAAM,iBAAiB;EACrB,IAAI,CAAC,YAAY,CAAC,QAAQ,QAAQ,GAAG;EACrC,MAAM,OAAO,SAAS,QAAQ;EAC9B,YAAY,gBAAgB,MAAM,QAAQ,UAAU,CAAC,CAAC;EACtD,IAAI,CAAC,aAAa,KAAK,WAAW,YAAY,GAAG;GAC/C,QAAQ,uBAAuB;IAC7B;IACA,cAAc;IACd,WAAW,KAAK;IAChB,eAAe;IACf;IACA,gBAAgB,yBAAyB,IAAI,KAAK,eAAe,yBAAyB;GAC5F,CAAC;GACD,IAAI,CAAC,OAAO;IACV,eAAe;IACf,wBAAwB;GAC1B;EACF;EACA,cAAc,KAAK;EAGnB,yBAAyB,KAAK;CAChC;CACA,MAAM,aAAa;EACjB,IAAI,CAAC,YAAY,CAAC,QAAQ,QAAQ,GAAG;EACrC,QAAQ;EACR,SAAS,UAAU,aAAa,SAAS,QAAQ,CAAC,CAAC;EACnD,YAAY,IAAI;EAGhB,SAAS,MAAM,EAAE,eAAe,KAAK,CAAC;CACxC;CAEA,mBAAmB;EACjB,QAAQ,SAAS;EACjB,MAAM,UAAU,QAAQ,QAAQ;EAChC,QAAQ;EACR,eAAe;EACf,wBAAwB;EACxB,cAAc;EACd,yBAAyB;EACzB,YAAY,IAAI;EAChB,IAAI,CAAC,WAAW,CAAC,UAAU;EAC3B,SAAS,UAAU,aAAa,SAAS,QAAQ,CAAC,CAAC;EACnD,IAAI,OAAO,mBAAmB,aAAa;EAC3C,MAAM,WAAW,IAAI,eAAe,OAAO;EAC3C,SAAS,QAAQ,QAAQ;EACzB,IAAI,SAAS,SAAS,QAAQ,OAAO;EACrC,gBAAgB,SAAS,WAAW,CAAC;CACvC,CAAC;CAED,OAAO;EACL;EACA;EACA;EACA,eAAe,YAA4B;GACzC,WAAW;EACb;EACA,cAAc,YAA4B;GACxC,UAAU;EACZ;CACF;AACF"}
|
package/dist/r/conversation.json
CHANGED
|
@@ -45,6 +45,16 @@
|
|
|
45
45
|
"type": "registry:ui",
|
|
46
46
|
"target": "components/conversation/message-row.tsx"
|
|
47
47
|
},
|
|
48
|
+
{
|
|
49
|
+
"path": "src/components/conversation/scroll-follow-core.ts",
|
|
50
|
+
"type": "registry:ui",
|
|
51
|
+
"target": "components/conversation/scroll-follow-core.ts"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "src/components/conversation/scroll-follow.ts",
|
|
55
|
+
"type": "registry:ui",
|
|
56
|
+
"target": "components/conversation/scroll-follow.ts"
|
|
57
|
+
},
|
|
48
58
|
{
|
|
49
59
|
"path": "src/components/conversation/thread-panel.tsx",
|
|
50
60
|
"type": "registry:ui",
|
package/dist/r/registry.json
CHANGED
|
@@ -1818,6 +1818,16 @@
|
|
|
1818
1818
|
"type": "registry:ui",
|
|
1819
1819
|
"target": "components/conversation/message-row.tsx"
|
|
1820
1820
|
},
|
|
1821
|
+
{
|
|
1822
|
+
"path": "src/components/conversation/scroll-follow-core.ts",
|
|
1823
|
+
"type": "registry:ui",
|
|
1824
|
+
"target": "components/conversation/scroll-follow-core.ts"
|
|
1825
|
+
},
|
|
1826
|
+
{
|
|
1827
|
+
"path": "src/components/conversation/scroll-follow.ts",
|
|
1828
|
+
"type": "registry:ui",
|
|
1829
|
+
"target": "components/conversation/scroll-follow.ts"
|
|
1830
|
+
},
|
|
1821
1831
|
{
|
|
1822
1832
|
"path": "src/components/conversation/thread-panel.tsx",
|
|
1823
1833
|
"type": "registry:ui",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { ArrowDown } from 'lucide-solid'
|
|
2
2
|
import type { ComponentProps, JSX } from 'solid-js'
|
|
3
|
-
import { Show,
|
|
3
|
+
import { Show, splitProps } from 'solid-js'
|
|
4
4
|
import { cn } from '../../lib/utils'
|
|
5
5
|
import { Button } from '../ui/button/button'
|
|
6
|
+
import { createScrollFollow } from './scroll-follow'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* ConversationSurface.
|
|
@@ -15,7 +16,7 @@ import { Button } from '../ui/button/button'
|
|
|
15
16
|
* to the end by an arriving message, and that is the single most common way a
|
|
16
17
|
* chat surface is made unusable.
|
|
17
18
|
* - **Offer a way back.** Once the reader is not at the bottom, a control appears
|
|
18
|
-
* that returns them
|
|
19
|
+
* that returns them. Pixel distance cannot prove an unread message count.
|
|
19
20
|
* - **`overscroll-contain`.** The transcript scrolls to its end and stops, rather
|
|
20
21
|
* than dragging the pane behind it — which in a desktop shell means the whole
|
|
21
22
|
* window moves.
|
|
@@ -23,48 +24,42 @@ import { Button } from '../ui/button/button'
|
|
|
23
24
|
export type ConversationSurfaceProps = ComponentProps<'div'> & {
|
|
24
25
|
/** How far from the bottom still counts as "at the bottom", in px. */
|
|
25
26
|
threshold?: number
|
|
27
|
+
/** Disable automatic positioning while the host owns scroll restoration. */
|
|
28
|
+
follow?: boolean
|
|
29
|
+
/** Optional conversation identity: changing it re-arms follow at the bottom. */
|
|
30
|
+
resetKey?: string
|
|
26
31
|
/** Rendered above the transcript when there is nothing in it. */
|
|
27
32
|
empty?: JSX.Element
|
|
28
33
|
/** A header that scrolls with the transcript, e.g. a day divider or a banner. */
|
|
29
34
|
header?: JSX.Element
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
/** How far the reader is from the end of the transcript, in px. */
|
|
33
|
-
function distanceFromBottom(element: HTMLDivElement): number {
|
|
34
|
-
return element.scrollHeight - element.scrollTop - element.clientHeight
|
|
35
|
-
}
|
|
36
|
-
|
|
37
37
|
export function ConversationSurface(props: ConversationSurfaceProps) {
|
|
38
|
-
const [local, rest] = splitProps(props, [
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
if (atBottom()) scroller?.scrollTo({ top: scroller.scrollHeight })
|
|
64
|
-
else onScroll()
|
|
65
|
-
})
|
|
66
|
-
mutation.observe(scroller, { childList: true, subtree: true })
|
|
67
|
-
onCleanup(() => mutation.disconnect())
|
|
38
|
+
const [local, rest] = splitProps(props, [
|
|
39
|
+
'class',
|
|
40
|
+
'threshold',
|
|
41
|
+
'follow',
|
|
42
|
+
'resetKey',
|
|
43
|
+
'empty',
|
|
44
|
+
'header',
|
|
45
|
+
'children',
|
|
46
|
+
'onScroll',
|
|
47
|
+
'ref',
|
|
48
|
+
'tabindex',
|
|
49
|
+
])
|
|
50
|
+
// Kobalte provides the control's semantics; transcript follow is a native
|
|
51
|
+
// geometry contract. KiroCrew's plain-scroller controller handles streaming,
|
|
52
|
+
// content collapse and pane resizing without treating them as user consent.
|
|
53
|
+
const follow = createScrollFollow({
|
|
54
|
+
enabled: () => local.follow !== false,
|
|
55
|
+
resetKey: () => local.resetKey,
|
|
56
|
+
threshold: () => local.threshold ?? 80,
|
|
57
|
+
})
|
|
58
|
+
const onScroll: JSX.EventHandlerUnion<HTMLDivElement, Event> = (event) => {
|
|
59
|
+
follow.onScroll()
|
|
60
|
+
const handler = local.onScroll
|
|
61
|
+
if (typeof handler === 'function') handler(event)
|
|
62
|
+
else if (handler) handler[0](handler[1], event)
|
|
68
63
|
}
|
|
69
64
|
|
|
70
65
|
return (
|
|
@@ -74,31 +69,33 @@ export function ConversationSurface(props: ConversationSurfaceProps) {
|
|
|
74
69
|
>
|
|
75
70
|
<div
|
|
76
71
|
ref={(element) => {
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
follow.bindScroller(element)
|
|
73
|
+
if (typeof local.ref === 'function') local.ref(element)
|
|
79
74
|
}}
|
|
80
75
|
onScroll={onScroll}
|
|
76
|
+
tabindex={local.tabindex ?? 0}
|
|
81
77
|
class="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
|
82
78
|
{...rest}
|
|
83
79
|
>
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
{local.children}
|
|
87
|
-
|
|
80
|
+
<div ref={follow.bindContent} data-slot="conversation-content">
|
|
81
|
+
<Show when={local.header}>{local.header}</Show>
|
|
82
|
+
<Show when={local.children} fallback={<div class="p-4">{local.empty}</div>}>
|
|
83
|
+
{local.children}
|
|
84
|
+
</Show>
|
|
85
|
+
</div>
|
|
88
86
|
</div>
|
|
89
87
|
|
|
90
|
-
<Show when={!atBottom()}>
|
|
88
|
+
<Show when={!follow.atBottom()}>
|
|
91
89
|
<div class="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center">
|
|
92
90
|
<Button
|
|
91
|
+
type="button"
|
|
93
92
|
size="sm"
|
|
94
93
|
variant="secondary"
|
|
95
94
|
class="pointer-events-auto gap-1.5 shadow-md"
|
|
96
|
-
onClick={
|
|
95
|
+
onClick={follow.jump}
|
|
97
96
|
>
|
|
98
97
|
<ArrowDown />
|
|
99
|
-
|
|
100
|
-
{missed()} new
|
|
101
|
-
</Show>
|
|
98
|
+
Jump to latest
|
|
102
99
|
</Button>
|
|
103
100
|
</div>
|
|
104
101
|
</Show>
|