diff --git a/docs/data-file-preview.png b/docs/data-file-preview.png
new file mode 100644
index 00000000..ca496e17
Binary files /dev/null and b/docs/data-file-preview.png differ
diff --git a/docs/marimo-embed.png b/docs/marimo-embed.png
new file mode 100644
index 00000000..7615c1e2
Binary files /dev/null and b/docs/marimo-embed.png differ
diff --git a/package.json b/package.json
index 7cb844f2..e920921c 100644
--- a/package.json
+++ b/package.json
@@ -36,9 +36,12 @@
"@zed-industries/codex-acp": "^0.9.5",
"better-sqlite3": "^12.6.2",
"cron-parser": "^5.5.0",
+ "hyparquet": "^1.25.1",
"js-yaml": "^4.1.1",
+ "papaparse": "^5.5.3",
"undici": "^7.21.0",
- "ws": "^8.19.0"
+ "ws": "^8.19.0",
+ "xlsx": "^0.18.5"
},
"devDependencies": {
"@electron-toolkit/preload": "^3.0.1",
@@ -62,6 +65,7 @@
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^7.6.13",
"@types/js-yaml": "^4.0.9",
+ "@types/papaparse": "^5.5.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ws": "^8.18.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 520eb2f4..3985f0a3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -41,15 +41,24 @@ importers:
cron-parser:
specifier: ^5.5.0
version: 5.5.0
+ hyparquet:
+ specifier: ^1.25.1
+ version: 1.25.1
js-yaml:
specifier: ^4.1.1
version: 4.1.1
+ papaparse:
+ specifier: ^5.5.3
+ version: 5.5.3
undici:
specifier: ^7.21.0
version: 7.21.0
ws:
specifier: ^8.19.0
version: 8.19.0
+ xlsx:
+ specifier: ^0.18.5
+ version: 0.18.5
devDependencies:
'@electron-toolkit/preload':
specifier: ^3.0.1
@@ -114,6 +123,9 @@ importers:
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
+ '@types/papaparse':
+ specifier: ^5.5.2
+ version: 5.5.2
'@types/react':
specifier: ^19.0.0
version: 19.2.13
@@ -1883,6 +1895,9 @@ packages:
'@types/node@25.2.3':
resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
+ '@types/papaparse@5.5.2':
+ resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
+
'@types/phoenix@1.6.7':
resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==}
@@ -2137,6 +2152,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ adler-32@1.3.1:
+ resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
+ engines: {node: '>=0.8'}
+
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -2388,6 +2407,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+ cfb@1.2.2:
+ resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
+ engines: {node: '>=0.8'}
+
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@@ -2460,6 +2483,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
+ codepage@1.15.0:
+ resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
+ engines: {node: '>=0.8'}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -2980,6 +3007,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
+ frac@1.1.2:
+ resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
+ engines: {node: '>=0.8'}
+
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -3182,6 +3213,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ hyparquet@1.25.1:
+ resolution: {integrity: sha512-CXcN/u6RdQqsK8IphUptpAEqY8IzgwzHY+MuXX+2wpoWTumfxPVr6JYbbywsNsiAl9aEbM5sRtxkwRBa22b49w==}
+
iceberg-js@0.8.1:
resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
engines: {node: '>=20.0.0'}
@@ -3944,6 +3978,9 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
+ papaparse@5.5.3:
+ resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
+
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -4396,6 +4433,10 @@ packages:
sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
+ ssf@0.11.2:
+ resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
+ engines: {node: '>=0.8'}
+
ssri@9.0.1:
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -4816,10 +4857,18 @@ packages:
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
+ wmf@1.0.2:
+ resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
+ engines: {node: '>=0.8'}
+
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ word@0.3.0:
+ resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
+ engines: {node: '>=0.8'}
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -4855,6 +4904,11 @@ packages:
utf-8-validate:
optional: true
+ xlsx@0.18.5:
+ resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+ engines: {node: '>=0.8'}
+ hasBin: true
+
xmlbuilder@15.1.1:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
@@ -6473,7 +6527,7 @@ snapshots:
dependencies:
'@types/http-cache-semantics': 4.2.0
'@types/keyv': 3.1.4
- '@types/node': 20.19.32
+ '@types/node': 25.2.3
'@types/responselike': 1.0.3
'@types/chai@5.2.3':
@@ -6527,7 +6581,7 @@ snapshots:
'@types/keyv@3.1.4':
dependencies:
- '@types/node': 20.19.32
+ '@types/node': 25.2.3
'@types/mdast@4.0.4':
dependencies:
@@ -6547,6 +6601,10 @@ snapshots:
dependencies:
undici-types: 7.16.0
+ '@types/papaparse@5.5.2':
+ dependencies:
+ '@types/node': 25.2.3
+
'@types/phoenix@1.6.7': {}
'@types/plist@3.0.5':
@@ -6565,7 +6623,7 @@ snapshots:
'@types/responselike@1.0.3':
dependencies:
- '@types/node': 20.19.32
+ '@types/node': 25.2.3
'@types/unist@2.0.11': {}
@@ -6867,6 +6925,8 @@ snapshots:
acorn@8.16.0: {}
+ adler-32@1.3.1: {}
+
agent-base@6.0.2:
dependencies:
debug: 4.4.3
@@ -7205,6 +7265,11 @@ snapshots:
ccount@2.0.1: {}
+ cfb@1.2.2:
+ dependencies:
+ adler-32: 1.3.1
+ crc-32: 1.2.2
+
chai@6.2.2: {}
chalk@4.1.2:
@@ -7262,6 +7327,8 @@ snapshots:
clsx@2.1.1: {}
+ codepage@1.15.0: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -7884,6 +7951,8 @@ snapshots:
forwarded@0.2.0: {}
+ frac@1.1.2: {}
+
fresh@2.0.0: {}
fs-constants@1.0.0: {}
@@ -8152,6 +8221,8 @@ snapshots:
husky@9.1.7: {}
+ hyparquet@1.25.1: {}
+
iceberg-js@0.8.1: {}
iconv-corefoundation@1.1.7:
@@ -9040,6 +9111,8 @@ snapshots:
package-json-from-dist@1.0.1: {}
+ papaparse@5.5.3: {}
+
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -9574,6 +9647,10 @@ snapshots:
sprintf-js@1.1.3:
optional: true
+ ssf@0.11.2:
+ dependencies:
+ frac: 1.1.2
+
ssri@9.0.1:
dependencies:
minipass: 3.3.6
@@ -9996,8 +10073,12 @@ snapshots:
dependencies:
string-width: 4.2.3
+ wmf@1.0.2: {}
+
word-wrap@1.2.5: {}
+ word@0.3.0: {}
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -10016,6 +10097,16 @@ snapshots:
ws@8.19.0: {}
+ xlsx@0.18.5:
+ dependencies:
+ adler-32: 1.3.1
+ cfb: 1.2.2
+ codepage: 1.15.0
+ crc-32: 1.2.2
+ ssf: 0.11.2
+ wmf: 1.0.2
+ word: 0.3.0
+
xmlbuilder@15.1.1: {}
y18n@5.0.8: {}
diff --git a/scripts/remotion/DataFilePreviewScreenshot.tsx b/scripts/remotion/DataFilePreviewScreenshot.tsx
new file mode 100644
index 00000000..6a561576
--- /dev/null
+++ b/scripts/remotion/DataFilePreviewScreenshot.tsx
@@ -0,0 +1,206 @@
+import React from 'react'
+import { AbsoluteFill } from 'remotion'
+
+// Sample data to showcase the DataFilePreview component
+const sampleColumns = ['name', 'email', 'department', 'salary', 'start_date', 'status']
+const sampleRows = [
+ { name: 'Alice Chen', email: 'alice@acme.co', department: 'Engineering', salary: 145000, start_date: '2023-01-15', status: 'Active' },
+ { name: 'Bob Martinez', email: 'bob@acme.co', department: 'Marketing', salary: 98000, start_date: '2022-06-20', status: 'Active' },
+ { name: 'Carol Patel', email: 'carol@acme.co', department: 'Engineering', salary: 152000, start_date: '2021-11-03', status: 'Active' },
+ { name: 'David Kim', email: 'david@acme.co', department: 'Sales', salary: 115000, start_date: '2023-03-08', status: 'On Leave' },
+ { name: 'Eva Johnson', email: 'eva@acme.co', department: 'Engineering', salary: 138000, start_date: '2022-09-14', status: 'Active' },
+ { name: 'Frank Liu', email: 'frank@acme.co', department: 'Design', salary: 105000, start_date: '2024-01-22', status: 'Active' },
+ { name: 'Grace Wilson', email: 'grace@acme.co', department: 'Engineering', salary: 160000, start_date: '2020-04-10', status: 'Active' },
+ { name: 'Henry Brown', email: 'henry@acme.co', department: 'Marketing', salary: 92000, start_date: '2023-07-01', status: 'Active' },
+]
+
+// Simulated chat message context
+function ToolCallHeader() {
+ return (
+
+
+
+
+
Read
+
— employees.csv
+
+
+
+
+
+ )
+}
+
+function DataTable() {
+ return (
+
+ {/* Table Header Bar */}
+
+
+
+
+
+
+
+
+
+ employees.csv
+ 1,247 rows · 6 cols
+
+
+
+
+ {/* Table */}
+
+
+
+ #
+ {sampleColumns.map((col) => (
+
+
+ {col}
+
+
+
+
+
+ ))}
+
+
+
+ {sampleRows.map((row, i) => (
+
+ {i + 1}
+ {row.name}
+ {row.email}
+ {row.department}
+ {row.salary.toLocaleString()}
+ {row.start_date}
+ {row.status}
+
+ ))}
+
+
+
+ {/* Footer */}
+
+ Showing 8 of 1,247 rows (truncated)
+ View full table →
+
+
+ )
+}
+
+export const DataFilePreviewScreenshot: React.FC = () => {
+ return (
+
+ {/* Title */}
+
+
+
+
+
+
+
+
+ Data File Viewer
+
+ inline preview
+
+
+
+ {/* Chat context - tool call message */}
+
+
+
+ {/* Embedded Data Preview */}
+
+
+
+
+ {/* Timestamp */}
+
+ 2:34:12 PM
+ 1.2s · in:450 out:120
+
+
+
+ {/* Feature callouts */}
+
+ {[
+ { label: 'Sort columns', desc: 'Click headers to sort' },
+ { label: 'Fullscreen', desc: 'Expand for deep analysis' },
+ { label: 'Auto-detect', desc: 'CSV, JSON, Excel, TSV' },
+ { label: 'Filter rows', desc: 'Search across all columns' },
+ ].map((feat) => (
+
+
{feat.label}
+
{feat.desc}
+
+ ))}
+
+
+ )
+}
diff --git a/scripts/remotion/MarimoEmbedScreenshot.tsx b/scripts/remotion/MarimoEmbedScreenshot.tsx
new file mode 100644
index 00000000..286b1e32
--- /dev/null
+++ b/scripts/remotion/MarimoEmbedScreenshot.tsx
@@ -0,0 +1,329 @@
+import React from 'react'
+import { AbsoluteFill } from 'remotion'
+
+// Render all states of MarimoEmbed as they appear inline in the chat
+
+function IdleState() {
+ return (
+
+
+ {/* Code2 icon */}
+
+
+
+
analysis_dashboard.py
+
marimo notebook
+
+
+
+ )
+}
+
+function LaunchingState() {
+ return (
+
+
+ {/* Spinner (static representation) */}
+
+
+
+
Starting marimo run server...
+
+
+ )
+}
+
+function RunningState() {
+ return (
+
+ {/* Toolbar */}
+
+
+
+
+
analysis_dashboard.py
+
+
+ running
+
+
+ {/* Edit button */}
+
+ {/* External link */}
+
+ {/* Fullscreen */}
+
+ {/* Stop */}
+
+
+
+
+
+
+
+
+ {/* Fake marimo iframe content */}
+
+ {/* Marimo app header */}
+
+ Sales Analytics Dashboard
+
+
+ {/* Simulated marimo cells */}
+
+ {/* Chart cell */}
+
+
Revenue by Quarter
+
+ {[60, 85, 72, 95, 110, 88, 120, 105].map((h, i) => (
+
+ ))}
+
+
+ {['Q1', 'Q2', 'Q3', 'Q4', 'Q1', 'Q2', 'Q3', 'Q4'].map((q, i) => (
+ {q}
+ ))}
+
+
+
+ {/* Stats cell */}
+
+ {[
+ { label: 'Total Revenue', value: '$2.4M', change: '+12%', color: '#3fb950' },
+ { label: 'Active Users', value: '14,832', change: '+8%', color: '#3fb950' },
+ { label: 'Churn Rate', value: '2.1%', change: '-0.3%', color: '#3fb950' },
+ ].map((stat) => (
+
+
{stat.label}
+
+ {stat.value}
+ {stat.change}
+
+
+ ))}
+
+
+
+ {/* Data table cell */}
+
+
mo.ui.dataframe(df)
+
+ {['Product', 'Revenue', 'Growth', 'Region'].map((col) => (
+ {col}
+ ))}
+
+ {[
+ ['Enterprise SaaS', '$890K', '+18%', 'APAC'],
+ ['Developer Tools', '$620K', '+24%', 'NA'],
+ ].map((row, i) => (
+
+ {row.map((cell, j) => (
+ {cell}
+ ))}
+
+ ))}
+
+
+
+ )
+}
+
+function NotInstalledState() {
+ return (
+
+
+
+
+
+
+
+ analysis_dashboard.py
+
+ marimo is not installed. Run: pip install marimo
+
+
+
+
+ )
+}
+
+function ToolCallWrapper({ children, toolName }: { children: React.ReactNode; toolName: string }) {
+ return (
+
+ {/* Tool call header */}
+
+
+
+
+
+
+
+
{toolName}
+
— analysis_dashboard.py
+
+
+
+
+
+ {/* Marimo embed below tool call */}
+
+ {children}
+
+
+ {/* Timestamp */}
+
+ 2:41:08 PM · 3.2s
+
+
+ )
+}
+
+export const MarimoEmbedScreenshot: React.FC = () => {
+ return (
+
+ {/* Title */}
+
+
+
+
+
Marimo Embed — Inline in Agent Chat
+
+
+ {/* State 1: Agent writes file → idle state with Run button */}
+ Agent writes a marimo notebook → detected automatically:
+
+
+
+
+ {/* State 2: Running with live iframe */}
+ User clicks Run → live marimo app embedded inline:
+
+
+ )
+}
diff --git a/scripts/remotion/Root.tsx b/scripts/remotion/Root.tsx
new file mode 100644
index 00000000..5896f5cf
--- /dev/null
+++ b/scripts/remotion/Root.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import { Composition } from 'remotion'
+import { DataFilePreviewScreenshot } from './DataFilePreviewScreenshot'
+import { MarimoEmbedScreenshot } from './MarimoEmbedScreenshot'
+
+export const RemotionRoot: React.FC = () => {
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/scripts/remotion/index.ts b/scripts/remotion/index.ts
new file mode 100644
index 00000000..3e5b3f9a
--- /dev/null
+++ b/scripts/remotion/index.ts
@@ -0,0 +1,4 @@
+import { registerRoot } from 'remotion'
+import { RemotionRoot } from './Root'
+
+registerRoot(RemotionRoot)
diff --git a/scripts/remotion/render-screenshot.mjs b/scripts/remotion/render-screenshot.mjs
new file mode 100644
index 00000000..57347d84
--- /dev/null
+++ b/scripts/remotion/render-screenshot.mjs
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+import { bundle } from '@remotion/bundler'
+import { renderStill } from '@remotion/renderer'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+const compositions = [
+ { id: 'DataFilePreview', width: 1200, height: 700, output: 'data-file-preview.png' },
+ { id: 'MarimoEmbed', width: 1200, height: 900, output: 'marimo-embed.png' },
+]
+
+async function main() {
+ const target = process.argv[2] // optional: render only one
+
+ console.log('Bundling Remotion project...')
+ const bundled = await bundle({
+ entryPoint: path.join(__dirname, 'index.ts'),
+ webpackOverride: (config) => config,
+ })
+
+ for (const comp of compositions) {
+ if (target && comp.id !== target) continue
+
+ const outputPath = path.join(__dirname, '..', '..', 'docs', comp.output)
+
+ console.log(`Rendering ${comp.id}...`)
+ await renderStill({
+ composition: {
+ id: comp.id,
+ durationInFrames: 1,
+ fps: 1,
+ width: comp.width,
+ height: comp.height,
+ defaultProps: {},
+ defaultCodec: null,
+ },
+ serveUrl: bundled,
+ output: outputPath,
+ frame: 0,
+ })
+
+ console.log(` → ${outputPath}`)
+ }
+
+ console.log('Done!')
+}
+
+main().catch((err) => {
+ console.error(err)
+ process.exit(1)
+})
diff --git a/src/main/index.ts b/src/main/index.ts
index 65969cf7..abaabd72 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -68,6 +68,12 @@ async function shutdownAppServices(): Promise {
// pkill exits 1 if no processes matched — that's fine
}
+ // Stop all marimo server instances
+ try {
+ const { stopAllMarimo } = await import('./marimo-server')
+ stopAllMarimo()
+ } catch { /* marimo module may not be loaded */ }
+
db?.close()
if (tray) {
diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts
index ac674388..71f41616 100644
--- a/src/main/ipc-handlers.ts
+++ b/src/main/ipc-handlers.ts
@@ -3,6 +3,8 @@ import { copyFileSync, existsSync, unlinkSync, readdirSync, statSync, readFileSy
import { execFile } from 'child_process'
import { promisify } from 'util'
import { join, basename, extname } from 'path'
+import Papa from 'papaparse'
+import * as XLSX from 'xlsx'
const execFileAsync = promisify(execFile)
import type {
@@ -178,6 +180,14 @@ export function registerIpcHandlers(
return db.getWorkspaceDir(taskId)
})
+ ipcMain.handle('attachments:resolvePath', (_, taskId: string, attachmentId: string): string | null => {
+ const dir = db.getAttachmentsDir(taskId)
+ if (!existsSync(dir)) return null
+ const files = readdirSync(dir)
+ const match = files.find((f) => f.startsWith(`${attachmentId}-`))
+ return match ? join(dir, match) : null
+ })
+
ipcMain.handle('attachments:open', (_, taskId: string, attachmentId: string) => {
console.log('[IPC] attachments:open called:', { taskId, attachmentId })
@@ -267,11 +277,120 @@ export function registerIpcHandlers(
await shell.openExternal(url)
})
+ // ── File Viewer handlers ──────────────────────────────────────────────────
+ const TABULAR_EXTENSIONS = new Set(['.csv', '.tsv', '.json', '.jsonl', '.xlsx', '.xls', '.parquet'])
+
+ ipcMain.handle('fileViewer:getFileInfo', (_, filePath: string): { exists: boolean; size: number; extension: string; isTabular: boolean } => {
+ if (!existsSync(filePath)) return { exists: false, size: 0, extension: '', isTabular: false }
+ const stat = statSync(filePath)
+ const ext = extname(filePath).toLowerCase()
+ return { exists: true, size: stat.size, extension: ext, isTabular: TABULAR_EXTENSIONS.has(ext) }
+ })
+
+ ipcMain.handle(
+ 'fileViewer:readTabularFile',
+ (_, filePath: string, limit?: number): { columns: string[]; rows: Record[]; totalRows: number; truncated: boolean; filePath: string } | { error: string } => {
+ const maxRows = limit ?? 500
+ if (!existsSync(filePath)) return { error: 'File not found' }
+
+ const ext = extname(filePath).toLowerCase()
+ try {
+ if (ext === '.csv' || ext === '.tsv') {
+ const content = readFileSync(filePath, 'utf-8')
+ const result = Papa.parse(content, {
+ header: true,
+ delimiter: ext === '.tsv' ? '\t' : undefined,
+ skipEmptyLines: true,
+ preview: maxRows
+ })
+ const columns = result.meta.fields || []
+ const rows = result.data as Record[]
+ // Count total lines for the footer
+ let totalRows = rows.length
+ if (rows.length === maxRows) {
+ // Estimate total by counting newlines
+ totalRows = content.split('\n').filter(l => l.trim()).length - 1 // minus header
+ }
+ return { columns, rows, totalRows, truncated: rows.length < totalRows, filePath }
+ }
+
+ if (ext === '.json') {
+ const content = readFileSync(filePath, 'utf-8')
+ let parsed = JSON.parse(content)
+ if (!Array.isArray(parsed)) {
+ // If it's a single object, wrap it
+ parsed = [parsed]
+ }
+ const totalRows = parsed.length
+ const rows = parsed.slice(0, maxRows) as Record[]
+ const columns = rows.length > 0 ? Object.keys(rows[0]) : []
+ return { columns, rows, totalRows, truncated: totalRows > maxRows, filePath }
+ }
+
+ if (ext === '.jsonl') {
+ const content = readFileSync(filePath, 'utf-8')
+ const lines = content.split('\n').filter(l => l.trim())
+ const totalRows = lines.length
+ const rows = lines.slice(0, maxRows).map(l => JSON.parse(l)) as Record[]
+ const columns = rows.length > 0 ? Object.keys(rows[0]) : []
+ return { columns, rows, totalRows, truncated: totalRows > maxRows, filePath }
+ }
+
+ if (ext === '.xlsx' || ext === '.xls') {
+ const workbook = XLSX.read(readFileSync(filePath), { type: 'buffer' })
+ const sheetName = workbook.SheetNames[0]
+ if (!sheetName) return { error: 'No sheets found in workbook' }
+ const sheet = workbook.Sheets[sheetName]
+ const allRows = XLSX.utils.sheet_to_json(sheet) as Record[]
+ const totalRows = allRows.length
+ const rows = allRows.slice(0, maxRows)
+ const columns = rows.length > 0 ? Object.keys(rows[0]) : []
+ return { columns, rows, totalRows, truncated: totalRows > maxRows, filePath }
+ }
+
+ if (ext === '.parquet') {
+ // Parquet requires async — for now return a helpful error
+ return { error: 'Parquet support coming soon. Convert to CSV or JSON for preview.' }
+ }
+
+ return { error: `Unsupported file type: ${ext}` }
+ } catch (err) {
+ return { error: `Failed to parse file: ${err instanceof Error ? err.message : String(err)}` }
+ }
+ }
+ )
+
// Notification handler
ipcMain.handle('notifications:show', (_, title: string, body: string) => {
new Notification({ title, body }).show()
})
+ // ── Marimo handlers ────────────────────────────────────────────────────
+ ipcMain.handle('marimo:check', async () => {
+ const { checkMarimo } = await import('./marimo-server')
+ return checkMarimo()
+ })
+
+ ipcMain.handle('marimo:isNotebook', async (_, filePath: string) => {
+ const { isMarimoNotebook } = await import('./marimo-server')
+ return isMarimoNotebook(filePath)
+ })
+
+ ipcMain.handle('marimo:launch', async (_, filePath: string, mode?: 'run' | 'edit') => {
+ const { launchMarimo } = await import('./marimo-server')
+ return launchMarimo(filePath, mode || 'run')
+ })
+
+ ipcMain.handle('marimo:stop', async (_, filePath: string) => {
+ const { stopMarimo } = await import('./marimo-server')
+ return stopMarimo(filePath)
+ })
+
+ ipcMain.handle('marimo:status', async (_, filePath: string) => {
+ const { getMarimoStatus } = await import('./marimo-server')
+ return getMarimoStatus(filePath)
+ })
+
// Agent handlers
ipcMain.handle('agent:getAll', () => {
return db.getAgents()
diff --git a/src/main/marimo-server.ts b/src/main/marimo-server.ts
new file mode 100644
index 00000000..af6d6045
--- /dev/null
+++ b/src/main/marimo-server.ts
@@ -0,0 +1,212 @@
+/**
+ * Manages marimo notebook server instances.
+ *
+ * When the agent writes a .py file that contains `import marimo`,
+ * we spin up `marimo run --headless --no-token --port `
+ * and expose the URL so the renderer can embed it in an iframe.
+ */
+import { spawn, execFile } from 'child_process'
+import { promisify } from 'util'
+import { existsSync, readFileSync } from 'fs'
+import type { ChildProcess } from 'child_process'
+
+const execFileAsync = promisify(execFile)
+
+interface MarimoInstance {
+ port: number
+ process: ChildProcess
+ filePath: string
+ url: string
+}
+
+// Track all running marimo instances by file path
+const instances = new Map()
+let marimoPath: string | null = null
+let marimoChecked = false
+
+/**
+ * Check if marimo is installed and return its path
+ */
+export async function checkMarimo(): Promise<{ installed: boolean; path: string | null; version: string | null }> {
+ if (marimoChecked && marimoPath) {
+ return { installed: true, path: marimoPath, version: null }
+ }
+
+ // Try common locations
+ const candidates = ['marimo', '/usr/local/bin/marimo', '/opt/homebrew/bin/marimo']
+
+ // Also check if it's in a pipx or pip --user location
+ const home = process.env.HOME || process.env.USERPROFILE || ''
+ if (home) {
+ candidates.push(`${home}/.local/bin/marimo`)
+ candidates.push(`${home}/Library/Python/3.11/bin/marimo`)
+ candidates.push(`${home}/Library/Python/3.12/bin/marimo`)
+ candidates.push(`${home}/Library/Python/3.13/bin/marimo`)
+ }
+
+ for (const candidate of candidates) {
+ try {
+ const { stdout } = await execFileAsync(candidate, ['--version'], { timeout: 5000 })
+ marimoPath = candidate
+ marimoChecked = true
+ const version = stdout.trim().replace('marimo ', '')
+ console.log(`[marimo] Found marimo at ${candidate} (v${version})`)
+ return { installed: true, path: candidate, version }
+ } catch {
+ // Not found at this path, try next
+ }
+ }
+
+ marimoChecked = true
+ return { installed: false, path: null, version: null }
+}
+
+/**
+ * Detect whether a .py file is a marimo notebook
+ */
+export function isMarimoNotebook(filePath: string): boolean {
+ if (!filePath.endsWith('.py')) return false
+ if (!existsSync(filePath)) return false
+
+ try {
+ // Read first 2KB to check for marimo markers
+ const content = readFileSync(filePath, 'utf-8').slice(0, 2048)
+ return (
+ content.includes('import marimo') ||
+ content.includes('marimo.App') ||
+ content.includes('@app.cell')
+ )
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Find a free port in a range
+ */
+function getRandomPort(): number {
+ return 2718 + Math.floor(Math.random() * 1000)
+}
+
+/**
+ * Launch a marimo server for a notebook file.
+ * Returns the URL to embed in an iframe.
+ */
+export async function launchMarimo(
+ filePath: string,
+ mode: 'run' | 'edit' = 'run'
+): Promise<{ url: string; port: number; pid: number }> {
+ // If already running for this file, return existing
+ const existing = instances.get(filePath)
+ if (existing) {
+ return { url: existing.url, port: existing.port, pid: existing.process.pid! }
+ }
+
+ const marimoStatus = await checkMarimo()
+ if (!marimoStatus.installed || !marimoStatus.path) {
+ throw new Error('marimo is not installed. Install it with: pip install marimo')
+ }
+
+ if (!existsSync(filePath)) {
+ throw new Error(`File not found: ${filePath}`)
+ }
+
+ const port = getRandomPort()
+ const args = [
+ mode,
+ '--headless',
+ '--no-token',
+ '--port', String(port),
+ '--host', '127.0.0.1',
+ filePath
+ ]
+
+ console.log(`[marimo] Launching: ${marimoStatus.path} ${args.join(' ')}`)
+
+ const proc = spawn(marimoStatus.path, args, {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ detached: false,
+ })
+
+ const url = `http://127.0.0.1:${port}`
+
+ const instance: MarimoInstance = {
+ port,
+ process: proc,
+ filePath,
+ url,
+ }
+
+ instances.set(filePath, instance)
+
+ // Log output for debugging
+ proc.stdout?.on('data', (data: Buffer) => {
+ console.log(`[marimo:${port}] ${data.toString().trim()}`)
+ })
+ proc.stderr?.on('data', (data: Buffer) => {
+ console.log(`[marimo:${port}:err] ${data.toString().trim()}`)
+ })
+
+ // Clean up on exit
+ proc.on('exit', (code) => {
+ console.log(`[marimo:${port}] Process exited with code ${code}`)
+ instances.delete(filePath)
+ })
+
+ // Wait for the server to be ready by polling
+ await waitForServer(url, 15000)
+
+ console.log(`[marimo] Server ready at ${url} (pid: ${proc.pid})`)
+ return { url, port, pid: proc.pid! }
+}
+
+/**
+ * Stop a marimo server instance
+ */
+export function stopMarimo(filePath: string): boolean {
+ const instance = instances.get(filePath)
+ if (!instance) return false
+
+ console.log(`[marimo] Stopping server for ${filePath} (pid: ${instance.process.pid})`)
+ instance.process.kill('SIGTERM')
+ instances.delete(filePath)
+ return true
+}
+
+/**
+ * Stop all running marimo instances (call on app quit)
+ */
+export function stopAllMarimo(): void {
+ for (const [filePath, instance] of instances) {
+ console.log(`[marimo] Stopping server for ${filePath}`)
+ instance.process.kill('SIGTERM')
+ }
+ instances.clear()
+}
+
+/**
+ * Get status of a running marimo instance
+ */
+export function getMarimoStatus(filePath: string): { running: boolean; url: string | null; port: number | null } {
+ const instance = instances.get(filePath)
+ if (!instance) return { running: false, url: null, port: null }
+ return { running: true, url: instance.url, port: instance.port }
+}
+
+/**
+ * Poll a URL until it responds (server startup)
+ */
+async function waitForServer(url: string, timeoutMs: number): Promise {
+ const start = Date.now()
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const response = await fetch(url, { signal: AbortSignal.timeout(1000) })
+ if (response.ok || response.status === 200) return
+ } catch {
+ // Server not ready yet
+ }
+ await new Promise((r) => setTimeout(r, 300))
+ }
+ // Don't throw — server may still be starting, let the iframe handle it
+ console.log(`[marimo] Server at ${url} did not respond within ${timeoutMs}ms, proceeding anyway`)
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 9eb7c7a5..8e958a6d 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -24,7 +24,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
open: (taskId: string, attachmentId: string): Promise =>
ipcRenderer.invoke('attachments:open', taskId, attachmentId),
download: (taskId: string, attachmentId: string): Promise =>
- ipcRenderer.invoke('attachments:download', taskId, attachmentId)
+ ipcRenderer.invoke('attachments:download', taskId, attachmentId),
+ resolvePath: (taskId: string, attachmentId: string): Promise =>
+ ipcRenderer.invoke('attachments:resolvePath', taskId, attachmentId)
},
shell: {
openPath: (filePath: string): Promise =>
@@ -36,6 +38,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
openExternal: (url: string): Promise =>
ipcRenderer.invoke('shell:openExternal', url)
},
+ fileViewer: {
+ getFileInfo: (filePath: string): Promise<{ exists: boolean; size: number; extension: string; isTabular: boolean }> =>
+ ipcRenderer.invoke('fileViewer:getFileInfo', filePath),
+ readTabularFile: (filePath: string, limit?: number): Promise<{ columns: string[]; rows: Record[]; totalRows: number; truncated: boolean; filePath: string } | { error: string }> =>
+ ipcRenderer.invoke('fileViewer:readTabularFile', filePath, limit)
+ },
+ marimo: {
+ check: (): Promise<{ installed: boolean; path: string | null; version: string | null }> =>
+ ipcRenderer.invoke('marimo:check'),
+ isNotebook: (filePath: string): Promise =>
+ ipcRenderer.invoke('marimo:isNotebook', filePath),
+ launch: (filePath: string, mode?: 'run' | 'edit'): Promise<{ url: string; port: number; pid: number }> =>
+ ipcRenderer.invoke('marimo:launch', filePath, mode),
+ stop: (filePath: string): Promise =>
+ ipcRenderer.invoke('marimo:stop', filePath),
+ status: (filePath: string): Promise<{ running: boolean; url: string | null; port: number | null }> =>
+ ipcRenderer.invoke('marimo:status', filePath)
+ },
oauth: {
startFlow: (provider: string, config: Record): Promise =>
ipcRenderer.invoke('oauth:startFlow', provider, config),
diff --git a/src/renderer/src/components/agents/AgentTranscriptPanel.tsx b/src/renderer/src/components/agents/AgentTranscriptPanel.tsx
index 93d58fee..3885ccf9 100644
--- a/src/renderer/src/components/agents/AgentTranscriptPanel.tsx
+++ b/src/renderer/src/components/agents/AgentTranscriptPanel.tsx
@@ -3,9 +3,67 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { StopCircle, Loader2, Terminal, Send, ChevronRight, ChevronDown, Wrench, AlertTriangle, CheckCircle2, Circle, Clock, RotateCcw, Code2, Eye, ListTodo, FileText, ArrowDown } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Markdown } from '@/components/ui/Markdown'
+import { DataFilePreview } from '@/components/ui/DataFilePreview'
+import { DataFileDialog } from '@/components/ui/DataFileDialog'
+import { MarimoEmbed } from '@/components/ui/MarimoEmbed'
+import { marimoApi } from '@/lib/ipc-client'
import type { AgentMessage } from '@/hooks/use-agent-session'
import { SessionStatus } from '@/stores/agent-store'
+const TABULAR_EXT_RE = /\.(csv|tsv|json|jsonl|xlsx|xls|parquet)$/i
+
+function extractFilePath(toolInput: unknown): string | null {
+ if (!toolInput) return null
+ try {
+ const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput
+ return input.file_path || input.path || input.filePath || input.filename || null
+ } catch {
+ if (typeof toolInput === 'string') return toolInput
+ }
+ return null
+}
+
+function extractTabularFilePath(toolInput: unknown): string | null {
+ const path = extractFilePath(toolInput)
+ if (path && TABULAR_EXT_RE.test(path)) return path
+ return null
+}
+
+function extractMarimoFilePath(toolInput: unknown, toolOutput: unknown): string | null {
+ const path = extractFilePath(toolInput)
+ if (!path || !path.endsWith('.py')) return null
+
+ // Check tool output for marimo markers (content written by agent)
+ const output = typeof toolOutput === 'string' ? toolOutput : ''
+ const input = typeof toolInput === 'string' ? toolInput : JSON.stringify(toolInput || '')
+ const combined = output + input
+
+ if (
+ combined.includes('import marimo') ||
+ combined.includes('marimo.App') ||
+ combined.includes('@app.cell')
+ ) {
+ return path
+ }
+ return null
+}
+
+/**
+ * Hook that async-checks if a .py file is a marimo notebook (reads from disk).
+ * Used as fallback when content isn't visible in tool input/output.
+ */
+function useMarimoDetection(filePath: string | null, isCompleted: boolean): boolean {
+ const [isMarimo, setIsMarimo] = useState(false)
+ useEffect(() => {
+ if (!filePath || !isCompleted || !filePath.endsWith('.py')) {
+ setIsMarimo(false)
+ return
+ }
+ marimoApi.isNotebook(filePath).then(setIsMarimo).catch(() => setIsMarimo(false))
+ }, [filePath, isCompleted])
+ return isMarimo
+}
+
enum ViewMode {
MARKDOWN = 'markdown',
RAW = 'raw'
@@ -258,53 +316,100 @@ function PlanReviewMessage({ message }: { message: AgentMessage }) {
function ToolCallMessage({ message }: { message: AgentMessage }) {
const [expanded, setExpanded] = useState(false)
+ const [showDataDialog, setShowDataDialog] = useState(false)
const tool = message.tool!
const isRunning = !tool.status || tool.status === 'in_progress' || tool.status === 'running' || tool.status === 'pending'
const isError = tool.status === 'error' || tool.status === 'failed'
+ const isCompleted = tool.status === 'completed' || tool.status === 'done' || tool.status === 'success'
const subtitle = deriveToolSubtitle(tool)
+ // Detect tabular file from tool input
+ const tabularFilePath = useMemo(() => {
+ if (!isCompleted) return null
+ return extractTabularFilePath(tool.input)
+ }, [isCompleted, tool.input])
+
+ // Detect marimo notebook from tool input/output (sync check via content)
+ const marimoFilePathFromContent = useMemo(() => {
+ if (!isCompleted) return null
+ return extractMarimoFilePath(tool.input, tool.output)
+ }, [isCompleted, tool.input, tool.output])
+
+ // Async fallback: check disk for marimo markers if .py file was written
+ const pyFilePath = useMemo(() => {
+ if (!isCompleted || marimoFilePathFromContent) return null
+ const fp = extractFilePath(tool.input)
+ return fp?.endsWith('.py') ? fp : null
+ }, [isCompleted, tool.input, marimoFilePathFromContent])
+
+ const isMarimoFromDisk = useMarimoDetection(pyFilePath, isCompleted)
+ const marimoFilePath = marimoFilePathFromContent || (isMarimoFromDisk ? pyFilePath : null)
+
return (
-
-
setExpanded(!expanded)}
- className="w-full flex items-center gap-2 px-3 py-2 text-xs font-mono hover:bg-white/5 transition-colors"
- >
-
-
- {tool.name}
- {subtitle && {subtitle} }
- {isRunning && }
- {isError && }
-
- {expanded && (
-
- {tool.input && (
-
-
Input:
-
{sanitizeToolContent(tool.input)}
-
- )}
- {tool.output && (
-
-
Output:
-
{sanitizeToolContent(tool.output)}
-
- )}
- {tool.error && (
-
-
Error:
-
{tool.error}
-
- )}
-
- )}
-
-
{message.timestamp.toLocaleTimeString()}
- {message.stepMeta && (
-
{formatStepMeta(message.stepMeta)}
+ <>
+
+
setExpanded(!expanded)}
+ className="w-full flex items-center gap-2 px-3 py-2 text-xs font-mono hover:bg-white/5 transition-colors"
+ >
+
+
+ {tool.name}
+ {subtitle && {subtitle} }
+ {isRunning && }
+ {isError && }
+
+ {expanded && (
+
+ {tool.input && (
+
+
Input:
+
{sanitizeToolContent(tool.input)}
+
+ )}
+ {tool.output && (
+
+
Output:
+
{sanitizeToolContent(tool.output)}
+
+ )}
+ {tool.error && (
+
+
Error:
+
{tool.error}
+
+ )}
+
+ )}
+
+ {/* Inline marimo notebook embed */}
+ {marimoFilePath && (
+
+
+
+ )}
+
+ {/* Inline data file preview (non-marimo tabular files) */}
+ {!marimoFilePath && tabularFilePath && (
+
+ setShowDataDialog(true)}
+ />
+
)}
-
+
+ {/* Fullscreen data dialog */}
+ {tabularFilePath && !marimoFilePath && (
+
+ )}
+ >
)
}
diff --git a/src/renderer/src/components/tasks/TaskAttachments.tsx b/src/renderer/src/components/tasks/TaskAttachments.tsx
index 6fa00232..33582b69 100644
--- a/src/renderer/src/components/tasks/TaskAttachments.tsx
+++ b/src/renderer/src/components/tasks/TaskAttachments.tsx
@@ -1,8 +1,14 @@
import { useState, useCallback } from 'react'
-import { Paperclip, X, FileText, Download } from 'lucide-react'
+import { Paperclip, X, FileText, Download, Table2 } from 'lucide-react'
import { attachmentApi } from '@/lib/ipc-client'
+import { DataFileDialog } from '@/components/ui/DataFileDialog'
import type { FileAttachment } from '@/types'
+const TABULAR_EXT_RE = /\.(csv|tsv|json|jsonl|xlsx|xls|parquet)$/i
+function isTabularFile(filename: string): boolean {
+ return TABULAR_EXT_RE.test(filename)
+}
+
export interface PendingFile {
id: string
filename: string
@@ -35,6 +41,7 @@ export function TaskAttachments({
onPendingChange
}: TaskAttachmentsProps) {
const [isDragging, setIsDragging] = useState(false)
+ const [dataDialogPath, setDataDialogPath] = useState
(null)
const processFilePaths = useCallback(
async (filePaths: string[]) => {
@@ -112,8 +119,16 @@ export function TaskAttachments({
onPendingChange?.(pendingFiles.filter((f) => f.id !== id))
}
- const handleOpen = (attachment: FileAttachment) => {
- if (taskId) attachmentApi.open(taskId, attachment.id)
+ const handleOpen = async (attachment: FileAttachment) => {
+ if (!taskId) return
+ if (isTabularFile(attachment.filename)) {
+ const resolved = await attachmentApi.resolvePath(taskId, attachment.id)
+ if (resolved) {
+ setDataDialogPath(resolved)
+ return
+ }
+ }
+ attachmentApi.open(taskId, attachment.id)
}
const handleDownload = (attachment: FileAttachment) => {
@@ -151,7 +166,11 @@ export function TaskAttachments({
{items.map((a) => (
-
+ {isTabularFile(a.filename) ? (
+
+ ) : (
+
+ )}
handleOpen(a)}
@@ -210,6 +229,14 @@ export function TaskAttachments({
Add files
)}
+
+ {dataDialogPath && (
+
{ if (!open) setDataDialogPath(null) }}
+ filePath={dataDialogPath}
+ />
+ )}
)
}
diff --git a/src/renderer/src/components/ui/DataFileDialog.tsx b/src/renderer/src/components/ui/DataFileDialog.tsx
new file mode 100644
index 00000000..79691604
--- /dev/null
+++ b/src/renderer/src/components/ui/DataFileDialog.tsx
@@ -0,0 +1,35 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog'
+import { DataFilePreview } from '@/components/ui/DataFilePreview'
+import { Table2 } from 'lucide-react'
+
+interface DataFileDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ filePath: string
+}
+
+export function DataFileDialog({ open, onOpenChange, filePath }: DataFileDialogProps) {
+ const fileName = filePath.split('/').pop() || filePath.split('\\').pop() || filePath
+
+ return (
+
+
+
+
+
+
+ {fileName}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/ui/DataFilePreview.tsx b/src/renderer/src/components/ui/DataFilePreview.tsx
new file mode 100644
index 00000000..1e60d2c1
--- /dev/null
+++ b/src/renderer/src/components/ui/DataFilePreview.tsx
@@ -0,0 +1,241 @@
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
+import { Table2, Maximize2, ArrowUpDown, ArrowUp, ArrowDown, AlertTriangle, Loader2 } from 'lucide-react'
+import { fileViewerApi } from '@/lib/ipc-client'
+import type { TabularData } from '@/types/electron'
+
+interface DataFilePreviewProps {
+ filePath: string
+ maxRows?: number
+ compact?: boolean
+ onExpand?: () => void
+}
+
+type SortDir = 'asc' | 'desc' | null
+
+function formatNumber(n: number): string {
+ return n.toLocaleString()
+}
+
+function formatCellValue(value: unknown): string {
+ if (value == null) return ''
+ if (typeof value === 'object') return JSON.stringify(value)
+ return String(value)
+}
+
+export function DataFilePreview({ filePath, maxRows = 100, compact = true, onExpand }: DataFilePreviewProps) {
+ const [data, setData] = useState
(null)
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [sortCol, setSortCol] = useState(null)
+ const [sortDir, setSortDir] = useState(null)
+ const [filter, setFilter] = useState('')
+ const tableRef = useRef(null)
+
+ useEffect(() => {
+ let cancelled = false
+ setLoading(true)
+ setError(null)
+
+ fileViewerApi.readTabularFile(filePath, maxRows).then((result) => {
+ if (cancelled) return
+ if ('error' in result) {
+ setError(result.error)
+ } else {
+ setData(result)
+ }
+ setLoading(false)
+ }).catch((err) => {
+ if (cancelled) return
+ setError(err instanceof Error ? err.message : String(err))
+ setLoading(false)
+ })
+
+ return () => { cancelled = true }
+ }, [filePath, maxRows])
+
+ const handleSort = useCallback((col: string) => {
+ if (sortCol === col) {
+ setSortDir((prev) => prev === 'asc' ? 'desc' : prev === 'desc' ? null : 'asc')
+ if (sortDir === 'desc') setSortCol(null)
+ } else {
+ setSortCol(col)
+ setSortDir('asc')
+ }
+ }, [sortCol, sortDir])
+
+ const processedRows = useMemo(() => {
+ if (!data) return []
+ let rows = [...data.rows]
+
+ // Filter
+ if (filter) {
+ const lowerFilter = filter.toLowerCase()
+ rows = rows.filter((row) =>
+ data.columns.some((col) => {
+ const val = row[col]
+ return val != null && String(val).toLowerCase().includes(lowerFilter)
+ })
+ )
+ }
+
+ // Sort
+ if (sortCol && sortDir) {
+ rows.sort((a, b) => {
+ const aVal = a[sortCol]
+ const bVal = b[sortCol]
+ if (aVal == null && bVal == null) return 0
+ if (aVal == null) return 1
+ if (bVal == null) return -1
+
+ // Try numeric comparison
+ const aNum = Number(aVal)
+ const bNum = Number(bVal)
+ if (!isNaN(aNum) && !isNaN(bNum)) {
+ return sortDir === 'asc' ? aNum - bNum : bNum - aNum
+ }
+
+ // String comparison
+ const aStr = String(aVal)
+ const bStr = String(bVal)
+ return sortDir === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr)
+ })
+ }
+
+ return rows
+ }, [data, sortCol, sortDir, filter])
+
+ const fileName = filePath.split('/').pop() || filePath.split('\\').pop() || filePath
+
+ if (loading) {
+ return (
+
+
+
+ Loading {fileName}...
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (!data || data.rows.length === 0) {
+ return (
+
+
+
+ No data in {fileName}
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
{fileName}
+
+ {formatNumber(data.totalRows)} rows {data.columns.length} cols
+
+
+
+ {!compact && (
+ setFilter(e.target.value)}
+ placeholder="Filter..."
+ className="w-40 bg-background border border-border/50 rounded px-2 py-1 text-[11px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary/50"
+ />
+ )}
+ {onExpand && (
+
+
+
+ )}
+
+
+
+ {/* Table */}
+
+
+
+
+ #
+ {data.columns.map((col) => (
+ handleSort(col)}
+ className="px-2.5 py-1.5 text-left font-medium text-muted-foreground cursor-pointer hover:text-foreground transition-colors select-none whitespace-nowrap"
+ >
+
+ {col}
+ {sortCol === col ? (
+ sortDir === 'asc' ? :
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+ {processedRows.map((row, i) => (
+
+ {i + 1}
+ {data.columns.map((col) => (
+
+ {formatCellValue(row[col])}
+
+ ))}
+
+ ))}
+
+
+
+
+ {/* Footer */}
+
+
+ Showing {formatNumber(processedRows.length)}{filter ? ` (filtered)` : ''} of {formatNumber(data.totalRows)} rows
+ {data.truncated && ' (truncated)'}
+
+ {compact && onExpand && (
+
+ View full table
+
+ )}
+
+
+ )
+}
diff --git a/src/renderer/src/components/ui/MarimoEmbed.tsx b/src/renderer/src/components/ui/MarimoEmbed.tsx
new file mode 100644
index 00000000..9c67af6d
--- /dev/null
+++ b/src/renderer/src/components/ui/MarimoEmbed.tsx
@@ -0,0 +1,247 @@
+import { useState, useEffect, useCallback } from 'react'
+import { Loader2, Maximize2, Minimize2, ExternalLink, Square, AlertTriangle, Play, Code2 } from 'lucide-react'
+import { Dialog, DialogContent } from '@/components/ui/Dialog'
+import { Button } from '@/components/ui/Button'
+import { marimoApi } from '@/lib/ipc-client'
+
+interface MarimoEmbedProps {
+ filePath: string
+ /** Compact inline mode (in chat) vs standalone */
+ compact?: boolean
+}
+
+type MarimoState =
+ | { phase: 'idle' }
+ | { phase: 'checking' }
+ | { phase: 'not-installed' }
+ | { phase: 'launching' }
+ | { phase: 'running'; url: string }
+ | { phase: 'error'; message: string }
+
+export function MarimoEmbed({ filePath, compact = true }: MarimoEmbedProps) {
+ const [state, setState] = useState({ phase: 'idle' })
+ const [fullscreen, setFullscreen] = useState(false)
+ const [mode, setMode] = useState<'run' | 'edit'>('run')
+
+ const fileName = filePath.split('/').pop() || filePath
+
+ const launch = useCallback(async (launchMode: 'run' | 'edit' = mode) => {
+ setState({ phase: 'checking' })
+ try {
+ // Check if already running
+ const status = await marimoApi.status(filePath)
+ if (status.running && status.url) {
+ setState({ phase: 'running', url: status.url })
+ return
+ }
+
+ // Check if marimo is installed
+ const check = await marimoApi.check()
+ if (!check.installed) {
+ setState({ phase: 'not-installed' })
+ return
+ }
+
+ setState({ phase: 'launching' })
+ const result = await marimoApi.launch(filePath, launchMode)
+ setMode(launchMode)
+ setState({ phase: 'running', url: result.url })
+ } catch (err) {
+ setState({ phase: 'error', message: err instanceof Error ? err.message : String(err) })
+ }
+ }, [filePath, mode])
+
+ const stop = useCallback(async () => {
+ await marimoApi.stop(filePath)
+ setState({ phase: 'idle' })
+ setFullscreen(false)
+ }, [filePath])
+
+ // Check on mount if already running
+ useEffect(() => {
+ marimoApi.status(filePath).then((status) => {
+ if (status.running && status.url) {
+ setState({ phase: 'running', url: status.url })
+ }
+ }).catch(() => { /* ignore */ })
+ }, [filePath])
+
+ // Idle state — show launch button
+ if (state.phase === 'idle') {
+ return (
+
+
+
+
{fileName}
+
marimo notebook
+
+
launch('run')}
+ className="h-7 px-3 text-xs gap-1.5"
+ >
+
+ Run
+
+
launch('edit')}
+ className="h-7 px-2 text-xs"
+ title="Open in edit mode"
+ >
+
+
+
+
+
+ )
+ }
+
+ // Not installed
+ if (state.phase === 'not-installed') {
+ return (
+
+
+
+
+ {fileName}
+
+ marimo is not installed. Run: pip install marimo
+
+
+
+
+ )
+ }
+
+ // Checking / Launching
+ if (state.phase === 'checking' || state.phase === 'launching') {
+ return (
+
+
+
+
+ {state.phase === 'checking' ? 'Checking marimo...' : `Starting marimo ${mode} server...`}
+
+
+
+ )
+ }
+
+ // Error
+ if (state.phase === 'error') {
+ return (
+
+
+
+
+ {state.message}
+
+
launch()} className="h-7 px-2 text-xs shrink-0">
+ Retry
+
+
+
+ )
+ }
+
+ // Running — show embedded iframe
+ const iframeContent = (
+
+ {/* Toolbar */}
+
+
+
{fileName}
+
+
+ {mode === 'edit' ? 'editing' : 'running'}
+
+
+ {mode === 'run' && (
+ { stop().then(() => launch('edit')) }}
+ className="h-6 px-2 text-[10px]"
+ title="Switch to edit mode"
+ >
+
+
+ )}
+ window.electronAPI.shell.openExternal(state.url)}
+ className="h-6 px-2 text-[10px]"
+ title="Open in browser"
+ >
+
+
+ {!fullscreen && (
+ setFullscreen(true)}
+ className="h-6 px-2 text-[10px]"
+ title="Fullscreen"
+ >
+
+
+ )}
+
+
+
+
+
+
+ {/* iframe */}
+
+
+ )
+
+ return (
+ <>
+
+ {!fullscreen && iframeContent}
+ {fullscreen && (
+
+
+ {fileName}
+
+
+ {mode} — fullscreen
+
+ setFullscreen(false)}
+ className="ml-auto h-6 px-2 text-[10px]"
+ >
+ Back to inline
+
+
+ )}
+
+
+ {/* Fullscreen dialog */}
+
+
+ {iframeContent}
+
+
+ >
+ )
+}
diff --git a/src/renderer/src/lib/ipc-client.ts b/src/renderer/src/lib/ipc-client.ts
index 63091dae..15bc4e94 100644
--- a/src/renderer/src/lib/ipc-client.ts
+++ b/src/renderer/src/lib/ipc-client.ts
@@ -1,5 +1,5 @@
import type { WorkfloTask, CreateTaskDTO, UpdateTaskDTO, FileAttachment, Agent, CreateAgentDTO, UpdateAgentDTO, McpServer, CreateMcpServerDTO, UpdateMcpServerDTO, Skill, CreateSkillDTO, UpdateSkillDTO, Secret, CreateSecretDTO, UpdateSecretDTO, TaskSource, CreateTaskSourceDTO, UpdateTaskSourceDTO, SyncResult, PluginMeta, ConfigFieldSchema, ConfigFieldOption, PluginAction, ActionResult, SourceUser, ReassignResult, MarketplaceSource, InstalledPlugin, DiscoverablePlugin, MarketplaceCatalog, PluginResources } from '@/types'
-import type { AgentOutputEvent, AgentOutputBatchEvent, AgentStatusEvent, AgentApprovalRequest, GhCliStatus, GitHubRepo, GitHubCollaborator, WorktreeProgressEvent, McpTestResult, SkillSyncResult, DepsStatus } from '@/types/electron'
+import type { AgentOutputEvent, AgentOutputBatchEvent, AgentStatusEvent, AgentApprovalRequest, GhCliStatus, GitHubRepo, GitHubCollaborator, WorktreeProgressEvent, McpTestResult, SkillSyncResult, DepsStatus, TabularDataResult, FileInfo, MarimoCheckResult, MarimoLaunchResult, MarimoStatusResult } from '@/types/electron'
export const taskApi = {
getAll: (): Promise => {
@@ -180,6 +180,42 @@ export const attachmentApi = {
download: (taskId: string, attachmentId: string): Promise => {
return window.electronAPI.attachments.download(taskId, attachmentId)
+ },
+
+ resolvePath: (taskId: string, attachmentId: string): Promise => {
+ return window.electronAPI.attachments.resolvePath(taskId, attachmentId)
+ }
+}
+
+export const fileViewerApi = {
+ getFileInfo: (filePath: string): Promise => {
+ return window.electronAPI.fileViewer.getFileInfo(filePath)
+ },
+
+ readTabularFile: (filePath: string, limit?: number): Promise => {
+ return window.electronAPI.fileViewer.readTabularFile(filePath, limit)
+ }
+}
+
+export const marimoApi = {
+ check: (): Promise => {
+ return window.electronAPI.marimo.check()
+ },
+
+ isNotebook: (filePath: string): Promise => {
+ return window.electronAPI.marimo.isNotebook(filePath)
+ },
+
+ launch: (filePath: string, mode?: 'run' | 'edit'): Promise => {
+ return window.electronAPI.marimo.launch(filePath, mode)
+ },
+
+ stop: (filePath: string): Promise => {
+ return window.electronAPI.marimo.stop(filePath)
+ },
+
+ status: (filePath: string): Promise => {
+ return window.electronAPI.marimo.status(filePath)
}
}
diff --git a/src/renderer/src/types/electron.d.ts b/src/renderer/src/types/electron.d.ts
index 17de4417..8ace9dae 100644
--- a/src/renderer/src/types/electron.d.ts
+++ b/src/renderer/src/types/electron.d.ts
@@ -133,6 +133,45 @@ export interface DepsStatus {
codexBinary: boolean
}
+export interface TabularData {
+ columns: string[]
+ rows: Record[]
+ totalRows: number
+ truncated: boolean
+ filePath: string
+}
+
+export interface TabularDataError {
+ error: string
+}
+
+export type TabularDataResult = TabularData | TabularDataError
+
+export interface FileInfo {
+ exists: boolean
+ size: number
+ extension: string
+ isTabular: boolean
+}
+
+export interface MarimoCheckResult {
+ installed: boolean
+ path: string | null
+ version: string | null
+}
+
+export interface MarimoLaunchResult {
+ url: string
+ port: number
+ pid: number
+}
+
+export interface MarimoStatusResult {
+ running: boolean
+ url: string | null
+ port: number | null
+}
+
interface ElectronAPI {
db: {
getTasks: () => Promise
@@ -185,6 +224,7 @@ interface ElectronAPI {
remove: (taskId: string, attachmentId: string) => Promise
open: (taskId: string, attachmentId: string) => Promise
download: (taskId: string, attachmentId: string) => Promise
+ resolvePath: (taskId: string, attachmentId: string) => Promise
}
shell: {
openPath: (filePath: string) => Promise
@@ -192,6 +232,17 @@ interface ElectronAPI {
readTextFile: (filePath: string) => Promise<{ content: string; size: number } | null>
openExternal: (url: string) => Promise
}
+ fileViewer: {
+ getFileInfo: (filePath: string) => Promise
+ readTabularFile: (filePath: string, limit?: number) => Promise
+ }
+ marimo: {
+ check: () => Promise
+ isNotebook: (filePath: string) => Promise
+ launch: (filePath: string, mode?: 'run' | 'edit') => Promise
+ stop: (filePath: string) => Promise
+ status: (filePath: string) => Promise
+ }
oauth: {
startFlow: (provider: string, config: Record) => Promise
exchangeCode: (provider: string, code: string, state: string, sourceId: string) => Promise