Compare commits
15 changed files with 23 additions and 214 deletions
|
|
@ -18,9 +18,7 @@ publishes rows bare, with no envelope around them
|
|||
read the time from.
|
||||
|
||||
The frontend renders a `TermMsg` close to as-is
|
||||
(`frontend/packages/shared/src/terminal/Row.tsx` — moved out of
|
||||
`packages/agent` once swarm-ui became a second consumer of it): `level`
|
||||
picks the
|
||||
(`frontend/packages/agent/src/components/Row.tsx`): `level` picks the
|
||||
CSS colour (`terminal.css`'s `.live .level-*`), an empty `summary` +
|
||||
markdown `body` renders as a flat row with just the body (assistant
|
||||
text), and any other bodied row is an expandable `<details>`, opened by
|
||||
|
|
@ -61,7 +59,7 @@ read that when you need the specifics, this doc doesn't duplicate it.
|
|||
|
||||
## Markdown
|
||||
|
||||
`frontend/packages/shared/src/terminal/markdown.ts`'s `renderMarkdown()` runs
|
||||
`frontend/packages/agent/src/lib/markdown.ts`'s `renderMarkdown()` runs
|
||||
`marked.parse()` through DOMPurify into a `<div class="md">`. Applied to
|
||||
any `TermMsg` with `body_format: "markdown"`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
sha256-cX/fNFPKFmN88KQiyau2yYp3xydx5wrv5ZFdMud7UcQ=
|
||||
sha256-9sh4G6A00NNi5YKaMQ1v0vZQj+gmGxYPhYZhlqweApM=
|
||||
|
|
|
|||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
|
|
@ -990,6 +990,8 @@
|
|||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"chart.js": "4.5.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "18.0.6",
|
||||
"preact": "10.29.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1010,8 +1012,6 @@
|
|||
"name": "@hive/shared",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "18.0.6",
|
||||
"preact": "10.29.8"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"chart.js": "4.5.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "18.0.6",
|
||||
"preact": "10.29.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
} from "preact/hooks";
|
||||
import { forwardRef } from "preact/compat";
|
||||
import { useLiveStream } from "../hooks/useLiveStream.js";
|
||||
import { Row } from "@hive/shared/term-row.js";
|
||||
import { Row } from "./Row.js";
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const LOAD_MORE_SCROLL_PX = 80;
|
||||
|
|
|
|||
105
frontend/packages/agent/src/components/Row.tsx
Normal file
105
frontend/packages/agent/src/components/Row.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Renders one `TermRow` — flat `<div class="row …">` or expandable
|
||||
// `<details class="row …">`, driven straight off the wire shape
|
||||
// (`lib/termMsg.ts`'s `TermMsg`, mirroring hive-agent's `term_msg.rs`):
|
||||
// `level` picks the colour class, an empty `summary` + markdown `body`
|
||||
// is a flat row with just the body (assistant text), anything else with
|
||||
// a body is an expandable details row gated by the operator's
|
||||
// expand-tool-output preference. No separate classification step —
|
||||
// mara: "StreamRow should now match what the server sends in TermMsg."
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { TermRow } from "../lib/termMsg.js";
|
||||
import { linkifyToNodes } from "../lib/linkify.js";
|
||||
import { renderMarkdown } from "../lib/markdown.js";
|
||||
import { getExpandDetailsPref, getHideDebugPref } from "@hive/shared/prefs.js";
|
||||
|
||||
function MarkdownBody({ text }: { text: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const html = renderMarkdown(text);
|
||||
useEffect(() => {
|
||||
// marked autolinks URLs but leaves them same-tab — open externally
|
||||
// so a click never navigates the terminal away.
|
||||
ref.current?.querySelectorAll("a[href]").forEach((a) => {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
}, [html]);
|
||||
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
|
||||
return (
|
||||
<div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
);
|
||||
}
|
||||
|
||||
function DiffBody({ text }: { text: string }) {
|
||||
const lines = String(text).split("\n");
|
||||
return (
|
||||
<pre className="tool-body diff-body">
|
||||
{lines.map((line, i) => {
|
||||
const cls = line.startsWith("+ ")
|
||||
? "diff-add"
|
||||
: line.startsWith("- ")
|
||||
? "diff-del"
|
||||
: "diff-ctx";
|
||||
return (
|
||||
<span key={i} className={cls}>
|
||||
{line}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function Row({ row }: { row: TermRow }) {
|
||||
// "hide debug output" is a skip, not a dimmer — a hidden debug row
|
||||
// still occupies no DOM node at all, same as it never streamed, rather
|
||||
// than a CSS `display: none` that would keep it in the layout/DOM for
|
||||
// no benefit.
|
||||
if (row.level === "debug" && getHideDebugPref()) return null;
|
||||
|
||||
const cssClass = "level-" + row.level;
|
||||
const icon = row.icon != null && row.icon !== "" && (
|
||||
<span className="row-glyph">{row.icon}</span>
|
||||
);
|
||||
|
||||
if (row.body == null) {
|
||||
return (
|
||||
<div className={`row ${cssClass}`}>
|
||||
{icon}
|
||||
{/* Explicit wrapper, not bare children — the row's grid places
|
||||
`.row-content` in its content column by class, not by
|
||||
auto-placement inference (an icon-less row's lone child would
|
||||
otherwise auto-place into the icon column instead). */}
|
||||
<span className="row-content">{linkifyToNodes(row.summary)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty summary + markdown body → the body itself is the whole row
|
||||
// (assistant text), no summary prefix line, never collapsible.
|
||||
if (row.body_format === "markdown" && row.summary === "") {
|
||||
return (
|
||||
<div className={`row ${cssClass}`}>
|
||||
{icon}
|
||||
<MarkdownBody text={row.body} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<details
|
||||
className={`row ${cssClass}`}
|
||||
open={getExpandDetailsPref() || undefined}
|
||||
>
|
||||
<summary>
|
||||
{icon}
|
||||
<span className="summary-text">{row.summary}</span>
|
||||
</summary>
|
||||
{row.body_format === "diff" && <DiffBody text={row.body} />}
|
||||
{row.body_format === "markdown" && <MarkdownBody text={row.body} />}
|
||||
{row.body_format == null && (
|
||||
<pre className="tool-body">{linkifyToNodes(row.body)}</pre>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
|
||||
// `web_ui/stream.rs`.
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { TermEnvelope, TermRow } from "@hive/shared/term-msg.js";
|
||||
import type { TermEnvelope, TermRow } from "../lib/termMsg.js";
|
||||
|
||||
export interface UseLiveStreamOptions {
|
||||
historyUrl?: string;
|
||||
|
|
|
|||
41
frontend/packages/agent/src/lib/linkify.tsx
Normal file
41
frontend/packages/agent/src/lib/linkify.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Auto-linkify bare http(s) URLs in plain (non-markdown) row text —
|
||||
// Preact-node port of @hive/shared/terminal.js's `linkify` (text-node
|
||||
// output there, JSX fragment here). Same regex + trailing-punctuation
|
||||
// strip; deliberately text-only, never innerHTML, so untrusted row text
|
||||
// (matrix-relayed bodies, tool args) can't inject markup this way.
|
||||
import type { JSX } from "preact";
|
||||
|
||||
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||
|
||||
export function linkifyToNodes(
|
||||
text: string | null | undefined,
|
||||
): (string | JSX.Element)[] {
|
||||
const str = text == null ? "" : String(text);
|
||||
if (str.indexOf("://") === -1) return [str];
|
||||
const out: (string | JSX.Element)[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let key = 0;
|
||||
LINKIFY_URL_RE.lastIndex = 0;
|
||||
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
|
||||
let url = m[0];
|
||||
const trail = url.match(/[.,;:!?)\]}'"]+$/);
|
||||
const tail = trail ? trail[0] : "";
|
||||
if (tail) url = url.slice(0, -tail.length);
|
||||
if (m.index > last) out.push(str.slice(last, m.index));
|
||||
if (!url.slice(url.indexOf("://") + 3)) {
|
||||
// Nothing past the scheme — not a real URL, emit verbatim.
|
||||
out.push(m[0]);
|
||||
} else {
|
||||
out.push(
|
||||
<a key={key++} href={url} target="_blank" rel="noopener noreferrer">
|
||||
{url}
|
||||
</a>,
|
||||
);
|
||||
if (tail) out.push(tail);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < str.length) out.push(str.slice(last));
|
||||
return out;
|
||||
}
|
||||
30
frontend/packages/agent/src/lib/markdown.ts
Normal file
30
frontend/packages/agent/src/lib/markdown.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Markdown → sanitized HTML, ported from app.js's `mdNode`. Message
|
||||
// bodies rendered into the live stream (assistant text, send/recv
|
||||
// payloads) are untrusted (peer-agent / matrix-relayed content,
|
||||
// agent-authored files) — `marked` itself no longer sanitizes (v5+
|
||||
// dropped the built-in sanitizer), so every parse is run through
|
||||
// DOMPurify before it's ever handed to `dangerouslySetInnerHTML`.
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
|
||||
const ESCAPE_RE = /[&<>"]/g;
|
||||
const ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
};
|
||||
|
||||
/** Render `text` as sanitized markdown HTML. Falls back to escaped plain
|
||||
* text if `marked` throws (mirrors app.js's try/catch fallback). */
|
||||
export function renderMarkdown(text: string | null | undefined): string {
|
||||
const src = String(text ?? "");
|
||||
try {
|
||||
return DOMPurify.sanitize(marked.parse(src) as string);
|
||||
} catch (err) {
|
||||
console.warn("marked failed", err);
|
||||
return src.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c] ?? c);
|
||||
}
|
||||
}
|
||||
42
frontend/packages/agent/src/lib/termMsg.ts
Normal file
42
frontend/packages/agent/src/lib/termMsg.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Wire types for the agent's terminal stream — mirrors hive-agent's
|
||||
// `term_msg.rs`/`web_ui/stream.rs` field-for-field. The frontend renders
|
||||
// a `TermMsg` close to as-is (see components/Row.tsx); there's no
|
||||
// separate client-side row model or classification step any more —
|
||||
// mara: "StreamRow should now match what the server sends in TermMsg."
|
||||
export type Level = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface TermMsg {
|
||||
/** When the event this row came from happened — ISO 8601 UTC, e.g.
|
||||
* "2026-09-13T12:35:03Z". The source event's own time, so a row replayed
|
||||
* from history reads as when it happened rather than when it was fetched. */
|
||||
ts: string;
|
||||
icon?: string;
|
||||
level: Level;
|
||||
summary: string;
|
||||
body?: string;
|
||||
body_format?: "markdown" | "diff";
|
||||
coalesce_key?: string;
|
||||
}
|
||||
|
||||
/** One SSE frame / history array entry — `hive-agent`'s `TermEnvelope`.
|
||||
* `seq` is the live per-event dedup counter (`BusEvent::seq`); absent on
|
||||
* history-replayed envelopes, see useLiveStream.ts's backfill dance. It is
|
||||
* the only field left: the event's time rides on each row's own `ts`, where
|
||||
* a consumer reading a bare row (the swarm queue's) can also see it. */
|
||||
export interface TermEnvelope {
|
||||
seq?: number;
|
||||
msgs: TermMsg[];
|
||||
}
|
||||
|
||||
/** A `TermMsg` plus the bookkeeping Preact needs to render a list —
|
||||
* stable identity for coalescing/keys, and whether it came from history
|
||||
* replay vs. the live tail. Not a separate model: everything content-wise
|
||||
* is still exactly the wire shape. */
|
||||
export interface TermRow extends Omit<TermMsg, "ts"> {
|
||||
/** Absent on the rows the client invents for itself — the history/live
|
||||
* separators, the connection note, a locally echoed prompt — which answer
|
||||
* to no server event and so have no event time to carry. */
|
||||
ts?: string;
|
||||
key: string;
|
||||
fromHistory: boolean;
|
||||
}
|
||||
|
|
@ -6,17 +6,11 @@
|
|||
"type": "module",
|
||||
"main": "./src/index.js",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "18.0.6",
|
||||
"preact": "10.29.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./terminal.js": "./src/terminal/terminal.js",
|
||||
"./term-msg.js": "./src/terminal/termMsg.ts",
|
||||
"./term-row.js": "./src/terminal/Row.tsx",
|
||||
"./term-linkify.js": "./src/terminal/linkify.tsx",
|
||||
"./term-markdown.js": "./src/terminal/markdown.ts",
|
||||
"./tabs.js": "./src/tabs/tabs.js",
|
||||
"./tabs.css": "./src/tabs/tabs.css",
|
||||
"./hive-tab-strip.js": "./src/tabs/hive-tab-strip.js",
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
/* <AgentTermPreview> — a *preview*, not the full terminal: a much
|
||||
shorter cap than @hive/shared/terminal.css's own 32em default so it
|
||||
reads as "peek at what's happening" inside the detail panel rather
|
||||
than competing with the panel's own fields for vertical space. */
|
||||
@import "@hive/shared/terminal.css";
|
||||
|
||||
.ui-agent-term-preview {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.ui-agent-term-preview-status {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
.ui-agent-term-preview-wrap.terminal-wrap {
|
||||
max-height: 12em;
|
||||
}
|
||||
|
||||
.ui-agent-term-preview-wrap .live.terminal {
|
||||
max-height: 12em;
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
// <AgentTermPreview> — the MVP mara's own ruling asked for: not a separate
|
||||
// page, a small read-only preview embedded below `AgentsPage`'s detail
|
||||
// panel fields. No header, no input — those (plus sending input back
|
||||
// to the agent) are explicitly follow-up scope, not this issue's.
|
||||
// Consumes swarm-controller's `GET /api/agents/{name}/term/stream` via
|
||||
// `useSwarmTermStream`, rendering through the same `@hive/shared`
|
||||
// `Row` component the per-hive local terminal uses — "share components
|
||||
// between hive and swarm level term as much as possible" (mara).
|
||||
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
import { Row } from "@hive/shared/term-row.js";
|
||||
import {
|
||||
useSwarmTermStream,
|
||||
type ConnectionState,
|
||||
} from "./useSwarmTermStream.js";
|
||||
import "./AgentTermPreview.css";
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
|
||||
const CONNECTION_LABEL: Record<ConnectionState, string> = {
|
||||
connecting: "connecting…",
|
||||
open: "live",
|
||||
reconnecting: "reconnecting…",
|
||||
closed: "disconnected",
|
||||
};
|
||||
|
||||
const CONNECTION_TONE: Record<ConnectionState, BadgeTone> = {
|
||||
connecting: "neutral",
|
||||
open: "positive",
|
||||
reconnecting: "warning",
|
||||
closed: "negative",
|
||||
};
|
||||
|
||||
export function AgentTermPreview({ agentName }: { agentName: string }) {
|
||||
const { rows, connection } = useSwarmTermStream(
|
||||
`/api/agents/${encodeURIComponent(agentName)}/term/stream`,
|
||||
);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
|
||||
function handleScroll() {
|
||||
const el = logRef.current;
|
||||
if (!el) return;
|
||||
setStickToBottom(
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX,
|
||||
);
|
||||
}
|
||||
|
||||
// Runs after Preact has committed new rows, so `scrollHeight` is
|
||||
// already current — same ordering `@hive/agent`'s `LiveStream` uses.
|
||||
useLayoutEffect(() => {
|
||||
const el = logRef.current;
|
||||
if (el && stickToBottom) el.scrollTop = el.scrollHeight - el.clientHeight;
|
||||
}, [rows, stickToBottom]);
|
||||
|
||||
return (
|
||||
<div class="ui-agent-term-preview">
|
||||
<Badge
|
||||
class="ui-agent-term-preview-status"
|
||||
tone={CONNECTION_TONE[connection]}
|
||||
value={CONNECTION_LABEL[connection]}
|
||||
/>
|
||||
<div class="terminal-wrap ui-agent-term-preview-wrap">
|
||||
<div class="live terminal" ref={logRef} onScroll={handleScroll}>
|
||||
{rows.length === 0 ? (
|
||||
<div class="row note">
|
||||
{connection === "open"
|
||||
? "(connected — waiting for events)"
|
||||
: CONNECTION_LABEL[connection]}
|
||||
</div>
|
||||
) : (
|
||||
rows.map((row) => <Row key={row.key} row={row} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,7 +31,6 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
|||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
import { AgentCard } from "./AgentCard.js";
|
||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||
|
|
@ -582,10 +581,6 @@ export function AgentsPage() {
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
{/* MVP scope per mara's own ruling: a small read-only
|
||||
preview, no header/no input — the full terminal
|
||||
(+ sending input back) is explicit follow-up scope. */}
|
||||
<AgentTermPreview agentName={detailTarget.name} />
|
||||
</div>
|
||||
) : (
|
||||
<p class="ui-agents-empty">select an agent to see its details</p>
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
// Live SSE consumer for the swarm-relayed agent terminal
|
||||
// (`GET /api/agents/{name}/term/stream`, swarm-controller's
|
||||
// `term_stream.rs`). Deliberately NOT `@hive/agent`'s `useLiveStream` —
|
||||
// that hook's wire shape is `TermEnvelope` (`{seq, msgs: []}`) plus a
|
||||
// history/backfill dance; the swarm relay forwards one bare `TermMsg`
|
||||
// per SSE event with no envelope and no history endpoint (a core NATS
|
||||
// subject, not `JetStream` — see `term_stream.rs`'s own module doc), so
|
||||
// there's nothing to buffer/dedupe/backfill against. Same row-coalescing
|
||||
// rule as `useLiveStream`'s `appendRow` (ported, not imported — one
|
||||
// small helper isn't worth a cross-package hop for).
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { TermMsg, TermRow } from "@hive/shared/term-msg.js";
|
||||
|
||||
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
|
||||
|
||||
function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
|
||||
const last = rows[rows.length - 1];
|
||||
if (row.coalesce_key && last && last.coalesce_key === row.coalesce_key) {
|
||||
return [...rows.slice(0, -1), { ...row, key: last.key }];
|
||||
}
|
||||
return [...rows, row];
|
||||
}
|
||||
|
||||
export interface UseSwarmTermStreamResult {
|
||||
rows: TermRow[];
|
||||
connection: ConnectionState;
|
||||
}
|
||||
|
||||
export function useSwarmTermStream(
|
||||
streamUrl: string,
|
||||
): UseSwarmTermStreamResult {
|
||||
const [rows, setRows] = useState<TermRow[]>([]);
|
||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||
const keySeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setRows([]);
|
||||
setConnection("connecting");
|
||||
keySeqRef.current = 0;
|
||||
const es = new EventSource(streamUrl);
|
||||
es.onopen = () => setConnection("open");
|
||||
es.onmessage = (e) => {
|
||||
keySeqRef.current += 1;
|
||||
const key = "r" + keySeqRef.current;
|
||||
let msg: TermMsg;
|
||||
try {
|
||||
msg = JSON.parse(e.data);
|
||||
} catch {
|
||||
setRows((prev) =>
|
||||
appendRow(prev, {
|
||||
key,
|
||||
level: "warn",
|
||||
fromHistory: false,
|
||||
summary: "[parse err] " + e.data,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRows((prev) => appendRow(prev, { ...msg, key, fromHistory: false }));
|
||||
};
|
||||
es.onerror = () => {
|
||||
setConnection(
|
||||
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
|
||||
);
|
||||
};
|
||||
return () => es.close();
|
||||
}, [streamUrl]);
|
||||
|
||||
return { rows, connection };
|
||||
}
|
||||
Loading…
Reference in a new issue