Assistant
One answer from the assistant, drawn from its stream of events.
SourcePackage @kerf-js/veneer
Preview
tsx
import { Assistant, foldAnswer } from '@kerf-js/veneer'import type { AgentEvent } from '@kerf-js/veneer'import type { AgentSource } from '@kerf-js/veneer'export function Answer() { return ( <Assistant answer={FINISHED} n={1} quiet onFeedback={() => {}} onRegenerate={() => {}} /> )}const DEPLOY_SOURCES: AgentSource[] = [ { title: 'd-4819 build log', path: 'mobile-bff · 15:27', snippet: 'health check failed: upstream ' + 'timed out after 3000 ms in ' + 'ap-south', }, { title: 'Health checks', path: 'kerf docs · Deploys', snippet: 'A deploy that fails three ' + 'checks in a row is held back ' + 'from traffic.', }, { title: 'c44e190', path: 'mobile-bff · retry.ts · L18', snippet: 'const attempts = 5 // was 2', },]const WHY_TEXT = 'Deploy `d-4819` failed its ' + 'health check. The new retry ' + 'code tries five times on ' + 'every timed out upstream call,' + ' so one slow request in ' + 'ap-south holds a connection ' + 'for 15 seconds [1]. Three ' + 'failed checks in a row held ' + 'the deploy back, so nobody ' + 'was served the bad version ' + '[2].\n\nThe cause is one line ' + 'in the retry settings [3]:\n\n' + '```TypeScript\nconst attempts ' + '= 5 // was 2\n```\n\n- `d-4801` ' + 'is still live and healthy.\n- ' + 'Lowering `attempts` to 2 ' + 'should get the deploy ' + 'through.\n'const WHY_FAILED: AgentEvent[] = [ { kind: 'plan', steps: [ { id: 'logs', title: 'Read the build log', state: 'done', }, { id: 'diff', title: 'Compare with the last good deploy', state: 'done', }, { id: 'sum', title: 'Explain what went wrong', state: 'running', }, ], }, { kind: 'reasoning', at: 0, text: 'The deploy reached the health ' + 'check, so the build itself is ' + 'fine. The log should say ' + 'which check failed and in ' + 'which region.', }, { kind: 'tool', id: 'logs', name: 'read_logs', args: '{ "deploy": "d-4819", "lines": 200 }', at: 4000, }, { kind: 'tool-result', id: 'logs', output: '3 failed checks\nupstream ' + 'timed out after 3000 ms ' + '(ap-south)', ms: 640, }, { kind: 'tool', id: 'diff', name: 'compare_deploys', args: '{ "from": "d-4801", "to": "d-4819" }', }, { kind: 'tool-result', id: 'diff', output: '1 file changed\nsrc/retry.ts: attempts 2 → 5', ms: 410, }, { kind: 'text', text: WHY_TEXT }, ...DEPLOY_SOURCES.map((s): AgentEvent => ({ kind: 'source', ...s, })), { kind: 'done' },]const FINISHED = foldAnswer(WHY_FAILED)Installation
npm install @kerf-js/veneerUsage
tsx
import { Assistant } from '@kerf-js/veneer'<Assistant answer={FINISHED} n={1} quiet onFeedback={() => {}} onRegenerate={() => {}}/>Failed
Retry runs it again.
tsx
import { state } from '@kerf-js/core'import { Assistant, foldAnswer } from '@kerf-js/veneer'import type { State } from '@kerf-js/core'import type { AgentEvent, Answer } from '@kerf-js/veneer'export function Failed() { const answer = state( foldAnswer([ { kind: 'text', text: 'Reading the log for `d-4819`', }, { kind: 'error', message: 'The connection dropped.', }, ]), ) const regenerate = () => { answer.value = foldAnswer([]) play(answer, [ { kind: 'text', text: 'Deploy `d-4819` failed its health ' + 'check in ap-south.', }, { kind: 'done' }, ]) } return ( <Assistant answer={answer.value} onRegenerate={regenerate} /> )}function play( answer: State<Answer>, events: AgentEvent[],) { let at = 0 const next = () => { const event = events[at++] if (!event || answer.value.state === 'stopped') return answer.value = foldAnswer(answer.value, event) setTimeout(next, 450) } setTimeout(next, 450)}Stopped
What arrived is kept.
tsx
import { Assistant, foldAnswer } from '@kerf-js/veneer'export function Stopped() { return ( <Assistant answer={CUT_OFF} quiet /> )}const CUT_OFF = foldAnswer([ { kind: 'text', text: 'Deploy `d-4819` failed its' }, { kind: 'done', stopped: true },])Waiting for the first word
An assistant answer with no content yet shows that a reply is on its way.
tsx
import { Assistant, foldAnswer } from '@kerf-js/veneer'export function Waiting() { return ( <Assistant answer={WAITING} /> )}const WAITING = foldAnswer([])Waiting for approval
Approve or Deny carries on the run. You can edit the arguments first.
tsx
import { state } from '@kerf-js/core'import { Assistant, Button, Stack, foldAnswer} from '@kerf-js/veneer'import type { State } from '@kerf-js/core'import type { AgentEvent, Answer } from '@kerf-js/veneer'export function Approval() { const answer = state(foldAnswer(ROLLBACK_ASK)) const reset = () => (answer.value = foldAnswer(ROLLBACK_ASK)) return ( <Stack gap="related"> <Assistant answer={answer.value} quiet onApprove={() => play(answer, ROLLBACK_RUN)} onDeny={() => play(answer, ROLLBACK_DENIED)} /> {answer.value.state === 'done' && ( <Button emphasis="ghost" tone="neutral" size="sm" onClick={reset} > Start again </Button> )} </Stack> )}const ROLLBACK_ARGS = '{\n "project": "mobile-bff",\n ' + ' "to": "d-4801",\n "regions": ' + '["ap-south"]\n}'const ROLLBACK_ASK: AgentEvent[] = [ { kind: 'reasoning', text: 'A rollback changes production,' + ' so I should check the target ' + 'first and ask before running ' + 'it.', }, { kind: 'tool', id: 'check', name: 'get_deploy', args: '{ "deploy": "d-4801" }', }, { kind: 'tool-result', id: 'check', output: 'd-4801 · live · healthy · 412 ms p95', ms: 280, }, { kind: 'text', text: '`d-4801` is healthy, so it is ' + 'a safe place to go back to. ' + 'This changes production, so I ' + 'need your approval.', }, { kind: 'approval', id: 'rollback', name: 'rollback', args: ROLLBACK_ARGS, },]const ROLLBACK_RUN: AgentEvent[] = [ { kind: 'tool', id: 'rollback' }, { kind: 'tool-result', id: 'rollback', output: 'mobile-bff is on d-4801 in ap-south', ms: 2100, }, { kind: 'text', text: 'Done. `mobile-bff` is back on ' + '`d-4801`, and the checks are ' + 'passing.', }, { kind: 'done' },]const ROLLBACK_DENIED: AgentEvent[] = [ { kind: 'tool-result', id: 'rollback', denied: true }, { kind: 'text', text: 'Okay, I left production as it is.', }, { kind: 'done' },]function play( answer: State<Answer>, events: AgentEvent[],) { let at = 0 const next = () => { const event = events[at++] if (!event || answer.value.state === 'stopped') return answer.value = foldAnswer(answer.value, event) setTimeout(next, 450) } setTimeout(next, 450)}When to use
Use it for
- Anything the assistant says: text, reasoning, tool calls, questions and sources in one turn
Accessibility
- The end of an answer is announced once. Set
quieton turns that were already there on load, so they take no focus.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
answer | Answer | — | The answer so far: foldAnswer(events), or the result of folding a stream. |
n | number | — | The turn number, used in its accessible name. |
arrive | boolean | false | Plays the entrance, for a turn that just started. |
quiet | boolean | false | The turn was there on load: approvals take no focus and nothing is announced. |
render | ((text: string, answer: Answer) => unknown) | — | Render a block of answer text yourself (Markdown, say); the default is Prose. |
alwaysAllow | ((tool: AgentTool) => string | undefined) | — | The label for the "always allow" box on an approval. |
branch | { index: number; count: number; onChange: (index: number) => void; } | — | Which of several answers is shown, with arrows between them. |
| Event | Type | Default | Description |
|---|---|---|---|
onApprove | ((tool: AgentTool, args: string, always: boolean) => void) | — | Called when a tool call is approved, with its arguments as edited and whether to always allow it. |
onDeny | ((tool: AgentTool) => void) | — | Called when a tool call is denied. |
onAnswer | ((id: string, answer: string | string[]) => void) | — | Called with a question's id and the answer picked or typed. |
onFeedback | ((value: Feedback, reason?: string | undefined) => void) | — | Called with up, down or null when thumbs change, and the reason picked for a thumbs down. |
onRegenerate | (() => void) | — | Adds Regenerate: ask for a new answer. |
onBranch | (() => void) | — | Adds Branch: start a new conversation from this answer. |
Parts
Every element carries a stable name, so you can style it from your own CSS: [data-scope="assistant"] [data-part="mark"]
markcontent