From e0182215affd7d495188416c7324a51737053534 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 28 Nov 2025 11:16:26 -0600 Subject: [PATCH 1/4] Workflow CFG Viewer and nuqs migration --- .../web-shared/src/api/workflow-api-client.ts | 2 +- .../src/api/workflow-server-actions.ts | 35 ++ packages/web-shared/src/index.ts | 5 + packages/web/package.json | 7 +- packages/web/src/app/globals.css | 544 +++++++++--------- packages/web/src/app/layout-client.tsx | 111 ++-- packages/web/src/app/layout.tsx | 5 +- packages/web/src/app/page.tsx | 95 ++- .../display-utils/table-skeleton.tsx | 119 +++- .../web/src/components/settings-dropdown.tsx | 88 +++ .../web/src/components/settings-sidebar.tsx | 53 +- packages/web/src/components/ui/badge.tsx | 36 ++ .../web/src/components/ui/dropdown-menu.tsx | 200 +++++++ .../src/components/ui/segmented-control.tsx | 44 ++ packages/web/src/components/ui/sheet.tsx | 139 +++++ packages/web/src/components/ui/tabs.tsx | 66 +++ .../src/components/workflow-graph-viewer.css | 47 ++ .../src/components/workflow-graph-viewer.tsx | 467 +++++++++++++++ .../web/src/components/workflows-list.tsx | 161 ++++++ packages/web/src/lib/config.ts | 128 ++--- packages/web/src/lib/url-state.ts | 66 +++ packages/web/src/lib/use-workflow-graph.ts | 61 ++ packages/web/src/lib/workflow-graph-types.ts | 99 ++++ pnpm-lock.yaml | 479 +++++++++++++++ 24 files changed, 2580 insertions(+), 477 deletions(-) create mode 100644 packages/web/src/components/settings-dropdown.tsx create mode 100644 packages/web/src/components/ui/badge.tsx create mode 100644 packages/web/src/components/ui/dropdown-menu.tsx create mode 100644 packages/web/src/components/ui/segmented-control.tsx create mode 100644 packages/web/src/components/ui/sheet.tsx create mode 100644 packages/web/src/components/ui/tabs.tsx create mode 100644 packages/web/src/components/workflow-graph-viewer.css create mode 100644 packages/web/src/components/workflow-graph-viewer.tsx create mode 100644 packages/web/src/components/workflows-list.tsx create mode 100644 packages/web/src/lib/url-state.ts create mode 100644 packages/web/src/lib/use-workflow-graph.ts create mode 100644 packages/web/src/lib/workflow-graph-types.ts diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index ee1a7527b..3143c5524 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -64,7 +64,7 @@ export const getErrorMessage = (error: Error | WorkflowAPIError): string => { /** * Helper to handle server action results and throw WorkflowAPIError on failure */ -function unwrapServerActionResult(result: { +export function unwrapServerActionResult(result: { success: boolean; data?: T; error?: ServerActionError; diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 66230d452..96ec65ac7 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -1,5 +1,7 @@ 'use server'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { hydrateResourceIO } from '@workflow/core/observability'; import { createWorld, start } from '@workflow/core/runtime'; import type { @@ -499,3 +501,36 @@ export async function readStreamServerAction( }; } } + +/** + * Fetch the workflows manifest from the data directory + * The manifest is generated at build time and contains static structure info about workflows + */ +export async function fetchWorkflowsManifest( + worldEnv: EnvMap +): Promise> { + try { + // Get the data directory from the world environment config + // This contains the correct absolute path passed from the client + const dataDir = + worldEnv.WORKFLOW_EMBEDDED_DATA_DIR || + process.env.WORKFLOW_EMBEDDED_DATA_DIR || + '.next/workflow-data'; + + // If dataDir is absolute, use it directly; otherwise join with cwd + const fullPath = path.isAbsolute(dataDir) + ? path.join(dataDir, 'workflows.json') + : path.join(process.cwd(), dataDir, 'workflows.json'); + + const content = await fs.readFile(fullPath, 'utf-8'); + const manifest = JSON.parse(content); + + return createResponse(manifest); + } catch (error) { + console.error('Failed to fetch workflows manifest:', error); + return { + success: false, + error: createServerActionError(error, 'fetchWorkflowsManifest', {}), + }; + } +} diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 60e9d2465..250d5eac6 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -5,6 +5,11 @@ export { export type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; export * from './api/workflow-api-client'; +export type { EnvMap } from './api/workflow-server-actions'; +export { + fetchEventsByCorrelationId, + fetchWorkflowsManifest, +} from './api/workflow-server-actions'; export { RunTraceView } from './run-trace-view'; export type { Span, SpanEvent } from './trace-viewer/types'; export { WorkflowTraceViewer } from './workflow-trace-view'; diff --git a/packages/web/package.json b/packages/web/package.json index f46a757d2..5f69bf135 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,9 @@ "format": "biome format --write" }, "dependencies": { - "next": "15.5.4" + "next": "15.5.4", + "nuqs": "^2.2.5", + "@xyflow/react": "12.9.3" }, "devDependencies": { "@biomejs/biome": "catalog:", @@ -36,6 +38,9 @@ "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-switch": "1.1.2", "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-dropdown-menu": "2.1.6", "@tailwindcss/postcss": "4", "@types/node": "catalog:", "@types/react": "19", diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index eba38b97a..452b00c9a 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -2,30 +2,83 @@ /* Scan web-shared package for Tailwind classes */ @source "../../../web-shared/src"; +@source "../components"; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --radius: 0.5rem; - - /* Geist Design System Colors */ + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + /* Geist Design System Colors - Light Mode */ /* Backgrounds */ --ds-background-100: rgb(255 255 255); --ds-background-200: rgb(250 250 250); @@ -42,17 +95,6 @@ --ds-gray-900: rgb(23 23 23); --ds-gray-1000: rgb(0 0 0); - /* Gray Alpha (with transparency) */ - --ds-gray-alpha-100: rgb(0 0 0 / 0.02); - --ds-gray-alpha-200: rgb(0 0 0 / 0.04); - --ds-gray-alpha-300: rgb(0 0 0 / 0.08); - --ds-gray-alpha-400: rgb(0 0 0 / 0.12); - --ds-gray-alpha-500: rgb(0 0 0 / 0.3); - --ds-gray-alpha-600: rgb(0 0 0 / 0.5); - --ds-gray-alpha-700: rgb(0 0 0 / 0.6); - --ds-gray-alpha-800: rgb(0 0 0 / 0.7); - --ds-gray-alpha-900: rgb(0 0 0 / 0.9); - /* Blue */ --ds-blue-100: rgb(224 242 254); --ds-blue-200: rgb(186 230 253); @@ -101,18 +143,6 @@ --ds-green-900: rgb(20 83 45); --ds-green-1000: rgb(5 46 22); - /* Teal */ - --ds-teal-100: rgb(204 251 241); - --ds-teal-200: rgb(153 246 228); - --ds-teal-300: rgb(94 234 212); - --ds-teal-400: rgb(45 212 191); - --ds-teal-500: rgb(20 184 166); - --ds-teal-600: rgb(13 148 136); - --ds-teal-700: rgb(15 118 110); - --ds-teal-800: rgb(17 94 89); - --ds-teal-900: rgb(19 78 74); - --ds-teal-1000: rgb(4 47 46); - /* Purple */ --ds-purple-100: rgb(243 232 255); --ds-purple-200: rgb(233 213 255); @@ -125,17 +155,17 @@ --ds-purple-900: rgb(88 28 135); --ds-purple-1000: rgb(59 7 100); - /* Pink */ - --ds-pink-100: rgb(252 231 243); - --ds-pink-200: rgb(251 207 232); - --ds-pink-300: rgb(249 168 212); - --ds-pink-400: rgb(244 114 182); - --ds-pink-500: rgb(236 72 153); - --ds-pink-600: rgb(219 39 119); - --ds-pink-700: rgb(190 24 93); - --ds-pink-800: rgb(157 23 77); - --ds-pink-900: rgb(131 24 67); - --ds-pink-1000: rgb(80 7 36); + /* Teal */ + --ds-teal-100: rgb(204 251 241); + --ds-teal-200: rgb(153 246 228); + --ds-teal-300: rgb(94 234 212); + --ds-teal-400: rgb(45 212 191); + --ds-teal-500: rgb(20 184 166); + --ds-teal-600: rgb(13 148 136); + --ds-teal-700: rgb(15 118 110); + --ds-teal-800: rgb(17 94 89); + --ds-teal-900: rgb(19 78 74); + --ds-teal-1000: rgb(4 47 46); /* Shadows */ --ds-shadow-small: 0 0 0 1px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.05), 0 12px 24px rgba(0, 0, 0, 0.05); @@ -143,169 +173,38 @@ --ds-shadow-large: 0 0 0 1px rgba(0, 0, 0, 0.07), 0 8px 16px rgba(0, 0, 0, 0.10), 0 48px 96px rgba(0, 0, 0, 0.10); } -/* Dark mode: applies when system prefers dark (and no .light class override) */ -@media (prefers-color-scheme: dark) { - :root:not(.light) { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - - /* Geist Design System Colors - Dark Mode */ - /* Backgrounds */ - --ds-background-100: rgb(0 0 0); - --ds-background-200: rgb(10 10 10); - - /* Gray Scale */ - --ds-gray-100: rgb(17 17 17); - --ds-gray-200: rgb(23 23 23); - --ds-gray-300: rgb(41 41 41); - --ds-gray-400: rgb(64 64 64); - --ds-gray-500: rgb(115 115 115); - --ds-gray-600: rgb(163 163 163); - --ds-gray-700: rgb(212 212 212); - --ds-gray-800: rgb(229 229 229); - --ds-gray-900: rgb(245 245 245); - --ds-gray-1000: rgb(255 255 255); - - /* Gray Alpha (with transparency) */ - --ds-gray-alpha-100: rgb(255 255 255 / 0.02); - --ds-gray-alpha-200: rgb(255 255 255 / 0.04); - --ds-gray-alpha-300: rgb(255 255 255 / 0.08); - --ds-gray-alpha-400: rgb(255 255 255 / 0.12); - --ds-gray-alpha-500: rgb(255 255 255 / 0.3); - --ds-gray-alpha-600: rgb(255 255 255 / 0.5); - --ds-gray-alpha-700: rgb(255 255 255 / 0.6); - --ds-gray-alpha-800: rgb(255 255 255 / 0.7); - --ds-gray-alpha-900: rgb(255 255 255 / 0.9); - - /* Blue - Dark Mode */ - --ds-blue-100: rgb(8 47 73); - --ds-blue-200: rgb(12 74 110); - --ds-blue-300: rgb(7 89 133); - --ds-blue-400: rgb(3 105 161); - --ds-blue-500: rgb(2 132 199); - --ds-blue-600: rgb(14 165 233); - --ds-blue-700: rgb(56 189 248); - --ds-blue-800: rgb(125 211 252); - --ds-blue-900: rgb(186 230 253); - --ds-blue-1000: rgb(224 242 254); - - /* Red - Dark Mode */ - --ds-red-100: rgb(69 10 10); - --ds-red-200: rgb(127 29 29); - --ds-red-300: rgb(153 27 27); - --ds-red-400: rgb(185 28 28); - --ds-red-500: rgb(220 38 38); - --ds-red-600: rgb(239 68 68); - --ds-red-700: rgb(248 113 113); - --ds-red-800: rgb(252 165 165); - --ds-red-900: rgb(254 202 202); - --ds-red-1000: rgb(254 226 226); - - /* Amber - Dark Mode */ - --ds-amber-100: rgb(69 26 3); - --ds-amber-200: rgb(120 53 15); - --ds-amber-300: rgb(146 64 14); - --ds-amber-400: rgb(180 83 9); - --ds-amber-500: rgb(217 119 6); - --ds-amber-600: rgb(245 158 11); - --ds-amber-700: rgb(251 191 36); - --ds-amber-800: rgb(252 211 77); - --ds-amber-900: rgb(253 230 138); - --ds-amber-1000: rgb(254 243 199); - - /* Green - Dark Mode */ - --ds-green-100: rgb(5 46 22); - --ds-green-200: rgb(20 83 45); - --ds-green-300: rgb(22 101 52); - --ds-green-400: rgb(21 128 61); - --ds-green-500: rgb(22 163 74); - --ds-green-600: rgb(34 197 94); - --ds-green-700: rgb(74 222 128); - --ds-green-800: rgb(134 239 172); - --ds-green-900: rgb(187 247 208); - --ds-green-1000: rgb(220 252 231); - - /* Teal - Dark Mode */ - --ds-teal-100: rgb(4 47 46); - --ds-teal-200: rgb(19 78 74); - --ds-teal-300: rgb(17 94 89); - --ds-teal-400: rgb(15 118 110); - --ds-teal-500: rgb(13 148 136); - --ds-teal-600: rgb(20 184 166); - --ds-teal-700: rgb(45 212 191); - --ds-teal-800: rgb(94 234 212); - --ds-teal-900: rgb(153 246 228); - --ds-teal-1000: rgb(204 251 241); - - /* Purple - Dark Mode */ - --ds-purple-100: rgb(59 7 100); - --ds-purple-200: rgb(88 28 135); - --ds-purple-300: rgb(107 33 168); - --ds-purple-400: rgb(126 34 206); - --ds-purple-500: rgb(147 51 234); - --ds-purple-600: rgb(168 85 247); - --ds-purple-700: rgb(192 132 252); - --ds-purple-800: rgb(216 180 254); - --ds-purple-900: rgb(233 213 255); - --ds-purple-1000: rgb(243 232 255); - - /* Pink - Dark Mode */ - --ds-pink-100: rgb(80 7 36); - --ds-pink-200: rgb(131 24 67); - --ds-pink-300: rgb(157 23 77); - --ds-pink-400: rgb(190 24 93); - --ds-pink-500: rgb(219 39 119); - --ds-pink-600: rgb(236 72 153); - --ds-pink-700: rgb(244 114 182); - --ds-pink-800: rgb(249 168 212); - --ds-pink-900: rgb(251 207 232); - --ds-pink-1000: rgb(252 231 243); - - /* Shadows - Dark Mode */ - --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); - --ds-shadow-medium: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 4px 8px rgba(0, 0, 0, 0.4), 0 24px 48px rgba(0, 0, 0, 0.4); - --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); - } -} - -/* Dark mode: applies when .dark class is present (overrides system preference) */ .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); /* Geist Design System Colors - Dark Mode */ /* Backgrounds */ @@ -324,17 +223,6 @@ --ds-gray-900: rgb(245 245 245); --ds-gray-1000: rgb(255 255 255); - /* Gray Alpha (with transparency) */ - --ds-gray-alpha-100: rgb(255 255 255 / 0.02); - --ds-gray-alpha-200: rgb(255 255 255 / 0.04); - --ds-gray-alpha-300: rgb(255 255 255 / 0.08); - --ds-gray-alpha-400: rgb(255 255 255 / 0.12); - --ds-gray-alpha-500: rgb(255 255 255 / 0.3); - --ds-gray-alpha-600: rgb(255 255 255 / 0.5); - --ds-gray-alpha-700: rgb(255 255 255 / 0.6); - --ds-gray-alpha-800: rgb(255 255 255 / 0.7); - --ds-gray-alpha-900: rgb(255 255 255 / 0.9); - /* Blue - Dark Mode */ --ds-blue-100: rgb(8 47 73); --ds-blue-200: rgb(12 74 110); @@ -383,18 +271,6 @@ --ds-green-900: rgb(187 247 208); --ds-green-1000: rgb(220 252 231); - /* Teal - Dark Mode */ - --ds-teal-100: rgb(4 47 46); - --ds-teal-200: rgb(19 78 74); - --ds-teal-300: rgb(17 94 89); - --ds-teal-400: rgb(15 118 110); - --ds-teal-500: rgb(13 148 136); - --ds-teal-600: rgb(20 184 166); - --ds-teal-700: rgb(45 212 191); - --ds-teal-800: rgb(94 234 212); - --ds-teal-900: rgb(153 246 228); - --ds-teal-1000: rgb(204 251 241); - /* Purple - Dark Mode */ --ds-purple-100: rgb(59 7 100); --ds-purple-200: rgb(88 28 135); @@ -407,17 +283,17 @@ --ds-purple-900: rgb(233 213 255); --ds-purple-1000: rgb(243 232 255); - /* Pink - Dark Mode */ - --ds-pink-100: rgb(80 7 36); - --ds-pink-200: rgb(131 24 67); - --ds-pink-300: rgb(157 23 77); - --ds-pink-400: rgb(190 24 93); - --ds-pink-500: rgb(219 39 119); - --ds-pink-600: rgb(236 72 153); - --ds-pink-700: rgb(244 114 182); - --ds-pink-800: rgb(249 168 212); - --ds-pink-900: rgb(251 207 232); - --ds-pink-1000: rgb(252 231 243); + /* Teal - Dark Mode */ + --ds-teal-100: rgb(4 47 46); + --ds-teal-200: rgb(19 78 74); + --ds-teal-300: rgb(17 94 89); + --ds-teal-400: rgb(15 118 110); + --ds-teal-500: rgb(13 148 136); + --ds-teal-600: rgb(20 184 166); + --ds-teal-700: rgb(45 212 191); + --ds-teal-800: rgb(94 234 212); + --ds-teal-900: rgb(153 246 228); + --ds-teal-1000: rgb(204 251 241); /* Shadows - Dark Mode */ --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); @@ -425,41 +301,137 @@ --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); } -@theme { - /* Allow Tailwind's default color palette while also defining custom colors */ - --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-border: hsl(var(--border)); - --color-input: hsl(var(--input)); - --color-ring: hsl(var(--ring)); - - --radius-sm: 0.375rem; - --radius: calc(var(--radius)); - --radius-md: calc(var(--radius)); - --radius-lg: 0.75rem; - --radius-xl: 1rem; +/* System dark mode preference (when no explicit light/dark class is set) */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + + /* Geist Design System Colors - Dark Mode (same as .dark) */ + --ds-background-100: rgb(0 0 0); + --ds-background-200: rgb(10 10 10); + --ds-gray-100: rgb(17 17 17); + --ds-gray-200: rgb(23 23 23); + --ds-gray-300: rgb(41 41 41); + --ds-gray-400: rgb(64 64 64); + --ds-gray-500: rgb(115 115 115); + --ds-gray-600: rgb(163 163 163); + --ds-gray-700: rgb(212 212 212); + --ds-gray-800: rgb(229 229 229); + --ds-gray-900: rgb(245 245 245); + --ds-gray-1000: rgb(255 255 255); + --ds-blue-100: rgb(8 47 73); + --ds-blue-200: rgb(12 74 110); + --ds-blue-300: rgb(7 89 133); + --ds-blue-400: rgb(3 105 161); + --ds-blue-500: rgb(2 132 199); + --ds-blue-600: rgb(14 165 233); + --ds-blue-700: rgb(56 189 248); + --ds-blue-800: rgb(125 211 252); + --ds-blue-900: rgb(186 230 253); + --ds-blue-1000: rgb(224 242 254); + --ds-red-100: rgb(69 10 10); + --ds-red-200: rgb(127 29 29); + --ds-red-300: rgb(153 27 27); + --ds-red-400: rgb(185 28 28); + --ds-red-500: rgb(220 38 38); + --ds-red-600: rgb(239 68 68); + --ds-red-700: rgb(248 113 113); + --ds-red-800: rgb(252 165 165); + --ds-red-900: rgb(254 202 202); + --ds-red-1000: rgb(254 226 226); + --ds-amber-100: rgb(69 26 3); + --ds-amber-200: rgb(120 53 15); + --ds-amber-300: rgb(146 64 14); + --ds-amber-400: rgb(180 83 9); + --ds-amber-500: rgb(217 119 6); + --ds-amber-600: rgb(245 158 11); + --ds-amber-700: rgb(251 191 36); + --ds-amber-800: rgb(252 211 77); + --ds-amber-900: rgb(253 230 138); + --ds-amber-1000: rgb(254 243 199); + --ds-green-100: rgb(5 46 22); + --ds-green-200: rgb(20 83 45); + --ds-green-300: rgb(22 101 52); + --ds-green-400: rgb(21 128 61); + --ds-green-500: rgb(22 163 74); + --ds-green-600: rgb(34 197 94); + --ds-green-700: rgb(74 222 128); + --ds-green-800: rgb(134 239 172); + --ds-green-900: rgb(187 247 208); + --ds-green-1000: rgb(220 252 231); + --ds-purple-100: rgb(59 7 100); + --ds-purple-200: rgb(88 28 135); + --ds-purple-300: rgb(107 33 168); + --ds-purple-400: rgb(126 34 206); + --ds-purple-500: rgb(147 51 234); + --ds-purple-600: rgb(168 85 247); + --ds-purple-700: rgb(192 132 252); + --ds-purple-800: rgb(216 180 254); + --ds-purple-900: rgb(233 213 255); + --ds-purple-1000: rgb(243 232 255); + --ds-teal-100: rgb(4 47 46); + --ds-teal-200: rgb(19 78 74); + --ds-teal-300: rgb(17 94 89); + --ds-teal-400: rgb(15 118 110); + --ds-teal-500: rgb(13 148 136); + --ds-teal-600: rgb(20 184 166); + --ds-teal-700: rgb(45 212 191); + --ds-teal-800: rgb(94 234 212); + --ds-teal-900: rgb(153 246 228); + --ds-teal-1000: rgb(204 251 241); + --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); + --ds-shadow-medium: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 4px 8px rgba(0, 0, 0, 0.4), 0 24px 48px rgba(0, 0, 0, 0.4); + --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); + } } -* { - border-color: hsl(var(--border)); +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground overscroll-none; + } + html { + @apply overscroll-none; + } } -body { - background: hsl(var(--background)); - color: hsl(var(--foreground)); - font-feature-settings: "rlig" 1, "calt" 1; -} +@keyframes gradient { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} \ No newline at end of file diff --git a/packages/web/src/app/layout-client.tsx b/packages/web/src/app/layout-client.tsx index 451d54745..286923356 100644 --- a/packages/web/src/app/layout-client.tsx +++ b/packages/web/src/app/layout-client.tsx @@ -2,11 +2,11 @@ import { TooltipProvider } from '@radix-ui/react-tooltip'; import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { ThemeProvider } from 'next-themes'; -import { useEffect } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { ThemeProvider, useTheme } from 'next-themes'; +import { useEffect, useRef } from 'react'; import { ConnectionStatus } from '@/components/display-utils/connection-status'; -import { SettingsSidebar } from '@/components/settings-sidebar'; +import { SettingsDropdown } from '@/components/settings-dropdown'; import { Toaster } from '@/components/ui/sonner'; import { buildUrlWithConfig, useQueryParamConfig } from '@/lib/config'; import { Logo } from '../icons/logo'; @@ -15,33 +15,49 @@ interface LayoutClientProps { children: React.ReactNode; } -export function LayoutClient({ children }: LayoutClientProps) { +function LayoutContent({ children }: LayoutClientProps) { const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const config = useQueryParamConfig(); + const { setTheme } = useTheme(); const id = searchParams.get('id'); const runId = searchParams.get('runId'); const stepId = searchParams.get('stepId'); const hookId = searchParams.get('hookId'); const resource = searchParams.get('resource'); - const theme = searchParams.get('theme') || 'system'; - - // Apply theme class to document - useEffect(() => { - const html = document.documentElement; + const themeParam = searchParams.get('theme'); - // Remove existing theme classes - html.classList.remove('light', 'dark'); + // Track if we've already handled the initial navigation + const hasNavigatedRef = useRef(false); - // Apply theme class (system will use CSS media query) - if (theme === 'light' || theme === 'dark') { - html.classList.add(theme); + // Sync theme from URL param to next-themes (one-time or when explicitly changed) + useEffect(() => { + if ( + themeParam && + (themeParam === 'light' || + themeParam === 'dark' || + themeParam === 'system') + ) { + setTheme(themeParam); } - }, [theme]); + }, [themeParam, setTheme]); // If initialized with a resource/id or direct ID params, we navigate to the appropriate page + // Only run this logic once on mount or when we're on the root path with special params useEffect(() => { + // Skip if we're not on the root path and we've already navigated + if (pathname !== '/' && hasNavigatedRef.current) { + return; + } + + // Skip if we're already on a run page (prevents interference with back navigation) + if (pathname.startsWith('/run/')) { + hasNavigatedRef.current = true; + return; + } + // Handle direct ID parameters (runId, stepId, hookId) without resource if (!resource) { if (runId) { @@ -63,6 +79,7 @@ export function LayoutClient({ children }: LayoutClientProps) { // Just open the run targetUrl = buildUrlWithConfig(`/run/${runId}`, config); } + hasNavigatedRef.current = true; router.push(targetUrl); return; } @@ -109,41 +126,49 @@ export function LayoutClient({ children }: LayoutClientProps) { return; } + hasNavigatedRef.current = true; router.push(targetUrl); - }, [resource, id, runId, stepId, hookId, router, config]); + }, [resource, id, runId, stepId, hookId, router, config, pathname]); + return ( +
+ + {/* Sticky Header */} +
+
+ +

+ +

+ +
+ + +
+
+
+ + {/* Scrollable Content */} +
{children}
+
+ +
+ ); +} + +export function LayoutClient({ children }: LayoutClientProps) { return ( -
- -
-
-
- -

- -

- -
- - -
-
-
- - {children} -
-
- -
+ {children}
); } diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 9f31ae308..fe198b9e1 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; import { connection } from 'next/server'; +import { NuqsAdapter } from 'nuqs/adapters/next/app'; import { LayoutClient } from './layout-client'; const geistSans = Geist({ @@ -34,7 +35,9 @@ export default async function RootLayout({ - {children} + + {children} + ); diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 088d59ca2..bd454f598 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,20 +1,38 @@ 'use client'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { AlertCircle } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { ErrorBoundary } from '@/components/error-boundary'; import { HooksTable } from '@/components/hooks-table'; import { RunsTable } from '@/components/runs-table'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { WorkflowsList } from '@/components/workflows-list'; import { buildUrlWithConfig, useQueryParamConfig } from '@/lib/config'; +import { + useHookIdState, + useSidebarState, + useTabState, + useWorkflowIdState, +} from '@/lib/url-state'; +import { useWorkflowGraphManifest } from '@/lib/use-workflow-graph'; export default function Home() { const router = useRouter(); - const searchParams = useSearchParams(); const config = useQueryParamConfig(); + const [sidebar] = useSidebarState(); + const [hookId] = useHookIdState(); + const [tab, setTab] = useTabState(); - const sidebar = searchParams.get('sidebar'); - const hookId = searchParams.get('hookId') || searchParams.get('hook'); const selectedHookId = sidebar === 'hook' && hookId ? hookId : undefined; + // Fetch workflow graph manifest + const { + manifest: graphManifest, + loading: graphLoading, + error: graphError, + } = useWorkflowGraphManifest(config); + const handleRunClick = (runId: string, streamId?: string) => { if (!streamId) { router.push(buildUrlWithConfig(`/run/${runId}`, config)); @@ -38,25 +56,58 @@ export default function Home() { } }; + const workflows = graphManifest ? Object.values(graphManifest.workflows) : []; + return ( -
- - - - - - - +
+ + + Runs + Hooks + Workflows + + + + + + + + + + + + + +
+ {graphError && ( + + + Error Loading Workflows + {graphError.message} + + )} + {}} + loading={graphLoading} + /> +
+
+
+
); } diff --git a/packages/web/src/components/display-utils/table-skeleton.tsx b/packages/web/src/components/display-utils/table-skeleton.tsx index 183c41562..fd6268f00 100644 --- a/packages/web/src/components/display-utils/table-skeleton.tsx +++ b/packages/web/src/components/display-utils/table-skeleton.tsx @@ -1,36 +1,113 @@ 'use client'; +import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { DEFAULT_PAGE_SIZE } from '@/lib/utils'; interface TableSkeletonProps { - title?: string; rows?: number; - bodyOnly?: boolean; + variant?: 'runs' | 'hooks' | 'workflows'; } export function TableSkeleton({ rows = DEFAULT_PAGE_SIZE, + variant = 'runs', }: TableSkeletonProps) { - return ( -
-
- -
- {Array.from({ length: rows }, (_, i) => ( -
- - - - - + const renderRow = (i: number) => { + switch (variant) { + case 'runs': + // Workflow, Run ID, Status (with duration), Started, Completed, Actions + return ( +
+ + +
+ + +
+ + + +
+ ); + case 'hooks': + // Hook ID, Run ID, Token, Created, Invocations, Actions + return ( +
+ + + + + + +
+ ); + case 'workflows': + // Workflow, File, Steps + return ( +
+ + + +
+ ); + default: + return null; + } + }; + + const renderHeader = () => { + switch (variant) { + case 'runs': + return ( +
+ + + + + +
- ))} -
- - -
-
-
+ ); + case 'hooks': + return ( +
+ + + + + +
+
+ ); + case 'workflows': + return ( +
+ + + +
+ ); + default: + return null; + } + }; + + return ( + + + {renderHeader()} + {Array.from({ length: rows }, (_, i) => renderRow(i))} + + ); } diff --git a/packages/web/src/components/settings-dropdown.tsx b/packages/web/src/components/settings-dropdown.tsx new file mode 100644 index 000000000..e587c2d2a --- /dev/null +++ b/packages/web/src/components/settings-dropdown.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { ExternalLink, Monitor, Moon, Settings, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { SegmentedControl } from '@/components/ui/segmented-control'; +import { SettingsSidebar } from './settings-sidebar'; + +// Controlled version that doesn't show the trigger button +function SettingsSidebarDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ; +} + +export function SettingsDropdown() { + const [settingsOpen, setSettingsOpen] = useState(false); + const { theme, setTheme } = useTheme(); + const currentTheme = theme || 'system'; + + return ( + <> + + + + + +
+ Theme + , + }, + { + value: 'light', + icon: , + }, + { + value: 'dark', + icon: , + }, + ]} + /> +
+ + setSettingsOpen(true)}> + Configuration + + + + + Docs + + + +
+
+ + + ); +} diff --git a/packages/web/src/components/settings-sidebar.tsx b/packages/web/src/components/settings-sidebar.tsx index f5affa3ba..a6363fa09 100644 --- a/packages/web/src/components/settings-sidebar.tsx +++ b/packages/web/src/components/settings-sidebar.tsx @@ -21,11 +21,21 @@ import { type WorldConfig, } from '@/lib/config-world'; -export function SettingsSidebar() { +interface SettingsSidebarProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function SettingsSidebar({ + open: controlledOpen, + onOpenChange, +}: SettingsSidebarProps = {}) { const config = useQueryParamConfig(); const updateConfig = useUpdateConfigQueryParams(); - const [isOpen, setIsOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setIsOpen = onOpenChange || setInternalOpen; const [localConfig, setLocalConfig] = useState(config); const [errors, setErrors] = useState([]); const [isValidating, setIsValidating] = useState(false); @@ -76,14 +86,16 @@ export function SettingsSidebar() { return ( <> - + {controlledOpen === undefined && ( + + )} {isOpen && ( <> {/* Backdrop */} @@ -99,13 +111,14 @@ export function SettingsSidebar() {

Configuration

- +
@@ -143,7 +156,7 @@ export function SettingsSidebar() { } /> {getFieldError('port') && ( -

+

{getFieldError('port')}

)} @@ -162,11 +175,6 @@ export function SettingsSidebar() { getFieldError('dataDir') ? 'border-destructive' : '' } /> - {getFieldError('dataDir') && ( -

- {getFieldError('dataDir')} -

- )}

Path to the workflow data directory. Can be relative or absolute. @@ -229,13 +237,16 @@ export function SettingsSidebar() { )} {errors.length > 0 && ( - + Configuration Error

    {errors.map((error, idx) => ( -
  • +
  • {error.field !== 'general' && ( {error.field}: )}{' '} diff --git a/packages/web/src/components/ui/badge.tsx b/packages/web/src/components/ui/badge.tsx new file mode 100644 index 000000000..797ce1561 --- /dev/null +++ b/packages/web/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +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 hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
    + ); +} + +export { Badge, badgeVariants }; diff --git a/packages/web/src/components/ui/dropdown-menu.tsx b/packages/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..99635132b --- /dev/null +++ b/packages/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +'use client'; + +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/packages/web/src/components/ui/segmented-control.tsx b/packages/web/src/components/ui/segmented-control.tsx new file mode 100644 index 000000000..fc8d641ad --- /dev/null +++ b/packages/web/src/components/ui/segmented-control.tsx @@ -0,0 +1,44 @@ +'use client'; + +import type * as React from 'react'; +import { cn } from '@/lib/utils'; + +interface SegmentedControlProps { + value: string; + onValueChange: (value: string) => void; + options: Array<{ value: string; icon: React.ReactNode; label?: string }>; + className?: string; +} + +export function SegmentedControl({ + value, + onValueChange, + options, + className, +}: SegmentedControlProps) { + return ( +
    + {options.map((option) => ( + + ))} +
    + ); +} diff --git a/packages/web/src/components/ui/sheet.tsx b/packages/web/src/components/ui/sheet.tsx new file mode 100644 index 000000000..8f432198e --- /dev/null +++ b/packages/web/src/components/ui/sheet.tsx @@ -0,0 +1,139 @@ +'use client'; + +import * as SheetPrimitive from '@radix-ui/react-dialog'; +import { XIcon } from 'lucide-react'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = 'right', + ...props +}: React.ComponentProps & { + side?: 'top' | 'right' | 'bottom' | 'left'; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
    + ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
    + ); +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/packages/web/src/components/ui/tabs.tsx b/packages/web/src/components/ui/tabs.tsx new file mode 100644 index 000000000..66678a489 --- /dev/null +++ b/packages/web/src/components/ui/tabs.tsx @@ -0,0 +1,66 @@ +'use client'; + +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/packages/web/src/components/workflow-graph-viewer.css b/packages/web/src/components/workflow-graph-viewer.css new file mode 100644 index 000000000..e35190555 --- /dev/null +++ b/packages/web/src/components/workflow-graph-viewer.css @@ -0,0 +1,47 @@ +/* Custom styling for React Flow controls in dark mode */ +.react-flow__controls { + button { + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + color: hsl(var(--foreground)); + } + + button:hover { + background-color: hsl(var(--accent)); + } + + button path { + fill: currentColor; + } + } + + /* Ensure text wraps properly in nodes */ + .react-flow__node { + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; + } + + .react-flow__node-default, + .react-flow__node-input, + .react-flow__node-output { + width: 220px !important; + min-width: 220px !important; + max-width: 220px !important; + border-width: 1px; + } + + /* Pulse animation for currently executing node */ + @keyframes pulse-subtle { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } + } + + .animate-pulse-subtle { + animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + \ No newline at end of file diff --git a/packages/web/src/components/workflow-graph-viewer.tsx b/packages/web/src/components/workflow-graph-viewer.tsx new file mode 100644 index 000000000..ffb3e4402 --- /dev/null +++ b/packages/web/src/components/workflow-graph-viewer.tsx @@ -0,0 +1,467 @@ +'use client'; + +import { + Background, + Controls, + type Edge, + MarkerType, + type Node, + Panel, + ReactFlow, + useEdgesState, + useNodesState, +} from '@xyflow/react'; +import { useEffect, useMemo } from 'react'; +import '@xyflow/react/dist/style.css'; +import { GitBranch, PlayCircle, StopCircle } from 'lucide-react'; +import './workflow-graph-viewer.css'; +import type { WorkflowGraph } from '@/lib/workflow-graph-types'; + +interface WorkflowGraphViewerProps { + workflow: WorkflowGraph; +} + +// Custom node components +const nodeTypes = {}; + +// Get node styling based on node kind - uses theme-aware colors +function getNodeStyle(nodeKind: string) { + const baseStyle = { + color: 'hsl(var(--card-foreground))', + }; + + if (nodeKind === 'workflow_start') { + return { + ...baseStyle, + backgroundColor: 'rgba(34, 197, 94, 0.15)', // green with 15% opacity + borderColor: '#22c55e', // green-500 - works in both light and dark + }; + } + if (nodeKind === 'workflow_end') { + return { + ...baseStyle, + backgroundColor: 'rgba(148, 163, 184, 0.15)', // slate with 15% opacity + borderColor: '#94a3b8', // slate-400 - works in both light and dark + }; + } + return { + ...baseStyle, + backgroundColor: 'rgba(96, 165, 250, 0.15)', // blue with 15% opacity + borderColor: '#60a5fa', // blue-400 - works in both light and dark + }; +} + +// Get node icon based on node kind +function getNodeIcon(nodeKind: string) { + if (nodeKind === 'workflow_start') { + return ( + + ); + } + if (nodeKind === 'workflow_end') { + return ( + + ); + } + return ; +} + +// Helper to calculate enhanced layout with control flow +function calculateEnhancedLayout(workflow: WorkflowGraph): { + nodes: typeof workflow.nodes; + additionalEdges: Array<{ + id: string; + source: string; + target: string; + type: string; + label?: string; + }>; +} { + const nodes = [...workflow.nodes]; + const additionalEdges: Array<{ + id: string; + source: string; + target: string; + type: string; + label?: string; + }> = []; + + // Group nodes by their control flow context + const parallelGroups = new Map(); + const loopNodes = new Map(); + const conditionalGroups = new Map< + string, + { thenBranch: typeof workflow.nodes; elseBranch: typeof workflow.nodes } + >(); + + for (const node of nodes) { + if (node.metadata?.parallelGroupId) { + const group = parallelGroups.get(node.metadata.parallelGroupId) || []; + group.push(node); + parallelGroups.set(node.metadata.parallelGroupId, group); + } + if (node.metadata?.loopId) { + const group = loopNodes.get(node.metadata.loopId) || []; + group.push(node); + loopNodes.set(node.metadata.loopId, group); + } + if (node.metadata?.conditionalId) { + const groups = conditionalGroups.get(node.metadata.conditionalId) || { + thenBranch: [], + elseBranch: [], + }; + if (node.metadata.conditionalBranch === 'Then') { + groups.thenBranch.push(node); + } else { + groups.elseBranch.push(node); + } + conditionalGroups.set(node.metadata.conditionalId, groups); + } + } + + // Layout parallel nodes side-by-side + for (const [, groupNodes] of parallelGroups) { + if (groupNodes.length <= 1) continue; + + const baseY = groupNodes[0].position.y; + const spacing = 300; // horizontal spacing + const totalWidth = (groupNodes.length - 1) * spacing; + const startX = 250 - totalWidth / 2; + + groupNodes.forEach((node, idx) => { + node.position = { + x: startX + idx * spacing, + y: baseY, + }; + }); + } + + // Layout conditional branches side-by-side + for (const [, branches] of conditionalGroups) { + const allNodes = [...branches.thenBranch, ...branches.elseBranch]; + if (allNodes.length <= 1) continue; + + const thenNodes = branches.thenBranch; + const elseNodes = branches.elseBranch; + + if (thenNodes.length > 0 && elseNodes.length > 0) { + // Position then branch on the left, else on the right + const baseY = Math.min( + thenNodes[0]?.position.y || 0, + elseNodes[0]?.position.y || 0 + ); + + thenNodes.forEach((node, idx) => { + node.position = { + x: 100, + y: baseY + idx * 120, + }; + }); + + elseNodes.forEach((node, idx) => { + node.position = { + x: 400, + y: baseY + idx * 120, + }; + }); + } + } + + // Add loop-back edges + for (const [loopId, loopNodeList] of loopNodes) { + if (loopNodeList.length > 0) { + // Find first and last nodes in the loop + loopNodeList.sort((a, b) => { + const aNum = parseInt(a.id.replace('node_', '')) || 0; + const bNum = parseInt(b.id.replace('node_', '')) || 0; + return aNum - bNum; + }); + + const firstNode = loopNodeList[0]; + const lastNode = loopNodeList[loopNodeList.length - 1]; + + // Add a back edge from last to first + // Note: no label needed - the nodes already show loop badges + additionalEdges.push({ + id: `loop_back_${loopId}`, + source: lastNode.id, + target: firstNode.id, + type: 'loop', + }); + } + } + + return { nodes, additionalEdges }; +} + +// Convert our graph nodes to React Flow format +function convertToReactFlowNodes(workflow: WorkflowGraph): Node[] { + const { nodes } = calculateEnhancedLayout(workflow); + + return nodes.map((node) => { + const styles = getNodeStyle(node.data.nodeKind); + + // Determine node type based on its role in the workflow + let nodeType: 'input' | 'output' | 'default' = 'default'; + if (node.type === 'workflowStart') { + nodeType = 'input'; // Only source handle (outputs edges) + } else if (node.type === 'workflowEnd') { + nodeType = 'output'; // Only target handle (receives edges) + } + + // Add CFG metadata badges + const metadata = node.metadata; + const badges: React.ReactNode[] = []; + + if (metadata?.loopId) { + badges.push( + + {metadata.loopIsAwait ? '⟳ await loop' : '⟳ loop'} + + ); + } + + if (metadata?.conditionalId) { + badges.push( + + {metadata.conditionalBranch === 'Then' ? '✓ if' : '✗ else'} + + ); + } + + if (metadata?.parallelGroupId) { + const parallelLabel = + metadata.parallelMethod === 'all' + ? 'Promise.all' + : metadata.parallelMethod === 'race' + ? 'Promise.race' + : metadata.parallelMethod === 'allSettled' + ? 'Promise.allSettled' + : `parallel: ${metadata.parallelMethod}`; + badges.push( + + {parallelLabel} + + ); + } + + return { + id: node.id, + type: nodeType, + position: node.position, + data: { + ...node.data, + label: ( +
    +
    +
    + {getNodeIcon(node.data.nodeKind)} +
    + + {node.data.label} + +
    + {badges.length > 0 && ( +
    {badges}
    + )} +
    + ), + }, + style: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + width: 220, + ...styles, + }, + }; + }); +} + +// Convert our graph edges to React Flow format +function convertToReactFlowEdges(workflow: WorkflowGraph): Edge[] { + const { additionalEdges } = calculateEnhancedLayout(workflow); + + // Combine original edges with additional loop-back edges + const allEdges = [ + ...workflow.edges.map((e) => ({ ...e, isOriginal: true })), + ...additionalEdges.map((e) => ({ ...e, isOriginal: false })), + ]; + + return allEdges.map((edge) => { + // Customize edge style based on type + let strokeColor = '#94a3b8'; // default gray + let strokeWidth = 1; + let strokeDasharray: string | undefined; + const animated = false; + let label: string | undefined = edge.label; + let edgeType: 'smoothstep' | 'straight' | 'step' = 'smoothstep'; + + switch (edge.type) { + case 'parallel': + strokeColor = '#3b82f6'; // blue + strokeWidth = 1.5; + strokeDasharray = '4,4'; + // No label needed - nodes have Promise.all/race/allSettled badges + label = undefined; + break; + case 'loop': + strokeColor = '#a855f7'; // purple + strokeWidth = 1.5; + strokeDasharray = '5,5'; + // Loop-back edges get a different path type for better visualization + if (edge.source === edge.target || !edge.isOriginal) { + edgeType = 'step'; + } + // No label needed - nodes have loop badges + label = undefined; + break; + case 'conditional': + strokeColor = '#f59e0b'; // amber + strokeWidth = 1; + strokeDasharray = '8,4'; + // No label needed - nodes have if/else badges + label = undefined; + break; + default: + // Keep default styling + break; + } + + return { + id: edge.id, + source: edge.source, + target: edge.target, + type: edgeType, + animated, + label, + labelStyle: { fontSize: 12, fontWeight: 600 }, + labelBgPadding: [4, 2] as [number, number], + labelBgBorderRadius: 4, + labelBgStyle: { fill: strokeColor, fillOpacity: 0.15 }, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 12, + height: 12, + color: strokeColor, + }, + style: { + strokeWidth, + stroke: strokeColor, + strokeDasharray, + }, + }; + }); +} + +export function WorkflowGraphViewer({ workflow }: WorkflowGraphViewerProps) { + const initialNodes = useMemo( + () => convertToReactFlowNodes(workflow), + [workflow] + ); + const initialEdges = useMemo( + () => convertToReactFlowEdges(workflow), + [workflow] + ); + + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + // Update nodes and edges when workflow changes + useEffect(() => { + setNodes(convertToReactFlowNodes(workflow)); + setEdges(convertToReactFlowEdges(workflow)); + }, [workflow, setNodes, setEdges]); + + return ( +
    + + + + +
    + {/* Node types */} +
    +
    + Node Types +
    +
    +
    + Workflow Start +
    +
    +
    + Step +
    +
    +
    + Workflow End +
    +
    + + {/* Control flow */} +
    +
    + Control Flow +
    +
    +
    + ∥ Parallel +
    +
    +
    + ⟳ Loop +
    +
    +
    + Conditional +
    +
    +
    + Sequential +
    +
    +
    + + +
    + ); +} diff --git a/packages/web/src/components/workflows-list.tsx b/packages/web/src/components/workflows-list.tsx new file mode 100644 index 000000000..d1f3fd44f --- /dev/null +++ b/packages/web/src/components/workflows-list.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { GitBranch, Workflow } from 'lucide-react'; +import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { WorkflowGraphViewer } from '@/components/workflow-graph-viewer'; +import type { WorkflowGraph } from '@/lib/workflow-graph-types'; +import { TableSkeleton } from './display-utils/table-skeleton'; + +interface WorkflowsListProps { + workflows: WorkflowGraph[]; + onWorkflowSelect: (workflowName: string) => void; + loading?: boolean; +} + +export function WorkflowsList({ + workflows, + onWorkflowSelect, + loading, +}: WorkflowsListProps) { + const [sheetOpen, setSheetOpen] = useState(false); + const [selectedWorkflow, setSelectedWorkflow] = + useState(null); + + const handleViewWorkflow = (workflow: WorkflowGraph) => { + setSelectedWorkflow(workflow); + setSheetOpen(true); + onWorkflowSelect(workflow.workflowName); + }; + + if (loading) { + return ; + } + + if (workflows.length === 0) { + return ( + + + +

    No Workflows Found

    +

    + No workflow definitions were found in the graph manifest. +

    +
    +
    + ); + } + + return ( + <> + + + + + + + Workflow + + + File + + + Steps + + + + + {workflows.map((workflow) => { + const stepCount = workflow.nodes.filter( + (node) => node.data.nodeKind === 'step' + ).length; + + return ( + handleViewWorkflow(workflow)} + > + + + {workflow.workflowName} + + + + + {workflow.filePath} + + + + + + {stepCount} {stepCount === 1 ? 'step' : 'steps'} + + + + ); + })} + +
    +
    +
    + + + + + + + {selectedWorkflow?.workflowName} + + {selectedWorkflow && ( + +
    + {selectedWorkflow.filePath} +
    + + + { + selectedWorkflow.nodes.filter( + (node) => node.data.nodeKind === 'step' + ).length + }{' '} + {selectedWorkflow.nodes.filter( + (node) => node.data.nodeKind === 'step' + ).length === 1 + ? 'step' + : 'steps'} + +
    +
    +
    + )} +
    +
    + {selectedWorkflow && ( + + )} +
    +
    +
    + + ); +} diff --git a/packages/web/src/lib/config.ts b/packages/web/src/lib/config.ts index 6d01d59a5..e866b880a 100644 --- a/packages/web/src/lib/config.ts +++ b/packages/web/src/lib/config.ts @@ -1,8 +1,7 @@ 'use client'; import type { EnvMap } from '@workflow/web-shared/server'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { useMemo } from 'react'; +import { createSerializer, parseAsString, useQueryStates } from 'nuqs'; import type { WorldConfig } from '@/lib/config-world'; // Default configuration @@ -13,110 +12,77 @@ const DEFAULT_CONFIG: WorldConfig = { env: 'production', }; -// Config query param keys -const CONFIG_PARAM_KEYS = [ - 'backend', - 'env', - 'authToken', - 'project', - 'team', - 'port', - 'dataDir', -] as const; +// nuqs parsers for config params +const configParsers = { + backend: parseAsString.withDefault(DEFAULT_CONFIG.backend || 'embedded'), + env: parseAsString.withDefault(DEFAULT_CONFIG.env || 'production'), + authToken: parseAsString, + project: parseAsString, + team: parseAsString, + port: parseAsString.withDefault(DEFAULT_CONFIG.port || '3000'), + dataDir: parseAsString.withDefault( + DEFAULT_CONFIG.dataDir || './.next/workflow-data' + ), +}; + +// Create a serializer for config params +const serializeConfig = createSerializer(configParsers); /** * Hook that reads query params and returns the current config + * Uses nuqs for type-safe URL state management * Config is derived from default config + query params */ export function useQueryParamConfig(): WorldConfig { - const searchParams = useSearchParams(); - - const config = useMemo(() => { - const configFromParams: WorldConfig = { ...DEFAULT_CONFIG }; + const [config] = useQueryStates(configParsers, { + history: 'push', + shallow: true, + }); - // Override with query parameters - for (const key of CONFIG_PARAM_KEYS) { - const value = searchParams.get(key); - if (value) { - configFromParams[key] = value; - } - } - - return configFromParams; - }, [searchParams]); - - return config; + return config as WorldConfig; } /** * Hook that returns a function to update config query params + * Uses nuqs for type-safe URL state management * Preserves all other query params while updating config params */ export function useUpdateConfigQueryParams() { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const updateConfig = useMemo( - () => (newConfig: WorldConfig) => { - const params = new URLSearchParams(searchParams.toString()); - - // Update config params - for (const key of CONFIG_PARAM_KEYS) { - const value = newConfig[key]; - if (value !== undefined && value !== null && value !== '') { - // Only set if it differs from default or if it was already set - if (value !== DEFAULT_CONFIG[key] || searchParams.has(key)) { - params.set(key, value); - } - } else { - // Remove param if it's undefined/null/empty - params.delete(key); - } + const [, setConfig] = useQueryStates(configParsers, { + history: 'push', + shallow: true, + }); + + return (newConfig: WorldConfig) => { + // Filter out null/undefined values and only set non-default values + const filtered: Record = {}; + + for (const [key, value] of Object.entries(newConfig)) { + if (value === undefined || value === null || value === '') { + filtered[key] = null; // nuqs uses null to clear params + } else if (value !== DEFAULT_CONFIG[key as keyof WorldConfig]) { + filtered[key] = value; + } else { + filtered[key] = null; } - - // Navigate with updated params - const queryString = params.toString(); - const newUrl = queryString ? `${pathname}?${queryString}` : pathname; - router.push(newUrl); - }, - [router, pathname, searchParams] - ); - - return updateConfig; -} - -/** - * Helper to get config params from a URLSearchParams object - * Useful for building URLs with config params - */ -export function getConfigParams(config: WorldConfig): URLSearchParams { - const params = new URLSearchParams(); - - for (const key of CONFIG_PARAM_KEYS) { - const value = config[key]; - if ( - value !== undefined && - value !== null && - value !== '' && - value !== DEFAULT_CONFIG[key] - ) { - params.set(key, value); } - } - return params; + setConfig(filtered); + }; } /** * Helper to build a URL with config params while preserving other params + * Uses nuqs serializer for type-safe URL construction */ export function buildUrlWithConfig( path: string, config: WorldConfig, additionalParams?: Record ): string { - const params = getConfigParams(config); + // Serialize config params using nuqs + const queryString = serializeConfig(config); + const params = new URLSearchParams(queryString); // Add additional params if (additionalParams) { @@ -127,8 +93,8 @@ export function buildUrlWithConfig( } } - const queryString = params.toString(); - return queryString ? `${path}?${queryString}` : path; + const search = params.toString(); + return search ? `${path}?${search}` : path; } export const worldConfigToEnvMap = (config: WorldConfig): EnvMap => { diff --git a/packages/web/src/lib/url-state.ts b/packages/web/src/lib/url-state.ts new file mode 100644 index 000000000..b5d0a045c --- /dev/null +++ b/packages/web/src/lib/url-state.ts @@ -0,0 +1,66 @@ +'use client'; + +import { parseAsString, useQueryState, useQueryStates } from 'nuqs'; + +/** + * Hook to manage sidebar state in URL + */ +export function useSidebarState() { + return useQueryState('sidebar', parseAsString); +} + +/** + * Hook to manage theme state in URL + */ +export function useThemeState() { + return useQueryState('theme', parseAsString.withDefault('system')); +} + +/** + * Hook to manage tab selection state in URL + */ +export function useTabState() { + return useQueryState('tab', parseAsString.withDefault('runs')); +} + +/** + * Hook to manage multiple navigation params at once + */ +export function useNavigationParams() { + return useQueryStates({ + sidebar: parseAsString, + hookId: parseAsString, + stepId: parseAsString, + eventId: parseAsString, + streamId: parseAsString, + runId: parseAsString, + id: parseAsString, + resource: parseAsString, + }); +} + +/** + * Hook to manage individual navigation params + */ +export function useHookIdState() { + return useQueryState('hookId', parseAsString); +} + +export function useStepIdState() { + return useQueryState('stepId', parseAsString); +} + +export function useEventIdState() { + return useQueryState('eventId', parseAsString); +} + +export function useStreamIdState() { + return useQueryState('streamId', parseAsString); +} + +/** + * Hook to manage selected workflow ID for graph visualization + */ +export function useWorkflowIdState() { + return useQueryState('workflowId', parseAsString); +} diff --git a/packages/web/src/lib/use-workflow-graph.ts b/packages/web/src/lib/use-workflow-graph.ts new file mode 100644 index 000000000..0247afed5 --- /dev/null +++ b/packages/web/src/lib/use-workflow-graph.ts @@ -0,0 +1,61 @@ +'use client'; + +import { + fetchWorkflowsManifest, + unwrapServerActionResult, + WorkflowAPIError, +} from '@workflow/web-shared'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { worldConfigToEnvMap } from '@/lib/config'; +import type { WorldConfig } from '@/lib/config-world'; +import type { WorkflowGraphManifest } from '@/lib/workflow-graph-types'; + +/** + * Hook to fetch the workflow graph manifest from the workflow data directory + * The manifest contains static structure information about all workflows + */ +export function useWorkflowGraphManifest(config: WorldConfig) { + const [manifest, setManifest] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const isFetchingRef = useRef(false); + + const fetchManifest = useCallback(async () => { + if (isFetchingRef.current) { + return; + } + isFetchingRef.current = true; + setLoading(true); + setError(null); + + try { + const env = worldConfigToEnvMap(config); + const serverResult = await fetchWorkflowsManifest(env); + const result = unwrapServerActionResult(serverResult); + setManifest(result); + } catch (err) { + const error = + err instanceof WorkflowAPIError + ? err + : err instanceof Error + ? new WorkflowAPIError(err.message, { cause: err, layer: 'client' }) + : new WorkflowAPIError(String(err), { layer: 'client' }); + setError(error); + setManifest(null); + } finally { + setLoading(false); + isFetchingRef.current = false; + } + }, [config]); + + useEffect(() => { + fetchManifest(); + }, [fetchManifest]); + + return { + manifest, + loading, + error, + refetch: fetchManifest, + }; +} diff --git a/packages/web/src/lib/workflow-graph-types.ts b/packages/web/src/lib/workflow-graph-types.ts new file mode 100644 index 000000000..75a0d7963 --- /dev/null +++ b/packages/web/src/lib/workflow-graph-types.ts @@ -0,0 +1,99 @@ +/** + * Types for workflow graph visualization + * These match the structure generated by the SWC plugin in graph mode + */ + +export interface Position { + x: number; + y: number; +} + +export interface NodeMetadata { + loopId?: string; + loopIsAwait?: boolean; + conditionalId?: string; + conditionalBranch?: 'Then' | 'Else'; + parallelGroupId?: string; + parallelMethod?: 'all' | 'race' | 'allSettled'; +} + +export interface NodeData { + label: string; + nodeKind: 'workflow_start' | 'workflow_end' | 'step'; + stepId?: string; + line: number; +} + +export interface GraphNode { + id: string; + type: string; + position: Position; + data: NodeData; + metadata?: NodeMetadata; +} + +export interface GraphEdge { + id: string; + source: string; + target: string; + type: 'default' | 'loop' | 'conditional' | 'parallel'; + label?: string; +} + +export interface WorkflowGraph { + workflowId: string; + workflowName: string; + filePath: string; + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +export interface WorkflowGraphManifest { + version: string; + workflows: Record; +} + +/** + * Types for overlaying execution data on workflow graphs + */ + +export interface StepExecution { + nodeId: string; + stepId?: string; + attemptNumber: number; + status: + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'retrying' + | 'cancelled'; + startedAt?: string; + completedAt?: string; + duration?: number; + input?: unknown; + output?: unknown; + error?: { message: string; stack: string }; +} + +export interface EdgeTraversal { + edgeId: string; + traversalCount: number; + lastTraversedAt?: string; + timings: number[]; // time taken to traverse (ms) +} + +export interface WorkflowRunExecution { + runId: string; + status: + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'paused' + | 'cancelled'; + nodeExecutions: Map; // nodeId -> array of executions (for retries) + edgeTraversals: Map; // edgeId -> traversal info + currentNode?: string; // for running workflows + executionPath: string[]; // ordered list of nodeIds +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 797a82b2f..520647682 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -775,9 +775,15 @@ importers: packages/web: dependencies: + '@xyflow/react': + specifier: 12.9.3 + version: 12.9.3(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: specifier: 15.5.4 version: 15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nuqs: + specifier: ^2.2.5 + version: 2.8.1(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) devDependencies: '@biomejs/biome': specifier: 'catalog:' @@ -785,6 +791,12 @@ importers: '@radix-ui/react-alert-dialog': specifier: 1.1.5 version: 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dialog': + specifier: 1.1.15 + version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dropdown-menu': + specifier: 2.1.6 + version: 2.1.6(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-label': specifier: 2.1.7 version: 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -797,6 +809,9 @@ importers: '@radix-ui/react-switch': specifier: 1.1.2 version: 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-tooltip': specifier: 1.2.8 version: 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -4415,6 +4430,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-arrow@1.1.2': + resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -4480,6 +4508,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.2': + resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -4568,6 +4609,15 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-direction@1.1.0': + resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -4603,6 +4653,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.5': + resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dropdown-menu@2.1.16': resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} peerDependencies: @@ -4616,6 +4679,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.6': + resolution: {integrity: sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.1': resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} peerDependencies: @@ -4647,6 +4723,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.2': + resolution: {integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-scope@1.1.7': resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} peerDependencies: @@ -4730,6 +4819,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.6': + resolution: {integrity: sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-menubar@1.1.16': resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} peerDependencies: @@ -4795,6 +4897,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popper@1.2.2': + resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -4821,6 +4936,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.4': + resolution: {integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.9': resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} peerDependencies: @@ -4873,6 +5001,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.0.2': + resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -4925,6 +5066,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.2': + resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-scroll-area@1.2.10': resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} peerDependencies: @@ -4986,6 +5140,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.1.2': + resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -5216,6 +5379,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.0': + resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -5256,6 +5428,9 @@ packages: '@types/react-dom': optional: true + '@radix-ui/rect@1.1.0': + resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} @@ -6499,6 +6674,15 @@ packages: resolution: {integrity: sha512-ueFCcIPaMgtuYDS9u0qlUoEvj6GiSsKrwnOLPp9SshqjtcRaR1IEHRjoReq3sXNydsF5i0ZnmuYgXq9dV53t0g==} engines: {node: '>=18.0.0'} + '@xyflow/react@12.9.3': + resolution: {integrity: sha512-PSWoJ8vHiEqSIkLIkge+0eiHWiw4C6dyFDA03VKWJkqbU4A13VlDIVwKqf/Znuysn2GQw/zA61zpHE4rGgax7Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.73': + resolution: {integrity: sha512-C2ymH2V4mYDkdVSiRx0D7R0s3dvfXiupVBcko6tXP5K4tVdSBMo22/e3V9yRNdn+2HQFv44RFKzwOyCcUUDAVQ==} + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -6949,6 +7133,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clean-stack@3.0.1: resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} engines: {node: '>=10'} @@ -9903,6 +10090,27 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuqs@2.8.1: + resolution: {integrity: sha512-kIw8UW5KXXfVla6B9h0EKzSH/YDpee6lojniQoMyul8wq9brwV0kElE2Jzg4NSCLBo7n2E3wc1J3o7IyPYVlqQ==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + nuxt@4.0.0: resolution: {integrity: sha512-HMhAEW59Ws3ty8SUZ0icOPoqP5xMaThZA5h7A7pz1Gl/feW1FwtJZnqjZ/aO/Xv2TlTIbkil2OOolDpJOAQjUg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -12500,6 +12708,21 @@ packages: zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -15491,6 +15714,15 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -15563,6 +15795,18 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-collection@1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.1.2(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.0) @@ -15637,6 +15881,28 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.1.13)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -15681,6 +15947,12 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-direction@1.1.0(@types/react@19.1.13)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-direction@1.1.1(@types/react@19.1.13)(react@19.1.0)': dependencies: react: 19.1.0 @@ -15732,6 +16004,19 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -15747,6 +16032,21 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-dropdown-menu@2.1.6(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-menu': 2.1.6(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-focus-guards@1.1.1(@types/react@19.1.13)(react@19.1.0)': dependencies: react: 19.1.0 @@ -15776,6 +16076,17 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-focus-scope@1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.0) @@ -15894,6 +16205,32 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-menu@2.1.6(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-direction': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.1.2(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.13)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.1.13)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -15993,6 +16330,24 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-popper@1.2.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-use-rect': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/rect': 1.1.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -16039,6 +16394,16 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-portal@1.1.4(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -16098,6 +16463,15 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-primitive@2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.1.2(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.1.13)(react@19.1.0) @@ -16144,6 +16518,23 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -16161,6 +16552,23 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-roving-focus@1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-direction': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/number': 1.1.1 @@ -16271,6 +16679,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-slot@1.1.2(@types/react@19.1.13)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-slot@1.2.3(@types/react@19.1.13)(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.0) @@ -16322,6 +16737,22 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.13)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -16558,6 +16989,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@radix-ui/react-use-rect@1.1.0(@types/react@19.1.13)(react@19.1.0)': + dependencies: + '@radix-ui/rect': 1.1.0 + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.13 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.1.13)(react@19.1.0)': dependencies: '@radix-ui/rect': 1.1.1 @@ -16611,6 +17049,8 @@ snapshots: '@types/react': 19.1.13 '@types/react-dom': 19.1.9(@types/react@19.1.13) + '@radix-ui/rect@1.1.0': {} + '@radix-ui/rect@1.1.1': {} '@rolldown/pluginutils@1.0.0-beta.29': {} @@ -18126,6 +18566,29 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 + '@xyflow/react@12.9.3(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@xyflow/system': 0.0.73 + classcat: 5.0.5 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + zustand: 4.5.7(@types/react@19.1.13)(react@19.1.0) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.73': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + abbrev@3.0.1: {} abort-controller@3.0.0: @@ -18601,6 +19064,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + clean-stack@3.0.1: dependencies: escape-string-regexp: 4.0.0 @@ -22232,6 +22697,13 @@ snapshots: dependencies: boolbase: 1.0.0 + nuqs@2.8.1(next@15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 19.1.0 + optionalDependencies: + next: 15.5.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nuxt@4.0.0(@biomejs/biome@2.3.3)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@22.19.0)(@vercel/functions@3.1.4(@aws-sdk/credential-provider-web-identity@3.844.0))(@vue/compiler-sfc@3.5.22)(better-sqlite3@11.10.0)(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.7))(eslint@9.38.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1): dependencies: '@nuxt/cli': 3.29.3(magicast@0.3.5) @@ -25527,4 +25999,11 @@ snapshots: zod@4.1.12: {} + zustand@4.5.7(@types/react@19.1.13)(react@19.1.0): + dependencies: + use-sync-external-store: 1.5.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.13 + react: 19.1.0 + zwitch@2.0.4: {} From 6d7cb0e7c492bdc2d2f888cf93e051517c734a69 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 28 Nov 2025 11:18:12 -0600 Subject: [PATCH 2/4] Adding changeset --- .changeset/old-hats-carry.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/old-hats-carry.md diff --git a/.changeset/old-hats-carry.md b/.changeset/old-hats-carry.md new file mode 100644 index 000000000..9b570c7e6 --- /dev/null +++ b/.changeset/old-hats-carry.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +Add workflow graph visualization to observability UI and o11y migration to nuqs for url state management From 89c7721d86f078c54e987886792741276c6a1fcb Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 28 Nov 2025 11:56:04 -0600 Subject: [PATCH 3/4] Workflow CFG Viewer and nuqs migration --- packages/web/src/app/run/[runId]/page.tsx | 22 +- .../components/display-utils/back-link.tsx | 21 - .../display-utils/copyable-text.tsx | 39 +- packages/web/src/components/hooks-table.tsx | 199 +++- .../web/src/components/run-detail-view.tsx | 205 +++- packages/web/src/components/ui/breadcrumb.tsx | 111 ++ .../workflow-graph-execution-viewer.tsx | 974 ++++++++++++++++++ .../web/src/lib/graph-execution-mapper.ts | 455 ++++++++ 8 files changed, 1915 insertions(+), 111 deletions(-) delete mode 100644 packages/web/src/components/display-utils/back-link.tsx create mode 100644 packages/web/src/components/ui/breadcrumb.tsx create mode 100644 packages/web/src/components/workflow-graph-execution-viewer.tsx create mode 100644 packages/web/src/lib/graph-execution-mapper.ts diff --git a/packages/web/src/app/run/[runId]/page.tsx b/packages/web/src/app/run/[runId]/page.tsx index 803589fb8..debf597d3 100644 --- a/packages/web/src/app/run/[runId]/page.tsx +++ b/packages/web/src/app/run/[runId]/page.tsx @@ -1,26 +1,24 @@ 'use client'; -import { useParams, useSearchParams } from 'next/navigation'; +import { useParams } from 'next/navigation'; import { ErrorBoundary } from '@/components/error-boundary'; import { RunDetailView } from '@/components/run-detail-view'; import { useQueryParamConfig } from '@/lib/config'; +import { + useEventIdState, + useHookIdState, + useStepIdState, +} from '@/lib/url-state'; export default function RunDetailPage() { const params = useParams(); - const searchParams = useSearchParams(); const config = useQueryParamConfig(); + const [stepId] = useStepIdState(); + const [eventId] = useEventIdState(); + const [hookId] = useHookIdState(); const runId = params.runId as string; - const stepId = searchParams.get('stepId') || searchParams.get('step'); - const eventId = searchParams.get('eventId') || searchParams.get('event'); - const hookId = searchParams.get('hookId') || searchParams.get('hook'); - const selectedId = stepId - ? stepId - : eventId - ? eventId - : hookId - ? hookId - : undefined; + const selectedId = stepId || eventId || hookId || undefined; return ( - - {label} - - ); -} diff --git a/packages/web/src/components/display-utils/copyable-text.tsx b/packages/web/src/components/display-utils/copyable-text.tsx index ea03022da..609d23c66 100644 --- a/packages/web/src/components/display-utils/copyable-text.tsx +++ b/packages/web/src/components/display-utils/copyable-text.tsx @@ -13,12 +13,20 @@ interface CopyableTextProps { text: string; children: React.ReactNode; className?: string; + /** If true, the copy button overlaps the text on the right */ + overlay?: boolean; } -export function CopyableText({ text, children, className }: CopyableTextProps) { +export function CopyableText({ + text, + children, + className, + overlay, +}: CopyableTextProps) { const [copied, setCopied] = useState(false); - const handleCopy = async () => { + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); try { await navigator.clipboard.writeText(text); setCopied(true); @@ -28,6 +36,33 @@ export function CopyableText({ text, children, className }: CopyableTextProps) { } }; + if (overlay) { + return ( + + {children} + + + + + +

    {copied ? 'Copied!' : 'Copy to clipboard'}

    +
    +
    +
    + ); + } + return (
    {children} diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index dc8b228c7..6c9761fb4 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -1,18 +1,34 @@ 'use client'; -import { getErrorMessage, useWorkflowHooks } from '@workflow/web-shared'; -import { fetchEventsByCorrelationId } from '@workflow/web-shared/server'; +import { + cancelRun, + fetchEventsByCorrelationId, + getErrorMessage, + recreateRun, + useWorkflowHooks, +} from '@workflow/web-shared'; import type { Event, Hook } from '@workflow/world'; import { AlertCircle, ChevronLeft, ChevronRight, + MoreHorizontal, RefreshCw, + RotateCw, + XCircle, } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; import { DocsLink } from '@/components/ui/docs-link'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Table, TableBody, @@ -28,6 +44,7 @@ import { } from '@/components/ui/tooltip'; import { worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; +import { CopyableText } from './display-utils/copyable-text'; import { RelativeTime } from './display-utils/relative-time'; import { TableSkeleton } from './display-utils/table-skeleton'; @@ -209,11 +226,9 @@ export function HooksTable({ return (
    -
    -

    - Hooks -

    -
    +
    +
    +

    Last refreshed

    {lastRefreshTime && ( )} +
    +
    +
    +
    + + {/* Content - scrollable */} +
    + {/* Basic attributes in bordered container */} +
    +
    + + type + + {node.data.nodeKind} +
    + {latestExecution && ( + <> +
    + + status + + +
    + {latestExecution.duration !== undefined && ( +
    + + duration + + + {formatDurationMs(latestExecution.duration)} + +
    + )} + {hasMultipleAttempts && ( +
    + + attempts + + + {executions.length} + +
    + )} + {latestExecution.startedAt && ( +
    + + startedAt + + + {new Date(latestExecution.startedAt).toLocaleString()} + +
    + )} + {latestExecution.completedAt && ( +
    + + completedAt + + + {new Date(latestExecution.completedAt).toLocaleString()} + +
    + )} + + )} +
    + + {/* Loading indicator for resolved data */} + {stepLoading && stepId && ( +
    + + Loading step data... +
    + )} + + {/* Input section */} + {resolvedInput !== undefined && ( +
    + + Input + + ({Array.isArray(resolvedInput) ? resolvedInput.length : 1} args) + + +
    +
    +
    +
    +                {JSON.stringify(resolvedInput, null, 2)}
    +              
    +
    +
    + )} + + {/* Output section */} + {resolvedOutput !== undefined && ( +
    + + Output + +
    +
    +
    +
    +                {JSON.stringify(resolvedOutput, null, 2)}
    +              
    +
    +
    + )} + + {/* Error section */} + {resolvedError && ( +
    + + + Error + + +
    +
    +
    +
    +                
    +                  {typeof resolvedError === 'object'
    +                    ? JSON.stringify(resolvedError, null, 2)
    +                    : String(resolvedError)}
    +                
    +              
    +
    +
    + )} + + {/* Attempt history for retries */} + {hasMultipleAttempts && ( +
    + + Attempt History + + ({executions.length} attempts) + + +
    +
    +
    +
    + {executions.map((exec) => ( +
    + + Attempt {exec.attemptNumber} + +
    + + {exec.duration !== undefined && ( + + {formatDurationMs(exec.duration)} + + )} +
    +
    + ))} +
    +
    +
    + )} +
    +
    + ); +} + +export function WorkflowGraphExecutionViewer({ + workflow, + execution, + env, + onNodeClick, +}: WorkflowGraphExecutionViewerProps) { + const [selectedNode, setSelectedNode] = useState( + null + ); + const panelWidth = 320; + + const initialNodes = useMemo( + () => convertToReactFlowNodes(workflow, execution), + [workflow, execution] + ); + const initialEdges = useMemo( + () => convertToReactFlowEdges(workflow, execution), + [workflow, execution] + ); + + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + // Update nodes and edges when workflow or execution changes + // Preserve user-dragged positions by merging with current node positions + useEffect(() => { + setNodes((currentNodes) => { + const newNodes = convertToReactFlowNodes(workflow, execution); + // Create a map of current positions (user may have dragged nodes) + const currentPositions = new Map( + currentNodes.map((n) => [n.id, n.position]) + ); + // Merge new node data with existing positions + return newNodes.map((node) => ({ + ...node, + position: currentPositions.get(node.id) ?? node.position, + })); + }); + setEdges(convertToReactFlowEdges(workflow, execution)); + }, [workflow, execution, setNodes, setEdges]); + + const handleNodeClick = useCallback( + (_event: React.MouseEvent, node: Node) => { + const graphNode = workflow.nodes.find((n) => n.id === node.id); + if (graphNode) { + const executions = (node.data.executions as StepExecution[]) || []; + const latestExecution = executions[executions.length - 1]; + setSelectedNode({ + nodeId: node.id, + node: graphNode, + executions, + stepId: latestExecution?.stepId, + runId: execution?.runId, + }); + // Also call the external handler if provided + if (onNodeClick && executions.length > 0) { + onNodeClick(node.id, executions); + } + } + }, + [workflow.nodes, execution?.runId, onNodeClick] + ); + + const handleClosePanel = useCallback(() => { + setSelectedNode(null); + }, []); + + return ( +
    + {/* Graph canvas */} +
    + + + + + {/* Legend with execution states (matching status-badge colors) */} + +
    +
    +
    + Running +
    +
    +
    + Completed +
    +
    +
    + Failed +
    +
    +
    + Canceled +
    +
    +
    + Paused +
    +
    +
    + Pending +
    +
    + + + {/* Execution summary panel */} + {execution && ( + +
    Execution
    +
    + Status: + + {execution.status} + +
    +
    + Progress: + + {execution.executionPath.length} / {workflow.nodes.length} + +
    +
    + )} + +
    + + {/* Detail panel */} + {selectedNode && ( +
    + +
    + )} +
    + ); +} diff --git a/packages/web/src/lib/graph-execution-mapper.ts b/packages/web/src/lib/graph-execution-mapper.ts new file mode 100644 index 000000000..6f0d33e54 --- /dev/null +++ b/packages/web/src/lib/graph-execution-mapper.ts @@ -0,0 +1,455 @@ +/** + * Utilities to map workflow run data to graph execution overlays + */ + +import type { Event, Step, WorkflowRun } from '@workflow/web-shared'; +import type { + EdgeTraversal, + GraphNode, + StepExecution, + WorkflowGraph, + WorkflowRunExecution, +} from './workflow-graph-types'; + +/** + * Normalize step/workflow names by removing path traversal patterns + * Graph has: "step//../example/workflows/1_simple.ts//add" + * Runtime has: "step//example/workflows/1_simple.ts//add" + */ +function normalizeStepName(name: string): string { + // Remove //../ patterns (path traversal) + return name.replace(/\/\/\.\.\//g, '//'); +} + +/** + * Create execution data for a single step attempt + * Handles all step statuses: pending, running, completed, failed, cancelled + */ +function createStepExecution( + attemptStep: Step, + graphNodeId: string, + idx: number, + totalAttempts: number +): StepExecution { + // Map step status to execution status + let status: StepExecution['status']; + switch (attemptStep.status) { + case 'completed': + status = 'completed'; + break; + case 'failed': + // If this is not the last attempt, it's a retry + status = idx < totalAttempts - 1 ? 'retrying' : 'failed'; + break; + case 'running': + status = 'running'; + break; + case 'cancelled': + status = 'cancelled'; + break; + case 'pending': + default: + status = 'pending'; + break; + } + + const duration = + attemptStep.completedAt && attemptStep.startedAt + ? new Date(attemptStep.completedAt).getTime() - + new Date(attemptStep.startedAt).getTime() + : undefined; + + return { + nodeId: graphNodeId, + stepId: attemptStep.stepId, + attemptNumber: attemptStep.attempt, + status, + startedAt: attemptStep.startedAt + ? new Date(attemptStep.startedAt).toISOString() + : undefined, + completedAt: attemptStep.completedAt + ? new Date(attemptStep.completedAt).toISOString() + : undefined, + duration, + input: attemptStep.input, + output: attemptStep.output, + error: attemptStep.error + ? { + message: attemptStep.error.message, + stack: attemptStep.error.stack || '', + } + : undefined, + }; +} + +/** + * Extract function name from a step ID + * "step//workflows/steps/post-slack-message.ts//postSlackMessage" -> "postSlackMessage" + */ +function extractFunctionName(stepId: string): string | null { + const parts = stepId.split('//'); + return parts.length >= 3 ? parts[parts.length - 1] : null; +} + +/** + * Build index of graph nodes by normalized stepId and by function name + */ +function buildNodeIndex(nodes: GraphNode[]): { + byStepId: Map; + byFunctionName: Map; +} { + const byStepId = new Map(); + const byFunctionName = new Map(); + + for (const node of nodes) { + if (node.data.stepId) { + // Index by full step ID + const normalizedStepId = normalizeStepName(node.data.stepId); + const existing = byStepId.get(normalizedStepId) || []; + existing.push(node); + byStepId.set(normalizedStepId, existing); + + // Also index by function name for fallback matching + const functionName = extractFunctionName(normalizedStepId); + if (functionName) { + const existingByName = byFunctionName.get(functionName) || []; + existingByName.push(node); + byFunctionName.set(functionName, existingByName); + } + } + } + return { byStepId, byFunctionName }; +} + +/** + * Calculate edge traversals based on execution path + */ +function calculateEdgeTraversals( + executionPath: string[], + graph: WorkflowGraph +): Map { + const edgeTraversals = new Map(); + + for (let i = 0; i < executionPath.length - 1; i++) { + const sourceNodeId = executionPath[i]; + const targetNodeId = executionPath[i + 1]; + + const edge = graph.edges.find( + (e) => e.source === sourceNodeId && e.target === targetNodeId + ); + + if (edge) { + const existing = edgeTraversals.get(edge.id); + if (existing) { + existing.traversalCount++; + } else { + edgeTraversals.set(edge.id, { + edgeId: edge.id, + traversalCount: 1, + timings: [], + }); + } + } + } + + return edgeTraversals; +} + +/** + * Initialize start node execution + */ +function initializeStartNode( + run: WorkflowRun, + graph: WorkflowGraph, + executionPath: string[], + nodeExecutions: Map +): void { + const startNode = graph.nodes.find( + (n) => n.data.nodeKind === 'workflow_start' + ); + if (startNode) { + executionPath.push(startNode.id); + nodeExecutions.set(startNode.id, [ + { + nodeId: startNode.id, + attemptNumber: 1, + status: 'completed', + startedAt: run.startedAt + ? new Date(run.startedAt).toISOString() + : undefined, + completedAt: run.startedAt + ? new Date(run.startedAt).toISOString() + : undefined, + duration: 0, + }, + ]); + } +} + +/** + * Add end node execution based on workflow run status + * Handles all run statuses: pending, running, completed, failed, paused, cancelled + */ +function addEndNodeExecution( + run: WorkflowRun, + graph: WorkflowGraph, + executionPath: string[], + nodeExecutions: Map +): void { + const endNode = graph.nodes.find((n) => n.data.nodeKind === 'workflow_end'); + if (!endNode || executionPath.includes(endNode.id)) { + return; + } + + // Map run status to end node execution status + let endNodeStatus: StepExecution['status']; + switch (run.status) { + case 'completed': + endNodeStatus = 'completed'; + break; + case 'failed': + endNodeStatus = 'failed'; + break; + case 'cancelled': + endNodeStatus = 'cancelled'; + break; + case 'running': + endNodeStatus = 'running'; + break; + case 'paused': + // Paused is like running but waiting + endNodeStatus = 'pending'; + break; + case 'pending': + default: + // Don't add end node for pending runs + return; + } + + executionPath.push(endNode.id); + nodeExecutions.set(endNode.id, [ + { + nodeId: endNode.id, + attemptNumber: 1, + status: endNodeStatus, + startedAt: run.completedAt + ? new Date(run.completedAt).toISOString() + : undefined, + completedAt: run.completedAt + ? new Date(run.completedAt).toISOString() + : undefined, + duration: 0, + }, + ]); +} + +/** + * Process a group of step attempts and map to graph node + */ +function processStepGroup( + stepGroup: Step[], + stepName: string, + nodesByStepId: Map, + nodesByFunctionName: Map, + occurrenceCount: Map, + nodeExecutions: Map, + executionPath: string[] +): string | undefined { + const normalizedStepName = normalizeStepName(stepName); + const occurrenceIndex = occurrenceCount.get(normalizedStepName) || 0; + occurrenceCount.set(normalizedStepName, occurrenceIndex + 1); + + let nodesWithStepId = nodesByStepId.get(normalizedStepName) || []; + let matchStrategy = 'step-id'; + + // Fallback: If no exact stepId match, try matching by function name + // This handles cases where step functions are in separate files + if (nodesWithStepId.length === 0) { + const functionName = extractFunctionName(normalizedStepName); + if (functionName) { + nodesWithStepId = nodesByFunctionName.get(functionName) || []; + matchStrategy = 'function-name'; + } + } + + // If there's only one node for this step but multiple invocations, + // map all invocations to that single node + const graphNode = + nodesWithStepId.length === 1 + ? nodesWithStepId[0] + : nodesWithStepId[occurrenceIndex]; + + console.log('[Graph Mapper] Processing step group:', { + stepName, + normalizedStepName, + attempts: stepGroup.length, + occurrenceIndex, + totalNodesWithStepId: nodesWithStepId.length, + selectedNode: graphNode?.id, + allNodesWithStepId: nodesWithStepId.map((n) => n.id), + matchStrategy, + strategy: + nodesWithStepId.length === 1 + ? 'single-node-multiple-invocations' + : 'occurrence-based', + }); + + if (!graphNode) { + return undefined; + } + + const executions: StepExecution[] = stepGroup.map((attemptStep, idx) => + createStepExecution(attemptStep, graphNode.id, idx, stepGroup.length) + ); + + // If there's only one node, append executions instead of replacing + if (nodesWithStepId.length === 1) { + const existing = nodeExecutions.get(graphNode.id) || []; + nodeExecutions.set(graphNode.id, [...existing, ...executions]); + } else { + nodeExecutions.set(graphNode.id, executions); + } + + if (!executionPath.includes(graphNode.id)) { + executionPath.push(graphNode.id); + } + + const latestExecution = executions[executions.length - 1]; + return latestExecution.status === 'running' ? graphNode.id : undefined; +} + +/** + * Maps a workflow run and its steps/events to an execution overlay for the graph + */ +export function mapRunToExecution( + run: WorkflowRun, + steps: Step[], + _events: Event[], + graph: WorkflowGraph +): WorkflowRunExecution { + const nodeExecutions = new Map(); + const executionPath: string[] = []; + let currentNode: string | undefined; + + console.log('[Graph Mapper] Mapping run to execution:', { + runId: run.runId, + workflowName: run.workflowName, + graphNodes: graph.nodes.length, + stepsCount: steps.length, + }); + + // Start node is always executed first + initializeStartNode(run, graph, executionPath, nodeExecutions); + + // Map steps to graph nodes + // Sort steps by createdAt to process in execution order + const sortedSteps = [...steps].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + + console.log( + '[Graph Mapper] Sorted steps:', + sortedSteps.map((s) => ({ + stepId: s.stepId, + stepName: s.stepName, + attempt: s.attempt, + status: s.status, + createdAt: s.createdAt, + })) + ); + + // Build an index of graph nodes by normalized stepId and function name for quick lookup + const { byStepId: nodesByStepId, byFunctionName: nodesByFunctionName } = + buildNodeIndex(graph.nodes); + + console.log('[Graph Mapper] Graph nodes by stepId:', { + allGraphNodes: graph.nodes.map((n) => ({ + id: n.id, + stepId: n.data.stepId, + normalizedStepId: n.data.stepId + ? normalizeStepName(n.data.stepId) + : undefined, + nodeKind: n.data.nodeKind, + })), + nodesByStepId: Array.from(nodesByStepId.entries()).map( + ([stepId, nodes]) => ({ + stepId, + nodeIds: nodes.map((n) => n.id), + }) + ), + }); + + // Track how many times we've seen each stepName to map to the correct occurrence + const stepNameOccurrenceCount = new Map(); + + // Group consecutive retries: steps with the same stepId (unique per invocation) are retries + let currentStepGroup: Step[] = []; + let currentStepId: string | null = null; + let currentStepName: string | null = null; + + for (let i = 0; i <= sortedSteps.length; i++) { + const step = sortedSteps[i]; + + // Start a new group if: + // 1. Different stepId (each invocation has a unique stepId, retries share the same stepId) + // 2. End of array + const isNewInvocation = !step || step.stepId !== currentStepId; + + if (isNewInvocation) { + // Process the previous group if it exists + if (currentStepGroup.length > 0 && currentStepName) { + const runningNode = processStepGroup( + currentStepGroup, + currentStepName, + nodesByStepId, + nodesByFunctionName, + stepNameOccurrenceCount, + nodeExecutions, + executionPath + ); + if (runningNode) { + currentNode = runningNode; + } + } + + // Start a new group with current step (if not at end) + if (step) { + currentStepGroup = [step]; + currentStepId = step.stepId; + currentStepName = step.stepName; + } + } else { + // Add to current group (this is a retry: same stepId) + currentStepGroup.push(step); + } + } + + // Add end node based on workflow status + addEndNodeExecution(run, graph, executionPath, nodeExecutions); + + // Calculate edge traversals based on execution path + const edgeTraversals = calculateEdgeTraversals(executionPath, graph); + + const result: WorkflowRunExecution = { + runId: run.runId, + status: run.status, + nodeExecutions, + edgeTraversals, + currentNode, + executionPath, + }; + + console.log('[Graph Mapper] Mapping complete:', { + executionPath, + nodeExecutionsCount: nodeExecutions.size, + nodeExecutions: Array.from(nodeExecutions.entries()).map( + ([nodeId, execs]) => ({ + nodeId, + executionCount: execs.length, + latestStatus: execs[execs.length - 1]?.status, + }) + ), + }); + + return result; +} From 6c51f455abaccf8f35ee60833e46a65f5eac65f1 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 28 Nov 2025 12:03:23 -0600 Subject: [PATCH 4/4] Workflow CFG Viewer and nuqs migration --- .../components/display-utils/status-badge.tsx | 140 ++++- packages/web/src/components/runs-table.tsx | 559 ++++++++++++------ 2 files changed, 473 insertions(+), 226 deletions(-) diff --git a/packages/web/src/components/display-utils/status-badge.tsx b/packages/web/src/components/display-utils/status-badge.tsx index 09fe7fe99..845e4e293 100644 --- a/packages/web/src/components/display-utils/status-badge.tsx +++ b/packages/web/src/components/display-utils/status-badge.tsx @@ -1,64 +1,142 @@ 'use client'; import type { Step, WorkflowRun } from '@workflow/world'; +import { Check, Copy } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; interface StatusBadgeProps { status: WorkflowRun['status'] | Step['status']; context?: { error?: unknown }; className?: string; + /** Duration in milliseconds to display below status */ + durationMs?: number; } -export function StatusBadge({ status, context, className }: StatusBadgeProps) { - const getStatusClasses = () => { +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) { + return remainingSeconds > 0 + ? `${minutes}m ${remainingSeconds}s` + : `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +export function StatusBadge({ + status, + context, + className, + durationMs, +}: StatusBadgeProps) { + const getCircleColor = () => { switch (status) { case 'running': - return 'text-blue-600 dark:text-blue-400'; + return 'bg-blue-500'; case 'completed': - return 'text-green-600 dark:text-green-400'; + return 'bg-emerald-500'; case 'failed': - return 'text-red-600 dark:text-red-400'; + return 'bg-red-500'; case 'cancelled': - return 'text-yellow-600 dark:text-yellow-400'; + return 'bg-yellow-500'; case 'pending': - return 'text-gray-600 dark:text-gray-400'; + return 'bg-gray-400'; case 'paused': - return 'text-orange-600 dark:text-orange-400'; + return 'bg-orange-500'; default: - return 'text-gray-500 dark:text-gray-400'; + return 'bg-gray-400'; } }; + const content = ( + + + + + {status} + + + {durationMs !== undefined && ( + + {formatDuration(durationMs)} + + )} + + ); + // Show error tooltip if status is failed and error exists if (status === 'failed' && context?.error) { - const errorMessage = - typeof context.error === 'string' - ? context.error - : context.error instanceof Error - ? context.error.message - : JSON.stringify(context.error); + return ; + } + + return content; +} + +function ErrorStatusBadge({ + content, + error, +}: { + content: React.ReactNode; + error: unknown; +}) { + const [copied, setCopied] = useState(false); - return ( - - - { + e.stopPropagation(); + await navigator.clipboard.writeText(errorMessage); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + {content} + + +
    + Error Details + +
    +
    +

    {errorMessage}

    - - - ); - } - - return {status}; +
    +
    +
    + ); } diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx index d8e32891d..5b6a497c1 100644 --- a/packages/web/src/components/runs-table.tsx +++ b/packages/web/src/components/runs-table.tsx @@ -1,7 +1,12 @@ 'use client'; import { parseWorkflowName } from '@workflow/core/parse-name'; -import { getErrorMessage, useWorkflowRuns } from '@workflow/web-shared'; +import { + cancelRun, + getErrorMessage, + recreateRun, + useWorkflowRuns, +} from '@workflow/web-shared'; import type { WorkflowRunStatus } from '@workflow/world'; import { AlertCircle, @@ -9,13 +14,24 @@ import { ArrowUpAZ, ChevronLeft, ChevronRight, + MoreHorizontal, RefreshCw, + RotateCw, + XCircle, } from 'lucide-react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; import { DocsLink } from '@/components/ui/docs-link'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Select, SelectContent, @@ -38,6 +54,7 @@ import { } from '@/components/ui/tooltip'; import { worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; +import { CopyableText } from './display-utils/copyable-text'; import { RelativeTime } from './display-utils/relative-time'; import { StatusBadge } from './display-utils/status-badge'; import { TableSkeleton } from './display-utils/table-skeleton'; @@ -56,15 +73,198 @@ const statusMap: Record = { cancelled: { label: 'Cancelled', color: 'bg-gray-600 dark:bg-gray-400' }, }; +// Helper: Handle workflow filter changes +function useWorkflowFilter() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + return useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === 'all') { + params.delete('workflow'); + params.delete('status'); + } else { + params.set('workflow', value); + } + router.push(`${pathname}?${params.toString()}`); + }, + [router, pathname, searchParams] + ); +} + +// Helper: Handle status filter changes +function useStatusFilter() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + return useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === 'all') { + params.delete('status'); + } else { + params.set('status', value); + } + router.push(`${pathname}?${params.toString()}`); + }, + [router, pathname, searchParams] + ); +} + +// Filter controls component +interface FilterControlsProps { + workflowNameFilter: string | 'all'; + status: WorkflowRunStatus | 'all' | undefined; + seenWorkflowNames: Set; + sortOrder: 'asc' | 'desc'; + loading: boolean; + statusFilterRequiresWorkflowNameFilter: boolean; + onWorkflowChange: (value: string) => void; + onStatusChange: (value: string) => void; + onSortToggle: () => void; + onRefresh: () => void; + lastRefreshTime: Date | null; +} + +function FilterControls({ + workflowNameFilter, + status, + seenWorkflowNames, + sortOrder, + loading, + statusFilterRequiresWorkflowNameFilter, + onWorkflowChange, + onStatusChange, + onSortToggle, + onRefresh, + lastRefreshTime, +}: FilterControlsProps) { + return ( +
    +
    +

    Last refreshed

    + {lastRefreshTime && ( + + )} +
    +
    + + + +
    + +
    +
    + + {statusFilterRequiresWorkflowNameFilter && + workflowNameFilter === 'all' + ? 'Select a workflow first to filter by status' + : 'Filter runs by status'} + +
    + + + + + + {sortOrder === 'desc' + ? 'Showing newest first' + : 'Showing oldest first'} + + + + + + + Note that this resets pages + +
    +
    + ); +} + /** * RunsTable - Displays workflow runs with server-side pagination. * Uses the PaginatingTable pattern: fetches data for each page as needed from the server. * The table and fetching behavior are intertwined - pagination controls trigger new API calls. */ export function RunsTable({ config, onRunClick }: RunsTableProps) { - const router = useRouter(); - const pathname = usePathname(); const searchParams = useSearchParams(); + const handleWorkflowFilter = useWorkflowFilter(); + const handleStatusFilter = useStatusFilter(); + // Validate status parameter - only allow known valid statuses or 'all' const rawStatus = searchParams.get('status'); const validStatuses = Object.keys(statusMap) as WorkflowRunStatus[]; @@ -82,7 +282,7 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { // TODO: World-vercel doesn't support filtering by status without a workflow name filter const statusFilterRequiresWorkflowNameFilter = - config.backend?.includes('vercel'); + config.backend?.includes('vercel') || false; // TODO: This is a workaround. We should be getting a list of valid workflow names // from the manifest, which we need to put on the World interface. const [seenWorkflowNames, setSeenWorkflowNames] = useState>( @@ -129,154 +329,23 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc')); }; - const createQueryString = useCallback( - (name: string, value: string) => { - const params = new URLSearchParams(searchParams.toString()); - params.set(name, value); - - return params.toString(); - }, - [searchParams] - ); - return (
    -
    -

    - Runs - {lastRefreshTime && ( - - )} -

    -
    - { - <> - - - -
    - -
    -
    - - {statusFilterRequiresWorkflowNameFilter && - workflowNameFilter === 'all' - ? 'Select a workflow first to filter by status' - : 'Filter runs by status'} - -
    - - - - - - {sortOrder === 'desc' - ? 'Showing newest first' - : 'Showing oldest first'} - - - - - - - Note that this resets pages - - - } -
    -
    + {error ? ( @@ -287,57 +356,157 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { ) : !loading && (!data.data || data.data.length === 0) ? (
    - No workflow runs found.{' '} + No workflow runs found.
    Learn how to create a workflow
    ) : ( <> - - - - Workflow - Run ID - Status - Started - Completed - - - - {data.data?.map((run) => ( - onRunClick(run.runId)} - > - - {parseWorkflowName(run.workflowName)?.shortName || '?'} - - - {run.runId} - - - - - - {run.startedAt ? ( - - ) : ( - '-' - )} - - - {run.completedAt ? ( - - ) : ( - '-' - )} - - - ))} - -
    + + + + + + + Workflow + + + Run ID + + + Status + + + Started + + + Completed + + + + + + {data.data?.map((run) => ( + onRunClick(run.runId)} + > + + + {parseWorkflowName(run.workflowName)?.shortName || + '?'} + + + + + {run.runId} + + + + + + + {run.startedAt ? ( + + ) : ( + '-' + )} + + + {run.completedAt ? ( + + ) : ( + '-' + )} + + + + + + + + { + e.stopPropagation(); + try { + const newRunId = await recreateRun( + env, + run.runId + ); + toast.success('New run started', { + description: `Run ID: ${newRunId}`, + }); + reload(); + } catch (err) { + toast.error('Failed to re-run', { + description: + err instanceof Error + ? err.message + : 'Unknown error', + }); + } + }} + > + + Re-run + + { + e.stopPropagation(); + if (run.status !== 'pending') { + toast.error('Cannot cancel', { + description: + 'Only pending runs can be cancelled', + }); + return; + } + try { + await cancelRun(env, run.runId); + toast.success('Run cancelled'); + reload(); + } catch (err) { + toast.error('Failed to cancel', { + description: + err instanceof Error + ? err.message + : 'Unknown error', + }); + } + }} + disabled={run.status !== 'pending'} + > + + Cancel + + + + + + ))} + +
    +
    +
    {pageInfo}