refactor: remove deprecated chat components in favor of @assistant-ui/react

This commit is contained in:
weishu
2025-12-18 08:20:14 +08:00
parent 532ceb5595
commit e2428f11b5
4 changed files with 4 additions and 1695 deletions
-185
View File
@@ -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 (
<svg className="h-[14px] w-[14px]" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 5v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="8" cy="11" r="0.75" fill="currentColor" />
</svg>
)
}
function MessageStatusIndicator(props: {
status?: MessageStatus
onRetry?: () => void
}) {
if (props.status !== 'failed') {
return null
}
return (
<span className="inline-flex items-center gap-1">
<span className="text-red-500">
<ErrorIcon />
</span>
{props.onRetry ? (
<button
type="button"
onClick={props.onRetry}
className="text-xs text-blue-500 hover:underline"
>
</button>
) : null}
</span>
)
}
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 (
<div className="flex flex-col gap-3">
{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 (
<div key={`user:${block.id}`} className={userBubbleClass}>
<div className="flex items-end gap-2">
<div className="flex-1">
<LazyRainbowText text={block.text} />
</div>
{status ? (
<div className="shrink-0 self-end pb-0.5">
<MessageStatusIndicator status={status} onRetry={onRetry} />
</div>
) : null}
</div>
</div>
)
}
if (block.kind === 'agent-text') {
return (
<div key={`agent:${block.id}`} className="px-1">
<MarkdownRenderer content={block.text} />
</div>
)
}
if (block.kind === 'agent-event') {
return (
<div key={`event:${block.id}`} className="py-1">
<div className="mx-auto w-fit max-w-[92%] px-2 text-center text-xs text-[var(--app-hint)] opacity-80">
{renderEventLabel(block)}
</div>
</div>
)
}
if (block.kind === 'tool-call') {
const isTask = block.tool.name === 'Task'
return (
<div key={`tool:${block.id}`} className="py-1">
<ToolCard
api={props.api}
sessionId={props.sessionId}
metadata={props.metadata}
disabled={props.disabled}
onDone={props.onRefresh}
block={block}
/>
{block.children.length > 0 ? (
isTask ? (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-[var(--app-hint)]">
Task details ({block.children.length})
</summary>
<div className="mt-2 pl-3">
<ChatBlockList
api={props.api}
sessionId={props.sessionId}
metadata={props.metadata}
disabled={props.disabled}
onRefresh={props.onRefresh}
blocks={block.children}
onRetryMessage={props.onRetryMessage}
/>
</div>
</details>
) : (
<div className="mt-2 pl-3">
<ChatBlockList
api={props.api}
sessionId={props.sessionId}
metadata={props.metadata}
disabled={props.disabled}
onRefresh={props.onRefresh}
blocks={block.children}
onRetryMessage={props.onRetryMessage}
/>
</div>
)
) : null}
</div>
)
}
return null
})}
</div>
)
}
-668
View File
@@ -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<void>
// Autocomplete
autocompletePrefixes?: string[]
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
}
// Permission mode display config
const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const
const PERMISSION_MODE_LABELS: Record<string, string> = {
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<string, string> = {
default: 'Default',
sonnet: 'Sonnet',
opus: 'Opus'
}
// Default empty suggestion handler
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
// 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<ChatInputHandle, ChatInputProps>(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<TextInputState>({
text: '',
selection: { start: 0, end: 0 }
})
const [showSettings, setShowSettings] = useState(false)
const [isAborting, setIsAborting] = useState(false)
// Refs
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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 (
<div className="bg-[var(--app-bg)] px-2 pt-1" style={{ paddingBottom: 'calc(8px + env(safe-area-inset-bottom, 0px))' }}>
<div className="relative mx-auto w-full max-w-[720px]">
{/* Autocomplete overlay */}
{suggestions.length > 0 && (
<div className="absolute bottom-full left-0 right-0 z-50 mb-2">
<FloatingOverlay maxHeight={240}>
<Autocomplete
suggestions={suggestions as Suggestion[]}
selectedIndex={selectedIndex}
onSelect={handleSuggestionSelect}
/>
</FloatingOverlay>
</div>
)}
{/* Settings overlay */}
{showSettings && (
<div className="settings-panel absolute bottom-full left-0 right-0 z-50 mb-2">
<FloatingOverlay maxHeight={320}>
{/* Permission Mode Section */}
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
Permission Mode
</div>
{PERMISSION_MODES.map((mode) => (
<button
key={mode}
type="button"
disabled={controlsDisabled}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
controlsDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
}`}
onClick={() => handlePermissionChange(mode)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
permissionMode === mode
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{permissionMode === mode && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={permissionMode === mode ? 'text-[var(--app-link)]' : ''}>
{PERMISSION_MODE_LABELS[mode]}
</span>
</button>
))}
</div>
{/* Divider */}
<div className="mx-3 h-px bg-[var(--app-divider)]" />
{/* Model Mode Section */}
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
Model
</div>
{MODEL_MODES.map((mode) => (
<button
key={mode}
type="button"
disabled={controlsDisabled}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
controlsDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
}`}
onClick={() => handleModelChange(mode)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
modelMode === mode
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{modelMode === mode && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={modelMode === mode ? 'text-[var(--app-link)]' : ''}>
{MODEL_MODE_LABELS[mode]}
</span>
</button>
))}
</div>
</FloatingOverlay>
</div>
)}
{/* Status bar */}
<div className="flex items-center justify-between px-2 pb-1">
{/* Left side: connection status and context */}
<div className="flex items-baseline gap-3">
{/* Connection status */}
<div className="flex items-center gap-1.5">
<span
className={`h-2 w-2 rounded-full ${connectionStatus.dotColor} ${connectionStatus.isPulsing ? 'animate-pulse' : ''}`}
/>
<span className={`text-xs ${connectionStatus.color}`}>
{connectionStatus.text}
</span>
</div>
{/* Context warning */}
{contextWarning && (
<span className={`text-[10px] ${contextWarning.color}`}>
{contextWarning.text}
</span>
)}
</div>
{/* Right side: permission mode */}
{(permissionMode && permissionMode !== 'default') && (
<span className={`text-xs ${
permissionMode === 'acceptEdits' ? 'text-amber-500' :
permissionMode === 'bypassPermissions' ? 'text-red-500' :
permissionMode === 'plan' ? 'text-blue-500' :
'text-[var(--app-hint)]'
}`}>
{PERMISSION_MODE_LABELS[permissionMode]}
</span>
)}
</div>
{/* Unified panel */}
<div className="overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)]">
{/* Input area */}
<div className="flex items-center px-4 py-3">
<TextareaAutosize
ref={textareaRef}
value={text}
onChange={handleChange}
onSelect={handleSelect}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={controlsDisabled}
maxRows={5}
className="flex-1 resize-none bg-transparent text-sm leading-snug text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{/* Action buttons */}
<div className="flex items-center justify-between px-2 pb-2">
<div className="flex items-center gap-1">
{/* Settings button */}
{onPermissionModeChange && (
<button
type="button"
className="settings-button flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)]"
onClick={handleSettingsToggle}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
)}
{/* Abort button */}
{onAbort && (
<button
type="button"
disabled={isAborting || controlsDisabled}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleAbort}
>
{isAborting ? (
<svg
className="animate-spin"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4-2.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-4a.5.5 0 0 1-.5-.5v-4Z" />
</svg>
)}
</button>
)}
</div>
{/* Send button */}
<button
type="button"
disabled={controlsDisabled || !hasText}
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
hasText && !controlsDisabled
? 'bg-black text-white'
: 'bg-[#C0C0C0] text-white'
} disabled:cursor-not-allowed`}
onClick={send}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
)
}))
-797
View File
@@ -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<string, unknown> {
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 <MarkdownRenderer content={content} />
}
if (Array.isArray(content)) {
return (
<div className="flex flex-col gap-3">
{content.map((block, idx) => (
<div key={idx}>
{renderBlock(block)}
</div>
))}
</div>
)
}
if (content) {
return renderBlock(content)
}
return (
<div className="text-xs text-[var(--app-hint)]">
{message.role}
</div>
)
}
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>(.*?)<\/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, unknown>): 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<string, unknown>): unknown {
return value.input ?? value.arguments ?? value.args ?? value.params ?? null
}
function isToolUseLike(value: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>).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 (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="flex items-center gap-1 text-left text-xs font-medium text-[var(--app-hint)] hover:underline"
>
🔧 {title}{titleSuffix}
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>🔧 {title}</DialogTitle>
</DialogHeader>
<div className="mt-3 flex max-h-[60vh] flex-col gap-3 overflow-auto">
{filePath && (
<div className="text-sm">
<span className="text-[var(--app-hint)]">File:</span>{' '}
<span className="font-mono break-all">{filePath}</span>
</div>
)}
{pattern && (
<div className="text-sm">
<span className="text-[var(--app-hint)]">Pattern:</span>{' '}
<span className="font-mono break-all">{pattern}</span>
</div>
)}
{url && (
<div className="text-sm">
<span className="text-[var(--app-hint)]">URL:</span>{' '}
<span className="font-mono break-all">{url}</span>
</div>
)}
{prompt && (
<div className="whitespace-pre-wrap break-words text-sm">
{prompt}
</div>
)}
{command && (
<CodeBlock code={command} language="bash" />
)}
{normalizedInput !== null && normalizedInput !== undefined && !filePath && !command && !pattern && !url && !prompt && (
<CodeBlock code={safeStringify(normalizedInput)} language="json" />
)}
{!hasDetails && (
<div className="text-sm text-[var(--app-hint)]">(no arguments)</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
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 (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="flex items-center gap-1 text-left text-xs font-medium text-[var(--app-hint)] hover:underline"
>
{header}
{summary !== null && <span>({summary})</span>}
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{header}</DialogTitle>
</DialogHeader>
<div className="mt-3 max-h-[60vh] overflow-auto">
{displayText !== null ? (
<CodeBlock code={displayText} language="text" />
) : hasContent ? (
<CodeBlock code={safeStringify(props.content)} language="json" />
) : (
<div className="text-sm text-[var(--app-hint)]">(no output)</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
function ThinkingView(props: { thinking: string }) {
const preview = truncate(props.thinking.split('\n')[0], 50)
return (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="flex items-center gap-1 text-left text-xs font-medium text-[var(--app-hint)] hover:underline"
>
💭 Thinking: {preview}
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>💭 Thinking</DialogTitle>
</DialogHeader>
<div className="mt-3 max-h-[60vh] overflow-auto">
<div className="whitespace-pre-wrap break-words text-sm">
{props.thinking}
</div>
</div>
</DialogContent>
</Dialog>
)
}
function ExitPlanModeView(props: { input: unknown }) {
const plan = extractPlanFromInput(props.input)
if (!plan) {
return (
<div className="text-xs text-[var(--app-hint)]">
📋 Plan proposal (empty)
</div>
)
}
return (
<div className="flex flex-col gap-2">
<div className="text-xs font-medium text-[var(--app-hint)]">
📋 Plan Proposal
</div>
<MarkdownRenderer content={plan} />
</div>
)
}
function renderOutputData(data: unknown): ReactNode {
if (!isObject(data)) {
return <CodeBlock code={safeStringify(data)} language="json" />
}
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 (
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
📝 {data.summary}
</div>
)
}
if (outputType === 'thinking' && typeof data.thinking === 'string') {
return <ThinkingView thinking={data.thinking} />
}
if (outputType === 'event') {
const event = (data.data ?? data.event ?? data) as unknown
if (isObject(event) && event.type === 'ready') {
return null
}
return (
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
{formatEventLabel(event)}
</div>
)
}
if (outputType === 'assistant') {
const message = isObject(data.message) ? data.message : null
const assistantContent = (message?.content ?? null) as unknown
if (typeof assistantContent === 'string') {
return <MarkdownRenderer content={assistantContent} />
}
if (Array.isArray(assistantContent)) {
return (
<div className="flex flex-col gap-3">
{assistantContent.map((block, idx) => (
<div key={idx}>
{renderBlock(block)}
</div>
))}
</div>
)
}
if (assistantContent) {
return renderBlock(assistantContent)
}
return (
<div className="text-xs text-[var(--app-hint)]">
Assistant
</div>
)
}
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 (
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
Title changed to &quot;{input.title}&quot;
</div>
)
}
// Special handling for ExitPlanMode - show plan content directly
if (isExitPlanModeTool(name)) {
return <ExitPlanModeView input={input} />
}
return <ToolUseView toolName={name} input={input} />
}
if (outputType === 'tool_result') {
const isError = Boolean(data.is_error ?? data.isError)
const content = getToolResultContent(data)
return <ToolResultView isError={isError} content={content} />
}
return <CodeBlock code={safeStringify(data)} language="json" />
}
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 (
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
Usage limit reached until {formatUnixTimestamp(usageLimit)}
</div>
)
}
return <MarkdownRenderer content={block} />
}
if (Array.isArray(block)) {
return (
<div className="flex flex-col gap-3">
{block.map((item, idx) => (
<div key={idx}>
{renderBlock(item)}
</div>
))}
</div>
)
}
if (!isObject(block)) {
return (
<pre className="text-xs whitespace-pre-wrap break-words">
{String(block)}
</pre>
)
}
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 (
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
Usage limit reached until {formatUnixTimestamp(usageLimit)}
</div>
)
}
return <MarkdownRenderer content={block.text} />
}
if (type === 'thinking' && typeof block.thinking === 'string') {
return <ThinkingView thinking={block.thinking} />
}
if (type === 'event') {
if (isObject(block.data) && block.data.type === 'ready') {
return null
}
return (
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
{formatEventLabel(block.data)}
</div>
)
}
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 (
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
Title changed to &quot;{input.title}&quot;
</div>
)
}
// Special handling for ExitPlanMode - show plan content directly
if (isExitPlanModeTool(name)) {
return <ExitPlanModeView input={input} />
}
return <ToolUseView toolName={name} input={input} />
}
if (type === 'tool_result') {
const isError = Boolean(block.is_error ?? block.isError)
const content = getToolResultContent(block)
return <ToolResultView isError={isError} content={content} />
}
if (isToolUseLike(block)) {
const name = getToolName(block)
const input = getToolInput(block)
// Special handling for ExitPlanMode - show plan content directly
if (isExitPlanModeTool(name)) {
return <ExitPlanModeView input={input} />
}
return <ToolUseView toolName={name} input={input} />
}
if (isToolResultLike(block)) {
const isError = Boolean(block.is_error ?? block.isError)
const content = getToolResultContent(block)
return <ToolResultView isError={isError} content={content} />
}
return (
<CodeBlock code={safeStringify(block)} language="json" />
)
}
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 (
<svg className="h-[14px] w-[14px]" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 5v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="8" cy="11" r="0.75" fill="currentColor" />
</svg>
)
}
function MessageStatusIndicator(props: {
status?: MessageStatus
onRetry?: () => void
}) {
// Only show indicator for failed status
if (props.status !== 'failed') {
return null
}
return (
<span className="inline-flex items-center gap-1">
<span className="text-red-500">
<ErrorIcon />
</span>
{props.onRetry && (
<button
type="button"
onClick={props.onRetry}
className="text-xs text-blue-500 hover:underline"
>
</button>
)}
</span>
)
}
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 (
<div className="py-1">
{renderBlock(inner)}
</div>
)
}
// 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 (
<div className={userBubbleClass}>
<div className="flex flex-col gap-3">
{inner.map((block, idx) => (
<div key={idx}>
{renderBlock(block)}
</div>
))}
</div>
{status && (
<div className="mt-0.5 flex justify-end">
<MessageStatusIndicator status={status} onRetry={props.onRetry} />
</div>
)}
</div>
)
}
if (isObject(inner)) {
return (
<div className={userBubbleClass}>
{renderBlock(inner)}
{status && (
<div className="mt-0.5 flex justify-end">
<MessageStatusIndicator status={status} onRetry={props.onRetry} />
</div>
)}
</div>
)
}
return (
<div className={userBubbleClass}>
<div className="flex items-end gap-2">
<div className="flex-1">
{renderBlock(typeof inner === 'string' ? inner : safeStringify(inner))}
</div>
{status && (
<div className="shrink-0 self-end pb-0.5">
<MessageStatusIndicator status={status} onRetry={props.onRetry} />
</div>
)}
</div>
</div>
)
}
// Agent messages: no bubble, full width
if (Array.isArray(inner)) {
return (
<div className="flex flex-col gap-2">
{inner.map((block, idx) => (
<div key={idx}>
{renderBlock(block)}
</div>
))}
</div>
)
}
if (isObject(inner)) {
return renderBlock(inner)
}
return renderBlock(typeof inner === 'string' ? inner : safeStringify(inner))
}
+4 -45
View File
@@ -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 { AssistantRuntimeProvider } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client' import type { ApiClient } from '@/api/client'
import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api' 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 { HappyThread } from '@/components/AssistantChat/HappyThread'
import { useHappyRuntime } from '@/lib/assistant-runtime' import { useHappyRuntime } from '@/lib/assistant-runtime'
import { SessionHeader } from '@/components/SessionHeader' import { SessionHeader } from '@/components/SessionHeader'
import { MessageBubble } from '@/components/MessageBubble'
import { getTelegramWebApp } from '@/hooks/useTelegram' import { getTelegramWebApp } from '@/hooks/useTelegram'
export function SessionChat(props: { 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 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 // Permission mode change handler
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => { const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
try { try {
@@ -95,7 +91,7 @@ export function SessionChat(props: {
const runtime = useHappyRuntime({ const runtime = useHappyRuntime({
session: props.session, session: props.session,
blocks: viewMode === 'raw' ? [] : reduced.blocks, blocks: reduced.blocks,
isSending: props.isSending, isSending: props.isSending,
onSendMessage: props.onSend, onSendMessage: props.onSend,
onAbort: handleAbort onAbort: handleAbort
@@ -118,25 +114,6 @@ export function SessionChat(props: {
</div> </div>
) : null} ) : null}
{import.meta.env.DEV ? (
<div className="mb-2 flex items-center gap-2">
<Button
variant={viewMode === 'reduced' ? 'default' : 'secondary'}
size="sm"
onClick={() => setDebugViewMode('reduced')}
>
Reduced
</Button>
<Button
variant={viewMode === 'raw' ? 'default' : 'secondary'}
size="sm"
onClick={() => setDebugViewMode('raw')}
>
Raw
</Button>
</div>
) : null}
{props.hasMoreMessages ? ( {props.hasMoreMessages ? (
<div className="mb-3"> <div className="mb-3">
<Button <Button
@@ -150,24 +127,9 @@ export function SessionChat(props: {
</div> </div>
) : null} ) : null}
{import.meta.env.DEV && viewMode === 'reduced' && normalizedMessages.length === 0 && props.messages.length > 0 ? ( {import.meta.env.DEV && normalizedMessages.length === 0 && props.messages.length > 0 ? (
<div className="mb-2 rounded-md bg-amber-500/10 p-2 text-xs"> <div className="mb-2 rounded-md bg-amber-500/10 p-2 text-xs">
Message normalization returned 0 items for {props.messages.length} messages (see `hapi/web/src/chat/normalize.ts`). Message normalization returned 0 items for {props.messages.length} messages (see `web/src/chat/normalize.ts`).
</div>
) : null}
{viewMode === 'raw' ? (
<div className="flex flex-col gap-2">
{props.messages.map((m) => (
<MessageBubble
key={m.id}
message={m}
onRetry={m.localId && m.status === 'failed' && props.onRetryMessage
? () => props.onRetryMessage!(m.localId!)
: undefined
}
/>
))}
</div> </div>
) : null} ) : null}
</> </>
@@ -178,10 +140,7 @@ export function SessionChat(props: {
props.hasMoreMessages, props.hasMoreMessages,
props.isLoadingMoreMessages, props.isLoadingMoreMessages,
props.onLoadMore, props.onLoadMore,
props.messages,
props.messages.length, props.messages.length,
props.onRetryMessage,
viewMode,
normalizedMessages.length normalizedMessages.length
]) ])