Preserve isolated local drafts across navigation and reload

This commit is contained in:
aiagent
2026-10-19 22:47:17 -07:00
parent 9d69629bc8
commit 6a36a49290
16 changed files with 174 additions and 38 deletions
+23 -17
View File
@@ -7,10 +7,12 @@ import { Textarea } from '@/components/ui/textarea'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { PageHeading, Panel, Empty } from '@/components/shared'
import DecisionChat from '@/components/decision-chat'
import VersionNotice from '@/components/version-notice'
import { useLocalDraft } from '@/lib/use-local-draft'
import { useWorkbench } from '@/lib/context'
import { ApiError, errorText, messageKey, request } from '@/lib/api'
import { date, type Decision } from '@/lib/types'
import { decisionStatus, decisionLabels, writable, submissionBody, confirmDraft, type DecisionDraft, type ChatDraft } from './decision-ui'
import { decisionStatus, decisionLabels, writable, submissionBody, confirmDraft, type DecisionDraft } from './decision-ui'
type HistoryEntry = { id?: string | number; kind?: string; created?: number; revision?: number; data?: Record<string, unknown> }
function Status({ decision }: { decision: Decision }) { const status = decisionStatus(decision); return <Badge variant="outline" className={status === 'completed' ? 'status-success' : status === 'needs_reconsideration' ? 'status-danger' : 'status-neutral'}>{decisionLabels[status]}</Badge> }
@@ -29,7 +31,7 @@ function HistoryRecord({ entry }: { entry: HistoryEntry }) {
</li>
}
function Detail({ id, initial, drafts, chats }: { id: string; initial?: Decision; drafts: Map<string, DecisionDraft>; chats: Map<string, ChatDraft> }) {
function Detail({ id, initial }: { id: string; initial?: Decision }) {
const { token, refresh, state } = useWorkbench()
const [decision, setDecision] = useState(initial), [history, setHistory] = useState<HistoryEntry[]>([]), [error, setError] = useState(''), [notice, setNotice] = useState(''), [, redraw] = useState(0)
const reload = useRef<() => Promise<void>>(async () => {}), generation = useRef(0)
@@ -45,37 +47,41 @@ function Detail({ id, initial, drafts, chats }: { id: string; initial?: Decision
reload.current = poll; void poll(); const timer = setInterval(() => void poll(), 3000)
return () => { controller.abort(); clearInterval(timer) }
}, [id, token])
if (decision && !drafts.has(id)) drafts.set(id, { option: null, comment: '', revision: decision.revision ?? 1, conflict: false })
const draft = drafts.get(id)
const revision = decision?.revision ?? 1
const local = useLocalDraft<DecisionDraft>(state.instance_id && decision && Number.isInteger(decision.revision) && (decision.revision ?? 0) > 0 ? [state.instance_id, 'decision', id, String(revision)] : null, {option:null,comment:'',revision,conflict:false}, (v): v is DecisionDraft => Boolean(v && typeof v === 'object' && typeof (v as DecisionDraft).comment === 'string' && Number.isInteger((v as DecisionDraft).revision)), true)
const draft = local.value
const sending = useRef(false)
function persist() { local.save(draft); redraw(n => n + 1) }
const changed = Boolean(draft && (draft.revision !== revision || draft.conflict))
const editable = Boolean(decision && writable(decision) && !draft?.sending)
const editable = Boolean(decision && writable(decision) && !draft?.sending && local.ready)
async function submit() {
if (!decision || !draft || !editable || changed || (!draft.option && !draft.comment.trim())) return
if (!decision || !draft || sending.current || !editable || changed || (!draft.option && !draft.comment.trim())) return
const body = submissionBody(decision, draft)
if (!body) return
const signature = JSON.stringify(body)
if (!draft.attempt || draft.attempt.signature !== signature) draft.attempt = { signature, key: messageKey() }
if (!local.save(draft)) return
sending.current = true
draft.sending = true; redraw(n => n + 1); setNotice('')
try {
const result = await request<Decision>(token, `/api/decisions/${encodeURIComponent(id)}/respond`, { ...body, idempotency_key: draft.attempt.key })
++generation.current; setDecision(result); setNotice(''); draft.option = null; draft.comment = ''; draft.attempt = undefined
++generation.current; setDecision(result); setNotice(''); local.clear()
await refresh().catch(() => {}); await reload.current()
} catch (cause) {
if (cause instanceof ApiError && cause.status === 409) { draft.conflict = true; setNotice('内容或状态已变化,草稿已保留。请阅读最新内容并重新确认,未自动重交。'); await reload.current() }
if (cause instanceof ApiError && cause.status === 409) { draft.conflict = true; persist(); setNotice('内容或状态已变化,草稿已保留。请阅读最新内容并重新确认,未自动重交。'); await reload.current() }
else setNotice(`${errorText(cause)} 草稿已保留,相同内容重试复用原提交记录。`)
} finally { draft.sending = false; redraw(n => n + 1) }
} finally { sending.current = false; draft.sending = false; redraw(n => n + 1) }
}
return <><SheetHeader className="pr-12"><SheetTitle>{decision?.title ?? '读取决策…'}</SheetTitle><SheetDescription>{decision?.summary || decision?.question || '查看方案、检查结论与本决策咨询。'}</SheetDescription></SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-5">{error && <p role="status" className="mb-4 text-sm text-destructive">{error} 保留已读取内容。<Button variant="link" onClick={() => void reload.current()}>重试</Button></p>}
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-5"><VersionNotice />{error && <p role="status" className="mb-4 text-sm text-destructive">{error} 保留已读取内容。<Button variant="link" onClick={() => void reload.current()}>重试</Button></p>}
{decision && draft ? <div className="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]"><div className="min-w-0 space-y-5">
<div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">版本 {revision}</Badge>{decision.task_id && <Link to={`/tasks?task=${encodeURIComponent(decision.task_id)}`} className="text-sm text-primary underline">{state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id}</Link>}</div>
<p role="status" className="text-xs text-muted-foreground">{local.error || '本决策草稿保存在此浏览器,提交成功才清除。'}</p>{local.error && <Button variant="outline" size="sm" disabled={draft.sending} onClick={() => persist()}>重试保存草稿</Button>}<Button variant="ghost" size="sm" disabled={draft.sending} onClick={() => local.clear(true)}>清除本决策本地草稿(含旧版本)</Button>{!writable(decision) && (draft.comment || draft.option) && <section className="rounded-lg border p-3 text-sm"><h3>保留的本地草稿(只读,未自动提交)</h3><p>版本 {draft.revision} · 选项 {draft.option || '无'}</p><p className="whitespace-pre-wrap">{draft.comment}</p></section>}<div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">版本 {revision}</Badge>{decision.task_id && <Link to={`/tasks?task=${encodeURIComponent(decision.task_id)}`} className="text-sm text-primary underline">{state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id}</Link>}</div>
<Text label="需要决定的问题" text={decision.question} /><Text label="背景与依据" text={decision.details} /><Text label="影响" text={decision.impact} /><Text label="推荐与理由(不会替你选择)" text={decision.recommendation} />
{decision.deadline && Number.isFinite(Date.parse(decision.deadline)) && <p className="text-sm">截止时间:{date(decision.deadline)}</p>}
<p className="text-xs text-muted-foreground">选择提交后需检查;接受方案不代表权限授权,也不代表已经完成。</p>
{decision.options.length > 0 && <fieldset disabled={!editable || changed} className="space-y-2"><legend className="mb-2 font-medium">全部方案</legend>{decision.options.map(option => <label key={option.id} className="flex gap-3 rounded-lg border p-3"><input type="radio" name={`decision-${id}`} checked={draft.option === option.id} onChange={() => { draft.option = option.id; redraw(n => n + 1) }} className="mt-1 size-4 shrink-0 accent-primary" /><span><span className="font-medium">{option.label}</span><span className="mt-1 block whitespace-pre-wrap text-sm text-muted-foreground">{option.description}</span></span></label>)}</fieldset>}
{writable(decision) && <div className="space-y-3"><Button variant="ghost" size="sm" disabled={!editable || changed || !draft.option} onClick={() => { draft.option = null; redraw(n => n + 1) }}>清除选项,仅填写意见</Button><label className="block text-sm font-medium" htmlFor={`opinion-${id}`}>你的意见(可不选方案,直接填写)</label><Textarea id={`opinion-${id}`} disabled={!editable} value={draft.comment} onChange={event => { draft.comment = event.target.value; redraw(n => n + 1) }} />
{changed && <div className="space-y-2 rounded-lg border p-3"><p className="text-sm">请重新阅读当前版本;之前的选择不会自动作为本次确认。</p><Button variant="outline" disabled={draft.sending || Boolean(error)} onClick={() => { confirmDraft(decision, draft); setNotice('已确认当前版本,请重新选择方案或检查保留的意见后提交。'); redraw(n => n + 1) }}>我已阅读版本 {revision},重新确认</Button></div>}
{decision.options.length > 0 && <fieldset disabled={!editable || changed} className="space-y-2"><legend className="mb-2 font-medium">全部方案</legend>{decision.options.map(option => <label key={option.id} className="flex gap-3 rounded-lg border p-3"><input type="radio" name={`decision-${id}`} checked={draft.option === option.id} onChange={() => { draft.option = option.id; persist() }} className="mt-1 size-4 shrink-0 accent-primary" /><span><span className="font-medium">{option.label}</span><span className="mt-1 block whitespace-pre-wrap text-sm text-muted-foreground">{option.description}</span></span></label>)}</fieldset>}
{writable(decision) && <div className="space-y-3"><Button variant="ghost" size="sm" disabled={!editable || changed || !draft.option} onClick={() => { draft.option = null; persist() }}>清除选项,仅填写意见</Button><label className="block text-sm font-medium" htmlFor={`opinion-${id}`}>你的意见(可不选方案,直接填写)</label><Textarea id={`opinion-${id}`} disabled={!editable} value={draft.comment} onChange={event => { draft.comment = event.target.value; persist() }} />
{changed && <div className="space-y-2 rounded-lg border p-3"><p className="text-sm">请重新阅读当前版本;之前的选择不会自动作为本次确认。</p><Button variant="outline" disabled={draft.sending || Boolean(error)} onClick={() => { confirmDraft(decision, draft); persist(); setNotice('已确认当前版本,请重新选择方案或检查保留的意见后提交。'); redraw(n => n + 1) }}>我已阅读版本 {revision},重新确认</Button></div>}
<Button disabled={!editable || changed || (!draft.option && !draft.comment.trim())} onClick={() => void submit()}>{draft.sending ? '正在提交…' : '提交本次选择或意见'}</Button>
</div>}
{notice && <p role="status" className="text-sm">{notice}</p>}
@@ -85,16 +91,16 @@ function Detail({ id, initial, drafts, chats }: { id: string; initial?: Decision
{decision.status === 'completed' && <p className="text-sm">{decision.archive_status === 'archived' ? '咨询会话已确认归档,历史只读。' : '决策已完成,咨询会话待归档确认。'}</p>}
{Boolean(decision.supersedes_id || decision.related_decision_ids?.length) && <section><h3 className="mb-2 font-medium">关联决策</h3>{[...new Set([...(decision.supersedes_id ? [decision.supersedes_id] : []), ...(decision.related_decision_ids ?? [])])].map(other => <Link key={other} className="mr-3 text-sm text-primary underline" to={`/decisions?decision=${encodeURIComponent(other)}`}>{other}{other === decision.supersedes_id ? '(被此决策替代)' : ''}</Link>)}</section>}
<section><h3 className="mb-2 font-medium">版本与处理历史</h3>{history.length ? <ol className="space-y-3">{history.map((entry,index) => <HistoryRecord key={entry.id ?? index} entry={entry} />)}</ol> : <p className="text-sm text-muted-foreground">暂无已读取的历史记录。</p>}</section>
</div><div className="min-w-0"><DecisionChat key={id} decision={decision} token={token} drafts={chats} /></div></div> : !error && <Empty>正在读取当前决策。</Empty>}
</div><div className="min-w-0"><DecisionChat key={id} decision={decision} token={token} /></div></div> : !error && <Empty>正在读取当前决策。</Empty>}
</div></>
}
export default function Decisions() {
const { state } = useWorkbench(), [params, setParams] = useSearchParams()
const id = params.get('decision'), drafts = useRef(new Map<string, DecisionDraft>()), chats = useRef(new Map<string, ChatDraft>())
const id = params.get('decision')
const decisions = state.decisions ?? []
return <div className="space-y-6"><PageHeading title="方案与决策" description="查看方案、咨询依据并明确提交,跟踪检查与落实结果。" />
{decisions.length ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{decisions.map(decision => <Card key={decision.id} className="p-0"><button type="button" onClick={() => setParams(previous => { const next = new URLSearchParams(previous); next.set('decision',decision.id); return next })} className="flex h-full w-full flex-col gap-3 rounded-xl p-6 text-left hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring"><div className="flex flex-wrap gap-2"><Status decision={decision} /><Badge variant="outline">{decision.task_id ? state.tasks.find(task => task.id === decision.task_id)?.name || decision.task_id : '未关联任务'}</Badge></div><h2 className="font-semibold">{decision.title}</h2><p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{decision.summary || decision.question}</p><span className="mt-auto text-sm text-primary">查看方案与咨询</span></button></Card>)}</div> : <Panel title="决策展板"><Empty>当前没有登记决策事项。</Empty></Panel>}
{state.tasks.some(task => task.decision) && <Panel title="旧版文字条目(只读)">{state.tasks.filter(task => task.decision).map(task => <p key={task.id} className="mb-3 whitespace-pre-wrap text-sm">{task.name}:{task.decision}</p>)}</Panel>}
<Sheet open={id !== null} onOpenChange={value => { if (!value) setParams(previous => { const next = new URLSearchParams(previous); next.delete('decision'); return next }) }}><SheetContent className="w-full sm:max-w-6xl">{id && <Detail key={id} id={id} initial={decisions.find(decision => decision.id === id)} drafts={drafts.current} chats={chats.current} />}</SheetContent></Sheet>
<Sheet open={id !== null} onOpenChange={value => { if (!value) setParams(previous => { const next = new URLSearchParams(previous); next.delete('decision'); return next }) }}><SheetContent className="w-full sm:max-w-6xl">{id && <Detail key={id} id={id} initial={decisions.find(decision => decision.id === id)} />}</SheetContent></Sheet>
</div>
}