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);
+69 -15
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";
@@ -19,14 +25,20 @@ export default function App() {
}, [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
className={classNames("sidebar", sidebarOpen && "open")}
aria-label="Sessions"
>
<div className="sidebar-header"> <div className="sidebar-header">
<div className="brand"> <div className="brand">
<span className="logo" aria-hidden="true">L</span> <span className="logo" aria-hidden="true">
L
</span>
lvmh lvmh
</div> </div>
<span <span
@@ -36,22 +48,37 @@ export default function App() {
aria-label={`connection ${store.state}`} aria-label={`connection ${store.state}`}
/> />
</div> </div>
<NavLink to="/new" className={({ isActive }) => classNames("new-chat-btn", isActive && "active")}> <NavLink
to="/new"
className={({ isActive }) =>
classNames("new-chat-btn", isActive && "active")
}
>
+ Spawn session + Spawn session
</NavLink> </NavLink>
<div className="sidebar-sessions"> <div className="sidebar-sessions">
{[...store.sessions] {[...store.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),
)
.map((s) => ( .map((s) => (
<NavLink <NavLink
key={s.id} key={s.id}
to={`/s/${s.id}`} to={`/s/${s.id}`}
className={({ isActive }) => classNames("session-link", isActive && "active")} className={({ isActive }) =>
classNames("session-link", isActive && "active")
}
style={{ display: "flex", alignItems: "center", gap: 6 }} style={{ display: "flex", alignItems: "center", gap: 6 }}
> >
<span className={classNames("online-dot", s.online && "on")} style={{ display: "inline-block" }} /> <span
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{s.name ?? s.repo ?? s.id}</span> className={classNames("online-dot", s.online && "on")}
style={{ display: "inline-block" }}
/>
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{s.name ?? s.repo ?? s.id}
</span>
</NavLink> </NavLink>
))} ))}
</div> </div>
@@ -70,27 +97,54 @@ export default function App() {
</button> </button>
</div> </div>
</nav> </nav>
{sidebarOpen && <div className="sidebar-backdrop" onClick={() => setSidebarOpen(false)} />} {sidebarOpen && (
<div
className="sidebar-backdrop"
onClick={() => setSidebarOpen(false)}
/>
)}
<main className="main"> <main className="main">
<button <button
type="button" type="button"
className="icon-btn menu-btn" className="icon-btn menu-btn"
aria-label="Open menu" aria-label="Open menu"
style={{ display: "none", padding: "8px 12px", alignSelf: "flex-start", borderRadius: 0 }} style={{
display: "none",
padding: "8px 12px",
alignSelf: "flex-start",
borderRadius: 0,
}}
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
> >
</button> </button>
<Routes> <Routes>
<Route path="/" element={<SessionsView sessions={store.sessions} onChanged={() => void store.refresh()} pushToast={push} />} /> <Route
<Route path="/s/:id" element={<ChatView store={store} pushToast={push} />} /> path="/"
<Route path="/new" element={<SpawnView store={store} pushToast={push} />} /> 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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</main> </main>
<div className="toasts" role="status"> <div className="toasts" role="status">
{toasts.map((t) => ( {toasts.map((t) => (
<div key={t.id} className="toast">{t.text}</div> <div key={t.id} className="toast">
{t.text}
</div>
))} ))}
</div> </div>
</div> </div>
+36 -9
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";
@@ -49,7 +54,10 @@ export default function ChatView({ store, pushToast }: Props) {
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(
lastSeqRef.current,
lastPersistedSeq(merged),
);
return merged; return merged;
}); });
}, []); }, []);
@@ -64,7 +72,9 @@ export default function ChatView({ store, pushToast }: Props) {
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[]>(
`${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`,
);
if (!alive) return; if (!alive) return;
applyEvents(evts); applyEvents(evts);
} catch (err) { } catch (err) {
@@ -88,7 +98,9 @@ export default function ChatView({ store, pushToast }: Props) {
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[]>(
`${Route.SessionEvents(sessionId)}?after=${after}&limit=${HISTORY_LIMIT}`,
)
.then(applyEvents) .then(applyEvents)
.catch(() => undefined); .catch(() => undefined);
}, [store.state, sessionId, applyEvents]); }, [store.state, sessionId, applyEvents]);
@@ -116,7 +128,8 @@ export default function ChatView({ store, pushToast }: Props) {
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)
pushToast("session offline");
else pushToast(errMessage(err)); else pushToast(errMessage(err));
} finally { } finally {
setSending(false); setSending(false);
@@ -150,10 +163,15 @@ export default function ChatView({ store, pushToast }: Props) {
<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">
{session?.name ?? session?.repo ?? sessionId}
</div>
<div className="sub">{session?.model}</div> <div className="sub">{session?.model}</div>
<span <span
className={classNames("conn-dot", session?.online === true ? "open" : "closed")} className={classNames(
"conn-dot",
session?.online === true ? "open" : "closed",
)}
title={session?.online === true ? "online" : "offline"} title={session?.online === true ? "online" : "offline"}
/> />
<div className="spacer" /> <div className="spacer" />
@@ -188,7 +206,11 @@ export default function ChatView({ store, pushToast }: Props) {
<Link to="/">Back to sessions</Link> <Link to="/">Back to sessions</Link>
</div> </div>
) : ( ) : (
<ChatStream messages={chat.messages} tools={chat.tools} busy={chat.busy} /> <ChatStream
messages={chat.messages}
tools={chat.tools}
busy={chat.busy}
/>
)} )}
<div className="composer"> <div className="composer">
<div className="composer-inner"> <div className="composer-inner">
@@ -202,7 +224,12 @@ export default function ChatView({ store, pushToast }: Props) {
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{chat.busy ? ( {chat.busy ? (
<button type="button" className="abort-btn" aria-label="Abort current run" onClick={() => void abort()}> <button
type="button"
className="abort-btn"
aria-label="Abort current run"
onClick={() => void abort()}
>
stop stop
</button> </button>
) : ( ) : (
+19 -6
View File
@@ -11,12 +11,19 @@ interface Props {
pushToast: (text: string) => void; pushToast: (text: string) => void;
} }
export default function SessionsView({ sessions, onChanged, pushToast }: Props) { export default function SessionsView({
sessions,
onChanged,
pushToast,
}: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const open = useCallback((id: string): void => { const open = useCallback(
(id: string): void => {
navigate(`/s/${id}`); navigate(`/s/${id}`);
}, [navigate]); },
[navigate],
);
const stop = async (e: MouseEvent, s: SessionListItem): Promise<void> => { const stop = async (e: MouseEvent, s: SessionListItem): Promise<void> => {
e.preventDefault(); e.preventDefault();
@@ -26,7 +33,9 @@ export default function SessionsView({ sessions, onChanged, pushToast }: Props)
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)}`,
);
} }
}; };
@@ -34,12 +43,16 @@ export default function SessionsView({ sessions, onChanged, pushToast }: Props)
// 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 && (
<p className="empty">No active sessions. Spawn one from the sidebar.</p>
)}
{sorted.map((s) => ( {sorted.map((s) => (
<div <div
key={s.id} key={s.id}