Make the Instruction agent reply in the user's language, at a human pace

The agent answers in the language each message is written in (Indonesian
or English), whatever the UI language; short replies such as "LLM" keep
the conversation's language. inLang() runs t() in that language.

Each turn now reads like a person: a thinking step with what the agent is
doing, the reply typed out with "Thought for Ns" above it, a short typing
pause, then one question, with its options shown once it is typed.
Reduced motion skips the animation.

"dari file LLM" / "from the document X" is read as the topic, and topics
keep the user's capitals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-23 11:38:08 +07:00
parent f55cccb6eb
commit a1d49b37b5
6 changed files with 195 additions and 38 deletions

View File

@@ -7,32 +7,51 @@ import {
dayName, describeRule, kindLabel, KINDS, LANGUAGES, languageLabel, monthNote, videoCadence, weeklyTarget,
type ContentKind, type ContentRule, type OutputLanguage, type ResearchRule, type Rule,
} from '../express/parse'
import { ask, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
import { t, type Key } from '../i18n'
import { budgetFor, canSchedule, createSchedule, defaultLanguage, VIDEO, videoQuote } from '../express/schedules'
import { ask, chatLangOf, editRule, EMPTY, nextQuestion, removeRule, respond, type AgentState } from '../express/agent'
import { inLang, t, useLang, type Key, type Lang } from '../i18n'
import { budgetFor, canSchedule, createSchedule, VIDEO, videoQuote } from '../express/schedules'
const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
const THINKING: Key[] = ['agent.think1', 'agent.think2', 'agent.think3']
// How the kinds that are not a plain Compose output are made, shown under the rule.
const KIND_NOTE: Partial<Record<ContentKind, Key>> = { video: 'ins.noteVideo', audio: 'ins.noteAudio', scorm: 'ins.noteScorm' }
type Outcome = { rule: Rule; ok: boolean; message?: string }
type Message = { role: 'agent' | 'user'; text: string; outcomes?: Outcome[] }
type Message = { role: 'agent' | 'user'; text: string; outcomes?: Outcome[]; thought?: number; typed?: boolean }
// The agent's pace: it thinks before replying, types its reply out, and pauses before the next question.
// Users who ask for reduced motion get the replies at once.
const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches
const thinkFor = (input: string) => (reducedMotion() ? 250 : Math.min(2200, 900 + input.length * 12) + Math.random() * 400)
const PAUSE = 650
type Phase = 'idle' | 'thinking' | 'replying' | 'pausing'
export function InstructionPage() {
const queryClient = useQueryClient()
const uiLang = useLang()
const [text, setText] = useState('')
const [agent, setAgent] = useState<AgentState>(EMPTY)
const [messages, setMessages] = useState<Message[]>([])
const [saving, setSaving] = useState(false)
const [phase, setPhase] = useState<Phase>('idle')
// The live question whose typing has finished, so its reply options can show.
const [typedLive, setTypedLive] = useState('')
// The agent answers in the language the user writes in; until they write, in the UI language.
const [chatLang, setChatLang] = useState<Lang | null>(null)
const lang = chatLang ?? uiLang
const chat = useRef<HTMLDivElement>(null)
const timers = useRef<number[]>([])
const later = (fn: () => void, ms: number) => { timers.current.push(window.setTimeout(fn, ms)) }
useEffect(() => () => timers.current.forEach(clearTimeout), [])
const question = nextQuestion(agent)
const ready = agent.items.filter(i => canSchedule(i.rule))
const hasContent = agent.items.some(i => i.rule.type === 'content')
// The agent's current turn: its question, or what it is waiting for. It stays live, so an edit in the plan updates it.
const live = saving
? { text: t('agent.approving'), options: [] }
const live = inLang(lang, () => saving
? { text: t('agent.approving'), options: [] as string[] }
: question
? ask(question, agent)
: agent.items.length
@@ -42,35 +61,49 @@ export function InstructionPage() {
: t('agent.ready', { n: ready.length }),
options: [t('agent.optApprove'), t('agent.optReset')] }
// The greeting comes once; after that the examples alone invite the next request.
: { text: messages.length ? '' : t('agent.greeting'), options: EXAMPLES.map(k => t(k)) }
: { text: messages.length ? '' : t('agent.greeting'), options: EXAMPLES.map(k => t(k)) })
const idle = phase === 'idle'
const showOptions = idle && !saving && live.options.length > 0 && (!live.text || typedLive === live.text)
// Scrolls only the chat, so the page itself does not jump while the user is reading the plan.
useEffect(() => { chat.current?.scrollTo({ top: chat.current.scrollHeight, behavior: 'smooth' }) }, [messages.length, live.text])
// A block body: newer browsers return a promise from scrollTo, which an effect must not return.
const scroll = () => { chat.current?.scrollTo({ top: chat.current.scrollHeight, behavior: 'smooth' }) }
useEffect(scroll, [messages.length, live.text, phase, showOptions])
function send(input: string) {
if (!input.trim() || saving) return
const turn = respond(agent, input)
if (!input.trim() || saving || !idle) return
// A picked option is in the conversation's language already; typed text may switch it.
const replyLang = live.options.includes(input) ? lang : chatLangOf(input) ?? lang
setChatLang(replyLang)
const turn = inLang(replyLang, () => respond(agent, input))
// The question being answered is kept in the history, with the reply after it.
setMessages(ms => [
...ms,
...(live.text ? [{ role: 'agent' as const, text: live.text }] : []),
{ role: 'user', text: input.trim() },
...(turn.reply ? [{ role: 'agent' as const, text: turn.reply }] : []),
])
setAgent(turn.state)
setMessages(ms => [...ms, ...(live.text ? [{ role: 'agent' as const, text: live.text }] : []), { role: 'user', text: input.trim() }])
setText('')
if (turn.approve) void approve(turn.state)
setPhase('thinking')
const ms = thinkFor(input)
later(() => {
setAgent(turn.state)
if (turn.approve) { setPhase('idle'); void approve(turn.state, replyLang); return }
setMessages(m => [...m, { role: 'agent', text: turn.reply, thought: Math.max(1, Math.round(ms / 1000)), typed: true }])
setPhase('replying')
}, ms)
}
async function approve(state = agent) {
// After a reply is typed out, a short pause before the next question appears.
function replied() {
setPhase('pausing')
later(() => setPhase('idle'), reducedMotion() ? 0 : PAUSE)
}
async function approve(state = agent, replyLang = lang) {
const items = state.items.filter(i => canSchedule(i.rule))
if (!items.length) return
setSaving(true)
const done: Outcome[] = []
const created = new Set<number>()
// Approving from the plan before the agent asked about the language uses the conversation's language.
const language: OutputLanguage = state.language ?? (replyLang === 'id' ? 'Bahasa Indonesia' : 'English')
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
// Approving from the plan before the agent asked about the language keeps the old default.
const language = state.language ?? defaultLanguage()
for (const { id, rule } of items) {
try {
await createSchedule(rule.type === 'content' ? { ...rule, language } : rule)
@@ -83,8 +116,9 @@ export function InstructionPage() {
setSaving(false)
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
const ok = done.filter(o => o.ok).length
const report = ok === done.length ? t('agent.done', { n: ok }) : t('agent.partial', { ok, n: done.length })
setMessages(ms => [...ms, { role: 'agent', text: report, outcomes: done }])
const report = inLang(replyLang, () => (ok === done.length ? t('agent.done', { n: ok }) : t('agent.partial', { ok, n: done.length })))
setMessages(ms => [...ms, { role: 'agent', text: report, outcomes: done, typed: true }])
setPhase('replying')
// Only the failed rules stay, so approving again does not create the others twice.
setAgent(s => (ok === done.length ? EMPTY : { ...s, items: s.items.filter(i => !created.has(i.id)) }))
}
@@ -98,15 +132,22 @@ export function InstructionPage() {
<div className="card-head">
<div className="assistant-title">
<span><Icon name="spark" /></span>
<div><h3>{t('agent.name')}</h3><small>{t('agent.status')}</small></div>
<div><h3>{t('agent.name')}</h3><small>{phase === 'thinking' ? t('agent.statusThinking') : t('agent.status')}</small></div>
</div>
</div>
<div className="ai-messages" aria-live="polite" ref={chat}>
{messages.map((m, i) => <Bubble key={i} message={m} />)}
{live.text && <Bubble message={{ role: 'agent', text: live.text }} busy={saving} />}
{messages.map((m, i) => (
<Bubble key={i} message={m} lang={lang} onTyped={i === messages.length - 1 && phase === 'replying' ? replied : undefined} onType={scroll} />
))}
{phase === 'thinking' && <Thinking lang={lang} />}
{phase === 'pausing' && <Typing />}
{idle && live.text && (
<Bubble key={live.text} message={{ role: 'agent', text: live.text, typed: true }} lang={lang} busy={saving}
onTyped={() => setTypedLive(live.text)} onType={scroll} />
)}
</div>
{live.options.length > 0 && (
<div className="prompt-chips">
{showOptions && (
<div className="prompt-chips appear">
{live.options.map((o, i) => (
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
))}
@@ -119,7 +160,7 @@ export function InstructionPage() {
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(text) } }} />
<div className="ai-compose-footer">
<small>{t('agent.hint')}</small>
<button className="btn primary" type="submit" disabled={!text.trim() || saving}><Icon name="arrow-right" />{t('agent.send')}</button>
<button className="btn primary" type="submit" disabled={!text.trim() || saving || !idle}><Icon name="arrow-right" />{t('agent.send')}</button>
</div>
</form>
</div>
@@ -144,7 +185,7 @@ export function InstructionPage() {
{ready.length > 0 && (
<div className="rule-approve">
<small>{t('ins.approveNote')}</small>
<button className="btn primary" disabled={saving} onClick={() => approve()}>
<button className="btn primary" disabled={saving || !idle} onClick={() => approve()}>
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
</button>
</div>
@@ -155,14 +196,35 @@ export function InstructionPage() {
)
}
function Bubble({ message, busy }: { message: Message; busy?: boolean }) {
// Types the text out over at most ~1.2 s, then calls onDone once.
function useTyped(text: string, enabled: boolean, onDone?: () => void, onType?: () => void) {
const [shown, setShown] = useState(enabled && !reducedMotion() ? 0 : text.length)
const done = useRef(onDone)
done.current = onDone
const step = useRef(onType)
step.current = onType
useEffect(() => {
if (shown >= text.length) { done.current?.(); return }
const perTick = Math.max(1, Math.ceil(text.length / 60))
const id = window.setTimeout(() => { setShown(n => Math.min(text.length, n + perTick)); step.current?.() }, 20)
return () => clearTimeout(id)
}, [shown, text.length])
return text.slice(0, shown)
}
function Bubble({ message, lang, busy, onTyped, onType }: {
message: Message; lang: Lang; busy?: boolean; onTyped?: () => void; onType?: () => void
}) {
const text = useTyped(message.text, !!message.typed, onTyped, onType)
const typing = text.length < message.text.length
return (
<div className={`ai-message${message.role === 'user' ? ' user' : ''}`}>
{message.role === 'agent' && <span>{busy ? <i className="spinner" /> : <Icon name="spark" />}</span>}
<div className="ai-bubble">
<p>{message.text}</p>
{message.outcomes && (
<ul className="rule-outcomes">
{message.thought && <small className="ai-thought">{inLang(lang, () => t('agent.thought', { s: message.thought! }))}</small>}
<p className={typing ? 'typing' : undefined}>{text}</p>
{message.outcomes && !typing && (
<ul className="rule-outcomes appear">
{message.outcomes.map((o, i) => (
<li key={i}>
<span className={`status ${o.ok ? 'ready' : 'draft'}`}><i className="dot"></i>{o.ok ? t('ins.active') : t('ins.failed')}</span>
@@ -178,6 +240,31 @@ function Bubble({ message, busy }: { message: Message; busy?: boolean }) {
)
}
// While the agent thinks: what it is doing, a step at a time.
function Thinking({ lang }: { lang: Lang }) {
const [step, setStep] = useState(0)
useEffect(() => {
const id = window.setInterval(() => setStep(n => Math.min(n + 1, THINKING.length - 1)), 700)
return () => clearInterval(id)
}, [])
return (
<div className="ai-message thinking" role="status">
<span><i className="spinner" /></span>
<div className="ai-bubble"><p>{inLang(lang, () => t(THINKING[step]))}<i className="dots"><b /><b /><b /></i></p></div>
</div>
)
}
// The short pause before the next question, like someone typing.
function Typing() {
return (
<div className="ai-message thinking" aria-hidden="true">
<span><Icon name="spark" /></span>
<div className="ai-bubble"><p><i className="dots"><b /><b /><b /></i></p></div>
</div>
)
}
function RuleCard({ rule, onChange, onRemove }: {
rule: Rule
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void