feat: integrate @assistant-ui/react library with new chat components

This commit is contained in:
weishu
2025-12-18 01:15:37 +08:00
parent 3c7387f684
commit 532ceb5595
15 changed files with 1495 additions and 93 deletions
@@ -0,0 +1,122 @@
import { ComposerPrimitive } from '@assistant-ui/react'
function SettingsIcon() {
return (
<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>
)
}
function AbortIcon(props: { spinning: boolean }) {
if (props.spinning) {
return (
<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>
)
}
return (
<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>
)
}
function SendIcon() {
return (
<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>
)
}
export function ComposerButtons(props: {
canSend: boolean
controlsDisabled: boolean
showSettingsButton: boolean
onSettingsToggle: () => void
showAbortButton: boolean
abortDisabled: boolean
isAborting: boolean
onAbort: () => void
}) {
return (
<div className="flex items-center justify-between px-2 pb-2">
<div className="flex items-center gap-1">
{props.showSettingsButton ? (
<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={props.onSettingsToggle}
disabled={props.controlsDisabled}
>
<SettingsIcon />
</button>
) : null}
{props.showAbortButton ? (
<button
type="button"
disabled={props.abortDisabled}
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={props.onAbort}
>
<AbortIcon spinning={props.isAborting} />
</button>
) : null}
</div>
<ComposerPrimitive.Send
disabled={props.controlsDisabled || !props.canSend}
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
props.canSend && !props.controlsDisabled
? 'bg-black text-white'
: 'bg-[#C0C0C0] text-white'
} disabled:cursor-not-allowed`}
>
<SendIcon />
</ComposerPrimitive.Send>
</div>
)
}
@@ -0,0 +1,428 @@
import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react'
import {
type ChangeEvent as ReactChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
type SyntheticEvent as ReactSyntheticEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react'
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 '@/components/ChatInput/FloatingOverlay'
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
import { StatusBar } from '@/components/AssistantChat/StatusBar'
import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons'
export interface TextInputState {
text: string
selection: { start: number; end: number }
}
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'
}
const MODEL_MODES = ['default', 'sonnet', 'opus'] as const
const MODEL_MODE_LABELS: Record<string, string> = {
default: 'Default',
sonnet: 'Sonnet',
opus: 'Opus'
}
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
export function HappyComposer(props: {
disabled?: boolean
permissionMode?: PermissionMode
modelMode?: ModelMode
active?: boolean
thinking?: boolean
agentState?: AgentState | null
contextSize?: number
onPermissionModeChange?: (mode: PermissionMode) => void
onModelModeChange?: (mode: ModelMode) => void
autocompletePrefixes?: string[]
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
}) {
const {
disabled = false,
permissionMode = 'default',
modelMode = 'default',
active = true,
thinking = false,
agentState,
contextSize,
onPermissionModeChange,
onModelModeChange,
autocompletePrefixes = ['@', '/'],
autocompleteSuggestions = defaultSuggestionHandler
} = props
const api = useAssistantApi()
const composerText = useAssistantState(({ composer }) => composer.text)
const threadIsRunning = useAssistantState(({ thread }) => thread.isRunning)
const threadIsDisabled = useAssistantState(({ thread }) => thread.isDisabled)
const controlsDisabled = disabled || !active || threadIsDisabled
const trimmed = composerText.trim()
const hasText = trimmed.length > 0
const canSend = hasText && !controlsDisabled && !threadIsRunning
const [inputState, setInputState] = useState<TextInputState>({
text: '',
selection: { start: 0, end: 0 }
})
const [showSettings, setShowSettings] = useState(false)
const [isAborting, setIsAborting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setInputState((prev) => {
if (prev.text === composerText) return prev
return { ...prev, text: composerText }
})
}, [composerText])
const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes)
const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions(
activeWord,
autocompleteSuggestions,
{ clampSelection: true, wrapAround: true }
)
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')
}
}, [])
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
)
api.composer().setText(result.text)
setInputState({
text: result.text,
selection: { start: result.cursorPosition, end: result.cursorPosition }
})
setTimeout(() => {
const el = textareaRef.current
if (!el) return
el.setSelectionRange(result.cursorPosition, result.cursorPosition)
try {
el.focus({ preventScroll: true })
} catch {
el.focus()
}
}, 0)
haptic('light')
}, [api, suggestions, inputState, autocompletePrefixes, haptic])
const abortDisabled = controlsDisabled || isAborting || !threadIsRunning
useEffect(() => {
if (!isAborting) return
if (threadIsRunning) return
setIsAborting(false)
}, [isAborting, threadIsRunning])
const handleAbort = useCallback(() => {
if (abortDisabled) return
haptic('error')
setIsAborting(true)
api.thread().cancelRun()
}, [abortDisabled, api, haptic])
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
const key = e.key
// Avoid intercepting IME composition keystrokes (Enter, arrows, etc.)
if (e.nativeEvent.isComposing) {
return
}
if (suggestions.length > 0) {
if (key === 'ArrowUp') {
e.preventDefault()
moveUp()
return
}
if (key === 'ArrowDown') {
e.preventDefault()
moveDown()
return
}
if ((key === 'Enter' || key === 'Tab') && !e.shiftKey) {
e.preventDefault()
const indexToSelect = selectedIndex >= 0 ? selectedIndex : 0
handleSuggestionSelect(indexToSelect)
return
}
if (key === 'Escape') {
e.preventDefault()
clearSuggestions()
return
}
}
if (key === 'Escape' && threadIsRunning) {
e.preventDefault()
handleAbort()
return
}
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')
}
}, [
suggestions,
selectedIndex,
moveUp,
moveDown,
clearSuggestions,
handleSuggestionSelect,
threadIsRunning,
handleAbort,
onPermissionModeChange,
permissionMode,
haptic
])
useEffect(() => {
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
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])
const handleChange = useCallback((e: ReactChangeEvent<HTMLTextAreaElement>) => {
const selection = {
start: e.target.selectionStart,
end: e.target.selectionEnd
}
setInputState({ text: e.target.value, selection })
}, [])
const handleSelect = useCallback((e: ReactSyntheticEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement
setInputState(prev => ({
...prev,
selection: { start: target.selectionStart, end: target.selectionEnd }
}))
}, [])
const handleSettingsToggle = useCallback(() => {
haptic('light')
setShowSettings(prev => !prev)
}, [haptic])
const handlePermissionChange = useCallback((mode: PermissionMode) => {
if (!onPermissionModeChange || controlsDisabled) return
onPermissionModeChange(mode)
setShowSettings(false)
haptic('light')
}, [onPermissionModeChange, controlsDisabled, haptic])
const handleModelChange = useCallback((mode: ModelMode) => {
if (!onModelModeChange || controlsDisabled) return
onModelModeChange(mode)
setShowSettings(false)
haptic('light')
}, [onModelModeChange, controlsDisabled, haptic])
const showSettingsButton = Boolean(onPermissionModeChange || onModelModeChange)
const showAbortButton = true
const overlays = useMemo(() => {
if (showSettings) {
return (
<div className="absolute bottom-[100%] mb-2 w-full">
<FloatingOverlay maxHeight={320}>
<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>
<div className="mx-3 h-px bg-[var(--app-divider)]" />
<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>
)
}
if (suggestions.length > 0) {
return (
<div className="absolute bottom-[100%] mb-2 w-full">
<FloatingOverlay>
<Autocomplete
suggestions={suggestions}
selectedIndex={selectedIndex}
onSelect={(index) => handleSuggestionSelect(index)}
/>
</FloatingOverlay>
</div>
)
}
return null
}, [
showSettings,
suggestions,
selectedIndex,
controlsDisabled,
permissionMode,
modelMode,
handlePermissionChange,
handleModelChange,
handleSuggestionSelect
])
return (
<div className="px-3 pb-3 pt-2 bg-[var(--app-bg)]">
<div className="mx-auto w-full max-w-[720px]">
<ComposerPrimitive.Root className="relative">
{overlays}
<StatusBar
active={active}
thinking={thinking}
agentState={agentState}
contextSize={contextSize}
permissionMode={permissionMode}
/>
<div className="overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)]">
<div className="flex items-center px-4 py-3">
<ComposerPrimitive.Input
ref={textareaRef}
placeholder="Type a message..."
disabled={controlsDisabled}
maxRows={5}
submitOnEnter
cancelOnEscape={false}
onChange={handleChange}
onSelect={handleSelect}
onKeyDown={handleKeyDown}
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>
<ComposerButtons
canSend={canSend}
controlsDisabled={controlsDisabled}
showSettingsButton={showSettingsButton}
onSettingsToggle={handleSettingsToggle}
showAbortButton={showAbortButton}
abortDisabled={abortDisabled}
isAborting={isAborting}
onAbort={handleAbort}
/>
</div>
</ComposerPrimitive.Root>
</div>
</div>
)
}
@@ -0,0 +1,46 @@
import type { ReactNode } from 'react'
import { ThreadPrimitive } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import { HappyChatProvider } from '@/components/AssistantChat/context'
import { HappyAssistantMessage } from '@/components/AssistantChat/messages/AssistantMessage'
import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage'
import { HappySystemMessage } from '@/components/AssistantChat/messages/SystemMessage'
const THREAD_MESSAGE_COMPONENTS = {
UserMessage: HappyUserMessage,
AssistantMessage: HappyAssistantMessage,
SystemMessage: HappySystemMessage
} as const
export function HappyThread(props: {
api: ApiClient
sessionId: string
metadata: SessionMetadataSummary | null
disabled: boolean
onRefresh: () => void
onRetryMessage?: (localId: string) => void
header?: ReactNode
}) {
return (
<HappyChatProvider value={{
api: props.api,
sessionId: props.sessionId,
metadata: props.metadata,
disabled: props.disabled,
onRefresh: props.onRefresh,
onRetryMessage: props.onRetryMessage
}}>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
<ThreadPrimitive.Viewport className="min-h-0 flex-1 overflow-y-auto" autoScroll>
<div className="mx-auto w-full max-w-[720px] p-3">
{props.header}
<div className="flex flex-col gap-3">
<ThreadPrimitive.Messages components={THREAD_MESSAGE_COMPONENTS} />
</div>
</div>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
</HappyChatProvider>
)
}
@@ -0,0 +1,139 @@
import { useMemo } from 'react'
import type { AgentState, PermissionMode } from '@/types/api'
const PERMISSION_MODE_LABELS: Record<string, string> = {
default: 'Default',
acceptEdits: 'Accept Edits',
plan: 'Plan Mode',
bypassPermissions: 'Bypass All'
}
// 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"
]
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
}
}
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 {
return { text: `${Math.round(percentageRemaining)}% left`, color: 'text-[var(--app-hint)]' }
}
}
export function StatusBar(props: {
active: boolean
thinking: boolean
agentState: AgentState | null | undefined
contextSize?: number
permissionMode?: PermissionMode
}) {
const connectionStatus = useMemo(
() => getConnectionStatus(props.active, props.thinking, props.agentState),
[props.active, props.thinking, props.agentState]
)
const contextWarning = useMemo(
() => props.contextSize !== undefined ? getContextWarning(props.contextSize) : null,
[props.contextSize]
)
const permissionMode = props.permissionMode
return (
<div className="flex items-center justify-between px-2 pb-1">
<div className="flex items-baseline gap-3">
<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>
{contextWarning ? (
<span className={`text-[10px] ${contextWarning.color}`}>
{contextWarning.text}
</span>
) : null}
</div>
{(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>
) : null}
</div>
)
}
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { createContext, useContext } from 'react'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
export type HappyChatContextValue = {
api: ApiClient
sessionId: string
metadata: SessionMetadataSummary | null
disabled: boolean
onRefresh: () => void
onRetryMessage?: (localId: string) => void
}
const HappyChatContext = createContext<HappyChatContextValue | null>(null)
export function HappyChatProvider(props: { value: HappyChatContextValue; children: ReactNode }) {
return (
<HappyChatContext.Provider value={props.value}>
{props.children}
</HappyChatContext.Provider>
)
}
export function useHappyChatContext(): HappyChatContextValue {
const ctx = useContext(HappyChatContext)
if (!ctx) {
throw new Error('HappyChatContext is missing')
}
return ctx
}
@@ -0,0 +1,55 @@
import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown'
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import remarkGfm from 'remark-gfm'
import { CodeBlock } from '@/components/CodeBlock'
import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage'
function MarkdownSyntaxHighlighter(props: SyntaxHighlighterProps) {
return (
<CodeBlock
code={props.code}
language={props.language}
showCopyButton={false}
/>
)
}
const MARKDOWN_PLUGINS = [remarkGfm]
const MARKDOWN_COMPONENTS = {
SyntaxHighlighter: MarkdownSyntaxHighlighter
} as const
function MarkdownText() {
return (
<MarkdownTextPrimitive
className="markdown-content text-sm"
remarkPlugins={MARKDOWN_PLUGINS}
components={MARKDOWN_COMPONENTS}
/>
)
}
const TOOL_COMPONENTS = {
Fallback: HappyToolMessage
} as const
const MESSAGE_PART_COMPONENTS = {
Text: MarkdownText,
tools: TOOL_COMPONENTS
} as const
export function HappyAssistantMessage() {
const toolOnly = useAssistantState(({ message }) => {
if (message.role !== 'assistant') return false
const parts = message.content
return parts.length > 0 && parts.every((part) => part.type === 'tool-call')
})
const rootClass = toolOnly ? 'py-1' : 'px-1'
return (
<MessagePrimitive.Root className={rootClass}>
<MessagePrimitive.Content components={MESSAGE_PART_COMPONENTS} />
</MessagePrimitive.Root>
)
}
@@ -0,0 +1,17 @@
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
export function HappySystemMessage() {
const message = useAssistantState(({ message }) => message)
if (message.role !== 'system') return null
const text = message.content[0]?.type === 'text' ? message.content[0].text : ''
return (
<MessagePrimitive.Root className="py-1">
<div className="mx-auto w-fit max-w-[92%] px-2 text-center text-xs text-[var(--app-hint)] opacity-80">
{text}
</div>
</MessagePrimitive.Root>
)
}
@@ -0,0 +1,225 @@
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import type { ChatBlock } from '@/chat/types'
import type { AgentEvent, ToolCallBlock } from '@/chat/types'
import type { MessageStatus } from '@/types/api'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
import { LazyRainbowText } from '@/components/LazyRainbowText'
import { ToolCard } from '@/components/ToolCard/ToolCard'
import { useHappyChatContext } from '@/components/AssistantChat/context'
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object'
}
function isToolCallBlock(value: unknown): value is ToolCallBlock {
if (!isObject(value)) return false
if (value.kind !== 'tool-call') return false
if (typeof value.id !== 'string') return false
if (!isObject(value.tool)) return false
return true
}
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: AgentEvent): string {
if (event.type === 'switch') {
const mode = event.mode === 'local' ? 'local' : 'remote'
return `🔄 Switched to ${mode}`
}
if (event.type === 'title-changed') {
const title = typeof event.title === 'string' ? event.title : ''
return title ? `Title changed to "${title}"` : 'Title changed'
}
if (event.type === 'permission-mode-changed') {
const modeValue = (event as Record<string, unknown>).mode
const mode = typeof modeValue === 'string' ? modeValue : 'default'
return `🔐 Permission mode: ${mode}`
}
if (event.type === 'limit-reached') {
const endsAt = typeof event.endsAt === 'number' ? event.endsAt : null
return endsAt ? `⏳ Usage limit reached until ${formatUnixTimestamp(endsAt)}` : '⏳ Usage limit reached'
}
if (event.type === 'message') {
return typeof event.message === 'string' ? event.message : 'Message'
}
try {
return JSON.stringify(event)
} catch {
return String(event.type)
}
}
function HappyNestedBlockList(props: {
blocks: ChatBlock[]
}) {
const ctx = useHappyChatContext()
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 canRetry = status === 'failed' && typeof block.localId === 'string' && Boolean(ctx.onRetryMessage)
const onRetry = canRetry ? () => ctx.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.event)}
</div>
</div>
)
}
if (block.kind === 'tool-call') {
const isTask = block.tool.name === 'Task'
return (
<div key={`tool:${block.id}`} className="py-1">
<ToolCard
api={ctx.api}
sessionId={ctx.sessionId}
metadata={ctx.metadata}
disabled={ctx.disabled}
onDone={ctx.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">
<HappyNestedBlockList blocks={block.children} />
</div>
</details>
) : (
<div className="mt-2 pl-3">
<HappyNestedBlockList blocks={block.children} />
</div>
)
) : null}
</div>
)
}
return null
})}
</div>
)
}
export function HappyToolMessage(props: ToolCallMessagePartProps) {
const ctx = useHappyChatContext()
const artifact = props.artifact
if (!isToolCallBlock(artifact)) {
return (
<div className="py-1">
<div className="text-xs text-[var(--app-hint)]">
Tool call: {props.toolName}
</div>
</div>
)
}
const block = artifact
const isTask = block.tool.name === 'Task'
return (
<div className="py-1">
<ToolCard
api={ctx.api}
sessionId={ctx.sessionId}
metadata={ctx.metadata}
disabled={ctx.disabled}
onDone={ctx.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">
<HappyNestedBlockList blocks={block.children} />
</div>
</details>
) : (
<div className="mt-2 pl-3">
<HappyNestedBlockList blocks={block.children} />
</div>
)
) : null}
</div>
)
}
@@ -0,0 +1,73 @@
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import type { MessageStatus } from '@/types/api'
import { LazyRainbowText } from '@/components/LazyRainbowText'
import { useHappyChatContext } from '@/components/AssistantChat/context'
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
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>
)
}
export function HappyUserMessage() {
const ctx = useHappyChatContext()
const message = useAssistantState(({ message }) => message)
if (message.role !== 'user') return null
const text = message.content.find((part) => part.type === 'text')?.text ?? ''
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const status = custom?.status
const localId = custom?.localId
const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage)
const onRetry = canRetry ? () => ctx.onRetryMessage!(localId) : undefined
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'
return (
<MessagePrimitive.Root className={userBubbleClass}>
<div className="flex items-end gap-2">
<div className="flex-1">
<LazyRainbowText text={text} />
</div>
{status ? (
<div className="shrink-0 self-end pb-0.5">
<MessageStatusIndicator status={status} onRetry={onRetry} />
</div>
) : null}
</div>
</MessagePrimitive.Root>
)
}