diff --git a/packages/cli-workflow/src/commands/serve/routes-live.ts b/packages/cli-workflow/src/commands/serve/routes-live.ts index 3b1ab04..47bb134 100644 --- a/packages/cli-workflow/src/commands/serve/routes-live.ts +++ b/packages/cli-workflow/src/commands/serve/routes-live.ts @@ -118,7 +118,7 @@ async function emitRecordsForHead(params: { params.eventId.n++; await params.stream.writeSSE({ event: "record", - data: JSON.stringify({ type: "workflow-result", role: null, content: wf.summary, timestamp: null, returnCode: wf.returnCode }), + data: JSON.stringify({ type: "workflow-result", returnCode: wf.returnCode, content: wf.summary, timestamp: null }), id: String(params.eventId.n), }); return true; diff --git a/packages/cli-workflow/src/commands/serve/routes-thread.ts b/packages/cli-workflow/src/commands/serve/routes-thread.ts index fdd9826..e2955f1 100644 --- a/packages/cli-workflow/src/commands/serve/routes-thread.ts +++ b/packages/cli-workflow/src/commands/serve/routes-thread.ts @@ -45,19 +45,11 @@ async function buildThreadDetailRecords( const records: unknown[] = [ { type: "thread-start", - role: null, - content: [ - `workflow: ${workflowName ?? "unknown"}`, - `thread: ${resolved.threadId}`, - `status: ${resolved.source}`, - prompt !== null ? `\nprompt: ${prompt}` : null, - ].filter(Boolean).join("\n"), - timestamp: null, + workflow: workflowName ?? "unknown", + prompt: prompt ?? null, threadId: resolved.threadId, - bundleHash: resolved.bundleHash, - head: resolved.head, - start: resolved.start, - source: resolved.source, + status: resolved.source, + timestamp: null, }, ]; @@ -69,7 +61,7 @@ async function buildThreadDetailRecords( const returnCode = fr.payload.meta.returnCode; const summary = fr.payload.meta.summary; if (typeof returnCode === "number" && typeof summary === "string") { - records.push({ type: "workflow-result", role: null, content: summary, timestamp: null, returnCode }); + records.push({ type: "workflow-result", returnCode, content: summary, timestamp: fr.payload.timestamp }); } continue; } diff --git a/packages/workflow-dashboard/package.json b/packages/workflow-dashboard/package.json index d0bdfc7..4a1d32a 100644 --- a/packages/workflow-dashboard/package.json +++ b/packages/workflow-dashboard/package.json @@ -10,7 +10,9 @@ }, "dependencies": { "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "react-markdown": "^10.1.0", + "shiki": "^4.0.2" }, "devDependencies": { "@tailwindcss/vite": "^4.2.4", diff --git a/packages/workflow-dashboard/src/api.ts b/packages/workflow-dashboard/src/api.ts index 4310989..1d1703a 100644 --- a/packages/workflow-dashboard/src/api.ts +++ b/packages/workflow-dashboard/src/api.ts @@ -52,14 +52,32 @@ export type ThreadSummary = { status: string | null; }; -export type ThreadRecord = { - type: string; - role: string | null; - content: string | null; - timestamp: number | null; - [key: string]: unknown; +export type ThreadStartRecord = { + type: "thread-start"; + workflow: string; + prompt: string | null; + threadId: string; + status: string; + timestamp: null; }; +export type RoleRecord = { + type: "role"; + role: string; + content: string; + timestamp: number | null; + meta: Record; +}; + +export type WorkflowResultRecord = { + type: "workflow-result"; + returnCode: number; + content: string; + timestamp: number | null; +}; + +export type ThreadRecord = ThreadStartRecord | RoleRecord | WorkflowResultRecord; + // ── Gateway endpoints ─────────────────────────────────────────────── export function listAgents(): Promise { diff --git a/packages/workflow-dashboard/src/components/markdown.tsx b/packages/workflow-dashboard/src/components/markdown.tsx new file mode 100644 index 0000000..453da12 --- /dev/null +++ b/packages/workflow-dashboard/src/components/markdown.tsx @@ -0,0 +1,107 @@ +import ReactMarkdown from "react-markdown"; +import { useEffect, useState } from "react"; +import { createHighlighter, type HighlighterGeneric, type BundledLanguage, type BundledTheme } from "shiki"; + +let highlighterPromise: Promise> | null = null; + +const LANGS: BundledLanguage[] = ["typescript", "javascript", "json", "yaml", "bash", "python", "markdown"]; + +function getHighlighter(): Promise> { + if (highlighterPromise === null) { + highlighterPromise = createHighlighter({ + themes: ["github-dark"], + langs: LANGS, + }); + } + return highlighterPromise; +} + +function CodeBlock({ className, children }: { className?: string; children?: React.ReactNode }) { + const [html, setHtml] = useState(null); + const code = String(children).replace(/\n$/, ""); + const lang = className?.replace("language-", "") ?? "text"; + + useEffect(() => { + let cancelled = false; + getHighlighter().then((hl) => { + if (cancelled) return; + try { + const result = hl.codeToHtml(code, { lang, theme: "github-dark" }); + setHtml(result); + } catch { + setHtml(null); + } + }); + return () => { cancelled = true; }; + }, [code, lang]); + + if (html !== null) { + return ( +
+ ); + } + + return ( +
+      {code}
+    
+ ); +} + +export function Markdown({ content }: { content: string }) { + return ( +
+ + {children} + + ); + } + return {children}; + }, + p({ children }) { + return

{children}

; + }, + ul({ children }) { + return
    {children}
; + }, + ol({ children }) { + return
    {children}
; + }, + h1({ children }) { + return

{children}

; + }, + h2({ children }) { + return

{children}

; + }, + h3({ children }) { + return

{children}

; + }, + blockquote({ children }) { + return ( +
+ {children} +
+ ); + }, + }} + /> +
+ ); +} diff --git a/packages/workflow-dashboard/src/components/record-card.tsx b/packages/workflow-dashboard/src/components/record-card.tsx new file mode 100644 index 0000000..cb966fc --- /dev/null +++ b/packages/workflow-dashboard/src/components/record-card.tsx @@ -0,0 +1,128 @@ +import type { ThreadStartRecord, RoleRecord, WorkflowResultRecord, ThreadRecord } from "../api.ts"; +import { Markdown } from "./markdown.tsx"; + +const ROLE_COLORS: Record = { + preparer: "#8b5cf6", + agent: "#3b82f6", + extractor: "#f59e0b", +}; + +function roleColor(role: string): string { + return ROLE_COLORS[role] ?? "var(--color-accent)"; +} + +function formatTime(ts: number | null): string | null { + if (ts === null) return null; + return new Date(ts).toLocaleTimeString(); +} + +function StartCard({ record }: { record: ThreadStartRecord }) { + return ( +
+
+ 🚀 + + {record.workflow} + + + {record.status} + +
+ {record.prompt !== null && ( +
+
+ Prompt +
+ +
+ )} +
+ ); +} + +function RoleMessage({ record }: { record: RoleRecord }) { + const color = roleColor(record.role); + return ( +
+
+ + {record.role} + + {formatTime(record.timestamp) !== null && ( + + {formatTime(record.timestamp)} + + )} +
+ +
+ ); +} + +function ResultCard({ record }: { record: WorkflowResultRecord }) { + const success = record.returnCode === 0; + return ( +
+
+ {success ? "✅" : "❌"} + + {success ? "Completed" : "Failed"} + + + exit {record.returnCode} + + {formatTime(record.timestamp) !== null && ( + + {formatTime(record.timestamp)} + + )} +
+ +
+ ); +} + +export function RecordCard({ record }: { record: ThreadRecord }) { + switch (record.type) { + case "thread-start": + return ; + case "role": + return ; + case "workflow-result": + return ; + } +} diff --git a/packages/workflow-dashboard/src/components/thread-detail.tsx b/packages/workflow-dashboard/src/components/thread-detail.tsx index 9722832..cb3f516 100644 --- a/packages/workflow-dashboard/src/components/thread-detail.tsx +++ b/packages/workflow-dashboard/src/components/thread-detail.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { getThread, killThread, pauseThread, resumeThread } from "../api.ts"; import { useFetch } from "../hooks.ts"; import { useSSE } from "../use-sse.ts"; +import { RecordCard } from "./record-card.tsx"; type Props = { agent: string; @@ -102,39 +103,8 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) { )} {(status === "ok" || liveActive || records.length > 0) && (
- {records.map((r) => ( -
-
- - {r.type} - - {r.role && ( - - {r.role} - - )} - {r.timestamp !== null && ( - - {new Date(r.timestamp).toLocaleTimeString()} - - )} -
- {r.content && ( -
-                  {typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
-                
- )} -
+ {records.map((r, i) => ( + ))}