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:
@@ -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);
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user