stagent 0.9.6 → 0.11.0

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 (396) hide show
  1. package/README.md +20 -44
  2. package/dist/cli.js +66 -18
  3. package/docs/.coverage-gaps.json +144 -56
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +20 -2
  12. package/docs/features/schedules.md +32 -4
  13. package/docs/features/settings.md +28 -5
  14. package/docs/features/shared-components.md +7 -3
  15. package/docs/features/tables.md +11 -2
  16. package/docs/features/tool-permissions.md +6 -2
  17. package/docs/features/workflows.md +14 -4
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +39 -2
  20. package/docs/journeys/personal-use.md +32 -8
  21. package/docs/journeys/power-user.md +45 -14
  22. package/docs/journeys/work-use.md +17 -8
  23. package/docs/manifest.json +15 -15
  24. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
  25. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  26. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  27. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  28. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  29. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  30. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  31. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  32. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  33. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  34. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  35. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  36. package/next.config.mjs +1 -0
  37. package/package.json +3 -2
  38. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  39. package/src/app/analytics/page.tsx +1 -21
  40. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  41. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  42. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  43. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  44. package/src/app/api/chat/export/route.ts +52 -0
  45. package/src/app/api/chat/files/search/route.ts +50 -0
  46. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  47. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  48. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  49. package/src/app/api/environment/skills/route.ts +13 -0
  50. package/src/app/api/instance/config/route.ts +41 -0
  51. package/src/app/api/instance/init/route.ts +34 -0
  52. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  53. package/src/app/api/instance/upgrade/route.ts +96 -0
  54. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  55. package/src/app/api/memory/route.ts +0 -11
  56. package/src/app/api/notifications/route.ts +4 -2
  57. package/src/app/api/projects/[id]/route.ts +5 -155
  58. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  59. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  60. package/src/app/api/schedules/[id]/route.ts +9 -1
  61. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  62. package/src/app/api/schedules/route.ts +3 -12
  63. package/src/app/api/settings/chat/pins/route.ts +94 -0
  64. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  65. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  66. package/src/app/api/settings/environment/route.ts +26 -0
  67. package/src/app/api/settings/openai/login/route.ts +22 -0
  68. package/src/app/api/settings/openai/logout/route.ts +7 -0
  69. package/src/app/api/settings/openai/route.ts +21 -1
  70. package/src/app/api/settings/providers/route.ts +35 -8
  71. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  72. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  73. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  74. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  75. package/src/app/api/tasks/[id]/execute/route.ts +52 -33
  76. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  77. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  78. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  79. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  80. package/src/app/api/workspace/context/route.ts +2 -0
  81. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  82. package/src/app/chat/page.tsx +11 -0
  83. package/src/app/documents/page.tsx +4 -1
  84. package/src/app/inbox/page.tsx +12 -5
  85. package/src/app/layout.tsx +42 -21
  86. package/src/app/page.tsx +0 -2
  87. package/src/app/settings/page.tsx +8 -9
  88. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  89. package/src/components/chat/__tests__/chat-session-provider.test.tsx +573 -0
  90. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  91. package/src/components/chat/capability-banner.tsx +68 -0
  92. package/src/components/chat/chat-command-popover.tsx +670 -49
  93. package/src/components/chat/chat-input.tsx +104 -10
  94. package/src/components/chat/chat-message.tsx +12 -3
  95. package/src/components/chat/chat-session-provider.tsx +790 -0
  96. package/src/components/chat/chat-shell.tsx +151 -401
  97. package/src/components/chat/command-tab-bar.tsx +68 -0
  98. package/src/components/chat/conversation-template-picker.tsx +421 -0
  99. package/src/components/chat/help-dialog.tsx +39 -0
  100. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  101. package/src/components/chat/skill-row.tsx +147 -0
  102. package/src/components/documents/document-browser.tsx +37 -19
  103. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  104. package/src/components/instance/instance-section.tsx +382 -0
  105. package/src/components/instance/upgrade-badge.tsx +219 -0
  106. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  107. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  108. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  109. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  110. package/src/components/notifications/inbox-list.tsx +11 -2
  111. package/src/components/notifications/notification-item.tsx +56 -2
  112. package/src/components/notifications/pending-approval-host.tsx +56 -37
  113. package/src/components/notifications/permission-response-actions.tsx +155 -1
  114. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  115. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  116. package/src/components/schedules/schedule-form.tsx +31 -0
  117. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  118. package/src/components/settings/auth-method-selector.tsx +19 -4
  119. package/src/components/settings/auth-status-badge.tsx +28 -3
  120. package/src/components/settings/environment-section.tsx +102 -0
  121. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  122. package/src/components/settings/openai-runtime-section.tsx +7 -1
  123. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  124. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  125. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  126. package/src/components/shared/app-sidebar.tsx +4 -3
  127. package/src/components/shared/command-palette.tsx +266 -7
  128. package/src/components/shared/filter-hint.tsx +70 -0
  129. package/src/components/shared/filter-input.tsx +59 -0
  130. package/src/components/shared/saved-searches-manager.tsx +199 -0
  131. package/src/components/shared/theme-toggle.tsx +5 -24
  132. package/src/components/shared/workspace-indicator.tsx +61 -2
  133. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  134. package/src/components/tables/table-create-sheet.tsx +4 -0
  135. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  136. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  137. package/src/components/tables/table-spreadsheet.tsx +29 -5
  138. package/src/components/tables/table-toolbar.tsx +10 -1
  139. package/src/components/tasks/kanban-board.tsx +1 -0
  140. package/src/components/tasks/kanban-column.tsx +53 -14
  141. package/src/components/tasks/task-bento-grid.tsx +31 -2
  142. package/src/components/tasks/task-card.tsx +29 -3
  143. package/src/components/tasks/task-chip-bar.tsx +54 -1
  144. package/src/components/tasks/task-result-renderer.tsx +1 -1
  145. package/src/components/workflows/delay-step-body.tsx +109 -0
  146. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  147. package/src/components/workflows/loop-status-view.tsx +1 -1
  148. package/src/components/workflows/shared/step-result.tsx +78 -0
  149. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  150. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  151. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  152. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  153. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  154. package/src/components/workflows/workflow-form-view.tsx +133 -16
  155. package/src/components/workflows/workflow-status-view.tsx +30 -740
  156. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  157. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  158. package/src/hooks/use-active-skills.ts +110 -0
  159. package/src/hooks/use-chat-autocomplete.ts +120 -7
  160. package/src/hooks/use-enriched-skills.ts +19 -0
  161. package/src/hooks/use-pinned-entries.ts +104 -0
  162. package/src/hooks/use-recent-user-messages.ts +19 -0
  163. package/src/hooks/use-saved-searches.ts +142 -0
  164. package/src/instrumentation-node.ts +94 -0
  165. package/src/instrumentation.ts +4 -48
  166. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  167. package/src/lib/agents/__tests__/claude-agent.test.ts +212 -0
  168. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  169. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  170. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  171. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  172. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  173. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  174. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  175. package/src/lib/agents/claude-agent.ts +217 -21
  176. package/src/lib/agents/execution-manager.ts +0 -35
  177. package/src/lib/agents/handoff/bus.ts +2 -2
  178. package/src/lib/agents/learned-context.ts +0 -12
  179. package/src/lib/agents/learning-session.ts +18 -5
  180. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  181. package/src/lib/agents/profiles/__tests__/registry.test.ts +53 -4
  182. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +97 -0
  183. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +36 -0
  184. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  185. package/src/lib/agents/profiles/registry.ts +18 -0
  186. package/src/lib/agents/profiles/types.ts +7 -1
  187. package/src/lib/agents/router.ts +3 -6
  188. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  189. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  190. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  191. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  192. package/src/lib/agents/runtime/catalog.ts +121 -0
  193. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  194. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  195. package/src/lib/agents/runtime/execution-target.ts +456 -0
  196. package/src/lib/agents/runtime/index.ts +4 -0
  197. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  198. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  199. package/src/lib/agents/runtime/openai-codex.ts +64 -60
  200. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  201. package/src/lib/agents/runtime/types.ts +8 -0
  202. package/src/lib/agents/task-dispatch.ts +220 -0
  203. package/src/lib/agents/tool-permissions.ts +16 -1
  204. package/src/lib/book/chapter-mapping.ts +11 -0
  205. package/src/lib/book/content.ts +10 -0
  206. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  207. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  208. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  209. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  210. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  211. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  212. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  213. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  214. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  215. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  216. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  217. package/src/lib/chat/__tests__/types.test.ts +28 -0
  218. package/src/lib/chat/active-skills.ts +31 -0
  219. package/src/lib/chat/active-streams.ts +27 -0
  220. package/src/lib/chat/clean-filter-input.ts +30 -0
  221. package/src/lib/chat/codex-engine.ts +46 -24
  222. package/src/lib/chat/command-tabs.ts +61 -0
  223. package/src/lib/chat/context-builder.ts +146 -4
  224. package/src/lib/chat/dismissals.ts +73 -0
  225. package/src/lib/chat/engine.ts +159 -18
  226. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  227. package/src/lib/chat/files/expand-mention.ts +76 -0
  228. package/src/lib/chat/files/search.ts +99 -0
  229. package/src/lib/chat/reconcile.ts +117 -0
  230. package/src/lib/chat/skill-composition.ts +210 -0
  231. package/src/lib/chat/skill-conflict.ts +105 -0
  232. package/src/lib/chat/stagent-tools.ts +7 -19
  233. package/src/lib/chat/stream-telemetry.ts +137 -0
  234. package/src/lib/chat/suggested-prompts.ts +28 -1
  235. package/src/lib/chat/system-prompt.ts +48 -1
  236. package/src/lib/chat/tool-catalog.ts +35 -4
  237. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  238. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  239. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  240. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  241. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  242. package/src/lib/chat/tools/__tests__/task-tools.test.ts +399 -0
  243. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +351 -0
  244. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  245. package/src/lib/chat/tools/document-tools.ts +29 -13
  246. package/src/lib/chat/tools/helpers.ts +41 -0
  247. package/src/lib/chat/tools/notification-tools.ts +9 -5
  248. package/src/lib/chat/tools/profile-tools.ts +120 -23
  249. package/src/lib/chat/tools/project-tools.ts +33 -0
  250. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  251. package/src/lib/chat/tools/skill-tools.ts +183 -0
  252. package/src/lib/chat/tools/table-tools.ts +71 -0
  253. package/src/lib/chat/tools/task-tools.ts +89 -21
  254. package/src/lib/chat/tools/workflow-tools.ts +275 -32
  255. package/src/lib/chat/types.ts +15 -0
  256. package/src/lib/constants/settings.ts +10 -18
  257. package/src/lib/data/__tests__/clear.test.ts +56 -2
  258. package/src/lib/data/clear.ts +17 -16
  259. package/src/lib/data/delete-project.ts +171 -0
  260. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  261. package/src/lib/db/bootstrap.ts +62 -16
  262. package/src/lib/db/index.ts +5 -0
  263. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  264. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  265. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  266. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  267. package/src/lib/db/migrations/meta/_journal.json +21 -0
  268. package/src/lib/db/schema.ts +94 -23
  269. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  270. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  271. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  272. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  273. package/src/lib/environment/data.ts +9 -0
  274. package/src/lib/environment/list-skills.ts +176 -0
  275. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  276. package/src/lib/environment/parsers/skill.ts +26 -5
  277. package/src/lib/environment/profile-generator.ts +54 -0
  278. package/src/lib/environment/skill-enrichment.ts +106 -0
  279. package/src/lib/environment/skill-recommendations.ts +66 -0
  280. package/src/lib/environment/workspace-context.ts +13 -1
  281. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  282. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  283. package/src/lib/filters/parse.ts +86 -0
  284. package/src/lib/import/dedup.ts +4 -54
  285. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  286. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  287. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  288. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  289. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  290. package/src/lib/instance/__tests__/upgrade-poller.test.ts +181 -0
  291. package/src/lib/instance/bootstrap.ts +270 -0
  292. package/src/lib/instance/detect.ts +49 -0
  293. package/src/lib/instance/fingerprint.ts +76 -0
  294. package/src/lib/instance/git-ops.ts +95 -0
  295. package/src/lib/instance/settings.ts +61 -0
  296. package/src/lib/instance/types.ts +77 -0
  297. package/src/lib/instance/upgrade-poller.ts +205 -0
  298. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  299. package/src/lib/notifications/visibility.ts +33 -0
  300. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  301. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  302. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  303. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  304. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  305. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  306. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  307. package/src/lib/schedules/collision-check.ts +105 -0
  308. package/src/lib/schedules/config.ts +53 -0
  309. package/src/lib/schedules/scheduler.ts +236 -17
  310. package/src/lib/schedules/slot-claim.ts +105 -0
  311. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  312. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  313. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  314. package/src/lib/settings/openai-auth.ts +105 -10
  315. package/src/lib/settings/openai-login-manager.ts +260 -0
  316. package/src/lib/settings/runtime-setup.ts +14 -4
  317. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  318. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  319. package/src/lib/tables/enrichment-planner.ts +454 -0
  320. package/src/lib/tables/enrichment.ts +328 -0
  321. package/src/lib/tables/query-builder.ts +5 -2
  322. package/src/lib/tables/trigger-evaluator.ts +3 -2
  323. package/src/lib/theme.ts +71 -0
  324. package/src/lib/usage/ledger.ts +2 -18
  325. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  326. package/src/lib/util/similarity.ts +77 -0
  327. package/src/lib/utils/format-timestamp.ts +24 -0
  328. package/src/lib/utils/stagent-paths.ts +12 -0
  329. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  330. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  331. package/src/lib/validators/blueprint.ts +70 -9
  332. package/src/lib/validators/profile.ts +2 -2
  333. package/src/lib/validators/settings.ts +3 -1
  334. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  335. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  336. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  337. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  338. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  339. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  340. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  341. package/src/lib/workflows/blueprints/types.ts +16 -2
  342. package/src/lib/workflows/delay.ts +106 -0
  343. package/src/lib/workflows/engine.ts +212 -7
  344. package/src/lib/workflows/loop-executor.ts +349 -24
  345. package/src/lib/workflows/post-action.ts +91 -0
  346. package/src/lib/workflows/types.ts +166 -1
  347. package/src/test/setup.ts +10 -0
  348. package/src/app/api/license/checkout/route.ts +0 -28
  349. package/src/app/api/license/portal/route.ts +0 -26
  350. package/src/app/api/license/route.ts +0 -89
  351. package/src/app/api/license/usage/route.ts +0 -63
  352. package/src/app/api/marketplace/browse/route.ts +0 -15
  353. package/src/app/api/marketplace/import/route.ts +0 -28
  354. package/src/app/api/marketplace/publish/route.ts +0 -40
  355. package/src/app/api/onboarding/email/route.ts +0 -53
  356. package/src/app/api/settings/telemetry/route.ts +0 -14
  357. package/src/app/api/sync/export/route.ts +0 -54
  358. package/src/app/api/sync/restore/route.ts +0 -37
  359. package/src/app/api/sync/sessions/route.ts +0 -24
  360. package/src/app/auth/callback/route.ts +0 -73
  361. package/src/app/marketplace/page.tsx +0 -19
  362. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  363. package/src/components/marketplace/blueprint-card.tsx +0 -61
  364. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  365. package/src/components/onboarding/email-capture-card.tsx +0 -104
  366. package/src/components/settings/activation-form.tsx +0 -95
  367. package/src/components/settings/cloud-account-section.tsx +0 -147
  368. package/src/components/settings/cloud-sync-section.tsx +0 -155
  369. package/src/components/settings/subscription-section.tsx +0 -410
  370. package/src/components/settings/telemetry-section.tsx +0 -80
  371. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  372. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  373. package/src/components/shared/upgrade-banner.tsx +0 -112
  374. package/src/hooks/use-supabase-auth.ts +0 -79
  375. package/src/lib/billing/email.ts +0 -54
  376. package/src/lib/billing/products.ts +0 -80
  377. package/src/lib/billing/stripe.ts +0 -101
  378. package/src/lib/cloud/supabase-browser.ts +0 -32
  379. package/src/lib/cloud/supabase-client.ts +0 -56
  380. package/src/lib/license/__tests__/features.test.ts +0 -56
  381. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  382. package/src/lib/license/__tests__/manager.test.ts +0 -64
  383. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  384. package/src/lib/license/cloud-validation.ts +0 -60
  385. package/src/lib/license/features.ts +0 -44
  386. package/src/lib/license/key-format.ts +0 -101
  387. package/src/lib/license/limit-check.ts +0 -111
  388. package/src/lib/license/limit-queries.ts +0 -51
  389. package/src/lib/license/manager.ts +0 -345
  390. package/src/lib/license/notifications.ts +0 -59
  391. package/src/lib/license/tier-limits.ts +0 -71
  392. package/src/lib/marketplace/marketplace-client.ts +0 -107
  393. package/src/lib/sync/cloud-sync.ts +0 -235
  394. package/src/lib/telemetry/conversion-events.ts +0 -71
  395. package/src/lib/telemetry/queue.ts +0 -122
  396. package/src/lib/validators/license.ts +0 -33
