summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--frontend/package-lock.json394
-rw-r--r--frontend/package.json9
-rw-r--r--frontend/src/components/ConfigModal.tsx35
-rw-r--r--frontend/src/components/VNInterface.tsx38
-rw-r--r--frontend/src/components/document/AutoSavePlugin.tsx140
-rw-r--r--frontend/src/components/document/ContextMenu.css79
-rw-r--r--frontend/src/components/document/ContextMenu.tsx98
-rw-r--r--frontend/src/components/document/DirectiveFileTree.tsx154
-rw-r--r--frontend/src/components/document/DocumentEditor.css246
-rw-r--r--frontend/src/components/document/DocumentEditor.tsx233
-rw-r--r--frontend/src/components/document/DocumentLayout.css347
-rw-r--r--frontend/src/components/document/DocumentLayout.tsx316
-rw-r--r--frontend/src/components/document/DocumentSettings.tsx76
-rw-r--r--frontend/src/components/document/EditorTheme.ts30
-rw-r--r--frontend/src/components/document/Toast.css100
-rw-r--r--frontend/src/components/document/Toast.tsx97
-rw-r--r--frontend/src/components/document/index.ts4
-rw-r--r--frontend/src/components/document/nodes/StepsDiagram.css360
-rw-r--r--frontend/src/components/document/nodes/StepsDiagramComponent.tsx180
-rw-r--r--frontend/src/components/document/nodes/StepsDiagramNode.tsx91
-rw-r--r--frontend/src/main.tsx9
-rw-r--r--frontend/src/services/directiveApi.ts136
-rw-r--r--frontend/src/stores/index.ts23
-rw-r--r--frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20260427000000_create_user_settings.sql11
-rw-r--r--makima/src/db/models.rs27
-rw-r--r--makima/src/db/repository.rs82
-rw-r--r--makima/src/orchestration/directive.rs30
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/handlers/settings.rs196
-rw-r--r--makima/src/server/mod.rs11
31 files changed, 3539 insertions, 16 deletions
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 230ed07..4c4bcbc 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,8 +8,17 @@
"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",
"nanostores": "^1.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -66,7 +75,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz",
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"dev": true,
- "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -675,6 +683,54 @@
"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",
@@ -710,6 +766,255 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lexical/clipboard": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.44.0.tgz",
+ "integrity": "sha512-nfmNIs7uENqlDI7cm2E4I1Yp8mDJGMhEQIrIV2rNWnL1oeHVXQ7yuYdyoPdcY1zuj/9nvkYBQYUEh0QiGwpETA==",
+ "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==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "lexical": "0.44.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"
+ },
+ "peerDependencies": {
+ "react": ">=17.x",
+ "react-dom": ">=17.x"
+ }
+ },
+ "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==",
+ "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"
+ }
+ },
+ "node_modules/@lexical/hashtag": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.44.0.tgz",
+ "integrity": "sha512-0WATahDSqYKVTudQv3KpFbLeCpmrCpRptPFbjxOMckAX2MRpYlrExlqKfgfpri5BSQPtG49EPSGeNfSx/Faavw==",
+ "dependencies": {
+ "@lexical/text": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.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==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.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==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "@lexical/selection": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.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==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.0"
+ }
+ },
+ "node_modules/@lexical/list": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.44.0.tgz",
+ "integrity": "sha512-ZTCWxDz1okPrC9FBXi1yV3W5fbQQeMUlFIcSVF9HibcVPmCsPa900IxthuiQbGiTycUyXDTOB3IUYRtlJNtpjw==",
+ "dependencies": {
+ "@lexical/extension": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.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==",
+ "dependencies": {
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.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==",
+ "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"
+ }
+ },
+ "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==",
+ "dependencies": {
+ "lexical": "0.44.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==",
+ "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"
+ }
+ },
+ "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"
+ },
+ "peerDependencies": {
+ "react": ">=17.x",
+ "react-dom": ">=17.x",
+ "yjs": ">=13.5.22"
+ },
+ "peerDependenciesMeta": {
+ "yjs": {
+ "optional": true
+ }
+ }
+ },
+ "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==",
+ "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"
+ }
+ },
+ "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==",
+ "dependencies": {
+ "lexical": "0.44.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==",
+ "dependencies": {
+ "@lexical/clipboard": "0.44.0",
+ "@lexical/extension": "0.44.0",
+ "@lexical/utils": "0.44.0",
+ "lexical": "0.44.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==",
+ "dependencies": {
+ "lexical": "0.44.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==",
+ "dependencies": {
+ "@lexical/selection": "0.44.0",
+ "lexical": "0.44.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==",
+ "dependencies": {
+ "@lexical/selection": "0.44.0",
+ "lexical": "0.44.0"
+ },
+ "peerDependencies": {
+ "yjs": ">=13.5.22"
+ }
+ },
"node_modules/@nanostores/react": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@nanostores/react/-/react-1.0.0.tgz",
@@ -728,6 +1033,15 @@
"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",
@@ -1060,7 +1374,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
"dev": true,
- "peer": true,
"dependencies": {
"undici-types": "~7.10.0"
}
@@ -1076,7 +1389,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
"integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
"dev": true,
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1110,6 +1422,11 @@
"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",
@@ -1159,7 +1476,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -1303,6 +1619,16 @@
"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",
@@ -1332,6 +1658,32 @@
"node": ">=6"
}
},
+ "node_modules/lexical": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.44.0.tgz",
+ "integrity": "sha512-ReDUjRlFgkGoPWzvdjr7s16PUVpHATN+2NH2NiZs+PLlISTaIFFgKil2P467oP3Vg+XgmpDsUgmWZsFJTztYjg=="
+ },
+ "node_modules/lib0": {
+ "version": "0.2.117",
+ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
+ "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
+ "peer": true,
+ "dependencies": {
+ "isomorphic.js": "^0.2.4"
+ },
+ "bin": {
+ "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
+ "0gentesthtml": "bin/gentesthtml.js",
+ "0serve": "bin/0serve.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1391,7 +1743,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "peer": true,
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
@@ -1440,7 +1791,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1452,7 +1802,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -1461,6 +1810,14 @@
"react": "^18.3.1"
}
},
+ "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==",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1567,6 +1924,11 @@
"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",
@@ -1626,7 +1988,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true,
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -1686,6 +2047,23 @@
"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 197c3d8..230a982 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,8 +9,17 @@
"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",
"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 e7b1f9f..9746e4e 100644
--- a/frontend/src/components/ConfigModal.tsx
+++ b/frontend/src/components/ConfigModal.tsx
@@ -1,4 +1,7 @@
import React from 'react'
+import { useStore } from '@nanostores/react'
+import { Link } from 'react-router-dom'
+import { documentUiEnabledStore, setDocumentUiEnabled } from '../stores'
type Props = {
isOpen: boolean
@@ -8,6 +11,8 @@ type Props = {
}
export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSkipIntroChange }) => {
+ const documentUiEnabled = useStore(documentUiEnabledStore)
+
if (!isOpen) return null
return (
@@ -15,9 +20,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}>×</button>
+ <button className="close-btn" onClick={onClose}>{'\u00D7'}</button>
</div>
-
+
<div className="modal-content">
<div className="config-option">
<label className="config-label">
@@ -33,8 +38,32 @@ 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 318a9b9..051c210 100644
--- a/frontend/src/components/VNInterface.tsx
+++ b/frontend/src/components/VNInterface.tsx
@@ -9,9 +9,11 @@ import {
showSettingsModalStore,
isVisibleStore,
yenBalanceStore,
+ documentUiEnabledStore,
toggleStandby,
toggleShowChoices,
- updateTime
+ updateTime,
+ setDocumentUiEnabled,
} from '../stores'
interface VNInterfaceProps {
@@ -26,6 +28,7 @@ export function VNInterface({ onLogout }: VNInterfaceProps) {
const showSettingsModal = useStore(showSettingsModalStore)
const isVisible = useStore(isVisibleStore)
const yenBalance = useStore(yenBalanceStore)
+ const documentUiEnabled = useStore(documentUiEnabledStore)
// Fade in effect on mount
useEffect(() => {
@@ -113,6 +116,14 @@ export function VNInterface({ onLogout }: VNInterfaceProps) {
<span className="info-value">Contracts</span>
</Link>
</div>
+ {documentUiEnabled && (
+ <div className="status-item">
+ <Link to="/directives" style={{ color: '#ff66cc', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}>
+ <span className="info-label">Docs:</span>
+ <span className="info-value">Directives</span>
+ </Link>
+ </div>
+ )}
</div>
</div>
</div>
@@ -198,6 +209,31 @@ export function VNInterface({ onLogout }: VNInterfaceProps) {
</div>
</div>
<div className="settings-section">
+ <h3>Experimental</h3>
+ <div className="setting-item">
+ <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
+ <input
+ type="checkbox"
+ checked={documentUiEnabled}
+ onChange={(e) => setDocumentUiEnabled(e.target.checked)}
+ />
+ Document UI (Experimental)
+ </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>
+ </div>
+ <div className="settings-section">
<h3>Audio</h3>
<div className="setting-item">
<label>Master Volume</label>
diff --git a/frontend/src/components/document/AutoSavePlugin.tsx b/frontend/src/components/document/AutoSavePlugin.tsx
new file mode 100644
index 0000000..d3d0eb5
--- /dev/null
+++ b/frontend/src/components/document/AutoSavePlugin.tsx
@@ -0,0 +1,140 @@
+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
new file mode 100644
index 0000000..4eed119
--- /dev/null
+++ b/frontend/src/components/document/ContextMenu.css
@@ -0,0 +1,79 @@
+/* ============================================
+ 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
new file mode 100644
index 0000000..5aed940
--- /dev/null
+++ b/frontend/src/components/document/ContextMenu.tsx
@@ -0,0 +1,98 @@
+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
new file mode 100644
index 0000000..21050ca
--- /dev/null
+++ b/frontend/src/components/document/DirectiveFileTree.tsx
@@ -0,0 +1,154 @@
+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>
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ )
+ })}
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/frontend/src/components/document/DocumentEditor.css b/frontend/src/components/document/DocumentEditor.css
new file mode 100644
index 0000000..0be1151
--- /dev/null
+++ b/frontend/src/components/document/DocumentEditor.css
@@ -0,0 +1,246 @@
+/* ============================================
+ 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
new file mode 100644
index 0000000..d50c093
--- /dev/null
+++ b/frontend/src/components/document/DocumentEditor.tsx
@@ -0,0 +1,233 @@
+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 editorTheme from './EditorTheme';
+import AutoSavePlugin from './AutoSavePlugin';
+import ContextMenu, { type ContextMenuAction } from './ContextMenu';
+import './DocumentEditor.css';
+import './nodes/StepsDiagram.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],
+ 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 the steps diagram node when extracting text
+ if ($isStepsDiagramNode(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
new file mode 100644
index 0000000..b18bb81
--- /dev/null
+++ b/frontend/src/components/document/DocumentLayout.css
@@ -0,0 +1,347 @@
+/* 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;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+/* 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;
+}
+
+/* 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
new file mode 100644
index 0000000..a555ad0
--- /dev/null
+++ b/frontend/src/components/document/DocumentLayout.tsx
@@ -0,0 +1,316 @@
+import { useEffect, useState, useCallback, useRef } 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 task 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
new file mode 100644
index 0000000..b575b3d
--- /dev/null
+++ b/frontend/src/components/document/DocumentSettings.tsx
@@ -0,0 +1,76 @@
+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
new file mode 100644
index 0000000..5b336ad
--- /dev/null
+++ b/frontend/src/components/document/EditorTheme.ts
@@ -0,0 +1,30 @@
+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
new file mode 100644
index 0000000..e97304c
--- /dev/null
+++ b/frontend/src/components/document/Toast.css
@@ -0,0 +1,100 @@
+/* ============================================
+ 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
new file mode 100644
index 0000000..653db8f
--- /dev/null
+++ b/frontend/src/components/document/Toast.tsx
@@ -0,0 +1,97 @@
+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
new file mode 100644
index 0000000..af9e362
--- /dev/null
+++ b/frontend/src/components/document/index.ts
@@ -0,0 +1,4 @@
+export { default as DocumentLayout } from './DocumentLayout'
+export { default as DocumentEditor } from './DocumentEditor'
+export { DirectiveFileTree } from './DirectiveFileTree'
+export { default as DocumentSettings } from './DocumentSettings'
diff --git a/frontend/src/components/document/nodes/StepsDiagram.css b/frontend/src/components/document/nodes/StepsDiagram.css
new file mode 100644
index 0000000..f3e9305
--- /dev/null
+++ b/frontend/src/components/document/nodes/StepsDiagram.css
@@ -0,0 +1,360 @@
+/* ============================================
+ 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;
+}
+
+/* ---- 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 ---- */
+.steps-diagram-card {
+ flex: 1 1 180px;
+ max-width: 280px;
+ 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;
+ animation: stepCardAppear 0.35s ease-out both;
+}
+
+@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-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.3rem;
+}
+
+.steps-diagram-card-name {
+ font-weight: 600;
+ font-size: 0.85rem;
+ color: #1f2937;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+}
+
+.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-progress {
+ color: #d97706;
+ font-style: italic;
+}
+
+.steps-diagram-card-time {
+ color: #6b7280;
+}
+
+/* ---- 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;
+}
+
+/* ---- 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;
+}
+
+/* ---- Responsive ---- */
+@media (max-width: 640px) {
+ .steps-diagram {
+ padding: 0.75rem;
+ }
+
+ .steps-diagram-card {
+ flex: 1 1 100%;
+ max-width: 100%;
+ }
+}
diff --git a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
new file mode 100644
index 0000000..606c0ab
--- /dev/null
+++ b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx
@@ -0,0 +1,180 @@
+import React, { useEffect, useState, useRef, useCallback } from 'react';
+import { getDirective, DirectiveStep, DirectiveWithSteps } from '../../../services/directiveApi';
+import './StepsDiagram.css';
+
+interface StepsDiagramComponentProps {
+ directiveId: string;
+}
+
+type StepStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'skipped';
+
+const STATUS_LABELS: Record<string, string> = {
+ pending: 'Pending',
+ ready: 'Ready',
+ running: 'Running',
+ completed: 'Done',
+ failed: 'Failed',
+ skipped: 'Skipped',
+};
+
+function formatTime(dateStr: string): string {
+ if (!dateStr) return '';
+ const d = new Date(dateStr);
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+}
+
+function StepCard({ step }: { step: DirectiveStep }) {
+ const status = (step.status || 'pending').toLowerCase() as StepStatus;
+
+ return (
+ <div className={`steps-diagram-card steps-diagram-card--${status}`}>
+ <div className="steps-diagram-card-header">
+ <span className="steps-diagram-card-name">{step.name}</span>
+ <span className={`steps-diagram-status-badge steps-diagram-status-badge--${status}`}>
+ {STATUS_LABELS[status] || status}
+ </span>
+ </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>
+ );
+}
+
+export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProps) {
+ const [steps, setSteps] = useState<DirectiveStep[]>([]);
+ const [directiveStatus, setDirectiveStatus] = useState<string>('');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
+ const prevStepCountRef = useRef(0);
+
+ const fetchSteps = useCallback(async () => {
+ try {
+ const data: DirectiveWithSteps = await getDirective(directiveId);
+ setSteps(data.steps || []);
+ setDirectiveStatus(data.status || '');
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load steps');
+ } finally {
+ setLoading(false);
+ }
+ }, [directiveId]);
+
+ useEffect(() => {
+ fetchSteps();
+ intervalRef.current = setInterval(fetchSteps, 5000);
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ }, [fetchSteps]);
+
+ // Track when new steps appear for animation
+ useEffect(() => {
+ prevStepCountRef.current = steps.length;
+ }, [steps.length]);
+
+ const completedCount = steps.filter(s => s.status?.toLowerCase() === 'completed').length;
+ const totalCount = steps.length;
+ const isActive = ['active', 'running', 'planning'].includes(directiveStatus.toLowerCase());
+ const isBuilding = isActive && steps.length === 0;
+ const isAddingSteps = isActive && steps.length > 0 && steps.length > prevStepCountRef.current;
+
+ // Group steps by orderIndex
+ const groupedSteps: Map<number, DirectiveStep[]> = new Map();
+ const sortedSteps = [...steps].sort((a, b) => a.orderIndex - b.orderIndex);
+ for (const step of sortedSteps) {
+ const idx = step.orderIndex;
+ if (!groupedSteps.has(idx)) groupedSteps.set(idx, []);
+ groupedSteps.get(idx)!.push(step);
+ }
+ const orderGroups = Array.from(groupedSteps.entries()).sort((a, b) => a[0] - b[0]);
+
+ if (loading) {
+ return (
+ <div className="steps-diagram" contentEditable={false}>
+ <div className="steps-diagram-header">
+ <span className="steps-diagram-header-title">Steps</span>
+ <span className="steps-diagram-header-author">Authored by Makima</span>
+ </div>
+ <div className="steps-diagram-loading">
+ <div className="steps-diagram-spinner" />
+ <span>Loading steps...</span>
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="steps-diagram" contentEditable={false}>
+ <div className="steps-diagram-header">
+ <span className="steps-diagram-header-title">Steps</span>
+ <span className="steps-diagram-header-author">Authored by Makima</span>
+ </div>
+ <div className="steps-diagram-error">Failed to load steps: {error}</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="steps-diagram" contentEditable={false}>
+ <div className="steps-diagram-header">
+ <div className="steps-diagram-header-left">
+ <span className="steps-diagram-header-title">Steps</span>
+ {totalCount > 0 && (
+ <span className="steps-diagram-header-count">
+ {completedCount}/{totalCount} completed
+ </span>
+ )}
+ </div>
+ <span className="steps-diagram-header-author">Authored by Makima</span>
+ </div>
+
+ {isBuilding && (
+ <div className="steps-diagram-planning">
+ <div className="steps-diagram-planning-dots">
+ <span /><span /><span />
+ </div>
+ <span>Makima is building the plan...</span>
+ </div>
+ )}
+
+ {totalCount === 0 && !isBuilding && (
+ <div className="steps-diagram-empty">No steps defined yet.</div>
+ )}
+
+ {totalCount > 0 && (
+ <div className="steps-diagram-dag">
+ {orderGroups.map(([orderIndex, groupSteps], groupIdx) => (
+ <React.Fragment key={orderIndex}>
+ {groupIdx > 0 && (
+ <div className="steps-diagram-arrow">
+ <div className="steps-diagram-arrow-line" />
+ <div className="steps-diagram-arrow-head" />
+ </div>
+ )}
+ <div className="steps-diagram-group">
+ {groupSteps.map((step) => (
+ <StepCard key={step.id} step={step} />
+ ))}
+ </div>
+ </React.Fragment>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/frontend/src/components/document/nodes/StepsDiagramNode.tsx b/frontend/src/components/document/nodes/StepsDiagramNode.tsx
new file mode 100644
index 0000000..8b37f52
--- /dev/null
+++ b/frontend/src/components/document/nodes/StepsDiagramNode.tsx
@@ -0,0 +1,91 @@
+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 04b8cde..3987f30 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -7,6 +7,7 @@ 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'
@@ -43,6 +44,14 @@ const router = createBrowserRouter([
path: '/daemons/:id',
element: <DaemonDetail />,
},
+ {
+ path: '/directives',
+ element: <DocumentLayout />,
+ },
+ {
+ path: '/directives/:id',
+ element: <DocumentLayout />,
+ },
])
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/frontend/src/services/directiveApi.ts b/frontend/src/services/directiveApi.ts
new file mode 100644
index 0000000..b82f594
--- /dev/null
+++ b/frontend/src/services/directiveApi.ts
@@ -0,0 +1,136 @@
+// API service for directive operations
+
+export interface DirectiveStepCounts {
+ pending: number
+ ready: number
+ running: number
+ completed: number
+ failed: number
+ skipped: number
+}
+
+export interface DirectiveSummary {
+ id: string
+ title: string
+ goal: string
+ status: string
+ repositoryUrl: string
+ prUrl: string
+ prBranch: string
+ createdAt: string
+ updatedAt: string
+ goalUpdatedAt: string
+ stepCounts: DirectiveStepCounts
+ version?: number
+ pr_branch?: string | null
+}
+
+export interface DirectiveStep {
+ id: string
+ directiveId: string
+ directive_id?: string
+ name: string
+ title?: string
+ description: string | null
+ taskPlan: string
+ dependsOn: string[]
+ status: string
+ taskId: string
+ contractId: string
+ orderIndex: number
+ sort_order?: number
+ completedAt: string
+}
+
+export interface DirectiveWithSteps extends DirectiveSummary {
+ steps: DirectiveStep[]
+ reconcileMode: string
+}
+
+// Alias for compatibility with context-menu branch types
+export type Directive = DirectiveSummary
+
+async function apiFetch(path: string, options?: RequestInit): Promise<Response> {
+ const response = await fetch(path, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ })
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({ message: response.statusText }))
+ throw new Error(body.message ?? body.error ?? `API error ${response.status}: ${response.statusText}`)
+ }
+ return response
+}
+
+export async function listDirectives(): Promise<DirectiveSummary[]> {
+ const response = await apiFetch('/api/v1/directives')
+ const data = await response.json()
+ return data.directives || []
+}
+
+export async function getDirective(id: string): Promise<DirectiveWithSteps> {
+ const response = await apiFetch(`/api/v1/directives/${id}`)
+ return response.json()
+}
+
+export async function getDirectiveSteps(id: string): Promise<DirectiveStep[]> {
+ const response = await apiFetch(`/api/v1/directives/${id}/steps`)
+ return response.json()
+}
+
+export async function updateGoal(id: string, goal: string): Promise<DirectiveWithSteps> {
+ const response = await apiFetch(`/api/v1/directives/${id}/goal`, {
+ method: 'PUT',
+ body: JSON.stringify({ goal }),
+ })
+ return response.json()
+}
+
+export async function updateDirective(
+ id: string,
+ updates: { title?: string; goal?: string; version?: number },
+): Promise<DirectiveWithSteps> {
+ const response = await apiFetch(`/api/v1/directives/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(updates),
+ })
+ return response.json()
+}
+
+export async function cleanupDirective(id: string): Promise<void> {
+ await apiFetch(`/api/v1/directives/${id}/cleanup`, { method: 'POST' })
+}
+
+export async function createPr(id: string): Promise<{ prUrl: string }> {
+ const response = await apiFetch(`/api/v1/directives/${id}/create-pr`, { method: 'POST' })
+ return response.json()
+}
+
+export async function pickUpOrders(id: string): Promise<void> {
+ await apiFetch(`/api/v1/directives/${id}/pick-up-orders`, { method: 'POST' })
+}
+
+export async function startDirective(id: string): Promise<DirectiveWithSteps> {
+ const response = await apiFetch(`/api/v1/directives/${id}/start`, { method: 'POST' })
+ return response.json()
+}
+
+export async function pauseDirective(id: string): Promise<DirectiveWithSteps> {
+ const response = await apiFetch(`/api/v1/directives/${id}/pause`, { method: 'POST' })
+ return response.json()
+}
+
+export async function getUserSetting(key: string): Promise<any> {
+ const response = await apiFetch(`/api/v1/settings/${key}`)
+ return response.json()
+}
+
+export async function upsertUserSetting(key: string, value: any): Promise<void> {
+ await apiFetch('/api/v1/settings', {
+ method: 'PUT',
+ body: JSON.stringify({ key, value }),
+ })
+}
diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts
index 58f461c..5ee9b08 100644
--- a/frontend/src/stores/index.ts
+++ b/frontend/src/stores/index.ts
@@ -36,6 +36,29 @@ export const skipIntroStore = atom<boolean>(
})()
)
+// 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 = (enabled: boolean) => {
+ documentUiEnabledStore.set(enabled)
+ localStorage.setItem('document_ui_enabled', JSON.stringify(enabled))
+ // Persist to backend (fire-and-forget)
+ fetch('/api/v1/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)
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
index 79408dc..83d0161 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/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/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/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
diff --git a/makima/migrations/20260427000000_create_user_settings.sql b/makima/migrations/20260427000000_create_user_settings.sql
new file mode 100644
index 0000000..60acbcc
--- /dev/null
+++ b/makima/migrations/20260427000000_create_user_settings.sql
@@ -0,0 +1,11 @@
+-- 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/db/models.rs b/makima/src/db/models.rs
index 97657dc..c03b4ac 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -3050,4 +3050,31 @@ pub struct DirectiveOrderGroupListResponse {
pub total: i64,
}
+/// User setting record from the database (key-value per owner).
+#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UserSetting {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub key: String,
+ pub value: serde_json::Value,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Request body for upserting a user setting.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpsertUserSettingRequest {
+ pub key: String,
+ pub value: serde_json::Value,
+}
+
+/// Response containing a list of user settings.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UserSettingsResponse {
+ pub settings: Vec<UserSetting>,
+}
+
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 57e8a78..401da94 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -21,6 +21,7 @@ use super::models::{
PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState,
Task, TaskCheckpoint, TaskEvent, TaskSummary, UpdateContractRequest,
UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest,
+ UserSetting,
};
/// Repository error types.
@@ -6698,3 +6699,84 @@ pub async fn get_available_orders_for_dog_pickup(
.await
}
+// ─── User Settings ───────────────────────────────────────────────────────────
+
+/// Get all settings for a given owner.
+pub async fn get_user_settings(
+ pool: &PgPool,
+ owner_id: Uuid,
+) -> Result<Vec<UserSetting>, sqlx::Error> {
+ sqlx::query_as::<_, UserSetting>(
+ r#"
+ SELECT id, owner_id, key, value, created_at, updated_at
+ FROM user_settings
+ WHERE owner_id = $1
+ ORDER BY key ASC
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Get a single setting by owner and key.
+pub async fn get_user_setting(
+ pool: &PgPool,
+ owner_id: Uuid,
+ key: &str,
+) -> Result<Option<UserSetting>, sqlx::Error> {
+ sqlx::query_as::<_, UserSetting>(
+ r#"
+ SELECT id, owner_id, key, value, created_at, updated_at
+ FROM user_settings
+ WHERE owner_id = $1 AND key = $2
+ "#,
+ )
+ .bind(owner_id)
+ .bind(key)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Upsert a user setting (insert or update on conflict).
+pub async fn upsert_user_setting(
+ pool: &PgPool,
+ owner_id: Uuid,
+ key: &str,
+ value: &serde_json::Value,
+) -> Result<UserSetting, sqlx::Error> {
+ sqlx::query_as::<_, UserSetting>(
+ 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()
+ RETURNING id, owner_id, key, value, created_at, updated_at
+ "#,
+ )
+ .bind(owner_id)
+ .bind(key)
+ .bind(value)
+ .fetch_one(pool)
+ .await
+}
+
+/// Delete a user setting. Returns true if a row was deleted.
+pub async fn delete_user_setting(
+ pool: &PgPool,
+ owner_id: Uuid,
+ key: &str,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM user_settings
+ WHERE owner_id = $1 AND key = $2
+ "#,
+ )
+ .bind(owner_id)
+ .bind(key)
+ .execute(pool)
+ .await?;
+ Ok(result.rows_affected() > 0)
+}
+
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs
index 1e025c8..19077c9 100644
--- a/makima/src/orchestration/directive.rs
+++ b/makima/src/orchestration/directive.rs
@@ -442,8 +442,34 @@ impl DirectiveOrchestrator {
let directives = repository::get_directives_needing_replanning(&self.pool).await?;
for directive in directives {
- if let Err(e) = async {
- tracing::info!(
+ tracing::info!(
+ directive_id = %directive.id,
+ title = %directive.title,
+ "Directive goal updated — spawning re-planning task"
+ );
+
+ 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);
+
+ 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,
title = %directive.title,
"Directive goal updated — spawning re-planning task"
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 4bdb424..b3c433b 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -20,6 +20,7 @@ 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
new file mode 100644
index 0000000..ae52d5a
--- /dev/null
+++ b/makima/src/server/handlers/settings.rs
@@ -0,0 +1,196 @@
+//! 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 b382f04..025ec85 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, 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, settings, speak, templates, transcript_analysis, users, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -281,6 +281,15 @@ 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
.route(
"/settings/repository-history",