@notis_ai/cli 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/README.md +125 -21
  2. package/dist/agent-hooks/notis-agent-hook.mjs +19750 -0
  3. package/{skills → dist/base-skills}/notis-apps/SKILL.md +93 -58
  4. package/{skills → dist/base-skills}/notis-cli/SKILL.md +9 -9
  5. package/dist/base-skills/notis-query/SKILL.md +705 -0
  6. package/dist/skill-sync/index.js +1528 -0
  7. package/dist/skill-sync/index.js.map +7 -0
  8. package/package.json +5 -2
  9. package/skills/notis-apps/cli.md +44 -18
  10. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  11. package/skills/notis-onboarding/BRIEF.md +16 -0
  12. package/src/agent-hook-entry.js +5 -0
  13. package/src/cli.js +23 -14
  14. package/src/command-specs/agents.js +392 -0
  15. package/src/command-specs/apps.js +1090 -418
  16. package/src/command-specs/auth.js +63 -10
  17. package/src/command-specs/handover.js +374 -0
  18. package/src/command-specs/index.js +9 -0
  19. package/src/command-specs/onboarding.js +69 -5
  20. package/src/command-specs/skills.js +56 -0
  21. package/src/command-specs/tools.js +6 -0
  22. package/src/runtime/agent-memory-state.js +126 -0
  23. package/src/runtime/agent-setup.js +383 -0
  24. package/src/runtime/app-dev-consumers.js +154 -0
  25. package/src/runtime/app-dev-host-lock.js +80 -0
  26. package/src/runtime/app-dev-roots.js +284 -0
  27. package/src/runtime/app-dev-server.js +229 -30
  28. package/src/runtime/app-dev-sessions.js +105 -145
  29. package/src/runtime/app-platform.js +1499 -161
  30. package/src/runtime/app-registry-scaffolds.js +367 -0
  31. package/src/runtime/base-skills.d.ts +20 -0
  32. package/src/runtime/base-skills.js +167 -0
  33. package/src/runtime/delegated-context.js +68 -0
  34. package/src/runtime/git.js +233 -0
  35. package/src/runtime/login-listener.js +15 -0
  36. package/src/runtime/oauth.js +1506 -141
  37. package/src/runtime/output.js +7 -0
  38. package/src/runtime/ports.js +16 -0
  39. package/src/runtime/profiles.js +0 -5
  40. package/src/runtime/skill-sync/cloud-client.ts +96 -0
  41. package/src/runtime/skill-sync/index.ts +644 -0
  42. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  43. package/src/runtime/skill-sync/symlink-manager.ts +383 -0
  44. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  45. package/src/runtime/skill-sync/types.ts +103 -0
  46. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  47. package/src/runtime/store-screenshot.js +6 -1
  48. package/src/runtime/sync-skills.d.ts +37 -0
  49. package/src/runtime/sync-skills.js +215 -0
  50. package/src/runtime/transport.js +19 -2
  51. package/template/.harness/index.html.tmpl +116 -47
  52. package/template/app/layout.tsx +5 -1
  53. package/template/notis.config.ts +1 -0
  54. package/template/packages/sdk/package.json +2 -1
  55. package/template/packages/sdk/src/components/DocumentEditor.tsx +13 -3
  56. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  57. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +37 -119
  58. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
  59. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
  60. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
  61. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  62. package/template/packages/sdk/src/config.ts +71 -0
  63. package/template/packages/sdk/src/documents.ts +28 -1
  64. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  65. package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
  66. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +709 -0
  67. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  68. package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
  69. package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
  70. package/template/packages/sdk/src/hooks/useTool.ts +5 -4
  71. package/template/packages/sdk/src/index.ts +64 -2
  72. package/template/packages/sdk/src/interactions/actions.ts +46 -0
  73. package/template/packages/sdk/src/interactions/shortcuts.tsx +628 -0
  74. package/template/packages/sdk/src/interactions.ts +41 -0
  75. package/template/packages/sdk/src/provider.tsx +2 -1
  76. package/template/packages/sdk/src/runtime.ts +219 -3
  77. package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
  78. package/dist/scaffolds/notis-database/app/globals.css +0 -44
  79. package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
  80. package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
  81. package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
  82. package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
  83. package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
  84. package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
  85. package/dist/scaffolds/notis-database/components.json +0 -20
  86. package/dist/scaffolds/notis-database/index.html +0 -12
  87. package/dist/scaffolds/notis-database/lib/types.ts +0 -132
  88. package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
  89. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  90. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  91. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  92. package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
  93. package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
  94. package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
  95. package/dist/scaffolds/notis-database/notis.config.ts +0 -73
  96. package/dist/scaffolds/notis-database/package-lock.json +0 -3935
  97. package/dist/scaffolds/notis-database/package.json +0 -32
  98. package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
  99. package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  100. package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
  101. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  102. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  103. package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  104. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -196
  105. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -229
  106. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
  107. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  108. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
  109. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
  110. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  111. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
  112. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  113. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
  114. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
  115. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  116. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  117. package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -83
  118. package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
  119. package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -220
  120. package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
  121. package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
  122. package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
  123. package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
  124. package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
  125. package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
  126. package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
  127. package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
  128. package/dist/scaffolds/notis-database/tsconfig.json +0 -23
  129. package/dist/scaffolds/notis-database/vite.config.ts +0 -22
  130. package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
  131. package/dist/scaffolds/notis-journal/app/globals.css +0 -37
  132. package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
  133. package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
  134. package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
  135. package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
  136. package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
  137. package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
  138. package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
  139. package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
  140. package/dist/scaffolds/notis-journal/components.json +0 -20
  141. package/dist/scaffolds/notis-journal/index.html +0 -12
  142. package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
  143. package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
  144. package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
  145. package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
  146. package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
  147. package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
  148. package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
  149. package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
  150. package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
  151. package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
  152. package/dist/scaffolds/notis-journal/package.json +0 -34
  153. package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
  154. package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  155. package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
  156. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  157. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  158. package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  159. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -196
  160. package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -229
  161. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
  162. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  163. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +0 -78
  164. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +0 -121
  165. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  166. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
  167. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  168. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
  169. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
  170. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  171. package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  172. package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -83
  173. package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
  174. package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -220
  175. package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
  176. package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
  177. package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
  178. package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
  179. package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
  180. package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
  181. package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
  182. package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -197
  183. package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
  184. package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
  185. package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
  186. package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
  187. package/dist/scaffolds/notis-notes/app/globals.css +0 -3
  188. package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
  189. package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
  190. package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
  191. package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
  192. package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
  193. package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
  194. package/dist/scaffolds/notis-notes/components.json +0 -20
  195. package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
  196. package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
  197. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  198. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  199. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  200. package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
  201. package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
  202. package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
  203. package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
  204. package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
  205. package/dist/scaffolds/notis-notes/package.json +0 -35
  206. package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
  207. package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  208. package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
  209. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  210. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  211. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  212. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -196
  213. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -229
  214. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
  215. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  216. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
  217. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
  218. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  219. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
  220. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  221. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
  222. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
  223. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  224. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  225. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -83
  226. package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
  227. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -220
  228. package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
  229. package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
  230. package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
  231. package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
  232. package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
  233. package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
  234. package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
  235. package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
  236. package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
  237. package/dist/scaffolds/notis-random/README.md +0 -33
  238. package/dist/scaffolds/notis-random/app/globals.css +0 -11
  239. package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
  240. package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
  241. package/dist/scaffolds/notis-random/app/page.tsx +0 -289
  242. package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
  243. package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
  244. package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
  245. package/dist/scaffolds/notis-random/components.json +0 -20
  246. package/dist/scaffolds/notis-random/index.html +0 -12
  247. package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
  248. package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
  249. package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
  250. package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
  251. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  252. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  253. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  254. package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
  255. package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
  256. package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
  257. package/dist/scaffolds/notis-random/notis.config.ts +0 -86
  258. package/dist/scaffolds/notis-random/package-lock.json +0 -4513
  259. package/dist/scaffolds/notis-random/package.json +0 -36
  260. package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
  261. package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
  262. package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
  263. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
  264. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
  265. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
  266. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -196
  267. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -229
  268. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
  269. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
  270. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
  271. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
  272. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
  273. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
  274. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
  275. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
  276. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
  277. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
  278. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
  279. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -83
  280. package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
  281. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -220
  282. package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
  283. package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
  284. package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
  285. package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
  286. package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
  287. package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
  288. package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
  289. package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
  290. package/dist/scaffolds/notis-random/tsconfig.json +0 -23
  291. package/dist/scaffolds/notis-random/vite.config.ts +0 -11
  292. package/dist/scaffolds.json +0 -49
  293. package/template/metadata/screenshot-1.png +0 -0
  294. package/template/metadata/screenshot-2.png +0 -0
  295. package/template/metadata/screenshot-3.png +0 -0
