feat: interactive AskUserQuestion + WebSocket stability
- Add WebSocket heartbeat (30s ping/pong) to prevent proxy timeouts - Add auto-reconnect with exponential backoff (1s-30s, max 10 attempts) - Add interactive AskUserQuestion rendering with clickable options - Add custom input field for free-text answers - Add smooth animations (hover, selection glow, checkbox scale) - Make interactive tool cards wider (max-w-2xl) without scrolling - Hide error badge and result section for interactive tools - Use TextareaAutosize for lag-free custom input - Add Skill, SlashCommand tool renderings - Add ThinkingBlock component for collapsible <thinking> tags 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -182,6 +182,7 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
||||
<MessageList
|
||||
messages={session.messages || []}
|
||||
isProcessing={session.isProcessing}
|
||||
onSendMessage={handleSendMessage}
|
||||
/>
|
||||
) : (
|
||||
<WelcomeScreen session={session} onStart={start} />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState, useMemo, memo, useCallback } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import {
|
||||
User, Bot, Terminal, CheckCircle, AlertCircle, Info,
|
||||
FileText, Search, FolderSearch, Pencil, FilePlus, Globe,
|
||||
ChevronDown, ChevronRight, Play, ArrowDown,
|
||||
ClipboardList, Brain, Paperclip, Image
|
||||
ClipboardList, Brain, Paperclip, Image, HelpCircle, Zap, Command
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -25,6 +26,50 @@ function parseSystemReminders(text) {
|
||||
return { content, reminders };
|
||||
}
|
||||
|
||||
// Helper to extract thinking tags from content
|
||||
function parseThinking(text) {
|
||||
if (!text || typeof text !== 'string') return { content: text || '', thinking: [] };
|
||||
|
||||
const thinking = [];
|
||||
const thinkingRegex = /<thinking>([\s\S]*?)<\/thinking>/g;
|
||||
let match;
|
||||
while ((match = thinkingRegex.exec(text)) !== null) {
|
||||
thinking.push(match[1].trim());
|
||||
}
|
||||
|
||||
const content = text.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim();
|
||||
return { content, thinking };
|
||||
}
|
||||
|
||||
// Collapsible thinking block component
|
||||
function ThinkingBlock({ thinking }) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
if (!thinking || thinking.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={() => setShow(!show)}
|
||||
className="flex items-center gap-2 text-xs text-purple-400 hover:text-purple-300 transition-colors"
|
||||
>
|
||||
<Brain className="w-3.5 h-3.5" />
|
||||
<span>Thinking</span>
|
||||
{show ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
{show && (
|
||||
<div className="mt-2 p-3 bg-purple-900/10 border border-purple-500/20 rounded-lg">
|
||||
{thinking.map((thought, idx) => (
|
||||
<div key={idx} className="text-sm text-purple-300/80 italic whitespace-pre-wrap">
|
||||
{thought}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Collapsible system hints component
|
||||
function SystemHints({ reminders, inline = false }) {
|
||||
const [show, setShow] = useState(false);
|
||||
@@ -72,7 +117,7 @@ function SystemHints({ reminders, inline = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
export const MessageList = memo(function MessageList({ messages, isProcessing }) {
|
||||
export const MessageList = memo(function MessageList({ messages, isProcessing, onSendMessage }) {
|
||||
const containerRef = useRef(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
@@ -183,7 +228,7 @@ export const MessageList = memo(function MessageList({ messages, isProcessing })
|
||||
className="h-full overflow-y-auto p-4 space-y-4"
|
||||
>
|
||||
{processedMessages.map((message, index) => (
|
||||
<Message key={`${message.type}-${message._originalIndex}-${message.timestamp || index}`} message={message} />
|
||||
<Message key={`${message.type}-${message._originalIndex}-${message.timestamp || index}`} message={message} onSendMessage={onSendMessage} />
|
||||
))}
|
||||
|
||||
{/* Processing indicator */}
|
||||
@@ -217,13 +262,19 @@ export const MessageList = memo(function MessageList({ messages, isProcessing })
|
||||
);
|
||||
});
|
||||
|
||||
const Message = memo(function Message({ message }) {
|
||||
const Message = memo(function Message({ message, onSendMessage }) {
|
||||
const { type, content, tool, input, timestamp, toolUseId, attachments } = message;
|
||||
|
||||
// Memoize parsed system reminders for assistant messages
|
||||
// Memoize parsed system reminders and thinking for assistant messages
|
||||
const parsedContent = useMemo(() => {
|
||||
if (type === 'assistant') {
|
||||
return parseSystemReminders(content);
|
||||
const withoutReminders = parseSystemReminders(content);
|
||||
const withoutThinking = parseThinking(withoutReminders.content);
|
||||
return {
|
||||
content: withoutThinking.content,
|
||||
reminders: withoutReminders.reminders,
|
||||
thinking: withoutThinking.thinking,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [type, content]);
|
||||
@@ -269,7 +320,7 @@ const Message = memo(function Message({ message }) {
|
||||
);
|
||||
|
||||
case 'assistant': {
|
||||
const { content: cleanContent, reminders } = parsedContent;
|
||||
const { content: cleanContent, reminders, thinking } = parsedContent;
|
||||
return (
|
||||
<div className="flex gap-3 message-enter">
|
||||
<div className="w-8 h-8 rounded-lg bg-orange-600 flex items-center justify-center flex-shrink-0">
|
||||
@@ -278,6 +329,7 @@ const Message = memo(function Message({ message }) {
|
||||
<div className="max-w-[85%]">
|
||||
<div className="text-xs text-dark-500 mb-1">Claude</div>
|
||||
<div className="bg-dark-850 rounded-lg rounded-tl-none p-4 border border-dark-700 claude-message inline-block">
|
||||
<ThinkingBlock thinking={thinking} />
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
@@ -318,7 +370,7 @@ const Message = memo(function Message({ message }) {
|
||||
case 'tool_use':
|
||||
// Note: ExitPlanMode approval now handled via PermissionDialog modal
|
||||
// Just show it as a normal tool use card in the message list
|
||||
return <ToolUseCard tool={tool} input={input} result={message._pairedResult} />;
|
||||
return <ToolUseCard tool={tool} input={input} result={message._pairedResult} onSendMessage={onSendMessage} />;
|
||||
|
||||
case 'tool_result':
|
||||
return <ToolResultCard content={content} isSuccess={!message.isError} />;
|
||||
@@ -456,16 +508,27 @@ const TOOL_CONFIG = {
|
||||
getSummary: (input) => input.task_id?.substring(0, 8) || 'task',
|
||||
},
|
||||
Skill: {
|
||||
icon: Play,
|
||||
icon: Zap,
|
||||
color: 'purple',
|
||||
label: 'Using skill',
|
||||
label: 'Launching skill',
|
||||
getSummary: (input) => input.skill || 'skill',
|
||||
},
|
||||
SlashCommand: {
|
||||
icon: Terminal,
|
||||
icon: Command,
|
||||
color: 'cyan',
|
||||
label: 'Running command',
|
||||
getSummary: (input) => input.command || 'command',
|
||||
getSummary: (input) => input.command?.split(' ')[0] || 'command',
|
||||
},
|
||||
AskUserQuestion: {
|
||||
icon: HelpCircle,
|
||||
color: 'yellow',
|
||||
label: 'Asking question',
|
||||
getSummary: (input) => {
|
||||
const questions = input.questions || [];
|
||||
if (questions.length === 1) return questions[0].header || 'question';
|
||||
return `${questions.length} questions`;
|
||||
},
|
||||
isInteractive: true,
|
||||
},
|
||||
ExitPlanMode: {
|
||||
icon: ClipboardList,
|
||||
@@ -541,8 +604,10 @@ const COLOR_CLASSES = {
|
||||
|
||||
// Note: PlanApprovalCard removed - ExitPlanMode approval now handled via PermissionDialog modal
|
||||
|
||||
const ToolUseCard = memo(function ToolUseCard({ tool, input, result }) {
|
||||
const ToolUseCard = memo(function ToolUseCard({ tool, input, result, onSendMessage }) {
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [selectedOptions, setSelectedOptions] = useState({});
|
||||
const [customInput, setCustomInput] = useState('');
|
||||
|
||||
const config = TOOL_CONFIG[tool] || {
|
||||
icon: Terminal,
|
||||
@@ -864,6 +929,187 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'Skill') {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm font-medium text-purple-400">{input.skill}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'SlashCommand') {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Command className="w-4 h-4 text-cyan-400" />
|
||||
<code className="text-sm text-cyan-400 bg-dark-800 px-2 py-0.5 rounded">{input.command}</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'AskUserQuestion') {
|
||||
const questions = input.questions || [];
|
||||
// Only consider it "answered" if there's a successful result (not an error)
|
||||
const hasResult = result && !result.isError;
|
||||
|
||||
// Handle option selection
|
||||
const handleOptionClick = (qIdx, optIdx, isMulti) => {
|
||||
if (hasResult) return; // Already answered
|
||||
setSelectedOptions(prev => {
|
||||
const key = `q${qIdx}`;
|
||||
if (isMulti) {
|
||||
const current = prev[key] || [];
|
||||
if (current.includes(optIdx)) {
|
||||
return { ...prev, [key]: current.filter(i => i !== optIdx) };
|
||||
}
|
||||
return { ...prev, [key]: [...current, optIdx] };
|
||||
}
|
||||
return { ...prev, [key]: [optIdx] };
|
||||
});
|
||||
};
|
||||
|
||||
// Submit selected answers or custom input
|
||||
const handleSubmit = () => {
|
||||
if (!onSendMessage || hasResult) return;
|
||||
|
||||
// If custom input is provided, use that
|
||||
if (customInput.trim()) {
|
||||
onSendMessage(customInput.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise use selected options
|
||||
const answers = questions.map((q, qIdx) => {
|
||||
const selected = selectedOptions[`q${qIdx}`] || [];
|
||||
const labels = selected.map(idx => q.options[idx]?.label).filter(Boolean);
|
||||
return labels.join(', ') || '';
|
||||
}).filter(Boolean);
|
||||
|
||||
if (answers.length > 0) {
|
||||
onSendMessage(answers.join('\n'));
|
||||
}
|
||||
};
|
||||
|
||||
const isOptionSelected = (qIdx, optIdx) => {
|
||||
return (selectedOptions[`q${qIdx}`] || []).includes(optIdx);
|
||||
};
|
||||
|
||||
const hasSelection = Object.values(selectedOptions).some(arr => arr.length > 0);
|
||||
const canSubmit = hasSelection || customInput.trim();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{questions.map((q, qIdx) => (
|
||||
<div key={qIdx} className="space-y-3">
|
||||
{/* Question header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 bg-yellow-500/20 text-yellow-400 text-xs font-medium rounded-md">
|
||||
{q.header}
|
||||
</span>
|
||||
{q.multiSelect && (
|
||||
<span className="text-xs text-dark-500">(select multiple)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Question text */}
|
||||
<p className="text-sm text-dark-200 leading-relaxed">{q.question}</p>
|
||||
|
||||
{/* Options as grid for better layout */}
|
||||
<div className="grid gap-2">
|
||||
{q.options?.map((opt, optIdx) => {
|
||||
const isSelected = isOptionSelected(qIdx, optIdx);
|
||||
return (
|
||||
<button
|
||||
key={optIdx}
|
||||
onClick={() => handleOptionClick(qIdx, optIdx, q.multiSelect)}
|
||||
disabled={hasResult}
|
||||
className={`w-full flex items-start gap-3 p-3 rounded-lg border text-left
|
||||
transition-all duration-200 ease-out
|
||||
${hasResult
|
||||
? 'bg-dark-800/30 border-dark-700 opacity-60 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-yellow-500/20 border-yellow-500/50 shadow-lg shadow-yellow-500/10 scale-[1.01]'
|
||||
: 'bg-dark-800/50 border-dark-700 hover:border-yellow-500/30 hover:bg-dark-800 hover:scale-[1.005]'
|
||||
}`}
|
||||
>
|
||||
<div className="mt-0.5 transition-transform duration-200">
|
||||
{q.multiSelect ? (
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all duration-200 ${
|
||||
isSelected ? 'border-yellow-500 bg-yellow-500 scale-110' : 'border-dark-500 bg-dark-700'
|
||||
}`}>
|
||||
{isSelected && <CheckCircle className="w-3.5 h-3.5 text-dark-900" />}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all duration-200 ${
|
||||
isSelected ? 'border-yellow-500 bg-yellow-500 scale-110' : 'border-dark-500 bg-dark-700'
|
||||
}`}>
|
||||
{isSelected && <div className="w-2.5 h-2.5 rounded-full bg-dark-900" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium transition-colors duration-200 ${isSelected ? 'text-yellow-300' : 'text-dark-200'}`}>
|
||||
{opt.label}
|
||||
</div>
|
||||
{opt.description && (
|
||||
<div className="text-xs text-dark-500 mt-1 leading-relaxed">{opt.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Custom input + Submit section */}
|
||||
{!hasResult && onSendMessage && (
|
||||
<div className="space-y-3 pt-3 border-t border-dark-700">
|
||||
{/* Custom input field */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-dark-500 font-medium">Or type a custom response:</label>
|
||||
<TextareaAutosize
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
placeholder="Enter your own answer..."
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
className="w-full px-3 py-2 bg-dark-800 border border-dark-700 rounded-lg text-sm text-dark-200
|
||||
placeholder-dark-500 resize-none
|
||||
focus:outline-none focus:border-yellow-500/50 focus:ring-1 focus:ring-yellow-500/20
|
||||
transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={`w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
canSubmit
|
||||
? 'bg-yellow-500 text-dark-900 hover:bg-yellow-400 shadow-lg shadow-yellow-500/20 hover:shadow-yellow-500/30'
|
||||
: 'bg-dark-700 text-dark-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Submit Answer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasResult && (
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-dark-700 text-sm text-green-400">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>Response submitted</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default JSON view
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
@@ -886,8 +1132,13 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result }) {
|
||||
resultData.contentStr.startsWith('[')
|
||||
);
|
||||
|
||||
// Interactive tools like AskUserQuestion need more space
|
||||
const isInteractiveTool = config.isInteractive;
|
||||
const cardWidthClass = isInteractiveTool ? 'w-full max-w-2xl' : 'inline-block max-w-[85%]';
|
||||
const contentHeightClass = isInteractiveTool ? '' : 'max-h-48 overflow-y-auto';
|
||||
|
||||
return (
|
||||
<div className={`ml-11 message-enter rounded-lg border ${colors.border} ${colors.bg} overflow-hidden transition-all duration-200 hover:shadow-lg ${colors.glow} inline-block max-w-[85%]`}>
|
||||
<div className={`ml-11 message-enter rounded-lg border ${colors.border} ${colors.bg} overflow-hidden transition-all duration-200 hover:shadow-lg ${colors.glow} ${cardWidthClass}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-2.5">
|
||||
{/* Icon */}
|
||||
@@ -902,8 +1153,8 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result }) {
|
||||
<span className="text-dark-600">•</span>
|
||||
<code className="text-xs text-dark-300 truncate">{summary}</code>
|
||||
|
||||
{/* Result badge */}
|
||||
{resultData && (
|
||||
{/* Result badge - hide for interactive tools since they handle their own state */}
|
||||
{resultData && !isInteractiveTool && (
|
||||
<>
|
||||
<span className="text-dark-600">•</span>
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${resultData.isSuccess ? 'text-green-400' : 'text-red-400'}`}>
|
||||
@@ -920,13 +1171,13 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - always visible with max height and scroll */}
|
||||
<div className="border-t border-dark-700/50 p-3 bg-dark-900/50 max-h-48 overflow-y-auto overflow-x-auto">
|
||||
{/* Content - interactive tools get full height, others are capped */}
|
||||
<div className={`border-t border-dark-700/50 p-3 bg-dark-900/50 overflow-x-auto ${contentHeightClass}`}>
|
||||
{renderSpecialInput()}
|
||||
</div>
|
||||
|
||||
{/* Collapsible Result Section */}
|
||||
{resultData && (
|
||||
{/* Collapsible Result Section - hide for interactive tools */}
|
||||
{resultData && !isInteractiveTool && (
|
||||
<div className="border-t border-dark-700/50">
|
||||
<button
|
||||
onClick={() => setShowResult(!showResult)}
|
||||
|
||||
Reference in New Issue
Block a user