diff --git a/web/src/components/ChatBlockList.tsx b/web/src/components/ChatBlockList.tsx deleted file mode 100644 index 7fee5f91..00000000 --- a/web/src/components/ChatBlockList.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import type { ChatBlock } from '@/chat/types' -import type { MessageStatus } from '@/types/api' -import type { ApiClient } from '@/api/client' -import type { SessionMetadataSummary } from '@/types/api' -import { MarkdownRenderer } from '@/components/MarkdownRenderer' -import { LazyRainbowText } from '@/components/LazyRainbowText' -import { ToolCard } from '@/components/ToolCard/ToolCard' - -function ErrorIcon() { - return ( - - - - - - ) -} - -function MessageStatusIndicator(props: { - status?: MessageStatus - onRetry?: () => void -}) { - if (props.status !== 'failed') { - return null - } - - return ( - - - - - {props.onRetry ? ( - - ) : null} - - ) -} - -function formatUnixTimestamp(value: number): string { - const ms = value < 1_000_000_000_000 ? value * 1000 : value - const date = new Date(ms) - if (Number.isNaN(date.getTime())) return String(value) - return date.toLocaleString() -} - -function renderEventLabel(event: ChatBlock & { kind: 'agent-event' }): string { - const data = event.event as { type: string; [key: string]: unknown } - if (data.type === 'switch') { - const mode = data.mode === 'local' ? 'local' : 'remote' - return `🔄 Switched to ${mode}` - } - if (data.type === 'title-changed') { - const title = typeof data.title === 'string' ? data.title : '' - return title ? `Title changed to "${title}"` : 'Title changed' - } - if (data.type === 'permission-mode-changed') { - const mode = typeof data.mode === 'string' ? data.mode : 'default' - return `🔐 Permission mode: ${mode}` - } - if (data.type === 'limit-reached') { - const endsAt = typeof data.endsAt === 'number' ? data.endsAt : null - return endsAt ? `⏳ Usage limit reached until ${formatUnixTimestamp(endsAt)}` : '⏳ Usage limit reached' - } - if (data.type === 'message') { - return typeof data.message === 'string' ? data.message : 'Message' - } - try { - return JSON.stringify(data) - } catch { - return 'Event' - } -} - -export function ChatBlockList(props: { - api: ApiClient - sessionId: string - metadata: SessionMetadataSummary | null - disabled: boolean - onRefresh: () => void - blocks: ChatBlock[] - onRetryMessage?: (localId: string) => void -}) { - return ( -
- {props.blocks.map((block) => { - if (block.kind === 'user-text') { - const userBubbleClass = 'w-fit max-w-[92%] ml-auto rounded-xl bg-[var(--app-secondary-bg)] px-3 py-2 text-[var(--app-fg)] shadow-sm' - const status = block.status - const onRetry = block.localId && status === 'failed' && props.onRetryMessage - ? () => props.onRetryMessage!(block.localId!) - : undefined - - return ( -
-
-
- -
- {status ? ( -
- -
- ) : null} -
-
- ) - } - - if (block.kind === 'agent-text') { - return ( -
- -
- ) - } - - if (block.kind === 'agent-event') { - return ( -
-
- {renderEventLabel(block)} -
-
- ) - } - - if (block.kind === 'tool-call') { - const isTask = block.tool.name === 'Task' - return ( -
- - {block.children.length > 0 ? ( - isTask ? ( -
- - Task details ({block.children.length}) - -
- -
-
- ) : ( -
- -
- ) - ) : null} -
- ) - } - - return null - })} -
- ) -} diff --git a/web/src/components/ChatInput.tsx b/web/src/components/ChatInput.tsx deleted file mode 100644 index abea3c36..00000000 --- a/web/src/components/ChatInput.tsx +++ /dev/null @@ -1,668 +0,0 @@ -import { - useState, - useCallback, - useRef, - useEffect, - useImperativeHandle, - forwardRef, - memo, - useMemo -} from 'react' -import TextareaAutosize from 'react-textarea-autosize' -import type { AgentState, ModelMode, PermissionMode } from '@/types/api' -import type { Suggestion } from '@/hooks/useActiveSuggestions' -import { useActiveWord } from '@/hooks/useActiveWord' -import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' -import { applySuggestion } from '@/utils/applySuggestion' -import { getTelegramWebApp } from '@/hooks/useTelegram' -import { FloatingOverlay } from './ChatInput/FloatingOverlay' -import { Autocomplete } from './ChatInput/Autocomplete' - -// Types -export type SupportedKey = 'Enter' | 'Escape' | 'ArrowUp' | 'ArrowDown' | 'Tab' - -export interface TextInputState { - text: string - selection: { start: number; end: number } -} - -export interface ChatInputHandle { - focus: () => void - blur: () => void -} - -export interface ChatInputProps { - disabled?: boolean - onSend: (text: string) => void - // Session data - sessionId?: string - permissionMode?: PermissionMode - modelMode?: ModelMode - active?: boolean - thinking?: boolean - agentState?: AgentState | null - // Usage data for context display - contextSize?: number - // Callbacks - onPermissionModeChange?: (mode: PermissionMode) => void - onModelModeChange?: (mode: ModelMode) => void - onAbort?: () => Promise - // Autocomplete - autocompletePrefixes?: string[] - autocompleteSuggestions?: (query: string) => Promise -} - -// Permission mode display config -const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const -const PERMISSION_MODE_LABELS: Record = { - default: 'Default', - acceptEdits: 'Accept Edits', - plan: 'Plan Mode', - bypassPermissions: 'Bypass All' -} - -// Model mode display config -const MODEL_MODES = ['default', 'sonnet', 'opus'] as const -const MODEL_MODE_LABELS: Record = { - default: 'Default', - sonnet: 'Sonnet', - opus: 'Opus' -} - -// Default empty suggestion handler -const defaultSuggestionHandler = async (): Promise => [] - -// Max context size for percentage calculation -const MAX_CONTEXT_SIZE = 190000 - -// Vibing messages for thinking state -const VIBING_MESSAGES = [ - "Accomplishing", "Actioning", "Actualizing", "Baking", "Booping", "Brewing", - "Calculating", "Cerebrating", "Channelling", "Churning", "Clauding", "Coalescing", - "Cogitating", "Computing", "Combobulating", "Concocting", "Conjuring", "Considering", - "Contemplating", "Cooking", "Crafting", "Creating", "Crunching", "Deciphering", - "Deliberating", "Determining", "Discombobulating", "Divining", "Doing", "Effecting", - "Elucidating", "Enchanting", "Envisioning", "Finagling", "Flibbertigibbeting", - "Forging", "Forming", "Frolicking", "Generating", "Germinating", "Hatching", - "Herding", "Honking", "Ideating", "Imagining", "Incubating", "Inferring", - "Manifesting", "Marinating", "Meandering", "Moseying", "Mulling", "Mustering", - "Musing", "Noodling", "Percolating", "Perusing", "Philosophising", "Pontificating", - "Pondering", "Processing", "Puttering", "Puzzling", "Reticulating", "Ruminating", - "Scheming", "Schlepping", "Shimmying", "Simmering", "Smooshing", "Spelunking", - "Spinning", "Stewing", "Sussing", "Synthesizing", "Thinking", "Tinkering", - "Transmuting", "Unfurling", "Unravelling", "Vibing", "Wandering", "Whirring", - "Wibbling", "Wizarding", "Working", "Wrangling" -] - -// Get connection status based on session state -function getConnectionStatus( - active: boolean, - thinking: boolean, - agentState: AgentState | null | undefined -): { text: string; color: string; dotColor: string; isPulsing: boolean } { - const hasPermissions = agentState?.requests && Object.keys(agentState.requests).length > 0 - - if (!active) { - return { - text: 'offline', - color: 'text-[#999]', - dotColor: 'bg-[#999]', - isPulsing: false - } - } - - if (hasPermissions) { - return { - text: 'permission required', - color: 'text-[#FF9500]', - dotColor: 'bg-[#FF9500]', - isPulsing: true - } - } - - if (thinking) { - const vibingMessage = VIBING_MESSAGES[Math.floor(Math.random() * VIBING_MESSAGES.length)].toLowerCase() + '…' - return { - text: vibingMessage, - color: 'text-[#007AFF]', - dotColor: 'bg-[#007AFF]', - isPulsing: true - } - } - - return { - text: 'online', - color: 'text-[#34C759]', - dotColor: 'bg-[#34C759]', - isPulsing: false - } -} - -// Get context warning based on usage -function getContextWarning(contextSize: number): { text: string; color: string } | null { - const percentageUsed = (contextSize / MAX_CONTEXT_SIZE) * 100 - const percentageRemaining = 100 - percentageUsed - - if (percentageRemaining <= 5) { - return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-red-500' } - } else if (percentageRemaining <= 10) { - return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-amber-500' } - } else { - // Always show context percentage - return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-[var(--app-hint)]' } - } -} - -export const ChatInput = memo(forwardRef(function ChatInput(props, ref) { - const { - disabled = false, - onSend, - permissionMode = 'default', - modelMode = 'default', - active = true, - thinking = false, - agentState, - contextSize, - onPermissionModeChange, - onModelModeChange, - onAbort, - autocompletePrefixes = ['@', '/'], - autocompleteSuggestions = defaultSuggestionHandler - } = props - - // Compute connection status - const connectionStatus = useMemo( - () => getConnectionStatus(active, thinking, agentState), - [active, thinking, agentState] - ) - - // Compute context warning - const contextWarning = useMemo( - () => contextSize !== undefined ? getContextWarning(contextSize) : null, - [contextSize] - ) - - // State - const [text, setText] = useState('') - const [inputState, setInputState] = useState({ - text: '', - selection: { start: 0, end: 0 } - }) - const [showSettings, setShowSettings] = useState(false) - const [isAborting, setIsAborting] = useState(false) - - // Refs - const textareaRef = useRef(null) - - // Imperative handle - useImperativeHandle(ref, () => ({ - focus: () => { - const el = textareaRef.current - if (!el) return - try { - el.focus({ preventScroll: true }) - } catch { - el.focus() - } - }, - blur: () => textareaRef.current?.blur() - }), []) - - // Autocomplete hooks - const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes) - const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions( - activeWord, - autocompleteSuggestions, - { clampSelection: true, wrapAround: true } - ) - - // Computed values - const trimmed = text.trim() - const hasText = trimmed.length > 0 - const controlsDisabled = disabled || !active - - // Haptic feedback helper - const haptic = useCallback((type: 'light' | 'success' | 'error' = 'light') => { - const tg = getTelegramWebApp() - if (type === 'light') { - tg?.HapticFeedback?.impactOccurred('light') - } else if (type === 'success') { - tg?.HapticFeedback?.notificationOccurred('success') - } else { - tg?.HapticFeedback?.notificationOccurred('error') - } - }, []) - - // Send message - const send = useCallback(() => { - if (!trimmed || controlsDisabled) return - haptic('light') - onSend(trimmed) - setText('') - setInputState({ text: '', selection: { start: 0, end: 0 } }) - }, [trimmed, controlsDisabled, haptic, onSend]) - - // Handle suggestion selection - const handleSuggestionSelect = useCallback((index: number) => { - const suggestion = suggestions[index] - if (!suggestion || !textareaRef.current) return - - const result = applySuggestion( - inputState.text, - inputState.selection, - suggestion.text, - autocompletePrefixes, - true - ) - - setText(result.text) - setInputState({ - text: result.text, - selection: { start: result.cursorPosition, end: result.cursorPosition } - }) - - // Set cursor position - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.setSelectionRange(result.cursorPosition, result.cursorPosition) - try { - textareaRef.current.focus({ preventScroll: true }) - } catch { - textareaRef.current.focus() - } - } - }, 0) - - haptic('light') - }, [suggestions, inputState, autocompletePrefixes, haptic]) - - // Handle abort - const handleAbort = useCallback(async () => { - if (!onAbort || isAborting) return - - haptic('error') - setIsAborting(true) - const startTime = Date.now() - - try { - await onAbort() - // Ensure minimum 300ms loading time - const elapsed = Date.now() - startTime - if (elapsed < 300) { - await new Promise(resolve => setTimeout(resolve, 300 - elapsed)) - } - } catch (error) { - console.error('Abort failed:', error) - } finally { - setIsAborting(false) - } - }, [onAbort, isAborting, haptic]) - - // Handle keyboard events - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - const key = e.key - - // Handle autocomplete navigation first - if (suggestions.length > 0) { - if (key === 'ArrowUp') { - e.preventDefault() - moveUp() - return - } else if (key === 'ArrowDown') { - e.preventDefault() - moveDown() - return - } else if ((key === 'Enter' || key === 'Tab') && !e.shiftKey) { - e.preventDefault() - const indexToSelect = selectedIndex >= 0 ? selectedIndex : 0 - handleSuggestionSelect(indexToSelect) - return - } else if (key === 'Escape') { - e.preventDefault() - clearSuggestions() - return - } - } - - // Handle Escape for abort when no suggestions - if (key === 'Escape' && onAbort && !isAborting) { - e.preventDefault() - handleAbort() - return - } - - // Handle Enter to send - if (key === 'Enter' && !e.shiftKey) { - e.preventDefault() - send() - return - } - - // Handle Shift+Tab for permission mode switching - if (key === 'Tab' && e.shiftKey && onPermissionModeChange) { - e.preventDefault() - const currentIndex = PERMISSION_MODES.indexOf(permissionMode as typeof PERMISSION_MODES[number]) - const nextIndex = (currentIndex + 1) % PERMISSION_MODES.length - onPermissionModeChange(PERMISSION_MODES[nextIndex]) - haptic('light') - return - } - }, [ - suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect, - onAbort, isAborting, handleAbort, send, onPermissionModeChange, permissionMode, haptic - ]) - - // Handle global keyboard for model mode switching - useEffect(() => { - const handleGlobalKeyDown = (e: KeyboardEvent) => { - // Handle Cmd/Ctrl+M for model mode switching - if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange) { - e.preventDefault() - const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number]) - const nextIndex = (currentIndex + 1) % MODEL_MODES.length - onModelModeChange(MODEL_MODES[nextIndex]) - haptic('light') - } - } - - window.addEventListener('keydown', handleGlobalKeyDown) - return () => window.removeEventListener('keydown', handleGlobalKeyDown) - }, [modelMode, onModelModeChange, haptic]) - - // Handle text change - const handleChange = useCallback((e: React.ChangeEvent) => { - const newText = e.target.value - const selection = { - start: e.target.selectionStart, - end: e.target.selectionEnd - } - setText(newText) - setInputState({ text: newText, selection }) - }, []) - - // Handle selection change - const handleSelect = useCallback((e: React.SyntheticEvent) => { - const target = e.target as HTMLTextAreaElement - setInputState(prev => ({ - ...prev, - selection: { start: target.selectionStart, end: target.selectionEnd } - })) - }, []) - - // Handle settings toggle - const handleSettingsToggle = useCallback(() => { - haptic('light') - setShowSettings(prev => !prev) - }, [haptic]) - - // Handle permission mode change - const handlePermissionChange = useCallback((mode: PermissionMode) => { - haptic('light') - onPermissionModeChange?.(mode) - }, [haptic, onPermissionModeChange]) - - // Handle model mode change - const handleModelChange = useCallback((mode: ModelMode) => { - haptic('light') - onModelModeChange?.(mode) - }, [haptic, onModelModeChange]) - - // Close settings when clicking outside - useEffect(() => { - if (!showSettings) return - - const handleClickOutside = (e: MouseEvent) => { - const target = e.target as HTMLElement - if (!target.closest('.settings-panel') && !target.closest('.settings-button')) { - setShowSettings(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showSettings]) - - return ( -
-
- {/* Autocomplete overlay */} - {suggestions.length > 0 && ( -
- - - -
- )} - - {/* Settings overlay */} - {showSettings && ( -
- - {/* Permission Mode Section */} -
-
- Permission Mode -
- {PERMISSION_MODES.map((mode) => ( - - ))} -
- - {/* Divider */} -
- - {/* Model Mode Section */} -
-
- Model -
- {MODEL_MODES.map((mode) => ( - - ))} -
- -
- )} - - {/* Status bar */} -
- {/* Left side: connection status and context */} -
- {/* Connection status */} -
- - - {connectionStatus.text} - -
- {/* Context warning */} - {contextWarning && ( - - {contextWarning.text} - - )} -
- {/* Right side: permission mode */} - {(permissionMode && permissionMode !== 'default') && ( - - {PERMISSION_MODE_LABELS[permissionMode]} - - )} -
- - {/* Unified panel */} -
- {/* Input area */} -
- -
- - {/* Action buttons */} -
-
- {/* Settings button */} - {onPermissionModeChange && ( - - )} - - {/* Abort button */} - {onAbort && ( - - )} -
- - {/* Send button */} - -
-
-
-
- ) -})) diff --git a/web/src/components/MessageBubble.tsx b/web/src/components/MessageBubble.tsx deleted file mode 100644 index 4340d58b..00000000 --- a/web/src/components/MessageBubble.tsx +++ /dev/null @@ -1,797 +0,0 @@ -import type { ReactNode } from 'react' -import type { DecryptedMessage, MessageStatus } from '@/types/api' -import { CodeBlock } from '@/components/CodeBlock' -import { MarkdownRenderer } from '@/components/MarkdownRenderer' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' - -function isObject(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' -} - -function truncate(text: string, maxLen: number): string { - if (text.length <= maxLen) return text - return text.slice(0, maxLen - 3) + '...' -} - -/** - * Converts snake_case string to Title Case with spaces. - * Example: "create_issue" -> "Create Issue" - */ -function snakeToTitleWithSpaces(value: string): string { - return value - .split('_') - .filter((part) => part.length > 0) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(' ') -} - -/** - * Formats MCP tool names for display. - * Example: "mcp__linear__create_issue" -> "MCP: Linear Create Issue" - */ -function formatMCPTitle(toolName: string): string { - const withoutPrefix = toolName.replace(/^mcp__/, '') - const parts = withoutPrefix.split('__') - if (parts.length >= 2) { - const serverName = snakeToTitleWithSpaces(parts[0]) - const toolPart = snakeToTitleWithSpaces(parts.slice(1).join('_')) - return `MCP: ${serverName} ${toolPart}` - } - return `MCP: ${snakeToTitleWithSpaces(withoutPrefix)}` -} - -function formatToolTitle(toolName: string): string { - if (toolName.startsWith('mcp__')) { - return formatMCPTitle(toolName) - } - return toolName -} - -type RoleWrappedMessage = { - role: string - content: unknown -} - -function isRoleWrappedMessage(value: unknown): value is RoleWrappedMessage { - if (!isObject(value)) return false - return typeof value.role === 'string' && 'content' in value -} - -function unwrapRoleWrappedMessageEnvelope(value: unknown): RoleWrappedMessage | null { - if (!isObject(value)) return null - - const direct = value.message - if (isRoleWrappedMessage(direct)) return direct - - const data = value.data - if (isObject(data) && isRoleWrappedMessage(data.message)) return data.message - - const payload = value.payload - if (isObject(payload) && isRoleWrappedMessage(payload.message)) return payload.message - - return null -} - -function normalizeMessageContent(value: unknown): { role: string | null; inner: unknown } { - if (isRoleWrappedMessage(value)) { - return { role: value.role, inner: value.content } - } - const unwrapped = unwrapRoleWrappedMessageEnvelope(value) - if (unwrapped) { - return { role: unwrapped.role, inner: unwrapped.content } - } - return { role: null, inner: value } -} - -function renderRoleWrappedMessageContent(message: RoleWrappedMessage): ReactNode { - const content = message.content - if (typeof content === 'string') { - return - } - - if (Array.isArray(content)) { - return ( -
- {content.map((block, idx) => ( -
- {renderBlock(block)} -
- ))} -
- ) - } - - if (content) { - return renderBlock(content) - } - - return ( -
- {message.role} -
- ) -} - -function formatEventLabel(event: unknown): string { - if (!isObject(event)) return 'Event' - const type = event.type - if (type === 'ready') return 'ready' - if (type === 'switch') { - const mode = event.mode === 'local' ? 'local' : 'remote' - return `🔄 Switched to ${mode}` - } - if (type === 'permission-mode-changed') { - const mode = typeof event.mode === 'string' ? event.mode : 'default' - return `🔐 Permission mode: ${mode}` - } - if (type === 'message') { - return typeof event.message === 'string' ? event.message : 'Message' - } - - try { - return JSON.stringify(event) - } catch { - return 'Event' - } -} - -function parseToolUseError(message: string): { isToolUseError: boolean; errorMessage: string | null } { - const regex = /(.*?)<\/tool_use_error>/s - const match = message.match(regex) - - if (match) { - return { - isToolUseError: true, - errorMessage: typeof match[1] === 'string' ? match[1].trim() : '' - } - } - - return { - isToolUseError: false, - errorMessage: null - } -} - -function parseClaudeUsageLimit(text: string): number | null { - const match = text.match(/^Claude AI usage limit reached\|(\d+)$/) - if (!match) return null - const timestamp = Number.parseInt(match[1], 10) - if (!Number.isFinite(timestamp)) return null - return timestamp -} - -function formatUnixTimestamp(value: number): string { - const ms = value < 1_000_000_000_000 ? value * 1000 : value - const date = new Date(ms) - if (Number.isNaN(date.getTime())) return String(value) - return date.toLocaleString() -} - -function getToolName(value: Record): string { - if (typeof value.name === 'string') return value.name - if (typeof value.tool === 'string') return value.tool - if (typeof value.toolName === 'string') return value.toolName - return 'Tool' -} - -function isExitPlanModeTool(name: string): boolean { - return name === 'ExitPlanMode' || name === 'exit_plan_mode' -} - -function extractPlanFromInput(input: unknown): string | null { - if (!isObject(input)) return null - const plan = input.plan - return typeof plan === 'string' ? plan : null -} - -function getToolInput(value: Record): unknown { - return value.input ?? value.arguments ?? value.args ?? value.params ?? null -} - -function isToolUseLike(value: Record): boolean { - const type = value.type - if (type === 'tool_use' || type === 'toolUse' || type === 'tool_call') return true - if (typeof value.name === 'string' || typeof value.tool === 'string') { - return 'input' in value || 'arguments' in value || 'args' in value || 'params' in value - } - return false -} - -function isToolResultLike(value: Record): boolean { - const type = value.type - if (type === 'tool_result' || type === 'toolResult') return true - const hasResult = 'content' in value || 'result' in value || 'output' in value - const hasErrorFlag = 'is_error' in value || 'isError' in value - return Boolean(hasResult && hasErrorFlag) -} - -function getToolResultContent(value: Record): unknown { - return value.content ?? value.result ?? value.output ?? null -} - -function extractTextFromToolResult(resultContent: unknown): string | null { - if (resultContent === null || resultContent === undefined) { - return null - } - - if (typeof resultContent === 'string') { - return resultContent - } - - if (Array.isArray(resultContent)) { - const textBlocks = resultContent - .filter((block) => isObject(block) && block.type === 'text' && typeof block.text === 'string') - .map((block) => (block as Record).text as string) - .filter((text) => text.trim().length > 0) - - if (textBlocks.length > 0) { - return textBlocks.join('\n') - } - } - - if (isObject(resultContent) && typeof resultContent.text === 'string') { - return resultContent.text - } - - return null -} - -function generateOutputSummary(text: string): string { - const lines = text.split('\n').length - const chars = text.length - if (chars >= 1024) { - return `${lines} lines, ${(chars / 1024).toFixed(1)}KB` - } - return `${lines} lines` -} - -function getInputString(input: unknown, key: string): string | null { - if (!isObject(input)) return null - const value = input[key] - return typeof value === 'string' ? value : null -} - -function getInputStringAny(input: unknown, keys: string[]): string | null { - for (const key of keys) { - const value = getInputString(input, key) - if (value) return value - } - return null -} - -function tryParseJsonString(value: unknown): unknown { - if (typeof value !== 'string') return value - const trimmed = value.trim() - if (!trimmed) return value - if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return value - try { - return JSON.parse(trimmed) as unknown - } catch { - return value - } -} - -function ToolUseView(props: { toolName: string; input: unknown }) { - const normalizedInput = tryParseJsonString(props.input) - const filePath = getInputStringAny(normalizedInput, ['file_path', 'path', 'filePath', 'file']) - const command = getInputStringAny(normalizedInput, ['command', 'cmd']) - const pattern = getInputStringAny(normalizedInput, ['pattern']) - const url = getInputStringAny(normalizedInput, ['url']) - const prompt = getInputStringAny(normalizedInput, ['description', 'prompt']) - - const title = formatToolTitle(props.toolName) - - // Generate compact title suffix - const titleSuffix = filePath - ? `: ${filePath.split('/').pop() ?? filePath}` - : command - ? `: ${truncate(command.split('\n')[0], 40)}` - : pattern - ? `: ${truncate(pattern, 40)}` - : url - ? `: ${truncate(url, 40)}` - : '' - - // Check if there's any detail to show (use explicit null/undefined checks for falsy values like 0, "", false) - const hasDetails = filePath !== null || command !== null || pattern !== null || url !== null || prompt !== null || (normalizedInput !== null && normalizedInput !== undefined) - - return ( - - - - - - - 🔧 {title} - -
- {filePath && ( -
- File:{' '} - {filePath} -
- )} - {pattern && ( -
- Pattern:{' '} - {pattern} -
- )} - {url && ( -
- URL:{' '} - {url} -
- )} - {prompt && ( -
- {prompt} -
- )} - {command && ( - - )} - {normalizedInput !== null && normalizedInput !== undefined && !filePath && !command && !pattern && !url && !prompt && ( - - )} - {!hasDetails && ( -
(no arguments)
- )} -
-
-
- ) -} - -function ToolResultView(props: { isError: boolean; content: unknown }) { - const text = extractTextFromToolResult(props.content) - const toolUseError = text !== null ? parseToolUseError(text) : null - const toolUseErrorText = toolUseError?.isToolUseError ? (toolUseError.errorMessage ?? '') : null - - const displayText = toolUseError?.isToolUseError ? toolUseErrorText : text - const summary = displayText !== null ? generateOutputSummary(displayText) : null - - const header = toolUseError?.isToolUseError - ? '⛔ Tool rejected' - : props.isError - ? '❌ Tool error' - : '✓ Tool result' - const hasContent = props.content !== null && props.content !== undefined - - return ( - - - - - - - {header} - -
- {displayText !== null ? ( - - ) : hasContent ? ( - - ) : ( -
(no output)
- )} -
-
-
- ) -} - -function ThinkingView(props: { thinking: string }) { - const preview = truncate(props.thinking.split('\n')[0], 50) - - return ( - - - - - - - 💭 Thinking - -
-
- {props.thinking} -
-
-
-
- ) -} - -function ExitPlanModeView(props: { input: unknown }) { - const plan = extractPlanFromInput(props.input) - - if (!plan) { - return ( -
- 📋 Plan proposal (empty) -
- ) - } - - return ( -
-
- 📋 Plan Proposal -
- -
- ) -} - -function renderOutputData(data: unknown): ReactNode { - if (!isObject(data)) { - return - } - - if (isRoleWrappedMessage(data)) { - return renderRoleWrappedMessageContent(data) - } - - const embeddedMessage = unwrapRoleWrappedMessageEnvelope(data) - if (embeddedMessage) { - return renderRoleWrappedMessageContent(embeddedMessage) - } - - const outputType = data.type - - if (outputType === 'summary' && typeof data.summary === 'string') { - return ( -
- 📝 {data.summary} -
- ) - } - - if (outputType === 'thinking' && typeof data.thinking === 'string') { - return - } - - if (outputType === 'event') { - const event = (data.data ?? data.event ?? data) as unknown - if (isObject(event) && event.type === 'ready') { - return null - } - return ( -
- {formatEventLabel(event)} -
- ) - } - - if (outputType === 'assistant') { - const message = isObject(data.message) ? data.message : null - const assistantContent = (message?.content ?? null) as unknown - - if (typeof assistantContent === 'string') { - return - } - - if (Array.isArray(assistantContent)) { - return ( -
- {assistantContent.map((block, idx) => ( -
- {renderBlock(block)} -
- ))} -
- ) - } - - if (assistantContent) { - return renderBlock(assistantContent) - } - - return ( -
- Assistant -
- ) - } - - if (outputType === 'tool_use') { - const name = getToolName(data) - const input = getToolInput(data) - if (name === 'mcp__happy__change_title' && isObject(input) && typeof input.title === 'string') { - return ( -
- Title changed to "{input.title}" -
- ) - } - // Special handling for ExitPlanMode - show plan content directly - if (isExitPlanModeTool(name)) { - return - } - return - } - - if (outputType === 'tool_result') { - const isError = Boolean(data.is_error ?? data.isError) - const content = getToolResultContent(data) - return - } - - return -} - -function renderBlock(block: unknown): ReactNode { - if (typeof block === 'string') { - const parsed = tryParseJsonString(block) - if (parsed !== block) { - return renderBlock(parsed) - } - const usageLimit = parseClaudeUsageLimit(block) - if (usageLimit !== null) { - return ( -
- ⏳ Usage limit reached until {formatUnixTimestamp(usageLimit)} -
- ) - } - return - } - - if (Array.isArray(block)) { - return ( -
- {block.map((item, idx) => ( -
- {renderBlock(item)} -
- ))} -
- ) - } - - if (!isObject(block)) { - return ( -
-                {String(block)}
-            
- ) - } - - if (isRoleWrappedMessage(block.message)) { - return renderBlock(block.message.content) - } - - const type = block.type - - if (type === 'text' && typeof block.text === 'string') { - const usageLimit = parseClaudeUsageLimit(block.text) - if (usageLimit !== null) { - return ( -
- ⏳ Usage limit reached until {formatUnixTimestamp(usageLimit)} -
- ) - } - return - } - - if (type === 'thinking' && typeof block.thinking === 'string') { - return - } - - if (type === 'event') { - if (isObject(block.data) && block.data.type === 'ready') { - return null - } - return ( -
- {formatEventLabel(block.data)} -
- ) - } - - if (type === 'output') { - return renderOutputData(block.data) - } - - if (type === 'tool_use') { - const name = getToolName(block) - const input = getToolInput(block) - if (name === 'mcp__happy__change_title' && isObject(input) && typeof input.title === 'string') { - return ( -
- Title changed to "{input.title}" -
- ) - } - // Special handling for ExitPlanMode - show plan content directly - if (isExitPlanModeTool(name)) { - return - } - return - } - - if (type === 'tool_result') { - const isError = Boolean(block.is_error ?? block.isError) - const content = getToolResultContent(block) - return - } - - if (isToolUseLike(block)) { - const name = getToolName(block) - const input = getToolInput(block) - // Special handling for ExitPlanMode - show plan content directly - if (isExitPlanModeTool(name)) { - return - } - return - } - - if (isToolResultLike(block)) { - const isError = Boolean(block.is_error ?? block.isError) - const content = getToolResultContent(block) - return - } - - return ( - - ) -} - -function safeStringify(value: unknown): string { - try { - const result = JSON.stringify(value, null, 2) - return typeof result === 'string' ? result : String(value) - } catch { - return String(value) - } -} - -function ErrorIcon() { - return ( - - - - - - ) -} - -function MessageStatusIndicator(props: { - status?: MessageStatus - onRetry?: () => void -}) { - // Only show indicator for failed status - if (props.status !== 'failed') { - return null - } - - return ( - - - - - {props.onRetry && ( - - )} - - ) -} - -export function MessageBubble(props: { - message: DecryptedMessage - onRetry?: () => void -}) { - const normalized = normalizeMessageContent(props.message.content) - const role = normalized.role - const inner = normalized.inner - - const isUser = role === 'user' - - // Events render centered without bubble - if (isObject(inner) && inner.type === 'event') { - if (isObject(inner.data) && inner.data.type === 'ready') { - return null - } - return ( -
- {renderBlock(inner)} -
- ) - } - - // User messages: bubble styling (right-aligned, secondary background like happy-app) - if (isUser) { - const userBubbleClass = 'w-fit max-w-[96%] ml-auto rounded-2xl px-3 py-2 bg-[var(--app-secondary-bg)] text-[var(--app-fg)]' - const status = props.message.status - - if (Array.isArray(inner)) { - return ( -
-
- {inner.map((block, idx) => ( -
- {renderBlock(block)} -
- ))} -
- {status && ( -
- -
- )} -
- ) - } - - if (isObject(inner)) { - return ( -
- {renderBlock(inner)} - {status && ( -
- -
- )} -
- ) - } - - return ( -
-
-
- {renderBlock(typeof inner === 'string' ? inner : safeStringify(inner))} -
- {status && ( -
- -
- )} -
-
- ) - } - - // Agent messages: no bubble, full width - if (Array.isArray(inner)) { - return ( -
- {inner.map((block, idx) => ( -
- {renderBlock(block)} -
- ))} -
- ) - } - - if (isObject(inner)) { - return renderBlock(inner) - } - - return renderBlock(typeof inner === 'string' ? inner : safeStringify(inner)) -} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 19d69866..26bd8b6d 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { AssistantRuntimeProvider } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api' @@ -10,7 +10,6 @@ import { HappyComposer } from '@/components/AssistantChat/HappyComposer' import { HappyThread } from '@/components/AssistantChat/HappyThread' import { useHappyRuntime } from '@/lib/assistant-runtime' import { SessionHeader } from '@/components/SessionHeader' -import { MessageBubble } from '@/components/MessageBubble' import { getTelegramWebApp } from '@/hooks/useTelegram' export function SessionChat(props: { @@ -60,9 +59,6 @@ export function SessionChat(props: { const reduced = useMemo(() => reduceChatBlocks(normalizedMessages, props.session.agentState), [normalizedMessages, props.session.agentState]) - const [debugViewMode, setDebugViewMode] = useState<'reduced' | 'raw'>('reduced') - const viewMode = import.meta.env.DEV ? debugViewMode : 'reduced' - // Permission mode change handler const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => { try { @@ -95,7 +91,7 @@ export function SessionChat(props: { const runtime = useHappyRuntime({ session: props.session, - blocks: viewMode === 'raw' ? [] : reduced.blocks, + blocks: reduced.blocks, isSending: props.isSending, onSendMessage: props.onSend, onAbort: handleAbort @@ -118,25 +114,6 @@ export function SessionChat(props: {
) : null} - {import.meta.env.DEV ? ( -
- - -
- ) : null} - {props.hasMoreMessages ? (