From 6677b1b64bc2ff2e7d4b75c701600645c5da1c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Fri, 20 Mar 2026 17:03:06 +1100 Subject: [PATCH] =?UTF-8?q?feat:=20node=20view=20redesign=20=E2=80=94=20si?= =?UTF-8?q?ngle-scroll,=20no=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the full node view redesign from ra-h. Replaces the 4-tab layout (Desc/Notes/Edges/Source) with a single-scroll document view. - New FocusPanel: frontmatter rows, collapse toggle, NodeSearchModal, TipTap SourceEditor, section headers with extending rules - NodeSearchModal: new full-screen portal modal for creating connections - SourceEditor: TipTap WYSIWYG editor (new file) - SourceReader: removed header chrome, added onContentClick prop - Source formatters: inherit font, 15px/1.7, no max-width centering - DimensionTags: subtle dashed + button replaces green pill - autoEmbedQueue: recover nodes stuck in 'chunking' state on startup - edges: fix extra parameter in getNodeConnectionsSQLite Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 829 +++- package.json | 3 + src/components/focus/FocusPanel.tsx | 3892 +++++------------ .../focus/dimensions/DimensionTags.tsx | 46 +- .../focus/edges/NodeSearchModal.tsx | 402 ++ src/components/focus/source/SourceEditor.tsx | 140 + src/components/focus/source/SourceReader.tsx | 92 +- .../focus/source/formatters/BookFormatter.tsx | 19 +- .../source/formatters/MarkdownFormatter.tsx | 14 +- .../focus/source/formatters/RawFormatter.tsx | 14 +- .../source/formatters/TranscriptFormatter.tsx | 28 +- src/services/database/edges.ts | 1 - src/services/embedding/autoEmbedQueue.ts | 11 +- 13 files changed, 2568 insertions(+), 2923 deletions(-) create mode 100644 src/components/focus/edges/NodeSearchModal.tsx create mode 100644 src/components/focus/source/SourceEditor.tsx diff --git a/package-lock.json b/package-lock.json index 63528fe..ef6e702 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@langchain/core": "^1.0.1", "@langchain/textsplitters": "^1.0.1", "@modelcontextprotocol/sdk": "^1.25.3", + "@tiptap/react": "^3.20.4", + "@tiptap/starter-kit": "^3.20.4", "@xyflow/react": "^12.10.0", "ai": "^6.0.27", "better-sqlite3": "^12.2.0", @@ -27,6 +29,7 @@ "react-dom": "^19.0.1", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "tiptap-markdown": "^0.9.0", "youtube-transcript": "^1.2.1", "youtube-transcript-plus": "^1.1.1", "zod": "^3.25.76" @@ -667,6 +670,34 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT", + "optional": true + }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -1691,6 +1722,12 @@ "node": ">=8.0.0" } }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", @@ -2076,6 +2113,440 @@ "tslib": "^2.8.0" } }, + "node_modules/@tiptap/core": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.4.tgz", + "integrity": "sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.4.tgz", + "integrity": "sha512-9sskyyhYj2oKat//lyZVXCp9YrPt4oJAZnGHYWXS0xlskjsLElrfKKlM4vpbhGss3VrhQRoEGqWLnIaJYPF1zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.4.tgz", + "integrity": "sha512-Md7/mNAeJCY+VLJc8JRGI+8XkVPKiOGB1NgqQPdh3aYtxXQDChQOZoJEQl6TuudDxZ85bLZB67NjZlx3jo8/0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.4.tgz", + "integrity": "sha512-EXywPlI8wjPcAb8ozymgVhjtMjFrnhtoyNTy8ZcObdpUi5CdO9j892Y7aPbKe5hLhlDpvJk7rMfir4FFKEmfng==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.4.tgz", + "integrity": "sha512-1RTGrur1EKoxfnLZ3M6xeNj8GITAz74jH2DHGcjLsd2Xr7Q7BozGaIq6GkkvKguMwbI1zCOxTHFCpUETXAIQQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.4.tgz", + "integrity": "sha512-7j8Hi964bH1SZ9oLdZC1fkqWz27mliSDV7M8lmL/M14+Qw42D/VOAKS4Aw9OCFtHMlTsjLR6qsoVxL8Lpkt6NA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.4.tgz", + "integrity": "sha512-Zlw3FrXTy01+o1yISeX/LC+iJeHA+ym602bMXGmtA6lyl7QSOSO7WExweJ6xeJGhbCjldwT5al6fkRAs8iGJZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.4.tgz", + "integrity": "sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.4.tgz", + "integrity": "sha512-TgMwvZ8myXYdmd6bUV7qkpZXv7ZUiSmX/8eo+iPEzYo2CnDLAGvDKgC50nfq/g87SDvfBgPuAiBfFvsMQQWaTw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.4.tgz", + "integrity": "sha512-AaPTFhoO8DBIElJyd/RTVJjkctvJuL+GHURX0npbtTxXq5HXbebVwf2ARNR7jMd/GThsmBaNJiGxZg4A2oeDqQ==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.4.tgz", + "integrity": "sha512-JJ6f1iQ1e0s4kISgq55U3UYGwWV/N9f0PYMtB6e3L+SBQjXnywaLK0g6vfN6IvTCC2vdIuqeSOX8VlSO97sJLw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.4.tgz", + "integrity": "sha512-gJbq58d8zB1gzyqVEopowej5CpW4/Fpg6oGJvlZxaCukqd0gJRWGC89K+jE62YA1Td4sfcKrekKvN7jm2y/ZUg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.4.tgz", + "integrity": "sha512-xsnkmTGggJc5P2iCwS1lv8KFG31xC/GNPJKoi/3UH67j/lKDhA3AdtshsLeyv2FKtTtYDb8oV0IqzHB1MM6a7w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.4.tgz", + "integrity": "sha512-y6joCi49haAA0bo3EGUY+dWUMHH1GPUc84hxrBY/0pMs+Bn+kQ1+DQJErZDTWGJrlHPWU/yekBZT72SNdp0DNA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.4.tgz", + "integrity": "sha512-4ZqiWr7cmqPFux8tj1ZLiYytyWf343IvQemNX6AvVWvscrJcrfj3YX4Le2BA0RW3A3M6RpLQXXozuF8vxYFDeQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.4.tgz", + "integrity": "sha512-JNDSkWrVdb8NSvbQXwHWvK5tCMbTWwOHFOweknQZ1JPK4dei9FJVofYQaHyW4bJBdcCjds3NZSnXE8DM9iAWmg==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.4.tgz", + "integrity": "sha512-X+5plTKhOioNcQ4KsAFJJSb/3+zR8Xhdpow4HzXtoV1KcbdDey1fhZdpsfkbrzCL0s6/wAgwZuAchCK7HujurQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.4.tgz", + "integrity": "sha512-QoTc5RACXaZF+vIIBBxjGO7D0oWFUDgBKJCpvUZ0CoGGKosnfe4a9I5THFyLj4201cf0oUqgf1oZhTqETGxlVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.4.tgz", + "integrity": "sha512-RIqXM649+8IP7p/KVfaGlJiwjCylm1m6OPlaoM3K8O7oEOGRQzNeexexECCD2jsXRxew4E+vBNMD2orXqJmu8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.4.tgz", + "integrity": "sha512-3budNL8BgBon3TcXZ4hjT0YpFvx1Ka3uSIECKDxHgES+OQcR+6cagxSb60gFEccf3Dr0PIwcVTY6g14lC1qKRQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.4.tgz", + "integrity": "sha512-lm6fOScWuZAF/Sfp97igUwFd3L1QHIVLAWP5NVdh0DTLrEIt4rMBmsww+yOpMQRhvz2uTgMbMXynrimhzi/QVw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.4.tgz", + "integrity": "sha512-It1Px9uDGTsVqyyg6cy7DigLoenljpQwqdI0jssM7QclZrHnsrye9fZxBBiiuCzzV1305MxKgHvratkHwqmVNA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.4.tgz", + "integrity": "sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.4.tgz", + "integrity": "sha512-0OjMc3FDujX16G+jhvqcY/mLot8SrNtDu8ggUwNLAfiI/QIvMVgk7giFD71DATC/4Nb8i/iwAEegTD8MxBIXCg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.4.tgz", + "integrity": "sha512-8p6hVT65DjuQjtEdlH6ewX9SOJHlVQAOee3sWIJQmeJNRnZNvqPIBLleebUqDiljNTpxBv6s6QWkSTKgf3btwg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.4.tgz", + "integrity": "sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.4.tgz", + "integrity": "sha512-1B8iWsHWwb5TeyVaUs8BRPzwWo4PsLQcl03urHaz0zTJ8DauopqvxzV3+lem1OkzRHn7wnrapDvwmIGoROCaQw==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.20.4", + "@tiptap/extension-floating-menu": "^3.20.4" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/pm": "^3.20.4", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.20.4", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.4.tgz", + "integrity": "sha512-WcyK6hsTl8eBsQhQ+d9Sq8fYZKOYdL+D45MyH3hz583elXqJlW3h3JPFYb0o87gddGxn8Mm57OA/gA1zEdeDMw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.20.4", + "@tiptap/extension-blockquote": "^3.20.4", + "@tiptap/extension-bold": "^3.20.4", + "@tiptap/extension-bullet-list": "^3.20.4", + "@tiptap/extension-code": "^3.20.4", + "@tiptap/extension-code-block": "^3.20.4", + "@tiptap/extension-document": "^3.20.4", + "@tiptap/extension-dropcursor": "^3.20.4", + "@tiptap/extension-gapcursor": "^3.20.4", + "@tiptap/extension-hard-break": "^3.20.4", + "@tiptap/extension-heading": "^3.20.4", + "@tiptap/extension-horizontal-rule": "^3.20.4", + "@tiptap/extension-italic": "^3.20.4", + "@tiptap/extension-link": "^3.20.4", + "@tiptap/extension-list": "^3.20.4", + "@tiptap/extension-list-item": "^3.20.4", + "@tiptap/extension-list-keymap": "^3.20.4", + "@tiptap/extension-ordered-list": "^3.20.4", + "@tiptap/extension-paragraph": "^3.20.4", + "@tiptap/extension-strike": "^3.20.4", + "@tiptap/extension-text": "^3.20.4", + "@tiptap/extension-underline": "^3.20.4", + "@tiptap/extensions": "^3.20.4", + "@tiptap/pm": "^3.20.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2204,6 +2675,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -2213,6 +2700,12 @@ "@types/unist": "*" } }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -2251,7 +2744,6 @@ "version": "19.0.2", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz", "integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2263,6 +2755,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -3155,7 +3653,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -4140,6 +4637,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4931,7 +5434,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5592,6 +6094,15 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -7210,6 +7721,21 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7282,6 +7808,29 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-task-lists": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz", + "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==", + "license": "ISC" + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -7583,6 +8132,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -8719,6 +9274,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -9288,6 +9849,201 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", + "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", + "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz", + "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz", + "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.7", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.7.tgz", + "integrity": "sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -9321,6 +10077,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -9746,6 +10511,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -10809,6 +11580,46 @@ "node": ">=14.0.0" } }, + "node_modules/tiptap-markdown": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz", + "integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==", + "license": "MIT", + "workspaces": [ + "example" + ], + "dependencies": { + "@types/markdown-it": "^13.0.7", + "markdown-it": "^14.1.0", + "markdown-it-task-lists": "^2.1.1", + "prosemirror-markdown": "^1.11.1" + }, + "peerDependencies": { + "@tiptap/core": "^3.0.1" + } + }, + "node_modules/tiptap-markdown/node_modules/@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "license": "MIT" + }, + "node_modules/tiptap-markdown/node_modules/@types/markdown-it": { + "version": "13.0.9", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz", + "integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^3", + "@types/mdurl": "^1" + } + }, + "node_modules/tiptap-markdown/node_modules/@types/mdurl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz", + "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11535,6 +12346,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -13028,6 +13845,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "4.0.0-beta.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", diff --git a/package.json b/package.json index d28c560..2e666ee 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "@langchain/core": "^1.0.1", "@langchain/textsplitters": "^1.0.1", "@modelcontextprotocol/sdk": "^1.25.3", + "@tiptap/react": "^3.20.4", + "@tiptap/starter-kit": "^3.20.4", "@xyflow/react": "^12.10.0", "ai": "^6.0.27", "better-sqlite3": "^12.2.0", @@ -40,6 +42,7 @@ "react-dom": "^19.0.1", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "tiptap-markdown": "^0.9.0", "youtube-transcript": "^1.2.1", "youtube-transcript-plus": "^1.1.1", "zod": "^3.25.76" diff --git a/src/components/focus/FocusPanel.tsx b/src/components/focus/FocusPanel.tsx index 2ac7dd3..a60fc47 100644 --- a/src/components/focus/FocusPanel.tsx +++ b/src/components/focus/FocusPanel.tsx @@ -1,25 +1,17 @@ "use client"; -import { useState, useEffect, useRef, type DragEvent } from 'react'; -import { Eye, Trash2, Link, Loader, Database, Check, RefreshCw, Pencil, X, Save, Plus } from 'lucide-react'; +import { useEffect, useRef, useState, type DragEvent } from 'react'; +import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft } from 'lucide-react'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; -import MarkdownWithNodeTokens from '@/components/helpers/MarkdownWithNodeTokens'; -import FormattingToolbar from '@/components/focus/FormattingToolbar'; -import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter'; -import { Node, NodeConnection, Chunk } from '@/types/database'; +import { Node, NodeConnection } from '@/types/database'; import DimensionTags from './dimensions/DimensionTags'; import { getNodeIcon } from '@/utils/nodeIcons'; import { useDimensionIcons } from '@/context/DimensionIconsContext'; import ConfirmDialog from '../common/ConfirmDialog'; import { SourceReader } from './source'; +import SourceEditor from './source/SourceEditor'; import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl'; - - -interface NodeSearchResult { - id: number; - title: string; - dimensions?: string[]; -} +import NodeSearchModal from './edges/NodeSearchModal'; interface FocusPanelProps { openTabs: number[]; @@ -28,752 +20,258 @@ interface FocusPanelProps { onNodeClick?: (nodeId: number) => void; onTabClose: (nodeId: number) => void; refreshTrigger?: number; + onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void; + highlightedPassage?: { nodeId: number; selectedText: string } | null; onReorderTabs?: (fromIndex: number, toIndex: number) => void; onOpenInOtherSlot?: (nodeId: number) => void; hideTabBar?: boolean; - onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void; - highlightedPassage?: { nodeId: number; selectedText: string } | null; } -export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) { - const [nodesData, setNodesData] = useState>({}); +type HoverSection = 'description' | 'source' | null; + +export default function FocusPanel({ + openTabs, + activeTab, + onTabSelect, + onNodeClick, + onTabClose, + refreshTrigger, + onTextSelect, + highlightedPassage, + onReorderTabs, + onOpenInOtherSlot, + hideTabBar, +}: FocusPanelProps) { const { dimensionIcons } = useDimensionIcons(); - - // Context menu state - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null); + const [nodesData, setNodesData] = useState>({}); + const [edgesData, setEdgesData] = useState>({}); const [loadingNodes, setLoadingNodes] = useState>(new Set()); - const [editingField, setEditingField] = useState(null); - const [editingValue, setEditingValue] = useState(''); - const [editingNodeId, setEditingNodeId] = useState(null); - const [savingField, setSavingField] = useState(null); - const [embeddingNode, setEmbeddingNode] = useState(null); - const [showReembedPrompt, setShowReembedPrompt] = useState(null); - - const activeNodeId = activeTab; - const currentNode = activeNodeId !== null ? nodesData[activeNodeId] : undefined; - - const inputRef = useRef(null); - - // Auto-hide re-embed prompt after a few seconds - useEffect(() => { - if (showReembedPrompt !== null) { - const timeout = setTimeout(() => setShowReembedPrompt(null), 4000); - return () => clearTimeout(timeout); - } - }, [showReembedPrompt]); - - // Close context menu when clicking outside - useEffect(() => { - if (!contextMenu) return; - const handleClick = () => setContextMenu(null); - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setContextMenu(null); - }; - document.addEventListener('click', handleClick); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('click', handleClick); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [contextMenu]); - - // Edges state management (following same patterns as nodes) - const [edgesData, setEdgesData] = useState<{ [key: number]: NodeConnection[] }>({}); const [loadingEdges, setLoadingEdges] = useState>(new Set()); - const [addingEdge, setAddingEdge] = useState(null); - const [nodeSearchQuery, setNodeSearchQuery] = useState(''); - const [nodeSearchSuggestions, setNodeSearchSuggestions] = useState([]); - const [selectedSearchIndex, setSelectedSearchIndex] = useState(0); - const [edgeExplanation, setEdgeExplanation] = useState(''); - const [pendingEdgeTarget, setPendingEdgeTarget] = useState<{ id: number; title: string } | null>(null); - const [deletingEdge, setDeletingEdge] = useState(null); - const [edgeEditingId, setEdgeEditingId] = useState(null); - const [edgeEditingValue, setEdgeEditingValue] = useState(''); - const [edgeSavingId, setEdgeSavingId] = useState(null); + const [embeddingNode, setEmbeddingNode] = useState(null); const [deletingNode, setDeletingNode] = useState(null); const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState(null); - - // Chunk content toggle state - default to expanded (true) - const [chunkExpanded, setChunkExpanded] = useState<{ [key: number]: boolean }>({}); - - // Edges expand/collapse state - const [edgesExpanded, setEdgesExpanded] = useState<{ [key: number]: boolean }>({}); - - // Title expanded state for click-to-expand full title - const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({}); + const [deletingEdge, setDeletingEdge] = useState(null); + const [edgeEditingId, setEdgeEditingId] = useState(null); + const [edgeEditingValue, setEdgeEditingValue] = useState(''); + const [edgeSavingId, setEdgeSavingId] = useState(null); + const [edgesExpanded, setEdgesExpanded] = useState>({}); + const [edgeSearchOpen, setEdgeSearchOpen] = useState(false); + const [hoveredConnectionId, setHoveredConnectionId] = useState(null); + const [propsCollapsed, setPropsCollapsed] = useState(false); - // Description regeneration state - const [regeneratingDescription, setRegeneratingDescription] = useState(null); - - // Content tab state: 'desc', 'notes', 'edges', or 'source' - const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('desc'); - - // Desc (description) edit mode state + const [titleEditMode, setTitleEditMode] = useState(false); + const [titleEditValue, setTitleEditValue] = useState(''); + const [titleSaving, setTitleSaving] = useState(false); + const [linkEditMode, setLinkEditMode] = useState(false); + const [linkEditValue, setLinkEditValue] = useState(''); + const [linkSaving, setLinkSaving] = useState(false); const [descEditMode, setDescEditMode] = useState(false); const [descEditValue, setDescEditValue] = useState(''); const [descSaving, setDescSaving] = useState(false); - - // Notes edit mode state (separate from inline editing) - const [notesEditMode, setNotesEditMode] = useState(false); - const [notesEditValue, setNotesEditValue] = useState(''); - const [notesSaving, setNotesSaving] = useState(false); - const notesTextareaRef = useRef(null); - - // Source (chunk) edit mode + const [regeneratingDescription, setRegeneratingDescription] = useState(null); const [sourceEditMode, setSourceEditMode] = useState(false); const [sourceEditValue, setSourceEditValue] = useState(''); const [sourceSaving, setSourceSaving] = useState(false); + const [hoveredSection, setHoveredSection] = useState(null); - // Source reader mode: 'raw' (monospace) or 'reader' (formatted typography) - const [sourceReaderMode, setSourceReaderMode] = useState<'raw' | 'reader'>('reader'); + const titleInputRef = useRef(null); + const linkInputRef = useRef(null); + const descTextareaRef = useRef(null); + const sourceTextareaRef = useRef(null); + const skipTitleBlurRef = useRef(false); + const skipLinkBlurRef = useRef(false); - // Embedded chunks state (actual chunks from chunks table) - const [chunksData, setChunksData] = useState>({}); - const [loadingChunks, setLoadingChunks] = useState>(new Set()); - const [chunksExpanded, setChunksExpanded] = useState>({}); + const currentNode = activeTab !== null ? nodesData[activeTab] : undefined; + const currentEdges = activeTab !== null ? edgesData[activeTab] || [] : []; + const currentEdgesExpanded = activeTab !== null ? Boolean(edgesExpanded[activeTab]) : false; + const visibleEdges = currentEdgesExpanded ? currentEdges : currentEdges.slice(0, 3); - // Edge creation search toggle - const [edgeSearchOpen, setEdgeSearchOpen] = useState(false); - - // Helper: preview edge type based on heuristics (mirrors backend logic) - const previewEdgeType = (explanation: string): { type: string; label: string } | null => { - const norm = (explanation || '').trim().toLowerCase(); - if (!norm) return null; - - const startsWithAny = (prefixes: string[]) => prefixes.some((p) => norm.startsWith(p)); - - if (startsWithAny(['created by', 'made by', 'authored by', 'written by', 'founded by'])) { - return { type: 'created_by', label: 'created by' }; - } - if (startsWithAny(['part of', 'episode of', 'belongs to', 'in the series', 'in this series'])) { - return { type: 'part_of', label: 'part of' }; - } - if (startsWithAny(['features', 'mentions', 'hosted by', 'guest:', 'host:'])) { - return { type: 'part_of', label: 'part of' }; - } - if (startsWithAny(['came from', 'inspired by', 'derived from', 'from'])) { - return { type: 'source_of', label: 'source of' }; - } - if (startsWithAny(['related to', 'related'])) { - return { type: 'related_to', label: 'related to' }; - } - // No heuristic match - will be AI-inferred - return { type: 'inferred', label: 'will be inferred' }; - }; - - - // Generate node search suggestions useEffect(() => { - if (!nodeSearchQuery.trim() || !activeTab) { - setNodeSearchSuggestions([]); - return; - } + if (titleEditMode) titleInputRef.current?.focus(); + }, [titleEditMode]); - const fetchNodeSearchSuggestions = async () => { - try { - const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(nodeSearchQuery)}&limit=10`); - const result = await response.json(); - - if (result.success) { - const nodeSuggestions: NodeSearchResult[] = result.data - .filter((node: any) => node.id !== activeTab) // Exclude current node - .map((node: any) => ({ - id: node.id, - title: node.title, - dimensions: node.dimensions || [] - })); - - setNodeSearchSuggestions(nodeSuggestions); - setSelectedSearchIndex(0); - } - } catch (error) { - console.error('Error fetching node search suggestions:', error); - setNodeSearchSuggestions([]); - } - }; + useEffect(() => { + if (linkEditMode) linkInputRef.current?.focus(); + }, [linkEditMode]); + + useEffect(() => { + if (descEditMode) descTextareaRef.current?.focus(); + }, [descEditMode]); + + useEffect(() => { + if (sourceEditMode) sourceTextareaRef.current?.focus(); + }, [sourceEditMode]); - const timeoutId = setTimeout(fetchNodeSearchSuggestions, 200); - return () => clearTimeout(timeoutId); - }, [nodeSearchQuery, activeTab]); - // Fetch node data when new tabs are opened useEffect(() => { openTabs.forEach((tabId) => { if (!nodesData[tabId] && !loadingNodes.has(tabId)) { - fetchNodeData(tabId); + void fetchNodeData(tabId); } }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [openTabs]); + }, [openTabs, nodesData, loadingNodes]); useEffect(() => { openTabs.forEach((tabId) => { if (nodesData[tabId] && !edgesData[tabId] && !loadingEdges.has(tabId)) { - fetchEdgesData(tabId); + void fetchEdgesData(tabId); } }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [openTabs, nodesData, edgesData]); + }, [openTabs, nodesData, edgesData, loadingEdges]); - - // Refresh data when SSE events trigger updates useEffect(() => { if (refreshTrigger !== undefined && refreshTrigger > 0 && activeTab) { - fetchNodeData(activeTab); - fetchEdgesData(activeTab); + void fetchNodeData(activeTab); + void fetchEdgesData(activeTab); } }, [refreshTrigger, activeTab]); - // Clear editing state when switching nodes useEffect(() => { - // Clear all edit modes when switching to a different node - setEditingField(null); - setEditingValue(''); - setEditingNodeId(null); - // Also clear notes/desc/source/edges edit modes - setNotesEditMode(false); - setNotesEditValue(''); + setTitleEditMode(false); + setTitleEditValue(''); + setLinkEditMode(false); + setLinkEditValue(''); setDescEditMode(false); setDescEditValue(''); setSourceEditMode(false); setSourceEditValue(''); setEdgeSearchOpen(false); - setNodeSearchQuery(''); - setNodeSearchSuggestions([]); + setEdgeEditingId(null); + setEdgeEditingValue(''); + setHoveredSection(null); }, [activeTab]); const fetchNodeData = async (id: number) => { setLoadingNodes(prev => new Set(prev).add(id)); - // First try to fetch as a node try { - const nodeResponse = await fetch(`/api/nodes/${id}`); - if (nodeResponse.ok) { - const data = await nodeResponse.json(); - if (data.node) { - setNodesData(prev => ({ ...prev, [id]: data.node })); - setLoadingNodes(prev => { - const newSet = new Set(prev); - newSet.delete(id); - return newSet; - }); - return; - } + const response = await fetch(`/api/nodes/${id}`); + const data = await response.json(); + if (response.ok && data.node) { + setNodesData(prev => ({ ...prev, [id]: data.node })); } } catch (error) { console.warn(`Failed to fetch node ${id}:`, error); + } finally { + setLoadingNodes(prev => { + const next = new Set(prev); + next.delete(id); + return next; + }); } - setLoadingNodes(prev => { - const newSet = new Set(prev); - newSet.delete(id); - return newSet; - }); }; - const fetchEdgesData = async (nodeId: number) => { setLoadingEdges(prev => new Set(prev).add(nodeId)); try { const response = await fetch(`/api/nodes/${nodeId}/edges`); const data = await response.json(); - if (data.success && data.data) { + if (response.ok && data.success && data.data) { setEdgesData(prev => ({ ...prev, [nodeId]: data.data })); } } catch (error) { console.error(`Error fetching edges for node ${nodeId}:`, error); } finally { setLoadingEdges(prev => { - const newSet = new Set(prev); - newSet.delete(nodeId); - return newSet; + const next = new Set(prev); + next.delete(nodeId); + return next; }); } }; - // Fetch embedded chunks for a node - const fetchChunksData = async (nodeId: number) => { - if (loadingChunks.has(nodeId)) return; - setLoadingChunks(prev => new Set(prev).add(nodeId)); - try { - const response = await fetch(`/api/nodes/${nodeId}/chunks`); - const data = await response.json(); - if (data.success && data.chunks) { - setChunksData(prev => ({ ...prev, [nodeId]: data.chunks })); - } - } catch (error) { - console.error(`Error fetching chunks for node ${nodeId}:`, error); - } finally { - setLoadingChunks(prev => { - const newSet = new Set(prev); - newSet.delete(nodeId); - return newSet; - }); + const updateNode = async (nodeId: number, updates: Record) => { + const response = await fetch(`/api/nodes/${nodeId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + + if (!response.ok) { + throw new Error('Failed to update node'); } - }; - // Fetch chunks when switching to Source tab - useEffect(() => { - if (activeContentTab === 'source' && activeTab && !chunksData[activeTab] && !loadingChunks.has(activeTab)) { - fetchChunksData(activeTab); + const result = await response.json(); + if (result.node) { + setNodesData(prev => ({ ...prev, [nodeId]: result.node })); + return result.node as Node; } - }, [activeContentTab, activeTab]); - const truncateTitle = (title: string, maxLength: number = 20) => { - if (title.length <= maxLength) return title; - return title.substring(0, maxLength) + '...'; + throw new Error('Missing updated node in response'); }; - // Focus input when editing starts - useEffect(() => { - if (editingField && inputRef.current) { - inputRef.current.focus(); - if (inputRef.current instanceof HTMLInputElement || inputRef.current instanceof HTMLTextAreaElement) { - inputRef.current.select(); - } - } - }, [editingField]); - - const startEdit = (field: string, currentValue: string) => { - if (savingField) return; // Don't start edit if currently saving - setEditingField(field); - setEditingValue(currentValue || ''); - if (activeNodeId !== null) setEditingNodeId(activeNodeId); + const startTitleEdit = () => { + if (!currentNode) return; + setTitleEditValue(currentNode.title || ''); + setTitleEditMode(true); }; - const cancelEdit = () => { - setEditingField(null); - setEditingValue(''); - setEditingNodeId(null); - }; - - const saveField = async () => { - const nodeId = editingNodeId ?? activeNodeId; - if (!nodeId || !editingField) return; - // Validate required fields - if (editingField === 'title' && !editingValue.trim()) { - alert('Title cannot be empty'); + const saveTitle = async () => { + if (!activeTab) return; + const nextTitle = titleEditValue.trim(); + if (!nextTitle) { + window.alert('Title cannot be empty'); return; } - setSavingField(editingField); + setTitleSaving(true); try { - const updateData: Record = {}; - - if (editingField === 'notes') { - updateData.notes = editingValue; - } else { - updateData[editingField] = editingValue; - } - - const response = await fetch(`/api/nodes/${nodeId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updateData), - }); - - if (!response.ok) { - throw new Error('Failed to save'); - } - - const result = await response.json(); - if (result.node) { - setNodesData(prev => ({ ...prev, [nodeId]: result.node })); - } - - // Safety net: ensure edges exist for any tokens present in saved notes - if (editingField === 'notes' && typeof editingValue === 'string') { - try { - const tokens = parseNodeMarkers(editingValue); - const uniqueTargets = Array.from(new Set(tokens.map(t => t.id))).filter(id => id !== nodeId); - await Promise.allSettled(uniqueTargets.map(async (toId) => { - await fetch('/api/edges', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from_node_id: nodeId, - to_node_id: toId, - source: 'user', - explanation: 'Referenced via @ mention' - }) - }); - })); - // Refresh edges after ensuring - await fetchEdgesData(nodeId); - } catch (e) { - console.warn('Failed to ensure edges from tokens:', e); - } - } - - setEditingField(null); - setEditingValue(''); - setEditingNodeId(null); + await updateNode(activeTab, { title: nextTitle }); + setTitleEditMode(false); + setTitleEditValue(''); } catch (error) { - console.error('Error saving field:', error); - alert('Failed to save changes. Please try again.'); + console.error('Error saving title:', error); + window.alert('Failed to save title. Please try again.'); } finally { - setSavingField(null); + setTitleSaving(false); } }; - // Explicit content saver to avoid stale state reads (used after @mention insert) - const saveContentExplicit = async (contentValue: string, nodeId: number) => { - if (!nodeId) return; - setSavingField('content'); + const startLinkEdit = () => { + setLinkEditValue(currentNode?.link || ''); + setLinkEditMode(true); + }; + + const saveLink = async () => { + if (!activeTab) return; + setLinkSaving(true); try { - const response = await fetch(`/api/nodes/${nodeId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: contentValue }), - }); - if (!response.ok) throw new Error('Failed to save'); - const result = await response.json(); - if (result.node) { - setNodesData(prev => ({ ...prev, [nodeId]: result.node })); - } - // Safety net: ensure edges for tokens in this specific content - try { - const tokens = parseNodeMarkers(contentValue); - const uniqueTargets = Array.from(new Set(tokens.map(t => t.id))).filter(id => id !== nodeId); - await Promise.allSettled(uniqueTargets.map(async (toId) => { - await fetch('/api/edges', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from_node_id: nodeId, - to_node_id: toId, - source: 'user', - explanation: 'Referenced via @ mention' - }) - }); - })); - await fetchEdgesData(nodeId); - } catch (e) { - console.warn('Failed to ensure edges from tokens (explicit save):', e); - } - // Exit edit mode - setEditingField(null); - setEditingValue(''); - } catch (e) { - console.error('Error saving content (explicit):', e); - alert('Failed to save changes. Please try again.'); + await updateNode(activeTab, { link: linkEditValue.trim() || null }); + setLinkEditMode(false); + setLinkEditValue(''); + } catch (error) { + console.error('Error saving link:', error); + window.alert('Failed to save URL. Please try again.'); } finally { - setSavingField(null); + setLinkSaving(false); } }; - // Save desc (description) with explicit Save button + const startDescEdit = () => { + setDescEditValue(currentNode?.description || ''); + setDescEditMode(true); + }; + const saveDesc = async () => { if (!activeTab) return; setDescSaving(true); try { - const response = await fetch(`/api/nodes/${activeTab}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ description: descEditValue }), - }); - if (!response.ok) throw new Error('Failed to save'); - const result = await response.json(); - if (result.node) { - setNodesData(prev => ({ ...prev, [activeTab]: result.node })); - } + await updateNode(activeTab, { description: descEditValue }); setDescEditMode(false); setDescEditValue(''); - } catch (e) { - console.error('Error saving description:', e); - alert('Failed to save description. Please try again.'); + } catch (error) { + console.error('Error saving description:', error); + window.alert('Failed to save description. Please try again.'); } finally { setDescSaving(false); } }; - // Cancel desc editing - const cancelDescEdit = () => { - setDescEditMode(false); - setDescEditValue(''); - }; - - // Start editing desc - const startDescEdit = () => { - if (!activeTab || !nodesData[activeTab]) return; - setDescEditValue(nodesData[activeTab].description || ''); - setDescEditMode(true); - }; - - // Sync description to source and re-embed - const syncDescToSource = async () => { - if (!activeTab) return; - setDescSaving(true); - try { - // Save description to source field - const response = await fetch(`/api/nodes/${activeTab}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ source: descEditValue }), - }); - if (!response.ok) throw new Error('Failed to sync'); - const result = await response.json(); - if (result.node) { - setNodesData(prev => ({ ...prev, [activeTab]: result.node })); - } - - // Trigger re-embedding - await fetch('/api/ingestion', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nodeId: activeTab }), - }); - - // Refresh chunks data - fetchChunksData(activeTab); - - alert('Description synced to source and re-embedded successfully.'); - } catch (e) { - console.error('Error syncing description to source:', e); - alert('Failed to sync to source. Please try again.'); - } finally { - setDescSaving(false); - } - }; - - // Save notes-tab content into canonical source - const saveNotes = async () => { - if (!activeTab) return; - setNotesSaving(true); - try { - const response = await fetch(`/api/nodes/${activeTab}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ source: notesEditValue }), - }); - if (!response.ok) throw new Error('Failed to save'); - const result = await response.json(); - if (result.node) { - setNodesData(prev => ({ ...prev, [activeTab]: result.node })); - } - // Ensure edges for any node tokens - try { - const tokens = parseNodeMarkers(notesEditValue); - const uniqueTargets = Array.from(new Set(tokens.map(t => t.id))).filter(id => id !== activeTab); - await Promise.allSettled(uniqueTargets.map(async (toId) => { - await fetch('/api/edges', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from_node_id: activeTab, - to_node_id: toId, - source: 'user', - explanation: 'Referenced via @ mention' - }) - }); - })); - await fetchEdgesData(activeTab); - } catch (e) { - console.warn('Failed to ensure edges from tokens:', e); - } - setNotesEditMode(false); - setNotesEditValue(''); - } catch (e) { - console.error('Error saving source content:', e); - alert('Failed to save content. Please try again.'); - } finally { - setNotesSaving(false); - } - }; - - // Cancel notes editing - const cancelNotesEdit = () => { - setNotesEditMode(false); - setNotesEditValue(''); - }; - - // Start editing notes - const startNotesEdit = () => { - if (!activeTab || !nodesData[activeTab]) return; - setNotesEditValue(nodesData[activeTab].source || nodesData[activeTab].notes || ''); - setNotesEditMode(true); - }; - - // Save source with explicit Save button - const saveSource = async () => { - if (!activeTab) return; - setSourceSaving(true); - try { - const response = await fetch(`/api/nodes/${activeTab}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ source: sourceEditValue }), - }); - if (!response.ok) throw new Error('Failed to save'); - const result = await response.json(); - if (result.node) { - setNodesData(prev => ({ ...prev, [activeTab]: result.node })); - } - setSourceEditMode(false); - setSourceEditValue(''); - } catch (e) { - console.error('Error saving source:', e); - alert('Failed to save source. Please try again.'); - } finally { - setSourceSaving(false); - } - }; - - // Cancel source editing - const cancelSourceEdit = () => { - setSourceEditMode(false); - setSourceEditValue(''); - }; - - // Start editing source - const startSourceEdit = () => { - if (!activeTab || !nodesData[activeTab]) return; - setSourceEditValue(nodesData[activeTab].source || nodesData[activeTab].chunk || ''); - setSourceEditMode(true); - }; - - // Sync Notes content to Source (with confirmation) - const [showSyncConfirm, setShowSyncConfirm] = useState(false); - const [syncing, setSyncing] = useState(false); - - // Create linked note state - const [creatingNote, setCreatingNote] = useState(false); - - const syncToSource = async () => { - if (!activeTab) return; - setSyncing(true); - setShowSyncConfirm(false); - try { - // First, save notes content to source field - const response = await fetch(`/api/nodes/${activeTab}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ source: notesEditValue }), - }); - if (!response.ok) throw new Error('Failed to sync'); - const result = await response.json(); - if (result.node) { - setNodesData(prev => ({ ...prev, [activeTab]: result.node })); - } - - // Then trigger re-embedding - await fetch('/api/ingestion', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nodeId: activeTab }), - }); - - // Refresh chunks data - fetchChunksData(activeTab); - - // Stay in edit mode but show success - alert('Content synced to source and re-embedded successfully.'); - } catch (e) { - console.error('Error syncing to source:', e); - alert('Failed to sync to source. Please try again.'); - } finally { - setSyncing(false); - } - }; - - // Create a new linked note from current node - const createLinkedNote = async () => { - if (!activeTab || !nodesData[activeTab]) return; - setCreatingNote(true); - - const sourceNodeId = activeTab; - const currentNode = nodesData[sourceNodeId]; - let newNodeId: number | null = null; - - try { - const noteTitle = `New Node from ${currentNode.title}`; - const noteDescription = `New node - ideas or insights from ${currentNode.title}`; - - // Create the new node - const createResponse = await fetch('/api/nodes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: noteTitle, - type: 'note', - content: '', - description: noteDescription, - dimensions: currentNode.dimensions || [] - }), - }); - - if (!createResponse.ok) throw new Error('Failed to create note'); - const createResult = await createResponse.json(); - newNodeId = createResult.data?.id || createResult.node?.id || createResult.id; - - if (!newNodeId) throw new Error('No node ID returned'); - - // Create edge from new note to source node - const edgeResponse = await fetch('/api/edges', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from_node_id: newNodeId, - to_node_id: sourceNodeId, - source: 'user', - explanation: `Ideas or insights from "${currentNode.title}"` - }), - }); - - if (!edgeResponse.ok) { - console.warn('Edge creation failed but note was created'); - } - - // Open the new note in focus - if (onTabSelect && newNodeId) { - onTabSelect(newNodeId); - } - } catch (e) { - console.error('Error creating linked note:', e); - // If node was created but something else failed, still open it - if (newNodeId && onTabSelect) { - onTabSelect(newNodeId); - } else { - alert('Failed to create note. Please try again.'); - } - } finally { - setCreatingNote(false); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - saveField(); - } else if (e.key === 'Escape') { - cancelEdit(); - } - }; - - const handleBlur = () => { - // If mention is active, defer saving slightly to avoid race with selection - const attemptSave = () => { - if (editingField) { - saveField(); - } - }; - if (mentionActive) { - setTimeout(() => { - if (!mentionActive) attemptSave(); - }, 220); - return; - } - // Small delay to allow for clicking inline buttons - setTimeout(attemptSave, 150); - }; - - // Regenerate description for a node const regenerateDescription = async (nodeId: number) => { setRegeneratingDescription(nodeId); try { const response = await fetch(`/api/nodes/${nodeId}/regenerate-description`, { method: 'POST', - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }); if (!response.ok) { @@ -786,328 +284,66 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli } } catch (error) { console.error('Error regenerating description:', error); - alert('Failed to regenerate description. Please try again.'); + window.alert('Failed to regenerate description. Please try again.'); } finally { setRegeneratingDescription(null); } }; - // --- @mention state --- - const [mentionActive, setMentionActive] = useState(false); - const [mentionQuery, setMentionQuery] = useState(''); - const [mentionResults, setMentionResults] = useState([]); - const [mentionIndex, setMentionIndex] = useState(0); - const mentionTimeout = useRef | null>(null); - - const resetMention = () => { - setMentionActive(false); - setMentionQuery(''); - setMentionResults([]); - setMentionIndex(0); + const startSourceEdit = () => { + setSourceEditValue(currentNode?.source || currentNode?.chunk || ''); + setSourceEditMode(true); }; - const findAtTrigger = (text: string, caret: number): { atIndex: number; query: string } | null => { - // Find last '@' before caret that is at start or preceded by whitespace - for (let i = caret - 1; i >= 0; i--) { - const ch = text[i]; - if (ch === '@') { - const prev = i === 0 ? ' ' : text[i - 1]; - if (/\s/.test(prev)) { - const span = text.slice(i + 1, caret); - // Stop if span contains disallowed characters (newline or punctuation other than - _ .) - if (/[^A-Za-z0-9 _\-.]/.test(span)) return null; - return { atIndex: i, query: span }; - } - // If '@' is not preceded by whitespace, keep scanning left - } - // Stop scanning back on whitespace/newline to avoid spanning words - if (ch === '\n') break; - } - return null; - }; - - const runMentionSearch = async (query: string) => { + const saveSource = async () => { if (!activeTab) return; - if (query.trim().length < 2) { - setMentionResults([]); - return; - } + setSourceSaving(true); try { - const resp = await fetch(`/api/nodes/search?q=${encodeURIComponent(query)}&limit=10`); - const payload = (await resp.json()) as { success: boolean; data: NodeSearchResult[] }; - if (payload?.success && Array.isArray(payload.data)) { - const filtered = payload.data - .filter((n) => n.id !== activeTab) - .map((n) => ({ ...n, dimensions: n.dimensions ?? [] })); - setMentionResults(filtered); - setMentionIndex(0); - } + await updateNode(activeTab, { source: sourceEditValue }); + setSourceEditMode(false); + setSourceEditValue(''); } catch (error) { - console.warn('mention search failed:', error); - setMentionResults([]); + console.error('Error saving source:', error); + window.alert('Failed to save source. Please try again.'); + } finally { + setSourceSaving(false); } }; - const handleTextareaChange = (e: React.ChangeEvent) => { - const val = e.target.value; - setEditingValue(val); - - // Detect @mention - const caret = e.target.selectionStart || val.length; - const trig = findAtTrigger(val, caret); - if (trig) { - setMentionActive(true); - setMentionQuery(trig.query); - if (mentionTimeout.current) clearTimeout(mentionTimeout.current); - mentionTimeout.current = setTimeout(() => runMentionSearch(trig.query), 280); - } else if (mentionActive) { - // Exit mention mode if trigger no longer valid - resetMention(); - } - }; - - const replaceMentionWithToken = async (nodeId: number, title: string) => { - if (!inputRef.current || !(inputRef.current instanceof HTMLTextAreaElement)) return; - if (activeNodeId === null) return; - const ta = inputRef.current as HTMLTextAreaElement; - const sourceNodeId = activeNodeId!; - const text = editingValue; - const caret = ta.selectionStart || text.length; - let trig = findAtTrigger(text, caret); - if (!trig) { - // Fallback: try to locate the last occurrence of the current mention token - if (mentionQuery && mentionQuery.length > 0) { - const needle = '@' + mentionQuery; - const idx = text.lastIndexOf(needle); - if (idx !== -1) { - trig = { atIndex: idx, query: mentionQuery }; - } - } - if (!trig) return; - } - // Build quote-safe token - const quoteTitleForToken = (t: string): string => { - if (t.includes('"')) { - // Wrap with single quotes; normalize inner straight single quotes - const norm = t.replace(/'/g, '’'); - return `'${norm}'`; - } - // Wrap with double quotes; keep title as-is (no straight doubles inside) - return `"${t}"`; - }; - const token = `[NODE:${nodeId}:${quoteTitleForToken(title)}]`; - const before = text.slice(0, trig.atIndex); - const after = text.slice(trig.atIndex + 1 + trig.query.length); // skip '@' and query - const newVal = before + token + after; - setEditingValue(newVal); - // Restore caret after token - const newCaret = (before + token).length; - requestAnimationFrame(() => { - ta.selectionStart = ta.selectionEnd = newCaret; - ta.focus(); - }); - - // Create edge (idempotent server-side) - if (sourceNodeId) { - try { - await fetch('/api/edges', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from_node_id: sourceNodeId, - to_node_id: nodeId, - source: 'user', - explanation: 'Referenced via @ mention' - }) - }); - } catch (e) { - console.warn('edge create failed for mention:', e); - } - } - - resetMention(); - - // Persist content immediately to avoid losing the token on refresh/navigation - try { - await saveContentExplicit(newVal, sourceNodeId); - } catch (e) { - console.warn('auto-save after mention failed:', e); - } - }; - - // Handle @mention selection in the Notes tab - const handleNotesMentionSelect = async (nodeId: number, title: string) => { - if (!notesTextareaRef.current || activeNodeId === null) return; - const ta = notesTextareaRef.current; - const sourceNodeId = activeNodeId; - const text = notesEditValue; - const caret = ta.selectionStart || text.length; - let trig = findAtTrigger(text, caret); - if (!trig) { - // Fallback: try to locate the last occurrence of the current mention token - if (mentionQuery && mentionQuery.length > 0) { - const needle = '@' + mentionQuery; - const idx = text.lastIndexOf(needle); - if (idx !== -1) { - trig = { atIndex: idx, query: mentionQuery }; - } - } - if (!trig) return; - } - // Build quote-safe token - const quoteTitleForToken = (t: string): string => { - if (t.includes('"')) { - const norm = t.replace(/'/g, '\u2019'); // curly right single quote - return `'${norm}'`; - } - return `"${t}"`; - }; - const token = `[NODE:${nodeId}:${quoteTitleForToken(title)}]`; - const before = text.slice(0, trig.atIndex); - const after = text.slice(trig.atIndex + 1 + trig.query.length); - const newVal = before + token + after; - setNotesEditValue(newVal); - // Restore caret after token - const newCaret = (before + token).length; - requestAnimationFrame(() => { - ta.selectionStart = ta.selectionEnd = newCaret; - ta.focus(); - }); - - // Create edge - if (sourceNodeId) { - try { - await fetch('/api/edges', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from_node_id: sourceNodeId, - to_node_id: nodeId, - source: 'user', - explanation: 'Referenced via @ mention' - }) - }); - // Refresh edges for the current node - fetchEdgesData(sourceNodeId); - } catch (e) { - console.warn('edge create failed for notes mention:', e); - } - } - - resetMention(); - }; - const embedContent = async (nodeId: number) => { - const node = nodesData[nodeId]; - const hasNotes = node?.notes?.trim(); - const hasSource = (node?.source || node?.chunk)?.trim(); - // If source is empty but notes exist, auto-populate source from notes - if (!hasSource && hasNotes) { - try { - const response = await fetch(`/api/nodes/${nodeId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ source: hasNotes }) - }); - - if (response.ok) { - const result = await response.json(); - if (result.node) { - setNodesData(prev => ({ ...prev, [nodeId]: result.node })); - } - } - } catch (error) { - console.error('Failed to auto-populate source for embedding:', error); - return; - } - } - // If neither notes nor source exist, require notes - if (!hasNotes && !hasSource) { - startEdit('notes', ''); - return; - } setEmbeddingNode(nodeId); try { const response = await fetch('/api/ingestion', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nodeId }), }); const result = await response.json(); - if (!response.ok) { throw new Error(result.error || 'Failed to embed content'); } - // Show result details - const nodeStatus = result.node_embedding?.status; - const chunkStatus = result.chunk_embeddings?.status; - const chunksCreated = result.chunk_embeddings?.chunks_created || 0; - - let message = 'Embedding complete:\n'; - if (nodeStatus === 'completed') message += '✓ Node metadata embedded\n'; - if (chunkStatus === 'completed') message += `✓ ${chunksCreated} chunks embedded\n`; - if (chunkStatus === 'skipped') message += '• No chunk content to embed\n'; - - console.log(message.trim()); - - // Refresh node data to get updated status await fetchNodeData(nodeId); - } catch (error) { console.error('Error embedding content:', error); - alert(`Failed to embed content: ${error instanceof Error ? error.message : 'Unknown error'}`); + window.alert(`Failed to embed content: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setEmbeddingNode(null); } }; - - // Edge management functions (following same patterns as node functions) - - const handleEdgeNodeSelect = async (targetNodeId: number, _targetNodeTitle?: string) => { - // Immediately create edge - backend auto-infers explanation and type - await createEdgeAuto(targetNodeId); - }; - - // Handle node search keyboard navigation - const handleNodeSearchKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedSearchIndex(prev => Math.min(prev + 1, nodeSearchSuggestions.length - 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedSearchIndex(prev => Math.max(prev - 1, 0)); - } else if (e.key === 'Enter' && nodeSearchSuggestions[selectedSearchIndex]) { - e.preventDefault(); - handleSelectNodeSuggestion(nodeSearchSuggestions[selectedSearchIndex]); - } - }; - - const handleSelectNodeSuggestion = async (suggestion: NodeSearchResult) => { - // Immediately create edge - backend auto-infers explanation and type - await createEdgeAuto(suggestion.id); - setNodeSearchSuggestions([]); - setNodeSearchQuery(''); - }; - - // Auto-create edge - backend handles explanation generation and type inference const createEdgeAuto = async (targetNodeId: number) => { - if (activeNodeId === null) return; + if (activeTab === null) return; try { const response = await fetch('/api/edges', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - from_node_id: activeNodeId, + from_node_id: activeTab, to_node_id: targetNodeId, source: 'user', - explanation: '' // Empty - backend will auto-generate + explanation: '', }), }); @@ -1115,34 +351,25 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli throw new Error('Failed to create edge'); } - // Refresh edges data - await fetchEdgesData(activeNodeId); - - // Reset state - setAddingEdge(null); - setEdgeExplanation(''); - setPendingEdgeTarget(null); - + await fetchEdgesData(activeTab); } catch (error) { console.error('Error creating edge:', error); - alert('Failed to create edge. Please try again.'); + window.alert('Failed to create connection. Please try again.'); + throw error; } }; - // Legacy function for manual explanation (kept for compatibility) const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => { - if (activeNodeId === null) return; + if (activeTab === null) return; try { const response = await fetch('/api/edges', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - from_node_id: activeNodeId, + from_node_id: activeTab, to_node_id: targetNodeId, source: 'user', - explanation: explanation || '' // Backend handles empty + explanation: explanation || '', }), }); @@ -1150,24 +377,15 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli throw new Error('Failed to create edge'); } - // Refresh edges data - await fetchEdgesData(activeNodeId); - - // Reset state - setAddingEdge(null); - setEdgeExplanation(''); - setPendingEdgeTarget(null); - setNodeSearchQuery(''); - setNodeSearchSuggestions([]); - + await fetchEdgesData(activeTab); } catch (error) { console.error('Error creating edge:', error); - alert('Failed to create edge. Please try again.'); + window.alert('Failed to create connection. Please try again.'); + throw error; } }; - const startEditEdgeExplanation = (edgeId: number, currentExplanation: string | undefined) => { - if (edgeSavingId) return; + const startEditEdgeExplanation = (edgeId: number, currentExplanation?: string) => { setEdgeEditingId(edgeId); setEdgeEditingValue(currentExplanation || ''); }; @@ -1181,379 +399,60 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli edgeId: number, currentContext: Record | null | undefined ) => { - if (activeNodeId === null) return; + if (activeTab === null) return; setEdgeSavingId(edgeId); try { - const newContext: Record = { - ...(currentContext ?? {}), - explanation: edgeEditingValue - }; const response = await fetch(`/api/edges/${edgeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ context: newContext }) + body: JSON.stringify({ + context: { + ...(currentContext ?? {}), + explanation: edgeEditingValue, + }, + }), }); - if (!response.ok) throw new Error('Failed to update edge'); - await fetchEdgesData(activeNodeId); - setEdgeEditingId(null); - setEdgeEditingValue(''); - } catch (e) { - console.error('Failed updating edge explanation:', e); - alert('Failed to update edge explanation'); + + if (!response.ok) { + throw new Error('Failed to update edge'); + } + + await fetchEdgesData(activeTab); + cancelEditEdgeExplanation(); + } catch (error) { + console.error('Failed updating edge explanation:', error); + window.alert('Failed to update connection explanation.'); } finally { setEdgeSavingId(null); } }; const deleteEdge = async (edgeId: number) => { - if (activeNodeId === null) return; + if (activeTab === null) return; setDeletingEdge(edgeId); try { const response = await fetch(`/api/edges/${edgeId}`, { - method: 'DELETE' + method: 'DELETE', }); if (!response.ok) { throw new Error('Failed to delete edge'); } - await fetchEdgesData(activeNodeId); - + await fetchEdgesData(activeTab); } catch (error) { console.error('Error deleting edge:', error); - alert('Failed to delete edge. Please try again.'); + window.alert('Failed to delete connection. Please try again.'); } finally { setDeletingEdge(null); } }; - const renderConnectionsBody = () => { - if (!activeTab) { - return
Open a node to manage connections.
; - } - - return ( -
- {/* Search Section — only visible when toggled */} - {edgeSearchOpen && ( -
- setNodeSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.preventDefault(); - setEdgeSearchOpen(false); - setNodeSearchQuery(''); - setNodeSearchSuggestions([]); - } else { - handleNodeSearchKeyDown(e); - } - }} - placeholder="Search for a node to connect..." - style={{ - width: '100%', - background: 'var(--rah-bg-surface)', - border: '1px solid var(--rah-border-strong)', - borderRadius: '6px', - padding: '8px 12px', - color: 'var(--rah-text-base)', - fontSize: '12px', - fontFamily: 'inherit', - outline: 'none', - }} - /> - {/* Search Suggestions */} - {nodeSearchSuggestions.length > 0 && ( -
- {nodeSearchSuggestions.map((suggestion, index) => ( -
{ - handleSelectNodeSuggestion(suggestion); - setNodeSearchQuery(''); - setNodeSearchSuggestions([]); - setEdgeSearchOpen(false); - }} - style={{ - padding: '8px 12px', - cursor: 'pointer', - borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid var(--rah-border)' : 'none', - background: index === selectedSearchIndex ? 'var(--rah-bg-active)' : 'transparent', - transition: 'background 100ms ease', - display: 'flex', - alignItems: 'center', - gap: '8px', - }} - onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--rah-bg-active)'; }} - onMouseLeave={(e) => { if (index !== selectedSearchIndex) e.currentTarget.style.background = 'transparent'; }} - > - - {suggestion.id} - - - {suggestion.title} - - {index === selectedSearchIndex && ( - - )} -
- ))} -
- )} -
- )} - - {/* Connections list */} -
- {loadingEdges.has(activeTab) ? ( -
Loading connections…
- ) : (() => { - const list = edgesData[activeTab] || []; - if (list.length === 0) { - return
No connections yet.
; - } - return ( -
- {list.map((connection) => ( -
- {/* Row 1: arrow + ID + title + delete */} -
- - {connection.edge.from_node_id === activeTab ? '→' : '←'} - - - {connection.connected_node.id} - - - {getNodeIcon(connection.connected_node, dimensionIcons, 12)} - - onNodeClick?.(connection.connected_node.id)} - style={{ - fontSize: '12px', - color: 'var(--rah-text-base)', - fontWeight: 500, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - flex: 1, - minWidth: 0, - cursor: 'pointer', - }} - onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-accent-green)'; }} - onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-base)'; }} - > - {connection.connected_node.title} - - -
- {/* Row 2: type chip + explanation (compact, single line) */} - {edgeEditingId === connection.edge.id ? ( -
- setEdgeEditingValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - saveEdgeExplanation(connection.edge.id, connection.edge.context); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancelEditEdgeExplanation(); - } - }} - style={{ - flex: 1, - fontSize: '11px', - color: 'var(--rah-text-base)', - background: 'var(--rah-bg-surface)', - border: '1px solid var(--rah-border-strong)', - borderRadius: '4px', - padding: '3px 6px', - outline: 'none', - fontFamily: 'inherit', - minWidth: 0, - }} - placeholder="Add explanation…" - autoFocus - /> - - -
- ) : ( -
startEditEdgeExplanation(connection.edge.id, connection.edge.context?.explanation)} - style={{ - display: 'flex', - alignItems: 'center', - gap: '6px', - paddingLeft: '20px', - cursor: 'pointer', - minWidth: 0, - }} - onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }} - onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }} - > - {typeof connection.edge.context?.type === 'string' && ( - - {String(connection.edge.context.type).replace(/_/g, ' ')} - - )} - - {(connection.edge.context?.explanation as string) || 'Add explanation...'} - -
- )} -
- ))} -
- ); - })()} -
-
- ); - }; - - const handleConfirmNodeDelete = () => { - if (pendingDeleteNodeId === null) return; - executeDeleteNode(pendingDeleteNodeId); - setPendingDeleteNodeId(null); - }; - - const handleCancelNodeDelete = () => { - setPendingDeleteNodeId(null); - }; - const executeDeleteNode = async (nodeId: number) => { setDeletingNode(nodeId); try { const response = await fetch(`/api/nodes/${nodeId}`, { - method: 'DELETE' + method: 'DELETE', }); if (!response.ok) { @@ -1562,1519 +461,978 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli onTabClose(nodeId); setNodesData(prev => { - const newData = { ...prev }; - delete newData[nodeId]; - return newData; + const next = { ...prev }; + delete next[nodeId]; + return next; }); - } catch (error) { console.error('Error deleting node:', error); - alert('Failed to delete node. Please try again.'); + window.alert('Failed to delete node. Please try again.'); } finally { setDeletingNode(null); } }; - const confirmDeleteNode = (nodeId: number) => { - setPendingDeleteNodeId(nodeId); + const renderStatusIndicator = () => { + if (!currentNode || activeTab === null) return null; + + const chunkStatus = currentNode.chunk_status ?? null; + if (embeddingNode === activeTab || chunkStatus === 'chunking') { + return ; + } + + if (chunkStatus === 'error') { + return ( + + ); + } + + if (chunkStatus === 'not_chunked') { + return ( + + Pending embed + + ); + } + + return null; + }; + + // ── Shared inline style constants (styled-jsx doesn't apply inside helper fns) ── + const S = { + section: { + display: 'flex' as const, + flexDirection: 'column' as const, + gap: '10px', + paddingTop: '18px', + marginTop: '4px', + }, + sectionHeader: { + display: 'flex' as const, + alignItems: 'center' as const, + gap: '10px', + marginBottom: '2px', + }, + sectionLabel: { + fontSize: '9.5px', + letterSpacing: '0.1em', + textTransform: 'uppercase' as const, + color: 'var(--rah-text-muted)', + fontWeight: 600, + flexShrink: 0, + }, + sectionRule: { + flex: 1, + height: '1px', + background: 'var(--rah-border)', + }, + iconBtn: { + display: 'inline-flex' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + width: '22px', + height: '22px', + background: 'transparent', + border: '1px solid transparent', + borderRadius: '5px', + color: 'var(--rah-text-muted)', + cursor: 'pointer', + padding: 0, + }, + editorBlock: { + display: 'flex' as const, + flexDirection: 'column' as const, + gap: '10px', + }, + textarea: { + display: 'block' as const, + width: '100%', + boxSizing: 'border-box' as const, + background: 'var(--rah-bg-surface)', + border: '1px solid var(--rah-border-strong)', + borderRadius: '8px', + color: 'var(--rah-text-base)', + fontFamily: 'inherit', + outline: 'none', + padding: '12px', + }, + editorActions: { + display: 'flex' as const, + justifyContent: 'flex-end' as const, + gap: '8px', + }, + primaryBtn: { + display: 'inline-flex' as const, + alignItems: 'center' as const, + gap: '5px', + padding: '6px 12px', + border: 'none', + background: 'var(--rah-accent-green)', + color: 'var(--rah-text-inverse)', + fontSize: '11px', + fontWeight: 600, + borderRadius: '6px', + cursor: 'pointer', + fontFamily: 'inherit', + }, + secondaryBtn: { + display: 'inline-flex' as const, + alignItems: 'center' as const, + gap: '5px', + padding: '6px 12px', + border: '1px solid var(--rah-border-strong)', + background: 'transparent', + color: 'var(--rah-text-soft)', + fontSize: '11px', + borderRadius: '6px', + cursor: 'pointer', + fontFamily: 'inherit', + }, + }; + + const renderSourceSection = () => { + const sourceContent = currentNode?.source || currentNode?.chunk || ''; + + return ( +
setHoveredSection('source')} + onMouseLeave={() => setHoveredSection(prev => prev === 'source' ? null : prev)} + > + {/* Section header with extending rule */} +
+ Source +
+
+ {!sourceEditMode && ( + + )} +
+
+ + {sourceEditMode ? ( +
+ +
+ + +
+
+ ) : sourceContent ? ( +
+ onTextSelect(activeTab!, currentNode?.title || 'Untitled', text) : undefined} + highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null} + onContentClick={startSourceEdit} + /> +
+ ) : ( + + )} +
+ ); }; return ( <> -
- {/* Tab Bar - hidden when rendered from NodePane which has its own tab bar */} - {!hideTabBar && ( -
- {openTabs.length === 0 ? ( -
- No tabs open -
- ) : ( - openTabs.map((tabId) => { - const node = nodesData[tabId]; - const isActive = activeTab === tabId; - const label = node ? truncateTitle(node.title || 'Untitled') : 'Loading...'; - - return ( -
{ - const title = node?.title || 'Untitled'; - e.dataTransfer.effectAllowed = 'copyMove'; - e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title })); - e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`); - }} - onContextMenu={(e) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY, tabId }); - }} - style={{ - display: 'flex', - alignItems: 'center', - borderRight: '1px solid var(--rah-border)', - background: isActive ? 'var(--rah-bg-subtle)' : 'var(--rah-bg-base)', - borderBottom: isActive ? '2px solid var(--rah-text-muted)' : 'none', - paddingBottom: isActive ? '0' : '2px', - minWidth: '120px', - maxWidth: '200px', - cursor: 'grab' - }} - > - - -
- ); - }) - )} -
- )} - - {/* Content Area */} -
+
{!activeTab ? ( -
- Select a node from the left panel to view details -
+
Select a node from the left panel to view details
) : loadingNodes.has(activeTab) ? ( -
- Loading... -
+
Loading...
) : !currentNode ? ( -
Node not found.
- ) : nodesData[activeTab] ? ( -
- {/* URL Row - Above Title */} -
- {/* Embedding status - only show when embedding or error */} - {(() => { - const node = nodesData[activeTab]; - const chunkStatus = node?.chunk_status ?? null; +
Node not found.
+ ) : ( +
- if (embeddingNode === activeTab || chunkStatus === 'chunking') { - return ( - - ); - } - - if (chunkStatus === 'error') { - return ( - - ); - } - - return null; - })()} - -
- {editingField === 'link' ? ( - } - type="url" - value={editingValue} - onChange={(e) => setEditingValue(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={handleBlur} - disabled={savingField === 'link'} - style={{ - color: '#3b82f6', - fontSize: '11px', - background: 'transparent', - border: '1px solid var(--rah-border)', - borderRadius: '4px', - padding: '4px 6px', - fontFamily: 'inherit', - width: '100%', - outline: 'none' - }} - placeholder="Enter URL..." - /> - ) : nodesData[activeTab].link ? ( - { - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - startEdit('link', nodesData[activeTab].link || ''); - return; - } - - const link = nodesData[activeTab].link || ''; - if (!shouldOpenExternally(link)) { - return; - } - - e.preventDefault(); - void openExternalUrl(link).catch((error) => { - console.error('[FocusPanel] Failed to open node link', error); - window.alert(`Unable to open ${link}`); - }); - }} - style={{ - color: '#3b82f6', - fontSize: '11px', - textDecoration: 'none', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - display: 'block' - }} - title={`${nodesData[activeTab].link} (Cmd+Click to edit)`} - > - {nodesData[activeTab].link} - - ) : ( - startEdit('link', '')} - style={{ - color: '#555', - fontSize: '11px', - cursor: 'pointer', - fontStyle: 'italic' - }} - > - Click to add URL - - )} -
-
- - {/* Title Row - Node ID, Title, Connections, Trash */} -
- {/* Node ID - Draggable */} - ) => { - const title = nodesData[activeTab]?.title || 'Untitled'; - e.dataTransfer.effectAllowed = 'copyMove'; - e.dataTransfer.setData('application/x-rah-node', JSON.stringify({ id: activeTab, title })); - e.dataTransfer.setData('application/node-info', JSON.stringify({ id: activeTab, title, dimensions: nodesData[activeTab]?.dimensions || [] })); - e.dataTransfer.setData('text/plain', `[NODE:${activeTab}:"${title}"]`); - }} - style={{ - display: 'inline-block', - background: 'var(--rah-accent-green)', - color: 'var(--rah-text-inverse)', - fontSize: '10px', - fontWeight: 600, - padding: '2px 6px', - borderRadius: '4px', - flexShrink: 0, - cursor: 'grab' - }} - title="Drag to chat to reference this node" - > - {activeTab} - - - {/* Node icon */} - {nodesData[activeTab] && ( - - {getNodeIcon(nodesData[activeTab], dimensionIcons, 18)} - - )} - - {editingField === 'title' ? ( + {/* ── Title ── */} +
+ {titleEditMode ? ( } + ref={titleInputRef} type="text" - value={editingValue} - onChange={(e) => setEditingValue(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={handleBlur} - disabled={savingField === 'title'} - style={{ - fontSize: '20px', - fontWeight: 'bold', - color: 'var(--rah-text-active)', - background: 'transparent', - border: '1px solid var(--rah-border)', - borderRadius: '0', - padding: '4px 8px', - fontFamily: 'inherit', - flex: 1, - outline: 'none' + value={titleEditValue} + onChange={(e) => setTitleEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); void saveTitle(); } + if (e.key === 'Escape') { e.preventDefault(); skipTitleBlurRef.current = true; setTitleEditMode(false); setTitleEditValue(''); } }} + onBlur={() => { + if (skipTitleBlurRef.current) { skipTitleBlurRef.current = false; return; } + void saveTitle(); + }} + disabled={titleSaving} + className="title-input" placeholder="Enter title..." /> ) : ( -

{ - if (titleExpanded[activeTab]) { - startEdit('title', nodesData[activeTab].title || ''); - } else { - setTitleExpanded(prev => ({ ...prev, [activeTab]: true })); - setTimeout(() => { - setTitleExpanded(prev => ({ ...prev, [activeTab]: false })); - }, 3000); - } - }} - style={{ - fontSize: '20px', - fontWeight: 'bold', - color: 'var(--rah-text-active)', - fontFamily: 'inherit', - cursor: 'pointer', - padding: '4px 8px', - margin: '0', - borderRadius: '0', - background: 'transparent', - border: '1px solid transparent', - transition: 'border-color 0.2s', - flex: 1, - minWidth: 0, - ...(titleExpanded[activeTab] ? { - whiteSpace: 'normal', - wordWrap: 'break-word' - } : { - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis' - }) - }} - onMouseEnter={(e) => { - e.currentTarget.style.borderColor = 'var(--rah-border)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'transparent'; - }} - title={titleExpanded[activeTab] ? undefined : (nodesData[activeTab].title || 'Untitled')} - > - {nodesData[activeTab].title || 'Untitled'} - {savingField === 'title' && saving...} -

+ )} - {/* Connections Button — opens Edges tab */} -
+ + {/* ── Properties block ── */} +
+ + {/* Node identity row — with collapse toggle + delete */} +
+
node
+
+ + {getNodeIcon(currentNode, dimensionIcons, 12)} + #{currentNode.id} - )} - - - {/* Delete Button */} - -
- - {/* Dimensions Section */} -
-
- { - try { - const response = await fetch(`/api/nodes/${activeTab}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ dimensions: newDimensions }), - }); - - if (!response.ok) { - throw new Error('Failed to save'); - } - - const result = await response.json(); - if (result.node) { - setNodesData(prev => ({ ...prev, [activeTab]: result.node })); - } - } catch (error) { - console.error('Error saving dimensions:', error); - alert('Failed to save dimensions. Please try again.'); - } - }} - /> +
+
+ + +
-
- {/* Notes | Desc | Source Tabs */} -
- {/* Tab Bar */} -
- - - - -
- {/* Action buttons for Desc tab */} - {activeContentTab === 'desc' && !descEditMode && ( -
- - -
- )} - {/* Action buttons for Notes tab */} - {activeContentTab === 'notes' && !notesEditMode && ( -
- - -
- )} - {/* Formatting toolbar for Notes edit mode - inline */} - {activeContentTab === 'notes' && notesEditMode && ( -
- -
- )} - {/* Action button for Edges tab */} - {activeContentTab === 'edges' && ( -
- -
- )} + )} +
- {/* Desc Tab Content */} - {activeContentTab === 'desc' && ( -
- {descEditMode ? ( -
-
- Used as context for AI. Clearly describe what this node is in 280 chars or less. -
- {/* Editor */} -
-