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) => ( + + ))} + + + + {sampleRows.map((row, i) => ( + + + + + + + + + + ))} + +
# + + {col} + + + + +
{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 +
+
+ + + + Run +
+
+ + + +
+
+
+
+ ) +} + +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 ( -
- - {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)} + <> +
+ + {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) ? ( + + ) : ( + + )} )} + + {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 ( +
+
+ + {error} +
+
+ ) + } + + 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) => ( + + ))} + + + + {processedRows.map((row, i) => ( + + + {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' ? : + ) : ( + + )} + +
{i + 1} + {formatCellValue(row[col])} +
+
+ + {/* Footer */} +
+ + Showing {formatNumber(processedRows.length)}{filter ? ` (filtered)` : ''} of {formatNumber(data.totalRows)} rows + {data.truncated && ' (truncated)'} + + {compact && onExpand && ( + + )} +
+
+ ) +} 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 +
+ + +
+
+
+ ) + } + + // 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} +
+ +
+
+ ) + } + + // Running — show embedded iframe + const iframeContent = ( +
+ {/* Toolbar */} +
+ + {fileName} + + + {mode === 'edit' ? 'editing' : 'running'} + +
+ {mode === 'run' && ( + + )} + + {!fullscreen && ( + + )} + +
+
+ + {/* iframe */} +