ui: lead task detail with diagnosis

This commit is contained in:
kami
2026-07-29 23:31:25 +04:00
parent 1ca9d64e89
commit f7027cb9a7
8 changed files with 134 additions and 117 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
export type TaskState='queued'|'leased'|'blocked'|'completed'|'failed'
export interface Task { id:string; source:string; external_id:string; project:string; title?:string; description?:string; state:TaskState; version:number; lease?:{harness_id:string;until:string}; handoff_ref?:string; blocker?:string; blocked_at?:string; last_pane_id?:string; last_harness_id?:string; pane_state?:string }
export type BlockReason='lease_failure'|'worker_offline'|'lease_expired'|'approval'|'handoff_validation'|'operator_block'|'system_error'|'unknown'
export interface SessionEvidence { pane_id?:string; harness_id?:string; pane_state?:string; source?:string; captured_at?:string; checked_at?:string }
export interface Task { id:string; source:string; external_id:string; project:string; title?:string; description?:string; state:TaskState; version:number; lease?:{harness_id:string;until:string}; handoff_ref?:string; blocker?:string; block_reason?:BlockReason; blocked_at?:string; last_pane_id?:string; last_harness_id?:string; pane_state?:string; last_session?:SessionEvidence }
export interface PendingApproval { kind:'shell'|'opencode_once'|'edit'|'unknown'; summary:string; command?:string; diff?:string; pane_id:string; capture_revision:number; detected_at:string }
export interface Capture { task_id:string; source:string; text:string; revision:number; at:string; truncated:boolean }
export interface Session { pane_id?:string; harness_id?:string; agent_status?:string; blocker?:string; lease_until?:string; capture?:Capture; pending_approval?:PendingApproval }
+9 -5
View File
@@ -3,12 +3,13 @@ import {createRoot} from 'react-dom/client'
import {BrowserRouter,Link,NavLink,Route,Routes,useLocation,useNavigate,useParams} from 'react-router-dom'
import {QueryClient,QueryClientProvider,useMutation,useQuery,useQueryClient} from '@tanstack/react-query'
import {api} from './api/client'
import type {Action,Capture,Detail,Overview,PendingApproval,Task,TaskState} from './api/types'
import type {Action,BlockReason,Capture,Detail,Overview,PendingApproval,Task,TaskState} from './api/types'
import './style.css'
const client=new QueryClient()
const states:TaskState[]=['queued','leased','blocked','completed','failed']
const states:TaskState[]=['queued','leased','completed','failed']
const label:Record<TaskState,string>={queued:'Queued',leased:'In session',blocked:'Blocked',completed:'Complete',failed:'Failed'}
const blockLabel:Record<BlockReason,string>={lease_failure:'Lease failure',worker_offline:'Worker offline',lease_expired:'Expired lease',approval:'Approval needed',handoff_validation:'Handoff validation',operator_block:'Operator block',system_error:'System error',unknown:'Unknown'}
const actionLabel:Record<string,string>={handoff:'Request handoff',release:'Release task',complete:'Complete task',block:'Mark blocked'}
const date=(value?:string)=>value?new Date(value).toLocaleString():'—'
@@ -16,8 +17,9 @@ function CommandPalette({close}:{close:()=>void}){const nav=useNavigate(),qc=use
function Shell({children,onLogout}:{children:React.ReactNode;onLogout:()=>void}){const where=useLocation(),nav=useNavigate(),[palette,setPalette]=useState(false),[accountOpen,setAccountOpen]=useState(false);const overview=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});const sessions=overview.data?.sessions?.length??0;useEffect(()=>{const key=(e:KeyboardEvent)=>{const editable=e.target instanceof HTMLInputElement||e.target instanceof HTMLTextAreaElement;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='k'){e.preventDefault();setPalette(true)}if(e.key==='n'&&!e.metaKey&&!e.ctrlKey&&!editable){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}if(e.key==='r'&&!e.metaKey&&!e.ctrlKey&&!editable)overview.refetch()};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)},[nav,overview]);return <div className="app-shell" data-app="orchestra"><aside className="rail"><Link className="mark" to="/" aria-label="Orchestra home"><i className="branch-mark"/><span>OR</span></Link><nav className="rail-nav" aria-label="Primary navigation"><NavLink end className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/"><span>Board</span></NavLink><NavLink className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/workers"><span>Workers</span></NavLink></nav><span className="rail-footer">v0.1</span></aside><header className="topbar"><div className="topbar-title">Orchestra <small>{where.pathname==='/'?'dispatch board':where.pathname==='/workers'?'worker pool':'task record'}</small></div><button className="command" onClick={()=>setPalette(true)} aria-label="Open command palette"><span>Command</span><kbd> K</kbd></button><div className="readout"><span>SESSIONS <b>{sessions}</b></span><span>SYNC <b>5s</b></span></div><div className="account"><button className="account-button" onClick={()=>setAccountOpen(v=>!v)} aria-expanded={accountOpen}>Signed in <span className="status-dot"/></button>{accountOpen&&<div className="account-menu"><span>Browser session active</span><button onClick={onLogout}>Sign out</button></div>}</div></header>{children}{palette&&<CommandPalette close={()=>setPalette(false)}/>}</div>}
function taskExplanation(task:Task,overview:Overview){const session=(overview.sessions??[]).find(s=>s.capture?.task_id===task.id);if(task.state==='queued'){const online=(overview.workers??[]).filter(w=>w.online);return online.length?`Waiting for a worker to lease it · ${online.length} worker${online.length===1?'':'s'} online`:'No registered worker is currently reachable'}if(task.state==='leased'){if(session?.pending_approval)return 'Waiting for an operator approval';if(task.lease&&new Date(task.lease.until)<new Date())return 'Lease has expired; waiting for reconciliation';if(session?.blocker)return session.blocker;return session?.capture?'Harness session is active':'Lease is active; live capture is unavailable'}if(task.state==='blocked')return task.blocker||'Unknown — legacy record has no retained blocker or pane evidence';if(task.state==='failed')return 'The task needs review before it can be retried';return 'Terminal task record'}
function Board({tasks,overview}:{tasks:Task[];overview:Overview}){return <div className="board">{states.map(state=>{const lane=tasks.filter(t=>t.state===state);return <section key={state}><div className="lane-head"><span>{label[state]}</span><span className="lane-count">{lane.length}</span></div>{lane.map(t=><Link className="card" to={'/tasks/'+t.id} key={t.id}><b>{t.title||t.id}</b><small><span>{t.project}</span><span className="machine">{t.id.slice(-5)}</span></small><em>{taskExplanation(t,overview)}</em></Link>)}{!lane.length&&<p className="empty-lane">Nothing here. New work will appear in this branch.</p>}</section>})}</div>}
function taskExplanation(task:Task,overview:Overview){const session=(overview.sessions??[]).find(s=>s.capture?.task_id===task.id);if(task.state==='queued'){const online=(overview.workers??[]).filter(w=>w.online);return online.length?`Waiting for a worker to lease it · ${online.length} worker${online.length===1?'':'s'} online`:'No registered worker is currently reachable'}if(task.state==='leased'){if(session?.pending_approval)return 'Waiting for an operator approval';if(task.lease&&new Date(task.lease.until)<new Date())return 'Lease has expired; waiting for reconciliation';if(session?.blocker)return session.blocker;return session?.capture?'Harness session is active':'Lease is active; live capture is unavailable'}if(task.state==='blocked'){const evidence=task.last_session;const observed=evidence?.captured_at||evidence?.checked_at;const suffix=evidence?` · ${evidence.source||'unknown'} observed ${date(observed)}`:'';return (task.blocker||'Unknown — legacy record has no retained blocker or pane evidence')+suffix}if(task.state==='failed')return 'The task needs review before it can be retried';return 'Terminal task record'}
function TaskCard({task,overview}:{task:Task;overview:Overview}){return <Link className="card" to={'/tasks/'+task.id}><b>{task.title||task.id}</b><small><span>{task.project}</span><span className="machine">{task.id.slice(-5)}</span></small><em>{taskExplanation(task,overview)}</em></Link>}
function Board({tasks,overview}:{tasks:Task[];overview:Overview}){const blocked=tasks.filter(t=>t.state==='blocked').sort((a,b)=>new Date(a.blocked_at||0).getTime()-new Date(b.blocked_at||0).getTime());const groups=new Map<BlockReason,Task[]>;for(const task of blocked){const reason=task.block_reason||'unknown';groups.set(reason,[...(groups.get(reason)||[]),task])}return <><div className="board">{states.map(state=>{const lane=tasks.filter(t=>t.state===state);return <section key={state}><div className="lane-head"><span>{label[state]}</span><span className="lane-count">{lane.length}</span></div>{lane.map(t=><TaskCard task={t} overview={overview} key={t.id}/>)}{!lane.length&&<p className="empty-lane">Nothing here. New work will appear in this branch.</p>}</section>})}</div>{blocked.length>0&&<section className="blocked-groups" aria-label="Blocked work by diagnosis"><div className="section-title"><div><h2>Needs attention</h2><p>Blocked work is grouped by diagnosis and ordered oldest first.</p></div><span className="machine">{blocked.length} blocked</span></div><div className="blocked-grid">{[...groups.entries()].map(([reason,group])=><section key={reason}><div className="lane-head"><span>{blockLabel[reason]}</span><span className="lane-count">{group.length}</span></div>{group.map(task=><TaskCard task={task} overview={overview} key={task.id}/>)}</section>)}</div></section>}</>}
function OverviewPage(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000}),[createOpen,setCreateOpen]=useState(false);useEffect(()=>{const open=()=>setCreateOpen(true);window.addEventListener('orchestra:new-task',open);return()=>window.removeEventListener('orchestra:new-task',open)},[]);if(q.isLoading)return <main className="page loading"><p className="eyebrow">Dispatch board</p><p>Fetching queue state from the coordinator</p></main>;if(q.error)return <main className="page error" role="alert">Queue unavailable: {String(q.error)}</main>;const d=q.data!,sessions=d.sessions??[],active=d.tasks.filter(t=>t.state==='leased').length,attention=d.tasks.filter(t=>t.state==='blocked'||t.state==='failed').length,approvals=new Set(sessions.filter(s=>s.pending_approval).map(s=>s.capture?.task_id));return <main className="page"><div className="page-heading"><div><p className="eyebrow">Agent dispatch</p><h1>Keep the work moving.</h1></div><div className="heading-actions"><p>Live task state across every connected harness. The board refreshes every five seconds.</p><button onClick={()=>setCreateOpen(true)}>New task <kbd>N</kbd></button></div></div><div className="status-strip" aria-label="Queue summary"><div><span>Tasks</span><b>{d.tasks.length}</b></div><div><span>In session</span><b>{active}</b></div><div className={attention?'attention':''}><span>Needs attention</span><b>{attention}</b></div><div className={approvals.size?'attention':''}><span>Awaiting approval</span><b>{approvals.size}</b></div></div><div className="section-title"><div><h2>Task flow</h2><p>Each card states the current reason it is waiting or running.</p></div><span className="machine" aria-live="polite">SYNCED {new Date(d.updated_at).toLocaleTimeString()}</span></div><Board tasks={d.tasks} overview={d}/>{createOpen&&<Create overview={d} close={()=>setCreateOpen(false)}/>}</main>}
@@ -30,7 +32,9 @@ function Lifecycle({action,mutate,pending}:{action:Action;mutate:(action:string,
function CaptureView({capture,blocker}:{capture?:Capture;blocker?:string}){const [expanded,setExpanded]=useState(false),[filter,setFilter]=useState('');if(!capture)return <div className="capture-empty"><b>Live capture unavailable</b><span>{blocker||'The session has not published a recent pane capture.'}</span></div>;const lines=capture.text.split('\n'),visible=filter?lines.filter(l=>l.toLowerCase().includes(filter.toLowerCase())):lines;const preview=expanded?visible:visible.slice(-18);return <section className="capture"><div className="capture-head"><div><h2>Live capture</h2><span>{capture.source} · updated {date(capture.at)} · {lines.length} lines{capture.truncated?' · truncated':''}</span></div><button className="quiet-button" onClick={()=>setExpanded(v=>!v)}>{expanded?'Show recent':'Expand'}</button></div><input className="capture-filter" value={filter} onChange={e=>setFilter(e.target.value)} placeholder="Filter visible capture" aria-label="Filter live capture"/><pre>{preview.join('\n')||'No lines match this filter.'}</pre>{!expanded&&visible.length>preview.length&&<p className="capture-more">Showing the most recent {preview.length} matching lines.</p>}</section>}
function TaskDetail(){const {taskID=''}=useParams(),qc=useQueryClient(),[notice,setNotice]=useState('');const q=useQuery({queryKey:['task',taskID],queryFn:()=>api.detail(taskID),refetchInterval:3000});const m=useMutation({mutationFn:({action,body}:{action:string;body?:object})=>api.action(taskID,action,body),onSuccess:(result:any,vars)=>{setNotice(vars.action.includes('approval')?'Approval request has been queued; waiting for the worker acknowledgement.':'Action recorded.');qc.invalidateQueries({queryKey:['task',taskID]});qc.invalidateQueries({queryKey:['overview']})}});if(q.isLoading)return <main className="page loading">Fetching task record</main>;if(q.error)return <main className="page error" role="alert">Task unavailable: {String(q.error)}</main>;const d=q.data!,events=d.events??[];return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Task record</p><h1>{d.task.title||d.task.id}</h1></div><p className="machine">{d.task.id}</p></div><div className={'task-banner state-'+d.task.state}><div><span className="task-state">{label[d.task.state]}</span><p>{taskDetailExplanation(d)}</p></div>{d.session?.lease_until&&<span>Lease ends <b>{date(d.session.lease_until)}</b></span>}</div>{d.session?.pending_approval&&<Approval approval={d.session.pending_approval} pending={m.isPending} notice={notice} onAction={a=>m.mutate({action:a})}/>}<div className="detail-grid"><div className="surface"><h2>Instructions</h2><p className="task-description">{d.task.description||'No immutable instructions were recorded.'}</p><CaptureView capture={d.session?.capture} blocker={d.session?.blocker}/><h2 className="actions-title">Recovery & lifecycle</h2><p className="section-copy">Actions are only enabled when their server-side requirements are met. Consequences are shown before the final submission.</p><div className="actions">{d.actions.map(a=><Lifecycle key={a.id} action={a} pending={m.isPending} mutate={(action,body)=>m.mutate({action,body})}/>)}</div>{m.error&&<p className="error" role="alert">{String(m.error)}</p>}{notice&&!d.session?.pending_approval&&<p className="notice" role="status">{notice}</p>}</div><aside className="surface"><h2>Session</h2><ul className="meta"><li><span>Project</span><span className="machine">{d.task.project}</span></li><li><span>State</span><span className="machine">{d.task.state}</span></li><li><span>Harness</span><span className="machine">{d.session?.harness_id||''}</span></li><li><span>Pane</span><span className="machine">{d.session?.pane_id||'—'}</span></li><li><span>Lease ends</span><span className="machine">{date(d.session?.lease_until)}</span></li></ul>{d.handoff_ref&&<p><Link className="back" to={'/artifacts/'+d.handoff_ref}>View handoff </Link></p>}{d.report_ref&&<p><Link className="back" to={'/artifacts/'+d.report_ref}>View report </Link></p>}</aside></div><div className="surface timeline-surface"><div className="timeline-heading"><div><h2>Timeline</h2><p>Recorded operator and lifecycle decisions.</p></div><span>{events.length} events</span></div><ol className="timeline">{events.map(e=><li key={e.id}><span>{e.type}</span><time>{date(e.at)}</time></li>)}</ol></div></main>}
function TaskDiagnosis({detail}:{detail:Detail}){const evidence=detail.task.last_session,approval=detail.session?.pending_approval,action=detail.actions.find(a=>a.enabled),observed=evidence?.captured_at||evidence?.checked_at;const next=approval?{title:'Review the permission request',copy:'This harness is paused at a permission boundary. Approve only the exact request shown.'}:action?{title:actionLabel[action.id]||action.id,copy:actionHelp[action.id]||'This action is currently safe to submit.'}:{title:'No safe lifecycle action is available',copy:'Review the retained diagnosis and pane evidence. Unavailable actions explain their requirements below.'};return <section className={'diagnosis state-'+detail.task.state} aria-label="Task diagnosis"><div className="diagnosis-heading"><div><p className="eyebrow">Current diagnosis</p><h2>{detail.task.state==='blocked'?blockLabel[detail.task.block_reason||'unknown']:label[detail.task.state]}</h2><p>{taskDetailExplanation(detail)}</p></div><span className="task-state">{label[detail.task.state]}</span></div><dl className="diagnosis-evidence"><div><dt>Last activity</dt><dd>{date(observed||detail.session?.capture?.at)}</dd></div><div><dt>Pane state</dt><dd>{evidence?.pane_state||detail.session?.agent_status||'unknown'}</dd></div><div><dt>Pane</dt><dd className="machine">{evidence?.pane_id||detail.session?.pane_id||'—'}</dd></div><div><dt>Source</dt><dd>{evidence?.source||detail.session?.capture?.source||'unknown'}</dd></div></dl><div className="next-action"><div><span>Next safe action</span><b>{next.title}</b><p>{next.copy}</p></div>{detail.session?.lease_until&&<small>Lease ends {date(detail.session.lease_until)}</small>}</div></section>}
function TaskDetail(){const {taskID=''}=useParams(),qc=useQueryClient(),[notice,setNotice]=useState('');const q=useQuery({queryKey:['task',taskID],queryFn:()=>api.detail(taskID),refetchInterval:3000});const m=useMutation({mutationFn:({action,body}:{action:string;body?:object})=>api.action(taskID,action,body),onSuccess:(result:any,vars)=>{setNotice(vars.action.includes('approval')?'Approval request has been queued; waiting for the worker acknowledgement.':'Action recorded.');qc.invalidateQueries({queryKey:['task',taskID]});qc.invalidateQueries({queryKey:['overview']})}});if(q.isLoading)return <main className="page loading">Fetching task record</main>;if(q.error)return <main className="page error" role="alert">Task unavailable: {String(q.error)}</main>;const d=q.data!,events=d.events??[],available=d.actions.filter(a=>a.enabled),unavailable=d.actions.filter(a=>!a.enabled),evidence=d.task.last_session;return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Task record</p><h1>{d.task.title||d.task.id}</h1></div><p className="machine">{d.task.id}</p></div><TaskDiagnosis detail={d}/>{d.session?.pending_approval&&<Approval approval={d.session.pending_approval} pending={m.isPending} notice={notice} onAction={a=>m.mutate({action:a})}/>}<div className="detail-grid"><div className="surface"><h2>Instructions</h2><p className="task-description">{d.task.description||'No immutable instructions were recorded.'}</p><CaptureView capture={d.session?.capture} blocker={d.session?.blocker||d.task.blocker}/>{available.length>0&&<><h2 className="actions-title">Available actions</h2><p className="section-copy">Only actions whose server-side safety requirements are met are shown here.</p><div className="actions">{available.map(a=><Lifecycle key={a.id} action={a} pending={m.isPending} mutate={(action,body)=>m.mutate({action,body})}/>)}</div></>}{unavailable.length>0&&<details className="unavailable-actions"><summary>Unavailable actions ({unavailable.length})</summary><p>These controls are disabled until their stated server-side requirements are met.</p><div className="actions">{unavailable.map(a=><Lifecycle key={a.id} action={a} pending={m.isPending} mutate={(action,body)=>m.mutate({action,body})}/>)}</div></details>}{m.error&&<p className="error" role="alert">{String(m.error)}</p>}{notice&&!d.session?.pending_approval&&<p className="notice" role="status">{notice}</p>}</div><aside className="surface"><h2>Session evidence</h2><ul className="meta"><li><span>Project</span><span className="machine">{d.task.project}</span></li><li><span>Harness</span><span className="machine">{evidence?.harness_id||d.session?.harness_id||'—'}</span></li><li><span>Pane</span><span className="machine">{evidence?.pane_id||d.session?.pane_id||'—'}</span></li><li><span>Observed</span><span className="machine">{date(evidence?.captured_at||evidence?.checked_at)}</span></li><li><span>Lease ends</span><span className="machine">{date(d.session?.lease_until)}</span></li></ul>{d.handoff_ref&&<p><Link className="back" to={'/artifacts/'+d.handoff_ref}>View handoff </Link></p>}{d.report_ref&&<p><Link className="back" to={'/artifacts/'+d.report_ref}>View report </Link></p>}</aside></div><div className="surface timeline-surface"><div className="timeline-heading"><div><h2>Timeline</h2><p>Recorded operator and lifecycle decisions.</p></div><span>{events.length} events</span></div><ol className="timeline">{events.map(e=><li key={e.id}><span>{e.type}</span><time>{date(e.at)}</time></li>)}</ol></div></main>}
function taskDetailExplanation(d:Detail){if(d.session?.pending_approval)return 'This harness is paused at a permission boundary. Review the exact request before allowing it.';if(d.task.state==='leased')return d.session?.capture?'The harness is active and publishing a recent capture.':'The task is leased, but its live capture is unavailable.';if(d.task.state==='blocked')return d.task.blocker||'Unknown — legacy record has no retained blocker or pane evidence.';return label[d.task.state]+' task record.'}
function Workers(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});if(q.isLoading)return <main className="page loading">Fetching worker heartbeats</main>;if(q.error)return <main className="page error" role="alert">Worker pool unavailable: {String(q.error)}</main>;const d=q.data!,workers=d.workers;const herdr=(status?:string)=>status==='reachable'?'local herdr reachable':status==='unreachable'?'local herdr unreachable':'local herdr not yet checked';return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Worker pool</p><h1>Available capacity.</h1></div><p>{workers.filter(w=>w.online).length} of {workers.length} registered workers are reachable right now.</p></div>{workers.length?<><p className="worker-note">Heartbeat proves the worker can reach Orchestra. Local herdr status is separately reported by that worker; it is never inferred from legacy coordinator TCP probes.</p><table><thead><tr><th>Worker</th><th>Heartbeat</th><th>Local herdr</th><th>Active work</th><th>Last error</th><th>Last heartbeat</th></tr></thead><tbody>{workers.map(w=><tr key={w.id}><td>{w.id}</td><td className={w.online?'online':'offline'}><span className="status-dot"/> {w.online?'reachable':'overdue'}</td><td className={w.health?.herdr_status==='reachable'?'online':w.health?.herdr_status==='unreachable'?'offline':''}>{herdr(w.health?.herdr_status)}<small>{date(w.health?.checked_at)}</small></td><td>{w.health?.active_task_id?<><span>{w.health.active_task_id}</span><small>{w.health.active_pane_id||'pane unknown'}</small></>:'idle'}</td><td>{w.health?.last_error?<><span>{w.health.last_error}</span><small>{date(w.health.error_at)}</small></>:'—'}</td><td>{date(w.last_seen)}</td></tr>)}</tbody></table></>:<section className="empty-panel"><i className="branch-mark"/><h2>No workers registered</h2><p>Connect a worker to begin leasing queued tasks.</p></section>}</main>}
+4 -1
View File
@@ -9,11 +9,14 @@
.heading-actions{display:flex;align-items:end;gap:16px}.heading-actions button{white-space:nowrap}.modal-backdrop{position:fixed;inset:0;z-index:15;display:grid;place-items:center;padding:20px;background:rgba(8,7,5,.72)}.create.modal{width:min(680px,100%);max-height:calc(100vh - 40px);overflow:auto}.modal-heading{display:flex;align-items:start;justify-content:space-between;gap:12px}.modal-heading h2{margin:0}.icon-button{display:grid;width:30px;height:30px;place-items:center;padding:0;border-color:var(--line-hi);color:var(--text-mid);background:transparent;font-size:21px;font-weight:400}.create form{display:grid;gap:9px}.create form label{display:grid;gap:6px;color:var(--text-mid);font-size:12px}.create form input,.create form textarea{margin:0}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}.quiet-button{border-color:var(--line-hi);color:var(--text-mid);background:transparent}
.login-page{display:grid;min-height:100vh;place-items:center;padding:20px;color:var(--text-hi)}.login-card{width:min(420px,100%);padding:28px;background:var(--bg-1);border:1px solid var(--line-hi);border-radius:var(--r-lg);box-shadow:var(--shadow-soft)}.login-card h1{margin:0 0 8px;font-size:31px;letter-spacing:-.04em}.login-card>p:not(.eyebrow){margin:0 0 22px;color:var(--text-mid);line-height:1.5}.login-card form{display:grid;gap:12px}.login-card label{display:grid;gap:6px;color:var(--text-mid);font-size:12px}
@media(max-width:760px){.heading-actions{width:100%;align-items:stretch;flex-direction:column}.heading-actions button{align-self:flex-start}.modal-backdrop{padding:12px}.create.modal{max-height:calc(100vh - 24px)}}
.blocked-groups{margin-top:32px}.blocked-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px}.blocked-grid>section{min-height:120px;padding:12px;background:var(--bg-1);border:1px solid rgba(213,160,146,.34);border-radius:var(--r-md)}
/* Operator comfort pass: make live state scannable before the raw data. */
.account{position:relative}.account-button{display:flex;align-items:center;gap:7px;padding:7px 9px;border-color:var(--line);color:var(--text-mid);background:transparent;font-size:11px;font-weight:500}.account-menu{position:absolute;top:calc(100% + 8px);right:0;z-index:10;display:grid;min-width:190px;gap:8px;padding:10px;border:1px solid var(--line-hi);border-radius:var(--r-sm);background:var(--bg-2);box-shadow:var(--shadow-soft);color:var(--text-lo);font-size:11px}.account-menu button{padding:7px 9px;text-align:left}.status-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent-hi);box-shadow:0 0 0 3px var(--accent-dim)}
.section-title>div{display:grid;gap:3px}.section-title p,.section-copy,.worker-note{margin:0;color:var(--text-lo);font-size:12px;line-height:1.5}.card em{display:block;overflow:hidden;margin-top:9px;color:var(--text-lo);font-size:11px;font-style:normal;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}.card:hover em{color:var(--text-mid)}.eligibility{display:grid;gap:3px;padding:10px 12px;border:1px solid var(--accent-line);border-radius:var(--r-sm);background:var(--accent-dim);color:var(--text-mid);font-size:12px}.eligibility b{color:var(--accent-hi);font-size:12px}.eligibility.warning{border-color:rgba(213,160,146,.45);background:rgba(213,160,146,.09)}.eligibility.warning b{color:#d5a092}
.task-banner{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:-8px 0 20px;padding:13px 16px;border:1px solid var(--accent-line);border-radius:var(--r-md);background:var(--accent-dim)}.task-banner>div{display:flex;align-items:center;gap:12px}.task-banner .task-state{margin:0;color:var(--accent-hi);font-weight:600}.task-banner p{margin:0;color:var(--text-mid);font-size:13px;line-height:1.45}.task-banner>span{color:var(--text-lo);font:11px var(--mono);white-space:nowrap}.task-banner>span b{color:var(--text-machine);font-weight:500}.task-banner.state-blocked,.task-banner.state-failed{border-color:rgba(213,160,146,.42);background:rgba(213,160,146,.08)}
.diagnosis{display:grid;gap:17px;margin:-8px 0 20px;padding:20px;border:1px solid var(--accent-line);border-radius:var(--r-md);background:var(--accent-dim)}.diagnosis.state-blocked,.diagnosis.state-failed{border-color:rgba(213,160,146,.42);background:rgba(213,160,146,.08)}.diagnosis-heading{display:flex;align-items:start;justify-content:space-between;gap:20px}.diagnosis .eyebrow{margin-bottom:6px}.diagnosis h2{margin:0 0 7px;font-size:22px;letter-spacing:-.03em}.diagnosis-heading p:not(.eyebrow){max-width:780px;margin:0;color:var(--text-mid);font-size:13px;line-height:1.5}.diagnosis .task-state{flex:none;margin:0;color:var(--accent-hi);font-weight:600}.diagnosis-evidence{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:0}.diagnosis-evidence div{min-width:0;padding:10px 11px;border:1px solid var(--line);border-radius:var(--r-sm);background:rgba(20,17,13,.28)}.diagnosis-evidence dt{margin-bottom:5px;color:var(--text-lo);font-size:10px;letter-spacing:.07em;text-transform:uppercase}.diagnosis-evidence dd{overflow:hidden;margin:0;color:var(--text-machine);font-size:12px;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}.next-action{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:13px 14px;border-left:3px solid var(--accent);background:rgba(20,17,13,.35)}.next-action>div{display:grid;gap:3px}.next-action span{color:var(--accent-hi);font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase}.next-action b{font-size:14px}.next-action p{margin:0;color:var(--text-mid);font-size:12px;line-height:1.45}.next-action small{flex:none;color:var(--text-lo);font:11px var(--mono)}
.capture{margin:24px 0}.capture-head{display:flex;align-items:start;justify-content:space-between;gap:12px;margin-bottom:9px}.capture-head h2{margin:0 0 4px}.capture-head span{color:var(--text-lo);font:11px var(--mono)}.capture-filter{margin-bottom:8px;font-size:12px}.capture-more{margin:8px 0 0;color:var(--text-lo);font-size:11px}.capture-empty{display:grid;gap:5px;margin:24px 0;padding:15px;border:1px dashed var(--line-hi);border-radius:var(--r-sm);color:var(--text-mid);font-size:12px}.capture-empty b{color:var(--text-hi)}.actions-title{margin:27px 0 5px!important}.actions{margin-top:13px}.action-row,.action-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:end;padding:13px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--bg-2)}.action-row>div,.action-form>div{display:grid;gap:4px}.action-row b,.action-form b{font-size:13px}.action-row span,.action-form span{color:var(--text-lo);font-size:11px;line-height:1.45}.action-form textarea,.action-form input{grid-column:1}.action-form button{grid-column:2;grid-row:1 / span 2}.action-form.compact input{margin:0}.notice,.session-message{padding:10px 12px;border:1px solid var(--accent-line);border-radius:var(--r-sm);color:var(--accent-hi);background:var(--accent-dim);font-size:12px;line-height:1.45}.timeline-surface{margin-top:20px}.timeline-heading{display:flex;align-items:start;justify-content:space-between;gap:16px}.timeline-heading p{margin:-8px 0 12px;color:var(--text-lo);font-size:12px}.timeline-heading>span{color:var(--text-lo);font:11px var(--mono)}.worker-note{margin:-10px 0 15px;padding:10px 12px;border-left:2px solid var(--accent-line);background:var(--bg-1)}.online .status-dot{margin-right:4px}.offline .status-dot{background:#c09280;box-shadow:0 0 0 3px rgba(192,146,128,.13)}
.unavailable-actions{margin-top:22px;padding-top:17px;border-top:1px solid var(--line)}.unavailable-actions summary{color:var(--text-mid);font-size:13px;font-weight:600}.unavailable-actions>p{margin:9px 0 0;color:var(--text-lo);font-size:12px;line-height:1.45}.unavailable-actions .actions{opacity:.72}.unavailable-actions button:disabled{display:none}
.approval-summary{display:flex;flex-wrap:wrap;gap:7px;margin:12px 0}.approval-summary span{padding:5px 7px;border:1px solid var(--line);border-radius:5px;color:var(--text-lo);font:10px var(--mono)}.approval-summary b{color:var(--text-machine);font-weight:500}.login-card .session-message{margin:-6px 0 4px}
@media(max-width:760px){.account{margin-left:auto}.account-button{font-size:0}.account-button .status-dot{width:8px;height:8px}.task-banner,.task-banner>div{align-items:start;flex-direction:column;gap:7px}.task-banner>span{white-space:normal}.action-row,.action-form{grid-template-columns:1fr}.action-row button,.action-form button{grid-column:auto;grid-row:auto;justify-self:start}.action-form textarea,.action-form input{grid-column:auto}.capture-head{align-items:stretch;flex-direction:column}.section-title{align-items:start;gap:12px;flex-direction:column}.section-title .machine{white-space:nowrap}.readout{display:none}}
@media(max-width:760px){.account{margin-left:auto}.account-button{font-size:0}.account-button .status-dot{width:8px;height:8px}.task-banner,.task-banner>div,.diagnosis-heading,.next-action{align-items:start;flex-direction:column;gap:7px}.task-banner>span{white-space:normal}.diagnosis-evidence{grid-template-columns:repeat(2,minmax(0,1fr))}.action-row,.action-form{grid-template-columns:1fr}.action-row button,.action-form button{grid-column:auto;grid-row:auto;justify-self:start}.action-form textarea,.action-form input{grid-column:auto}.capture-head{align-items:stretch;flex-direction:column}.section-title{align-items:start;gap:12px;flex-direction:column}.section-title .machine{white-space:nowrap}.readout{display:none}}