From bbd6044c3090d928e14dd3da85570b384ebcb722 Mon Sep 17 00:00:00 2001 From: Tony Powell Date: Fri, 13 Feb 2026 11:36:45 -0500 Subject: [PATCH] feat(report): integrate report generation progress in UI components - add progress indicators and update report handling in session management --- packages/cli/src/agent/report-subagent.ts | 496 ++++++++++++++++++ packages/cli/src/server/session.ts | 35 +- packages/ui/src/App.test.tsx | 4 + packages/ui/src/components/ReportPanel.tsx | 15 +- packages/ui/src/components/ReportRenderer.tsx | 77 ++- packages/ui/src/hooks/useWebSocket.ts | 40 ++ packages/ui/src/lib/db.ts | 10 +- packages/ui/src/lib/types.ts | 3 + packages/ui/src/lib/websocket.ts | 21 + packages/ui/src/store/report.ts | 80 ++- 10 files changed, 766 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/agent/report-subagent.ts diff --git a/packages/cli/src/agent/report-subagent.ts b/packages/cli/src/agent/report-subagent.ts new file mode 100644 index 0000000..e07f6c0 --- /dev/null +++ b/packages/cli/src/agent/report-subagent.ts @@ -0,0 +1,496 @@ +/** + * Report Subagent for Phoenix Insight + * + * A specialized subagent that generates structured reports autonomously. + * The parent agent delegates report generation tasks to this subagent, + * which has its own context window for processing data and generating + * the JSON-Render tree structure. + * + * Benefits: + * - Offloads report generation from main agent's context + * - Streams progress updates to the UI in real-time via WebSocket + * - Returns only a summary to the main agent (token efficient) + */ + +import { generateText, stepCountIs, tool } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; + +// Import component schemas and catalog from UI package (single source of truth) +import { + catalog, + CardSchema, + ChartSchema, + TextSchema, + HeadingSchema, + ListSchema, + TableSchema, + MetricSchema, + BadgeSchema, + AlertSchema, + SeparatorSchema, + CodeSchema, +} from "@cephalization/phoenix-insight-ui/catalog"; + +// ============================================================================ +// Schema Definitions (duplicated from report-tool.ts for subagent use) +// ============================================================================ + +/** + * UIElement schema - a single element in the render tree + */ +const UIElementSchema = z.object({ + key: z.string(), + type: z.enum([ + "Card", + "Chart", + "Text", + "Heading", + "List", + "Table", + "Metric", + "Badge", + "Alert", + "Separator", + "Code", + ]), + props: z.record(z.string(), z.unknown()), + children: z.array(z.string()).optional(), + parentKey: z.string().optional(), +}); + +/** + * UITree schema - the full JSON-Render tree structure + */ +const UITreeSchema = z.object({ + root: z.string(), + elements: z.record(z.string(), UIElementSchema), +}); + +type UITree = z.infer; + +// ============================================================================ +// Validation (duplicated from report-tool.ts for subagent validation) +// ============================================================================ + +const VALID_COMPONENT_TYPES = [ + "Card", + "Chart", + "Text", + "Heading", + "List", + "Table", + "Metric", + "Badge", + "Alert", + "Separator", + "Code", +] as const; + +type ComponentType = (typeof VALID_COMPONENT_TYPES)[number]; + +function getPropsSchemaForType( + type: ComponentType +): z.ZodType> { + switch (type) { + case "Card": + return CardSchema; + case "Chart": + return ChartSchema; + case "Text": + return TextSchema; + case "Heading": + return HeadingSchema; + case "List": + return ListSchema; + case "Table": + return TableSchema; + case "Metric": + return MetricSchema; + case "Badge": + return BadgeSchema; + case "Alert": + return AlertSchema; + case "Separator": + return SeparatorSchema; + case "Code": + return CodeSchema; + } +} + +function validateReportContent(content: unknown): { + success: boolean; + error?: string; +} { + const treeResult = UITreeSchema.safeParse(content); + if (!treeResult.success) { + return { + success: false, + error: `Invalid tree structure: ${treeResult.error.message}`, + }; + } + + const tree = treeResult.data; + + if (!tree.elements[tree.root]) { + return { + success: false, + error: `Root element "${tree.root}" not found in elements`, + }; + } + + for (const [key, element] of Object.entries(tree.elements)) { + const type = element.type as ComponentType; + + if (!VALID_COMPONENT_TYPES.includes(type)) { + return { + success: false, + error: `Unknown component type "${type}" for element "${key}"`, + }; + } + + const propsSchema = getPropsSchemaForType(type); + const propsResult = propsSchema.safeParse(element.props); + + if (!propsResult.success) { + return { + success: false, + error: `Invalid props for ${type} element "${key}": ${propsResult.error.message}`, + }; + } + + if (element.children) { + for (const childKey of element.children) { + if (!tree.elements[childKey]) { + return { + success: false, + error: `Child element "${childKey}" not found for parent "${key}"`, + }; + } + } + } + + if (element.parentKey && !tree.elements[element.parentKey]) { + return { + success: false, + error: `Parent element "${element.parentKey}" not found for element "${key}"`, + }; + } + } + + return { success: true }; +} + +// ============================================================================ +// Component Documentation Generation +// ============================================================================ + +function extractPropNames(schema: z.ZodTypeAny): string[] { + if (schema instanceof z.ZodObject) { + const shape = schema.shape; + return Object.entries(shape).map(([key, value]) => { + const isOptional = + value instanceof z.ZodOptional || + value instanceof z.ZodNullable || + (value as z.ZodTypeAny).isOptional?.(); + return isOptional ? `${key}?` : key; + }); + } + return []; +} + +function generateComponentDocs(catalogDef: typeof catalog): string { + const lines: string[] = []; + + for (const [name, component] of Object.entries(catalogDef.components)) { + const propNames = extractPropNames(component.props); + const propsStr = + propNames.length > 0 ? `props: ${propNames.join(", ")}` : "no props"; + const childrenNote = component.hasChildren ? "; can have children" : ""; + lines.push( + `- ${name}: ${component.description} (${propsStr}${childrenNote})` + ); + } + + return lines.join("\n"); +} + +// ============================================================================ +// Report Subagent Types +// ============================================================================ + +/** + * Report generation progress state - sent to UI via WebSocket + */ +export interface ReportProgress { + /** Current status of report generation */ + status: "starting" | "generating" | "validating" | "complete" | "error"; + /** Optional status message */ + message?: string; + /** The report title if available */ + title?: string; +} + +/** + * Callback for sending report progress updates to the client + */ +export type ReportProgressCallback = (progress: ReportProgress) => void; + +/** + * Callback for sending completed reports to the client (JSON-Render tree) + */ +export type ReportCompleteCallback = (content: UITree, title?: string) => void; + +/** + * Options for running the report subagent + */ +export interface ReportSubagentOptions { + /** Callback for sending completed reports to the client */ + onReport: ReportCompleteCallback; + /** Callback for streaming report progress to the client */ + onProgress: ReportProgressCallback; +} + +/** + * Result returned to the main agent (summarized for token efficiency) + */ +export interface ReportSubagentResult { + success: boolean; + title?: string; + message: string; + /** Brief summary of what was generated */ + summary?: string; +} + +// ============================================================================ +// Report Subagent Implementation +// ============================================================================ + +/** + * The system prompt for the report subagent + */ +function getReportSubagentPrompt(): string { + const componentDocs = generateComponentDocs(catalog); + + return `You are a report generation specialist. Your task is to analyze the provided data and generate a structured report using the JSON-Render tree format. + +## Available Components + +${componentDocs} + +## JSON-Render Tree Format + +The report must be a valid JSON object with this structure: +{ + "root": "", + "elements": { + "": { + "key": "", + "type": "", + "props": { }, + "children": ["", "", ...], // optional, for containers + "parentKey": "" // optional, for non-root elements + }, + ... + } +} + +## Guidelines + +1. Start with a Card as the root element to contain the report +2. Use Headings (h1, h2, h3) to structure sections +3. Use Metrics for key performance indicators +4. Use Charts for data visualization (bar, line, pie, area) +5. Use Tables for tabular data +6. Use Lists for bullet points or numbered items +7. Use Text for paragraphs and descriptions +8. Use Alerts for important warnings or information +9. Use Badges for status indicators +10. Use Separators to divide sections + +## Important + +- Generate a COMPLETE report that fully addresses the analysis request +- Include relevant metrics, charts, and tables based on the data +- Organize content logically with clear section headings +- Write a final text summary of your findings at the end of your response + +When you call the submit_report tool, the report will be sent to the user interface.`; +} + +/** + * Create the internal report submission tool used by the subagent + */ +function createSubmitReportTool( + onReport: ReportCompleteCallback, + onProgress: ReportProgressCallback +) { + return tool({ + description: + "Submit the generated report. Call this when you have completed building the report structure.", + inputSchema: z.object({ + title: z.string().optional().describe("Title for the report"), + content: UITreeSchema.describe("The complete JSON-Render tree structure"), + }), + execute: async (params: { + title?: string; + content: UITree; + }): Promise<{ success: boolean; error?: string }> => { + const { title, content } = params; + + // Send validating progress + onProgress({ + status: "validating", + message: "Validating report structure...", + title, + }); + + // Validate the report + const validation = validateReportContent(content); + if (!validation.success) { + onProgress({ + status: "error", + message: validation.error, + title, + }); + return { success: false, error: validation.error }; + } + + // Send the completed report + onReport(content, title); + + // Send completion progress + onProgress({ + status: "complete", + message: title + ? `Report "${title}" generated successfully` + : "Report generated successfully", + title, + }); + + return { success: true }; + }, + }); +} + +/** + * Run the report subagent to generate a report from the given task + * + * @param task - Description of what report to generate and what data to analyze + * @param options - Callbacks for report and progress + * @param abortSignal - Optional abort signal for cancellation + * @returns The result to be returned to the main agent + */ +export async function runReportSubagent( + task: string, + options: ReportSubagentOptions, + abortSignal?: AbortSignal +): Promise { + const { onReport, onProgress } = options; + + // Use claude-sonnet-4-5 for report generation (same as main agent) + const model = anthropic("claude-sonnet-4-5"); + + // Send starting progress + onProgress({ + status: "starting", + message: "Initializing report generation...", + }); + + try { + // Create the submission tool + const submitReportTool = createSubmitReportTool(onReport, onProgress); + + // Send generating progress + onProgress({ + status: "generating", + message: "Analyzing data and generating report structure...", + }); + + // Run the subagent with its own context window + const result = await generateText({ + model, + system: getReportSubagentPrompt(), + prompt: task, + tools: { + submit_report: submitReportTool, + }, + stopWhen: stepCountIs(3), // Usually 1-2 steps: generate + submit + abortSignal, + }); + + // Extract the final text as a summary for the main agent + const summaryText = + result.text || "Report generated and submitted successfully."; + + return { + success: true, + message: "Report generation completed", + summary: summaryText.slice(0, 500), // Limit summary length for main agent + }; + } catch (error) { + // Send error progress + const errorMessage = + error instanceof Error ? error.message : String(error); + onProgress({ + status: "error", + message: `Report generation failed: ${errorMessage}`, + }); + + return { + success: false, + message: `Report generation failed: ${errorMessage}`, + }; + } +} + +/** + * Create the report subagent tool for the main agent + * + * This tool delegates report generation to the report subagent, + * which runs autonomously with its own context window. + * Progress updates are sent to the UI via WebSocket callbacks. + * + * @param options - Callbacks for report and progress + * @returns AI SDK tool definition for the main agent + */ +export function createReportSubagentTool(options: ReportSubagentOptions) { + return tool({ + description: `Delegate report generation to a specialized subagent. + +Use this tool when you need to generate a structured visual report for the user. +Provide a detailed task description including: +- What data to analyze (reference specific findings from your analysis) +- What insights to highlight +- What metrics, charts, or tables to include +- Any specific formatting requirements + +The subagent will generate the report autonomously and stream progress updates to the UI. +You will receive a summary when complete - do NOT try to generate the report structure yourself.`, + inputSchema: z.object({ + task: z + .string() + .describe( + "Detailed description of what report to generate, including the data and insights to present" + ), + }), + execute: async (params, { abortSignal }) => { + const { task } = params; + + // Run the subagent - progress is streamed via the onProgress callback + const result = await runReportSubagent(task, options, abortSignal); + + return result; + }, + // Control what the main agent model sees - only the summarized result + toModelOutput: ({ output }) => { + const result = output as ReportSubagentResult; + return { + type: "text" as const, + value: result.success + ? `Report generated successfully. ${result.summary || ""}` + : `Report generation failed: ${result.message}`, + }; + }, + }); +} diff --git a/packages/cli/src/server/session.ts b/packages/cli/src/server/session.ts index f91d11c..9f4200d 100644 --- a/packages/cli/src/server/session.ts +++ b/packages/cli/src/server/session.ts @@ -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, @@ -123,13 +126,18 @@ 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 { 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 = { @@ -137,7 +145,7 @@ export class AgentSession { client: this.client, maxSteps: this.maxSteps, additionalTools: { - generate_report: reportTool, + generate_report: reportSubagentTool, }, }; this.agent = await createInsightAgent(config); @@ -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 */ diff --git a/packages/ui/src/App.test.tsx b/packages/ui/src/App.test.tsx index a0c9aae..81956db 100644 --- a/packages/ui/src/App.test.tsx +++ b/packages/ui/src/App.test.tsx @@ -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); }), diff --git a/packages/ui/src/components/ReportPanel.tsx b/packages/ui/src/components/ReportPanel.tsx index 4664ee0..c5cd9ee 100644 --- a/packages/ui/src/components/ReportPanel.tsx +++ b/packages/ui/src/components/ReportPanel.tsx @@ -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 */ @@ -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) { @@ -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; @@ -137,12 +145,12 @@ export function ReportPanel({ className, isStreaming = false }: ReportPanelProps
- {/* Download button */} + {/* Download button - disabled when no report or report has no content */}
diff --git a/packages/ui/src/components/ReportRenderer.tsx b/packages/ui/src/components/ReportRenderer.tsx index 400c2fb..d7ef18a 100644 --- a/packages/ui/src/components/ReportRenderer.tsx +++ b/packages/ui/src/components/ReportRenderer.tsx @@ -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 { @@ -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; } /** @@ -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 ; + case "error": + return ; + default: + return ; + } + }; + + 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 ( +
+ {getStatusIcon()} +
+

{getStatusText()}

+ {progress.title && progress.status !== "complete" && ( +

+ Creating: {progress.title} +

+ )} +
+
+ ); +} + /** * Fallback component for unknown component types */ @@ -141,6 +200,7 @@ 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 @@ -148,7 +208,20 @@ function FallbackComponent({ 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 ; + } + // No report and not streaming - show empty state if (!report && !isStreaming) { return ; diff --git a/packages/ui/src/hooks/useWebSocket.ts b/packages/ui/src/hooks/useWebSocket.ts index 3f57743..d7b1d5d 100644 --- a/packages/ui/src/hooks/useWebSocket.ts +++ b/packages/ui/src/hooks/useWebSocket.ts @@ -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( @@ -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; } @@ -393,6 +431,8 @@ export function useWebSocket( updateToolCallResult, setIsStreaming, setReport, + setGenerationProgress, + startGeneratingReport, ] ); diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index ed4420b..ed403ba 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -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"); diff --git a/packages/ui/src/lib/types.ts b/packages/ui/src/lib/types.ts index a26f3ca..9f3bfd6 100644 --- a/packages/ui/src/lib/types.ts +++ b/packages/ui/src/lib/types.ts @@ -7,6 +7,9 @@ export type { ClientMessage, ServerMessage, JSONRenderTree, + // Report progress types + ReportProgressStatus, + ReportProgressPayload, // Conversation history types UIConversationMessage, UIUserMessage, diff --git a/packages/ui/src/lib/websocket.ts b/packages/ui/src/lib/websocket.ts index bf9c3ee..e7c70d7 100644 --- a/packages/ui/src/lib/websocket.ts +++ b/packages/ui/src/lib/websocket.ts @@ -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 */ @@ -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 } } | { diff --git a/packages/ui/src/store/report.ts b/packages/ui/src/store/report.ts index d05acec..605a098 100644 --- a/packages/ui/src/store/report.ts +++ b/packages/ui/src/store/report.ts @@ -5,6 +5,7 @@ import { loadReports as dbLoadReports, deleteReport as dbDeleteReport, } from "@/lib/db"; +import type { ReportProgressStatus } from "@/lib/websocket"; // JSONRenderTree type - will be properly typed when @json-render/core is added // For now, define as a flexible JSON structure that json-render expects @@ -14,14 +15,31 @@ export type JSONRenderTree = Record; export interface Report { id: string; sessionId: string; - content: JSONRenderTree; + /** Report content, null when report is being generated */ + content: JSONRenderTree | null; createdAt: number; title?: string; } +/** + * Report generation progress state + */ +export interface ReportGenerationProgress { + /** Current status of report generation */ + status: ReportProgressStatus; + /** Optional status message */ + message?: string; + /** The report title if available */ + title?: string; + /** Session ID this progress belongs to */ + sessionId: string; +} + export interface ReportState { reports: Report[]; currentReportId: string | null; + /** Current report generation progress (null if not generating) */ + generationProgress: ReportGenerationProgress | null; } export interface ReportActions { @@ -30,6 +48,15 @@ export interface ReportActions { deleteReport: (reportId: string) => void; listReports: () => Report[]; getCurrentReport: () => Report | null; + /** Update report generation progress */ + setGenerationProgress: (progress: ReportGenerationProgress | null) => void; + /** Check if a report is currently being generated */ + isGenerating: () => boolean; + /** + * Start generating a new report - creates a placeholder report with null content + * and switches to it immediately. Returns the new report's ID. + */ + startGeneratingReport: (sessionId: string, title?: string) => string; } export type ReportStore = ReportState & ReportActions; @@ -44,6 +71,7 @@ export const useReportStore = create()( // State reports: [], currentReportId: null, + generationProgress: null, // Actions setReport: (reportData) => { @@ -121,6 +149,56 @@ export const useReportStore = create()( if (!state.currentReportId) return null; return state.reports.find((r) => r.id === state.currentReportId) ?? null; }, + + setGenerationProgress: (progress) => { + set({ generationProgress: progress }); + }, + + isGenerating: () => { + const state = get(); + return ( + state.generationProgress !== null && + state.generationProgress.status !== "complete" && + state.generationProgress.status !== "error" + ); + }, + + startGeneratingReport: (sessionId, title) => { + const state = get(); + + // Check if a report for this session already exists + const existingReport = state.reports.find((r) => r.sessionId === sessionId); + + if (existingReport) { + // Clear the existing report's content and switch to it + // This shows loading state on the existing report + set((state) => ({ + reports: state.reports.map((r) => + r.id === existingReport.id + ? { ...r, content: null, title: title ?? r.title } + : r + ), + currentReportId: existingReport.id, + })); + return existingReport.id; + } else { + // Create new report with null content (placeholder) + const newReport: Report = { + id: generateId(), + sessionId, + content: null, + createdAt: Date.now(), + title, + }; + + set((state) => ({ + reports: [...state.reports, newReport], + currentReportId: newReport.id, + })); + + return newReport.id; + } + }, }))); // ============================================