Skip to content

Commit a94de07

Browse files
staticoclaude
andcommitted
fix: auto-scroll, project switching, and activity indicators
- Fix auto-scroll: ScrollArea ref was on wrapper not viewport, so chat never scrolled to show new messages/thinking indicators. Use bottom anchor div with scrollIntoView instead. - Fix project switching: reset chat state (messages, loading, activity) when projectId changes, abort in-flight requests, and guard against stale responses overwriting current project data. - Fix activity indicator: always show during loading (default to "Thinking..."), set initial activity to "Thinking..." on send. - Fix readonly input: reset isLoading in loadHistory catch block so textarea doesn't stay disabled after failed history loads. - Add structured logging throughout: [useChat:*], [claude-stream:*], [chat-api:*] for debugging streaming and project switch issues. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 622c237 commit a94de07

4 files changed

Lines changed: 100 additions & 25 deletions

File tree

src/app/api/projects/[id]/chat/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@ import { startClaude, isSessionActive, subscribe, StreamEvent } from "@/lib/clau
1212

1313
export const dynamic = "force-dynamic";
1414

15+
function log(prefix: string, ...args: unknown[]) {
16+
console.log(`[chat-api:${prefix}]`, ...args);
17+
}
18+
1519
export async function GET(
1620
request: NextRequest,
1721
{ params }: { params: Promise<{ id: string }> }
1822
) {
1923
const { id } = await params;
2024
const reconnect = request.nextUrl.searchParams.get("reconnect");
25+
log("GET", `id=${id} reconnect=${reconnect}`);
2126

2227
// Check for active session and stream events
2328
if (reconnect === "1") {
@@ -73,6 +78,7 @@ export async function POST(
7378

7479
const body = await request.json();
7580
const userMessage = body.message as string;
81+
log("POST", `id=${id} message="${userMessage?.slice(0, 80)}"`);
7682

7783
if (!userMessage?.trim()) {
7884
return NextResponse.json({ error: "Message required" }, { status: 400 });

src/components/chat/chat-panel.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,11 @@ export function ChatPanel({
3232
onUndo,
3333
onShowPreview,
3434
}: ChatPanelProps) {
35-
const scrollRef = useRef<HTMLDivElement>(null);
35+
const bottomRef = useRef<HTMLDivElement>(null);
3636

3737
useEffect(() => {
38-
// Auto-scroll to bottom on new messages
39-
if (scrollRef.current) {
40-
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
41-
}
38+
// Auto-scroll to bottom on new messages or activity changes
39+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
4240
}, [messages, activity]);
4341

4442
return (
@@ -56,7 +54,7 @@ export function ChatPanel({
5654
</Button>
5755
</div>
5856
)}
59-
<ScrollArea className="min-h-0 flex-1 p-4" ref={scrollRef}>
57+
<ScrollArea className="min-h-0 flex-1 p-4">
6058
<div className="mx-auto max-w-2xl space-y-4">
6159
{messages.length === 0 && (
6260
<div className="flex flex-col items-center justify-center py-20 text-center">
@@ -71,7 +69,8 @@ export function ChatPanel({
7169
{messages.map((msg, i) => (
7270
<MessageBubble key={i} message={msg} />
7371
))}
74-
{isLoading && activity && <ActivityIndicator activity={activity} />}
72+
{isLoading && <ActivityIndicator activity={activity || "Thinking..."} />}
73+
<div ref={bottomRef} />
7574
</div>
7675
</ScrollArea>
7776
<div className="border-t p-4">

src/hooks/use-chat.ts

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useCallback, useRef } from "react";
3+
import { useState, useCallback, useRef, useEffect } from "react";
44

55
export interface ChatMessage {
66
role: "user" | "assistant";
@@ -19,19 +19,39 @@ interface UseChatOptions {
1919
onFileChange?: (fileName: string) => void;
2020
}
2121

22+
function log(prefix: string, ...args: unknown[]) {
23+
console.log(`[useChat:${prefix}]`, ...args);
24+
}
25+
2226
export function useChat({ projectId, onTitle, onFileChange }: UseChatOptions) {
2327
const [messages, setMessages] = useState<ChatMessage[]>([]);
2428
const [isLoading, setIsLoading] = useState(false);
2529
const [activity, setActivity] = useState<string>("");
2630
const abortRef = useRef<AbortController | null>(null);
31+
const projectIdRef = useRef(projectId);
32+
33+
// Reset state when projectId changes to prevent showing stale data
34+
useEffect(() => {
35+
if (projectIdRef.current !== projectId) {
36+
log("reset", `project changed: ${projectIdRef.current} -> ${projectId}`);
37+
projectIdRef.current = projectId;
38+
abortRef.current?.abort();
39+
abortRef.current = null;
40+
setMessages([]);
41+
setIsLoading(false);
42+
setActivity("");
43+
}
44+
}, [projectId]);
2745

2846
const processSSEStream = useCallback(
29-
async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
47+
async (reader: ReadableStreamDefaultReader<Uint8Array>, forProjectId: string) => {
3048
const decoder = new TextDecoder();
3149
let buffer = "";
3250
let assistantText = "";
3351
let gotTitle = false;
3452

53+
log("stream", `starting SSE processing for project=${forProjectId}`);
54+
3555
// Add placeholder assistant message
3656
setMessages((prev) => [
3757
...prev,
@@ -40,7 +60,17 @@ export function useChat({ projectId, onTitle, onFileChange }: UseChatOptions) {
4060

4161
while (true) {
4262
const { done, value } = await reader.read();
43-
if (done) break;
63+
if (done) {
64+
log("stream", "reader done");
65+
break;
66+
}
67+
68+
// Guard against stale project
69+
if (projectIdRef.current !== forProjectId) {
70+
log("stream", `stale stream for ${forProjectId}, current is ${projectIdRef.current} — dropping`);
71+
reader.cancel();
72+
return;
73+
}
4474

4575
buffer += decoder.decode(value as BufferSource, { stream: true });
4676
const lines = buffer.split("\n");
@@ -50,6 +80,7 @@ export function useChat({ projectId, onTitle, onFileChange }: UseChatOptions) {
5080
if (line.startsWith("data: ")) {
5181
try {
5282
const event = JSON.parse(line.slice(6));
83+
log("event", event.type, event.type === "text" ? `(${(event.content as string).length} chars)` : event.content || "");
5384

5485
switch (event.type) {
5586
case "text": {
@@ -77,13 +108,16 @@ export function useChat({ projectId, onTitle, onFileChange }: UseChatOptions) {
77108
onTitle?.(event.content);
78109
break;
79110
case "activity":
111+
log("activity", event.content);
80112
setActivity(event.content);
81113
break;
82114
case "file-change":
115+
log("file-change", event.content, event.detail);
83116
onFileChange?.(event.content);
84117
setActivity(`Updated ${event.content}`);
85118
break;
86119
case "error":
120+
log("error", event.content);
87121
assistantText += `\n\nError: ${event.content}`;
88122
setMessages((prev) => {
89123
const updated = [...prev];
@@ -95,10 +129,11 @@ export function useChat({ projectId, onTitle, onFileChange }: UseChatOptions) {
95129
});
96130
break;
97131
case "done":
132+
log("done", `total text length: ${assistantText.length}`);
98133
break;
99134
}
100-
} catch {
101-
// ignore parse errors
135+
} catch (e) {
136+
log("parse-error", line.slice(0, 200), e);
102137
}
103138
}
104139
}
@@ -108,51 +143,72 @@ export function useChat({ projectId, onTitle, onFileChange }: UseChatOptions) {
108143
);
109144

110145
const loadHistory = useCallback(async () => {
146+
const loadForId = projectId;
147+
log("loadHistory", `starting for project=${loadForId}`);
148+
111149
try {
112150
// First check for active session to reconnect to
113-
const reconnectRes = await fetch(`/api/projects/${projectId}/chat?reconnect=1`);
151+
const reconnectRes = await fetch(`/api/projects/${loadForId}/chat?reconnect=1`);
152+
153+
if (projectIdRef.current !== loadForId) {
154+
log("loadHistory", `stale after reconnect check — aborting`);
155+
return;
156+
}
114157

115158
if (reconnectRes.headers.get("Content-Type")?.includes("text/event-stream")) {
159+
log("loadHistory", "active session found, reconnecting...");
116160
// Active session found — load saved history first, then stream remaining
117-
const historyRes = await fetch(`/api/projects/${projectId}/chat`);
118-
if (historyRes.ok) {
161+
const historyRes = await fetch(`/api/projects/${loadForId}/chat`);
162+
if (historyRes.ok && projectIdRef.current === loadForId) {
119163
const data = await historyRes.json();
164+
log("loadHistory", `loaded ${data.length} history messages before reconnect`);
120165
setMessages(data);
121166
}
122167

123168
setIsLoading(true);
124169
setActivity("Reconnecting...");
125170

126171
const reader = reconnectRes.body!.getReader();
127-
await processSSEStream(reader);
172+
await processSSEStream(reader, loadForId);
173+
174+
if (projectIdRef.current !== loadForId) return;
128175

129176
setIsLoading(false);
130177
setActivity("");
131178

132179
// Reload history to get the final saved state
133-
const finalRes = await fetch(`/api/projects/${projectId}/chat`);
134-
if (finalRes.ok) {
180+
const finalRes = await fetch(`/api/projects/${loadForId}/chat`);
181+
if (finalRes.ok && projectIdRef.current === loadForId) {
135182
const data = await finalRes.json();
183+
log("loadHistory", `reloaded ${data.length} messages after reconnect`);
136184
setMessages(data);
137185
}
138186
return;
139187
}
140188

141189
// No active session — just load history normally
142-
const res = await fetch(`/api/projects/${projectId}/chat`);
143-
if (res.ok) {
190+
const res = await fetch(`/api/projects/${loadForId}/chat`);
191+
if (res.ok && projectIdRef.current === loadForId) {
144192
const data = await res.json();
193+
log("loadHistory", `loaded ${data.length} history messages`);
145194
setMessages(data);
195+
} else if (projectIdRef.current !== loadForId) {
196+
log("loadHistory", `stale after history fetch — dropping`);
146197
}
147-
} catch {
148-
// ignore
198+
} catch (e) {
199+
log("loadHistory", "error:", e);
200+
setIsLoading(false);
201+
setActivity("");
149202
}
150203
}, [projectId, processSSEStream]);
151204

152205
const sendMessage = useCallback(
153206
async (content: string) => {
154207
if (!content.trim() || isLoading) return;
155208

209+
const sendForId = projectId;
210+
log("send", `message to project=${sendForId}: "${content.slice(0, 50)}..."`);
211+
156212
const userMsg: ChatMessage = {
157213
role: "user",
158214
content,
@@ -161,11 +217,11 @@ export function useChat({ projectId, onTitle, onFileChange }: UseChatOptions) {
161217

162218
setMessages((prev) => [...prev, userMsg]);
163219
setIsLoading(true);
164-
setActivity("");
220+
setActivity("Thinking...");
165221

166222
try {
167223
abortRef.current = new AbortController();
168-
const res = await fetch(`/api/projects/${projectId}/chat`, {
224+
const res = await fetch(`/api/projects/${sendForId}/chat`, {
169225
method: "POST",
170226
headers: { "Content-Type": "application/json" },
171227
body: JSON.stringify({ message: content }),
@@ -176,10 +232,12 @@ export function useChat({ projectId, onTitle, onFileChange }: UseChatOptions) {
176232
throw new Error(`HTTP ${res.status}`);
177233
}
178234

235+
log("send", `POST response ok, reading stream...`);
179236
const reader = res.body!.getReader();
180-
await processSSEStream(reader);
237+
await processSSEStream(reader, sendForId);
181238
} catch (err) {
182239
if ((err as Error).name !== "AbortError") {
240+
log("send", "error:", err);
183241
setMessages((prev) => {
184242
const updated = [...prev];
185243
const last = updated[updated.length - 1];
@@ -202,6 +260,7 @@ export function useChat({ projectId, onTitle, onFileChange }: UseChatOptions) {
202260
);
203261

204262
const stop = useCallback(() => {
263+
log("stop", "aborting");
205264
abortRef.current?.abort();
206265
}, []);
207266

src/lib/claude-stream.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { spawn, ChildProcess } from "child_process";
22
import path from "path";
33
import { SYSTEM_PROMPT } from "./constants";
44

5+
function log(prefix: string, ...args: unknown[]) {
6+
console.log(`[claude-stream:${prefix}]`, ...args);
7+
}
8+
59
export interface StreamEvent {
610
type: "text" | "activity" | "file-change" | "title" | "done" | "error";
711
content: string;
@@ -114,6 +118,7 @@ export function startClaude(
114118
let seenTitle = false;
115119

116120
function emit(event: StreamEvent) {
121+
log("emit", `[${projectId}] ${event.type}: ${event.content?.slice(0, 100)}`);
117122
session.eventBuffer.push(event);
118123
for (const sub of session.subscribers) {
119124
sub(event);
@@ -127,10 +132,12 @@ export function startClaude(
127132
try {
128133
data = JSON.parse(line);
129134
} catch {
135+
log("parse", `[${projectId}] failed to parse: ${line.slice(0, 200)}`);
130136
return;
131137
}
132138

133139
const type = data.type as string;
140+
log("json", `[${projectId}] type=${type}`);
134141

135142
if (type === "assistant") {
136143
const message = data.message as Record<string, unknown> | undefined;
@@ -205,12 +212,16 @@ export function startClaude(
205212

206213
claudeProcess.stderr!.on("data", (chunk: Buffer) => {
207214
const text = chunk.toString();
215+
log("stderr", `[${projectId}] ${text.slice(0, 500)}`);
208216
if (text.includes("Error") || text.includes("error")) {
209217
emit({ type: "error", content: text.trim() });
210218
}
211219
});
212220

221+
log("start", `[${projectId}] Claude process started, pid=${claudeProcess.pid}`);
222+
213223
claudeProcess.on("close", (code) => {
224+
log("close", `[${projectId}] Claude process exited code=${code}`);
214225
if (buffer.trim()) {
215226
processJsonLine(buffer);
216227
}

0 commit comments

Comments
 (0)