feat: stream real-time progress for agent SDK install/upgrade/uninstall

Replace the spinner-only UX with live log output during agent SDK
operations, matching the existing OpenCode plugin install experience.

Backend: emit structured events (started/log/completed/failed) via
EventEmitter during npm install and binary download. npm commands now
run with piped stdio for line-by-line streaming; binary downloads
report chunked progress every 1 MB.

Frontend: subscribe to `app://agent-install` events through a new
`useAgentInstallStream` hook and render a theme-aware log terminal
below the preflight checks panel.

Also fixes the install log container in both agent settings and the
OpenCode plugins modal: auto-scroll no longer shifts the outer page,
and colours now follow the active light/dark theme.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-12 21:43:54 +08:00
parent 6c69f432b9
commit a763adaf36
10 changed files with 541 additions and 118 deletions

View File

@@ -0,0 +1,74 @@
import { useCallback, useRef, useState } from "react"
import { subscribe } from "@/lib/platform"
import type { AgentInstallEvent, AgentInstallEventKind } from "@/lib/types"
const AGENT_INSTALL_EVENT = "app://agent-install"
export type AgentInstallStatus = "idle" | "running" | "success" | "failed"
interface AgentInstallStreamState {
status: AgentInstallStatus
logs: string[]
error: string | null
}
export function useAgentInstallStream() {
const [state, setState] = useState<AgentInstallStreamState>({
status: "idle",
logs: [],
error: null,
})
const unsubRef = useRef<(() => void) | null>(null)
const start = useCallback(async (taskId: string) => {
setState({ status: "running", logs: [], error: null })
unsubRef.current?.()
const unsub = await subscribe<AgentInstallEvent>(
AGENT_INSTALL_EVENT,
(event) => {
if (event.task_id !== taskId) return
switch (event.kind as AgentInstallEventKind) {
case "started":
setState((prev) => ({ ...prev, status: "running" }))
break
case "log":
setState((prev) => ({
...prev,
logs: [...prev.logs, event.payload],
}))
break
case "completed":
setState((prev) => ({
...prev,
status: "success",
logs: [...prev.logs, event.payload],
}))
unsubRef.current?.()
break
case "failed":
setState((prev) => ({
...prev,
status: "failed",
error: event.payload,
logs: [...prev.logs, `ERROR: ${event.payload}`],
}))
unsubRef.current?.()
break
}
}
)
unsubRef.current = unsub
}, [])
const reset = useCallback(() => {
unsubRef.current?.()
unsubRef.current = null
setState({ status: "idle", logs: [], error: null })
}, [])
return { ...state, start, reset }
}