Feedback sweep: spinners on every async button, toasts for action outcomes, top activity bar, loading states on every page, terminal connecting overlay, dashboard save indicator, task pane pinned above the package list with auto-scroll and completion toast
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d08b246b5f
commit
724f30c02b
+12
-8
@@ -1,21 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, TaskInfo, wsURLPath } from './api'
|
||||
import { Btn, Spinner } from './ui'
|
||||
|
||||
// Follows one background task: backlog, then live chunks over a WebSocket, until it exits.
|
||||
export default function TaskOutput({ id, onDone }: { id: string; onDone?: (t: TaskInfo) => void }) {
|
||||
const [task, setTask] = useState<TaskInfo | null>(null)
|
||||
const [text, setText] = useState('')
|
||||
const [connected, setConnected] = useState(false)
|
||||
const box = useRef<HTMLPreElement>(null)
|
||||
const doneRef = useRef(onDone)
|
||||
doneRef.current = onDone
|
||||
|
||||
useEffect(() => {
|
||||
setText(''); setTask(null)
|
||||
let closed = false
|
||||
setText(''); setTask(null); setConnected(false)
|
||||
api.task(id).then(setTask).catch(() => { /* shown as unknown */ })
|
||||
const s = new WebSocket(wsURLPath(`/api/tasks/${id}/stream`))
|
||||
s.binaryType = 'arraybuffer'
|
||||
const dec = new TextDecoder()
|
||||
s.onopen = () => setConnected(true)
|
||||
s.onmessage = (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) { setText((t) => t + dec.decode(ev.data, { stream: true })); return }
|
||||
try {
|
||||
@@ -23,22 +25,24 @@ export default function TaskOutput({ id, onDone }: { id: string; onDone?: (t: Ta
|
||||
if (m.type === 'exit') api.task(id).then((t) => { setTask(t); doneRef.current?.(t) }).catch(() => { /* ignore */ })
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return () => { closed = true; s.close(); void closed }
|
||||
return () => s.close()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { if (box.current) box.current.scrollTop = box.current.scrollHeight }, [text])
|
||||
|
||||
const status = !task ? '' : task.running ? 'running…' : task.error ? `failed: ${task.error}` : task.exit_code === 0 ? 'done' : `exited ${task.exit_code}`
|
||||
const running = !task || task.running
|
||||
const failed = !!task && !task.running && (task.exit_code !== 0 || !!task.error)
|
||||
const status = !task ? 'starting…' : task.running ? 'running…' : task.error ? `failed: ${task.error}` : task.exit_code === 0 ? 'finished' : `exited ${task.exit_code}`
|
||||
return (
|
||||
<div className="task">
|
||||
<div className={'task' + (running ? ' running' : failed ? ' failed' : ' done')}>
|
||||
<div className="logs-bar">
|
||||
<span className={'dot ' + (task?.running ? 'warn' : task && (task.exit_code !== 0 || task.error) ? 'bad' : 'ok')} />
|
||||
{running ? <Spinner /> : <span className={'dot ' + (failed ? 'bad' : 'ok')} />}
|
||||
<span>{task?.title ?? '…'}</span>
|
||||
<span className="muted">· {status}</span>
|
||||
<span className="spacer" />
|
||||
{task?.running && <button className="btn" onClick={() => api.cancelTask(id)}>Cancel</button>}
|
||||
{task?.running && <Btn className="btn" onClick={() => api.cancelTask(id)}>Cancel</Btn>}
|
||||
</div>
|
||||
<pre className="run-out task-out" ref={box}>{text || (task?.running ? '' : '(no output)')}</pre>
|
||||
<pre className="run-out task-out" ref={box}>{text || (running ? (connected ? 'waiting for output…' : 'connecting…') : '(no output)')}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user