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