feat(dashboard): workflow detail as separate page with fixed graph sidebar
- Workflow list is now a simple clickable list (no expand/collapse) - Clicking a workflow navigates to dedicated detail page (#client/workflows/name) - Detail page: fixed graph sidebar on left, scrollable role cards on right - Back button returns to workflow list - Route: added workflowName to hash routing
This commit is contained in:
@@ -6,12 +6,13 @@ import { Sidebar } from "./components/sidebar.tsx";
|
|||||||
import { StatusBar } from "./components/status-bar.tsx";
|
import { StatusBar } from "./components/status-bar.tsx";
|
||||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||||
import { ThreadList } from "./components/thread-list.tsx";
|
import { ThreadList } from "./components/thread-list.tsx";
|
||||||
|
import { WorkflowDetail } from "./components/workflow-detail.tsx";
|
||||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||||
import { useHashRoute } from "./use-hash-route.ts";
|
import { useHashRoute } from "./use-hash-route.ts";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [authed, setAuthed] = useState(hasApiKey());
|
const [authed, setAuthed] = useState(hasApiKey());
|
||||||
const { view, client, threadId, setView, setClient, setThreadId } = useHashRoute();
|
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } = useHashRoute();
|
||||||
const [showRun, setShowRun] = useState(false);
|
const [showRun, setShowRun] = useState(false);
|
||||||
|
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
@@ -46,7 +47,12 @@ export function App() {
|
|||||||
{client && view === "threads" && threadId !== null && (
|
{client && view === "threads" && threadId !== null && (
|
||||||
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
|
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
|
||||||
)}
|
)}
|
||||||
{client && view === "workflows" && <WorkflowList client={client} />}
|
{client && view === "workflows" && workflowName === null && (
|
||||||
|
<WorkflowList client={client} onSelect={setWorkflowName} />
|
||||||
|
)}
|
||||||
|
{client && view === "workflows" && workflowName !== null && (
|
||||||
|
<WorkflowDetail client={client} workflowName={workflowName} onBack={() => setWorkflowName(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{showRun && client && (
|
{showRun && client && (
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
|
||||||
|
import { getWorkflowDetail } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
client: string;
|
||||||
|
workflowName: string;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function versionCount(detail: WorkflowDetailData): number {
|
||||||
|
return detail.history.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schema rendering helpers ────────────────────────────────────────
|
||||||
|
|
||||||
|
type SchemaRow = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
depth: number;
|
||||||
|
prefix: string;
|
||||||
|
isVariantHeader: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveType(prop: Record<string, unknown>): string {
|
||||||
|
if (prop.type === "array") {
|
||||||
|
const items = prop.items as Record<string, unknown> | undefined;
|
||||||
|
if (items !== undefined) {
|
||||||
|
const itemType = String(items.type ?? "unknown");
|
||||||
|
return `${itemType}[]`;
|
||||||
|
}
|
||||||
|
return "array";
|
||||||
|
}
|
||||||
|
return String(prop.type ?? "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenSchema(
|
||||||
|
schema: Record<string, unknown>,
|
||||||
|
depth: number,
|
||||||
|
parentPrefix: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
_parentRequired: Set<string>,
|
||||||
|
): SchemaRow[] {
|
||||||
|
const rows: SchemaRow[] = [];
|
||||||
|
|
||||||
|
// Handle oneOf / discriminatedUnion
|
||||||
|
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||||
|
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||||
|
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||||
|
const variant = oneOf[vi];
|
||||||
|
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||||
|
let variantLabel = `Variant ${vi + 1}`;
|
||||||
|
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||||
|
if (pDef.const !== undefined) {
|
||||||
|
variantLabel = `${pName}: ${String(pDef.const)}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isLast = vi === oneOf.length - 1;
|
||||||
|
const connector = isLast ? "└" : "├";
|
||||||
|
rows.push({
|
||||||
|
key: `${keyPrefix}variant-${vi}`,
|
||||||
|
name: `${parentPrefix}${connector} ${variantLabel}`,
|
||||||
|
type: "",
|
||||||
|
description: "",
|
||||||
|
depth,
|
||||||
|
prefix: parentPrefix,
|
||||||
|
isVariantHeader: true,
|
||||||
|
});
|
||||||
|
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||||
|
const variantRequired = new Set<string>(
|
||||||
|
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||||
|
);
|
||||||
|
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||||
|
if (pDef.const !== undefined) continue;
|
||||||
|
const subRows = flattenProperty(pName, pDef, depth + 1, childPrefix, `${keyPrefix}v${vi}-`, variantRequired);
|
||||||
|
rows.push(...subRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||||
|
const required = new Set<string>(
|
||||||
|
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||||
|
);
|
||||||
|
for (const [name, prop] of Object.entries(props)) {
|
||||||
|
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
|
||||||
|
rows.push(...subRows);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenProperty(
|
||||||
|
name: string,
|
||||||
|
prop: Record<string, unknown>,
|
||||||
|
depth: number,
|
||||||
|
parentPrefix: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
required: Set<string>,
|
||||||
|
): SchemaRow[] {
|
||||||
|
const rows: SchemaRow[] = [];
|
||||||
|
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
||||||
|
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
||||||
|
if (!required.has(name)) type += "?";
|
||||||
|
const description = String(prop.description ?? "");
|
||||||
|
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
key: `${keyPrefix}${name}`,
|
||||||
|
name: displayName,
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
depth,
|
||||||
|
prefix: parentPrefix,
|
||||||
|
isVariantHeader: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prop.type === "object" && prop.properties !== undefined) {
|
||||||
|
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||||
|
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop.type === "array") {
|
||||||
|
const items = prop.items as Record<string, unknown> | undefined;
|
||||||
|
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||||
|
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||||
|
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`, new Set()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOneOf) {
|
||||||
|
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||||
|
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Components ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RoleCard({
|
||||||
|
roleName,
|
||||||
|
role,
|
||||||
|
}: {
|
||||||
|
roleName: string;
|
||||||
|
role: WorkflowRoleDescriptor;
|
||||||
|
}) {
|
||||||
|
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`, new Set());
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`role-${roleName}`}
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
className="text-sm font-semibold font-mono mb-1"
|
||||||
|
style={{ color: "var(--color-text)" }}
|
||||||
|
>
|
||||||
|
{roleName}
|
||||||
|
</h4>
|
||||||
|
{role.description !== "" && (
|
||||||
|
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{role.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Meta Schema
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
|
||||||
|
<th className="text-left py-1 pr-3 font-medium" style={{ color: "var(--color-text-muted)" }}>Field</th>
|
||||||
|
<th className="text-left py-1 pr-3 font-medium" style={{ color: "var(--color-text-muted)" }}>Type</th>
|
||||||
|
<th className="text-left py-1 font-medium" style={{ color: "var(--color-text-muted)" }}>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.key}
|
||||||
|
style={{
|
||||||
|
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="py-1 pr-3 font-mono whitespace-pre"
|
||||||
|
style={{
|
||||||
|
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
|
||||||
|
fontStyle: r.isVariantHeader ? "italic" : "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>{r.type}</td>
|
||||||
|
<td className="py-1" style={{ color: "var(--color-text)" }}>{r.description || (r.isVariantHeader ? "" : "—")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
||||||
|
<pre
|
||||||
|
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
|
||||||
|
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{JSON.stringify(role.schema, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function WorkflowDetail({ client, workflowName, onBack }: Props) {
|
||||||
|
const { status, data, error } = useFetch(
|
||||||
|
() => getWorkflowDetail(client, workflowName),
|
||||||
|
[client, workflowName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
|
||||||
|
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const detail = status === "ok" ? data : null;
|
||||||
|
const descriptor = detail?.descriptor ?? null;
|
||||||
|
const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : [];
|
||||||
|
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
||||||
|
const hasGraph = descriptor !== null && edgeCount > 0;
|
||||||
|
|
||||||
|
const allLitStates = useMemo(() => {
|
||||||
|
const m = new Map<string, NodeState>();
|
||||||
|
m.set("__start__", "completed");
|
||||||
|
m.set("__end__", "completed");
|
||||||
|
for (const [name] of roleEntries) {
|
||||||
|
m.set(name, "completed");
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [roleEntries]);
|
||||||
|
|
||||||
|
function handleGraphNodeClick(nodeId: string) {
|
||||||
|
const el = document.getElementById(`role-${nodeId}`);
|
||||||
|
if (el === null) return;
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||||
|
setHighlightedRole(nodeId);
|
||||||
|
highlightTimerRef.current = setTimeout(() => {
|
||||||
|
setHighlightedRole(null);
|
||||||
|
highlightTimerRef.current = null;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-sm hover:underline"
|
||||||
|
style={{ color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
← Back to workflows
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold mb-4 font-mono">{workflowName}</h2>
|
||||||
|
|
||||||
|
{status === "loading" && (
|
||||||
|
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||||
|
)}
|
||||||
|
{status === "error" && (
|
||||||
|
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail !== null && (
|
||||||
|
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
|
||||||
|
{/* Left: fixed graph sidebar */}
|
||||||
|
{hasGraph && (
|
||||||
|
<div
|
||||||
|
className="shrink-0"
|
||||||
|
style={{
|
||||||
|
width: 280,
|
||||||
|
position: "sticky",
|
||||||
|
top: 16,
|
||||||
|
height: "calc(100vh - 160px)",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-lg border h-full flex flex-col overflow-hidden"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-3 py-2 text-xs"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
<span className="font-mono">Workflow graph</span>
|
||||||
|
<span>
|
||||||
|
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<WorkflowGraph
|
||||||
|
graph={descriptor.graph}
|
||||||
|
roles={descriptor.roles}
|
||||||
|
nodeStates={allLitStates}
|
||||||
|
onNodeClick={handleGraphNodeClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right: scrollable content */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-4">
|
||||||
|
{/* Workflow overview */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<p className="text-sm whitespace-pre-wrap mb-3" style={{ color: "var(--color-text)" }}>
|
||||||
|
{descriptor !== null && descriptor.description !== ""
|
||||||
|
? descriptor.description
|
||||||
|
: "—"}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
<span>
|
||||||
|
Hash:{" "}
|
||||||
|
<code className="font-mono" style={{ color: "var(--color-accent)" }}>
|
||||||
|
{detail.hash}
|
||||||
|
</code>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{roleEntries.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role cards */}
|
||||||
|
{roleEntries.map(([name, role]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
style={{
|
||||||
|
transition: "box-shadow 0.3s",
|
||||||
|
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RoleCard roleName={name} role={role} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,441 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { listWorkflows } from "../api.ts";
|
||||||
import type { WorkflowDetail, WorkflowRoleDescriptor } from "../api.ts";
|
|
||||||
import { getWorkflowDetail, listWorkflows } from "../api.ts";
|
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
client: string;
|
client: string;
|
||||||
|
onSelect: (name: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DetailCacheEntry =
|
export function WorkflowList({ client, onSelect }: Props) {
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "error"; message: string }
|
|
||||||
| { status: "ok"; detail: WorkflowDetail };
|
|
||||||
|
|
||||||
function versionCount(detail: WorkflowDetail): number {
|
|
||||||
return detail.history.length + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SchemaRow = {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
depth: number;
|
|
||||||
prefix: string;
|
|
||||||
isVariantHeader: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveType(prop: Record<string, unknown>): string {
|
|
||||||
if (prop.type === "array") {
|
|
||||||
const items = prop.items as Record<string, unknown> | undefined;
|
|
||||||
if (items !== undefined) {
|
|
||||||
const itemType = String(items.type ?? "unknown");
|
|
||||||
return `${itemType}[]`;
|
|
||||||
}
|
|
||||||
return "array";
|
|
||||||
}
|
|
||||||
return String(prop.type ?? "unknown");
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenSchema(
|
|
||||||
schema: Record<string, unknown>,
|
|
||||||
depth: number,
|
|
||||||
parentPrefix: string,
|
|
||||||
keyPrefix: string,
|
|
||||||
parentRequired: Set<string>,
|
|
||||||
): SchemaRow[] {
|
|
||||||
const rows: SchemaRow[] = [];
|
|
||||||
|
|
||||||
// Handle oneOf / discriminatedUnion
|
|
||||||
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
|
||||||
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
|
||||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
|
||||||
const variant = oneOf[vi];
|
|
||||||
// Try to find a distinguishing literal (e.g. status: "approved")
|
|
||||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
|
||||||
let variantLabel = `Variant ${vi + 1}`;
|
|
||||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
|
||||||
if (pDef.const !== undefined) {
|
|
||||||
variantLabel = `${pName}: ${String(pDef.const)}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const isLast = vi === oneOf.length - 1;
|
|
||||||
const connector = isLast ? "└" : "├";
|
|
||||||
rows.push({
|
|
||||||
key: `${keyPrefix}variant-${vi}`,
|
|
||||||
name: `${parentPrefix}${connector} ${variantLabel}`,
|
|
||||||
type: "",
|
|
||||||
description: "",
|
|
||||||
depth,
|
|
||||||
prefix: parentPrefix,
|
|
||||||
isVariantHeader: true,
|
|
||||||
});
|
|
||||||
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
|
||||||
const variantRequired = new Set<string>(
|
|
||||||
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
|
||||||
);
|
|
||||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
|
||||||
if (pDef.const !== undefined) continue; // skip discriminator field
|
|
||||||
const subRows = flattenProperty(pName, pDef, depth + 1, childPrefix, `${keyPrefix}v${vi}-`, variantRequired);
|
|
||||||
rows.push(...subRows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle regular object with properties
|
|
||||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
|
||||||
const required = new Set<string>(
|
|
||||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
|
||||||
);
|
|
||||||
for (const [name, prop] of Object.entries(props)) {
|
|
||||||
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
|
|
||||||
rows.push(...subRows);
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenProperty(
|
|
||||||
name: string,
|
|
||||||
prop: Record<string, unknown>,
|
|
||||||
depth: number,
|
|
||||||
parentPrefix: string,
|
|
||||||
keyPrefix: string,
|
|
||||||
required: Set<string>,
|
|
||||||
): SchemaRow[] {
|
|
||||||
const rows: SchemaRow[] = [];
|
|
||||||
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
|
||||||
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
|
||||||
if (!required.has(name)) type += "?";
|
|
||||||
const description = String(prop.description ?? "");
|
|
||||||
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
key: `${keyPrefix}${name}`,
|
|
||||||
name: displayName,
|
|
||||||
type,
|
|
||||||
description,
|
|
||||||
depth,
|
|
||||||
prefix: parentPrefix,
|
|
||||||
isVariantHeader: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Recurse into nested object
|
|
||||||
if (prop.type === "object" && prop.properties !== undefined) {
|
|
||||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
|
||||||
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurse into array of objects
|
|
||||||
if (prop.type === "array") {
|
|
||||||
const items = prop.items as Record<string, unknown> | undefined;
|
|
||||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
|
||||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
|
||||||
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`, new Set()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurse into oneOf
|
|
||||||
if (hasOneOf) {
|
|
||||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
|
||||||
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoleCard({
|
|
||||||
roleName,
|
|
||||||
role,
|
|
||||||
}: {
|
|
||||||
roleName: string;
|
|
||||||
role: WorkflowRoleDescriptor;
|
|
||||||
}) {
|
|
||||||
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`, new Set());
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id={`role-${roleName}`}
|
|
||||||
className="rounded-lg border p-4"
|
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
|
||||||
>
|
|
||||||
<h4
|
|
||||||
className="text-sm font-semibold font-mono mb-1"
|
|
||||||
style={{ color: "var(--color-text)" }}
|
|
||||||
>
|
|
||||||
{roleName}
|
|
||||||
</h4>
|
|
||||||
{role.description !== "" && (
|
|
||||||
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
{role.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{rows.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
Meta Schema
|
|
||||||
</p>
|
|
||||||
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
|
|
||||||
<th className="text-left py-1 pr-3 font-medium" style={{ color: "var(--color-text-muted)" }}>Field</th>
|
|
||||||
<th className="text-left py-1 pr-3 font-medium" style={{ color: "var(--color-text-muted)" }}>Type</th>
|
|
||||||
<th className="text-left py-1 font-medium" style={{ color: "var(--color-text-muted)" }}>Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((r) => (
|
|
||||||
<tr
|
|
||||||
key={r.key}
|
|
||||||
style={{
|
|
||||||
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
className="py-1 pr-3 font-mono whitespace-pre"
|
|
||||||
style={{
|
|
||||||
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
|
|
||||||
fontStyle: r.isVariantHeader ? "italic" : "normal",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{r.name}
|
|
||||||
</td>
|
|
||||||
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>{r.type}</td>
|
|
||||||
<td className="py-1" style={{ color: "var(--color-text)" }}>{r.description || (r.isVariantHeader ? "" : "—")}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
|
||||||
<pre
|
|
||||||
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
|
|
||||||
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{JSON.stringify(role.schema, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExpandedWorkflowBody({
|
|
||||||
cacheEntry,
|
|
||||||
staticNodeStates,
|
|
||||||
}: {
|
|
||||||
cacheEntry: DetailCacheEntry | undefined;
|
|
||||||
staticNodeStates: Map<string, NodeState>;
|
|
||||||
}) {
|
|
||||||
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
|
|
||||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const detail = cacheEntry?.status === "ok" ? cacheEntry.detail : null;
|
|
||||||
const descriptor = detail?.descriptor ?? null;
|
|
||||||
const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : [];
|
|
||||||
|
|
||||||
// All roles are "completed" (static view, all nodes lit) — must be before early returns
|
|
||||||
const allLitStates = useMemo(() => {
|
|
||||||
const m = new Map<string, NodeState>();
|
|
||||||
m.set("__start__", "completed");
|
|
||||||
m.set("__end__", "completed");
|
|
||||||
for (const [name] of roleEntries) {
|
|
||||||
m.set(name, "completed");
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}, [roleEntries]);
|
|
||||||
|
|
||||||
if (cacheEntry === undefined || cacheEntry.status === "loading") {
|
|
||||||
return (
|
|
||||||
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Loading workflow details...
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cacheEntry.status === "error") {
|
|
||||||
return (
|
|
||||||
<p className="text-sm py-2" style={{ color: "var(--color-error)" }}>
|
|
||||||
{cacheEntry.message}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
|
||||||
const vc = detail !== null ? versionCount(detail) : 0;
|
|
||||||
|
|
||||||
const hasGraph = descriptor !== null && edgeCount > 0;
|
|
||||||
|
|
||||||
function handleGraphNodeClick(nodeId: string) {
|
|
||||||
const el = document.getElementById(`role-${nodeId}`);
|
|
||||||
if (el === null) return;
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
|
||||||
setHighlightedRole(nodeId);
|
|
||||||
highlightTimerRef.current = setTimeout(() => {
|
|
||||||
setHighlightedRole(null);
|
|
||||||
highlightTimerRef.current = null;
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
|
|
||||||
{/* Left: graph sidebar */}
|
|
||||||
{hasGraph && (
|
|
||||||
<div
|
|
||||||
className="shrink-0"
|
|
||||||
style={{
|
|
||||||
width: 280,
|
|
||||||
position: "sticky",
|
|
||||||
top: 16,
|
|
||||||
height: "calc(100vh - 120px)",
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="rounded-lg border h-full flex flex-col overflow-hidden"
|
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-3 py-2 text-xs"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
<span className="font-mono">Workflow graph</span>
|
|
||||||
<span>
|
|
||||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<WorkflowGraph
|
|
||||||
graph={descriptor.graph}
|
|
||||||
roles={descriptor.roles}
|
|
||||||
nodeStates={allLitStates}
|
|
||||||
onNodeClick={handleGraphNodeClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Right: workflow info + role cards */}
|
|
||||||
<div className="flex-1 min-w-0 space-y-4">
|
|
||||||
{/* Workflow overview */}
|
|
||||||
<div
|
|
||||||
className="rounded-lg border p-4"
|
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
|
||||||
>
|
|
||||||
<h3 className="text-base font-semibold mb-2" style={{ color: "var(--color-text)" }}>
|
|
||||||
{detail.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm whitespace-pre-wrap mb-3" style={{ color: "var(--color-text)" }}>
|
|
||||||
{descriptor !== null && descriptor.description !== ""
|
|
||||||
? descriptor.description
|
|
||||||
: descriptor !== null
|
|
||||||
? "—"
|
|
||||||
: "No descriptor available for this workflow version."}
|
|
||||||
</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>
|
|
||||||
{vc} version{vc !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
{roleEntries.length > 0 && (
|
|
||||||
<span>
|
|
||||||
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Role cards */}
|
|
||||||
{roleEntries.map(([name, role]) => (
|
|
||||||
<div
|
|
||||||
key={name}
|
|
||||||
style={{
|
|
||||||
transition: "box-shadow 0.3s",
|
|
||||||
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RoleCard roleName={name} role={role} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowList({ client }: Props) {
|
|
||||||
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
|
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
|
||||||
const [detailsByName, setDetailsByName] = useState<Map<string, DetailCacheEntry>>(
|
|
||||||
() => new Map(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const staticNodeStates = useMemo(() => new Map<string, NodeState>(), []);
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching clients
|
|
||||||
useEffect(() => {
|
|
||||||
setExpanded(new Set());
|
|
||||||
setDetailsByName(new Map());
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
const ensureDetailLoaded = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
setDetailsByName((prev) => {
|
|
||||||
const cur = prev.get(name);
|
|
||||||
if (cur !== undefined && (cur.status === "ok" || cur.status === "loading")) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return new Map(prev).set(name, { status: "loading" });
|
|
||||||
});
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const detail = await getWorkflowDetail(client, name);
|
|
||||||
setDetailsByName((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(name, { status: "ok", detail });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
|
||||||
setDetailsByName((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(name, { status: "error", message });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
[client],
|
|
||||||
);
|
|
||||||
|
|
||||||
function toggleExpanded(name: string) {
|
|
||||||
const wasExpanded = expanded.has(name);
|
|
||||||
setExpanded((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(name)) {
|
|
||||||
next.delete(name);
|
|
||||||
} else {
|
|
||||||
next.add(name);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (!wasExpanded) {
|
|
||||||
ensureDetailLoaded(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "loading")
|
if (status === "loading")
|
||||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
||||||
@@ -450,58 +22,33 @@ export function WorkflowList({ client }: Props) {
|
|||||||
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{workflows.map((w) => {
|
{workflows.map((w) => (
|
||||||
const isOpen = expanded.has(w.name);
|
<button
|
||||||
return (
|
key={w.name}
|
||||||
<div
|
type="button"
|
||||||
key={w.name}
|
onClick={() => onSelect(w.name)}
|
||||||
className="rounded-lg border overflow-hidden"
|
className="w-full text-left p-4 rounded-lg border hover:opacity-90"
|
||||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)", color: "var(--color-text)" }}
|
||||||
>
|
>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<span className="font-medium">{w.name}</span>
|
||||||
onClick={() => toggleExpanded(w.name)}
|
|
||||||
className="w-full text-left p-4 flex items-start justify-between gap-3 hover:opacity-90"
|
|
||||||
style={{ color: "var(--color-text)" }}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="text-xs font-mono"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{isOpen ? "▼" : "▶"}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">{w.name}</span>
|
|
||||||
</div>
|
|
||||||
<code
|
|
||||||
className="text-xs mt-1 block font-mono truncate"
|
|
||||||
style={{ color: "var(--color-accent)" }}
|
|
||||||
>
|
|
||||||
{w.hash !== null ? w.hash : "—"}
|
|
||||||
</code>
|
|
||||||
{w.timestamp !== null ? (
|
|
||||||
<span
|
|
||||||
className="text-xs mt-1 block"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
Updated {new Date(w.timestamp).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{isOpen ? (
|
|
||||||
<div className="px-4 pb-4">
|
|
||||||
<ExpandedWorkflowBody
|
|
||||||
cacheEntry={detailsByName.get(w.name)}
|
|
||||||
staticNodeStates={staticNodeStates}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<code
|
||||||
})}
|
className="text-xs mt-1 block font-mono truncate"
|
||||||
|
style={{ color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
{w.hash !== null ? w.hash : "—"}
|
||||||
|
</code>
|
||||||
|
{w.timestamp !== null ? (
|
||||||
|
<span
|
||||||
|
className="text-xs mt-1 block"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Updated {new Date(w.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type HashRoute = {
|
|||||||
view: View;
|
view: View;
|
||||||
client: string | null;
|
client: string | null;
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
|
workflowName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseHash(hash: string): HashRoute {
|
function parseHash(hash: string): HashRoute {
|
||||||
@@ -19,6 +20,7 @@ function parseHash(hash: string): HashRoute {
|
|||||||
view: parts[0] as View,
|
view: parts[0] as View,
|
||||||
client: null,
|
client: null,
|
||||||
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||||
|
workflowName: parts[0] === "workflows" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,13 +29,17 @@ function parseHash(hash: string): HashRoute {
|
|||||||
const viewPart = parts[1] ?? "threads";
|
const viewPart = parts[1] ?? "threads";
|
||||||
const view: View = viewPart === "workflows" ? "workflows" : "threads";
|
const view: View = viewPart === "workflows" ? "workflows" : "threads";
|
||||||
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||||
|
const workflowName = view === "workflows" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||||
|
|
||||||
return { view, client, threadId };
|
return { view, client, threadId, workflowName };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHash(route: HashRoute): string {
|
function buildHash(route: HashRoute): string {
|
||||||
const prefix = route.client ? `${route.client}/` : "";
|
const prefix = route.client ? `${route.client}/` : "";
|
||||||
if (route.view === "workflows") {
|
if (route.view === "workflows") {
|
||||||
|
if (route.workflowName !== null) {
|
||||||
|
return `#${prefix}workflows/${route.workflowName}`;
|
||||||
|
}
|
||||||
return `#${prefix}workflows`;
|
return `#${prefix}workflows`;
|
||||||
}
|
}
|
||||||
if (route.threadId !== null) {
|
if (route.threadId !== null) {
|
||||||
@@ -46,9 +52,11 @@ export function useHashRoute(): {
|
|||||||
view: View;
|
view: View;
|
||||||
client: string | null;
|
client: string | null;
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
|
workflowName: string | null;
|
||||||
setView: (v: View) => void;
|
setView: (v: View) => void;
|
||||||
setClient: (a: string | null) => void;
|
setClient: (a: string | null) => void;
|
||||||
setThreadId: (id: string | null) => void;
|
setThreadId: (id: string | null) => void;
|
||||||
|
setWorkflowName: (name: string | null) => void;
|
||||||
} {
|
} {
|
||||||
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
||||||
|
|
||||||
@@ -67,17 +75,22 @@ export function useHashRoute(): {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setView = useCallback(
|
const setView = useCallback(
|
||||||
(v: View) => navigate({ view: v, client: route.client, threadId: null }),
|
(v: View) => navigate({ view: v, client: route.client, threadId: null, workflowName: null }),
|
||||||
[navigate, route.client],
|
[navigate, route.client],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setClient = useCallback(
|
const setClient = useCallback(
|
||||||
(a: string | null) => navigate({ view: route.view, client: a, threadId: null }),
|
(a: string | null) => navigate({ view: route.view, client: a, threadId: null, workflowName: null }),
|
||||||
[navigate, route.view],
|
[navigate, route.view],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThreadId = useCallback(
|
const setThreadId = useCallback(
|
||||||
(id: string | null) => navigate({ view: "threads", client: route.client, threadId: id }),
|
(id: string | null) => navigate({ view: "threads", client: route.client, threadId: id, workflowName: null }),
|
||||||
|
[navigate, route.client],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWorkflowName = useCallback(
|
||||||
|
(name: string | null) => navigate({ view: "workflows", client: route.client, threadId: null, workflowName: name }),
|
||||||
[navigate, route.client],
|
[navigate, route.client],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -85,8 +98,10 @@ export function useHashRoute(): {
|
|||||||
view: route.view,
|
view: route.view,
|
||||||
client: route.client,
|
client: route.client,
|
||||||
threadId: route.threadId,
|
threadId: route.threadId,
|
||||||
|
workflowName: route.workflowName,
|
||||||
setView,
|
setView,
|
||||||
setClient,
|
setClient,
|
||||||
setThreadId,
|
setThreadId,
|
||||||
|
setWorkflowName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user