Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 56 additions & 41 deletions src/components/ClaudeCodeSession.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Copy,
Expand All @@ -16,7 +16,6 @@ import { Popover } from "@/components/ui/popover";
import { api, type Session } from "@/lib/api";
import { cn } from "@/lib/utils";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { StreamMessage } from "./StreamMessage";
import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput";
import { ErrorBoundary } from "./ErrorBoundary";
import { TimelineNavigator } from "./TimelineNavigator";
Expand All @@ -28,6 +27,7 @@ import { SplitPane } from "@/components/ui/split-pane";
import { WebviewPreview } from "./WebviewPreview";
import type { ClaudeStreamMessage } from "./AgentExecution";
import { useVirtualizer } from "@tanstack/react-virtual";
import { StreamMessage } from "./StreamMessage";
import { useTrackEvent, useComponentMetrics, useWorkflowTracking } from "@/hooks";
import { SessionPersistenceService } from "@/services/sessionPersistence";

Expand Down Expand Up @@ -78,7 +78,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const [projectPath] = useState(initialProjectPath || session?.project_path || "");
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, setError] = useState<string | null>(null);
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
const [isFirstPrompt, setIsFirstPrompt] = useState(!session);
Expand Down Expand Up @@ -167,7 +167,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({

// Filter out messages that shouldn't be displayed
const displayableMessages = useMemo(() => {
return messages.filter((message, index) => {
const filtered = messages.filter((message, index) => {
// Skip meta messages that don't have meaningful content
if (message.isMeta && !message.leafUuid && !message.summary) {
return false;
Expand Down Expand Up @@ -196,13 +196,13 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
for (let i = index - 1; i >= 0; i--) {
const prevMsg = messages[i];
if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) {
const toolUse = prevMsg.message.content.find((c: any) =>
const toolUse = prevMsg.message.content.find((c: any) =>
c.type === 'tool_use' && c.id === content.tool_use_id
);
if (toolUse) {
const toolName = toolUse.name?.toLowerCase();
const toolsWithWidgets = [
'task', 'edit', 'multiedit', 'todowrite', 'ls', 'read',
'task', 'edit', 'multiedit', 'todowrite', 'ls', 'read',
'glob', 'bash', 'write', 'grep'
];
if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) {
Expand All @@ -226,15 +226,36 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
}
return true;
});
}, [messages]);

return filtered;
}, [messages, session?.id]);

// Stable key to remount list when the session changes
const virtualizerKey = useMemo(() => `virtualizer-${session?.id || 'new'}`, [session?.id]);
const rowVirtualizer = useVirtualizer({
count: displayableMessages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 150, // Estimate, will be dynamically measured
estimateSize: () => 150,
overscan: 5,
});

// Ensure the latest item stays in view when messages update
useLayoutEffect(() => {
if (displayableMessages.length > 0) {
requestAnimationFrame(() => {
try {
rowVirtualizer.scrollToIndex(displayableMessages.length - 1, {
align: 'end',
behavior: 'smooth',
});
} catch {
const el = parentRef.current;
if (el) el.scrollTop = el.scrollHeight;
}
});
}
}, [displayableMessages.length, rowVirtualizer]);

// Debug logging
useEffect(() => {
console.log('[ClaudeCodeSession] State update:', {
Expand All @@ -247,6 +268,15 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
});
}, [projectPath, session, extractedSessionInfo, effectiveSession, messages.length, isLoading]);

// Reset state quickly on session change to avoid race conditions on some platforms
useLayoutEffect(() => {
if (session) {
setMessages([]);
setError(null);
setIsLoading(true);
}
}, [session?.id]);

// Load session history if resuming
useEffect(() => {
if (session) {
Expand All @@ -271,12 +301,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
onStreamingChange?.(isLoading, claudeSessionId);
}, [isLoading, claudeSessionId, onStreamingChange]);

// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (displayableMessages.length > 0) {
rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: 'end', behavior: 'smooth' });
}
}, [displayableMessages.length, rowVirtualizer]);
// Smooth scroll helper handled directly where needed

// Calculate total tokens from messages
useEffect(() => {
Expand Down Expand Up @@ -324,11 +349,16 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
setIsFirstPrompt(false);

// Scroll to bottom after loading history
setTimeout(() => {
if (loadedMessages.length > 0) {
rowVirtualizer.scrollToIndex(loadedMessages.length - 1, { align: 'end', behavior: 'auto' });
}
}, 100);
requestAnimationFrame(() => {
setTimeout(() => {
try {
rowVirtualizer.scrollToIndex(loadedMessages.length - 1, { align: 'end', behavior: 'auto' });
} catch {
const el = parentRef.current;
if (el) el.scrollTop = el.scrollHeight;
}
}, 100);
});
} catch (err) {
console.error("Failed to load session history:", err);
setError("Failed to load session history");
Expand Down Expand Up @@ -1140,11 +1170,10 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({

const messagesList = (
<div
key={virtualizerKey}
ref={parentRef}
className="flex-1 overflow-y-auto relative pb-40"
style={{
contain: 'strict',
}}
style={{ contain: 'strict' }}
>
<div
className="relative w-full max-w-6xl mx-auto px-4 pt-8 pb-4"
Expand All @@ -1156,22 +1185,21 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<AnimatePresence>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
const message = displayableMessages[virtualItem.index];
if (!message) return null;
return (
<motion.div
key={virtualItem.key}
key={`${virtualizerKey}-${virtualItem.key}`}
data-index={virtualItem.index}
ref={(el) => el && rowVirtualizer.measureElement(el)}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.3 }}
className="absolute inset-x-4 pb-4"
style={{
top: virtualItem.start,
}}
style={{ top: virtualItem.start }}
>
<StreamMessage
message={message}
<StreamMessage
message={message}
streamMessages={messages}
onLinkDetected={handleLinkDetected}
/>
Expand All @@ -1181,7 +1209,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</AnimatePresence>
</div>

{/* Loading indicator under the latest message */}
{isLoading && (
<motion.div
initial={{ opacity: 0, y: 8 }}
Expand All @@ -1192,18 +1219,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<div className="rotating-symbol text-primary" />
</motion.div>
)}

{/* Error indicator */}
{error && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive mb-40 w-full max-w-6xl mx-auto"
>
{error}
</motion.div>
)}
</div>
);

Expand Down