refactor: introduce react-router

This commit is contained in:
2026-05-18 11:53:38 +08:00
parent 40530d757e
commit 6f68a5314b
13 changed files with 135 additions and 214 deletions
+1
View File
@@ -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": {
+7 -46
View File
@@ -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)",
+3 -2
View File
@@ -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,
};
}