refactor: introduce react-router
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.1",
|
||||
"shiki": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,74 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { Navigate, Outlet, useParams } from "react-router";
|
||||
import { clearApiKey, hasApiKey } from "./api.ts";
|
||||
import { LoginPage } from "./components/login.tsx";
|
||||
import { RunDialog } from "./components/run-dialog.tsx";
|
||||
import { Sidebar } from "./components/sidebar.tsx";
|
||||
import { StatusBar } from "./components/status-bar.tsx";
|
||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||
import { ThreadList } from "./components/thread-list.tsx";
|
||||
import { WorkflowDetail } from "./components/workflow-detail.tsx";
|
||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||
import { useHashRoute } from "./use-hash-route.ts";
|
||||
|
||||
export function App() {
|
||||
export function Layout() {
|
||||
const [authed, setAuthed] = useState(hasApiKey());
|
||||
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
|
||||
useHashRoute();
|
||||
const { client } = useParams();
|
||||
const [showRun, setShowRun] = useState(false);
|
||||
|
||||
if (!authed) {
|
||||
return <LoginPage onLogin={() => setAuthed(true)} />;
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
view={view}
|
||||
client={client}
|
||||
onViewChange={setView}
|
||||
onClientChange={setClient}
|
||||
onLogout={() => {
|
||||
clearApiKey();
|
||||
setAuthed(false);
|
||||
}}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
<StatusBar client={client} onRun={() => setShowRun(true)} />
|
||||
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{!client && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p style={{ color: "var(--color-text-muted)" }}>
|
||||
Select an client from the sidebar to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{client && view === "threads" && threadId === null && (
|
||||
<ThreadList client={client} onSelect={setThreadId} />
|
||||
)}
|
||||
{client && view === "threads" && threadId !== null && (
|
||||
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
|
||||
)}
|
||||
{client && view === "workflows" && workflowName === null && (
|
||||
<WorkflowList client={client} onSelect={setWorkflowName} />
|
||||
)}
|
||||
{client && view === "workflows" && workflowName !== null && (
|
||||
<WorkflowDetail
|
||||
client={client}
|
||||
workflowName={workflowName}
|
||||
onBack={() => setWorkflowName(null)}
|
||||
/>
|
||||
)}
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
{showRun && client && (
|
||||
<RunDialog
|
||||
client={client}
|
||||
onClose={() => setShowRun(false)}
|
||||
onCreated={(id) => {
|
||||
setShowRun(false);
|
||||
setThreadId(id);
|
||||
}}
|
||||
/>
|
||||
<RunDialog client={client} onClose={() => setShowRun(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Navigate } from "react-router";
|
||||
import { listClients } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
export function ClientRedirect() {
|
||||
const { status, data } = useFetch(() => listClients(), []);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Loading clients...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "ok" && data.length > 0) {
|
||||
return <Navigate to={`/${data[0].name}/threads`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p style={{ color: "var(--color-text-muted)" }}>
|
||||
Select a client from the sidebar to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { setApiKey } from "../api.ts";
|
||||
|
||||
type Props = {
|
||||
onLogin: () => void;
|
||||
};
|
||||
|
||||
export function LoginPage({ onLogin }: Props) {
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -40,7 +38,7 @@ export function LoginPage({ onLogin }: Props) {
|
||||
}
|
||||
|
||||
setApiKey(key.trim());
|
||||
onLogin();
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { listWorkflows, runThread } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
client: string;
|
||||
onClose: () => void;
|
||||
onCreated: (threadId: string) => void;
|
||||
};
|
||||
|
||||
export function RunDialog({ client, onClose, onCreated }: Props) {
|
||||
export function RunDialog({ client, onClose }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const workflows = useFetch(() => listWorkflows(client), [client]);
|
||||
const [workflow, setWorkflow] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
@@ -22,7 +23,8 @@ export function RunDialog({ client, onClose, onCreated }: Props) {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await runThread(client, workflow, prompt);
|
||||
onCreated(result.threadId);
|
||||
onClose();
|
||||
navigate(`/${client}/threads/${result.threadId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setSubmitting(false);
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router";
|
||||
import type { ClientEndpoint } from "../api.ts";
|
||||
import { listClients } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
view: "threads" | "workflows";
|
||||
client: string | null;
|
||||
onViewChange: (v: "threads" | "workflows") => void;
|
||||
onClientChange: (a: string | null) => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
|
||||
export function Sidebar({ onLogout }: Props) {
|
||||
const { client } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { status, data } = useFetch(() => listClients(), []);
|
||||
|
||||
const clients: ClientEndpoint[] = status === "ok" ? data : [];
|
||||
|
||||
// Auto-select first client when none is selected
|
||||
useEffect(() => {
|
||||
if (client === null && clients.length > 0) {
|
||||
onClientChange(clients[0].name);
|
||||
}
|
||||
}, [client, clients, onClientChange]);
|
||||
const view = location.pathname.includes("/workflows") ? "workflows" : "threads";
|
||||
|
||||
const viewItems = [
|
||||
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
||||
@@ -42,7 +36,6 @@ export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Client selector */}
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<label
|
||||
className="block text-xs font-medium mb-1"
|
||||
@@ -60,7 +53,12 @@ export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
value={client ?? ""}
|
||||
onChange={(e) => onClientChange(e.target.value || null)}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value;
|
||||
if (name) {
|
||||
navigate(`/${name}/${view}`);
|
||||
}
|
||||
}}
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
@@ -77,13 +75,16 @@ export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* View navigation */}
|
||||
<nav className="flex-1 p-2 space-y-1">
|
||||
{viewItems.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.key}
|
||||
onClick={() => onViewChange(item.key)}
|
||||
onClick={() => {
|
||||
if (client) {
|
||||
navigate(`/${client}/${item.key}`);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
|
||||
style={{
|
||||
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
getThread,
|
||||
getWorkflowDescriptor,
|
||||
@@ -13,12 +14,6 @@ import { useSSE } from "../use-sse.ts";
|
||||
import { RecordCard } from "./record-card.tsx";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
client: string;
|
||||
threadId: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
|
||||
for (const r of records) {
|
||||
if (r.type === "thread-start") return r.workflow;
|
||||
@@ -53,7 +48,11 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
||||
return states;
|
||||
}
|
||||
|
||||
export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
export function ThreadDetail() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const client = params.client as string;
|
||||
const threadId = params.threadId as string;
|
||||
const sse = useSSE(client, threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||
@@ -98,7 +97,6 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
|
||||
const handleGraphNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
// Only allow clicks on lit (non-default) nodes
|
||||
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
||||
|
||||
// __start__: scroll to the first record (thread-start prompt)
|
||||
@@ -163,7 +161,7 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
onClick={() => navigate(`/${client}/threads`)}
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { listThreads } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
client: string;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
export function ThreadList({ client, onSelect }: Props) {
|
||||
export function ThreadList() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const client = params.client as string;
|
||||
const { status, data, error } = useFetch(() => listThreads(client), [client]);
|
||||
|
||||
if (status === "loading")
|
||||
@@ -31,7 +30,7 @@ export function ThreadList({ client, onSelect }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
key={t.threadId}
|
||||
onClick={() => onSelect(t.threadId)}
|
||||
onClick={() => navigate(`/${client}/threads/${t.threadId}`)}
|
||||
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
|
||||
import { getWorkflowDetail } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { Markdown } from "./markdown.tsx";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
client: string;
|
||||
workflowName: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
function versionCount(detail: WorkflowDetailData): number {
|
||||
return detail.history.length + 1;
|
||||
}
|
||||
@@ -274,7 +269,11 @@ function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDesc
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────
|
||||
|
||||
export function WorkflowDetail({ client, workflowName, onBack }: Props) {
|
||||
export function WorkflowDetail() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const client = params.client as string;
|
||||
const workflowName = params.workflowName as string;
|
||||
const { status, data, error } = useFetch(
|
||||
() => getWorkflowDetail(client, workflowName),
|
||||
[client, workflowName],
|
||||
@@ -316,7 +315,7 @@ export function WorkflowDetail({ client, workflowName, onBack }: Props) {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
onClick={() => navigate(`/${client}/workflows`)}
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { listWorkflows } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
client: string;
|
||||
onSelect: (name: string) => void;
|
||||
};
|
||||
|
||||
export function WorkflowList({ client, onSelect }: Props) {
|
||||
export function WorkflowList() {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const client = params.client as string;
|
||||
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
|
||||
|
||||
if (status === "loading")
|
||||
@@ -26,7 +25,7 @@ export function WorkflowList({ client, onSelect }: Props) {
|
||||
<button
|
||||
key={w.name}
|
||||
type="button"
|
||||
onClick={() => onSelect(w.name)}
|
||||
onClick={() => navigate(`/${client}/workflows/${w.name}`)}
|
||||
className="w-full text-left p-4 rounded-lg border hover:opacity-90"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router";
|
||||
import "./index.css";
|
||||
import { App } from "./app.tsx";
|
||||
import { router } from "./router.tsx";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createHashRouter, redirect } from "react-router";
|
||||
import { Layout } from "./app.tsx";
|
||||
import { ClientRedirect } from "./components/client-redirect.tsx";
|
||||
import { LoginPage } from "./components/login.tsx";
|
||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||
import { ThreadList } from "./components/thread-list.tsx";
|
||||
import { WorkflowDetail } from "./components/workflow-detail.tsx";
|
||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||
|
||||
export const router = createHashRouter([
|
||||
{
|
||||
path: "/login",
|
||||
Component: LoginPage,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
Component: Layout,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
Component: ClientRedirect,
|
||||
},
|
||||
{
|
||||
path: ":client/threads",
|
||||
Component: ThreadList,
|
||||
},
|
||||
{
|
||||
path: ":client/threads/:threadId",
|
||||
Component: ThreadDetail,
|
||||
},
|
||||
{
|
||||
path: ":client/workflows",
|
||||
Component: WorkflowList,
|
||||
},
|
||||
{
|
||||
path: ":client/workflows/:workflowName",
|
||||
Component: WorkflowDetail,
|
||||
},
|
||||
{
|
||||
path: ":client",
|
||||
loader: ({ params }) => redirect(`/${params.client}/threads`),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type View = "threads" | "workflows";
|
||||
|
||||
type HashRoute = {
|
||||
view: View;
|
||||
client: string | null;
|
||||
threadId: string | null;
|
||||
workflowName: string | null;
|
||||
};
|
||||
|
||||
function parseHash(hash: string): HashRoute {
|
||||
const raw = hash.replace(/^#\/?/, "");
|
||||
// Format: #client/threads/id or #client/workflows or #threads or #workflows
|
||||
const parts = raw.split("/");
|
||||
|
||||
// Check if first part is a known view
|
||||
if (parts[0] === "threads" || parts[0] === "workflows") {
|
||||
return {
|
||||
view: parts[0] as View,
|
||||
client: null,
|
||||
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||
workflowName: parts[0] === "workflows" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||
};
|
||||
}
|
||||
|
||||
// First part is client name
|
||||
const client = parts[0] || null;
|
||||
const viewPart = parts[1] ?? "threads";
|
||||
const view: View = viewPart === "workflows" ? "workflows" : "threads";
|
||||
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||
const workflowName = view === "workflows" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||
|
||||
return { view, client, threadId, workflowName };
|
||||
}
|
||||
|
||||
function buildHash(route: HashRoute): string {
|
||||
const prefix = route.client ? `${route.client}/` : "";
|
||||
if (route.view === "workflows") {
|
||||
if (route.workflowName !== null) {
|
||||
return `#${prefix}workflows/${route.workflowName}`;
|
||||
}
|
||||
return `#${prefix}workflows`;
|
||||
}
|
||||
if (route.threadId !== null) {
|
||||
return `#${prefix}threads/${route.threadId}`;
|
||||
}
|
||||
return `#${prefix}threads`;
|
||||
}
|
||||
|
||||
export function useHashRoute(): {
|
||||
view: View;
|
||||
client: string | null;
|
||||
threadId: string | null;
|
||||
workflowName: string | null;
|
||||
setView: (v: View) => void;
|
||||
setClient: (a: string | null) => void;
|
||||
setThreadId: (id: string | null) => void;
|
||||
setWorkflowName: (name: string | null) => void;
|
||||
} {
|
||||
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
||||
|
||||
useEffect(() => {
|
||||
function onHashChange(): void {
|
||||
setRoute(parseHash(window.location.hash));
|
||||
}
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback((next: HashRoute) => {
|
||||
const hash = buildHash(next);
|
||||
window.location.hash = hash;
|
||||
setRoute(next);
|
||||
}, []);
|
||||
|
||||
const setView = useCallback(
|
||||
(v: View) => navigate({ view: v, client: route.client, threadId: null, workflowName: null }),
|
||||
[navigate, route.client],
|
||||
);
|
||||
|
||||
const setClient = useCallback(
|
||||
(a: string | null) =>
|
||||
navigate({ view: route.view, client: a, threadId: null, workflowName: null }),
|
||||
[navigate, route.view],
|
||||
);
|
||||
|
||||
const setThreadId = useCallback(
|
||||
(id: string | null) =>
|
||||
navigate({ view: "threads", client: route.client, threadId: id, workflowName: null }),
|
||||
[navigate, route.client],
|
||||
);
|
||||
|
||||
const setWorkflowName = useCallback(
|
||||
(name: string | null) =>
|
||||
navigate({ view: "workflows", client: route.client, threadId: null, workflowName: name }),
|
||||
[navigate, route.client],
|
||||
);
|
||||
|
||||
return {
|
||||
view: route.view,
|
||||
client: route.client,
|
||||
threadId: route.threadId,
|
||||
workflowName: route.workflowName,
|
||||
setView,
|
||||
setClient,
|
||||
setThreadId,
|
||||
setWorkflowName,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user