diff --git a/components/backend/go.mod b/components/backend/go.mod index 69050d560..602d4daa7 100644 --- a/components/backend/go.mod +++ b/components/backend/go.mod @@ -16,6 +16,7 @@ require ( ) require ( + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect diff --git a/components/backend/go.sum b/components/backend/go.sum index 34f4ab619..76280deed 100644 --- a/components/backend/go.sum +++ b/components/backend/go.sum @@ -1,3 +1,5 @@ +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go index 57b4462b0..39b21de18 100644 --- a/components/backend/handlers/content.go +++ b/components/backend/handlers/content.go @@ -4,16 +4,19 @@ import ( "context" "encoding/base64" "encoding/json" + "fmt" "log" "net/http" "os" "os/exec" "path/filepath" + "sort" "strings" "time" "ambient-code-backend/git" + "github.com/bmatcuk/doublestar/v4" "github.com/gin-gonic/gin" ) @@ -21,6 +24,12 @@ import ( // Set by main during initialization var StateBaseDir string +// MaxResultFileSize is the maximum size for result files to prevent memory issues +const MaxResultFileSize = 10 * 1024 * 1024 // 10MB + +// MaxGlobMatches limits the number of files that can be matched to prevent resource exhaustion +const MaxGlobMatches = 100 + // Git operation functions - set by main package during initialization // These are set to the actual implementations from git package var ( @@ -472,8 +481,8 @@ func ContentWorkflowMetadata(c *gin.Context) { log.Printf("ContentWorkflowMetadata: session=%q", sessionName) - // Find active workflow directory - workflowDir := findActiveWorkflowDir(sessionName) + // Find active workflow directory (no workflow name provided, will search) + workflowDir := findActiveWorkflowDir(sessionName, "") if workflowDir == "" { log.Printf("ContentWorkflowMetadata: no active workflow found for session=%q", sessionName) c.JSON(http.StatusOK, gin.H{ @@ -601,10 +610,11 @@ func parseFrontmatter(filePath string) map[string]string { // AmbientConfig represents the ambient.json configuration type AmbientConfig struct { - Name string `json:"name"` - Description string `json:"description"` - SystemPrompt string `json:"systemPrompt"` - ArtifactsDir string `json:"artifactsDir"` + Name string `json:"name"` + Description string `json:"description"` + SystemPrompt string `json:"systemPrompt"` + ArtifactsDir string `json:"artifactsDir"` + Results map[string]string `json:"results,omitempty"` // displayName -> glob pattern } // parseAmbientConfig reads and parses ambient.json from workflow directory @@ -640,24 +650,293 @@ func parseAmbientConfig(workflowDir string) *AmbientConfig { return &config } +// ResultFile represents a workflow result file +type ResultFile struct { + DisplayName string `json:"displayName"` + Path string `json:"path"` // Relative path from workspace + Exists bool `json:"exists"` + Content string `json:"content,omitempty"` + Error string `json:"error,omitempty"` +} + +// listArtifactsFiles lists all files in the artifacts directory +func listArtifactsFiles(artifactsDir string) []ResultFile { + results := []ResultFile{} + + // Check if artifacts directory exists + if _, err := os.Stat(artifactsDir); os.IsNotExist(err) { + log.Printf("listArtifactsFiles: artifacts directory %q does not exist", artifactsDir) + return results + } + + // Walk the artifacts directory recursively + err := filepath.Walk(artifactsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + log.Printf("listArtifactsFiles: error accessing %q: %v", path, err) + return nil // Continue walking + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Get relative path from artifacts directory + relPath, err := filepath.Rel(artifactsDir, path) + if err != nil { + log.Printf("listArtifactsFiles: failed to get relative path for %q: %v", path, err) + return nil + } + + // Use filename as display name + displayName := filepath.Base(relPath) + + result := ResultFile{ + DisplayName: displayName, + Path: relPath, + Exists: true, + } + + // Check file size before reading + if info.Size() > MaxResultFileSize { + result.Error = fmt.Sprintf("File too large (%d bytes, max %d)", info.Size(), MaxResultFileSize) + results = append(results, result) + return nil + } + + // Read file content + content, readErr := os.ReadFile(path) + if readErr != nil { + result.Error = fmt.Sprintf("Failed to read: %v", readErr) + } else { + result.Content = string(content) + } + + results = append(results, result) + return nil + }) + + if err != nil { + log.Printf("listArtifactsFiles: error walking artifacts directory %q: %v", artifactsDir, err) + } + + // Sort results by path for consistent order + sort.Slice(results, func(i, j int) bool { + return results[i].Path < results[j].Path + }) + + return results +} + +// ContentWorkflowResults handles GET /content/workflow-results?session=&workflow= +func ContentWorkflowResults(c *gin.Context) { + sessionName := c.Query("session") + if sessionName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing session parameter"}) + return + } + + // Get workflow name from query parameter (if provided from CR) + workflowName := c.Query("workflow") + workflowDir := findActiveWorkflowDir(sessionName, workflowName) + if workflowDir == "" { + // No workflow found - return files from artifacts folder at root + workspaceBase := filepath.Join(StateBaseDir, "sessions", sessionName, "workspace") + artifactsDir := filepath.Join(workspaceBase, "artifacts") + log.Printf("ContentWorkflowResults: no workflow found, listing artifacts from %q", artifactsDir) + results := listArtifactsFiles(artifactsDir) + c.JSON(http.StatusOK, gin.H{"results": results}) + return + } + + ambientConfig := parseAmbientConfig(workflowDir) + if len(ambientConfig.Results) == 0 { + c.JSON(http.StatusOK, gin.H{"results": []ResultFile{}}) + return + } + + workspaceBase := filepath.Join(StateBaseDir, "sessions", sessionName, "workspace") + results := []ResultFile{} + + // Sort keys to ensure consistent order (maps are unordered in Go) + displayNames := make([]string, 0, len(ambientConfig.Results)) + for displayName := range ambientConfig.Results { + displayNames = append(displayNames, displayName) + } + sort.Strings(displayNames) + + for _, displayName := range displayNames { + pattern := ambientConfig.Results[displayName] + matches, err := findMatchingFiles(workspaceBase, pattern) + + if err != nil { + results = append(results, ResultFile{ + DisplayName: displayName, + Path: pattern, + Exists: false, + Error: fmt.Sprintf("Pattern error: %v", err), + }) + continue + } + + if len(matches) == 0 { + results = append(results, ResultFile{ + DisplayName: displayName, + Path: pattern, + Exists: false, + }) + } else { + // Sort matches for consistent order + sort.Strings(matches) + + for _, matchedPath := range matches { + relPath, _ := filepath.Rel(workspaceBase, matchedPath) + + result := ResultFile{ + DisplayName: displayName, + Path: relPath, + Exists: true, + } + + // Check file size before reading + fileInfo, statErr := os.Stat(matchedPath) + if statErr != nil { + result.Error = fmt.Sprintf("Failed to stat file: %v", statErr) + results = append(results, result) + continue + } + + if fileInfo.Size() > MaxResultFileSize { + result.Error = fmt.Sprintf("File too large (%d bytes, max %d)", fileInfo.Size(), MaxResultFileSize) + results = append(results, result) + continue + } + + // Read file content + content, readErr := os.ReadFile(matchedPath) + if readErr != nil { + result.Error = fmt.Sprintf("Failed to read: %v", readErr) + } else { + result.Content = string(content) + } + + results = append(results, result) + } + } + } + + c.JSON(http.StatusOK, gin.H{"results": results}) +} + +// findMatchingFiles finds files matching a glob pattern with ** support for recursive matching +// Returns matched files and an error if validation fails or too many matches found +func findMatchingFiles(baseDir, pattern string) ([]string, error) { + // Validate baseDir is absolute and exists + if !filepath.IsAbs(baseDir) { + return nil, fmt.Errorf("baseDir must be absolute path") + } + + baseInfo, err := os.Stat(baseDir) + if err != nil { + return nil, fmt.Errorf("baseDir does not exist: %w", err) + } + if !baseInfo.IsDir() { + return nil, fmt.Errorf("baseDir is not a directory") + } + + // Use doublestar for glob matching with ** support + fsys := os.DirFS(baseDir) + matches, err := doublestar.Glob(fsys, pattern) + if err != nil { + return nil, fmt.Errorf("glob pattern error: %w", err) + } + + // Enforce match limit to prevent resource exhaustion + if len(matches) > MaxGlobMatches { + log.Printf("findMatchingFiles: pattern %q matched %d files, limiting to %d", pattern, len(matches), MaxGlobMatches) + matches = matches[:MaxGlobMatches] + } + + // Convert relative paths to absolute paths and validate they stay within baseDir + var absolutePaths []string + baseDirAbs, err := filepath.Abs(baseDir) + if err != nil { + return nil, fmt.Errorf("failed to resolve baseDir: %w", err) + } + + for _, match := range matches { + // Join and clean the path + absPath := filepath.Join(baseDirAbs, match) + absPath = filepath.Clean(absPath) + + // Security: Ensure resolved path stays within baseDir (prevent directory traversal) + relPath, err := filepath.Rel(baseDirAbs, absPath) + if err != nil { + log.Printf("findMatchingFiles: failed to compute relative path for %q: %v", absPath, err) + continue + } + + // Check for directory traversal attempts (paths like "../" or starting with "../") + if strings.HasPrefix(relPath, "..") { + log.Printf("findMatchingFiles: rejected path traversal attempt: %q", absPath) + continue + } + + absolutePaths = append(absolutePaths, absPath) + } + + return absolutePaths, nil +} + // findActiveWorkflowDir finds the active workflow directory for a session -func findActiveWorkflowDir(sessionName string) string { +// If workflowName is provided, it uses that directly; otherwise searches for it +func findActiveWorkflowDir(sessionName, workflowName string) string { // Workflows are stored at {StateBaseDir}/sessions/{session-name}/workspace/workflows/{workflow-name} // The runner creates this nested structure workflowsBase := filepath.Join(StateBaseDir, "sessions", sessionName, "workspace", "workflows") + // If workflow name is provided, use it directly + if workflowName != "" { + workflowPath := filepath.Join(workflowsBase, workflowName) + // Verify it exists and has either .claude or .ambient/ambient.json + claudeDir := filepath.Join(workflowPath, ".claude") + ambientConfig := filepath.Join(workflowPath, ".ambient", "ambient.json") + + if stat, err := os.Stat(claudeDir); err == nil && stat.IsDir() { + return workflowPath + } + if stat, err := os.Stat(ambientConfig); err == nil && !stat.IsDir() { + log.Printf("findActiveWorkflowDir: found workflow via ambient.json: %q", workflowPath) + return workflowPath + } + // If direct path doesn't work, fall through to search + log.Printf("findActiveWorkflowDir: workflow %q not found at expected path, searching...", workflowName) + } + + // Search for workflow directory (fallback when workflowName not provided) entries, err := os.ReadDir(workflowsBase) if err != nil { log.Printf("findActiveWorkflowDir: failed to read workflows directory %q: %v", workflowsBase, err) return "" } - // Find first directory that has .claude subdirectory (excluding temp clones) + // Find first directory that has .claude subdirectory OR .ambient/ambient.json (excluding temp clones) + // Check for .ambient/ambient.json as fallback for temp content pods when main runner isn't running for _, entry := range entries { if entry.IsDir() && entry.Name() != "default" && !strings.HasSuffix(entry.Name(), "-clone-temp") { - claudeDir := filepath.Join(workflowsBase, entry.Name(), ".claude") + workflowPath := filepath.Join(workflowsBase, entry.Name()) + + // Check for .claude subdirectory (preferred, indicates active runner) + claudeDir := filepath.Join(workflowPath, ".claude") if stat, err := os.Stat(claudeDir); err == nil && stat.IsDir() { - return filepath.Join(workflowsBase, entry.Name()) + return workflowPath + } + + // Fallback: check for .ambient/ambient.json (works from temp content pod) + ambientConfig := filepath.Join(workflowPath, ".ambient", "ambient.json") + if stat, err := os.Stat(ambientConfig); err == nil && !stat.IsDir() { + log.Printf("findActiveWorkflowDir: found workflow via ambient.json: %q", workflowPath) + return workflowPath } } } diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 9cee44f7a..9abc13ffc 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -1329,6 +1329,132 @@ func GetWorkflowMetadata(c *gin.Context) { c.Data(resp.StatusCode, "application/json", b) } +// deriveWorkflowNameFromURL derives the workflow directory name from a git URL +// This matches the logic used by the runner (wrapper.py) +func deriveWorkflowNameFromURL(gitURL string) string { + if gitURL == "" { + return "" + } + + // Try to parse as URL first + parsedURL, err := url.Parse(gitURL) + if err == nil && parsedURL.Host != "" { + // Extract repo name from path (e.g., /owner/repo.git -> repo) + path := strings.TrimPrefix(parsedURL.Path, "/") + parts := strings.FieldsFunc(path, func(r rune) bool { + return r == '/' + }) + if len(parts) > 0 { + repoName := parts[len(parts)-1] + repoName = strings.TrimSuffix(repoName, ".git") + repoName = strings.TrimSpace(repoName) + if repoName != "" { + return repoName + } + } + } + + // Fallback: extract from path-like string + parts := strings.FieldsFunc(gitURL, func(r rune) bool { + return r == '/' || r == ':' + }) + if len(parts) > 0 { + repoName := parts[len(parts)-1] + repoName = strings.TrimSuffix(repoName, ".git") + repoName = strings.TrimSpace(repoName) + if repoName != "" { + return repoName + } + } + + return "" +} + +// GetWorkflowResults retrieves workflow result files from the active workflow +// GET /api/projects/:projectName/agentic-sessions/:sessionName/workflow/results +func GetWorkflowResults(c *gin.Context) { + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + if project == "" { + log.Printf("GetWorkflowResults: project is empty, session=%s", sessionName) + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) + return + } + + // Get authorization token + token := c.GetHeader("Authorization") + if strings.TrimSpace(token) == "" { + token = c.GetHeader("X-Forwarded-Access-Token") + } + + // Read AgenticSession CR to get activeWorkflow + var workflowName string + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn != nil { + gvr := GetAgenticSessionV1Alpha1Resource() + item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) + if err == nil { + // Extract activeWorkflow from spec + if spec, ok := item.Object["spec"].(map[string]interface{}); ok { + if workflow, ok := spec["activeWorkflow"].(map[string]interface{}); ok { + if gitURL, ok := workflow["gitUrl"].(string); ok && gitURL != "" { + // Derive workflow name from git URL (matches runner logic) + workflowName = deriveWorkflowNameFromURL(gitURL) + if workflowName != "" { + log.Printf("GetWorkflowResults: derived workflow name %q from gitUrl %q", workflowName, gitURL) + } + } + } + } + } else { + log.Printf("GetWorkflowResults: failed to get session CR (non-fatal): %v", err) + } + } + + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", sessionName) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { + // Temp service doesn't exist, use regular service + serviceName = fmt.Sprintf("ambient-content-%s", sessionName) + } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", sessionName) + } + + // Build URL to content service with workflow parameter if available + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + u := fmt.Sprintf("%s/content/workflow-results?session=%s", endpoint, sessionName) + if workflowName != "" { + u = fmt.Sprintf("%s&workflow=%s", u, workflowName) + } + + log.Printf("GetWorkflowResults: project=%s session=%s endpoint=%s workflow=%s", project, sessionName, endpoint, workflowName) + + // Create and send request to content pod + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, u, nil) + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", token) + } + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("GetWorkflowResults: content service request failed: %v", err) + // Return empty results on error + c.JSON(http.StatusOK, gin.H{"results": []interface{}{}}) + return + } + defer resp.Body.Close() + + b, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, "application/json", b) +} + // fetchGitHubFileContent fetches a file from GitHub via API // token is optional - works for public repos without authentication (but has rate limits) func fetchGitHubFileContent(ctx context.Context, owner, repo, ref, path, token string) ([]byte, error) { diff --git a/components/backend/routes.go b/components/backend/routes.go index b4a28b1ff..a98e91dde 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -18,6 +18,7 @@ func registerContentRoutes(r *gin.Engine) { r.POST("/content/git-configure-remote", handlers.ContentGitConfigureRemote) r.POST("/content/git-sync", handlers.ContentGitSync) r.GET("/content/workflow-metadata", handlers.ContentWorkflowMetadata) + r.GET("/content/workflow-results", handlers.ContentWorkflowResults) r.GET("/content/git-merge-status", handlers.ContentGitMergeStatus) r.POST("/content/git-pull", handlers.ContentGitPull) r.POST("/content/git-push", handlers.ContentGitPushToBranch) @@ -74,6 +75,7 @@ func registerRoutes(r *gin.Engine) { projectGroup.DELETE("/agentic-sessions/:sessionName/content-pod", handlers.DeleteContentPod) projectGroup.POST("/agentic-sessions/:sessionName/workflow", handlers.SelectWorkflow) projectGroup.GET("/agentic-sessions/:sessionName/workflow/metadata", handlers.GetWorkflowMetadata) + projectGroup.GET("/agentic-sessions/:sessionName/workflow/results", handlers.GetWorkflowResults) projectGroup.POST("/agentic-sessions/:sessionName/repos", handlers.AddRepo) projectGroup.DELETE("/agentic-sessions/:sessionName/repos/:repoName", handlers.RemoveRepo) diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json index 247672243..8b5c6bda1 100644 --- a/components/frontend/package-lock.json +++ b/components/frontend/package-lock.json @@ -41,6 +41,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.19", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -2454,6 +2455,19 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tanstack/query-core": { "version": "5.90.7", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.7.tgz", @@ -3743,6 +3757,19 @@ "node": ">= 8" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -7500,6 +7527,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8923,6 +8964,13 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/components/frontend/package.json b/components/frontend/package.json index f5931698c..1fbd73eed 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -42,6 +42,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.19", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workflow/results/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workflow/results/route.ts new file mode 100644 index 000000000..5b4a48ec6 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workflow/results/route.ts @@ -0,0 +1,17 @@ +import { BACKEND_URL } from '@/lib/config'; +import { buildForwardHeadersAsync } from '@/lib/auth'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ name: string; sessionName: string }> }, +) { + const { name, sessionName } = await params; + const headers = await buildForwardHeadersAsync(request); + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/workflow/results`, + { headers } + ); + const data = await resp.text(); + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }); +} + diff --git a/components/frontend/src/app/globals.css b/components/frontend/src/app/globals.css index 51ac5e9b6..7ab698355 100644 --- a/components/frontend/src/app/globals.css +++ b/components/frontend/src/app/globals.css @@ -120,6 +120,76 @@ body { @apply bg-background text-foreground; } + + /* Scrollbar styling with dark mode support */ + * { + scrollbar-width: thin; + scrollbar-color: oklch(0.7 0 0) oklch(0.95 0 0); + } + + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + *::-webkit-scrollbar-track { + background: oklch(0.95 0 0); + } + + *::-webkit-scrollbar-thumb { + background: oklch(0.7 0 0); + border-radius: 4px; + } + + *::-webkit-scrollbar-thumb:hover { + background: oklch(0.6 0 0); + } + + .dark * { + scrollbar-color: oklch(0.4 0 0) oklch(0.2 0 0); + } + + .dark *::-webkit-scrollbar-track { + background: oklch(0.2 0 0); + } + + .dark *::-webkit-scrollbar-thumb { + background: oklch(0.4 0 0); + } + + .dark *::-webkit-scrollbar-thumb:hover { + background: oklch(0.5 0 0); + } + + /* Checkbox styling for markdown - uses theme colors */ + article input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 1rem; + height: 1rem; + border: 1px solid var(--border); + border-radius: 0.25rem; + background-color: var(--background); + cursor: not-allowed; + position: relative; + } + + article input[type="checkbox"]:checked { + background-color: var(--primary); + border-color: var(--primary); + } + + article input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 0.25rem; + top: 0.1rem; + width: 0.375rem; + height: 0.625rem; + border: solid var(--primary-foreground); + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } } /* Thin scrollbar styling */ diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/artifacts-accordion.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/artifacts-accordion.tsx deleted file mode 100644 index 96c7c40ce..000000000 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/artifacts-accordion.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { Folder, NotepadText, Download, FolderSync, Loader2 } from "lucide-react"; -import { AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; -import { Button } from "@/components/ui/button"; -import { FileTree, type FileTreeNode } from "@/components/file-tree"; - -type WorkspaceFile = { - name: string; - path: string; - isDir: boolean; - size?: number; -}; - -type ArtifactsAccordionProps = { - files: WorkspaceFile[]; - currentSubPath: string; - viewingFile: { path: string; content: string } | null; - isLoadingFile: boolean; - onFileOrFolderSelect: (node: FileTreeNode) => void; - onRefresh: () => void; - onDownloadFile: () => void; - onNavigateBack: () => void; -}; - -export function ArtifactsAccordion({ - files, - currentSubPath, - viewingFile, - isLoadingFile, - onFileOrFolderSelect, - onRefresh, - onDownloadFile, - onNavigateBack, -}: ArtifactsAccordionProps) { - return ( - - -
- - Artifacts -
-
- -
-

- Artifacts created by the AI will be added here. -

- - {/* File Browser for Artifacts */} -
- {/* Header with breadcrumbs and actions */} -
-
- {/* Back button when in subfolder or viewing file */} - {(currentSubPath || viewingFile) && ( - - )} - - {/* Breadcrumb path */} - - - artifacts - {currentSubPath && `/${currentSubPath}`} - {viewingFile && `/${viewingFile.path}`} - -
- - {/* Action buttons */} - {viewingFile ? ( - /* Download button when viewing file */ - - ) : ( - /* Refresh button when not viewing file */ - - )} -
- - {/* Content area */} -
- {isLoadingFile ? ( -
- -
- ) : viewingFile ? ( - /* File content view */ -
-
-                    {viewingFile.content}
-                  
-
- ) : files.length === 0 ? ( - /* Empty state */ -
- -

No artifacts yet

-

AI-generated artifacts will appear here

-
- ) : ( - /* File tree */ - ({ - name: item.name, - path: item.path, - type: item.isDir ? 'folder' : 'file', - sizeKb: item.size ? item.size / 1024 : undefined, - }))} - onSelect={onFileOrFolderSelect} - /> - )} -
-
-
-
-
- ); -} - diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index 54b1d8be1..a95f4490e 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -1,8 +1,10 @@ "use client"; import { useState, useEffect, useMemo, useRef } from "react"; -import { Loader2, FolderTree, GitBranch, Edit, RefreshCw, Folder, Sparkles, X, CloudUpload, CloudDownload, MoreVertical, Cloud, FolderSync, Download, LibraryBig, MessageSquare } from "lucide-react"; +import { Loader2, FolderTree, AlertCircle, GitBranch, Edit, RefreshCw, Folder, Sparkles, X, CloudUpload, CloudDownload, MoreVertical, Cloud, FolderSync, Download, FileCheck, CheckCircle2, Clock, LibraryBig, MessageSquare } from "lucide-react"; import { useRouter } from "next/navigation"; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; // Custom components import MessagesTab from "@/components/session/MessagesTab"; @@ -27,7 +29,6 @@ import { ManageRemoteDialog } from "./components/modals/manage-remote-dialog"; import { CommitChangesDialog } from "./components/modals/commit-changes-dialog"; import { WorkflowsAccordion } from "./components/accordions/workflows-accordion"; import { RepositoriesAccordion } from "./components/accordions/repositories-accordion"; -import { ArtifactsAccordion } from "./components/accordions/artifacts-accordion"; // Extracted hooks and utilities import { useGitOperations } from "./hooks/use-git-operations"; @@ -52,7 +53,8 @@ import { } from "@/services/queries"; import { useWorkspaceList, useGitMergeStatus, useGitListBranches } from "@/services/queries/use-workspace"; import { successToast, errorToast } from "@/hooks/use-toast"; -import { useOOTBWorkflows, useWorkflowMetadata } from "@/services/queries/use-workflows"; +import { useOOTBWorkflows, useWorkflowMetadata, useWorkflowResults } from "@/services/queries/use-workflows"; +import { cn } from "@/lib/utils"; import { useMutation } from "@tanstack/react-query"; export default function ProjectSessionDetailPage({ @@ -75,6 +77,13 @@ export default function ProjectSessionDetailPage({ const [repoChanging, setRepoChanging] = useState(false); const [firstMessageLoaded, setFirstMessageLoaded] = useState(false); + // Tabbed interface state + const [openTabs, setOpenTabs] = useState>([{id: 'chat', name: 'Chat', path: '', content: ''}]); + const [activeTab, setActiveTab] = useState('chat'); + + // Maximum number of open tabs to prevent memory issues + const MAX_TABS = 10; + // Directory browser state (unified for artifacts, repos, and workflow) const [selectedDirectory, setSelectedDirectory] = useState({ type: 'artifacts', @@ -204,6 +213,12 @@ export default function ProjectSessionDetailPage({ !!workflowManagement.activeWorkflow && !workflowManagement.workflowActivating ); + // Fetch workflow results + const { data: workflowResults } = useWorkflowResults( + projectName, + sessionName + ); + // Git operations for selected directory const currentRemote = directoryRemotes[selectedDirectory.path]; const { data: mergeStatus, refetch: refetchMergeStatus } = useGitMergeStatus( @@ -242,20 +257,6 @@ export default function ProjectSessionDetailPage({ { enabled: openAccordionItems.includes("directories") } ); - // Artifacts file operations - const artifactsOps = useFileOperations({ - projectName, - sessionName, - basePath: 'artifacts', - }); - - const { data: artifactsFiles = [], refetch: refetchArtifactsFiles } = useWorkspaceList( - projectName, - sessionName, - artifactsOps.currentSubPath ? `artifacts/${artifactsOps.currentSubPath}` : 'artifacts', - { enabled: openAccordionItems.includes("artifacts") } - ); - // Track if we've already initialized from session const initializedFromSessionRef = useRef(false); @@ -305,6 +306,49 @@ export default function ProjectSessionDetailPage({ initializedFromSessionRef.current = true; }, [session, ootbWorkflows, workflowManagement]); + // Memoize results by path for efficient lookup + const resultsByPath = useMemo(() => { + if (!workflowResults?.results) return new Map(); + const map = new Map(); + workflowResults.results.forEach(r => { + if (r.exists && r.content) { + map.set(r.path, r.content); + } + }); + return map; + }, [workflowResults?.results]); + + // Sync open tabs with latest workflow results (auto-refresh tab content) + // Only updates when content actually changes, not on every poll + useEffect(() => { + if (resultsByPath.size === 0) return; + + setOpenTabs(prevTabs => { + let hasChanges = false; + const updatedTabs = prevTabs.map(tab => { + // Don't update chat tab + if (tab.id === 'chat') return tab; + + // Get latest content for this path + const latestContent = resultsByPath.get(tab.path); + + // Only update if content actually changed + if (latestContent && latestContent !== tab.content) { + hasChanges = true; + return { + ...tab, + content: latestContent + }; + } + + return tab; + }); + + // Only trigger state update if something actually changed + return hasChanges ? updatedTabs : prevTabs; + }); + }, [resultsByPath]); + // Compute directory options const directoryOptions = useMemo(() => { const options: DirectoryOption[] = [ @@ -550,8 +594,8 @@ export default function ProjectSessionDetailPage({ return (
-
- Loading session... +
+ Loading session...
); @@ -592,54 +636,54 @@ export default function ProjectSessionDetailPage({
{/* Fixed header */}
-
- - + + -
+ onDelete={handleDelete} + durationMs={durationMs} + k8sResources={k8sResources} + messageCount={messages.length} + />
+
{/* Main content area */}
- {/* Left Column - Accordions */} + {/* Left Column - Accordions */}
{/* Blocking overlay when first message hasn't loaded and session is pending */} {!firstMessageLoaded && session?.status?.phase === 'Pending' && (
-
+

No context yet

-
-

Context will appear once the session starts...

-
+

Context will appear once the session starts...

+
+
)}
@@ -668,289 +712,278 @@ export default function ProjectSessionDetailPage({ onRemoveRepository={(repoName) => removeRepoMutation.mutate(repoName)} /> - - {/* Experimental - File Explorer */} - -
+ +
Experimental -
-
- -
+
+ + +
- -
- - File Explorer + +
+ + File Explorer {gitOps.gitStatus?.hasChanges && ( -
+
{(gitOps.gitStatus?.totalAdded ?? 0) > 0 && ( - + +{gitOps.gitStatus.totalAdded} - - )} + + )} {(gitOps.gitStatus?.totalRemoved ?? 0) > 0 && ( - + -{gitOps.gitStatus.totalRemoved} - - )} -
- )} -
- - -
-

- Browse, view, and manage files in your workspace directories. Track changes and sync with Git for version control. -

- - {/* Directory Selector */} -
- - -
- + + )} +
+ )} +
+
+ +
+

