feat(@projects): ✨ add core routing and page scaffolding
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8f12844217
commit
23712cdf4e
22 changed files with 603 additions and 72 deletions
|
|
@ -1,31 +1,42 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
/**
|
||||
* R1 scaffold entry. Hello-world that proves:
|
||||
* - Vite serves the SPA on :5173
|
||||
* - The proxy to FastAPI on :8767 works (we hit /api/v1/health and render the result)
|
||||
* - Build chain compiles
|
||||
* Router + theme. Owned by the foundation (not the per-page agents).
|
||||
*
|
||||
* Real pages (chat, projects, sessions, broadcast, dashboard) land in R3–R5.
|
||||
* Route table:
|
||||
* / → DashboardPage (R5)
|
||||
* /chat → ChatPage scope=orchestrator (R3)
|
||||
* /chat/project/:name → ChatPage scope=project (R3)
|
||||
* /chat/session/:uuid → ChatPage scope=session (R3)
|
||||
* /projects → ProjectsListPage (R4)
|
||||
* /projects/:name → ProjectDetailPage (R4)
|
||||
* /sessions → SessionsPage (R4)
|
||||
* /broadcast → BroadcastPage (R5)
|
||||
*
|
||||
* Agents implement the pages in their respective subdirs. They MUST NOT
|
||||
* edit this file (route registration) or lib/* (shared infra).
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import { AppShell } from "./AppShell";
|
||||
import { clareTheme } from "./theme";
|
||||
import { ChatPage } from "./chat/ChatPage";
|
||||
import { ProjectsListPage } from "./projects/ProjectsListPage";
|
||||
import { ProjectDetailPage } from "./projects/ProjectDetailPage";
|
||||
import { SessionsPage } from "./sessions/SessionsPage";
|
||||
import { BroadcastPage } from "./broadcast/BroadcastPage";
|
||||
import { DashboardPage } from "./dashboard/DashboardPage";
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Clare's state is event-sourced + SSE-pushed; aggressive refetch is
|
||||
// unnecessary. Pages that want push updates use useChatStream or
|
||||
// their own EventSource.
|
||||
staleTime: 5_000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
export function App() {
|
||||
const [health, setHealth] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
fetch("/api/v1/health", { signal: ctrl.signal })
|
||||
.then((r) => {
|
||||
if (!r.ok)
|
||||
throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((h) => setHealth(h))
|
||||
.catch((e) => {
|
||||
if (e.name !== "AbortError") {
|
||||
setError(String(e));
|
||||
}
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, []);
|
||||
return (_jsxs("main", { style: { fontFamily: "ui-monospace, monospace", padding: "2rem" }, children: [_jsx("h1", { children: "clare" }), _jsx("p", { children: "R1 scaffold. Vite \u2192 FastAPI proxy check:" }), error ? (_jsxs("pre", { style: { color: "tomato" }, children: ["error: ", error] })) : health ? (_jsx("pre", { children: `status: ${health.status}\nmachine_id: ${health.machine_id}` })) : (_jsx("p", { children: "loading\u2026" })), _jsx("p", { style: { color: "#888", marginTop: "2rem" }, children: "R3: chat surface. R4: projects + sessions. R5: broadcast + dashboard. R6: delete Jinja templates." })] }));
|
||||
return (_jsx(QueryClientProvider, { client: queryClient, children: _jsx(ThemeProvider, { theme: clareTheme, children: _jsx(BrowserRouter, { children: _jsx(Routes, { children: _jsxs(Route, { element: _jsx(AppShell, {}), children: [_jsx(Route, { index: true, element: _jsx(DashboardPage, {}) }), _jsx(Route, { path: "chat", element: _jsx(ChatPage, { scope: "orchestrator" }) }), _jsx(Route, { path: "chat/project/:name", element: _jsx(ChatPage, { scope: "project" }) }), _jsx(Route, { path: "chat/session/:uuid", element: _jsx(ChatPage, { scope: "session" }) }), _jsx(Route, { path: "projects", element: _jsx(ProjectsListPage, {}) }), _jsx(Route, { path: "projects/:name", element: _jsx(ProjectDetailPage, {}) }), _jsx(Route, { path: "sessions", element: _jsx(SessionsPage, {}) }), _jsx(Route, { path: "broadcast", element: _jsx(BroadcastPage, {}) }), _jsx(Route, { path: "*", element: _jsx(Navigate, { to: "/", replace: true }) })] }) }) }) }) }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,66 @@
|
|||
/**
|
||||
* R1 scaffold entry. Hello-world that proves:
|
||||
* - Vite serves the SPA on :5173
|
||||
* - The proxy to FastAPI on :8767 works (we hit /api/v1/health and render the result)
|
||||
* - Build chain compiles
|
||||
* Router + theme. Owned by the foundation (not the per-page agents).
|
||||
*
|
||||
* Real pages (chat, projects, sessions, broadcast, dashboard) land in R3–R5.
|
||||
* Route table:
|
||||
* / → DashboardPage (R5)
|
||||
* /chat → ChatPage scope=orchestrator (R3)
|
||||
* /chat/project/:name → ChatPage scope=project (R3)
|
||||
* /chat/session/:uuid → ChatPage scope=session (R3)
|
||||
* /projects → ProjectsListPage (R4)
|
||||
* /projects/:name → ProjectDetailPage (R4)
|
||||
* /sessions → SessionsPage (R4)
|
||||
* /broadcast → BroadcastPage (R5)
|
||||
*
|
||||
* Agents implement the pages in their respective subdirs. They MUST NOT
|
||||
* edit this file (route registration) or lib/* (shared infra).
|
||||
*/
|
||||
import { useEffect, useState, type ReactElement } from "react";
|
||||
|
||||
interface Health {
|
||||
status: string;
|
||||
machine_id: string;
|
||||
}
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { AppShell } from "./AppShell";
|
||||
import { clareTheme } from "./theme";
|
||||
import { ChatPage } from "./chat/ChatPage";
|
||||
import { ProjectsListPage } from "./projects/ProjectsListPage";
|
||||
import { ProjectDetailPage } from "./projects/ProjectDetailPage";
|
||||
import { SessionsPage } from "./sessions/SessionsPage";
|
||||
import { BroadcastPage } from "./broadcast/BroadcastPage";
|
||||
import { DashboardPage } from "./dashboard/DashboardPage";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Clare's state is event-sourced + SSE-pushed; aggressive refetch is
|
||||
// unnecessary. Pages that want push updates use useChatStream or
|
||||
// their own EventSource.
|
||||
staleTime: 5_000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function App(): ReactElement {
|
||||
const [health, setHealth] = useState<Health | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
const ctrl = new AbortController();
|
||||
fetch("/api/v1/health", { signal: ctrl.signal })
|
||||
.then((r: Response): Promise<Health> => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Health>;
|
||||
})
|
||||
.then((h: Health): void => setHealth(h))
|
||||
.catch((e: unknown): void => {
|
||||
if ((e as { name?: string }).name !== "AbortError") {
|
||||
setError(String(e));
|
||||
}
|
||||
});
|
||||
return (): void => ctrl.abort();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "ui-monospace, monospace", padding: "2rem" }}>
|
||||
<h1>clare</h1>
|
||||
<p>R1 scaffold. Vite → FastAPI proxy check:</p>
|
||||
{error ? (
|
||||
<pre style={{ color: "tomato" }}>error: {error}</pre>
|
||||
) : health ? (
|
||||
<pre>
|
||||
{`status: ${health.status}\nmachine_id: ${health.machine_id}`}
|
||||
</pre>
|
||||
) : (
|
||||
<p>loading…</p>
|
||||
)}
|
||||
<p style={{ color: "#888", marginTop: "2rem" }}>
|
||||
R3: chat surface. R4: projects + sessions. R5: broadcast + dashboard.
|
||||
R6: delete Jinja templates.
|
||||
</p>
|
||||
</main>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={clareTheme}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="chat" element={<ChatPage scope="orchestrator" />} />
|
||||
<Route path="chat/project/:name" element={<ChatPage scope="project" />} />
|
||||
<Route path="chat/session/:uuid" element={<ChatPage scope="session" />} />
|
||||
<Route path="projects" element={<ProjectsListPage />} />
|
||||
<Route path="projects/:name" element={<ProjectDetailPage />} />
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route path="broadcast" element={<BroadcastPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
49
src/clare/web/app/src/AppShell.js
Normal file
49
src/clare/web/app/src/AppShell.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
/**
|
||||
* AppShell — top navigation + content slot. Wrapped by ThemeProvider in App.tsx.
|
||||
*
|
||||
* Pages are rendered into the <main> via react-router's <Outlet />.
|
||||
* The terminal aesthetic comes from @lilith/react-terminal-themes; we wire
|
||||
* the "nord" theme by default and let pages override via context if they
|
||||
* want a different vibe (e.g. orchestrator could go "matrix").
|
||||
*/
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
const Shell = styled.div `
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${({ theme }) => theme.colors?.bg ?? "#0f1115"};
|
||||
color: ${({ theme }) => theme.colors?.fg ?? "#d8dee9"};
|
||||
font: 14px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
|
||||
`;
|
||||
const Nav = styled.nav `
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors?.border ?? "#2a2f3a"};
|
||||
background: ${({ theme }) => theme.colors?.bgAlt ?? "#0a0c11"};
|
||||
align-items: center;
|
||||
`;
|
||||
const Brand = styled(NavLink) `
|
||||
font-weight: bold;
|
||||
color: ${({ theme }) => theme.colors?.accent ?? "#88c0d0"};
|
||||
text-decoration: none;
|
||||
margin-right: auto;
|
||||
`;
|
||||
const NavItem = styled(NavLink) `
|
||||
color: ${({ theme }) => theme.colors?.dim ?? "#7a8290"};
|
||||
text-decoration: none;
|
||||
|
||||
&.active {
|
||||
color: ${({ theme }) => theme.colors?.accent ?? "#88c0d0"};
|
||||
}
|
||||
`;
|
||||
const Main = styled.main `
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
`;
|
||||
export function AppShell() {
|
||||
return (_jsxs(Shell, { children: [_jsxs(Nav, { children: [_jsx(Brand, { to: "/", children: "clare" }), _jsx(NavItem, { to: "/chat", children: "chat" }), _jsx(NavItem, { to: "/projects", children: "projects" }), _jsx(NavItem, { to: "/sessions", children: "sessions" }), _jsx(NavItem, { to: "/broadcast", children: "broadcast" })] }), _jsx(Main, { children: _jsx(Outlet, {}) })] }));
|
||||
}
|
||||
69
src/clare/web/app/src/AppShell.tsx
Normal file
69
src/clare/web/app/src/AppShell.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* AppShell — top navigation + content slot. Wrapped by ThemeProvider in App.tsx.
|
||||
*
|
||||
* Pages are rendered into the <main> via react-router's <Outlet />.
|
||||
* The terminal aesthetic comes from @lilith/react-terminal-themes; we wire
|
||||
* the "nord" theme by default and let pages override via context if they
|
||||
* want a different vibe (e.g. orchestrator could go "matrix").
|
||||
*/
|
||||
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
const Shell = styled.div`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${({ theme }): string => theme.colors?.bg ?? "#0f1115"};
|
||||
color: ${({ theme }): string => theme.colors?.fg ?? "#d8dee9"};
|
||||
font: 14px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
|
||||
`;
|
||||
|
||||
const Nav = styled.nav`
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid ${({ theme }): string => theme.colors?.border ?? "#2a2f3a"};
|
||||
background: ${({ theme }): string => theme.colors?.bgAlt ?? "#0a0c11"};
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Brand = styled(NavLink)`
|
||||
font-weight: bold;
|
||||
color: ${({ theme }): string => theme.colors?.accent ?? "#88c0d0"};
|
||||
text-decoration: none;
|
||||
margin-right: auto;
|
||||
`;
|
||||
|
||||
const NavItem = styled(NavLink)`
|
||||
color: ${({ theme }): string => theme.colors?.dim ?? "#7a8290"};
|
||||
text-decoration: none;
|
||||
|
||||
&.active {
|
||||
color: ${({ theme }): string => theme.colors?.accent ?? "#88c0d0"};
|
||||
}
|
||||
`;
|
||||
|
||||
const Main = styled.main`
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
export function AppShell(): ReactElement {
|
||||
return (
|
||||
<Shell>
|
||||
<Nav>
|
||||
<Brand to="/">clare</Brand>
|
||||
<NavItem to="/chat">chat</NavItem>
|
||||
<NavItem to="/projects">projects</NavItem>
|
||||
<NavItem to="/sessions">sessions</NavItem>
|
||||
<NavItem to="/broadcast">broadcast</NavItem>
|
||||
</Nav>
|
||||
<Main>
|
||||
<Outlet />
|
||||
</Main>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
4
src/clare/web/app/src/broadcast/BroadcastPage.js
Normal file
4
src/clare/web/app/src/broadcast/BroadcastPage.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
export function BroadcastPage() {
|
||||
return (_jsxs("div", { style: { padding: "1.5rem" }, children: [_jsx("h2", { children: "broadcast" }), _jsx("p", { style: { color: "#7a8290" }, children: "R5 placeholder." })] }));
|
||||
}
|
||||
13
src/clare/web/app/src/broadcast/BroadcastPage.tsx
Normal file
13
src/clare/web/app/src/broadcast/BroadcastPage.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* R5 placeholder — broadcast form. Owned by the R5 agent.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export function BroadcastPage(): ReactElement {
|
||||
return (
|
||||
<div style={{ padding: "1.5rem" }}>
|
||||
<h2>broadcast</h2>
|
||||
<p style={{ color: "#7a8290" }}>R5 placeholder.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/clare/web/app/src/chat/ChatPage.js
Normal file
11
src/clare/web/app/src/chat/ChatPage.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
||||
/**
|
||||
* R3 placeholder — replaced by the orchestrator/project/session chat surface.
|
||||
* Owned by the R3 agent. Don't edit `App.tsx` or `lib/*` from here.
|
||||
*/
|
||||
import { useParams } from "react-router-dom";
|
||||
export function ChatPage({ scope }) {
|
||||
const params = useParams();
|
||||
const ref = scope === "project" ? params.name : scope === "session" ? params.uuid : null;
|
||||
return (_jsxs("div", { style: { padding: "1.5rem" }, children: [_jsxs("h2", { children: ["chat \u00B7 ", scope, ref ? ` · ${ref}` : ""] }), _jsx("p", { style: { color: "#7a8290" }, children: "R3 placeholder \u2014 agent will replace this." })] }));
|
||||
}
|
||||
22
src/clare/web/app/src/chat/ChatPage.tsx
Normal file
22
src/clare/web/app/src/chat/ChatPage.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* R3 placeholder — replaced by the orchestrator/project/session chat surface.
|
||||
* Owned by the R3 agent. Don't edit `App.tsx` or `lib/*` from here.
|
||||
*/
|
||||
import { useParams } from "react-router-dom";
|
||||
import type { ReactElement } from "react";
|
||||
import type { ChatScope } from "../lib/types";
|
||||
|
||||
export interface ChatPageProps {
|
||||
scope: ChatScope;
|
||||
}
|
||||
|
||||
export function ChatPage({ scope }: ChatPageProps): ReactElement {
|
||||
const params = useParams<{ name?: string; uuid?: string }>();
|
||||
const ref = scope === "project" ? params.name : scope === "session" ? params.uuid : null;
|
||||
return (
|
||||
<div style={{ padding: "1.5rem" }}>
|
||||
<h2>chat · {scope}{ref ? ` · ${ref}` : ""}</h2>
|
||||
<p style={{ color: "#7a8290" }}>R3 placeholder — agent will replace this.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/clare/web/app/src/dashboard/DashboardPage.js
Normal file
4
src/clare/web/app/src/dashboard/DashboardPage.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
export function DashboardPage() {
|
||||
return (_jsxs("div", { style: { padding: "1.5rem" }, children: [_jsx("h2", { children: "dashboard" }), _jsx("p", { style: { color: "#7a8290" }, children: "R5 placeholder." })] }));
|
||||
}
|
||||
13
src/clare/web/app/src/dashboard/DashboardPage.tsx
Normal file
13
src/clare/web/app/src/dashboard/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* R5 placeholder — landing dashboard. Owned by the R5 agent.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export function DashboardPage(): ReactElement {
|
||||
return (
|
||||
<div style={{ padding: "1.5rem" }}>
|
||||
<h2>dashboard</h2>
|
||||
<p style={{ color: "#7a8290" }}>R5 placeholder.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/clare/web/app/src/lib/api.js
Normal file
128
src/clare/web/app/src/lib/api.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Typed fetch wrapper for Clare's JSON API.
|
||||
*
|
||||
* Every page calls these — never raw `fetch`. Centralizes:
|
||||
* - URL construction (relative paths; Vite proxies in dev, same-origin in prod)
|
||||
* - error translation: non-2xx → `ApiError(status, detail)`
|
||||
* - JSON parsing with typed return
|
||||
* - AbortSignal threading so callers can cancel
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
status;
|
||||
detail;
|
||||
constructor(status, detail) {
|
||||
super(`${status}: ${detail}`);
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
async function request(method, path, options = {}) {
|
||||
const url = buildUrl(path, options.params);
|
||||
const init = {
|
||||
method,
|
||||
signal: options.signal,
|
||||
headers: options.body !== undefined ? { "content-type": "application/json" } : undefined,
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
};
|
||||
const resp = await fetch(url, init);
|
||||
if (!resp.ok) {
|
||||
let detail = resp.statusText;
|
||||
try {
|
||||
const j = await resp.json();
|
||||
if (j && typeof j === "object" && "detail" in j) {
|
||||
detail = String(j.detail);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// body wasn't json — keep statusText
|
||||
}
|
||||
throw new ApiError(resp.status, detail);
|
||||
}
|
||||
// 204 / empty body → cast to T (caller must declare void in that case)
|
||||
if (resp.status === 204)
|
||||
return undefined;
|
||||
return (await resp.json());
|
||||
}
|
||||
function buildUrl(path, params) {
|
||||
if (!params)
|
||||
return path;
|
||||
const usp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null)
|
||||
usp.append(k, String(v));
|
||||
}
|
||||
const q = usp.toString();
|
||||
return q ? `${path}?${q}` : path;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Projects
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function listProjects(signal) {
|
||||
const r = await request("GET", "/api/v1/projects", { signal });
|
||||
return r.projects;
|
||||
}
|
||||
export async function createProject(body, signal) {
|
||||
return request("POST", "/api/v1/projects", { body, signal });
|
||||
}
|
||||
export async function getProject(nameOrId, signal) {
|
||||
return request("GET", `/api/v1/projects/${encodeURIComponent(nameOrId)}`, { signal });
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function listTasks(params = {}, signal) {
|
||||
const r = await request("GET", "/api/v1/tasks", { params, signal });
|
||||
return r.tasks;
|
||||
}
|
||||
export async function createTask(body, signal) {
|
||||
return request("POST", "/api/v1/tasks", { body, signal });
|
||||
}
|
||||
export async function updateTask(taskId, body, signal) {
|
||||
return request("POST", `/api/v1/tasks/${encodeURIComponent(taskId)}`, { body, signal });
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function listAssignments(active = true, signal) {
|
||||
const r = await request("GET", "/api/v1/assignments", {
|
||||
params: { active: active ? 1 : 0 }, signal,
|
||||
});
|
||||
return r.assignments;
|
||||
}
|
||||
export async function createAssignment(body, signal) {
|
||||
return request("POST", "/api/v1/assignments", { body, signal });
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sessions + pull
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function listSessions(signal) {
|
||||
const r = await request("GET", "/api/v1/sessions", { signal });
|
||||
return r.sessions;
|
||||
}
|
||||
export async function pull(signal) {
|
||||
return request("POST", "/api/v1/pull", { signal });
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Broadcast
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function broadcast(body, signal) {
|
||||
return request("POST", "/api/v1/broadcast", { body, signal });
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function listChatMessages(params, signal) {
|
||||
const r = await request("GET", "/api/v1/chat", { params, signal });
|
||||
return r.messages;
|
||||
}
|
||||
export async function postChatMessage(body, signal) {
|
||||
return request("POST", "/api/v1/chat", { body, signal });
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Autocomplete
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function autocomplete(params, signal) {
|
||||
const r = await request("GET", "/api/v1/autocomplete", { params, signal });
|
||||
return r.hits;
|
||||
}
|
||||
67
src/clare/web/app/src/lib/sse.js
Normal file
67
src/clare/web/app/src/lib/sse.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* useChatStream — subscribe to the SSE channel for a chat scope.
|
||||
*
|
||||
* The FastAPI endpoint at `/chat/stream?scope=...&scope_ref=...&after_rowid=N`
|
||||
* emits `event: chat\ndata: <html>` frames whenever new chat_messages rows
|
||||
* appear. The HTML payload was for HTMX; we need parsed ChatMessage objects.
|
||||
*
|
||||
* The cleanest path is a sibling JSON SSE channel — but plumbing a second
|
||||
* stream is out of scope here. Instead the hook re-fetches `listChatMessages`
|
||||
* with the latest known rowid each time a `chat` event fires (and on mount).
|
||||
* The SSE channel is used only as a wake-up signal.
|
||||
*
|
||||
* Hand-rolled hook (no react-query streaming) to keep the dependency surface
|
||||
* minimal and the wake-up logic explicit.
|
||||
*/
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { listChatMessages } from "./api";
|
||||
export function useChatStream({ scope, scopeRef }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const lastRowidRef = useRef(0);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ctrl = new AbortController();
|
||||
async function refresh() {
|
||||
try {
|
||||
const rows = await listChatMessages({ scope, scope_ref: scopeRef, after_rowid: lastRowidRef.current, limit: 200 }, ctrl.signal);
|
||||
if (cancelled || rows.length === 0)
|
||||
return;
|
||||
lastRowidRef.current = rows[rows.length - 1].rowid;
|
||||
setMessages((prev) => mergeById(prev, rows));
|
||||
}
|
||||
catch (e) {
|
||||
if (e.name === "AbortError")
|
||||
return;
|
||||
// Network blips during reconnect are common; swallow rather than
|
||||
// surface — the SSE channel will fire another wake-up.
|
||||
}
|
||||
}
|
||||
// Initial backfill (rowid 0 → everything).
|
||||
void refresh();
|
||||
const params = new URLSearchParams({ scope, scope_ref: scopeRef ?? "", after_rowid: "0" });
|
||||
const es = new EventSource(`/chat/stream?${params.toString()}`);
|
||||
es.addEventListener("open", () => setConnected(true));
|
||||
es.addEventListener("error", () => setConnected(false));
|
||||
es.addEventListener("chat", () => void refresh());
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ctrl.abort();
|
||||
es.close();
|
||||
};
|
||||
}, [scope, scopeRef]);
|
||||
function ingest(msgs) {
|
||||
if (msgs.length === 0)
|
||||
return;
|
||||
lastRowidRef.current = Math.max(lastRowidRef.current, msgs.reduce((m, r) => Math.max(m, r.rowid), 0));
|
||||
setMessages((prev) => mergeById(prev, msgs));
|
||||
}
|
||||
return { messages, connected, ingest };
|
||||
}
|
||||
function mergeById(prev, next) {
|
||||
const seen = new Set(prev.map((m) => m.rowid));
|
||||
const additions = next.filter((m) => !seen.has(m.rowid));
|
||||
if (additions.length === 0)
|
||||
return prev;
|
||||
return [...prev, ...additions].sort((a, b) => a.rowid - b.rowid);
|
||||
}
|
||||
6
src/clare/web/app/src/lib/types.js
Normal file
6
src/clare/web/app/src/lib/types.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Wire types mirroring the FastAPI JSON shapes in src/clare/web/api.py.
|
||||
* Hand-written rather than generated — the surface is small and stable.
|
||||
* If a field is added to a Pydantic model, mirror it here too.
|
||||
*/
|
||||
export {};
|
||||
9
src/clare/web/app/src/projects/ProjectDetailPage.js
Normal file
9
src/clare/web/app/src/projects/ProjectDetailPage.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
||||
/**
|
||||
* R4 placeholder — single project detail. Owned by the R4 agent.
|
||||
*/
|
||||
import { useParams } from "react-router-dom";
|
||||
export function ProjectDetailPage() {
|
||||
const { name } = useParams();
|
||||
return (_jsxs("div", { style: { padding: "1.5rem" }, children: [_jsxs("h2", { children: ["project \u00B7 ", name] }), _jsx("p", { style: { color: "#7a8290" }, children: "R4 placeholder." })] }));
|
||||
}
|
||||
15
src/clare/web/app/src/projects/ProjectDetailPage.tsx
Normal file
15
src/clare/web/app/src/projects/ProjectDetailPage.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* R4 placeholder — single project detail. Owned by the R4 agent.
|
||||
*/
|
||||
import { useParams } from "react-router-dom";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export function ProjectDetailPage(): ReactElement {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
return (
|
||||
<div style={{ padding: "1.5rem" }}>
|
||||
<h2>project · {name}</h2>
|
||||
<p style={{ color: "#7a8290" }}>R4 placeholder.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/clare/web/app/src/projects/ProjectsListPage.js
Normal file
4
src/clare/web/app/src/projects/ProjectsListPage.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
export function ProjectsListPage() {
|
||||
return (_jsxs("div", { style: { padding: "1.5rem" }, children: [_jsx("h2", { children: "projects" }), _jsx("p", { style: { color: "#7a8290" }, children: "R4 placeholder." })] }));
|
||||
}
|
||||
13
src/clare/web/app/src/projects/ProjectsListPage.tsx
Normal file
13
src/clare/web/app/src/projects/ProjectsListPage.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* R4 placeholder — projects list. Owned by the R4 agent.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export function ProjectsListPage(): ReactElement {
|
||||
return (
|
||||
<div style={{ padding: "1.5rem" }}>
|
||||
<h2>projects</h2>
|
||||
<p style={{ color: "#7a8290" }}>R4 placeholder.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/clare/web/app/src/sessions/SessionsPage.js
Normal file
4
src/clare/web/app/src/sessions/SessionsPage.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
export function SessionsPage() {
|
||||
return (_jsxs("div", { style: { padding: "1.5rem" }, children: [_jsx("h2", { children: "sessions" }), _jsx("p", { style: { color: "#7a8290" }, children: "R4 placeholder." })] }));
|
||||
}
|
||||
13
src/clare/web/app/src/sessions/SessionsPage.tsx
Normal file
13
src/clare/web/app/src/sessions/SessionsPage.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* R4 placeholder — fleet sessions list. Owned by the R4 agent.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export function SessionsPage(): ReactElement {
|
||||
return (
|
||||
<div style={{ padding: "1.5rem" }}>
|
||||
<h2>sessions</h2>
|
||||
<p style={{ color: "#7a8290" }}>R4 placeholder.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/clare/web/app/src/theme.js
Normal file
21
src/clare/web/app/src/theme.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Terminal-styled theme for styled-components.
|
||||
*
|
||||
* Hand-tuned to match the Nord-ish look the prior Jinja CSS used.
|
||||
* @lilith/react-terminal-themes is available too — agents can import its
|
||||
* themes for specific surfaces (e.g. the xterm widget in @lilith/
|
||||
* react-terminal-ui) without disturbing this base palette.
|
||||
*/
|
||||
export const clareTheme = {
|
||||
colors: {
|
||||
bg: "#0f1115",
|
||||
bgAlt: "#0a0c11",
|
||||
fg: "#d8dee9",
|
||||
dim: "#7a8290",
|
||||
accent: "#88c0d0",
|
||||
border: "#2a2f3a",
|
||||
warn: "#ebcb8b",
|
||||
good: "#a3be8c",
|
||||
bad: "#bf616a",
|
||||
},
|
||||
};
|
||||
44
src/clare/web/app/src/theme.ts
Normal file
44
src/clare/web/app/src/theme.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Terminal-styled theme for styled-components.
|
||||
*
|
||||
* Hand-tuned to match the Nord-ish look the prior Jinja CSS used.
|
||||
* @lilith/react-terminal-themes is available too — agents can import its
|
||||
* themes for specific surfaces (e.g. the xterm widget in @lilith/
|
||||
* react-terminal-ui) without disturbing this base palette.
|
||||
*/
|
||||
|
||||
export interface ClareTheme {
|
||||
colors: {
|
||||
bg: string;
|
||||
bgAlt: string;
|
||||
fg: string;
|
||||
dim: string;
|
||||
accent: string;
|
||||
border: string;
|
||||
warn: string;
|
||||
good: string;
|
||||
bad: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const clareTheme: ClareTheme = {
|
||||
colors: {
|
||||
bg: "#0f1115",
|
||||
bgAlt: "#0a0c11",
|
||||
fg: "#d8dee9",
|
||||
dim: "#7a8290",
|
||||
accent: "#88c0d0",
|
||||
border: "#2a2f3a",
|
||||
warn: "#ebcb8b",
|
||||
good: "#a3be8c",
|
||||
bad: "#bf616a",
|
||||
},
|
||||
};
|
||||
|
||||
// Augment styled-components' DefaultTheme so `theme.colors.bg` is typed
|
||||
// throughout the app without explicit casts.
|
||||
declare module "styled-components" {
|
||||
export interface DefaultTheme extends ClareTheme {
|
||||
readonly _brand?: never;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/app.tsx","./src/main.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/appshell.tsx","./src/main.tsx","./src/theme.ts","./src/broadcast/broadcastpage.tsx","./src/chat/chatpage.tsx","./src/dashboard/dashboardpage.tsx","./src/lib/api.ts","./src/lib/sse.ts","./src/lib/types.ts","./src/projects/projectdetailpage.tsx","./src/projects/projectslistpage.tsx","./src/sessions/sessionspage.tsx","./vite.config.ts"],"version":"5.9.3"}
|
||||
Loading…
Add table
Reference in a new issue