From 7dd2f8522387d0f9c5857334058aa326f6539341 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Tue, 11 Nov 2025 11:04:35 -0600 Subject: [PATCH 1/6] feat: add workflow results endpoints and UI integration - Implemented new backend endpoints for retrieving workflow results: - `GET /content/workflow-results` for fetching results based on session. - `GET /api/projects/:projectName/agentic-sessions/:sessionName/workflow/results` for project-specific results. - Updated frontend to display workflow results in a tabbed interface, allowing users to view output files and their statuses. - Enhanced the `useWorkflowResults` hook for fetching results with polling every 5 seconds. These changes improve the user experience by providing access to workflow output files directly within the session interface. --- components/backend/handlers/content.go | 87 +++++- components/backend/handlers/sessions.go | 58 ++++ components/backend/routes.go | 2 + components/frontend/package-lock.json | 48 +++ components/frontend/package.json | 1 + .../[sessionName]/workflow/results/route.ts | 17 ++ components/frontend/src/app/globals.css | 54 ++++ .../[name]/sessions/[sessionName]/page.tsx | 280 ++++++++++++++++-- .../frontend/src/services/api/workflows.ts | 22 ++ .../src/services/queries/use-workflows.ts | 14 + components/frontend/tailwind.config.js | 4 +- 11 files changed, 563 insertions(+), 24 deletions(-) create mode 100644 components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workflow/results/route.ts diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go index 57b4462b0..85a0bec7a 100644 --- a/components/backend/handlers/content.go +++ b/components/backend/handlers/content.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "fmt" "log" "net/http" "os" @@ -601,10 +602,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,6 +642,83 @@ 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"` +} + +// ContentWorkflowResults handles GET /content/workflow-results?session= +func ContentWorkflowResults(c *gin.Context) { + sessionName := c.Query("session") + if sessionName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing session parameter"}) + return + } + + workflowDir := findActiveWorkflowDir(sessionName) + if workflowDir == "" { + c.JSON(http.StatusOK, gin.H{"results": []ResultFile{}}) + 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{} + + for displayName, pattern := range ambientConfig.Results { + absPattern := filepath.Join(workspaceBase, pattern) + matches, err := filepath.Glob(absPattern) + + if err != nil { + results = append(results, ResultFile{ + DisplayName: displayName, + Path: pattern, + Exists: false, + Error: fmt.Sprintf("Invalid pattern: %v", err), + }) + continue + } + + if len(matches) == 0 { + results = append(results, ResultFile{ + DisplayName: displayName, + Path: pattern, + Exists: false, + }) + } else { + for _, matchedPath := range matches { + relPath, _ := filepath.Rel(workspaceBase, matchedPath) + content, readErr := os.ReadFile(matchedPath) + + result := ResultFile{ + DisplayName: displayName, + Path: relPath, + Exists: true, + } + + 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}) +} + // findActiveWorkflowDir finds the active workflow directory for a session func findActiveWorkflowDir(sessionName string) string { // Workflows are stored at {StateBaseDir}/sessions/{session-name}/workspace/workflows/{workflow-name} diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 9cee44f7a..b15f574f3 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -1329,6 +1329,64 @@ func GetWorkflowMetadata(c *gin.Context) { c.Data(resp.StatusCode, "application/json", b) } +// 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") + } + + // 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 + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + u := fmt.Sprintf("%s/content/workflow-results?session=%s", endpoint, sessionName) + + log.Printf("GetWorkflowResults: project=%s session=%s endpoint=%s", project, sessionName, endpoint) + + // 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..b89e585e0 100644 --- a/components/frontend/src/app/globals.css +++ b/components/frontend/src/app/globals.css @@ -120,6 +120,60 @@ body { @apply bg-background text-foreground; } + + /* Force light scrollbar styling */ + * { + scrollbar-width: thin; + scrollbar-color: rgb(203 213 225) rgb(241 245 249); + } + + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + *::-webkit-scrollbar-track { + background: rgb(241 245 249); + } + + *::-webkit-scrollbar-thumb { + background: rgb(203 213 225); + border-radius: 4px; + } + + *::-webkit-scrollbar-thumb:hover { + background: rgb(148 163 184); + } + + /* Force light mode for checkboxes in markdown */ + article input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 1rem; + height: 1rem; + border: 1px solid rgb(209 213 219); + border-radius: 0.25rem; + background-color: white; + cursor: not-allowed; + position: relative; + } + + article input[type="checkbox"]:checked { + background-color: rgb(37 99 235); + border-color: rgb(37 99 235); + } + + article input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 0.25rem; + top: 0.1rem; + width: 0.375rem; + height: 0.625rem; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } } /* Thin scrollbar styling */ 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..722232e08 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"; @@ -52,7 +54,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 +78,10 @@ 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'); + // Directory browser state (unified for artifacts, repos, and workflow) const [selectedDirectory, setSelectedDirectory] = useState({ type: 'artifacts', @@ -204,6 +211,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( @@ -958,6 +971,77 @@ 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) { + const tabId = `result-${idx}`; + if (!openTabs.find(t => t.id === tabId)) { + setOpenTabs([...openTabs, { + id: tabId, + name: result.displayName, + path: result.path, + content: result.content + }]); + } + setActiveTab(tabId); + } + }} + > +
+
+ {result.exists ? ( + + ) : ( + + )} +
+ {result.displayName} +

{result.path}

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

{result.error}

+ )} +
+ ))} +
+
+
+
+ )} @@ -1008,24 +1092,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}
    +                                          
    + )} +
    + ); + })()} +
    + )} +
    + + ); + })()}
    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"), ], } From e20933ac13d83f33f40a139e7a24a9378d5686e6 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Tue, 11 Nov 2025 11:57:07 -0600 Subject: [PATCH 2/6] feat: integrate doublestar for enhanced file matching in content workflow - Added the `doublestar` package to support recursive glob pattern matching in the `ContentWorkflowResults` function. - Refactored file matching logic to utilize `findMatchingFiles`, improving the accuracy of file retrieval based on specified patterns. - Updated `go.mod` and `go.sum` to include the new dependency. These changes enhance the functionality of the content workflow by allowing for more flexible file matching capabilities. --- components/backend/go.mod | 1 + components/backend/go.sum | 2 ++ components/backend/handlers/content.go | 33 ++++++++++++++++---------- 3 files changed, 24 insertions(+), 12 deletions(-) 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 85a0bec7a..2c599d0ef 100644 --- a/components/backend/handlers/content.go +++ b/components/backend/handlers/content.go @@ -15,6 +15,7 @@ import ( "ambient-code-backend/git" + "github.com/bmatcuk/doublestar/v4" "github.com/gin-gonic/gin" ) @@ -675,18 +676,7 @@ func ContentWorkflowResults(c *gin.Context) { results := []ResultFile{} for displayName, pattern := range ambientConfig.Results { - absPattern := filepath.Join(workspaceBase, pattern) - matches, err := filepath.Glob(absPattern) - - if err != nil { - results = append(results, ResultFile{ - DisplayName: displayName, - Path: pattern, - Exists: false, - Error: fmt.Sprintf("Invalid pattern: %v", err), - }) - continue - } + matches := findMatchingFiles(workspaceBase, pattern) if len(matches) == 0 { results = append(results, ResultFile{ @@ -719,6 +709,25 @@ func ContentWorkflowResults(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"results": results}) } +// findMatchingFiles finds files matching a glob pattern with ** support for recursive matching +func findMatchingFiles(baseDir, pattern string) []string { + // Use doublestar for glob matching with ** support + fsys := os.DirFS(baseDir) + matches, err := doublestar.Glob(fsys, pattern) + if err != nil { + log.Printf("findMatchingFiles: glob error for pattern %q: %v", pattern, err) + return []string{} + } + + // Convert relative paths to absolute paths + var absolutePaths []string + for _, match := range matches { + absolutePaths = append(absolutePaths, filepath.Join(baseDir, match)) + } + + return absolutePaths +} + // findActiveWorkflowDir finds the active workflow directory for a session func findActiveWorkflowDir(sessionName string) string { // Workflows are stored at {StateBaseDir}/sessions/{session-name}/workspace/workflows/{workflow-name} From e120fb9ca49ef9dcb38326a7604a86952db5d0b8 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Wed, 12 Nov 2025 07:14:32 -0600 Subject: [PATCH 3/6] feat: enhance content workflow and UI for session management - Added sorting of result display names and matches in the `ContentWorkflowResults` function to ensure consistent order of files. - Implemented auto-refresh for open tabs in the session detail page, allowing for real-time updates of workflow results. - Improved UI structure and formatting for better readability and user experience in the session detail page. These changes enhance the functionality and usability of the content workflow and session management interface. --- components/backend/handlers/content.go | 14 +- .../[name]/sessions/[sessionName]/page.tsx | 651 +++++++++--------- 2 files changed, 352 insertions(+), 313 deletions(-) diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go index 2c599d0ef..672bfb9b3 100644 --- a/components/backend/handlers/content.go +++ b/components/backend/handlers/content.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "time" @@ -675,7 +676,15 @@ func ContentWorkflowResults(c *gin.Context) { workspaceBase := filepath.Join(StateBaseDir, "sessions", sessionName, "workspace") results := []ResultFile{} - for displayName, pattern := range ambientConfig.Results { + // 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 := findMatchingFiles(workspaceBase, pattern) if len(matches) == 0 { @@ -685,6 +694,9 @@ func ContentWorkflowResults(c *gin.Context) { Exists: false, }) } else { + // Sort matches for consistent order + sort.Strings(matches) + for _, matchedPath := range matches { relPath, _ := filepath.Rel(workspaceBase, matchedPath) content, readErr := os.ReadFile(matchedPath) 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 722232e08..e6817de4a 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -318,6 +318,33 @@ export default function ProjectSessionDetailPage({ initializedFromSessionRef.current = true; }, [session, ootbWorkflows, workflowManagement]); + // Sync open tabs with latest workflow results (auto-refresh tab content) + useEffect(() => { + if (!workflowResults?.results) return; + + setOpenTabs(prevTabs => { + return prevTabs.map(tab => { + // Don't update chat tab + if (tab.id === 'chat') return tab; + + // Find matching result by path + const matchingResult = workflowResults.results.find(r => + r.exists && r.path === tab.path + ); + + // Update tab content if we found newer data + if (matchingResult?.content && matchingResult.content !== tab.content) { + return { + ...tab, + content: matchingResult.content + }; + } + + return tab; + }); + }); + }, [workflowResults]); + // Compute directory options const directoryOptions = useMemo(() => { const options: DirectoryOption[] = [ @@ -563,8 +590,8 @@ export default function ProjectSessionDetailPage({ return (
    -
    - Loading session... +
    + Loading session...
    ); @@ -605,54 +632,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...

    +
    +
    )}
    @@ -694,276 +721,276 @@ export default function ProjectSessionDetailPage({ {/* 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 + + + +
    +
    + )}
    @@ -1038,46 +1065,46 @@ export default function ProjectSessionDetailPage({
    ))}
    -
    - -
    +
    +
    + )} - +
    -
    +
    {/* 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' && (
    @@ -1100,18 +1127,18 @@ export default function ProjectSessionDetailPage({ // 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} + Promise.resolve(sendChat())} + onInterrupt={() => Promise.resolve(handleInterrupt())} + onEndSession={() => Promise.resolve(handleEndSession())} + onGoToResults={() => {}} + onContinue={handleContinue} + selectedAgents={selectedAgents} + autoSelectAgents={autoSelectAgents} workflowMetadata={workflowMetadata} onSetSelectedAgents={setSelectedAgents} onSetAutoSelectAgents={setAutoSelectAgents} @@ -1161,9 +1188,9 @@ export default function ProjectSessionDetailPage({ )} -
    +
    ))} -
    +
    {/* Tab Content */}
    @@ -1198,7 +1225,7 @@ export default function ProjectSessionDetailPage({

    {tab.name}

    {tab.path}

    -
    +
    {isMarkdown ? (
    @@ -1259,12 +1286,12 @@ export default function ProjectSessionDetailPage({ {tab.content} )} -
    +
    ); })()} - - )} - + + )} + ); })()} @@ -1275,7 +1302,7 @@ export default function ProjectSessionDetailPage({ - + {/* Modals */} { const success = await gitOps.handleCommit(message); if (success) { - setCommitModalOpen(false); + setCommitModalOpen(false); refetchMergeStatus(); } }} From 9c50f8504dea86ef595a788134dec854c7a39b4a Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Wed, 12 Nov 2025 11:41:22 -0600 Subject: [PATCH 4/6] feat: add file size validation in content workflow results - Introduced a constant `MaxResultFileSize` to limit the size of result files to 10MB, preventing potential memory issues. - Implemented file size checks before reading files in the `ContentWorkflowResults` function, ensuring that files exceeding the limit are handled gracefully with appropriate error messages. These changes enhance the robustness of the content workflow by preventing excessive memory usage when processing large files. --- components/backend/handlers/content.go | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go index 672bfb9b3..fbd3b9da4 100644 --- a/components/backend/handlers/content.go +++ b/components/backend/handlers/content.go @@ -24,6 +24,9 @@ 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 + // Git operation functions - set by main package during initialization // These are set to the actual implementations from git package var ( @@ -699,14 +702,29 @@ func ContentWorkflowResults(c *gin.Context) { for _, matchedPath := range matches { relPath, _ := filepath.Rel(workspaceBase, matchedPath) - content, readErr := os.ReadFile(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 { From 9798183618f65019d76b77877a8944ad6309195b Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Wed, 12 Nov 2025 11:56:58 -0600 Subject: [PATCH 5/6] feat: enhance file matching and session tab management - Introduced a new constant `MaxGlobMatches` to limit the number of matched files, preventing resource exhaustion during file searches. - Updated `findMatchingFiles` to return an error for invalid base directories and enforce security checks against directory traversal. - Enhanced the session detail page to limit the number of open tabs, ensuring efficient memory usage and improved user experience. - Implemented memoization for workflow results to optimize tab content updates, reducing unnecessary re-renders. These changes improve the robustness and performance of the content workflow and session management interface. --- components/backend/handlers/content.go | 68 +++++++++++-- components/frontend/src/app/globals.css | 38 +++++--- .../[name]/sessions/[sessionName]/page.tsx | 95 ++++++++++++++----- 3 files changed, 161 insertions(+), 40 deletions(-) diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go index fbd3b9da4..6fff12f24 100644 --- a/components/backend/handlers/content.go +++ b/components/backend/handlers/content.go @@ -27,6 +27,9 @@ 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 ( @@ -688,7 +691,17 @@ func ContentWorkflowResults(c *gin.Context) { for _, displayName := range displayNames { pattern := ambientConfig.Results[displayName] - matches := findMatchingFiles(workspaceBase, pattern) + 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{ @@ -740,22 +753,63 @@ func ContentWorkflowResults(c *gin.Context) { } // findMatchingFiles finds files matching a glob pattern with ** support for recursive matching -func findMatchingFiles(baseDir, pattern string) []string { +// 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 { - log.Printf("findMatchingFiles: glob error for pattern %q: %v", pattern, err) - return []string{} + return nil, fmt.Errorf("glob pattern error: %w", err) } - // Convert relative paths to absolute paths + // 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 { - absolutePaths = append(absolutePaths, filepath.Join(baseDir, match)) + // 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 + return absolutePaths, nil } // findActiveWorkflowDir finds the active workflow directory for a session diff --git a/components/frontend/src/app/globals.css b/components/frontend/src/app/globals.css index b89e585e0..7ab698355 100644 --- a/components/frontend/src/app/globals.css +++ b/components/frontend/src/app/globals.css @@ -121,10 +121,10 @@ @apply bg-background text-foreground; } - /* Force light scrollbar styling */ + /* Scrollbar styling with dark mode support */ * { scrollbar-width: thin; - scrollbar-color: rgb(203 213 225) rgb(241 245 249); + scrollbar-color: oklch(0.7 0 0) oklch(0.95 0 0); } *::-webkit-scrollbar { @@ -133,34 +133,50 @@ } *::-webkit-scrollbar-track { - background: rgb(241 245 249); + background: oklch(0.95 0 0); } *::-webkit-scrollbar-thumb { - background: rgb(203 213 225); + background: oklch(0.7 0 0); border-radius: 4px; } *::-webkit-scrollbar-thumb:hover { - background: rgb(148 163 184); + background: oklch(0.6 0 0); } - /* Force light mode for checkboxes in markdown */ + .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 rgb(209 213 219); + border: 1px solid var(--border); border-radius: 0.25rem; - background-color: white; + background-color: var(--background); cursor: not-allowed; position: relative; } article input[type="checkbox"]:checked { - background-color: rgb(37 99 235); - border-color: rgb(37 99 235); + background-color: var(--primary); + border-color: var(--primary); } article input[type="checkbox"]:checked::after { @@ -170,7 +186,7 @@ top: 0.1rem; width: 0.375rem; height: 0.625rem; - border: solid white; + border: solid var(--primary-foreground); border-width: 0 2px 2px 0; transform: rotate(45deg); } 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 e6817de4a..d638eb376 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -82,6 +82,9 @@ export default function ProjectSessionDetailPage({ 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', @@ -318,32 +321,48 @@ 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 (!workflowResults?.results) return; + if (resultsByPath.size === 0) return; setOpenTabs(prevTabs => { - return prevTabs.map(tab => { + let hasChanges = false; + const updatedTabs = prevTabs.map(tab => { // Don't update chat tab if (tab.id === 'chat') return tab; - // Find matching result by path - const matchingResult = workflowResults.results.find(r => - r.exists && r.path === tab.path - ); + // Get latest content for this path + const latestContent = resultsByPath.get(tab.path); - // Update tab content if we found newer data - if (matchingResult?.content && matchingResult.content !== tab.content) { + // Only update if content actually changed + if (latestContent && latestContent !== tab.content) { + hasChanges = true; return { ...tab, - content: matchingResult.content + content: latestContent }; } return tab; }); + + // Only trigger state update if something actually changed + return hasChanges ? updatedTabs : prevTabs; }); - }, [workflowResults]); + }, [resultsByPath]); // Compute directory options const directoryOptions = useMemo(() => { @@ -1029,15 +1048,47 @@ export default function ProjectSessionDetailPage({ )} onClick={() => { if (result.exists && result.content) { - const tabId = `result-${idx}`; - if (!openTabs.find(t => t.id === tabId)) { - setOpenTabs([...openTabs, { + // 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 - }]); - } + content: result.content || '' + }); + + return newTabs; + }); + setActiveTab(tabId); } }} @@ -1228,15 +1279,15 @@ export default function ProjectSessionDetailPage({ {isMarkdown ? ( -
    +

    {children}

    , - h2: ({children}) =>

    {children}

    , - h3: ({children}) =>

    {children}

    , - h4: ({children}) =>

    {children}

    , - p: ({children}) =>

    {children}

    , + h1: ({children}) =>

    {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'); From 1d621228a6510a73c04df65eb29e01ff2b3fc749 Mon Sep 17 00:00:00 2001 From: Gage Krumbach Date: Sat, 15 Nov 2025 08:28:43 -0600 Subject: [PATCH 6/6] refactor: simplify session repository structure and status handling - Replaced the unified session repository mapping with a simplified format, reducing complexity in the session spec. - Removed unnecessary fields from the session status, focusing on essential information (phase, message, is_error). - Updated frontend components to align with the new repository structure, enhancing clarity and usability. - Eliminated deprecated fields and logic related to input/output repositories, streamlining the session management process. These changes improve the maintainability and performance of the session handling system. --- components/backend/handlers/content.go | 131 ++++++++++++++-- components/backend/handlers/sessions.go | 72 ++++++++- .../accordions/artifacts-accordion.tsx | 140 ------------------ .../[name]/sessions/[sessionName]/page.tsx | 26 ---- 4 files changed, 189 insertions(+), 180 deletions(-) delete mode 100644 components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/accordions/artifacts-accordion.tsx diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go index 6fff12f24..39b21de18 100644 --- a/components/backend/handlers/content.go +++ b/components/backend/handlers/content.go @@ -481,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{ @@ -659,7 +659,76 @@ type ResultFile struct { Error string `json:"error,omitempty"` } -// ContentWorkflowResults handles GET /content/workflow-results?session= +// 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 == "" { @@ -667,9 +736,16 @@ func ContentWorkflowResults(c *gin.Context) { return } - workflowDir := findActiveWorkflowDir(sessionName) + // Get workflow name from query parameter (if provided from CR) + workflowName := c.Query("workflow") + workflowDir := findActiveWorkflowDir(sessionName, workflowName) if workflowDir == "" { - c.JSON(http.StatusOK, gin.H{"results": []ResultFile{}}) + // 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 } @@ -712,10 +788,10 @@ func ContentWorkflowResults(c *gin.Context) { } else { // Sort matches for consistent order sort.Strings(matches) - + for _, matchedPath := range matches { relPath, _ := filepath.Rel(workspaceBase, matchedPath) - + result := ResultFile{ DisplayName: displayName, Path: relPath, @@ -759,7 +835,7 @@ func findMatchingFiles(baseDir, pattern string) ([]string, error) { 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) @@ -813,23 +889,54 @@ func findMatchingFiles(baseDir, pattern string) ([]string, error) { } // 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 b15f574f3..9abc13ffc 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -1329,6 +1329,47 @@ 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) { @@ -1350,6 +1391,30 @@ func GetWorkflowResults(c *gin.Context) { 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) @@ -1362,11 +1427,14 @@ func GetWorkflowResults(c *gin.Context) { serviceName = fmt.Sprintf("ambient-content-%s", sessionName) } - // Build URL to content service + // 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", project, sessionName, endpoint) + 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) 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 d638eb376..a95f4490e 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -29,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"; @@ -258,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); @@ -727,17 +712,6 @@ export default function ProjectSessionDetailPage({ onRemoveRepository={(repoName) => removeRepoMutation.mutate(repoName)} /> - - {/* Experimental - File Explorer */}