diff --git a/.changeset/post-bundle-graph-extraction.md b/.changeset/post-bundle-graph-extraction.md new file mode 100644 index 000000000..112455c94 --- /dev/null +++ b/.changeset/post-bundle-graph-extraction.md @@ -0,0 +1,18 @@ +--- +"@workflow/builders": patch +"@workflow/next": patch +"@workflow/web": patch +--- + +Refactor graph extraction to post-bundle TypeScript AST traversal. This replaces the previous Rust-based per-file analysis with a more accurate approach that analyzes the bundled workflow code, enabling: + +- Full CFG representation including loops, conditionals (with proper Then/Else branch metadata), and parallel execution +- Detection of step functions from imported packages +- Detection of indirect step calls through helper functions +- Step reference detection in tool configurations + +Web dashboard improvements: +- Migrate to nuqs for URL state management +- Add graph execution mapper to match runtime step executions to graph nodes +- Improve workflow graph visualization with proper execution status display + diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index b0929873b..3f4085696 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -26,6 +26,7 @@ const EMIT_SOURCEMAPS_FOR_DEBUGGING = */ export abstract class BaseBuilder { protected config: WorkflowConfig; + protected lastWorkflowManifest?: WorkflowManifest; constructor(config: WorkflowConfig) { this.config = config; @@ -253,6 +254,7 @@ export abstract class BaseBuilder { * Steps have full Node.js runtime access and handle side effects, API calls, etc. * * @param externalizeNonSteps - If true, only bundles step entry points and externalizes other code + * @returns Build context (for watch mode) and the collected workflow manifest */ protected async createStepsBundle({ inputFiles, @@ -268,7 +270,10 @@ export abstract class BaseBuilder { outfile: string; format?: 'cjs' | 'esm'; externalizeNonSteps?: boolean; - }): Promise { + }): Promise<{ + context: esbuild.BuildContext | undefined; + manifest: WorkflowManifest; + }> { // These need to handle watching for dev to scan for // new entries and changes to existing ones const { discoveredSteps: stepFiles } = await this.discoverEntries( @@ -389,10 +394,14 @@ export abstract class BaseBuilder { // Create .gitignore in .swc directory await this.createSwcGitignore(); + // Store the manifest for later use (e.g., graph generation in watch mode) + this.lastWorkflowManifest = workflowManifest; + if (this.config.watch) { - return esbuildCtx; + return { context: esbuildCtx, manifest: workflowManifest }; } await esbuildCtx.dispose(); + return { context: undefined, manifest: workflowManifest }; } /** @@ -838,4 +847,43 @@ export const OPTIONS = handler;`; // We're intentionally silently ignoring this error - creating .gitignore isn't critical } } + + /** + * Creates a graph manifest JSON file by extracting from the bundled workflow file. + * The manifest contains React Flow-compatible graph data for visualizing workflows. + */ + protected async createGraphManifest({ + workflowBundlePath, + outfile, + }: { + workflowBundlePath: string; + outfile: string; + }): Promise { + const graphBuildStart = Date.now(); + console.log('Creating workflow graph manifest...'); + + try { + // Import the graph extractor + const { extractGraphFromBundle } = await import('./graph-extractor.js'); + + // Extract graph from the bundled workflow file + const graphManifest = await extractGraphFromBundle(workflowBundlePath); + + // Write the graph manifest + await this.ensureDirectory(outfile); + await writeFile(outfile, JSON.stringify(graphManifest, null, 2)); + + console.log( + `Created graph manifest with ${ + Object.keys(graphManifest.workflows).length + } workflow(s)`, + `${Date.now() - graphBuildStart}ms` + ); + } catch (error) { + console.warn( + 'Failed to extract graph from bundle:', + error instanceof Error ? error.message : String(error) + ); + } + } } diff --git a/packages/builders/src/graph-extractor.ts b/packages/builders/src/graph-extractor.ts new file mode 100644 index 000000000..7847c7980 --- /dev/null +++ b/packages/builders/src/graph-extractor.ts @@ -0,0 +1,1168 @@ +import { readFile } from 'node:fs/promises'; +import type { + ArrowFunctionExpression, + BlockStatement, + CallExpression, + Expression, + FunctionDeclaration, + FunctionExpression, + Identifier, + MemberExpression, + Program, + Statement, + VariableDeclaration, +} from '@swc/core'; +import { parseSync } from '@swc/core'; + +/** + * Represents a function in the bundle (can be declaration, expression, or arrow) + */ +interface FunctionInfo { + name: string; + body: BlockStatement | Expression | null | undefined; + isStep: boolean; + stepId?: string; +} + +/** + * Graph manifest structure + */ +export interface GraphManifest { + version: string; + workflows: Record; + debugInfo?: DebugInfo; +} + +export interface WorkflowGraph { + workflowId: string; + workflowName: string; + filePath: string; + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +export interface GraphNode { + id: string; + type: string; + position: { x: number; y: number }; + data: { + label: string; + nodeKind: string; + stepId?: string; + line: number; + }; + metadata?: NodeMetadata; +} + +export interface NodeMetadata { + loopId?: string; + loopIsAwait?: boolean; + conditionalId?: string; + conditionalBranch?: 'Then' | 'Else'; + parallelGroupId?: string; + parallelMethod?: string; + /** Step is passed as a reference (callback/tool) rather than directly called */ + isStepReference?: boolean; + /** Context where the step reference was found (e.g., "tools.getWeather.execute") */ + referenceContext?: string; +} + +export interface GraphEdge { + id: string; + source: string; + target: string; + type: 'default' | 'loop' | 'conditional' | 'parallel'; + label?: string; +} + +export interface DebugInfo { + manifestPresent?: boolean; + manifestStepFiles?: number; + importsResolved?: number; + importsWithKind?: number; + importDetails?: Array<{ + localName: string; + source: string; + importedName: string; + kind?: string; + lookupCandidates: string[]; + }>; + error?: string; +} + +/** + * Extracts workflow graph from a bundled workflow file + */ +export async function extractGraphFromBundle( + bundlePath: string +): Promise { + const bundleCode = await readFile(bundlePath, 'utf-8'); + + try { + // The workflow bundle wraps the actual code in a template literal: + // const workflowCode = `...`; + // We need to parse the bundle AST first to properly extract the unescaped string + let actualWorkflowCode = bundleCode; + + // First, try to parse the bundle itself to extract workflowCode properly + const bundleAst = parseSync(bundleCode, { + syntax: 'ecmascript', + target: 'es2022', + }); + + // Find the workflowCode variable declaration + const workflowCodeValue = extractWorkflowCodeFromBundle(bundleAst); + if (workflowCodeValue) { + actualWorkflowCode = workflowCodeValue; + } + + // Now parse the actual workflow code + const ast = parseSync(actualWorkflowCode, { + syntax: 'ecmascript', + target: 'es2022', + }); + + // Extract step declarations + const stepDeclarations = extractStepDeclarations(actualWorkflowCode); + + // Build a map of ALL functions in the bundle (for transitive step resolution) + const functionMap = buildFunctionMap(ast, stepDeclarations); + + // Extract workflows with transitive step resolution + const workflows = extractWorkflows(ast, stepDeclarations, functionMap); + + return { + version: '1.0.0', + workflows, + }; + } catch (error) { + console.error('Failed to extract graph from bundle:', error); + // Return empty manifest on parsing errors + return { + version: '1.0.0', + workflows: {}, + debugInfo: { + error: error instanceof Error ? error.message : String(error), + }, + }; + } +} + +/** + * Extract the workflowCode string value from a parsed bundle AST + */ +function extractWorkflowCodeFromBundle(ast: Program): string | null { + for (const item of ast.body) { + if (item.type === 'VariableDeclaration') { + for (const decl of item.declarations) { + if ( + decl.id.type === 'Identifier' && + decl.id.value === 'workflowCode' && + decl.init + ) { + // Handle template literal + if (decl.init.type === 'TemplateLiteral') { + // Concatenate all quasis (the string parts of template literal) + return decl.init.quasis.map((q) => q.cooked || q.raw).join(''); + } + // Handle regular string literal + if (decl.init.type === 'StringLiteral') { + return decl.init.value; + } + } + } + } + } + return null; +} + +/** + * Extract step declarations using regex for speed + */ +function extractStepDeclarations( + bundleCode: string +): Map { + const stepDeclarations = new Map(); + + // Match: var stepName = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//path//name"); + const stepPattern = + /var (\w+) = globalThis\[Symbol\.for\("WORKFLOW_USE_STEP"\)\]\("([^"]+)"\)/g; + + // Track line numbers + const lines = bundleCode.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + stepPattern.lastIndex = 0; + const match = stepPattern.exec(line); + if (match) { + const [, varName, stepId] = match; + stepDeclarations.set(varName, { + stepId, + line: i + 1, + }); + } + } + + return stepDeclarations; +} + +/** + * Build a map of all functions in the bundle for transitive step resolution + */ +function buildFunctionMap( + ast: Program, + stepDeclarations: Map +): Map { + const functionMap = new Map(); + + for (const item of ast.body) { + // Handle function declarations: function foo() {} + if (item.type === 'FunctionDeclaration') { + const func = item as FunctionDeclaration; + if (func.identifier) { + const name = func.identifier.value; + const isStep = stepDeclarations.has(name); + functionMap.set(name, { + name, + body: func.body, + isStep, + stepId: isStep ? stepDeclarations.get(name)?.stepId : undefined, + }); + } + } + + // Handle variable declarations: const foo = function() {} or const foo = () => {} + if (item.type === 'VariableDeclaration') { + const varDecl = item as VariableDeclaration; + for (const decl of varDecl.declarations) { + if (decl.id.type === 'Identifier' && decl.init) { + const name = decl.id.value; + const isStep = stepDeclarations.has(name); + + if (decl.init.type === 'FunctionExpression') { + const funcExpr = decl.init as FunctionExpression; + functionMap.set(name, { + name, + body: funcExpr.body, + isStep, + stepId: isStep ? stepDeclarations.get(name)?.stepId : undefined, + }); + } else if (decl.init.type === 'ArrowFunctionExpression') { + const arrowFunc = decl.init as ArrowFunctionExpression; + functionMap.set(name, { + name, + body: arrowFunc.body, + isStep, + stepId: isStep ? stepDeclarations.get(name)?.stepId : undefined, + }); + } + } + } + } + } + + return functionMap; +} + +/** + * Extract workflows from AST + */ +function extractWorkflows( + ast: Program, + stepDeclarations: Map, + functionMap: Map +): Record { + const workflows: Record = {}; + + // Find all function declarations + for (const item of ast.body) { + if (item.type === 'FunctionDeclaration') { + const func = item as FunctionDeclaration; + if (!func.identifier) continue; + + const workflowName = func.identifier.value; + + // Check if this function has a workflowId property assignment + // Look for: functionName.workflowId = "workflow//path//name"; + const workflowId = findWorkflowId(ast, workflowName); + if (!workflowId) continue; + + // Extract file path from workflowId + // Format: "workflow//path/to/file.ts//functionName" + const parts = workflowId.split('//'); + const filePath = parts.length > 1 ? parts[1] : ''; + + // Analyze the function body with transitive step resolution + const graph = analyzeWorkflowFunction( + func, + workflowName, + workflowId, + filePath, + stepDeclarations, + functionMap + ); + + workflows[workflowName] = graph; + } + } + + return workflows; +} + +/** + * Find workflowId assignment for a function + */ +function findWorkflowId(ast: Program, functionName: string): string | null { + for (const item of ast.body) { + if (item.type === 'ExpressionStatement') { + const expr = item.expression; + if (expr.type === 'AssignmentExpression') { + const left = expr.left; + if (left.type === 'MemberExpression') { + const obj = left.object; + const prop = left.property; + if ( + obj.type === 'Identifier' && + obj.value === functionName && + prop.type === 'Identifier' && + prop.value === 'workflowId' + ) { + const right = expr.right; + if (right.type === 'StringLiteral') { + return right.value; + } + } + } + } + } + } + return null; +} + +/** + * Analyze a workflow function and build its graph + */ +function analyzeWorkflowFunction( + func: FunctionDeclaration, + workflowName: string, + workflowId: string, + filePath: string, + stepDeclarations: Map, + functionMap: Map +): WorkflowGraph { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + + // Add start node + nodes.push({ + id: 'start', + type: 'workflowStart', + position: { x: 250, y: 0 }, + data: { + label: `Start: ${workflowName}`, + nodeKind: 'workflow_start', + line: func.span.start, + }, + }); + + // Context for control flow analysis + const context: AnalysisContext = { + parallelCounter: 0, + loopCounter: 0, + conditionalCounter: 0, + nodeCounter: 0, + yPosition: 100, + inLoop: null, + inConditional: null, + }; + + let prevExitIds = ['start']; + + // Analyze function body + if (func.body?.stmts) { + for (const stmt of func.body.stmts) { + const result = analyzeStatement( + stmt, + stepDeclarations, + context, + functionMap + ); + + // Add all nodes and edges from this statement + nodes.push(...result.nodes); + edges.push(...result.edges); + + // Connect previous exits to this statement's entries + for (const prevId of prevExitIds) { + for (const entryId of result.entryNodeIds) { + // Check if edge already exists + const edgeId = `e_${prevId}_${entryId}`; + if (!edges.find((e) => e.id === edgeId)) { + const targetNode = result.nodes.find((n) => n.id === entryId); + const edgeType = targetNode?.metadata?.parallelGroupId + ? 'parallel' + : targetNode?.metadata?.loopId + ? 'loop' + : 'default'; + edges.push({ + id: edgeId, + source: prevId, + target: entryId, + type: edgeType, + }); + } + } + } + + // Update prev exits for next iteration + if (result.exitNodeIds.length > 0) { + prevExitIds = result.exitNodeIds; + } + } + } + + // Add end node + const endY = context.yPosition; + nodes.push({ + id: 'end', + type: 'workflowEnd', + position: { x: 250, y: endY }, + data: { + label: 'Return', + nodeKind: 'workflow_end', + line: func.span.end, + }, + }); + + // Connect last exits to end + for (const prevId of prevExitIds) { + edges.push({ + id: `e_${prevId}_end`, + source: prevId, + target: 'end', + type: 'default', + }); + } + + return { + workflowId, + workflowName, + filePath, + nodes, + edges, + }; +} + +interface AnalysisContext { + parallelCounter: number; + loopCounter: number; + conditionalCounter: number; + nodeCounter: number; + yPosition: number; + inLoop: string | null; + inConditional: string | null; +} + +interface AnalysisResult { + nodes: GraphNode[]; + edges: GraphEdge[]; + entryNodeIds: string[]; // Nodes that should receive edge from previous + exitNodeIds: string[]; // Nodes that should send edge to next +} + +/** + * Analyze a statement and extract step calls with proper CFG structure + */ +function analyzeStatement( + stmt: Statement, + stepDeclarations: Map, + context: AnalysisContext, + functionMap: Map +): AnalysisResult { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + let entryNodeIds: string[] = []; + let exitNodeIds: string[] = []; + + // Variable declaration (const result = await step()) + if (stmt.type === 'VariableDeclaration') { + const varDecl = stmt as VariableDeclaration; + for (const decl of varDecl.declarations) { + if (decl.init) { + const result = analyzeExpression( + decl.init, + stepDeclarations, + context, + functionMap + ); + nodes.push(...result.nodes); + edges.push(...result.edges); + if (entryNodeIds.length === 0) { + entryNodeIds = result.entryNodeIds; + } else { + // Connect previous exits to new entries + for (const prevId of exitNodeIds) { + for (const entryId of result.entryNodeIds) { + edges.push({ + id: `e_${prevId}_${entryId}`, + source: prevId, + target: entryId, + type: 'default', + }); + } + } + } + exitNodeIds = result.exitNodeIds; + } + } + } + + // Expression statement (await step()) + if (stmt.type === 'ExpressionStatement') { + const result = analyzeExpression( + stmt.expression, + stepDeclarations, + context, + functionMap + ); + nodes.push(...result.nodes); + edges.push(...result.edges); + entryNodeIds = result.entryNodeIds; + exitNodeIds = result.exitNodeIds; + } + + // If statement + if (stmt.type === 'IfStatement') { + const savedConditional = context.inConditional; + const conditionalId = `cond_${context.conditionalCounter++}`; + context.inConditional = conditionalId; + + // Analyze consequent (then branch) + if (stmt.consequent.type === 'BlockStatement') { + const branchResult = analyzeBlock( + stmt.consequent.stmts, + stepDeclarations, + context, + functionMap + ); + + // Mark all nodes with conditional metadata for 'Then' branch + for (const node of branchResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.conditionalId = conditionalId; + node.metadata.conditionalBranch = 'Then'; + } + + nodes.push(...branchResult.nodes); + edges.push(...branchResult.edges); + if (entryNodeIds.length === 0) { + entryNodeIds = branchResult.entryNodeIds; + } + exitNodeIds.push(...branchResult.exitNodeIds); + } + + // Analyze alternate (else branch) + if (stmt.alternate?.type === 'BlockStatement') { + const branchResult = analyzeBlock( + stmt.alternate.stmts, + stepDeclarations, + context, + functionMap + ); + + // Mark all nodes with conditional metadata for 'Else' branch + for (const node of branchResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.conditionalId = conditionalId; + node.metadata.conditionalBranch = 'Else'; + } + + nodes.push(...branchResult.nodes); + edges.push(...branchResult.edges); + // Add else branch entries to entryNodeIds for proper edge connection + if (entryNodeIds.length === 0) { + entryNodeIds = branchResult.entryNodeIds; + } else { + entryNodeIds.push(...branchResult.entryNodeIds); + } + exitNodeIds.push(...branchResult.exitNodeIds); + } + + context.inConditional = savedConditional; + } + + // While/For loops + if (stmt.type === 'WhileStatement' || stmt.type === 'ForStatement') { + const loopId = `loop_${context.loopCounter++}`; + const savedLoop = context.inLoop; + context.inLoop = loopId; + + const body = + stmt.type === 'WhileStatement' ? stmt.body : (stmt as any).body; + if (body.type === 'BlockStatement') { + const loopResult = analyzeBlock( + body.stmts, + stepDeclarations, + context, + functionMap + ); + + // Mark all nodes with loop metadata + for (const node of loopResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.loopId = loopId; + } + + nodes.push(...loopResult.nodes); + edges.push(...loopResult.edges); + entryNodeIds = loopResult.entryNodeIds; + exitNodeIds = loopResult.exitNodeIds; + + // Add loop back-edge from last nodes to first nodes + for (const exitId of loopResult.exitNodeIds) { + for (const entryId of loopResult.entryNodeIds) { + edges.push({ + id: `e_${exitId}_back_${entryId}`, + source: exitId, + target: entryId, + type: 'loop', + }); + } + } + } + + context.inLoop = savedLoop; + } + + // For-of loops (including `for await...of`) + if (stmt.type === 'ForOfStatement') { + const loopId = `loop_${context.loopCounter++}`; + const savedLoop = context.inLoop; + context.inLoop = loopId; + + const isAwait = (stmt as any).isAwait || (stmt as any).await; + const body = (stmt as any).body; + + if (body.type === 'BlockStatement') { + const loopResult = analyzeBlock( + body.stmts, + stepDeclarations, + context, + functionMap + ); + + // Mark all nodes with loop metadata + for (const node of loopResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.loopId = loopId; + node.metadata.loopIsAwait = isAwait; + } + + nodes.push(...loopResult.nodes); + edges.push(...loopResult.edges); + entryNodeIds = loopResult.entryNodeIds; + exitNodeIds = loopResult.exitNodeIds; + + // Add loop back-edge from last nodes to first nodes + for (const exitId of loopResult.exitNodeIds) { + for (const entryId of loopResult.entryNodeIds) { + edges.push({ + id: `e_${exitId}_back_${entryId}`, + source: exitId, + target: entryId, + type: 'loop', + }); + } + } + } + + context.inLoop = savedLoop; + } + + // Return statement with expression + if (stmt.type === 'ReturnStatement' && (stmt as any).argument) { + const result = analyzeExpression( + (stmt as any).argument, + stepDeclarations, + context, + functionMap + ); + nodes.push(...result.nodes); + edges.push(...result.edges); + entryNodeIds = result.entryNodeIds; + exitNodeIds = result.exitNodeIds; + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; +} + +/** + * Analyze a block of statements with proper sequential chaining + */ +function analyzeBlock( + stmts: Statement[], + stepDeclarations: Map, + context: AnalysisContext, + functionMap: Map +): AnalysisResult { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + let entryNodeIds: string[] = []; + let currentExitIds: string[] = []; + + for (const stmt of stmts) { + const result = analyzeStatement( + stmt, + stepDeclarations, + context, + functionMap + ); + + if (result.nodes.length === 0) continue; + + nodes.push(...result.nodes); + edges.push(...result.edges); + + // Set entry nodes from first statement with nodes + if (entryNodeIds.length === 0 && result.entryNodeIds.length > 0) { + entryNodeIds = result.entryNodeIds; + } + + // Connect previous exits to current entries + if (currentExitIds.length > 0 && result.entryNodeIds.length > 0) { + for (const prevId of currentExitIds) { + for (const entryId of result.entryNodeIds) { + const targetNode = result.nodes.find((n) => n.id === entryId); + const edgeType = targetNode?.metadata?.parallelGroupId + ? 'parallel' + : 'default'; + edges.push({ + id: `e_${prevId}_${entryId}`, + source: prevId, + target: entryId, + type: edgeType, + }); + } + } + } + + // Update exit nodes + if (result.exitNodeIds.length > 0) { + currentExitIds = result.exitNodeIds; + } + } + + return { nodes, edges, entryNodeIds, exitNodeIds: currentExitIds }; +} + +/** + * Analyze an expression and extract step calls (including transitive calls through helper functions) + */ +function analyzeExpression( + expr: Expression, + stepDeclarations: Map, + context: AnalysisContext, + functionMap: Map, + visitedFunctions: Set = new Set() // Prevent infinite recursion +): AnalysisResult { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const entryNodeIds: string[] = []; + const exitNodeIds: string[] = []; + + // Await expression + if (expr.type === 'AwaitExpression') { + const awaitedExpr = expr.argument; + if (awaitedExpr.type === 'CallExpression') { + const callExpr = awaitedExpr as CallExpression; + + // Check for Promise.all/race/allSettled + if (callExpr.callee.type === 'MemberExpression') { + const member = callExpr.callee as MemberExpression; + if ( + member.object.type === 'Identifier' && + (member.object as Identifier).value === 'Promise' && + member.property.type === 'Identifier' + ) { + const method = (member.property as Identifier).value; + if (['all', 'race', 'allSettled'].includes(method)) { + // Create a new parallel group for this Promise.all + const parallelId = `parallel_${context.parallelCounter++}`; + + // Analyze array elements + if (callExpr.arguments.length > 0) { + const arg = callExpr.arguments[0].expression; + if (arg.type === 'ArrayExpression') { + for (const element of arg.elements) { + if (element?.expression) { + const elemResult = analyzeExpression( + element.expression, + stepDeclarations, + context, + functionMap, + visitedFunctions + ); + + // Set parallel metadata on all nodes from this element + for (const node of elemResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.parallelGroupId = parallelId; + node.metadata.parallelMethod = method; + // Preserve loop context if we're inside a loop + if (context.inLoop) { + node.metadata.loopId = context.inLoop; + } + } + + nodes.push(...elemResult.nodes); + edges.push(...elemResult.edges); + entryNodeIds.push(...elemResult.entryNodeIds); + exitNodeIds.push(...elemResult.exitNodeIds); + } + } + } + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; + } + } + } + + // Regular call - check if it's a step or a helper function + if (callExpr.callee.type === 'Identifier') { + const funcName = (callExpr.callee as Identifier).value; + const stepInfo = stepDeclarations.get(funcName); + + if (stepInfo) { + // Direct step call + const nodeId = `node_${context.nodeCounter++}`; + const metadata: NodeMetadata = {}; + + if (context.inLoop) { + metadata.loopId = context.inLoop; + } + if (context.inConditional) { + metadata.conditionalId = context.inConditional; + } + + const node: GraphNode = { + id: nodeId, + type: 'step', + position: { x: 250, y: context.yPosition }, + data: { + label: funcName, + nodeKind: 'step', + stepId: stepInfo.stepId, + line: expr.span.start, + }, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + context.yPosition += 100; + } else { + // Check if it's a helper function - analyze transitively + const transitiveResult = analyzeTransitiveCall( + funcName, + stepDeclarations, + context, + functionMap, + visitedFunctions, + expr.span.start + ); + nodes.push(...transitiveResult.nodes); + edges.push(...transitiveResult.edges); + entryNodeIds.push(...transitiveResult.entryNodeIds); + exitNodeIds.push(...transitiveResult.exitNodeIds); + } + } + } + } + + // Non-awaited call expression + if (expr.type === 'CallExpression') { + const callExpr = expr as CallExpression; + if (callExpr.callee.type === 'Identifier') { + const funcName = (callExpr.callee as Identifier).value; + const stepInfo = stepDeclarations.get(funcName); + + if (stepInfo) { + // Direct step call + const nodeId = `node_${context.nodeCounter++}`; + const metadata: NodeMetadata = {}; + + if (context.inLoop) { + metadata.loopId = context.inLoop; + } + if (context.inConditional) { + metadata.conditionalId = context.inConditional; + } + + const node: GraphNode = { + id: nodeId, + type: 'step', + position: { x: 250, y: context.yPosition }, + data: { + label: funcName, + nodeKind: 'step', + stepId: stepInfo.stepId, + line: expr.span.start, + }, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + context.yPosition += 100; + } else { + // Check if it's a helper function - analyze transitively + const transitiveResult = analyzeTransitiveCall( + funcName, + stepDeclarations, + context, + functionMap, + visitedFunctions, + expr.span.start + ); + nodes.push(...transitiveResult.nodes); + edges.push(...transitiveResult.edges); + entryNodeIds.push(...transitiveResult.entryNodeIds); + exitNodeIds.push(...transitiveResult.exitNodeIds); + } + } + } + + // Check for step references in object literals (e.g., { execute: stepFunc, tools: { ... } }) + if (expr.type === 'ObjectExpression') { + const refResult = analyzeObjectForStepReferences( + expr, + stepDeclarations, + context, + '' + ); + nodes.push(...refResult.nodes); + edges.push(...refResult.edges); + entryNodeIds.push(...refResult.entryNodeIds); + exitNodeIds.push(...refResult.exitNodeIds); + } + + // Check for step references in function call arguments + if (expr.type === 'CallExpression') { + const callExpr = expr as CallExpression; + for (const arg of callExpr.arguments) { + if (arg.expression) { + // Check if argument is a step reference + if (arg.expression.type === 'Identifier') { + const argName = (arg.expression as Identifier).value; + const stepInfo = stepDeclarations.get(argName); + if (stepInfo) { + const nodeId = `node_${context.nodeCounter++}`; + const node: GraphNode = { + id: nodeId, + type: 'step', + position: { x: 250, y: context.yPosition }, + data: { + label: `${argName} (ref)`, + nodeKind: 'step', + stepId: stepInfo.stepId, + line: arg.expression.span.start, + }, + metadata: { + isStepReference: true, + referenceContext: 'function argument', + }, + }; + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + context.yPosition += 100; + } + } + // Check for object literals in arguments + if (arg.expression.type === 'ObjectExpression') { + const refResult = analyzeObjectForStepReferences( + arg.expression, + stepDeclarations, + context, + '' + ); + nodes.push(...refResult.nodes); + edges.push(...refResult.edges); + entryNodeIds.push(...refResult.entryNodeIds); + exitNodeIds.push(...refResult.exitNodeIds); + } + } + } + } + + // Check for step references in 'new' expressions (e.g., new DurableAgent({ tools: ... })) + if (expr.type === 'NewExpression') { + const newExpr = expr as any; + if (newExpr.arguments) { + for (const arg of newExpr.arguments) { + if (arg.expression?.type === 'ObjectExpression') { + const refResult = analyzeObjectForStepReferences( + arg.expression, + stepDeclarations, + context, + '' + ); + nodes.push(...refResult.nodes); + edges.push(...refResult.edges); + entryNodeIds.push(...refResult.entryNodeIds); + exitNodeIds.push(...refResult.exitNodeIds); + } + } + } + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; +} + +/** + * Analyze an object expression for step references (e.g., { execute: stepFunc }) + */ +function analyzeObjectForStepReferences( + obj: any, + stepDeclarations: Map, + context: AnalysisContext, + path: string +): AnalysisResult { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const entryNodeIds: string[] = []; + const exitNodeIds: string[] = []; + + if (!obj.properties) return { nodes, edges, entryNodeIds, exitNodeIds }; + + for (const prop of obj.properties) { + if (prop.type !== 'KeyValueProperty') continue; + + // Get property key name + let keyName = ''; + if (prop.key.type === 'Identifier') { + keyName = prop.key.value; + } else if (prop.key.type === 'StringLiteral') { + keyName = prop.key.value; + } + + const currentPath = path ? `${path}.${keyName}` : keyName; + + // Check if the value is a step reference + if (prop.value.type === 'Identifier') { + const valueName = prop.value.value; + const stepInfo = stepDeclarations.get(valueName); + if (stepInfo) { + const nodeId = `node_${context.nodeCounter++}`; + const node: GraphNode = { + id: nodeId, + type: 'step', + position: { x: 250, y: context.yPosition }, + data: { + label: `${valueName} (tool)`, + nodeKind: 'step', + stepId: stepInfo.stepId, + line: prop.value.span.start, + }, + metadata: { + isStepReference: true, + referenceContext: currentPath, + }, + }; + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + context.yPosition += 100; + } + } + + // Recursively check nested objects + if (prop.value.type === 'ObjectExpression') { + const nestedResult = analyzeObjectForStepReferences( + prop.value, + stepDeclarations, + context, + currentPath + ); + nodes.push(...nestedResult.nodes); + edges.push(...nestedResult.edges); + entryNodeIds.push(...nestedResult.entryNodeIds); + exitNodeIds.push(...nestedResult.exitNodeIds); + } + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; +} + +/** + * Analyze a transitive function call to find step calls within helper functions + */ +function analyzeTransitiveCall( + funcName: string, + stepDeclarations: Map, + context: AnalysisContext, + functionMap: Map, + visitedFunctions: Set, + _callLine: number // Reserved for future debug info +): AnalysisResult { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const entryNodeIds: string[] = []; + const exitNodeIds: string[] = []; + + // Prevent infinite recursion + if (visitedFunctions.has(funcName)) { + return { nodes, edges, entryNodeIds, exitNodeIds }; + } + + // Look up the function in our map + const funcInfo = functionMap.get(funcName); + if (!funcInfo || funcInfo.isStep) { + // Not a helper function or already a step + return { nodes, edges, entryNodeIds, exitNodeIds }; + } + + // Mark as visited to prevent cycles + visitedFunctions.add(funcName); + + try { + // Analyze the function body + if (funcInfo.body) { + if (funcInfo.body.type === 'BlockStatement') { + // Function body is a block statement + const bodyResult = analyzeBlock( + funcInfo.body.stmts, + stepDeclarations, + context, + functionMap + ); + nodes.push(...bodyResult.nodes); + edges.push(...bodyResult.edges); + entryNodeIds.push(...bodyResult.entryNodeIds); + exitNodeIds.push(...bodyResult.exitNodeIds); + } else { + // Arrow function with expression body + const exprResult = analyzeExpression( + funcInfo.body, + stepDeclarations, + context, + functionMap, + visitedFunctions + ); + nodes.push(...exprResult.nodes); + edges.push(...exprResult.edges); + entryNodeIds.push(...exprResult.entryNodeIds); + exitNodeIds.push(...exprResult.exitNodeIds); + } + } + } finally { + // Unmark after analysis to allow the same function to be called multiple times + // (just not recursively) + visitedFunctions.delete(funcName); + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; +} diff --git a/packages/builders/src/standalone.ts b/packages/builders/src/standalone.ts index c4ef2c936..10b5562de 100644 --- a/packages/builders/src/standalone.ts +++ b/packages/builders/src/standalone.ts @@ -14,6 +14,10 @@ export class StandaloneBuilder extends BaseBuilder { await this.buildWorkflowsBundle(options); await this.buildWebhookFunction(); + // Build graph manifest from workflow bundle (post-bundle extraction) + const workflowBundlePath = this.resolvePath('.swc/workflows.js'); + await this.buildGraphManifest({ workflowBundlePath }); + await this.createClientLibrary(); } @@ -25,18 +29,20 @@ export class StandaloneBuilder extends BaseBuilder { inputFiles: string[]; tsBaseUrl?: string; tsPaths?: Record; - }): Promise { + }) { console.log('Creating steps bundle at', this.config.stepsBundlePath); const stepsBundlePath = this.resolvePath(this.config.stepsBundlePath); await this.ensureDirectory(stepsBundlePath); - await this.createStepsBundle({ + const { manifest } = await this.createStepsBundle({ outfile: stepsBundlePath, inputFiles, tsBaseUrl, tsPaths, }); + + return manifest; } private async buildWorkflowsBundle({ @@ -76,4 +82,18 @@ export class StandaloneBuilder extends BaseBuilder { outfile: webhookBundlePath, }); } + + private async buildGraphManifest({ + workflowBundlePath, + }: { + workflowBundlePath: string; + }): Promise { + const graphManifestPath = this.resolvePath('.swc/graph-manifest.json'); + await this.ensureDirectory(graphManifestPath); + + await this.createGraphManifest({ + workflowBundlePath, + outfile: graphManifestPath, + }); + } } diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 28a52e681..6e5052107 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -43,9 +43,23 @@ export async function getNextBuilder() { tsPaths: tsConfig.paths, }; - const stepsBuildContext = await this.buildStepsFunction(options); + const { context: stepsBuildContext } = + await this.buildStepsFunction(options); const workflowsBundle = await this.buildWorkflowsFunction(options); await this.buildWebhookRoute({ workflowGeneratedDir }); + + // Write graph manifest to workflow data directory (post-bundle extraction) + const workflowDataDir = join( + this.config.workingDir, + '.next/workflow-data' + ); + await mkdir(workflowDataDir, { recursive: true }); + const workflowBundlePath = join(workflowGeneratedDir, 'flow/route.js'); + await this.createGraphManifest({ + workflowBundlePath, + outfile: join(workflowDataDir, 'graph-manifest.json'), + }); + await this.writeFunctionsConfig(outputDir); if (this.config.watch) { @@ -150,7 +164,8 @@ export async function getNextBuilder() { options.inputFiles = newInputFiles; await stepsCtx.dispose(); - const newStepsCtx = await this.buildStepsFunction(options); + const { context: newStepsCtx } = + await this.buildStepsFunction(options); if (!newStepsCtx) { throw new Error( 'Invariant: expected steps build context after rebuild' @@ -166,6 +181,25 @@ export async function getNextBuilder() { ); } workflowsCtx = newWorkflowsCtx; + + // Rebuild graph manifest to workflow data directory + try { + const workflowDataDir = join( + this.config.workingDir, + '.next/workflow-data' + ); + await mkdir(workflowDataDir, { recursive: true }); + const workflowBundlePath = join( + workflowGeneratedDir, + 'flow/route.js' + ); + await this.createGraphManifest({ + workflowBundlePath, + outfile: join(workflowDataDir, 'graph-manifest.json'), + }); + } catch (error) { + console.error('Failed to rebuild graph manifest:', error); + } }; const logBuildMessages = ( @@ -220,6 +254,25 @@ export async function getNextBuilder() { 'Rebuilt workflow bundle', `${Date.now() - rebuiltWorkflowStart}ms` ); + + // Rebuild graph manifest to workflow data directory (post-bundle extraction) + try { + const workflowDataDir = join( + this.config.workingDir, + '.next/workflow-data' + ); + await mkdir(workflowDataDir, { recursive: true }); + const workflowBundlePath = join( + workflowGeneratedDir, + 'flow/route.js' + ); + await this.createGraphManifest({ + workflowBundlePath, + outfile: join(workflowDataDir, 'graph-manifest.json'), + }); + } catch (error) { + console.error('Failed to rebuild graph manifest:', error); + } }; const isWatchableFile = (path: string) => 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..fe0a98ed8 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -499,3 +499,40 @@ export async function readStreamServerAction( }; } } + +/** + * Fetch the workflow graph manifest from the data directory + * The manifest is generated at build time and contains static structure info about workflows + */ +export async function fetchGraphManifest( + 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'; + + // Read the manifest file + const fs = await import('fs/promises'); + const path = await import('path'); + + // If dataDir is absolute, use it directly; otherwise join with cwd + const fullPath = path.isAbsolute(dataDir) + ? path.join(dataDir, 'graph-manifest.json') + : path.join(process.cwd(), dataDir, 'graph-manifest.json'); + + const content = await fs.readFile(fullPath, 'utf-8'); + const manifest = JSON.parse(content); + + return createResponse(manifest); + } catch (error) { + console.error('Failed to fetch graph manifest:', error); + return { + success: false, + error: createServerActionError(error, 'fetchGraphManifest', {}), + }; + } +} diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 60e9d2465..f1592da95 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, + fetchGraphManifest, +} 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-shared/src/sidebar/attribute-panel.tsx b/packages/web-shared/src/sidebar/attribute-panel.tsx index adb110f9c..aba8ef89d 100644 --- a/packages/web-shared/src/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/sidebar/attribute-panel.tsx @@ -10,7 +10,7 @@ import { DetailCard } from './detail-card'; const JsonBlock = (value: unknown) => { return (
                 
                   Error Code:{' '}
                 
                 
                   {error.code}
@@ -182,7 +182,7 @@ const attributeToDisplayFn: Record<
             )}
             {/* Show stack if available, otherwise just the message */}
             
         
 {
   const displayFn =
     attributeToDisplayFn[attribute as keyof typeof attributeToDisplayFn];
@@ -245,6 +247,23 @@ export const AttributeBlock = ({
   if (!displayValue) {
     return null;
   }
+
+  if (inline) {
+    return (
+      
+ + {attribute} + + + {displayValue} + +
+ ); + } + return (
{typeof isLoading === 'boolean' && isLoading && ( @@ -256,10 +275,15 @@ export const AttributeBlock = ({
)}
- + {attribute} - {displayValue} + + {displayValue} +
); @@ -282,15 +306,54 @@ export const AttributePanel = ({ .filter((key) => resolvableAttributes.includes(key)) .sort(sortByAttributeOrder); + // Filter out attributes that return null + const visibleBasicAttributes = basicAttributes.filter((attribute) => { + const displayFn = + attributeToDisplayFn[attribute as keyof typeof attributeToDisplayFn]; + if (!displayFn) return false; + const displayValue = displayFn( + displayData[attribute as keyof typeof displayData] + ); + return displayValue !== null; + }); + return (
- {basicAttributes.map((attribute) => ( - - ))} + {/* Basic attributes in a vertical layout with border */} + {visibleBasicAttributes.length > 0 && ( +
+ {visibleBasicAttributes.map((attribute) => ( +
+ + {attribute} + + + {attributeToDisplayFn[ + attribute as keyof typeof attributeToDisplayFn + ]?.(displayData[attribute as keyof typeof displayData])} + +
+ ))} +
+ )} {error ? ( diff --git a/packages/web-shared/src/sidebar/detail-card.tsx b/packages/web-shared/src/sidebar/detail-card.tsx index fff85a30c..1b05509c8 100644 --- a/packages/web-shared/src/sidebar/detail-card.tsx +++ b/packages/web-shared/src/sidebar/detail-card.tsx @@ -10,7 +10,7 @@ export function DetailCard({ return (
{summary} -
{children}
+ {/* Expanded content with connecting line */} +
+ {/* Curved connecting line - vertical part from summary */} +
+ {/* Curved corner */} +
+ {/* Horizontal part to content */} +
+
{children}
+
); } diff --git a/packages/web-shared/src/sidebar/events-list.tsx b/packages/web-shared/src/sidebar/events-list.tsx index b03f1515d..02136c03a 100644 --- a/packages/web-shared/src/sidebar/events-list.tsx +++ b/packages/web-shared/src/sidebar/events-list.tsx @@ -94,25 +94,52 @@ export function EventsList({ } > -
+ {/* Bordered container with separator */} +
{Object.entries(event.attributes) .filter(([key]) => key !== 'eventData') .map(([key, value]) => ( - +
+ + {key} + + + {String(value)} + +
))} -
- {eventError &&
Error loading event data
} - {!eventError && - !eventsLoading && - event.attributes.eventData && ( - - )} -
+ {/* Event data section */} + {eventError && ( +
+ Error loading event data +
+ )} + {!eventError && !eventsLoading && event.attributes.eventData && ( +
+ +
+ )} ))}
diff --git a/packages/web-shared/src/trace-viewer/components/span-detail-panel.tsx b/packages/web-shared/src/trace-viewer/components/span-detail-panel.tsx index 4b43e6f38..ded81fede 100644 --- a/packages/web-shared/src/trace-viewer/components/span-detail-panel.tsx +++ b/packages/web-shared/src/trace-viewer/components/span-detail-panel.tsx @@ -262,17 +262,17 @@ export function SpanDetailPanel({ {attached && !isMobile ? : null}
-
+ {/* Name/ID first */} + + {span.name} + + {/* Right side: duration badge, separator, close */} +
{selected.isInstrumentationHint ? null : ( {formatDuration(selected.duration)} )} - - {span.name} - -
-
diff --git a/packages/web-shared/src/trace-viewer/trace-viewer.module.css b/packages/web-shared/src/trace-viewer/trace-viewer.module.css index 1ce4876c2..816b2084f 100644 --- a/packages/web-shared/src/trace-viewer/trace-viewer.module.css +++ b/packages/web-shared/src/trace-viewer/trace-viewer.module.css @@ -324,7 +324,7 @@ .spanDetailPanel { display: flex; flex-direction: column; - height: var(--panel-height); + height: 100%; align-items: stretch; justify-content: flex-start; flex: 0 1 auto; @@ -360,22 +360,23 @@ align-items: stretch; justify-content: flex-start; gap: 12px; - flex: 0 1 100%; + flex: 1 1 0; + min-height: 0; padding: 16px 12px 24px; overflow-x: hidden; overflow-y: auto; } .spanDetailPanelTopInfo { - --row-height: 40px; + --row-height: 32px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; - gap: 12px; - padding: 12px 16px; + gap: 8px; + padding: 8px 12px; width: 100%; - font-size: 14px; + font-size: 12px; border-bottom: 1px solid var(--border-color); flex: 0 1 auto; } @@ -390,26 +391,32 @@ display: flex; flex-direction: row; align-items: center; - gap: 12px; + gap: 8px; flex: 0 1 100%; overflow: hidden; justify-content: flex-start; } .spanDetailPanelName { - flex: 0 1 100%; - font-size: 14px; + flex: 1 1 0; + font-size: 12px; + font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--ds-gray-1000); } .spanDetailPanelDuration { font-family: var(--font-mono); - font-size: 13px; + font-size: 10px; font-variant-numeric: tabular-nums; - color: var(--ds-gray-900); + color: var(--ds-gray-600); + background-color: var(--ds-gray-200); + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; } .spanDetailPanelCorner { @@ -419,7 +426,7 @@ gap: 8px; flex: 0 0 auto; height: var(--row-height); - justify-content: flex-start; + justify-content: flex-end; } .spanDetailPanelCornerInner { @@ -431,19 +438,19 @@ } .spanDetailPanelCloseVerticalRule { - border-left: 1px solid var(--accents-2); - height: var(--geist-space-gap); + border-left: 1px solid var(--ds-gray-300); + height: 16px; } .spanDetailPanelClose { display: flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 24px; + height: 24px; background: none; border: none; - border-radius: 6px; + border-radius: 4px; cursor: pointer; &:hover, @@ -1146,8 +1153,8 @@ margin-top: -1px; right: 0; top: calc(var(--search-height) + 2 * var(--search-gap) + var(--map-height) + 9px); + bottom: 0; width: var(--panel-width); - height: var(--panel-height); border-top: 1px solid var(--ds-gray-200); border-left: 1px solid var(--ds-gray-200); background-color: var(--geist-background); @@ -1159,7 +1166,6 @@ &.mobile { --panel-width: calc(var(--timeline-width) + 2 * var(--timeline-padding)); - --panel-height: var(--height); top: 0; border-left: none; } diff --git a/packages/web/package.json b/packages/web/package.json index f46a757d2..641de4da7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,11 +26,16 @@ "format": "biome format --write" }, "dependencies": { - "next": "15.5.4" + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-tabs": "^1.1.13", + "@xyflow/react": "12.9.3", + "next": "15.5.4", + "nuqs": "^2.2.5" }, "devDependencies": { "@biomejs/biome": "catalog:", "@radix-ui/react-alert-dialog": "1.1.5", + "@radix-ui/react-dropdown-menu": "2.1.6", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-slot": "1.1.1", @@ -41,8 +46,8 @@ "@types/react": "19", "@types/react-dom": "19", "@workflow/core": "workspace:*", - "@workflow/world": "workspace:*", "@workflow/web-shared": "workspace:*", + "@workflow/world": "workspace:*", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "date-fns": "4.1.0", diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index eba38b97a..7d6c3cb3e 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%; + } } 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/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/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/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/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 && ( )} +
+
+ + + {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}
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/stream-detail-view.tsx b/packages/web/src/components/stream-detail-view.tsx index 4a4db95e0..8a206ecc5 100644 --- a/packages/web/src/components/stream-detail-view.tsx +++ b/packages/web/src/components/stream-detail-view.tsx @@ -1,12 +1,11 @@ 'use client'; -import { readStream } from '@workflow/web-shared'; -import type { EnvMap } from '@workflow/web-shared/server'; -import { useParams } from 'next/navigation'; +import { type EnvMap, readStream } from '@workflow/web-shared'; +import { ChevronLeft } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { buildUrlWithConfig } from '@/lib/config'; -import { BackLink } from './display-utils/back-link'; interface StreamDetailViewProps { env: EnvMap; @@ -19,8 +18,7 @@ interface Chunk { } export function StreamDetailView({ env, streamId }: StreamDetailViewProps) { - const params = useParams(); - const runId = params.runId as string | undefined; + const router = useRouter(); const [chunks, setChunks] = useState([]); const [isLive, setIsLive] = useState(true); const [error, setError] = useState(null); @@ -105,14 +103,17 @@ export function StreamDetailView({ env, streamId }: StreamDetailViewProps) { }; }, [env, streamId]); - // Determine back link - if we have a runId, go back to the run detail page, otherwise go home - const backHref = runId - ? buildUrlWithConfig(`/run/${runId}`, env) - : buildUrlWithConfig('/', env); - return (
    - + 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/breadcrumb.tsx b/packages/web/src/components/ui/breadcrumb.tsx new file mode 100644 index 000000000..0c90f7613 --- /dev/null +++ b/packages/web/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { ChevronRight, MoreHorizontal } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return