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