@@ -0,0 +1,215 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ import { reconcileBaseSkills } from './base-skills.js';
7
+
8
+ const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
9
+ const DEFAULT_LOCK_STALE_MS = 10 * 60_000;
10
+ const DEFAULT_LOCK_POLL_MS = 50;
11
+
12
+ async function lockSnapshot(lockDirectory) {
13
+ try {
14
+ const [raw, metadata] = await Promise.all([
15
+ readFile(join(lockDirectory, 'owner'), 'utf8').catch(() => ''),
16
+ stat(lockDirectory),
17
+ ]);
18
+ let owner = {};
19
+ try {
20
+ owner = raw ? JSON.parse(raw) : {};
21
+ } catch {
22
+ // A process can die between mkdir and the atomic owner write. Preserve
23
+ // the directory mtime as a reclaimable ownerless lease.
24
+ }
25
+ return {
26
+ id: typeof owner.id === 'string' ? owner.id : null,
27
+ pid: Number.isInteger(Number(owner.pid)) ? Number(owner.pid) : null,
28
+ at: Number.isFinite(Number(owner.at)) ? Number(owner.at) : metadata.mtimeMs,
29
+ mtimeMs: metadata.mtimeMs,
30
+ };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function sameLockSnapshot(left, right) {
37
+ return Boolean(
38
+ left
39
+ && right
40
+ && left.id === right.id
41
+ && left.pid === right.pid
42
+ && left.at === right.at
43
+ && left.mtimeMs === right.mtimeMs,
44
+ );
45
+ }
46
+
47
+ function processIsAlive(pid) {
48
+ if (!Number.isInteger(pid) || pid <= 0) return false;
49
+ try {
50
+ process.kill(pid, 0);
51
+ return true;
52
+ } catch (error) {
53
+ return error?.code === 'EPERM';
54
+ }
55
+ }
56
+
57
+ function delay(milliseconds) {
58
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
59
+ }
60
+
61
+ async function quarantineStaleLock(lockDirectory, snapshot) {
62
+ const quarantineRoot = join(dirname(lockDirectory), '.stale-operation-locks');
63
+ const snapshotToken = createHash('sha256')
64
+ .update(JSON.stringify(snapshot))
65
+ .digest('hex');
66
+ await mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
67
+ try {
68
+ // The deterministic destination is the compare-and-swap guard. If two
69
+ // waiters observed the same stale owner, only one can move it here. The
70
+ // retained non-empty tombstone prevents the loser from ever moving a new
71
+ // live lock that appeared at the shared pathname in the meantime.
72
+ await rename(lockDirectory, join(quarantineRoot, snapshotToken));
73
+ return true;
74
+ } catch (error) {
75
+ if (['EEXIST', 'ENOTEMPTY', 'ENOENT'].includes(error?.code)) return false;
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ async function writeLockOwnerAtomically(lockDirectory, owner) {
81
+ const temporaryOwnerPath = join(lockDirectory, `.owner.${owner.id}.tmp`);
82
+ const ownerPath = join(lockDirectory, 'owner');
83
+ await writeFile(temporaryOwnerPath, JSON.stringify(owner), { mode: 0o600 });
84
+ await rename(temporaryOwnerPath, ownerPath);
85
+ }
86
+
87
+ /** Serialize Desktop and terminal skill sync across processes on one Mac. */
88
+ export async function withSkillSyncLock(callback, {
89
+ home = homedir(),
90
+ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
91
+ staleMs = DEFAULT_LOCK_STALE_MS,
92
+ pollMs = DEFAULT_LOCK_POLL_MS,
93
+ now = () => Date.now(),
94
+ } = {}) {
95
+ const lockDirectory = join(home, '.notis', 'skills', '.operation-lock');
96
+ const ownerId = `${process.pid}.${randomUUID()}`;
97
+ const deadline = now() + timeoutMs;
98
+ await mkdir(dirname(lockDirectory), { recursive: true, mode: 0o700 });
99
+
100
+ for (;;) {
101
+ try {
102
+ await mkdir(lockDirectory, { mode: 0o700 });
103
+ try {
104
+ await writeLockOwnerAtomically(lockDirectory, {
105
+ id: ownerId,
106
+ pid: process.pid,
107
+ at: now(),
108
+ });
109
+ } catch (error) {
110
+ // This process exclusively created the directory and has not yet
111
+ // published an owner, so it is safe to undo a failed acquisition.
112
+ await rm(lockDirectory, { recursive: true, force: true });
113
+ throw error;
114
+ }
115
+ break;
116
+ } catch (error) {
117
+ if (error?.code !== 'EEXIST') throw error;
118
+ }
119
+
120
+ const observed = await lockSnapshot(lockDirectory);
121
+ if (
122
+ observed
123
+ && now() - observed.at > staleMs
124
+ && !processIsAlive(observed.pid)
125
+ ) {
126
+ // Require an unchanged observation interval before stealing. This avoids
127
+ // racing a holder that is publishing or refreshing its owner lease.
128
+ await delay(Math.max(pollMs, 10));
129
+ const current = await lockSnapshot(lockDirectory);
130
+ if (sameLockSnapshot(observed, current) && !processIsAlive(current?.pid)) {
131
+ if (await quarantineStaleLock(lockDirectory, current)) continue;
132
+ }
133
+ }
134
+ if (now() >= deadline) {
135
+ throw new Error('Timed out waiting for another Notis skill sync to finish.');
136
+ }
137
+ await delay(pollMs);
138
+ }
139
+
140
+ const heartbeatMs = Math.max(10, Math.min(30_000, Math.floor(staleMs / 3)));
141
+ let heartbeatStopped = false;
142
+ let heartbeatInFlight = Promise.resolve();
143
+ const refreshHeartbeat = async () => {
144
+ if (heartbeatStopped) return;
145
+ const temporaryOwnerPath = join(lockDirectory, `.owner.${ownerId}.tmp`);
146
+ await writeFile(
147
+ temporaryOwnerPath,
148
+ JSON.stringify({ id: ownerId, pid: process.pid, at: now() }),
149
+ { mode: 0o600 },
150
+ );
151
+ const owner = await lockSnapshot(lockDirectory);
152
+ if (owner?.id !== ownerId) {
153
+ await rm(temporaryOwnerPath, { force: true });
154
+ return;
155
+ }
156
+ // If a stale-lock reclaimer moved this directory after the ownership
157
+ // check, the source path disappears and rename fails instead of
158
+ // overwriting the owner of a newly acquired lock at the shared path.
159
+ await rename(temporaryOwnerPath, join(lockDirectory, 'owner'));
160
+ };
161
+ const heartbeat = setInterval(() => {
162
+ // Serialize refreshes and retain the active promise so cleanup cannot race
163
+ // a temporary owner write that was already in flight when the interval was
164
+ // cleared.
165
+ heartbeatInFlight = heartbeatInFlight
166
+ .then(refreshHeartbeat)
167
+ .catch(() => undefined);
168
+ }, heartbeatMs);
169
+ heartbeat.unref?.();
170
+
171
+ try {
172
+ return await callback();
173
+ } finally {
174
+ heartbeatStopped = true;
175
+ clearInterval(heartbeat);
176
+ await heartbeatInFlight;
177
+ const owner = await lockSnapshot(lockDirectory);
178
+ if (owner?.id === ownerId) {
179
+ await rm(lockDirectory, { recursive: true, force: true });
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Shared CLI-owned orchestration for base-skill installation plus account sync.
186
+ * Desktop injects the source engine that webpack bundles; the CLI command
187
+ * injects the generated dist engine used by the published package.
188
+ */
189
+ export async function reconcileAllSkills({
190
+ serverUrl,
191
+ jwt,
192
+ honorSyncEnabled,
193
+ userId = null,
194
+ home,
195
+ runAccountSync,
196
+ reconcileBase = reconcileBaseSkills,
197
+ lockOptions = {},
198
+ }) {
199
+ return withSkillSyncLock(async () => {
200
+ const base = reconcileBase({ userId, ...(home ? { home } : {}) });
201
+ const account = await runAccountSync(
202
+ serverUrl,
203
+ jwt,
204
+ {},
205
+ { honorSyncEnabled },
206
+ );
207
+ return {
208
+ ...account,
209
+ baseSkills: base.skills,
210
+ baseInstalled: base.installed,
211
+ baseLinked: base.linked,
212
+ baseBackups: base.backups,
213
+ };
214
+ }, { ...lockOptions, ...(home ? { home } : {}) });
215
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { createReadStream } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { CliError, EXIT_CODES } from './errors.js';
5
+ import { delegatedContextReason } from './delegated-context.js';
5
6
  import {
6
7
  credentialIsExpired,
7
8
  getJwtExpiration,
@@ -327,9 +328,11 @@ export async function httpRequest({
327
328
  });
328
329
 
329
330
  let payload = null;
331
+ let payloadReadError = null;
330
332
  try {
331
333
  payload = await response.json();
332
- } catch {
334
+ } catch (error) {
335
+ payloadReadError = error;
333
336
  payload = null;
334
337
  }
335
338
 
@@ -350,9 +353,11 @@ export async function httpRequest({
350
353
  signal: controller.signal,
351
354
  ...(requestBody.duplex ? { duplex: requestBody.duplex } : {}),
352
355
  });
356
+ payloadReadError = null;
353
357
  try {
354
358
  payload = await response.json();
355
- } catch {
359
+ } catch (error) {
360
+ payloadReadError = error;
356
361
  payload = null;
357
362
  }
358
363
  }
@@ -376,6 +381,14 @@ export async function httpRequest({
376
381
  throw normalizeBackendError(response.status, payload, runtime);
377
382
  }
378
383
 
384
+ // A successful status is not a successful tool call until its response
385
+ // body has been read. Mutations may already have committed before a socket
386
+ // reset truncates the JSON; surface that as an ambiguous network failure
387
+ // so callers never persist an undefined result or attempt a second write.
388
+ if (response.status !== 204 && payloadReadError) {
389
+ throw payloadReadError;
390
+ }
391
+
379
392
  return {
380
393
  requestId,
381
394
  payload: payload || {},
@@ -428,6 +441,10 @@ export async function callTool({
428
441
  cwd: process.cwd(),
429
442
  agent_mode: runtime.agentMode,
430
443
  cli_version: runtime.cliVersion,
444
+ // Reported so the server can refuse work-handing tools from a run Notis
445
+ // is itself driving. Advisory -- a determined agent can unset the markers
446
+ // -- so the server treats it as one layer, not the whole guard.
447
+ ...(delegatedContextReason() ? { delegated_context: true } : {}),
431
448
  ...(runtime.debugEntitlementOverride
432
449
  ? { debug_entitlement_override: runtime.debugEntitlementOverride }
433
450
  : {}),
@@ -164,8 +164,35 @@
164
164
  });
165
165
  });
166
166
 
167
+ /**
168
+ * Records one runtime call and hands back the entry so the caller can
169
+ * stamp its outcome once the promise settles. Verify reads `ok` to tell a
170
+ * route that rendered from real data apart from one that only rendered its
171
+ * error states, so every runtime path must settle its own entry.
172
+ */
167
173
  function record(op, args) {
168
- window.__harness.runtimeCalls.push({ op, args });
174
+ const entry = { op, args, ok: null, error: null, durationMs: null };
175
+ entry.startedAt = Date.now();
176
+ window.__harness.runtimeCalls.push(entry);
177
+ return entry;
178
+ }
179
+
180
+ function settle(entry, error) {
181
+ entry.durationMs = Date.now() - entry.startedAt;
182
+ delete entry.startedAt;
183
+ entry.ok = !error;
184
+ entry.error = error ? (error.message ? String(error.message) : String(error)) : null;
185
+ }
186
+
187
+ async function tracked(entry, run) {
188
+ try {
189
+ const value = await run();
190
+ settle(entry, null);
191
+ return value;
192
+ } catch (error) {
193
+ settle(entry, error);
194
+ throw error;
195
+ }
169
196
  }
170
197
 
171
198
  async function readJsonOrSnippet(response) {
@@ -214,29 +241,58 @@
214
241
  app: descriptor.app,
215
242
  route: descriptor.route,
216
243
  context: descriptor.context || {},
217
- navigate: (args) => record('navigate', args),
244
+ navigate: (args) => {
245
+ settle(record('navigate', args), null);
246
+ },
247
+ // There is no manager chat here, so the handover is recorded and
248
+ // reported as drafted without any UI.
249
+ handover: async (payload) => {
250
+ settle(record('handover', payload), null);
251
+ return { status: 'drafted' };
252
+ },
218
253
  registerTopBarSearch: () => {},
219
254
  setTopBarSearchValue: () => {},
220
255
  setTopBarSearchLoading: () => {},
221
- listTools: async () => {
222
- record('listTools', {});
223
- return descriptor.tools || [];
224
- },
225
- callTool: async (name, args) => {
226
- record('callTool', { name, arguments: args || {} });
227
- const fixture = lookupToolFixture(name, args || {});
228
- if (fixture !== undefined) {
229
- return structuredClone(fixture);
230
- }
231
- return { ok: true, result: null };
232
- },
233
- request: async (path, options) => {
234
- record('request', { path, options });
235
- if (fixtures && fixtures.requests && Object.prototype.hasOwnProperty.call(fixtures.requests, path)) {
236
- return structuredClone(fixtures.requests[path]);
237
- }
238
- return null;
239
- },
256
+ // No change feed here, so `useDatabaseSubscription` reports live=false
257
+ // and the app keeps whatever manual refresh it offers.
258
+ subscribeDatabase: () => () => {},
259
+ // There is no cloud computer behind the harness. Answering "not
260
+ // available" rather than throwing keeps `useCloudComputer` on the same
261
+ // fallback branch a user without a cloud computer would see.
262
+ cloudComputerFacts: async () => tracked(
263
+ record('cloudComputerFacts', {}),
264
+ async () => ({
265
+ available: false,
266
+ reason: 'unsupported_host',
267
+ sandbox: null,
268
+ cli_auth: {
269
+ gh: { authenticated: null, account: null, checked_at: null, reason: 'unsupported_host' },
270
+ },
271
+ }),
272
+ ),
273
+ listTools: async () => tracked(
274
+ record('listTools', {}),
275
+ async () => descriptor.tools || [],
276
+ ),
277
+ callTool: async (name, args) => tracked(
278
+ record('callTool', { name, arguments: args || {} }),
279
+ async () => {
280
+ const fixture = lookupToolFixture(name, args || {});
281
+ if (fixture !== undefined) {
282
+ return structuredClone(fixture);
283
+ }
284
+ return { ok: true, result: null };
285
+ },
286
+ ),
287
+ request: async (path, options) => tracked(
288
+ record('request', { path, options }),
289
+ async () => {
290
+ if (fixtures && fixtures.requests && Object.prototype.hasOwnProperty.call(fixtures.requests, path)) {
291
+ return structuredClone(fixtures.requests[path]);
292
+ }
293
+ return null;
294
+ },
295
+ ),
240
296
  };
241
297
  }
242
298
 
@@ -270,32 +326,45 @@
270
326
  function liveRuntime() {
271
327
  return {
272
328
  ...stubRuntime(),
273
- listTools: async () => {
274
- record('listTools', {});
275
- const result = await runtimeQuery({ method: 'tools/list' });
276
- return result.tools || [];
277
- },
278
- callTool: async (name, args) => {
279
- record('callTool', { name, arguments: args || {} });
280
- return runtimeQuery({ method: 'tools/call', name, arguments: args || {} });
281
- },
282
- request: async (path, options) => {
283
- record('request', { path, options });
284
- const response = await fetch(`${String(apiBase).replace(/\/$/, '')}${path}`, {
285
- method: (options && options.method) || 'GET',
286
- headers: {
287
- Authorization: `Bearer ${jwt}`,
288
- ...((options && options.body) ? { 'Content-Type': 'application/json' } : {}),
289
- ...((options && options.headers) || {}),
290
- },
291
- body: options && options.body ? JSON.stringify(options.body) : undefined,
292
- });
293
- const { payload, snippet } = await readJsonOrSnippet(response);
294
- if (!response.ok) {
295
- throw new Error((payload && (payload.error || payload.message)) || snippet || `Request failed with status ${response.status}`);
296
- }
297
- return payload;
298
- },
329
+ listTools: async () => tracked(
330
+ record('listTools', {}),
331
+ async () => {
332
+ const result = await runtimeQuery({ method: 'tools/list' });
333
+ return result.tools || [];
334
+ },
335
+ ),
336
+ callTool: async (name, args) => tracked(
337
+ record('callTool', { name, arguments: args || {} }),
338
+ async () => runtimeQuery({ method: 'tools/call', name, arguments: args || {} }),
339
+ ),
340
+ // Live mode must not inherit the stub's canned answer: it would settle
341
+ // ok: true with fake data and soften the all-calls-failed assertion.
342
+ cloudComputerFacts: async (options) => tracked(
343
+ record('cloudComputerFacts', options || {}),
344
+ async () => runtimeQuery({
345
+ method: 'cloud_computer/facts',
346
+ ...(options && options.refresh ? { refresh: true } : {}),
347
+ }),
348
+ ),
349
+ request: async (path, options) => tracked(
350
+ record('request', { path, options }),
351
+ async () => {
352
+ const response = await fetch(`${String(apiBase).replace(/\/$/, '')}${path}`, {
353
+ method: (options && options.method) || 'GET',
354
+ headers: {
355
+ Authorization: `Bearer ${jwt}`,
356
+ ...((options && options.body) ? { 'Content-Type': 'application/json' } : {}),
357
+ ...((options && options.headers) || {}),
358
+ },
359
+ body: options && options.body ? JSON.stringify(options.body) : undefined,
360
+ });
361
+ const { payload, snippet } = await readJsonOrSnippet(response);
362
+ if (!response.ok) {
363
+ throw new Error((payload && (payload.error || payload.message)) || snippet || `Request failed with status ${response.status}`);
364
+ }
365
+ return payload;
366
+ },
367
+ ),
299
368
  };
300
369
  }
301
370
 
@@ -1,6 +1,10 @@
1
1
  import '@notis/sdk/styles.css';
2
+ import { ShortcutProvider } from '@notis/sdk/interactions';
2
3
  import './globals.css';
3
4
 
4
5
  export default function AppShell({ children }: { children: React.ReactNode }) {
5
- return children;
6
+ // NotisProvider supplies this in the product host. Keeping the shell-level
7
+ // provider makes the same scope arbitration available in the local harness;
8
+ // ShortcutProvider is nesting-safe, so the hosted app still has one registry.
9
+ return <ShortcutProvider>{children}</ShortcutProvider>;
6
10
  }
@@ -2,6 +2,7 @@ import { defineNotisApp } from '@notis/sdk/config';
2
2
 
3
3
  export default defineNotisApp({
4
4
  name: 'my-notis-app',
5
+ devSlug: 'my-notis-app',
5
6
  title: 'My Notis App',
6
7
  description: 'A focused Notis app with one route and one database-backed workflow.',
7
8
  icon: 'phosphor:squares-four',
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@notis/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "exports": {
7
7
  ".": "./src/index.ts",
8
+ "./interactions": "./src/interactions.ts",
8
9
  "./config": "./src/config.ts",
9
10
  "./vite": "./src/vite.ts",
10
11
  "./ui": "./src/ui.ts",
@@ -51,7 +51,7 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
51
51
  );
52
52
  }
53
53
 
54
- const isMarkdown = document.contentType !== 'file';
54
+ const isMarkdown = document.contentType === 'markdown' || !document.contentType;
55
55
  return (
56
56
  <div className={className}>
57
57
  {variant === 'full' ? <h1 style={fallbackTitleStyle}>{document.title}</h1> : null}
@@ -62,6 +62,15 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
62
62
  <p style={fallbackNoticeStyle}>Read-only preview — editing is available inside the Notis portal.</p>
63
63
  )}
64
64
  </>
65
+ ) : document.contentType === 'view' ? (
66
+ <div style={fallbackFrameStyle}>
67
+ {document.viewType === 'report'
68
+ ? 'Interactive report'
69
+ : document.viewType === 'html'
70
+ ? 'Interactive HTML document'
71
+ : 'View document'} — open it in Notis Documents for
72
+ the full native experience.
73
+ </div>
65
74
  ) : (
66
75
  <div style={fallbackFrameStyle}>
67
76
  {document.fileType ? `${document.fileType.toUpperCase()} document` : 'File document'} — open it in
@@ -76,8 +85,9 @@ function DocumentEditorFallback({ documentId, variant = 'full', className }: Not
76
85
  * Embeds the document editor for a document anywhere in an app view. The host
77
86
  * (portal) provides the implementation through `runtime.ui.DocumentEditor` and
78
87
  * dispatches on the document's content type (markdown -> rich text editor,
79
- * files -> matching viewer). Outside the portal this falls back to a read-only
80
- * preview.
88
+ * files -> matching viewer, views -> their trusted native Documents surface).
89
+ * Outside the portal this falls back to a read-only preview or an explicit
90
+ * open-in-Documents notice for native views.
81
91
  *
82
92
  * ```tsx
83
93
  * <DocumentEditor documentId={entry.id} variant="body" className="min-h-[320px]" />
@@ -0,0 +1,121 @@
1
+ import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { useNotisRuntime } from '../provider';
4
+ import type { NotisMarkdownEditorProps } from '../runtime';
5
+
6
+ function MarkdownEditorFallback({
7
+ value,
8
+ revision,
9
+ readOnly,
10
+ autosaveMs = 1500,
11
+ placeholder = 'Write in Markdown…',
12
+ className,
13
+ onChange,
14
+ onSave,
15
+ onDirtyChange,
16
+ onSavingChange,
17
+ }: NotisMarkdownEditorProps) {
18
+ const [draft, setDraft] = useState(value);
19
+ const [saveAttempt, setSaveAttempt] = useState(0);
20
+ const draftRef = useRef(value);
21
+ const dirtyRef = useRef(false);
22
+ const savingRef = useRef(false);
23
+ const revisionRef = useRef(revision);
24
+ const onSaveRef = useRef(onSave);
25
+ const readOnlyRef = useRef(readOnly);
26
+ const unmountedRef = useRef(false);
27
+ const saveRef = useRef<() => Promise<void>>(async () => undefined);
28
+
29
+ useEffect(() => {
30
+ onSaveRef.current = onSave;
31
+ readOnlyRef.current = readOnly;
32
+ }, [onSave, readOnly]);
33
+
34
+ useEffect(() => {
35
+ if (dirtyRef.current) return;
36
+ draftRef.current = value;
37
+ revisionRef.current = revision;
38
+ setDraft(value);
39
+ }, [revision, value]);
40
+
41
+ const save = useCallback(async () => {
42
+ if (!onSave || !dirtyRef.current || readOnly || savingRef.current) return;
43
+ const markdown = draftRef.current;
44
+ savingRef.current = true;
45
+ if (!unmountedRef.current) onSavingChange?.(true);
46
+ try {
47
+ const result = await onSave({ markdown, expectedRevision: revisionRef.current });
48
+ if (result?.revision) revisionRef.current = result.revision;
49
+ if (draftRef.current === markdown) {
50
+ dirtyRef.current = false;
51
+ if (!unmountedRef.current) onDirtyChange?.(false);
52
+ }
53
+ } finally {
54
+ savingRef.current = false;
55
+ if (!unmountedRef.current) onSavingChange?.(false);
56
+ if (dirtyRef.current && draftRef.current !== markdown) {
57
+ if (unmountedRef.current) {
58
+ void saveRef.current().catch((error) => console.error('Could not save Markdown on unmount', error));
59
+ } else {
60
+ setSaveAttempt((attempt) => attempt + 1);
61
+ }
62
+ }
63
+ }
64
+ }, [onDirtyChange, onSave, onSavingChange, readOnly]);
65
+ saveRef.current = save;
66
+
67
+ useEffect(() => {
68
+ unmountedRef.current = false;
69
+ return () => {
70
+ unmountedRef.current = true;
71
+ if (!savingRef.current) {
72
+ void saveRef.current().catch((error) => console.error('Could not save Markdown on unmount', error));
73
+ }
74
+ };
75
+ }, []);
76
+
77
+ useEffect(() => {
78
+ if (!dirtyRef.current || !onSave || readOnly) return;
79
+ const timer = window.setTimeout(() => void save(), Math.max(250, autosaveMs));
80
+ return () => window.clearTimeout(timer);
81
+ }, [autosaveMs, draft, onSave, readOnly, save, saveAttempt]);
82
+
83
+ const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
84
+ const next = event.currentTarget.value;
85
+ draftRef.current = next;
86
+ setDraft(next);
87
+ if (!dirtyRef.current) {
88
+ dirtyRef.current = true;
89
+ onDirtyChange?.(true);
90
+ }
91
+ onChange?.(next);
92
+ };
93
+
94
+ const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
95
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
96
+ event.preventDefault();
97
+ void save();
98
+ }
99
+ };
100
+
101
+ return (
102
+ <textarea
103
+ value={draft}
104
+ readOnly={readOnly}
105
+ placeholder={placeholder}
106
+ className={className}
107
+ onChange={handleChange}
108
+ onKeyDown={handleKeyDown}
109
+ style={{ minHeight: 360, width: '100%', resize: 'vertical' }}
110
+ />
111
+ );
112
+ }
113
+
114
+ /** Use Notis' editor while leaving persistence entirely under app control. */
115
+ export function MarkdownEditor(props: NotisMarkdownEditorProps): ReactElement {
116
+ const runtime = useNotisRuntime();
117
+ const HostEditor = runtime?.ui?.MarkdownEditor;
118
+ return HostEditor
119
+ ? <HostEditor key={props.resourceKey} {...props} />
120
+ : <MarkdownEditorFallback key={props.resourceKey} {...props} />;
121
+ }