summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/frontend/package-lock.json385
-rw-r--r--makima/frontend/package.json8
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx664
-rw-r--r--makima/frontend/src/components/directives/StepsBlockNode.tsx281
-rw-r--r--makima/frontend/src/hooks/useUserSettings.ts115
-rw-r--r--makima/frontend/src/lib/api.ts46
-rw-r--r--makima/frontend/src/routes/directives.tsx33
-rw-r--r--makima/frontend/src/routes/document-directives.tsx394
-rw-r--r--makima/frontend/src/routes/settings.tsx64
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20260305000000_add_user_settings.sql11
-rw-r--r--makima/src/db/repository.rs50
-rw-r--r--makima/src/orchestration/directive.rs346
-rw-r--r--makima/src/server/handlers/directives.rs85
-rw-r--r--makima/src/server/handlers/users.rs154
-rw-r--r--makima/src/server/mod.rs5
-rw-r--r--makima/src/server/openapi.rs4
17 files changed, 2626 insertions, 21 deletions
diff --git a/makima/frontend/package-lock.json b/makima/frontend/package-lock.json
index f1d54d6..382f56f 100644
--- a/makima/frontend/package-lock.json
+++ b/makima/frontend/package-lock.json
@@ -8,8 +8,16 @@
"name": "makima-frontend",
"version": "0.1.0",
"dependencies": {
+ "@lexical/history": "^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",
"@supabase/supabase-js": "^2.90.1",
"@xyflow/react": "^12.10.0",
+ "lexical": "^0.44.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.0",
@@ -905,6 +913,54 @@
"node": ">=18"
}
},
+ "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",
@@ -950,12 +1006,270 @@
"@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/@mjackson/node-fetch-server": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz",
"integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==",
"dev": true
},
+ "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/@react-router/dev": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.11.0.tgz",
@@ -1905,6 +2219,11 @@
"@types/react": "^19.2.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/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -2546,6 +2865,16 @@
"node": ">=18"
}
},
+ "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/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -2585,6 +2914,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/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -3018,6 +3373,14 @@
"react": "^19.2.3"
}
},
+ "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-is": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
@@ -3205,6 +3568,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/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -3469,6 +3837,23 @@
"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"
+ }
+ },
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
diff --git a/makima/frontend/package.json b/makima/frontend/package.json
index 3b908aa..ee89d0b 100644
--- a/makima/frontend/package.json
+++ b/makima/frontend/package.json
@@ -11,8 +11,16 @@
"preview": "vite preview"
},
"dependencies": {
+ "@lexical/history": "^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",
"@supabase/supabase-js": "^2.90.1",
"@xyflow/react": "^12.10.0",
+ "lexical": "^0.44.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.0",
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx
new file mode 100644
index 0000000..40fccf1
--- /dev/null
+++ b/makima/frontend/src/components/directives/DocumentEditor.tsx
@@ -0,0 +1,664 @@
+/**
+ * DocumentEditor — the Lexical-based document body for Document Mode.
+ *
+ * Layout (top to bottom):
+ * - Read-only H1 with the directive's title.
+ * - Editable paragraph with the directive's goal. Editing the goal triggers
+ * a 3-second countdown bar at the bottom of the editor; if the timer
+ * expires, we call updateGoal(). Esc / ⌘Z cancels; further typing extends.
+ * - A custom non-editable StepsBlock decorator node showing each step.
+ *
+ * Right-click anywhere in the editor opens a custom context menu offering
+ * the three directive-level actions: Clean Up, Update PR, Plan Orders.
+ *
+ * Live updates:
+ * - The directive prop is updated by the parent's useDirective polling.
+ * - StepsBlockContextProvider wraps the LexicalComposer so that the steps
+ * block (a Lexical DecoratorNode) sees fresh data on every render
+ * without us having to mutate the editor state.
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $isElementNode,
+ COMMAND_PRIORITY_LOW,
+ KEY_ESCAPE_COMMAND,
+ UNDO_COMMAND,
+ type LexicalEditor,
+} from "lexical";
+import { $createHeadingNode, HeadingNode } from "@lexical/rich-text";
+import { ListNode, ListItemNode } from "@lexical/list";
+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 { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import type { DirectiveWithSteps } from "../../lib/api";
+import {
+ $createStepsBlockNode,
+ $isStepsBlockNode,
+ StepsBlockNode,
+ StepsBlockContextProvider,
+} from "./StepsBlockNode";
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+const SAVE_COUNTDOWN_MS = 3000;
+const SAVED_TOAST_MS = 1200;
+
+// =============================================================================
+// Editor theme — minimal, just enough so the rich-text plugin has something to
+// hang class names on. We rely on our own typography otherwise.
+// =============================================================================
+
+const editorTheme = {
+ paragraph: "makima-doc-paragraph",
+ heading: {
+ h1: "makima-doc-h1",
+ h2: "makima-doc-h2",
+ },
+ text: {
+ bold: "font-bold",
+ italic: "italic",
+ underline: "underline",
+ },
+};
+
+// =============================================================================
+// Plugins
+// =============================================================================
+
+/**
+ * (Re)builds the editor's root content from the directive whenever the
+ * directive ID changes. We keep this controlled so that switching documents
+ * resets the editor cleanly.
+ *
+ * We deliberately do NOT re-seed on every directive update — only on id
+ * change — so the user's in-flight goal edits aren't trampled by a poll that
+ * happens mid-keystroke.
+ */
+function SeedContentPlugin({
+ directive,
+}: {
+ directive: DirectiveWithSteps;
+}) {
+ const [editor] = useLexicalComposerContext();
+ const seededIdRef = useRef<string | null>(null);
+
+ useEffect(() => {
+ if (seededIdRef.current === directive.id) return;
+ seededIdRef.current = directive.id;
+
+ editor.update(
+ () => {
+ const root = $getRoot();
+ root.clear();
+
+ // H1: title (read-only — see ReadOnlyTitlePlugin).
+ const heading = $createHeadingNode("h1");
+ heading.append($createTextNode(directive.title));
+ root.append(heading);
+
+ // Paragraph: goal (editable).
+ const goalPara = $createParagraphNode();
+ if (directive.goal.length > 0) {
+ goalPara.append($createTextNode(directive.goal));
+ }
+ root.append(goalPara);
+
+ // Steps block (decorator — non-editable).
+ root.append($createStepsBlockNode());
+
+ // Trailing empty paragraph so the cursor has somewhere to land below
+ // the steps block.
+ root.append($createParagraphNode());
+ },
+ { tag: "history-merge" },
+ );
+ }, [editor, directive.id, directive.title, directive.goal]);
+
+ return null;
+}
+
+/**
+ * Prevents edits to the H1 (title) node. The title is meant to feel like a
+ * file name — clicking it shows a caret, but typing is no-op'd. We watch the
+ * editor's update stream and, if the H1's text drifts from the seed value,
+ * revert it on the next microtask so the user sees the change get rejected
+ * rather than silently swallowed.
+ */
+function ReadOnlyTitlePlugin({ title }: { title: string }) {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ let reverting = false;
+ return editor.registerUpdateListener(({ editorState }) => {
+ if (reverting) return;
+ editorState.read(() => {
+ const root = $getRoot();
+ const first = root.getFirstChild();
+ if (!first || first.getType() !== "heading") return;
+ const text = first.getTextContent();
+ if (text === title) return;
+ reverting = true;
+ queueMicrotask(() => {
+ editor.update(
+ () => {
+ const r = $getRoot();
+ const h = r.getFirstChild();
+ if (!h || h.getType() !== "heading") {
+ reverting = false;
+ return;
+ }
+ if ($isElementNode(h) && h.getTextContent() !== title) {
+ h.getChildren().forEach((c) => c.remove());
+ if (title.length > 0) {
+ h.append($createTextNode(title));
+ }
+ }
+ reverting = false;
+ },
+ { tag: "history-merge" },
+ );
+ });
+ });
+ });
+ }, [editor, title]);
+
+ return null;
+}
+
+/**
+ * Watches the goal paragraph (the second top-level child) and reports its
+ * current text to the parent on every change. Kept separate from the
+ * countdown bar so the bar is purely a UI concern.
+ */
+function GoalChangePlugin({
+ onGoalChange,
+}: {
+ onGoalChange: (goal: string) => void;
+}) {
+ return (
+ <OnChangePlugin
+ ignoreSelectionChange
+ onChange={(editorState) => {
+ editorState.read(() => {
+ const root = $getRoot();
+ const children = root.getChildren();
+ // The goal lives at index 1 (after the H1 title).
+ const goalNode = children[1];
+ if (!goalNode) return;
+ onGoalChange(goalNode.getTextContent());
+ });
+ }}
+ />
+ );
+}
+
+/**
+ * Wires Lexical commands into UI callbacks. We forward Esc presses (used to
+ * cancel the countdown) and Undo (⌘/Ctrl-Z) without intercepting them.
+ */
+function CountdownKeyBridge({
+ onEsc,
+ onUndo,
+}: {
+ onEsc: () => void;
+ onUndo: () => void;
+}) {
+ const [editor] = useLexicalComposerContext();
+ useEffect(() => {
+ const unEsc = editor.registerCommand(
+ KEY_ESCAPE_COMMAND,
+ () => {
+ onEsc();
+ return false; // don't consume; let other handlers run
+ },
+ COMMAND_PRIORITY_LOW,
+ );
+ const unUndo = editor.registerCommand(
+ UNDO_COMMAND,
+ () => {
+ onUndo();
+ return false;
+ },
+ COMMAND_PRIORITY_LOW,
+ );
+ return () => {
+ unEsc();
+ unUndo();
+ };
+ }, [editor, onEsc, onUndo]);
+ return null;
+}
+
+// =============================================================================
+// Right-click context menu
+// =============================================================================
+
+interface EditorContextMenuProps {
+ x: number;
+ y: number;
+ onClose: () => void;
+ onCleanup: () => void;
+ onUpdatePR: () => void;
+ onPlanOrders: () => void;
+}
+
+function EditorContextMenu({
+ x,
+ y,
+ onClose,
+ onCleanup,
+ onUpdatePR,
+ onPlanOrders,
+}: EditorContextMenuProps) {
+ const ref = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const handleClick = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
+ };
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ document.addEventListener("mousedown", handleClick);
+ document.addEventListener("keydown", handleKey);
+ return () => {
+ document.removeEventListener("mousedown", handleClick);
+ document.removeEventListener("keydown", handleKey);
+ };
+ }, [onClose]);
+
+ // Clamp into viewport.
+ useEffect(() => {
+ if (!ref.current) return;
+ const rect = ref.current.getBoundingClientRect();
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ if (rect.right > vw) ref.current.style.left = `${x - rect.width}px`;
+ if (rect.bottom > vh) ref.current.style.top = `${y - rect.height}px`;
+ }, [x, y]);
+
+ const item =
+ "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
+
+ return (
+ <div
+ ref={ref}
+ className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
+ style={{ left: x, top: y }}
+ >
+ <div className="px-3 py-1.5 text-[10px] font-mono text-[#556677] uppercase border-b border-[rgba(117,170,252,0.2)]">
+ Document
+ </div>
+ <button
+ type="button"
+ className={item}
+ onClick={() => {
+ onCleanup();
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">⎚</span>
+ Clean Up
+ </button>
+ <button
+ type="button"
+ className={item}
+ onClick={() => {
+ onUpdatePR();
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">↗</span>
+ Update PR
+ </button>
+ <button
+ type="button"
+ className={item}
+ onClick={() => {
+ onPlanOrders();
+ onClose();
+ }}
+ >
+ <span className="text-[#c084fc]">◆</span>
+ Plan Orders
+ </button>
+ </div>
+ );
+}
+
+// =============================================================================
+// Countdown bar
+// =============================================================================
+
+interface SaveCountdownBarProps {
+ state: "idle" | "pending" | "saving" | "saved" | "error";
+ remainingMs: number;
+ totalMs: number;
+ onCancel: () => void;
+}
+
+function SaveCountdownBar({
+ state,
+ remainingMs,
+ totalMs,
+ onCancel,
+}: SaveCountdownBarProps) {
+ if (state === "idle") return null;
+
+ let label: string;
+ let progressPct = 0;
+ let tone = "border-[rgba(117,170,252,0.3)] text-[#9bc3ff]";
+
+ if (state === "pending") {
+ const seconds = Math.max(0, Math.ceil(remainingMs / 1000));
+ label = `Saving goal in ${seconds}s — press Esc or Undo to cancel.`;
+ progressPct = Math.max(0, Math.min(100, (1 - remainingMs / totalMs) * 100));
+ } else if (state === "saving") {
+ label = "Saving…";
+ progressPct = 100;
+ tone = "border-emerald-700 text-emerald-300";
+ } else if (state === "saved") {
+ label = "Saved";
+ progressPct = 100;
+ tone = "border-emerald-700 text-emerald-300";
+ } else {
+ label = "Save failed — try again.";
+ progressPct = 100;
+ tone = "border-red-700 text-red-300";
+ }
+
+ return (
+ <div
+ className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`}
+ data-makima-countdown={state}
+ >
+ <div className="h-0.5 bg-[#10203a]">
+ <div
+ className="h-full bg-[#75aafc] transition-[width] duration-100 ease-linear"
+ style={{ width: `${progressPct}%` }}
+ />
+ </div>
+ <div className="flex items-center justify-between px-4 py-1.5">
+ <span className="text-[10px] font-mono">{label}</span>
+ {state === "pending" && (
+ <button
+ type="button"
+ onClick={onCancel}
+ className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
+ >
+ Cancel
+ </button>
+ )}
+ </div>
+ </div>
+ );
+}
+
+// =============================================================================
+// Main component
+// =============================================================================
+
+export interface DocumentEditorProps {
+ directive: DirectiveWithSteps;
+ onUpdateGoal: (goal: string) => Promise<void> | void;
+ onCleanup: () => Promise<void> | void;
+ onCreatePR: () => Promise<void> | void;
+ onPickUpOrders: () => Promise<unknown> | unknown;
+}
+
+type SaveState = "idle" | "pending" | "saving" | "saved" | "error";
+
+export function DocumentEditor({
+ directive,
+ onUpdateGoal,
+ onCleanup,
+ onCreatePR,
+ onPickUpOrders,
+}: DocumentEditorProps) {
+ // ---- Lexical config ----------------------------------------------------
+ const initialConfig = useMemo(
+ () => ({
+ // Re-key the composer when the directive id changes so we get a clean
+ // editor state per document. We do this via the `key` prop on
+ // <LexicalComposer> below as well.
+ namespace: `makima-doc-${directive.id}`,
+ onError: (err: Error) => {
+ // eslint-disable-next-line no-console
+ console.error("[DocumentEditor]", err);
+ },
+ nodes: [HeadingNode, ListNode, ListItemNode, StepsBlockNode],
+ theme: editorTheme,
+ editable: true,
+ }),
+ [directive.id],
+ );
+
+ // ---- Goal auto-save state machine --------------------------------------
+ const [saveState, setSaveState] = useState<SaveState>("idle");
+ const [remainingMs, setRemainingMs] = useState(SAVE_COUNTDOWN_MS);
+ const pendingGoalRef = useRef<string>(directive.goal);
+ const timerRef = useRef<number | null>(null);
+ const tickRef = useRef<number | null>(null);
+ const deadlineRef = useRef<number>(0);
+ const editorRef = useRef<LexicalEditor | null>(null);
+
+ // Reset state when switching directives.
+ useEffect(() => {
+ pendingGoalRef.current = directive.goal;
+ cancelTimers();
+ setSaveState("idle");
+ setRemainingMs(SAVE_COUNTDOWN_MS);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [directive.id]);
+
+ // If the persisted goal updated externally and matches the pending goal,
+ // settle the bar.
+ useEffect(() => {
+ if (saveState === "pending" && pendingGoalRef.current === directive.goal) {
+ cancelTimers();
+ setSaveState("idle");
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [directive.goal]);
+
+ function cancelTimers() {
+ if (timerRef.current != null) {
+ window.clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ if (tickRef.current != null) {
+ window.clearInterval(tickRef.current);
+ tickRef.current = null;
+ }
+ }
+
+ const fireSave = useCallback(async () => {
+ const next = pendingGoalRef.current;
+ cancelTimers();
+ setSaveState("saving");
+ try {
+ await onUpdateGoal(next);
+ setSaveState("saved");
+ window.setTimeout(() => {
+ // Only fade if no new edit has reopened a pending state in the meantime.
+ setSaveState((s) => (s === "saved" ? "idle" : s));
+ }, SAVED_TOAST_MS);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error("Failed to save goal", e);
+ setSaveState("error");
+ window.setTimeout(() => {
+ setSaveState((s) => (s === "error" ? "idle" : s));
+ }, 2500);
+ }
+ }, [onUpdateGoal]);
+
+ const startOrExtendCountdown = useCallback(() => {
+ cancelTimers();
+ deadlineRef.current = Date.now() + SAVE_COUNTDOWN_MS;
+ setSaveState("pending");
+ setRemainingMs(SAVE_COUNTDOWN_MS);
+ tickRef.current = window.setInterval(() => {
+ const remaining = Math.max(0, deadlineRef.current - Date.now());
+ setRemainingMs(remaining);
+ if (remaining <= 0 && tickRef.current != null) {
+ window.clearInterval(tickRef.current);
+ tickRef.current = null;
+ }
+ }, 100);
+ timerRef.current = window.setTimeout(() => {
+ void fireSave();
+ }, SAVE_COUNTDOWN_MS);
+ }, [fireSave]);
+
+ const cancelCountdown = useCallback(() => {
+ if (saveState !== "pending") return;
+ cancelTimers();
+ pendingGoalRef.current = directive.goal; // reset pending edit
+ setSaveState("idle");
+ setRemainingMs(SAVE_COUNTDOWN_MS);
+ // Also revert the editor's goal paragraph back to the persisted value, so
+ // the user sees the rollback.
+ const editor = editorRef.current;
+ if (editor) {
+ editor.update(
+ () => {
+ const root = $getRoot();
+ const goalNode = root.getChildren()[1];
+ if (!goalNode || !$isElementNode(goalNode)) return;
+ goalNode.getChildren().forEach((c) => c.remove());
+ if (directive.goal.length > 0) {
+ goalNode.append($createTextNode(directive.goal));
+ }
+ },
+ { tag: "history-merge" },
+ );
+ }
+ }, [directive.goal, saveState]);
+
+ // Cleanup on unmount.
+ useEffect(() => {
+ return cancelTimers;
+ }, []);
+
+ const handleGoalChange = useCallback(
+ (goal: string) => {
+ pendingGoalRef.current = goal;
+ if (goal === directive.goal) {
+ // Edit reverted — cancel the countdown (if any).
+ if (saveState === "pending") {
+ cancelTimers();
+ setSaveState("idle");
+ }
+ return;
+ }
+ startOrExtendCountdown();
+ },
+ [directive.goal, saveState, startOrExtendCountdown],
+ );
+
+ // ---- Right-click context menu -----------------------------------------
+ const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
+
+ const handleContextMenu = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ setMenu({ x: e.clientX, y: e.clientY });
+ }, []);
+
+ // ---- Render ------------------------------------------------------------
+ return (
+ <div className="flex flex-col h-full overflow-hidden">
+ <StepsBlockContextProvider value={{ directive }}>
+ <LexicalComposer key={directive.id} initialConfig={initialConfig}>
+ {/* Capture the editor ref via a tiny inline plugin */}
+ <EditorRefCapture editorRef={editorRef} />
+ <SeedContentPlugin directive={directive} />
+ <ReadOnlyTitlePlugin title={directive.title} />
+ <HistoryPlugin />
+ <GoalChangePlugin onGoalChange={handleGoalChange} />
+ <CountdownKeyBridge onEsc={cancelCountdown} onUndo={cancelCountdown} />
+
+ <div
+ className="flex-1 overflow-auto"
+ onContextMenu={handleContextMenu}
+ >
+ <div className="max-w-3xl mx-auto px-8 py-10">
+ <RichTextPlugin
+ contentEditable={
+ <ContentEditable
+ aria-placeholder="Describe the directive's goal…"
+ placeholder={
+ <div className="pointer-events-none absolute text-[#445566] font-mono text-[13px] mt-2">
+ Describe the directive's goal…
+ </div>
+ }
+ className="outline-none font-mono text-[13px] leading-relaxed text-[#dbe7ff] [&_.makima-doc-h1]:text-[24px] [&_.makima-doc-h1]:font-medium [&_.makima-doc-h1]:text-white [&_.makima-doc-h1]:mb-3 [&_.makima-doc-h1]:tracking-tight [&_.makima-doc-paragraph]:my-2 [&_.makima-doc-paragraph]:text-[13px] [&_.makima-doc-paragraph]:text-[#c0d0e0] relative"
+ />
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ </div>
+ </div>
+ </LexicalComposer>
+ </StepsBlockContextProvider>
+
+ <SaveCountdownBar
+ state={saveState}
+ remainingMs={remainingMs}
+ totalMs={SAVE_COUNTDOWN_MS}
+ onCancel={cancelCountdown}
+ />
+
+ {menu && (
+ <EditorContextMenu
+ x={menu.x}
+ y={menu.y}
+ onClose={() => setMenu(null)}
+ onCleanup={() => {
+ void onCleanup();
+ }}
+ onUpdatePR={() => {
+ void onCreatePR();
+ }}
+ onPlanOrders={() => {
+ void onPickUpOrders();
+ }}
+ />
+ )}
+ </div>
+ );
+}
+
+/**
+ * Tiny plugin that stashes the LexicalEditor instance on a ref so the parent
+ * component can issue updates from outside (e.g. to revert the goal on cancel).
+ */
+function EditorRefCapture({
+ editorRef,
+}: {
+ editorRef: React.MutableRefObject<LexicalEditor | null>;
+}) {
+ const [editor] = useLexicalComposerContext();
+ useEffect(() => {
+ editorRef.current = editor;
+ return () => {
+ if (editorRef.current === editor) {
+ editorRef.current = null;
+ }
+ };
+ }, [editor, editorRef]);
+ return null;
+}
+
+// Re-export the steps-block helpers so consumers can include the node class
+// in their own initial configs if needed.
+export { $createStepsBlockNode, $isStepsBlockNode, StepsBlockNode };
diff --git a/makima/frontend/src/components/directives/StepsBlockNode.tsx b/makima/frontend/src/components/directives/StepsBlockNode.tsx
new file mode 100644
index 0000000..ab3d7da
--- /dev/null
+++ b/makima/frontend/src/components/directives/StepsBlockNode.tsx
@@ -0,0 +1,281 @@
+/**
+ * StepsBlockNode — a Lexical DecoratorNode that renders the directive's steps
+ * as an in-document, non-editable diagram.
+ *
+ * The actual data (steps, orchestratorTaskId, etc.) does NOT live on the node
+ * itself — that would require us to dispatch a Lexical update on every poll,
+ * which is wasteful and fights against Lexical's content-equality model. The
+ * node is a marker that says "render the steps block here", and the React
+ * component pulls live data from a context provided by DocumentEditor. So when
+ * `useDirective` polls and produces new steps, the StepsBlock re-renders
+ * automatically without touching the editor state at all.
+ */
+import {
+ DecoratorNode,
+ type LexicalNode,
+ type NodeKey,
+ type SerializedLexicalNode,
+ type Spread,
+} from "lexical";
+import { createContext, useContext, type JSX } from "react";
+import type { DirectiveStep, DirectiveWithSteps, StepStatus } from "../../lib/api";
+
+// =============================================================================
+// Context provided by DocumentEditor — the StepsBlock reads live directive data
+// =============================================================================
+
+interface StepsBlockContextValue {
+ directive: DirectiveWithSteps | null;
+}
+
+const StepsBlockContext = createContext<StepsBlockContextValue>({ directive: null });
+
+export const StepsBlockContextProvider = StepsBlockContext.Provider;
+
+// =============================================================================
+// Status palette (matches StepNode.tsx for consistency)
+// =============================================================================
+
+const STATUS_COLORS: Record<StepStatus, { bg: string; border: string; text: string; pill: string }> = {
+ pending: {
+ bg: "bg-[#1a2540]",
+ border: "border-[#2a3a5a]",
+ text: "text-[#7788aa]",
+ pill: "bg-[#0f1a30] text-[#7788aa] border-[#2a3a5a]",
+ },
+ ready: {
+ bg: "bg-[#2a2a10]",
+ border: "border-[#4a4a20]",
+ text: "text-yellow-400",
+ pill: "bg-[#1a1a08] text-yellow-300 border-[#4a4a20]",
+ },
+ running: {
+ bg: "bg-[#0a2a1a]",
+ border: "border-[#1a5a3a]",
+ text: "text-green-400",
+ pill: "bg-[#062014] text-green-300 border-[#1a5a3a]",
+ },
+ completed: {
+ bg: "bg-[#0a2a2a]",
+ border: "border-[#1a5a5a]",
+ text: "text-emerald-400",
+ pill: "bg-[#062424] text-emerald-300 border-[#1a5a5a]",
+ },
+ failed: {
+ bg: "bg-[#2a1a1a]",
+ border: "border-[#5a2a2a]",
+ text: "text-red-400",
+ pill: "bg-[#241010] text-red-300 border-[#5a2a2a]",
+ },
+ skipped: {
+ bg: "bg-[#1a1a2a]",
+ border: "border-[#2a2a4a]",
+ text: "text-[#7788aa]",
+ pill: "bg-[#101020] text-[#7788aa] border-[#2a2a4a]",
+ },
+};
+
+const STATUS_LABEL: Record<StepStatus, string> = {
+ pending: "PENDING",
+ ready: "READY",
+ running: "RUNNING",
+ completed: "DONE",
+ failed: "FAILED",
+ skipped: "SKIP",
+};
+
+// =============================================================================
+// React component rendered inside the editor body
+// =============================================================================
+
+function StepCard({ step }: { step: DirectiveStep }) {
+ const colors = STATUS_COLORS[step.status] ?? STATUS_COLORS.pending;
+ const label = STATUS_LABEL[step.status] ?? step.status.toUpperCase();
+ return (
+ <div
+ className={`${colors.bg} border ${colors.border} rounded px-3 py-2 flex items-start gap-3`}
+ >
+ <span
+ className="text-[10px] font-mono text-[#556677] shrink-0 w-5 text-right"
+ aria-hidden
+ >
+ {String(step.orderIndex + 1).padStart(2, "0")}
+ </span>
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center justify-between gap-2">
+ <span className="text-[12px] font-mono text-white truncate">
+ {step.name}
+ </span>
+ <span
+ className={`text-[9px] font-mono uppercase tracking-wide border rounded px-1.5 py-0.5 shrink-0 ${colors.pill}`}
+ >
+ {label}
+ </span>
+ </div>
+ {step.description && (
+ <p className="text-[10px] font-mono text-[#7788aa] truncate mt-0.5">
+ {step.description}
+ </p>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function StepsBlock(): JSX.Element {
+ const { directive } = useContext(StepsBlockContext);
+
+ // While the directive is loading or absent, render a quiet placeholder so the
+ // editor body still has something visible — but make sure it has the same
+ // outline as the loaded view so the document doesn't reflow.
+ if (!directive) {
+ return (
+ <div
+ contentEditable={false}
+ className="my-3 border border-dashed border-[rgba(117,170,252,0.2)] rounded px-3 py-4 select-none"
+ >
+ <div className="text-[10px] font-mono text-[#556677] uppercase tracking-wide">
+ steps · loading
+ </div>
+ </div>
+ );
+ }
+
+ const steps = [...directive.steps].sort((a, b) => a.orderIndex - b.orderIndex);
+ const isOrchestratorRunning = !!directive.orchestratorTaskId;
+ const completed = steps.filter((s) => s.status === "completed").length;
+ const total = steps.length;
+ const caption = isOrchestratorRunning
+ ? "makima is editing this document"
+ : total === 0
+ ? "no steps yet"
+ : total === 1
+ ? "1 step"
+ : `${total} steps`;
+
+ return (
+ <div
+ // contentEditable={false} keeps Lexical from treating this as editable
+ // content — the user can't put a caret inside it.
+ contentEditable={false}
+ // Use a small data attribute so external CSS / tests can target it.
+ data-makima-block="steps"
+ className="my-3 border border-[rgba(117,170,252,0.2)] rounded bg-[#091428] select-none"
+ >
+ {/* Caption */}
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-dashed border-[rgba(117,170,252,0.15)]">
+ <div className="flex items-center gap-1.5">
+ {isOrchestratorRunning && (
+ <span
+ className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse"
+ aria-hidden
+ />
+ )}
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ {caption}
+ </span>
+ </div>
+ {total > 0 && (
+ <span className="text-[10px] font-mono text-[#556677]">
+ {completed}/{total} done
+ </span>
+ )}
+ </div>
+
+ {/* Step diagram */}
+ {steps.length === 0 ? (
+ <div className="px-3 py-4 text-[11px] font-mono text-[#556677] italic">
+ {isOrchestratorRunning
+ ? "Planner is generating steps…"
+ : "No steps yet — start the directive or plan orders to populate."}
+ </div>
+ ) : (
+ <ol className="px-3 py-3 flex flex-col gap-1.5">
+ {steps.map((step, idx) => (
+ <li key={step.id} className="relative">
+ <StepCard step={step} />
+ {idx < steps.length - 1 && (
+ <div
+ className="absolute left-[18px] -bottom-1 h-1 w-px bg-[rgba(117,170,252,0.2)]"
+ aria-hidden
+ />
+ )}
+ </li>
+ ))}
+ </ol>
+ )}
+ </div>
+ );
+}
+
+// =============================================================================
+// Lexical decorator node
+// =============================================================================
+
+export type SerializedStepsBlockNode = Spread<
+ { /* No fields — the block is a marker; live data comes from context. */ },
+ SerializedLexicalNode
+>;
+
+export class StepsBlockNode extends DecoratorNode<JSX.Element> {
+ static getType(): string {
+ return "makima-steps-block";
+ }
+
+ static clone(node: StepsBlockNode): StepsBlockNode {
+ return new StepsBlockNode(node.__key);
+ }
+
+ constructor(key?: NodeKey) {
+ super(key);
+ }
+
+ createDOM(): HTMLElement {
+ const el = document.createElement("div");
+ el.className = "makima-steps-block-host";
+ return el;
+ }
+
+ updateDOM(): false {
+ return false;
+ }
+
+ static importJSON(_serializedNode: SerializedStepsBlockNode): StepsBlockNode {
+ return $createStepsBlockNode();
+ }
+
+ exportJSON(): SerializedStepsBlockNode {
+ return {
+ type: StepsBlockNode.getType(),
+ version: 1,
+ };
+ }
+
+ isInline(): boolean {
+ return false;
+ }
+
+ isIsolated(): boolean {
+ // Isolated decorator nodes can't be partially selected — the user can only
+ // click into them, not drag a selection into them. That's what we want.
+ return true;
+ }
+
+ isKeyboardSelectable(): boolean {
+ return true;
+ }
+
+ decorate(): JSX.Element {
+ return <StepsBlock />;
+ }
+}
+
+export function $createStepsBlockNode(): StepsBlockNode {
+ return new StepsBlockNode();
+}
+
+export function $isStepsBlockNode(
+ node: LexicalNode | null | undefined,
+): node is StepsBlockNode {
+ return node instanceof StepsBlockNode;
+}
diff --git a/makima/frontend/src/hooks/useUserSettings.ts b/makima/frontend/src/hooks/useUserSettings.ts
new file mode 100644
index 0000000..b39244d
--- /dev/null
+++ b/makima/frontend/src/hooks/useUserSettings.ts
@@ -0,0 +1,115 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ getUserSettings,
+ updateUserSettings,
+ type UserSettings,
+} from "../lib/api";
+
+const DEFAULT_SETTINGS: UserSettings = { documentModeEnabled: false };
+
+// Module-level cache + pub-sub so multiple components mounting the hook stay
+// in sync without a full provider/context. Toggling the flag in <SettingsPage>
+// will reactively update <DirectivesPage> if it's mounted, and vice versa.
+let cachedSettings: UserSettings | null = null;
+let inflight: Promise<void> | null = null;
+const subscribers = new Set<(s: UserSettings | null) => void>();
+
+function notify() {
+ for (const sub of subscribers) sub(cachedSettings);
+}
+
+function loadOnce(): Promise<void> {
+ if (inflight) return inflight;
+ inflight = getUserSettings()
+ .then((s) => {
+ cachedSettings = s;
+ notify();
+ })
+ .catch((err) => {
+ // Swallow but log — fall back to safe defaults so the existing UI keeps
+ // rendering even if /settings endpoint is unavailable.
+ console.error("Failed to load user settings:", err);
+ cachedSettings = DEFAULT_SETTINGS;
+ notify();
+ })
+ .finally(() => {
+ inflight = null;
+ });
+ return inflight;
+}
+
+export interface UseUserSettingsResult {
+ /** Loaded settings, or null while loading for the first time. */
+ settings: UserSettings | null;
+ /** True while the initial GET is in flight. */
+ loading: boolean;
+ /** Update one or more settings; persists via PUT and updates the cache. */
+ update: (patch: Partial<UserSettings>) => Promise<UserSettings>;
+ /** Force a refresh from the server (e.g. after sign-in). */
+ refresh: () => Promise<void>;
+}
+
+/**
+ * React hook for the per-user settings record (feature flags).
+ *
+ * Calls GET /api/v1/users/me/settings on first mount and caches the result.
+ * Subsequent mounts read from the cache. `update()` PUTs to the server and
+ * notifies all live subscribers so UI gates reactively flip without a reload.
+ */
+export function useUserSettings(): UseUserSettingsResult {
+ const [settings, setSettings] = useState<UserSettings | null>(cachedSettings);
+ const [loading, setLoading] = useState<boolean>(cachedSettings === null);
+
+ useEffect(() => {
+ let mounted = true;
+ const sub = (s: UserSettings | null) => {
+ if (!mounted) return;
+ setSettings(s);
+ setLoading(false);
+ };
+ subscribers.add(sub);
+
+ if (cachedSettings === null) {
+ loadOnce();
+ } else {
+ // Already cached — make sure local state matches.
+ setSettings(cachedSettings);
+ setLoading(false);
+ }
+
+ return () => {
+ mounted = false;
+ subscribers.delete(sub);
+ };
+ }, []);
+
+ const update = useCallback(
+ async (patch: Partial<UserSettings>): Promise<UserSettings> => {
+ const base = cachedSettings ?? DEFAULT_SETTINGS;
+ const merged: UserSettings = { ...base, ...patch };
+ // Optimistic update so the toggle flips immediately.
+ cachedSettings = merged;
+ notify();
+ try {
+ const result = await updateUserSettings(merged);
+ cachedSettings = result;
+ notify();
+ return result;
+ } catch (err) {
+ // Roll back to last-known-good on failure.
+ cachedSettings = base;
+ notify();
+ throw err;
+ }
+ },
+ [],
+ );
+
+ const refresh = useCallback(async () => {
+ cachedSettings = null;
+ notify();
+ await loadOnce();
+ }, []);
+
+ return { settings, loading, update, refresh };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index d597b44..8896f2c 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -1696,6 +1696,52 @@ export async function deleteAccount(
}
// =============================================================================
+// User Settings (per-user feature flags)
+// =============================================================================
+
+/** Per-user settings / feature flags. */
+export interface UserSettings {
+ /** Whether the new "document mode" UI is enabled for this user. */
+ documentModeEnabled: boolean;
+}
+
+/** Request body for updating user settings. */
+export interface UpdateUserSettingsRequest {
+ documentModeEnabled: boolean;
+}
+
+/**
+ * Get the authenticated user's settings (feature flags).
+ */
+export async function getUserSettings(): Promise<UserSettings> {
+ const res = await authFetch(`${API_BASE}/api/v1/users/me/settings`);
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ const errorMessage = errorData?.message || res.statusText;
+ throw new Error(errorMessage);
+ }
+ return res.json();
+}
+
+/**
+ * Replace the authenticated user's settings (feature flags).
+ */
+export async function updateUserSettings(
+ req: UpdateUserSettingsRequest
+): Promise<UserSettings> {
+ const res = await authFetch(`${API_BASE}/api/v1/users/me/settings`, {
+ method: "PUT",
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ const errorMessage = errorData?.message || res.statusText;
+ throw new Error(errorMessage);
+ }
+ return res.json();
+}
+
+// =============================================================================
// Contract Types for Workflow Management
// =============================================================================
diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx
index 8de0335..895c86a 100644
--- a/makima/frontend/src/routes/directives.tsx
+++ b/makima/frontend/src/routes/directives.tsx
@@ -5,10 +5,43 @@ import { DirectiveList } from "../components/directives/DirectiveList";
import { DirectiveDetail } from "../components/directives/DirectiveDetail";
import { useDirectives, useDirective } from "../hooks/useDirectives";
import { useDogs } from "../hooks/useDogs";
+import { useUserSettings } from "../hooks/useUserSettings";
import { useAuth } from "../contexts/AuthContext";
+import DocumentDirectivesPage from "./document-directives";
import { getRepositorySuggestions, startDirective, pauseDirective, updateDirective, type RepositoryHistoryEntry, type DirectiveSummary } from "../lib/api";
+/**
+ * Top-level /directives route. Gates between the legacy tabular UI and the
+ * Document Mode (POC) UI based on the user's settings flag.
+ *
+ * Both code paths support /directives/:id deep links — the param is read by
+ * each branch independently via useParams.
+ */
export default function DirectivesPage() {
+ const { settings, loading: settingsLoading } = useUserSettings();
+
+ // While settings are loading for the very first time, render nothing inside
+ // a Masthead-wrapped shell so we don't briefly flash the legacy UI just to
+ // swap to document mode a moment later.
+ if (settingsLoading && !settings) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ if (settings?.documentModeEnabled) {
+ return <DocumentDirectivesPage />;
+ }
+
+ return <LegacyDirectivesPage />;
+}
+
+function LegacyDirectivesPage() {
const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const { id: selectedId } = useParams<{ id: string }>();
diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx
new file mode 100644
index 0000000..42e6a69
--- /dev/null
+++ b/makima/frontend/src/routes/document-directives.tsx
@@ -0,0 +1,394 @@
+import { useEffect, useMemo, useState } from "react";
+import { useNavigate, useParams } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { useDirective, useDirectives } from "../hooks/useDirectives";
+import { useAuth } from "../contexts/AuthContext";
+import { DocumentEditor } from "../components/directives/DocumentEditor";
+import type { DirectiveSummary, DirectiveStatus } from "../lib/api";
+
+// Status dot color, matching the existing tabular UI's badge palette so the
+// document mode feels like a sibling of the existing list, not a foreign UI.
+const STATUS_DOT: Record<DirectiveStatus, string> = {
+ draft: "bg-[#556677]",
+ active: "bg-green-400",
+ idle: "bg-yellow-400",
+ paused: "bg-orange-400",
+ archived: "bg-[#3a4a6a]",
+};
+
+// =============================================================================
+// Sidebar grouping — group directives by lifecycle stage so the file tree
+// reads like a folder per status. We collapse the noisy ones (Archived) by
+// default and keep Active / Idle expanded.
+// =============================================================================
+
+type SidebarGroup = "active" | "idle" | "archived";
+
+const GROUP_LABEL: Record<SidebarGroup, string> = {
+ active: "active",
+ idle: "idle",
+ archived: "archived",
+};
+
+function bucketOf(status: DirectiveStatus): SidebarGroup {
+ if (status === "active" || status === "paused") return "active";
+ if (status === "archived") return "archived";
+ // draft + idle land in the idle bucket (i.e. "not currently running").
+ return "idle";
+}
+
+// =============================================================================
+// Sidebar icons (inline SVG, no new deps)
+// =============================================================================
+
+function FolderIcon({ open = false }: { open?: boolean }) {
+ return (
+ <svg
+ viewBox="0 0 16 16"
+ width={12}
+ height={12}
+ className="shrink-0"
+ aria-hidden
+ >
+ {open ? (
+ <path
+ d="M1.5 3.5a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V6H1.5V3.5z M1 6.5h13.382a.5.5 0 0 1 .49.598l-.9 5A.5.5 0 0 1 13.482 12.5H2.518a.5.5 0 0 1-.49-.402l-.9-5A.5.5 0 0 1 1.62 6.5H1z"
+ fill="#75aafc"
+ opacity="0.85"
+ />
+ ) : (
+ <path
+ d="M1.5 4a1 1 0 0 1 1-1h3.382a1 1 0 0 1 .894.553l.448.894a1 1 0 0 0 .894.553H13.5a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4z"
+ fill="#75aafc"
+ opacity="0.65"
+ />
+ )}
+ </svg>
+ );
+}
+
+function FileIcon() {
+ return (
+ <svg
+ viewBox="0 0 16 16"
+ width={12}
+ height={12}
+ className="shrink-0"
+ aria-hidden
+ >
+ <path
+ d="M3 1.5h6.293a1 1 0 0 1 .707.293l3 3A1 1 0 0 1 13.293 5.5H13V14a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V1.5z"
+ fill="none"
+ stroke="#9bc3ff"
+ strokeWidth="1"
+ />
+ <path
+ d="M9.5 1.5v3h3"
+ fill="none"
+ stroke="#9bc3ff"
+ strokeWidth="1"
+ />
+ </svg>
+ );
+}
+
+function Caret({ open }: { open: boolean }) {
+ return (
+ <svg
+ viewBox="0 0 8 8"
+ width={8}
+ height={8}
+ className={`shrink-0 transition-transform ${open ? "rotate-90" : ""}`}
+ aria-hidden
+ >
+ <path d="M2 1l4 3-4 3z" fill="#7788aa" />
+ </svg>
+ );
+}
+
+// =============================================================================
+// Sidebar
+// =============================================================================
+
+interface SidebarProps {
+ directives: DirectiveSummary[];
+ loading: boolean;
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+}
+
+function DocumentSidebar({ directives, loading, selectedId, onSelect }: SidebarProps) {
+ const groups: Record<SidebarGroup, DirectiveSummary[]> = useMemo(() => {
+ const out: Record<SidebarGroup, DirectiveSummary[]> = {
+ active: [],
+ idle: [],
+ archived: [],
+ };
+ for (const d of directives) {
+ out[bucketOf(d.status)].push(d);
+ }
+ // Sort each group alphabetically so it feels like a stable file tree.
+ (Object.keys(out) as SidebarGroup[]).forEach((k) => {
+ out[k].sort((a, b) =>
+ a.title.localeCompare(b.title, undefined, { sensitivity: "base" }),
+ );
+ });
+ return out;
+ }, [directives]);
+
+ // Default-collapsed state per folder. Archived is collapsed by default
+ // (it's history); the other two are open so users see their work.
+ const [openGroups, setOpenGroups] = useState<Record<SidebarGroup, boolean>>({
+ active: true,
+ idle: true,
+ archived: false,
+ });
+
+ const toggleGroup = (g: SidebarGroup) =>
+ setOpenGroups((prev) => ({ ...prev, [g]: !prev[g] }));
+
+ return (
+ <div className="flex flex-col h-full">
+ {/* Sidebar header */}
+ <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.2)]">
+ <span className="text-[11px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Documents
+ </span>
+ <span className="text-[10px] font-mono text-[#556677]">
+ {directives.length}
+ </span>
+ </div>
+
+ {/* Top-level "directives/" folder */}
+ <div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono text-[#9bc3ff]">
+ <FolderIcon open />
+ <span>directives/</span>
+ </div>
+
+ {/* Body */}
+ <div className="flex-1 overflow-y-auto pb-4">
+ {loading && directives.length === 0 ? (
+ <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
+ Loading...
+ </div>
+ ) : directives.length === 0 ? (
+ <div className="px-3 py-6 text-center text-[#556677] font-mono text-[11px]">
+ No directives yet
+ </div>
+ ) : (
+ (Object.keys(groups) as SidebarGroup[]).map((group) => {
+ const list = groups[group];
+ if (list.length === 0) return null;
+ const open = openGroups[group];
+ return (
+ <div key={group} className="select-none">
+ {/* Group header (sub-folder) */}
+ <button
+ type="button"
+ onClick={() => toggleGroup(group)}
+ className="w-full flex items-center gap-1.5 pl-4 pr-3 py-1 font-mono text-[11px] text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.05)]"
+ >
+ <Caret open={open} />
+ <FolderIcon open={open} />
+ <span>{GROUP_LABEL[group]}/</span>
+ <span className="ml-auto text-[10px] text-[#556677]">
+ {list.length}
+ </span>
+ </button>
+
+ {/* Files inside the group */}
+ {open && (
+ <ul className="py-0.5">
+ {list.map((d) => {
+ const isSelected = d.id === selectedId;
+ const dot = STATUS_DOT[d.status] ?? STATUS_DOT.draft;
+ const slug = d.title
+ .trim()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-zA-Z0-9._-]/g, "")
+ .toLowerCase();
+ const fileName =
+ slug.length > 0 ? `${slug}.md` : `${d.id.slice(0, 8)}.md`;
+ const orchestratorRunning = !!d.orchestratorTaskId;
+ return (
+ <li key={d.id}>
+ <button
+ type="button"
+ onClick={() => onSelect(d.id)}
+ title={d.title}
+ className={`w-full text-left flex items-center gap-1.5 pl-9 pr-3 py-1 font-mono text-[11px] transition-colors ${
+ isSelected
+ ? "bg-[rgba(117,170,252,0.12)] text-white border-l-2 border-[#75aafc]"
+ : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.06)] border-l-2 border-transparent"
+ }`}
+ >
+ <FileIcon />
+ <span
+ className={`inline-block w-1.5 h-1.5 rounded-full shrink-0 ${dot}`}
+ aria-hidden
+ />
+ <span className="truncate flex-1">{fileName}</span>
+ {orchestratorRunning && (
+ <span
+ className="inline-block w-1.5 h-1.5 rounded-full shrink-0 bg-yellow-400 animate-pulse"
+ title="Orchestrator running"
+ aria-label="Orchestrator running"
+ />
+ )}
+ </button>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </div>
+ );
+ })
+ )}
+ </div>
+ </div>
+ );
+}
+
+// =============================================================================
+// Editor shell — wraps DocumentEditor and handles the "no document selected"
+// and loading states.
+// =============================================================================
+
+interface EditorShellProps {
+ selectedId: string | undefined;
+ hasDirectives: boolean;
+ listLoading: boolean;
+}
+
+function EditorShell({ selectedId, hasDirectives, listLoading }: EditorShellProps) {
+ const {
+ directive,
+ loading,
+ updateGoal,
+ cleanup,
+ createPR,
+ pickUpOrders,
+ } = useDirective(selectedId);
+
+ if (!selectedId) {
+ return (
+ <div className="flex-1 flex items-center justify-center h-full">
+ <p className="text-[#556677] font-mono text-[12px]">
+ {listLoading
+ ? "Loading documents..."
+ : hasDirectives
+ ? "Select a document from the sidebar"
+ : "No documents yet — create one from the legacy UI"}
+ </p>
+ </div>
+ );
+ }
+
+ if (loading && !directive) {
+ return (
+ <div className="flex-1 flex items-center justify-center h-full">
+ <p className="text-[#556677] font-mono text-[12px]">Loading document...</p>
+ </div>
+ );
+ }
+
+ if (!directive) {
+ return (
+ <div className="flex-1 flex items-center justify-center h-full">
+ <p className="text-[#7788aa] font-mono text-[12px]">Document not found</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex-1 flex flex-col h-full overflow-hidden">
+ {/* Document header — breadcrumb-like, mirrors a code editor's tab bar */}
+ <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]">
+ <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">
+ <FileIcon />
+ <span>directives /</span>
+ <span className="text-[#9bc3ff]">{directive.id.slice(0, 8)}</span>
+ {!!directive.orchestratorTaskId && (
+ <span className="ml-2 inline-flex items-center gap-1 text-yellow-400">
+ <span className="inline-block w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
+ orchestrator running
+ </span>
+ )}
+ </div>
+ </div>
+
+ {/* Lexical editor body */}
+ <DocumentEditor
+ directive={directive}
+ onUpdateGoal={async (goal) => {
+ await updateGoal(goal);
+ }}
+ onCleanup={async () => {
+ await cleanup();
+ }}
+ onCreatePR={async () => {
+ await createPR();
+ }}
+ onPickUpOrders={async () => {
+ await pickUpOrders();
+ }}
+ />
+ </div>
+ );
+}
+
+// =============================================================================
+// Page
+// =============================================================================
+
+export default function DocumentDirectivesPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+ const { id: selectedId } = useParams<{ id: string }>();
+ const { directives, loading: listLoading } = useDirectives();
+
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ if (authLoading) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main
+ className="flex-1 flex overflow-hidden"
+ style={{ height: "calc(100vh - 80px)" }}
+ >
+ {/* Left: file-tree sidebar */}
+ <div className="w-[240px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]">
+ <DocumentSidebar
+ directives={directives}
+ loading={listLoading}
+ selectedId={selectedId ?? null}
+ onSelect={(id) => navigate(`/directives/${id}`)}
+ />
+ </div>
+
+ {/* Right: Lexical editor */}
+ <EditorShell
+ selectedId={selectedId}
+ hasDirectives={directives.length > 0}
+ listLoading={listLoading}
+ />
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx
index 73537bd..a77ad95 100644
--- a/makima/frontend/src/routes/settings.tsx
+++ b/makima/frontend/src/routes/settings.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect, type FormEvent } from "react";
import { useAuth } from "../contexts/AuthContext";
import { useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
+import { useUserSettings } from "../hooks/useUserSettings";
import {
getApiKey,
createApiKey,
@@ -267,6 +268,11 @@ export default function SettingsPage() {
const { user, isAuthConfigured, signOut } = useAuth();
const navigate = useNavigate();
+ // User settings (feature flags) state
+ const { settings: userSettings, loading: userSettingsLoading, update: updateUserSettings } = useUserSettings();
+ const [featureFlagSaving, setFeatureFlagSaving] = useState(false);
+ const [featureFlagError, setFeatureFlagError] = useState<string | null>(null);
+
// API Key state
const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
const [newKey, setNewKey] = useState<string | null>(null);
@@ -490,6 +496,21 @@ export default function SettingsPage() {
}
};
+ // Feature flag toggle handlers
+ const handleToggleDocumentMode = async () => {
+ if (featureFlagSaving) return;
+ setFeatureFlagError(null);
+ setFeatureFlagSaving(true);
+ try {
+ const next = !(userSettings?.documentModeEnabled ?? false);
+ await updateUserSettings({ documentModeEnabled: next });
+ } catch (err) {
+ setFeatureFlagError(err instanceof Error ? err.message : "Failed to update setting");
+ } finally {
+ setFeatureFlagSaving(false);
+ }
+ };
+
const passwordStrength = getPasswordStrength(passwordForm.newPassword);
return (
@@ -789,6 +810,49 @@ export default function SettingsPage() {
</section>
)}
+ {/* Feature Flags (POC) */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Feature Flags (POC)</SectionHeader>
+ {featureFlagError && <ErrorAlert>{featureFlagError}</ErrorAlert>}
+ <div className="flex items-start gap-3">
+ <button
+ type="button"
+ role="switch"
+ aria-checked={userSettings?.documentModeEnabled ?? false}
+ aria-label="Document Mode for directives"
+ onClick={handleToggleDocumentMode}
+ disabled={userSettingsLoading || featureFlagSaving}
+ className={`relative shrink-0 mt-0.5 w-10 h-5 border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
+ userSettings?.documentModeEnabled
+ ? "bg-[#3f6fb3] border-[#75aafc]"
+ : "bg-[#0a1628] border-[rgba(117,170,252,0.35)]"
+ }`}
+ >
+ <span
+ className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 transition-transform ${
+ userSettings?.documentModeEnabled
+ ? "translate-x-5 bg-white"
+ : "translate-x-0 bg-[#9bc3ff]"
+ }`}
+ />
+ </button>
+ <div className="flex-1 min-w-0">
+ <div className="font-mono text-xs text-[#9bc3ff] mb-1">
+ Document Mode for directives
+ </div>
+ <p className="font-mono text-[10px] text-[#7788aa] leading-snug">
+ Replaces the tabular directives UI with a Lexical-based interactive
+ document editor. Proof of concept; expect rough edges.
+ </p>
+ {(userSettingsLoading || featureFlagSaving) && (
+ <p className="font-mono text-[10px] text-[#556677] mt-1">
+ {userSettingsLoading ? "Loading..." : "Saving..."}
+ </p>
+ )}
+ </div>
+ </div>
+ </section>
+
{/* Danger Zone */}
{isAuthConfigured && user && (
<section className="border border-red-900/50 bg-[#0d1b2d] p-4">
diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo
index 59abd45..56c723a 100644
--- a/makima/frontend/tsconfig.tsbuildinfo
+++ b/makima/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/doglist.tsx","./src/components/directives/directivecontextmenu.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/directivelogstream.tsx","./src/components/directives/orchestratorstepnode.tsx","./src/components/directives/stepnode.tsx","./src/components/directives/taskslideoutpanel.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/orders/ordercontextmenu.tsx","./src/components/orders/orderdetail.tsx","./src/components/orders/orderlist.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usedogs.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usemultitasksubscription.ts","./src/hooks/useorders.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/daemons.tsx","./src/routes/directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/orders.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
+{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/directives/doglist.tsx","./src/components/directives/directivecontextmenu.tsx","./src/components/directives/directivedag.tsx","./src/components/directives/directivedetail.tsx","./src/components/directives/directivelist.tsx","./src/components/directives/directivelogstream.tsx","./src/components/directives/documenteditor.tsx","./src/components/directives/orchestratorstepnode.tsx","./src/components/directives/stepnode.tsx","./src/components/directives/stepsblocknode.tsx","./src/components/directives/taskslideoutpanel.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/orders/ordercontextmenu.tsx","./src/components/orders/orderdetail.tsx","./src/components/orders/orderlist.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usedogs.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usemultitasksubscription.ts","./src/hooks/useorders.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useusersettings.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/daemons.tsx","./src/routes/directives.tsx","./src/routes/document-directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/orders.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
diff --git a/makima/migrations/20260305000000_add_user_settings.sql b/makima/migrations/20260305000000_add_user_settings.sql
new file mode 100644
index 0000000..0b84b1c
--- /dev/null
+++ b/makima/migrations/20260305000000_add_user_settings.sql
@@ -0,0 +1,11 @@
+-- Add user-level settings columns.
+--
+-- For now we only add a per-user feature flag controlling whether the user
+-- sees the new "document mode" UI. Defaults to FALSE so existing users are
+-- unaffected; the frontend gates on this flag.
+
+ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS document_mode_enabled BOOLEAN NOT NULL DEFAULT FALSE;
+
+COMMENT ON COLUMN users.document_mode_enabled IS
+ 'Per-user feature flag: when true, the frontend shows the new document-mode UI.';
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 57e8a78..ca07d92 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -4962,6 +4962,22 @@ pub async fn get_directive_for_owner(
.await
}
+/// Get a directive without an owner scope check.
+///
+/// Used by background orchestration code that has already established the
+/// directive identity through other means (e.g. it just received the
+/// directive_id from a different already-authorized query). HTTP handlers
+/// must continue to use `get_directive_for_owner` to enforce isolation.
+pub async fn get_directive(
+ pool: &PgPool,
+ id: Uuid,
+) -> Result<Option<Directive>, sqlx::Error> {
+ sqlx::query_as::<_, Directive>(r#"SELECT * FROM directives WHERE id = $1"#)
+ .bind(id)
+ .fetch_optional(pool)
+ .await
+}
+
/// Get a directive with all its steps.
pub async fn get_directive_with_steps_for_owner(
pool: &PgPool,
@@ -5637,6 +5653,40 @@ pub async fn update_directive_goal(
.await
}
+/// Update a directive's goal WITHOUT clearing the orchestrator task id.
+///
+/// This is the path used by the goal-edit interrupt cycle: when a small goal
+/// edit arrives while a planner is already running, we want to keep the
+/// planner attached so a `SendMessage` can summarise the change in-flight
+/// instead of cancelling and respawning. We still bump `goal_updated_at` so
+/// the timestamp reflects the edit, but we do NOT trigger replanning by
+/// clearing the orchestrator task. We also do not flip status from
+/// idle/paused → active here, since by definition a planner is already
+/// running.
+pub async fn update_directive_goal_keep_orchestrator(
+ pool: &PgPool,
+ owner_id: Uuid,
+ directive_id: Uuid,
+ goal: &str,
+) -> Result<Option<Directive>, sqlx::Error> {
+ sqlx::query_as::<_, Directive>(
+ r#"
+ UPDATE directives
+ SET goal = $3,
+ goal_updated_at = NOW(),
+ updated_at = NOW(),
+ version = version + 1
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(directive_id)
+ .bind(owner_id)
+ .bind(goal)
+ .fetch_optional(pool)
+ .await
+}
+
/// Save a goal to the directive goal history.
pub async fn save_directive_goal_history(
pool: &PgPool,
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs
index 8b3ae7e..22279e8 100644
--- a/makima/src/orchestration/directive.rs
+++ b/makima/src/orchestration/directive.rs
@@ -56,7 +56,7 @@ impl DirectiveOrchestrator {
"Directive needs planning — spawning planning task"
);
- let plan = build_planning_prompt(&directive, &[], 1, &[]);
+ let plan = build_planning_prompt(&directive, &[], 1, &[], None);
if let Err(e) = self
.spawn_orchestrator_task(
@@ -477,8 +477,20 @@ impl DirectiveOrchestrator {
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 steps are currently running (or recently completed), build a
+ // WORK IN PROGRESS summary for the planner so it doesn't re-issue
+ // already-running work. We only include this section when there
+ // really is work in flight — pure pending plans don't need it.
+ let progress_summary =
+ summarize_in_progress_steps(&existing_steps);
+
+ let plan = build_planning_prompt(
+ &directive,
+ &existing_steps,
+ generation,
+ &goal_history,
+ progress_summary.as_deref(),
+ );
if let Err(e) = self
.spawn_orchestrator_task(
@@ -1390,15 +1402,97 @@ pub async fn trigger_completion_task(
Ok(task.id)
}
+/// Summarize currently-running and recently-completed steps for the planner.
+///
+/// Returns `None` when there is no in-flight or recently-completed work to
+/// report; otherwise returns a multi-line block listing running steps first
+/// (these are the ones the planner most needs to be aware of so it doesn't
+/// re-issue them) followed by recently completed steps.
+fn summarize_in_progress_steps(
+ steps: &[crate::db::models::DirectiveStep],
+) -> Option<String> {
+ let mut running: Vec<&crate::db::models::DirectiveStep> = Vec::new();
+ let mut completed: Vec<&crate::db::models::DirectiveStep> = Vec::new();
+
+ for step in steps {
+ match step.status.as_str() {
+ "running" => running.push(step),
+ "completed" => completed.push(step),
+ _ => {}
+ }
+ }
+
+ if running.is_empty() && completed.is_empty() {
+ return None;
+ }
+
+ let mut out = String::new();
+ if !running.is_empty() {
+ out.push_str("Currently running:\n");
+ for step in &running {
+ out.push_str(&format!(
+ " • {} (id: {}){}\n",
+ step.name,
+ step.id,
+ step.description
+ .as_deref()
+ .map(|d| format!(" — {}", d))
+ .unwrap_or_default()
+ ));
+ }
+ }
+ if !completed.is_empty() {
+ if !running.is_empty() {
+ out.push('\n');
+ }
+ out.push_str("Recently completed (work already done — do not re-issue):\n");
+ for step in &completed {
+ out.push_str(&format!(
+ " • {} (id: {}){}\n",
+ step.name,
+ step.id,
+ step.description
+ .as_deref()
+ .map(|d| format!(" — {}", d))
+ .unwrap_or_default()
+ ));
+ }
+ }
+
+ Some(out)
+}
+
/// Build the planning prompt for a directive.
+///
+/// `progress_summary` — when supplied, a `WORK IN PROGRESS` section is rendered
+/// near the top of the prompt so the (re)planning task knows what step work is
+/// currently in flight or recently completed. This is used by replanning when
+/// steps are running but the planner has finished, so the new plan can take
+/// in-progress work into account instead of re-issuing it.
fn build_planning_prompt(
directive: &crate::db::models::Directive,
existing_steps: &[crate::db::models::DirectiveStep],
generation: i32,
goal_history: &[crate::db::models::DirectiveGoalHistory],
+ progress_summary: Option<&str>,
) -> String {
let mut prompt = String::new();
+ if let Some(progress) = progress_summary {
+ let trimmed = progress.trim();
+ if !trimmed.is_empty() {
+ prompt.push_str("── WORK IN PROGRESS ──\n");
+ prompt.push_str(
+ "Steps from the previous plan are already executing or recently completed. \
+ Take this into account when revising the plan: do not re-issue work that is \
+ already underway, and prefer to extend / refine the in-flight chain rather \
+ than rebuild it.\n\n",
+ );
+ prompt.push_str(trimmed);
+ prompt.push_str("\n\n");
+ }
+ }
+
if !existing_steps.is_empty() {
// ── RE-PLANNING header ──────────────────────────────────────
prompt.push_str(&format!(
@@ -2364,3 +2458,249 @@ Do NOT ask questions for trivial decisions — use your best judgment.
prompt
}
+
+// =============================================================================
+// Goal-edit classification (small vs large) and interrupt helpers
+// =============================================================================
+
+/// Classification of a goal change for the goal-edit interrupt cycle.
+///
+/// When a user edits a directive's goal while a planning/replanning task is
+/// already running, we want to differentiate between:
+/// • Small edits (typo fixes, clarifications, small additions) → interrupt
+/// the current planner with a `SendMessage` so it can adjust its in-flight
+/// plan rather than throwing away its work.
+/// • Large edits (substantial rewrites, completely different objective) →
+/// fall back to the existing replan path (cancel + spawn a new planner).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GoalChangeKind {
+ /// Small change — interrupt the running planner with the diff.
+ Small,
+ /// Large change — proceed with full replan.
+ Large,
+}
+
+/// Heuristic: classify a goal edit as small or large.
+///
+/// Rules (POC heuristic, kept deliberately simple):
+/// 1. Empty old goal or empty new goal → Large (treat as a fresh start).
+/// 2. If one goal is a prefix of the other → Small (pure addition / truncation).
+/// 3. If the absolute length difference relative to the longer goal is < 0.3,
+/// classify as Small. Otherwise Large.
+pub fn classify_goal_change(old: &str, new: &str) -> GoalChangeKind {
+ let old = old.trim();
+ let new = new.trim();
+
+ if old.is_empty() || new.is_empty() {
+ return GoalChangeKind::Large;
+ }
+
+ if old == new {
+ // No content change — treat as small (no-op for the planner).
+ return GoalChangeKind::Small;
+ }
+
+ // Pure prefix changes (added a sentence at the end, or removed a trailing
+ // clause) are almost always small.
+ if old.starts_with(new) || new.starts_with(old) {
+ return GoalChangeKind::Small;
+ }
+
+ let old_len = old.chars().count();
+ let new_len = new.chars().count();
+ let longer = old_len.max(new_len) as f64;
+ let diff = (old_len as i64 - new_len as i64).unsigned_abs() as f64;
+ if longer == 0.0 {
+ return GoalChangeKind::Large;
+ }
+ let length_ratio = diff / longer;
+
+ if length_ratio < 0.3 {
+ GoalChangeKind::Small
+ } else {
+ GoalChangeKind::Large
+ }
+}
+
+/// Format the goal-edit interrupt message sent to a running planner task
+/// when the user edits the directive goal mid-flight.
+pub fn build_goal_edit_interrupt_message(old_goal: &str, new_goal: &str) -> String {
+ format!(
+ "GOAL_UPDATED: The user has edited the directive goal. Summary of changes follows. \
+ Adjust your current plan in-flight rather than starting over.\n\
+ --- OLD GOAL ---\n\
+ {old}\n\
+ --- NEW GOAL ---\n\
+ {new}\n",
+ old = old_goal,
+ new = new_goal,
+ )
+}
+
+/// Result of attempting to send a goal-edit interrupt to a running planner.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GoalEditInterruptResult {
+ /// A `SendMessage` daemon command was dispatched to the running planner.
+ Sent,
+ /// No suitable planner task was running, or the change was classified as
+ /// large — caller should fall through to the regular replanning path.
+ Skipped,
+}
+
+/// Attempt to interrupt a directive's currently-running planner with a goal
+/// edit summary instead of replanning from scratch.
+///
+/// Returns `Ok(GoalEditInterruptResult::Sent)` when a `SendMessage` was
+/// dispatched. Returns `Ok(GoalEditInterruptResult::Skipped)` when the change
+/// was large, no orchestrator task exists, the task has already finished, or
+/// no daemon is currently assigned.
+///
+/// This function is best-effort: errors talking to the daemon are logged and
+/// translated into `Skipped` so the caller can fall through to the normal
+/// replan path.
+pub async fn try_interrupt_planner_with_goal_edit(
+ pool: &PgPool,
+ state: &SharedState,
+ directive_id: Uuid,
+ old_goal: &str,
+ new_goal: &str,
+) -> Result<GoalEditInterruptResult, anyhow::Error> {
+ // Only fire if the change classifies as small.
+ if classify_goal_change(old_goal, new_goal) != GoalChangeKind::Small {
+ tracing::debug!(
+ directive_id = %directive_id,
+ "Goal change classified as large — skipping planner interrupt"
+ );
+ return Ok(GoalEditInterruptResult::Skipped);
+ }
+
+ // Look up the directive's current orchestrator task (planner).
+ let directive = match repository::get_directive(pool, directive_id).await? {
+ Some(d) => d,
+ None => return Ok(GoalEditInterruptResult::Skipped),
+ };
+ let Some(orchestrator_task_id) = directive.orchestrator_task_id else {
+ return Ok(GoalEditInterruptResult::Skipped);
+ };
+
+ // Fetch the planner task to confirm it's still queued/running.
+ let task = match repository::get_task(pool, orchestrator_task_id).await? {
+ Some(t) => t,
+ None => return Ok(GoalEditInterruptResult::Skipped),
+ };
+
+ let interruptible = matches!(
+ task.status.as_str(),
+ "queued" | "pending" | "starting" | "running"
+ );
+ if !interruptible {
+ tracing::debug!(
+ directive_id = %directive_id,
+ task_id = %orchestrator_task_id,
+ task_status = %task.status,
+ "Planner task is not in an interruptible state — skipping interrupt"
+ );
+ return Ok(GoalEditInterruptResult::Skipped);
+ }
+
+ let Some(daemon_id) = task.daemon_id else {
+ tracing::debug!(
+ directive_id = %directive_id,
+ task_id = %orchestrator_task_id,
+ "Planner task has no assigned daemon — skipping interrupt"
+ );
+ return Ok(GoalEditInterruptResult::Skipped);
+ };
+
+ let message = build_goal_edit_interrupt_message(old_goal, new_goal);
+ let command = DaemonCommand::SendMessage {
+ task_id: orchestrator_task_id,
+ message,
+ };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => {
+ tracing::info!(
+ directive_id = %directive_id,
+ task_id = %orchestrator_task_id,
+ daemon_id = %daemon_id,
+ "Sent goal-edit interrupt to running planner"
+ );
+ Ok(GoalEditInterruptResult::Sent)
+ }
+ Err(e) => {
+ tracing::warn!(
+ directive_id = %directive_id,
+ task_id = %orchestrator_task_id,
+ daemon_id = %daemon_id,
+ error = %e,
+ "Failed to send goal-edit interrupt — falling back to replan"
+ );
+ Ok(GoalEditInterruptResult::Skipped)
+ }
+ }
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn classifier_identical_goal_is_small() {
+ assert_eq!(
+ classify_goal_change("Build a todo app", "Build a todo app"),
+ GoalChangeKind::Small
+ );
+ }
+
+ #[test]
+ fn classifier_pure_addition_is_small() {
+ let old = "Build a todo app";
+ let new = "Build a todo app with authentication";
+ assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
+ }
+
+ #[test]
+ fn classifier_pure_truncation_is_small() {
+ let old = "Build a todo app with authentication and tests";
+ let new = "Build a todo app";
+ assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
+ }
+
+ #[test]
+ fn classifier_typo_fix_is_small() {
+ // Same length, single character diff — well below 0.3 length ratio.
+ let old = "Build a todo aap with authentication and tests today";
+ let new = "Build a todo app with authentication and tests today";
+ assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
+ }
+
+ #[test]
+ fn classifier_completely_different_is_large() {
+ // Wildly different lengths and content.
+ let old = "Build a todo app";
+ let new = "Migrate the entire backend to Rust, port the frontend to Svelte, \
+ and add a new realtime collaboration feature with operational transforms";
+ assert_eq!(classify_goal_change(old, new), GoalChangeKind::Large);
+ }
+
+ #[test]
+ fn classifier_empty_goals_are_large() {
+ assert_eq!(classify_goal_change("", "Anything"), GoalChangeKind::Large);
+ assert_eq!(classify_goal_change("Anything", ""), GoalChangeKind::Large);
+ }
+
+ #[test]
+ fn interrupt_message_contains_old_and_new() {
+ let msg = build_goal_edit_interrupt_message("OLD", "NEW");
+ assert!(msg.contains("GOAL_UPDATED"));
+ assert!(msg.contains("OLD"));
+ assert!(msg.contains("NEW"));
+ assert!(msg.contains("--- OLD GOAL ---"));
+ assert!(msg.contains("--- NEW GOAL ---"));
+ }
+}
diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs
index d1edf7e..01c4659 100644
--- a/makima/src/server/handlers/directives.rs
+++ b/makima/src/server/handlers/directives.rs
@@ -18,7 +18,10 @@ use crate::db::models::{
OrderListResponse,
};
use crate::db::repository;
-use crate::orchestration::directive::{build_cleanup_prompt, build_order_pickup_prompt};
+use crate::orchestration::directive::{
+ build_cleanup_prompt, build_order_pickup_prompt, classify_goal_change,
+ try_interrupt_planner_with_goal_edit, GoalChangeKind, GoalEditInterruptResult,
+};
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
use crate::server::state::SharedState;
@@ -827,31 +830,79 @@ pub async fn update_goal(
.into_response();
};
- // Save old goal to history before overwriting (best-effort)
- match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
- Ok(Some(current)) => {
- if let Err(e) = repository::save_directive_goal_history(pool, id, &current.goal).await
- {
- tracing::warn!(
- directive_id = %id,
- error = %e,
- "Failed to save goal history before update — continuing with goal update"
- );
- }
- }
- Ok(None) => {
- // Directive not found — update_directive_goal will handle this
- }
+ // Fetch the current directive so we can:
+ // 1. Save the old goal to history (best-effort).
+ // 2. Decide whether to fire a goal-edit interrupt at a running planner.
+ let current = match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
+ Ok(Some(d)) => Some(d),
+ Ok(None) => None,
Err(e) => {
tracing::warn!(
directive_id = %id,
error = %e,
"Failed to fetch current directive for goal history — continuing with goal update"
);
+ None
+ }
+ };
+
+ // Save old goal to history before overwriting (best-effort).
+ if let Some(ref current) = current {
+ if let Err(e) = repository::save_directive_goal_history(pool, id, &current.goal).await {
+ tracing::warn!(
+ directive_id = %id,
+ error = %e,
+ "Failed to save goal history before update — continuing with goal update"
+ );
+ }
+ }
+
+ // Goal-edit interrupt cycle: if a planner task is currently running for
+ // this directive AND the goal change classifies as 'small', interrupt the
+ // running planner via SendMessage instead of clearing it (which would
+ // trigger a fresh replan on the next orchestrator tick).
+ let mut interrupted = false;
+ if let Some(ref current) = current {
+ if current.orchestrator_task_id.is_some()
+ && classify_goal_change(&current.goal, &req.goal) == GoalChangeKind::Small
+ {
+ match try_interrupt_planner_with_goal_edit(
+ pool,
+ &state,
+ id,
+ &current.goal,
+ &req.goal,
+ )
+ .await
+ {
+ Ok(GoalEditInterruptResult::Sent) => {
+ interrupted = true;
+ }
+ Ok(GoalEditInterruptResult::Skipped) => {}
+ Err(e) => {
+ tracing::warn!(
+ directive_id = %id,
+ error = %e,
+ "Goal-edit interrupt attempt errored — falling back to replan"
+ );
+ }
+ }
}
}
- match repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await {
+ // If we successfully interrupted a running planner, persist the new goal
+ // WITHOUT clearing the orchestrator task — the planner will react to the
+ // SendMessage and adjust in-flight. Otherwise, fall through to the normal
+ // path which clears orchestrator_task_id and lets phase_replanning kick
+ // in on the next tick.
+ let update_result = if interrupted {
+ repository::update_directive_goal_keep_orchestrator(pool, auth.owner_id, id, &req.goal)
+ .await
+ } else {
+ repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await
+ };
+
+ match update_result {
Ok(Some(directive)) => Json(directive).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
diff --git a/makima/src/server/handlers/users.rs b/makima/src/server/handlers/users.rs
index 0b2ccdd..0b86592 100644
--- a/makima/src/server/handlers/users.rs
+++ b/makima/src/server/handlers/users.rs
@@ -928,6 +928,160 @@ pub async fn delete_account_handler(
}
// =============================================================================
+// User Settings (per-user feature flags)
+// =============================================================================
+
+/// User settings response.
+#[derive(Debug, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UserSettingsResponse {
+ /// Whether the new "document mode" UI is enabled for this user.
+ pub document_mode_enabled: bool,
+}
+
+/// Update user settings request.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateUserSettingsRequest {
+ /// Whether to enable the new "document mode" UI for this user.
+ pub document_mode_enabled: bool,
+}
+
+/// Get the authenticated user's settings (feature flags).
+///
+/// Returns the user's per-user settings, currently consisting of feature flags
+/// used by the frontend to decide which UI to show.
+#[utoipa::path(
+ get,
+ path = "/api/v1/users/me/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 = "Users"
+)]
+pub async fn get_user_settings_handler(
+ State(state): State<SharedState>,
+ UserOnly(user): UserOnly,
+) -> 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 sqlx::query_scalar::<_, bool>(
+ "SELECT document_mode_enabled FROM users WHERE id = $1",
+ )
+ .bind(user.user_id)
+ .fetch_optional(pool)
+ .await
+ {
+ Ok(Some(document_mode_enabled)) => Json(UserSettingsResponse {
+ document_mode_enabled,
+ })
+ .into_response(),
+ Ok(None) => {
+ // User row missing — fall back to defaults rather than 404 since the
+ // user is authenticated.
+ tracing::warn!(user_id = %user.user_id, "User row not found when fetching settings — returning defaults");
+ Json(UserSettingsResponse {
+ document_mode_enabled: false,
+ })
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to fetch user settings: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update the authenticated user's settings (feature flags).
+///
+/// Replaces the user's settings record with the provided values.
+#[utoipa::path(
+ put,
+ path = "/api/v1/users/me/settings",
+ request_body = UpdateUserSettingsRequest,
+ responses(
+ (status = 200, description = "Updated user settings", body = UserSettingsResponse),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 404, description = "User not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Users"
+)]
+pub async fn update_user_settings_handler(
+ State(state): State<SharedState>,
+ UserOnly(user): UserOnly,
+ Json(req): Json<UpdateUserSettingsRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ let result = sqlx::query_scalar::<_, bool>(
+ r#"
+ UPDATE users
+ SET document_mode_enabled = $1, updated_at = NOW()
+ WHERE id = $2
+ RETURNING document_mode_enabled
+ "#,
+ )
+ .bind(req.document_mode_enabled)
+ .bind(user.user_id)
+ .fetch_optional(pool)
+ .await;
+
+ match result {
+ Ok(Some(document_mode_enabled)) => {
+ tracing::info!(
+ user_id = %user.user_id,
+ document_mode_enabled = document_mode_enabled,
+ "Updated user settings"
+ );
+ Json(UserSettingsResponse {
+ document_mode_enabled,
+ })
+ .into_response()
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("USER_NOT_FOUND", "User not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update user settings: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+// =============================================================================
// Tests
// =============================================================================
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index b382f04..beee0e9 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -162,6 +162,11 @@ pub fn make_router(state: SharedState) -> Router {
)
.route("/users/me/password", axum::routing::put(users::change_password_handler))
.route("/users/me/email", axum::routing::put(users::change_email_handler))
+ .route(
+ "/users/me/settings",
+ get(users::get_user_settings_handler)
+ .put(users::update_user_settings_handler),
+ )
// Contract endpoints
.route("/contracts/discuss", post(contract_discuss::discuss_contract_handler))
.route(
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index 6065eeb..37cd113 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -90,6 +90,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
users::change_password_handler,
users::change_email_handler,
users::delete_account_handler,
+ users::get_user_settings_handler,
+ users::update_user_settings_handler,
// Contract endpoints
contracts::list_contracts,
contracts::get_contract,
@@ -210,6 +212,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
users::ChangeEmailResponse,
users::DeleteAccountRequest,
users::DeleteAccountResponse,
+ users::UserSettingsResponse,
+ users::UpdateUserSettingsRequest,
// Contract schemas
Contract,
ContractSummary,