@btst/stack 2.0.0 → 2.0.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.
@@ -118,6 +118,9 @@ function ChatInterface({
118
118
  transport,
119
119
  onError: (err) => {
120
120
  console.error("useChat onError:", err);
121
+ if (!id && !hasNavigatedRef.current) {
122
+ isFirstMessageSentRef.current = false;
123
+ }
121
124
  },
122
125
  onFinish: async () => {
123
126
  if (isPublicMode) return;
@@ -183,7 +186,29 @@ function ChatInterface({
183
186
  const [input, setInput] = React.useState("");
184
187
  const [attachedFiles, setAttachedFiles] = React.useState([]);
185
188
  const scrollRef = React.useRef(null);
189
+ const userHasScrolledRef = React.useRef(false);
190
+ const prevStatusRef = React.useRef(status);
191
+ React.useEffect(() => {
192
+ if (status !== prevStatusRef.current && (status === "streaming" || status === "submitted")) {
193
+ userHasScrolledRef.current = false;
194
+ }
195
+ prevStatusRef.current = status;
196
+ }, [status]);
186
197
  React.useEffect(() => {
198
+ const viewport = scrollRef.current?.querySelector(
199
+ "[data-radix-scroll-area-viewport]"
200
+ );
201
+ if (!viewport) return;
202
+ const handleScroll = () => {
203
+ const { scrollTop, scrollHeight, clientHeight } = viewport;
204
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
205
+ userHasScrolledRef.current = !isNearBottom;
206
+ };
207
+ viewport.addEventListener("scroll", handleScroll);
208
+ return () => viewport.removeEventListener("scroll", handleScroll);
209
+ }, []);
210
+ React.useEffect(() => {
211
+ if (userHasScrolledRef.current) return;
187
212
  if (scrollRef.current) {
188
213
  const scrollElement = scrollRef.current.querySelector(
189
214
  "[data-radix-scroll-area-viewport]"
@@ -208,8 +233,10 @@ function ChatInterface({
208
233
  if (!isPublicMode && !id && messages.length === 0) {
209
234
  isFirstMessageSentRef.current = true;
210
235
  }
236
+ userHasScrolledRef.current = false;
211
237
  const savedInput = input;
212
238
  const savedFiles = files ? [...files] : [];
239
+ const messageCountBeforeSend = messages.length;
213
240
  setInput("");
214
241
  setAttachedFiles([]);
215
242
  try {
@@ -231,6 +258,10 @@ function ChatInterface({
231
258
  } catch (error2) {
232
259
  setInput(savedInput);
233
260
  setAttachedFiles(savedFiles);
261
+ if (isFirstMessageSentRef.current && !hasNavigatedRef.current) {
262
+ isFirstMessageSentRef.current = false;
263
+ }
264
+ setMessages((prev) => prev.slice(0, messageCountBeforeSend));
234
265
  console.error("Error sending message:", error2);
235
266
  }
236
267
  };
@@ -272,6 +303,7 @@ function ChatInterface({
272
303
  className
273
304
  ),
274
305
  "data-testid": "chat-interface",
306
+ "data-chat-status": status,
275
307
  children: /* @__PURE__ */ jsxRuntime.jsx(scrollArea.ScrollArea, { ref: scrollRef, className: "flex-1 h-full", children: /* @__PURE__ */ jsxRuntime.jsxs(
276
308
  "div",
277
309
  {
@@ -116,6 +116,9 @@ function ChatInterface({
116
116
  transport,
117
117
  onError: (err) => {
118
118
  console.error("useChat onError:", err);
119
+ if (!id && !hasNavigatedRef.current) {
120
+ isFirstMessageSentRef.current = false;
121
+ }
119
122
  },
120
123
  onFinish: async () => {
121
124
  if (isPublicMode) return;
@@ -181,7 +184,29 @@ function ChatInterface({
181
184
  const [input, setInput] = useState("");
182
185
  const [attachedFiles, setAttachedFiles] = useState([]);
183
186
  const scrollRef = useRef(null);
187
+ const userHasScrolledRef = useRef(false);
188
+ const prevStatusRef = useRef(status);
189
+ useEffect(() => {
190
+ if (status !== prevStatusRef.current && (status === "streaming" || status === "submitted")) {
191
+ userHasScrolledRef.current = false;
192
+ }
193
+ prevStatusRef.current = status;
194
+ }, [status]);
184
195
  useEffect(() => {
196
+ const viewport = scrollRef.current?.querySelector(
197
+ "[data-radix-scroll-area-viewport]"
198
+ );
199
+ if (!viewport) return;
200
+ const handleScroll = () => {
201
+ const { scrollTop, scrollHeight, clientHeight } = viewport;
202
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
203
+ userHasScrolledRef.current = !isNearBottom;
204
+ };
205
+ viewport.addEventListener("scroll", handleScroll);
206
+ return () => viewport.removeEventListener("scroll", handleScroll);
207
+ }, []);
208
+ useEffect(() => {
209
+ if (userHasScrolledRef.current) return;
185
210
  if (scrollRef.current) {
186
211
  const scrollElement = scrollRef.current.querySelector(
187
212
  "[data-radix-scroll-area-viewport]"
@@ -206,8 +231,10 @@ function ChatInterface({
206
231
  if (!isPublicMode && !id && messages.length === 0) {
207
232
  isFirstMessageSentRef.current = true;
208
233
  }
234
+ userHasScrolledRef.current = false;
209
235
  const savedInput = input;
210
236
  const savedFiles = files ? [...files] : [];
237
+ const messageCountBeforeSend = messages.length;
211
238
  setInput("");
212
239
  setAttachedFiles([]);
213
240
  try {
@@ -229,6 +256,10 @@ function ChatInterface({
229
256
  } catch (error2) {
230
257
  setInput(savedInput);
231
258
  setAttachedFiles(savedFiles);
259
+ if (isFirstMessageSentRef.current && !hasNavigatedRef.current) {
260
+ isFirstMessageSentRef.current = false;
261
+ }
262
+ setMessages((prev) => prev.slice(0, messageCountBeforeSend));
232
263
  console.error("Error sending message:", error2);
233
264
  }
234
265
  };
@@ -270,6 +301,7 @@ function ChatInterface({
270
301
  className
271
302
  ),
272
303
  "data-testid": "chat-interface",
304
+ "data-chat-status": status,
273
305
  children: /* @__PURE__ */ jsx(ScrollArea, { ref: scrollRef, className: "flex-1 h-full", children: /* @__PURE__ */ jsxs(
274
306
  "div",
275
307
  {
@@ -16,8 +16,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
16
16
  itemCount: number;
17
17
  createdAt: string;
18
18
  updatedAt: string;
19
- id: string;
20
19
  name: string;
20
+ id: string;
21
21
  slug: string;
22
22
  description?: string | undefined;
23
23
  jsonSchema: string;
@@ -16,8 +16,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
16
16
  itemCount: number;
17
17
  createdAt: string;
18
18
  updatedAt: string;
19
- id: string;
20
19
  name: string;
20
+ id: string;
21
21
  slug: string;
22
22
  description?: string | undefined;
23
23
  jsonSchema: string;
@@ -16,8 +16,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
16
16
  itemCount: number;
17
17
  createdAt: string;
18
18
  updatedAt: string;
19
- id: string;
20
19
  name: string;
20
+ id: string;
21
21
  slug: string;
22
22
  description?: string | undefined;
23
23
  jsonSchema: string;
@@ -5,12 +5,12 @@ import { B as Board, C as Column, T as Task, d as ColumnWithTasks } from '../../
5
5
 
6
6
  declare const createBoardSchema: z.ZodObject<{
7
7
  description: z.ZodOptional<z.ZodString>;
8
- createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
9
- updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
10
8
  name: z.ZodString;
11
9
  slug: z.ZodOptional<z.ZodString>;
12
10
  ownerId: z.ZodOptional<z.ZodString>;
13
11
  organizationId: z.ZodOptional<z.ZodString>;
12
+ createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
13
+ updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
14
14
  }, z.core.$strip>;
15
15
  declare const updateBoardSchema: z.ZodObject<{
16
16
  createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
@@ -24,9 +24,9 @@ declare const updateBoardSchema: z.ZodObject<{
24
24
  }, z.core.$strip>;
25
25
  declare const createColumnSchema: z.ZodObject<{
26
26
  title: z.ZodString;
27
+ boardId: z.ZodString;
27
28
  createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
28
29
  updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
29
- boardId: z.ZodString;
30
30
  order: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
31
31
  }, z.core.$strip>;
32
32
  declare const updateColumnSchema: z.ZodObject<{
@@ -265,12 +265,12 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
265
265
  method: "POST";
266
266
  body: z.ZodObject<{
267
267
  description: z.ZodOptional<z.ZodString>;
268
- createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
269
- updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
270
268
  name: z.ZodString;
271
269
  slug: z.ZodOptional<z.ZodString>;
272
270
  ownerId: z.ZodOptional<z.ZodString>;
273
271
  organizationId: z.ZodOptional<z.ZodString>;
272
+ createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
273
+ updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
274
274
  }, z.core.$strip>;
275
275
  }, {
276
276
  columns: ColumnWithTasks[];
@@ -287,12 +287,12 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
287
287
  method: "PUT";
288
288
  body: z.ZodObject<{
289
289
  description: z.ZodOptional<z.ZodOptional<z.ZodString>>;
290
- createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
291
- updatedAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
292
290
  name: z.ZodOptional<z.ZodString>;
293
291
  slug: z.ZodOptional<z.ZodString>;
294
292
  ownerId: z.ZodOptional<z.ZodOptional<z.ZodString>>;
295
293
  organizationId: z.ZodOptional<z.ZodOptional<z.ZodString>>;
294
+ createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
295
+ updatedAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
296
296
  }, z.core.$strip>;
297
297
  }, Board>;
298
298
  readonly deleteBoard: better_call.StrictEndpoint<"/boards/:id", {
@@ -304,9 +304,9 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
304
304
  method: "POST";
305
305
  body: z.ZodObject<{
306
306
  title: z.ZodString;
307
+ boardId: z.ZodString;
307
308
  createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
308
309
  updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
309
- boardId: z.ZodString;
310
310
  order: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
311
311
  }, z.core.$strip>;
312
312
  }, Column>;
@@ -314,9 +314,9 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
314
314
  method: "PUT";
315
315
  body: z.ZodObject<{
316
316
  title: z.ZodOptional<z.ZodString>;
317
+ boardId: z.ZodOptional<z.ZodString>;
317
318
  createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
318
319
  updatedAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
319
- boardId: z.ZodOptional<z.ZodString>;
320
320
  order: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodNumber>>>;
321
321
  }, z.core.$strip>;
322
322
  }, Column>;
@@ -5,12 +5,12 @@ import { B as Board, C as Column, T as Task, d as ColumnWithTasks } from '../../
5
5
 
6
6
  declare const createBoardSchema: z.ZodObject<{
7
7
  description: z.ZodOptional<z.ZodString>;
8
- createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
9
- updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
10
8
  name: z.ZodString;
11
9
  slug: z.ZodOptional<z.ZodString>;
12
10
  ownerId: z.ZodOptional<z.ZodString>;
13
11
  organizationId: z.ZodOptional<z.ZodString>;
12
+ createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
13
+ updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
14
14
  }, z.core.$strip>;
15
15
  declare const updateBoardSchema: z.ZodObject<{
16
16
  createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
@@ -24,9 +24,9 @@ declare const updateBoardSchema: z.ZodObject<{
24
24
  }, z.core.$strip>;
25
25
  declare const createColumnSchema: z.ZodObject<{
26
26
  title: z.ZodString;
27
+ boardId: z.ZodString;
27
28
  createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
28
29
  updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
29
- boardId: z.ZodString;
30
30
  order: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
31
31
  }, z.core.$strip>;
32
32
  declare const updateColumnSchema: z.ZodObject<{
@@ -265,12 +265,12 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
265
265
  method: "POST";
266
266
  body: z.ZodObject<{
267
267
  description: z.ZodOptional<z.ZodString>;
268
- createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
269
- updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
270
268
  name: z.ZodString;
271
269
  slug: z.ZodOptional<z.ZodString>;
272
270
  ownerId: z.ZodOptional<z.ZodString>;
273
271
  organizationId: z.ZodOptional<z.ZodString>;
272
+ createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
273
+ updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
274
274
  }, z.core.$strip>;
275
275
  }, {
276
276
  columns: ColumnWithTasks[];
@@ -287,12 +287,12 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
287
287
  method: "PUT";
288
288
  body: z.ZodObject<{
289
289
  description: z.ZodOptional<z.ZodOptional<z.ZodString>>;
290
- createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
291
- updatedAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
292
290
  name: z.ZodOptional<z.ZodString>;
293
291
  slug: z.ZodOptional<z.ZodString>;
294
292
  ownerId: z.ZodOptional<z.ZodOptional<z.ZodString>>;
295
293
  organizationId: z.ZodOptional<z.ZodOptional<z.ZodString>>;
294
+ createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
295
+ updatedAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
296
296
  }, z.core.$strip>;
297
297
  }, Board>;
298
298
  readonly deleteBoard: better_call.StrictEndpoint<"/boards/:id", {
@@ -304,9 +304,9 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
304
304
  method: "POST";
305
305
  body: z.ZodObject<{
306
306
  title: z.ZodString;
307
+ boardId: z.ZodString;
307
308
  createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
308
309
  updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
309
- boardId: z.ZodString;
310
310
  order: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
311
311
  }, z.core.$strip>;
312
312
  }, Column>;
@@ -314,9 +314,9 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
314
314
  method: "PUT";
315
315
  body: z.ZodObject<{
316
316
  title: z.ZodOptional<z.ZodString>;
317
+ boardId: z.ZodOptional<z.ZodString>;
317
318
  createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
318
319
  updatedAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
319
- boardId: z.ZodOptional<z.ZodString>;
320
320
  order: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodNumber>>>;
321
321
  }, z.core.$strip>;
322
322
  }, Column>;
@@ -5,12 +5,12 @@ import { B as Board, C as Column, T as Task, d as ColumnWithTasks } from '../../
5
5
 
6
6
  declare const createBoardSchema: z.ZodObject<{
7
7
  description: z.ZodOptional<z.ZodString>;
8
- createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
9
- updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
10
8
  name: z.ZodString;
11
9
  slug: z.ZodOptional<z.ZodString>;
12
10
  ownerId: z.ZodOptional<z.ZodString>;
13
11
  organizationId: z.ZodOptional<z.ZodString>;
12
+ createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
13
+ updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
14
14
  }, z.core.$strip>;
15
15
  declare const updateBoardSchema: z.ZodObject<{
16
16
  createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
@@ -24,9 +24,9 @@ declare const updateBoardSchema: z.ZodObject<{
24
24
  }, z.core.$strip>;
25
25
  declare const createColumnSchema: z.ZodObject<{
26
26
  title: z.ZodString;
27
+ boardId: z.ZodString;
27
28
  createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
28
29
  updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
29
- boardId: z.ZodString;
30
30
  order: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
31
31
  }, z.core.$strip>;
32
32
  declare const updateColumnSchema: z.ZodObject<{
@@ -265,12 +265,12 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
265
265
  method: "POST";
266
266
  body: z.ZodObject<{
267
267
  description: z.ZodOptional<z.ZodString>;
268
- createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
269
- updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
270
268
  name: z.ZodString;
271
269
  slug: z.ZodOptional<z.ZodString>;
272
270
  ownerId: z.ZodOptional<z.ZodString>;
273
271
  organizationId: z.ZodOptional<z.ZodString>;
272
+ createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
273
+ updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
274
274
  }, z.core.$strip>;
275
275
  }, {
276
276
  columns: ColumnWithTasks[];
@@ -287,12 +287,12 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
287
287
  method: "PUT";
288
288
  body: z.ZodObject<{
289
289
  description: z.ZodOptional<z.ZodOptional<z.ZodString>>;
290
- createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
291
- updatedAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
292
290
  name: z.ZodOptional<z.ZodString>;
293
291
  slug: z.ZodOptional<z.ZodString>;
294
292
  ownerId: z.ZodOptional<z.ZodOptional<z.ZodString>>;
295
293
  organizationId: z.ZodOptional<z.ZodOptional<z.ZodString>>;
294
+ createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
295
+ updatedAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
296
296
  }, z.core.$strip>;
297
297
  }, Board>;
298
298
  readonly deleteBoard: better_call.StrictEndpoint<"/boards/:id", {
@@ -304,9 +304,9 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
304
304
  method: "POST";
305
305
  body: z.ZodObject<{
306
306
  title: z.ZodString;
307
+ boardId: z.ZodString;
307
308
  createdAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
308
309
  updatedAt: z.ZodOptional<z.ZodCoercedDate<unknown>>;
309
- boardId: z.ZodString;
310
310
  order: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
311
311
  }, z.core.$strip>;
312
312
  }, Column>;
@@ -314,9 +314,9 @@ declare const kanbanBackendPlugin: (hooks?: KanbanBackendHooks) => _btst_stack_p
314
314
  method: "PUT";
315
315
  body: z.ZodObject<{
316
316
  title: z.ZodOptional<z.ZodString>;
317
+ boardId: z.ZodOptional<z.ZodString>;
317
318
  createdAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
318
319
  updatedAt: z.ZodOptional<z.ZodOptional<z.ZodCoercedDate<unknown>>>;
319
- boardId: z.ZodOptional<z.ZodString>;
320
320
  order: z.ZodOptional<z.ZodDefault<z.ZodOptional<z.ZodNumber>>>;
321
321
  }, z.core.$strip>;
322
322
  }, Column>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btst/stack",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "A composable, plugin-based library for building full-stack applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -170,6 +170,12 @@ export function ChatInterface({
170
170
  transport,
171
171
  onError: (err) => {
172
172
  console.error("useChat onError:", err);
173
+ // Reset first-message tracking if the send failed before a conversation was created.
174
+ // Without this, isFirstMessageSentRef stays true and the next successful send
175
+ // skips the "first message" navigation logic, corrupting the conversation flow.
176
+ if (!id && !hasNavigatedRef.current) {
177
+ isFirstMessageSentRef.current = false;
178
+ }
173
179
  },
174
180
  onFinish: async () => {
175
181
  // In public mode, skip all persistence-related operations
@@ -271,8 +277,43 @@ export function ChatInterface({
271
277
  const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);
272
278
  const scrollRef = useRef<HTMLDivElement>(null);
273
279
 
274
- // Auto-scroll to bottom when messages change
280
+ // Track whether the user has manually scrolled away from the bottom.
281
+ // When true, auto-scroll is paused so the user can read earlier context.
282
+ const userHasScrolledRef = useRef(false);
283
+ const prevStatusRef = useRef(status);
284
+
285
+ // Reset the scroll lock when a new generation starts so auto-scroll
286
+ // resumes for the next assistant response.
275
287
  useEffect(() => {
288
+ if (
289
+ status !== prevStatusRef.current &&
290
+ (status === "streaming" || status === "submitted")
291
+ ) {
292
+ userHasScrolledRef.current = false;
293
+ }
294
+ prevStatusRef.current = status;
295
+ }, [status]);
296
+
297
+ // Attach a scroll listener to detect when the user scrolls away from the bottom.
298
+ useEffect(() => {
299
+ const viewport = scrollRef.current?.querySelector(
300
+ "[data-radix-scroll-area-viewport]",
301
+ );
302
+ if (!viewport) return;
303
+
304
+ const handleScroll = () => {
305
+ const { scrollTop, scrollHeight, clientHeight } = viewport;
306
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
307
+ userHasScrolledRef.current = !isNearBottom;
308
+ };
309
+
310
+ viewport.addEventListener("scroll", handleScroll);
311
+ return () => viewport.removeEventListener("scroll", handleScroll);
312
+ }, []);
313
+
314
+ // Auto-scroll to bottom when messages change, unless the user has scrolled away
315
+ useEffect(() => {
316
+ if (userHasScrolledRef.current) return;
276
317
  if (scrollRef.current) {
277
318
  const scrollElement = scrollRef.current.querySelector(
278
319
  "[data-radix-scroll-area-viewport]",
@@ -309,10 +350,24 @@ export function ChatInterface({
309
350
  isFirstMessageSentRef.current = true;
310
351
  }
311
352
 
353
+ // Re-enable auto-scroll so the user's own message (and any subsequent
354
+ // error indicator or assistant reply) is scrolled into view. Without
355
+ // this, if the user had scrolled up earlier, userHasScrolledRef stays
356
+ // true and none of the new content would be auto-scrolled to — and if
357
+ // the request fails before reaching "streaming" status the ref would
358
+ // remain stuck permanently.
359
+ userHasScrolledRef.current = false;
360
+
312
361
  // Save current values before clearing - we'll restore them if send fails
313
362
  const savedInput = input;
314
363
  const savedFiles = files ? [...files] : [];
315
364
 
365
+ // Capture the message count before sending so we can restore to this
366
+ // exact point on failure. The SDK may append both a user message and a
367
+ // partial assistant message during streaming — using a fixed snapshot
368
+ // length removes all of them instead of just the last one.
369
+ const messageCountBeforeSend = messages.length;
370
+
316
371
  // Clear input immediately (optimistically) - the AI SDK renders messages optimistically,
317
372
  // so we need to clear the input before the message appears to avoid duplicate text
318
373
  setInput("");
@@ -341,6 +396,13 @@ export function ChatInterface({
341
396
  // Restore input on failure so user can retry
342
397
  setInput(savedInput);
343
398
  setAttachedFiles(savedFiles);
399
+ // Reset first-message tracking so the next attempt still triggers navigation
400
+ if (isFirstMessageSentRef.current && !hasNavigatedRef.current) {
401
+ isFirstMessageSentRef.current = false;
402
+ }
403
+ // Remove all messages the SDK added after our send attempt (optimistic
404
+ // user message AND any partial assistant message from a mid-stream failure).
405
+ setMessages((prev) => prev.slice(0, messageCountBeforeSend));
344
406
  console.error("Error sending message:", error);
345
407
  }
346
408
  };
@@ -410,6 +472,7 @@ export function ChatInterface({
410
472
  className,
411
473
  )}
412
474
  data-testid="chat-interface"
475
+ data-chat-status={status}
413
476
  >
414
477
  {/* Messages Area */}
415
478
  <ScrollArea ref={scrollRef} className="flex-1 h-full">