diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 81afc6db3d..edc6cfcb61 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -2449,27 +2449,22 @@ export function registerApiRoutes( return apiError(c, 500, 'Failed to look up workflow run'); } - if (!run?.working_path) { + if (!run) { return apiError(c, 404, 'Workflow run not found'); } - // Derive owner/repo from working_path (must be under ~/.archon/workspaces/owner/repo/...) - const normalizedWorkspacesPath = normalize(getArchonWorkspacesPath()); - const normalizedWorkingPath = normalize(run.working_path); - if (!normalizedWorkingPath.startsWith(normalizedWorkspacesPath + sep)) { - getLog().error( - { runId, workingPath: run.working_path }, - 'artifacts.working_path_outside_workspaces' - ); - return apiError(c, 404, 'Artifact not available: working path not in workspaces'); + // Derive owner/repo from codebase name (format: "owner/repo") + const codebase = run.codebase_id ? await codebaseDb.getCodebase(run.codebase_id) : null; + if (!codebase?.name) { + getLog().error({ runId, codebaseId: run.codebase_id }, 'artifacts.codebase_lookup_failed'); + return apiError(c, 404, 'Artifact not available: codebase not found'); } - const relative = normalizedWorkingPath.substring(normalizedWorkspacesPath.length + 1); - const parts = relative.split(sep).filter(p => p.length > 0); - if (parts.length < 2) { - getLog().error({ runId, workingPath: run.working_path }, 'artifacts.owner_repo_parse_failed'); + const nameParts = codebase.name.split('/'); + if (nameParts.length < 2) { + getLog().error({ runId, codebaseName: codebase.name }, 'artifacts.owner_repo_parse_failed'); return apiError(c, 404, 'Artifact not available: could not determine owner/repo'); } - const [owner, repo] = parts; + const [owner, repo] = nameParts; const artifactDir = getRunArtifactsPath(owner, repo, runId); const filePath = join(artifactDir, filename); diff --git a/packages/web/src/components/chat/MessageBubble.tsx b/packages/web/src/components/chat/MessageBubble.tsx index 9f2c8887c8..3d7823ef48 100644 --- a/packages/web/src/components/chat/MessageBubble.tsx +++ b/packages/web/src/components/chat/MessageBubble.tsx @@ -1,72 +1,124 @@ -import { memo, useState } from 'react'; -import { Copy, Check } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; +import { memo, useMemo, useState } from 'react'; +import { Copy, Check, Paperclip } from 'lucide-react'; +import ReactMarkdown, { type Components } from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; -import { Paperclip } from 'lucide-react'; import type { ChatMessage, FileAttachment } from '@/lib/types'; import { cn } from '@/lib/utils'; +import { ArtifactViewerModal } from '@/components/workflows/ArtifactViewerModal'; // Hoisted to module scope to prevent new references on every render const REMARK_PLUGINS = [remarkGfm, remarkBreaks]; const REHYPE_PLUGINS = [rehypeHighlight]; -const MARKDOWN_COMPONENTS = { - pre: ({ children, ...props }: React.ComponentPropsWithoutRef<'pre'>): React.ReactElement => ( -
-      {children}
-    
- ), - code: ({ - children, - className, - ...props - }: React.ComponentPropsWithoutRef<'code'> & { className?: string }): React.ReactElement => { - const isBlock = className?.startsWith('language-') || className?.startsWith('hljs'); - if (isBlock) { +// Matches artifact paths (forward- and back-slash safe); groups: [1] runId, [2] filename +const ARTIFACT_PATH_RE = /artifacts[/\\]runs[/\\]([a-fA-F0-9-]+)[/\\](.+)/; + +function extractArtifactInfo(text: string): { runId: string; filename: string } | null { + const match = ARTIFACT_PATH_RE.exec(text); + if (!match) return null; + const filename = match[2].replace(/\\/g, '/'); + if (filename.split('/').some(s => s === '..')) return null; + return { + runId: match[1], + filename, + }; +} + +function makeMarkdownComponents( + onArtifactClick: (runId: string, filename: string) => void +): Components { + return { + pre: ({ children, ...props }: React.ComponentPropsWithoutRef<'pre'>): React.ReactElement => ( +
+        {children}
+      
+ ), + code: ({ + children, + className, + ...props + }: React.ComponentPropsWithoutRef<'code'> & { className?: string }): React.ReactElement => { + const isBlock = className?.startsWith('language-') || className?.startsWith('hljs'); + if (isBlock) { + return ( + + {children} + + ); + } + if (typeof children === 'string') { + const artifact = extractArtifactInfo(children); + if (artifact) { + const { runId, filename } = artifact; + const displayName = filename.split('/').pop() ?? filename; + if (filename.endsWith('.md')) { + return ( + + ); + } + const encodedFilename = filename.split('/').map(encodeURIComponent).join('/'); + return ( + + {displayName} + + ); + } + } return ( - + {children} ); - } - return ( - ): React.ReactElement => ( +
+ {children}
+
+ ), + blockquote: ({ + children, + ...props + }: React.ComponentPropsWithoutRef<'blockquote'>): React.ReactElement => ( +
+ {children} +
+ ), + a: ({ children, ...props }: React.ComponentPropsWithoutRef<'a'>): React.ReactElement => ( + {children} -
- ); - }, - table: ({ children, ...props }: React.ComponentPropsWithoutRef<'table'>): React.ReactElement => ( -
- {children}
-
- ), - blockquote: ({ - children, - ...props - }: React.ComponentPropsWithoutRef<'blockquote'>): React.ReactElement => ( -
- {children} -
- ), - a: ({ children, ...props }: React.ComponentPropsWithoutRef<'a'>): React.ReactElement => ( -
- {children} - - ), -}; + + ), + }; +} /** Detect if a string is a complete JSON object/array */ function isJsonString(str: string): boolean { @@ -88,6 +140,17 @@ function MessageBubbleRaw({ message }: MessageBubbleProps): React.ReactElement { const isUser = message.role === 'user'; const isThinking = message.isStreaming && !message.content; const [copied, setCopied] = useState(false); + const [artifactViewer, setArtifactViewer] = useState<{ runId: string; filename: string } | null>( + null + ); + // setArtifactViewer is a stable React state setter — empty dep array is intentional + const markdownComponents = useMemo( + () => + makeMarkdownComponents((runId, filename) => { + setArtifactViewer({ runId, filename }); + }), + [] + ); const copyMessage = (): void => { void navigator.clipboard.writeText(message.content).then(() => { @@ -99,97 +162,109 @@ function MessageBubbleRaw({ message }: MessageBubbleProps): React.ReactElement { }; return ( -
-
- {isUser ? ( -
-
-

- {message.content} -

- -
- {message.files && message.files.length > 0 && ( -
- {message.files.map((file: FileAttachment) => ( -
- - {file.name} -
- ))} -
- )} -
- ) : ( -
- {isThinking && ( -
- - - + <> +
+
+ {isUser ? ( +
+
+

+ {message.content} +

+
- )} - {isJsonString(message.content) ? ( -
- - - JSON output - - -
-                  {JSON.stringify(JSON.parse(message.content.trim()) as unknown, null, 2)}
-                
-
- ) : ( - - {message.content} - - )} - {message.isStreaming && message.content && ( - - )} -
- )} + {message.files && message.files.length > 0 && ( +
+ {message.files.map((file: FileAttachment) => ( +
+ + {file.name} +
+ ))} +
+ )} +
+ ) : ( +
+ {isThinking && ( +
+ + + +
+ )} + {isJsonString(message.content) ? ( +
+ + + JSON output + + +
+                    {JSON.stringify(JSON.parse(message.content.trim()) as unknown, null, 2)}
+                  
+
+ ) : ( + + {message.content} + + )} + {message.isStreaming && message.content && ( + + )} +
+ )} - {!isThinking && ( -
- {new Date(message.timestamp).toLocaleTimeString()} -
- )} + {!isThinking && ( +
+ {new Date(message.timestamp).toLocaleTimeString()} +
+ )} +
-
+ {artifactViewer && ( + { + setArtifactViewer(null); + }} + runId={artifactViewer.runId} + filename={artifactViewer.filename} + /> + )} + ); } diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index 59a8c30298..447ad4616d 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -1,6 +1,6 @@ -import { memo, useEffect, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; -import ReactMarkdown from 'react-markdown'; +import ReactMarkdown, { type Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { ArrowDown, Sparkles, ArrowRight, MessageSquare } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -8,22 +8,83 @@ import { MessageBubble } from './MessageBubble'; import { ToolCallCard } from './ToolCallCard'; import { ErrorCard } from './ErrorCard'; import { WorkflowProgressCard } from './WorkflowProgressCard'; +import { ArtifactViewerModal } from '@/components/workflows/ArtifactViewerModal'; import { useAutoScroll } from '@/hooks/useAutoScroll'; import type { ChatMessage } from '@/lib/types'; -// Hoisted to module scope to prevent new references on every render -const WORKFLOW_RESULT_MARKDOWN_COMPONENTS = { - a: ({ children, ...props }: React.ComponentPropsWithoutRef<'a'>): React.ReactElement => ( - - {children} - - ), -}; +// Matches artifact paths (forward- and back-slash safe); groups: [1] runId, [2] filename +const ARTIFACT_PATH_RE = /artifacts[/\\]runs[/\\]([a-fA-F0-9-]+)[/\\](.+)/; + +function extractArtifactInfo(text: string): { runId: string; filename: string } | null { + const match = ARTIFACT_PATH_RE.exec(text); + if (!match) return null; + const filename = match[2].replace(/\\/g, '/'); + if (filename.split('/').some(s => s === '..')) return null; + return { runId: match[1], filename }; +} + +function makeResultMarkdownComponents( + onArtifactClick: (runId: string, filename: string) => void +): Components { + return { + a: ({ children, ...props }: React.ComponentPropsWithoutRef<'a'>): React.ReactElement => ( + + {children} + + ), + code: ({ + children, + className, + ...props + }: React.ComponentPropsWithoutRef<'code'> & { className?: string }): React.ReactElement => { + const isBlock = className?.startsWith('language-') || className?.startsWith('hljs'); + if (isBlock) { + return ( + + {children} + + ); + } + if (typeof children === 'string') { + const artifact = extractArtifactInfo(children); + if (artifact) { + const { runId, filename } = artifact; + const displayName = filename.split('/').pop() ?? filename; + const encodedFilename = filename.split('/').map(encodeURIComponent).join('/'); + const artifactHref = `/api/artifacts/${encodeURIComponent(runId)}/${encodedFilename}`; + return ( + { + e.preventDefault(); + onArtifactClick(runId, filename); + } + : undefined + } + target={filename.endsWith('.md') ? undefined : '_blank'} + rel={filename.endsWith('.md') ? undefined : 'noopener noreferrer'} + className="!text-accent-bright hover:!text-primary font-mono font-medium underline decoration-accent-bright/40 hover:decoration-accent-bright" + > + {displayName} + + ); + } + } + return ( + + {children} + + ); + }, + }; +} function WorkflowResultCard({ workflowName, @@ -36,6 +97,19 @@ function WorkflowResultCard({ }): React.ReactElement { const navigate = useNavigate(); const [expanded, setExpanded] = useState(false); + const [artifactViewer, setArtifactViewer] = useState<{ + runId: string; + filename: string; + } | null>(null); + + // setArtifactViewer is a stable React state setter — empty dep array is intentional + const mdComponents = useMemo( + () => + makeResultMarkdownComponents((aRunId, filename) => { + setArtifactViewer({ runId: aRunId, filename }); + }), + [] + ); const lines = content.split('\n'); const isTruncatable = content.length > 500 || lines.length > 8; @@ -47,42 +121,51 @@ function WorkflowResultCard({ const displayContent = expanded || !isTruncatable ? content : preview; return ( -
-
- - - Workflow complete: {workflowName} - - -
-
-
- - {displayContent} - -
- {isTruncatable && ( + <> +
+
+ + + Workflow complete: {workflowName} + - )} +
+
+
+ + {displayContent} + +
+ {isTruncatable && ( + + )} +
-
+ {artifactViewer && ( + { + setArtifactViewer(null); + }} + runId={artifactViewer.runId} + filename={artifactViewer.filename} + /> + )} + ); } diff --git a/packages/workflows/src/store.ts b/packages/workflows/src/store.ts index da4e832093..9d9a85e275 100644 --- a/packages/workflows/src/store.ts +++ b/packages/workflows/src/store.ts @@ -26,6 +26,7 @@ export const WORKFLOW_EVENT_TYPES = [ 'approval_requested', 'approval_received', 'workflow_cancelled', + 'workflow_artifact', ] as const; export type WorkflowEventType = (typeof WORKFLOW_EVENT_TYPES)[number];