@stigmer/react 3.7.0 → 3.8.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 (177) hide show
  1. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  2. package/channel/AgentChannelsPanel.js +21 -2
  3. package/channel/AgentChannelsPanel.js.map +1 -1
  4. package/channel/ChannelConversationsDialog.d.ts +12 -4
  5. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  6. package/channel/ChannelConversationsDialog.js +18 -24
  7. package/channel/ChannelConversationsDialog.js.map +1 -1
  8. package/channel/providers.d.ts +23 -0
  9. package/channel/providers.d.ts.map +1 -1
  10. package/channel/providers.js +4 -0
  11. package/channel/providers.js.map +1 -1
  12. package/channel/useSaveAgentChannel.d.ts +5 -4
  13. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  14. package/channel/useSaveAgentChannel.js +18 -4
  15. package/channel/useSaveAgentChannel.js.map +1 -1
  16. package/composer/ComposerToolbar.d.ts +1 -1
  17. package/composer/ComposerToolbar.d.ts.map +1 -1
  18. package/composer/SessionComposer.d.ts +1 -1
  19. package/composer/SessionComposer.js +1 -1
  20. package/composer/SessionComposer.js.map +1 -1
  21. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  22. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  23. package/conversation/ConversationAttentionBanner.js +25 -0
  24. package/conversation/ConversationAttentionBanner.js.map +1 -0
  25. package/conversation/ConversationComposer.d.ts +41 -0
  26. package/conversation/ConversationComposer.d.ts.map +1 -0
  27. package/conversation/ConversationComposer.js +57 -0
  28. package/conversation/ConversationComposer.js.map +1 -0
  29. package/conversation/ConversationControlBanner.d.ts +52 -0
  30. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  31. package/conversation/ConversationControlBanner.js +59 -0
  32. package/conversation/ConversationControlBanner.js.map +1 -0
  33. package/conversation/ConversationListPane.d.ts +68 -0
  34. package/conversation/ConversationListPane.d.ts.map +1 -0
  35. package/conversation/ConversationListPane.js +120 -0
  36. package/conversation/ConversationListPane.js.map +1 -0
  37. package/conversation/ConversationTimelineView.d.ts +46 -0
  38. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  39. package/conversation/ConversationTimelineView.js +166 -0
  40. package/conversation/ConversationTimelineView.js.map +1 -0
  41. package/conversation/ConversationsWorkbench.d.ts +63 -0
  42. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  43. package/conversation/ConversationsWorkbench.js +165 -0
  44. package/conversation/ConversationsWorkbench.js.map +1 -0
  45. package/conversation/conversationPresentation.d.ts +137 -0
  46. package/conversation/conversationPresentation.d.ts.map +1 -0
  47. package/conversation/conversationPresentation.js +230 -0
  48. package/conversation/conversationPresentation.js.map +1 -0
  49. package/conversation/index.d.ts +26 -0
  50. package/conversation/index.d.ts.map +1 -0
  51. package/conversation/index.js +14 -0
  52. package/conversation/index.js.map +1 -0
  53. package/conversation/polling.d.ts +39 -0
  54. package/conversation/polling.d.ts.map +1 -0
  55. package/conversation/polling.js +39 -0
  56. package/conversation/polling.js.map +1 -0
  57. package/conversation/useConversation.d.ts +61 -0
  58. package/conversation/useConversation.d.ts.map +1 -0
  59. package/conversation/useConversation.js +153 -0
  60. package/conversation/useConversation.js.map +1 -0
  61. package/conversation/useConversationList.d.ts +98 -0
  62. package/conversation/useConversationList.d.ts.map +1 -0
  63. package/conversation/useConversationList.js +200 -0
  64. package/conversation/useConversationList.js.map +1 -0
  65. package/conversation/useConversationParticipation.d.ts +63 -0
  66. package/conversation/useConversationParticipation.d.ts.map +1 -0
  67. package/conversation/useConversationParticipation.js +84 -0
  68. package/conversation/useConversationParticipation.js.map +1 -0
  69. package/conversation/useConversationTimeline.d.ts +71 -0
  70. package/conversation/useConversationTimeline.d.ts.map +1 -0
  71. package/conversation/useConversationTimeline.js +152 -0
  72. package/conversation/useConversationTimeline.js.map +1 -0
  73. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  74. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  75. package/conversation/useConversationsWantsHumanCount.js +40 -0
  76. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  77. package/index.d.ts +4 -0
  78. package/index.d.ts.map +1 -1
  79. package/index.js +8 -0
  80. package/index.js.map +1 -1
  81. package/internal/tooltip.d.ts.map +1 -1
  82. package/internal/tooltip.js +7 -1
  83. package/internal/tooltip.js.map +1 -1
  84. package/internal/useAutoScroll.d.ts +25 -4
  85. package/internal/useAutoScroll.d.ts.map +1 -1
  86. package/internal/useAutoScroll.js +55 -15
  87. package/internal/useAutoScroll.js.map +1 -1
  88. package/internal/useFetch.d.ts.map +1 -1
  89. package/internal/useFetch.js +14 -4
  90. package/internal/useFetch.js.map +1 -1
  91. package/models/ModelSelector.d.ts +24 -5
  92. package/models/ModelSelector.d.ts.map +1 -1
  93. package/models/ModelSelector.js +31 -9
  94. package/models/ModelSelector.js.map +1 -1
  95. package/models/registry.d.ts +3 -2
  96. package/models/registry.d.ts.map +1 -1
  97. package/models/registry.js.map +1 -1
  98. package/models/service-tier.d.ts +1 -1
  99. package/models/service-tier.d.ts.map +1 -1
  100. package/models/service-tier.js +1 -1
  101. package/models/service-tier.js.map +1 -1
  102. package/package.json +4 -4
  103. package/schedule/ScheduleDetailView.js +1 -1
  104. package/schedule/ScheduleDetailView.js.map +1 -1
  105. package/schedule/ScheduleForm.d.ts.map +1 -1
  106. package/schedule/ScheduleForm.js +7 -6
  107. package/schedule/ScheduleForm.js.map +1 -1
  108. package/sharing/useSaveAgentShare.d.ts +10 -1
  109. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  110. package/sharing/useSaveAgentShare.js +9 -0
  111. package/sharing/useSaveAgentShare.js.map +1 -1
  112. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  113. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  114. package/sidebar/WorkspaceSidebar.js +19 -7
  115. package/sidebar/WorkspaceSidebar.js.map +1 -1
  116. package/src/channel/AgentChannelsPanel.tsx +33 -2
  117. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  118. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  119. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  120. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  121. package/src/channel/providers.ts +27 -0
  122. package/src/channel/useSaveAgentChannel.ts +18 -4
  123. package/src/composer/ComposerToolbar.tsx +1 -1
  124. package/src/composer/SessionComposer.tsx +2 -2
  125. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
  126. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  127. package/src/conversation/ConversationComposer.tsx +156 -0
  128. package/src/conversation/ConversationControlBanner.tsx +193 -0
  129. package/src/conversation/ConversationListPane.tsx +459 -0
  130. package/src/conversation/ConversationTimelineView.tsx +435 -0
  131. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  132. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  133. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  134. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  135. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  136. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  137. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  138. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  139. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  140. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  141. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  142. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  143. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  144. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  145. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  146. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  147. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  148. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  149. package/src/conversation/conversationPresentation.ts +305 -0
  150. package/src/conversation/index.ts +80 -0
  151. package/src/conversation/polling.ts +41 -0
  152. package/src/conversation/useConversation.ts +221 -0
  153. package/src/conversation/useConversationList.ts +328 -0
  154. package/src/conversation/useConversationParticipation.ts +178 -0
  155. package/src/conversation/useConversationTimeline.ts +244 -0
  156. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  157. package/src/index.ts +67 -0
  158. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  159. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  160. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  161. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  162. package/src/internal/tooltip.tsx +6 -1
  163. package/src/internal/useAutoScroll.ts +66 -20
  164. package/src/internal/useFetch.ts +15 -4
  165. package/src/models/ModelSelector.tsx +81 -49
  166. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
  167. package/src/models/registry.ts +3 -2
  168. package/src/models/service-tier.ts +1 -1
  169. package/src/schedule/ScheduleDetailView.tsx +1 -1
  170. package/src/schedule/ScheduleForm.tsx +7 -6
  171. package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
  172. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  173. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  174. package/src/sharing/useSaveAgentShare.ts +19 -1
  175. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  176. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  177. package/styles.css +1 -1
