feat(ui): enhance message rendering with thinking widget and session improvements
- Add ThinkingWidget component for displaying AI reasoning content in collapsible interface - Improve session initialization by removing redundant event listener and enhancing ID extraction - Enhance StreamMessage component to handle diverse content structures and thinking content - Add comprehensive debug logging for better message structure understanding - Fix cost display logic to handle both cost_usd and total_cost_usd fields - Refactor user message rendering to support both nested and direct content structures
This commit is contained in:
@@ -285,16 +285,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
// Set up event listeners before executing
|
// Set up event listeners before executing
|
||||||
console.log('[ClaudeCodeSession] Setting up event listeners...');
|
console.log('[ClaudeCodeSession] Setting up event listeners...');
|
||||||
|
|
||||||
// Listen for the session started event to get the Claude session ID
|
|
||||||
const sessionStartedUnlisten = await listen<string>(`claude-session-started:*`, (event) => {
|
|
||||||
const eventName = event.event;
|
|
||||||
const sessionId = eventName.split(':')[1];
|
|
||||||
if (sessionId && !claudeSessionId) {
|
|
||||||
console.log('[ClaudeCodeSession] Received Claude session ID:', sessionId);
|
|
||||||
setClaudeSessionId(sessionId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we already have a Claude session ID, use isolated listeners
|
// If we already have a Claude session ID, use isolated listeners
|
||||||
const eventSuffix = claudeSessionId ? `:${claudeSessionId}` : '';
|
const eventSuffix = claudeSessionId ? `:${claudeSessionId}` : '';
|
||||||
|
|
||||||
@@ -315,14 +305,22 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Extract session info from system init message
|
// Extract session info from system init message
|
||||||
if (message.type === "system" && message.subtype === "init" && message.session_id && !extractedSessionInfo) {
|
if (message.type === "system" && message.subtype === "init" && message.session_id) {
|
||||||
console.log('[ClaudeCodeSession] Extracting session info from init message');
|
console.log('[ClaudeCodeSession] Extracting session info from init message');
|
||||||
// Extract project ID from the project path
|
// Extract project ID from the project path
|
||||||
const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
|
const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
setExtractedSessionInfo({
|
|
||||||
sessionId: message.session_id,
|
// Set both claudeSessionId and extractedSessionInfo
|
||||||
projectId: projectId
|
if (!claudeSessionId) {
|
||||||
});
|
setClaudeSessionId(message.session_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!extractedSessionInfo) {
|
||||||
|
setExtractedSessionInfo({
|
||||||
|
sessionId: message.session_id,
|
||||||
|
projectId: projectId
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to parse message:", err, event.payload);
|
console.error("Failed to parse message:", err, event.payload);
|
||||||
@@ -364,7 +362,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
unlistenRefs.current = [sessionStartedUnlisten, outputUnlisten, errorUnlisten, completeUnlisten];
|
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten];
|
||||||
|
|
||||||
// Add the user message immediately to the UI (after setting up listeners)
|
// Add the user message immediately to the UI (after setting up listeners)
|
||||||
const userMessage: ClaudeStreamMessage = {
|
const userMessage: ClaudeStreamMessage = {
|
||||||
|
@@ -33,7 +33,8 @@ import {
|
|||||||
SystemReminderWidget,
|
SystemReminderWidget,
|
||||||
SystemInitializedWidget,
|
SystemInitializedWidget,
|
||||||
TaskWidget,
|
TaskWidget,
|
||||||
LSResultWidget
|
LSResultWidget,
|
||||||
|
ThinkingWidget
|
||||||
} from "./ToolWidgets";
|
} from "./ToolWidgets";
|
||||||
|
|
||||||
interface StreamMessageProps {
|
interface StreamMessageProps {
|
||||||
@@ -73,6 +74,15 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
|
|||||||
if (!toolId) return null;
|
if (!toolId) return null;
|
||||||
return toolResults.get(toolId) || null;
|
return toolResults.get(toolId) || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Debug logging to understand message structure
|
||||||
|
console.log('[StreamMessage] Rendering message:', {
|
||||||
|
type: message.type,
|
||||||
|
hasMessage: !!message.message,
|
||||||
|
messageStructure: message.message ? Object.keys(message.message) : 'no message field',
|
||||||
|
fullMessage: message
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Skip rendering for meta messages that don't have meaningful content
|
// Skip rendering for meta messages that don't have meaningful content
|
||||||
if (message.isMeta && !message.leafUuid && !message.summary) {
|
if (message.isMeta && !message.leafUuid && !message.summary) {
|
||||||
@@ -147,6 +157,19 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thinking content - render with ThinkingWidget
|
||||||
|
if (content.type === "thinking") {
|
||||||
|
renderedSomething = true;
|
||||||
|
return (
|
||||||
|
<div key={idx}>
|
||||||
|
<ThinkingWidget
|
||||||
|
thinking={content.thinking || ''}
|
||||||
|
signature={content.signature}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Tool use - render custom widgets based on tool name
|
// Tool use - render custom widgets based on tool name
|
||||||
if (content.type === "tool_use") {
|
if (content.type === "tool_use") {
|
||||||
const toolName = content.name?.toLowerCase();
|
const toolName = content.name?.toLowerCase();
|
||||||
@@ -258,6 +281,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{msg.usage && (
|
{msg.usage && (
|
||||||
<div className="text-xs text-muted-foreground mt-2">
|
<div className="text-xs text-muted-foreground mt-2">
|
||||||
Tokens: {msg.usage.input_tokens} in, {msg.usage.output_tokens} out
|
Tokens: {msg.usage.input_tokens} in, {msg.usage.output_tokens} out
|
||||||
@@ -268,16 +292,18 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!renderedSomething) return null;
|
if (!renderedSomething) return null;
|
||||||
return renderedCard;
|
return renderedCard;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User message
|
// User message - handle both nested and direct content structures
|
||||||
if (message.type === "user" && message.message) {
|
if (message.type === "user") {
|
||||||
// Don't render meta messages, which are for system use
|
// Don't render meta messages, which are for system use
|
||||||
if (message.isMeta) return null;
|
if (message.isMeta) return null;
|
||||||
|
|
||||||
const msg = message.message;
|
// Handle different message structures
|
||||||
|
const msg = message.message || message;
|
||||||
|
|
||||||
let renderedSomething = false;
|
let renderedSomething = false;
|
||||||
|
|
||||||
@@ -288,9 +314,9 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
|
|||||||
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||||
<div className="flex-1 space-y-2 min-w-0">
|
<div className="flex-1 space-y-2 min-w-0">
|
||||||
{/* Handle content that is a simple string (e.g. from user commands) */}
|
{/* Handle content that is a simple string (e.g. from user commands) */}
|
||||||
{typeof msg.content === 'string' && (
|
{(typeof msg.content === 'string' || (msg.content && !Array.isArray(msg.content))) && (
|
||||||
(() => {
|
(() => {
|
||||||
const contentStr = msg.content as string;
|
const contentStr = typeof msg.content === 'string' ? msg.content : String(msg.content);
|
||||||
if (contentStr.trim() === '') return null;
|
if (contentStr.trim() === '') return null;
|
||||||
renderedSomething = true;
|
renderedSomething = true;
|
||||||
|
|
||||||
@@ -316,9 +342,9 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
|
|||||||
|
|
||||||
// Otherwise render as plain text
|
// Otherwise render as plain text
|
||||||
return (
|
return (
|
||||||
<pre className="text-sm font-mono whitespace-pre-wrap text-muted-foreground">
|
<div className="text-sm">
|
||||||
{contentStr}
|
{contentStr}
|
||||||
</pre>
|
</div>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
)}
|
)}
|
||||||
@@ -646,8 +672,8 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground space-y-1 mt-2">
|
<div className="text-xs text-muted-foreground space-y-1 mt-2">
|
||||||
{message.cost_usd !== undefined && (
|
{(message.cost_usd !== undefined || message.total_cost_usd !== undefined) && (
|
||||||
<div>Cost: ${message.cost_usd.toFixed(4)} USD</div>
|
<div>Cost: ${((message.cost_usd || message.total_cost_usd)!).toFixed(4)} USD</div>
|
||||||
)}
|
)}
|
||||||
{message.duration_ms !== undefined && (
|
{message.duration_ms !== undefined && (
|
||||||
<div>Duration: {(message.duration_ms / 1000).toFixed(2)}s</div>
|
<div>Duration: {(message.duration_ms / 1000).toFixed(2)}s</div>
|
||||||
|
@@ -1869,3 +1869,53 @@ export const TaskWidget: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget for displaying AI thinking/reasoning content
|
||||||
|
* Collapsible and closed by default
|
||||||
|
*/
|
||||||
|
export const ThinkingWidget: React.FC<{
|
||||||
|
thinking: string;
|
||||||
|
signature?: string;
|
||||||
|
}> = ({ thinking, signature }) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-purple-500/20 bg-gradient-to-br from-purple-500/5 to-violet-500/5 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-purple-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Bot className="h-4 w-4 text-purple-500" />
|
||||||
|
<Sparkles className="h-2.5 w-2.5 text-purple-400 absolute -top-1 -right-1 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-purple-600 dark:text-purple-400">
|
||||||
|
Thinking...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className={cn(
|
||||||
|
"h-4 w-4 text-purple-500 transition-transform",
|
||||||
|
isExpanded && "rotate-90"
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 pt-2 space-y-3 border-t border-purple-500/20">
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<pre className="text-xs font-mono text-purple-700 dark:text-purple-300 whitespace-pre-wrap bg-purple-500/5 p-3 rounded-lg">
|
||||||
|
{thinking}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{signature && (
|
||||||
|
<div className="text-xs text-purple-600/60 dark:text-purple-400/60 font-mono truncate">
|
||||||
|
Signature: {signature.slice(0, 16)}...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Reference in New Issue
Block a user