mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-09 07:29:51 +00:00
refactor: remove deprecated chat components in favor of @assistant-ui/react
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}))
|
||||
@@ -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 "{input.title}"
|
||||
</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 "{input.title}"
|
||||
</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))
|
||||
}
|
||||
@@ -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: {
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="mb-3">
|
||||
<Button
|
||||
@@ -150,24 +127,9 @@ export function SessionChat(props: {
|
||||
</div>
|
||||
) : 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">
|
||||
Message normalization returned 0 items for {props.messages.length} messages (see `hapi/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
|
||||
}
|
||||
/>
|
||||
))}
|
||||
Message normalization returned 0 items for {props.messages.length} messages (see `web/src/chat/normalize.ts`).
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
@@ -178,10 +140,7 @@ export function SessionChat(props: {
|
||||
props.hasMoreMessages,
|
||||
props.isLoadingMoreMessages,
|
||||
props.onLoadMore,
|
||||
props.messages,
|
||||
props.messages.length,
|
||||
props.onRetryMessage,
|
||||
viewMode,
|
||||
normalizedMessages.length
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user