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 ? (