diff --git a/docker/bridge/index.mjs b/docker/bridge/index.mjs index ac6d482..26bedca 100644 --- a/docker/bridge/index.mjs +++ b/docker/bridge/index.mjs @@ -22,7 +22,9 @@ async function runRepoSetup() { stdio: ["ignore", "inherit", "inherit"], }); const timer = setTimeout(() => { - console.error(`[lvmh-bridge] setup timed out after ${SETUP_TIMEOUT_MS}ms, killing`); + console.error( + `[lvmh-bridge] setup timed out after ${SETUP_TIMEOUT_MS}ms, killing`, + ); child.kill("SIGKILL"); resolve(124); }, SETUP_TIMEOUT_MS); diff --git a/web/src/App.tsx b/web/src/App.tsx index eeaf843..e8cc9c8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,11 @@ import { useEffect, useState } from "react"; -import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { + NavLink, + Navigate, + Route, + Routes, + useLocation, +} from "react-router-dom"; import ChatView from "./ChatView"; import SessionsView from "./SessionsView"; import SpawnView from "./SpawnView"; @@ -8,91 +14,139 @@ import { classNames, useSessions, useToasts } from "./store"; import { clearSettings, getSettings } from "./settings"; export default function App() { - const [configured, setConfigured] = useState(getSettings() !== null); - const [sidebarOpen, setSidebarOpen] = useState(false); - const { toasts, push } = useToasts(); - const store = useSessions(push); - const location = useLocation(); + const [configured, setConfigured] = useState(getSettings() !== null); + const [sidebarOpen, setSidebarOpen] = useState(false); + const { toasts, push } = useToasts(); + const store = useSessions(push); + const location = useLocation(); - useEffect(() => { - setSidebarOpen(false); - }, [location.pathname]); + useEffect(() => { + setSidebarOpen(false); + }, [location.pathname]); - if (!configured) return setConfigured(true)} />; - if (store === null) return setConfigured(true)} />; + if (!configured) return setConfigured(true)} />; + if (store === null) + return setConfigured(true)} />; - return ( -
- - {sidebarOpen &&
setSidebarOpen(false)} />} -
- - - void store.refresh()} pushToast={push} />} /> - } /> - } /> - } /> - -
-
- {toasts.map((t) => ( -
{t.text}
- ))} -
-
- ); + return ( +
+ + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} +
+ + + void store.refresh()} + pushToast={push} + /> + } + /> + } + /> + } + /> + } /> + +
+
+ {toasts.map((t) => ( +
+ {t.text} +
+ ))} +
+
+ ); } diff --git a/web/src/ChatView.tsx b/web/src/ChatView.tsx index 0a19f8b..2fdedcc 100644 --- a/web/src/ChatView.tsx +++ b/web/src/ChatView.tsx @@ -3,7 +3,12 @@ import { Link, useParams } from "react-router-dom"; import type { EventFrame } from "./protocol"; import { Route } from "./protocol"; import { ApiError, errMessage, fetchJson } from "./api"; -import { deriveChat, deriveTasks, lastPersistedSeq, mergeEvents } from "./derive"; +import { + deriveChat, + deriveTasks, + lastPersistedSeq, + mergeEvents, +} from "./derive"; import type { SessionsStore } from "./store"; import { classNames } from "./store"; import ChatStream from "./ChatStream"; @@ -14,214 +19,236 @@ const TEXTAREA_MAX_H: number = 200; const SEND_KEY: string = "Enter"; interface Props { - store: SessionsStore; - pushToast: (text: string) => void; + store: SessionsStore; + pushToast: (text: string) => void; } export default function ChatView({ store, pushToast }: Props) { - const { id } = useParams<{ id: string }>(); - const sessionId: string = id ?? ""; + const { id } = useParams<{ id: string }>(); + const sessionId: string = id ?? ""; - const [events, setEvents] = useState([]); - const [loadError, setLoadError] = useState(""); - const [draft, setDraft] = useState(""); - const [sending, setSending] = useState(false); - const [tasksOpen, setTasksOpen] = useState(false); + const [events, setEvents] = useState([]); + const [loadError, setLoadError] = useState(""); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [tasksOpen, setTasksOpen] = useState(false); - const lastSeqRef = useRef(0); - const loadedRef = useRef(false); - const taRef = useRef(null); + const lastSeqRef = useRef(0); + const loadedRef = useRef(false); + const taRef = useRef(null); - const session = store.sessions.find((s) => s.id === sessionId); + const session = store.sessions.find((s) => s.id === sessionId); - // Close the pi behind an agent session (stops + removes the container). - const closePi = useCallback(async (): Promise => { - if (sessionId.length === 0) return; - try { - await fetchJson(Route.SessionContainer(sessionId), { method: "DELETE" }); - pushToast(`closed pi ${session?.name ?? sessionId}`); - await store.refresh(); - } catch (err) { - pushToast(`close failed: ${errMessage(err)}`); - } - }, [sessionId, session, store, pushToast]); + // Close the pi behind an agent session (stops + removes the container). + const closePi = useCallback(async (): Promise => { + if (sessionId.length === 0) return; + try { + await fetchJson(Route.SessionContainer(sessionId), { method: "DELETE" }); + pushToast(`closed pi ${session?.name ?? sessionId}`); + await store.refresh(); + } catch (err) { + pushToast(`close failed: ${errMessage(err)}`); + } + }, [sessionId, session, store, pushToast]); - const applyEvents = useCallback((incoming: EventFrame[]): void => { - setEvents((prev) => { - const merged = mergeEvents(prev, incoming); - lastSeqRef.current = Math.max(lastSeqRef.current, lastPersistedSeq(merged)); - return merged; - }); - }, []); + const applyEvents = useCallback((incoming: EventFrame[]): void => { + setEvents((prev) => { + const merged = mergeEvents(prev, incoming); + lastSeqRef.current = Math.max( + lastSeqRef.current, + lastPersistedSeq(merged), + ); + return merged; + }); + }, []); - // history load on mount / session switch - useEffect(() => { - if (sessionId.length === 0) return; - loadedRef.current = false; - setLoadError(""); - setEvents([]); - lastSeqRef.current = 0; - let alive = true; - void (async () => { - try { - const evts = await fetchJson(`${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`); - if (!alive) return; - applyEvents(evts); - } catch (err) { - if (alive) setLoadError(errMessage(err)); - } finally { - if (alive) loadedRef.current = true; - } - })(); - return () => { - alive = false; - }; - }, [sessionId, applyEvents]); + // history load on mount / session switch + useEffect(() => { + if (sessionId.length === 0) return; + loadedRef.current = false; + setLoadError(""); + setEvents([]); + lastSeqRef.current = 0; + let alive = true; + void (async () => { + try { + const evts = await fetchJson( + `${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`, + ); + if (!alive) return; + applyEvents(evts); + } catch (err) { + if (alive) setLoadError(errMessage(err)); + } finally { + if (alive) loadedRef.current = true; + } + })(); + return () => { + alive = false; + }; + }, [sessionId, applyEvents]); - // ws subscription - useEffect(() => { - if (sessionId.length === 0 || store.state !== "open") return; - return store.subscribe(sessionId, applyEvents); - }, [sessionId, store.state, store, applyEvents]); + // ws subscription + useEffect(() => { + if (sessionId.length === 0 || store.state !== "open") return; + return store.subscribe(sessionId, applyEvents); + }, [sessionId, store.state, store, applyEvents]); - // missed events after reconnect (persisted only) - useEffect(() => { - if (store.state !== "open" || !loadedRef.current) return; - const after: number = lastSeqRef.current; - void fetchJson(`${Route.SessionEvents(sessionId)}?after=${after}&limit=${HISTORY_LIMIT}`) - .then(applyEvents) - .catch(() => undefined); - }, [store.state, sessionId, applyEvents]); + // missed events after reconnect (persisted only) + useEffect(() => { + if (store.state !== "open" || !loadedRef.current) return; + const after: number = lastSeqRef.current; + void fetchJson( + `${Route.SessionEvents(sessionId)}?after=${after}&limit=${HISTORY_LIMIT}`, + ) + .then(applyEvents) + .catch(() => undefined); + }, [store.state, sessionId, applyEvents]); - const chat = useMemo(() => deriveChat(events), [events]); - const tasks = useMemo(() => deriveTasks(events), [events]); + const chat = useMemo(() => deriveChat(events), [events]); + const tasks = useMemo(() => deriveTasks(events), [events]); - const autosize = useCallback((): void => { - const el = taRef.current; - if (el === null) return; - el.style.height = "auto"; - el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_H)}px`; - }, []); + const autosize = useCallback((): void => { + const el = taRef.current; + if (el === null) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_H)}px`; + }, []); - useEffect(autosize, [draft, autosize]); + useEffect(autosize, [draft, autosize]); - const send = async (): Promise => { - const text = draft.trim(); - if (text.length === 0 || sending) return; - setDraft(""); - setSending(true); - try { - await fetchJson(Route.SessionPrompt(sessionId), { - method: "POST", - body: JSON.stringify({ message: text }), - }); - } catch (err) { - if (err instanceof ApiError && err.status === 409) pushToast("session offline"); - else pushToast(errMessage(err)); - } finally { - setSending(false); - } - }; + const send = async (): Promise => { + const text = draft.trim(); + if (text.length === 0 || sending) return; + setDraft(""); + setSending(true); + try { + await fetchJson(Route.SessionPrompt(sessionId), { + method: "POST", + body: JSON.stringify({ message: text }), + }); + } catch (err) { + if (err instanceof ApiError && err.status === 409) + pushToast("session offline"); + else pushToast(errMessage(err)); + } finally { + setSending(false); + } + }; - const abort = async (): Promise => { - try { - await fetchJson(Route.SessionAbort(sessionId), { method: "POST" }); - } catch (err) { - pushToast(errMessage(err)); - } - }; + const abort = async (): Promise => { + try { + await fetchJson(Route.SessionAbort(sessionId), { method: "POST" }); + } catch (err) { + pushToast(errMessage(err)); + } + }; - const onKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === SEND_KEY && !e.shiftKey) { - e.preventDefault(); - void send(); - } - }; + const onKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === SEND_KEY && !e.shiftKey) { + e.preventDefault(); + void send(); + } + }; - if (sessionId.length === 0) { - return ( -
-

No session selected.

-
- ); - } + if (sessionId.length === 0) { + return ( +
+

No session selected.

+
+ ); + } - return ( -
-
-
-
{session?.name ?? session?.repo ?? sessionId}
-
{session?.model}
- -
- {session?.agent === true && ( - - )} - -
- {tasksOpen && ( -
- -
- )} - {loadError.length > 0 ? ( -
-

Failed to load history: {loadError}

- Back to sessions -
- ) : ( - - )} -
-
-