From c67ccc81b4f1cf270477f7ccc308bc1a4695bf21 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 7 Dec 2025 06:30:49 +0000 Subject: [PATCH 01/21] Refactor ToolCallSidePanel to KortixComputer with Actions, Files, and Browser tabs. - Replaced ToolCallSidePanel with KortixComputer implementation from upstream/PRODUCTION. - Added support for 'Actions', 'Files', and 'Browser' tabs. - Integrated `FileBrowserView` and `FileViewerView` for full file system access. - Implemented `BrowserTakeover` component for full-screen VNC browser control. - Updated `BrowserToolView` header to support 'Use Browser' takeover action. - Added `kortix-computer-store` for managing tab and file selection state. - Updated dependencies for file rendering and markdown support. --- frontend/package-lock.json | 498 +++++- frontend/package.json | 6 +- .../components/file-editors/code-editor.tsx | 504 ++++++ .../src/components/file-editors/index.tsx | 289 ++++ .../file-editors/markdown-editor.tsx | 794 ++++++++++ .../file-editors/markdown-toolbar.tsx | 984 ++++++++++++ frontend/src/components/file-editors/utils.ts | 108 ++ .../file-renderers/binary-renderer.tsx | 10 + .../file-renderers/csv-renderer.tsx | 44 +- .../file-renderers/html-renderer.tsx | 156 +- .../src/components/file-renderers/index.tsx | 305 +--- .../file-renderers/pdf-renderer.tsx | 281 +++- .../file-renderers/pptx-renderer.tsx | 162 ++ .../file-renderers/xlsx-renderer.tsx | 185 ++- frontend/src/components/markdown/index.tsx | 11 + .../components/markdown/unified-markdown.tsx | 446 ++++++ .../thread/HealthCheckedVncIframe.tsx | 60 +- .../kortix-computer/BrowserTakeover.tsx | 113 ++ .../kortix-computer/FileBrowserView.tsx | 1033 ++++++++++++ .../thread/kortix-computer/FileViewerView.tsx | 1395 +++++++++++++++++ .../thread/kortix-computer/KortixComputer.tsx | 1289 +++++++++++++++ .../thread/kortix-computer/VersionBanner.tsx | 38 + .../thread/kortix-computer/index.ts | 3 + .../thread/tool-call-side-panel.tsx | 627 ++++---- .../thread/tool-views/BrowserToolView.tsx | 59 +- .../tool-views/shared/FileDownloadButton.tsx | 185 +++ frontend/src/hooks/billing/index.ts | 90 +- .../src/hooks/billing/use-account-state.ts | 2 +- .../src/hooks/billing/use-credit-usage.ts | 3 +- .../hooks/billing/use-download-restriction.ts | 102 ++ .../src/hooks/billing/use-thread-billing.ts | 29 +- .../src/hooks/billing/use-thread-usage.ts | 3 +- .../hooks/billing/use-tier-configurations.ts | 3 +- .../src/hooks/billing/use-transactions.ts | 5 +- frontend/src/lib/site-config.ts | 66 + frontend/src/lib/utils/url-autolink.ts | 150 ++ frontend/src/stores/kortix-computer-store.ts | 357 +++++ 37 files changed, 9618 insertions(+), 777 deletions(-) create mode 100644 frontend/src/components/file-editors/code-editor.tsx create mode 100644 frontend/src/components/file-editors/index.tsx create mode 100644 frontend/src/components/file-editors/markdown-editor.tsx create mode 100644 frontend/src/components/file-editors/markdown-toolbar.tsx create mode 100644 frontend/src/components/file-editors/utils.ts create mode 100644 frontend/src/components/file-renderers/pptx-renderer.tsx create mode 100644 frontend/src/components/markdown/index.tsx create mode 100644 frontend/src/components/markdown/unified-markdown.tsx create mode 100644 frontend/src/components/thread/kortix-computer/BrowserTakeover.tsx create mode 100644 frontend/src/components/thread/kortix-computer/FileBrowserView.tsx create mode 100644 frontend/src/components/thread/kortix-computer/FileViewerView.tsx create mode 100644 frontend/src/components/thread/kortix-computer/KortixComputer.tsx create mode 100644 frontend/src/components/thread/kortix-computer/VersionBanner.tsx create mode 100644 frontend/src/components/thread/kortix-computer/index.ts create mode 100644 frontend/src/components/thread/tool-views/shared/FileDownloadButton.tsx create mode 100644 frontend/src/hooks/billing/use-download-restriction.ts create mode 100644 frontend/src/lib/site-config.ts create mode 100644 frontend/src/lib/utils/url-autolink.ts create mode 100644 frontend/src/stores/kortix-computer-store.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b4479a817a..a583cf142e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,15 +1,16 @@ { - "name": "kortix", + "name": "suna-community", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "kortix", + "name": "suna-community", "version": "0.1.0", "dependencies": { "@calcom/embed-react": "^1.5.2", "@codemirror/view": "^6.38.1", + "@cyntler/react-doc-viewer": "^1.17.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -133,6 +134,7 @@ "mermaid": "^11.12.0", "motion": "^12.5.0", "next": "^15.4.7", + "next-intl": "^3.26.3", "next-themes": "^0.4.6", "npm": "^11.5.2", "papaparse": "^5.5.2", @@ -155,6 +157,7 @@ "react-phone-number-input": "^3.4.12", "react-resizable-panels": "^3.0.6", "react-scan": "^0.0.44", + "read-excel-file": "^6.0.1", "recharts": "^3.2.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", @@ -8334,6 +8337,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -8777,6 +8781,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -8825,6 +8830,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -8851,6 +8857,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -8878,6 +8885,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", @@ -9078,6 +9086,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -9154,6 +9163,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -9175,6 +9185,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -9218,6 +9229,48 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@cyntler/react-doc-viewer": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@cyntler/react-doc-viewer/-/react-doc-viewer-1.17.1.tgz", + "integrity": "sha512-KaH2iuwBP0PiLKu+tiTfq46AadiCdaUQALExAomxkR9TpNaDipTs0HDTneW/oDrnX5bJkb1FhshL/gro8+Q2kg==", + "license": "Apache License 2.0", + "dependencies": { + "@types/mustache": "^4.2.5", + "@types/papaparse": "^5.3.14", + "ajv": "^7.2.4", + "core-js": "^3.37.1", + "mustache": "^4.2.0", + "papaparse": "^5.4.1", + "react-pdf": "^9.0.0", + "styled-components": "^6.1.11" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@cyntler/react-doc-viewer/node_modules/ajv": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.2.4.tgz", + "integrity": "sha512-nBeQgg/ZZA3u3SYxyaDvpvDtgZ/EZPF547ARgZBrG9Bhu1vKDwAIjtIf+sDtJUKa2zOcEbmRLBRSyMraS/Oy1A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@cyntler/react-doc-viewer/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -9235,6 +9288,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -9340,6 +9394,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9519,6 +9574,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -10171,6 +10227,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" @@ -10223,6 +10280,66 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", + "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "license": "MIT", + "dependencies": { + "tslib": "2" + } + }, "node_modules/@hookform/resolvers": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", @@ -11204,7 +11321,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz", "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@lezer/cpp": { "version": "1.1.3", @@ -11244,6 +11362,7 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.3.0" } @@ -11275,6 +11394,7 @@ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", @@ -11297,6 +11417,7 @@ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.3.tgz", "integrity": "sha512-yenN5SqAxAPv/qMnpWW0AT7l+SxVrgG+u0tNsRQWqbrz66HIl8DnEbBObvy21J5K7+I1v7gsAnlE2VQ5yYVSeA==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -11548,6 +11669,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-5.10.5.tgz", "integrity": "sha512-hFQp71QZDfivPzfIUOQZfMKLiOL/Cn2EnzacRlbUr55myteTfzYN8YMt+nzniE/6c4IRopFHEAdbKEtfyQc6kg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": ">=16.8.0" } @@ -11842,6 +11964,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -17258,6 +17381,7 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.86.0.tgz", "integrity": "sha512-BaC9sv5+HGNy1ulZwY8/Ev7EjfYYmWD4fOMw9bDBqTawEj6JHAiOHeTwXLRzVaeSay4p17xYLN2NSCoGgXMQnw==", "license": "MIT", + "peer": true, "dependencies": { "@supabase/auth-js": "2.86.0", "@supabase/functions-js": "2.86.0", @@ -17615,6 +17739,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz", "integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.11" }, @@ -17681,6 +17806,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.0.tgz", "integrity": "sha512-kmS7ZVpHm1EMnW1Wmft9H5ZLM7E0G0NGBx+aGEHGDcNxZBXD2ZUa76CuWjIhOGpwsPbELp684ZdpF2JWoNi4Dg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -17777,6 +17903,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.11.0.tgz", "integrity": "sha512-y01RJVbygDJWYXxZ0SiCYwvUF2X91RANCLSdb8X0qiwVPgNOzsDrrzS/iqoXkiYmM93pJw+ZWelEZxRvxEwsrg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -18049,6 +18176,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.0.tgz", "integrity": "sha512-4Ane7VCVZ+GFOQNuy2nMP+SoWH7EemC3geTTqvgHm1H0tbSosxLJAVaZ9dF06F35RJmYCm+jLJUhRVd156eCRQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -18318,6 +18446,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.0.tgz", "integrity": "sha512-g43beA73ZMLezez1st9LEwYrRHZ0FLzlsSlOZKk7sdmtHLmuqWHf4oyb0XAHol1HZIdGv104rYaGNgmQXr1ecQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -18332,6 +18461,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.0.tgz", "integrity": "sha512-plCQDLCZIOc92cizB8NNhBRN0szvYR3cx9i5IXo6v9Xsgcun8KHNcJkesc2AyeqdIs0BtOJZaqQ9adHThz8UDw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -18893,6 +19023,12 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/mustache": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.6.tgz", + "integrity": "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==", + "license": "MIT" + }, "node_modules/@types/ndarray": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/@types/ndarray/-/ndarray-1.0.14.tgz", @@ -18970,6 +19106,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -18980,10 +19117,17 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } }, + "node_modules/@types/stylis": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", + "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -19071,6 +19215,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -19783,6 +19928,15 @@ } } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xyflow/react": { "version": "12.9.3", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.3.tgz", @@ -19907,6 +20061,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -20434,6 +20589,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", @@ -20549,6 +20710,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -20802,6 +20964,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -21743,6 +21906,15 @@ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, "node_modules/css-line-break": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", @@ -21752,6 +21924,17 @@ "utrie": "^1.0.2" } }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -21784,6 +21967,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -22193,6 +22377,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -22363,6 +22548,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -22391,6 +22577,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -22671,6 +22863,51 @@ "dev": true, "license": "MIT" }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -22771,6 +23008,7 @@ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -23185,6 +23423,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -23358,6 +23597,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -24280,6 +24520,20 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -24601,7 +24855,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -24927,6 +25180,7 @@ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=12.0.0" } @@ -25211,6 +25465,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -25322,6 +25577,18 @@ "node": ">=12" } }, + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" + } + }, "node_modules/iobuffer": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", @@ -26018,6 +26285,18 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsonrepair": { "version": "3.13.1", "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz", @@ -26117,6 +26396,7 @@ "https://github.com/sponsors/katex" ], "license": "MIT", + "peer": true, "dependencies": { "commander": "^8.3.0" }, @@ -26636,6 +26916,7 @@ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", @@ -28151,6 +28432,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/nanoid": { "version": "3.3.11", "funding": [ @@ -28376,7 +28666,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -28385,6 +28674,7 @@ "node_modules/next": { "version": "15.4.7", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "15.4.7", "@swc/helpers": "0.5.15", @@ -28432,6 +28722,27 @@ } } }, + "node_modules/next-intl": { + "version": "3.26.5", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.26.5.tgz", + "integrity": "sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", + "negotiator": "^1.0.0", + "use-intl": "^3.26.5" + }, + "peerDependencies": { + "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -28540,6 +28851,12 @@ } } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -30429,6 +30746,7 @@ "version": "4.0.3", "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -31181,6 +31499,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", @@ -31245,6 +31564,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -31475,6 +31795,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -31504,6 +31825,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -31552,6 +31874,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz", "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -31921,6 +32244,7 @@ "node_modules/react": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -31996,6 +32320,7 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -32032,6 +32357,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz", "integrity": "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -32163,6 +32489,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -32328,6 +32655,17 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/read-excel-file": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-6.0.1.tgz", + "integrity": "sha512-rH6huBFxsjZsUARCYh55O08cn1gqZH8bnLf0kI6y5K7+9yqBVzy8veO4gPV4VGKv4M9rdcRtXTDGZZNwPi1gDA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.11", + "fflate": "^0.8.2", + "unzipper": "^0.12.3" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -32446,7 +32784,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -32955,6 +33294,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz", "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -33055,6 +33395,12 @@ "dev": true, "license": "ISC" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "hasInstallScript": true, @@ -33648,6 +33994,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz", "integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -34143,6 +34490,101 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/styled-components": { + "version": "6.1.19", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.19.tgz", + "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "1.2.2", + "@emotion/unitless": "0.8.1", + "@types/stylis": "4.2.5", + "css-to-react-native": "3.2.0", + "csstype": "3.1.3", + "postcss": "8.4.49", + "shallowequal": "1.1.0", + "stylis": "4.3.2", + "tslib": "2.6.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, + "node_modules/styled-components/node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/styled-components/node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==", + "license": "MIT" + }, + "node_modules/styled-components/node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", + "license": "MIT" + }, + "node_modules/styled-components/node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/styled-components/node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/styled-components/node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==", + "license": "MIT" + }, + "node_modules/styled-components/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "license": "0BSD" + }, "node_modules/styled-jsx": { "version": "5.1.6", "license": "MIT", @@ -34254,7 +34696,8 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -34419,6 +34862,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -34759,6 +35203,7 @@ "version": "5.8.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -34820,6 +35265,7 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -34943,6 +35389,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -35002,6 +35457,19 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -35092,6 +35560,19 @@ } } }, + "node_modules/use-intl": { + "version": "3.26.5", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.26.5.tgz", + "integrity": "sha512-OdsJnC/znPvHCHLQH/duvQNXnP1w0hPfS+tkSi3mAbfjYBGh4JnyfdwkQBfIVf7t8gs9eSX/CntxUMvtKdG2MQ==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^2.2.0", + "intl-messageformat": "^10.5.14" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", @@ -35556,6 +36037,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -35576,6 +36058,7 @@ "integrity": "sha512-JHv+58UxM2//e4kf9ASDwg016xd/OdDNDUKW6zLQyE7Uc9ayYKX1QJ9NsYtpo4dC1dfg6rT67pf1aNK1cTzUDg==", "dev": true, "license": "MIT OR Apache-2.0", + "peer": true, "dependencies": { "@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/unenv-preset": "2.7.11", @@ -36439,6 +36922,7 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", "license": "MIT", + "peer": true, "dependencies": { "lib0": "^0.2.99" }, diff --git a/frontend/package.json b/frontend/package.json index b10c9560db..2a523423e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "dependencies": { "@calcom/embed-react": "^1.5.2", "@codemirror/view": "^6.38.1", + "@cyntler/react-doc-viewer": "^1.17.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -23,6 +24,7 @@ "@emoji-mart/react": "^1.1.1", "@hookform/resolvers": "^5.2.1", "@icons-pack/react-simple-icons": "^13.7.0", + "@lexical/react": "^0.16.0", "@next/third-parties": "^15.3.1", "@novu/nextjs": "^3.11.0", "@novu/notification-center": "^2.0.0", @@ -129,7 +131,6 @@ "jszip": "^3.10.1", "katex": "^0.16.22", "lexical": "^0.16.0", - "@lexical/react": "^0.16.0", "libphonenumber-js": "^1.12.10", "lodash": "^4.17.21", "lottie-react": "^2.4.1", @@ -162,6 +163,7 @@ "react-phone-number-input": "^3.4.12", "react-resizable-panels": "^3.0.6", "react-scan": "^0.0.44", + "read-excel-file": "^6.0.1", "recharts": "^3.2.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", @@ -207,4 +209,4 @@ "typescript": "^5", "wrangler": "^4.51.0" } -} \ No newline at end of file +} diff --git a/frontend/src/components/file-editors/code-editor.tsx b/frontend/src/components/file-editors/code-editor.tsx new file mode 100644 index 0000000000..2bc6306f5f --- /dev/null +++ b/frontend/src/components/file-editors/code-editor.tsx @@ -0,0 +1,504 @@ +'use client'; + +import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import CodeMirror from '@uiw/react-codemirror'; +import { vscodeDark } from '@uiw/codemirror-theme-vscode'; +import { xcodeLight } from '@uiw/codemirror-theme-xcode'; +import { langs } from '@uiw/codemirror-extensions-langs'; +import { EditorView, keymap } from '@codemirror/view'; +import { indentWithTab } from '@codemirror/commands'; +import { cn } from '@/lib/utils'; +import { useTheme } from 'next-themes'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Check, Loader2, AlertCircle, Save, RotateCcw } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +// Map of language aliases to CodeMirror language support +// Note: langs object from @uiw/codemirror-extensions-langs is keyed by file extensions +// Using type assertion because TypeScript types are incomplete +const langsTyped = langs as Record any>; +const languageMap: Record any> = { + js: () => langsTyped.javascript(), + javascript: () => langsTyped.javascript(), + jsx: () => langsTyped.jsx(), + ts: () => langsTyped.typescript(), + typescript: () => langsTyped.typescript(), + tsx: () => langsTyped.tsx(), + html: () => langsTyped.html(), + css: () => langsTyped.css(), + json: () => langsTyped.json(), + md: () => langsTyped.markdown(), + markdown: () => langsTyped.markdown(), + python: () => langsTyped.python(), + py: () => langsTyped.python(), + rust: () => langsTyped.rust(), + go: () => langsTyped.go(), + java: () => langsTyped.java(), + c: () => langsTyped.c(), + cpp: () => langsTyped.cpp(), + cs: () => langsTyped.csharp(), + csharp: () => langsTyped.csharp(), + php: () => langsTyped.php(), + ruby: () => langsTyped.ruby(), + rb: () => langsTyped.ruby(), + sh: () => langsTyped.shell(), + bash: () => langsTyped.shell(), + shell: () => langsTyped.shell(), + sql: () => langsTyped.sql(), + yaml: () => langsTyped.yaml(), + yml: () => langsTyped.yaml(), + xml: () => langsTyped.xml(), + swift: () => langsTyped.swift(), + kotlin: () => langsTyped.kotlin(), + scala: () => langsTyped.scala(), + r: () => langsTyped.r(), + lua: () => langsTyped.lua(), + perl: () => langsTyped.perl(), + toml: () => langsTyped.toml(), +}; + +// Get language from file extension +export function getLanguageFromExtension(fileName: string): string { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + const fileNameLower = fileName.toLowerCase(); + + // Check for common plain text file patterns first + if (fileNameLower.includes('.env') || fileNameLower.startsWith('.env')) { + return 'text'; + } + if (fileNameLower.includes('gitignore') || + fileNameLower.includes('editorconfig') || + fileNameLower.includes('dockerignore') || + fileNameLower.includes('npmignore') || + fileNameLower.includes('prettierignore') || + fileNameLower.includes('eslintignore')) { + return 'text'; + } + + const extensionToLanguage: Record = { + js: 'javascript', + jsx: 'jsx', + ts: 'typescript', + tsx: 'tsx', + html: 'html', + htm: 'html', + css: 'css', + json: 'json', + md: 'markdown', + markdown: 'markdown', + py: 'python', + python: 'python', + java: 'java', + c: 'c', + cpp: 'cpp', + h: 'c', + hpp: 'cpp', + cs: 'csharp', + go: 'go', + rs: 'rust', + php: 'php', + rb: 'ruby', + sh: 'shell', + bash: 'shell', + zsh: 'shell', + fish: 'shell', + xml: 'xml', + yml: 'yaml', + yaml: 'yaml', + sql: 'sql', + swift: 'swift', + kt: 'kotlin', + scala: 'scala', + r: 'r', + lua: 'lua', + pl: 'perl', + toml: 'toml', + txt: 'text', + log: 'text', + env: 'text', + ini: 'text', + gitignore: 'text', + editorconfig: 'text', + }; + return extensionToLanguage[extension] || 'text'; +} + +interface CodeEditorProps { + content: string; + originalContent?: string; // The saved/persisted content (for tracking unsaved changes across remounts) + hasUnsavedChanges?: boolean; // Controlled by parent - persists across remounts + onUnsavedChange?: (hasUnsaved: boolean) => void; // Notify parent when unsaved state changes + fileName: string; + language?: string; + onChange?: (content: string) => void; + onSave?: (content: string) => Promise; + onDiscard?: () => void; // Called when user discards changes + readOnly?: boolean; + className?: string; + showLineNumbers?: boolean; +} + +export function CodeEditor({ + content, + originalContent, + hasUnsavedChanges: externalHasUnsaved, + onUnsavedChange, + fileName, + language: propLanguage, + onChange, + onSave, + onDiscard, + readOnly = false, + className, + showLineNumbers = true, +}: CodeEditorProps) { + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + const [localContent, setLocalContent] = useState(content); + // Use originalContent if provided, otherwise fall back to content (for backwards compatibility) + const savedContent = useRef(originalContent ?? content); + const editorContainerRef = useRef(null); + const [editorHeight, setEditorHeight] = useState('100%'); + // Track initialization state + const [isReady, setIsReady] = useState(false); + + // Store callback in ref to avoid it being a dependency + const onUnsavedChangeRef = useRef(onUnsavedChange); + onUnsavedChangeRef.current = onUnsavedChange; + + // Compute hasChanges - only after editor is ready and content differs from saved + const hasChanges = isReady && localContent !== savedContent.current; + + // Track previous hasChanges to notify parent only on change + const prevHasChanges = useRef(false); + + // Notify parent when hasChanges state changes + useEffect(() => { + if (prevHasChanges.current !== hasChanges) { + prevHasChanges.current = hasChanges; + onUnsavedChangeRef.current?.(hasChanges); + } + }, [hasChanges]); + + // Update savedContent ref when originalContent prop changes (e.g., after external save) + useEffect(() => { + if (originalContent !== undefined) { + savedContent.current = originalContent; + } + }, [originalContent]); + + // Set mounted state + useEffect(() => { + setMounted(true); + }, []); + + // Calculate editor height based on container - never exceed container bounds + // For read-only mode, use auto height to let content expand naturally (better for preview contexts) + useEffect(() => { + // In read-only mode, let CodeMirror auto-expand based on content + if (readOnly) { + setEditorHeight('auto'); + return; + } + + const updateHeight = () => { + if (editorContainerRef.current) { + const rect = editorContainerRef.current.getBoundingClientRect(); + // Only use container height - never exceed it + const height = rect.height > 0 ? rect.height : 400; // Reasonable fallback + setEditorHeight(`${height}px`); + } else { + // Fallback to a reasonable default + setEditorHeight('100%'); + } + }; + + updateHeight(); + const resizeObserver = new ResizeObserver(updateHeight); + if (editorContainerRef.current) { + resizeObserver.observe(editorContainerRef.current); + } + + window.addEventListener('resize', updateHeight); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', updateHeight); + }; + }, [readOnly]); + + // Determine language + const language = propLanguage || getLanguageFromExtension(fileName); + + // Get language extension + const langExtension = useMemo(() => { + const langFn = languageMap[language]; + return langFn ? [langFn()] : []; + }, [language]); + + // Manual save function + const handleSave = useCallback(async () => { + if (!onSave) return; + if (localContent === savedContent.current) return; + + try { + setSaveState('saving'); + await onSave(localContent); + savedContent.current = localContent; + setSaveState('saved'); + + // Reset to idle after showing saved state + setTimeout(() => setSaveState('idle'), 2000); + } catch (error) { + console.error('Save error:', error); + setSaveState('error'); + setTimeout(() => setSaveState('idle'), 3000); + } + }, [onSave, localContent]); + + // Discard changes function + const handleDiscard = useCallback(() => { + setLocalContent(savedContent.current); + if (onChange) { + onChange(savedContent.current); + } + if (onDiscard) { + onDiscard(); + } + }, [onChange, onDiscard]); + + // Handle content change + const handleChange = useCallback( + (value: string) => { + setLocalContent(value); + if (onChange) { + onChange(value); + } + }, + [onChange] + ); + + // Update local content when external content changes (but not if we have unsaved local changes) + useEffect(() => { + // Only update if the external content changed and we don't have local modifications + // Also update if localContent is null/empty but content is available + const hasNoLocalChanges = localContent === savedContent.current || !localContent; + if (content !== localContent && hasNoLocalChanges) { + setLocalContent(content); + // Also update savedContent when content is first loaded + if (!isReady && content) { + savedContent.current = originalContent ?? content; + // Mark as ready after first real content load + setIsReady(true); + } + } + }, [content, localContent, originalContent, isReady]); + + // Manual save handler (Cmd/Ctrl + S) + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 's') { + event.preventDefault(); + if (!readOnly) { + handleSave(); + } + } + }, + [readOnly, handleSave] + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + + // Theme selection + const theme = mounted && resolvedTheme === 'dark' ? vscodeDark : xcodeLight; + + // Extensions + const extensions = useMemo(() => { + const exts = [ + ...langExtension, + EditorView.lineWrapping, + keymap.of([indentWithTab]), + ]; + return exts; + }, [langExtension]); + + const SaveButton = () => { + if (readOnly || !onSave) return null; + + switch (saveState) { + case 'saving': + return ( + + ); + case 'saved': + return ( + + ); + case 'error': + return ( + + ); + default: + return ( + + + + + + + {hasChanges ? ( + <>Save changes ⌘S + ) : ( + 'No changes to save' + )} + + + + ); + } + }; + + return ( +
+ {/* Header with save controls and language */} + {!readOnly && ( +
+ {/* Left: Save/Discard/Unsaved */} +
+ + {hasChanges && ( + + + + + + + Discard changes + + + + )} + {hasChanges && ( +
+ + + + + Unsaved +
+ )} +
+ {/* Right: Language badge */} + + {language} + +
+ )} + + {/* Editor */} +
+ {mounted && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/file-editors/index.tsx b/frontend/src/components/file-editors/index.tsx new file mode 100644 index 0000000000..4405d98663 --- /dev/null +++ b/frontend/src/components/file-editors/index.tsx @@ -0,0 +1,289 @@ +'use client'; + +import React from 'react'; +import { cn } from '@/lib/utils'; +import { MarkdownEditor, type MarkdownEditorControls } from './markdown-editor'; +import { UnifiedMarkdown } from '@/components/markdown'; +import { CodeEditor } from './code-editor'; +import { PdfRenderer } from '@/components/file-renderers/pdf-renderer'; +import { ImageRenderer } from '@/components/file-renderers/image-renderer'; +import { BinaryRenderer } from '@/components/file-renderers/binary-renderer'; +import { CsvRenderer } from '@/components/file-renderers/csv-renderer'; +import { XlsxRenderer } from '@/components/file-renderers/xlsx-renderer'; +import { PptxRenderer } from '@/components/file-renderers/pptx-renderer'; +import { HtmlRenderer } from '@/components/file-renderers/html-renderer'; +import { constructHtmlPreviewUrl } from '@/lib/utils/url'; +import { processUnicodeContent, getFileTypeFromExtension, getLanguageFromExtension } from './utils'; + +export type EditableFileType = + | 'markdown' + | 'code' + | 'text' + | 'html' + | 'pdf' + | 'image' + | 'binary' + | 'csv' + | 'xlsx' + | 'pptx'; + +export interface FileEditorProject { + id?: string; + name?: string; + description?: string; + created_at?: string; + sandbox?: { + id?: string; + sandbox_url?: string; + vnc_preview?: string; + pass?: string; + }; +} + +interface EditableFileRendererProps { + content: string | null; + originalContent?: string; // The saved/persisted content (for tracking unsaved changes across remounts) + hasUnsavedChanges?: boolean; // Controlled by parent - persists across remounts + onUnsavedChange?: (hasUnsaved: boolean) => void; // Notify parent when unsaved state changes + binaryUrl: string | null; + fileName: string; + filePath?: string; + className?: string; + project?: FileEditorProject; + readOnly?: boolean; + onChange?: (content: string) => void; + onSave?: (content: string) => Promise; + onDiscard?: () => void; // Called when user discards changes + onDownload?: () => void; + isDownloading?: boolean; + onFullScreen?: () => void; + // Markdown editor specific + hideMarkdownToolbarActions?: boolean; + onMarkdownEditorReady?: (controls: MarkdownEditorControls | null) => void; +} + +// Helper function to determine file type from extension +export function getEditableFileType(fileName: string): EditableFileType { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + const fileNameLower = fileName.toLowerCase(); + + const markdownExtensions = ['md', 'markdown']; + const htmlExtensions = ['html', 'htm']; + const codeExtensions = [ + 'js', 'jsx', 'ts', 'tsx', 'css', 'json', 'py', 'python', + 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'go', 'rs', 'php', + 'rb', 'sh', 'bash', 'zsh', 'xml', 'yml', 'yaml', 'toml', + 'sql', 'graphql', 'swift', 'kotlin', 'kt', 'dart', 'r', + 'lua', 'scala', 'perl', 'pl', 'haskell', 'hs', 'rust', + 'dockerfile', 'makefile', 'cmake', + ]; + const textExtensions = ['txt', 'log', 'env', 'ini', 'conf', 'cfg', 'gitignore', 'editorconfig']; + const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']; + const pdfExtensions = ['pdf']; + const csvExtensions = ['csv', 'tsv']; + const xlsxExtensions = ['xlsx', 'xls']; + const pptxExtensions = ['pptx', 'ppt']; + + if (markdownExtensions.includes(extension)) return 'markdown'; + if (htmlExtensions.includes(extension)) return 'html'; + if (codeExtensions.includes(extension)) return 'code'; + if (textExtensions.includes(extension)) return 'text'; + + // Check for common plain text file patterns (e.g., .env.example, .env.local, .gitignore, etc.) + if (fileNameLower.includes('.env') || + fileNameLower.startsWith('.env') || + fileNameLower.includes('gitignore') || + fileNameLower.includes('editorconfig') || + fileNameLower.includes('dockerignore') || + fileNameLower.includes('npmignore') || + fileNameLower.includes('prettierignore') || + fileNameLower.includes('eslintignore')) { + return 'text'; + } + + if (imageExtensions.includes(extension)) return 'image'; + if (pdfExtensions.includes(extension)) return 'pdf'; + if (csvExtensions.includes(extension)) return 'csv'; + if (xlsxExtensions.includes(extension)) return 'xlsx'; + if (pptxExtensions.includes(extension)) return 'pptx'; + + return 'binary'; +} + +// Check if file type supports editing +export function isEditableFileType(fileType: EditableFileType): boolean { + return ['markdown', 'code', 'text', 'html'].includes(fileType); +} + +export function EditableFileRenderer({ + content, + originalContent, + hasUnsavedChanges, + onUnsavedChange, + binaryUrl, + fileName, + filePath, + className, + project, + readOnly = false, + onChange, + onSave, + onDiscard, + onDownload, + isDownloading, + onFullScreen, + hideMarkdownToolbarActions = false, + onMarkdownEditorReady, +}: EditableFileRendererProps) { + const fileType = getEditableFileType(fileName); + const isHtmlFile = fileName.toLowerCase().endsWith('.html') || fileName.toLowerCase().endsWith('.htm'); + + // HTML preview URL for HTML files + const htmlPreviewUrl = React.useMemo(() => { + if (isHtmlFile && content && !project?.sandbox?.sandbox_url) { + const blob = new Blob([content], { type: 'text/html' }); + return URL.createObjectURL(blob); + } + if (isHtmlFile && project?.sandbox?.sandbox_url && (filePath || fileName)) { + return constructHtmlPreviewUrl(project.sandbox.sandbox_url, filePath || fileName); + } + return undefined; + }, [isHtmlFile, content, project?.sandbox?.sandbox_url, filePath, fileName]); + + // Cleanup blob URLs + React.useEffect(() => { + return () => { + if (htmlPreviewUrl && htmlPreviewUrl.startsWith('blob:')) { + URL.revokeObjectURL(htmlPreviewUrl); + } + }; + }, [htmlPreviewUrl]); + + // Check if we have text content even when fileType is 'binary' + // This handles cases like .env.example where the extension isn't recognized + // but we have text content that should be rendered + const shouldRenderAsText = fileType === 'binary' && content !== null && !binaryUrl; + + return ( +
+ {/* Binary files - not editable, unless we have text content */} + {fileType === 'binary' && !shouldRenderAsText ? ( + + ) : shouldRenderAsText ? ( + // Render as plain text with CodeMirror when we have text content but fileType is binary + + ) : fileType === 'image' && binaryUrl ? ( + + ) : fileType === 'pdf' && binaryUrl ? ( + + ) : fileType === 'csv' ? ( + + ) : fileType === 'xlsx' ? ( + + ) : fileType === 'pptx' ? ( + + ) : fileType === 'html' && htmlPreviewUrl ? ( + // HTML files - show preview (could add split view editor later) + + ) : fileType === 'markdown' ? ( + // Markdown - use UnifiedMarkdown for read-only, Editor for editing + readOnly ? ( +
+ +
+ ) : ( + + ) + ) : fileType === 'code' || fileType === 'text' ? ( + // Code and text files - CodeMirror editor + + ) : ( + // Fallback - CodeMirror as plain text + + )} +
+ ); +} + +// Re-export components +export { MarkdownEditor, type MarkdownEditorControls } from './markdown-editor'; +export { CodeEditor } from './code-editor'; +export { UnifiedMarkdown } from '@/components/markdown'; + +// Re-export utilities +export { processUnicodeContent, getFileTypeFromExtension, getLanguageFromExtension } from './utils'; diff --git a/frontend/src/components/file-editors/markdown-editor.tsx b/frontend/src/components/file-editors/markdown-editor.tsx new file mode 100644 index 0000000000..4ae848f0a0 --- /dev/null +++ b/frontend/src/components/file-editors/markdown-editor.tsx @@ -0,0 +1,794 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { EditorContent, useEditor, type Editor as TiptapEditor } from '@tiptap/react'; +import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus'; +import StarterKit from '@tiptap/starter-kit'; +import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'; +import TaskList from '@tiptap/extension-task-list'; +import TaskItem from '@tiptap/extension-task-item'; +import Blockquote from '@tiptap/extension-blockquote'; +import CodeBlock from '@tiptap/extension-code-block'; +import Document from '@tiptap/extension-document'; +import HardBreak from '@tiptap/extension-hard-break'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import Heading from '@tiptap/extension-heading'; +import HorizontalRule from '@tiptap/extension-horizontal-rule'; +import Image from '@tiptap/extension-image'; +import { TableKit } from '@tiptap/extension-table'; +import Underline from '@tiptap/extension-underline'; +import Link from '@tiptap/extension-link'; +import { TextStyle } from '@tiptap/extension-text-style'; +import { Color } from '@tiptap/extension-color'; +import Highlight from '@tiptap/extension-highlight'; +import TextAlign from '@tiptap/extension-text-align'; +import Placeholder from '@tiptap/extension-placeholder'; +import CharacterCount from '@tiptap/extension-character-count'; +import Dropcursor from '@tiptap/extension-dropcursor'; +import Gapcursor from '@tiptap/extension-gapcursor'; +import Typography from '@tiptap/extension-typography'; +import Strike from '@tiptap/extension-strike'; +import { Mathematics } from '@tiptap/extension-mathematics'; +import 'katex/dist/katex.min.css'; + +import { marked } from 'marked'; +import TurndownService from 'turndown'; +import { gfm } from 'turndown-plugin-gfm'; +import { cn } from '@/lib/utils'; +import { MarkdownToolbar } from './markdown-toolbar'; +import { UnifiedMarkdown } from '@/components/markdown'; + +// Configure marked for GFM +marked.setOptions({ + gfm: true, + breaks: true, +}); + +// Configure turndown for markdown conversion +const turndownService = new TurndownService({ + headingStyle: 'atx', + hr: '---', + bulletListMarker: '-', + codeBlockStyle: 'fenced', + fence: '```', + emDelimiter: '*', + strongDelimiter: '**', + linkStyle: 'inlined', +}); +turndownService.use(gfm); + +// Custom rule for code blocks to preserve language +turndownService.addRule('fencedCodeBlock', { + filter: (node) => { + return ( + node.nodeName === 'PRE' && + node.firstChild && + node.firstChild.nodeName === 'CODE' + ); + }, + replacement: (content, node) => { + const codeNode = node.firstChild as HTMLElement; + const className = codeNode.getAttribute('class') || ''; + const languageMatch = className.match(/language-(\w+)/); + const language = languageMatch ? languageMatch[1] : ''; + const code = codeNode.textContent || ''; + return `\n\`\`\`${language}\n${code}\n\`\`\`\n`; + }, +}); + +export interface MarkdownEditorControls { + getHtml: () => string; + save: () => void; + saveState: 'idle' | 'saving' | 'saved' | 'error'; + hasChanges: boolean; +} + +interface MarkdownEditorProps { + content: string; + originalContent?: string; // The saved/persisted content (for tracking unsaved changes across remounts) + hasUnsavedChanges?: boolean; // Controlled by parent - persists across remounts + onUnsavedChange?: (hasUnsaved: boolean) => void; // Notify parent when unsaved state changes + onChange?: (markdown: string) => void; + onSave?: (markdown: string) => Promise; + onDiscard?: () => void; // Called when user discards changes + readOnly?: boolean; + className?: string; + placeholder?: string; + showToolbar?: boolean; + fileName?: string; + hideToolbarActions?: boolean; // Hide Export/Save from toolbar (for when they're in header) + onEditorReady?: (controls: MarkdownEditorControls | null) => void; + sandboxId?: string; // Sandbox ID for uploading images +} + +export function MarkdownEditor({ + content, + originalContent, + hasUnsavedChanges: externalHasUnsaved, + onUnsavedChange, + onChange, + onSave, + onDiscard, + readOnly = false, + className, + placeholder = 'Start writing...', + showToolbar = true, + fileName = 'document', + hideToolbarActions = false, + onEditorReady, + sandboxId, +}: MarkdownEditorProps) { + const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + const [editorInstance, setEditorInstance] = useState(null); + // Use originalContent if provided, otherwise fall back to content (for backwards compatibility) + const savedContent = useRef(originalContent ?? content); + const initialHtmlRef = useRef(null); + // Track changes state + const [hasChanges, setHasChanges] = useState(false); + const isInitializing = useRef(true); + // Store the "normalized" content after first render (markdown→HTML→markdown) + const normalizedContent = useRef(null); + + // Store callback in ref to avoid it being a dependency + const onUnsavedChangeRef = useRef(onUnsavedChange); + onUnsavedChangeRef.current = onUnsavedChange; + + // Notify parent when hasChanges state changes + const prevHasChanges = useRef(hasChanges); + useEffect(() => { + if (prevHasChanges.current !== hasChanges) { + prevHasChanges.current = hasChanges; + onUnsavedChangeRef.current?.(hasChanges); + } + }, [hasChanges]); + + // Convert markdown to HTML on initial load + const initialHtml = useMemo(() => { + if (initialHtmlRef.current !== null) { + return initialHtmlRef.current; + } + try { + const html = marked.parse(content || '', { async: false }) as string; + initialHtmlRef.current = html; + return html; + } catch (e) { + console.error('Failed to parse markdown:', e); + initialHtmlRef.current = `

${content}

`; + return initialHtmlRef.current; + } + }, [content]); + + // Convert HTML back to markdown + const htmlToMarkdown = useCallback((html: string): string => { + try { + return turndownService.turndown(html); + } catch (e) { + console.error('Failed to convert HTML to markdown:', e); + return ''; + } + }, []); + + // Update savedContent ref when originalContent prop changes (e.g., after external save) + useEffect(() => { + if (originalContent !== undefined) { + savedContent.current = originalContent; + } + }, [originalContent]); + + // Manual save function + const handleSave = useCallback(async () => { + if (!onSave || !editorInstance) return; + + const html = editorInstance.getHTML(); + const markdown = htmlToMarkdown(html); + + if (markdown === savedContent.current) return; + + try { + setSaveState('saving'); + await onSave(markdown); + savedContent.current = markdown; + normalizedContent.current = markdown; + setHasChanges(false); + setSaveState('saved'); + + setTimeout(() => setSaveState('idle'), 2000); + } catch (error) { + console.error('Save error:', error); + setSaveState('error'); + setTimeout(() => setSaveState('idle'), 3000); + } + }, [onSave, editorInstance, htmlToMarkdown]); + + // Discard changes function + const handleDiscard = useCallback(() => { + if (!editorInstance) return; + + const newHtml = marked.parse(savedContent.current || '', { async: false }) as string; + editorInstance.commands.setContent(newHtml); + // Update normalized content after discard + const discardedMarkdown = htmlToMarkdown(newHtml); + normalizedContent.current = discardedMarkdown; + setHasChanges(false); + + if (onChange) { + onChange(savedContent.current); + } + if (onDiscard) { + onDiscard(); + } + }, [editorInstance, onChange, onDiscard, htmlToMarkdown]); + + // TipTap extensions + const extensions = useMemo( + () => [ + Document, + Paragraph.extend({ + addAttributes() { + return { + ...this.parent?.(), + class: { + default: 'text-foreground leading-relaxed my-4 first:mt-0 last:mb-0', + }, + }; + }, + }), + Text, + StarterKit.configure({ + document: false, + paragraph: false, + text: false, + heading: false, + bulletList: false, + orderedList: false, + listItem: false, + blockquote: false, + codeBlock: false, + horizontalRule: false, + hardBreak: false, + strike: false, + }), + BulletList.extend({ + addAttributes() { + return { + ...this.parent?.(), + class: { + default: 'my-4 ml-6 list-disc space-y-2 first:mt-0 last:mb-0', + }, + }; + }, + }), + OrderedList.extend({ + addAttributes() { + return { + ...this.parent?.(), + class: { + default: 'my-4 ml-6 list-decimal space-y-2 first:mt-0 last:mb-0', + }, + }; + }, + }), + ListItem.extend({ + addAttributes() { + return { + ...this.parent?.(), + class: { + default: 'text-foreground leading-relaxed pl-1', + }, + }; + }, + }), + TaskList, + TaskItem.configure({ nested: true }), + Blockquote.configure({ + HTMLAttributes: { + class: 'my-5 pl-4 py-1 border-l-2 border-border text-muted-foreground', + }, + }), + CodeBlock.configure({ + HTMLAttributes: { + class: 'my-5 p-4 rounded-xl overflow-x-auto bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 text-[13px] font-mono leading-relaxed text-zinc-800 dark:text-zinc-200', + }, + }), + HardBreak, + Heading.configure({ + levels: [1, 2, 3, 4, 5, 6], + }), + HorizontalRule.configure({ + HTMLAttributes: { + class: 'my-8 border-0 h-px bg-border/60', + }, + }), + Image.configure({ + inline: true, + allowBase64: true, + HTMLAttributes: { + class: 'max-w-full h-auto rounded-xl border border-border/40 shadow-sm my-5', + }, + }), + TableKit.configure({ + table: { + resizable: true, + HTMLAttributes: { + class: 'w-full text-sm my-5 rounded-xl border border-border/60 overflow-hidden', + }, + }, + tableHeader: { + HTMLAttributes: { + class: 'px-4 py-3 text-left text-xs font-semibold text-foreground uppercase tracking-wider bg-muted/50 dark:bg-muted/30', + }, + }, + tableCell: { + HTMLAttributes: { + class: 'px-4 py-3 text-foreground border-t border-border/40', + }, + }, + }), + Underline, + Strike, + Link.configure({ + openOnClick: false, + autolink: true, + defaultProtocol: 'https', + HTMLAttributes: { + class: 'font-medium text-foreground underline decoration-foreground/30 underline-offset-[3px] decoration-[1px] hover:decoration-foreground/60 transition-colors duration-150', + }, + }), + TextStyle, + Color, + Highlight.configure({ multicolor: true }), + TextAlign.configure({ + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right'], + }), + Placeholder.configure({ + placeholder, + showOnlyWhenEditable: true, + }), + CharacterCount, + Dropcursor.configure({ color: 'hsl(var(--primary))', width: 2 }), + Gapcursor, + Typography, + Mathematics, + ], + [placeholder] + ); + + const editor = useEditor({ + extensions, + content: initialHtml, + editable: !readOnly, + immediatelyRender: false, + onCreate({ editor }) { + setEditorInstance(editor); + // Store the normalized content (after markdown→HTML→markdown conversion) + // This is our baseline for detecting actual user changes + const html = editor.getHTML(); + const markdown = htmlToMarkdown(html); + normalizedContent.current = markdown; + isInitializing.current = false; + // Don't set hasChanges on initial load - only after user edits + setHasChanges(false); + }, + onUpdate({ editor, transaction }) { + setEditorInstance(editor); + + const html = editor.getHTML(); + const markdown = htmlToMarkdown(html); + + // Check if this is a user-initiated change (not programmatic) + // TipTap transactions from user input don't have the 'preventUpdate' meta + // Only track changes after initial load + if (!isInitializing.current && normalizedContent.current !== null) { + const contentChanged = markdown !== normalizedContent.current; + setHasChanges(contentChanged); + } + + if (onChange) { + onChange(markdown); + } + }, + editorProps: { + attributes: { + class: cn( + 'focus:outline-none min-h-[200px]', + // Remove all prose classes - use direct styling like UnifiedMarkdown + ), + spellcheck: 'true', + }, + handleDrop: (view, event, slice, moved) => { + if (!moved && event.dataTransfer && event.dataTransfer.files.length > 0) { + const file = event.dataTransfer.files[0]; + if (file.type.startsWith('image/')) { + const reader = new FileReader(); + reader.onload = (e) => { + const src = e.target?.result as string; + const { schema } = view.state; + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + if (coordinates) { + const node = schema.nodes.image.create({ src }); + const transaction = view.state.tr.insert(coordinates.pos, node); + view.dispatch(transaction); + } + }; + reader.readAsDataURL(file); + return true; + } + } + return false; + }, + }, + }); + + // Update editor content when external content changes (but not if we have unsaved local changes) + useEffect(() => { + if (editor && !hasChanges) { + const newHtml = marked.parse(content || '', { async: false }) as string; + const currentHtml = editor.getHTML(); + + if (newHtml !== currentHtml) { + editor.commands.setContent(newHtml); + } + } + }, [content, editor, hasChanges]); + + // Update editable state + useEffect(() => { + if (editor) { + editor.setEditable(!readOnly); + } + }, [readOnly, editor]); + + // Manual save handler (Cmd/Ctrl + S) + useEffect(() => { + if (!editor || readOnly) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + handleSave(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [editor, readOnly, handleSave]); + + // Expose editor controls to parent + useEffect(() => { + if (onEditorReady) { + if (editorInstance) { + onEditorReady({ + getHtml: () => editorInstance.getHTML(), + save: handleSave, + saveState, + hasChanges, + }); + } else { + onEditorReady(null); + } + } + }, [editorInstance, handleSave, saveState, hasChanges, onEditorReady]); + + // In read-only mode, use UnifiedMarkdown for consistent rendering + if (readOnly) { + return ( +
+
+ +
+
+ ); + } + + // In edit mode, use TipTap editor + return ( +
+ {showToolbar && editorInstance && ( + + )} +
+
+ {editor && ( + <> + { + // Show when text is selected + const { selection } = state; + return selection && !selection.empty && selection.from !== selection.to; + }} + > +
+ +
+
+ { + // Show when editor is empty + const { selection } = state; + const { $anchor } = selection; + const isRootDepth = $anchor.depth === 1; + const isEmpty = editor.state.doc.content.size === 0; + return isEmpty && isRootDepth; + }} + > +
+ +
+
+ + )} + - - - ${tiptapHtmlContent} - - - `; - const blob = new Blob([htmlWrapper], { type: 'text/html' }); - return URL.createObjectURL(blob); - } - return undefined; - }, [isHtmlFile, content, project?.sandbox?.sandbox_url, tiptapHtmlContent]); - - const htmlPreviewUrl = - isHtmlFile && project?.sandbox?.sandbox_url && (filePath || fileName) - ? constructHtmlPreviewUrl(project.sandbox.sandbox_url, filePath || fileName) - : blobHtmlUrl; - - React.useEffect(() => { - return () => { - if (blobHtmlUrl) { - URL.revokeObjectURL(blobHtmlUrl); - } - }; - }, [blobHtmlUrl]); - - return ( -
- {fileType === 'binary' ? ( - - ) : fileType === 'image' && binaryUrl ? ( - - ) : fileType === 'pdf' && binaryUrl ? ( - - ) : fileType === 'markdown' ? ( - - ) : fileType === 'csv' ? ( - - ) : fileType === 'xlsx' ? ( - - ) : isHtmlFile || tiptapHtmlContent ? ( - - ) : fileType === 'code' || fileType === 'text' ? ( - - ) : ( -
-
-            {content || ''}
-          
-
- )} -
- ); + return binaryExtensions.includes(extension); } diff --git a/frontend/src/components/file-renderers/pdf-renderer.tsx b/frontend/src/components/file-renderers/pdf-renderer.tsx index 437ec9dc28..b5b3121170 100644 --- a/frontend/src/components/file-renderers/pdf-renderer.tsx +++ b/frontend/src/components/file-renderers/pdf-renderer.tsx @@ -1,8 +1,15 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { cn } from '@/lib/utils'; import { Document, Page, pdfjs } from 'react-pdf'; +import { Button } from '@/components/ui/button'; +import { + ChevronLeft, + ChevronRight, + Loader, + AlertTriangle, +} from 'lucide-react'; // Import styles for annotations and text layer import 'react-pdf/dist/Page/AnnotationLayer.css'; @@ -17,97 +24,241 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL( interface PdfRendererProps { url: string; className?: string; + /** Compact mode for inline previews - shows first page only, no controls */ + compact?: boolean; } -export function PdfRenderer({ url, className }: PdfRendererProps) { +export function PdfRenderer({ url, className, compact = false }: PdfRendererProps) { const [numPages, setNumPages] = useState(null); const [pageNumber, setPageNumber] = useState(1); - const [scale, setScale] = useState(1.0); + const [containerWidth, setContainerWidth] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const containerRef = useRef(null); + const scrollContainerRef = useRef(null); + + // Track container width for responsive scaling - always fit to width + useEffect(() => { + if (!containerRef.current) return; + + const element = containerRef.current; + const updateWidth = () => { + // Use getBoundingClientRect for accurate width at any zoom level + const rect = element.getBoundingClientRect(); + const width = Math.floor(rect.width); + if (width > 0) { + setContainerWidth(width); + } + }; + + // Initial update + updateWidth(); + + // Use ResizeObserver for container size changes + const observer = new ResizeObserver(() => { + updateWidth(); + }); + observer.observe(element); + + // Also listen to window resize for browser zoom changes + window.addEventListener('resize', updateWidth); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', updateWidth); + }; + }, []); function onDocumentLoadSuccess({ numPages }: { numPages: number }): void { setNumPages(numPages); + setIsLoading(false); + setError(null); } - function changePage(offset: number) { - setPageNumber((prevPageNumber) => { - const newPageNumber = prevPageNumber + offset; - return newPageNumber >= 1 && newPageNumber <= (numPages || 1) - ? newPageNumber - : prevPageNumber; - }); + function onDocumentLoadError(error: Error): void { + console.error('PDF load error:', error); + setError('Failed to load PDF'); + setIsLoading(false); } - function previousPage() { - changePage(-1); - } + const goToPage = useCallback((page: number) => { + if (page >= 1 && page <= (numPages || 1)) { + setPageNumber(page); + // Scroll to top when changing pages + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + } + }, [numPages]); - function nextPage() { - changePage(1); - } + const previousPage = useCallback(() => { + goToPage(pageNumber - 1); + }, [pageNumber, goToPage]); - function zoomIn() { - setScale((prevScale) => Math.min(prevScale + 0.2, 3.0)); - } + const nextPage = useCallback(() => { + goToPage(pageNumber + 1); + }, [pageNumber, goToPage]); + + // Keyboard navigation + useEffect(() => { + if (compact) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } - function zoomOut() { - setScale((prevScale) => Math.max(prevScale - 0.2, 0.5)); + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + previousPage(); + break; + case 'ArrowRight': + e.preventDefault(); + nextPage(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [compact, previousPage, nextPage]); + + // Calculate page width - always fit to container with padding + const pageWidth = containerWidth > 0 ? Math.max(containerWidth - 48, 100) : undefined; + + // Handle missing URL + if (!url) { + return ( +
+
No PDF URL provided
+
+ ); } - return ( -
-
- - - + // Compact mode: first page only, no controls + if (compact) { + return ( +
+
+ + +
+ } + error={ +
+ Failed to load PDF +
+ } + > + + +
+ ); + } - {numPages && ( -
-
- - {Math.round(scale * 100)}% - + + +
+ } + className="shadow-lg rounded-lg overflow-hidden bg-white max-w-full" + /> +
+ )} +
-
- - - Page {pageNumber} of {numPages} - - + +
+ + {pageNumber} + + / + + {numPages} + +
+ + + +
)} diff --git a/frontend/src/components/file-renderers/pptx-renderer.tsx b/frontend/src/components/file-renderers/pptx-renderer.tsx new file mode 100644 index 0000000000..bd8dea541e --- /dev/null +++ b/frontend/src/components/file-renderers/pptx-renderer.tsx @@ -0,0 +1,162 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + Loader2, + Download, + AlertTriangle, +} from 'lucide-react'; +import DocViewer, { DocViewerRenderers } from '@cyntler/react-doc-viewer'; +import '@cyntler/react-doc-viewer/dist/index.css'; +import { constructHtmlPreviewUrl } from '@/lib/utils/url'; + +interface PptxRendererProps { + content?: string | null; + binaryUrl?: string | null; + filePath?: string; + fileName: string; + className?: string; + sandboxId?: string; + project?: { + sandbox?: { + id?: string; + sandbox_url?: string; + }; + }; + onDownload?: () => void; + isDownloading?: boolean; + onFullScreen?: () => void; +} + +export function PptxRenderer({ + binaryUrl, + filePath, + fileName, + className, + project, + onDownload, + isDownloading, +}: PptxRendererProps) { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Build the public URL for the PPTX file + const publicUrl = useMemo(() => { + // Priority 1: Build URL from sandbox_url + filePath + if (project?.sandbox?.sandbox_url && filePath) { + const url = constructHtmlPreviewUrl(project.sandbox.sandbox_url, filePath); + console.log('[PptxRenderer] Public URL:', url); + return url || null; + } + + // Priority 2: Use binaryUrl if it's already a public URL + if (binaryUrl && !binaryUrl.startsWith('blob:')) { + return binaryUrl; + } + + return null; + }, [binaryUrl, filePath, project?.sandbox?.sandbox_url]); + + // Documents configuration for react-doc-viewer + const documents = useMemo(() => { + if (!publicUrl) return []; + return [{ + uri: publicUrl, + fileName: fileName, + fileType: fileName.endsWith('.ppt') ? 'ppt' : 'pptx', + }]; + }, [publicUrl, fileName]); + + // Handle loading states + useEffect(() => { + if (publicUrl) { + setIsLoading(false); + setError(null); + } else if (!project?.sandbox?.sandbox_url && filePath) { + setIsLoading(false); + setError('Waiting for computer to start...'); + } else if (!filePath && binaryUrl?.startsWith('blob:')) { + setIsLoading(false); + setError('Cannot preview local file.'); + } else if (!filePath && !binaryUrl) { + setIsLoading(false); + setError('No file available'); + } else { + setIsLoading(true); + } + }, [publicUrl, binaryUrl, filePath, project?.sandbox?.sandbox_url]); + + // Loading state + if (isLoading) { + return ( +
+ +
+ ); + } + + // Error state + if (error || !publicUrl) { + return ( +
+
+ +

+ {error || 'Cannot preview'} +

+ {onDownload && ( + + )} +
+
+ ); + } + + // Render DocViewer - fills entire container + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/file-renderers/xlsx-renderer.tsx b/frontend/src/components/file-renderers/xlsx-renderer.tsx index 8e92375467..b2dbf062ea 100644 --- a/frontend/src/components/file-renderers/xlsx-renderer.tsx +++ b/frontend/src/components/file-renderers/xlsx-renderer.tsx @@ -39,6 +39,115 @@ interface XlsxRendererProps { isDownloading?: boolean; } +type FileFormat = 'xlsx' | 'xls' | 'unknown'; + +/** + * Detect Excel file format from magic bytes + */ +function detectExcelFormat(buffer: ArrayBuffer): FileFormat { + const view = new Uint8Array(buffer); + + // XLSX/ZIP signature: PK (0x50 0x4B 0x03 0x04) + if (view.length >= 4 && view[0] === 0x50 && view[1] === 0x4B && view[2] === 0x03 && view[3] === 0x04) { + return 'xlsx'; + } + + // XLS/OLE signature: D0 CF 11 E0 A1 B1 1A E1 + if (view.length >= 8 && + view[0] === 0xD0 && view[1] === 0xCF && view[2] === 0x11 && view[3] === 0xE0 && + view[4] === 0xA1 && view[5] === 0xB1 && view[6] === 0x1A && view[7] === 0xE1) { + return 'xls'; + } + + return 'unknown'; +} + +interface ParsedWorkbook { + sheets: { headers: string[]; data: Record[] }[]; + sheetNames: string[]; +} + +/** + * Parse XLSX format using read-excel-file (no vulnerabilities) + */ +async function parseXlsxFormat(file: File): Promise { + const readXlsxFile = (await import('read-excel-file')).default; + const readSheetNames = (await import('read-excel-file')).readSheetNames; + + const sheetNames = await readSheetNames(file); + + const sheets = await Promise.all( + sheetNames.map(async (sheetName) => { + try { + const rows = await readXlsxFile(file, { sheet: sheetName }); + + if (!rows || rows.length === 0) { + return { headers: [], data: [] }; + } + + const headers = rows[0].map((h, idx) => + h == null || h === '' ? `Column ${idx + 1}` : String(h) + ); + + const data = rows.slice(1).map((row) => { + const obj: Record = {}; + headers.forEach((header, i) => { + let value = row[i]; + if (value instanceof Date) { + value = value.toLocaleDateString(); + } + obj[header] = value ?? ''; + }); + return obj; + }); + + return { headers, data }; + } catch (err) { + console.error(`[XlsxRenderer] Error parsing sheet "${sheetName}":`, err); + return { headers: [], data: [] }; + } + }) + ); + + return { sheets, sheetNames }; +} + +/** + * Parse XLS (old Excel format) using xlsx library + * Note: xlsx has known vulnerabilities but is the only option for XLS files + */ +async function parseXlsFormat(arrayBuffer: ArrayBuffer): Promise { + const XLSX = await import('xlsx'); + const workbook = XLSX.read(arrayBuffer, { type: 'array' }); + + const sheetNames: string[] = workbook.SheetNames || []; + + const sheets = sheetNames.map((name) => { + const ws = workbook.Sheets[name]; + const rows: any[][] = XLSX.utils.sheet_to_json(ws, { header: 1 }); + + if (!rows || rows.length === 0) { + return { headers: [], data: [] }; + } + + const headers = (rows[0] as any[]).map((h, idx) => + h == null || h === '' ? `Column ${idx + 1}` : String(h) + ); + + const data = rows.slice(1).map((row) => { + const obj: Record = {}; + headers.forEach((header, i) => { + obj[header] = (row as any[])[i] ?? ''; + }); + return obj; + }); + + return { headers, data }; + }); + + return { sheets, sheetNames }; +} + export function XlsxRenderer({ filePath, fileName, @@ -55,13 +164,14 @@ export function XlsxRenderer({ const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' | null }>({ column: '', direction: null }); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [parsed, setParsed] = useState<{ sheets: { headers: string[]; data: any[] }[]; sheetNames: string[] }>({ sheets: [], sheetNames: [] }); + const [parsed, setParsed] = useState({ sheets: [], sheetNames: [] }); const xlsxPath = filePath || fileName; const resolvedSandboxId = sandboxId || project?.sandbox?.id; React.useEffect(() => { let cancelled = false; + async function load() { try { setIsLoading(true); @@ -72,47 +182,65 @@ export function XlsxRenderer({ setSheetIndex(0); let arrayBuffer: ArrayBuffer; + if (typeof xlsxPath === 'string' && xlsxPath.startsWith('blob:')) { const resp = await fetch(xlsxPath); if (!resp.ok) throw new Error(`Fetch failed: ${resp.status}`); arrayBuffer = await resp.arrayBuffer(); } else if (resolvedSandboxId && session?.access_token) { - const blob = (await fetchFileContent( - resolvedSandboxId, - xlsxPath, - 'blob', - session.access_token - )) as Blob; - arrayBuffer = await blob.arrayBuffer(); + const normalizedPath = xlsxPath.startsWith('/workspace') + ? xlsxPath + : `/workspace/${xlsxPath.startsWith('/') ? xlsxPath.substring(1) : xlsxPath}`; + + const url = new URL(`${process.env.NEXT_PUBLIC_BACKEND_URL}/sandboxes/${resolvedSandboxId}/files/content`); + url.searchParams.append('path', normalizedPath); + + const response = await fetch(url.toString(), { + headers: { + 'Authorization': `Bearer ${session.access_token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Fetch failed: ${response.status}`); + } + + arrayBuffer = await response.arrayBuffer(); } else { const resp = await fetch(xlsxPath); if (!resp.ok) throw new Error(`Fetch failed: ${resp.status}`); arrayBuffer = await resp.arrayBuffer(); } - const XLSX = await import('xlsx'); - const workbook = XLSX.read(arrayBuffer, { type: 'array' }); - const sheetNames: string[] = workbook.SheetNames || []; - const sheets = sheetNames.map((name) => { - const ws = workbook.Sheets[name]; - const rows: any[][] = XLSX.utils.sheet_to_json(ws, { header: 1 }); - if (!rows || rows.length === 0) return { headers: [], data: [] }; - const headers = (rows[0] as string[]).map((h) => (h == null ? '' : String(h))); - const data = rows.slice(1).map((row) => { - const obj: Record = {}; - headers.forEach((header, i) => { - obj[header] = (row as any[])[i] ?? ''; - }); - return obj; + if (!arrayBuffer || arrayBuffer.byteLength === 0) { + throw new Error('Empty file received'); + } + + // Detect file format + const format = detectExcelFormat(arrayBuffer); + console.log('[XlsxRenderer] Detected format:', format, 'size:', arrayBuffer.byteLength); + + let result: ParsedWorkbook; + + if (format === 'xlsx') { + // Use read-excel-file for XLSX (modern format, no vulnerabilities) + const file = new File([arrayBuffer], fileName || 'spreadsheet.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); - return { headers, data }; - }); + result = await parseXlsxFormat(file); + } else if (format === 'xls') { + // Use xlsx library for XLS (old format, required for compatibility) + result = await parseXlsFormat(arrayBuffer); + } else { + throw new Error('Unknown file format. Expected XLS or XLSX file.'); + } if (!cancelled) { - setParsed({ sheets, sheetNames }); + setParsed(result); setIsLoading(false); } } catch (e: any) { + console.error('[XlsxRenderer] Parse error:', e); if (!cancelled) { setError(e?.message || 'Failed to load spreadsheet'); setParsed({ sheets: [], sheetNames: [] }); @@ -120,9 +248,10 @@ export function XlsxRenderer({ } } } + load(); return () => { cancelled = true; }; - }, [xlsxPath, resolvedSandboxId, session?.access_token]); + }, [xlsxPath, resolvedSandboxId, session?.access_token, fileName]); const currentSheet = parsed.sheets[sheetIndex] || { headers: [], data: [] }; @@ -194,7 +323,7 @@ export function XlsxRenderer({
-

