1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 resolveThreadFeedLiveFollow,
5 resolveThreadFeedSubmissionAnchor,
6 resolveThreadWorkGroupInitialScroll,
7 shouldFollowThreadWorkGroupAppend,
8} from "./thread-feed-live-follow";
9
10describe("tool-group scroll restoration", () => {
11 const position = {
12 rowId: "read-output",
13 offsetWithinRow: 80,
14 scrollOffset: 600,
15 contentHeight: 1_000,
16 };
17
18 it("restores the visible row and its detail offset rather than stale absolute pixels", () => {
19 const before = [{ id: "first" }, { id: "read-output" }, { id: "last" }];
20 const after = [{ id: "older" }, ...before];
21 expect(resolveThreadWorkGroupInitialScroll(before, position)).toEqual({
22 index: 1,
23 viewOffset: -80,
24 });
25 expect(resolveThreadWorkGroupInitialScroll(after, position)).toEqual({
26 index: 2,
27 viewOffset: -80,
28 });
29 });
30
31 it("starts normally when the saved row no longer belongs to the group", () => {
32 expect(resolveThreadWorkGroupInitialScroll([{ id: "other" }], position)).toBeUndefined();
33 expect(resolveThreadWorkGroupInitialScroll([{ id: "read-output" }], undefined)).toBeUndefined();
34 });
35});
36
37describe("tool-group append following", () => {
38 const previousRows = Array.from({ length: 10 }, (_, index) => ({ id: `call-${index}` }));
39 const appendedRows = [...previousRows, { id: "new-call" }];
40 const atEnd = {
41 previousRows,
42 rows: appendedRows,
43 previousContentHeight: 289,
44 contentHeight: 318,
45 viewportHeight: 256,
46 scrollOffset: 33,
47 detailsChanged: false,
48 userScrolling: false,
49 };
50
51 it("follows a new call when the reader was at the end", () => {
52 expect(shouldFollowThreadWorkGroupAppend(atEnd)).toBe(true);
53 });
54
55 it("follows the first overflowing append as a short group reaches its height cap", () => {
56 expect(
57 shouldFollowThreadWorkGroupAppend({
58 ...atEnd,
59 previousRows: previousRows.slice(0, 8),
60 rows: previousRows.slice(0, 9),
61 previousContentHeight: 231,
62 contentHeight: 260,
63 viewportHeight: 231,
64 scrollOffset: 0,
65 }),
66 ).toBe(true);
67 });
68
69 it.each([
70 { name: "reading earlier calls", changes: { scrollOffset: 20 } },
71 { name: "dragging before leaving the edge", changes: { userScrolling: true } },
72 { name: "opening detail during an append", changes: { detailsChanged: true } },
73 { name: "streaming a result", changes: { rows: previousRows, contentHeight: 600 } },
74 { name: "updating a lifecycle label", changes: { rows: previousRows, contentHeight: 289 } },
75 { name: "prepending old calls", changes: { rows: [{ id: "older" }, ...previousRows] } },
76 {
77 name: "replacing a call while appending",
78 changes: { rows: [{ id: "replacement" }, ...appendedRows.slice(1)] },
79 },
80 ])("does not steal the reader's position when $name", ({ changes }) => {
81 expect(shouldFollowThreadWorkGroupAppend({ ...atEnd, ...changes })).toBe(false);
82 });
83});
84
85describe("resolveThreadFeedSubmissionAnchor", () => {
86 it("anchors the first user message in a thread", () => {
87 expect(
88 resolveThreadFeedSubmissionAnchor({
89 currentAnchorMessageId: null,
90 submittedMessageId: "first-message",
91 hasStartedTurn: false,
92 hasUserMessage: false,
93 queuedMessageCount: 0,
94 }),
95 ).toBe("first-message");
96 });
97
98 it("preserves the first-message anchor when another message is queued", () => {
99 expect(
100 resolveThreadFeedSubmissionAnchor({
101 currentAnchorMessageId: "first-message",
102 submittedMessageId: "second-message",
103 hasStartedTurn: false,
104 hasUserMessage: false,
105 queuedMessageCount: 1,
106 }),
107 ).toBe("first-message");
108 });
109
110 it("preserves the first-message anchor after its outbox entry drains", () => {
111 expect(
112 resolveThreadFeedSubmissionAnchor({
113 currentAnchorMessageId: "first-message",
114 submittedMessageId: "second-message",
115 hasStartedTurn: false,
116 hasUserMessage: false,
117 queuedMessageCount: 0,
118 }),
119 ).toBe("first-message");
120 });
121
122 it("does not anchor a follow-up after a user message appears", () => {
123 expect(
124 resolveThreadFeedSubmissionAnchor({
125 currentAnchorMessageId: "first-message",
126 submittedMessageId: "second-message",
127 hasStartedTurn: false,
128 hasUserMessage: true,
129 queuedMessageCount: 0,
130 }),
131 ).toBeNull();
132 });
133
134 it("does not anchor a thread that has already started a turn", () => {
135 expect(
136 resolveThreadFeedSubmissionAnchor({
137 currentAnchorMessageId: null,
138 submittedMessageId: "second-message",
139 hasStartedTurn: true,
140 hasUserMessage: false,
141 queuedMessageCount: 0,
142 }),
143 ).toBeNull();
144 });
145});
146
147describe("resolveThreadFeedLiveFollow", () => {
148 it("pauses immediately when the user starts scrolling", () => {
149 expect(resolveThreadFeedLiveFollow(true, { type: "user-scroll-begin" })).toBe(false);
150 });
151
152 it("stays paused away from the actual end", () => {
153 expect(
154 resolveThreadFeedLiveFollow(false, {
155 type: "scroll",
156 isAtEnd: false,
157 userScrollSessionActive: true,
158 }),
159 ).toBe(false);
160 });
161
162 it("does not mistake programmatic layout compensation for a user scroll", () => {
163 expect(
164 resolveThreadFeedLiveFollow(true, {
165 type: "scroll",
166 isAtEnd: false,
167 userScrollSessionActive: false,
168 }),
169 ).toBe(true);
170 });
171
172 it("does not re-arm at the end while a user scroll session is active", () => {
173 expect(
174 resolveThreadFeedLiveFollow(false, {
175 type: "scroll",
176 isAtEnd: true,
177 userScrollSessionActive: true,
178 }),
179 ).toBe(false);
180 });
181
182 it.each([
183 { isAtEnd: false, userScrollSessionActive: false, expected: false },
184 { isAtEnd: true, userScrollSessionActive: false, expected: true },
185 { isAtEnd: false, userScrollSessionActive: true, expected: false },
186 { isAtEnd: true, userScrollSessionActive: true, expected: false },
187 ])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
188 expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
189 expected,
190 );
191 });
192
193 it("re-arms at the actual end only after the user scroll session ends", () => {
194 expect(
195 resolveThreadFeedLiveFollow(false, {
196 type: "user-scroll-end",
197 isAtEnd: true,
198 userScrollSessionActive: true,
199 }),
200 ).toBe(true);
201 expect(
202 resolveThreadFeedLiveFollow(false, {
203 type: "user-scroll-end",
204 isAtEnd: false,
205 userScrollSessionActive: true,
206 }),
207 ).toBe(false);
208 });
209
210 it("ignores momentum-end events from programmatic scrolling", () => {
211 expect(
212 resolveThreadFeedLiveFollow(true, {
213 type: "user-scroll-end",
214 isAtEnd: false,
215 userScrollSessionActive: false,
216 }),
217 ).toBe(true);
218 });
219
220 it("re-arms after an explicit reset", () => {
221 expect(resolveThreadFeedLiveFollow(false, { type: "reset" })).toBe(true);
222 });
223});