style: formatter pass on bridge + web views

This commit is contained in:
Raphael Westphal
2026-08-18 18:05:12 +02:00
parent 60460aebd7
commit 42fcaa1fe3
4 changed files with 443 additions and 347 deletions
+3 -1
View File
@@ -22,7 +22,9 @@ async function runRepoSetup() {
stdio: ["ignore", "inherit", "inherit"], stdio: ["ignore", "inherit", "inherit"],
}); });
const timer = setTimeout(() => { 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"); child.kill("SIGKILL");
resolve(124); resolve(124);
}, SETUP_TIMEOUT_MS); }, SETUP_TIMEOUT_MS);
+139 -85
View File
@@ -1,5 +1,11 @@
import { useEffect, useState } from "react"; 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 ChatView from "./ChatView";
import SessionsView from "./SessionsView"; import SessionsView from "./SessionsView";
import SpawnView from "./SpawnView"; import SpawnView from "./SpawnView";
@@ -8,91 +14,139 @@ import { classNames, useSessions, useToasts } from "./store";
import { clearSettings, getSettings } from "./settings"; import { clearSettings, getSettings } from "./settings";
export default function App() { export default function App() {
const [configured, setConfigured] = useState<boolean>(getSettings() !== null); const [configured, setConfigured] = useState<boolean>(getSettings() !== null);
const [sidebarOpen, setSidebarOpen] = useState<boolean>(false); const [sidebarOpen, setSidebarOpen] = useState<boolean>(false);
const { toasts, push } = useToasts(); const { toasts, push } = useToasts();
const store = useSessions(push); const store = useSessions(push);
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
setSidebarOpen(false); setSidebarOpen(false);
}, [location.pathname]); }, [location.pathname]);
if (!configured) return <SettingsGate onSaved={() => setConfigured(true)} />; if (!configured) return <SettingsGate onSaved={() => setConfigured(true)} />;
if (store === null) return <SettingsGate onSaved={() => setConfigured(true)} />; if (store === null)
return <SettingsGate onSaved={() => setConfigured(true)} />;
return ( return (
<div className="app-shell"> <div className="app-shell">
<nav className={classNames("sidebar", sidebarOpen && "open")} aria-label="Sessions"> <nav
<div className="sidebar-header"> className={classNames("sidebar", sidebarOpen && "open")}
<div className="brand"> aria-label="Sessions"
<span className="logo" aria-hidden="true">L</span> >
lvmh <div className="sidebar-header">
</div> <div className="brand">
<span <span className="logo" aria-hidden="true">
className={classNames("conn-dot", store.state)} L
title={`ws ${store.state}`} </span>
role="img" lvmh
aria-label={`connection ${store.state}`} </div>
/> <span
</div> className={classNames("conn-dot", store.state)}
<NavLink to="/new" className={({ isActive }) => classNames("new-chat-btn", isActive && "active")}> title={`ws ${store.state}`}
+ Spawn session role="img"
</NavLink> aria-label={`connection ${store.state}`}
<div className="sidebar-sessions"> />
{[...store.sessions] </div>
.filter((s) => s.online) <NavLink
.sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt)) to="/new"
.map((s) => ( className={({ isActive }) =>
<NavLink classNames("new-chat-btn", isActive && "active")
key={s.id} }
to={`/s/${s.id}`} >
className={({ isActive }) => classNames("session-link", isActive && "active")} + Spawn session
style={{ display: "flex", alignItems: "center", gap: 6 }} </NavLink>
> <div className="sidebar-sessions">
<span className={classNames("online-dot", s.online && "on")} style={{ display: "inline-block" }} /> {[...store.sessions]
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{s.name ?? s.repo ?? s.id}</span> .filter((s) => s.online)
</NavLink> .sort(
))} (a, b) =>
</div> (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt),
<div className="sidebar-footer"> )
<span className="spacer" /> .map((s) => (
<button <NavLink
type="button" key={s.id}
className="icon-btn danger" to={`/s/${s.id}`}
aria-label="Disconnect and clear settings" className={({ isActive }) =>
onClick={() => { classNames("session-link", isActive && "active")
clearSettings(); }
window.location.reload(); style={{ display: "flex", alignItems: "center", gap: 6 }}
}} >
> <span
disconnect className={classNames("online-dot", s.online && "on")}
</button> style={{ display: "inline-block" }}
</div> />
</nav> <span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{sidebarOpen && <div className="sidebar-backdrop" onClick={() => setSidebarOpen(false)} />} {s.name ?? s.repo ?? s.id}
<main className="main"> </span>
<button </NavLink>
type="button" ))}
className="icon-btn menu-btn" </div>
aria-label="Open menu" <div className="sidebar-footer">
style={{ display: "none", padding: "8px 12px", alignSelf: "flex-start", borderRadius: 0 }} <span className="spacer" />
onClick={() => setSidebarOpen(true)} <button
> type="button"
className="icon-btn danger"
</button> aria-label="Disconnect and clear settings"
<Routes> onClick={() => {
<Route path="/" element={<SessionsView sessions={store.sessions} onChanged={() => void store.refresh()} pushToast={push} />} /> clearSettings();
<Route path="/s/:id" element={<ChatView store={store} pushToast={push} />} /> window.location.reload();
<Route path="/new" element={<SpawnView store={store} pushToast={push} />} /> }}
<Route path="*" element={<Navigate to="/" replace />} /> >
</Routes> disconnect
</main> </button>
<div className="toasts" role="status"> </div>
{toasts.map((t) => ( </nav>
<div key={t.id} className="toast">{t.text}</div> {sidebarOpen && (
))} <div
</div> className="sidebar-backdrop"
</div> onClick={() => setSidebarOpen(false)}
); />
)}
<main className="main">
<button
type="button"
className="icon-btn menu-btn"
aria-label="Open menu"
style={{
display: "none",
padding: "8px 12px",
alignSelf: "flex-start",
borderRadius: 0,
}}
onClick={() => setSidebarOpen(true)}
>
</button>
<Routes>
<Route
path="/"
element={
<SessionsView
sessions={store.sessions}
onChanged={() => void store.refresh()}
pushToast={push}
/>
}
/>
<Route
path="/s/:id"
element={<ChatView store={store} pushToast={push} />}
/>
<Route
path="/new"
element={<SpawnView store={store} pushToast={push} />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<div className="toasts" role="status">
{toasts.map((t) => (
<div key={t.id} className="toast">
{t.text}
</div>
))}
</div>
</div>
);
} }
+219 -192
View File
@@ -3,7 +3,12 @@ import { Link, useParams } from "react-router-dom";
import type { EventFrame } from "./protocol"; import type { EventFrame } from "./protocol";
import { Route } from "./protocol"; import { Route } from "./protocol";
import { ApiError, errMessage, fetchJson } from "./api"; 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 type { SessionsStore } from "./store";
import { classNames } from "./store"; import { classNames } from "./store";
import ChatStream from "./ChatStream"; import ChatStream from "./ChatStream";
@@ -14,214 +19,236 @@ const TEXTAREA_MAX_H: number = 200;
const SEND_KEY: string = "Enter"; const SEND_KEY: string = "Enter";
interface Props { interface Props {
store: SessionsStore; store: SessionsStore;
pushToast: (text: string) => void; pushToast: (text: string) => void;
} }
export default function ChatView({ store, pushToast }: Props) { export default function ChatView({ store, pushToast }: Props) {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const sessionId: string = id ?? ""; const sessionId: string = id ?? "";
const [events, setEvents] = useState<EventFrame[]>([]); const [events, setEvents] = useState<EventFrame[]>([]);
const [loadError, setLoadError] = useState<string>(""); const [loadError, setLoadError] = useState<string>("");
const [draft, setDraft] = useState<string>(""); const [draft, setDraft] = useState<string>("");
const [sending, setSending] = useState<boolean>(false); const [sending, setSending] = useState<boolean>(false);
const [tasksOpen, setTasksOpen] = useState<boolean>(false); const [tasksOpen, setTasksOpen] = useState<boolean>(false);
const lastSeqRef = useRef<number>(0); const lastSeqRef = useRef<number>(0);
const loadedRef = useRef<boolean>(false); const loadedRef = useRef<boolean>(false);
const taRef = useRef<HTMLTextAreaElement | null>(null); const taRef = useRef<HTMLTextAreaElement | null>(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). // Close the pi behind an agent session (stops + removes the container).
const closePi = useCallback(async (): Promise<void> => { const closePi = useCallback(async (): Promise<void> => {
if (sessionId.length === 0) return; if (sessionId.length === 0) return;
try { try {
await fetchJson(Route.SessionContainer(sessionId), { method: "DELETE" }); await fetchJson(Route.SessionContainer(sessionId), { method: "DELETE" });
pushToast(`closed pi ${session?.name ?? sessionId}`); pushToast(`closed pi ${session?.name ?? sessionId}`);
await store.refresh(); await store.refresh();
} catch (err) { } catch (err) {
pushToast(`close failed: ${errMessage(err)}`); pushToast(`close failed: ${errMessage(err)}`);
} }
}, [sessionId, session, store, pushToast]); }, [sessionId, session, store, pushToast]);
const applyEvents = useCallback((incoming: EventFrame[]): void => { const applyEvents = useCallback((incoming: EventFrame[]): void => {
setEvents((prev) => { setEvents((prev) => {
const merged = mergeEvents(prev, incoming); const merged = mergeEvents(prev, incoming);
lastSeqRef.current = Math.max(lastSeqRef.current, lastPersistedSeq(merged)); lastSeqRef.current = Math.max(
return merged; lastSeqRef.current,
}); lastPersistedSeq(merged),
}, []); );
return merged;
});
}, []);
// history load on mount / session switch // history load on mount / session switch
useEffect(() => { useEffect(() => {
if (sessionId.length === 0) return; if (sessionId.length === 0) return;
loadedRef.current = false; loadedRef.current = false;
setLoadError(""); setLoadError("");
setEvents([]); setEvents([]);
lastSeqRef.current = 0; lastSeqRef.current = 0;
let alive = true; let alive = true;
void (async () => { void (async () => {
try { try {
const evts = await fetchJson<EventFrame[]>(`${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`); const evts = await fetchJson<EventFrame[]>(
if (!alive) return; `${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`,
applyEvents(evts); );
} catch (err) { if (!alive) return;
if (alive) setLoadError(errMessage(err)); applyEvents(evts);
} finally { } catch (err) {
if (alive) loadedRef.current = true; if (alive) setLoadError(errMessage(err));
} } finally {
})(); if (alive) loadedRef.current = true;
return () => { }
alive = false; })();
}; return () => {
}, [sessionId, applyEvents]); alive = false;
};
}, [sessionId, applyEvents]);
// ws subscription // ws subscription
useEffect(() => { useEffect(() => {
if (sessionId.length === 0 || store.state !== "open") return; if (sessionId.length === 0 || store.state !== "open") return;
return store.subscribe(sessionId, applyEvents); return store.subscribe(sessionId, applyEvents);
}, [sessionId, store.state, store, applyEvents]); }, [sessionId, store.state, store, applyEvents]);
// missed events after reconnect (persisted only) // missed events after reconnect (persisted only)
useEffect(() => { useEffect(() => {
if (store.state !== "open" || !loadedRef.current) return; if (store.state !== "open" || !loadedRef.current) return;
const after: number = lastSeqRef.current; const after: number = lastSeqRef.current;
void fetchJson<EventFrame[]>(`${Route.SessionEvents(sessionId)}?after=${after}&limit=${HISTORY_LIMIT}`) void fetchJson<EventFrame[]>(
.then(applyEvents) `${Route.SessionEvents(sessionId)}?after=${after}&limit=${HISTORY_LIMIT}`,
.catch(() => undefined); )
}, [store.state, sessionId, applyEvents]); .then(applyEvents)
.catch(() => undefined);
}, [store.state, sessionId, applyEvents]);
const chat = useMemo(() => deriveChat(events), [events]); const chat = useMemo(() => deriveChat(events), [events]);
const tasks = useMemo(() => deriveTasks(events), [events]); const tasks = useMemo(() => deriveTasks(events), [events]);
const autosize = useCallback((): void => { const autosize = useCallback((): void => {
const el = taRef.current; const el = taRef.current;
if (el === null) return; if (el === null) return;
el.style.height = "auto"; el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_H)}px`; el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_H)}px`;
}, []); }, []);
useEffect(autosize, [draft, autosize]); useEffect(autosize, [draft, autosize]);
const send = async (): Promise<void> => { const send = async (): Promise<void> => {
const text = draft.trim(); const text = draft.trim();
if (text.length === 0 || sending) return; if (text.length === 0 || sending) return;
setDraft(""); setDraft("");
setSending(true); setSending(true);
try { try {
await fetchJson(Route.SessionPrompt(sessionId), { await fetchJson(Route.SessionPrompt(sessionId), {
method: "POST", method: "POST",
body: JSON.stringify({ message: text }), body: JSON.stringify({ message: text }),
}); });
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 409) pushToast("session offline"); if (err instanceof ApiError && err.status === 409)
else pushToast(errMessage(err)); pushToast("session offline");
} finally { else pushToast(errMessage(err));
setSending(false); } finally {
} setSending(false);
}; }
};
const abort = async (): Promise<void> => { const abort = async (): Promise<void> => {
try { try {
await fetchJson(Route.SessionAbort(sessionId), { method: "POST" }); await fetchJson(Route.SessionAbort(sessionId), { method: "POST" });
} catch (err) { } catch (err) {
pushToast(errMessage(err)); pushToast(errMessage(err));
} }
}; };
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => { const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === SEND_KEY && !e.shiftKey) { if (e.key === SEND_KEY && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
void send(); void send();
} }
}; };
if (sessionId.length === 0) { if (sessionId.length === 0) {
return ( return (
<div className="page"> <div className="page">
<p className="empty">No session selected.</p> <p className="empty">No session selected.</p>
</div> </div>
); );
} }
return ( return (
<div className="chat-layout"> <div className="chat-layout">
<div className="chat-col"> <div className="chat-col">
<div className="chat-header"> <div className="chat-header">
<div className="title">{session?.name ?? session?.repo ?? sessionId}</div> <div className="title">
<div className="sub">{session?.model}</div> {session?.name ?? session?.repo ?? sessionId}
<span </div>
className={classNames("conn-dot", session?.online === true ? "open" : "closed")} <div className="sub">{session?.model}</div>
title={session?.online === true ? "online" : "offline"} <span
/> className={classNames(
<div className="spacer" /> "conn-dot",
{session?.agent === true && ( session?.online === true ? "open" : "closed",
<button )}
type="button" title={session?.online === true ? "online" : "offline"}
className="icon-btn danger" />
aria-label={`Close pi ${session.name ?? session.id}`} <div className="spacer" />
onClick={() => void closePi()} {session?.agent === true && (
> <button
close type="button"
</button> className="icon-btn danger"
)} aria-label={`Close pi ${session.name ?? session.id}`}
<button onClick={() => void closePi()}
type="button" >
className="task-toggle icon-btn" close
aria-expanded={tasksOpen} </button>
aria-label="Toggle task panel" )}
onClick={() => setTasksOpen(!tasksOpen)} <button
> type="button"
tasks className="task-toggle icon-btn"
</button> aria-expanded={tasksOpen}
</div> aria-label="Toggle task panel"
{tasksOpen && ( onClick={() => setTasksOpen(!tasksOpen)}
<div className="mobile-tasks"> >
<TaskPanel tasks={tasks} /> tasks
</div> </button>
)} </div>
{loadError.length > 0 ? ( {tasksOpen && (
<div className="page"> <div className="mobile-tasks">
<p className="empty">Failed to load history: {loadError}</p> <TaskPanel tasks={tasks} />
<Link to="/">Back to sessions</Link> </div>
</div> )}
) : ( {loadError.length > 0 ? (
<ChatStream messages={chat.messages} tools={chat.tools} busy={chat.busy} /> <div className="page">
)} <p className="empty">Failed to load history: {loadError}</p>
<div className="composer"> <Link to="/">Back to sessions</Link>
<div className="composer-inner"> </div>
<textarea ) : (
ref={taRef} <ChatStream
rows={1} messages={chat.messages}
value={draft} tools={chat.tools}
placeholder="Message…" busy={chat.busy}
aria-label="Message" />
onChange={(e) => setDraft(e.target.value)} )}
onKeyDown={onKeyDown} <div className="composer">
/> <div className="composer-inner">
{chat.busy ? ( <textarea
<button type="button" className="abort-btn" aria-label="Abort current run" onClick={() => void abort()}> ref={taRef}
stop rows={1}
</button> value={draft}
) : ( placeholder="Message…"
<button aria-label="Message"
type="button" onChange={(e) => setDraft(e.target.value)}
className="send-btn" onKeyDown={onKeyDown}
aria-label="Send message" />
disabled={draft.trim().length === 0 || sending} {chat.busy ? (
onClick={() => void send()} <button
> type="button"
className="abort-btn"
</button> aria-label="Abort current run"
)} onClick={() => void abort()}
</div> >
</div> stop
</div> </button>
<aside className="task-col" aria-label="Task panel"> ) : (
<TaskPanel tasks={tasks} /> <button
</aside> type="button"
</div> className="send-btn"
); aria-label="Send message"
disabled={draft.trim().length === 0 || sending}
onClick={() => void send()}
>
</button>
)}
</div>
</div>
</div>
<aside className="task-col" aria-label="Task panel">
<TaskPanel tasks={tasks} />
</aside>
</div>
);
} }
+82 -69
View File
@@ -6,79 +6,92 @@ import { fetchJson } from "./api";
import { classNames, relativeTime } from "./store"; import { classNames, relativeTime } from "./store";
interface Props { interface Props {
sessions: SessionListItem[]; sessions: SessionListItem[];
onChanged: () => void; onChanged: () => void;
pushToast: (text: string) => void; pushToast: (text: string) => void;
} }
export default function SessionsView({ sessions, onChanged, pushToast }: Props) { export default function SessionsView({
const navigate = useNavigate(); sessions,
onChanged,
pushToast,
}: Props) {
const navigate = useNavigate();
const open = useCallback((id: string): void => { const open = useCallback(
navigate(`/s/${id}`); (id: string): void => {
}, [navigate]); navigate(`/s/${id}`);
},
[navigate],
);
const stop = async (e: MouseEvent, s: SessionListItem): Promise<void> => { const stop = async (e: MouseEvent, s: SessionListItem): Promise<void> => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
try { try {
await fetchJson(Route.SessionContainer(s.id), { method: "DELETE" }); await fetchJson(Route.SessionContainer(s.id), { method: "DELETE" });
pushToast(`stopped ${s.name ?? s.id}`); pushToast(`stopped ${s.name ?? s.id}`);
onChanged(); onChanged();
} catch (err) { } catch (err) {
pushToast(`stop failed: ${err instanceof Error ? err.message : String(err)}`); pushToast(
} `stop failed: ${err instanceof Error ? err.message : String(err)}`,
}; );
}
};
// Only active (online) pi sessions are shown; offline transcripts stay // Only active (online) pi sessions are shown; offline transcripts stay
// reachable by direct URL and are dropped from the default list. // reachable by direct URL and are dropped from the default list.
const sorted = [...sessions] const sorted = [...sessions]
.filter((s) => s.online) .filter((s) => s.online)
.sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt)); .sort(
(a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt),
);
return ( return (
<div className="page"> <div className="page">
<h1>Active sessions</h1> <h1>Active sessions</h1>
{sorted.length === 0 && <p className="empty">No active sessions. Spawn one from the sidebar.</p>} {sorted.length === 0 && (
{sorted.map((s) => ( <p className="empty">No active sessions. Spawn one from the sidebar.</p>
<div )}
key={s.id} {sorted.map((s) => (
className="session-card" <div
role="button" key={s.id}
tabIndex={0} className="session-card"
aria-label={`Open session ${s.name ?? s.id}`} role="button"
onClick={() => open(s.id)} tabIndex={0}
onKeyDown={(e) => { aria-label={`Open session ${s.name ?? s.id}`}
if (e.key === "Enter" || e.key === " ") open(s.id); onClick={() => open(s.id)}
}} onKeyDown={(e) => {
> if (e.key === "Enter" || e.key === " ") open(s.id);
<span }}
className={classNames("online-dot", s.online && "on")} >
title={s.online ? "online" : "offline"} <span
/> className={classNames("online-dot", s.online && "on")}
<span className="card-body"> title={s.online ? "online" : "offline"}
<span className="card-title" style={{ display: "block" }}> />
{s.name ?? s.repo ?? s.cwd} <span className="card-body">
</span> <span className="card-title" style={{ display: "block" }}>
<span className="card-meta" style={{ display: "flex" }}> {s.name ?? s.repo ?? s.cwd}
{s.repo !== null && <span>{s.repo}</span>} </span>
<span>{s.model}</span> <span className="card-meta" style={{ display: "flex" }}>
<span>{relativeTime(s.lastEventAt)}</span> {s.repo !== null && <span>{s.repo}</span>}
</span> <span>{s.model}</span>
</span> <span>{relativeTime(s.lastEventAt)}</span>
{s.agent && <span className="badge">agent</span>} </span>
{s.agent && ( </span>
<button {s.agent && <span className="badge">agent</span>}
type="button" {s.agent && (
className="icon-btn danger" <button
aria-label={`Stop container for ${s.name ?? s.id}`} type="button"
onClick={(e) => void stop(e, s)} className="icon-btn danger"
> aria-label={`Stop container for ${s.name ?? s.id}`}
onClick={(e) => void stop(e, s)}
</button> >
)}
</div> </button>
))} )}
</div> </div>
); ))}
</div>
);
} }