).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 (
-
- )
-}
-
-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 (
-
- )
-}
-
-function ThinkingView(props: { thinking: string }) {
- const preview = truncate(props.thinking.split('\n')[0], 50)
-
- return (
-
- )
-}
-
-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 ? (