web: React+Vite SPA — sessions, streaming chat, task panel, spawn flow (build clean)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 };
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
Reference in New Issue
Block a user