Skip to content

Commit 4622966

Browse files
committed
improvement(home): interactions
1 parent e9550c6 commit 4622966

10 files changed

Lines changed: 749 additions & 419 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { MessageContent } from './message-content'
2+
export { UserInput } from './user-input'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { MessageContent } from './message-content'
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'use client'
2+
3+
import { Check, CircleAlert, Loader2 } from 'lucide-react'
4+
import ReactMarkdown from 'react-markdown'
5+
import remarkGfm from 'remark-gfm'
6+
import { cn } from '@/lib/core/utils/cn'
7+
import type { ContentBlock, ToolCallStatus } from '../../types'
8+
9+
const REMARK_PLUGINS = [remarkGfm]
10+
const ICON_BASE = 'h-[12px] w-[12px] flex-shrink-0'
11+
12+
function StatusIcon({ status }: { status: ToolCallStatus }) {
13+
switch (status) {
14+
case 'executing':
15+
return <Loader2 className={cn(ICON_BASE, 'animate-spin text-[var(--text-tertiary)]')} />
16+
case 'success':
17+
return <Check className={cn(ICON_BASE, 'text-emerald-500')} />
18+
case 'error':
19+
return <CircleAlert className={cn(ICON_BASE, 'text-red-400')} />
20+
}
21+
}
22+
23+
function formatToolName(name: string): string {
24+
return name
25+
.replace(/_v\d+$/, '')
26+
.split('_')
27+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
28+
.join(' ')
29+
}
30+
31+
interface TextSegment {
32+
type: 'text'
33+
content: string
34+
}
35+
36+
interface ActionSegment {
37+
type: 'action'
38+
id: string
39+
label: string
40+
status: ToolCallStatus
41+
}
42+
43+
type MessageSegment = TextSegment | ActionSegment
44+
45+
/**
46+
* Flattens raw content blocks into a uniform list of text and action segments.
47+
* Tool calls and subagents are treated identically as action items.
48+
*/
49+
function parseBlocks(blocks: ContentBlock[], isStreaming: boolean): MessageSegment[] {
50+
const segments: MessageSegment[] = []
51+
const lastSubagentIdx = blocks.findLastIndex((b) => b.type === 'subagent')
52+
53+
for (let i = 0; i < blocks.length; i++) {
54+
const block = blocks[i]
55+
56+
switch (block.type) {
57+
case 'text': {
58+
if (block.content?.trim()) {
59+
const last = segments[segments.length - 1]
60+
if (last?.type === 'text') {
61+
last.content += block.content
62+
} else {
63+
segments.push({ type: 'text', content: block.content })
64+
}
65+
}
66+
break
67+
}
68+
case 'subagent': {
69+
if (block.content) {
70+
segments.push({
71+
type: 'action',
72+
id: `subagent-${i}`,
73+
label: block.content,
74+
status: isStreaming && i === lastSubagentIdx ? 'executing' : 'success',
75+
})
76+
}
77+
break
78+
}
79+
case 'tool_call': {
80+
if (block.toolCall) {
81+
segments.push({
82+
type: 'action',
83+
id: block.toolCall.id,
84+
label: block.toolCall.displayTitle || formatToolName(block.toolCall.name),
85+
status: block.toolCall.status,
86+
})
87+
}
88+
break
89+
}
90+
}
91+
}
92+
93+
return segments
94+
}
95+
96+
interface MessageContentProps {
97+
blocks: ContentBlock[]
98+
fallbackContent: string
99+
isStreaming: boolean
100+
}
101+
102+
export function MessageContent({ blocks, fallbackContent, isStreaming }: MessageContentProps) {
103+
const parsed = blocks.length > 0 ? parseBlocks(blocks, isStreaming) : []
104+
105+
const segments: MessageSegment[] =
106+
parsed.length > 0
107+
? parsed
108+
: fallbackContent?.trim()
109+
? [{ type: 'text' as const, content: fallbackContent }]
110+
: []
111+
112+
if (segments.length === 0) return null
113+
114+
return (
115+
<>
116+
{segments.map((segment, i) => {
117+
if (segment.type === 'text') {
118+
return (
119+
<div
120+
key={`text-${i}`}
121+
className='prose prose-neutral prose-sm dark:prose-invert max-w-none'
122+
>
123+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{segment.content}</ReactMarkdown>
124+
</div>
125+
)
126+
}
127+
128+
return (
129+
<div key={segment.id} className='flex items-center gap-[8px] py-[4px]'>
130+
<StatusIcon status={segment.status} />
131+
<span className='text-[13px] text-[var(--text-secondary)]'>{segment.label}</span>
132+
</div>
133+
)
134+
})}
135+
</>
136+
)
137+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { UserInput } from './user-input'
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useRef, useState } from 'react'
4+
import { ArrowUp, Mic } from 'lucide-react'
5+
import { Button } from '@/components/emcn'
6+
import { cn } from '@/lib/core/utils/cn'
7+
import { useAnimatedPlaceholder } from '../../hooks'
8+
9+
const TEXTAREA_CLASSES =
10+
'm-0 box-border h-auto max-h-[30vh] min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[4px] py-[4px] font-medium font-sans text-[14px] text-[var(--text-primary)] leading-[20px] outline-none placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
11+
12+
const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors'
13+
const SEND_BUTTON_ACTIVE =
14+
'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
15+
const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
16+
17+
function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>) {
18+
const target = e.target as HTMLTextAreaElement
19+
target.style.height = 'auto'
20+
target.style.height = `${Math.min(target.scrollHeight, window.innerHeight * 0.3)}px`
21+
}
22+
23+
interface UserInputProps {
24+
value: string
25+
onChange: (value: string) => void
26+
onSubmit: () => void
27+
isSending: boolean
28+
onStopGeneration: () => void
29+
animate?: boolean
30+
}
31+
32+
export function UserInput({
33+
value,
34+
onChange,
35+
onSubmit,
36+
isSending,
37+
onStopGeneration,
38+
animate = true,
39+
}: UserInputProps) {
40+
const animatedPlaceholder = useAnimatedPlaceholder()
41+
const placeholder = animate ? animatedPlaceholder : 'Send message to Sim'
42+
const canSubmit = value.trim().length > 0 && !isSending
43+
44+
const [isListening, setIsListening] = useState(false)
45+
const recognitionRef = useRef<SpeechRecognition | null>(null)
46+
const prefixRef = useRef('')
47+
48+
useEffect(() => {
49+
return () => {
50+
recognitionRef.current?.abort()
51+
}
52+
}, [])
53+
54+
const textareaRef = useRef<HTMLTextAreaElement>(null)
55+
56+
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
57+
if ((e.target as HTMLElement).closest('button')) return
58+
textareaRef.current?.focus()
59+
}, [])
60+
61+
const handleKeyDown = useCallback(
62+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
63+
if (e.key === 'Enter' && !e.shiftKey) {
64+
e.preventDefault()
65+
onSubmit()
66+
}
67+
},
68+
[onSubmit]
69+
)
70+
71+
const toggleListening = useCallback(() => {
72+
if (isListening) {
73+
recognitionRef.current?.stop()
74+
recognitionRef.current = null
75+
setIsListening(false)
76+
return
77+
}
78+
79+
const w = window as Window & {
80+
SpeechRecognition?: typeof SpeechRecognition
81+
webkitSpeechRecognition?: typeof SpeechRecognition
82+
}
83+
const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition
84+
if (!SpeechRecognitionAPI) return
85+
86+
prefixRef.current = value
87+
88+
const recognition = new SpeechRecognitionAPI()
89+
recognition.continuous = true
90+
recognition.interimResults = true
91+
recognition.lang = 'en-US'
92+
93+
recognition.onresult = (event: SpeechRecognitionEvent) => {
94+
let transcript = ''
95+
for (let i = 0; i < event.results.length; i++) {
96+
transcript += event.results[i][0].transcript
97+
}
98+
const prefix = prefixRef.current
99+
onChange(prefix ? `${prefix} ${transcript}` : transcript)
100+
}
101+
102+
recognition.onend = () => {
103+
if (recognitionRef.current === recognition) {
104+
try {
105+
recognition.start()
106+
} catch {
107+
recognitionRef.current = null
108+
setIsListening(false)
109+
}
110+
}
111+
}
112+
recognition.onerror = (e: SpeechRecognitionErrorEvent) => {
113+
if (e.error === 'aborted' || e.error === 'not-allowed') {
114+
recognitionRef.current = null
115+
setIsListening(false)
116+
}
117+
}
118+
119+
recognitionRef.current = recognition
120+
recognition.start()
121+
setIsListening(true)
122+
}, [isListening, value, onChange])
123+
124+
return (
125+
<div
126+
onClick={handleContainerClick}
127+
className='mx-auto w-full max-w-[640px] cursor-text rounded-[12px] border border-[var(--border-1)] bg-white px-[10px] py-[8px] shadow-sm dark:bg-[var(--surface-4)]'
128+
>
129+
<textarea
130+
ref={textareaRef}
131+
value={value}
132+
onChange={(e) => onChange(e.target.value)}
133+
onKeyDown={handleKeyDown}
134+
onInput={autoResizeTextarea}
135+
placeholder={placeholder}
136+
rows={1}
137+
className={TEXTAREA_CLASSES}
138+
/>
139+
<div className='flex items-center justify-end gap-[6px]'>
140+
<button
141+
type='button'
142+
onClick={toggleListening}
143+
className={cn(
144+
'flex h-[28px] w-[28px] items-center justify-center rounded-full transition-colors',
145+
isListening
146+
? 'bg-red-500 text-white hover:bg-red-600'
147+
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)]'
148+
)}
149+
title={isListening ? 'Stop listening' : 'Voice input'}
150+
>
151+
<Mic className='h-[16px] w-[16px]' strokeWidth={2} />
152+
</button>
153+
{isSending ? (
154+
<Button
155+
onClick={onStopGeneration}
156+
className={cn(SEND_BUTTON_BASE, SEND_BUTTON_ACTIVE)}
157+
title='Stop generation'
158+
>
159+
<svg
160+
className='block h-[14px] w-[14px] fill-white dark:fill-black'
161+
viewBox='0 0 24 24'
162+
xmlns='http://www.w3.org/2000/svg'
163+
>
164+
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
165+
</svg>
166+
</Button>
167+
) : (
168+
<Button
169+
onClick={onSubmit}
170+
disabled={!canSubmit}
171+
className={cn(SEND_BUTTON_BASE, canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED)}
172+
>
173+
<ArrowUp
174+
className='block h-[16px] w-[16px] text-white dark:text-black'
175+
strokeWidth={2.25}
176+
/>
177+
</Button>
178+
)}
179+
</div>
180+
</div>
181+
)
182+
}

0 commit comments

Comments
 (0)