Merge pull request 'jshang/optimize-dashboard-ui' (#308) from jshang/optimize-dashboard-ui into main
Reviewed-on: uncaged/workflow#308
This commit is contained in:
@@ -4,6 +4,14 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workflow Dashboard</title>
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem("theme");
|
||||
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,11 +13,23 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"shiki": "^4.0.2"
|
||||
"react-router": "^7.15.1",
|
||||
"shiki": "^4.0.2",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { createFilter, type Plugin } from "vite";
|
||||
|
||||
type LimitLineOverride = {
|
||||
files: string;
|
||||
maxReactFCLines: number | null;
|
||||
maxFileLines: number | null;
|
||||
};
|
||||
|
||||
type LimitLineOptions = {
|
||||
maxReactFCLines: number;
|
||||
maxFileLines: number;
|
||||
include: RegExp;
|
||||
exclude: RegExp | null;
|
||||
overrides: Array<LimitLineOverride>;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: LimitLineOptions = {
|
||||
maxReactFCLines: 300,
|
||||
maxFileLines: 600,
|
||||
include: /\.[tj]sx$/,
|
||||
exclude: null,
|
||||
overrides: [],
|
||||
};
|
||||
|
||||
type ResolvedLimits = {
|
||||
maxReactFCLines: number | null;
|
||||
maxFileLines: number | null;
|
||||
};
|
||||
|
||||
type ComponentInfo = {
|
||||
name: string;
|
||||
startLine: number;
|
||||
lineCount: number;
|
||||
};
|
||||
|
||||
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
|
||||
|
||||
// --- AST types (Rolldown ESTree subset) ---
|
||||
|
||||
type Identifier = {
|
||||
type: "Identifier";
|
||||
name: string;
|
||||
};
|
||||
|
||||
type MemberExpression = {
|
||||
type: "MemberExpression";
|
||||
object: AstExpression;
|
||||
property: Identifier;
|
||||
};
|
||||
|
||||
type CallExpression = {
|
||||
type: "CallExpression";
|
||||
callee: AstExpression;
|
||||
arguments: Array<AstExpression>;
|
||||
};
|
||||
|
||||
type AstExpression = Identifier | MemberExpression | CallExpression | {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type VariableDeclarator = {
|
||||
id: Identifier | null;
|
||||
init: AstExpression | null;
|
||||
};
|
||||
|
||||
type AstStatement = {
|
||||
type: string;
|
||||
id: Identifier | null;
|
||||
declaration: AstStatement | null;
|
||||
declarations: Array<VariableDeclarator>;
|
||||
body: Array<AstStatement>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type AstProgram = {
|
||||
type: "Program";
|
||||
body: Array<AstStatement>;
|
||||
};
|
||||
|
||||
// --- AST helpers ---
|
||||
|
||||
function isFunctionLike(node: AstExpression): boolean {
|
||||
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
||||
}
|
||||
|
||||
const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]);
|
||||
|
||||
function isWrapperCall(node: AstExpression): boolean {
|
||||
if (node.type !== "CallExpression") return false;
|
||||
const call = node as CallExpression;
|
||||
const callee = call.callee;
|
||||
|
||||
if (callee.type === "Identifier") {
|
||||
return WRAPPER_NAMES.has((callee as Identifier).name);
|
||||
}
|
||||
|
||||
if (callee.type === "MemberExpression") {
|
||||
const member = callee as MemberExpression;
|
||||
return member.property.type === "Identifier" && WRAPPER_NAMES.has(member.property.name);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractComponentNames(ast: AstProgram): Array<string> {
|
||||
const names: Array<string> = [];
|
||||
|
||||
for (const node of ast.body) {
|
||||
if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) {
|
||||
names.push(node.id.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type === "ExportNamedDeclaration" && node.declaration) {
|
||||
const decl = node.declaration;
|
||||
if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) {
|
||||
names.push(decl.id.name);
|
||||
continue;
|
||||
}
|
||||
if (decl.type === "VariableDeclaration") {
|
||||
collectNamesFromVarDeclaration(decl, names);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === "VariableDeclaration") {
|
||||
collectNamesFromVarDeclaration(node, names);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function collectNamesFromVarDeclaration(node: AstStatement, names: Array<string>): void {
|
||||
for (const declarator of node.declarations ?? []) {
|
||||
if (!declarator.id || !PASCAL_CASE.test(declarator.id.name) || !declarator.init) continue;
|
||||
const init = declarator.init;
|
||||
if (isFunctionLike(init)) {
|
||||
names.push(declarator.id.name);
|
||||
} else if (isWrapperCall(init)) {
|
||||
const args = (init as CallExpression).arguments;
|
||||
if (args.length > 0 && isFunctionLike(args[0])) {
|
||||
names.push(declarator.id.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Source measurement ---
|
||||
|
||||
function measureComponentInSource(name: string, lines: Array<string>): ComponentInfo | null {
|
||||
const fnPattern = new RegExp(`^(?:export\\s+)?function\\s+${name}\\s*[(<]`);
|
||||
const varPattern = new RegExp(`^(?:export\\s+)?const\\s+${name}\\s*[=:]`);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trimStart();
|
||||
const isFnDecl = fnPattern.test(trimmed);
|
||||
const isVarDecl = varPattern.test(trimmed);
|
||||
if (!isFnDecl && !isVarDecl) continue;
|
||||
|
||||
if (isFnDecl) {
|
||||
const result = measureFromParams(i, lines);
|
||||
if (result) return { ...result, name };
|
||||
return null;
|
||||
}
|
||||
const result = measureFromArrow(i, lines);
|
||||
if (result) return { ...result, name };
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// function Foo(...) { ... } — skip params via parens, then brace-match the body
|
||||
function measureFromParams(startLine: number, lines: Array<string>): ComponentInfo | null {
|
||||
let parenDepth = 0;
|
||||
let pastParams = false;
|
||||
let braceDepth = 0;
|
||||
|
||||
for (let j = startLine; j < lines.length; j++) {
|
||||
for (const ch of lines[j]) {
|
||||
if (!pastParams) {
|
||||
if (ch === "(") parenDepth++;
|
||||
else if (ch === ")") {
|
||||
parenDepth--;
|
||||
if (parenDepth === 0) pastParams = true;
|
||||
}
|
||||
} else {
|
||||
if (ch === "{") braceDepth++;
|
||||
else if (ch === "}") {
|
||||
braceDepth--;
|
||||
if (braceDepth === 0) {
|
||||
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// const Foo = (...) => { ... } / const Foo = memo((...) => { ... })
|
||||
// Find `=>` first, then brace-match from there to skip type annotations in params
|
||||
function measureFromArrow(startLine: number, lines: Array<string>): ComponentInfo | null {
|
||||
let arrowFound = false;
|
||||
let braceDepth = 0;
|
||||
let foundBrace = false;
|
||||
|
||||
for (let j = startLine; j < lines.length; j++) {
|
||||
const line = lines[j];
|
||||
for (let c = 0; c < line.length; c++) {
|
||||
if (!arrowFound) {
|
||||
if (line[c] === "=" && line[c + 1] === ">") {
|
||||
arrowFound = true;
|
||||
c++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line[c] === "{") {
|
||||
braceDepth++;
|
||||
foundBrace = true;
|
||||
} else if (line[c] === "}") {
|
||||
braceDepth--;
|
||||
if (foundBrace && braceDepth === 0) {
|
||||
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Config resolution ---
|
||||
|
||||
function createLimitResolver(options: LimitLineOptions): (id: string) => ResolvedLimits {
|
||||
const matchers = options.overrides.map((override) => ({
|
||||
match: createFilter(override.files),
|
||||
maxReactFCLines: override.maxReactFCLines,
|
||||
maxFileLines: override.maxFileLines,
|
||||
}));
|
||||
|
||||
return (id: string): ResolvedLimits => {
|
||||
let maxReactFCLines: number | null = options.maxReactFCLines;
|
||||
let maxFileLines: number | null = options.maxFileLines;
|
||||
|
||||
for (const matcher of matchers) {
|
||||
if (matcher.match(id)) {
|
||||
maxReactFCLines = matcher.maxReactFCLines;
|
||||
maxFileLines = matcher.maxFileLines;
|
||||
}
|
||||
}
|
||||
|
||||
return { maxReactFCLines, maxFileLines };
|
||||
};
|
||||
}
|
||||
|
||||
function shouldProcess(id: string, options: LimitLineOptions): boolean {
|
||||
return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id));
|
||||
}
|
||||
|
||||
// --- Plugin ---
|
||||
|
||||
function viteLimitLinePlugin(
|
||||
userOptions: Partial<LimitLineOptions> = {},
|
||||
): Array<Plugin> {
|
||||
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
|
||||
const resolve = createLimitResolver(options);
|
||||
|
||||
const rawCodeCache = new Map<string, string>();
|
||||
|
||||
return [
|
||||
{
|
||||
name: "vite-plugin-limit-line:pre",
|
||||
enforce: "pre",
|
||||
|
||||
transform(code, id) {
|
||||
if (!shouldProcess(id, options)) return null;
|
||||
|
||||
rawCodeCache.set(id, code);
|
||||
|
||||
const limits = resolve(id);
|
||||
if (limits.maxFileLines === null) return null;
|
||||
|
||||
const totalLines = code.split("\n").length;
|
||||
if (totalLines > limits.maxFileLines) {
|
||||
this.error(
|
||||
[
|
||||
`[vite-limit-line] File too long: ${totalLines} lines (limit: ${limits.maxFileLines})`,
|
||||
` file: ${id}`,
|
||||
"",
|
||||
"How to fix:",
|
||||
" Split this file into smaller modules — extract related types, helpers,",
|
||||
" or sub-components into separate files and re-export from an index.ts.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vite-plugin-limit-line:fc",
|
||||
|
||||
transform(code, id) {
|
||||
if (!shouldProcess(id, options)) return null;
|
||||
|
||||
const limits = resolve(id);
|
||||
if (limits.maxReactFCLines === null) return null;
|
||||
|
||||
const ast = this.parse(code) as unknown as AstProgram;
|
||||
const componentNames = extractComponentNames(ast);
|
||||
if (componentNames.length === 0) return null;
|
||||
|
||||
const raw = rawCodeCache.get(id) ?? code;
|
||||
rawCodeCache.delete(id);
|
||||
const rawLines = raw.split("\n");
|
||||
|
||||
const maxFCLines = limits.maxReactFCLines;
|
||||
const violations: Array<ComponentInfo> = [];
|
||||
for (const name of componentNames) {
|
||||
const info = measureComponentInSource(name, rawLines);
|
||||
if (info && info.lineCount > maxFCLines) {
|
||||
violations.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
const details = violations
|
||||
.map(
|
||||
(v) =>
|
||||
` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${maxFCLines})`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
this.error(
|
||||
[
|
||||
`[vite-limit-line] React component too long in ${id}:`,
|
||||
details,
|
||||
"",
|
||||
"How to fix:",
|
||||
" Break each oversized component into smaller ones. Extract reusable",
|
||||
" sections into child components, move complex logic into custom hooks,",
|
||||
" and keep each component focused on a single responsibility.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
buildEnd() {
|
||||
rawCodeCache.clear();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export { viteLimitLinePlugin };
|
||||
export type { LimitLineOptions, LimitLineOverride };
|
||||
@@ -1,75 +1,38 @@
|
||||
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";
|
||||
import { useTheme } from "./hooks/use-theme.tsx";
|
||||
|
||||
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);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
if (!authed) {
|
||||
return <LoginPage onLogin={() => setAuthed(true)} />;
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar
|
||||
view={view}
|
||||
client={client}
|
||||
onViewChange={setView}
|
||||
onClientChange={setClient}
|
||||
onLogout={() => {
|
||||
clearApiKey();
|
||||
setAuthed(false);
|
||||
}}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Loader2, Users } from "lucide-react";
|
||||
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 flex-col items-center justify-center h-full gap-3">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading clients...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "ok" && data.length > 0) {
|
||||
return <Navigate to={`/${data[0].name}/threads`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-sm font-medium">No client selected</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a client from the sidebar to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { AlertCircle, Loader2, Moon, Settings, Sun } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { setApiKey } from "../api.ts";
|
||||
import { useTheme } from "../hooks/use-theme.tsx";
|
||||
import { Button } from "./ui/button.tsx";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card.tsx";
|
||||
import { Input } from "./ui/input.tsx";
|
||||
|
||||
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);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -17,7 +21,6 @@ export function LoginPage({ onLogin }: Props) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Test the key by hitting the endpoints list
|
||||
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
||||
try {
|
||||
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
|
||||
@@ -40,56 +43,59 @@ export function LoginPage({ onLogin }: Props) {
|
||||
}
|
||||
|
||||
setApiKey(key.trim());
|
||||
onLogin();
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
<div
|
||||
className="p-8 rounded-lg border w-full max-w-sm"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
<div className="min-h-screen flex items-center justify-center bg-background relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-4 right-4 transition-colors duration-200"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}>
|
||||
⚙ Workflow Dashboard
|
||||
</h1>
|
||||
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
|
||||
Enter your API key to continue
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="API Key"
|
||||
className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !key.trim()}
|
||||
className="w-full px-3 py-2 rounded text-sm font-medium"
|
||||
style={{
|
||||
background: "var(--color-accent)",
|
||||
color: "var(--color-bg)",
|
||||
opacity: loading || !key.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{loading ? "Verifying..." : "Login"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</Button>
|
||||
<Card className="w-full max-w-sm shadow-lg transition-all duration-200 hover:shadow-xl hover:border-primary/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl tracking-tight">
|
||||
<Settings className="h-5 w-5" />
|
||||
Workflow Dashboard
|
||||
</CardTitle>
|
||||
<CardDescription>Enter your API key to continue</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="API Key"
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !key.trim()}
|
||||
className="w-full transition-all duration-200"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Verifying…
|
||||
</span>
|
||||
) : (
|
||||
"Login"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,19 +52,23 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
|
||||
|
||||
if (html !== null) {
|
||||
return (
|
||||
<div
|
||||
className="rounded overflow-x-auto text-xs my-2"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<div className="relative rounded-lg border border-border overflow-hidden my-3">
|
||||
{lang !== "text" && (
|
||||
<span className="absolute top-2 right-2 text-[10px] uppercase tracking-wider text-muted-foreground/70 font-mono">
|
||||
{lang}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className="overflow-x-auto text-xs"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="rounded overflow-x-auto text-xs my-2 p-3"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
<pre className="rounded-lg overflow-x-auto text-xs my-3 p-3 bg-muted/50 border border-border">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
@@ -80,8 +84,7 @@ export function Markdown({ content }: { content: string }) {
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="text-xs px-1 py-0.5 rounded"
|
||||
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
|
||||
className="bg-muted rounded px-1.5 py-0.5 text-[13px] font-mono text-foreground"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -91,7 +94,7 @@ export function Markdown({ content }: { content: string }) {
|
||||
return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||
},
|
||||
p({ children }) {
|
||||
return <p className="my-1.5 leading-relaxed">{children}</p>;
|
||||
return <p className="my-2 leading-relaxed">{children}</p>;
|
||||
},
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc pl-4 my-1.5">{children}</ul>;
|
||||
@@ -100,20 +103,25 @@ export function Markdown({ content }: { content: string }) {
|
||||
return <ol className="list-decimal pl-4 my-1.5">{children}</ol>;
|
||||
},
|
||||
h1({ children }) {
|
||||
return <h1 className="text-lg font-bold mt-3 mb-1">{children}</h1>;
|
||||
return (
|
||||
<h1 className="text-lg font-bold mt-3 mb-2 border-b border-border pb-1">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-base font-bold mt-2 mb-1">{children}</h2>;
|
||||
return (
|
||||
<h2 className="text-base font-bold mt-2 mb-2 border-b border-border pb-1">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote
|
||||
className="border-l-2 pl-3 my-2 text-sm"
|
||||
style={{ borderColor: "var(--color-accent)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<blockquote className="border-l-2 border-ring pl-3 my-2 text-sm text-muted-foreground bg-muted/30 rounded-r-md py-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { CheckCircle2, Clock, MessageSquare, Rocket, User, XCircle } from "lucide-react";
|
||||
import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
|
||||
import { cn } from "../lib/utils.ts";
|
||||
import { Markdown } from "./markdown.tsx";
|
||||
import { Badge } from "./ui/badge.tsx";
|
||||
import { Card } from "./ui/card.tsx";
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
preparer: "#8b5cf6",
|
||||
client: "#3b82f6",
|
||||
extractor: "#f59e0b",
|
||||
};
|
||||
const ROLE_HUES = [262, 210, 35, 150, 330, 180, 15, 280, 55, 195, 345, 120, 240, 75, 305];
|
||||
|
||||
function roleColor(role: string): string {
|
||||
return ROLE_COLORS[role] ?? "var(--color-accent)";
|
||||
function roleHue(role: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < role.length; i++) {
|
||||
hash = (hash * 31 + role.charCodeAt(i)) | 0;
|
||||
}
|
||||
return ROLE_HUES[Math.abs(hash) % ROLE_HUES.length];
|
||||
}
|
||||
|
||||
function roleBadgeStyle(role: string): { backgroundColor: string; borderColor: string } {
|
||||
const hue = roleHue(role);
|
||||
return {
|
||||
backgroundColor: `oklch(0.58 0.12 ${hue} / 0.85)`,
|
||||
borderColor: `oklch(0.58 0.12 ${hue} / 0.25)`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(ts: number | null): string | null {
|
||||
@@ -18,99 +30,86 @@ function formatTime(ts: number | null): string | null {
|
||||
|
||||
function StartCard({ record }: { record: ThreadStartRecord }) {
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-lg border"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<Card className="p-4 transition-all duration-200 overflow-hidden relative">
|
||||
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-primary/80 via-primary/40 to-transparent" />
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">🚀</span>
|
||||
<span className="font-semibold" style={{ color: "var(--color-accent)" }}>
|
||||
{record.workflow}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background: record.status === "active" ? "var(--color-success)" : "var(--color-border)",
|
||||
color: record.status === "active" ? "var(--color-bg)" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<Rocket className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold text-foreground">{record.workflow}</span>
|
||||
<Badge variant={record.status === "active" ? "success" : "secondary"}>
|
||||
{record.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{record.prompt !== null && (
|
||||
<div
|
||||
className="mt-2 p-3 rounded text-sm border-l-2"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-accent)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
<div className="text-xs mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
<div className="mt-2 p-3 rounded-md text-sm border-l-2 border-ring bg-muted/50">
|
||||
<div className="text-xs mb-1 text-muted-foreground flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Prompt
|
||||
</div>
|
||||
<Markdown content={record.prompt} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
|
||||
const color = roleColor(record.role);
|
||||
const style = roleBadgeStyle(record.role);
|
||||
return (
|
||||
<div
|
||||
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
<Card
|
||||
className={cn(
|
||||
"p-3 text-sm transition-all duration-200 border-l-4",
|
||||
highlighted && "wf-record-card-highlight",
|
||||
)}
|
||||
style={{ borderLeftColor: style.borderColor }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded font-mono font-medium"
|
||||
style={{ background: color, color: "#fff" }}
|
||||
className="text-xs px-2 py-0.5 rounded font-mono font-medium text-white shadow-sm inline-flex items-center gap-1"
|
||||
style={{ backgroundColor: style.backgroundColor }}
|
||||
>
|
||||
<User className="h-3 w-3" />
|
||||
{record.role}
|
||||
</span>
|
||||
{formatTime(record.timestamp) !== null && (
|
||||
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||
<span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(record.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Markdown content={record.content} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultCard({ record }: { record: WorkflowResultRecord }) {
|
||||
const success = record.returnCode === 0;
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-lg border"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: success ? "var(--color-success)" : "var(--color-error)",
|
||||
}}
|
||||
<Card
|
||||
className={cn(
|
||||
"p-4 transition-all duration-200 border-l-4",
|
||||
success ? "border-l-success" : "border-l-destructive",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">{success ? "✅" : "❌"}</span>
|
||||
{success ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-success" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
)}
|
||||
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded font-mono"
|
||||
style={{
|
||||
background: success ? "var(--color-success)" : "var(--color-error)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
exit {record.returnCode}
|
||||
</span>
|
||||
</Badge>
|
||||
{formatTime(record.timestamp) !== null && (
|
||||
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
|
||||
<span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(record.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Markdown content={record.content} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { listWorkflows, runThread } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { Button } from "./ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
|
||||
import { Textarea } from "./ui/textarea.tsx";
|
||||
|
||||
type Props = {
|
||||
client: string;
|
||||
onClose: () => void;
|
||||
onCreated: (threadId: string) => void;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function RunDialog({ client, onClose, onCreated }: Props) {
|
||||
export function RunDialog({ client, open, onOpenChange }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const workflows = useFetch(() => listWorkflows(client), [client]);
|
||||
const [workflow, setWorkflow] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
@@ -22,7 +35,8 @@ export function RunDialog({ client, onClose, onCreated }: Props) {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await runThread(client, workflow, prompt);
|
||||
onCreated(result.threadId);
|
||||
onOpenChange(false);
|
||||
navigate(`/${client}/threads/${result.threadId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setSubmitting(false);
|
||||
@@ -30,95 +44,54 @@ export function RunDialog({ client, onClose, onCreated }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{ background: "rgba(0,0,0,0.6)" }}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg p-6 rounded-lg border"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Run Thread</DialogTitle>
|
||||
<DialogDescription>Start a new thread on {client}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="run-workflow"
|
||||
className="text-sm block mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<label htmlFor="run-workflow" className="text-sm block mb-1.5 text-muted-foreground">
|
||||
Workflow
|
||||
</label>
|
||||
<select
|
||||
id="run-workflow"
|
||||
value={workflow}
|
||||
onChange={(e) => setWorkflow(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
<option value="">Select a workflow...</option>
|
||||
{workflows.status === "ok" &&
|
||||
workflows.data.workflows.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Select value={workflow} onValueChange={setWorkflow}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a workflow..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{workflows.status === "ok" &&
|
||||
workflows.data.workflows.map((w) => (
|
||||
<SelectItem key={w.name} value={w.name}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="run-prompt"
|
||||
className="text-sm block mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<label htmlFor="run-prompt" className="text-sm block mb-1.5 text-muted-foreground">
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id="run-prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
placeholder="Enter the task prompt..."
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm" style={{ color: "var(--color-error)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded border"
|
||||
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !workflow || !prompt}
|
||||
className="px-4 py-2 text-sm rounded"
|
||||
style={{
|
||||
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
|
||||
color: "#fff",
|
||||
opacity: !workflow || !prompt ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting || !workflow || !prompt}>
|
||||
{submitting ? "Starting..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,109 +1,131 @@
|
||||
import { useEffect } from "react";
|
||||
import { Loader2, LogOut, Moon, Package, Sun, Zap } from "lucide-react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router";
|
||||
import type { ClientEndpoint } from "../api.ts";
|
||||
import { listClients } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { cn } from "../lib/utils.ts";
|
||||
import { Button } from "./ui/button.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
|
||||
import { Separator } from "./ui/separator.tsx";
|
||||
|
||||
type Props = {
|
||||
view: "threads" | "workflows";
|
||||
client: string | null;
|
||||
onViewChange: (v: "threads" | "workflows") => void;
|
||||
onClientChange: (a: string | null) => void;
|
||||
onLogout: () => void;
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
};
|
||||
|
||||
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
|
||||
export function Sidebar({ onLogout, theme, onToggleTheme }: 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: "⚡" },
|
||||
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
|
||||
{ key: "threads" as const, label: "Threads", icon: Zap },
|
||||
{ key: "workflows" as const, label: "Workflows", icon: Package },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="w-56 border-r flex flex-col"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
|
||||
⚙ Workflow
|
||||
</h1>
|
||||
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Dashboard
|
||||
</p>
|
||||
<aside className="w-56 border-r border-border flex flex-col bg-sidebar">
|
||||
<div className="p-4 border-b border-primary/20">
|
||||
<h1 className="text-xl font-bold text-foreground tracking-tight">Workflow</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">Dashboard</p>
|
||||
</div>
|
||||
|
||||
{/* Client selector */}
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<div className="px-3 py-3">
|
||||
<label
|
||||
className="block text-xs font-medium mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
className="block text-xs font-medium mb-1.5 text-muted-foreground"
|
||||
htmlFor="client-select"
|
||||
>
|
||||
Client
|
||||
</label>
|
||||
<select
|
||||
id="client-select"
|
||||
className="w-full rounded px-2 py-1.5 text-xs"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
value={client ?? ""}
|
||||
onChange={(e) => onClientChange(e.target.value || null)}
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<option value="">Loading…</option>
|
||||
) : clients.length === 0 ? (
|
||||
<option value="">No clients online</option>
|
||||
) : (
|
||||
clients.map((a) => (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.status === "online" ? "🟢" : "🔴"} {a.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</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)}
|
||||
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
|
||||
style={{
|
||||
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
|
||||
color: view === item.key ? "#fff" : "var(--color-text-muted)",
|
||||
{status === "loading" ? (
|
||||
<div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
<div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center">
|
||||
No clients online
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={client ?? ""}
|
||||
onValueChange={(name) => {
|
||||
if (name) navigate(`/${name}/${view}`);
|
||||
}}
|
||||
>
|
||||
{item.icon} {item.label}
|
||||
</button>
|
||||
<SelectTrigger className="h-8 text-xs transition-colors duration-200">
|
||||
<SelectValue placeholder="Select client…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((a) => (
|
||||
<SelectItem key={a.name} value={a.name} className="text-xs">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
a.status === "online" ? "bg-success animate-pulse" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
{a.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<nav className="flex-1 p-2 space-y-1">
|
||||
{viewItems.map((item) => (
|
||||
<Button
|
||||
key={item.key}
|
||||
variant={view === item.key ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full justify-start gap-2 transition-colors duration-200",
|
||||
view === item.key
|
||||
? "text-foreground border-l-2 border-primary rounded-l-none"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (client) navigate(`/${client}/${item.key}`);
|
||||
}}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-2 border-t" style={{ borderColor: "var(--color-border)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="w-full text-left px-3 py-2 rounded text-xs transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
<Separator />
|
||||
|
||||
<div className="p-2 space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
onClick={onToggleTheme}
|
||||
>
|
||||
🚪 Logout
|
||||
</button>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Loader2, Play, Wifi, WifiOff } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getClientHealth } from "../api.ts";
|
||||
import { Button } from "./ui/button.tsx";
|
||||
|
||||
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
||||
|
||||
@@ -8,14 +10,29 @@ type Props = {
|
||||
onRun: () => void;
|
||||
};
|
||||
|
||||
function statusLabel(status: HealthStatus): { text: string; color: string } {
|
||||
function StatusIndicator({ status }: { status: HealthStatus }) {
|
||||
if (status === "connected") {
|
||||
return { text: "● Connected", color: "var(--color-success)" };
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-success transition-colors duration-200">
|
||||
<Wifi className="h-3.5 w-3.5" />
|
||||
Connected
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "reconnecting") {
|
||||
return { text: "● Reconnecting...", color: "var(--color-warning, #f59e0b)" };
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-warning transition-colors duration-200">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Reconnecting…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return { text: "● Offline", color: "var(--color-error)" };
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-destructive transition-colors duration-200">
|
||||
<WifiOff className="h-3.5 w-3.5" />
|
||||
Offline
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBar({ client, onRun }: Props) {
|
||||
@@ -48,32 +65,24 @@ export function StatusBar({ client, onRun }: Props) {
|
||||
return () => clearInterval(interval);
|
||||
}, [checkHealth]);
|
||||
|
||||
const label = statusLabel(status);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-2 text-xs border-b"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-2 text-xs border-b border-border bg-card/80 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
<span className="text-muted-foreground">
|
||||
{client ? `Client: ${client}` : "No client selected"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={!client}
|
||||
className="px-3 py-1 rounded text-xs font-medium"
|
||||
style={{
|
||||
background: client ? "var(--color-accent)" : "var(--color-border)",
|
||||
color: "#fff",
|
||||
opacity: client ? 1 : 0.5,
|
||||
}}
|
||||
onClick={onRun}
|
||||
className="h-7 gap-1.5 transition-all duration-200"
|
||||
>
|
||||
▶ Run Thread
|
||||
</button>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Run Thread
|
||||
</Button>
|
||||
</div>
|
||||
<span style={{ color: label.color }}>{label.text}</span>
|
||||
<StatusIndicator status={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AlertCircle, ArrowLeft, Layers, Loader2, Pause, Play, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
getThread,
|
||||
getWorkflowDescriptor,
|
||||
@@ -11,14 +13,12 @@ import {
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { useSSE } from "../use-sse.ts";
|
||||
import { RecordCard } from "./record-card.tsx";
|
||||
import { Badge } from "./ui/badge.tsx";
|
||||
import { Button } from "./ui/button.tsx";
|
||||
import { Card } from "./ui/card.tsx";
|
||||
import { ResizablePanel } from "./ui/resizable-panel.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,36 +53,11 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
||||
return states;
|
||||
}
|
||||
|
||||
function isClickableGraphNode(nodeStates: Map<string, NodeState>, nodeId: string): boolean {
|
||||
const state = nodeStates.get(nodeId);
|
||||
return state !== undefined && state !== "default";
|
||||
}
|
||||
|
||||
function scrollToFirstRecord(): void {
|
||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function scrollToRoleOccurrence(
|
||||
nodeId: string,
|
||||
indicesByRole: Map<string, number[]>,
|
||||
clickCycleRef: { current: Map<string, number> },
|
||||
onHighlight: (role: string) => void,
|
||||
): void {
|
||||
const indices = indicesByRole.get(nodeId);
|
||||
if (indices === undefined || indices.length === 0) return;
|
||||
|
||||
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
|
||||
const idx = indices[cycle % indices.length];
|
||||
clickCycleRef.current.set(nodeId, cycle + 1);
|
||||
|
||||
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
||||
if (el === null) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
onHighlight(nodeId);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -122,32 +97,42 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
return m;
|
||||
}, [records]);
|
||||
|
||||
// Track which occurrence to jump to next per role (cycling)
|
||||
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const highlightRole = useCallback((role: string) => {
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(role);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
const handleGraphNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (!isClickableGraphNode(nodeStates, nodeId)) return;
|
||||
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
||||
|
||||
if (nodeId === "__start__") {
|
||||
scrollToFirstRecord();
|
||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodeId === "__end__") {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
return;
|
||||
}
|
||||
scrollToRoleOccurrence(nodeId, indicesByRole, clickCycleRef, highlightRole);
|
||||
|
||||
const indices = indicesByRole.get(nodeId);
|
||||
if (indices === undefined || indices.length === 0) return;
|
||||
|
||||
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
|
||||
const idx = indices[cycle % indices.length];
|
||||
clickCycleRef.current.set(nodeId, cycle + 1);
|
||||
|
||||
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
||||
if (el !== null) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(nodeId);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}
|
||||
},
|
||||
[nodeStates, indicesByRole, highlightRole],
|
||||
[nodeStates, indicesByRole],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -166,7 +151,7 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
try {
|
||||
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||
await fn(client, threadId);
|
||||
setActionStatus(`${action} sent ✓`);
|
||||
setActionStatus(null);
|
||||
} catch (e) {
|
||||
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
@@ -175,88 +160,84 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
onClick={() => navigate(`/${client}/threads`)}
|
||||
>
|
||||
← Back to threads
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to threads
|
||||
</Button>
|
||||
<div className="flex gap-1 rounded-lg border border-border bg-muted/30 p-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="transition-colors duration-200"
|
||||
onClick={() => handleAction("pause")}
|
||||
className="px-3 py-1 text-xs rounded border"
|
||||
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
|
||||
>
|
||||
⏸ Pause
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<Pause className="h-3.5 w-3.5 text-warning" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="transition-colors duration-200"
|
||||
onClick={() => handleAction("resume")}
|
||||
className="px-3 py-1 text-xs rounded border"
|
||||
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
|
||||
>
|
||||
▶ Resume
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<Play className="h-3.5 w-3.5 text-success" />
|
||||
Resume
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="transition-colors duration-200"
|
||||
onClick={() => handleAction("kill")}
|
||||
className="px-3 py-1 text-xs rounded border"
|
||||
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
|
||||
>
|
||||
✕ Kill
|
||||
</button>
|
||||
<X className="h-3.5 w-3.5 text-destructive" />
|
||||
Kill
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-xl font-semibold mb-2 font-mono tracking-tight flex items-center gap-2 flex-wrap">
|
||||
<span>{threadId}</span>
|
||||
{sse.connected && !sse.completed && (
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded"
|
||||
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
|
||||
>
|
||||
<Badge variant="success" className="animate-pulse flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-success-foreground" />
|
||||
Live
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
{actionStatus && (
|
||||
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
|
||||
<Badge variant="secondary" className="mb-4 text-xs font-normal">
|
||||
{actionStatus}
|
||||
</p>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
|
||||
{descriptor !== null && descriptor.graph.edges.length > 0 && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
<ResizablePanel
|
||||
defaultWidth={360}
|
||||
minWidth={240}
|
||||
maxWidth={560}
|
||||
className={null}
|
||||
style={{
|
||||
width: 280,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
height: "calc(100vh - 120px)",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border h-full flex flex-col overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="font-mono">
|
||||
<Card className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground bg-muted/50 border-b border-border">
|
||||
<span className="font-mono flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
Workflow graph
|
||||
{workflowName !== null && (
|
||||
<span className="ml-2" style={{ color: "var(--color-text)" }}>
|
||||
{workflowName}
|
||||
</span>
|
||||
<span className="ml-2 text-foreground">{workflowName}</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<span className="tabular-nums">
|
||||
{descriptor.graph.edges.length} edge
|
||||
{descriptor.graph.edges.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
@@ -269,19 +250,25 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</ResizablePanel>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{status === "loading" && !liveActive && records.length === 0 && (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-3">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-sm">Loading thread...</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && !liveActive && (
|
||||
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
|
||||
<div className="flex items-center gap-2 py-8 justify-center text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span className="text-sm">Error: {error}</span>
|
||||
</div>
|
||||
)}
|
||||
{(status === "ok" || liveActive || records.length > 0) && (
|
||||
<div className="space-y-3">
|
||||
<div className="border-l-2 border-border ml-2 pl-4 space-y-3">
|
||||
{records.map((r, i) => {
|
||||
const key = `${threadId}-${i}`;
|
||||
if (r.type === "role") {
|
||||
@@ -292,18 +279,21 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
<div
|
||||
key={key}
|
||||
data-record-index={i}
|
||||
className="relative"
|
||||
ref={(el) => {
|
||||
if (!isFirstForRole) return;
|
||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
||||
else firstCardByRoleRef.current.delete(r.role);
|
||||
}}
|
||||
>
|
||||
<div className="absolute -left-[1.3rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-border bg-background" />
|
||||
<RecordCard record={r} highlighted={flash} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={key} data-record-index={i}>
|
||||
<div key={key} data-record-index={i} className="relative">
|
||||
<div className="absolute -left-[1.3rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-border bg-background" />
|
||||
<RecordCard record={r} highlighted={false} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { AlertCircle, Clock, Loader2, Workflow, Zap } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { listThreads } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { Badge } from "./ui/badge.tsx";
|
||||
import { Card } from "./ui/card.tsx";
|
||||
|
||||
type Props = {
|
||||
client: string;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
function statusVariant(status: string): "success" | "destructive" | "secondary" {
|
||||
if (status === "completed") return "success";
|
||||
if (status === "failed") return "destructive";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
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")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
||||
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading threads...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (status === "error")
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<p className="text-sm text-destructive">Error: {error}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const threads = [...data.threads].sort((a, b) => {
|
||||
if (!a.startedAt && !b.startedAt) return 0;
|
||||
@@ -22,51 +42,44 @@ export function ThreadList({ client, onSelect }: Props) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Threads</h2>
|
||||
<h2 className="text-xl font-semibold tracking-tight mb-4">Threads</h2>
|
||||
{threads.length === 0 ? (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>No threads found.</p>
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<Zap className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-sm font-medium">No threads</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run a workflow to create your first thread.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{threads.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
<Card
|
||||
key={t.threadId}
|
||||
onClick={() => onSelect(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)" }}
|
||||
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
|
||||
onClick={() => navigate(`/${client}/threads/${t.threadId}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
|
||||
{t.threadId}
|
||||
</code>
|
||||
<code className="font-mono text-sm text-foreground">{t.threadId}</code>
|
||||
{t.status && (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background:
|
||||
t.status === "completed"
|
||||
? "var(--color-success)"
|
||||
: t.status === "failed"
|
||||
? "var(--color-error)"
|
||||
: "var(--color-accent)",
|
||||
color: "#000",
|
||||
}}
|
||||
>
|
||||
<Badge variant={statusVariant(t.status)} className="text-xs">
|
||||
{t.status}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{t.workflow && (
|
||||
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
<p className="text-sm mt-1 font-medium text-foreground flex items-center gap-1.5">
|
||||
<Workflow className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{t.workflow}
|
||||
</p>
|
||||
)}
|
||||
{t.startedAt && (
|
||||
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
<p className="text-xs mt-1 text-muted-foreground flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t.startedAt}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground shadow",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-success text-success-foreground shadow",
|
||||
warning: "border-transparent bg-warning text-warning-foreground shadow",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type BadgeProps = HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
success: "border border-success text-success hover:bg-success/10",
|
||||
warning: "border border-warning text-warning hover:bg-warning/10",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
|
||||
@@ -0,0 +1,104 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import type { ComponentPropsWithoutRef, HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Input({ className, type, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
type Props = {
|
||||
defaultWidth: number;
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
className: string | null;
|
||||
style: CSSProperties | null;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ResizablePanel({
|
||||
defaultWidth,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}: Props) {
|
||||
const [width, setWidth] = useState(defaultWidth);
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startW = useRef(0);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
dragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
startW.current = width;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[width],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging.current) return;
|
||||
const delta = e.clientX - startX.current;
|
||||
const next = Math.min(maxWidth, Math.max(minWidth, startW.current + delta));
|
||||
setWidth(next);
|
||||
},
|
||||
[minWidth, maxWidth],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
dragging.current = false;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative shrink-0", className)}
|
||||
style={{ ...style, width }}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className="absolute top-0 -right-1 w-2 h-full cursor-col-resize z-10 group"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-1/2 w-px bg-border opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
|
||||
return (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return (
|
||||
<tfoot
|
||||
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<td
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({ className, ...props }: HTMLAttributes<HTMLTableCaptionElement>) {
|
||||
return <caption className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
@@ -1,22 +1,50 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
Hash,
|
||||
Layers,
|
||||
Loader2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
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 { cn } from "../lib/utils.ts";
|
||||
import { Markdown } from "./markdown.tsx";
|
||||
import { Button } from "./ui/button.tsx";
|
||||
import { Card } from "./ui/card.tsx";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible.tsx";
|
||||
import { ResizablePanel } from "./ui/resizable-panel.tsx";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table.tsx";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
client: string;
|
||||
workflowName: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
const ROLE_BORDER_COLORS = [
|
||||
"border-l-blue-400/60",
|
||||
"border-l-emerald-400/60",
|
||||
"border-l-amber-400/60",
|
||||
"border-l-violet-400/60",
|
||||
"border-l-rose-400/60",
|
||||
"border-l-cyan-400/60",
|
||||
"border-l-orange-400/60",
|
||||
"border-l-teal-400/60",
|
||||
];
|
||||
|
||||
function roleBorderColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
||||
}
|
||||
return ROLE_BORDER_COLORS[Math.abs(hash) % ROLE_BORDER_COLORS.length];
|
||||
}
|
||||
|
||||
function versionCount(detail: WorkflowDetailData): number {
|
||||
return detail.history.length + 1;
|
||||
}
|
||||
|
||||
// ── Schema rendering helpers ────────────────────────────────────────
|
||||
|
||||
type SchemaRow = {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -39,119 +67,66 @@ function resolveType(prop: Record<string, unknown>): string {
|
||||
return String(prop.type ?? "unknown");
|
||||
}
|
||||
|
||||
function variantLabel(
|
||||
variantProps: Record<string, Record<string, unknown>>,
|
||||
variantIndex: number,
|
||||
): string {
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) return `${pName}: ${String(pDef.const)}`;
|
||||
}
|
||||
return `Variant ${variantIndex + 1}`;
|
||||
}
|
||||
|
||||
function childPrefixForDepth(depth: number, parentPrefix: string): string {
|
||||
return depth > 0 ? `${parentPrefix} ` : " ";
|
||||
}
|
||||
|
||||
function flattenOneOfVariants(
|
||||
oneOf: Array<Record<string, unknown>>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||
const variant = oneOf[vi];
|
||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const isLast = vi === oneOf.length - 1;
|
||||
const connector = isLast ? "└" : "├";
|
||||
rows.push({
|
||||
key: `${keyPrefix}variant-${vi}`,
|
||||
name: `${parentPrefix}${connector} ${variantLabel(variantProps, vi)}`,
|
||||
type: "",
|
||||
description: "",
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: true,
|
||||
});
|
||||
const variantChildPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||
const variantRequired = new Set<string>(
|
||||
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||
);
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) continue;
|
||||
rows.push(
|
||||
...flattenProperty(
|
||||
pName,
|
||||
pDef,
|
||||
depth + 1,
|
||||
variantChildPrefix,
|
||||
`${keyPrefix}v${vi}-`,
|
||||
variantRequired,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenSchemaProperties(
|
||||
schema: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = new Set<string>(
|
||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||
);
|
||||
const rows: SchemaRow[] = [];
|
||||
for (const [name, prop] of Object.entries(props)) {
|
||||
rows.push(...flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenSchema(
|
||||
schema: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
|
||||
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||
return flattenOneOfVariants(oneOf, depth, parentPrefix, keyPrefix);
|
||||
}
|
||||
return flattenSchemaProperties(schema, depth, parentPrefix, keyPrefix);
|
||||
}
|
||||
|
||||
function flattenNestedPropertyRows(
|
||||
name: string,
|
||||
prop: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
hasOneOf: boolean,
|
||||
): SchemaRow[] {
|
||||
const childPrefix = childPrefixForDepth(depth, parentPrefix);
|
||||
const nestedKeyPrefix = `${keyPrefix}${name}-`;
|
||||
|
||||
if (prop.type === "object" && prop.properties !== undefined) {
|
||||
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
}
|
||||
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||
return flattenSchema(items, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||
const variant = oneOf[vi];
|
||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
let variantLabel = `Variant ${vi + 1}`;
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) {
|
||||
variantLabel = `${pName}: ${String(pDef.const)}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isLast = vi === oneOf.length - 1;
|
||||
const connector = isLast ? "└" : "├";
|
||||
rows.push({
|
||||
key: `${keyPrefix}variant-${vi}`,
|
||||
name: `${parentPrefix}${connector} ${variantLabel}`,
|
||||
type: "",
|
||||
description: "",
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: true,
|
||||
});
|
||||
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||
const variantRequired = new Set<string>(
|
||||
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||
);
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) continue;
|
||||
const subRows = flattenProperty(
|
||||
pName,
|
||||
pDef,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}v${vi}-`,
|
||||
variantRequired,
|
||||
);
|
||||
rows.push(...subRows);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (hasOneOf) {
|
||||
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = new Set<string>(
|
||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||
);
|
||||
for (const [name, prop] of Object.entries(props)) {
|
||||
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
|
||||
rows.push(...subRows);
|
||||
}
|
||||
|
||||
return [];
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenProperty(
|
||||
@@ -162,139 +137,145 @@ function flattenProperty(
|
||||
keyPrefix: string,
|
||||
required: Set<string>,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
||||
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
||||
if (!required.has(name)) type += "?";
|
||||
const description = String(prop.description ?? "");
|
||||
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
|
||||
|
||||
const rows: SchemaRow[] = [
|
||||
{
|
||||
key: `${keyPrefix}${name}`,
|
||||
name: depth > 0 ? `${parentPrefix}└─ ${name}` : name,
|
||||
type,
|
||||
description: String(prop.description ?? ""),
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: false,
|
||||
},
|
||||
];
|
||||
rows.push({
|
||||
key: `${keyPrefix}${name}`,
|
||||
name: displayName,
|
||||
type,
|
||||
description,
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: false,
|
||||
});
|
||||
|
||||
if (prop.type === "object" && prop.properties !== undefined) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(
|
||||
...flattenSchema(
|
||||
prop as Record<string, unknown>,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}${name}-`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`));
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOneOf) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(
|
||||
...flattenSchema(
|
||||
prop as Record<string, unknown>,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}${name}-`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(...flattenNestedPropertyRows(name, prop, depth, parentPrefix, keyPrefix, hasOneOf));
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Components ──────────────────────────────────────────────────────
|
||||
|
||||
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
|
||||
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
|
||||
const [promptOpen, setPromptOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`role-${roleName}`}
|
||||
className="rounded-lg border p-4"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<h4 className="text-sm font-semibold font-mono mb-1" style={{ color: "var(--color-text)" }}>
|
||||
<Card id={`role-${roleName}`} className={cn("p-4 border-l-4", roleBorderColor(roleName))}>
|
||||
<h4 className="text-sm font-semibold font-mono mb-1 text-foreground flex items-center gap-1.5">
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{roleName}
|
||||
</h4>
|
||||
{role.description !== "" && (
|
||||
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
|
||||
{role.description}
|
||||
</p>
|
||||
<p className="text-xs mb-3 text-muted-foreground">{role.description}</p>
|
||||
)}
|
||||
{role.systemPrompt !== "" && (
|
||||
<details className="mb-3">
|
||||
<summary
|
||||
className="text-[10px] uppercase tracking-wider font-medium cursor-pointer select-none"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
System Prompt
|
||||
</summary>
|
||||
<div
|
||||
className="mt-1 p-2 rounded overflow-y-auto text-xs"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
>
|
||||
<Markdown content={role.systemPrompt} />
|
||||
</div>
|
||||
</details>
|
||||
<Collapsible open={promptOpen} onOpenChange={setPromptOpen} className="mb-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1 h-7 px-2 text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/50 rounded-md transition-all duration-200"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn("h-3 w-3 transition-transform", promptOpen && "rotate-180")}
|
||||
/>
|
||||
System Prompt
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-1 p-2 rounded-md overflow-y-auto text-xs bg-background border border-border max-h-[300px]">
|
||||
<Markdown content={role.systemPrompt} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div>
|
||||
<p
|
||||
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-wider mb-1 font-medium text-muted-foreground">
|
||||
Meta Schema
|
||||
</p>
|
||||
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
|
||||
<th
|
||||
className="text-left py-1 pr-3 font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Field
|
||||
</th>
|
||||
<th
|
||||
className="text-left py-1 pr-3 font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
className="text-left py-1 font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs">Field</TableHead>
|
||||
<TableHead className="text-xs">Type</TableHead>
|
||||
<TableHead className="text-xs">Description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
<TableRow
|
||||
key={r.key}
|
||||
style={{
|
||||
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
|
||||
}}
|
||||
className={cn(r.isVariantHeader ? "border-b-0" : "", "even:bg-muted/30")}
|
||||
>
|
||||
<td
|
||||
className="py-1 pr-3 font-mono whitespace-pre"
|
||||
style={{
|
||||
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
|
||||
fontStyle: r.isVariantHeader ? "italic" : "normal",
|
||||
}}
|
||||
<TableCell
|
||||
className={cn(
|
||||
"font-mono whitespace-pre text-xs",
|
||||
r.isVariantHeader ? "italic text-muted-foreground" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{r.name}
|
||||
</td>
|
||||
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{r.type}
|
||||
</td>
|
||||
<td className="py-1" style={{ color: "var(--color-text)" }}>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{r.description || (r.isVariantHeader ? "" : "—")}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
||||
<pre
|
||||
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
|
||||
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<pre className="text-[10px] font-mono p-2 rounded-md overflow-x-auto bg-background text-muted-foreground">
|
||||
{JSON.stringify(role.schema, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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],
|
||||
@@ -334,44 +315,52 @@ export function WorkflowDetail({ client, workflowName, onBack }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-all duration-200"
|
||||
onClick={() => navigate(`/${client}/workflows`)}
|
||||
>
|
||||
← Back to workflows
|
||||
</button>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to workflows
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-4 font-mono">{workflowName}</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 font-mono tracking-tight">{workflowName}</h2>
|
||||
|
||||
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
|
||||
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
|
||||
{status === "loading" && (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading workflow...</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>Error: {error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail !== null && (
|
||||
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
|
||||
{/* Left: fixed graph sidebar */}
|
||||
{hasGraph && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
<ResizablePanel
|
||||
defaultWidth={360}
|
||||
minWidth={240}
|
||||
maxWidth={560}
|
||||
className={null}
|
||||
style={{
|
||||
width: 280,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
height: "calc(100vh - 160px)",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border h-full flex flex-col overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="font-mono">Workflow graph</span>
|
||||
<Card className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground bg-muted/50">
|
||||
<span className="font-mono flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
Workflow graph
|
||||
</span>
|
||||
<span>
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
@@ -384,52 +373,44 @@ export function WorkflowDetail({ client, workflowName, onBack }: Props) {
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</ResizablePanel>
|
||||
)}
|
||||
|
||||
{/* Right: scrollable content */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Workflow overview */}
|
||||
<div
|
||||
className="rounded-lg border p-4"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<p
|
||||
className="text-sm whitespace-pre-wrap mb-3"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{descriptor !== null && descriptor.description !== ""
|
||||
? descriptor.description
|
||||
: "—"}
|
||||
</p>
|
||||
<div className="flex gap-4 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
<span>
|
||||
Hash:{" "}
|
||||
<code className="font-mono" style={{ color: "var(--color-accent)" }}>
|
||||
{detail.hash}
|
||||
</code>
|
||||
<Card className="p-4">
|
||||
<div className="rounded-md bg-muted/30 px-3 py-2 mb-3">
|
||||
<p className="text-sm whitespace-pre-wrap text-foreground">
|
||||
{descriptor !== null && descriptor.description !== ""
|
||||
? descriptor.description
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-mono">
|
||||
<Hash className="h-3 w-3" />
|
||||
<span className="text-foreground">{detail.hash}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{roleEntries.length > 0 && (
|
||||
<span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Role cards */}
|
||||
{roleEntries.map(([name, role]) => (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
transition: "box-shadow 0.3s",
|
||||
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-lg transition-shadow duration-300",
|
||||
highlightedRole === name && "ring-2 ring-ring",
|
||||
)}
|
||||
>
|
||||
<RoleCard roleName={name} role={role} />
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
defaultLabelY = result[2];
|
||||
}
|
||||
|
||||
const stroke = "var(--color-accent)";
|
||||
const stroke = "hsl(var(--ring))";
|
||||
const label = isFallback ? "" : (edgeData?.condition ?? "");
|
||||
|
||||
// Use pre-computed label position if available, otherwise fall back to default
|
||||
@@ -107,9 +107,9 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
background: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
color: "hsl(var(--foreground))",
|
||||
whiteSpace: "nowrap",
|
||||
zIndex: 10,
|
||||
}}
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react";
|
||||
import { Check, Circle } from "lucide-react";
|
||||
import type { RoleNodeData } from "./types.ts";
|
||||
|
||||
function borderColor(state: RoleNodeData["state"]): string {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "var(--color-success)";
|
||||
return "hsl(var(--success))";
|
||||
case "active":
|
||||
return "var(--color-accent)";
|
||||
return "hsl(var(--ring))";
|
||||
default:
|
||||
return "var(--color-border)";
|
||||
return "hsl(var(--border))";
|
||||
}
|
||||
}
|
||||
|
||||
function stateIcon(state: RoleNodeData["state"]): string | null {
|
||||
if (state === "completed") return "✓";
|
||||
if (state === "active") return "●";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RoleNode(props: NodeProps) {
|
||||
const data = props.data as RoleNodeData;
|
||||
const icon = stateIcon(data.state);
|
||||
const isActive = data.state === "active";
|
||||
const handleStyle = {
|
||||
background: "var(--color-text-muted)",
|
||||
background: "hsl(var(--muted-foreground))",
|
||||
width: 6,
|
||||
height: 6,
|
||||
border: "none",
|
||||
@@ -35,9 +29,9 @@ export function RoleNode(props: NodeProps) {
|
||||
style={{
|
||||
width: 180,
|
||||
height: 60,
|
||||
background: "var(--color-surface)",
|
||||
background: "hsl(var(--card))",
|
||||
borderColor: borderColor(data.state),
|
||||
color: "var(--color-text)",
|
||||
color: "hsl(var(--foreground))",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
@@ -81,19 +75,15 @@ export function RoleNode(props: NodeProps) {
|
||||
isConnectable={false}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
{icon !== null && (
|
||||
<span
|
||||
style={{
|
||||
color: data.state === "active" ? "var(--color-accent)" : "var(--color-success)",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{data.state === "completed" && <Check className="h-3 w-3 text-success" />}
|
||||
{data.state === "active" && <Circle className="h-3 w-3 fill-current text-ring" />}
|
||||
<span className="truncate">{data.label}</span>
|
||||
</div>
|
||||
{data.description !== "" && (
|
||||
<div className="text-[10px] truncate mt-0.5" style={{ color: "var(--color-text-muted)" }}>
|
||||
<div
|
||||
className="text-[10px] truncate mt-0.5"
|
||||
style={{ color: "hsl(var(--muted-foreground))" }}
|
||||
>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react";
|
||||
import { Play, Square } from "lucide-react";
|
||||
import type { TerminalNodeData } from "./types.ts";
|
||||
|
||||
function borderColor(state: TerminalNodeData["state"]): string {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "var(--color-success)";
|
||||
return "hsl(var(--success))";
|
||||
case "active":
|
||||
return "var(--color-accent)";
|
||||
return "hsl(var(--ring))";
|
||||
default:
|
||||
return "var(--color-border)";
|
||||
return "hsl(var(--border))";
|
||||
}
|
||||
}
|
||||
|
||||
function bgColor(state: TerminalNodeData["state"]): string {
|
||||
if (state === "completed") return "var(--color-success)";
|
||||
if (state === "active") return "var(--color-accent)";
|
||||
return "var(--color-surface)";
|
||||
if (state === "completed") return "hsl(var(--success))";
|
||||
if (state === "active") return "hsl(var(--ring))";
|
||||
return "hsl(var(--card))";
|
||||
}
|
||||
|
||||
export function TerminalNode(props: NodeProps) {
|
||||
@@ -23,7 +24,7 @@ export function TerminalNode(props: NodeProps) {
|
||||
const isStart = data.kind === "start";
|
||||
const isActive = data.state === "active";
|
||||
const handleStyle = {
|
||||
background: "var(--color-text-muted)",
|
||||
background: "hsl(var(--muted-foreground))",
|
||||
width: 6,
|
||||
height: 6,
|
||||
border: "none",
|
||||
@@ -31,13 +32,16 @@ export function TerminalNode(props: NodeProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
|
||||
className={`rounded-full border-2 flex items-center justify-center ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
background: bgColor(data.state),
|
||||
borderColor: borderColor(data.state),
|
||||
color: data.state === "default" ? "var(--color-text-muted)" : "var(--color-bg)",
|
||||
color:
|
||||
data.state === "default"
|
||||
? "hsl(var(--muted-foreground))"
|
||||
: "hsl(var(--primary-foreground))",
|
||||
}}
|
||||
title={isStart ? "Start" : "End"}
|
||||
>
|
||||
@@ -74,7 +78,7 @@ export function TerminalNode(props: NodeProps) {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isStart ? "▶" : "■"}
|
||||
{isStart ? <Play className="h-3 w-3" /> : <Square className="h-3 w-3" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
type EdgeTypes,
|
||||
MarkerType,
|
||||
type Node,
|
||||
type NodeMouseHandler,
|
||||
type NodeTypes,
|
||||
type OnNodeClick,
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useMemo } from "react";
|
||||
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
||||
import { useTheme } from "../../hooks/use-theme.tsx";
|
||||
import { ConditionEdge } from "./condition-edge.tsx";
|
||||
import { RoleNode } from "./role-node.tsx";
|
||||
import { TerminalNode } from "./terminal-node.tsx";
|
||||
@@ -39,9 +40,12 @@ function handleNodeClick(onNodeClick: (nodeId: string) => void, node: Node): voi
|
||||
|
||||
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||
const { theme } = useTheme();
|
||||
|
||||
const onNodeClickHandler: OnNodeClick | undefined =
|
||||
onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
|
||||
const onNodeClickHandler: NodeMouseHandler | undefined =
|
||||
onNodeClick !== null
|
||||
? (_e: React.MouseEvent, node: Node) => handleNodeClick(onNodeClick, node)
|
||||
: undefined;
|
||||
|
||||
const styledEdges = useMemo(
|
||||
() =>
|
||||
@@ -51,7 +55,7 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 14,
|
||||
height: 14,
|
||||
color: "var(--color-text)",
|
||||
color: "hsl(var(--foreground))",
|
||||
},
|
||||
})),
|
||||
[layout.edges],
|
||||
@@ -72,10 +76,10 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode="dark"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
colorMode={theme}
|
||||
style={{ background: "hsl(var(--background))" }}
|
||||
>
|
||||
<Background color="var(--color-border)" gap={20} size={1} />
|
||||
<Background color="hsl(var(--border))" gap={20} size={1} />
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,65 @@
|
||||
import { AlertCircle, Clock, Hash, Loader2, Package } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { listWorkflows } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { Card } from "./ui/card.tsx";
|
||||
|
||||
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")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
||||
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading workflows...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (status === "error")
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<p className="text-sm text-destructive">Error: {error}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const workflows = data.workflows;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Workflows</h2>
|
||||
<h2 className="text-xl font-semibold tracking-tight mb-4">Workflows</h2>
|
||||
{workflows.length === 0 ? (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<Package className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-sm font-medium">No workflows</p>
|
||||
<p className="text-xs text-muted-foreground">Register a workflow to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{workflows.map((w) => (
|
||||
<button
|
||||
<Card
|
||||
key={w.name}
|
||||
type="button"
|
||||
onClick={() => onSelect(w.name)}
|
||||
className="w-full text-left p-4 rounded-lg border hover:opacity-90"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
|
||||
onClick={() => navigate(`/${client}/workflows/${w.name}`)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{w.name}</span>
|
||||
</div>
|
||||
<code
|
||||
className="text-xs mt-1 block font-mono truncate"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
<Package className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{w.name}
|
||||
</span>
|
||||
<code className="text-xs mt-1 font-mono text-muted-foreground flex items-center gap-1.5">
|
||||
<Hash className="h-3 w-3" />
|
||||
{w.hash !== null ? w.hash : "—"}
|
||||
</code>
|
||||
{w.timestamp !== null ? (
|
||||
<span className="text-xs mt-1 block" style={{ color: "var(--color-text-muted)" }}>
|
||||
<span className="text-xs mt-1 text-muted-foreground flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
Updated {new Date(w.timestamp).toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
setTheme: (t: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function getStoredTheme(): Theme | null {
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSystemTheme(): Theme {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme): void {
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => getStoredTheme() ?? getSystemTheme());
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
function handler() {
|
||||
if (getStoredTheme() === null) {
|
||||
const sys = getSystemTheme();
|
||||
setThemeState(sys);
|
||||
applyTheme(sys);
|
||||
}
|
||||
}
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
localStorage.setItem("theme", t);
|
||||
setThemeState(t);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((prev) => {
|
||||
const next = prev === "dark" ? "light" : "dark";
|
||||
localStorage.setItem("theme", next);
|
||||
applyTheme(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <ThemeContext value={{ theme, setTheme, toggleTheme }}>{children}</ThemeContext>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (ctx === null) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,32 +1,107 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius: 0.625rem;
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--color-success: hsl(var(--success));
|
||||
--color-success-foreground: hsl(var(--success-foreground));
|
||||
--color-warning: hsl(var(--warning));
|
||||
--color-warning-foreground: hsl(var(--warning-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--color-sidebar: hsl(var(--sidebar));
|
||||
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-bg: #0a0a0f;
|
||||
--color-surface: #12121a;
|
||||
--color-border: #1e1e2e;
|
||||
--color-text: #e4e4ef;
|
||||
--color-text-muted: #6b6b8a;
|
||||
--color-accent: #7c6df0;
|
||||
--color-accent-dim: #5a4db8;
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #f87171;
|
||||
--radius: 0.625rem;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--success: 160 60% 40%;
|
||||
--success-foreground: 0 0% 98%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--sidebar: 0 0% 98%;
|
||||
--sidebar-foreground: 240 3.8% 46.1%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 6% 6.5%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 6% 6.5%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--success: 160 60% 45%;
|
||||
--success-foreground: 0 0% 98%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--sidebar: 240 6% 6.5%;
|
||||
--sidebar-foreground: 240 5% 64.9%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes wf-node-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(124, 109, 240, 0.55);
|
||||
box-shadow: 0 0 0 0 hsl(var(--ring) / 0.55);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgba(124, 109, 240, 0);
|
||||
box-shadow: 0 0 0 6px hsl(var(--ring) / 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,13 +111,13 @@ body {
|
||||
|
||||
@keyframes wf-record-card-highlight {
|
||||
0% {
|
||||
border-color: var(--color-accent);
|
||||
border-color: hsl(var(--ring));
|
||||
}
|
||||
35% {
|
||||
border-color: var(--color-accent);
|
||||
border-color: hsl(var(--ring));
|
||||
}
|
||||
100% {
|
||||
border-color: var(--color-border);
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router";
|
||||
import { ThemeProvider } from "./hooks/use-theme.tsx";
|
||||
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 />
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -4,7 +4,9 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"types": ["vite/client"],
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -13,5 +15,5 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "plugins"]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { viteLimitLinePlugin } from "./plugins/vite-limit-line-plugin.js";
|
||||
|
||||
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
...viteLimitLinePlugin({ maxReactFCLines: 300, maxFileLines: 600 }),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user