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];