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

@@ -129,6 +129,9 @@ function generateRequestId() {
const server = createServer(app); const server = createServer(app);
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
// WebSocket heartbeat interval (30 seconds)
const WS_HEARTBEAT_INTERVAL = 30000;
// Scan directory for projects // Scan directory for projects
function scanProjects(basePath, depth = 0, maxDepth = 1) { function scanProjects(basePath, depth = 0, maxDepth = 1) {
const projects = []; const projects = [];
@@ -560,6 +563,20 @@ wss.on('connection', async (ws, req) => {
const sessionId = uuidv4(); const sessionId = uuidv4();
console.log(`[${sessionId}] New WebSocket connection`); console.log(`[${sessionId}] New WebSocket connection`);
// Track connection health
ws.isAlive = true;
// Heartbeat to keep connection alive through proxies
const heartbeatInterval = setInterval(() => {
if (ws.readyState === ws.OPEN) {
ws.ping();
}
}, WS_HEARTBEAT_INTERVAL);
ws.on('pong', () => {
ws.isAlive = true;
});
// Authenticate WebSocket connection // Authenticate WebSocket connection
let wsUser = null; let wsUser = null;
if (authConfig.app.authEnabled && sessionStore) { if (authConfig.app.authEnabled && sessionStore) {
@@ -1030,6 +1047,8 @@ wss.on('connection', async (ws, req) => {
ws.on('close', () => { ws.on('close', () => {
console.log(`[${sessionId}] WebSocket closed`); console.log(`[${sessionId}] WebSocket closed`);
clearInterval(heartbeatInterval);
clearInterval(cleanupInterval);
if (claudeProcess) { if (claudeProcess) {
claudeProcess.kill(); claudeProcess.kill();
sessions.delete(sessionId); sessions.delete(sessionId);

View File

@@ -182,6 +182,7 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
<MessageList <MessageList
messages={session.messages || []} messages={session.messages || []}
isProcessing={session.isProcessing} isProcessing={session.isProcessing}
onSendMessage={handleSendMessage}
/> />
) : ( ) : (
<WelcomeScreen session={session} onStart={start} /> <WelcomeScreen session={session} onStart={start} />

View File

@@ -1,9 +1,10 @@
import { useEffect, useRef, useState, useMemo, memo, useCallback } from 'react'; import { useEffect, useRef, useState, useMemo, memo, useCallback } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { import {
User, Bot, Terminal, CheckCircle, AlertCircle, Info, User, Bot, Terminal, CheckCircle, AlertCircle, Info,
FileText, Search, FolderSearch, Pencil, FilePlus, Globe, FileText, Search, FolderSearch, Pencil, FilePlus, Globe,
ChevronDown, ChevronRight, Play, ArrowDown, ChevronDown, ChevronRight, Play, ArrowDown,
ClipboardList, Brain, Paperclip, Image ClipboardList, Brain, Paperclip, Image, HelpCircle, Zap, Command
} from 'lucide-react'; } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@@ -25,6 +26,50 @@ function parseSystemReminders(text) {
return { content, reminders }; 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 // Collapsible system hints component
function SystemHints({ reminders, inline = false }) { function SystemHints({ reminders, inline = false }) {
const [show, setShow] = useState(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 containerRef = useRef(null);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const [showScrollButton, setShowScrollButton] = useState(false); 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" className="h-full overflow-y-auto p-4 space-y-4"
> >
{processedMessages.map((message, index) => ( {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 */} {/* 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; 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(() => { const parsedContent = useMemo(() => {
if (type === 'assistant') { 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; return null;
}, [type, content]); }, [type, content]);
@@ -269,7 +320,7 @@ const Message = memo(function Message({ message }) {
); );
case 'assistant': { case 'assistant': {
const { content: cleanContent, reminders } = parsedContent; const { content: cleanContent, reminders, thinking } = parsedContent;
return ( return (
<div className="flex gap-3 message-enter"> <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"> <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="max-w-[85%]">
<div className="text-xs text-dark-500 mb-1">Claude</div> <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"> <div className="bg-dark-850 rounded-lg rounded-tl-none p-4 border border-dark-700 claude-message inline-block">
<ThinkingBlock thinking={thinking} />
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
@@ -318,7 +370,7 @@ const Message = memo(function Message({ message }) {
case 'tool_use': case 'tool_use':
// Note: ExitPlanMode approval now handled via PermissionDialog modal // Note: ExitPlanMode approval now handled via PermissionDialog modal
// Just show it as a normal tool use card in the message list // 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': case 'tool_result':
return <ToolResultCard content={content} isSuccess={!message.isError} />; return <ToolResultCard content={content} isSuccess={!message.isError} />;
@@ -456,16 +508,27 @@ const TOOL_CONFIG = {
getSummary: (input) => input.task_id?.substring(0, 8) || 'task', getSummary: (input) => input.task_id?.substring(0, 8) || 'task',
}, },
Skill: { Skill: {
icon: Play, icon: Zap,
color: 'purple', color: 'purple',
label: 'Using skill', label: 'Launching skill',
getSummary: (input) => input.skill || 'skill', getSummary: (input) => input.skill || 'skill',
}, },
SlashCommand: { SlashCommand: {
icon: Terminal, icon: Command,
color: 'cyan', color: 'cyan',
label: 'Running command', 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: { ExitPlanMode: {
icon: ClipboardList, icon: ClipboardList,
@@ -541,8 +604,10 @@ const COLOR_CLASSES = {
// Note: PlanApprovalCard removed - ExitPlanMode approval now handled via PermissionDialog modal // 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 [showResult, setShowResult] = useState(false);
const [selectedOptions, setSelectedOptions] = useState({});
const [customInput, setCustomInput] = useState('');
const config = TOOL_CONFIG[tool] || { const config = TOOL_CONFIG[tool] || {
icon: Terminal, 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 // Default JSON view
return ( return (
<SyntaxHighlighter <SyntaxHighlighter
@@ -886,8 +1132,13 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result }) {
resultData.contentStr.startsWith('[') 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 ( 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 */} {/* Header */}
<div className="flex items-center gap-3 p-2.5"> <div className="flex items-center gap-3 p-2.5">
{/* Icon */} {/* Icon */}
@@ -902,8 +1153,8 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result }) {
<span className="text-dark-600"></span> <span className="text-dark-600"></span>
<code className="text-xs text-dark-300 truncate">{summary}</code> <code className="text-xs text-dark-300 truncate">{summary}</code>
{/* Result badge */} {/* Result badge - hide for interactive tools since they handle their own state */}
{resultData && ( {resultData && !isInteractiveTool && (
<> <>
<span className="text-dark-600"></span> <span className="text-dark-600"></span>
<span className={`inline-flex items-center gap-1 text-xs ${resultData.isSuccess ? 'text-green-400' : 'text-red-400'}`}> <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>
</div> </div>
{/* Content - always visible with max height and scroll */} {/* Content - interactive tools get full height, others are capped */}
<div className="border-t border-dark-700/50 p-3 bg-dark-900/50 max-h-48 overflow-y-auto overflow-x-auto"> <div className={`border-t border-dark-700/50 p-3 bg-dark-900/50 overflow-x-auto ${contentHeightClass}`}>
{renderSpecialInput()} {renderSpecialInput()}
</div> </div>
{/* Collapsible Result Section */} {/* Collapsible Result Section - hide for interactive tools */}
{resultData && ( {resultData && !isInteractiveTool && (
<div className="border-t border-dark-700/50"> <div className="border-t border-dark-700/50">
<button <button
onClick={() => setShowResult(!showResult)} onClick={() => setShowResult(!showResult)}

View File

@@ -9,6 +9,11 @@ function getWsUrl() {
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Reconnect configuration
const RECONNECT_DELAY_BASE = 1000; // 1 second
const RECONNECT_DELAY_MAX = 30000; // 30 seconds
const RECONNECT_MAX_ATTEMPTS = 10;
export function useClaudeSession() { export function useClaudeSession() {
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [sessionActive, setSessionActive] = useState(false); const [sessionActive, setSessionActive] = useState(false);
@@ -50,6 +55,9 @@ export function useClaudeSession() {
const wsRef = useRef(null); const wsRef = useRef(null);
const currentAssistantMessage = useRef(null); const currentAssistantMessage = useRef(null);
const reconnectAttempts = useRef(0);
const reconnectTimeout = useRef(null);
const intentionalClose = useRef(false);
const connect = useCallback(() => { const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return; if (wsRef.current?.readyState === WebSocket.OPEN) return;
@@ -61,13 +69,34 @@ export function useClaudeSession() {
console.log('WebSocket connected'); console.log('WebSocket connected');
setConnected(true); setConnected(true);
setError(null); setError(null);
reconnectAttempts.current = 0; // Reset on successful connection
}; };
ws.onclose = () => { ws.onclose = (event) => {
console.log('WebSocket disconnected'); console.log('WebSocket disconnected', { code: event.code, reason: event.reason, wasClean: event.wasClean });
setConnected(false); setConnected(false);
setSessionActive(false); setSessionActive(false);
setIsProcessing(false); setIsProcessing(false);
// Attempt reconnect unless it was intentional or auth failure
if (!intentionalClose.current && event.code !== 1008) {
if (reconnectAttempts.current < RECONNECT_MAX_ATTEMPTS) {
const delay = Math.min(
RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current),
RECONNECT_DELAY_MAX
);
reconnectAttempts.current++;
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current}/${RECONNECT_MAX_ATTEMPTS})`);
reconnectTimeout.current = setTimeout(() => {
console.log('Attempting reconnect...');
connect();
}, delay);
} else {
console.log('Max reconnect attempts reached');
setError('Connection lost. Please refresh the page.');
}
}
}; };
ws.onerror = (err) => { ws.onerror = (err) => {
@@ -543,8 +572,13 @@ export function useClaudeSession() {
// Auto-connect on mount // Auto-connect on mount
useEffect(() => { useEffect(() => {
intentionalClose.current = false;
connect(); connect();
return () => { return () => {
intentionalClose.current = true;
if (reconnectTimeout.current) {
clearTimeout(reconnectTimeout.current);
}
wsRef.current?.close(); wsRef.current?.close();
}; };
}, [connect]); }, [connect]);