From ec54ea44733fb6ff8c152b4562448fd095ddb677 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:17:47 +0800 Subject: [PATCH] feat(web): AcpPanel streaming over WebSocket Replace the synchronous useAcpPrompt mutation with a streaming WS hook. The agent's reply now arrives chunk-by-chunk and the same agent bubble grows in real time. - web/src/lib/api/acp.ts: - Add AcpStreamEvent / AcpStreamStatus types. - Add useAcpStream(workspaceId) hook: per-prompt WS lifecycle (idle -> connecting -> open -> closed / error), open(content) / close() / onEvent(cb) API. Reuses the reconnection / 1000-1001 / pinger pattern from useProcessWebSocket. - Keep useAcpPrompt and useAcpCancel exported for back-compat. - web/src/components/acp/AcpPanel.tsx: - Switch to useAcpStream. handleSend pushes an optimistic user message, opens the stream, sets an inflight agent bubble with empty text. - chunk events append to inflight.text; complete clears inflight; error clears inflight and shows a local error toast. - renderMessages = coalesced + localUserMessage + inflight. - Local user message is auto-cleared when the next history poll surfaces the same text (avoids duplicate render). - Auto-scroll effect depends on inflight.text so it fires as chunks stream. - Cancel button closes the WS (server's read loop triggers session/cancel). - Remove the standalone ThinkingIndicator; the in-flight bubble serves as the streaming affordance. Conversation: 7B62CB8E-ACC8-4333-BC64-B927C8FDC397 --- web/src/components/acp/AcpPanel.tsx | 180 ++++++++++++++++++----- web/src/lib/api/acp.ts | 213 ++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+), 38 deletions(-) diff --git a/web/src/components/acp/AcpPanel.tsx b/web/src/components/acp/AcpPanel.tsx index 4f97c4d..b364e41 100644 --- a/web/src/components/acp/AcpPanel.tsx +++ b/web/src/components/acp/AcpPanel.tsx @@ -1,18 +1,12 @@ import { Ban, Send } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { MessageBubble } from "./MessageBubble"; -import { ThinkingIndicator } from "./ThinkingIndicator"; -import { groupMessages } from "./groupMessages"; +import { groupMessages, type CoalescedMessage } from "./groupMessages"; -import { - useAcpCancel, - useAcpHistory, - useAcpPrompt, - useAcpStatus, -} from "@/lib/api/acp"; +import { useAcpHistory, useAcpStatus, useAcpStream } from "@/lib/api/acp"; import { cn } from "@/lib/utils"; interface AcpPanelProps { @@ -71,13 +65,22 @@ function StatusPill({ status }: { status: ReturnType }) { export function AcpPanel({ workspaceId }: AcpPanelProps) { const acpStatus = useAcpStatus(workspaceId); const acpHistory = useAcpHistory(workspaceId); - const prompt = useAcpPrompt(); - const cancel = useAcpCancel(); + const stream = useAcpStream(workspaceId); + const { onEvent, close } = stream; const [input, setInput] = useState(""); const [localErrors, setLocalErrors] = useState([]); + const [localUserMessage, setLocalUserMessage] = + useState(null); + const [inflight, setInflight] = useState<{ + messageId: string; + text: string; + } | null>(null); + const scrollRootRef = useRef(null); const sentinelRef = useRef(null); + const localTimeoutRef = useRef(null); + const errorTimeoutsRef = useRef>(new Set()); const messages = useMemo( () => acpHistory.data?.messages ?? [], @@ -85,19 +88,81 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) { ); const coalesced = useMemo(() => groupMessages(messages), [messages]); - useEffect(() => { - if (prompt.error) { - const id = Date.now(); - setLocalErrors((prev) => [ - ...prev, - { id, message: prompt.error?.message ?? "Failed to send prompt" }, - ]); - const timeout = window.setTimeout(() => { - setLocalErrors((prev) => prev.filter((e) => e.id !== id)); - }, 5000); - return () => window.clearTimeout(timeout); + const pushError = useCallback((message: string) => { + const id = Date.now(); + setLocalErrors((prev) => [...prev, { id, message }]); + const timeout = window.setTimeout(() => { + errorTimeoutsRef.current.delete(timeout); + setLocalErrors((prev) => prev.filter((e) => e.id !== id)); + }, 5000); + errorTimeoutsRef.current.add(timeout); + }, []); + + const scheduleLocalClear = useCallback(() => { + if (localTimeoutRef.current !== null) { + window.clearTimeout(localTimeoutRef.current); } - }, [prompt.error]); + localTimeoutRef.current = window.setTimeout(() => { + localTimeoutRef.current = null; + setLocalUserMessage(null); + }, 6000); + }, []); + + useEffect(() => { + return onEvent((event) => { + switch (event.type) { + case "chunk": + setInflight((cur) => + cur + ? { + messageId: event.messageId || cur.messageId, + text: cur.text + event.text, + } + : cur, + ); + break; + case "complete": + setInflight(null); + close(); + break; + case "error": + setInflight(null); + pushError(event.error || "stream error"); + close(); + break; + } + }); + }, [onEvent, close, pushError]); + + useEffect(() => { + if (stream.status === "error") { + setInflight(null); + pushError("stream connection failed"); + } + }, [stream.status, pushError]); + + useEffect(() => { + if (!localUserMessage) return; + const lastUser = [...coalesced].reverse().find((m) => m.role === "user"); + if (lastUser && lastUser.text === localUserMessage.text) { + setLocalUserMessage(null); + if (localTimeoutRef.current !== null) { + window.clearTimeout(localTimeoutRef.current); + localTimeoutRef.current = null; + } + } + }, [coalesced, localUserMessage]); + + useEffect(() => { + const timeoutsSet = errorTimeoutsRef.current; + return () => { + if (localTimeoutRef.current !== null) { + window.clearTimeout(localTimeoutRef.current); + } + const timeouts = Array.from(timeoutsSet); + timeouts.forEach((timeout) => window.clearTimeout(timeout)); + }; + }, []); useEffect(() => { const root = scrollRootRef.current; @@ -114,13 +179,26 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) { if (distanceFromBottom <= 80) { sentinel.scrollIntoView({ behavior: "smooth", block: "end" }); } - }, [coalesced, prompt.isPending]); + }, [coalesced, inflight?.text, localUserMessage]); const handleSend = () => { const content = input.trim(); - if (!content || !workspaceId || prompt.isPending) return; - prompt.mutate({ workspaceId, content }); + if (!content || !workspaceId || inflight) return; + setLocalUserMessage({ + role: "user", + text: content, + time: new Date().toISOString(), + }); + scheduleLocalClear(); setInput(""); + setInflight({ messageId: "", text: "" }); + stream.open(content); + }; + + const handleCancel = () => { + if (!inflight) return; + stream.close(); + setInflight(null); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -137,6 +215,21 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) { ? `${sessionId.slice(0, 12)}…` : sessionId; + const renderMessages = useMemo(() => { + const items: CoalescedMessage[] = [...coalesced]; + if (localUserMessage) { + items.push(localUserMessage); + } + if (inflight) { + items.push({ + role: "agent", + text: inflight.text, + time: new Date().toISOString(), + }); + } + return items; + }, [coalesced, localUserMessage, inflight]); + if (!workspaceId) { return (
@@ -164,15 +257,26 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
- {messages.length === 0 && isReady && ( -
- Send a message to start the conversation. -
- )} - {coalesced.map((message) => ( - + {messages.length === 0 && + !localUserMessage && + !inflight && + isReady && ( +
+ Send a message to start the conversation. +
+ )} + {renderMessages.map((message, index) => ( + ))} - {prompt.isPending && } {localErrors.map((err) => (
- {prompt.isPending ? ( + {inflight ? (