@@ -0,0 +1,790 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ChatSessionProvider — layout-level provider that owns chat session state.
5
+ *
6
+ * Why this exists:
7
+ *
8
+ * Before this provider, every piece of chat-domain state (conversations,
9
+ * messagesByConversation, activeId, isStreaming, abortController) lived in
10
+ * local useState hooks inside `ChatShell`. ChatShell is rendered from
11
+ * `src/app/chat/page.tsx`, which is a route-level component — so navigating
12
+ * away from /chat via the sidebar unmounted ChatShell and destroyed all
13
+ * state. In-flight SSE reader loops ran off into the void, partial assistant
14
+ * messages were lost from client memory (though the server-side
15
+ * finalizeStreamingMessage() salvaged them into the DB), and on return to
16
+ * /chat the `handleSelectConversation` catch block would call
17
+ * `setMessages([])`, wiping visible turn history entirely.
18
+ *
19
+ * By hoisting state into a provider rendered from `src/app/layout.tsx`
20
+ * around `<main>{children}</main>`, the provider — and everything it holds —
21
+ * persists across child-route transitions. ChatShell becomes a thin "view"
22
+ * that reads from the provider via `useChatSession()`. The SSE reader loop
23
+ * runs inside the provider callback, so view unmounts no longer touch it.
24
+ *
25
+ * See `features/chat-session-persistence-provider.md` for the full spec.
26
+ */
27
+
28
+ import {
29
+ createContext,
30
+ useCallback,
31
+ useContext,
32
+ useEffect,
33
+ useMemo,
34
+ useRef,
35
+ useState,
36
+ type ReactNode,
37
+ } from "react";
38
+ import { useRouter } from "next/navigation";
39
+ import { toast } from "sonner";
40
+ import type { ConversationRow, ChatMessageRow } from "@/lib/db/schema";
41
+ import { HelpDialog } from "./help-dialog";
42
+ import {
43
+ DEFAULT_CHAT_MODEL,
44
+ CHAT_MODELS,
45
+ getRuntimeForModel,
46
+ type ChatModelOption,
47
+ } from "@/lib/chat/types";
48
+ import type { MentionReference } from "@/hooks/use-chat-autocomplete";
49
+
50
+ // ── Types ──────────────────────────────────────────────────────────────
51
+
52
+ interface StreamingState {
53
+ conversationId: string;
54
+ assistantMsgId: string;
55
+ abortController: AbortController;
56
+ startedAt: number;
57
+ }
58
+
59
+ interface ChatSessionValue {
60
+ // State
61
+ conversations: ConversationRow[];
62
+ activeId: string | null;
63
+ messages: ChatMessageRow[]; // messages for the active conversation
64
+ isStreaming: boolean;
65
+ modelId: string;
66
+ availableModels: ChatModelOption[];
67
+ hydrated: boolean;
68
+
69
+ // Actions
70
+ hydrate: (payload: {
71
+ conversations: ConversationRow[];
72
+ initialActiveId: string | null;
73
+ }) => void;
74
+ setActiveConversation: (id: string | null, opts?: { skipLoad?: boolean }) => void;
75
+ sendMessage: (content: string, mentions?: MentionReference[]) => Promise<void>;
76
+ stopStreaming: () => void;
77
+ createConversation: (opts?: { title?: string }) => Promise<string | null>;
78
+ deleteConversation: (id: string) => Promise<void>;
79
+ renameConversation: (id: string, title: string) => Promise<void>;
80
+ setMessageStatus: (
81
+ messageId: string,
82
+ status: "pending" | "streaming" | "complete" | "error"
83
+ ) => void;
84
+ setModelId: (modelId: string) => Promise<void>;
85
+ }
86
+
87
+ const ChatSessionContext = createContext<ChatSessionValue | null>(null);
88
+
89
+ // ── Provider ───────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Wraps the app and owns all chat session state. Rendered from
93
+ * `src/app/layout.tsx` around `<main>{children}</main>` so it survives
94
+ * sidebar navigation.
95
+ */
96
+ export function ChatSessionProvider({ children }: { children: ReactNode }) {
97
+ const router = useRouter();
98
+
99
+ // ── State ────────────────────────────────────────────────────────────
100
+ // Keyed by conversation id so multiple conversations can hold messages
101
+ // without clobbering each other.
102
+ const [conversations, setConversations] = useState<ConversationRow[]>([]);
103
+ const [messagesByConversation, setMessagesByConversation] = useState<
104
+ Record<string, ChatMessageRow[]>
105
+ >({});
106
+ const [activeId, setActiveId] = useState<string | null>(null);
107
+ const [streamingState, setStreamingState] = useState<StreamingState | null>(
108
+ null
109
+ );
110
+ const [modelId, setModelIdState] = useState<string>(DEFAULT_CHAT_MODEL);
111
+ const [availableModels, setAvailableModels] =
112
+ useState<ChatModelOption[]>(CHAT_MODELS);
113
+ const [hydrated, setHydrated] = useState(false);
114
+
115
+ const [helpDialogOpen, setHelpDialogOpen] = useState(false);
116
+
117
+ // Refs for values read from async callbacks that mustn't see stale state.
118
+ const activeIdRef = useRef<string | null>(null);
119
+ activeIdRef.current = activeId;
120
+ const modelIdRef = useRef<string>(modelId);
121
+ modelIdRef.current = modelId;
122
+ const messagesByConversationRef = useRef<Record<string, ChatMessageRow[]>>({});
123
+ messagesByConversationRef.current = messagesByConversation;
124
+
125
+ // ── One-time model + available-models fetch ──────────────────────────
126
+ // Runs once per page load (provider lives in root layout, not /chat page).
127
+ useEffect(() => {
128
+ let cancelled = false;
129
+ fetch("/api/settings/chat")
130
+ .then((r) => (r.ok ? r.json() : null))
131
+ .then((data) => {
132
+ if (!cancelled && data?.defaultModel) {
133
+ setModelIdState(data.defaultModel);
134
+ }
135
+ })
136
+ .catch(() => {});
137
+
138
+ fetch("/api/chat/models")
139
+ .then((r) => (r.ok ? r.json() : null))
140
+ .then((models) => {
141
+ if (!cancelled && models?.length) {
142
+ setAvailableModels(models);
143
+ }
144
+ })
145
+ .catch(() => {});
146
+
147
+ return () => {
148
+ cancelled = true;
149
+ };
150
+ }, []);
151
+
152
+ // ── Hydration from server-rendered page ──────────────────────────────
153
+ // ChatShell calls this on mount with the conversations loaded by
154
+ // `src/app/chat/page.tsx`. On first call we populate everything. On
155
+ // subsequent calls (remount after navigation) we only refresh the
156
+ // conversation list — we do NOT clobber in-memory streaming state or
157
+ // messagesByConversation, which may contain a partial assistant message
158
+ // that is still streaming.
159
+ const hydrate = useCallback(
160
+ (payload: {
161
+ conversations: ConversationRow[];
162
+ initialActiveId: string | null;
163
+ }) => {
164
+ setConversations(payload.conversations);
165
+ setHydrated((already) => {
166
+ if (already) return true;
167
+ // First-time hydration: restore active id from URL/prop, then from localStorage.
168
+ let restoredId = payload.initialActiveId;
169
+ if (!restoredId) {
170
+ try {
171
+ restoredId = localStorage.getItem("stagent-active-chat") || null;
172
+ } catch {
173
+ /* localStorage unavailable */
174
+ }
175
+ }
176
+ if (
177
+ restoredId &&
178
+ payload.conversations.some((c) => c.id === restoredId)
179
+ ) {
180
+ setActiveId(restoredId);
181
+ // Fetch messages for the restored conversation. On failure we
182
+ // do NOT clear — we leave messages as-is (empty on first load)
183
+ // and surface a toast.
184
+ void loadMessagesForConversation(restoredId);
185
+ }
186
+ return true;
187
+ });
188
+ },
189
+ // loadMessagesForConversation is stable via useCallback below
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps
191
+ []
192
+ );
193
+
194
+ // ── Message loading ──────────────────────────────────────────────────
195
+ const loadMessagesForConversation = useCallback(
196
+ async (conversationId: string): Promise<void> => {
197
+ try {
198
+ const res = await fetch(
199
+ `/api/chat/conversations/${conversationId}/messages`
200
+ );
201
+ if (!res.ok) {
202
+ // IMPORTANT: do NOT clear existing messages on failure. The old
203
+ // ChatShell catch-all was `setMessages([])`, which wiped visible
204
+ // turn history on any fetch hiccup. Preserve what we have and
205
+ // surface a non-blocking toast.
206
+ toast.error("Failed to load conversation messages");
207
+ return;
208
+ }
209
+ const rows = (await res.json()) as ChatMessageRow[];
210
+ // Clean up stale "streaming" rows from interrupted prior sessions.
211
+ // The server's reconcile sweep handles this as a safety net, but
212
+ // normalize on the client so the UI never shows a permanent spinner.
213
+ const cleaned = rows.map((m) =>
214
+ m.status === "streaming"
215
+ ? { ...m, status: "complete" as const }
216
+ : m
217
+ );
218
+ setMessagesByConversation((prev) => ({
219
+ ...prev,
220
+ [conversationId]: cleaned,
221
+ }));
222
+ } catch (err) {
223
+ // Network failure — same policy, do NOT clear.
224
+ console.warn(
225
+ "[chat-session] loadMessagesForConversation failed:",
226
+ err
227
+ );
228
+ }
229
+ },
230
+ []
231
+ );
232
+
233
+ // ── Conversation selection ───────────────────────────────────────────
234
+ const setActiveConversation = useCallback(
235
+ (id: string | null, opts?: { skipLoad?: boolean }) => {
236
+ setActiveId(id);
237
+ try {
238
+ if (id) localStorage.setItem("stagent-active-chat", id);
239
+ else localStorage.removeItem("stagent-active-chat");
240
+ } catch {
241
+ /* localStorage unavailable */
242
+ }
243
+ // Only update URL when we're on /chat. If the user clicked a
244
+ // conversation from a different route (unlikely today but possible
245
+ // via future deep links), leave their current location alone.
246
+ if (typeof window !== "undefined" && window.location.pathname === "/chat") {
247
+ router.replace(id ? `/chat?c=${id}` : "/chat", { scroll: false });
248
+ }
249
+ if (id && !opts?.skipLoad && !messagesByConversation[id]) {
250
+ void loadMessagesForConversation(id);
251
+ }
252
+ // Also refresh conversation metadata (title, model, etc.) in the
253
+ // background. Failure is non-blocking.
254
+ if (id) {
255
+ fetch(`/api/chat/conversations/${id}`)
256
+ .then((r) => (r.ok ? r.json() : null))
257
+ .then((conv) => {
258
+ if (conv?.modelId) setModelIdState(conv.modelId);
259
+ })
260
+ .catch(() => {});
261
+ }
262
+ },
263
+ [messagesByConversation, loadMessagesForConversation, router]
264
+ );
265
+
266
+ // ── Conversation CRUD ────────────────────────────────────────────────
267
+ const createConversation = useCallback(
268
+ async (opts?: { title?: string }): Promise<string | null> => {
269
+ try {
270
+ const res = await fetch("/api/chat/conversations", {
271
+ method: "POST",
272
+ headers: { "Content-Type": "application/json" },
273
+ body: JSON.stringify({
274
+ runtimeId: getRuntimeForModel(modelIdRef.current),
275
+ modelId: modelIdRef.current,
276
+ ...(opts?.title ? { title: opts.title } : {}),
277
+ }),
278
+ });
279
+ if (!res.ok) return null;
280
+ const conversation = (await res.json()) as ConversationRow;
281
+ setConversations((prev) => [conversation, ...prev]);
282
+ // Set empty messages BEFORE activating so the conversation has an
283
+ // entry in messagesByConversation. Use skipLoad to prevent
284
+ // setActiveConversation from firing an async loadMessagesForConversation
285
+ // that would race with the optimistic messages added by sendMessage().
286
+ setMessagesByConversation((prev) => ({
287
+ ...prev,
288
+ [conversation.id]: [],
289
+ }));
290
+ setActiveConversation(conversation.id, { skipLoad: true });
291
+ return conversation.id;
292
+ } catch {
293
+ return null;
294
+ }
295
+ },
296
+ [setActiveConversation]
297
+ );
298
+
299
+ // ── Environment rescan on conversation activation ────────────────────
300
+ // Fire-and-forget; endpoint self-guards with shouldRescan() (5min TTL).
301
+ useEffect(() => {
302
+ if (!activeId) return;
303
+ fetch("/api/environment/rescan-if-stale", { method: "POST" }).catch(() => {});
304
+ }, [activeId]);
305
+
306
+ // ── Chat command event listeners ─────────────────────────────────────
307
+ // Handles CustomEvents dispatched by chat-input.tsx (⌘L, slash commands).
308
+ useEffect(() => {
309
+ const handleClear = () => {
310
+ void createConversation();
311
+ };
312
+ const handleCompact = () => {
313
+ toast.info("Compact is not wired yet — coming soon.");
314
+ };
315
+ const handleExport = async () => {
316
+ const activeConversationId = activeIdRef.current;
317
+ const msgs = activeConversationId
318
+ ? messagesByConversationRef.current[activeConversationId]
319
+ : undefined;
320
+ if (!msgs || msgs.length === 0) {
321
+ toast.error("Nothing to export — this conversation is empty.");
322
+ return;
323
+ }
324
+ const title = `Chat — ${new Date().toISOString().slice(0, 10)}`;
325
+ const markdown = msgs
326
+ .map((m) => `### ${m.role === "user" ? "You" : "Assistant"}\n\n${m.content}`)
327
+ .join("\n\n---\n\n");
328
+ try {
329
+ const res = await fetch("/api/chat/export", {
330
+ method: "POST",
331
+ headers: { "Content-Type": "application/json" },
332
+ body: JSON.stringify({
333
+ title,
334
+ markdown,
335
+ conversationId: activeConversationId,
336
+ }),
337
+ });
338
+ if (!res.ok) throw new Error(`Export failed: ${res.status}`);
339
+ toast.success("Conversation exported to documents.");
340
+ } catch (err) {
341
+ toast.error(err instanceof Error ? err.message : "Export failed");
342
+ }
343
+ };
344
+ const handleHelp = () => setHelpDialogOpen(true);
345
+
346
+ window.addEventListener("stagent.chat.clear", handleClear);
347
+ window.addEventListener("stagent.chat.compact", handleCompact);
348
+ window.addEventListener("stagent.chat.export", handleExport);
349
+ window.addEventListener("stagent.chat.help", handleHelp);
350
+
351
+ return () => {
352
+ window.removeEventListener("stagent.chat.clear", handleClear);
353
+ window.removeEventListener("stagent.chat.compact", handleCompact);
354
+ window.removeEventListener("stagent.chat.export", handleExport);
355
+ window.removeEventListener("stagent.chat.help", handleHelp);
356
+ };
357
+ }, [createConversation]);
358
+
359
+ const deleteConversation = useCallback(
360
+ async (id: string) => {
361
+ try {
362
+ await fetch(`/api/chat/conversations/${id}`, { method: "DELETE" });
363
+ setConversations((prev) => prev.filter((c) => c.id !== id));
364
+ setMessagesByConversation((prev) => {
365
+ const next = { ...prev };
366
+ delete next[id];
367
+ return next;
368
+ });
369
+ if (activeIdRef.current === id) {
370
+ setActiveConversation(null);
371
+ }
372
+ } catch {
373
+ toast.error("Failed to delete conversation");
374
+ }
375
+ },
376
+ [setActiveConversation]
377
+ );
378
+
379
+ const renameConversation = useCallback(async (id: string, title: string) => {
380
+ try {
381
+ const res = await fetch(`/api/chat/conversations/${id}`, {
382
+ method: "PATCH",
383
+ headers: { "Content-Type": "application/json" },
384
+ body: JSON.stringify({ title }),
385
+ });
386
+ if (res.ok) {
387
+ const updated = (await res.json()) as ConversationRow;
388
+ setConversations((prev) =>
389
+ prev.map((c) => (c.id === id ? updated : c))
390
+ );
391
+ }
392
+ } catch {
393
+ toast.error("Failed to rename conversation");
394
+ }
395
+ }, []);
396
+
397
+ // ── Message status (used by inline permission / question UI) ─────────
398
+ const setMessageStatus = useCallback(
399
+ (
400
+ messageId: string,
401
+ status: "pending" | "streaming" | "complete" | "error"
402
+ ) => {
403
+ setMessagesByConversation((prev) => {
404
+ const next: Record<string, ChatMessageRow[]> = {};
405
+ for (const [convId, msgs] of Object.entries(prev)) {
406
+ next[convId] = msgs.map((m) =>
407
+ m.id === messageId ? { ...m, status } : m
408
+ );
409
+ }
410
+ return next;
411
+ });
412
+ },
413
+ []
414
+ );
415
+
416
+ // ── Model selection ──────────────────────────────────────────────────
417
+ const setModelId = useCallback(async (newModelId: string) => {
418
+ setModelIdState(newModelId);
419
+ const currentActive = activeIdRef.current;
420
+ if (currentActive) {
421
+ const newRuntimeId = getRuntimeForModel(newModelId);
422
+ try {
423
+ await fetch(`/api/chat/conversations/${currentActive}`, {
424
+ method: "PATCH",
425
+ headers: { "Content-Type": "application/json" },
426
+ body: JSON.stringify({ modelId: newModelId, runtimeId: newRuntimeId }),
427
+ });
428
+ } catch {
429
+ /* non-fatal */
430
+ }
431
+ setConversations((prev) =>
432
+ prev.map((c) =>
433
+ c.id === currentActive
434
+ ? { ...c, modelId: newModelId, runtimeId: newRuntimeId }
435
+ : c
436
+ )
437
+ );
438
+ }
439
+ }, []);
440
+
441
+ // ── Streaming: sendMessage + stopStreaming ──────────────────────────
442
+ // The SSE reader loop runs inside the provider. If the consumer view
443
+ // (ChatShell) unmounts mid-stream, this loop continues — state updates
444
+ // go to the provider, which is still mounted from the root layout.
445
+ const sendMessage = useCallback(
446
+ async (content: string, mentions?: MentionReference[]): Promise<void> => {
447
+ let conversationId = activeIdRef.current;
448
+
449
+ // Create conversation on first message if none active
450
+ if (!conversationId) {
451
+ conversationId = await createConversation();
452
+ if (!conversationId) return;
453
+ }
454
+
455
+ // Optimistic user message
456
+ const userMsg: ChatMessageRow = {
457
+ id: crypto.randomUUID(),
458
+ conversationId,
459
+ role: "user",
460
+ content,
461
+ metadata: null,
462
+ status: "complete",
463
+ createdAt: new Date(),
464
+ };
465
+
466
+ // Placeholder assistant message
467
+ const assistantMsgId = crypto.randomUUID();
468
+ const assistantMsg: ChatMessageRow = {
469
+ id: assistantMsgId,
470
+ conversationId,
471
+ role: "assistant",
472
+ content: "",
473
+ metadata: null,
474
+ status: "streaming",
475
+ createdAt: new Date(),
476
+ };
477
+
478
+ setMessagesByConversation((prev) => ({
479
+ ...prev,
480
+ [conversationId!]: [...(prev[conversationId!] ?? []), userMsg, assistantMsg],
481
+ }));
482
+
483
+ const controller = new AbortController();
484
+ const startedAt = Date.now();
485
+ setStreamingState({
486
+ conversationId,
487
+ assistantMsgId,
488
+ abortController: controller,
489
+ startedAt,
490
+ });
491
+
492
+ // Capture conversationId in a local (non-null) binding for callbacks
493
+ const convId = conversationId;
494
+
495
+ try {
496
+ const res = await fetch(
497
+ `/api/chat/conversations/${convId}/messages`,
498
+ {
499
+ method: "POST",
500
+ headers: { "Content-Type": "application/json" },
501
+ body: JSON.stringify({ content, mentions }),
502
+ signal: controller.signal,
503
+ }
504
+ );
505
+
506
+ if (!res.ok || !res.body) {
507
+ throw new Error("Failed to send message");
508
+ }
509
+
510
+ const reader = res.body.getReader();
511
+ const decoder = new TextDecoder();
512
+ let buffer = "";
513
+
514
+ // Helper: update the single assistant message being streamed,
515
+ // without touching any other conversation's state.
516
+ const updateAssistant = (
517
+ updater: (msg: ChatMessageRow) => ChatMessageRow
518
+ ) => {
519
+ setMessagesByConversation((prev) => {
520
+ const msgs = prev[convId] ?? [];
521
+ return {
522
+ ...prev,
523
+ [convId]: msgs.map((m) =>
524
+ m.id === assistantMsgId ? updater(m) : m
525
+ ),
526
+ };
527
+ });
528
+ };
529
+
530
+ const appendMessage = (msg: ChatMessageRow) => {
531
+ setMessagesByConversation((prev) => ({
532
+ ...prev,
533
+ [convId]: [...(prev[convId] ?? []), msg],
534
+ }));
535
+ };
536
+
537
+ while (true) {
538
+ const { done, value } = await reader.read();
539
+ if (done) {
540
+ console.info("[chat-stream] client.stream.done", {
541
+ conversationId: convId,
542
+ messageId: assistantMsgId,
543
+ durationMs: Date.now() - startedAt,
544
+ });
545
+ break;
546
+ }
547
+
548
+ buffer += decoder.decode(value, { stream: true });
549
+ const lines = buffer.split("\n");
550
+ buffer = lines.pop() ?? "";
551
+
552
+ for (const line of lines) {
553
+ if (!line.startsWith("data: ")) continue;
554
+ const json = line.slice(6);
555
+ try {
556
+ const event = JSON.parse(json);
557
+ if (event.type === "status") {
558
+ updateAssistant((m) => ({
559
+ ...m,
560
+ metadata: JSON.stringify({
561
+ statusPhase: event.phase,
562
+ statusMessage: event.message,
563
+ }),
564
+ }));
565
+ } else if (event.type === "delta") {
566
+ updateAssistant((m) => ({
567
+ ...m,
568
+ content: m.content + event.content,
569
+ }));
570
+ } else if (event.type === "done") {
571
+ updateAssistant((m) => {
572
+ const existing = m.metadata
573
+ ? (() => {
574
+ try {
575
+ return JSON.parse(m.metadata!);
576
+ } catch {
577
+ return {};
578
+ }
579
+ })()
580
+ : {};
581
+ if (event.quickAccess?.length) {
582
+ existing.quickAccess = event.quickAccess;
583
+ }
584
+ return {
585
+ ...m,
586
+ id: event.messageId,
587
+ status: "complete",
588
+ metadata: JSON.stringify(existing),
589
+ };
590
+ });
591
+ // Refresh conversation title from server (auto-generated on
592
+ // first exchange).
593
+ fetch(`/api/chat/conversations/${convId}`)
594
+ .then((r) => (r.ok ? r.json() : null))
595
+ .then((conv) => {
596
+ if (conv) {
597
+ setConversations((prev) =>
598
+ prev.map((c) =>
599
+ c.id === convId
600
+ ? { ...c, title: conv.title, updatedAt: new Date() }
601
+ : c
602
+ )
603
+ );
604
+ }
605
+ })
606
+ .catch(() => {});
607
+
608
+ } else if (
609
+ event.type === "permission_request" ||
610
+ event.type === "question"
611
+ ) {
612
+ const systemMsg: ChatMessageRow = {
613
+ id: event.messageId,
614
+ conversationId: convId,
615
+ role: "system",
616
+ content:
617
+ event.type === "permission_request"
618
+ ? `Permission required: ${event.toolName}`
619
+ : "Agent has a question",
620
+ metadata: JSON.stringify(
621
+ event.type === "permission_request"
622
+ ? {
623
+ type: "permission_request",
624
+ requestId: event.requestId,
625
+ toolName: event.toolName,
626
+ toolInput: event.toolInput,
627
+ }
628
+ : {
629
+ type: "question",
630
+ requestId: event.requestId,
631
+ questions: event.questions,
632
+ }
633
+ ),
634
+ status: "pending",
635
+ createdAt: new Date(),
636
+ };
637
+ appendMessage(systemMsg);
638
+ } else if (event.type === "screenshot") {
639
+ updateAssistant((m) => {
640
+ const meta = m.metadata
641
+ ? (() => {
642
+ try {
643
+ return JSON.parse(m.metadata!);
644
+ } catch {
645
+ return {};
646
+ }
647
+ })()
648
+ : {};
649
+ const attachments = Array.isArray(meta.attachments)
650
+ ? meta.attachments
651
+ : [];
652
+ attachments.push({
653
+ documentId: event.documentId,
654
+ thumbnailUrl: event.thumbnailUrl,
655
+ originalUrl: event.originalUrl,
656
+ width: event.width,
657
+ height: event.height,
658
+ });
659
+ return {
660
+ ...m,
661
+ metadata: JSON.stringify({ ...meta, attachments }),
662
+ };
663
+ });
664
+ } else if (event.type === "error") {
665
+ updateAssistant((m) => ({
666
+ ...m,
667
+ content: m.content || event.message,
668
+ status: "error",
669
+ }));
670
+ }
671
+ } catch {
672
+ // Ignore malformed SSE data
673
+ }
674
+ }
675
+ }
676
+ } catch (error) {
677
+ const isAbort = (error as Error).name === "AbortError";
678
+ if (isAbort) {
679
+ console.info("[chat-stream] client.stream.user-abort", {
680
+ conversationId: convId,
681
+ messageId: assistantMsgId,
682
+ durationMs: Date.now() - startedAt,
683
+ });
684
+ } else {
685
+ console.info("[chat-stream] client.stream.reader-error", {
686
+ conversationId: convId,
687
+ messageId: assistantMsgId,
688
+ durationMs: Date.now() - startedAt,
689
+ error: (error as Error).message,
690
+ });
691
+ setMessagesByConversation((prev) => {
692
+ const msgs = prev[convId] ?? [];
693
+ return {
694
+ ...prev,
695
+ [convId]: msgs.map((m) =>
696
+ m.id === assistantMsgId
697
+ ? {
698
+ ...m,
699
+ content:
700
+ m.content || "Failed to get response. Please try again.",
701
+ status: "error",
702
+ }
703
+ : m
704
+ ),
705
+ };
706
+ });
707
+ }
708
+ } finally {
709
+ setStreamingState(null);
710
+ }
711
+ },
712
+ [createConversation]
713
+ );
714
+
715
+ const stopStreaming = useCallback(() => {
716
+ setStreamingState((current) => {
717
+ current?.abortController.abort();
718
+ return current;
719
+ });
720
+ }, []);
721
+
722
+ // ── Derived: messages for the active conversation ───────────────────
723
+ const messages = useMemo<ChatMessageRow[]>(
724
+ () => (activeId ? messagesByConversation[activeId] ?? [] : []),
725
+ [activeId, messagesByConversation]
726
+ );
727
+
728
+ const isStreaming = streamingState !== null;
729
+
730
+ const value = useMemo<ChatSessionValue>(
731
+ () => ({
732
+ conversations,
733
+ activeId,
734
+ messages,
735
+ isStreaming,
736
+ modelId,
737
+ availableModels,
738
+ hydrated,
739
+ hydrate,
740
+ setActiveConversation,
741
+ sendMessage,
742
+ stopStreaming,
743
+ createConversation,
744
+ deleteConversation,
745
+ renameConversation,
746
+ setMessageStatus,
747
+ setModelId,
748
+ }),
749
+ [
750
+ conversations,
751
+ activeId,
752
+ messages,
753
+ isStreaming,
754
+ modelId,
755
+ availableModels,
756
+ hydrated,
757
+ hydrate,
758
+ setActiveConversation,
759
+ sendMessage,
760
+ stopStreaming,
761
+ createConversation,
762
+ deleteConversation,
763
+ renameConversation,
764
+ setMessageStatus,
765
+ setModelId,
766
+ ]
767
+ );
768
+
769
+ return (
770
+ <ChatSessionContext.Provider value={value}>
771
+ {children}
772
+ <HelpDialog open={helpDialogOpen} onOpenChange={setHelpDialogOpen} />
773
+ </ChatSessionContext.Provider>
774
+ );
775
+ }
776
+
777
+ /**
778
+ * Consume chat session state and actions. Throws if called outside a
779
+ * `ChatSessionProvider` — that is always a bug and we'd rather fail loud
780
+ * than render stale state.
781
+ */
782
+ export function useChatSession(): ChatSessionValue {
783
+ const ctx = useContext(ChatSessionContext);
784
+ if (!ctx) {
785
+ throw new Error(
786
+ "useChatSession must be used within a ChatSessionProvider"
787
+ );
788
+ }
789
+ return ctx;
790
+ }