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
+2 -1
View File
@@ -13,6 +13,7 @@ import { Workbench } from '@/lib/context'
import { request, errorText } from '@/lib/api'
import type { State, AttentionItem } from '@/lib/types'
import { projectTitle, date } from '@/lib/types'
import VersionNotice from '@/components/version-notice'
import Overview from '@/pages/overview'
import Tasks from '@/pages/tasks'
import Cluster from '@/pages/cluster'
@@ -44,7 +45,7 @@ function Shell({ token, initial, logout }: { token: string; initial: State; logo
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-4 md:px-6"><div className="flex min-w-0 items-center gap-3"><SidebarTrigger aria-label="切换导航" /><Separator orientation="vertical" className="h-4" />{isChat ? <div className="min-w-0"><h1 className="font-semibold">项目沟通</h1><p className="truncate text-xs text-muted-foreground">询问进度、讨论方案,或告诉我你的新想法。</p></div> : <Breadcrumb><BreadcrumbList><BreadcrumbItem><BreadcrumbPage>{navigation.find(n => n.url === location.pathname)?.title || '总览'}</BreadcrumbPage></BreadcrumbItem></BreadcrumbList></Breadcrumb>}</div>
{isChat ? <Button variant="ghost" size="sm" onClick={() => window.dispatchEvent(new Event('workbench-refresh-conversation'))}><RefreshCw />刷新</Button> : <div className="flex items-center gap-2 text-xs text-muted-foreground"><Circle className={`size-2 fill-current ${error ? 'text-destructive' : 'text-primary'}`} /><span>{error ? '连接中断' : '实时同步'}</span><span className="hidden lg:inline">{date(state.updated)}</span></div>}
</header>
<main className={isChat ? 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background' : 'min-w-0 flex-1 space-y-5 bg-muted/25 p-4 md:p-6 lg:p-8'}>{error && <Alert variant="destructive"><AlertDescription>{error} 当前显示上次同步的数据。</AlertDescription></Alert>}<AttentionNotice state={state} pathname={location.pathname} /><Routes><Route path="/overview" element={<Overview />} /><Route path="/tasks" element={<Tasks />} /><Route path="/cluster" element={<Cluster />} /><Route path="/communication" element={<Communication />} /><Route path="/decisions" element={<Decisions />} /><Route path="/logs" element={<Logs />} /><Route path="*" element={<Navigate to="/overview" replace />} /></Routes></main>
<main className={isChat ? 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background' : 'min-w-0 flex-1 space-y-5 bg-muted/25 p-4 md:p-6 lg:p-8'}>{error && <Alert variant="destructive"><AlertDescription>{error} 当前显示上次同步的数据。</AlertDescription></Alert>}<VersionNotice /><AttentionNotice state={state} pathname={location.pathname} /><Routes><Route path="/overview" element={<Overview />} /><Route path="/tasks" element={<Tasks />} /><Route path="/cluster" element={<Cluster />} /><Route path="/communication" element={<Communication />} /><Route path="/decisions" element={<Decisions />} /><Route path="/logs" element={<Logs />} /><Route path="*" element={<Navigate to="/overview" replace />} /></Routes></main>
{!isChat && <footer className="border-t px-6 py-4 text-xs text-muted-foreground">Agent Workbench · 每 3 秒同步 · AI 接入状态以会话显示为准</footer>}
</SidebarInset></SidebarProvider></Workbench.Provider>
}
@@ -3,15 +3,21 @@ import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { request, errorText, messageKey } from '@/lib/api'
import type { Decision, Message } from '@/lib/types'
import { useLocalDraft } from '@/lib/use-local-draft'
import { useWorkbench } from '@/lib/context'
import type { ChatDraft } from '@/pages/decision-ui'
export default function DecisionChat({ decision, token, drafts }: { decision: Decision; token: string; drafts: Map<string, ChatDraft> }) {
export default function DecisionChat({ decision, token }: { decision: Decision; token: string }) {
const id = decision.id, bound = Boolean(decision.session_id && decision.session_thread_id)
const readOnly = decision.status === 'completed' || decision.archive_status === 'archived'
const [messages, setMessages] = useState<Message[]>([]), [error, setError] = useState(''), [loaded, setLoaded] = useState(false), [, redraw] = useState(0)
const refresh = useRef<() => Promise<void>>(async () => {})
const scroll = useRef<HTMLDivElement>(null)
if (!drafts.has(id)) drafts.set(id, { text: '', sending: false, error: '' })
const draft = drafts.get(id)!
const { state } = useWorkbench()
const revision = decision.revision ?? 1
const local = useLocalDraft<ChatDraft & {revision:number}>(state.instance_id && Number.isInteger(decision.revision) && (decision.revision ?? 0) > 0 ? [state.instance_id, 'consultation', id, decision.session_id ?? 'unbound', String(revision)] : null, {text:'',sending:false,error:'',revision}, (v): v is ChatDraft & {revision:number} => Boolean(v && typeof v === 'object' && typeof (v as ChatDraft).text === 'string'), true)
const draft = local.value
const sending = useRef(false)
function persist() {local.save(draft);redraw(n => n+1)}
useEffect(() => {
if (!bound) return
const controller = new AbortController(); let busy = false
@@ -26,18 +32,21 @@ export default function DecisionChat({ decision, token, drafts }: { decision: De
return () => { clearInterval(timer); controller.abort() }
}, [id, token, bound])
async function send() {
if (!bound || readOnly || draft.sending || !draft.text.trim()) return
if (!bound || readOnly || !local.ready || draft.revision !== revision || sending.current || draft.sending || !draft.text.trim()) return
const text = draft.text
if (!draft.attempt || draft.attempt.text !== text) draft.attempt = { text, key: messageKey() }
if (!local.save(draft)) return
sending.current=true
draft.sending = true; draft.error = ''; redraw(n => n + 1)
try { await request(token, `/api/decisions/${encodeURIComponent(id)}/consultation`, { text, client_key: draft.attempt.key }); if (draft.text === text) draft.text = ''; draft.attempt = undefined; await refresh.current() }
try { await request(token, `/api/decisions/${encodeURIComponent(id)}/consultation`, { text, client_key: draft.attempt.key }); local.clear(); await refresh.current() }
catch (cause) { draft.error = `${errorText(cause)} 草稿已保留,可重试同一消息。` }
finally { draft.sending = false; redraw(n => n + 1) }
finally { sending.current=false; draft.sending = false; redraw(n => n + 1) }
}
return <section className="flex min-w-0 flex-col rounded-xl border bg-card" aria-label="本决策咨询聊天"><div className="flex items-center justify-between border-b p-3"><h3 className="font-medium">本决策咨询</h3><Button size="sm" variant="ghost" disabled={!bound} onClick={() => void refresh.current()}>刷新</Button></div>
<p className="px-3 pt-3 text-xs text-muted-foreground">{!bound ? '咨询会话待接通,暂不能发送。' : readOnly ? '本决策已完成,咨询历史只读。' : '消息仅发送到本决策绑定的咨询会话。绑定不代表回复链路已验收。'}</p>
<div ref={scroll} role="log" aria-label="本决策消息" className="max-h-[45vh] min-h-40 space-y-3 overflow-y-auto p-3">{messages.map(message => <article key={message.id} className={`rounded-lg p-3 text-sm ${message.source === 'human' ? 'ml-6 bg-primary text-primary-foreground' : 'mr-6 bg-muted'}`}><p className="mb-1 text-xs opacity-70">{message.source === 'human' ? '你' : '咨询助手'}</p><p className="whitespace-pre-wrap break-words">{message.text}</p>{message.source === 'human' && ['queued','claimed'].includes(message.status) && <p className="mt-1 text-xs">等待回复…</p>}{['failed','uncertain'].includes(message.status) && <p className="mt-1 text-xs">回复尚未确认,请勿重复发送。</p>}</article>)}{!messages.length && <p className="text-sm text-muted-foreground">{!bound ? '暂无已接通的咨询会话。' : loaded ? '暂无咨询消息。' : '正在读取咨询历史…'}</p>}</div>
{(error || draft.error) && <p role="status" className="px-3 pb-3 text-sm text-destructive">{draft.error || error}</p>}
{!readOnly && <form className="space-y-2 border-t p-3" onSubmit={event => { event.preventDefault(); void send() }}><Textarea aria-label="本决策咨询问题" value={draft.text} disabled={!bound || draft.sending} maxLength={32000} onChange={event => { draft.text = event.target.value; redraw(n => n + 1) }} placeholder="询问方案、依据或影响…" /><Button type="submit" disabled={!bound || draft.sending || !draft.text.trim()}>{draft.sending ? '正在发送…' : '发送咨询'}</Button></form>}
<div className="space-y-2 px-3 pb-3"><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>{draft.revision !== revision && <div className="text-sm"><p>这是版本 {draft.revision} 的咨询草稿,请重新确认当前问题。</p>{!readOnly && <Button variant="outline" size="sm" onClick={() => {draft.revision=revision;draft.attempt=undefined;persist()}}>确认用于当前版本</Button>}</div>}{readOnly && draft.text && <p className="whitespace-pre-wrap text-sm">保留的未发送草稿(只读):{draft.text}</p>}</div>
{!readOnly && <form className="space-y-2 border-t p-3" onSubmit={event => { event.preventDefault(); void send() }}><Textarea aria-label="本决策咨询问题" value={draft.text} disabled={!bound || draft.sending} maxLength={32000} onChange={event => { draft.text = event.target.value; persist() }} placeholder="询问方案、依据或影响…" /><Button type="submit" disabled={!bound || !local.ready || draft.revision !== revision || draft.sending || !draft.text.trim()}>{draft.sending ? '正在发送…' : '发送咨询'}</Button></form>}
</section>
}
@@ -0,0 +1,12 @@
import { Alert, AlertDescription } from '@/components/ui/alert'
import { useWorkbench } from '@/lib/context'
export default function VersionNotice() {
const { state } = useWorkbench()
const entry = document.querySelector<HTMLScriptElement>('script[type="module"][src]')?.getAttribute('src')
if (!entry || !state.frontend_entrypoint || entry !== state.frontend_entrypoint) {
if (!entry || !state.frontend_entrypoint) return null
return <Alert role="status"><AlertDescription>页面已有新版。输入处会显示本地保存状态。若提示保存失败,请先复制草稿并记下选择;确认已保存后可重新加载并登录恢复。仅刷新消息或状态不会更新页面;不会自动重载或重发你的输入。</AlertDescription></Alert>
}
return null
}
+28
View File
@@ -0,0 +1,28 @@
export type DraftStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem' | 'key' | 'length'>
const prefix = 'workbench-draft-v1:'
export function draftKey(scope: string[]) { return prefix + JSON.stringify(scope) }
export function readDraft<T>(storage: DraftStorage, scope: string[], empty: T, valid: (value: unknown) => value is T, olderVersions = false): T {
const raw = storage.getItem(draftKey(scope))
if (raw !== null) { const record = JSON.parse(raw); if (record.cleared) return empty; if (!valid(record.value)) throw new Error('草稿格式无法读取'); return record.value }
if (olderVersions) {
const base = scope.slice(0,-1), current = Number(scope.at(-1)); let latest = -1, found: T | undefined
for (let i=0;i<storage.length;i++) { const key=storage.key(i); if (!key?.startsWith(prefix)) continue
const parts=JSON.parse(key.slice(prefix.length)); if (!Array.isArray(parts) || JSON.stringify(parts.slice(0,-1))!==JSON.stringify(base)) continue
const version=Number(parts.at(-1)); if (version>=current || version<=latest) continue
const record=JSON.parse(storage.getItem(key) ?? 'null'); if (record?.cleared) continue
if (valid(record?.value)) { latest=version; found=record.value }
}
if(found) return found
}
return empty
}
export function writeDraft<T>(storage: DraftStorage, scope: string[], value: T) { storage.setItem(draftKey(scope), JSON.stringify({ value }, (key, item) => key === 'sending' || key === 'error' ? undefined : item)) }
export function clearDraft(storage: DraftStorage, scope: string[], allVersions = false) {
if(allVersions) { const base=scope.slice(0,-1); const keys:string[]=[]; for(let i=0;i<storage.length;i++){const key=storage.key(i);if(!key?.startsWith(prefix))continue; const parts=JSON.parse(key.slice(prefix.length));if(JSON.stringify(parts.slice(0,-1))===JSON.stringify(base))keys.push(key)} for(const key of keys)storage.removeItem(key) }
storage.setItem(draftKey(scope), JSON.stringify({cleared:true}))
}
export function clearIfUnchanged(storage: DraftStorage, scope: string[], expected: string | null) {
if(storage.getItem(draftKey(scope))!==expected) return false
clearDraft(storage,scope)
return true
}
+1 -1
View File
@@ -14,7 +14,7 @@ export type NodeMetric = { memory_used_percent?: number; memory_total_bytes?: nu
export type Mount = { mount_target: string; used_percent?: number; total_bytes?: number; available_bytes?: number }
export type SchedulerData = { count?: number; by_state?: Record<string, number>; queues?: { name: string; state: string; pend?: number; run?: number }[] }
export type Metrics = { sampled_at?: number; status?: string; message?: string; data?: { status?: string; message?: string; collected_at?: string; nodes?: Sample<NodeMetric>[]; storage?: Sample<Mount[]>[]; schedulers?: Record<string, Record<string, Sample<SchedulerData>>> } }
export type State = { attention?: Attention; decisions?: Decision[]; hooks?: { pending_notifications: number; active_firing: number; recent: { source: string; summary: string; status: string; severity: string }[] }; config?: { title?: string }; title?: string; primary_session_id?: string; tasks: Task[]; sessions: Session[]; events: Event[]; requests: { id: string; name: string; status: string }[]; metrics?: Metrics; updated?: number }
export type State = { instance_id?: string; frontend_entrypoint?: string | null; attention?: Attention; decisions?: Decision[]; hooks?: { pending_notifications: number; active_firing: number; recent: { source: string; summary: string; status: string; severity: string }[] }; config?: { title?: string }; title?: string; primary_session_id?: string; tasks: Task[]; sessions: Session[]; events: Event[]; requests: { id: string; name: string; status: string }[]; metrics?: Metrics; updated?: number }
export const statusLabels: Record<string, string> = { queued: '排队', running: '进行中', waiting: '等待', blocked: '受阻', completed: '已完成', failed: '失败', cancelled: '已取消', claimed: '处理中', delivered: '已送达', uncertain: '待人工核查' }
export const phaseLabels: Record<string, string> = { planning: '规划', implementation: '实现', validation: '验证', integration: '集成', done: '完成' }
export function date(value?: string | number) { return value == null ? '暂无' : new Date(typeof value === 'number' ? value * 1000 : value).toLocaleString('zh-CN') }
@@ -0,0 +1,11 @@
import { useRef, useState } from 'react'
import { clearDraft, clearIfUnchanged, draftKey, readDraft, writeDraft } from './draft-storage'
export function useLocalDraft<T>(scope: string[] | null, empty: T, valid: (value: unknown) => value is T, olderVersions = false) {
const identity=JSON.stringify(scope), ref=useRef<{identity:string;value:T;error:string;loaded:boolean;persisted:string|null} | null>(null)
const [, redraw]=useState(0)
if(ref.current?.identity!==identity){let value=empty,error='',persisted=null;try{if(scope){value=readDraft(window.localStorage,scope,empty,valid,olderVersions);persisted=window.localStorage.getItem(draftKey(scope))}else error='实例或版本信息尚未就绪,暂不能保存或发送。'}catch{error='本地草稿读取失败,请先复制输入;暂不能确认已保存。'}ref.current={identity,value,error,loaded:!error,persisted}}
const record=ref.current!
function save(value:T){record.value=value;try{if(!scope)throw Error();writeDraft(window.localStorage,scope,value);record.persisted=window.localStorage.getItem(draftKey(scope));record.error='';record.loaded=true;redraw(n=>n+1);return true}catch{record.error='本地保存失败,请复制草稿;刷新或退出可能丢失。';record.loaded=false;redraw(n=>n+1);return false}}
function clear(allVersions=false,userRequested=false){try{if(!scope)throw Error();if(allVersions||userRequested)clearDraft(window.localStorage,scope,allVersions);else if(!clearIfUnchanged(window.localStorage,scope,record.persisted)){record.error='已提交;保留了随后编辑的本地草稿。';redraw(n=>n+1);return false}record.value=empty;record.persisted=window.localStorage.getItem(draftKey(scope));record.error='';record.loaded=true;redraw(n=>n+1);return true}catch{record.error='本地草稿清除失败,旧草稿可能仍保留;请勿重复提交。';record.loaded=false;redraw(n=>n+1);return false}}
return {value:record.value,error:record.error,save,clear,ready:Boolean(scope)&&record.loaded}
}
+13 -11
View File
@@ -7,6 +7,7 @@ import { Textarea } from '@/components/ui/textarea'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { useWorkbench } from '@/lib/context'
import { request, errorText, messageKey } from '@/lib/api'
import { useLocalDraft } from '@/lib/use-local-draft'
import type { Message } from '@/lib/types'
export default function Communication() {
const { state, token, refresh } = useWorkbench()
@@ -14,11 +15,11 @@ export default function Communication() {
const selected = primary
const session = state.sessions.find(s => s.id === selected)
const [messages, setMessages] = useState<{ session: string; items: Message[] }>({ session: '', items: [] })
const [drafts, setDrafts] = useState<Record<string, string>>({})
const local = useLocalDraft(state.instance_id ? [state.instance_id, 'communication', selected] : null, { text: '', attempt: undefined as {text:string;key:string}|undefined }, (v): v is {text:string;attempt?:{text:string;key:string}} => Boolean(v && typeof v === 'object' && typeof (v as {text?:unknown}).text === 'string'))
const [error, setError] = useState('')
const [sending, setSending] = useState(false)
const [refreshVersion, setRefreshVersion] = useState(0)
const pending = useRef<{ session_id: string; text: string; idempotency_key: string } | null>(null)
const sendingRef = useRef(false)
const scroll = useRef<HTMLDivElement>(null)
const loaded = useRef('')
const known = Boolean(session)
@@ -54,21 +55,22 @@ export default function Communication() {
loaded.current = selected
}, [lastId, selected, messages.session])
const decisions = (state as { decisions?: { id: string; status: string }[] }).decisions?.filter(d => ['open','needs_reconsideration'].includes(d.status)) || []
const text = drafts[selected] || ''
const text = local.value.text
async function send(event: FormEvent) {
event.preventDefault()
if (!session || !text.trim() || sending) return
if (!session || !text.trim() || sendingRef.current || !local.ready) return
const sid = selected, body = text
if (!pending.current || pending.current.session_id !== sid || pending.current.text !== body) pending.current = { session_id: sid, text: body, idempotency_key: messageKey() }
const attempt = local.value.attempt?.text === body ? local.value.attempt : {text:body,key:messageKey()}
if (!local.save({text:body,attempt})) return
sendingRef.current = true
setSending(true); setError('')
try {
await request(token, '/api/messages', { ...pending.current, source: 'human' })
pending.current = null
setDrafts(old => old[sid] === body ? { ...old, [sid]: '' } : old)
await request(token, '/api/messages', { session_id:sid, text:body, idempotency_key:attempt.key, source:'human' })
local.clear()
setRefreshVersion(n => n + 1)
await refresh()
} catch (e) { setError(errorText(e)) }
finally { setSending(false) }
finally { sendingRef.current=false; setSending(false) }
}
return <div className="flex min-h-0 w-full flex-1 flex-col">
@@ -87,8 +89,8 @@ export default function Communication() {
</article>) : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{known ? '有什么想了解的?从这里开始交流。' : '会话准备好后,即可在这里交流。'}</div>}
</div>
<form onSubmit={send} className="shrink-0 border-t p-3 sm:px-8">
<Textarea id="message" aria-label="输入消息" value={text} onChange={e => setDrafts(old => ({ ...old, [selected]: e.target.value }))} placeholder="输入你的问题或想法…" maxLength={32000} rows={2} required className="resize-none border-0 shadow-none focus-visible:ring-0" />
<div className="mt-2 flex items-center justify-between gap-3"><p className="text-xs text-muted-foreground">{!known ? '正在准备会话' : session?.connection === 'connected' ? '' : '暂时离线,消息会保存并等待回复。'}</p><Button type="submit" disabled={!known || sending || !text.trim()}><Send />{sending ? '发送中…' : '发送'}</Button></div>
<p role="status" className="mb-2 text-xs text-muted-foreground">{local.error || '草稿保存在此浏览器,可在重新登录后恢复。'}</p><Button type="button" variant="ghost" size="sm" disabled={sending} onClick={() => local.clear(false,true)}>清除本地沟通草稿</Button><Textarea id="message" aria-label="输入消息" value={text} disabled={sending} onChange={e => local.save({...local.value,text:e.target.value})} placeholder="输入你的问题或想法…" maxLength={32000} rows={2} required className="resize-none border-0 shadow-none focus-visible:ring-0" />
<div className="mt-2 flex items-center justify-between gap-3"><p className="text-xs text-muted-foreground">{!known ? '正在准备会话' : session?.connection === 'connected' ? '' : '暂时离线,消息会保存并等待回复。'}</p><Button type="submit" disabled={!known || !local.ready || sending || !text.trim()}><Send />{sending ? '发送中…' : '发送'}</Button></div>
</form>
</section>
</div>
+2 -1
View File
@@ -7,7 +7,8 @@ export type DecisionDraft = { option: string | null; comment: string; revision:
export type ChatDraft = { text: string; sending: boolean; error: string; attempt?: { text: string; key: string } }
export function writable(decision: Decision) { return ['open', 'needs_reconsideration'].includes(decisionStatus(decision)) }
export function submissionBody(decision: Decision, draft: DecisionDraft) {
const revision = decision.revision ?? 1
const revision = decision.revision
if (!Number.isInteger(revision) || (revision ?? 0) < 1) return null
if (!writable(decision) || draft.sending || draft.conflict || draft.revision !== revision) return null
if (draft.option && !decision.options.some(option => option.id === draft.option)) return null
if (!draft.option && !draft.comment.trim()) return null
+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>
}
@@ -7,3 +7,4 @@ test('free text allowed with enumerated options; empty and invalid options rejec
test('old revision or conflict cannot resubmit without explicit confirmation',()=>{const d={...draft(),revision:1,option:'a',comment:'保留意见',conflict:true,attempt:{key:'old',signature:'old'}};assert.equal(submissionBody(decision,d),null);confirmDraft(decision,d);assert.equal(d.option,null);assert.equal(d.comment,'保留意见');assert.equal(d.attempt,undefined);assert.equal(submissionBody(decision,d).expected_revision,2)})
test('pending accepted completed read only; reconsideration permits new submission',()=>{for(const status of ['pending_review','accepted','completed'])assert.equal(submissionBody({...decision,status},{...draft(),option:'a'}),null);assert.ok(submissionBody({...decision,status:'needs_reconsideration'},{...draft(),option:'a'}));assert.equal(submissionBody(decision,{...draft(),option:'a',sending:true}),null)})
test('legacy response maps to pending review, never acceptance',()=>{for(const status of ['decided','pending_verification'])assert.equal(decisionStatus({status,response:{id:'r'}}),'pending_review')})
test('missing revision cannot be guessed for a legacy request',()=>{assert.equal(submissionBody({...decision,revision:undefined},{...draft(),revision:1,option:'a'}),null)})
@@ -0,0 +1,13 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { draftKey,readDraft,writeDraft,clearDraft,clearIfUnchanged } from '../src/lib/draft-storage.ts'
class Memory { data=new Map();get length(){return this.data.size}key(i){return [...this.data.keys()][i]??null}getItem(k){return this.data.get(k)??null}setItem(k,v){this.data.set(k,v)}removeItem(k){this.data.delete(k)} }
const valid=v=>!!v&&typeof v.text==='string';const empty={text:''}
test('remount/reload recovers text and same unknown-delivery attempt key',()=>{const s=new Memory(),scope=['instance','communication','human'];writeDraft(s,scope,{text:'hello',attempt:{key:'stable',text:'hello'}});assert.deepEqual(readDraft(s,scope,empty,valid),{text:'hello',attempt:{key:'stable',text:'hello'}})})
test('instance session decision channel and version isolate records',()=>{const s=new Memory(),a=['i','decision','d1','1'];writeDraft(s,a,{text:'private'});for(const b of [['other','decision','d1','1'],['i','decision','d2','1'],['i','consultation','d1','1'],['i','decision','d1','2']])assert.deepEqual(readDraft(s,b,empty,valid),empty)})
test('new revision recovers old content without changing its revision or attempt',()=>{const s=new Memory();writeDraft(s,['i','decision','d','1'],{text:'old',revision:1,attempt:{key:'same'}});assert.equal(readDraft(s,['i','decision','d','2'],empty,valid,true).revision,1);assert.equal(s.length,1)})
test('successful clearing does not clear consultation or restore old selection',()=>{const s=new Memory(),scope=['i','decision','d','2'];writeDraft(s,['i','decision','d','1'],{text:'old'});writeDraft(s,scope,{text:'sent'});writeDraft(s,['i','consultation','d','2'],{text:'chat'});clearIfUnchanged(s,scope,s.getItem(draftKey(scope)));assert.deepEqual(readDraft(s,scope,empty,valid,true),empty);assert.equal(readDraft(s,['i','consultation','d','2'],empty,valid).text,'chat')})
test('late success cannot remove a newer draft',()=>{const s=new Memory(),scope=['i','c','s'];writeDraft(s,scope,{text:'sent'});const expected=s.getItem(draftKey(scope));writeDraft(s,scope,{text:'new'});assert.equal(clearIfUnchanged(s,scope,expected),false);assert.equal(readDraft(s,scope,empty,valid).text,'new')})
test('explicit clear removes only selected decision versions',()=>{const s=new Memory();for(const id of ['a','b'])for(const version of ['1','2'])writeDraft(s,['i','decision',id,version],{text:id});clearDraft(s,['i','decision','a','2'],true);assert.equal(readDraft(s,['i','decision','b','1'],empty,valid).text,'b');assert.deepEqual(readDraft(s,['i','decision','a','2'],empty,valid,true),empty);assert.equal(s.getItem(draftKey(['i','decision','a','1'])),null)})
test('unavailable storage and corrupt data throw instead of claiming saved',()=>{const s=new Memory();s.setItem=()=>{throw Error('quota')};assert.throws(()=>writeDraft(s,['i'],{text:'x'}));s.getItem=()=>'{broken';assert.throws(()=>readDraft(s,['i'],empty,valid))})
test('transient sending/errors are not revived as a stuck pending UI',()=>{const s=new Memory();writeDraft(s,['i'],{text:'retry',sending:true,error:'offline',attempt:{key:'stable'}});const value=readDraft(s,['i'],empty,valid);assert.equal(value.sending,undefined);assert.equal(value.error,undefined);assert.equal(value.attempt.key,'stable')})