feat(workbench-shell): ✨ Add dynamic chart artifact rendering to thread messages#169
feat(workbench-shell): ✨ Add dynamic chart artifact rendering to thread messages#169
Conversation
…hread messages Introduce a multi-part message protocol and chart artifact system that enables dynamic chart preview generation within the thread message flow. This is the foundational layer for model-driven data visualization. Frontend changes: - Extend MessageDto/SurfaceMessage with structured `parts` array - Refactor LongMessageBody to render messages by part type - Add ThreadChartArtifactCard with real Vega-Lite rendering via vega-embed - Wire ArtifactEvent through ThreadStream and runtime-thread-surface - Add mapMessageParts() with fallback compatibility for legacy messages Backend changes: - Add `parts_json` column to messages table (migration) - Extend MessageRecord/MessageDto with parts field - Add message_repo::replace_parts and merge_chart_artifact_part - Add ThreadStreamEvent::ArtifactUpdated variant - Propagate parts_json through all MessageRecord construction sites Dependencies: - Add vega, vega-lite, vega-embed for declarative chart rendering
… error boundary Implement the controlled chart generation tool that enables the model to produce dynamic Vega-Lite visualizations in thread messages. Runtime tool: - Register render_chart in runtime_tools_for_profile (both profiles) - Add execute_render_chart in agent_session_execution with full lifecycle: persist tool call → validate spec → resolve target message → emit ArtifactUpdated started/completed → merge_chart_artifact_part → emit ToolCompleted Frontend hardening: - Add validateSpec() with size limit (512KB) and data point cap (50K) - Add ChartErrorBoundary (React class component) to catch render crashes - Add responsive width fitting and dark theme autosize config - Show validation errors inline without breaking message layout - Support show/hide spec toggle for debugging
AI Code Review SummaryPR: #169 (feat(workbench-shell): ✨ Add dynamic chart artifact rendering to thread messages) Overall AssessmentDetected 13 actionable findings, prioritize CRITICAL/HIGH before merge. Major Findings by Severity
Actionable Suggestions
Potential Risks
Test Suggestions
File-Level Coverage Notes
Inline Downgraded Items (processed but not inline)
Coverage Status
Uncovered list:
No-patch covered list:
Runtime/Budget
|
| } | ||
| } | ||
|
|
||
| async fn execute_render_chart( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
|
|
||
| void render(); | ||
| return () => { cancelled = true; }; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
| } | ||
|
|
||
| async fn execute_render_chart( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| .await; | ||
| } | ||
|
|
||
| if tool_name == "render_chart" { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| // Resolve the message to attach the chart to: use the last completed | ||
| // assistant message in this run, or create a reference if needed. | ||
| let target_message_id = { | ||
| let runs = self.active_runs_message_id().await; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| run_id: String, | ||
| task_board: TaskBoardDto, | ||
| }, | ||
| ArtifactUpdated { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| Ok(()) | ||
| } | ||
|
|
||
| pub async fn merge_chart_artifact_part( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| function mapUnknownPart(part: MessagePartDto): SurfaceUnknownMessagePart { | ||
| return { | ||
| type: part.type, | ||
| value: part as Record<string, unknown>, |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| async function render() { | ||
| if (!containerRef.current) return; | ||
| try { | ||
| const vegaEmbed = (await import("vega-embed")).default; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
|
|
||
| /// Get the last streaming/completed message ID for the current run. | ||
| async fn active_runs_message_id(&self) -> Option<String> { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| run_id: Option<String>, | ||
| role: String, | ||
| content_markdown: String, | ||
| parts_json: Option<String>, |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| }; | ||
| } | ||
|
|
||
| const nextParts = message.parts.slice(); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
| } | ||
|
|
||
| async fn execute_render_chart( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| try { | ||
| const vegaEmbed = (await import("vega-embed")).default; | ||
| if (cancelled) return; | ||
| const result = await vegaEmbed(containerRef.current, spec as object, { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| run_id: Some(self.spec.run_id.clone()), | ||
| role: "assistant".to_string(), | ||
| content_markdown: plan_markdown(&plan_metadata), | ||
| parts_json: None, |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| error: event.error, | ||
| kind: event.status, | ||
| messageId: event.messageId, | ||
| payload: event.payload, |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| content_markdown: "<context_summary>\nCarry this forward.\n</context_summary>" | ||
| .to_string(), | ||
| parts_json: None, | ||
|
|
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| const content = message.content || (message.status === "streaming" ? "…" : ""); | ||
| const preview = shouldUseLongMessagePreview(message) | ||
| ? getLongMessagePreview(content) | ||
| const parts: SurfaceMessagePart[] = message.parts ?? [{ type: "text", text: message.content || (message.status === "streaming" ? "…" : "") }]; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| : "assistant", | ||
| runId: message.runId, | ||
| content: message.contentMarkdown, | ||
| parts: mapMessageParts(message.parts, message.contentMarkdown), |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| this.onRunStateChange = null; | ||
| this.onContextCompressing = null; | ||
| this.onPlan = null; | ||
| this.onArtifact = null; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
Implement chart artifact handling across the full stack: - Add shared chart spec validation in chart-spec-validation.ts with size and data constraints - Add type declaration for vega-embed library - Refactor thread-chart-artifact-card.tsx to use shared validation and hoist vega-embed import - Add normalizeThreadStreamEvent to agent-commands.ts with status validation and fallback - Add Rust tests for render_chart tool availability and spec requirements - Add Rust integration tests for ThreadStreamEvent::ArtifactUpdated serialization - Add unit tests for client-side event normalization and spec validation This enables agents to render Vega-Lite charts as artifacts within threads, with proper validation, error handling, and type safety.
| @@ -0,0 +1,178 @@ | |||
| import { Component, useEffect, useRef, useState } from "react"; | |||
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
| } | ||
|
|
||
| async fn execute_render_chart( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| // Validate spec field exists | ||
| let spec = match tool_input.get("spec") { | ||
| Some(s) if s.is_object() || s.is_array() => s.clone(), | ||
| _ => { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| tool_input: tool_input.clone(), | ||
| }); | ||
|
|
||
| // Validate spec field exists |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| Ok(()) | ||
| } | ||
|
|
||
| pub async fn replace_parts( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| runId: readRequiredString(rawEvent, "runId", "run_id"), | ||
| plan: readValue(rawEvent, "plan", "plan"), | ||
| }; | ||
| case "artifact_updated": |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| url: string | null; | ||
| } | ||
|
|
||
| export interface TextMessagePartDto { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| }); | ||
|
|
||
| // Persist chart artifact into message parts | ||
| if let Err(error) = message_repo::merge_chart_artifact_part( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| }); | ||
|
|
||
| it("falls back to 'completed' for unknown status values", () => { | ||
| const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| if (VALID_ARTIFACT_STATUSES.has(rawStatus)) { | ||
| return rawStatus as "started" | "delta" | "completed" | "failed"; | ||
| } | ||
| console.warn(`[agent-commands] Unknown artifact status "${rawStatus}", falling back to "completed"`); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| } | ||
| } | ||
|
|
||
| async fn execute_render_chart( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| }); | ||
|
|
||
| // Persist chart artifact into message parts | ||
| if let Err(error) = message_repo::merge_chart_artifact_part( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| .iter() | ||
| .rev() | ||
| .find(|m| m.run_id.as_deref() == Some(&self.spec.run_id) && m.role == "assistant") | ||
| .map(|m| m.id.clone()) |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| "tailwind-merge": "^3.3.1", | ||
| "tw-animate-css": "^1.3.7", | ||
| "use-stick-to-bottom": "^1.1.3", | ||
| "vega": "^6.2.0", |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| .contains("incrementally refine the plan")); | ||
| } | ||
|
|
||
| #[test] |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| }; | ||
| } | ||
|
|
||
| const nextParts = message.parts.slice(); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
|
|
||
| export function ThreadChartArtifactCard({ part }: ThreadChartArtifactCardProps) { | ||
| const [showSpec, setShowSpec] = useState(false); | ||
| const specText = JSON.stringify(part.spec, null, 2); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| this.onPlan?.({ runId: event.runId, plan: event.plan }); | ||
| break; | ||
|
|
||
| case "artifact_updated": |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| content_markdown: "<context_summary>\nCarry this forward.\n</context_summary>" | ||
| .to_string(), | ||
| parts_json: None, | ||
|
|
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| <p className="text-sm leading-6 text-app-muted">{part.caption}</p> | ||
| ) : null} | ||
| </div> | ||
| <button |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
Add comprehensive integration tests for the persistence layer: - Render chart tool call insertion, success and failure updates - Replace message parts with JSON and nullification - Merge chart artifact part creation, upsert, and empty content migration behavior Supporting changes: - Expose persistence module as public for external test access - Refactor merge_chart_artifact_part to eliminate redundant database fetch and improve error handling - Memoize specText in ThreadChartArtifactCard for performance
…L/SVG artifacts Rename the `render_chart` tool to `render` to broaden its purpose beyond charts. The updated tool now supports three libraries: `vega-lite` (requires `spec`), `html`, and `svg` (both require `source`). This allows the agent to render HTML pages and SVG graphics inline alongside existing Vega-Lite visualizations. Previously, only the `render_chart` tool name was available and it required a `spec` field for Vega-Lite. Now the tool accepts a `library` parameter that selects the render path, with separate validation rules for each library type. HTML/SVG source is enforced as a non-empty string with a 1MB size limit. On the frontend, the `ThreadChartArtifactCard` component is extended to handle HTML/SVG artifacts with a preview dialog (using `dangerouslySetInnerHTML` for SVG and `<iframe>` for HTML). The `SurfaceChartMessagePart` type gains an optional `source` field, and the state mapping logic is updated to accept artifacts that contain either `spec` or `source`. The `render` tool is also collapsed by default in the thread surface, consistent with the previous `render_chart` behavior.
…ming parts accumulation Add support for reading HTML/SVG source from a file path instead of requiring inline source only. The `render` tool now accepts an optional `path` parameter that resolves relative to the workspace root. Includes proper error handling for file read failures and path validation using workspace path resolution. Update the tool schema to document the new `path` parameter alongside the existing `source` field. Fix message streaming in RuntimeThreadSurface to preserve non-text parts (e.g., tool calls) when delta updates arrive. Previously, each delta replaced the parts array with only a text part, causing loss of tool call information during streaming.
…or tools Extract and export `isDefaultCollapsedTool` function to centralize logic that determines which tool blocks should remain collapsed by default. Previously, only task board tools (`isTaskBoardTool`) were forced to collapsed state. Now the condition also includes the "render" tool, ensuring consistent behavior for all default-collapsed tools regardless of their state. Update `RuntimeThreadSurface` to use `isDefaultCollapsedTool` instead of `isTaskBoardTool`, both when preserving user state and when initializing new tool entries.
…I and remove unused import
…essage ID retrieval - Add in-memory `active_runs` reference to `AgentSession` for race-free access to streaming message IDs - Refactor active message ID retrieval to prioritize in-memory state over DB fallback - Implement artifact event buffering in React runtime thread surface to handle artifacts arriving before their target message exists in state
…nd surface components Extract the preview overlay and content rendering logic from `ThreadChartArtifactCard` into reusable `WorkbenchPreviewOverlay` and `FilePreviewSurface` components. This eliminates duplicated DOM structure and prepares for future preview use cases beyond chart artifacts. - Create `WorkbenchPreviewOverlay` as a generic full-screen overlay with backdrop blur, header, and scrollable content area - Create `FilePreviewSurface` to handle HTML, SVG, and Markdown preview content with proper sandboxing and lazy imports - Refactor `ThreadChartArtifactCard` to use `FilePreviewSurface`, removing ~30 lines of inline preview code
| return ( | ||
| <div | ||
| className="flex min-h-full w-full items-center justify-center p-6" | ||
| dangerouslySetInnerHTML={{ __html: source }} |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| ); | ||
| }); | ||
|
|
||
| stream.onArtifact = withActiveStream((event) => { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| ); | ||
| } | ||
|
|
||
| export function ThreadChartArtifactCard({ part }: ThreadChartArtifactCardProps) { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| // Resolve the message to attach the artifact to | ||
| let target_message_id = { | ||
| let runs = self.active_runs_message_id().await; | ||
| runs.unwrap_or_else(|| format!("chart-msg-{}", &artifact_id[..8])) |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| message_id: String, | ||
| artifact_id: String, | ||
| artifact_type: String, | ||
| status: String, |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| </div> | ||
| </div> | ||
|
|
||
| {isHtmlSvg && part.source ? ( |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| Ok(path) => match tokio::fs::read_to_string(&path).await { | ||
| Ok(content) if !content.is_empty() => Some(content), | ||
| Ok(_) => None, | ||
| Err(e) => { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| checkpoint_requested: AtomicBool::new(false), | ||
| abort_signal: tiycore::agent::AbortSignal::new(), | ||
| context_compression_state, | ||
| active_runs, |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| const VALID_ARTIFACT_STATUSES = new Set(["started", "delta", "completed", "failed"]); | ||
|
|
||
| function readArtifactStatus(rawStatus: string): "started" | "delta" | "completed" | "failed" { | ||
| if (VALID_ARTIFACT_STATUSES.has(rawStatus)) { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| artifactId: string; | ||
| artifactType: string; | ||
| status: "started" | "delta" | "completed" | "failed"; | ||
| payload?: unknown; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
…tatus enum Replace string-based artifact status with the `ArtifactStatus` enum in the Rust backend, improving type safety across `ThreadStreamEvent::ArtifactUpdated` events and corresponding tests. In the frontend, move the `mergeArtifactPartIntoMessage` function from the `runtime-thread-surface` component into the `runtime-thread-surface-state` module, exporting it with a dedicated `ArtifactEvent` type. Add comprehensive unit tests for the extracted function to ensure correct handling of all artifact lifecycle states.
| } | ||
| } | ||
|
|
||
| async fn execute_render( |
There was a problem hiding this comment.
[HIGH] No integration test covering the new render tool execution path
Add integration tests that exercise execute_render for vega-lite, html (inline source), html (file path), edge cases like empty source, missing spec, oversized source, and path outside workspace.
Suggestion: Create tests in src-tauri/tests/agent_run.rs (or a new agent_render.rs) that spin a real session, call the render tool via tool dispatch, and assert the emitted ToolRequested/ArtifactUpdated/ToolCompleted events and DB records.
Confidence: 0.90
| pub run_id: Option<String>, | ||
| pub role: String, | ||
| pub content_markdown: String, | ||
| pub parts_json: Option<String>, |
There was a problem hiding this comment.
[HIGH] Missing DB migration for new parts_json column
The parts_json field is added to the MessageRecord struct but no corresponding database schema migration is provided.
Suggestion: Add a migration to alter the messages table (e.g., ALTER TABLE messages ADD COLUMN parts_json TEXT) and handle existing rows appropriately.
Risk: Existing databases will be missing the column, causing runtime errors when the field is read or written.
Confidence: 0.90
| Ok(()) | ||
| } | ||
|
|
||
| pub async fn merge_chart_artifact_part( |
There was a problem hiding this comment.
[HIGH] Race condition in merge_chart_artifact_part: read-modify-write is not atomic
Concurrent calls to merge_chart_artifact_part for the same message ID can lose previously merged parts because the read-modify-write sequence is not atomic.
Suggestion: Wrap the find_by_id → serialize → replace_parts sequence in a database transaction (BEGIN IMMEDIATE / COMMIT) using sqlx or connection-level transactions to ensure atomicity.
Risk: Under concurrent chart artifact completions, a message's parts array may silently lose chart entries, leading to missing visualizations in the UI.
Confidence: 0.85
| "tailwind-merge": "^3.3.1", | ||
| "tw-animate-css": "^1.3.7", | ||
| "use-stick-to-bottom": "^1.1.3", | ||
| "vega": "^6.2.0", |
There was a problem hiding this comment.
[MEDIUM] Missing UI integration for newly added Vega dependencies
Vega, vega-embed, and vega-lite are added to package.json without any consuming UI code in the current diff, risking them becoming orphaned dependencies.
Suggestion: Either wire up a Vega chart component using these packages or remove them from the dependency list to prevent unused dependency accumulation and unnecessary bundle size growth.
Risk: Dead code, increased bundle size, dormant maintenance burden.
Confidence: 0.90
| // Resolve the message to attach the artifact to | ||
| let target_message_id = { | ||
| let runs = self.active_runs_message_id().await; | ||
| runs.unwrap_or_else(|| format!("chart-msg-{}", &artifact_id[..8])) |
There was a problem hiding this comment.
[MEDIUM] Chart artifact target message can be a synthetic fallback ID
When active_runs_message_id() returns None, the render tool uses a synthetic message ID like chart-msg-xxxx. This synthetic ID is persisted via merge_chart_artifact_part and emitted in ArtifactUpdated events, but it does not correspond to any DB message. The UI may try to display chart artifacts on a non-existent message, which can cause missing charts in the conversation history.
Suggestion: Ensure the artifact always attaches to a real message. Options: (a) make the render tool wait until a message is created (e.g., retry with a timeout loop), (b) ensure the caller always provides a message ID in the tool input, or (c) document and handle synthetic IDs on the frontend if this is intentional.
Risk: Chart artifacts may not display in the conversation if no real message was created yet, leading to a poor user experience.
Confidence: 1.00
| Ok(()) | ||
| } | ||
|
|
||
| pub async fn replace_parts( |
There was a problem hiding this comment.
[MEDIUM] Missing test coverage for new DB functions
replace_parts and merge_chart_artifact_part are untested; the new parts_json field is only exercised with None in existing tests.
Suggestion: Add mod tests cases for replace_parts (null parts, valid JSON, empty string) and for merge_chart_artifact_part (message not found, empty parts, merging into existing text/chart parts, serialization failure).
Risk: Regressions in parts merging logic may not be caught until manual testing; serialization errors could cause unexpected runtime failures.
Confidence: 0.85
| /// Validates the complete render data flow: tool_call persistence, | ||
| /// chart artifact merge into message parts, and expected DB state. | ||
| #[tokio::test] | ||
| async fn test_render_tool_persists_tool_call_and_chart_artifact() { |
There was a problem hiding this comment.
[MEDIUM] No test for concurrent or out-of-order tool call updates
Lack of concurrency/ordering tests for render tool call persistence
Suggestion: Add a test that simulates two overlapping render tool calls or out-of-order (requested/processing/completed) updates to ensure idempotency and correct DB state.
Risk: Race conditions or inconsistent tool_call statuses under concurrent agent execution may go undetected.
Confidence: 0.85
| run_id: None, | ||
| role: "system".to_string(), | ||
| content_markdown: "Context is now reset".to_string(), | ||
| parts_json: None, |
There was a problem hiding this comment.
[LOW] Missing DB fallback after persist_clear_context_reset_to_pool
The diff adds parts_json: None to all MessageInsert struct instantiations across several files, which is consistent. However, the struct likely already had a parts_json field. There is no evidence that this is wrong, but the diff does not show the struct definition update. If the field was newly added elsewhere, this is fine; if it is an older field, the diff simply ensures consistency.
Suggestion: Verify that MessageInsert already defines parts_json (possibly Option<serde_json::Value>). If it doesn't, define it before merging.
Risk: Minor compilation error if the struct doesn't include this field, but the existing CI typecheck should catch it.
Confidence: 0.85
| "INSERT INTO messages (id, thread_id, run_id, role, content_markdown, | ||
| message_type, status, metadata_json, attachments_json, created_at) | ||
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", | ||
| parts_json, message_type, status, metadata_json, attachments_json, created_at) |
There was a problem hiding this comment.
[LOW] INSERT parameter count mismatch in comment vs actual bind arguments
The SQL INSERT statement now accepts 11 columns/values after adding parts_json, but the diff shows the old pattern. Ensure the new count is correct and verify the VALUES list is aligned.
Suggestion: No runtime bug; just verify that the VALUES clause actually has 11 placeholders in the final file. The diff suggests the code is correct, but a quick manual check is good hygiene.
Risk: Minimal — if the VALUES list were accidentally left with 10 placeholders, it would be a compile/test blocker; this seems cosmetic.
Confidence: 0.90
| children, | ||
| className, | ||
| }: WorkbenchPreviewOverlayProps) { | ||
| if (!open) return null; |
There was a problem hiding this comment.
[LOW] WorkbenchPreviewOverlay renders DOM nodes even when closed (conditional return vs CSS)
The overlay correctly avoids rendering any DOM when open is false, minimizing memory footprint.
Suggestion: None; this is the optimal approach.
Risk: None.
Confidence: 0.85
Summary
Introduce a complete "model-driven dynamic chart preview" capability for thread messages, enabling the agent to generate interactive Vega-Lite visualizations inline within the conversation flow.
Changes
Backend (Rust)
parts_jsoncolumn to messages table (new migration)MessageRecord/MessageDtowith structured parts fieldreplace_parts()andmerge_chart_artifact_part()for chart persistenceThreadStreamEvent::ArtifactUpdatedvariant for streaming chart updatesrender_chartin runtime tool profile with full Vega-Lite schemaexecute_render_chart()with artifact lifecycle (started → completed)Frontend (TypeScript/React)
MessagePartDtounion types (text, chart, data-*, unknown)mapMessageParts()with backward-compatiblecontentMarkdownfallbackLongMessageBodyto render by part type (text → markdown, chart → card)vega-embedwith dark theme, SVG, responsive widthvalidateSpec()(512KB size limit, 50K data points),ChartErrorBoundaryfor crash isolationArtifactEvent→onArtifact→mergeArtifactPartIntoMessage()for live updatesDependencies
vega,vega-lite,vega-embedfor declarative chart renderingMotivation
Enable the agent to produce rich data visualizations (distributions, comparisons, statistical plots) directly in the thread message flow — similar to the generative chart demo UI pattern — while maintaining security (no arbitrary JS execution), persistence (survives refresh), and progressive rendering (loading → ready).
Testing
npm run typecheck— zero errorsnpm run test:unit— 544 tests passedcargo fmt --check— cleancargo test --locked— all crates passBreaking Changes
None. The
contentMarkdownfield is preserved for backward compatibility;partsis additive.Checklist
🤖 Generated with TiyCode