MessageBubble.tsx
Language TypeScript5.0 KB150 lines
import { memo, useState } from 'react'import type { Message } from '../ipc/gen/Message'import { seconds, shortModel, thousands, usd } from '../lib/format'import { useStore } from '../state/store'import { AttachmentChip, AttachmentImage } from './Attachment'import { Markdown } from './Markdown'export function CostLine({ m }: { m: Message }) {const est = m.usage_source === 'estimated'const u = m.usageif (m.usage_source === 'none') return nullreturn (<span className="cost-line" data-testid="cost-line">{thousands(u.prompt_tokens)} in{u.cached_tokens > 0 ? ` (${thousands(u.cached_tokens)} cached)` : ''},{' '}{thousands(u.completion_tokens)} out, <strong>{m.prices ? usd(m.cost_nanousd) : 'price unknown'}</strong>{est && (<abbr className="est" title="Estimated: the server did not report token counts">est.</abbr>)}</span>)}interface Props {m: Messagelive?: { text: string; reasoning: string } | nullisLastAssistant?: boolean}export const MessageBubble = memo(function MessageBubble({ m, live, isLastAssistant }: Props) {const [editing, setEditing] = useState(false)const [draft, setDraft] = useState(m.content)const regenerate = useStore((s) => s.regenerate)const editAndResend = useStore((s) => s.editAndResend)const busy = useStore((s) => !!s.streaming)if (m.role === 'user') {return (<div className="msg user" data-testid="msg-user"><div className="bubble">{m.attachments.filter((a) => a.kind === 'image').map((a) => (<AttachmentImage key={a.id} a={a} />))}{m.attachments.some((a) => a.kind === 'text') && (<div className="chips">{m.attachments.filter((a) => a.kind === 'text').map((a) => (<AttachmentChip key={a.id} a={a} />))}</div>)}{editing ? (<div className="edit"><textareavalue={draft}onChange={(e) => setDraft(e.target.value)}rows={Math.min(10, draft.split('\n').length + 1)}autoFocus/><div className="row end"><button className="ghost" onClick={() => setEditing(false)}>Cancel</button><buttonclassName="primary"disabled={!draft.trim() || busy}onClick={() => {setEditing(false)void editAndResend(m.id, draft)}}>Send edit</button></div></div>) : (m.content && <div className="user-text selectable">{m.content}</div>)}</div>{!editing && (<div className="msg-actions"><button className="ghost small" onClick={() => void navigator.clipboard?.writeText(m.content)}>Copy</button><buttonclassName="ghost small"disabled={busy}onClick={() => {setDraft(m.content)setEditing(true)}}>Edit</button></div>)}</div>)}const text = live ? live.text : m.contentconst reasoning = live ? live.reasoning : m.reasoningreturn (<div className={`msg assistant ${m.status}`} data-testid="msg-assistant" aria-busy={!!live}>{reasoning && (<details className="reasoning"><summary>{live && !text ? 'Thinking...' : 'Reasoning'}</summary><div className="reasoning-text selectable">{reasoning}</div></details>)}{text ? (<Markdown text={text} />) : live ? (<div className="typing" aria-label="Waiting for the reply"><span /><span /><span /></div>) : null}{live && text && <span className="caret" aria-hidden />}{m.status === 'error' && !live && (<div className="msg-error" role="alert">{m.error_message ?? 'The reply failed.'}</div>)}{m.status === 'cancelled' && !live && <div className="msg-note">Stopped.</div>}{!live && (<div className="msg-meta"><span className="model-tag">{shortModel(m.model_id ?? '')}</span><CostLine m={m} />{m.duration_ms != null && <span className="timing">{seconds(m.duration_ms)}</span>}<span className="spacer" /><button className="ghost small" onClick={() => void navigator.clipboard?.writeText(m.content)}>Copy</button>{isLastAssistant && (<button className="ghost small" disabled={busy} onClick={() => void regenerate()}>{m.status === 'complete' ? 'Regenerate' : 'Retry'}</button>)}</div>)}</div>)})