+ Browse, view, and manage files in your workspace directories. Track changes and sync with Git for version control. +

+ + {/* Directory Selector */} +
+ + +
+ {/* File Browser */} -
-
-
+
+
+
{(fileOps.currentSubPath || fileOps.viewingFile) && ( - - )} - - - - {selectedDirectory.path} + className="h-6 px-1.5 mr-1" + > + ← Back + + )} + + + + {selectedDirectory.path} {fileOps.currentSubPath && `/${fileOps.currentSubPath}`} {fileOps.viewingFile && `/${fileOps.viewingFile.path}`} - -
+ +
{fileOps.viewingFile ? ( -
- - - - - - - - Sync to Jira - Coming soon - - - Sync to GDrive - Coming soon - - - -
- ) : ( - - )} -
- -
+ className="h-6 px-2 flex-shrink-0" + title="Download file" + > + + + + + + + + + Sync to Jira - Coming soon + + + Sync to GDrive - Coming soon + + + +
+ ) : ( + + )} +
+ +
{fileOps.loadingFile ? ( -
- -
+
+ +
) : fileOps.viewingFile ? ( -
-
+                          
+
                                             {fileOps.viewingFile.content}
-                                          
-
- ) : directoryFiles.length === 0 ? ( -
- -

No files yet

-

Files will appear here

-
- ) : ( - ({ - name: item.name, - path: item.path, - type: item.isDir ? 'folder' : 'file', - sizeKb: item.size ? item.size / 1024 : undefined, - }))} +
+
+ ) : directoryFiles.length === 0 ? ( +
+ +

No files yet

+

Files will appear here

+
+ ) : ( + ({ + name: item.name, + path: item.path, + type: item.isDir ? 'folder' : 'file', + sizeKb: item.size ? item.size / 1024 : undefined, + }))} onSelect={fileOps.handleFileOrFolderSelect} - /> - )} -
-
- + /> + )} +
+
+ {/* Remote Configuration */} - {!currentRemote ? ( -
- Set up Git remote for version control + {!currentRemote ? ( +
+ Set up Git remote for version control -
- ) : ( -
-
-
- - - {currentRemote?.url?.split('/').slice(-2).join('/').replace('.git', '') || ''}/{currentRemote?.branch || 'main'} - -
- -
- - {mergeStatus && !mergeStatus.canMergeClean ? ( -
- - conflict -
+ + Configure + +
+ ) : ( +
+
+
+ + + {currentRemote?.url?.split('/').slice(-2).join('/').replace('.git', '') || ''}/{currentRemote?.branch || 'main'} + +
+ +
+ + {mergeStatus && !mergeStatus.canMergeClean ? ( +
+ + conflict +
) : (gitOps.gitStatus?.hasChanges || mergeStatus?.remoteCommitsAhead) ? ( -
- {mergeStatus?.remoteCommitsAhead ? ( - ↓{mergeStatus.remoteCommitsAhead} - ) : null} +
+ {mergeStatus?.remoteCommitsAhead ? ( + ↓{mergeStatus.remoteCommitsAhead} + ) : null} {gitOps.gitStatus?.hasChanges ? ( {gitOps.gitStatus?.uncommittedFiles ?? 0} uncommitted - ) : null} -
- ) : null} - - - - - - - + + ) : ( + + )} + + +

{gitOps.gitStatus?.hasChanges ? 'Commit changes first' : `Sync with origin/${currentRemote?.branch || 'main'}`}

-
-
-
+ + + - - - - - + + + + + setRemoteDialogOpen(true)}> - - Manage Remote - - - + Manage Remote + + + setCommitModalOpen(true)} disabled={!gitOps.gitStatus?.hasChanges} - > - - Commit Changes - - + + Commit Changes + + gitOps.handleGitPull(refetchMergeStatus)} disabled={!mergeStatus?.canMergeClean || gitOps.isPulling} - > - - Pull - - + + Pull + + gitOps.handleGitPush(refetchMergeStatus)} disabled={!mergeStatus?.canMergeClean || gitOps.isPushing || gitOps.gitStatus?.hasChanges} - > - - Push - - - { - const newRemotes = {...directoryRemotes}; - delete newRemotes[selectedDirectory.path]; - setDirectoryRemotes(newRemotes); - successToast("Git remote disconnected"); - }} - > - - Disconnect - - - -
-
- )} + > + + Push + + + { + const newRemotes = {...directoryRemotes}; + delete newRemotes[selectedDirectory.path]; + setDirectoryRemotes(newRemotes); + successToast("Git remote disconnected"); + }} + > + + Disconnect + + + +
+
+ )}
@@ -958,42 +991,145 @@ export default function ProjectSessionDetailPage({
- + + {/* Results - Workflow Output Files */} + {workflowResults?.results && workflowResults.results.length > 0 && ( + + +
+ + Results + {workflowResults.results.filter(r => r.exists).length > 0 && ( + + {workflowResults.results.filter(r => r.exists).length} + + )} +
+
+ +
+

+ View workflow output files +

+ +
+ {workflowResults.results.map((result, idx) => ( +
{ + if (result.exists && result.content) { + // Use path-based stable ID instead of array index + const tabId = `result-${result.path}`; + + // Check if tab already exists + const existingTab = openTabs.find(t => t.id === tabId); + if (existingTab) { + // Tab exists, just switch to it + setActiveTab(tabId); + return; + } + + // Enforce tab limit - remove oldest non-chat tab if at limit + setOpenTabs(prevTabs => { + const nonChatTabs = prevTabs.filter(t => t.id !== 'chat'); + + let newTabs = [...prevTabs]; + + // If at limit, remove oldest non-chat tab + if (nonChatTabs.length >= MAX_TABS - 1) { + // Remove first non-chat tab (oldest) + const oldestTabId = nonChatTabs[0]?.id; + if (oldestTabId) { + newTabs = newTabs.filter(t => t.id !== oldestTabId); + // If we removed the active tab, switch to chat + if (activeTab === oldestTabId) { + setActiveTab('chat'); + } + } + } + + // Add new tab + newTabs.push({ + id: tabId, + name: result.displayName, + path: result.path, + content: result.content || '' + }); + + return newTabs; + }); + + setActiveTab(tabId); + } + }} + > +
+
+ {result.exists ? ( + + ) : ( + + )} +
+ {result.displayName} +

{result.path}

+
+
+ {result.error && ( + + )} +
+ + {result.error && ( +

{result.error}

+ )} +
+ ))} +
+
+
+
+ )} +
-
+
{/* Right Column - Messages */}
- {/* Workflow activation overlay */} + {/* Workflow activation overlay */} {workflowManagement.workflowActivating && ( -
- - - Activating Workflow... - +
+ + + Activating Workflow... +

The new workflow is being loaded. Please wait...

-
-
-
- )} - - {/* Repository change overlay */} - {repoChanging && ( -
- - - Updating Repositories... - -
+ + +
+ )} + + {/* Repository change overlay */} + {repoChanging && ( +
+ + + Updating Repositories... + +

Please wait while repositories are being updated. This may take 10-20 seconds...

-
-
-
-
- )} - +
+
+
+
+ )} + {/* Session starting overlay */} {!firstMessageLoaded && session?.status?.phase === 'Pending' && (
@@ -1008,24 +1144,182 @@ export default function ProjectSessionDetailPage({
)} + {/* Tabbed Interface */}
- Promise.resolve(sendChat())} - onInterrupt={() => Promise.resolve(handleInterrupt())} - onEndSession={() => Promise.resolve(handleEndSession())} - onGoToResults={() => {}} - onContinue={handleContinue} - selectedAgents={selectedAgents} - autoSelectAgents={autoSelectAgents} - workflowMetadata={workflowMetadata} - onSetSelectedAgents={setSelectedAgents} - onSetAutoSelectAgents={setAutoSelectAgents} - onCommandClick={handleCommandClick} - /> + {(() => { + const fileTabs = openTabs.filter(t => t.id !== 'chat'); + + // If no file tabs, show chat directly without tabs + if (fileTabs.length === 0) { + return ( + Promise.resolve(sendChat())} + onInterrupt={() => Promise.resolve(handleInterrupt())} + onEndSession={() => Promise.resolve(handleEndSession())} + onGoToResults={() => {}} + onContinue={handleContinue} + selectedAgents={selectedAgents} + autoSelectAgents={autoSelectAgents} + workflowMetadata={workflowMetadata} + onSetSelectedAgents={setSelectedAgents} + onSetAutoSelectAgents={setAutoSelectAgents} + onCommandClick={handleCommandClick} + /> + ); + } + + // Show tabs when there are files + return ( + <> + {/* Tab Headers */} +
+ {openTabs.map(tab => ( +
setActiveTab(tab.id)} + > + {tab.name} + {tab.id !== 'chat' && ( + + )} +
+ ))} +
+ + {/* Tab Content */} +
+ {activeTab === 'chat' ? ( + Promise.resolve(sendChat())} + onInterrupt={() => Promise.resolve(handleInterrupt())} + onEndSession={() => Promise.resolve(handleEndSession())} + onGoToResults={() => {}} + onContinue={handleContinue} + selectedAgents={selectedAgents} + autoSelectAgents={autoSelectAgents} + workflowMetadata={workflowMetadata} + onSetSelectedAgents={setSelectedAgents} + onSetAutoSelectAgents={setAutoSelectAgents} + onCommandClick={handleCommandClick} + /> + ) : ( +
+ {(() => { + const tab = openTabs.find(t => t.id === activeTab); + if (!tab) return null; + + const isMarkdown = tab.path.endsWith('.md'); + + return ( +
+
+

{tab.name}

+

{tab.path}

+
+ + {isMarkdown ? ( +
+

{children}

, + h2: ({children}) =>

{children}

, + h3: ({children}) =>

{children}

, + h4: ({children}) =>

{children}

, + p: ({children}) =>

{children}

, + a: ({href, children}) => {children}, + ul: ({children, className}) => { + const isTaskList = className?.includes('contains-task-list'); + return isTaskList ? +
    {children}
: +
    {children}
; + }, + ol: ({children}) =>
    {children}
, + li: ({children, className}) => { + const isTaskItem = className?.includes('task-list-item'); + return isTaskItem ? +
  • {children}
  • : +
  • {children}
  • ; + }, + input: ({type, checked}) => + type === 'checkbox' ? + : + null, + blockquote: ({children}) =>
    {children}
    , + code: ({inline, children}: {inline?: boolean; children?: React.ReactNode}) => + inline ? + {children} : + {children}, + pre: ({children}) =>
    {children}
    , + table: ({children}) => {children}
    , + thead: ({children}) => {children}, + tbody: ({children}) => {children}, + tr: ({children}) => {children}, + th: ({children}) => {children}, + td: ({children}) => {children}, + hr: () =>
    , + strong: ({children}) => {children}, + em: ({children}) => {children}, + del: ({children}) => {children}, + }} + > + {tab.content} +
    +
    + ) : ( +
    +                                            {tab.content}
    +                                          
    + )} +
    + ); + })()} +
    + )} +
    + + ); + })()}
    @@ -1033,7 +1327,7 @@ export default function ProjectSessionDetailPage({
    -
    +
    {/* Modals */} { const success = await gitOps.handleCommit(message); if (success) { - setCommitModalOpen(false); + setCommitModalOpen(false); refetchMergeStatus(); } }} diff --git a/components/frontend/src/services/api/workflows.ts b/components/frontend/src/services/api/workflows.ts index 54d427b7d..737ade472 100644 --- a/components/frontend/src/services/api/workflows.ts +++ b/components/frontend/src/services/api/workflows.ts @@ -60,3 +60,25 @@ export async function getWorkflowMetadata( return response; } +export type ResultFile = { + displayName: string; + path: string; + exists: boolean; + content?: string; + error?: string; +}; + +export type WorkflowResultsResponse = { + results: ResultFile[]; +}; + +export async function getWorkflowResults( + projectName: string, + sessionName: string +): Promise { + const response = await apiClient.get( + `/projects/${projectName}/agentic-sessions/${sessionName}/workflow/results` + ); + return response; +} + diff --git a/components/frontend/src/services/queries/use-workflows.ts b/components/frontend/src/services/queries/use-workflows.ts index 92b075598..9664208fc 100644 --- a/components/frontend/src/services/queries/use-workflows.ts +++ b/components/frontend/src/services/queries/use-workflows.ts @@ -6,6 +6,8 @@ export const workflowKeys = { ootb: (projectName?: string) => [...workflowKeys.all, "ootb", projectName] as const, metadata: (projectName: string, sessionName: string) => [...workflowKeys.all, "metadata", projectName, sessionName] as const, + results: (projectName: string, sessionName: string) => + [...workflowKeys.all, "results", projectName, sessionName] as const, }; export function useOOTBWorkflows(projectName?: string) { @@ -30,3 +32,15 @@ export function useWorkflowMetadata( }); } +export function useWorkflowResults( + projectName: string, + sessionName: string +) { + return useQuery({ + queryKey: workflowKeys.results(projectName, sessionName), + queryFn: () => workflowsApi.getWorkflowResults(projectName, sessionName), + enabled: !!projectName && !!sessionName, + refetchInterval: 5000, // Poll every 5 seconds + }); +} + diff --git a/components/frontend/tailwind.config.js b/components/frontend/tailwind.config.js index d0e96481a..8c0ead5cf 100644 --- a/components/frontend/tailwind.config.js +++ b/components/frontend/tailwind.config.js @@ -75,6 +75,8 @@ module.exports = { }, plugins: [ // eslint-disable-next-line @typescript-eslint/no-require-imports - require("tw-animate-css") + require("tw-animate-css"), + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("@tailwindcss/typography"), ], }