Turn Instruction into a chat with an agent that asks
The Instruction page is now a conversation with the Express Agent. Each message updates the draft plan, and when something is unclear (a part it did not understand, a video count with no cadence, a schedule over 50 per week, weekly research with no day, a missing topic) the agent asks right in the chat, one question at a time, with replies to pick or type. Once the plan is clear it offers to start the schedules and reports the result in the chat. The plan stays editable beside the chat. The chat text is also larger and darker than the mockup's 9-11px grey. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Icon, ruleIcon } from '../components/Icon'
|
||||
import { PageHead } from '../components/Page'
|
||||
import {
|
||||
dayName, describeRule, kindLabel, KINDS, monthNote, parseInstruction, videoCadence, weeklyTarget,
|
||||
dayName, describeRule, kindLabel, KINDS, monthNote, videoCadence, weeklyTarget,
|
||||
type ContentKind, type ContentRule, 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, VIDEO, videoQuote } from '../express/schedules'
|
||||
|
||||
@@ -16,112 +17,151 @@ const EXAMPLES: Key[] = ['ins.ex1', 'ins.ex2', 'ins.ex3', 'ins.ex4', 'ins.ex5']
|
||||
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[] }
|
||||
|
||||
export function InstructionPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [text, setText] = useState('')
|
||||
const [rules, setRules] = useState<Rule[] | null>(null)
|
||||
const [unknown, setUnknown] = useState<string[]>([])
|
||||
const [agent, setAgent] = useState<AgentState>(EMPTY)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null)
|
||||
const chat = useRef<HTMLDivElement>(null)
|
||||
|
||||
function compose() {
|
||||
const result = parseInstruction(text)
|
||||
setRules(result.rules)
|
||||
setUnknown(result.unknown)
|
||||
setOutcomes(null)
|
||||
const question = nextQuestion(agent)
|
||||
const ready = agent.items.filter(i => canSchedule(i.rule))
|
||||
// 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: [] }
|
||||
: question
|
||||
? ask(question, agent)
|
||||
: agent.items.length
|
||||
? { text: 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)) }
|
||||
|
||||
// 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])
|
||||
|
||||
function send(input: string) {
|
||||
if (!input.trim() || saving) return
|
||||
const turn = 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)
|
||||
setText('')
|
||||
if (turn.approve) void approve(turn.state)
|
||||
}
|
||||
|
||||
// A count or frequency typed in by hand replaces the monthly request it was derived from.
|
||||
const update = (i: number, patch: Partial<ContentRule> | Partial<ResearchRule>) =>
|
||||
setRules(rs => rs!.map((r, j) => (j === i
|
||||
? ({ ...r, ...patch, ...('count' in patch || 'per' in patch ? { monthly: undefined } : {}) } as Rule)
|
||||
: r)))
|
||||
const remove = (i: number) => setRules(rs => rs!.filter((_, j) => j !== i))
|
||||
|
||||
const ready = rules?.filter(canSchedule) ?? []
|
||||
|
||||
async function approve() {
|
||||
async function approve(state = agent) {
|
||||
const items = state.items.filter(i => canSchedule(i.rule))
|
||||
if (!items.length) return
|
||||
setSaving(true)
|
||||
const done: Outcome[] = []
|
||||
const created = new Set<number>()
|
||||
// One at a time so a failure (e.g. not an organization owner) is reported per schedule.
|
||||
for (const rule of ready) {
|
||||
for (const { id, rule } of items) {
|
||||
try {
|
||||
await createSchedule(rule)
|
||||
done.push({ rule, ok: true })
|
||||
created.add(id)
|
||||
} catch (e) {
|
||||
done.push({ rule, ok: false, message: (e as Error).message })
|
||||
}
|
||||
setOutcomes([...done])
|
||||
}
|
||||
setSaving(false)
|
||||
await queryClient.invalidateQueries({ queryKey: ['schedules'] })
|
||||
if (done.every(o => o.ok)) { setRules(null); setText('') }
|
||||
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 }])
|
||||
// Only the failed rules stay, so approving again does not create the others twice.
|
||||
else setRules(rs => rs!.filter(r => !done.some(o => o.ok && o.rule === r)))
|
||||
setAgent(s => (ok === done.length ? EMPTY : { ...s, items: s.items.filter(i => !created.has(i.id)) }))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('ins.title')} description={t('ins.description')} />
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('ins.cardTitle')}</h3><Icon name="spark" /></div>
|
||||
<div style={{ padding: '20px 22px 8px' }}>
|
||||
<label htmlFor="instruction">{t('ins.label')}</label>
|
||||
<textarea id="instruction" rows={3} value={text} placeholder={t('ins.placeholder')}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && text.trim()) compose() }} />
|
||||
<div className="ai-compose-footer">
|
||||
<small>{t('ins.hint')}</small>
|
||||
<button className="btn primary" disabled={!text.trim()} onClick={compose}><Icon name="calendar" />{t('ins.compose')}</button>
|
||||
<div className="ai-workspace">
|
||||
<div className="card agent-chat">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prompt-chips" style={{ paddingTop: 12 }}>
|
||||
{EXAMPLES.map(key => <button key={key} onClick={() => { setText(t(key)); setRules(null); setOutcomes(null) }}>{t(key)}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rules && (
|
||||
<div className="card" style={{ marginTop: 22 }}>
|
||||
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
|
||||
{rules.length === 0 && (
|
||||
<div className="empty"><h3>{t('ins.notUnderstood')}</h3><p>{t('ins.notUnderstoodCopy')}</p></div>
|
||||
)}
|
||||
<div className="rule-list">
|
||||
{rules.map((rule, i) => <RuleCard key={i} rule={rule} onChange={p => update(i, p)} onRemove={() => remove(i)} />)}
|
||||
<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} />}
|
||||
</div>
|
||||
{unknown.length > 0 && (
|
||||
<div className="notice-row" style={{ margin: '0 22px 18px' }}>
|
||||
<Icon name="info" /><span>{t('ins.skipped', { parts: unknown.map(u => `“${u}”`).join(', ') })}</span>
|
||||
{live.options.length > 0 && (
|
||||
<div className="prompt-chips">
|
||||
{live.options.map((o, i) => (
|
||||
<button key={o} className={i === 0 && !question && agent.items.length ? 'primary' : undefined} onClick={() => send(o)}>{o}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form id="ai-request-form" onSubmit={e => { e.preventDefault(); send(text) }}>
|
||||
<label htmlFor="instruction" className="sr-only">{t('ins.label')}</label>
|
||||
<textarea id="instruction" rows={2} value={text} placeholder={t('agent.placeholder')} disabled={saving}
|
||||
onChange={e => setText(e.target.value)}
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>{t('ins.previewTitle')}</h3><span className="badge neutral">{t('ins.readyCount', { n: ready.length })}</span></div>
|
||||
{agent.items.length === 0 && <div className="empty"><p>{t('agent.planEmpty')}</p></div>}
|
||||
<div className="rule-list">
|
||||
{agent.items.map(({ id, rule }) => (
|
||||
<RuleCard key={id} rule={rule} onChange={p => setAgent(s => editRule(s, id, p))} onRemove={() => setAgent(s => removeRule(s, id))} />
|
||||
))}
|
||||
</div>
|
||||
{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} onClick={() => approve()}>
|
||||
<Icon name="check" />{saving ? t('ins.approving') : t('ins.approve', { n: ready.length })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{outcomes && (
|
||||
<ul className="rule-outcomes">
|
||||
{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>
|
||||
<span>{describeRule(o.rule)}</span>
|
||||
{o.message && <span className="error">{o.message}</span>}
|
||||
</li>
|
||||
))}
|
||||
{outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Bubble({ message, busy }: { message: Message; busy?: boolean }) {
|
||||
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.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>
|
||||
<span>{describeRule(o.rule)}</span>
|
||||
{o.message && <span className="error">{o.message}</span>}
|
||||
</li>
|
||||
))}
|
||||
{message.outcomes.some(o => o.ok) && <li><Link className="btn small" to="/schedule">{t('ins.viewSchedule')} <Icon name="arrow-right" /></Link></li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RuleCard({ rule, onChange, onRemove }: {
|
||||
rule: Rule
|
||||
onChange: (patch: Partial<ContentRule> | Partial<ResearchRule>) => void
|
||||
|
||||
Reference in New Issue
Block a user