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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
src/hooks/use-agent-install-stream.ts
Normal file
74
src/hooks/use-agent-install-stream.ts
Normal 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 }
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user