@@ -0,0 +1,143 @@
1
+ // Scroll-behavior regression suite for useAutoScroll (channel-conversations
2
+ // F-09). Runs in a real Chromium via `vitest.a11y.config.ts` — the defect
3
+ // lives in IntersectionObserver/ResizeObserver timing against real layout,
4
+ // which happy-dom cannot evaluate (there, every scrollHeight is 0 and the
5
+ // observers are mocks).
6
+ //
7
+ // The production shape under test: a chat thread whose items arrive ASYNC
8
+ // after mount, so the content wrapper renders inside a loading branch and
9
+ // does not exist on the first render. The shipped bug: the hook attached
10
+ // its ResizeObserver only at mount, so a late-mounting wrapper was never
11
+ // observed — every conversation opened at the OLDEST message with "Jump to
12
+ // latest" showing, and nothing ever scrolled the view again.
13
+ //
14
+ // Layout is driven by inline styles (not the shipped stylesheet): this
15
+ // suite pins the hook's observer mechanics, not CSS — the real component's
16
+ // rendering is covered by ConversationTimelineView.layout.test.tsx.
17
+
18
+ import { describe, it, expect, vi, afterEach } from "vitest";
19
+ import { render, cleanup } from "@testing-library/react";
20
+ import { useAutoScroll, type UseAutoScrollReturn } from "../useAutoScroll";
21
+
22
+ const VIEWPORT_PX = 240;
23
+ const ITEM_PX = 48;
24
+
25
+ let latest: UseAutoScrollReturn;
26
+
27
+ /**
28
+ * The production thread shape: scroller and sentinel always mounted, the
29
+ * content wrapper only once items exist (the loading branch renders a
30
+ * skeleton instead).
31
+ */
32
+ function AsyncThread({ items }: { readonly items: readonly string[] }) {
33
+ const hook = useAutoScroll();
34
+ latest = hook;
35
+ return (
36
+ <div
37
+ ref={hook.scrollRef}
38
+ data-testid="scroller"
39
+ style={{ height: VIEWPORT_PX, overflowY: "auto" }}
40
+ >
41
+ {items.length === 0 ? (
42
+ <div data-testid="skeleton" style={{ height: 100 }} />
43
+ ) : (
44
+ <div ref={hook.contentRef} data-testid="content">
45
+ {items.map((text) => (
46
+ <div key={text} style={{ height: ITEM_PX }}>
47
+ {text}
48
+ </div>
49
+ ))}
50
+ </div>
51
+ )}
52
+ <div ref={hook.sentinelRef} aria-hidden="true" />
53
+ </div>
54
+ );
55
+ }
56
+
57
+ const messages = (count: number, offset = 0) =>
58
+ Array.from({ length: count }, (_, i) => `message ${i + offset}`);
59
+
60
+ function scroller(): HTMLElement {
61
+ return document.querySelector('[data-testid="scroller"]') as HTMLElement;
62
+ }
63
+
64
+ const isPinnedToBottom = (el: HTMLElement) =>
65
+ Math.abs(el.scrollTop - (el.scrollHeight - el.clientHeight)) <= 1;
66
+
67
+ /** Wait out rAF-batched scroll writes and async observer callbacks. */
68
+ async function settled(assertion: () => void): Promise<void> {
69
+ await vi.waitFor(assertion, { timeout: 2000, interval: 20 });
70
+ }
71
+
72
+ // Determinism note: every case fills content AFTER mount on purpose. With
73
+ // a late-mounting wrapper the mount-time scroll write is a no-op (the
74
+ // skeleton does not overflow), so the bottom pin can only come from the
75
+ // ResizeObserver pathway — "pinned" therefore PROVES the observer's
76
+ // initial delivery and its rAF write have both run, and no pinning work
77
+ // is pending when a case then simulates the reader's scroll. (Headless
78
+ // Chromium delivers initial observations on its own schedule; waiting a
79
+ // frame count instead was measured flaky.)
80
+
81
+ afterEach(() => cleanup());
82
+
83
+ describe("useAutoScroll under real layout (F-09)", () => {
84
+ it("pins to the newest content when items arrive after mount", async () => {
85
+ const { rerender } = render(<AsyncThread items={[]} />);
86
+
87
+ // The async first fill: 30 × 48px inside a 240px viewport.
88
+ rerender(<AsyncThread items={messages(30)} />);
89
+
90
+ await settled(() => {
91
+ expect(isPinnedToBottom(scroller()), "view must open at the newest message").toBe(true);
92
+ expect(latest.isFollowing).toBe(true);
93
+ });
94
+ });
95
+
96
+ it("keeps following growth that lands after an async first fill", async () => {
97
+ const { rerender } = render(<AsyncThread items={[]} />);
98
+ rerender(<AsyncThread items={messages(30)} />);
99
+ await settled(() => expect(isPinnedToBottom(scroller())).toBe(true));
100
+
101
+ // A new message lands (a poll tick): the pinned view must follow it.
102
+ rerender(<AsyncThread items={messages(31)} />);
103
+
104
+ await settled(() => {
105
+ expect(isPinnedToBottom(scroller())).toBe(true);
106
+ expect(latest.isFollowing).toBe(true);
107
+ });
108
+ });
109
+
110
+ it("leaves a reader who scrolled up alone, and jumpToLatest recovers", async () => {
111
+ const { rerender } = render(<AsyncThread items={[]} />);
112
+ rerender(<AsyncThread items={messages(30)} />);
113
+ await settled(() => {
114
+ expect(isPinnedToBottom(scroller())).toBe(true);
115
+ // Follow-STATE quiescence, not just position: the pin's own IO
116
+ // deliveries (a transient not-visible mid-pin, then visible)
117
+ // must have landed before the reader scrolls up — otherwise the
118
+ // disengage wait below can pass vacuously on the transient FALSE
119
+ // while a queued pre-scroll TRUE re-arms follow behind it, and
120
+ // the growth step yanks the reader (measured ~1-in-40 headless).
121
+ expect(latest.isFollowing).toBe(true);
122
+ });
123
+
124
+ // The reader scrolls up to read history — follow disengages.
125
+ scroller().scrollTop = 0;
126
+ await settled(() => expect(latest.isFollowing).toBe(false));
127
+
128
+ // Growth while disengaged (an incoming message, or the reader's own
129
+ // send arriving via refetch): the view must NOT move under them.
130
+ // This is the measured scroll-on-send posture — see the F-09 record.
131
+ rerender(<AsyncThread items={messages(31)} />);
132
+ await new Promise((resolve) => setTimeout(resolve, 100));
133
+ expect(scroller().scrollTop).toBe(0);
134
+ expect(latest.isFollowing).toBe(false);
135
+
136
+ // The recovery affordance: jumpToLatest re-pins and re-engages.
137
+ latest.jumpToLatest();
138
+ await settled(() => {
139
+ expect(isPinnedToBottom(scroller())).toBe(true);
140
+ expect(latest.isFollowing).toBe(true);
141
+ });
142
+ });
143
+ });
@@ -22,6 +22,29 @@ function Harness() {
22
22
  );
