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

@@ -85,6 +85,7 @@ import type {
ModelProviderInfo,
PreflightResult,
} from "@/lib/types"
import { useAgentInstallStream } from "@/hooks/use-agent-install-stream"
import { OpencodePluginsModal } from "./opencode-plugins-modal"
interface AgentCheckState {
@@ -2624,6 +2625,9 @@ export function AcpAgentSettings() {
const busyActionRef = useRef<Set<AgentType>>(new Set())
const handledSearchAgentRef = useRef<string | null>(null)
const agentListRef = useRef<HTMLDivElement | null>(null)
const installStream = useAgentInstallStream()
const [streamAgentType, setStreamAgentType] = useState<AgentType | null>(null)
const installLogEndRef = useRef<HTMLDivElement | null>(null)
const sortedAgents = useMemo(
() =>
@@ -2748,6 +2752,30 @@ export function AcpAgentSettings() {
[runPreflight]
)
useEffect(() => {
return () => installStream.reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const container = installLogEndRef.current?.parentElement
if (container) {
container.scrollTop = container.scrollHeight
}
}, [installStream.logs])
useEffect(() => {
if (
installStream.status === "success" ||
installStream.status === "failed"
) {
if (streamAgentType) {
runPreflight(streamAgentType).catch(() => {})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [installStream.status])
useEffect(() => {
refreshAgents().catch((err) => {
console.error("[Settings] refresh agents failed:", err)
@@ -2940,11 +2968,14 @@ export function AcpAgentSettings() {
[agent.agent_type]:
kind ?? (mode === "download" ? "download_binary" : "upgrade_binary"),
}))
const taskId = crypto.randomUUID()
setStreamAgentType(agent.agent_type)
await installStream.start(taskId)
try {
if (mode === "upgrade") {
await acpClearBinaryCache(agent.agent_type)
}
await acpDownloadAgentBinary(agent.agent_type)
await acpDownloadAgentBinary(agent.agent_type, taskId)
await runPreflight(agent.agent_type)
const detectedVersion = await acpDetectAgentLocalVersion(
agent.agent_type
@@ -2990,7 +3021,8 @@ export function AcpAgentSettings() {
}))
}
},
[runPreflight, t]
// eslint-disable-next-line react-hooks/exhaustive-deps
[runPreflight, t, installStream.start]
)
const runNpxAction = useCallback(
@@ -3002,10 +3034,14 @@ export function AcpAgentSettings() {
...prev,
[agent.agent_type]: mode === "install" ? "install_npx" : "upgrade_npx",
}))
const taskId = crypto.randomUUID()
setStreamAgentType(agent.agent_type)
await installStream.start(taskId)
try {
const installedVersion = await acpPrepareNpxAgent(
agent.agent_type,
agent.registry_version
agent.registry_version,
taskId
)
setAgents((prev) =>
prev.map((item) =>
@@ -3062,7 +3098,8 @@ export function AcpAgentSettings() {
}))
}
},
[runPreflight, t]
// eslint-disable-next-line react-hooks/exhaustive-deps
[runPreflight, t, installStream.start]
)
const runUninstallAction = useCallback(
@@ -3077,8 +3114,11 @@ export function AcpAgentSettings() {
? "uninstall_binary"
: "uninstall_npx",
}))
const taskId = crypto.randomUUID()
setStreamAgentType(agent.agent_type)
await installStream.start(taskId)
try {
await acpUninstallAgent(agent.agent_type)
await acpUninstallAgent(agent.agent_type, taskId)
setAgents((prev) =>
prev.map((item) =>
item.agent_type === agent.agent_type
@@ -3105,7 +3145,8 @@ export function AcpAgentSettings() {
}))
}
},
[runPreflight, t]
// eslint-disable-next-line react-hooks/exhaustive-deps
[runPreflight, t, installStream.start]
)
const handleFixAction = async (agent: AcpAgentInfo, action: UiFixAction) => {
@@ -5003,6 +5044,24 @@ export function AcpAgentSettings() {
{t("preflight.notRun")}
</div>
)}
{installStream.status !== "idle" &&
streamAgentType === selectedAgent.agent_type && (
<div className="mt-2 rounded-md border bg-muted/50 text-muted-foreground p-3 max-h-[200px] overflow-y-auto font-mono text-[11px] leading-relaxed">
{installStream.logs.map((line, i) => (
<div
key={i}
className={
line.startsWith("ERROR:")
? "text-destructive"
: ""
}
>
{line}
</div>
))}
<div ref={installLogEndRef} />
</div>
)}
</div>
<div className="space-y-2">

