From b92fed2d0a5484b034bcae01a211acd6c1d4bfff Mon Sep 17 00:00:00 2001 From: Yuang Peng <116251109+genni613@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:08:17 +0800 Subject: [PATCH] feat(explorer): add Markdown editor write path (#1349) * feat(explorer): add Markdown editing write path * fix(explorer): address markdown editor review findings * feat(explorer): add Markdown editor write path --------- Co-authored-by: Sameer Kadam --- README.md | 18 +- explorer/package-lock.json | 653 ++++++++++++++++++ explorer/package.json | 4 + explorer/src/App.tsx | 59 +- explorer/src/ExploreWorkspaceTabs.tsx | 29 + explorer/src/explorerCapabilities.ts | 24 + explorer/src/store/registryStore.ts | 1 + .../EnrichWorkspace/RegistryTab.tsx | 3 +- .../GraphWorkspace/GraphInspectorPanel.tsx | 30 +- .../GraphWorkspace/GraphWorkspace.tsx | 142 +++- .../GraphWorkspace/MarkdownContentViewer.tsx | 268 +++++-- .../GraphWorkspace/markdownEditorState.ts | 114 +++ .../GraphWorkspace/markdownResourceClient.ts | 103 +++ .../GraphWorkspace/nodeMarkdownSync.ts | 81 +++ .../GraphWorkspace/useMarkdownEditor.ts | 177 +++++ explorer/src/workspaces/MemoryWorkspace.tsx | 464 +++++++++++++ .../deterministicExplorerRendering.e2e.ts | 3 + explorer/tests/explorerCapabilities.test.tsx | 74 ++ explorer/tests/markdownContentViewer.test.ts | 25 +- .../tests/markdownEditorInteraction.test.tsx | 639 +++++++++++++++++ explorer/tests/markdownEditorState.test.ts | 132 ++++ explorer/tests/nodeMarkdownSync.test.ts | 91 +++ semantica/context/agent_memory.py | 115 +++ semantica/context/context_graph.py | 195 +++++- semantica/context/markdown.py | 25 + semantica/explorer/app.py | 104 +-- semantica/explorer/dependencies.py | 24 + semantica/explorer/markdown_resources.py | 283 ++++++++ semantica/explorer/routes/graph.py | 24 +- semantica/explorer/routes/markdown.py | 115 +++ semantica/explorer/routes/memories.py | 41 ++ semantica/explorer/runtime.py | 45 ++ semantica/explorer/schemas.py | 36 + semantica/explorer/ws.py | 51 +- semantica/server.py | 31 +- tests/context/test_agent_memory_markdown.py | 145 ++++ tests/context/test_context_graph_markdown.py | 136 ++++ tests/explorer/test_explorer_api.py | 12 + tests/explorer/test_explorer_auth.py | 16 + tests/explorer/test_explorer_entrypoints.py | 135 ++++ tests/explorer/test_markdown_api.py | 319 +++++++++ tests/explorer/test_markdown_resources.py | 115 +++ 42 files changed, 4889 insertions(+), 212 deletions(-) create mode 100644 explorer/src/ExploreWorkspaceTabs.tsx create mode 100644 explorer/src/explorerCapabilities.ts create mode 100644 explorer/src/workspaces/GraphWorkspace/markdownEditorState.ts create mode 100644 explorer/src/workspaces/GraphWorkspace/markdownResourceClient.ts create mode 100644 explorer/src/workspaces/GraphWorkspace/nodeMarkdownSync.ts create mode 100644 explorer/src/workspaces/GraphWorkspace/useMarkdownEditor.ts create mode 100644 explorer/src/workspaces/MemoryWorkspace.tsx create mode 100644 explorer/tests/explorerCapabilities.test.tsx create mode 100644 explorer/tests/markdownEditorInteraction.test.tsx create mode 100644 explorer/tests/markdownEditorState.test.ts create mode 100644 explorer/tests/nodeMarkdownSync.test.ts create mode 100644 semantica/context/markdown.py create mode 100644 semantica/explorer/markdown_resources.py create mode 100644 semantica/explorer/routes/markdown.py create mode 100644 semantica/explorer/routes/memories.py create mode 100644 semantica/explorer/runtime.py create mode 100644 tests/explorer/test_explorer_entrypoints.py create mode 100644 tests/explorer/test_markdown_api.py create mode 100644 tests/explorer/test_markdown_resources.py diff --git a/README.md b/README.md index 0272d00a..0bd57d19 100644 --- a/README.md +++ b/README.md @@ -1461,6 +1461,22 @@ semantica-explorer --graph my_graph.json For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](explorer/README.md)** +The CLI exposes the loaded `ContextGraph`. To also browse and edit an existing +`AgentMemory`, create the ASGI app programmatically with both live objects: + +```python +from semantica.context import AgentMemory, ContextGraph +from semantica.explorer.app import create_app +from semantica.explorer.session import GraphSession + +graph = ContextGraph() +memory = AgentMemory() +app = create_app(session=GraphSession(graph), agent_memory=memory) +``` + +The Memories workspace is shown only when `agent_memory` is provided. Apply +updates the supplied runtime object; it does not add disk persistence. + --- ## What's New in v0.6.7 @@ -1469,7 +1485,7 @@ For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](ex - **First-class LangChain integration** (`semantica[langchain]`): a `BaseRetriever` and `VectorStore` over `HybridSearch`, plus graph/decision-query tools - **SAP OData ingestor** (`semantica[ingest-sap]`): OAuth2/Basic-auth, SSRF-guarded ingestion for Business Partners and Sales Orders, following the existing Snowflake/Databricks connector pattern -- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** alongside the existing JSON API, and the Explorer graph inspector gains a read-only Markdown content viewer +- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** alongside the existing JSON API, and Explorer can validate and apply Markdown edits to individual graph nodes and AgentMemory items supplied by the hosting application - **`reasoning` gains a structured Action layer**: rule-driven `Assert`/`Retract`/`Call`/`EmitEvent` actions with optional provenance, turning the reasoner into a production-rule system - **`run_shacl_validation` is now a public, documented API**, and a dozen ontology/RDF export correctness fixes land: OWL property/class export, SHACL target-namespace resolution, one canonical confidence datatype across all four RDF formats, reachable OWL-Time reification, JSON-LD default-graph and content-derived document identity, and full metadata passthrough on every RDF serializer - **Security**: Agno's `AgnoKnowledgeGraph.load_urls()` and OpenClaw's MCP tool now route outbound requests through the shared SSRF guard diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 3946dd39..76921905 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -33,7 +33,9 @@ "devDependencies": { "@babel/core": "^7.29.6", "@eslint/js": "^9.39.4", + "@testing-library/react": "^16.3.3", "@types/babel__core": "^7.20.5", + "@types/jsdom": "^21.1.7", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -43,12 +45,34 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^26.1.0", "tsx": "^4.21.0", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^6.4.2" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -340,6 +364,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -1473,6 +1612,63 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1614,6 +1810,18 @@ "@types/unist": "*" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmmirror.com/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1665,6 +1873,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2010,6 +2225,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2027,6 +2252,42 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/attr-accept": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", @@ -2259,6 +2520,20 @@ "license": "MIT", "peer": true }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2370,6 +2645,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2387,6 +2676,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -2449,6 +2745,14 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dompurify": { "version": "3.4.13", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", @@ -2466,6 +2770,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -3058,6 +3375,19 @@ "react-is": "^16.7.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -3068,6 +3398,47 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3173,6 +3544,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3186,6 +3564,46 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3321,6 +3739,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -4283,6 +4712,13 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4382,6 +4818,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4505,6 +4954,30 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4817,6 +5290,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4924,6 +5424,13 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -4941,6 +5448,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5364,6 +5917,67 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5390,6 +6004,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xss": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", diff --git a/explorer/package.json b/explorer/package.json index d37815a9..714aacb2 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -9,6 +9,7 @@ "lint": "eslint .", "preview": "vite preview", "test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs", + "test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/markdownEditorInteraction.test.tsx tests/markdownEditorState.test.ts tests/nodeMarkdownSync.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts", "test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts", "test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts", "test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs" @@ -39,7 +40,9 @@ "devDependencies": { "@babel/core": "^7.29.6", "@eslint/js": "^9.39.4", + "@testing-library/react": "^16.3.3", "@types/babel__core": "^7.20.5", + "@types/jsdom": "^21.1.7", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -49,6 +52,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^26.1.0", "tsx": "^4.21.0", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index 75a95c2d..51bf4c90 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -17,10 +17,13 @@ import { type LucideIcon, } from 'lucide-react'; import { ErrorBoundary } from './ErrorBoundary'; +import { ExploreWorkspaceTabs, type ExploreView } from './ExploreWorkspaceTabs'; +import { fetchAgentMemoryAvailability } from './explorerCapabilities'; const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace }))); const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace }))); const GraphWorkspace = lazy(() => import('./workspaces/GraphWorkspace/GraphWorkspace').then((module) => ({ default: module.GraphWorkspace }))); +const MemoryWorkspace = lazy(() => import('./workspaces/MemoryWorkspace').then((module) => ({ default: module.MemoryWorkspace }))); const ImportExportWorkspace = lazy(() => import('./workspaces/ImportExportWorkspace/ImportExportWorkspace').then((module) => ({ default: module.ImportExportWorkspace }))); const LineageDiagram = lazy(() => import('./workspaces/LineageWorkspace/LineageDiagram').then((module) => ({ default: module.LineageDiagram }))); const ReasoningWorkspace = lazy(() => import('./workspaces/ReasoningWorkspace').then((module) => ({ default: module.ReasoningWorkspace }))); @@ -33,7 +36,6 @@ const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/Ontol const OntologyWorkspace = lazy(() => import('./workspaces/OntologyWorkspace').then((module) => ({ default: module.OntologyWorkspace }))); type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage' | 'ontology-hub'; -type ExploreView = 'graph' | 'vocabulary'; type AnalyzeView = 'sparql' | 'reasoning'; type EnrichView = 'import' | 'merge' | 'registry' | 'resolve'; type ManageView = 'lineage' | 'kg-overview' | 'ontology'; @@ -1791,6 +1793,37 @@ export default function App() { const [enrichView, setEnrichView] = useState('import'); const [manageView, setManageView] = useState('lineage'); const [graphFocusRequest, setGraphFocusRequest] = useState<{ nodeId: string; token: number } | null>(null); + const [exploreDraftDirty, setExploreDraftDirty] = useState(false); + const [agentMemoryAvailable, setAgentMemoryAvailable] = useState(false); + + useEffect(() => { + let active = true; + void fetchAgentMemoryAvailability().then((available) => { + if (active) setAgentMemoryAvailable(available); + }); + return () => { + active = false; + }; + }, []); + + const confirmDiscardExploreDraft = () => ( + !exploreDraftDirty + || window.confirm("Discard the unapplied Markdown draft and leave this resource?") + ); + + const switchExploreView = (nextView: ExploreView) => { + if (nextView === exploreView) return; + if (!confirmDiscardExploreDraft()) return; + setExploreDraftDirty(false); + setExploreView(nextView); + }; + + const switchWorkspace = (nextWorkspace: WorkspaceId) => { + if (nextWorkspace === activeWorkspace) return; + if (activeWorkspace === "explore" && !confirmDiscardExploreDraft()) return; + setExploreDraftDirty(false); + setActiveWorkspace(nextWorkspace); + }; const renderWorkspace = () => { @@ -1823,18 +1856,15 @@ export default function App() { return ( - - - + } > @@ -1843,8 +1873,9 @@ export default function App() { - ) : } + ) : exploreView === 'memories' ? : } @@ -1989,13 +2020,13 @@ export default function App() {
) : null} - {/* Content Section — only rendered when the node carries actual content. - This matches the existing inspector convention: sections that have no - data for the current node are either hidden (temporal bounds) or closed - by default (Source Attribution, Properties). Always showing an open - empty panel would add noise for every relationship/predicate node. */} - {nodeContent && ( -
- Content -
- -
-
- )} + {/* Canonical nodes remain editable even when their current body is empty. */} +
+ Content +
+ +
+
{/* Actions */}
diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx index a586d2e9..3d710bfb 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx @@ -44,6 +44,12 @@ import { createTemporalSnapshotGuards, type TemporalSnapshotResponse } from "./t import { SMALL_GRAPH_MAX_NODES } from "./smallGraphLayout"; import { buildRealtimeEdgeAttributes } from "./realtimeGraphAttributes"; import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel"; +import type { MarkdownApplyResult } from "./markdownResourceClient"; +import { + NodeMarkdownRefreshGuard, + buildNodeMarkdownAttributeUpdate, + readNodeMarkdownAttributeUpdate, +} from "./nodeMarkdownSync"; import type { GraphSceneHandle, GraphSceneRuntime } from "./scene"; import type { GraphAnalyticsSnapshot, @@ -1241,9 +1247,10 @@ function collectPluginOverlays( interface GraphWorkspaceProps { externalFocusNodeId?: string; externalFocusToken?: number; + onDirtyChange?: (dirty: boolean) => void; } -export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: GraphWorkspaceProps = {}) { +export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirtyChange }: GraphWorkspaceProps = {}) { const [selectedNodeId, setSelectedNodeId] = useState(""); const [focusedNodeId, setFocusedNodeId] = useState(""); const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState(""); @@ -1251,6 +1258,12 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap const [isLayoutRunning, setIsLayoutRunning] = useState(false); const [graphReady, setGraphReady] = useState(false); const [graphVersion, setGraphVersion] = useState(0); + const [markdownDraftDirty, setMarkdownDraftDirty] = useState(false); + const markdownRefreshGuard = useMemo(() => new NodeMarkdownRefreshGuard(), []); + const handleMarkdownDirtyChange = useCallback((dirty: boolean) => { + setMarkdownDraftDirty(dirty); + onDirtyChange?.(dirty); + }, [onDirtyChange]); const [viewMode, setViewMode] = useState("full"); const [aggregationEnabled] = useState(true); const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState([]); @@ -1628,8 +1641,17 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap : null, [viewMode, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion], ); + const confirmDiscardMarkdownDraft = useCallback(() => { + if (!markdownDraftDirty) return true; + const discard = window.confirm( + "Discard the unapplied Markdown draft and leave this node?", + ); + return discard; + }, [markdownDraftDirty]); + const requestViewMode = useCallback((nextViewMode: GraphViewMode) => { + if (nextViewMode !== viewMode && !confirmDiscardMarkdownDraft()) return; if (nextViewMode === "focused") { const resolution = resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph); if (!resolution.resolvedNodeId) { @@ -1682,6 +1704,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap )); setViewMode("full"); }, [ + confirmDiscardMarkdownDraft, aggregationEnabled, collapsedNeighborhoodNodeIds, focusedNodeId, @@ -1692,9 +1715,11 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap lastGroupedSelectedNodeId, resolveNodeIdForFocusedMode, selectedNodeId, + viewMode, ]); const focusNode = useCallback((nodeId: string) => { + if (nodeId !== selectedNodeId && !confirmDiscardMarkdownDraft()) return; if (!nodeId) { setSelectedNodeId(""); setSelectedEdgeId(""); @@ -1720,14 +1745,18 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap setFocusedNodeId(nextSelectedNodeId); setIsLayoutRunning(false); } - }, [viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes + }, [confirmDiscardMarkdownDraft, selectedNodeId, viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes useEffect(() => { if (!externalFocusNodeId || externalFocusToken == null) return; if (lastExternalFocusTokenRef.current === externalFocusToken) return; if (!graphReady || !graph.hasNode(externalFocusNodeId)) return; - + if ( + externalFocusNodeId !== selectedNodeId + && !confirmDiscardMarkdownDraft() + ) return; lastExternalFocusTokenRef.current = externalFocusToken; + // Set state directly instead of going through focusNode(), which captures // a stale viewMode in its closure. setViewMode is called first so the node // is visible in the full graph before the scene pans to it. @@ -1737,7 +1766,13 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap window.setTimeout(() => { sceneRef.current?.focusNode(externalFocusNodeId); }, 0); - }, [externalFocusNodeId, externalFocusToken, graphReady]); + }, [ + confirmDiscardMarkdownDraft, + externalFocusNodeId, + externalFocusToken, + graphReady, + selectedNodeId, + ]); const handleEdgeSelect = useCallback((edgeId: string) => { setSelectedEdgeId(edgeId); @@ -1843,6 +1878,37 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap document.body.removeChild(anchor); }, [inspectableNodeId]); + const handleMarkdownApplied = useCallback((result: MarkdownApplyResult) => { + if (result.resource.kind !== "context-node") return; + if (!graph.hasNode(result.resource.id)) return; + const syncGeneration = markdownRefreshGuard.begin(result.resource.id); + const attributes = graph.getNodeAttributes(result.resource.id) as NodeAttributes; + graph.mergeNodeAttributes( + result.resource.id, + buildNodeMarkdownAttributeUpdate( + result.resource.id, + result.body, + attributes.properties ?? {}, + ), + ); + setGraphVersion((current) => current + 1); + sceneRef.current?.getRuntime()?.requestRender(); + + void readNodeMarkdownAttributeUpdate(result.resource.id) + .then((savedAttributes) => { + if ( + !markdownRefreshGuard.isCurrent(result.resource.id, syncGeneration) + || !graph.hasNode(result.resource.id) + ) return; + graph.mergeNodeAttributes(result.resource.id, savedAttributes); + setGraphVersion((current) => current + 1); + sceneRef.current?.getRuntime()?.requestRender(); + }) + .catch((syncError) => { + console.error("[GraphWorkspace] applied node refresh failed", syncError); + }); + }, [markdownRefreshGuard]); + useEffect(() => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const socket = new WebSocket(`${protocol}//${window.location.host}/ws/graph-updates`); @@ -1873,6 +1939,25 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap setGraphVersion((current) => current + 1); sceneRef.current?.getRuntime()?.requestRender(); } + if (eventType === "UPDATE_NODE" && payload?.id && graph.hasNode(payload.id)) { + markdownRefreshGuard.invalidate(payload.id); + const properties = payload.properties ?? {}; + const current = graph.getNodeAttributes(payload.id) as NodeAttributes; + const content = typeof properties.content === "string" ? properties.content : ""; + graph.mergeNodeAttributes(payload.id, { + ...buildNodeMarkdownAttributeUpdate(payload.id, content, properties), + nodeType: payload.type ?? current.nodeType, + valid_from: properties.valid_from ?? null, + valid_until: properties.valid_until ?? null, + }); + logEvent( + "update-node", + `Updated node ${payload.id} via realtime ws`, + { nodeId: payload.id, nodeType: payload.type }, + ); + setGraphVersion((version) => version + 1); + sceneRef.current?.getRuntime()?.requestRender(); + } if (eventType === "ADD_EDGE") { const isSmallGraph = smallGraphModeRef.current; batchMergeEdges([ @@ -1899,7 +1984,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap return () => { socket.close(); }; - }, []); + }, [markdownRefreshGuard]); useEffect(() => { setCollapsedNeighborhoodNodeIds([]); @@ -2172,28 +2257,29 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap }, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]); const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH; const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH; - const displayResult = useMemo( - () => ( - viewMode === "grouped" - ? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", { - aggregationEnabled, - collapsedNeighborhoodNodeIds, - })) - : resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, { - aggregationEnabled, - collapsedNeighborhoodNodeIds, - }) - ), - [ - aggregationEnabled, - collapsedNeighborhoodNodeIds, - groupedDisplayCandidate, - structuralActivePath, - structuralActivePathEdgeIds, - structuralSelectedNodeId, - viewMode, - ], - ); + const displayResult = useMemo(() => { + // The displayed graph is an aggregated clone. Rebuild it after domain + // mutations so applied Markdown labels do not remain stale on the canvas. + void graphVersion; + return viewMode === "grouped" + ? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", { + aggregationEnabled, + collapsedNeighborhoodNodeIds, + })) + : resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, { + aggregationEnabled, + collapsedNeighborhoodNodeIds, + }); + }, [ + aggregationEnabled, + collapsedNeighborhoodNodeIds, + graphVersion, + groupedDisplayCandidate, + structuralActivePath, + structuralActivePathEdgeIds, + structuralSelectedNodeId, + viewMode, + ]); const displayState = useMemo( () => ( viewMode === "grouped" @@ -3295,6 +3381,8 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap pathResult={pathResult} onDownloadProvenance={(format) => void handleDownloadProvenance(format)} onFocusNode={focusNode} + onMarkdownApplied={handleMarkdownApplied} + onMarkdownDirtyChange={handleMarkdownDirtyChange} /> diff --git a/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx b/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx index f8121cf5..433e0060 100644 --- a/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx +++ b/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx @@ -1,125 +1,243 @@ -import { useState, useRef, useEffect, useMemo, type CSSProperties } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; -import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react"; +import { + Check, + Code2, + Copy, + Eye, + ExternalLink, + Image as ImageIcon, + Loader2, + Pencil, + RefreshCw, + X, +} from "lucide-react"; import { GRAPH_THEME } from "./graphTheme"; +import type { MarkdownApplyResult } from "./markdownResourceClient"; +import type { MarkdownResourceRef } from "./markdownEditorState"; import { isSafeUrl } from "./markdownUrlSafety"; +import { useMarkdownEditor } from "./useMarkdownEditor"; export interface MarkdownContentViewerProps { content?: string | null; + resource?: MarkdownResourceRef; + onApplied?: (result: MarkdownApplyResult) => void; + onDirtyChange?: (dirty: boolean) => void; className?: string; defaultMode?: "preview" | "source"; } export function MarkdownContentViewer({ content, + resource, + onApplied, + onDirtyChange, className, defaultMode = "preview", }: MarkdownContentViewerProps) { const [activeMode, setActiveMode] = useState<"preview" | "source">(defaultMode); const [copied, setCopied] = useState(false); + const modeBeforeEditRef = useRef<"preview" | "source">(defaultMode); + const resourceKey = resource ? `${resource.kind}:${resource.id}` : ""; + const [activeResourceKey, setActiveResourceKey] = useState(resourceKey); + const editor = useMarkdownEditor({ resource, onApplied, onDirtyChange }); + const { + session, + error, + dirty, + editing, + saving, + loading, + } = editor; + + if (activeResourceKey !== resourceKey) { + setActiveResourceKey(resourceKey); + setCopied(false); + setActiveMode(defaultMode); + } + // Track the content value for which the copied indicator is valid. - // When content changes (i.e. the user selects a different node), reset the - // copied indicator inline during render rather than in a useEffect — this - // avoids a cascading-render lint error and is the React-recommended pattern - // for resetting derived visual state on prop changes. const [copiedForContent, setCopiedForContent] = useState(content); if (copiedForContent !== content) { setCopiedForContent(content); - if (copied) { - // Clear the stale indicator synchronously so the new node's copy button - // never shows "Copied" from the previous selection. - setCopied(false); - } + if (copied) setCopied(false); } - const copyTimeoutRef = useRef | null>(null); - - // Clean up any outstanding timeout on unmount. + const copyTimeoutRef = useRef(undefined); useEffect(() => { return () => { - if (copyTimeoutRef.current) { - clearTimeout(copyTimeoutRef.current); - } + clearTimeout(copyTimeoutRef.current); }; }, []); - const rawContent = typeof content === "string" ? content : ""; + const rawContent = editor.editing + ? editor.session?.draft ?? "" + : (typeof content === "string" ? content : ""); + const previewContent = useMemo(() => { + if (!editor.editing) return rawContent; + const lines = rawContent.split(/\r?\n/); + if (lines[0] !== "---") return rawContent; + const closingIndex = lines.findIndex((line, index) => index > 0 && line === "---"); + return closingIndex < 0 ? rawContent : lines.slice(closingIndex + 1).join("\n").replace(/^\n/, ""); + }, [editor.editing, rawContent]); const hasContent = rawContent.trim().length > 0; - - // react-markdown runs the whole remark pipeline synchronously inside its own - // render, so without this memo every unrelated re-render of this component -- - // clicking Copy, toggling Preview/Source -- re-parses the entire document. - // Measured at ~364ms per re-render for a 1000-row GFM table (issue #1118). - // Keyed on rawContent so a genuine node change still re-parses exactly once. const renderedMarkdown = useMemo( () => ( - {rawContent} + {previewContent} ), - [rawContent], + [previewContent], ); - const handleCopy = async () => { if (!hasContent) return; try { await navigator.clipboard.writeText(rawContent); - if (copyTimeoutRef.current) { - clearTimeout(copyTimeoutRef.current); - } + clearTimeout(copyTimeoutRef.current); setCopied(true); - copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500); + copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500); } catch { - // Clipboard write unavailable + // Clipboard write unavailable. + } + }; + + const handleEdit = async () => { + modeBeforeEditRef.current = activeMode; + setActiveMode("source"); + if (!await editor.beginEdit()) { + setActiveMode(modeBeforeEditRef.current); + } + }; + + const handleCancel = () => { + editor.discard(); + setActiveMode(modeBeforeEditRef.current); + }; + + const handleApply = async () => { + if (await editor.save()) { + setActiveMode("preview"); } }; return (
-
+
- {hasContent && ( - - )} +
+ {hasContent && ( + + )} + {resource && !editing && !loading ? ( + + ) : null} + {loading ? ( + + ) : null} + {editing ? ( + <> + + + + ) : null} +
-
- {!hasContent ? ( + {error ? ( + + ) : null} + +
+ {activeMode === "source" && editing ? ( +