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
This commit is contained in:
@@ -1,18 +1,12 @@
|
|||||||
import { Ban, Send } from "lucide-react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { MessageBubble } from "./MessageBubble";
|
import { MessageBubble } from "./MessageBubble";
|
||||||
import { ThinkingIndicator } from "./ThinkingIndicator";
|
import { groupMessages, type CoalescedMessage } from "./groupMessages";
|
||||||
import { groupMessages } from "./groupMessages";
|
|
||||||
|
|
||||||
import {
|
import { useAcpHistory, useAcpStatus, useAcpStream } from "@/lib/api/acp";
|
||||||
useAcpCancel,
|
|
||||||
useAcpHistory,
|
|
||||||
useAcpPrompt,
|
|
||||||
useAcpStatus,
|
|
||||||
} from "@/lib/api/acp";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface AcpPanelProps {
|
interface AcpPanelProps {
|
||||||
@@ -71,13 +65,22 @@ function StatusPill({ status }: { status: ReturnType<typeof useAcpStatus> }) {
|
|||||||
export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
||||||
const acpStatus = useAcpStatus(workspaceId);
|
const acpStatus = useAcpStatus(workspaceId);
|
||||||
const acpHistory = useAcpHistory(workspaceId);
|
const acpHistory = useAcpHistory(workspaceId);
|
||||||
const prompt = useAcpPrompt();
|
const stream = useAcpStream(workspaceId);
|
||||||
const cancel = useAcpCancel();
|
const { onEvent, close } = stream;
|
||||||
|
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [localErrors, setLocalErrors] = useState<LocalError[]>([]);
|
const [localErrors, setLocalErrors] = useState<LocalError[]>([]);
|
||||||
|
const [localUserMessage, setLocalUserMessage] =
|
||||||
|
useState<CoalescedMessage | null>(null);
|
||||||
|
const [inflight, setInflight] = useState<{
|
||||||
|
messageId: string;
|
||||||
|
text: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const scrollRootRef = useRef<HTMLDivElement | null>(null);
|
const scrollRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const localTimeoutRef = useRef<number | null>(null);
|
||||||
|
const errorTimeoutsRef = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
const messages = useMemo(
|
const messages = useMemo(
|
||||||
() => acpHistory.data?.messages ?? [],
|
() => acpHistory.data?.messages ?? [],
|
||||||
@@ -85,19 +88,81 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
);
|
);
|
||||||
const coalesced = useMemo(() => groupMessages(messages), [messages]);
|
const coalesced = useMemo(() => groupMessages(messages), [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
const pushError = useCallback((message: string) => {
|
||||||
if (prompt.error) {
|
const id = Date.now();
|
||||||
const id = Date.now();
|
setLocalErrors((prev) => [...prev, { id, message }]);
|
||||||
setLocalErrors((prev) => [
|
const timeout = window.setTimeout(() => {
|
||||||
...prev,
|
errorTimeoutsRef.current.delete(timeout);
|
||||||
{ id, message: prompt.error?.message ?? "Failed to send prompt" },
|
setLocalErrors((prev) => prev.filter((e) => e.id !== id));
|
||||||
]);
|
}, 5000);
|
||||||
const timeout = window.setTimeout(() => {
|
errorTimeoutsRef.current.add(timeout);
|
||||||
setLocalErrors((prev) => prev.filter((e) => e.id !== id));
|
}, []);
|
||||||
}, 5000);
|
|
||||||
return () => window.clearTimeout(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(() => {
|
useEffect(() => {
|
||||||
const root = scrollRootRef.current;
|
const root = scrollRootRef.current;
|
||||||
@@ -114,13 +179,26 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
if (distanceFromBottom <= 80) {
|
if (distanceFromBottom <= 80) {
|
||||||
sentinel.scrollIntoView({ behavior: "smooth", block: "end" });
|
sentinel.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
}
|
}
|
||||||
}, [coalesced, prompt.isPending]);
|
}, [coalesced, inflight?.text, localUserMessage]);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
const content = input.trim();
|
const content = input.trim();
|
||||||
if (!content || !workspaceId || prompt.isPending) return;
|
if (!content || !workspaceId || inflight) return;
|
||||||
prompt.mutate({ workspaceId, content });
|
setLocalUserMessage({
|
||||||
|
role: "user",
|
||||||
|
text: content,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
scheduleLocalClear();
|
||||||
setInput("");
|
setInput("");
|
||||||
|
setInflight({ messageId: "", text: "" });
|
||||||
|
stream.open(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (!inflight) return;
|
||||||
|
stream.close();
|
||||||
|
setInflight(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
@@ -137,6 +215,21 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
? `${sessionId.slice(0, 12)}…`
|
? `${sessionId.slice(0, 12)}…`
|
||||||
: sessionId;
|
: 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) {
|
if (!workspaceId) {
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
|
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
|
||||||
@@ -164,15 +257,26 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
|
|
||||||
<ScrollArea ref={scrollRootRef} className="flex-1">
|
<ScrollArea ref={scrollRootRef} className="flex-1">
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="flex flex-col gap-3 p-3">
|
||||||
{messages.length === 0 && isReady && (
|
{messages.length === 0 &&
|
||||||
<div className="flex flex-1 items-center justify-center py-8 text-sm text-muted-foreground">
|
!localUserMessage &&
|
||||||
Send a message to start the conversation.
|
!inflight &&
|
||||||
</div>
|
isReady && (
|
||||||
)}
|
<div className="flex flex-1 items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
{coalesced.map((message) => (
|
Send a message to start the conversation.
|
||||||
<MessageBubble key={message.time} message={message} />
|
</div>
|
||||||
|
)}
|
||||||
|
{renderMessages.map((message, index) => (
|
||||||
|
<MessageBubble
|
||||||
|
key={
|
||||||
|
localUserMessage && message === localUserMessage
|
||||||
|
? "local-user"
|
||||||
|
: inflight && index === renderMessages.length - 1
|
||||||
|
? "inflight-agent"
|
||||||
|
: message.time
|
||||||
|
}
|
||||||
|
message={message}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{prompt.isPending && <ThinkingIndicator />}
|
|
||||||
{localErrors.map((err) => (
|
{localErrors.map((err) => (
|
||||||
<div
|
<div
|
||||||
key={err.id}
|
key={err.id}
|
||||||
@@ -196,12 +300,12 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
disabled={!workspaceId}
|
disabled={!workspaceId}
|
||||||
className="max-h-32 min-h-8 flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
className="max-h-32 min-h-8 flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{prompt.isPending ? (
|
{inflight ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => workspaceId && cancel.mutate(workspaceId)}
|
onClick={handleCancel}
|
||||||
disabled={cancel.isPending}
|
disabled={stream.status !== "open"}
|
||||||
>
|
>
|
||||||
<Ban className="size-4" aria-hidden="true" />
|
<Ban className="size-4" aria-hidden="true" />
|
||||||
<span className="sr-only">Cancel</span>
|
<span className="sr-only">Cancel</span>
|
||||||
@@ -210,7 +314,7 @@ export function AcpPanel({ workspaceId }: AcpPanelProps) {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!input.trim() || prompt.isPending}
|
disabled={!input.trim() || !!inflight}
|
||||||
>
|
>
|
||||||
<Send className="size-4" aria-hidden="true" />
|
<Send className="size-4" aria-hidden="true" />
|
||||||
<span className="sr-only">Send</span>
|
<span className="sr-only">Send</span>
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
export interface AcpStatus {
|
export interface AcpStatus {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -34,6 +41,25 @@ export interface AcpPromptResponse {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AcpStreamEvent =
|
||||||
|
| { type: "chunk"; messageId?: string; text: string }
|
||||||
|
| { type: "complete"; stopReason: string }
|
||||||
|
| { type: "error"; error: string };
|
||||||
|
|
||||||
|
export type AcpStreamStatus =
|
||||||
|
| "idle"
|
||||||
|
| "connecting"
|
||||||
|
| "open"
|
||||||
|
| "closed"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface AcpStream {
|
||||||
|
status: AcpStreamStatus;
|
||||||
|
open: (content: string) => void;
|
||||||
|
close: () => void;
|
||||||
|
onEvent: (cb: (event: AcpStreamEvent) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
function acpStatusQueryKey(workspaceId: string) {
|
function acpStatusQueryKey(workspaceId: string) {
|
||||||
return ["workspaces", workspaceId, "acp", "status"] as const;
|
return ["workspaces", workspaceId, "acp", "status"] as const;
|
||||||
}
|
}
|
||||||
@@ -147,3 +173,190 @@ export function useAcpCancel(): UseMutationResult<void, Error, string> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAcpStreamEvent(data: unknown): data is AcpStreamEvent {
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ev = data as Record<string, unknown>;
|
||||||
|
if (ev.type === "chunk") {
|
||||||
|
return typeof ev.text === "string";
|
||||||
|
}
|
||||||
|
if (ev.type === "complete") {
|
||||||
|
return typeof ev.stopReason === "string";
|
||||||
|
}
|
||||||
|
if (ev.type === "error") {
|
||||||
|
return typeof ev.error === "string";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAcpStream(workspaceId: string | null): AcpStream {
|
||||||
|
const [status, setStatus] = useState<AcpStreamStatus>("idle");
|
||||||
|
const statusRef = useRef(status);
|
||||||
|
useEffect(() => {
|
||||||
|
statusRef.current = status;
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||||
|
const intentionalCloseRef = useRef(false);
|
||||||
|
const serverCleanCloseRef = useRef(false);
|
||||||
|
const terminalEventRef = useRef(false);
|
||||||
|
const pendingContentRef = useRef<string | null>(null);
|
||||||
|
const callbacksRef = useRef(new Set<(event: AcpStreamEvent) => void>());
|
||||||
|
|
||||||
|
const clearReconnectTimeout = useCallback(() => {
|
||||||
|
if (reconnectTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeSocket = useCallback(() => {
|
||||||
|
intentionalCloseRef.current = true;
|
||||||
|
clearReconnectTimeout();
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket) {
|
||||||
|
socket.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
setStatus("closed");
|
||||||
|
}, [clearReconnectTimeout]);
|
||||||
|
|
||||||
|
const open = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close any existing socket before starting a fresh prompt stream.
|
||||||
|
if (socketRef.current) {
|
||||||
|
intentionalCloseRef.current = true;
|
||||||
|
const socket = socketRef.current;
|
||||||
|
socket.onclose = null;
|
||||||
|
socket.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
intentionalCloseRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearReconnectTimeout();
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
serverCleanCloseRef.current = false;
|
||||||
|
terminalEventRef.current = false;
|
||||||
|
pendingContentRef.current = content;
|
||||||
|
|
||||||
|
const openSocket = () => {
|
||||||
|
const wsUrl =
|
||||||
|
(window.location.protocol === "https:" ? "wss:" : "ws:") +
|
||||||
|
"//" +
|
||||||
|
window.location.host +
|
||||||
|
"/api/workspaces/" +
|
||||||
|
encodeURIComponent(workspaceId) +
|
||||||
|
"/acp/stream";
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
socketRef.current = ws;
|
||||||
|
setStatus("connecting");
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
serverCleanCloseRef.current = false;
|
||||||
|
terminalEventRef.current = false;
|
||||||
|
setStatus("open");
|
||||||
|
const contentToSend = pendingContentRef.current;
|
||||||
|
if (contentToSend !== null) {
|
||||||
|
ws.send(JSON.stringify({ type: "prompt", content: contentToSend }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(
|
||||||
|
typeof event.data === "string" ? event.data : "",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isAcpStreamEvent(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callbacksRef.current.forEach((cb) => cb(data));
|
||||||
|
if (data.type === "complete" || data.type === "error") {
|
||||||
|
terminalEventRef.current = true;
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
socketRef.current = null;
|
||||||
|
if (
|
||||||
|
(event.code === 1000 || event.code === 1001) &&
|
||||||
|
!intentionalCloseRef.current
|
||||||
|
) {
|
||||||
|
serverCleanCloseRef.current = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
intentionalCloseRef.current ||
|
||||||
|
terminalEventRef.current ||
|
||||||
|
serverCleanCloseRef.current
|
||||||
|
) {
|
||||||
|
setStatus("closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attempts = reconnectAttemptsRef.current;
|
||||||
|
if (attempts < 3) {
|
||||||
|
setStatus("connecting");
|
||||||
|
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
reconnectTimeoutRef.current = null;
|
||||||
|
openSocket();
|
||||||
|
}, Math.pow(2, attempts) * 1000);
|
||||||
|
reconnectAttemptsRef.current = attempts + 1;
|
||||||
|
} else {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
// The browser fires onclose after an error; handle retries there.
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
openSocket();
|
||||||
|
},
|
||||||
|
[workspaceId, clearReconnectTimeout],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearReconnectTimeout();
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket) {
|
||||||
|
socket.onclose = null;
|
||||||
|
socket.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [workspaceId, clearReconnectTimeout]);
|
||||||
|
|
||||||
|
const onEvent = useCallback(
|
||||||
|
(cb: (event: AcpStreamEvent) => void) => {
|
||||||
|
callbacksRef.current.add(cb);
|
||||||
|
return () => {
|
||||||
|
callbacksRef.current.delete(cb);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
closeSocket();
|
||||||
|
}, [closeSocket]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ status, open, close, onEvent }),
|
||||||
|
[status, open, close, onEvent],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user