{error ? 'Failed to load XLSX' : 'No Data'}

+

{error ? 'Failed to load spreadsheet' : 'No Data'}

{!error &&

This sheet appears to be empty.

} {error &&

{error}

}
@@ -210,7 +339,7 @@ export function XlsxRenderer({
-

XLSX Data

+

Spreadsheet

- {processedData.length.toLocaleString()} rows, {visibleHeaders.length} columns

diff --git a/frontend/src/components/markdown/index.tsx b/frontend/src/components/markdown/index.tsx new file mode 100644 index 0000000000..f65cb5f53c --- /dev/null +++ b/frontend/src/components/markdown/index.tsx @@ -0,0 +1,11 @@ +/** + * Unified Markdown System + * + * Single source of truth for all markdown rendering. + * Import from here to ensure consistency across the entire application. + * + * All markdown uses the same consistent spacing and styling - no variants. + */ + +export { UnifiedMarkdown } from './unified-markdown'; +export type { UnifiedMarkdownProps } from './unified-markdown'; diff --git a/frontend/src/components/markdown/unified-markdown.tsx b/frontend/src/components/markdown/unified-markdown.tsx new file mode 100644 index 0000000000..48797c6b5e --- /dev/null +++ b/frontend/src/components/markdown/unified-markdown.tsx @@ -0,0 +1,446 @@ +'use client'; + +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import Link from 'next/link'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import { Check, Copy } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { MermaidRenderer } from '@/components/ui/mermaid-renderer'; +import { isMermaidCode } from '@/lib/mermaid-utils'; +import { autoLinkUrls } from '@/lib/utils/url-autolink'; + +// Helper to check if a URL is internal (same origin) +function isInternalUrl(href: string | undefined): boolean { + if (!href) return false; + + // External URLs (http/https/mailto/tel) + if (href.startsWith('http://') || href.startsWith('https://')) { + return false; + } + + // Protocol links (mailto, tel, etc.) + if (href.includes('://')) { + return false; + } + + // Internal links (starting with / or #) + return href.startsWith('/') || href.startsWith('#'); +} + +// Helper to handle hash link clicks for smooth scrolling +function handleHashClick(e: React.MouseEvent, href: string) { + if (href.startsWith('#')) { + e.preventDefault(); + const targetId = href.substring(1); + const element = document.getElementById(targetId); + + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } +} + +// Copy button component for code blocks +function CopyButton({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }, [code]); + + return ( + + ); +} + +// Code block component with copy functionality +function CodeBlock({ children }: { children: React.ReactNode }) { + const preRef = useRef(null); + const [codeText, setCodeText] = useState(''); + + useEffect(() => { + if (preRef.current) { + const codeElement = preRef.current.querySelector('code'); + if (codeElement) { + const text = codeElement.textContent || ''; + setCodeText(text.trim()); + } + } + }, [children]); + + return ( +
+
+        {children}
+      
+ {codeText && } +
+ ); +} + +export interface UnifiedMarkdownProps { + content: string; + className?: string; +} + +/** + * UNIFIED MARKDOWN RENDERER + * + * Single source of truth for all markdown rendering across the application. + * Optimized for Kortix brand with Vercel-level UX/UI polish. + * + * Design principles: + * - Clean, minimal aesthetic + * - Consistent spacing rhythm + * - Excellent readability in light & dark modes + * - Brand-aligned colors and border radius + */ +export const UnifiedMarkdown = React.memo(({ + content, + className, +}) => { + if (!content) { + return ( +
+ No content +
+ ); + } + + // Auto-link plain URLs before rendering + const processedContent = autoLinkUrls(content); + + return ( +
+ ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + h4: ({ children }) => ( +

+ {children} +

+ ), + h5: ({ children }) => ( +
+ {children} +
+ ), + h6: ({ children }) => ( +
+ {children} +
+ ), + + // ═══════════════════════════════════════════════════════════════ + // PARAGRAPHS - Optimal line height for readability + // ═══════════════════════════════════════════════════════════════ + p: ({ children }) => ( +

+ {children} +

+ ), + + // ═══════════════════════════════════════════════════════════════ + // LISTS - Clean bullets with proper spacing + // ═══════════════════════════════════════════════════════════════ + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) => ( +
  • + {children} +
  • + ), + + // ═══════════════════════════════════════════════════════════════ + // LINKS - Subtle, professional styling with Next.js routing + // ═══════════════════════════════════════════════════════════════ + a: ({ href, children }) => { + const isInternal = isInternalUrl(href); + const isHashLink = href?.startsWith('#'); + const linkClassName = cn( + "font-medium text-foreground", + "underline decoration-foreground/30 underline-offset-[3px] decoration-[1px]", + "hover:decoration-foreground/60 transition-colors duration-150" + ); + + if (isHashLink) { + // Hash links use smooth scroll + return ( + handleHashClick(e, href)} + className={linkClassName} + > + {children} + + ); + } + + if (isInternal) { + // Internal links use Next.js Link for client-side navigation + return ( + + {children} + + ); + } + + // External links open in new tab + return ( + + {children} + + ); + }, + + // ═══════════════════════════════════════════════════════════════ + // CODE - Clean, readable code styling with copy button + // ═══════════════════════════════════════════════════════════════ + code: ({ children, className: codeClassName }) => { + const match = /language-(\w+)/.exec(codeClassName || ''); + const language = match ? match[1] : ''; + const code = String(children).replace(/\n$/, ''); + const isBlock = codeClassName?.includes('language-'); + + if (isBlock) { + // Mermaid diagrams + if (isMermaidCode(language, code)) { + return ; + } + + // Block code inside pre - inherit styles + return ( + + {children} + + ); + } + + // Inline code - subtle pill style + return ( + + {children} + + ); + }, + pre: ({ children }) => {children}, + + // ═══════════════════════════════════════════════════════════════ + // BLOCKQUOTES - Clean side border + // ═══════════════════════════════════════════════════════════════ + blockquote: ({ children }) => ( +
    p]:my-2" + )}> + {children} +
    + ), + + // ═══════════════════════════════════════════════════════════════ + // HORIZONTAL RULE - Subtle divider + // ═══════════════════════════════════════════════════════════════ + hr: () => ( +
    + ), + + // ═══════════════════════════════════════════════════════════════ + // TABLES - Clean, modern table design + // ═══════════════════════════════════════════════════════════════ + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + + {children} + + ), + tbody: ({ children }) => ( + + {children} + + ), + tr: ({ children }) => ( + + {children} + + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + + // ═══════════════════════════════════════════════════════════════ + // IMAGES - Polished with proper spacing and rounded corners + // ═══════════════════════════════════════════════════════════════ + img: ({ src, alt }) => { + // Don't render img with empty src to avoid browser warning + if (!src) return null; + return ( + + {alt + {alt && ( + + {alt} + + )} + + ); + }, + + // ═══════════════════════════════════════════════════════════════ + // TEXT FORMATTING - Proper emphasis styling + // ═══════════════════════════════════════════════════════════════ + strong: ({ children }) => ( + + {children} + + ), + em: ({ children }) => ( + + {children} + + ), + del: ({ children }) => ( + + {children} + + ), + + // ═══════════════════════════════════════════════════════════════ + // TASK LISTS - Checkbox styling (GFM) + // ═══════════════════════════════════════════════════════════════ + input: ({ checked, ...props }) => ( + + ), + + // ═══════════════════════════════════════════════════════════════ + // RAW HTML SUPPORT (GFM allows raw HTML) + // ═══════════════════════════════════════════════════════════════ + div: ({ children, style, className: divClassName, ...props }) => ( +
    + {children} +
    + ), + span: ({ children, style, className: spanClassName, ...props }) => ( + + {children} + + ), + }} + > + {processedContent} +
    +
    + ); +}); + +UnifiedMarkdown.displayName = 'UnifiedMarkdown'; diff --git a/frontend/src/components/thread/HealthCheckedVncIframe.tsx b/frontend/src/components/thread/HealthCheckedVncIframe.tsx index 060e33a31e..8ef2a06231 100644 --- a/frontend/src/components/thread/HealthCheckedVncIframe.tsx +++ b/frontend/src/components/thread/HealthCheckedVncIframe.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Loader2, AlertCircle, RefreshCw } from 'lucide-react'; @@ -17,27 +17,47 @@ interface HealthCheckedVncIframeProps { export function HealthCheckedVncIframe({ sandbox, className }: HealthCheckedVncIframeProps) { const [iframeKey, setIframeKey] = useState(0); + const [isBrowserLoading, setIsBrowserLoading] = useState(true); // Use the enhanced VNC preloader hook - const { status, retryCount, retry, isPreloaded, accessToken } = useVncPreloader(sandbox, { + const { status, retryCount, retry, isPreloaded } = useVncPreloader(sandbox, { maxRetries: 5, initialDelay: 1000, timeoutMs: 5000 }); + // When iframe is preloaded, show loading overlay for a bit to let browser initialize + useEffect(() => { + if (isPreloaded && isBrowserLoading) { + // Give browser time to initialize and navigate (3-4 seconds) + const timer = setTimeout(() => { + setIsBrowserLoading(false); + }, 4000); + return () => clearTimeout(timer); + } + }, [isPreloaded, isBrowserLoading]); + + // Reset loading state when sandbox changes + useEffect(() => { + setIsBrowserLoading(true); + }, [sandbox?.id]); + + + + // VNC URL received but preloading in progress if (status === 'loading') { return (
    -
    - -

    Connecting to browser...

    +
    + +

    Connecting to browser...

    Testing VNC connection

    {retryCount > 0 && ( -

    +

    🔄 Attempt {retryCount + 1}/5

    )} @@ -79,26 +99,20 @@ export function HealthCheckedVncIframe({ sandbox, className }: HealthCheckedVncI