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:
2025-12-18 06:50:57 +01:00
parent 1186cb1b5e
commit eb45891d6f
4 changed files with 327 additions and 22 deletions

View File

@@ -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)}