@intentface/chat 0.2.0 → 0.2.1

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.
@@ -1,6 +1,12 @@
1
1
  export declare const prefersReducedMotion: () => boolean;
2
2
  export declare const resolveScrollBehavior: (behavior: ScrollBehavior) => ScrollBehavior;
3
3
  export declare const scrollContainerTo: (el: HTMLElement, top: number, behavior: ScrollBehavior) => void;
4
+ export type ViewportBox = {
5
+ width: number;
6
+ height: number;
7
+ };
8
+ export declare const readViewportBox: (el: HTMLElement | null) => ViewportBox;
9
+ export declare const sameViewportBox: (a: ViewportBox, b: ViewportBox) => boolean;
4
10
  export declare const wasPrepended: (mutations: MutationRecord[], previousFirst: Element | null, content: HTMLElement) => boolean;
5
11
  export declare const DEFAULT_BOTTOM_OFFSET = 128;
6
12
  export declare const DOCK_SELECTOR = "[data-thread-composer]";
@@ -11,6 +11,8 @@ export const resolveScrollBehavior = (behavior) => behavior === "smooth" && pref
11
11
  export const scrollContainerTo = (el, top, behavior) => {
12
12
  el.scrollTo({ top, behavior: resolveScrollBehavior(behavior) });
13
13
  };
14
+ export const readViewportBox = (el) => el ? { width: el.clientWidth, height: el.clientHeight } : { width: 0, height: 0 };
15
+ export const sameViewportBox = (a, b) => a.width === b.width && a.height === b.height;
14
16
  // A prepend = rows were added, nothing removed, and the previously-first row
15
17
  // is still connected but no longer first (older history loading in above).
16
18
  export const wasPrepended = (mutations, previousFirst, content) => previousFirst?.isConnected === true &&
@@ -12,7 +12,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
12
12
  import { createContext, memo, use, useCallback, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react";
13
13
  import { useRefWithInit } from "../internal/render/useRefWithInit.js";
14
14
  import { useRenderElement } from "../internal/render/useRenderElement.js";
15
- import { DEFAULT_BOTTOM_OFFSET, measureDockInset, measureTopInset, queryDock, resolveScrollBehavior, scrollContainerTo, wasPrepended, } from "./geometry.js";
15
+ import { DEFAULT_BOTTOM_OFFSET, measureDockInset, measureTopInset, queryDock, readViewportBox, resolveScrollBehavior, sameViewportBox, scrollContainerTo, wasPrepended, } from "./geometry.js";
16
16
  import { createEdgeStore, createVisibilityStore, EMPTY_VISIBILITY, } from "./stores.js";
17
17
  // Latest-ref: read the current value from long-lived effects/callbacks without
18
18
  // re-subscribing them when it changes.
@@ -299,14 +299,24 @@ const useThreadScroll = (rootRef, mode, preserveScrollOnPrepend) => {
299
299
  // late, so a position check here would scroll on stale "at bottom" the
300
300
  // instant after the user wheels up, hijacking their scroll and re-arming
301
301
  // the follow in a loop they can't escape.
302
+ //
303
+ // A resize alone can't say whether content grew or the viewport did: the
304
+ // reserve tracks --thread-turn-area, so a container animating open resizes
305
+ // `content` every frame. Only a viewport change moves the scroller's own
306
+ // box — re-pin those instantly instead of smooth-scrolling a moving target
307
+ // for the length of the animation.
308
+ let viewport = readViewportBox(scrollRef.current);
302
309
  const follow = () => {
310
+ const previous = viewport;
311
+ viewport = readViewportBox(scrollRef.current);
312
+ const resized = !sameViewportBox(previous, viewport);
303
313
  if (skipNextResize) {
304
314
  skipNextResize = false;
305
315
  return;
306
316
  }
307
- if (followingRef.current) {
308
- scrollToBottom("smooth");
309
- }
317
+ if (!followingRef.current)
318
+ return;
319
+ scrollToBottom(resized ? "instant" : "smooth");
310
320
  };
311
321
  land();
312
322
  const turns = new MutationObserver(land);
@@ -522,13 +532,15 @@ const useThreadInsets = (rootRef) => {
522
532
  const root = rootRef.current;
523
533
  if (!root)
524
534
  return;
535
+ // Read both insets before writing either property: a write followed by a forced
536
+ // layout read dips scrollHeight for a frame, and that scrollTop clamp is final.
525
537
  const apply = () => {
526
538
  const bottomInset = measureDockInset(root);
539
+ const topInset = measureTopInset(root);
540
+ const area = Math.max(0, Math.round(root.clientHeight - topInset - (bottomInset ?? DEFAULT_BOTTOM_OFFSET)));
527
541
  if (bottomInset !== null) {
528
542
  root.style.setProperty("--thread-overlay-bottom-height", `${bottomInset}px`);
529
543
  }
530
- const topInset = measureTopInset(root);
531
- const area = Math.max(0, Math.round(root.clientHeight - topInset - (bottomInset ?? DEFAULT_BOTTOM_OFFSET)));
532
544
  root.style.setProperty("--thread-turn-area", `${area}px`);
533
545
  };
534
546
  apply();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentface/chat",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Headless chat UI primitives — unstyled compound components, hooks, and wire formats for building AI chat interfaces.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -80,6 +80,7 @@
80
80
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/add-dist-extensions.mjs && node scripts/smoke-dist.mjs",
81
81
  "test": "bun test",
82
82
  "typecheck": "tsc --noEmit",
83
+ "verify:release": "bun run typecheck && bun test && bun run build && publint",
83
84
  "prepack": "bun run build"
84
85
  },
85
86
  "dependencies": {
@@ -97,6 +98,7 @@
97
98
  "@types/react": "^19",
98
99
  "@types/react-dom": "^19",
99
100
  "axe-core": "^4.12.1",
101
+ "publint": "0.3.23",
100
102
  "typescript": "^5"
101
103
  },
102
104
  "publishConfig": {