web: React+Vite SPA — sessions, streaming chat, task panel, spawn flow (build clean)

This commit is contained in:
Raphael Westphal
2026-08-18 13:37:14 +02:00
parent 345025b070
commit 7d5f866527
22 changed files with 4253 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
import ChatView from "./ChatView";
import SessionsView from "./SessionsView";
import SpawnView from "./SpawnView";
import SettingsGate from "./SettingsGate";
import { classNames, useSessions, useToasts } from "./store";
import { clearSettings, getSettings } from "./settings";
export default function App() {
const [configured, setConfigured] = useState<boolean>(getSettings() !== null);
const [sidebarOpen, setSidebarOpen] = useState<boolean>(false);
const { toasts, push } = useToasts();
const store = useSessions(push);
const location = useLocation();
useEffect(() => {
setSidebarOpen(false);
}, [location.pathname]);
if (!configured) return <SettingsGate onSaved={() => setConfigured(true)} />;
if (store === null) return <SettingsGate onSaved={() => setConfigured(true)} />;
return (
<div className="app-shell">
<nav className={classNames("sidebar", sidebarOpen && "open")} aria-label="Sessions">
<div className="sidebar-header">
<div className="brand">
<span className="logo" aria-hidden="true">L</span>
lvmh
</div>
<span
className={classNames("conn-dot", store.state)}
title={`ws ${store.state}`}
role="img"
aria-label={`connection ${store.state}`}
/>
</div>
<NavLink to="/new" className={({ isActive }) => classNames("new-chat-btn", isActive && "active")}>
+ Spawn session
</NavLink>
<div className="sidebar-sessions">
{[...store.sessions]
.sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt))
.map((s) => (
<NavLink
key={s.id}
to={`/s/${s.id}`}
className={({ isActive }) => classNames("session-link", isActive && "active")}
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<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>
))}
</div>
<div className="sidebar-footer">
<span className="spacer" />
<button
type="button"
className="icon-btn danger"
aria-label="Disconnect and clear settings"
onClick={() => {
clearSettings();
window.location.reload();
}}
>
disconnect
</button>
</div>
</nav>
{sidebarOpen && <div className="sidebar-backdrop" 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>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useRef } from "react";
import type { ChatMessage, ToolState } from "./derive";
const PREVIEW_LEN: number = 120;
function oneLine(text: string): string {
const flat = text.replace(/\s+/g, " ").trim();
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}` : flat;
}
function ToolCard({ tool }: { tool: ToolState }) {
const status: string = tool.running
? "running…"
: tool.isError
? "error"
: "done";
const statusClass: string = tool.isError ? "tool-status-err" : "tool-status-ok";
return (
<details className="tool-card">
<summary>
<span className="tool-name">🛠 {tool.name}</span>
<span className={tool.running ? "" : statusClass}>{tool.running ? "working…" : status}</span>
</summary>
<div className="tool-body">
<div>
<strong>args</strong>
<pre style={{ margin: "4px 0 10px", whiteSpace: "pre-wrap" }}>{tool.args}</pre>
</div>
<div>
<strong>result</strong>
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>{tool.preview}</pre>
</div>
</div>
</details>
);
}
function Thinking({ text }: { text: string }) {
return (
<details className="thinking">
<summary>thinking</summary>
<div className="thinking-body">{text}</div>
</details>
);
}
export function Bubble({ msg, tools }: { msg: ChatMessage; tools: Map<string, ToolState> }) {
const rowClass = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "system";
const msgTools: ToolState[] = [];
if (msg.role === "assistant") {
for (const c of msg.toolCalls) {
const t = tools.get(c.id);
if (t !== undefined) msgTools.push(t);
}
}
return (
<div className={`bubble-row ${rowClass}`}>
<div className="bubble">
{msg.thinking !== null && msg.thinking.length > 0 && <Thinking text={msg.thinking} />}
{msgTools.map((t) => (
<ToolCard key={t.id} tool={t} />
))}
{msg.role === "toolResult" ? (
<details className="tool-card">
<summary>
<span className="tool-name">result</span>
<span>{oneLine(msg.text)}</span>
</summary>
<div className="tool-body">{msg.text}</div>
</details>
) : (
msg.text.length > 0 && <div>{msg.text}</div>
)}
{msg.streaming && <span className="stream-caret"></span>}
</div>
</div>
);
}
export function TypingIndicator() {
return (
<div className="bubble-row assistant" aria-live="polite">
<div className="typing">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
</div>
);
}
interface Props {
messages: ChatMessage[];
tools: Map<string, ToolState>;
busy: boolean;
}
export default function ChatStream({ messages, tools, busy }: Props) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const pinnedRef = useRef<boolean>(true);
const onScroll = (): void => {
const el = scrollRef.current;
if (el === null) return;
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
};
useEffect(() => {
const el = scrollRef.current;
if (el !== null && pinnedRef.current) el.scrollTop = el.scrollHeight;
}, [messages, busy]);
useEffect(() => {
pinnedRef.current = true;
const el = scrollRef.current;
if (el !== null) el.scrollTop = el.scrollHeight;
}, []);
const last: ChatMessage | undefined = messages[messages.length - 1];
const streamingOpen: boolean = last !== undefined && last.streaming;
const showTyping: boolean = busy && !streamingOpen;
return (
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="chat-inner">
{messages.map((m) => (
<Bubble key={m.key} msg={m} tools={tools} />
))}
{showTyping && <TypingIndicator />}
</div>
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 type { SessionsStore } from "./store";
import { classNames } from "./store";
import ChatStream from "./ChatStream";
import TaskPanel from "./TaskPanel";
const HISTORY_LIMIT: number = 1000;
const TEXTAREA_MAX_H: number = 200;
const SEND_KEY: string = "Enter";
interface Props {
store: SessionsStore;
pushToast: (text: string) => void;
}
export default function ChatView({ store, pushToast }: Props) {
const { id } = useParams<{ id: string }>();
const sessionId: string = id ?? "";
const [events, setEvents] = useState<EventFrame[]>([]);
const [loadError, setLoadError] = useState<string>("");
const [draft, setDraft] = useState<string>("");
const [sending, setSending] = useState<boolean>(false);
const [tasksOpen, setTasksOpen] = useState<boolean>(false);
const lastSeqRef = useRef<number>(0);
const loadedRef = useRef<boolean>(false);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const session = store.sessions.find((s) => s.id === sessionId);
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<EventFrame[]>(`${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]);
// missed events after reconnect (persisted only)
useEffect(() => {
if (store.state !== "open" || !loadedRef.current) return;
const after: number = lastSeqRef.current;
void fetchJson<EventFrame[]>(`${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 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]);
const send = async (): Promise<void> => {
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<void> => {
try {
await fetchJson(Route.SessionAbort(sessionId), { method: "POST" });
} catch (err) {
pushToast(errMessage(err));
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === SEND_KEY && !e.shiftKey) {
e.preventDefault();
void send();
}
};
if (sessionId.length === 0) {
return (
<div className="page">
<p className="empty">No session selected.</p>
</div>
);
}
return (
<div className="chat-layout">
<div className="chat-col">
<div className="chat-header">
<div className="title">{session?.name ?? session?.repo ?? sessionId}</div>
<div className="sub">{session?.model}</div>
<span
className={classNames("conn-dot", session?.online === true ? "open" : "closed")}
title={session?.online === true ? "online" : "offline"}
/>
<div className="spacer" />
<button
type="button"
className="task-toggle icon-btn"
aria-expanded={tasksOpen}
aria-label="Toggle task panel"
onClick={() => setTasksOpen(!tasksOpen)}
>
tasks
</button>
</div>
{tasksOpen && (
<div className="mobile-tasks">
<TaskPanel tasks={tasks} />
</div>
)}
{loadError.length > 0 ? (
<div className="page">
<p className="empty">Failed to load history: {loadError}</p>
<Link to="/">Back to sessions</Link>
</div>
) : (
<ChatStream messages={chat.messages} tools={chat.tools} busy={chat.busy} />
)}
<div className="composer">
<div className="composer-inner">
<textarea
ref={taRef}
rows={1}
value={draft}
placeholder="Message…"
aria-label="Message"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
/>
{chat.busy ? (
<button type="button" className="abort-btn" aria-label="Abort current run" onClick={() => void abort()}>
stop
</button>
) : (
<button
type="button"
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>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { type MouseEvent, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import type { SessionListItem } from "./protocol";
import { Route } from "./protocol";
import { fetchJson } from "./api";
import { classNames, relativeTime } from "./store";
interface Props {
sessions: SessionListItem[];
onChanged: () => void;
pushToast: (text: string) => void;
}
export default function SessionsView({ sessions, onChanged, pushToast }: Props) {
const navigate = useNavigate();
const open = useCallback((id: string): void => {
navigate(`/s/${id}`);
}, [navigate]);
const stop = async (e: MouseEvent, s: SessionListItem): Promise<void> => {
e.preventDefault();
e.stopPropagation();
try {
await fetchJson(Route.SessionContainer(s.id), { method: "DELETE" });
pushToast(`stopped ${s.name ?? s.id}`);
onChanged();
} catch (err) {
pushToast(`stop failed: ${err instanceof Error ? err.message : String(err)}`);
}
};
const sorted = [...sessions].sort((a, b) => (b.lastEventAt ?? b.startedAt) - (a.lastEventAt ?? a.startedAt));
return (
<div className="page">
<h1>Sessions</h1>
{sorted.length === 0 && <p className="empty">No sessions yet. Spawn one from the sidebar.</p>}
{sorted.map((s) => (
<div
key={s.id}
className="session-card"
role="button"
tabIndex={0}
aria-label={`Open session ${s.name ?? 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="card-body">
<span className="card-title" style={{ display: "block" }}>
{s.name ?? s.repo ?? s.cwd}
</span>
<span className="card-meta" style={{ display: "flex" }}>
{s.repo !== null && <span>{s.repo}</span>}
<span>{s.model}</span>
<span>{relativeTime(s.lastEventAt)}</span>
</span>
</span>
{s.agent && <span className="badge">agent</span>}
{s.agent && (
<button
type="button"
className="icon-btn danger"
aria-label={`Stop container for ${s.name ?? s.id}`}
onClick={(e) => void stop(e, s)}
>
</button>
)}
</div>
))}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { type FormEvent, useState } from "react";
import { saveSettings } from "./settings";
interface Props {
onSaved: () => void;
}
export default function SettingsGate({ onSaved }: Props) {
const [serverUrl, setServerUrl] = useState<string>(defaultServerUrl());
const [token, setToken] = useState<string>("");
const [error, setError] = useState<string>("");
const [busy, setBusy] = useState<boolean>(false);
const submit = async (ev: FormEvent<HTMLFormElement>): Promise<void> => {
ev.preventDefault();
if (token.trim().length === 0) {
setError("token required");
return;
}
setBusy(true);
setError("");
const url = normalizeUrl(serverUrl);
try {
await validate(url, token.trim());
saveSettings({ serverUrl: url, token: token.trim() });
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div className="gate">
<form className="gate-card" onSubmit={(e) => void submit(e)}>
<h1>
<span className="logo" aria-hidden="true">L</span>
lvmh
</h1>
<p className="sub">Connect to your lvmh daemon.</p>
<label htmlFor="gate-server">Server URL</label>
<input
id="gate-server"
type="text"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="http://localhost:8686"
autoComplete="url"
/>
<label htmlFor="gate-token">Bearer token</label>
<input
id="gate-token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="LVMH_TOKEN"
autoComplete="current-password"
/>
<p className="gate-error" role="alert">{error}</p>
<button className="gate-submit" type="submit" disabled={busy}>
{busy ? "Connecting…" : "Connect"}
</button>
</form>
</div>
);
}
function defaultServerUrl(): string {
return `${window.location.protocol}//${window.location.host}`;
}
function normalizeUrl(raw: string): string {
const trimmed = raw.trim().replace(/\/+$/, "");
return trimmed.length > 0 ? trimmed : defaultServerUrl();
}
async function validate(serverUrl: string, token: string): Promise<void> {
const res = await fetch(`${serverUrl}/api/sessions`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`connection failed (${res.status})`);
}
+260
View File
@@ -0,0 +1,260 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { GitlabStatus, Repo, SpawnJob, SpawnResponse } from "./protocol";
import { Route } from "./protocol";
import { errMessage, fetchJson } from "./api";
import type { SessionsStore } from "./store";
const POLL_MS: number = 1500;
const POLL_MAX_TICKS: number = 400; // ~10min then give up polling (spawn may still finish)
interface Props {
store: SessionsStore;
pushToast: (text: string) => void;
}
export default function SpawnView({ store, pushToast }: Props) {
const navigate = useNavigate();
const [status, setStatus] = useState<GitlabStatus | null>(null);
const [pat, setPat] = useState<string>("");
const [repos, setRepos] = useState<Repo[] | null>(null);
const [query, setQuery] = useState<string>("");
const [selected, setSelected] = useState<Repo | null>(null);
const [branch, setBranch] = useState<string>("");
const [busy, setBusy] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [spawning, setSpawning] = useState<SpawnResponse | null>(null);
const timerRef = useRef<number | null>(null);
const tickRef = useRef<number>(0);
useEffect(() => {
return () => {
if (timerRef.current !== null) window.clearInterval(timerRef.current);
};
}, []);
const loadStatus = async (): Promise<GitlabStatus> => {
const s = await fetchJson<GitlabStatus>(Route.GitlabStatus);
setStatus(s);
return s;
};
const loadRepos = async (): Promise<void> => {
const list = await fetchJson<Repo[]>(Route.GitlabRepos);
setRepos(list);
};
useEffect(() => {
void (async () => {
try {
const s = await loadStatus();
if (s.connected) await loadRepos();
} catch (err) {
setError(errMessage(err));
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const connect = async (): Promise<void> => {
if (pat.trim().length === 0) {
setError("token required");
return;
}
setBusy(true);
setError("");
try {
await fetchJson(Route.GitlabConnect, { method: "POST", body: JSON.stringify({ token: pat.trim() }) });
setPat("");
await loadStatus();
await loadRepos();
} catch (err) {
setError(errMessage(err));
} finally {
setBusy(false);
}
};
const pick = (repo: Repo): void => {
setSelected(repo);
setBranch(repo.defaultBranch);
};
const filtered: Repo[] = (repos ?? []).filter((r) => r.path.toLowerCase().includes(query.toLowerCase()));
const spawn = async (): Promise<void> => {
if (selected === null) {
setError("pick a repo first");
return;
}
setBusy(true);
setError("");
try {
const body = { repo: selected.path, ...(branch.trim().length > 0 ? { branch: branch.trim() } : {}) };
const res = await fetchJson<SpawnResponse>(Route.Spawn, { method: "POST", body: JSON.stringify(body) });
setSpawning(res);
startPolling(res.sessionId);
} catch (err) {
setError(errMessage(err));
} finally {
setBusy(false);
}
};
const startPolling = (sessionId: string): void => {
tickRef.current = 0;
if (timerRef.current !== null) window.clearInterval(timerRef.current);
timerRef.current = window.setInterval(() => {
tickRef.current += 1;
if (tickRef.current > POLL_MAX_TICKS) {
if (timerRef.current !== null) window.clearInterval(timerRef.current);
timerRef.current = null;
return;
}
void (async () => {
try {
await fetchJson<SpawnJob[]>(Route.SpawnStatus).catch(() => undefined);
await store.refresh();
const s = store.sessions.find((x) => x.id === sessionId);
if (s !== undefined && s.online) {
if (timerRef.current !== null) window.clearInterval(timerRef.current);
timerRef.current = null;
navigate(`/s/${sessionId}`);
}
} catch (err) {
pushToast(errMessage(err));
}
})();
}, POLL_MS);
};
const jobLine = (): string => {
if (spawning === null) return "";
const job: SpawnJob | undefined = store.spawnJobs.find((j) => j.sessionId === spawning.sessionId);
if (job !== undefined) return `${job.repo}: ${job.state}`;
return "waiting for session to come online…";
};
if (status === null) {
return (
<div className="page">
<h1>Spawn</h1>
<p className="empty">{error.length > 0 ? `gitlab status failed: ${error}` : "checking gitlab…"}</p>
</div>
);
}
if (!status.connected) {
return (
<div className="page">
<h1>Spawn</h1>
<form
className="spawn-card"
onSubmit={(e) => {
e.preventDefault();
void connect();
}}
>
<h2>Connect GitLab</h2>
<p className="repo-meta" style={{ margin: "0 0 10px" }}>
Paste a personal access token (api scope). Stored daemon-side only.
</p>
<input
type="password"
value={pat}
placeholder="glpat-…"
aria-label="GitLab personal access token"
onChange={(e) => setPat(e.target.value)}
/>
{error.length > 0 && <p className="error-text">{error}</p>}
<div className="actions">
<button className="btn-primary" type="submit" disabled={busy}>
{busy ? "Connecting…" : "Connect"}
</button>
</div>
</form>
</div>
);
}
return (
<div className="page">
<h1>Spawn</h1>
{spawning !== null && (
<div className="spawn-card">
<h2>Spawning</h2>
<p className="spawn-job-line">{jobLine()}</p>
<p className="repo-meta">container {spawning.containerId.slice(0, 12)}</p>
{error.length > 0 && <p className="error-text">{error}</p>}
</div>
)}
{spawning === null && (
<>
<div className="spawn-card">
<h2>Repository</h2>
<input
type="text"
value={query}
placeholder="filter repos…"
aria-label="Filter repositories"
onChange={(e) => setQuery(e.target.value)}
/>
{repos === null && <p className="repo-meta">loading repos</p>}
{repos !== null && filtered.length === 0 && <p className="repo-meta">no matching repos</p>}
<div className="repo-list" style={{ marginTop: 10 }}>
{filtered.map((r) => (
<div
key={r.path}
className={`repo-item ${selected?.path === r.path ? "selected" : ""}`}
role="button"
tabIndex={0}
aria-pressed={selected?.path === r.path}
onClick={() => pick(r)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") pick(r);
}}
>
<div style={{ minWidth: 0 }}>
<div className="repo-path">{r.path}</div>
<div className="repo-meta">
default {r.defaultBranch} · {r.lastActivityAt.slice(0, 10)}
</div>
</div>
</div>
))}
</div>
</div>
<div className="spawn-card">
<h2>Branch</h2>
<div className="row">
<input
type="text"
value={branch}
aria-label="Branch"
placeholder={selected?.defaultBranch ?? "main"}
disabled={selected === null}
onChange={(e) => setBranch(e.target.value)}
/>
<button
type="button"
className="btn-primary"
disabled={selected === null || busy}
aria-label="Spawn container"
onClick={() => void spawn()}
>
{busy ? "Spawning…" : "Spawn"}
</button>
</div>
{selected === null && <p className="repo-meta" style={{ marginTop: 8 }}>select a repo above</p>}
{error.length > 0 && <p className="error-text">{error}</p>}
</div>
</>
)}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { SubagentRun, TodoItem, TodoStatus } from "./derive";
import type { TaskDerivation } from "./derive";
const STATUS_ICON: Record<TodoStatus, string> = {
pending: "○",
"in-progress": "◺",
completed: "●",
};
function TodoRow({ item }: { item: TodoItem }) {
return (
<div className={`todo-item ${item.status === "completed" ? "done" : ""} ${item.deleted ? "deleted" : ""}`}>
<span className={`todo-icon ${item.status}`} aria-hidden="true">
{STATUS_ICON[item.status]}
</span>
<span className="todo-text" style={item.deleted ? { textDecoration: "line-through", color: "var(--text-faint)" } : undefined}>
{item.content}
</span>
</div>
);
}
function SubagentRow({ run }: { run: SubagentRun }) {
return (
<div className="subagent-item">
{run.running ? (
<span className="spinner" role="status" aria-label="running" />
) : (
<span className="done-icon" aria-hidden="true">
{run.isError ? "✕" : "✓"}
</span>
)}
<span>{run.name}</span>
<span style={{ color: "var(--text-faint)", fontSize: 11 }}>
{run.running ? "running" : run.isError ? "failed" : "done"}
</span>
</div>
);
}
export default function TaskPanel({ tasks }: { tasks: TaskDerivation }) {
const hasTodos: boolean = tasks.todos.length > 0;
const hasSubagents: boolean = tasks.subagents.length > 0;
const hasWorking: boolean = tasks.workingTools.length > 0;
const empty: boolean = !hasTodos && !hasSubagents && !hasWorking;
return (
<div className="task-panel">
{empty && <p className="empty">No tasks yet.</p>}
{hasTodos && (
<section className="task-section">
<h2>Tasks</h2>
{tasks.todos.map((t) => (
<TodoRow key={t.content} item={t} />
))}
</section>
)}
{hasSubagents && (
<section className="task-section">
<h2>Subagents</h2>
{tasks.subagents.map((s) => (
<SubagentRow key={s.key} run={s} />
))}
</section>
)}
{hasWorking && (
<section className="task-section">
<h2>Working</h2>
{tasks.workingTools.map((w) => (
<div key={w.id} className="working-line">
<span className="spinner" role="status" aria-label="working" />
{w.name}
</div>
))}
</section>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { getSettings } from "./settings";
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const settings = getSettings();
if (settings === null) throw new ApiError("not configured", 401);
const headers: Record<string, string> = { Authorization: `Bearer ${settings.token}` };
if (init?.body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(`${settings.serverUrl}${path}`, { ...init, headers });
if (!res.ok) {
let message: string = `${res.status} ${res.statusText}`;
try {
const body: unknown = await res.json();
if (body !== null && typeof body === "object" && typeof (body as { error?: unknown }).error === "string") {
message = (body as { error: string }).error;
}
} catch {
// keep status-derived message
}
throw new ApiError(message, res.status);
}
return (await res.json()) as T;
}
export function errMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
+293
View File
@@ -0,0 +1,293 @@
import type { EventFrame } from "./protocol";
// ---------- event list merge ----------
export function mergeEvents(existing: EventFrame[], incoming: EventFrame[]): EventFrame[] {
const bySeq = new Map<number, EventFrame>();
for (const e of existing) bySeq.set(e.seq, e);
for (const e of incoming) bySeq.set(e.seq, e);
return Array.from(bySeq.values()).sort((a, b) => a.seq - b.seq);
}
/** Highest persisted seq — deltas (`message_update`) are not persisted, so they
* must not advance the refetch cursor. */
export function lastPersistedSeq(events: EventFrame[]): number {
let max = 0;
for (const e of events) if (e.type !== "message_update" && e.seq > max) max = e.seq;
return max;
}
// ---------- chat view derivation ----------
export interface ToolState {
id: string;
name: string;
args: string;
running: boolean;
isError: boolean;
preview: string;
}
export interface ChatMessage {
key: string;
role: "user" | "assistant" | "system" | "toolResult";
text: string;
thinking: string | null;
toolCalls: { id: string; name: string; argsJson: string }[];
toolCallId: string | null;
streaming: boolean;
}
export interface ChatDerivation {
messages: ChatMessage[];
tools: Map<string, ToolState>;
busy: boolean;
}
export function deriveChat(events: EventFrame[]): ChatDerivation {
const messages: ChatMessage[] = [];
const tools = new Map<string, ToolState>();
let stream: { id: string; text: string } | null = null;
let busy = false;
for (const e of events) {
switch (e.type) {
case "tool_execution_start": {
if (e.toolCallId !== undefined) {
tools.set(e.toolCallId, {
id: e.toolCallId,
name: e.toolName ?? "tool",
args: e.args ?? "",
running: true,
isError: false,
preview: "",
});
}
break;
}
case "tool_execution_end": {
const t = e.toolCallId !== undefined ? tools.get(e.toolCallId) : undefined;
if (t !== undefined) {
t.running = false;
t.isError = e.isError ?? false;
t.preview = e.resultPreview ?? "";
}
break;
}
case "agent_start":
busy = true;
break;
case "agent_settled":
busy = false;
break;
case "message_start":
if (e.message?.role === "assistant") stream = { id: e.message.id, text: "" };
break;
case "message_update":
if (stream !== null) stream.text += e.delta ?? "";
break;
case "message_end": {
const m = e.message;
if (m !== undefined) {
if (stream !== null && stream.id === m.id) stream = null;
messages.push({
key: `msg-${e.seq}`,
role: m.role,
text: m.text,
thinking: m.thinking,
toolCalls: m.toolCalls ?? [],
toolCallId: m.toolCallId,
streaming: false,
});
}
break;
}
default:
break;
}
}
if (stream !== null) {
messages.push({
key: `stream-${stream.id}`,
role: "assistant",
text: stream.text,
thinking: null,
toolCalls: [],
toolCallId: null,
streaming: true,
});
}
return { messages, tools, busy: busy || stream !== null };
}
// ---------- todo / subagent derivation (client-side, per PROTOCOL.md) ----------
export type TodoStatus = "pending" | "in-progress" | "completed";
export interface TodoItem {
content: string;
status: TodoStatus;
deleted: boolean;
}
export interface SubagentRun {
key: string;
name: string;
running: boolean;
isError: boolean;
}
export interface TaskDerivation {
todos: TodoItem[];
subagents: SubagentRun[];
workingTools: ToolState[];
}
const TODO_TOOL = "todo";
const SUBAGENT_TOOL = "subagent";
function parseJson(text: string | undefined | null): unknown {
if (typeof text !== "string" || text.length === 0) return undefined;
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
function normalizeStatus(raw: unknown): TodoStatus {
if (typeof raw !== "string") return "pending";
switch (raw.toLowerCase()) {
case "in_progress":
case "in-progress":
case "inprogress":
case "in progress":
case "doing":
case "started":
return "in-progress";
case "completed":
case "complete":
case "done":
return "completed";
default:
return "pending";
}
}
function extractSnapshot(raw: unknown): { content: string; status: TodoStatus }[] | null {
let arr: unknown = raw;
if (Array.isArray(raw) === false && raw !== null && typeof raw === "object") {
const o = raw as Record<string, unknown>;
const nested = o.todos ?? o.items ?? o.tasks ?? o.list;
if (Array.isArray(nested)) arr = nested;
}
if (Array.isArray(arr) === false) return null;
const items: { content: string; status: TodoStatus }[] = [];
for (const entry of arr) {
if (typeof entry === "string") {
items.push({ content: entry, status: "pending" });
continue;
}
if (entry !== null && typeof entry === "object") {
const o = entry as Record<string, unknown>;
const content = o.content ?? o.title ?? o.text ?? o.subject ?? o.summary;
if (typeof content === "string" && content.length > 0) {
items.push({ content, status: normalizeStatus(o.status) });
}
}
}
return items.length > 0 ? items : null;
}
export function deriveTasks(events: EventFrame[]): TaskDerivation {
const toolNames = new Map<string, string>(); // toolCallId -> toolName
for (const e of events) {
if (e.type === "tool_execution_start" && e.toolCallId !== undefined) {
toolNames.set(e.toolCallId, e.toolName ?? "");
}
}
// todo snapshots, seq-ordered: args from execution start, result from execution end + toolResult message
const snapshots: { seq: number; items: { content: string; status: TodoStatus }[] }[] = [];
const subagents: SubagentRun[] = [];
const runningTools: ToolState[] = [];
for (const e of events) {
if (e.type === "tool_execution_start" && e.toolCallId !== undefined) {
const name = e.toolName ?? "";
if (name === TODO_TOOL) {
const snap = extractSnapshot(parseJson(e.args));
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
} else if (name === SUBAGENT_TOOL) {
const parsed = parseJson(e.args);
const o = parsed !== null && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
const nameField = o.agentName ?? o.agent ?? o.name ?? o.agentType ?? o.role;
subagents.push({
key: e.toolCallId,
name: typeof nameField === "string" && nameField.length > 0 ? nameField : "subagent",
running: true,
isError: false,
});
} else {
runningTools.push({
id: e.toolCallId,
name,
args: e.args ?? "",
running: true,
isError: false,
preview: "",
});
}
} else if (e.type === "tool_execution_end" && e.toolCallId !== undefined) {
const name = toolNames.get(e.toolCallId) ?? "";
if (name === SUBAGENT_TOOL) {
const run = subagents.find((s) => s.key === e.toolCallId);
if (run !== undefined) {
run.running = false;
run.isError = e.isError ?? false;
}
} else if (name !== TODO_TOOL) {
const t = runningTools.find((w) => w.id === e.toolCallId);
if (t !== undefined) {
t.running = false;
t.isError = e.isError ?? false;
t.preview = e.resultPreview ?? "";
}
}
if (name === TODO_TOOL) {
const snap = extractSnapshot(parseJson(e.resultPreview));
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
}
} else if (e.type === "message_end" && e.message?.role === "toolResult" && e.message.toolCallId !== null) {
const name = toolNames.get(e.message.toolCallId) ?? "";
if (name === TODO_TOOL) {
const snap = extractSnapshot(parseJson(e.message.text));
if (snap !== null) snapshots.push({ seq: e.seq, items: snap });
}
}
}
// todos: latest snapshot wins; earlier items missing from it are deleted
const todos: TodoItem[] = [];
if (snapshots.length > 0) {
snapshots.sort((a, b) => a.seq - b.seq);
const latest = snapshots[snapshots.length - 1]?.items ?? [];
const seen = new Map<string, TodoStatus>();
for (const snap of snapshots) {
for (const item of snap.items) if (!seen.has(item.content)) seen.set(item.content, item.status);
}
for (const item of latest) {
todos.push({ content: item.content, status: item.status, deleted: false });
seen.delete(item.content);
}
for (const [content] of seen) todos.push({ content, status: "pending", deleted: true });
}
return {
todos,
subagents,
workingTools: runningTools.filter((t) => t.running),
};
}
+468
View File
@@ -0,0 +1,468 @@
:root {
color-scheme: dark;
--bg: #212121;
--bg-raised: #2f2f2f;
--bg-hover: #383838;
--border: #3d3d3d;
--text: #ececec;
--text-dim: #a6a6a6;
--text-faint: #7c7c7c;
--accent: #10a37f;
--accent-dim: #0d8a6c;
--danger: #ef4444;
--user-bubble: #2f2f2f;
--assistant-bubble: #212121;
--radius: 12px;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--text);
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
button:disabled { cursor: not-allowed; opacity: 0.5; }
input, textarea, select {
font: inherit;
color: var(--text);
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
}
input:focus, textarea:focus, select:focus { outline: 1px solid var(--accent); }
a { color: var(--text-dim); text-decoration: none; }
a:hover { color: var(--text); }
/* ---------- app shell ---------- */
.app-shell { display: flex; height: 100%; overflow: hidden; }
.sidebar {
width: 264px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-raised);
border-right: 1px solid var(--border);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
font-weight: 600;
}
.sidebar-header .brand { display: flex; align-items: center; gap: 8px; }
.sidebar-header .brand .logo {
width: 24px; height: 24px;
border-radius: 6px;
background: var(--accent);
color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700;
}
.conn-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.conn-dot.open { background: #22c55e; }
.conn-dot.connecting { background: #eab308; }
.conn-dot.closed { background: var(--danger); }
.new-chat-btn {
display: block;
width: calc(100% - 20px);
margin: 0 10px 8px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 10px;
text-align: center;
color: var(--text);
}
.new-chat-btn:hover { background: var(--bg-hover); }
.sidebar-sessions { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
.session-link {
display: block;
padding: 8px 10px;
border-radius: 8px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-link:hover { background: var(--bg-hover); color: var(--text); }
.session-link.active { background: var(--bg-hover); color: var(--text); }
.sidebar-footer {
padding: 10px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
align-items: center;
}
.sidebar-footer .spacer { flex: 1; }
.icon-btn {
padding: 6px;
border-radius: 8px;
color: var(--text-dim);
font-size: 13px;
}
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
.icon-btn.danger:hover { color: var(--danger); }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
/* ---------- sessions view ---------- */
.page { flex: 1; overflow-y: auto; padding: 24px; max-width: 860px; margin: 0 auto; width: 100%; }
.page h1 { font-size: 20px; margin: 0 0 16px; }
.page .empty { color: var(--text-faint); padding: 40px 0; text-align: center; }
.session-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 10px;
cursor: pointer;
background: var(--bg);
}
.session-card:hover { background: var(--bg-raised); }
.session-card .online-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
background: #525252;
}
.session-card .online-dot.on { background: #22c55e; }
.session-card .card-body { flex: 1; min-width: 0; }
.session-card .card-title {
font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.session-card .card-meta {
font-size: 12px; color: var(--text-faint);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
display: flex; gap: 8px; flex-wrap: wrap;
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-dim);
flex-shrink: 0;
}
/* ---------- chat view ---------- */
.chat-layout { flex: 1; display: flex; min-height: 0; }
.chat-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.chat-header {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.chat-header .title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-header .sub { font-size: 12px; color: var(--text-faint); }
.chat-header .spacer, .task-col .spacer { flex: 1; }
.chat-scroll { flex: 1; overflow-y: auto; padding: 20px 16px; scroll-behavior: smooth; }
.chat-inner { max-width: 760px; margin: 0 auto; }
.bubble-row { display: flex; margin-bottom: 14px; }
.bubble-row.user { justify-content: flex-end; }
.bubble {
max-width: 86%;
padding: 10px 14px;
border-radius: var(--radius);
white-space: pre-wrap;
word-break: break-word;
}
.bubble-row.user .bubble { background: var(--user-bubble); }
.bubble-row.assistant .bubble { background: transparent; padding: 0; max-width: 100%; }
.bubble-row.system .bubble { color: var(--text-faint); font-size: 13px; font-style: italic; }
.bubble .tool-card {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-raised);
margin: 8px 0;
overflow: hidden;
}
.bubble .tool-card summary {
cursor: pointer;
padding: 8px 12px;
font-size: 13px;
color: var(--text-dim);
display: flex; align-items: center; gap: 8px;
list-style: none;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bubble .tool-card summary::-webkit-details-marker { display: none; }
.bubble .tool-card .tool-body {
border-top: 1px solid var(--border);
padding: 10px 12px;
font-size: 13px;
color: var(--text-dim);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
.tool-status-ok { color: var(--accent); }
.tool-status-err { color: var(--danger); }
details.thinking { margin-bottom: 8px; }
details.thinking summary {
cursor: pointer;
font-size: 12px;
color: var(--text-faint);
list-style: none;
}
details.thinking summary::-webkit-details-marker { display: none; }
details.thinking summary::before { content: "◆ "; }
details.thinking .thinking-body {
font-size: 13px;
color: var(--text-faint);
border-left: 2px solid var(--border);
padding-left: 10px;
margin-top: 6px;
white-space: pre-wrap;
max-height: 260px;
overflow-y: auto;
}
.typing {
display: inline-flex; gap: 4px; align-items: center;
padding: 8px 0;
}
.typing .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--text-faint);
animation: typing-bounce 1.2s infinite;
}
.typing .dot:nth-child(2) { animation-delay: 0.15s; }
.typing .dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
30% { transform: translateY(-4px); opacity: 1; }
}
.composer {
border-top: 1px solid var(--border);
padding: 12px 16px;
background: var(--bg);
}
.composer-inner { max-width: 760px; margin: 0 auto; display: flex; gap: 8px; align-items: flex-end; }
.composer textarea {
flex: 1;
resize: none;
max-height: 200px;
min-height: 42px;
border-radius: var(--radius);
}
.composer .send-btn {
background: var(--accent);
color: #fff;
border-radius: 10px;
padding: 10px 14px;
}
.composer .send-btn:hover { background: var(--accent-dim); }
.composer .send-btn:disabled { background: var(--bg-hover); color: var(--text-faint); }
.composer .abort-btn {
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px;
color: var(--danger);
}
/* ---------- task panel ---------- */
.task-col {
width: 300px;
flex-shrink: 0;
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 14px;
background: var(--bg);
}
.task-col h2 { font-size: 13px; margin: 0 0 8px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
.task-col .empty { color: var(--text-faint); font-size: 13px; }
.task-section { margin-bottom: 18px; }
.todo-item { display: flex; gap: 8px; align-items: baseline; font-size: 13px; padding: 3px 0; }
.todo-item .todo-icon { flex-shrink: 0; width: 14px; text-align: center; }
.todo-item .todo-icon.pending { color: var(--text-faint); }
.todo-item .todo-icon.in-progress { color: #eab308; }
.todo-item .todo-icon.completed { color: var(--accent); }
.todo-item.done .todo-text { text-decoration: line-through; color: var(--text-faint); }
.todo-item .todo-text { color: var(--text); }
.subagent-item { font-size: 13px; padding: 4px 0; display: flex; gap: 8px; align-items: center; }
.subagent-item .spinner {
width: 12px; height: 12px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.subagent-item .done-icon { color: var(--accent); flex-shrink: 0; }
.working-line { font-size: 12px; color: var(--text-faint); padding: 3px 0; display: flex; gap: 6px; align-items: center; }
.task-toggle { display: none; }
/* ---------- settings gate ---------- */
.gate {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.gate-card {
width: 100%;
max-width: 380px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--bg-raised);
padding: 28px;
}
.gate-card h1 { font-size: 18px; margin: 0 0 4px; display: flex; align-items: center; gap: 8px; }
.gate-card .sub { color: var(--text-faint); font-size: 13px; margin-bottom: 20px; }
.gate-card label { display: block; font-size: 13px; color: var(--text-dim); margin: 12px 0 4px; }
.gate-card input { width: 100%; }
.gate-card .gate-error { color: var(--danger); font-size: 13px; margin-top: 12px; min-height: 18px; }
.gate-card .gate-submit {
width: 100%;
margin-top: 20px;
padding: 10px;
background: var(--accent);
color: #fff;
border-radius: 10px;
}
/* ---------- spawn view ---------- */
.spawn-card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 16px;
background: var(--bg-raised);
}
.spawn-card h2 { font-size: 15px; margin: 0 0 10px; }
.spawn-card input[type="text"], .spawn-card input[type="password"], .spawn-card select { width: 100%; }
.spawn-card .row { display: flex; gap: 10px; }
.spawn-card .row > * { flex: 1; }
.spawn-card .actions { display: flex; gap: 8px; margin-top: 14px; }
.btn-primary {
background: var(--accent);
color: #fff;
border-radius: 10px;
padding: 8px 16px;
}
.btn-primary:hover { background: var(--accent-dim); }
.btn-primary:disabled { background: var(--bg-hover); color: var(--text-faint); }
.btn-secondary { border: 1px solid var(--border); border-radius: 10px; padding: 8px 16px; }
.btn-secondary:hover { background: var(--bg-hover); }
.error-text { color: var(--danger); font-size: 13px; margin-top: 10px; }
.repo-list { max-height: 420px; overflow-y: auto; }
.repo-item {
display: flex; align-items: center; gap: 10px;
padding: 10px;
border-radius: 8px;
cursor: pointer;
}
.repo-item:hover { background: var(--bg-hover); }
.repo-item.selected { background: var(--bg-hover); outline: 1px solid var(--accent); }
.repo-item .repo-path { font-weight: 600; }
.repo-item .repo-meta { font-size: 12px; color: var(--text-faint); }
.spawn-job-line { font-size: 13px; color: var(--text-dim); padding: 4px 0; }
/* ---------- toasts ---------- */
.toasts {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 100;
}
.toast {
background: var(--bg-raised);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 16px;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
font-size: 14px;
}
/* ---------- mobile ≤400px ---------- */
@media (max-width: 860px) {
.task-col { display: none; }
.task-toggle {
display: inline-flex;
align-items: center;
}
.mobile-tasks {
border-bottom: 1px solid var(--border);
padding: 12px 16px;
background: var(--bg-raised);
}
}
@media (max-width: 700px) {
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 50;
transform: translateX(-100%);
transition: transform 0.2s ease;
width: 280px;
}
.sidebar.open { transform: translateX(0); }
.sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 40;
}
.menu-btn { display: inline-flex !important; }
.page { padding: 14px; }
.chat-scroll { padding: 14px 10px; }
.bubble { max-width: 94%; }
}
@media (max-width: 400px) {
body { font-size: 14px; }
.sidebar { width: 100%; }
.gate-card { padding: 20px; }
.session-card { padding: 10px; gap: 8px; }
.composer textarea { font-size: 16px; } /* prevent iOS zoom */
.composer .send-btn { padding: 10px 12px; }
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { HashRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
const rootEl = document.getElementById("root");
if (rootEl === null) throw new Error("#root missing in index.html");
createRoot(rootEl).render(
<StrictMode>
<HashRouter>
<App />
</HashRouter>
</StrictMode>
);
+158
View File
@@ -0,0 +1,158 @@
// Typed mirror of PROTOCOL.md v1.
export const PROTOCOL_VERSION: 1 = 1;
/** Event types emitted by the plugin, carried in the envelope `type` field. */
export const EventType = {
Hello: "hello",
MessageStart: "message_start",
MessageUpdate: "message_update",
MessageEnd: "message_end",
ToolExecutionStart: "tool_execution_start",
ToolExecutionUpdate: "tool_execution_update",
ToolExecutionEnd: "tool_execution_end",
AgentStart: "agent_start",
AgentEnd: "agent_end",
AgentSettled: "agent_settled",
SessionInfo: "session_info",
Bye: "bye",
} as const;
export type EventType = (typeof EventType)[keyof typeof EventType];
/** REST routes (base `/api`, bearer auth). */
export const Route = {
Sessions: "/api/sessions",
SessionEvents: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/events`,
SessionPrompt: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/prompt`,
SessionAbort: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/abort`,
SessionContainer: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/container`,
Spawn: "/api/spawn",
SpawnStatus: "/api/spawn/status",
GitlabStatus: "/api/gitlab/status",
GitlabConnect: "/api/gitlab/connect",
GitlabRepos: "/api/gitlab/repos",
} as const;
/** Session snapshot, carried by `hello`/`session_info` and REST session list. */
export interface SessionInfo {
id: string;
name: string | null;
cwd: string;
model: string;
provider: string;
/** true if spawned-by-daemon container session */
agent: boolean;
repo: string | null;
startedAt: number;
}
/** Session list row (REST `GET /api/sessions` + WS `session_list`). */
export interface SessionListItem extends SessionInfo {
online: boolean;
lastEventAt: number | null;
}
export type MessageRole = "user" | "assistant" | "toolResult" | "system";
export interface ToolCall {
id: string;
name: string;
argsJson: string;
}
export interface Message {
role: MessageRole;
id: string;
text: string;
thinking: string | null;
toolCalls: ToolCall[];
/** toolResult messages: which call this answers */
toolCallId: string | null;
}
/** Persisted event envelope + flattened payload. */
export interface EventFrame {
v: 1;
sessionId: string;
/** monotonic per-session, plugin-assigned, starts at 1 */
seq: number;
/** unix ms */
ts: number;
type: EventType | string;
// ---- payload fields (union, present depending on `type`) ----
session?: SessionInfo;
message?: Message;
delta?: string;
toolCallId?: string;
toolName?: string;
args?: string;
partial?: string;
isError?: boolean;
resultPreview?: string;
usage?: { inputTokens?: number; outputTokens?: number; totalCost?: number };
reason?: string;
}
/** Query params + response shapes for REST routes. */
export interface PromptBody {
message: string;
}
export interface PromptResponse {
ok: boolean;
}
export interface AbortResponse {
ok: boolean;
}
export interface ContainerResponse {
ok: boolean;
}
export interface SpawnBody {
repo: string;
branch?: string;
}
export interface SpawnResponse {
sessionId: string;
containerId: string;
}
export interface SpawnJob {
repo: string;
state: string;
containerId?: string;
sessionId?: string;
}
export interface GitlabStatus {
connected: boolean;
baseUrl: string;
username?: string;
}
export interface GitlabConnectResponse {
username: string;
}
export interface Repo {
path: string;
name: string;
namespace: string;
lastActivityAt: string;
webUrl: string;
defaultBranch: string;
}
/** ---- WS transport 3 (browser → daemon) ---- */
export type ServerFrame =
| { type: "session_list"; sessions: SessionListItem[] }
| { type: "events"; sessionId: string; after: number; events: EventFrame[] }
| { type: "spawn_status"; jobs: SpawnJob[] };
export type ClientFrame =
| { type: "subscribe"; sessionId: string }
| { type: "unsubscribe"; sessionId: string };
+38
View File
@@ -0,0 +1,38 @@
export interface Settings {
serverUrl: string;
token: string;
}
const STORAGE_KEY: string = "lvmh.settings";
export function getSettings(): Settings | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return null;
const parsed: unknown = JSON.parse(raw);
if (
parsed !== null &&
typeof parsed === "object" &&
typeof (parsed as { serverUrl?: unknown }).serverUrl === "string" &&
typeof (parsed as { token?: unknown }).token === "string"
) {
return parsed as Settings;
}
return null;
} catch {
return null;
}
}
export function saveSettings(settings: Settings): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
export function clearSettings(): void {
localStorage.removeItem(STORAGE_KEY);
}
export function buildWsUrl(settings: Settings): string {
const base = settings.serverUrl.replace(/\/+$/, "").replace(/^http/, "ws");
return `${base}/ws?token=${encodeURIComponent(settings.token)}`;
}
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useState } from "react";
import type { EventFrame, ServerFrame, SessionListItem, SpawnJob } from "./protocol";
import { Route } from "./protocol";
import { buildWsUrl, getSettings, clearSettings } from "./settings";
import { createWsManager, type WsManager, type WsState } from "./ws";
import { errMessage, fetchJson } from "./api";
// ---------- small shared helpers ----------
const MINUTE_MS: number = 60_000;
const HOUR_MS: number = 60 * MINUTE_MS;
const DAY_MS: number = 24 * HOUR_MS;
export function relativeTime(ts: number | null): string {
if (ts === null) return "never";
const abs: number = Math.abs(Date.now() - ts);
if (abs < MINUTE_MS) return "just now";
if (abs < HOUR_MS) return `${Math.floor(abs / MINUTE_MS)}m ago`;
if (abs < DAY_MS) return `${Math.floor(abs / HOUR_MS)}h ago`;
return `${Math.floor(abs / DAY_MS)}d ago`;
}
export function classNames(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
// ---------- toasts ----------
export interface Toast {
id: number;
text: string;
}
let toastSeq: number = 0;
const TOAST_TTL_MS: number = 4000;
export function useToasts(): { toasts: Toast[]; push: (text: string) => void } {
const [toasts, setToasts] = useState<Toast[]>([]);
const push = useCallback((text: string): void => {
const id: number = ++toastSeq;
setToasts((prev) => [...prev, { id, text }]);
window.setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), TOAST_TTL_MS);
}, []);
return { toasts, push };
}
// ---------- sessions store (REST seed + WS updates) ----------
export interface SessionsStore {
sessions: SessionListItem[];
state: WsState;
spawnJobs: SpawnJob[];
refresh: () => Promise<void>;
/** Subscribe to one session's event stream (protocol allows one at a time). */
subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void) => () => void;
}
export function useSessions(pushToast: (text: string) => void): SessionsStore | null {
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [spawnJobs, setSpawnJobs] = useState<SpawnJob[]>([]);
const [state, setState] = useState<WsState>("connecting");
const [manager, setManager] = useState<WsManager | null>(null);
useEffect(() => {
const settings = getSettings();
if (settings === null) return;
const m = createWsManager(buildWsUrl(settings));
setManager(m);
const offState = m.onState(setState);
const offFrames = m.onFrame((frame: ServerFrame) => {
if (frame.type === "session_list") setSessions(frame.sessions);
else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs);
});
m.onAuthError(() => {
clearSettings();
window.location.reload();
});
const refresh = async (): Promise<void> => {
try {
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
} catch (err) {
pushToast(`sessions: ${errMessage(err)}`);
}
};
void refresh();
return () => {
offState();
offFrames();
m.close();
setManager(null);
};
}, [pushToast]);
if (getSettings() === null) return null;
const refresh = useCallback(async (): Promise<void> => {
try {
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
} catch (err) {
pushToast(`sessions: ${errMessage(err)}`);
}
}, [pushToast]);
const subscribe = useCallback(
(sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => {
if (manager === null) return () => undefined;
const off = manager.onFrame((frame: ServerFrame) => {
if (frame.type === "events" && frame.sessionId === sessionId) onEvents(frame.events);
});
manager.subscribe(sessionId);
return () => {
manager.unsubscribe(sessionId);
off();
};
},
[manager]
);
return { sessions, state, spawnJobs, refresh, subscribe };
}
+142
View File
@@ -0,0 +1,142 @@
import type { ServerFrame, ClientFrame, EventFrame } from "./protocol";
export type WsState = "connecting" | "open" | "closed";
export interface WsManager {
onState(cb: (state: WsState) => void): () => void;
onFrame(cb: (frame: ServerFrame) => void): () => void;
onAuthError(cb: () => void): () => void;
subscribe(sessionId: string): void;
unsubscribe(sessionId: string): void;
/** Resubscribe all live subscriptions; call on (re)open. */
resubscribeAll(): void;
/** SessionIds subscribed right now (used by onOpen hooks to refetch). */
subscriptions(): ReadonlySet<string>;
close(): void;
}
const BACKOFF_INITIAL_MS: number = 500;
const BACKOFF_MAX_MS: number = 30_000;
const CLOSED_BY_USER: number = 4900;
export function createWsManager(url: string | (() => string)): WsManager {
const urlFn: () => string = typeof url === "string" ? () => url : url;
let ws: WebSocket | null = null;
let closed = false;
let attempt: number = 0;
let reconnectTimer: number | undefined;
let state: WsState = "connecting";
const stateCbs = new Set<(s: WsState) => void>();
const frameCbs = new Set<(f: ServerFrame) => void>();
const authErrorCbs = new Set<() => void>();
const subs = new Set<string>();
const setState = (s: WsState): void => {
state = s;
stateCbs.forEach((cb) => cb(s));
};
const connect = (): void => {
if (closed) return;
setState("connecting");
const sock = new WebSocket(urlFn());
ws = sock;
sock.onopen = () => {
attempt = 0;
setState("open");
// resubscribe current chat + sessions fan-out resumes automatically
subs.forEach((id) => {
const frame: ClientFrame = { type: "subscribe", sessionId: id };
sock.send(JSON.stringify(frame));
});
};
sock.onmessage = (ev: MessageEvent) => {
let frame: ServerFrame;
try {
frame = JSON.parse(typeof ev.data === "string" ? ev.data : "") as ServerFrame;
} catch {
return;
}
if (frame === null || typeof frame !== "object" || typeof frame.type !== "string") return;
frameCbs.forEach((cb) => cb(frame));
};
sock.onclose = (ev: CloseEvent) => {
ws = null;
setState("closed");
if (closed) return;
if (ev.code === 1008) {
// policy violation: token rejected
authErrorCbs.forEach((cb) => cb());
return;
}
const jitter: number = Math.random();
const exp: number = Math.min(BACKOFF_INITIAL_MS * 2 ** attempt, BACKOFF_MAX_MS);
attempt += 1;
reconnectTimer = window.setTimeout(connect, exp * (0.5 + 0.5 * jitter));
};
sock.onerror = () => {
/* onclose follows */
};
};
connect();
return {
onState(cb) {
stateCbs.add(cb);
cb(state);
return () => stateCbs.delete(cb);
},
onFrame(cb) {
frameCbs.add(cb);
return () => frameCbs.delete(cb);
},
onAuthError(cb) {
authErrorCbs.add(cb);
return () => authErrorCbs.delete(cb);
},
subscribe(sessionId) {
subs.add(sessionId);
if (ws !== null && ws.readyState === WebSocket.OPEN) {
const frame: ClientFrame = { type: "subscribe", sessionId };
ws.send(JSON.stringify(frame));
}
},
unsubscribe(sessionId) {
subs.delete(sessionId);
if (ws !== null && ws.readyState === WebSocket.OPEN) {
const frame: ClientFrame = { type: "unsubscribe", sessionId };
ws.send(JSON.stringify(frame));
}
},
resubscribeAll() {
const sock: WebSocket | null = ws;
if (sock !== null && sock.readyState === WebSocket.OPEN) {
subs.forEach((id) => {
const frame: ClientFrame = { type: "subscribe", sessionId: id };
sock.send(JSON.stringify(frame));
});
}
},
subscriptions() {
return subs;
},
close() {
closed = true;
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
if (ws !== null) {
ws.onclose = null;
ws.onmessage = null;
ws.onerror = null;
ws.onopen = null;
ws.close(CLOSED_BY_USER);
ws = null;
}
setState("closed");
},
};
}
export type { EventFrame };