View File

@@ -60,7 +60,10 @@ export function OpencodePluginsModal({
}, [open])
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" })
const container = logEndRef.current?.parentElement
if (container) {
container.scrollTop = container.scrollHeight
}
}, [stream.logs])
useEffect(() => {
@@ -240,11 +243,13 @@ export function OpencodePluginsModal({
)}
{stream.status !== "idle" && (
<div className="rounded-md border bg-black/80 text-green-400 p-3 max-h-[200px] overflow-y-auto font-mono text-[11px] leading-relaxed">
<div className="rounded-md border bg-muted/50 text-muted-foreground p-3 max-h-[200px] overflow-y-auto font-mono text-[11px] leading-relaxed">
{stream.logs.map((line, i) => (
<div
key={i}
className={line.startsWith("ERROR:") ? "text-red-400" : ""}
className={
line.startsWith("ERROR:") ? "text-destructive" : ""
}
>
{line}
</div>

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 }
}

View File

@@ -184,9 +184,10 @@ export async function acpClearBinaryCache(agentType: AgentType): Promise<void> {
}
export async function acpDownloadAgentBinary(
agentType: AgentType
agentType: AgentType,
taskId: string
): Promise<void> {
return getTransport().call("acp_download_agent_binary", { agentType })
return getTransport().call("acp_download_agent_binary", { agentType, taskId })
}
export async function acpDetectAgentLocalVersion(
@@ -197,16 +198,21 @@ export async function acpDetectAgentLocalVersion(
export async function acpPrepareNpxAgent(
agentType: AgentType,
registryVersion?: string | null
registryVersion: string | null | undefined,
taskId: string
): Promise<string> {
return getTransport().call("acp_prepare_npx_agent", {
agentType,
registryVersion: registryVersion ?? null,
taskId,
})
}
export async function acpUninstallAgent(agentType: AgentType): Promise<void> {
return getTransport().call("acp_uninstall_agent", { agentType })
export async function acpUninstallAgent(
agentType: AgentType,
taskId: string
): Promise<void> {
return getTransport().call("acp_uninstall_agent", { agentType, taskId })
}
export async function acpUpdateAgentPreferences(

View File

@@ -172,9 +172,10 @@ export async function acpClearBinaryCache(agentType: AgentType): Promise<void> {
}
export async function acpDownloadAgentBinary(
agentType: AgentType
agentType: AgentType,
taskId: string
): Promise<void> {
return invoke("acp_download_agent_binary", { agentType })
return invoke("acp_download_agent_binary", { agentType, taskId })
}
export async function acpDetectAgentLocalVersion(
@@ -185,16 +186,21 @@ export async function acpDetectAgentLocalVersion(
export async function acpPrepareNpxAgent(
agentType: AgentType,
registryVersion?: string | null
registryVersion: string | null | undefined,
taskId: string
): Promise<string> {
return invoke("acp_prepare_npx_agent", {
agentType,
registryVersion: registryVersion ?? null,
taskId,
})
}
export async function acpUninstallAgent(agentType: AgentType): Promise<void> {
return invoke("acp_uninstall_agent", { agentType })
export async function acpUninstallAgent(
agentType: AgentType,
taskId: string
): Promise<void> {
return invoke("acp_uninstall_agent", { agentType, taskId })
}
export async function acpUpdateAgentPreferences(

View File

@@ -936,6 +936,14 @@ export interface PluginInstallEvent {
payload: string
}
export type AgentInstallEventKind = "started" | "log" | "completed" | "failed"
export interface AgentInstallEvent {
task_id: string
kind: AgentInstallEventKind
payload: string
}
// ─── Chat Channels ───
export type ChannelType = "lark" | "telegram" | "weixin"