From eb45891d6f0e91d9d1b65b9e5371f913ddec653b Mon Sep 17 00:00:00 2001 From: Nikolas Syring Date: Thu, 18 Dec 2025 06:50:57 +0100 Subject: [PATCH] feat: interactive AskUserQuestion + WebSocket stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 tags 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/server.js | 19 ++ frontend/src/components/ChatPanel.jsx | 1 + frontend/src/components/MessageList.jsx | 291 ++++++++++++++++++++++-- frontend/src/hooks/useClaudeSession.js | 38 +++- 4 files changed, 327 insertions(+), 22 deletions(-) diff --git a/backend/server.js b/backend/server.js index 50ca575..6a19746 100644 --- a/backend/server.js +++ b/backend/server.js @@ -129,6 +129,9 @@ function generateRequestId() { const server = createServer(app); const wss = new WebSocketServer({ server }); +// WebSocket heartbeat interval (30 seconds) +const WS_HEARTBEAT_INTERVAL = 30000; + // Scan directory for projects function scanProjects(basePath, depth = 0, maxDepth = 1) { const projects = []; @@ -560,6 +563,20 @@ wss.on('connection', async (ws, req) => { const sessionId = uuidv4(); 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 let wsUser = null; if (authConfig.app.authEnabled && sessionStore) { @@ -1030,6 +1047,8 @@ wss.on('connection', async (ws, req) => { ws.on('close', () => { console.log(`[${sessionId}] WebSocket closed`); + clearInterval(heartbeatInterval); + clearInterval(cleanupInterval); if (claudeProcess) { claudeProcess.kill(); sessions.delete(sessionId); diff --git a/frontend/src/components/ChatPanel.jsx b/frontend/src/components/ChatPanel.jsx index adce136..dd845eb 100644 --- a/frontend/src/components/ChatPanel.jsx +++ b/frontend/src/components/ChatPanel.jsx @@ -182,6 +182,7 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) { ) : ( diff --git a/frontend/src/components/MessageList.jsx b/frontend/src/components/MessageList.jsx index 4aa7723..bf1db66 100644 --- a/frontend/src/components/MessageList.jsx +++ b/frontend/src/components/MessageList.jsx @@ -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 = /([\s\S]*?)<\/thinking>/g; + let match; + while ((match = thinkingRegex.exec(text)) !== null) { + thinking.push(match[1].trim()); + } + + const content = text.replace(/[\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 ( +
+ + {show && ( +
+ {thinking.map((thought, idx) => ( +
+ {thought} +
+ ))} +
+ )} +
+ ); +} + // 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) => ( - + ))} {/* 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 (
@@ -278,6 +329,7 @@ const Message = memo(function Message({ message }) {
Claude
+ ; + return ; case 'tool_result': return ; @@ -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 ( +
+
+ + {input.skill} +
+
+ ); + } + + if (tool === 'SlashCommand') { + return ( +
+
+ + {input.command} +
+
+ ); + } + + 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 ( +
+ {questions.map((q, qIdx) => ( +
+ {/* Question header */} +
+ + {q.header} + + {q.multiSelect && ( + (select multiple) + )} +
+ + {/* Question text */} +

{q.question}

+ + {/* Options as grid for better layout */} +
+ {q.options?.map((opt, optIdx) => { + const isSelected = isOptionSelected(qIdx, optIdx); + return ( + + ); + })} +
+
+ ))} + + {/* Custom input + Submit section */} + {!hasResult && onSendMessage && ( +
+ {/* Custom input field */} +
+ + 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" + /> +
+ + {/* Submit button */} + +
+ )} + + {hasResult && ( +
+ + Response submitted +
+ )} +
+ ); + } + // Default JSON view return ( +
{/* Header */}
{/* Icon */} @@ -902,8 +1153,8 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result }) { • {summary} - {/* Result badge */} - {resultData && ( + {/* Result badge - hide for interactive tools since they handle their own state */} + {resultData && !isInteractiveTool && ( <> • @@ -920,13 +1171,13 @@ const ToolUseCard = memo(function ToolUseCard({ tool, input, result }) {
- {/* Content - always visible with max height and scroll */} -
+ {/* Content - interactive tools get full height, others are capped */} +
{renderSpecialInput()}
- {/* Collapsible Result Section */} - {resultData && ( + {/* Collapsible Result Section - hide for interactive tools */} + {resultData && !isInteractiveTool && (