refactor: optimize ui for dashboard
This commit is contained in:
@@ -4,6 +4,14 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Workflow Dashboard</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -13,12 +13,23 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@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": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.15.1",
|
"react-router": "^7.15.1",
|
||||||
"shiki": "^4.0.2"
|
"shiki": "^4.0.2",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
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]*$/;
|
||||||
|
|
||||||
|
type AstNode = {
|
||||||
|
type: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
id: { name: string } | null;
|
||||||
|
init: AstNode | null;
|
||||||
|
declaration: AstNode | null;
|
||||||
|
declarations: Array<{ id: { name: string }; init: AstNode | null }>;
|
||||||
|
callee: { name: string } | null;
|
||||||
|
arguments: Array<AstNode>;
|
||||||
|
body: Array<AstNode>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function lineAt(code: string, offset: number): number {
|
||||||
|
let line = 1;
|
||||||
|
for (let i = 0; i < offset && i < code.length; i++) {
|
||||||
|
if (code[i] === "\n") line++;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineSpan(code: string, start: number, end: number): { startLine: number; lineCount: number } {
|
||||||
|
const startLine = lineAt(code, start);
|
||||||
|
const endLine = lineAt(code, end);
|
||||||
|
return { startLine, lineCount: endLine - startLine + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractComponents(ast: AstNode, code: string): Array<ComponentInfo> {
|
||||||
|
const results: Array<ComponentInfo> = [];
|
||||||
|
|
||||||
|
for (const node of ast.body ?? []) {
|
||||||
|
if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) {
|
||||||
|
const span = lineSpan(code, node.start, node.end);
|
||||||
|
results.push({ name: node.id.name, ...span });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "ExportNamedDeclaration" && node.declaration) {
|
||||||
|
const decl = node.declaration;
|
||||||
|
|
||||||
|
if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) {
|
||||||
|
const span = lineSpan(code, node.start, node.end);
|
||||||
|
results.push({ name: decl.id.name, ...span });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decl.type === "VariableDeclaration") {
|
||||||
|
collectFromVarDeclaration(decl, code, results);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "VariableDeclaration") {
|
||||||
|
collectFromVarDeclaration(node, code, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFromVarDeclaration(node: AstNode, code: string, results: Array<ComponentInfo>): 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)) {
|
||||||
|
const span = lineSpan(code, node.start, node.end);
|
||||||
|
results.push({ name: declarator.id.name, ...span });
|
||||||
|
} else if (isWrapperCall(init) && init.arguments.length > 0 && isFunctionLike(init.arguments[0])) {
|
||||||
|
const span = lineSpan(code, node.start, node.end);
|
||||||
|
results.push({ name: declarator.id.name, ...span });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFunctionLike(node: AstNode): boolean {
|
||||||
|
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
|
||||||
|
}
|
||||||
|
|
||||||
|
const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]);
|
||||||
|
|
||||||
|
function isWrapperCall(node: AstNode): boolean {
|
||||||
|
return node.type === "CallExpression" && node.callee !== null && node.callee.name !== undefined && WRAPPER_NAMES.has(node.callee.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function viteLimitLinePlugin(
|
||||||
|
userOptions: Partial<LimitLineOptions> = {},
|
||||||
|
): Array<Plugin> {
|
||||||
|
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
|
||||||
|
const resolve = createLimitResolver(options);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "vite-plugin-file-line-limit",
|
||||||
|
enforce: "pre",
|
||||||
|
|
||||||
|
transform(code, id) {
|
||||||
|
if (!shouldProcess(id, options)) return null;
|
||||||
|
|
||||||
|
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-react-fc-line-limit",
|
||||||
|
|
||||||
|
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 AstNode;
|
||||||
|
const components = extractComponents(ast, code);
|
||||||
|
const violations = components.filter((c) => c.lineCount > (limits.maxReactFCLines as number));
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
const details = violations
|
||||||
|
.map(
|
||||||
|
(v) =>
|
||||||
|
` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${limits.maxReactFCLines})`,
|
||||||
|
)
|
||||||
|
.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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { viteLimitLinePlugin };
|
||||||
|
export type { LimitLineOptions, LimitLineOverride };
|
||||||
@@ -4,23 +4,27 @@ import { clearApiKey, hasApiKey } from "./api.ts";
|
|||||||
import { RunDialog } from "./components/run-dialog.tsx";
|
import { RunDialog } from "./components/run-dialog.tsx";
|
||||||
import { Sidebar } from "./components/sidebar.tsx";
|
import { Sidebar } from "./components/sidebar.tsx";
|
||||||
import { StatusBar } from "./components/status-bar.tsx";
|
import { StatusBar } from "./components/status-bar.tsx";
|
||||||
|
import { useTheme } from "./hooks/use-theme.tsx";
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const [authed, setAuthed] = useState(hasApiKey());
|
const [authed, setAuthed] = useState(hasApiKey());
|
||||||
const { client } = useParams();
|
const { client } = useParams();
|
||||||
const [showRun, setShowRun] = useState(false);
|
const [showRun, setShowRun] = useState(false);
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
onLogout={() => {
|
onLogout={() => {
|
||||||
clearApiKey();
|
clearApiKey();
|
||||||
setAuthed(false);
|
setAuthed(false);
|
||||||
}}
|
}}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 overflow-hidden flex flex-col">
|
<main className="flex-1 overflow-hidden flex flex-col">
|
||||||
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
|
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
|
||||||
@@ -28,9 +32,7 @@ export function Layout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{showRun && client && (
|
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
|
||||||
<RunDialog client={client} onClose={() => setShowRun(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Loader2, Users } from "lucide-react";
|
||||||
import { Navigate } from "react-router";
|
import { Navigate } from "react-router";
|
||||||
import { listClients } from "../api.ts";
|
import { listClients } from "../api.ts";
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
@@ -7,8 +8,9 @@ export function ClientRedirect() {
|
|||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
<p style={{ color: "var(--color-text-muted)" }}>Loading clients...</p>
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading clients...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -18,8 +20,10 @@ export function ClientRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
<p style={{ color: "var(--color-text-muted)" }}>
|
<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.
|
Select a client from the sidebar to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
|
import { AlertCircle, Loader2, Moon, Settings, Sun } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { setApiKey } from "../api.ts";
|
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";
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -15,7 +21,6 @@ export function LoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Test the key by hitting the endpoints list
|
|
||||||
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
|
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
|
||||||
@@ -42,52 +47,55 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="min-h-screen flex items-center justify-center bg-background relative">
|
||||||
className="min-h-screen flex items-center justify-center"
|
<Button
|
||||||
style={{ background: "var(--color-bg)" }}
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<div
|
className="absolute top-4 right-4 transition-colors duration-200"
|
||||||
className="p-8 rounded-lg border w-full max-w-sm"
|
onClick={toggleTheme}
|
||||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
|
||||||
>
|
>
|
||||||
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}>
|
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||||
⚙ Workflow Dashboard
|
</Button>
|
||||||
</h1>
|
<Card className="w-full max-w-sm shadow-lg transition-all duration-200 hover:shadow-xl hover:border-primary/30">
|
||||||
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
|
<CardHeader>
|
||||||
Enter your API key to continue
|
<CardTitle className="flex items-center gap-2 text-xl tracking-tight">
|
||||||
</p>
|
<Settings className="h-5 w-5" />
|
||||||
<form onSubmit={handleSubmit}>
|
Workflow Dashboard
|
||||||
<input
|
</CardTitle>
|
||||||
type="password"
|
<CardDescription>Enter your API key to continue</CardDescription>
|
||||||
value={key}
|
</CardHeader>
|
||||||
onChange={(e) => setKey(e.target.value)}
|
<CardContent>
|
||||||
placeholder="API Key"
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none"
|
<Input
|
||||||
style={{
|
type="password"
|
||||||
background: "var(--color-bg)",
|
value={key}
|
||||||
borderColor: "var(--color-border)",
|
onChange={(e) => setKey(e.target.value)}
|
||||||
color: "var(--color-text)",
|
placeholder="API Key"
|
||||||
}}
|
className="transition-all duration-200"
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
|
<p className="text-xs text-destructive flex items-center gap-1.5">
|
||||||
{error}
|
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||||
</p>
|
{error}
|
||||||
)}
|
</p>
|
||||||
<button
|
)}
|
||||||
type="submit"
|
<Button
|
||||||
disabled={loading || !key.trim()}
|
type="submit"
|
||||||
className="w-full px-3 py-2 rounded text-sm font-medium"
|
disabled={loading || !key.trim()}
|
||||||
style={{
|
className="w-full transition-all duration-200"
|
||||||
background: "var(--color-accent)",
|
>
|
||||||
color: "var(--color-bg)",
|
{loading ? (
|
||||||
opacity: loading || !key.trim() ? 0.5 : 1,
|
<span className="flex items-center gap-2">
|
||||||
}}
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
>
|
Verifying…
|
||||||
{loading ? "Verifying..." : "Login"}
|
</span>
|
||||||
</button>
|
) : (
|
||||||
</form>
|
"Login"
|
||||||
</div>
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,19 +52,23 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
|
|||||||
|
|
||||||
if (html !== null) {
|
if (html !== null) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative rounded-lg border border-border overflow-hidden my-3">
|
||||||
className="rounded overflow-x-auto text-xs my-2"
|
{lang !== "text" && (
|
||||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
|
<span className="absolute top-2 right-2 text-[10px] uppercase tracking-wider text-muted-foreground/70 font-mono">
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
{lang}
|
||||||
/>
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="overflow-x-auto text-xs"
|
||||||
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<pre
|
<pre className="rounded-lg overflow-x-auto text-xs my-3 p-3 bg-muted/50 border border-border">
|
||||||
className="rounded overflow-x-auto text-xs my-2 p-3"
|
|
||||||
style={{ background: "var(--color-bg)" }}
|
|
||||||
>
|
|
||||||
<code>{code}</code>
|
<code>{code}</code>
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
@@ -80,8 +84,7 @@ export function Markdown({ content }: { content: string }) {
|
|||||||
if (isInline) {
|
if (isInline) {
|
||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
className="text-xs px-1 py-0.5 rounded"
|
className="bg-muted rounded px-1.5 py-0.5 text-[13px] font-mono text-foreground"
|
||||||
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -91,7 +94,7 @@ export function Markdown({ content }: { content: string }) {
|
|||||||
return <CodeBlock className={className}>{children}</CodeBlock>;
|
return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||||
},
|
},
|
||||||
p({ children }) {
|
p({ children }) {
|
||||||
return <p className="my-1.5 leading-relaxed">{children}</p>;
|
return <p className="my-2 leading-relaxed">{children}</p>;
|
||||||
},
|
},
|
||||||
ul({ children }) {
|
ul({ children }) {
|
||||||
return <ul className="list-disc pl-4 my-1.5">{children}</ul>;
|
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>;
|
return <ol className="list-decimal pl-4 my-1.5">{children}</ol>;
|
||||||
},
|
},
|
||||||
h1({ children }) {
|
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 }) {
|
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 }) {
|
h3({ children }) {
|
||||||
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
|
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
|
||||||
},
|
},
|
||||||
blockquote({ children }) {
|
blockquote({ children }) {
|
||||||
return (
|
return (
|
||||||
<blockquote
|
<blockquote className="border-l-2 border-ring pl-3 my-2 text-sm text-muted-foreground bg-muted/30 rounded-r-md py-2">
|
||||||
className="border-l-2 pl-3 my-2 text-sm"
|
|
||||||
style={{ borderColor: "var(--color-accent)", color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</blockquote>
|
</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 type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
|
||||||
|
import { cn } from "../lib/utils.ts";
|
||||||
import { Markdown } from "./markdown.tsx";
|
import { Markdown } from "./markdown.tsx";
|
||||||
|
import { Badge } from "./ui/badge.tsx";
|
||||||
|
import { Card } from "./ui/card.tsx";
|
||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
const ROLE_HUES = [262, 210, 35, 150, 330, 180, 15, 280, 55, 195, 345, 120, 240, 75, 305];
|
||||||
preparer: "#8b5cf6",
|
|
||||||
client: "#3b82f6",
|
|
||||||
extractor: "#f59e0b",
|
|
||||||
};
|
|
||||||
|
|
||||||
function roleColor(role: string): string {
|
function roleHue(role: string): number {
|
||||||
return ROLE_COLORS[role] ?? "var(--color-accent)";
|
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 {
|
function formatTime(ts: number | null): string | null {
|
||||||
@@ -18,99 +30,86 @@ function formatTime(ts: number | null): string | null {
|
|||||||
|
|
||||||
function StartCard({ record }: { record: ThreadStartRecord }) {
|
function StartCard({ record }: { record: ThreadStartRecord }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Card className="p-4 transition-all duration-200 overflow-hidden relative">
|
||||||
className="p-4 rounded-lg border"
|
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-primary/80 via-primary/40 to-transparent" />
|
||||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-lg">🚀</span>
|
<Rocket className="h-5 w-5 text-primary" />
|
||||||
<span className="font-semibold" style={{ color: "var(--color-accent)" }}>
|
<span className="font-semibold text-foreground">{record.workflow}</span>
|
||||||
{record.workflow}
|
<Badge variant={record.status === "active" ? "success" : "secondary"}>
|
||||||
</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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{record.status}
|
{record.status}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{record.prompt !== null && (
|
{record.prompt !== null && (
|
||||||
<div
|
<div className="mt-2 p-3 rounded-md text-sm border-l-2 border-ring bg-muted/50">
|
||||||
className="mt-2 p-3 rounded text-sm border-l-2"
|
<div className="text-xs mb-1 text-muted-foreground flex items-center gap-1">
|
||||||
style={{
|
<MessageSquare className="h-3 w-3" />
|
||||||
background: "var(--color-bg)",
|
|
||||||
borderColor: "var(--color-accent)",
|
|
||||||
color: "var(--color-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-xs mb-1" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Prompt
|
Prompt
|
||||||
</div>
|
</div>
|
||||||
<Markdown content={record.prompt} />
|
<Markdown content={record.prompt} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
|
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
|
||||||
const color = roleColor(record.role);
|
const style = roleBadgeStyle(record.role);
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
|
className={cn(
|
||||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
"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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span
|
<span
|
||||||
className="text-xs px-2 py-0.5 rounded font-mono font-medium"
|
className="text-xs px-2 py-0.5 rounded font-mono font-medium text-white shadow-sm inline-flex items-center gap-1"
|
||||||
style={{ background: color, color: "#fff" }}
|
style={{ backgroundColor: style.backgroundColor }}
|
||||||
>
|
>
|
||||||
|
<User className="h-3 w-3" />
|
||||||
{record.role}
|
{record.role}
|
||||||
</span>
|
</span>
|
||||||
{formatTime(record.timestamp) !== null && (
|
{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)}
|
{formatTime(record.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Markdown content={record.content} />
|
<Markdown content={record.content} />
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResultCard({ record }: { record: WorkflowResultRecord }) {
|
function ResultCard({ record }: { record: WorkflowResultRecord }) {
|
||||||
const success = record.returnCode === 0;
|
const success = record.returnCode === 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
className="p-4 rounded-lg border"
|
className={cn(
|
||||||
style={{
|
"p-4 transition-all duration-200 border-l-4",
|
||||||
background: "var(--color-surface)",
|
success ? "border-l-success" : "border-l-destructive",
|
||||||
borderColor: success ? "var(--color-success)" : "var(--color-error)",
|
)}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<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="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
|
||||||
<span
|
<Badge variant="outline" className="font-mono">
|
||||||
className="text-xs px-2 py-0.5 rounded font-mono"
|
|
||||||
style={{
|
|
||||||
background: success ? "var(--color-success)" : "var(--color-error)",
|
|
||||||
color: "#fff",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
exit {record.returnCode}
|
exit {record.returnCode}
|
||||||
</span>
|
</Badge>
|
||||||
{formatTime(record.timestamp) !== null && (
|
{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)}
|
{formatTime(record.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Markdown content={record.content} />
|
<Markdown content={record.content} />
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,25 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { listWorkflows, runThread } from "../api.ts";
|
import { listWorkflows, runThread } from "../api.ts";
|
||||||
import { useFetch } from "../hooks.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 = {
|
type Props = {
|
||||||
client: string;
|
client: string;
|
||||||
onClose: () => void;
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RunDialog({ client, onClose }: Props) {
|
export function RunDialog({ client, open, onOpenChange }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const workflows = useFetch(() => listWorkflows(client), [client]);
|
const workflows = useFetch(() => listWorkflows(client), [client]);
|
||||||
const [workflow, setWorkflow] = useState("");
|
const [workflow, setWorkflow] = useState("");
|
||||||
@@ -23,7 +35,7 @@ export function RunDialog({ client, onClose }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await runThread(client, workflow, prompt);
|
const result = await runThread(client, workflow, prompt);
|
||||||
onClose();
|
onOpenChange(false);
|
||||||
navigate(`/${client}/threads/${result.threadId}`);
|
navigate(`/${client}/threads/${result.threadId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -32,95 +44,54 @@ export function RunDialog({ client, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
className="fixed inset-0 flex items-center justify-center z-50"
|
<DialogContent>
|
||||||
style={{ background: "rgba(0,0,0,0.6)" }}
|
<DialogHeader>
|
||||||
>
|
<DialogTitle>Run Thread</DialogTitle>
|
||||||
<div
|
<DialogDescription>Start a new thread on {client}</DialogDescription>
|
||||||
className="w-full max-w-lg p-6 rounded-lg border"
|
</DialogHeader>
|
||||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
|
||||||
>
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="run-workflow" className="text-sm block mb-1.5 text-muted-foreground">
|
||||||
htmlFor="run-workflow"
|
|
||||||
className="text-sm block mb-1"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
Workflow
|
Workflow
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select value={workflow} onValueChange={setWorkflow}>
|
||||||
id="run-workflow"
|
<SelectTrigger>
|
||||||
value={workflow}
|
<SelectValue placeholder="Select a workflow..." />
|
||||||
onChange={(e) => setWorkflow(e.target.value)}
|
</SelectTrigger>
|
||||||
className="w-full px-3 py-2 rounded border text-sm"
|
<SelectContent>
|
||||||
style={{
|
{workflows.status === "ok" &&
|
||||||
background: "var(--color-bg)",
|
workflows.data.workflows.map((w) => (
|
||||||
borderColor: "var(--color-border)",
|
<SelectItem key={w.name} value={w.name}>
|
||||||
color: "var(--color-text)",
|
{w.name}
|
||||||
}}
|
</SelectItem>
|
||||||
>
|
))}
|
||||||
<option value="">Select a workflow...</option>
|
</SelectContent>
|
||||||
{workflows.status === "ok" &&
|
</Select>
|
||||||
workflows.data.workflows.map((w) => (
|
|
||||||
<option key={w.name} value={w.name}>
|
|
||||||
{w.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label htmlFor="run-prompt" className="text-sm block mb-1.5 text-muted-foreground">
|
||||||
htmlFor="run-prompt"
|
|
||||||
className="text-sm block mb-1"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
Prompt
|
Prompt
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<Textarea
|
||||||
id="run-prompt"
|
id="run-prompt"
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
rows={4}
|
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..."
|
placeholder="Enter the task prompt..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
<p className="text-sm" style={{ color: "var(--color-error)" }}>
|
<DialogFooter>
|
||||||
{error}
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
</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)" }}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="submit" disabled={submitting || !workflow || !prompt}>
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{submitting ? "Starting..." : "Run"}
|
{submitting ? "Starting..." : "Run"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
|
import { Loader2, LogOut, Moon, Package, Sun, Zap } from "lucide-react";
|
||||||
import { useLocation, useNavigate, useParams } from "react-router";
|
import { useLocation, useNavigate, useParams } from "react-router";
|
||||||
import type { ClientEndpoint } from "../api.ts";
|
import type { ClientEndpoint } from "../api.ts";
|
||||||
import { listClients } from "../api.ts";
|
import { listClients } from "../api.ts";
|
||||||
import { useFetch } from "../hooks.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 = {
|
type Props = {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
theme: "light" | "dark";
|
||||||
|
onToggleTheme: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Sidebar({ onLogout }: Props) {
|
export function Sidebar({ onLogout, theme, onToggleTheme }: Props) {
|
||||||
const { client } = useParams();
|
const { client } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -18,93 +25,107 @@ export function Sidebar({ onLogout }: Props) {
|
|||||||
const view = location.pathname.includes("/workflows") ? "workflows" : "threads";
|
const view = location.pathname.includes("/workflows") ? "workflows" : "threads";
|
||||||
|
|
||||||
const viewItems = [
|
const viewItems = [
|
||||||
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
{ key: "threads" as const, label: "Threads", icon: Zap },
|
||||||
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
|
{ key: "workflows" as const, label: "Workflows", icon: Package },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside className="w-56 border-r border-border flex flex-col bg-sidebar">
|
||||||
className="w-56 border-r flex flex-col"
|
<div className="p-4 border-b border-primary/20">
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
<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 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
|
<div className="px-3 py-3">
|
||||||
<label
|
<label
|
||||||
className="block text-xs font-medium mb-1"
|
className="block text-xs font-medium mb-1.5 text-muted-foreground"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
htmlFor="client-select"
|
htmlFor="client-select"
|
||||||
>
|
>
|
||||||
Client
|
Client
|
||||||
</label>
|
</label>
|
||||||
<select
|
{status === "loading" ? (
|
||||||
id="client-select"
|
<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">
|
||||||
className="w-full rounded px-2 py-1.5 text-xs"
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
style={{
|
Loading…
|
||||||
background: "var(--color-bg)",
|
</div>
|
||||||
color: "var(--color-text)",
|
) : clients.length === 0 ? (
|
||||||
border: "1px solid var(--color-border)",
|
<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
|
||||||
value={client ?? ""}
|
</div>
|
||||||
onChange={(e) => {
|
) : (
|
||||||
const name = e.target.value;
|
<Select
|
||||||
if (name) {
|
value={client ?? ""}
|
||||||
navigate(`/${name}/${view}`);
|
onValueChange={(name) => {
|
||||||
}
|
if (name) navigate(`/${name}/${view}`);
|
||||||
}}
|
}}
|
||||||
disabled={status === "loading"}
|
>
|
||||||
>
|
<SelectTrigger className="h-8 text-xs transition-colors duration-200">
|
||||||
{status === "loading" ? (
|
<SelectValue placeholder="Select client…" />
|
||||||
<option value="">Loading…</option>
|
</SelectTrigger>
|
||||||
) : clients.length === 0 ? (
|
<SelectContent>
|
||||||
<option value="">No clients online</option>
|
{clients.map((a) => (
|
||||||
) : (
|
<SelectItem key={a.name} value={a.name} className="text-xs">
|
||||||
clients.map((a) => (
|
<span className="flex items-center gap-2">
|
||||||
<option key={a.name} value={a.name}>
|
<span
|
||||||
{a.status === "online" ? "🟢" : "🔴"} {a.name}
|
className={cn(
|
||||||
</option>
|
"inline-block h-2 w-2 rounded-full",
|
||||||
))
|
a.status === "online" ? "bg-success animate-pulse" : "bg-destructive",
|
||||||
)}
|
)}
|
||||||
</select>
|
/>
|
||||||
|
{a.name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<nav className="flex-1 p-2 space-y-1">
|
<nav className="flex-1 p-2 space-y-1">
|
||||||
{viewItems.map((item) => (
|
{viewItems.map((item) => (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
key={item.key}
|
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={() => {
|
onClick={() => {
|
||||||
if (client) {
|
if (client) navigate(`/${client}/${item.key}`);
|
||||||
navigate(`/${client}/${item.key}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
|
|
||||||
style={{
|
|
||||||
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
|
|
||||||
color: view === item.key ? "#fff" : "var(--color-text-muted)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.icon} {item.label}
|
<item.icon className="h-4 w-4" />
|
||||||
</button>
|
{item.label}
|
||||||
|
</Button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-2 border-t" style={{ borderColor: "var(--color-border)" }}>
|
<Separator />
|
||||||
<button
|
|
||||||
type="button"
|
<div className="p-2 space-y-1">
|
||||||
onClick={onLogout}
|
<Button
|
||||||
className="w-full text-left px-3 py-2 rounded text-xs transition-colors"
|
variant="ghost"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
size="sm"
|
||||||
|
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||||
|
onClick={onToggleTheme}
|
||||||
>
|
>
|
||||||
🚪 Logout
|
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
</button>
|
{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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { Loader2, Play, Wifi, WifiOff } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getClientHealth } from "../api.ts";
|
import { getClientHealth } from "../api.ts";
|
||||||
|
import { Button } from "./ui/button.tsx";
|
||||||
|
|
||||||
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
||||||
|
|
||||||
@@ -8,14 +10,29 @@ type Props = {
|
|||||||
onRun: () => void;
|
onRun: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function statusLabel(status: HealthStatus): { text: string; color: string } {
|
function StatusIndicator({ status }: { status: HealthStatus }) {
|
||||||
if (status === "connected") {
|
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") {
|
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) {
|
export function StatusBar({ client, onRun }: Props) {
|
||||||
@@ -48,32 +65,24 @@ export function StatusBar({ client, onRun }: Props) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [checkHealth]);
|
}, [checkHealth]);
|
||||||
|
|
||||||
const label = statusLabel(status);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center justify-between px-6 py-2 text-xs border-b border-border bg-card/80 backdrop-blur-sm">
|
||||||
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 gap-4">
|
<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"}
|
{client ? `Client: ${client}` : "No client selected"}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="default"
|
||||||
onClick={onRun}
|
size="sm"
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
className="px-3 py-1 rounded text-xs font-medium"
|
onClick={onRun}
|
||||||
style={{
|
className="h-7 gap-1.5 transition-all duration-200"
|
||||||
background: client ? "var(--color-accent)" : "var(--color-border)",
|
|
||||||
color: "#fff",
|
|
||||||
opacity: client ? 1 : 0.5,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
▶ Run Thread
|
<Play className="h-3.5 w-3.5" />
|
||||||
</button>
|
Run Thread
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: label.color }}>{label.text}</span>
|
<StatusIndicator status={status} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AlertCircle, ArrowLeft, Layers, Loader2, Pause, Play, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,10 @@ import {
|
|||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
import { useSSE } from "../use-sse.ts";
|
import { useSSE } from "../use-sse.ts";
|
||||||
import { RecordCard } from "./record-card.tsx";
|
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";
|
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||||
|
|
||||||
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
|
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
|
||||||
@@ -92,27 +97,23 @@ export function ThreadDetail() {
|
|||||||
return m;
|
return m;
|
||||||
}, [records]);
|
}, [records]);
|
||||||
|
|
||||||
// Track which occurrence to jump to next per role (cycling)
|
|
||||||
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
const handleGraphNodeClick = useCallback(
|
const handleGraphNodeClick = useCallback(
|
||||||
(nodeId: string) => {
|
(nodeId: string) => {
|
||||||
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
||||||
|
|
||||||
// __start__: scroll to the first record (thread-start prompt)
|
|
||||||
if (nodeId === "__start__") {
|
if (nodeId === "__start__") {
|
||||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// __end__: scroll to bottom
|
|
||||||
if (nodeId === "__end__") {
|
if (nodeId === "__end__") {
|
||||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role nodes: cycle through occurrences
|
|
||||||
const indices = indicesByRole.get(nodeId);
|
const indices = indicesByRole.get(nodeId);
|
||||||
if (indices === undefined || indices.length === 0) return;
|
if (indices === undefined || indices.length === 0) return;
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ export function ThreadDetail() {
|
|||||||
try {
|
try {
|
||||||
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||||
await fn(client, threadId);
|
await fn(client, threadId);
|
||||||
setActionStatus(`${action} sent ✓`);
|
setActionStatus(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
}
|
}
|
||||||
@@ -159,88 +160,84 @@ export function ThreadDetail() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||||
onClick={() => navigate(`/${client}/threads`)}
|
onClick={() => navigate(`/${client}/threads`)}
|
||||||
className="text-sm hover:underline"
|
|
||||||
style={{ color: "var(--color-accent)" }}
|
|
||||||
>
|
>
|
||||||
← Back to threads
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</button>
|
Back to threads
|
||||||
<div className="flex gap-2">
|
</Button>
|
||||||
<button
|
<div className="flex gap-1 rounded-lg border border-border bg-muted/30 p-1">
|
||||||
type="button"
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="transition-colors duration-200"
|
||||||
onClick={() => handleAction("pause")}
|
onClick={() => handleAction("pause")}
|
||||||
className="px-3 py-1 text-xs rounded border"
|
|
||||||
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
|
|
||||||
>
|
>
|
||||||
⏸ Pause
|
<Pause className="h-3.5 w-3.5 text-warning" />
|
||||||
</button>
|
Pause
|
||||||
<button
|
</Button>
|
||||||
type="button"
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="transition-colors duration-200"
|
||||||
onClick={() => handleAction("resume")}
|
onClick={() => handleAction("resume")}
|
||||||
className="px-3 py-1 text-xs rounded border"
|
|
||||||
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
|
|
||||||
>
|
>
|
||||||
▶ Resume
|
<Play className="h-3.5 w-3.5 text-success" />
|
||||||
</button>
|
Resume
|
||||||
<button
|
</Button>
|
||||||
type="button"
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="transition-colors duration-200"
|
||||||
onClick={() => handleAction("kill")}
|
onClick={() => handleAction("kill")}
|
||||||
className="px-3 py-1 text-xs rounded border"
|
|
||||||
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
|
|
||||||
>
|
>
|
||||||
✕ Kill
|
<X className="h-3.5 w-3.5 text-destructive" />
|
||||||
</button>
|
Kill
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<span>{threadId}</span>
|
||||||
{sse.connected && !sse.completed && (
|
{sse.connected && !sse.completed && (
|
||||||
<span
|
<Badge variant="success" className="animate-pulse flex items-center gap-1.5">
|
||||||
className="text-xs font-medium px-2 py-0.5 rounded"
|
<span className="inline-block h-2 w-2 rounded-full bg-success-foreground" />
|
||||||
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
|
|
||||||
>
|
|
||||||
Live
|
Live
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
{actionStatus && (
|
{actionStatus && (
|
||||||
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
|
<Badge variant="secondary" className="mb-4 text-xs font-normal">
|
||||||
{actionStatus}
|
{actionStatus}
|
||||||
</p>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
|
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
|
||||||
{descriptor !== null && descriptor.graph.edges.length > 0 && (
|
{descriptor !== null && descriptor.graph.edges.length > 0 && (
|
||||||
<div
|
<ResizablePanel
|
||||||
className="shrink-0"
|
defaultWidth={360}
|
||||||
|
minWidth={240}
|
||||||
|
maxWidth={560}
|
||||||
|
className={null}
|
||||||
style={{
|
style={{
|
||||||
width: 280,
|
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
top: 16,
|
top: 16,
|
||||||
height: "calc(100vh - 120px)",
|
height: "calc(100vh - 120px)",
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Card className="h-full flex flex-col overflow-hidden">
|
||||||
className="rounded-lg border 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">
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
<span className="font-mono flex items-center gap-1.5">
|
||||||
>
|
<Layers className="h-3.5 w-3.5" />
|
||||||
<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
|
Workflow graph
|
||||||
{workflowName !== null && (
|
{workflowName !== null && (
|
||||||
<span className="ml-2" style={{ color: "var(--color-text)" }}>
|
<span className="ml-2 text-foreground">{workflowName}</span>
|
||||||
{workflowName}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="tabular-nums">
|
||||||
{descriptor.graph.edges.length} edge
|
{descriptor.graph.edges.length} edge
|
||||||
{descriptor.graph.edges.length === 1 ? "" : "s"}
|
{descriptor.graph.edges.length === 1 ? "" : "s"}
|
||||||
</span>
|
</span>
|
||||||
@@ -253,19 +250,25 @@ export function ThreadDetail() {
|
|||||||
onNodeClick={handleGraphNodeClick}
|
onNodeClick={handleGraphNodeClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{status === "loading" && !liveActive && records.length === 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 && (
|
{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) && (
|
{(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) => {
|
{records.map((r, i) => {
|
||||||
const key = `${threadId}-${i}`;
|
const key = `${threadId}-${i}`;
|
||||||
if (r.type === "role") {
|
if (r.type === "role") {
|
||||||
@@ -276,18 +279,21 @@ export function ThreadDetail() {
|
|||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
data-record-index={i}
|
data-record-index={i}
|
||||||
|
className="relative"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (!isFirstForRole) return;
|
if (!isFirstForRole) return;
|
||||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
||||||
else firstCardByRoleRef.current.delete(r.role);
|
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} />
|
<RecordCard record={r} highlighted={flash} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
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} />
|
<RecordCard record={r} highlighted={false} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
|
import { AlertCircle, Clock, Loader2, Workflow, Zap } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import { listThreads } from "../api.ts";
|
import { listThreads } from "../api.ts";
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
import { Badge } from "./ui/badge.tsx";
|
||||||
|
import { Card } from "./ui/card.tsx";
|
||||||
|
|
||||||
|
function statusVariant(status: string): "success" | "destructive" | "secondary" {
|
||||||
|
if (status === "completed") return "success";
|
||||||
|
if (status === "failed") return "destructive";
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
export function ThreadList() {
|
export function ThreadList() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -9,8 +18,20 @@ export function ThreadList() {
|
|||||||
const { status, data, error } = useFetch(() => listThreads(client), [client]);
|
const { status, data, error } = useFetch(() => listThreads(client), [client]);
|
||||||
|
|
||||||
if (status === "loading")
|
if (status === "loading")
|
||||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
return (
|
||||||
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
|
<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) => {
|
const threads = [...data.threads].sort((a, b) => {
|
||||||
if (!a.startedAt && !b.startedAt) return 0;
|
if (!a.startedAt && !b.startedAt) return 0;
|
||||||
@@ -21,51 +42,44 @@ export function ThreadList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 ? (
|
{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">
|
<div className="space-y-2">
|
||||||
{threads.map((t) => (
|
{threads.map((t) => (
|
||||||
<button
|
<Card
|
||||||
type="button"
|
|
||||||
key={t.threadId}
|
key={t.threadId}
|
||||||
|
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
|
||||||
onClick={() => navigate(`/${client}/threads/${t.threadId}`)}
|
onClick={() => navigate(`/${client}/threads/${t.threadId}`)}
|
||||||
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
|
|
||||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
|
<code className="font-mono text-sm text-foreground">{t.threadId}</code>
|
||||||
{t.threadId}
|
|
||||||
</code>
|
|
||||||
{t.status && (
|
{t.status && (
|
||||||
<span
|
<Badge variant={statusVariant(t.status)} className="text-xs">
|
||||||
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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.status}
|
{t.status}
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{t.workflow && (
|
{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}
|
{t.workflow}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{t.startedAt && (
|
{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}
|
{t.startedAt}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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,17 +1,50 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronDown,
|
||||||
|
GitBranch,
|
||||||
|
Hash,
|
||||||
|
Layers,
|
||||||
|
Loader2,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
|
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
|
||||||
import { getWorkflowDetail } from "../api.ts";
|
import { getWorkflowDetail } from "../api.ts";
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
import { cn } from "../lib/utils.ts";
|
||||||
import { Markdown } from "./markdown.tsx";
|
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";
|
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||||
|
|
||||||
|
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 {
|
function versionCount(detail: WorkflowDetailData): number {
|
||||||
return detail.history.length + 1;
|
return detail.history.length + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Schema rendering helpers ────────────────────────────────────────
|
|
||||||
|
|
||||||
type SchemaRow = {
|
type SchemaRow = {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -42,7 +75,6 @@ function flattenSchema(
|
|||||||
): SchemaRow[] {
|
): SchemaRow[] {
|
||||||
const rows: SchemaRow[] = [];
|
const rows: SchemaRow[] = [];
|
||||||
|
|
||||||
// Handle oneOf / discriminatedUnion
|
|
||||||
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||||
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||||
@@ -157,118 +189,88 @@ function flattenProperty(
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Components ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
|
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
|
||||||
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
|
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
|
||||||
|
const [promptOpen, setPromptOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card id={`role-${roleName}`} className={cn("p-4 border-l-4", roleBorderColor(roleName))}>
|
||||||
id={`role-${roleName}`}
|
<h4 className="text-sm font-semibold font-mono mb-1 text-foreground flex items-center gap-1.5">
|
||||||
className="rounded-lg border p-4"
|
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
|
||||||
>
|
|
||||||
<h4 className="text-sm font-semibold font-mono mb-1" style={{ color: "var(--color-text)" }}>
|
|
||||||
{roleName}
|
{roleName}
|
||||||
</h4>
|
</h4>
|
||||||
{role.description !== "" && (
|
{role.description !== "" && (
|
||||||
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
|
<p className="text-xs mb-3 text-muted-foreground">{role.description}</p>
|
||||||
{role.description}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{role.systemPrompt !== "" && (
|
{role.systemPrompt !== "" && (
|
||||||
<details className="mb-3">
|
<Collapsible open={promptOpen} onOpenChange={setPromptOpen} className="mb-3">
|
||||||
<summary
|
<CollapsibleTrigger asChild>
|
||||||
className="text-[10px] uppercase tracking-wider font-medium cursor-pointer select-none"
|
<Button
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
variant="ghost"
|
||||||
>
|
size="sm"
|
||||||
System Prompt
|
className="gap-1 h-7 px-2 text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/50 rounded-md transition-all duration-200"
|
||||||
</summary>
|
>
|
||||||
<div
|
<ChevronDown
|
||||||
className="mt-1 p-2 rounded overflow-y-auto text-xs"
|
className={cn("h-3 w-3 transition-transform", promptOpen && "rotate-180")}
|
||||||
style={{
|
/>
|
||||||
background: "var(--color-bg)",
|
System Prompt
|
||||||
border: "1px solid var(--color-border)",
|
</Button>
|
||||||
maxHeight: "300px",
|
</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} />
|
<Markdown content={role.systemPrompt} />
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
)}
|
)}
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p className="text-[10px] uppercase tracking-wider mb-1 font-medium text-muted-foreground">
|
||||||
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
Meta Schema
|
Meta Schema
|
||||||
</p>
|
</p>
|
||||||
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
|
<Table>
|
||||||
<thead>
|
<TableHeader>
|
||||||
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
|
<TableRow>
|
||||||
<th
|
<TableHead className="text-xs">Field</TableHead>
|
||||||
className="text-left py-1 pr-3 font-medium"
|
<TableHead className="text-xs">Type</TableHead>
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
<TableHead className="text-xs">Description</TableHead>
|
||||||
>
|
</TableRow>
|
||||||
Field
|
</TableHeader>
|
||||||
</th>
|
<TableBody>
|
||||||
<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>
|
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<tr
|
<TableRow
|
||||||
key={r.key}
|
key={r.key}
|
||||||
style={{
|
className={cn(r.isVariantHeader ? "border-b-0" : "", "even:bg-muted/30")}
|
||||||
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<td
|
<TableCell
|
||||||
className="py-1 pr-3 font-mono whitespace-pre"
|
className={cn(
|
||||||
style={{
|
"font-mono whitespace-pre text-xs",
|
||||||
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
|
r.isVariantHeader ? "italic text-muted-foreground" : "text-foreground",
|
||||||
fontStyle: r.isVariantHeader ? "italic" : "normal",
|
)}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{r.name}
|
{r.name}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
{r.type}
|
{r.type}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="py-1" style={{ color: "var(--color-text)" }}>
|
<TableCell className="text-xs">
|
||||||
{r.description || (r.isVariantHeader ? "" : "—")}
|
{r.description || (r.isVariantHeader ? "" : "—")}
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
||||||
<pre
|
<pre className="text-[10px] font-mono p-2 rounded-md overflow-x-auto bg-background text-muted-foreground">
|
||||||
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
|
|
||||||
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{JSON.stringify(role.schema, null, 2)}
|
{JSON.stringify(role.schema, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main component ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function WorkflowDetail() {
|
export function WorkflowDetail() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -313,44 +315,52 @@ export function WorkflowDetail() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
|
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-all duration-200"
|
||||||
onClick={() => navigate(`/${client}/workflows`)}
|
onClick={() => navigate(`/${client}/workflows`)}
|
||||||
className="text-sm hover:underline"
|
|
||||||
style={{ color: "var(--color-accent)" }}
|
|
||||||
>
|
>
|
||||||
← Back to workflows
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</button>
|
Back to workflows
|
||||||
|
</Button>
|
||||||
</div>
|
</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 === "loading" && (
|
||||||
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
|
<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 && (
|
{detail !== null && (
|
||||||
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
|
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
|
||||||
{/* Left: fixed graph sidebar */}
|
|
||||||
{hasGraph && (
|
{hasGraph && (
|
||||||
<div
|
<ResizablePanel
|
||||||
className="shrink-0"
|
defaultWidth={360}
|
||||||
|
minWidth={240}
|
||||||
|
maxWidth={560}
|
||||||
|
className={null}
|
||||||
style={{
|
style={{
|
||||||
width: 280,
|
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
top: 16,
|
top: 16,
|
||||||
height: "calc(100vh - 160px)",
|
height: "calc(100vh - 160px)",
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Card className="h-full flex flex-col overflow-hidden">
|
||||||
className="rounded-lg border 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">
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
<span className="font-mono flex items-center gap-1.5">
|
||||||
>
|
<Layers className="h-3.5 w-3.5" />
|
||||||
<div
|
Workflow graph
|
||||||
className="flex items-center justify-between px-3 py-2 text-xs"
|
</span>
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
<span className="font-mono">Workflow graph</span>
|
|
||||||
<span>
|
<span>
|
||||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||||
</span>
|
</span>
|
||||||
@@ -363,52 +373,44 @@ export function WorkflowDetail() {
|
|||||||
onNodeClick={handleGraphNodeClick}
|
onNodeClick={handleGraphNodeClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</ResizablePanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Right: scrollable content */}
|
|
||||||
<div className="flex-1 min-w-0 space-y-4">
|
<div className="flex-1 min-w-0 space-y-4">
|
||||||
{/* Workflow overview */}
|
<Card className="p-4">
|
||||||
<div
|
<div className="rounded-md bg-muted/30 px-3 py-2 mb-3">
|
||||||
className="rounded-lg border p-4"
|
<p className="text-sm whitespace-pre-wrap text-foreground">
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
{descriptor !== null && descriptor.description !== ""
|
||||||
>
|
? descriptor.description
|
||||||
<p
|
: "—"}
|
||||||
className="text-sm whitespace-pre-wrap mb-3"
|
</p>
|
||||||
style={{ color: "var(--color-text)" }}
|
</div>
|
||||||
>
|
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||||
{descriptor !== null && descriptor.description !== ""
|
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-mono">
|
||||||
? descriptor.description
|
<Hash className="h-3 w-3" />
|
||||||
: "—"}
|
<span className="text-foreground">{detail.hash}</span>
|
||||||
</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>
|
|
||||||
</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" : ""}
|
{versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
{roleEntries.length > 0 && (
|
{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" : ""}
|
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Role cards */}
|
|
||||||
{roleEntries.map(([name, role]) => (
|
{roleEntries.map(([name, role]) => (
|
||||||
<div
|
<div
|
||||||
key={name}
|
key={name}
|
||||||
style={{
|
className={cn(
|
||||||
transition: "box-shadow 0.3s",
|
"rounded-lg transition-shadow duration-300",
|
||||||
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
|
highlightedRole === name && "ring-2 ring-ring",
|
||||||
borderRadius: 8,
|
)}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RoleCard roleName={name} role={role} />
|
<RoleCard roleName={name} role={role} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function ConditionEdge(props: EdgeProps) {
|
|||||||
defaultLabelY = result[2];
|
defaultLabelY = result[2];
|
||||||
}
|
}
|
||||||
|
|
||||||
const stroke = "var(--color-accent)";
|
const stroke = "hsl(var(--ring))";
|
||||||
const label = isFallback ? "" : (edgeData?.condition ?? "");
|
const label = isFallback ? "" : (edgeData?.condition ?? "");
|
||||||
|
|
||||||
// Use pre-computed label position if available, otherwise fall back to default
|
// 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"
|
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
background: "var(--color-surface)",
|
background: "hsl(var(--card))",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid hsl(var(--border))",
|
||||||
color: "var(--color-text)",
|
color: "hsl(var(--foreground))",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,29 +1,23 @@
|
|||||||
import { Handle, type NodeProps, Position } from "@xyflow/react";
|
import { Handle, type NodeProps, Position } from "@xyflow/react";
|
||||||
|
import { Check, Circle } from "lucide-react";
|
||||||
import type { RoleNodeData } from "./types.ts";
|
import type { RoleNodeData } from "./types.ts";
|
||||||
|
|
||||||
function borderColor(state: RoleNodeData["state"]): string {
|
function borderColor(state: RoleNodeData["state"]): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "completed":
|
case "completed":
|
||||||
return "var(--color-success)";
|
return "hsl(var(--success))";
|
||||||
case "active":
|
case "active":
|
||||||
return "var(--color-accent)";
|
return "hsl(var(--ring))";
|
||||||
default:
|
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) {
|
export function RoleNode(props: NodeProps) {
|
||||||
const data = props.data as RoleNodeData;
|
const data = props.data as RoleNodeData;
|
||||||
const icon = stateIcon(data.state);
|
|
||||||
const isActive = data.state === "active";
|
const isActive = data.state === "active";
|
||||||
const handleStyle = {
|
const handleStyle = {
|
||||||
background: "var(--color-text-muted)",
|
background: "hsl(var(--muted-foreground))",
|
||||||
width: 6,
|
width: 6,
|
||||||
height: 6,
|
height: 6,
|
||||||
border: "none",
|
border: "none",
|
||||||
@@ -35,9 +29,9 @@ export function RoleNode(props: NodeProps) {
|
|||||||
style={{
|
style={{
|
||||||
width: 180,
|
width: 180,
|
||||||
height: 60,
|
height: 60,
|
||||||
background: "var(--color-surface)",
|
background: "hsl(var(--card))",
|
||||||
borderColor: borderColor(data.state),
|
borderColor: borderColor(data.state),
|
||||||
color: "var(--color-text)",
|
color: "hsl(var(--foreground))",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -81,19 +75,15 @@ export function RoleNode(props: NodeProps) {
|
|||||||
isConnectable={false}
|
isConnectable={false}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1.5 font-mono">
|
<div className="flex items-center gap-1.5 font-mono">
|
||||||
{icon !== null && (
|
{data.state === "completed" && <Check className="h-3 w-3 text-success" />}
|
||||||
<span
|
{data.state === "active" && <Circle className="h-3 w-3 fill-current text-ring" />}
|
||||||
style={{
|
|
||||||
color: data.state === "active" ? "var(--color-accent)" : "var(--color-success)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="truncate">{data.label}</span>
|
<span className="truncate">{data.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{data.description !== "" && (
|
{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}
|
{data.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { Handle, type NodeProps, Position } from "@xyflow/react";
|
import { Handle, type NodeProps, Position } from "@xyflow/react";
|
||||||
|
import { Play, Square } from "lucide-react";
|
||||||
import type { TerminalNodeData } from "./types.ts";
|
import type { TerminalNodeData } from "./types.ts";
|
||||||
|
|
||||||
function borderColor(state: TerminalNodeData["state"]): string {
|
function borderColor(state: TerminalNodeData["state"]): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "completed":
|
case "completed":
|
||||||
return "var(--color-success)";
|
return "hsl(var(--success))";
|
||||||
case "active":
|
case "active":
|
||||||
return "var(--color-accent)";
|
return "hsl(var(--ring))";
|
||||||
default:
|
default:
|
||||||
return "var(--color-border)";
|
return "hsl(var(--border))";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgColor(state: TerminalNodeData["state"]): string {
|
function bgColor(state: TerminalNodeData["state"]): string {
|
||||||
if (state === "completed") return "var(--color-success)";
|
if (state === "completed") return "hsl(var(--success))";
|
||||||
if (state === "active") return "var(--color-accent)";
|
if (state === "active") return "hsl(var(--ring))";
|
||||||
return "var(--color-surface)";
|
return "hsl(var(--card))";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalNode(props: NodeProps) {
|
export function TerminalNode(props: NodeProps) {
|
||||||
@@ -23,7 +24,7 @@ export function TerminalNode(props: NodeProps) {
|
|||||||
const isStart = data.kind === "start";
|
const isStart = data.kind === "start";
|
||||||
const isActive = data.state === "active";
|
const isActive = data.state === "active";
|
||||||
const handleStyle = {
|
const handleStyle = {
|
||||||
background: "var(--color-text-muted)",
|
background: "hsl(var(--muted-foreground))",
|
||||||
width: 6,
|
width: 6,
|
||||||
height: 6,
|
height: 6,
|
||||||
border: "none",
|
border: "none",
|
||||||
@@ -31,13 +32,16 @@ export function TerminalNode(props: NodeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
background: bgColor(data.state),
|
background: bgColor(data.state),
|
||||||
borderColor: borderColor(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"}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import {
|
|||||||
type EdgeTypes,
|
type EdgeTypes,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
type Node,
|
type Node,
|
||||||
|
type NodeMouseHandler,
|
||||||
type NodeTypes,
|
type NodeTypes,
|
||||||
type OnNodeClick,
|
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
||||||
|
import { useTheme } from "../../hooks/use-theme.tsx";
|
||||||
import { ConditionEdge } from "./condition-edge.tsx";
|
import { ConditionEdge } from "./condition-edge.tsx";
|
||||||
import { RoleNode } from "./role-node.tsx";
|
import { RoleNode } from "./role-node.tsx";
|
||||||
import { TerminalNode } from "./terminal-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) {
|
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
const onNodeClickHandler: OnNodeClick | undefined =
|
const onNodeClickHandler: NodeMouseHandler | undefined =
|
||||||
onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
|
onNodeClick !== null
|
||||||
|
? (_e: React.MouseEvent, node: Node) => handleNodeClick(onNodeClick, node)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const styledEdges = useMemo(
|
const styledEdges = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -51,7 +55,7 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
|
|||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
color: "var(--color-text)",
|
color: "hsl(var(--foreground))",
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
[layout.edges],
|
[layout.edges],
|
||||||
@@ -72,10 +76,10 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
|
|||||||
nodesConnectable={false}
|
nodesConnectable={false}
|
||||||
elementsSelectable={false}
|
elementsSelectable={false}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
colorMode="dark"
|
colorMode={theme}
|
||||||
style={{ background: "var(--color-bg)" }}
|
style={{ background: "hsl(var(--background))" }}
|
||||||
>
|
>
|
||||||
<Background color="var(--color-border)" gap={20} size={1} />
|
<Background color="hsl(var(--border))" gap={20} size={1} />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { AlertCircle, Clock, Hash, Loader2, Package } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import { listWorkflows } from "../api.ts";
|
import { listWorkflows } from "../api.ts";
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
import { Card } from "./ui/card.tsx";
|
||||||
|
|
||||||
export function WorkflowList() {
|
export function WorkflowList() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -9,45 +11,55 @@ export function WorkflowList() {
|
|||||||
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
|
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
|
||||||
|
|
||||||
if (status === "loading")
|
if (status === "loading")
|
||||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
return (
|
||||||
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
|
<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;
|
const workflows = data.workflows;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 ? (
|
{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">
|
<div className="space-y-2">
|
||||||
{workflows.map((w) => (
|
{workflows.map((w) => (
|
||||||
<button
|
<Card
|
||||||
key={w.name}
|
key={w.name}
|
||||||
type="button"
|
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
|
||||||
onClick={() => navigate(`/${client}/workflows/${w.name}`)}
|
onClick={() => navigate(`/${client}/workflows/${w.name}`)}
|
||||||
className="w-full text-left p-4 rounded-lg border hover:opacity-90"
|
|
||||||
style={{
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
borderColor: "var(--color-border)",
|
|
||||||
color: "var(--color-text)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||||
<span className="font-medium">{w.name}</span>
|
<Package className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</div>
|
{w.name}
|
||||||
<code
|
</span>
|
||||||
className="text-xs mt-1 block font-mono truncate"
|
<code className="text-xs mt-1 font-mono text-muted-foreground flex items-center gap-1.5">
|
||||||
style={{ color: "var(--color-accent)" }}
|
<Hash className="h-3 w-3" />
|
||||||
>
|
|
||||||
{w.hash !== null ? w.hash : "—"}
|
{w.hash !== null ? w.hash : "—"}
|
||||||
</code>
|
</code>
|
||||||
{w.timestamp !== null ? (
|
{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()}
|
Updated {new Date(w.timestamp).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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";
|
@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 {
|
:root {
|
||||||
--color-bg: #0a0a0f;
|
--radius: 0.625rem;
|
||||||
--color-surface: #12121a;
|
--background: 0 0% 100%;
|
||||||
--color-border: #1e1e2e;
|
--foreground: 240 10% 3.9%;
|
||||||
--color-text: #e4e4ef;
|
--card: 0 0% 100%;
|
||||||
--color-text-muted: #6b6b8a;
|
--card-foreground: 240 10% 3.9%;
|
||||||
--color-accent: #7c6df0;
|
--popover: 0 0% 100%;
|
||||||
--color-accent-dim: #5a4db8;
|
--popover-foreground: 240 10% 3.9%;
|
||||||
--color-success: #34d399;
|
--primary: 240 5.9% 10%;
|
||||||
--color-warning: #fbbf24;
|
--primary-foreground: 0 0% 98%;
|
||||||
--color-error: #f87171;
|
--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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--color-bg);
|
background: hsl(var(--background));
|
||||||
color: var(--color-text);
|
color: hsl(var(--foreground));
|
||||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes wf-node-pulse {
|
@keyframes wf-node-pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
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% {
|
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 {
|
@keyframes wf-record-card-highlight {
|
||||||
0% {
|
0% {
|
||||||
border-color: var(--color-accent);
|
border-color: hsl(var(--ring));
|
||||||
}
|
}
|
||||||
35% {
|
35% {
|
||||||
border-color: var(--color-accent);
|
border-color: hsl(var(--ring));
|
||||||
}
|
}
|
||||||
100% {
|
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,6 +1,7 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { RouterProvider } from "react-router";
|
import { RouterProvider } from "react-router";
|
||||||
|
import { ThemeProvider } from "./hooks/use-theme.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { router } from "./router.tsx";
|
import { router } from "./router.tsx";
|
||||||
|
|
||||||
@@ -8,7 +9,9 @@ const root = document.getElementById("root");
|
|||||||
if (root) {
|
if (root) {
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<RouterProvider router={router} />
|
<ThemeProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -13,5 +15,5 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "plugins"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import { viteLimitLinePlugin } from "./plugins/vite-limit-line-plugin.js";
|
||||||
|
|
||||||
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
|
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
...viteLimitLinePlugin({ maxReactFCLines: 300, maxFileLines: 600 }),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user