Something went wrong. Try again.
A local-first event pipeline for independent agents, built on Jazz.
Something went wrong. Try again.
19 kB · 219 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";import { createRoot } from "react-dom/client";import Markdown from "react-markdown";import { isUnsettled, mergeRows, type ChatRow, type ChatSend, type ChatSnapshot, type WebConversation } from "./co-chat-types.js";import "./co-chat-app.css";
const statusText = { dispatching: "Sending to Co…", running: "Turn accepted · waiting for completion", stopping: "Stop requested · waiting for the runtime", completed: "Completed", interrupted: "Stopped", failed: "The runtime reported a failed turn.", unknown: "Outcome unknown. History is reconciled, but this turn will not be resent. Stop may still cancel remote work. Start a new chat or ask the operator to resolve this receipt.",};class RequestError extends Error { constructor(readonly status: number, code: string) { super(code); } }async function api<T>(path: string, csrf?: string, payload?: unknown): Promise<T> { const response = await fetch(`/chat/api/${path}`, { credentials: "same-origin", cache: "no-store", signal: AbortSignal.timeout(130_000), ...(payload !== undefined ? { method: "POST", headers: { "content-type": "application/json", "x-co-csrf": csrf ?? "" }, body: JSON.stringify(payload) } : {}) }); if (!response.headers.get("content-type")?.includes("application/json")) throw new RequestError(response.status, "service-unavailable"); const data = await response.json() as T & { error?: string }; if (!response.ok) throw new RequestError(response.status, data.error ?? "request-failed"); return data;}function readableError(error: unknown): string { if (error instanceof RequestError && error.status === 401) return "Your session has expired. Sign in again to continue."; if (error instanceof RequestError && error.message === "chat-not-configured") return "Private chat is not activated yet. No message was sent."; if (error instanceof RequestError && error.status === 429) return "Please wait a moment before trying again."; if (error instanceof RequestError && error.message === "turn-unsettled") return "A turn is still running or uncertain. Reconcile its history before continuing."; if (error instanceof RequestError && error.message === "creation-uncertain") return "Chat creation could not be confirmed. No message was sent. Check the conversation list before starting another."; if (error instanceof RequestError && error.status === 403) return "Request verification failed. Sign in again."; if (error instanceof RequestError && error.status === 503) return "Chat service unavailable. No automatic retry."; return "The service could not confirm this request. Reconnect before trying again.";}function Copy({ text, label = "Copy" }: { text: string; label?: string }) { const [state, setState] = useState(label); return <button className="copy" onClick={() => { void navigator.clipboard.writeText(text).then(() => setState("Copied"), () => setState("Copy unavailable")); }}>{state}</button>;}function nodeText(node: React.ReactNode): string { if (typeof node === "string" || typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(nodeText).join(""); if (React.isValidElement<{ children?: React.ReactNode }>(node)) return nodeText(node.props.children); return "";}function Row({ row }: { row: ChatRow }) { if (row.kind === "tool_call") return <details className="tool"><summary>{row.name} <span>{row.outcome === "pending" ? "Awaiting outcome" : row.outcome}</span></summary><p>Tool activity only. Arguments and output stay out of this view.</p></details>; return <article className={`message ${row.kind}`}><div className="speaker">{row.kind === "user" ? "You" : "Co"}</div><Markdown skipHtml components={{ img: () => null, a: ({ children, href }) => <a href={href} target="_blank" rel="noreferrer noopener">{children}</a>, pre: ({ children }) => <div className="code"><Copy text={nodeText(children)} label="Copy code"/><pre>{children}</pre></div>, }}>{row.text}</Markdown>{row.kind === "assistant" && <Copy text={row.text}/>}</article>;}function selectedHash(): string | undefined { const id = window.location.hash.slice(1); return /^[a-f0-9-]{36}$/.test(id) ? id : undefined; }const pendingKey = "co-web-pending-send-v1";function pendingMetadata(): { id: string; requestId: string } | undefined { try { const value = JSON.parse(sessionStorage.getItem(pendingKey) ?? "null") as { id?: string; requestId?: string } | null; if (value && /^[a-f0-9-]{36}$/.test(value.id ?? "") && /^[a-f0-9-]{36}$/.test(value.requestId ?? "")) return { id: value.id!, requestId: value.requestId! }; } catch { /* Storage can be unavailable; admission then fails before dispatch. */ } return undefined;}function App() { const [csrf, setCsrf] = useState<string>(); const [conversations, setConversations] = useState<WebConversation[]>([]); const [selected, setSelected] = useState<string | undefined>(selectedHash); const selectedRef = useRef(selected); selectedRef.current = selected; const [snapshot, setSnapshot] = useState<ChatSnapshot>(); const [rows, setRows] = useState<ChatRow[]>([]); const [draft, setDraft] = useState(""); const drafts = useRef(new Map<string, string>()); const [sidebar, setSidebar] = useState(false); const [mobile, setMobile] = useState(() => matchMedia("(max-width:760px)").matches); useEffect(() => { const media = matchMedia("(max-width:760px)"); const change = () => setMobile(media.matches); const escape = (event: KeyboardEvent) => { if (event.key === "Escape") setSidebar(false); }; media.addEventListener("change", change); window.addEventListener("keydown", escape); return () => { media.removeEventListener("change", change); window.removeEventListener("keydown", escape); }; }, []); const [contextOpen, setContextOpen] = useState(false); const [source, setSource] = useState(""); const [observedAt, setObservedAt] = useState(""); const [excerpt, setExcerpt] = useState(""); const [pending, setPending] = useState(false); const [uncertain, setUncertain] = useState<{ id: string; requestId: string; input?: ChatSend } | undefined>(pendingMetadata); const createRequest = useRef<string | undefined>(undefined); const [error, setError] = useState(""); const [connected, setConnected] = useState(false); const [loading, setLoading] = useState(true); const [older, setOlder] = useState<string | null>(); const viewport = useRef<HTMLDivElement>(null); const follow = useRef(true); const olderHeight = useRef<number | undefined>(undefined); const olderRows = useRef<ChatRow[]>([]); const composer = useRef<HTMLTextAreaElement>(null); const refresh = useCallback(async () => { const auth = await api<{ csrfToken: string }>("session"); setCsrf(auth.csrfToken); const list = await api<{ conversations: WebConversation[] }>("conversations"); setConversations(list.conversations); setConnected(true); }, []); const choose = (id: string) => { window.location.hash = id; setSidebar(false); }; useEffect(() => { const changed = () => setSelected(selectedHash()); window.addEventListener("hashchange", changed); void refresh().catch((reason: unknown) => setError(readableError(reason))).finally(() => setLoading(false)); return () => window.removeEventListener("hashchange", changed); }, [refresh]); useEffect(() => { setSnapshot(undefined); setRows([]); setOlder(undefined); olderRows.current = []; follow.current = true; setDraft(selected ? drafts.current.get(selected) ?? "" : ""); setExcerpt(""); setSource(""); setObservedAt(""); setContextOpen(false); let disposed = false; let timer: ReturnType<typeof setTimeout>; let interval = 1_000; let polling = false; async function tick() { if (polling) return; polling = true; try { if (!selected) return; const next = await api<ChatSnapshot>(`conversations/${selected}`); if (disposed) return; setSnapshot(next); setRows(mergeRows(olderRows.current, next.rows)); interval = isUnsettled(next.conversation.turn?.status) ? 1_000 : 10_000; const pendingSend = pendingMetadata(); if (pendingSend?.id === selected && pendingSend.requestId === next.conversation.turn?.requestId) { sessionStorage.removeItem(pendingKey); setUncertain(undefined); } setOlder((prior) => prior === undefined ? next.hasMore === false ? null : next.nextBefore : prior); setConversations((prior) => prior.map((item) => item.id === next.conversation.id ? next.conversation : item)); setConnected(true); } catch (reason) { if (!disposed) { setConnected(false); setError(readableError(reason)); } } finally { polling = false; if (!disposed) timer = setTimeout(() => { void tick(); }, document.hidden ? 10_000 : interval); } } const wake = () => { clearTimeout(timer); void tick(); }; window.addEventListener("co-chat-refresh", wake); void tick(); return () => { disposed = true; clearTimeout(timer); window.removeEventListener("co-chat-refresh", wake); }; }, [selected]); useLayoutEffect(() => { const view = viewport.current; if (!view) return; if (olderHeight.current !== undefined) { view.scrollTop += view.scrollHeight - olderHeight.current; olderHeight.current = undefined; } else if (follow.current) view.scrollTop = view.scrollHeight; }, [rows]); useLayoutEffect(() => { if (composer.current) { composer.current.style.height = "auto"; composer.current.style.height = `${Math.min(composer.current.scrollHeight, 220)}px`; } }, [draft]); const turn = snapshot?.conversation.turn; const busy = isUnsettled(turn?.status); async function create() { setPending(true); setError(""); createRequest.current ??= crypto.randomUUID(); try { const conversation = await api<WebConversation>("conversations", csrf, { requestId: createRequest.current }); createRequest.current = undefined; setConversations((prior) => [conversation, ...prior.filter((item) => item.id !== conversation.id)]); choose(conversation.id); } catch (reason) { setError(readableError(reason)); } finally { setPending(false); } } async function send(inputOverride?: ChatSend) { if (!selected || !csrf) return; if (uncertain && uncertain.id !== selected) { setError("Resolve the unconfirmed send in the other conversation first."); return; } const id = selected; let input: ChatSend; try { input = inputOverride ?? { requestId: uncertain?.id === id ? uncertain.requestId : crypto.randomUUID(), text: draft.trim(), ...(excerpt.trim() ? { context: { source: source.trim(), observedAt: new Date(observedAt).toISOString(), excerpt: excerpt.trim() } } : {}) }; if (input.context && !input.context.source) { setError("Add the source of your selected excerpt."); return; } } catch { setError("Add a valid observation time for the selected context."); return; } if (!input.text) return; if (new TextEncoder().encode(JSON.stringify(input)).length > 32_768) { setError("Message and selected context exceed 32 KB. Shorten them before sending."); return; } setPending(true); setError(""); follow.current = true; try { // Persist only opaque admission metadata, never text/context or credentials. sessionStorage.setItem(pendingKey, JSON.stringify({ id, requestId: input.requestId })); setUncertain({ id, requestId: input.requestId, input }); await api(`conversations/${id}/send`, csrf, input); sessionStorage.removeItem(pendingKey); drafts.current.delete(id); if (selectedRef.current === id) { setDraft(""); setExcerpt(""); setContextOpen(false); } setUncertain(undefined); window.dispatchEvent(new Event("co-chat-refresh")); } catch (reason) { setUncertain({ id, requestId: input.requestId, input }); setError(readableError(reason)); } finally { setPending(false); } } async function stop() { if (!selected) return; setPending(true); setError(""); try { const conversation = await api<WebConversation>(`conversations/${selected}/stop`, csrf, {}); setSnapshot((prior) => prior ? { ...prior, conversation } : prior); } catch (reason) { setError(readableError(reason)); } finally { setPending(false); } } async function loadOlder() { if (!selected || !older) return; setPending(true); try { const page = await api<ChatSnapshot>(`conversations/${selected}?before=${encodeURIComponent(older)}`); olderHeight.current = viewport.current?.scrollHeight; olderRows.current = mergeRows(page.rows, olderRows.current); setRows((prior) => mergeRows(page.rows, prior)); setOlder(page.hasMore === false ? null : page.nextBefore); } catch (reason) { setError(readableError(reason)); } finally { setPending(false); } } return <div className="shell"> {sidebar && <button className="scrim" aria-label="Close conversations" onClick={() => setSidebar(false)}/>} <aside className={sidebar ? "sidebar open" : "sidebar"} inert={mobile && !sidebar} aria-label="Web conversations"> <div className="brand">The Stream</div> <button className="new" disabled={!csrf || pending} onClick={() => { void create(); }}>+ New chat</button> <nav>{conversations.map((item) => <button key={item.id} className={selected === item.id ? "conversation selected" : "conversation"} onClick={() => choose(item.id)} aria-current={selected === item.id ? "page" : undefined}><span>{item.title}</span><small>{new Date(item.updatedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}{isUnsettled(item.turn?.status) ? " · active / uncertain" : ""}</small></button>)}</nav> <div className="sidebar-bottom"><a href="/inspector/">Feed</a><a href="/oauth/logout">Sign out</a></div> </aside> <main inert={mobile && sidebar}> <header><button className="menu" aria-label="Show conversations" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}>☰</button><div><strong>Co</strong>{selected && <span className="subtitle">{snapshot?.conversation.title ?? "Conversation"}</span>}</div><div className="header-actions"><span className={connected ? "connection" : "connection offline"}>{connected ? "Connected" : "Connecting"}</span>{selected && <button disabled={pending || !csrf} onClick={() => { const title = window.prompt("Conversation title", snapshot?.conversation.title ?? ""); if (title?.trim()) void api<WebConversation>(`conversations/${selected}/rename`, csrf, { title }).then((conversation) => { setConversations((prior) => prior.map((item) => item.id === conversation.id ? conversation : item)); }, (reason: unknown) => setError(readableError(reason))); }}>Rename</button>}</div></header> <div className="transcript" ref={viewport} onScroll={() => { const view = viewport.current; if (view) follow.current = view.scrollHeight - view.clientHeight - view.scrollTop < 120; }}> <div className="reading"> {!selected && <section className="welcome"><button className="primary" disabled={!csrf || pending} onClick={() => { void create(); }}>{pending ? "Creating…" : "New conversation"}</button></section>} {selected && !snapshot && <p className="quiet" role="status">Loading this conversation…</p>} {older && <button className="older" disabled={pending} onClick={() => { void loadOlder(); }}>Load older messages</button>} {rows.map((row) => <Row key={row.kind !== "tool_call" && row.otid ? `${row.kind}:${row.otid}` : row.key} row={row}/>)} {selected && snapshot && !rows.length && <section className="empty" />} {snapshot?.historyPending && <p className="quiet" role="status">Saved history is catching up. Showing the last observed live output.</p>} {turn && <div className={`turn ${turn.status}`} role="status">{statusText[turn.status]}{turn.status === "unknown" && <details><summary>Send receipt</summary><code>{turn.requestId}</code></details>}</div>} </div> </div> <section className="compose-area"> {error && <div className="error" role="alert">{error}<div><button onClick={() => { void refresh().then(() => setError(""), (reason: unknown) => setError(readableError(reason))); }}>Reconnect</button> <a href="/oauth/login">Sign in</a></div></div>} {uncertain && uncertain.id !== selected && <div className="error">There is an unconfirmed send in another conversation. <button onClick={() => choose(uncertain.id)}>Return to that chat</button></div>} {uncertain && !pending && uncertain.id === selected && <div className="error">Send confirmation is unverified. Checking the same request is safe; a new request could duplicate it. {!uncertain.input && "Re-enter the exact original message and selected context to retry its same request id."}<button disabled={pending || (!uncertain.input && !draft.trim())} onClick={() => { void send(uncertain.input); }}>Check / retry same request</button></div>} {selected && <div className="composer"> {contextOpen && <div className="context-fields"><p>Paste one selected Stream excerpt. This is not a live feed; include its actual observation time.</p><input aria-label="Context source" placeholder="Source or provenance label" maxLength={200} value={source} onChange={(event) => setSource(event.target.value)}/><input aria-label="Context observation time" type="datetime-local" value={observedAt} onChange={(event) => setObservedAt(event.target.value)}/><textarea aria-label="Selected context" placeholder="Selected excerpt (up to 4,000 characters)" maxLength={4000} value={excerpt} onChange={(event) => setExcerpt(event.target.value)}/><button onClick={() => { setExcerpt(""); setContextOpen(false); }}>Remove context</button></div>} <textarea ref={composer} autoFocus aria-label="Message Co" placeholder="Message Co…" maxLength={16000} rows={2} value={draft} onChange={(event) => { setDraft(event.target.value); if (selected) drafts.current.set(selected, event.target.value); }} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing && event.keyCode !== 229) { event.preventDefault(); if (!busy && !pending && !uncertain && connected) void send(); } }}/> <div className="composer-actions"><button className="context-button" aria-expanded={contextOpen} onClick={() => setContextOpen(!contextOpen)}>{excerpt ? "Context attached" : "+ Selected context"}</button>{busy ? <button className="stop" disabled={pending || !csrf} onClick={() => { void stop(); }}>Stop</button> : <button className="primary" disabled={!draft.trim() || pending || !csrf || !connected || Boolean(uncertain)} onClick={() => { void send(); }}>Send</button>}</div> </div>} {loading && <p className="footnote" role="status">Connecting…</p>} </section> </main> </div>;}createRoot(document.getElementById("root")!).render(<App/>);