23
23
  }
24
24
 
25
+ /**
26
+ * The conversation-timeline shape (channel-conversations F-09): the content
27
+ * wrapper legitimately sits inside a loading branch, so it does NOT exist on
28
+ * the first render — the hook must attach its ResizeObserver whenever the
29
+ * wrapper appears, not only at mount.
30
+ */
31
+ function LateContentHarness({ showContent }: { readonly showContent: boolean }) {
32
+ const result = useAutoScroll();
33
+ latestResult = result;
34
+ return (
35
+ <div ref={result.scrollRef} data-testid="scroller">
36
+ {showContent ? (
37
+ <div ref={result.contentRef} data-testid="content">
38
+ <p>Hello</p>
39
+ </div>
40
+ ) : (
41
+ <div data-testid="skeleton" />
42
+ )}
43
+ <div ref={result.sentinelRef} data-testid="sentinel" />
44
+ </div>
45
+ );
46
+ }
47
+
25
48
  // ---------------------------------------------------------------------------
26
49
  // Observer mocks
27
50
  // ---------------------------------------------------------------------------
@@ -91,12 +114,22 @@ afterEach(() => {
91
114
  // Helpers
92
115
  // ---------------------------------------------------------------------------
93
116
 
94
- function fireIO(isIntersecting: boolean) {
117
+ /**
118
+ * Deliver an IO notification with the scroller's LIVE geometry staged to
119
+ * match the scenario. The hook measures live geometry at delivery time
120
+ * rather than trusting the entry payload — an entry snapshots
121
+ * observation-time geometry and can be delivered after the reader has
122
+ * scrolled again (the stale-payload race pinned in the browser suite) —
123
+ * so the entry list itself is deliberately empty here.
124
+ */
125
+ function fireIO(nearBottom: boolean) {
126
+ const scroller = screen.getByTestId("scroller");
127
+ Object.defineProperty(scroller, "scrollHeight", { value: 1000, configurable: true });
128
+ Object.defineProperty(scroller, "clientHeight", { value: 200, configurable: true });
129
+ // Within the 80px near-bottom margin, or far above it.
130
+ scroller.scrollTop = nearBottom ? 800 : 0;
95
131
  act(() => {
96
- ioCallback(
97
- [{ isIntersecting } as IntersectionObserverEntry],
98
- {} as IntersectionObserver,
99
- );
132
+ ioCallback([], {} as IntersectionObserver);
100
133
  });
101
134
  }
102
135
 
@@ -126,6 +159,30 @@ describe("useAutoScroll", () => {
126
159
  expect(roObserve).toHaveBeenCalledWith(screen.getByTestId("content"));
127
160
  });
128
161
 
162
+ it("observes content that mounts after the first render (F-09: loading-branch consumers)", () => {
163
+ const { rerender } = render(<LateContentHarness showContent={false} />);
164
+ expect(roObserve).not.toHaveBeenCalled();
165
+
166
+ // The async first fill: the skeleton gives way to real content.
167
+ rerender(<LateContentHarness showContent />);
168
+ expect(roObserve).toHaveBeenCalledWith(screen.getByTestId("content"));
169
+ });
170
+
171
+ it("disconnects and re-observes across content unmount/remount round-trips", () => {
172
+ const { rerender } = render(<LateContentHarness showContent />);
173
+ expect(roObserve).toHaveBeenCalledTimes(1);
174
+
175
+ // Back to a loading state (e.g. an identity switch resets the data
176
+ // hook): the observer must let go of the dead node…
177
+ rerender(<LateContentHarness showContent={false} />);
178
+ expect(roDisconnect).toHaveBeenCalled();
179
+
180
+ // …and adopt the replacement when content returns.
181
+ rerender(<LateContentHarness showContent />);
182
+ expect(roObserve).toHaveBeenCalledTimes(2);
183
+ expect(roObserve).toHaveBeenLastCalledWith(screen.getByTestId("content"));
184
+ });
185
+
129
186
  it("configures IO with 80px bottom root margin", () => {
130
187
  render(<Harness />);
131
188
  const IOConstructor = vi.mocked(IntersectionObserver);
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { act, render, cleanup } from "@testing-library/react";
3
+ import { useEffect, useState } from "react";
4
+ import { useFetch } from "../useFetch";
5
+ import { advanceInSlices } from "./fake-timer-slices";
6
+
7
+ /**
8
+ * The F-14 starvation net (channel-conversations T06): a polling
9
+ * `useFetch` must keep its interval's phase across re-renders of its
10
+ * host component.
11
+ *
12
+ * Every real consumer passes an inline closure as `fetchFn`, which is
13
+ * referentially new on each render. If the interval effect keys on that
14
+ * identity, any co-mounted render source with a period shorter than the
15
+ * poll interval tears the timer down before it can ever fire — the
16
+ * interval needs an uninterrupted `refetchInterval` window that
17
+ * structurally never arrives. In production this starved the
18
+ * conversation timeline (5s poll) and inbox (20s poll) under
19
+ * `useConversation`'s 5s render cadence, deterministically.
20
+ *
21
+ * The ticker period here (600ms) is deliberately SHORTER than the poll
22
+ * interval (1000ms) and misaligned with it. Do not "simplify" this to
23
+ * aligned periods: when both timers share a phase under fake timers,
24
+ * both callbacks fire inside the same advanced instant — before React
25
+ * commits the re-render that would tear the poll timer down — and the
26
+ * starvation never manifests, leaving a test that cannot fail.
27
+ */
28
+
29
+ /** Calls `useFetch` the way every real consumer does: inline closure. */
30
+ function PollingConsumer({ onFetch }: { readonly onFetch: () => void }) {
31
+ useFetch(
32
+ async () => {
33
+ onFetch();
34
+ return "ok";
35
+ },
36
+ [],
37
+ "",
38
+ { refetchInterval: 1000 },
39
+ );
40
+ return null;
41
+ }
42
+
43
+ /** Re-renders its subtree every 600ms — the fast co-mounted render source. */
44
+ function RenderPressureHost({ onFetch }: { readonly onFetch: () => void }) {
45
+ const [, setTick] = useState(0);
46
+ useEffect(() => {
47
+ const id = setInterval(() => setTick((t) => t + 1), 600);
48
+ return () => clearInterval(id);
49
+ }, []);
50
+ return <PollingConsumer onFetch={onFetch} />;
51
+ }
52
+
53
+ describe("useFetch — polling under render pressure (F-14)", () => {
54
+ beforeEach(() => {
55
+ vi.useFakeTimers();
56
+ });
57
+
58
+ afterEach(() => {
59
+ cleanup();
60
+ vi.useRealTimers();
61
+ });
62
+
63
+ it("keeps polling while a faster sibling re-renders the consumer every 600ms", async () => {
64
+ const onFetch = vi.fn();
65
+ render(<RenderPressureHost onFetch={onFetch} />);
66
+
67
+ // Flush the initial fetch.
68
+ await act(async () => {
69
+ await Promise.resolve();
70
+ });
71
+ expect(onFetch).toHaveBeenCalledTimes(1);
72
+
73
+ // 3 seconds under render pressure: the ticker re-renders at 600ms,
74
+ // 1200ms, 1800ms, 2400ms, 3000ms. The poll interval must still fire
75
+ // at 1000ms, 2000ms, and 3000ms — its phase survives the renders.
76
+ await advanceInSlices(3000);
77
+ expect(onFetch).toHaveBeenCalledTimes(4);
78
+ });
79
+ });
@@ -40,7 +40,12 @@ function TooltipContent({
40
40
  const portalContainer = useStigmerPortalContainer();
41
41
 
42
42
  return (
43
- <TooltipPrimitive.Portal container={portalContainer}>
43
+ // `?? undefined`, never a raw null: Base UI treats an EXPLICIT null
44
+ // container as "wait for one" and renders the popup nowhere, while
45
+ // undefined falls back to document.body — the standalone behavior
46
+ // `useStigmerPortalContainer` documents. Without the coalesce, a
47
+ // tooltip outside a StigmerProvider opens invisibly.
48
+ <TooltipPrimitive.Portal container={portalContainer ?? undefined}>
44
49
  <TooltipPrimitive.Positioner
45
50
  className="isolate z-50 outline-none"
46
51
  side={side}
@@ -11,12 +11,20 @@ import { useCallback, useEffect, useRef, useState } from "react";
11
11
  const NEAR_BOTTOM_MARGIN_PX = 80;
12
12
 
13
13
  export interface UseAutoScrollReturn {
14
- /** Attach to the scrollable container. */
14
+ /** Attach to the scrollable container. Must be mounted from the first render. */
15
15
  readonly scrollRef: React.RefObject<HTMLDivElement | null>;
16
- /** Attach to a zero-height div as the last child of the scroll container. */
16
+ /**
17
+ * Attach to a zero-height div as the last child of the scroll container.
18
+ * Must be mounted from the first render.
19
+ */
17
20
  readonly sentinelRef: React.RefObject<HTMLDivElement | null>;
18
- /** Attach to a wrapper div around the thread content (ResizeObserver target). */
19
- readonly contentRef: React.RefObject<HTMLDivElement | null>;
21
+ /**
22
+ * Attach to a wrapper div around the thread content (ResizeObserver
23
+ * target). MAY mount late or round-trip through unmount — a callback
24
+ * ref, so consumers whose content sits inside a loading branch (the
25
+ * conversation timeline) are observed the moment the wrapper appears.
26
+ */
27
+ readonly contentRef: React.RefCallback<HTMLDivElement>;
20
28
  /** True when the thread auto-scrolls to follow new content. */
21
29
  readonly isFollowing: boolean;
22
30
  /** Scroll to the latest content and re-engage follow mode. */
@@ -39,12 +47,24 @@ export interface UseAutoScrollReturn {
39
47
  * detects height growth and triggers rAF-batched scroll writes when
40
48
  * in the Following state.
41
49
  *
50
+ * Ref contract (asymmetric on purpose): the scroller and sentinel must
51
+ * exist from the first render — every thread renders its scroll pane
52
+ * unconditionally, and the observers that need them attach once at
53
+ * mount. The CONTENT wrapper may appear later or round-trip through
54
+ * unmount: chat threads legitimately render a loading skeleton first
55
+ * (the conversation timeline does), so `contentRef` is a callback ref
56
+ * that owns the ResizeObserver's lifecycle — React hands it the node on
57
+ * attach and `null` on detach, so the observer follows the wrapper
58
+ * wherever it goes, with no render cost. Attaching only at mount
59
+ * shipped channel-conversations F-09: a late-mounting wrapper was never
60
+ * observed, so every conversation opened at its oldest message and
61
+ * nothing ever scrolled the view.
62
+ *
42
63
  * @internal Not part of the public API.
43
64
  */
44
65
  export function useAutoScroll(): UseAutoScrollReturn {
45
66
  const scrollRef = useRef<HTMLDivElement | null>(null);
46
67
  const sentinelRef = useRef<HTMLDivElement | null>(null);
47
- const contentRef = useRef<HTMLDivElement | null>(null);
48
68
 
49
69
  const [isFollowing, setIsFollowing] = useState(true);
50
70
  const isFollowingRef = useRef(true);
@@ -67,10 +87,20 @@ export function useAutoScroll(): UseAutoScrollReturn {
67
87
  scroller.scrollTop = scroller.scrollHeight;
68
88
 
69
89
  const io = new IntersectionObserver(
70
- (entries) => {
71
- const entry = entries[0];
72
- if (!entry) return;
73
- const visible = entry.isIntersecting;
90
+ () => {
91
+ // The delivery is the signal; the LIVE geometry is the truth.
92
+ // An entry snapshots geometry at OBSERVATION time and can be
93
+ // delivered after the reader has scrolled again — measured in
94
+ // the F-09 net: a stale "visible" (captured at the pinned
95
+ // instant) landed after a scroll-up, re-engaged follow, and the
96
+ // next growth yanked the reader to the bottom. Measuring at
97
+ // delivery time keeps the observer as a poll-free change
98
+ // detector without trusting its stale payload.
99
+ const el = scrollRef.current;
100
+ if (!el) return;
101
+ const visible =
102
+ el.scrollHeight - el.scrollTop - el.clientHeight <=
103
+ NEAR_BOTTOM_MARGIN_PX;
74
104
  isFollowingRef.current = visible;
75
105
  setIsFollowing(visible);
76
106
  },
@@ -86,25 +116,41 @@ export function useAutoScroll(): UseAutoScrollReturn {
86
116
  }, []);
87
117
 
88
118
  // --- ResizeObserver: scroll on content height growth while following ---
89
- useEffect(() => {
90
- const content = contentRef.current;
91
- if (!content || !scrollRef.current) return;
119
+ // Lifecycle lives in the callback ref (not a run-once effect): the
120
+ // observer must adopt a wrapper that mounts after a loading branch and
121
+ // let go of one that unmounts — including on component unmount, where
122
+ // React also calls the ref with null. Its initial delivery on observe()
123
+ // doubles as the first-fill bottom pin. The scroller is read lazily at
124
+ // fire time (deliveries are async, after every commit's refs are set).
125
+ const resizeObserverRef = useRef<ResizeObserver | null>(null);
126
+ const contentRef = useCallback((node: HTMLDivElement | null) => {
127
+ resizeObserverRef.current?.disconnect();
128
+ resizeObserverRef.current = null;
129
+ cancelAnimationFrame(rafIdRef.current);
130
+ if (!node) return;
92
131
 
93
132
  const ro = new ResizeObserver(() => {
94
133
  if (!isFollowingRef.current) return;
95
134
  cancelAnimationFrame(rafIdRef.current);
135
+ // The reader-took-control guard, decided at WRITE time: a frame
136
+ // can lag (headless and busy tabs throttle rAF) long enough for
137
+ // the reader to scroll up between scheduling and writing, and a
138
+ // stale pin then yanks them back to the bottom (the F-09 suite's
139
+ // scrolled-up case, flaking ~1-in-5 under real Chromium). The
140
+ // discriminator is scrollTop itself: content growth never moves
141
+ // it, only the reader does — `isFollowingRef` cannot arbitrate
142
+ // here because growth makes the sentinel leave the viewport
143
+ // transiently, so the flag reads false mid-pin by design.
144
+ const scheduledAt = scrollRef.current?.scrollTop ?? null;
96
145
  rafIdRef.current = requestAnimationFrame(() => {
97
146
  const el = scrollRef.current;
98
- if (el) el.scrollTop = el.scrollHeight;
147
+ if (!el) return;
148
+ if (scheduledAt !== null && el.scrollTop !== scheduledAt) return;
149
+ el.scrollTop = el.scrollHeight;
99
150
  });
100
151
  });
101
-
102
- ro.observe(content);
103
-
104
- return () => {
105
- ro.disconnect();
106
- cancelAnimationFrame(rafIdRef.current);
107
- };
152
+ ro.observe(node);
153
+ resizeObserverRef.current = ro;
108
154
  }, []);
109
155
 
110
156
  const jumpToLatest = useCallback(() => {
@@ -182,18 +182,29 @@ export function useFetch<T>(
182
182
  };
183
183
  }, [...deps, fetchKey]);
184
184
 
185
+ // The two effects below use `fetchFn` only as an enabled/disabled
186
+ // signal — they trigger fetches through `refetch()`, and the fetch
187
+ // effect above already closes over the current render's `fetchFn`.
188
+ // Keying them on this boolean instead of on `fetchFn` itself is
189
+ // load-bearing: consumers pass inline closures (referentially new
190
+ // every render), so an identity dep tears the interval down on every
191
+ // render — and under any co-mounted faster render source the timer
192
+ // never completes a period and the consumer never polls at all
193
+ // (channel-conversations F-14; pinned by the render-pressure tests).
194
+ const fetchEnabled = fetchFn !== null;
195
+
185
196
  const refetchInterval = options?.refetchInterval;
186
197
  useEffect(() => {
187
- if (!refetchInterval || refetchInterval <= 0 || !fetchFn) return;
198
+ if (!refetchInterval || refetchInterval <= 0 || !fetchEnabled) return;
188
199
  const id = setInterval(() => {
189
200
  if (!isFetchingRef.current) refetch();
190
201
  }, refetchInterval);
191
202
  return () => clearInterval(id);
192
- }, [refetchInterval, fetchFn, refetch]);
203
+ }, [refetchInterval, fetchEnabled, refetch]);
193
204
 
194
205
  const refetchOnWindowFocus = options?.refetchOnWindowFocus;
195
206
  useEffect(() => {
196
- if (!refetchOnWindowFocus || !fetchFn) return;
207
+ if (!refetchOnWindowFocus || !fetchEnabled) return;
197
208
  if (typeof window === "undefined") return;
198
209
  const onActive = () => {
199
210
  if (!isFetchingRef.current) refetch();
@@ -207,7 +218,7 @@ export function useFetch<T>(
207
218
  window.removeEventListener("focus", onActive);
208
219
  document.removeEventListener("visibilitychange", onVisible);
209
220
  };
210
- }, [refetchOnWindowFocus, fetchFn, refetch]);
221
+ }, [refetchOnWindowFocus, fetchEnabled, refetch]);
211
222
 
212
223
  const isLoading = isFetching && !hasDataRef.current;
213
224
  const isRefetching = isFetching && hasDataRef.current;