diff options
| author | soryu <soryu@soryu.co> | 2026-04-28 19:12:52 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-28 19:12:52 +0100 |
| commit | d1fdfb140cc440664f77a24886172f9976a05a31 (patch) | |
| tree | 454739f80dde60fc6c1cd97acbaef3223ac041c6 | |
| parent | 636694182fe9381479f2e9062229dda3838c5421 (diff) | |
| download | soryu-d1fdfb140cc440664f77a24886172f9976a05a31.tar.gz soryu-d1fdfb140cc440664f77a24886172f9976a05a31.zip | |
feat: revert broken directive PRs, re-implement Lexical document orchestrator (#98)
* feat: soryu-co/soryu - makima: Revert broken directive PRs and verify clean build
* feat: soryu-co/soryu - makima: Re-implement frontend: Lexical document editor with feature flag and base components
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Add contract blocks, expandable log rows, and interaction controls
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: End-to-end build verification and integration polish
49 files changed, 385 insertions, 4430 deletions
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4c4bcbc..771bdff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,17 +8,13 @@ "name": "pc98-vn", "version": "0.0.1", "dependencies": { - "@lexical/html": "^0.44.0", "@lexical/link": "^0.44.0", "@lexical/list": "^0.44.0", - "@lexical/markdown": "^0.44.0", "@lexical/react": "^0.44.0", "@lexical/rich-text": "^0.44.0", - "@lexical/selection": "^0.44.0", - "@lexical/utils": "^0.44.0", "@nanostores/react": "^1.0.0", "@types/three": "^0.180.0", - "lexical": "^0.44.0", + "lexical": "^0.21.0", "nanostores": "^1.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -265,6 +261,14 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -683,54 +687,6 @@ "node": ">=12" } }, - "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==", - "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==", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/react": { - "version": "0.27.19", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", - "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", - "dependencies": { - "@floating-ui/react-dom": "^2.1.8", - "@floating-ui/utils": "^0.2.11", - "tabbable": "^6.0.0" - }, - "peerDependencies": { - "react": ">=17.0.0", - "react-dom": ">=17.0.0" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", - "dependencies": { - "@floating-ui/dom": "^1.7.6" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "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==" - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -767,39 +723,38 @@ } }, "node_modules/@lexical/clipboard": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.44.0.tgz", - "integrity": "sha512-nfmNIs7uENqlDI7cm2E4I1Yp8mDJGMhEQIrIV2rNWnL1oeHVXQ7yuYdyoPdcY1zuj/9nvkYBQYUEh0QiGwpETA==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.21.0.tgz", + "integrity": "sha512-3lNMlMeUob9fcnRXGVieV/lmPbmet/SVWckNTOwzfKrZ/YW5HiiyJrWviLRVf50dGXTbmBGt7K/2pfPYvWCHFA==", "dependencies": { - "@lexical/extension": "0.44.0", - "@lexical/html": "0.44.0", - "@lexical/list": "0.44.0", - "@lexical/selection": "0.44.0", - "@lexical/utils": "0.44.0", - "@types/trusted-types": "^2.0.7", - "lexical": "0.44.0" - } - }, - "node_modules/@lexical/code-core": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/code-core/-/code-core-0.44.0.tgz", - "integrity": "sha512-m57JyXTIvW1tsqw/Vuogk8jqWCZZIeFQbWybRc46ytR8ReDgzPRODpN8+dacIIeRH5yC5UC3lAa743mtdNkxqg==", + "@lexical/html": "0.21.0", + "@lexical/list": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/code": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.21.0.tgz", + "integrity": "sha512-E0DNSFu4I+LMn3ft+UT0Dbntc8ZKjIA0BJj6BDewm0qh3bir40YUf5DkI2lpiFNRF2OpcmmcIxakREeU6avqTA==", "dependencies": { - "@lexical/extension": "0.44.0", - "lexical": "0.44.0" + "@lexical/utils": "0.21.0", + "lexical": "0.21.0", + "prismjs": "^1.27.0" } }, "node_modules/@lexical/devtools-core": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.44.0.tgz", - "integrity": "sha512-X3uNG3P1vOsdzmEcy+7m9DxAcIVtVUZnvskmLqqLs6VluVVwH9xy7h1bPsvlDKvj1Nj73tWJ3TW0qXQWDTo5tw==", - "dependencies": { - "@lexical/html": "0.44.0", - "@lexical/link": "0.44.0", - "@lexical/mark": "0.44.0", - "@lexical/table": "0.44.0", - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.21.0.tgz", + "integrity": "sha512-csK41CmRLZbKNV5pT4fUn5RzdPjU5PoWR8EqaS9kiyayhDg2zEnuPtvUYWanLfCLH9A2oOfbEsGxjMctAySlJw==", + "dependencies": { + "@lexical/html": "0.21.0", + "@lexical/link": "0.21.0", + "@lexical/mark": "0.21.0", + "@lexical/table": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" }, "peerDependencies": { "react": ">=17.x", @@ -807,209 +762,196 @@ } }, "node_modules/@lexical/dragon": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.44.0.tgz", - "integrity": "sha512-RhlsjVDket9k1+YFEkDE0/7Qyrh2BI0vxBMzrWwPJTXX/4YFanYN9su8RSabkIukBBJ3QiNOOoC8FKK4Lkr4qg==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.21.0.tgz", + "integrity": "sha512-ahTCaOtRFNauEzplN1qVuPjyGAlDd+XcVM5FQCdxVh/1DvqmBxEJRVuCBqatzUUVb89jRBekYUcEdnY9iNjvEQ==", "dependencies": { - "@lexical/extension": "0.44.0", - "lexical": "0.44.0" - } - }, - "node_modules/@lexical/extension": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.44.0.tgz", - "integrity": "sha512-BsYtoc+0EU0pqcOpf/lIUDU6LQVO6zX2AawZoUWJzT3Wzfov23qsqZWvl2WGM9dnRTN5iISJL3Fl53bQVxiXxw==", - "dependencies": { - "@lexical/utils": "0.44.0", - "@preact/signals-core": "^1.14.1", - "lexical": "0.44.0" + "lexical": "0.21.0" } }, "node_modules/@lexical/hashtag": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.44.0.tgz", - "integrity": "sha512-0WATahDSqYKVTudQv3KpFbLeCpmrCpRptPFbjxOMckAX2MRpYlrExlqKfgfpri5BSQPtG49EPSGeNfSx/Faavw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.21.0.tgz", + "integrity": "sha512-O4dxcZNq1Xm45HLoRifbGAYvQkg3qLoBc6ibmHnDqZL5mQDsufnH6QEKWfgDtrvp9++3iqsSC+TE7VzWIvA7ww==", "dependencies": { - "@lexical/text": "0.44.0", - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/history": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.44.0.tgz", - "integrity": "sha512-RGXcbFTgYL1GIWaReBI26mNSsJTfiA9EAtDY4LBeZ14NrIQhYNokKgNiOxq5Bn8xXrl2+mawQEqoMfgpWp/5YA==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.21.0.tgz", + "integrity": "sha512-Sv2sici2NnAfHYHYRSjjS139MDT8fHP6PlYM2hVr+17dOg7/fJl22VBLRgQ7/+jLtAPxQjID69jvaMlOvt4Oog==", "dependencies": { - "@lexical/extension": "0.44.0", - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/html": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.44.0.tgz", - "integrity": "sha512-5X6eGsgwtqPxABsuShUxF7ZfyB/U4GwSEyeonvwH1Vc/5Q2uQVjlB+FAYd+MNwWMHMh4d4+yZ3l70AtIuhr5eg==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.21.0.tgz", + "integrity": "sha512-UGahVsGz8OD7Ya39qwquE+JPStTxCw/uaQrnUNorCM7owtPidO2H+tsilAB3A1GK3ksFGdHeEjBjG0Gf7gOg+Q==", "dependencies": { - "@lexical/extension": "0.44.0", - "@lexical/selection": "0.44.0", - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/link": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.44.0.tgz", - "integrity": "sha512-uvEqEol/mLEzGVQd8Rok9I48RgYPKokM/nsclI9nYcEdccVOM2Nri4ntoRwodhbccFLtjMPl8OBldwXbfc77tQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.21.0.tgz", + "integrity": "sha512-/coktIyRXg8rXz/7uxXsSEfSQYxPIx8CmignAXWYhcyYtCWA0fD2mhEhWwVvHH9ofNzvidclRPYKUnrmUm3z3Q==", "dependencies": { - "@lexical/extension": "0.44.0", - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/list": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.44.0.tgz", - "integrity": "sha512-ZTCWxDz1okPrC9FBXi1yV3W5fbQQeMUlFIcSVF9HibcVPmCsPa900IxthuiQbGiTycUyXDTOB3IUYRtlJNtpjw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.21.0.tgz", + "integrity": "sha512-WItGlwwNJCS8b6SO1QPKzArShmD+OXQkLbhBcAh+EfpnkvmCW5T5LqY+OfIRmEN1dhDOnwqCY7mXkivWO8o5tw==", "dependencies": { - "@lexical/extension": "0.44.0", - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/mark": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.44.0.tgz", - "integrity": "sha512-bWMowllwe6BcgYMAkrsZx6Z+CX/72qCQpFKhlkR4ael92yOWSBkz68xp1wxxkSnQX9zoI1gYTeWBofVsSDKcsQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.21.0.tgz", + "integrity": "sha512-2x/LoHDYPOkZbKHz4qLFWsPywjRv9KggTOtmRazmaNRUG0FpkImJwUbbaKjWQXeESVGpzfL3qNFSAmCWthsc4g==", "dependencies": { - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/markdown": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.44.0.tgz", - "integrity": "sha512-DwlXdp85pYMo3exDF6W3iz8plpuP+RQ4Me4Iljm7O5aPDp0SSrIoZxyX4zS668mVAoz5HHj1Ka0kQkft8mq26Q==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.21.0.tgz", + "integrity": "sha512-XCQCyW5ujK0xR6evV8sF0hv/MRUA//kIrB2JiyF12tLQyjLRNEXO+0IKastWnMKSaDdJMKjzgd+4PiummYs7uA==", + "dependencies": { + "@lexical/code": "0.21.0", + "@lexical/link": "0.21.0", + "@lexical/list": "0.21.0", + "@lexical/rich-text": "0.21.0", + "@lexical/text": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" + } + }, + "node_modules/@lexical/offset": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.21.0.tgz", + "integrity": "sha512-UR0wHg+XXbq++6aeUPdU0K41xhUDBYzX+AeiqU9bZ7yoOq4grvKD8KBr5tARCSYTy0yvQnL1ddSO12TrP/98Lg==", "dependencies": { - "@lexical/code-core": "0.44.0", - "@lexical/link": "0.44.0", - "@lexical/list": "0.44.0", - "@lexical/rich-text": "0.44.0", - "@lexical/text": "0.44.0", - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "lexical": "0.21.0" } }, "node_modules/@lexical/overflow": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.44.0.tgz", - "integrity": "sha512-5GYaYjSxn27pqHRfU+tQ2STF10wgJvI+MUnwTnUFSzy3dko1b+oV94K/Yx0TuEewPbwDibfoFA8CwqUvOLHAyw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.21.0.tgz", + "integrity": "sha512-93P+d1mbvaJvZF8KK2pG22GuS2pHLtyC7N3GBfkbyAIb7TL/rYs47iR+eADJ4iNY680lylJ4Sl/AEnWvlY7hAg==", "dependencies": { - "lexical": "0.44.0" + "lexical": "0.21.0" } }, "node_modules/@lexical/plain-text": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.44.0.tgz", - "integrity": "sha512-bIV4Lljk0x70zFhkZIwzSPK5q3m9FpDisjGm2/3Q/chb+5BW3Tv8QJmqnpCiSO6S2KXO7gfSy81ZfkQ1dcd4EQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.21.0.tgz", + "integrity": "sha512-r4CsAknBD7qGYSE5fPdjpJ6EjfvzHbDtuCeKciL9muiswQhw4HeJrT1qb/QUIY+072uvXTgCgmjUmkbYnxKyPA==", "dependencies": { - "@lexical/clipboard": "0.44.0", - "@lexical/dragon": "0.44.0", - "@lexical/selection": "0.44.0", - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "@lexical/clipboard": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/react": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.44.0.tgz", - "integrity": "sha512-p/NQd/fMh3pXb1XqegE2ruvWDcUmfB12OidQ9nwtMtj5VfcUjQu2I+trUhgGRIADxSYxMWmw+8PPj5YSf4m5oA==", - "dependencies": { - "@floating-ui/react": "^0.27.16", - "@lexical/devtools-core": "0.44.0", - "@lexical/dragon": "0.44.0", - "@lexical/extension": "0.44.0", - "@lexical/hashtag": "0.44.0", - "@lexical/history": "0.44.0", - "@lexical/link": "0.44.0", - "@lexical/list": "0.44.0", - "@lexical/mark": "0.44.0", - "@lexical/markdown": "0.44.0", - "@lexical/overflow": "0.44.0", - "@lexical/plain-text": "0.44.0", - "@lexical/rich-text": "0.44.0", - "@lexical/table": "0.44.0", - "@lexical/text": "0.44.0", - "@lexical/utils": "0.44.0", - "@lexical/yjs": "0.44.0", - "lexical": "0.44.0", - "react-error-boundary": "^6.0.0" + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.21.0.tgz", + "integrity": "sha512-tKwx8EoNkBBKOZf8c10QfyDImH87+XUI1QDL8KXt+Lb8E4ho7g1jAjoEirNEn9gMBj33K4l2qVdbe3XmPAdpMQ==", + "dependencies": { + "@lexical/clipboard": "0.21.0", + "@lexical/code": "0.21.0", + "@lexical/devtools-core": "0.21.0", + "@lexical/dragon": "0.21.0", + "@lexical/hashtag": "0.21.0", + "@lexical/history": "0.21.0", + "@lexical/link": "0.21.0", + "@lexical/list": "0.21.0", + "@lexical/mark": "0.21.0", + "@lexical/markdown": "0.21.0", + "@lexical/overflow": "0.21.0", + "@lexical/plain-text": "0.21.0", + "@lexical/rich-text": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/table": "0.21.0", + "@lexical/text": "0.21.0", + "@lexical/utils": "0.21.0", + "@lexical/yjs": "0.21.0", + "lexical": "0.21.0", + "react-error-boundary": "^3.1.4" }, "peerDependencies": { "react": ">=17.x", - "react-dom": ">=17.x", - "yjs": ">=13.5.22" - }, - "peerDependenciesMeta": { - "yjs": { - "optional": true - } + "react-dom": ">=17.x" } }, "node_modules/@lexical/rich-text": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.44.0.tgz", - "integrity": "sha512-IIdrutK5GY47ITjPlZB7KzUi9dBDwygsyFOwolnrYSL7m6TtGhAqrYiFg/YNOTT/nBzK3KQeCJRbnxpjJAVZtQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.21.0.tgz", + "integrity": "sha512-+pvEKUneEkGfWOSTl9jU58N9knePilMLxxOtppCAcgnaCdilOh3n5YyRppXhvmprUe0JaTseCMoik2LP51G/JA==", "dependencies": { - "@lexical/clipboard": "0.44.0", - "@lexical/dragon": "0.44.0", - "@lexical/selection": "0.44.0", - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "@lexical/clipboard": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/selection": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.44.0.tgz", - "integrity": "sha512-AEyeZJFFr5YRLeqVR+X0QAW19c4Fk4MFAQu52z2gxAyDGTj9xwVJxjfepVpfUp4P9K+sPtJ/yaqfMXH506ksSQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.21.0.tgz", + "integrity": "sha512-4u53bc8zlPPF0rnHjsGQExQ1St8NafsDd70/t1FMw7yvoMtUsKdH7+ap00esLkJOMv45unJD7UOzKRqU1X0sEA==", "dependencies": { - "lexical": "0.44.0" + "lexical": "0.21.0" } }, "node_modules/@lexical/table": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.44.0.tgz", - "integrity": "sha512-5Uq0O/fBCxcZp9y17fXUONY7dU9lVo/mB5JHy23laIiKzBKP5IzzTLMU9ikZTppIXbMNxYXd+R2pmy7PYTLyvw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.21.0.tgz", + "integrity": "sha512-JhylAWcf4qKD4FmxMUt3YzH5zg2+baBr4+/haLZL7178hMvUzJwGIiWk+3hD3phzmW3WrP49uFXzM7DMSCkE8w==", "dependencies": { - "@lexical/clipboard": "0.44.0", - "@lexical/extension": "0.44.0", - "@lexical/utils": "0.44.0", - "lexical": "0.44.0" + "@lexical/clipboard": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/text": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.44.0.tgz", - "integrity": "sha512-1XJD8ZbwaXljTl8k4+jjiopdhnYZm26IJw9Gv8+cIThVC0b6B3JZ/WxH97BMDcSloKvWHFkGiPztxRwNwA29Rw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.21.0.tgz", + "integrity": "sha512-ceB4fhYejCoR8ID4uIs0sO/VyQoayRjrRWTIEMvOcQtwUkcyciKRhY0A7f2wVeq/MFStd+ajLLjy4WKYK5zUnA==", "dependencies": { - "lexical": "0.44.0" + "lexical": "0.21.0" } }, "node_modules/@lexical/utils": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.44.0.tgz", - "integrity": "sha512-/D2ptztNevfBJgtkj4uaiYBeRcvSy+1mQj6pNYaCFZIoPJIwl6H5fXwWAvpvr11vcQKP9DEEoXR+V4qkMOA+EA==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.21.0.tgz", + "integrity": "sha512-YzsNOAiLkCy6R3DuP18gtseDrzgx+30lFyqRvp5M7mckeYgQElwdfG5biNFDLv7BM9GjSzgU5Cunjycsx6Sjqg==", "dependencies": { - "@lexical/selection": "0.44.0", - "lexical": "0.44.0" + "@lexical/list": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/table": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/yjs": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.44.0.tgz", - "integrity": "sha512-b3QTub9J/3LuwSSdooynb6GbMHBRyBT4xUbXzXqNPbDHgYe6CDrqf/uJIHRihIjAhOnPaHYqo9XUzitl++N1DQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.21.0.tgz", + "integrity": "sha512-AtPhC3pJ92CHz3dWoniSky7+MSK2WSd0xijc76I2qbTeXyeuFfYyhR6gWMg4knuY9Wz3vo9/+dXGdbQIPD8efw==", "dependencies": { - "@lexical/selection": "0.44.0", - "lexical": "0.44.0" + "@lexical/offset": "0.21.0", + "@lexical/selection": "0.21.0", + "lexical": "0.21.0" }, "peerDependencies": { "yjs": ">=13.5.22" @@ -1033,15 +975,6 @@ "react": ">=18.0.0" } }, - "node_modules/@preact/signals-core": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.1.tgz", - "integrity": "sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1422,11 +1355,6 @@ "meshoptimizer": "~0.22.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" - }, "node_modules/@types/webxr": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.23.tgz", @@ -1619,16 +1547,6 @@ "node": ">=6.9.0" } }, - "node_modules/isomorphic.js": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", - "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", - "peer": true, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1659,9 +1577,9 @@ } }, "node_modules/lexical": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.44.0.tgz", - "integrity": "sha512-ReDUjRlFgkGoPWzvdjr7s16PUVpHATN+2NH2NiZs+PLlISTaIFFgKil2P467oP3Vg+XgmpDsUgmWZsFJTztYjg==" + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.21.0.tgz", + "integrity": "sha512-Dxc5SCG4kB+wF+Rh55ism3SuecOKeOtCtGHFGKd6pj2QKVojtjkxGTQPMt7//2z5rMSue4R+hmRM0pCEZflupA==" }, "node_modules/lib0": { "version": "0.2.117", @@ -1787,6 +1705,14 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1811,11 +1737,18 @@ } }, "node_modules/react-error-boundary": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz", - "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" + "react": ">=16.13.1" } }, "node_modules/react-refresh": { @@ -1924,11 +1857,6 @@ "node": ">=0.10.0" } }, - "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==" - }, "node_modules/three": { "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", @@ -2047,23 +1975,6 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true - }, - "node_modules/yjs": { - "version": "13.6.30", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", - "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", - "peer": true, - "dependencies": { - "lib0": "^0.2.99" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } } } } diff --git a/frontend/package.json b/frontend/package.json index 230a982..1ef66c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,17 +9,13 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/html": "^0.44.0", "@lexical/link": "^0.44.0", "@lexical/list": "^0.44.0", - "@lexical/markdown": "^0.44.0", "@lexical/react": "^0.44.0", "@lexical/rich-text": "^0.44.0", - "@lexical/selection": "^0.44.0", - "@lexical/utils": "^0.44.0", "@nanostores/react": "^1.0.0", "@types/three": "^0.180.0", - "lexical": "^0.44.0", + "lexical": "^0.21.0", "nanostores": "^1.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/components/ConfigModal.tsx b/frontend/src/components/ConfigModal.tsx index 9746e4e..e7b1f9f 100644 --- a/frontend/src/components/ConfigModal.tsx +++ b/frontend/src/components/ConfigModal.tsx @@ -1,7 +1,4 @@ import React from 'react' -import { useStore } from '@nanostores/react' -import { Link } from 'react-router-dom' -import { documentUiEnabledStore, setDocumentUiEnabled } from '../stores' type Props = { isOpen: boolean @@ -11,8 +8,6 @@ type Props = { } export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSkipIntroChange }) => { - const documentUiEnabled = useStore(documentUiEnabledStore) - if (!isOpen) return null return ( @@ -20,9 +15,9 @@ export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSki <div className="config-modal" onClick={e => e.stopPropagation()}> <div className="modal-header"> <h2>Configuration</h2> - <button className="close-btn" onClick={onClose}>{'\u00D7'}</button> + <button className="close-btn" onClick={onClose}>×</button> </div> - + <div className="modal-content"> <div className="config-option"> <label className="config-label"> @@ -38,32 +33,8 @@ export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSki Skip the loading screen animation on startup </div> </div> - - <div className="config-option" style={{ marginTop: '16px' }}> - <label className="config-label"> - <input - type="checkbox" - checked={documentUiEnabled} - onChange={e => setDocumentUiEnabled(e.target.checked)} - className="config-checkbox" - /> - <span className="config-text">Enable Document UI (Experimental)</span> - </label> - <div className="config-description"> - Replace the directive management interface with an interactive document editor. This is a proof of concept. - </div> - {documentUiEnabled && ( - <Link - to="/directives" - style={{ display: 'inline-block', marginTop: '8px', color: '#ff66cc', fontSize: '0.9em' }} - onClick={onClose} - > - Open Directives Editor {'\u2192'} - </Link> - )} - </div> </div> - + <div className="modal-footer"> <button className="modal-btn" onClick={onClose}>Close</button> </div> diff --git a/frontend/src/components/VNInterface.tsx b/frontend/src/components/VNInterface.tsx index 48b150a..0a77f39 100644 --- a/frontend/src/components/VNInterface.tsx +++ b/frontend/src/components/VNInterface.tsx @@ -9,11 +9,11 @@ import { showSettingsModalStore, isVisibleStore, yenBalanceStore, - documentUiEnabledStore, + documentEditorEnabledStore, toggleStandby, toggleShowChoices, updateTime, - setDocumentUiEnabled, + setDocumentEditorEnabled } from '../stores' interface VNInterfaceProps { @@ -28,7 +28,7 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { const showSettingsModal = useStore(showSettingsModalStore) const isVisible = useStore(isVisibleStore) const yenBalance = useStore(yenBalanceStore) - const documentUiEnabled = useStore(documentUiEnabledStore) + const documentEditorEnabled = useStore(documentEditorEnabledStore) // Fade in effect on mount useEffect(() => { @@ -110,20 +110,20 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { <span className="info-value">Daemons</span> </Link> </div> - {documentUiEnabled && ( + <div className="status-item"> + <Link to="/contracts" style={{ color: '#66ccff', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}> + <span className="info-label">View:</span> + <span className="info-value">Contracts</span> + </Link> + </div> + {documentEditorEnabled && ( <div className="status-item"> - <Link to="/directives" style={{ color: '#ff66cc', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px', fontWeight: 600 }}> - <span className="info-label">View:</span> + <Link to="/directives" style={{ color: '#66ccff', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}> + <span className="info-label">Edit:</span> <span className="info-value">Directives</span> </Link> </div> )} - <div className="status-item"> - <Link to="/contracts" style={{ color: documentUiEnabled ? '#8899bb' : '#66ccff', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px', fontSize: documentUiEnabled ? '0.85em' : undefined }}> - <span className="info-label">{documentUiEnabled ? 'Legacy:' : 'View:'}</span> - <span className="info-value">Contracts</span> - </Link> - </div> </div> </div> </div> @@ -209,28 +209,18 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { </div> </div> <div className="settings-section"> - <h3>Experimental</h3> + <h3>Feature Flags</h3> <div className="setting-item"> - <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}> + <label> <input type="checkbox" - checked={documentUiEnabled} - onChange={(e) => setDocumentUiEnabled(e.target.checked)} - /> - Document UI (Experimental) + checked={documentEditorEnabled} + onChange={(e) => setDocumentEditorEnabled(e.target.checked)} + /> Document Editor (Directives) </label> - <p style={{ fontSize: '0.8em', color: '#9ca3af', marginTop: '4px' }}> - Replace the directive management interface with an interactive document editor. This is a proof of concept. - </p> - {documentUiEnabled && ( - <Link - to="/directives" - style={{ display: 'inline-block', marginTop: '8px', color: '#ff66cc', fontSize: '0.9em' }} - onClick={() => showSettingsModalStore.set(false)} - > - Open Directives Editor {'\u2192'} - </Link> - )} + <div style={{ fontSize: '0.8em', opacity: 0.7, marginTop: '4px' }}> + Enable the directive document editor interface + </div> </div> </div> <div className="settings-section"> diff --git a/frontend/src/components/document/AutoSavePlugin.tsx b/frontend/src/components/document/AutoSavePlugin.tsx deleted file mode 100644 index d3d0eb5..0000000 --- a/frontend/src/components/document/AutoSavePlugin.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { UNDO_COMMAND } from 'lexical'; - -const COUNTDOWN_DURATION_MS = 3000; -const TICK_INTERVAL_MS = 50; - -interface AutoSavePluginProps { - onAutoSave: (content: string) => void; - getContent: () => string; - enabled?: boolean; -} - -export default function AutoSavePlugin({ - onAutoSave, - getContent, - enabled = true, -}: AutoSavePluginProps) { - const [editor] = useLexicalComposerContext(); - const [countdown, setCountdown] = useState<number | null>(null); - const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); - const startTimeRef = useRef<number>(0); - const pendingContentRef = useRef<string>(''); - const lastSavedContentRef = useRef<string>(''); - - const clearTimer = useCallback(() => { - if (timerRef.current !== null) { - clearInterval(timerRef.current); - timerRef.current = null; - } - setCountdown(null); - }, []); - - const cancelCountdown = useCallback(() => { - clearTimer(); - }, [clearTimer]); - - const startCountdown = useCallback( - (content: string) => { - pendingContentRef.current = content; - clearTimer(); - - startTimeRef.current = Date.now(); - setCountdown(COUNTDOWN_DURATION_MS); - - timerRef.current = setInterval(() => { - const elapsed = Date.now() - startTimeRef.current; - const remaining = COUNTDOWN_DURATION_MS - elapsed; - - if (remaining <= 0) { - clearTimer(); - lastSavedContentRef.current = pendingContentRef.current; - onAutoSave(pendingContentRef.current); - } else { - setCountdown(remaining); - } - }, TICK_INTERVAL_MS); - }, - [clearTimer, onAutoSave] - ); - - // Listen for editor updates (content changes) - useEffect(() => { - if (!enabled) return; - - const unregister = editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => { - // Only trigger on actual content changes - if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return; - - const content = getContent(); - if (content !== lastSavedContentRef.current) { - startCountdown(content); - } - }); - - return unregister; - }, [editor, enabled, getContent, startCountdown]); - - // Listen for undo command to cancel countdown - useEffect(() => { - const unregister = editor.registerCommand( - UNDO_COMMAND, - () => { - cancelCountdown(); - return false; // Don't prevent the undo from executing - }, - 1 // COMMAND_PRIORITY_LOW - ); - - return unregister; - }, [editor, cancelCountdown]); - - // Listen for Escape key to cancel countdown - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && countdown !== null) { - e.preventDefault(); - cancelCountdown(); - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [countdown, cancelCountdown]); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (timerRef.current !== null) { - clearInterval(timerRef.current); - } - }; - }, []); - - if (countdown === null) return null; - - const progressPercent = (countdown / COUNTDOWN_DURATION_MS) * 100; - const secondsLeft = Math.ceil(countdown / 1000); - - return ( - <div className="autosave-bar"> - <span className="autosave-bar-text"> - Saving in {secondsLeft}s... <kbd>Esc</kbd> to cancel - </span> - <div className="autosave-bar-progress-track"> - <div - className="autosave-bar-progress-fill" - style={{ width: `${progressPercent}%` }} - /> - </div> - <button - className="autosave-bar-cancel" - onClick={cancelCountdown} - type="button" - > - Cancel - </button> - </div> - ); -} diff --git a/frontend/src/components/document/ContextMenu.css b/frontend/src/components/document/ContextMenu.css deleted file mode 100644 index 4eed119..0000000 --- a/frontend/src/components/document/ContextMenu.css +++ /dev/null @@ -1,79 +0,0 @@ -/* ============================================ - Custom Context Menu - ============================================ */ - -.ctx-menu { - position: fixed; - z-index: 10000; - min-width: 200px; - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 10px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06); - padding: 4px 0; - animation: ctxFadeIn 0.12s ease-out; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -} - -/* Menu item */ -.ctx-menu-item { - display: flex; - align-items: center; - gap: 0.6rem; - width: 100%; - padding: 0.5rem 0.85rem; - border: none; - background: none; - cursor: pointer; - font-size: 0.875rem; - color: #1f2937; - text-align: left; - border-radius: 0; - transition: background 0.1s ease; -} - -.ctx-menu-item:hover:not(:disabled) { - background: #f3f4f6; -} - -.ctx-menu-item:active:not(:disabled) { - background: #e5e7eb; -} - -/* Disabled state */ -.ctx-menu-item-disabled, -.ctx-menu-item:disabled { - color: #9ca3af; - cursor: not-allowed; -} - -/* Icon */ -.ctx-menu-icon { - font-size: 1rem; - width: 1.25rem; - text-align: center; - flex-shrink: 0; -} - -.ctx-menu-label { - flex: 1; -} - -/* Divider */ -.ctx-menu-divider { - height: 1px; - background: #e5e7eb; - margin: 4px 0; -} - -/* Animation */ -@keyframes ctxFadeIn { - from { - opacity: 0; - transform: scale(0.96); - } - to { - opacity: 1; - transform: scale(1); - } -} diff --git a/frontend/src/components/document/ContextMenu.tsx b/frontend/src/components/document/ContextMenu.tsx deleted file mode 100644 index 5aed940..0000000 --- a/frontend/src/components/document/ContextMenu.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react'; -import './ContextMenu.css'; - -export interface ContextMenuAction { - label: string; - icon: string; - disabled?: boolean; - onClick: () => void; -} - -export interface ContextMenuProps { - x: number; - y: number; - actions: ContextMenuAction[]; - dividerAfter?: number[]; - onClose: () => void; -} - -export default function ContextMenu({ - x, - y, - actions, - dividerAfter = [], - onClose, -}: ContextMenuProps) { - const menuRef = useRef<HTMLDivElement>(null); - - // Adjust position so menu stays within viewport - const adjustedPosition = useCallback(() => { - const el = menuRef.current; - if (!el) return { left: x, top: y }; - const rect = el.getBoundingClientRect(); - const left = x + rect.width > window.innerWidth ? x - rect.width : x; - const top = y + rect.height > window.innerHeight ? y - rect.height : y; - return { left: Math.max(0, left), top: Math.max(0, top) }; - }, [x, y]); - - // Close on click outside - useEffect(() => { - const handler = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - // Use capture so we catch clicks before any other handler - document.addEventListener('mousedown', handler, true); - return () => document.removeEventListener('mousedown', handler, true); - }, [onClose]); - - // Close on Escape - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - document.addEventListener('keydown', handler); - return () => document.removeEventListener('keydown', handler); - }, [onClose]); - - // After mount, adjust position - useEffect(() => { - const el = menuRef.current; - if (!el) return; - const pos = adjustedPosition(); - el.style.left = `${pos.left}px`; - el.style.top = `${pos.top}px`; - }, [adjustedPosition]); - - const dividerSet = new Set(dividerAfter); - - return ( - <div - ref={menuRef} - className="ctx-menu" - style={{ left: x, top: y }} - role="menu" - > - {actions.map((action, i) => ( - <div key={i}> - <button - className={`ctx-menu-item ${action.disabled ? 'ctx-menu-item-disabled' : ''}`} - role="menuitem" - disabled={action.disabled} - onClick={() => { - if (!action.disabled) { - action.onClick(); - onClose(); - } - }} - > - <span className="ctx-menu-icon">{action.icon}</span> - <span className="ctx-menu-label">{action.label}</span> - </button> - {dividerSet.has(i) && <div className="ctx-menu-divider" />} - </div> - ))} - </div> - ); -} diff --git a/frontend/src/components/document/DirectiveFileTree.tsx b/frontend/src/components/document/DirectiveFileTree.tsx deleted file mode 100644 index bacffe6..0000000 --- a/frontend/src/components/document/DirectiveFileTree.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { listDirectives, DirectiveSummary } from '../../services/directiveApi' - -interface DirectiveFileTreeProps { - selectedDirectiveId: string | null - onSelectDirective: (id: string) => void - onNewDirective: () => void -} - -interface GroupState { - [key: string]: boolean -} - -const STATUS_GROUPS = [ - { key: 'active', label: 'Active', defaultExpanded: true }, - { key: 'idle', label: 'Idle', defaultExpanded: true }, - { key: 'draft', label: 'Draft', defaultExpanded: false }, - { key: 'archived', label: 'Archived', defaultExpanded: false }, -] as const - -function statusColor(status: string): string { - switch (status.toLowerCase()) { - case 'active': - case 'running': - return '#4caf50' - case 'idle': - case 'paused': - return '#ffc107' - case 'draft': - case 'pending': - return '#9e9e9e' - case 'archived': - case 'failed': - return '#f44336' - default: - return '#9e9e9e' - } -} - -function groupDirectives(directives: DirectiveSummary[]): Record<string, DirectiveSummary[]> { - const groups: Record<string, DirectiveSummary[]> = { - active: [], - idle: [], - draft: [], - archived: [], - } - - for (const d of directives) { - const s = d.status.toLowerCase() - if (s === 'active' || s === 'running') { - groups.active.push(d) - } else if (s === 'idle' || s === 'paused') { - groups.idle.push(d) - } else if (s === 'draft' || s === 'pending') { - groups.draft.push(d) - } else { - groups.archived.push(d) - } - } - - return groups -} - -export function DirectiveFileTree({ selectedDirectiveId, onSelectDirective, onNewDirective }: DirectiveFileTreeProps) { - const [directives, setDirectives] = useState<DirectiveSummary[]>([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState<string | null>(null) - const [expanded, setExpanded] = useState<GroupState>(() => { - const state: GroupState = {} - for (const g of STATUS_GROUPS) { - state[g.key] = g.defaultExpanded - } - return state - }) - - useEffect(() => { - async function load() { - try { - setLoading(true) - const data = await listDirectives() - setDirectives(data) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load directives') - } finally { - setLoading(false) - } - } - load() - }, []) - - const toggleGroup = (key: string) => { - setExpanded(prev => ({ ...prev, [key]: !prev[key] })) - } - - const grouped = groupDirectives(directives) - - return ( - <div className="directive-file-tree"> - <div className="file-tree-header"> - <span className="file-tree-title">Directives</span> - <button className="file-tree-new-btn" onClick={onNewDirective} title="New Directive"> - + - </button> - </div> - - {loading && <div className="file-tree-loading">Loading...</div>} - {error && <div className="file-tree-error">{error}</div>} - - {!loading && !error && ( - <div className="file-tree-groups"> - {STATUS_GROUPS.map(group => { - const items = grouped[group.key] - if (!items || items.length === 0) return null - - return ( - <div key={group.key} className="file-tree-group"> - <button - className="file-tree-group-header" - onClick={() => toggleGroup(group.key)} - > - <span className={`file-tree-chevron ${expanded[group.key] ? 'expanded' : ''}`}> - {'\u25B6'} - </span> - <span className="file-tree-group-label">{group.label}</span> - <span className="file-tree-group-count">{items.length}</span> - </button> - - {expanded[group.key] && ( - <div className="file-tree-items"> - {items.map(directive => ( - <button - key={directive.id} - className={`file-tree-item ${selectedDirectiveId === directive.id ? 'selected' : ''}`} - onClick={() => onSelectDirective(directive.id)} - title={directive.title} - > - <span - className="file-tree-status-dot" - style={{ backgroundColor: statusColor(directive.status) }} - /> - <span className="file-tree-doc-icon">{'\u{1F4C4}'}</span> - <span className="file-tree-item-title">{directive.title || 'Untitled'}</span> - {directive.stepCounts && ( - <span className="file-tree-step-count" title="Contract steps"> - {directive.stepCounts.completed}/{ - directive.stepCounts.pending + - directive.stepCounts.ready + - directive.stepCounts.running + - directive.stepCounts.completed + - directive.stepCounts.failed + - directive.stepCounts.skipped - } - </span> - )} - </button> - ))} - </div> - )} - </div> - ) - })} - </div> - )} - </div> - ) -} diff --git a/frontend/src/components/document/DocumentEditor.css b/frontend/src/components/document/DocumentEditor.css deleted file mode 100644 index 0be1151..0000000 --- a/frontend/src/components/document/DocumentEditor.css +++ /dev/null @@ -1,246 +0,0 @@ -/* ============================================ - Document Editor - Clean, modern document UI - ============================================ */ - -.document-editor-container { - max-width: 800px; - margin: 0 auto; - padding: 2rem 1rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - position: relative; -} - -/* ---- Lexical Root ---- */ -.doc-editor-root { - outline: none; - min-height: 400px; - padding: 1rem 0; - color: #1a1a2e; - line-height: 1.7; - font-size: 16px; -} - -/* ---- Headings ---- */ -.doc-editor-h1 { - font-size: 2.25rem; - font-weight: 700; - color: #0f0f23; - margin: 0 0 0.25rem 0; - padding: 0; - line-height: 1.3; - letter-spacing: -0.02em; - border: none; -} - -.doc-editor-h2 { - font-size: 1.5rem; - font-weight: 600; - color: #1a1a2e; - margin: 1.5rem 0 0.5rem 0; - line-height: 1.4; -} - -.doc-editor-h3 { - font-size: 1.2rem; - font-weight: 600; - color: #2a2a4a; - margin: 1.25rem 0 0.4rem 0; - line-height: 1.4; -} - -/* ---- Paragraphs ---- */ -.doc-editor-paragraph { - margin: 0.4rem 0; - padding: 0; - color: #374151; - line-height: 1.7; -} - -/* ---- Text Formatting ---- */ -.doc-editor-text-bold { - font-weight: 700; -} - -.doc-editor-text-italic { - font-style: italic; -} - -.doc-editor-text-underline { - text-decoration: underline; -} - -.doc-editor-text-strikethrough { - text-decoration: line-through; -} - -.doc-editor-text-code { - background: #f3f4f6; - border-radius: 3px; - padding: 0.15em 0.35em; - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace; - font-size: 0.9em; - color: #e11d48; -} - -/* ---- Lists ---- */ -.doc-editor-list-ul { - padding-left: 1.5rem; - margin: 0.5rem 0; - list-style-type: disc; -} - -.doc-editor-list-ol { - padding-left: 1.5rem; - margin: 0.5rem 0; - list-style-type: decimal; -} - -.doc-editor-listitem { - margin: 0.25rem 0; - color: #374151; -} - -.doc-editor-nested-listitem { - list-style-type: circle; -} - -/* ---- Links ---- */ -.doc-editor-link { - color: #2563eb; - text-decoration: underline; - cursor: pointer; -} - -.doc-editor-link:hover { - color: #1d4ed8; -} - -/* ---- Placeholder ---- */ -.doc-editor-placeholder { - color: #9ca3af; - position: absolute; - top: 1rem; - left: 0; - pointer-events: none; - font-size: 16px; - user-select: none; -} - -/* ---- Content Editable wrapper ---- */ -.doc-editor-input { - position: relative; -} - -.doc-editor-content-editable { - outline: none; - position: relative; -} - -/* ---- Divider between title and body ---- */ -.doc-editor-title-divider { - height: 1px; - background: #e5e7eb; - margin: 0.5rem 0 1rem 0; - border: none; -} - -/* ============================================ - Auto-Save Countdown Bar - ============================================ */ - -.autosave-bar { - position: sticky; - bottom: 0; - left: 0; - right: 0; - z-index: 50; - background: #fefce8; - border-top: 1px solid #fde68a; - padding: 0.5rem 1rem; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - font-size: 0.85rem; - color: #92400e; - transition: opacity 0.2s ease; -} - -.autosave-bar-hidden { - opacity: 0; - pointer-events: none; - height: 0; - padding: 0; - overflow: hidden; -} - -.autosave-bar-text { - display: flex; - align-items: center; - gap: 0.5rem; - white-space: nowrap; -} - -.autosave-bar-text kbd { - background: #fef3c7; - border: 1px solid #fde68a; - border-radius: 3px; - padding: 0.1em 0.4em; - font-size: 0.8em; - font-family: inherit; -} - -.autosave-bar-progress-track { - flex: 1; - height: 4px; - background: #fde68a; - border-radius: 2px; - overflow: hidden; - min-width: 80px; -} - -.autosave-bar-progress-fill { - height: 100%; - background: #f59e0b; - border-radius: 2px; - transition: width 0.1s linear; -} - -.autosave-bar-cancel { - background: none; - border: 1px solid #d97706; - border-radius: 4px; - color: #92400e; - padding: 0.2rem 0.6rem; - cursor: pointer; - font-size: 0.8rem; - white-space: nowrap; - transition: background 0.15s ease; -} - -.autosave-bar-cancel:hover { - background: #fef3c7; -} - -/* ============================================ - Responsive - ============================================ */ - -@media (max-width: 640px) { - .document-editor-container { - padding: 1rem 0.75rem; - } - - .doc-editor-h1 { - font-size: 1.75rem; - } - - .doc-editor-root { - font-size: 15px; - } - - .autosave-bar { - font-size: 0.78rem; - padding: 0.4rem 0.75rem; - } -} diff --git a/frontend/src/components/document/DocumentEditor.tsx b/frontend/src/components/document/DocumentEditor.tsx deleted file mode 100644 index 2ef37fe..0000000 --- a/frontend/src/components/document/DocumentEditor.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; -import { LexicalComposer } from '@lexical/react/LexicalComposer'; -import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; -import { ContentEditable } from '@lexical/react/LexicalContentEditable'; -import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; -import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; -import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; -import { HeadingNode } from '@lexical/rich-text'; -import { ListNode, ListItemNode } from '@lexical/list'; -import { LinkNode } from '@lexical/link'; -import { - $getRoot, - $createParagraphNode, - $createTextNode, - type EditorState, - type LexicalEditor, -} from 'lexical'; -import { $createHeadingNode } from '@lexical/rich-text'; - -import { StepsDiagramNode, $isStepsDiagramNode, $createStepsDiagramNode } from './nodes/StepsDiagramNode'; -import { ContractBlockNode, $isContractBlockNode } from './nodes/ContractBlockNode'; -import editorTheme from './EditorTheme'; -import AutoSavePlugin from './AutoSavePlugin'; -import ContextMenu, { type ContextMenuAction } from './ContextMenu'; -import './DocumentEditor.css'; -import './nodes/StepsDiagram.css'; -import './nodes/ContractBlock.css'; - -interface DocumentEditorProps { - directiveId: string; - title: string; - goal: string; - status: string; - prBranch?: string | null; - onGoalChange?: (newGoal: string) => void; - onTitleChange?: (newTitle: string) => void; - onCleanup?: () => void; - onCreatePr?: () => void; - onPlanOrders?: () => void; - onTogglePause?: () => void; - readOnly?: boolean; -} - -function buildInitialEditorState(directiveId: string, title: string, goal: string) { - return () => { - const root = $getRoot(); - - // Title as H1 - const heading = $createHeadingNode('h1'); - heading.append($createTextNode(title)); - root.append(heading); - - // Goal as paragraph(s), split by newlines - const lines = goal.split('\n'); - for (const line of lines) { - const paragraph = $createParagraphNode(); - if (line.trim()) { - paragraph.append($createTextNode(line)); - } - root.append(paragraph); - } - - // Insert steps diagram node after the goal content - const stepsNode = $createStepsDiagramNode(directiveId); - root.append(stepsNode); - - // Add a trailing paragraph so the user can type below the diagram - const trailingParagraph = $createParagraphNode(); - root.append(trailingParagraph); - }; -} - -function onError(error: Error) { - console.error('[DocumentEditor] Lexical error:', error); -} - -export default function DocumentEditor({ - directiveId, - title, - goal, - status, - prBranch, - onGoalChange, - onTitleChange, - onCleanup, - onCreatePr, - onPlanOrders, - onTogglePause, - readOnly = false, -}: DocumentEditorProps) { - const editorRef = useRef<LexicalEditor | null>(null); - const latestGoalRef = useRef(goal); - const latestTitleRef = useRef(title); - - // Context menu state - const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); - - const initialConfig = { - namespace: `DocumentEditor-${directiveId}`, - theme: editorTheme, - editorState: buildInitialEditorState(directiveId, title, goal), - nodes: [HeadingNode, ListNode, ListItemNode, LinkNode, StepsDiagramNode, ContractBlockNode], - onError, - editable: !readOnly, - }; - - const handleChange = useCallback( - (_editorState: EditorState, editor: LexicalEditor) => { - editorRef.current = editor; - - editor.getEditorState().read(() => { - const root = $getRoot(); - const children = root.getChildren(); - - let newTitle = ''; - const goalLines: string[] = []; - - for (let i = 0; i < children.length; i++) { - const child = children[i]; - // Skip decorator nodes (steps diagram, contract blocks) when extracting text - if ($isStepsDiagramNode(child)) continue; - if ($isContractBlockNode(child)) continue; - - const text = child.getTextContent(); - - if (i === 0 && child.getType() === 'heading') { - newTitle = text; - } else { - goalLines.push(text); - } - } - - const newGoal = goalLines.join('\n'); - - if (newTitle !== latestTitleRef.current) { - latestTitleRef.current = newTitle; - onTitleChange?.(newTitle); - } - - if (newGoal !== latestGoalRef.current) { - latestGoalRef.current = newGoal; - onGoalChange?.(newGoal); - } - }); - }, - [onGoalChange, onTitleChange] - ); - - const getContent = useCallback(() => { - return latestGoalRef.current; - }, []); - - const handleAutoSave = useCallback( - (content: string) => { - onGoalChange?.(content); - }, - [onGoalChange] - ); - - // Context menu handler - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - setCtxMenu({ x: e.clientX, y: e.clientY }); - }, - [] - ); - - const closeCtxMenu = useCallback(() => setCtxMenu(null), []); - - const isPaused = status === 'paused'; - const isIdle = status === 'idle'; - - const ctxActions: ContextMenuAction[] = [ - { - label: 'Clean Up', - icon: '\uD83E\uDDF9', // broom - disabled: !isIdle, - onClick: () => onCleanup?.(), - }, - { - label: 'Update PR', - icon: '\uD83D\uDD00', // shuffle - disabled: !prBranch, - onClick: () => onCreatePr?.(), - }, - { - label: 'Plan Orders', - icon: '\uD83D\uDCCB', // clipboard - onClick: () => onPlanOrders?.(), - }, - { - label: isPaused ? 'Resume Directive' : 'Pause Directive', - icon: isPaused ? '\u25B6' : '\u23F8', - onClick: () => onTogglePause?.(), - }, - ]; - - return ( - <div className="document-editor-container" onContextMenu={handleContextMenu}> - <LexicalComposer initialConfig={initialConfig}> - <div className="doc-editor-input"> - <RichTextPlugin - contentEditable={ - <ContentEditable className="doc-editor-content-editable doc-editor-root" /> - } - placeholder={ - <div className="doc-editor-placeholder">Start writing...</div> - } - ErrorBoundary={LexicalErrorBoundary} - /> - </div> - <HistoryPlugin /> - <OnChangePlugin onChange={handleChange} /> - {!readOnly && ( - <AutoSavePlugin - onAutoSave={handleAutoSave} - getContent={getContent} - enabled={!readOnly} - /> - )} - </LexicalComposer> - - {ctxMenu && ( - <ContextMenu - x={ctxMenu.x} - y={ctxMenu.y} - actions={ctxActions} - dividerAfter={[2]} - onClose={closeCtxMenu} - /> - )} - </div> - ); -} - diff --git a/frontend/src/components/document/DocumentLayout.css b/frontend/src/components/document/DocumentLayout.css deleted file mode 100644 index ae73e7a..0000000 --- a/frontend/src/components/document/DocumentLayout.css +++ /dev/null @@ -1,363 +0,0 @@ -/* Document Layout - Main container */ -.document-layout { - display: flex; - height: 100vh; - width: 100vw; - overflow: hidden; - background: #1a1a2e; - color: #e0e0e0; -} - -/* Sidebar */ -.document-sidebar { - flex-shrink: 0; - height: 100%; - overflow-y: auto; - overflow-x: hidden; - background: #16162a; - border-right: 1px solid #2a2a4a; -} - -/* Back link */ -.document-sidebar-back { - padding: 8px 12px; - border-bottom: 1px solid #2a2a4a; -} - -.document-back-link { - color: #9ca3af; - text-decoration: none; - font-size: 0.85rem; - display: flex; - align-items: center; - gap: 4px; - transition: color 0.15s; -} - -.document-back-link:hover { - color: #e0e0e0; -} - -/* Resize handle */ -.document-resize-handle { - width: 4px; - cursor: col-resize; - background: transparent; - flex-shrink: 0; - transition: background 0.15s; -} - -.document-resize-handle:hover, -.document-resize-handle:active { - background: #4a4a8a; -} - -/* Main content area */ -.document-main { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - overflow: hidden; -} - -/* Top bar */ -.document-topbar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 20px; - border-bottom: 1px solid #2a2a4a; - background: #1e1e38; - flex-shrink: 0; -} - -.document-topbar-left { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; -} - -.document-topbar-title { - font-size: 16px; - font-weight: 600; - margin: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: #f0f0f0; -} - -.document-topbar-right { - display: flex; - align-items: center; - gap: 8px; -} - -.document-topbar-gear { - background: none; - border: none; - color: #888; - font-size: 20px; - cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - transition: color 0.15s, background 0.15s; -} - -.document-topbar-gear:hover { - color: #fff; - background: rgba(255, 255, 255, 0.08); -} - -/* Status badge */ -.doc-status-badge { - display: inline-block; - padding: 2px 10px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - color: #fff; - letter-spacing: 0.5px; -} - -/* Content area */ -.document-content { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - display: flex; - flex-direction: column; - align-items: center; - scroll-behavior: smooth; - /* Ensure expanded log feeds don't break layout */ - min-height: 0; -} - -/* Placeholder / empty state */ -.document-placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - text-align: center; - color: #888; - padding: 40px; -} - -.document-placeholder-icon { - font-size: 48px; - margin-bottom: 16px; - opacity: 0.5; -} - -.document-placeholder h2 { - font-size: 20px; - font-weight: 500; - margin: 0 0 8px; - color: #aaa; -} - -.document-placeholder p { - font-size: 14px; - margin: 0; - max-width: 400px; - line-height: 1.5; -} - -.document-error { - color: #f44336; -} - -/* File Tree styles */ -.directive-file-tree { - display: flex; - flex-direction: column; - height: 100%; - font-size: 13px; -} - -.file-tree-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 14px; - border-bottom: 1px solid #2a2a4a; - flex-shrink: 0; -} - -.file-tree-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 1px; - color: #888; -} - -.file-tree-new-btn { - background: none; - border: 1px solid #3a3a6a; - color: #aaa; - font-size: 16px; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - cursor: pointer; - padding: 0; - line-height: 1; - transition: all 0.15s; -} - -.file-tree-new-btn:hover { - background: #3a3a6a; - color: #fff; - border-color: #5a5a9a; -} - -.file-tree-loading, -.file-tree-error { - padding: 16px; - color: #888; - font-size: 12px; - text-align: center; -} - -.file-tree-error { - color: #f44336; -} - -.file-tree-groups { - flex: 1; - overflow-y: auto; - padding: 4px 0; -} - -/* Group header */ -.file-tree-group { - margin-bottom: 2px; -} - -.file-tree-group-header { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 6px 14px; - background: none; - border: none; - color: #999; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - text-align: left; - transition: color 0.15s; -} - -.file-tree-group-header:hover { - color: #ccc; -} - -.file-tree-chevron { - font-size: 8px; - transition: transform 0.15s; - display: inline-block; -} - -.file-tree-chevron.expanded { - transform: rotate(90deg); -} - -.file-tree-group-count { - margin-left: auto; - color: #666; - font-size: 10px; -} - -.file-tree-group-label { - flex: 1; -} - -/* Tree items */ -.file-tree-items { - padding: 0; -} - -.file-tree-item { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - padding: 5px 14px 5px 28px; - background: none; - border: none; - color: #ccc; - font-size: 13px; - cursor: pointer; - text-align: left; - transition: background 0.1s; - white-space: nowrap; - overflow: hidden; -} - -.file-tree-item:hover { - background: rgba(255, 255, 255, 0.05); -} - -.file-tree-item.selected { - background: rgba(100, 100, 200, 0.15); - color: #fff; -} - -.file-tree-status-dot { - width: 7px; - height: 7px; - border-radius: 50%; - flex-shrink: 0; -} - -.file-tree-doc-icon { - font-size: 14px; - flex-shrink: 0; - opacity: 0.7; -} - -.file-tree-item-title { - overflow: hidden; - text-overflow: ellipsis; - flex: 1; -} - -.file-tree-step-count { - margin-left: auto; - font-size: 10px; - color: #666; - background: rgba(255, 255, 255, 0.06); - border-radius: 8px; - padding: 1px 6px; - flex-shrink: 0; - white-space: nowrap; -} - -/* Responsive: mobile */ -@media (max-width: 768px) { - .document-sidebar { - position: absolute; - z-index: 100; - left: 0; - top: 0; - height: 100%; - box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5); - } - - .document-resize-handle { - display: none; - } -} diff --git a/frontend/src/components/document/DocumentLayout.tsx b/frontend/src/components/document/DocumentLayout.tsx deleted file mode 100644 index 05f4190..0000000 --- a/frontend/src/components/document/DocumentLayout.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { useEffect, useState, useCallback, useRef, useMemo } from 'react' -import { useParams, useNavigate, Link } from 'react-router-dom' -import { DirectiveFileTree } from './DirectiveFileTree' -import DocumentEditor from './DocumentEditor' -import { ToastProvider, useToast } from './Toast' -import { - type DirectiveWithSteps, - type DirectiveStep, - getDirective, - getDirectiveSteps, - updateGoal, - updateDirective, - cleanupDirective, - createPr, - pickUpOrders, - pauseDirective, - startDirective, -} from '../../services/directiveApi' -import './DocumentLayout.css' - -function StatusBadge({ status }: { status: string }) { - const colors: Record<string, string> = { - active: '#4caf50', - running: '#4caf50', - idle: '#ffc107', - paused: '#ffc107', - draft: '#9e9e9e', - pending: '#9e9e9e', - archived: '#f44336', - failed: '#f44336', - } - const color = colors[status.toLowerCase()] || '#9e9e9e' - - return ( - <span className="doc-status-badge" style={{ backgroundColor: color }}> - {status} - </span> - ) -} - -function DocumentLayoutInner() { - const { id: urlDirectiveId } = useParams<{ id: string }>() - const navigate = useNavigate() - const { addToast } = useToast() - - const [selectedId, setSelectedId] = useState<string | null>(urlDirectiveId || null) - const [directive, setDirective] = useState<DirectiveWithSteps | null>(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState<string | null>(null) - const [sidebarWidth, setSidebarWidth] = useState(250) - const resizingRef = useRef(false) - const startXRef = useRef(0) - const startWidthRef = useRef(250) - const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) - - // Sync URL param on mount - useEffect(() => { - if (urlDirectiveId && urlDirectiveId !== selectedId) { - setSelectedId(urlDirectiveId) - } - }, [urlDirectiveId]) - - // Handle directive selection - update URL - const handleSelectDirective = useCallback((id: string) => { - setSelectedId(id) - navigate(`/directives/${id}`, { replace: true }) - }, [navigate]) - - // Load directive when selected - useEffect(() => { - if (!selectedId) { - setDirective(null) - return - } - - let cancelled = false - async function load() { - try { - setLoading(true) - setError(null) - const data = await getDirective(selectedId!) - if (!cancelled) setDirective(data) - } catch (err) { - if (!cancelled) { - const msg = err instanceof Error ? err.message : 'Failed to load directive' - setError(msg) - addToast(msg, 'error') - } - } finally { - if (!cancelled) setLoading(false) - } - } - load() - - return () => { cancelled = true } - }, [selectedId, addToast]) - - // Step polling (after goal update triggers supervisor) - const startStepPolling = useCallback(() => { - if (pollRef.current) clearInterval(pollRef.current) - pollRef.current = setInterval(async () => { - if (!selectedId) return - try { - const data = await getDirective(selectedId) - setDirective(data) - } catch { - // Silently fail for polling - } - }, 3000) - // Stop after 60 seconds - setTimeout(() => { - if (pollRef.current) { - clearInterval(pollRef.current) - pollRef.current = null - } - }, 60000) - }, [selectedId]) - - useEffect(() => { - return () => { - if (pollRef.current) clearInterval(pollRef.current) - } - }, []) - - // Auto-save goal changes - const handleGoalChange = useCallback(async (newGoal: string) => { - if (!selectedId) return - try { - const updated = await updateGoal(selectedId, newGoal) - setDirective(updated) - addToast('Goal saved', 'success') - startStepPolling() - } catch (err) { - addToast(`Failed to save goal: ${(err as Error).message}`, 'error') - } - }, [selectedId, addToast, startStepPolling]) - - const handleTitleChange = useCallback(async (newTitle: string) => { - if (!selectedId || !directive) return - try { - const updated = await updateDirective(selectedId, { - title: newTitle, - version: directive.version, - }) - setDirective(updated) - } catch (err) { - addToast(`Failed to update title: ${(err as Error).message}`, 'error') - } - }, [selectedId, directive, addToast]) - - const handleCleanup = useCallback(async () => { - if (!selectedId) return - try { - await cleanupDirective(selectedId) - addToast('Cleanup contract spawned', 'success') - startStepPolling() - } catch (err) { - addToast(`Cleanup failed: ${(err as Error).message}`, 'error') - } - }, [selectedId, addToast, startStepPolling]) - - const handleCreatePr = useCallback(async () => { - if (!selectedId) return - try { - await createPr(selectedId) - addToast('PR update triggered', 'success') - } catch (err) { - addToast(`PR update failed: ${(err as Error).message}`, 'error') - } - }, [selectedId, addToast]) - - const handlePlanOrders = useCallback(async () => { - if (!selectedId) return - try { - await pickUpOrders(selectedId) - addToast('Planning orders...', 'info') - startStepPolling() - } catch (err) { - addToast(`Plan orders failed: ${(err as Error).message}`, 'error') - } - }, [selectedId, addToast, startStepPolling]) - - const handleTogglePause = useCallback(async () => { - if (!selectedId || !directive) return - try { - if (directive.status === 'paused') { - const result = await startDirective(selectedId) - setDirective(result) - addToast('Directive resumed', 'success') - } else { - const updated = await pauseDirective(selectedId) - setDirective(updated) - addToast('Directive paused', 'info') - } - } catch (err) { - addToast(`Failed to toggle pause: ${(err as Error).message}`, 'error') - } - }, [selectedId, directive, addToast]) - - // Sidebar resize handlers - const handleMouseDown = useCallback((e: React.MouseEvent) => { - resizingRef.current = true - startXRef.current = e.clientX - startWidthRef.current = sidebarWidth - document.body.style.cursor = 'col-resize' - document.body.style.userSelect = 'none' - - const handleMouseMove = (e: MouseEvent) => { - if (!resizingRef.current) return - const diff = e.clientX - startXRef.current - const newWidth = Math.max(180, Math.min(500, startWidthRef.current + diff)) - setSidebarWidth(newWidth) - } - - const handleMouseUp = () => { - resizingRef.current = false - document.body.style.cursor = '' - document.body.style.userSelect = '' - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - } - - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - }, [sidebarWidth]) - - const handleNewDirective = useCallback(() => { - // Placeholder - will be implemented with full directive creation flow - console.log('New directive requested') - }, []) - - return ( - <div className="document-layout"> - {/* Sidebar */} - <div className="document-sidebar" style={{ width: sidebarWidth }}> - <div className="document-sidebar-back"> - <Link to="/" className="document-back-link"> - {'\u2190'} Back to Main - </Link> - </div> - <DirectiveFileTree - selectedDirectiveId={selectedId} - onSelectDirective={handleSelectDirective} - onNewDirective={handleNewDirective} - /> - </div> - - {/* Resize handle */} - <div className="document-resize-handle" onMouseDown={handleMouseDown} /> - - {/* Main content */} - <div className="document-main"> - {directive && ( - <div className="document-topbar"> - <div className="document-topbar-left"> - <h1 className="document-topbar-title">{directive.title || 'Untitled'}</h1> - <StatusBadge status={directive.status} /> - </div> - <div className="document-topbar-right"> - <button className="document-topbar-gear" title="Settings"> - {'\u2699'} - </button> - </div> - </div> - )} - - <div className="document-content"> - {loading && ( - <div className="document-placeholder"> - <p>Loading directive...</p> - </div> - )} - - {error && ( - <div className="document-placeholder"> - <p className="document-error">Error: {error}</p> - </div> - )} - - {!loading && !error && !directive && ( - <div className="document-placeholder"> - <div className="document-placeholder-icon">{'\u{1F4DD}'}</div> - <h2>No directive selected</h2> - <p>Select a directive from the sidebar or create a new one to get started.</p> - </div> - )} - - {!loading && !error && directive && ( - <DocumentEditor - directiveId={directive.id} - title={directive.title || 'Untitled'} - goal={directive.goal || ''} - status={directive.status} - prBranch={directive.prBranch || directive.pr_branch} - onGoalChange={handleGoalChange} - onTitleChange={handleTitleChange} - onCleanup={handleCleanup} - onCreatePr={handleCreatePr} - onPlanOrders={handlePlanOrders} - onTogglePause={handleTogglePause} - /> - )} - </div> - </div> - </div> - ) -} - -// Wrapper that provides toast context -export default function DocumentLayout() { - return ( - <ToastProvider> - <DocumentLayoutInner /> - </ToastProvider> - ) -} diff --git a/frontend/src/components/document/DocumentSettings.tsx b/frontend/src/components/document/DocumentSettings.tsx deleted file mode 100644 index b575b3d..0000000 --- a/frontend/src/components/document/DocumentSettings.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useState, useCallback } from 'react' -import { upsertUserSetting } from '../../services/directiveApi' - -interface DocumentSettingsProps { - isOpen: boolean - onClose: () => void - enabled: boolean - onToggle: (enabled: boolean) => void -} - -export default function DocumentSettings({ - isOpen, - onClose, - enabled, - onToggle, -}: DocumentSettingsProps) { - const [saving, setSaving] = useState(false) - - const handleToggle = useCallback(async () => { - const newValue = !enabled - setSaving(true) - try { - // Update localStorage immediately for instant UI response - localStorage.setItem('document_ui_enabled', JSON.stringify(newValue)) - onToggle(newValue) - - // Persist to backend - await upsertUserSetting('document_ui_enabled', newValue) - } catch (err) { - console.error('Failed to save document UI setting:', err) - // Revert on failure - localStorage.setItem('document_ui_enabled', JSON.stringify(!newValue)) - onToggle(!newValue) - } finally { - setSaving(false) - } - }, [enabled, onToggle]) - - if (!isOpen) return null - - return ( - <div className="modal-overlay" onClick={onClose}> - <div className="config-modal" onClick={(e) => e.stopPropagation()}> - <div className="modal-header"> - <h2>Document UI Settings</h2> - <button className="close-btn" onClick={onClose}>{'\u00D7'}</button> - </div> - - <div className="modal-content"> - <div className="config-option"> - <label className="config-label" style={{ cursor: 'pointer' }}> - <input - type="checkbox" - checked={enabled} - onChange={handleToggle} - disabled={saving} - className="config-checkbox" - /> - <span className="config-text"> - Enable Document UI (Experimental) - </span> - </label> - <div className="config-description"> - Replace the directive management interface with an interactive - document editor. This is a proof of concept. - </div> - </div> - </div> - - <div className="modal-footer"> - <button className="modal-btn" onClick={onClose}>Close</button> - </div> - </div> - </div> - ) -} diff --git a/frontend/src/components/document/EditorTheme.ts b/frontend/src/components/document/EditorTheme.ts deleted file mode 100644 index 5b336ad..0000000 --- a/frontend/src/components/document/EditorTheme.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { EditorThemeClasses } from 'lexical'; - -const editorTheme: EditorThemeClasses = { - root: 'doc-editor-root', - paragraph: 'doc-editor-paragraph', - heading: { - h1: 'doc-editor-h1', - h2: 'doc-editor-h2', - h3: 'doc-editor-h3', - }, - text: { - bold: 'doc-editor-text-bold', - italic: 'doc-editor-text-italic', - underline: 'doc-editor-text-underline', - strikethrough: 'doc-editor-text-strikethrough', - code: 'doc-editor-text-code', - }, - list: { - ul: 'doc-editor-list-ul', - ol: 'doc-editor-list-ol', - listitem: 'doc-editor-listitem', - nested: { - listitem: 'doc-editor-nested-listitem', - }, - }, - link: 'doc-editor-link', - placeholder: 'doc-editor-placeholder', -}; - -export default editorTheme; diff --git a/frontend/src/components/document/Toast.css b/frontend/src/components/document/Toast.css deleted file mode 100644 index e97304c..0000000 --- a/frontend/src/components/document/Toast.css +++ /dev/null @@ -1,100 +0,0 @@ -/* ============================================ - Toast Notifications - ============================================ */ - -.toast-container { - position: fixed; - bottom: 1.5rem; - right: 1.5rem; - z-index: 9999; - display: flex; - flex-direction: column-reverse; - gap: 0.5rem; - pointer-events: none; -} - -.toast-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.65rem 1rem; - border-radius: 8px; - font-size: 0.875rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08); - pointer-events: auto; - max-width: 360px; - backdrop-filter: blur(6px); -} - -/* Types */ -.toast-success { - background: #ecfdf5; - color: #065f46; - border: 1px solid #a7f3d0; -} - -.toast-error { - background: #fef2f2; - color: #991b1b; - border: 1px solid #fecaca; -} - -.toast-info { - background: #eff6ff; - color: #1e40af; - border: 1px solid #bfdbfe; -} - -/* Icon */ -.toast-icon { - flex-shrink: 0; - font-size: 1rem; - line-height: 1; -} - -.toast-message { - line-height: 1.4; -} - -/* Animations */ -.toast-enter { - animation: toastSlideIn 0.25s ease-out forwards; -} - -.toast-exit { - animation: toastSlideOut 0.3s ease-in forwards; -} - -@keyframes toastSlideIn { - from { - opacity: 0; - transform: translateY(8px) scale(0.96); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes toastSlideOut { - from { - opacity: 1; - transform: translateY(0) scale(1); - } - to { - opacity: 0; - transform: translateY(8px) scale(0.96); - } -} - -@media (max-width: 640px) { - .toast-container { - right: 0.75rem; - bottom: 0.75rem; - } - .toast-item { - max-width: calc(100vw - 1.5rem); - font-size: 0.8rem; - } -} diff --git a/frontend/src/components/document/Toast.tsx b/frontend/src/components/document/Toast.tsx deleted file mode 100644 index 653db8f..0000000 --- a/frontend/src/components/document/Toast.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useRef, - useState, - type ReactNode, -} from 'react'; -import './Toast.css'; - -// -- Types ------------------------------------------------------------------- - -export type ToastType = 'success' | 'error' | 'info'; - -interface ToastItem { - id: number; - message: string; - type: ToastType; -} - -interface ToastContextValue { - addToast: (message: string, type?: ToastType) => void; -} - -// -- Context ----------------------------------------------------------------- - -const ToastContext = createContext<ToastContextValue | null>(null); - -export function useToast(): ToastContextValue { - const ctx = useContext(ToastContext); - if (!ctx) throw new Error('useToast must be used within a ToastProvider'); - return ctx; -} - -// -- Provider ---------------------------------------------------------------- - -const DISMISS_MS = 3000; - -export function ToastProvider({ children }: { children: ReactNode }) { - const [toasts, setToasts] = useState<ToastItem[]>([]); - const nextId = useRef(0); - - const addToast = useCallback((message: string, type: ToastType = 'info') => { - const id = nextId.current++; - setToasts((prev) => [...prev, { id, message, type }]); - }, []); - - const removeToast = useCallback((id: number) => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - }, []); - - return ( - <ToastContext.Provider value={{ addToast }}> - {children} - <div className="toast-container"> - {toasts.map((t) => ( - <ToastItem key={t.id} toast={t} onDismiss={removeToast} /> - ))} - </div> - </ToastContext.Provider> - ); -} - -// -- Single toast ------------------------------------------------------------ - -function ToastItem({ - toast, - onDismiss, -}: { - toast: ToastItem; - onDismiss: (id: number) => void; -}) { - const [exiting, setExiting] = useState(false); - - useEffect(() => { - const timer = setTimeout(() => setExiting(true), DISMISS_MS - 300); - const remove = setTimeout(() => onDismiss(toast.id), DISMISS_MS); - return () => { - clearTimeout(timer); - clearTimeout(remove); - }; - }, [toast.id, onDismiss]); - - const icon = - toast.type === 'success' ? '\u2713' : toast.type === 'error' ? '\u2717' : '\u2139'; - - return ( - <div - className={`toast-item toast-${toast.type} ${exiting ? 'toast-exit' : 'toast-enter'}`} - role="status" - > - <span className="toast-icon">{icon}</span> - <span className="toast-message">{toast.message}</span> - </div> - ); -} diff --git a/frontend/src/components/document/index.ts b/frontend/src/components/document/index.ts index 3217a1b..906c1dc 100644 --- a/frontend/src/components/document/index.ts +++ b/frontend/src/components/document/index.ts @@ -11,3 +11,4 @@ export { ContractBlockNode, $createContractBlockNode, $isContractBlockNode } fro export { StepsDiagramComponent } from './nodes/StepsDiagramComponent' export { ContractBlockComponent } from './nodes/ContractBlockComponent' export { StepLogFeed } from './nodes/StepLogFeed' +export { ContractLogFeed } from './nodes/ContractLogFeed' diff --git a/frontend/src/components/document/nodes/ContractBlock.css b/frontend/src/components/document/nodes/ContractBlock.css deleted file mode 100644 index 80edb74..0000000 --- a/frontend/src/components/document/nodes/ContractBlock.css +++ /dev/null @@ -1,123 +0,0 @@ -/* ============================================ - Contract Block - Inline contract reference - ============================================ */ - -.contract-block-wrapper { - margin: 1rem 0; - user-select: none; -} - -.contract-block { - background: #fafbff; - border: 1px solid #e2e5ef; - border-radius: 8px; - padding: 0.65rem 0.85rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - font-size: 13px; - color: #374151; - transition: box-shadow 0.2s ease, border-color 0.2s ease; - animation: contractBlockAppear 0.25s ease-out both; -} - -@keyframes contractBlockAppear { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.contract-block:hover { - border-color: #c7cce0; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); -} - -.contract-block--error { - border-color: #fecaca; - background: #fef2f2; -} - -/* Header */ -.contract-block-header { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.contract-block-icon { - font-size: 1rem; - flex-shrink: 0; -} - -.contract-block-name { - font-weight: 600; - font-size: 0.88rem; - color: #1f2937; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.contract-block-phase-badge { - font-size: 0.68rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; - padding: 0.1rem 0.4rem; - border-radius: 8px; - white-space: nowrap; - flex-shrink: 0; -} - -.contract-block-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -/* Meta */ -.contract-block-meta { - margin-top: 0.3rem; - padding-left: 1.5rem; -} - -.contract-block-type { - font-size: 0.75rem; - color: #9ca3af; - font-style: italic; -} - -.contract-block-error-msg { - margin-top: 0.25rem; - font-size: 0.78rem; - color: #dc2626; - padding-left: 1.5rem; -} - -/* Loading state */ -.contract-block-loading { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0; - color: #9ca3af; - font-size: 0.82rem; -} - -.contract-block-spinner { - width: 14px; - height: 14px; - border: 2px solid #e5e7eb; - border-top-color: #6b7280; - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} diff --git a/frontend/src/components/document/nodes/ContractBlockComponent.tsx b/frontend/src/components/document/nodes/ContractBlockComponent.tsx deleted file mode 100644 index 0d9a25a..0000000 --- a/frontend/src/components/document/nodes/ContractBlockComponent.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import './ContractBlock.css'; - -interface ContractBlockComponentProps { - contractId: string; - contractName: string; -} - -interface ContractInfo { - id: string; - name: string; - status: string; - phase: string; - contract_type: string; -} - -const PHASE_COLORS: Record<string, string> = { - planning: '#3b82f6', - execution: '#f59e0b', - review: '#8b5cf6', - completed: '#10b981', - failed: '#ef4444', -}; - -const STATUS_COLORS: Record<string, string> = { - active: '#10b981', - running: '#10b981', - idle: '#f59e0b', - paused: '#f59e0b', - completed: '#10b981', - failed: '#ef4444', - archived: '#6b7280', -}; - -export function ContractBlockComponent({ contractId, contractName }: ContractBlockComponentProps) { - const [contract, setContract] = useState<ContractInfo | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - - useEffect(() => { - let cancelled = false; - - async function fetchContract() { - try { - const response = await fetch(`/api/v1/contracts/${contractId}`); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const data = await response.json(); - if (!cancelled) { - setContract(data.contract || data); - setError(null); - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load'); - } - } finally { - if (!cancelled) setLoading(false); - } - } - - fetchContract(); - return () => { cancelled = true; }; - }, [contractId]); - - if (loading) { - return ( - <div className="contract-block" contentEditable={false}> - <div className="contract-block-loading"> - <div className="contract-block-spinner" /> - <span>Loading contract...</span> - </div> - </div> - ); - } - - if (error) { - return ( - <div className="contract-block contract-block--error" contentEditable={false}> - <div className="contract-block-header"> - <span className="contract-block-icon">📦</span> - <span className="contract-block-name">{contractName}</span> - </div> - <div className="contract-block-error-msg">Unable to load: {error}</div> - </div> - ); - } - - const phase = contract?.phase?.toLowerCase() || 'unknown'; - const status = contract?.status?.toLowerCase() || 'unknown'; - const phaseColor = PHASE_COLORS[phase] || '#6b7280'; - const statusColor = STATUS_COLORS[status] || '#6b7280'; - - return ( - <div className="contract-block" contentEditable={false}> - <div className="contract-block-header"> - <span className="contract-block-icon">📦</span> - <span className="contract-block-name">{contract?.name || contractName}</span> - <span - className="contract-block-phase-badge" - style={{ backgroundColor: phaseColor + '20', color: phaseColor }} - > - {phase} - </span> - <span - className="contract-block-status-dot" - style={{ backgroundColor: statusColor }} - title={status} - /> - </div> - {contract?.contract_type && ( - <div className="contract-block-meta"> - <span className="contract-block-type">{contract.contract_type}</span> - </div> - )} - </div> - ); -} diff --git a/frontend/src/components/document/nodes/ContractBlockNode.tsx b/frontend/src/components/document/nodes/ContractBlockNode.tsx deleted file mode 100644 index 86e4c9d..0000000 --- a/frontend/src/components/document/nodes/ContractBlockNode.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { - DecoratorNode, - DOMExportOutput, - LexicalNode, - NodeKey, - SerializedLexicalNode, - Spread, -} from 'lexical'; -import React from 'react'; -import { ContractBlockComponent } from './ContractBlockComponent'; - -export type SerializedContractBlockNode = Spread< - { - contractId: string; - contractName: string; - }, - SerializedLexicalNode ->; - -export class ContractBlockNode extends DecoratorNode<JSX.Element> { - __contractId: string; - __contractName: string; - - static getType(): string { - return 'contract-block'; - } - - static clone(node: ContractBlockNode): ContractBlockNode { - return new ContractBlockNode(node.__contractId, node.__contractName, node.__key); - } - - constructor(contractId: string, contractName: string, key?: NodeKey) { - super(key); - this.__contractId = contractId; - this.__contractName = contractName; - } - - createDOM(): HTMLElement { - const div = document.createElement('div'); - div.className = 'contract-block-wrapper'; - return div; - } - - updateDOM(): boolean { - return false; - } - - decorate(): JSX.Element { - return ( - <ContractBlockComponent - contractId={this.__contractId} - contractName={this.__contractName} - /> - ); - } - - exportJSON(): SerializedContractBlockNode { - return { - ...super.exportJSON(), - type: 'contract-block', - contractId: this.__contractId, - contractName: this.__contractName, - version: 1, - }; - } - - static importJSON(serializedNode: SerializedContractBlockNode): ContractBlockNode { - return $createContractBlockNode( - serializedNode.contractId, - serializedNode.contractName - ); - } - - isInline(): boolean { - return false; - } - - canInsertTextBefore(): boolean { - return false; - } - - canInsertTextAfter(): boolean { - return false; - } - - exportDOM(): DOMExportOutput { - const element = document.createElement('div'); - element.className = 'contract-block-wrapper'; - element.setAttribute('data-contract-id', this.__contractId); - element.textContent = `[Contract: ${this.__contractName}]`; - return { element }; - } -} - -export function $createContractBlockNode( - contractId: string, - contractName: string -): ContractBlockNode { - return new ContractBlockNode(contractId, contractName); -} - -export function $isContractBlockNode( - node: LexicalNode | null | undefined, -): node is ContractBlockNode { - return node instanceof ContractBlockNode; -} diff --git a/frontend/src/components/document/nodes/ContractLogFeed.css b/frontend/src/components/document/nodes/ContractLogFeed.css deleted file mode 100644 index b5dd15d..0000000 --- a/frontend/src/components/document/nodes/ContractLogFeed.css +++ /dev/null @@ -1,346 +0,0 @@ -/* ============================================ - Contract Log Feed - ============================================ */ - -.contract-log-feed { - display: flex; - flex-direction: column; - background: #1a1d23; - border: 1px solid #2d3039; - border-radius: 8px; - overflow: hidden; - margin-top: 0.5rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - font-size: 13px; - max-height: 420px; - animation: logFeedSlideIn 0.25s ease-out; -} - -@keyframes logFeedSlideIn { - from { - opacity: 0; - transform: translateY(-6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* ---- Header ---- */ -.contract-log-feed-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.5rem 0.75rem; - background: #22252b; - border-bottom: 1px solid #2d3039; -} - -.contract-log-feed-title { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.contract-log-feed-name { - font-weight: 600; - font-size: 0.82rem; - color: #e5e7eb; -} - -.contract-log-feed-status { - font-size: 0.65rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 0.1rem 0.4rem; - border-radius: 8px; -} - -.contract-log-feed-status--running, -.contract-log-feed-status--starting { - background: rgba(245, 158, 11, 0.2); - color: #fbbf24; - animation: statusPulse 2s ease-in-out infinite; -} - -.contract-log-feed-status--completed { - background: rgba(16, 185, 129, 0.2); - color: #34d399; -} - -.contract-log-feed-status--failed { - background: rgba(239, 68, 68, 0.2); - color: #f87171; -} - -.contract-log-feed-status--pending, -.contract-log-feed-status--ready { - background: rgba(107, 114, 128, 0.2); - color: #9ca3af; -} - -.contract-log-feed-close { - background: none; - border: none; - color: #6b7280; - font-size: 1.1rem; - cursor: pointer; - padding: 0 0.25rem; - line-height: 1; - border-radius: 3px; - transition: color 0.15s, background 0.15s; -} - -.contract-log-feed-close:hover { - color: #e5e7eb; - background: rgba(255, 255, 255, 0.08); -} - -/* ---- Log Content ---- */ -.contract-log-feed-content { - flex: 1; - overflow-y: auto; - padding: 0.5rem 0.75rem; - min-height: 80px; - max-height: 240px; - scrollbar-width: thin; - scrollbar-color: #3a3f4b transparent; -} - -.contract-log-feed-content::-webkit-scrollbar { - width: 5px; -} - -.contract-log-feed-content::-webkit-scrollbar-thumb { - background: #3a3f4b; - border-radius: 3px; -} - -.contract-log-feed-empty { - color: #6b7280; - font-size: 0.82rem; - font-style: italic; - text-align: center; - padding: 1.5rem 0; -} - -/* ---- Log Entry ---- */ -.contract-log-entry { - display: flex; - gap: 0.5rem; - padding: 0.2rem 0; - line-height: 1.5; - animation: entryFadeIn 0.2s ease-out; -} - -@keyframes entryFadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -.contract-log-entry-time { - flex-shrink: 0; - font-size: 0.7rem; - color: #4b5563; - font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; - line-height: 1.65; -} - -.contract-log-entry-text { - color: #d1d5db; - white-space: pre-wrap; - word-break: break-word; - font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; - font-size: 0.78rem; -} - -.contract-log-entry--user .contract-log-entry-text { - color: #93c5fd; -} - -.contract-log-entry--user::before { - content: '>'; - color: #3b82f6; - font-weight: 700; - font-family: monospace; - flex-shrink: 0; - line-height: 1.5; -} - -.contract-log-entry--system .contract-log-entry-text { - color: #fbbf24; - font-style: italic; -} - -/* ---- Error ---- */ -.contract-log-feed-error { - padding: 0.4rem 0.75rem; - background: rgba(239, 68, 68, 0.12); - border-top: 1px solid rgba(239, 68, 68, 0.25); - color: #f87171; - font-size: 0.78rem; -} - -/* ---- Interaction Bar ---- */ -.contract-interaction-bar { - border-top: 1px solid #2d3039; - padding: 0.5rem 0.75rem; - background: #22252b; -} - -.contract-interaction-bar--disabled { - display: flex; - align-items: center; - justify-content: center; - padding: 0.6rem 0.75rem; -} - -.contract-interaction-disabled-text { - color: #6b7280; - font-size: 0.78rem; - font-style: italic; -} - -.contract-interaction-message-row { - display: flex; - align-items: flex-end; - gap: 0.4rem; - position: relative; -} - -.contract-message-input { - flex: 1; - background: #1a1d23; - border: 1px solid #3a3f4b; - border-radius: 6px; - color: #e5e7eb; - padding: 0.4rem 0.6rem; - font-size: 0.82rem; - font-family: inherit; - resize: none; - min-height: 32px; - max-height: 80px; - line-height: 1.4; - outline: none; - transition: border-color 0.15s; -} - -.contract-message-input::placeholder { - color: #4b5563; - font-size: 0.78rem; -} - -.contract-message-input:focus { - border-color: #3b82f6; -} - -.contract-message-input:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.contract-send-btn { - flex-shrink: 0; - background: #3b82f6; - color: #fff; - border: none; - border-radius: 6px; - padding: 0.4rem 0.85rem; - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - transition: background 0.15s, opacity 0.15s; - min-height: 32px; -} - -.contract-send-btn:hover:not(:disabled) { - background: #2563eb; -} - -.contract-send-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.contract-sent-indicator { - position: absolute; - right: 0; - top: -1.4rem; - font-size: 0.7rem; - color: #34d399; - font-weight: 500; - animation: sentFlash 1.5s ease-out forwards; -} - -@keyframes sentFlash { - 0% { - opacity: 1; - transform: translateY(0); - } - 70% { - opacity: 1; - } - 100% { - opacity: 0; - transform: translateY(-4px); - } -} - -/* ---- Actions Row ---- */ -.contract-interaction-actions-row { - display: flex; - align-items: center; - gap: 0.5rem; - margin-top: 0.4rem; -} - -.contract-interrupt-btn { - background: transparent; - color: #ef4444; - border: 1px solid rgba(239, 68, 68, 0.3); - border-radius: 6px; - padding: 0.3rem 0.7rem; - font-size: 0.75rem; - font-weight: 500; - cursor: pointer; - transition: background 0.15s, border-color 0.15s, color 0.15s; -} - -.contract-interrupt-btn:hover:not(:disabled) { - background: rgba(239, 68, 68, 0.1); - border-color: rgba(239, 68, 68, 0.5); -} - -.contract-interrupt-btn--confirm { - background: rgba(239, 68, 68, 0.15); - border-color: #ef4444; - color: #f87171; - animation: confirmPulse 0.8s ease-in-out infinite; -} - -@keyframes confirmPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } -} - -.contract-interrupt-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -/* ---- Responsive ---- */ -@media (max-width: 640px) { - .contract-log-feed { - max-height: 360px; - } - - .contract-log-feed-content { - max-height: 180px; - } - - .contract-message-input::placeholder { - font-size: 0.72rem; - } -} diff --git a/frontend/src/components/document/nodes/ContractLogFeed.tsx b/frontend/src/components/document/nodes/ContractLogFeed.tsx deleted file mode 100644 index 79af91c..0000000 --- a/frontend/src/components/document/nodes/ContractLogFeed.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { - sendContractMessage, - interruptContract, - getContractOutput, -} from '../../../services/directiveApi'; -import './ContractLogFeed.css'; - -interface ContractLogFeedProps { - taskId: string; - contractName: string; - status: string; - onClose?: () => void; -} - -interface LogEntry { - id: string; - text: string; - type: 'output' | 'user' | 'system'; - timestamp: Date; -} - -const INTERACTIVE_STATUSES = ['running', 'starting']; - -export function ContractLogFeed({ taskId, contractName, status, onClose }: ContractLogFeedProps) { - const [logEntries, setLogEntries] = useState<LogEntry[]>([]); - const [message, setMessage] = useState(''); - const [sending, setSending] = useState(false); - const [sentIndicator, setSentIndicator] = useState(false); - const [interruptConfirm, setInterruptConfirm] = useState(false); - const [interrupting, setInterrupting] = useState(false); - const [error, setError] = useState<string | null>(null); - const logEndRef = useRef<HTMLDivElement>(null); - const textareaRef = useRef<HTMLTextAreaElement>(null); - const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); - const lastOutputRef = useRef<string>(''); - const entryIdRef = useRef(0); - - const isInteractive = INTERACTIVE_STATUSES.includes(status.toLowerCase()); - - const addLogEntry = useCallback((text: string, type: LogEntry['type']) => { - entryIdRef.current += 1; - setLogEntries(prev => [ - ...prev, - { id: `entry-${entryIdRef.current}`, text, type, timestamp: new Date() }, - ]); - }, []); - - // Poll for contract output - const fetchOutput = useCallback(async () => { - if (!taskId) return; - try { - const data = await getContractOutput(taskId); - const output = data.output || ''; - if (output && output !== lastOutputRef.current) { - // Find new content - const newContent = output.startsWith(lastOutputRef.current) - ? output.slice(lastOutputRef.current.length).trim() - : output.trim(); - lastOutputRef.current = output; - if (newContent) { - addLogEntry(newContent, 'output'); - } - } - } catch { - // Silently ignore fetch errors for output polling - } - }, [taskId, addLogEntry]); - - useEffect(() => { - fetchOutput(); - pollRef.current = setInterval(fetchOutput, 3000); - return () => { - if (pollRef.current) clearInterval(pollRef.current); - }; - }, [fetchOutput]); - - // Auto-scroll to bottom on new entries - useEffect(() => { - logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [logEntries]); - - // Reset interrupt confirm after timeout - useEffect(() => { - if (!interruptConfirm) return; - const timer = setTimeout(() => setInterruptConfirm(false), 3000); - return () => clearTimeout(timer); - }, [interruptConfirm]); - - const handleSendMessage = async () => { - const trimmed = message.trim(); - if (!trimmed || sending || !isInteractive) return; - - setSending(true); - setError(null); - try { - await sendContractMessage(taskId, trimmed); - addLogEntry(trimmed, 'user'); - setMessage(''); - setSentIndicator(true); - setTimeout(() => setSentIndicator(false), 1500); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to send message'); - } finally { - setSending(false); - } - }; - - const handleInterrupt = async () => { - if (!interruptConfirm) { - setInterruptConfirm(true); - return; - } - - setInterrupting(true); - setError(null); - setInterruptConfirm(false); - try { - await interruptContract(taskId); - addLogEntry('Contract interrupted by user', 'system'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to interrupt contract'); - } finally { - setInterrupting(false); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }; - - const statusLower = status.toLowerCase(); - - return ( - <div className="contract-log-feed"> - <div className="contract-log-feed-header"> - <div className="contract-log-feed-title"> - <span className="contract-log-feed-name">{contractName}</span> - <span className={`contract-log-feed-status contract-log-feed-status--${statusLower}`}> - {status} - </span> - </div> - {onClose && ( - <button className="contract-log-feed-close" onClick={onClose} title="Close"> - × - </button> - )} - </div> - - <div className="contract-log-feed-content"> - {logEntries.length === 0 && ( - <div className="contract-log-feed-empty"> - {isInteractive ? 'Waiting for output...' : 'No output available.'} - </div> - )} - {logEntries.map(entry => ( - <div key={entry.id} className={`contract-log-entry contract-log-entry--${entry.type}`}> - <span className="contract-log-entry-time"> - {entry.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} - </span> - <span className="contract-log-entry-text">{entry.text}</span> - </div> - ))} - <div ref={logEndRef} /> - </div> - - {error && ( - <div className="contract-log-feed-error">{error}</div> - )} - - {isInteractive && ( - <div className="contract-interaction-bar"> - <div className="contract-interaction-message-row"> - <textarea - ref={textareaRef} - className="contract-message-input" - value={message} - onChange={e => setMessage(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Send a message to the contract... (Enter to send, Shift+Enter for newline)" - rows={1} - disabled={sending} - /> - <button - className="contract-send-btn" - onClick={handleSendMessage} - disabled={sending || !message.trim()} - title="Send message" - > - {sending ? 'Sending...' : 'Send'} - </button> - {sentIndicator && ( - <span className="contract-sent-indicator">Sent</span> - )} - </div> - <div className="contract-interaction-actions-row"> - <button - className={`contract-interrupt-btn ${interruptConfirm ? 'contract-interrupt-btn--confirm' : ''}`} - onClick={handleInterrupt} - disabled={interrupting} - title={interruptConfirm ? 'Click again to confirm interrupt' : 'Interrupt contract'} - > - {interrupting - ? 'Interrupting...' - : interruptConfirm - ? 'Click again to confirm interrupt' - : 'Interrupt'} - </button> - </div> - </div> - )} - - {!isInteractive && statusLower !== 'pending' && statusLower !== 'ready' && ( - <div className="contract-interaction-bar contract-interaction-bar--disabled"> - <span className="contract-interaction-disabled-text"> - Contract is {status.toLowerCase()} - interaction unavailable - </span> - </div> - )} - </div> - ); -} diff --git a/frontend/src/components/document/nodes/StepLogFeed.tsx b/frontend/src/components/document/nodes/StepLogFeed.tsx index 0357de8..2f2f553 100644 --- a/frontend/src/components/document/nodes/StepLogFeed.tsx +++ b/frontend/src/components/document/nodes/StepLogFeed.tsx @@ -211,7 +211,7 @@ export function StepLogFeed({ taskId, stepName, stepStatus, onCollapse }: StepLo <button className="step-log-feed-interrupt-btn" onClick={handleInterrupt} - title="Interrupt this task" + title="Interrupt this contract" > ⏹ Interrupt </button> @@ -256,7 +256,7 @@ export function StepLogFeed({ taskId, stepName, stepStatus, onCollapse }: StepLo ref={inputRef} type="text" className="step-log-feed-input-field" - placeholder="Send a message to this task..." + placeholder="Send a message to this contract..." value={message} onChange={(e) => setMessage(e.target.value)} onKeyDown={handleKeyDown} diff --git a/frontend/src/components/document/nodes/StepsDiagram.css b/frontend/src/components/document/nodes/StepsDiagram.css deleted file mode 100644 index 9856c6d..0000000 --- a/frontend/src/components/document/nodes/StepsDiagram.css +++ /dev/null @@ -1,683 +0,0 @@ -/* ============================================ - Steps Diagram Block - ============================================ */ - -.steps-diagram-block { - margin: 1.5rem 0; - user-select: none; -} - -.steps-diagram { - background: #f8f9fc; - border: 1px solid #e2e5ef; - border-radius: 10px; - padding: 1rem 1.25rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - font-size: 14px; - color: #374151; - transition: max-height 0.3s ease; -} - -.steps-diagram--has-expanded { - /* Allow more vertical space when a step is expanded */ - max-height: none; -} - -/* ---- Header ---- */ -.steps-diagram-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; - padding-bottom: 0.6rem; - border-bottom: 1px solid #e5e7eb; -} - -.steps-diagram-header-left { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.steps-diagram-header-title { - font-weight: 600; - font-size: 0.9rem; - color: #1f2937; - letter-spacing: 0.01em; -} - -.steps-diagram-header-count { - font-size: 0.78rem; - color: #6b7280; - background: #e5e7eb; - border-radius: 10px; - padding: 0.15rem 0.55rem; -} - -.steps-diagram-header-author { - font-size: 0.72rem; - color: #9ca3af; - font-style: italic; -} - -/* ---- DAG Layout ---- */ -.steps-diagram-dag { - display: flex; - flex-direction: column; - align-items: center; - gap: 0; -} - -.steps-diagram-group { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; - justify-content: center; - width: 100%; -} - -/* ---- Arrow between groups ---- */ -.steps-diagram-arrow { - display: flex; - flex-direction: column; - align-items: center; - padding: 0.15rem 0; -} - -.steps-diagram-arrow-line { - width: 2px; - height: 16px; - background: #cbd5e1; -} - -.steps-diagram-arrow-head { - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 6px solid #cbd5e1; -} - -/* ---- Step Card Wrapper ---- */ -.steps-diagram-card-wrapper { - flex: 1 1 180px; - max-width: 280px; -} - -/* ---- Step Card ---- */ -.steps-diagram-card { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 8px; - padding: 0.65rem 0.8rem; - transition: box-shadow 0.2s ease, border-color 0.2s ease, max-width 0.3s ease; - animation: stepCardAppear 0.35s ease-out both; -} - -.steps-diagram-card--expanded { - flex: 1 1 100%; - max-width: 100%; - border-color: #93c5fd; - box-shadow: 0 2px 12px rgba(59, 130, 246, 0.1); -} - -@keyframes stepCardAppear { - from { - opacity: 0; - transform: translateY(8px) scale(0.97); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -.steps-diagram-card:hover { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); -} - -.steps-diagram-card--expandable { - cursor: pointer; -} - -.steps-diagram-card--expandable:hover { - border-color: #c7cbd5; -} - -.steps-diagram-card--expanded { - border-radius: 8px 8px 0 0; - border-bottom-color: transparent; -} - -.steps-diagram-card-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - margin-bottom: 0.3rem; -} - -.steps-diagram-card-header--clickable { - cursor: pointer; - user-select: none; -} - -.steps-diagram-card-header--clickable:hover .steps-diagram-card-name { - color: #2563eb; -} - -.steps-diagram-card-header-right { - display: flex; - align-items: center; - gap: 0.4rem; - flex-shrink: 0; -} - -.steps-diagram-card-name { - font-weight: 600; - font-size: 0.85rem; - color: #1f2937; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; - transition: color 0.15s; -} - -.steps-diagram-card-desc { - font-size: 0.78rem; - color: #6b7280; - margin: 0.2rem 0 0.4rem 0; - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.steps-diagram-card-footer { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 0.72rem; - color: #9ca3af; -} - -.steps-diagram-card-index { - font-weight: 500; -} - -.steps-diagram-card-contract-ref { - font-family: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, monospace; - font-size: 0.68rem; - color: #6b7280; - background: #f3f4f6; - padding: 0.08rem 0.35rem; - border-radius: 4px; - cursor: default; -} - -.steps-diagram-card-progress { - color: #d97706; - font-style: italic; -} - -.steps-diagram-card-time { - color: #6b7280; -} - -/* ---- Expand Icon ---- */ -.steps-diagram-expand-icon { - font-size: 0.6rem; - color: #9ca3af; - transition: transform 0.2s ease, color 0.15s; - display: inline-block; -} - -.steps-diagram-expand-icon.expanded { - transform: rotate(90deg); - color: #3b82f6; -} - -.steps-diagram-card-header--clickable:hover .steps-diagram-expand-icon { - color: #3b82f6; -} - -/* ---- Status Badge ---- */ -.steps-diagram-status-badge { - font-size: 0.68rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; - padding: 0.12rem 0.45rem; - border-radius: 9px; - white-space: nowrap; - flex-shrink: 0; -} - -.steps-diagram-status-badge--pending { - background: #f3f4f6; - color: #6b7280; -} - -.steps-diagram-status-badge--ready { - background: #dbeafe; - color: #2563eb; -} - -.steps-diagram-status-badge--running { - background: #fef3c7; - color: #d97706; - animation: statusPulse 2s ease-in-out infinite; -} - -.steps-diagram-status-badge--completed { - background: #d1fae5; - color: #059669; -} - -.steps-diagram-status-badge--failed { - background: #fee2e2; - color: #dc2626; -} - -.steps-diagram-status-badge--skipped { - background: repeating-linear-gradient( - 45deg, - #f3f4f6, - #f3f4f6 4px, - #e5e7eb 4px, - #e5e7eb 8px - ); - color: #9ca3af; -} - -/* ---- Status-specific Card Borders ---- */ -.steps-diagram-card--pending { - border-left: 3px solid #d1d5db; -} - -.steps-diagram-card--ready { - border-left: 3px solid #3b82f6; -} - -.steps-diagram-card--running { - border-left: 3px solid #f59e0b; - animation: cardGlow 2s ease-in-out infinite; -} - -.steps-diagram-card--completed { - border-left: 3px solid #10b981; -} - -.steps-diagram-card--failed { - border-left: 3px solid #ef4444; -} - -.steps-diagram-card--skipped { - border-left: 3px solid #d1d5db; - opacity: 0.7; -} - -/* ---- Expanded Card ---- */ -.steps-diagram-card--expanded { - flex: 1 1 100%; - max-width: 100%; -} - -.steps-diagram-card-expand { - flex-shrink: 0; - font-size: 0.7rem; - color: #9ca3af; - margin-left: 0.25rem; -} - -/* ---- Animations ---- */ -@keyframes statusPulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.65; - } -} - -@keyframes cardGlow { - 0%, 100% { - box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); - } - 50% { - box-shadow: 0 0 8px 2px rgba(245, 158, 11, 0.15); - } -} - -/* ---- Loading State ---- */ -.steps-diagram-loading { - display: flex; - align-items: center; - gap: 0.6rem; - padding: 1rem 0; - color: #9ca3af; - font-size: 0.85rem; -} - -.steps-diagram-spinner { - width: 16px; - height: 16px; - border: 2px solid #e5e7eb; - border-top-color: #6b7280; - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* ---- Planning State ---- */ -.steps-diagram-planning { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 1.25rem 0; - color: #6b7280; - font-size: 0.85rem; - font-style: italic; -} - -.steps-diagram-planning-dots { - display: flex; - gap: 4px; -} - -.steps-diagram-planning-dots span { - width: 6px; - height: 6px; - background: #9ca3af; - border-radius: 50%; - animation: dotBounce 1.4s ease-in-out infinite; -} - -.steps-diagram-planning-dots span:nth-child(2) { - animation-delay: 0.2s; -} - -.steps-diagram-planning-dots span:nth-child(3) { - animation-delay: 0.4s; -} - -@keyframes dotBounce { - 0%, 80%, 100% { - transform: scale(0.6); - opacity: 0.4; - } - 40% { - transform: scale(1); - opacity: 1; - } -} - -/* ---- Empty / Error ---- */ -.steps-diagram-empty { - padding: 1rem 0; - color: #9ca3af; - font-size: 0.85rem; - text-align: center; -} - -.steps-diagram-error { - padding: 0.75rem; - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 6px; - color: #dc2626; - font-size: 0.82rem; -} - -/* ============================================ - Step Log Feed (Expandable) - ============================================ */ - -.step-log-feed { - margin-top: 0.5rem; - border-top: 1px solid #e5e7eb; - padding-top: 0.5rem; - animation: logFeedSlideIn 0.25s ease-out both; -} - -@keyframes logFeedSlideIn { - from { - opacity: 0; - max-height: 0; - } - to { - opacity: 1; - max-height: 500px; - } -} - -.step-log-feed-header { - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: 0.4rem; - margin-bottom: 0.4rem; - border-bottom: 1px solid #f3f4f6; -} - -.step-log-feed-header-left { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.step-log-feed-header-right { - display: flex; - align-items: center; - gap: 0.35rem; -} - -.step-log-feed-title { - font-size: 0.75rem; - font-weight: 600; - color: #4b5563; -} - -.step-log-feed-status { - font-size: 0.65rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 0.1rem 0.4rem; - border-radius: 8px; -} - -.step-log-feed-status.connected { - background: #d1fae5; - color: #059669; -} - -.step-log-feed-status.disconnected { - background: #f3f4f6; - color: #9ca3af; -} - -.step-log-feed-interrupt-btn { - background: #fef2f2; - border: 1px solid #fecaca; - color: #dc2626; - font-size: 0.72rem; - font-weight: 600; - padding: 0.2rem 0.5rem; - border-radius: 5px; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} - -.step-log-feed-interrupt-btn:hover { - background: #fee2e2; - border-color: #f87171; -} - -.step-log-feed-collapse-btn { - background: none; - border: 1px solid #e5e7eb; - color: #6b7280; - font-size: 0.75rem; - width: 22px; - height: 22px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s; - padding: 0; -} - -.step-log-feed-collapse-btn:hover { - background: #f3f4f6; - color: #1f2937; - border-color: #d1d5db; -} - -/* Log content area */ -.step-log-feed-content { - max-height: 280px; - overflow-y: auto; - background: #1a1b26; - border-radius: 6px; - padding: 0.5rem; - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace; - font-size: 0.75rem; - line-height: 1.5; -} - -.step-log-feed-empty { - color: #6b7280; - font-style: italic; - padding: 1rem; - text-align: center; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -} - -.step-log-feed-error { - color: #f87171; - padding: 0.25rem; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - font-size: 0.78rem; -} - -/* Log entries */ -.step-log-entry { - display: flex; - gap: 0.5rem; - padding: 0.1rem 0.25rem; - border-radius: 2px; -} - -.step-log-entry:hover { - background: rgba(255, 255, 255, 0.03); -} - -.step-log-entry-time { - color: #565f89; - white-space: nowrap; - flex-shrink: 0; - min-width: 5.5em; -} - -.step-log-entry-content { - color: #a9b1d6; - word-break: break-word; - white-space: pre-wrap; -} - -.step-log-entry--stderr .step-log-entry-content { - color: #f7768e; -} - -.step-log-entry--system .step-log-entry-content { - color: #7aa2f7; - font-style: italic; -} - -.step-log-entry--user .step-log-entry-content { - color: #9ece6a; -} - -.step-log-entry--user::before { - content: '> '; - color: #9ece6a; -} - -/* Message input */ -.step-log-feed-input { - display: flex; - gap: 0.35rem; - margin-top: 0.4rem; -} - -.step-log-feed-input-field { - flex: 1; - background: #f9fafb; - border: 1px solid #e5e7eb; - border-radius: 5px; - padding: 0.35rem 0.6rem; - font-size: 0.78rem; - color: #1f2937; - outline: none; - transition: border-color 0.15s; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -} - -.step-log-feed-input-field:focus { - border-color: #93c5fd; - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); -} - -.step-log-feed-input-field:disabled { - opacity: 0.5; -} - -.step-log-feed-send-btn { - background: #3b82f6; - border: none; - color: #ffffff; - font-size: 0.82rem; - padding: 0.35rem 0.65rem; - border-radius: 5px; - cursor: pointer; - transition: background 0.15s; - white-space: nowrap; -} - -.step-log-feed-send-btn:hover:not(:disabled) { - background: #2563eb; -} - -.step-log-feed-send-btn:disabled { - background: #93c5fd; - cursor: not-allowed; -} - -/* ---- Responsive ---- */ -@media (max-width: 640px) { - .steps-diagram { - padding: 0.75rem; - } - - .steps-diagram-card-wrapper { - flex: 1 1 100%; - max-width: 100%; - } - - .steps-diagram-card-wrapper { - flex: 1 1 100%; - max-width: 100%; - } - - .step-log-feed-content { - max-height: 200px; - } -} diff --git a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx index 53f860e..ac1cb83 100644 --- a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx +++ b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx @@ -70,32 +70,7 @@ function StepCard({ step, isExpanded, onToggleExpand, onCollapse }: StepCardProp <span className="steps-diagram-card-time"> Completed {formatTime(step.completedAt)} </span> - {hasTask && ( - <button - className={`steps-diagram-card-expand-btn ${expanded ? 'steps-diagram-card-expand-btn--open' : ''}`} - onClick={() => setExpanded((v) => !v)} - title={expanded ? 'Collapse log feed' : 'Expand log feed'} - > - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> - <polyline points="6 9 12 15 18 9" /> - </svg> - </button> - )} - </div> - {step.description && ( - <p className="steps-diagram-card-desc">{step.description}</p> )} - <div className="steps-diagram-card-footer"> - <span className="steps-diagram-card-index">#{step.orderIndex}</span> - {status === 'running' && ( - <span className="steps-diagram-card-progress">In progress...</span> - )} - {status === 'completed' && step.completedAt && ( - <span className="steps-diagram-card-time"> - Completed {formatTime(step.completedAt)} - </span> - )} - </div> </div> {/* Expandable log feed */} @@ -120,18 +95,6 @@ export function StepsDiagramComponent({ directiveId, onExpandContract }: StepsDi const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const prevStepCountRef = useRef(0); - const toggleStep = useCallback((stepId: string) => { - setExpandedSteps((prev) => { - const next = new Set(prev); - if (next.has(stepId)) { - next.delete(stepId); - } else { - next.add(stepId); - } - return next; - }); - }, []); - const fetchSteps = useCallback(async () => { try { const data: DirectiveWithSteps = await getDirective(directiveId); diff --git a/frontend/src/components/document/nodes/StepsDiagramNode.tsx b/frontend/src/components/document/nodes/StepsDiagramNode.tsx deleted file mode 100644 index 8b37f52..0000000 --- a/frontend/src/components/document/nodes/StepsDiagramNode.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { - DecoratorNode, - DOMExportOutput, - LexicalNode, - NodeKey, - SerializedLexicalNode, - Spread, -} from 'lexical'; -import React from 'react'; -import { StepsDiagramComponent } from './StepsDiagramComponent'; - -export type SerializedStepsDiagramNode = Spread< - { - directiveId: string; - }, - SerializedLexicalNode ->; - -export class StepsDiagramNode extends DecoratorNode<JSX.Element> { - __directiveId: string; - - static getType(): string { - return 'steps-diagram'; - } - - static clone(node: StepsDiagramNode): StepsDiagramNode { - return new StepsDiagramNode(node.__directiveId, node.__key); - } - - constructor(directiveId: string, key?: NodeKey) { - super(key); - this.__directiveId = directiveId; - } - - createDOM(): HTMLElement { - const div = document.createElement('div'); - div.className = 'steps-diagram-block'; - return div; - } - - updateDOM(): boolean { - return false; - } - - decorate(): JSX.Element { - return <StepsDiagramComponent directiveId={this.__directiveId} />; - } - - exportJSON(): SerializedStepsDiagramNode { - return { - ...super.exportJSON(), - type: 'steps-diagram', - directiveId: this.__directiveId, - version: 1, - }; - } - - static importJSON(serializedNode: SerializedStepsDiagramNode): StepsDiagramNode { - return $createStepsDiagramNode(serializedNode.directiveId); - } - - isInline(): boolean { - return false; - } - - canInsertTextBefore(): boolean { - return false; - } - - canInsertTextAfter(): boolean { - return false; - } - - exportDOM(): DOMExportOutput { - const element = document.createElement('div'); - element.className = 'steps-diagram-block'; - element.setAttribute('data-directive-id', this.__directiveId); - element.textContent = '[Steps Diagram]'; - return { element }; - } -} - -export function $createStepsDiagramNode(directiveId: string): StepsDiagramNode { - return new StepsDiagramNode(directiveId); -} - -export function $isStepsDiagramNode( - node: LexicalNode | null | undefined, -): node is StepsDiagramNode { - return node instanceof StepsDiagramNode; -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9527d8f..7688159 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,24 +2,23 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { createBrowserRouter, RouterProvider } from 'react-router-dom' import App from './App' +import { ContractList } from './components/ContractList' +import { ContractDetail } from './components/ContractDetail' +import { FileDetail } from './components/FileDetail' import { DaemonList } from './components/DaemonList' import { DaemonDetail } from './components/DaemonDetail' -import { DocumentLayout } from './components/document' import './styles/pc98.css' import './styles/mobile.css' // Route configuration: -// Primary (Document UI - when feature flag enabled): -// - /directives - Document layout with file tree sidebar and Lexical editor -// - /directives/:id - Open a specific directive in the document editor -// -// Legacy (Contract UI - kept for backward compatibility): // - /contracts - List all contracts // - /contracts/:id - View contract details with tabs (including Files tab) // - /contracts/:contractId/files/:fileId - View a specific file within contract context +// - /directives - Document editor layout (file tree sidebar) +// - /directives/:id - Document editor for a specific directive // -// Note: When Document UI is enabled via Settings, /directives is the primary interface. -// The /contracts routes remain available as a legacy fallback. +// Note: Standalone file routes (/files, /files/:id) have been removed. +// Files are now only accessible through their parent contract. const router = createBrowserRouter([ { @@ -27,12 +26,16 @@ const router = createBrowserRouter([ element: <App />, }, { - path: '/daemons', - element: <DaemonList />, + path: '/contracts', + element: <ContractList />, }, { - path: '/daemons/:id', - element: <DaemonDetail />, + path: '/contracts/:id', + element: <ContractDetail />, + }, + { + path: '/contracts/:contractId/files/:fileId', + element: <FileDetail />, }, { path: '/directives', @@ -42,6 +45,14 @@ const router = createBrowserRouter([ path: '/directives/:id', element: <DocumentLayout />, }, + { + path: '/daemons', + element: <DaemonList />, + }, + { + path: '/daemons/:id', + element: <DaemonDetail />, + }, ]) ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/services/directiveApi.ts b/frontend/src/services/directiveApi.ts index 4d1fd82..fcb6636 100644 --- a/frontend/src/services/directiveApi.ts +++ b/frontend/src/services/directiveApi.ts @@ -162,3 +162,24 @@ export async function startTask(taskId: string): Promise<void> { method: 'POST', }) } + +// ---- Contract interaction APIs ---- + +export async function sendContractMessage(taskId: string, message: string): Promise<void> { + await apiFetch(`/api/v1/mesh/tasks/${taskId}/message`, { + method: 'POST', + body: JSON.stringify({ message }), + }) +} + +export async function interruptContract(taskId: string): Promise<void> { + await apiFetch(`/api/v1/mesh/tasks/${taskId}/message`, { + method: 'POST', + body: JSON.stringify({ message: '/interrupt' }), + }) +} + +export async function getContractOutput(taskId: string): Promise<{ output: string }> { + const response = await apiFetch(`/api/v1/mesh/tasks/${taskId}/output`) + return response.json() +} diff --git a/frontend/src/services/taskWs.ts b/frontend/src/services/taskWs.ts deleted file mode 100644 index 832648e..0000000 --- a/frontend/src/services/taskWs.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { VNWebSocket } from './ws' - -export interface TaskOutputMessage { - task_id: string - message_type: string - content: string - tool_name?: string - tool_input?: string - is_error?: boolean - cost_usd?: number - duration_ms?: number - is_partial?: boolean -} - -type TaskOutputCallback = (msg: TaskOutputMessage) => void - -export class TaskOutputStream { - private ws: VNWebSocket - private listeners: Map<string, Set<TaskOutputCallback>> = new Map() - private connected = false - - constructor() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - this.ws = new VNWebSocket(`${protocol}//${window.location.host}/ws/tasks`) - - this.ws.on('message', (data: any) => { - if (data && data.type === 'TaskOutput') { - const payload = data.payload || data - const taskId = payload.task_id - if (taskId && this.listeners.has(taskId)) { - this.listeners.get(taskId)!.forEach(cb => cb(payload)) - } - } - }) - - this.ws.on('open', () => { - this.connected = true - // Re-subscribe all active subscriptions on reconnect - for (const taskId of this.listeners.keys()) { - this.ws.send({ type: 'SubscribeOutput', task_id: taskId }) - } - }) - - this.ws.on('close', () => { - this.connected = false - }) - } - - connect() { - this.ws.connect() - } - - subscribe(taskId: string, callback: TaskOutputCallback) { - if (!this.listeners.has(taskId)) { - this.listeners.set(taskId, new Set()) - // Only send subscribe if this is a new task subscription - this.ws.send({ type: 'SubscribeOutput', task_id: taskId }) - } - this.listeners.get(taskId)!.add(callback) - } - - unsubscribe(taskId: string, callback?: TaskOutputCallback) { - if (!this.listeners.has(taskId)) return - - if (callback) { - this.listeners.get(taskId)!.delete(callback) - if (this.listeners.get(taskId)!.size > 0) return - } - - this.listeners.delete(taskId) - this.ws.send({ type: 'UnsubscribeOutput', task_id: taskId }) - } - - close() { - this.ws.close() - this.listeners.clear() - } -} - -let instance: TaskOutputStream | null = null - -export function getTaskOutputStream(): TaskOutputStream { - if (!instance) { - instance = new TaskOutputStream() - instance.connect() - } - return instance -} diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index e247068..1853f4f 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -1,5 +1,29 @@ import { atom } from 'nanostores' import { ChatMessage, Choice } from '../types' +import { upsertUserSetting } from '../services/directiveApi' + +// Document UI feature flag +export const documentUiEnabledStore = atom<boolean>( + (() => { + try { + const saved = localStorage.getItem('document_ui_enabled') + return saved === 'true' + } catch { + return false + } + })() +) + +export const setDocumentUiEnabled = async (enabled: boolean) => { + documentUiEnabledStore.set(enabled) + localStorage.setItem('document_ui_enabled', enabled.toString()) + // Persist to backend (best-effort) + try { + await upsertUserSetting('document_ui_enabled', enabled) + } catch (err) { + console.error('Failed to persist document UI setting:', err) + } +} // Authentication state export const isLoggedInStore = atom<boolean>(false) @@ -29,36 +53,21 @@ export const nameStore = atom<string>('???') export const backgroundStore = atom<string>('/__gaogao__56242cbde8f18ac64522e410bad04e68_waifu2x_art_noise2.png') export const locationStore = atom<string>('Tokyo') export const configModalOpenStore = atom<boolean>(false) -export const skipIntroStore = atom<boolean>( + +// Feature flags +export const documentEditorEnabledStore = atom<boolean>( (() => { - const saved = localStorage.getItem('skipIntro') + const saved = localStorage.getItem('documentEditorEnabled') return saved === 'true' })() ) - -// Document UI feature flag -export const documentUiEnabledStore = atom<boolean>( +export const skipIntroStore = atom<boolean>( (() => { - try { - const saved = localStorage.getItem('document_ui_enabled') - return saved === 'true' - } catch { - return false - } + const saved = localStorage.getItem('skipIntro') + return saved === 'true' })() ) -export const setDocumentUiEnabled = (enabled: boolean) => { - documentUiEnabledStore.set(enabled) - localStorage.setItem('document_ui_enabled', JSON.stringify(enabled)) - // Persist to backend (fire-and-forget) - fetch('/api/v1/user-settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key: 'document_ui_enabled', value: enabled }), - }).catch((err) => console.error('Failed to persist document_ui_enabled:', err)) -} - // Actions export const login = () => { isLoggedInStore.set(true) @@ -109,6 +118,11 @@ export const setSkipIntro = (skip: boolean) => { localStorage.setItem('skipIntro', skip.toString()) } +export const setDocumentEditorEnabled = (enabled: boolean) => { + documentEditorEnabledStore.set(enabled) + localStorage.setItem('documentEditorEnabled', enabled.toString()) +} + export const setLoadingComplete = (complete: boolean) => { loadingCompleteStore.set(complete) if (complete) { diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 83d0161..b4f8fee 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/daemondetail.tsx","./src/components/daemonlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/components/document/autosaveplugin.tsx","./src/components/document/contextmenu.tsx","./src/components/document/directivefiletree.tsx","./src/components/document/documenteditor.tsx","./src/components/document/documentlayout.tsx","./src/components/document/documentsettings.tsx","./src/components/document/editortheme.ts","./src/components/document/toast.tsx","./src/components/document/index.ts","./src/components/document/nodes/stepsdiagramcomponent.tsx","./src/components/document/nodes/stepsdiagramnode.tsx","./src/services/directiveapi.ts","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"}
\ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/daemondetail.tsx","./src/components/daemonlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/components/document/autosaveplugin.tsx","./src/components/document/contextmenu.tsx","./src/components/document/directivefiletree.tsx","./src/components/document/documenteditor.tsx","./src/components/document/documentlayout.tsx","./src/components/document/documentsettings.tsx","./src/components/document/editortheme.ts","./src/components/document/toast.tsx","./src/components/document/index.ts","./src/components/document/nodes/contractblockcomponent.tsx","./src/components/document/nodes/contractblocknode.tsx","./src/components/document/nodes/contractlogfeed.tsx","./src/components/document/nodes/steplogfeed.tsx","./src/components/document/nodes/stepsdiagramcomponent.tsx","./src/components/document/nodes/stepsdiagramnode.tsx","./src/services/directiveapi.ts","./src/services/taskws.ts","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"}
\ No newline at end of file diff --git a/makima/migrations/20260427000000_create_user_settings.sql b/makima/migrations/20260427000000_create_user_settings.sql deleted file mode 100644 index 60acbcc..0000000 --- a/makima/migrations/20260427000000_create_user_settings.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Create user_settings table for per-user feature flags and preferences -CREATE TABLE IF NOT EXISTS user_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - owner_id UUID NOT NULL, - key TEXT NOT NULL, - value JSONB NOT NULL DEFAULT '"false"'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE(owner_id, key) -); -CREATE INDEX idx_user_settings_owner ON user_settings(owner_id); diff --git a/makima/src/daemon/process/claude.rs b/makima/src/daemon/process/claude.rs index aa18fab..57c8f77 100644 --- a/makima/src/daemon/process/claude.rs +++ b/makima/src/daemon/process/claude.rs @@ -577,7 +577,6 @@ impl ProcessManager { // On Unix, create a new process group so we can kill all child processes #[cfg(unix)] { - #[allow(unused_imports)] use std::os::unix::process::CommandExt; cmd.process_group(0); } @@ -763,7 +762,6 @@ impl ProcessManager { // On Unix, create a new process group so we can kill all child processes #[cfg(unix)] { - #[allow(unused_imports)] use std::os::unix::process::CommandExt; cmd.process_group(0); } diff --git a/makima/src/daemon/storage/patch.rs b/makima/src/daemon/storage/patch.rs index c9bc6f5..05c45a3 100644 --- a/makima/src/daemon/storage/patch.rs +++ b/makima/src/daemon/storage/patch.rs @@ -387,7 +387,6 @@ fn parse_diff_stat(stat_output: &str) -> (usize, usize) { } /// Checkout a specific commit in the worktree. -#[allow(dead_code)] pub async fn checkout_commit(worktree_path: &Path, sha: &str) -> Result<(), PatchError> { let output = Command::new("git") .current_dir(worktree_path) diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index f483218..ca97453 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -20,7 +20,7 @@ use crate::daemon::error::{DaemonError, TaskError, TaskResult}; use crate::daemon::process::{ClaudeInputMessage, ProcessManager}; use crate::daemon::storage; use crate::daemon::temp::TempManager; -use crate::daemon::worktree::{is_new_repo_request, ConflictResolution, WorktreeInfo, WorktreeManager}; +use crate::daemon::worktree::{is_new_repo_request, ConflictResolution, WorktreeError, WorktreeInfo, WorktreeManager}; use crate::daemon::db::local::LocalDb; use crate::daemon::ws::{BranchInfo, DaemonCommand, DaemonMessage}; @@ -3706,7 +3706,6 @@ impl TaskManager { } /// Handle GetWorktreeDiff command - get git diff for a task's worktree. - #[allow(dead_code)] async fn handle_get_worktree_diff( &self, task_id: Uuid, @@ -5623,7 +5622,7 @@ impl TaskManagerInner { let ws_tx = self.ws_tx.clone(); // For auth error detection - let _claude_command = self.process_manager.claude_command().to_string(); + let claude_command = self.process_manager.claude_command().to_string(); let daemon_hostname = hostname::get().ok().and_then(|h| h.into_string().ok()); let mut auth_error_handled = false; @@ -5631,7 +5630,6 @@ impl TaskManagerInner { let mut accumulated_output = String::new(); let mut circuit_breaker = CircuitBreaker::new(); let mut iteration_count = 0u32; - #[allow(unused_assignments)] let mut final_exit_code: i64 = -1; // Track the final exit code across iterations // Autonomous loop: we may run multiple iterations diff --git a/makima/src/daemon/tui/mod.rs b/makima/src/daemon/tui/mod.rs index 46652ec..e52b12a 100644 --- a/makima/src/daemon/tui/mod.rs +++ b/makima/src/daemon/tui/mod.rs @@ -26,6 +26,7 @@ use crossterm::{ execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use ratatui::prelude::*; use ratatui::backend::CrosstermBackend; pub type Terminal = ratatui::Terminal<CrosstermBackend<io::Stdout>>; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 121897d..c11150f 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -7,7 +7,6 @@ use utoipa::ToSchema; use uuid::Uuid; /// Default max retries for task daemon failover (3 attempts) -#[allow(dead_code)] fn default_max_retries() -> i32 { 3 } @@ -3051,19 +3050,24 @@ pub struct DirectiveOrderGroupListResponse { pub total: i64, } -/// User setting record from the database (key-value per owner). +// ============================================================================= +// User Settings Types +// ============================================================================= + +/// A user setting (feature flag / preference) stored in the database. #[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserSetting { pub id: Uuid, pub owner_id: Uuid, pub key: String, + #[sqlx(json)] pub value: serde_json::Value, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } -/// Request body for upserting a user setting. +/// Request to upsert (create or update) a user setting. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpsertUserSettingRequest { @@ -3071,7 +3075,7 @@ pub struct UpsertUserSettingRequest { pub value: serde_json::Value, } -/// Response containing a list of user settings. +/// Response wrapping a list of user settings. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserSettingsResponse { diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 10633d5..5a912e4 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -21,7 +21,6 @@ use super::models::{ PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest, - UserSetting, }; /// Repository error types. @@ -4912,7 +4911,6 @@ pub async fn sync_supervisor_state( // ============================================================================= /// Helper to truncate string to max length -#[allow(dead_code)] fn truncate_string(s: &str, max_len: usize) -> String { if s.len() <= max_len { s.to_string() @@ -6700,9 +6698,11 @@ pub async fn get_available_orders_for_dog_pickup( .await } -// ─── User Settings ─────────────────────────────────────────────────────────── +// ============================================================================= +// User Settings +// ============================================================================= -/// Get all settings for a given owner. +/// Get all settings for a user. pub async fn get_user_settings( pool: &PgPool, owner_id: Uuid, @@ -6720,7 +6720,7 @@ pub async fn get_user_settings( .await } -/// Get a single setting by owner and key. +/// Get a specific setting by key for a user. pub async fn get_user_setting( pool: &PgPool, owner_id: Uuid, @@ -6739,7 +6739,7 @@ pub async fn get_user_setting( .await } -/// Upsert a user setting (insert or update on conflict). +/// Upsert (create or update) a user setting. pub async fn upsert_user_setting( pool: &PgPool, owner_id: Uuid, @@ -6750,8 +6750,9 @@ pub async fn upsert_user_setting( r#" INSERT INTO user_settings (owner_id, key, value) VALUES ($1, $2, $3) - ON CONFLICT (owner_id, key) DO UPDATE - SET value = EXCLUDED.value, updated_at = now() + ON CONFLICT (owner_id, key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = NOW() RETURNING id, owner_id, key, value, created_at, updated_at "#, ) @@ -6762,7 +6763,7 @@ pub async fn upsert_user_setting( .await } -/// Delete a user setting. Returns true if a row was deleted. +/// Delete a user setting by key. Returns true if a row was deleted. pub async fn delete_user_setting( pool: &PgPool, owner_id: Uuid, @@ -6778,6 +6779,7 @@ pub async fn delete_user_setting( .bind(key) .execute(pool) .await?; + Ok(result.rows_affected() > 0) } diff --git a/makima/src/listen.rs b/makima/src/listen.rs index 6391453..91d616c 100644 --- a/makima/src/listen.rs +++ b/makima/src/listen.rs @@ -6,7 +6,6 @@ pub use parakeet_rs::{ParakeetEOU, ParakeetTDT, TimedToken, TimestampMode}; use crate::audio; -#[allow(dead_code)] const STREAM_CHUNK_MS: u32 = 5_000; /// A segment of dialogue with speaker identification and timing. @@ -18,7 +17,6 @@ pub struct DialogueSegment { pub text: String, } -#[allow(dead_code)] pub(crate) fn listen() -> Result<Vec<DialogueSegment>, Box<dyn std::error::Error>> { let audio_path = Path::new("audio-ftc.mp3"); diff --git a/makima/src/llm/groq.rs b/makima/src/llm/groq.rs index 8da9746..ee01fcf 100644 --- a/makima/src/llm/groq.rs +++ b/makima/src/llm/groq.rs @@ -83,7 +83,6 @@ struct Choice { #[derive(Debug, Deserialize)] struct MessageResponse { - #[allow(dead_code)] role: String, content: Option<String>, tool_calls: Option<Vec<ToolCallResponse>>, diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index b9ff3fe..1e025c8 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -442,59 +442,64 @@ impl DirectiveOrchestrator { let directives = repository::get_directives_needing_replanning(&self.pool).await?; for directive in directives { - tracing::info!( - directive_id = %directive.id, - title = %directive.title, - "Directive goal updated — spawning re-planning task" - ); + if let Err(e) = async { + tracing::info!( + directive_id = %directive.id, + title = %directive.title, + "Directive goal updated — spawning re-planning task" + ); - // If directive already has a PR, remove completed steps that were included in it - if directive.pr_url.is_some() || directive.pr_branch.is_some() { - match remove_already_merged_steps(&self.pool, directive.id).await { - Ok(count) if count > 0 => { - tracing::info!( - directive_id = %directive.id, - removed = count, - "Auto-removed completed steps already included in PR before replanning" - ); - } - Err(e) => { - tracing::warn!( - directive_id = %directive.id, - error = %e, - "Failed to auto-remove merged steps before replanning" - ); + // If directive already has a PR, remove completed steps that were included in it + if directive.pr_url.is_some() || directive.pr_branch.is_some() { + match remove_already_merged_steps(&self.pool, directive.id).await { + Ok(count) if count > 0 => { + tracing::info!( + directive_id = %directive.id, + removed = count, + "Auto-removed completed steps already included in PR before replanning" + ); + } + Err(e) => { + tracing::warn!( + directive_id = %directive.id, + error = %e, + "Failed to auto-remove merged steps before replanning" + ); + } + _ => {} } - _ => {} } - } - let existing_steps = - repository::list_directive_steps(&self.pool, directive.id).await?; - let generation = - repository::get_directive_max_generation(&self.pool, directive.id).await? + 1; - let goal_history = - repository::get_directive_goal_history(&self.pool, directive.id, 3).await?; + let existing_steps = + repository::list_directive_steps(&self.pool, directive.id).await?; + let generation = + repository::get_directive_max_generation(&self.pool, directive.id).await? + 1; + let goal_history = + repository::get_directive_goal_history(&self.pool, directive.id, 3).await?; - let plan = - build_planning_prompt(&directive, &existing_steps, generation, &goal_history); + let plan = + build_planning_prompt(&directive, &existing_steps, generation, &goal_history); - if let Err(e) = self - .spawn_orchestrator_task( - directive.id, - directive.owner_id, - format!("Re-plan: {}", directive.title), - plan, - directive.repository_url.as_deref(), - directive.base_branch.as_deref(), - ) - .await - { - tracing::warn!( - directive_id = %directive.id, - error = %e, - "Failed to spawn re-planning task" - ); + if let Err(e) = self + .spawn_orchestrator_task( + directive.id, + directive.owner_id, + format!("Re-plan: {}", directive.title), + plan, + directive.repository_url.as_deref(), + directive.base_branch.as_deref(), + ) + .await + { + tracing::warn!( + directive_id = %directive.id, + error = %e, + "Failed to spawn re-planning task" + ); + } + Ok::<(), anyhow::Error>(()) + }.await { + tracing::warn!(directive_id = %directive.id, error = %e, "Error in re-planning directive — continuing"); } } Ok(()) diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs index 7edcdfc..e1bc30e 100644 --- a/makima/src/server/handlers/listen.rs +++ b/makima/src/server/handlers/listen.rs @@ -208,7 +208,8 @@ async fn handle_socket(socket: WebSocket, state: SharedState) { audio_offset = 0.0; finalized_segments.clear(); file_id = None; - // authenticated_owner_id and target_contract_id are kept from above + authenticated_owner_id = authenticated_owner_id; // Keep from above + target_contract_id = target_contract_id; // Keep from above // Reset models for new session let mut sortformer = ml_models.sortformer.lock().await; diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index d9d40d7..1a5b9c1 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -20,7 +20,7 @@ use crate::db::models::{ use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; -use crate::server::state::{DaemonCommand, SharedState, TaskUpdateNotification}; +use crate::server::state::{DaemonCommand, DaemonReauthStatus, SharedState, TaskUpdateNotification}; // ============================================================================= // Authentication Types diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index ed1b603..e5f0a81 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -1627,7 +1627,7 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re ); // Broadcast as task output with auth_required type so UI can display the login link - let _content = format!( + let content = format!( "🔐 Authentication required on daemon{}. Click to login: {}", hostname.as_ref().map(|h| format!(" ({})", h)).unwrap_or_default(), login_url diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs index 3adb850..ebde52b 100644 --- a/makima/src/server/handlers/mesh_supervisor.rs +++ b/makima/src/server/handlers/mesh_supervisor.rs @@ -1272,7 +1272,7 @@ pub async fn create_branch( headers: HeaderMap, Json(request): Json<CreateBranchRequest>, ) -> impl IntoResponse { - let (supervisor_id, _owner_id) = match verify_supervisor_auth(&state, &headers, None).await { + let (supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await { Ok(ids) => ids, Err(e) => return e.into_response(), }; diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index b3c433b..4bdb424 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -20,7 +20,6 @@ pub mod mesh_merge; pub mod mesh_supervisor; pub mod mesh_ws; pub mod repository_history; -pub mod settings; pub mod speak; pub mod templates; pub mod voice; diff --git a/makima/src/server/handlers/settings.rs b/makima/src/server/handlers/settings.rs deleted file mode 100644 index ae52d5a..0000000 --- a/makima/src/server/handlers/settings.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! HTTP handlers for user settings (feature flags / preferences). - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; - -use crate::db::models::{UpsertUserSettingRequest, UserSettingsResponse}; -use crate::db::repository; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -/// List all settings for the authenticated user. -#[utoipa::path( - get, - path = "/api/v1/settings", - responses( - (status = 200, description = "User settings", body = UserSettingsResponse), - (status = 401, description = "Not authenticated", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []) - ), - tag = "Settings" -)] -pub async fn list_settings( - State(state): State<SharedState>, - Authenticated(user): Authenticated, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::get_user_settings(pool, user.owner_id).await { - Ok(settings) => Json(UserSettingsResponse { settings }).into_response(), - Err(e) => { - tracing::error!("Failed to list user settings: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get a specific setting by key. -#[utoipa::path( - get, - path = "/api/v1/settings/{key}", - params( - ("key" = String, Path, description = "Setting key") - ), - responses( - (status = 200, description = "User setting", body = crate::db::models::UserSetting), - (status = 401, description = "Not authenticated", body = ApiError), - (status = 404, description = "Setting not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []) - ), - tag = "Settings" -)] -pub async fn get_setting( - State(state): State<SharedState>, - Authenticated(user): Authenticated, - Path(key): Path<String>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::get_user_setting(pool, user.owner_id, &key).await { - Ok(Some(setting)) => Json(setting).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", format!("Setting '{}' not found", key))), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get user setting: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Upsert a user setting (create or update). -#[utoipa::path( - put, - path = "/api/v1/settings", - request_body = UpsertUserSettingRequest, - responses( - (status = 200, description = "Setting upserted", body = crate::db::models::UserSetting), - (status = 401, description = "Not authenticated", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []) - ), - tag = "Settings" -)] -pub async fn upsert_setting( - State(state): State<SharedState>, - Authenticated(user): Authenticated, - Json(req): Json<UpsertUserSettingRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::upsert_user_setting(pool, user.owner_id, &req.key, &req.value).await { - Ok(setting) => Json(setting).into_response(), - Err(e) => { - tracing::error!("Failed to upsert user setting: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a user setting by key. -#[utoipa::path( - delete, - path = "/api/v1/settings/{key}", - params( - ("key" = String, Path, description = "Setting key") - ), - responses( - (status = 200, description = "Setting deleted"), - (status = 401, description = "Not authenticated", body = ApiError), - (status = 404, description = "Setting not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []) - ), - tag = "Settings" -)] -pub async fn delete_setting( - State(state): State<SharedState>, - Authenticated(user): Authenticated, - Path(key): Path<String>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::delete_user_setting(pool, user.owner_id, &key).await { - Ok(true) => StatusCode::OK.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", format!("Setting '{}' not found", key))), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete user setting: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 025ec85..99dc1f3 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, settings, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -281,16 +281,7 @@ pub fn make_router(state: SharedState) -> Router { .route("/timeline", get(history::get_timeline)) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) - // User settings (feature flags) endpoints - .route( - "/user-settings", - get(settings::list_settings).put(settings::upsert_setting), - ) - .route( - "/user-settings/{key}", - get(settings::get_setting).delete(settings::delete_setting), - ) - // Settings endpoints + // Settings endpoints (static routes first to avoid conflict with /settings/{key}) .route( "/settings/repository-history", get(repository_history::list_repository_history), @@ -303,6 +294,15 @@ pub fn make_router(state: SharedState) -> Router { "/settings/repository-history/{id}", axum::routing::delete(repository_history::delete_repository_history), ) + // User settings (feature flags / preferences) + .route( + "/settings", + get(settings::list_settings).put(settings::upsert_setting), + ) + .route( + "/settings/{key}", + get(settings::get_setting).delete(settings::delete_setting), + ) .with_state(state); let swagger = SwaggerUi::new("/swagger-ui") diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1f59868 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "6c8cfabe-soryu-co-soryu---makima--add-contract-blocks--expa", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} |
