Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
496 changes: 496 additions & 0 deletions packages/cli/src/agent/report-subagent.ts

Large diffs are not rendered by default.

35 changes: 29 additions & 6 deletions packages/cli/src/server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
} from "../agent/index.js";
import type { ExecutionMode } from "../modes/types.js";
import type { PhoenixClient } from "@arizeai/phoenix-client";
import { createReportTool } from "../commands/report-tool.js";
import {
createReportSubagentTool,
type ReportProgress,
} from "../agent/report-subagent.js";
import {
type ConversationMessage,
createUserMessage,
Expand Down Expand Up @@ -123,21 +126,26 @@ export class AgentSession {
}

/**
* Initialize the agent lazily, including the report tool for UI mode
* Initialize the agent lazily, including the report subagent tool for UI mode
*/
private async getAgent(): Promise<PhoenixInsightAgent> {
if (!this.agent) {
// Create the report tool with a callback to send reports to the client
const reportTool = createReportTool((content, title) => {
this.sendReport(content, title);
// Create the report subagent tool with callbacks for progress and completion
const reportSubagentTool = createReportSubagentTool({
onReport: (content, title) => {
this.sendReport(content, title);
},
onProgress: (progress) => {
this.sendReportProgress(progress);
},
});

const config: PhoenixInsightAgentConfig = {
mode: this.mode,
client: this.client,
maxSteps: this.maxSteps,
additionalTools: {
generate_report: reportTool,
generate_report: reportSubagentTool,
},
};
this.agent = await createInsightAgent(config);
Expand Down Expand Up @@ -192,6 +200,21 @@ export class AgentSession {
});
}

/**
* Send a report generation progress update to the client
*/
private sendReportProgress(progress: ReportProgress): void {
this.send({
type: "report_progress",
payload: {
status: progress.status,
message: progress.message,
title: progress.title,
sessionId: this.sessionId,
},
});
}

/**
* Send an error message to the client
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ vi.mock("@/store/report", () => ({
const state = {
reports: [],
currentReportId: null,
generationProgress: null,
getCurrentReport: () => null,
setGenerationProgress: vi.fn(),
isGenerating: () => false,
startGeneratingReport: vi.fn(() => "mock-report-id"),
};
return selector(state);
}),
Expand Down
15 changes: 12 additions & 3 deletions packages/ui/src/components/ReportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* - Displays report with header toolbar
* - Provides download as markdown
* - Shows report history in a dialog
* - Shows report generation progress
* - Integrates with report store and IndexedDB persistence
*/

Expand Down Expand Up @@ -102,6 +103,7 @@ function downloadAsFile(content: string, filename: string): void {
* Features:
* - Header toolbar with title, download, and history buttons
* - ReportRenderer integration for displaying report content
* - Report generation progress indicator
* - History dialog showing all cached reports with view/delete/download actions
*/
export function ReportPanel({ className, isStreaming = false }: ReportPanelProps) {
Expand All @@ -114,6 +116,12 @@ export function ReportPanel({ className, isStreaming = false }: ReportPanelProps
return state.reports.find((r) => r.id === state.currentReportId) ?? null;
});

// Subscribe to generation progress
const generationProgress = useReportStore((state) => state.generationProgress);
const isGenerating = generationProgress != null &&
generationProgress.status !== "complete" &&
generationProgress.status !== "error";

// Handle downloading current report as markdown
const handleDownload = useCallback(() => {
if (!currentReport) return;
Expand All @@ -137,12 +145,12 @@ export function ReportPanel({ className, isStreaming = false }: ReportPanelProps
</span>

<div className="flex items-center gap-1">
{/* Download button */}
{/* Download button - disabled when no report or report has no content */}
<Button
variant="ghost"
size="sm"
onClick={handleDownload}
disabled={!currentReport}
disabled={!currentReport || !currentReport.content}
aria-label="Download as markdown"
>
<DownloadIcon className="h-4 w-4" />
Expand All @@ -169,7 +177,8 @@ export function ReportPanel({ className, isStreaming = false }: ReportPanelProps
<ScrollArea className="min-h-0 flex-1">
<ReportRenderer
report={currentReport ? (currentReport.content as unknown as UITree) : null}
isStreaming={isStreaming}
isStreaming={isStreaming || isGenerating}
generationProgress={generationProgress}
/>
</ScrollArea>
</div>
Expand Down
77 changes: 75 additions & 2 deletions packages/ui/src/components/ReportRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* ReportRenderer Component
*
* Renders a JSON report using the json-render library with shadcn/ui components.
* Supports loading states, empty states, and streaming indicators.
* Supports loading states, empty states, generation progress, and streaming indicators.
*/

import {
Expand All @@ -14,13 +14,16 @@ import {
import type { UITree } from "@json-render/core";
import { registry } from "@/lib/json-render/registryManifest";
import { Skeleton } from "@/components/ui/skeleton";
import { FileText } from "lucide-react";
import { FileText, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
import type { ReportGenerationProgress } from "@/store/report";

export interface ReportRendererProps {
/** The report content as a JSON render tree, or null if no report */
report: UITree | null;
/** Whether the report is currently streaming */
isStreaming?: boolean;
/** Current report generation progress (null if not generating) */
generationProgress?: ReportGenerationProgress | null;
}

/**
Expand Down Expand Up @@ -121,6 +124,62 @@ function StreamingIndicator() {
);
}

/**
* Generation progress indicator shown while report subagent is working
*/
function GenerationProgressIndicator({
progress,
}: {
progress: ReportGenerationProgress;
}) {
const getStatusIcon = () => {
switch (progress.status) {
case "complete":
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case "error":
return <AlertCircle className="h-5 w-5 text-destructive" />;
default:
return <Loader2 className="h-5 w-5 animate-spin text-primary" />;
}
};

const getStatusText = () => {
switch (progress.status) {
case "starting":
return "Initializing report generation...";
case "generating":
return "Analyzing data and generating report...";
case "validating":
return "Validating report structure...";
case "complete":
return progress.title
? `Report "${progress.title}" generated`
: "Report generated successfully";
case "error":
return progress.message || "Report generation failed";
default:
return progress.message || "Generating report...";
}
};

return (
<div
className="flex min-h-[200px] flex-col items-center justify-center gap-4 p-8"
data-testid="report-generation-progress"
>
{getStatusIcon()}
<div className="space-y-2 text-center">
<p className="text-sm font-medium">{getStatusText()}</p>
{progress.title && progress.status !== "complete" && (
<p className="text-xs text-muted-foreground">
Creating: {progress.title}
</p>
)}
</div>
</div>
);
}

/**
* Fallback component for unknown component types
*/
Expand All @@ -141,14 +200,28 @@ function FallbackComponent({
*
* Shows:
* - Empty state when no report is available
* - Generation progress when report subagent is working (hides old report)
* - Loading skeleton when streaming without content
* - Report content with streaming indicator when streaming with content
* - Full report when complete
*/
export function ReportRenderer({
report,
isStreaming = false,
generationProgress = null,
}: ReportRendererProps) {
// Check if actively generating (not complete/error)
const isGenerating =
generationProgress !== null &&
generationProgress.status !== "complete" &&
generationProgress.status !== "error";

// Show generation progress when subagent is working
// This takes priority over showing the old report to avoid confusion
if (isGenerating) {
return <GenerationProgressIndicator progress={generationProgress} />;
}

// No report and not streaming - show empty state
if (!report && !isStreaming) {
return <EmptyState />;
Expand Down
40 changes: 40 additions & 0 deletions packages/ui/src/hooks/useWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,12 @@ export function useWebSocket(

// Report store selectors
const setReport = useReportStore((state) => state.setReport);
const setGenerationProgress = useReportStore(
(state) => state.setGenerationProgress
);
const startGeneratingReport = useReportStore(
(state) => state.startGeneratingReport
);

// Handle incoming server messages
const handleMessage = useCallback(
Expand Down Expand Up @@ -337,6 +343,38 @@ export function useWebSocket(
sessionId,
content: content as JSONRenderTree,
});
// Clear generation progress when report is actually received
setGenerationProgress(null);
break;
}

case "report_progress": {
const { status, message: progressMessage, title } = message.payload;

// When generation starts, create/switch to a new report with null content
// This immediately shows the loading state on the new report
if (status === "starting") {
startGeneratingReport(sessionId, title);
}

// Update report generation progress, but NOT for "complete" status.
// Progress is cleared only when the actual "report" message arrives.
// This ensures the loading state stays visible until the report is ready.
if (status !== "complete") {
setGenerationProgress({
status,
message: progressMessage,
title,
sessionId,
});
}

// Clear on error since no report will arrive
if (status === "error") {
setTimeout(() => {
setGenerationProgress(null);
}, 3000);
}
break;
}

Expand Down Expand Up @@ -393,6 +431,8 @@ export function useWebSocket(
updateToolCallResult,
setIsStreaming,
setReport,
setGenerationProgress,
startGeneratingReport,
]
);

Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,13 @@ export function exportReportAsMarkdown(report: Report): string {
parts.push(`# ${report.title}\n\n`);
}

// Convert content to markdown
const contentMarkdown = jsonRenderTreeToMarkdown(report.content);
parts.push(contentMarkdown);
// Convert content to markdown (handle null content for reports being generated)
if (report.content) {
const contentMarkdown = jsonRenderTreeToMarkdown(report.content);
parts.push(contentMarkdown);
} else {
parts.push("*Report content is being generated...*\n\n");
}

// Add metadata footer
parts.push("\n---\n\n");
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export type {
ClientMessage,
ServerMessage,
JSONRenderTree,
// Report progress types
ReportProgressStatus,
ReportProgressPayload,
// Conversation history types
UIConversationMessage,
UIUserMessage,
Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/lib/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,26 @@ export type ClientMessage =
}
| { type: "cancel"; payload: { sessionId?: string } };

/**
* Report generation progress status
*/
export type ReportProgressStatus =
| "starting"
| "generating"
| "validating"
| "complete"
| "error";

/**
* Report generation progress payload
*/
export interface ReportProgressPayload {
status: ReportProgressStatus;
message?: string;
title?: string;
sessionId: string;
}

/**
* Messages sent from the server to the UI client
*/
Expand All @@ -110,6 +130,7 @@ export type ServerMessage =
payload: { toolName: string; result: unknown; sessionId: string };
}
| { type: "report"; payload: { content: JSONRenderTree; sessionId: string } }
| { type: "report_progress"; payload: ReportProgressPayload }
| { type: "error"; payload: { message: string; sessionId?: string } }
| { type: "done"; payload: { sessionId: string } }
| {
Expand Down
Loading