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,219 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { ArrowUpCircle } from "lucide-react";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogTrigger,
14
+ } from "@/components/ui/dialog";
15
+ import { Button } from "@/components/ui/button";
16
+ import type { UpgradeState } from "@/lib/instance/types";
17
+
18
+ interface InstanceConfig {
19
+ instanceId: string;
20
+ branchName: string;
21
+ isPrivateInstance: boolean;
22
+ createdAt: number;
23
+ }
24
+
25
+ interface ConfigResponse {
26
+ devMode?: boolean;
27
+ config: InstanceConfig | null;
28
+ upgrade: UpgradeState | null;
29
+ }
30
+
31
+ type StatusResponse = UpgradeState & { devMode?: boolean };
32
+
33
+ /**
34
+ * Sidebar upgrade badge + pre-flight modal combined into a single Client
35
+ * Component. Fetches status on mount and every 5 minutes; renders nothing
36
+ * when no upgrade is available. When clicked, opens the pre-flight modal
37
+ * and loads the full config for the fact panel.
38
+ *
39
+ * Combined into one component because Next.js 16's stricter client/server
40
+ * boundary rules reject passing callback props between separately-imported
41
+ * client components unless they're Server Actions. Bundling the two here
42
+ * preserves the spec's separation of concerns at the design level while
43
+ * satisfying the framework.
44
+ */
45
+ export function UpgradeBadge() {
46
+ const router = useRouter();
47
+ const [state, setState] = useState<StatusResponse | null>(null);
48
+ const [open, setOpen] = useState(false);
49
+ const [config, setConfig] = useState<ConfigResponse | null>(null);
50
+ const [loading, setLoading] = useState(false);
51
+ const [starting, setStarting] = useState(false);
52
+ const [error, setError] = useState<string | null>(null);
53
+
54
+ useEffect(() => {
55
+ let cancelled = false;
56
+
57
+ async function fetchStatus() {
58
+ try {
59
+ const res = await fetch("/api/instance/upgrade/status", {
60
+ cache: "no-store",
61
+ });
62
+ if (!res.ok) return;
63
+ const data = (await res.json()) as StatusResponse;
64
+ if (!cancelled) setState(data);
65
+ } catch {
66
+ // Silent — the badge is ambient; status fetch failures should not
67
+ // produce UI noise. Persistent poll failures surface as a warning
68
+ // variant via state.pollFailureCount >= 3.
69
+ }
70
+ }
71
+
72
+ fetchStatus();
73
+ const interval = setInterval(fetchStatus, 5 * 60 * 1000);
74
+ // Refetch when the tab regains focus — picks up DB changes made by the
75
+ // hourly poller or by a manual "Check for upgrades" click while the user
76
+ // was running git commands in the terminal.
77
+ window.addEventListener("focus", fetchStatus);
78
+ return () => {
79
+ cancelled = true;
80
+ clearInterval(interval);
81
+ window.removeEventListener("focus", fetchStatus);
82
+ };
83
+ }, []);
84
+
85
+ useEffect(() => {
86
+ if (!open) return;
87
+ let cancelled = false;
88
+ setLoading(true);
89
+ setError(null);
90
+ (async () => {
91
+ try {
92
+ const res = await fetch("/api/instance/config", { cache: "no-store" });
93
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
94
+ const data = (await res.json()) as ConfigResponse;
95
+ if (!cancelled) setConfig(data);
96
+ } catch (err) {
97
+ if (!cancelled) setError(err instanceof Error ? err.message : String(err));
98
+ } finally {
99
+ if (!cancelled) setLoading(false);
100
+ }
101
+ })();
102
+ return () => {
103
+ cancelled = true;
104
+ };
105
+ }, [open]);
106
+
107
+ async function startUpgrade() {
108
+ setStarting(true);
109
+ setError(null);
110
+ try {
111
+ const res = await fetch("/api/instance/upgrade", { method: "POST" });
112
+ if (!res.ok) {
113
+ const body = await res.json().catch(() => ({}));
114
+ throw new Error(body.error ?? `HTTP ${res.status}`);
115
+ }
116
+ const data = (await res.json()) as { taskId: string };
117
+ setOpen(false);
118
+ router.push(`/tasks/${data.taskId}`);
119
+ } catch (err) {
120
+ setError(err instanceof Error ? err.message : String(err));
121
+ } finally {
122
+ setStarting(false);
123
+ }
124
+ }
125
+
126
+ if (!state || state.devMode || !state.upgradeAvailable) return null;
127
+
128
+ const failing = state.pollFailureCount >= 3;
129
+ const count = state.commitsBehind;
130
+ const label = failing
131
+ ? "Check failing"
132
+ : `${count} update${count === 1 ? "" : "s"}`;
133
+ const tooltip = failing
134
+ ? "Upgrade check failing — click to retry"
135
+ : `${count} upstream update${count === 1 ? "" : "s"} ready to merge`;
136
+ const buttonClass = failing
137
+ ? "h-7 px-2 rounded-md border border-amber-500/40 bg-amber-500/10 text-[11px] font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 transition-colors cursor-pointer inline-flex items-center gap-1.5 group-data-[collapsible=icon]:hidden"
138
+ : "h-7 px-2 rounded-md border border-blue-500/40 bg-blue-500/10 text-[11px] font-medium text-blue-700 dark:text-blue-400 hover:bg-blue-500/20 transition-colors cursor-pointer inline-flex items-center gap-1.5 group-data-[collapsible=icon]:hidden";
139
+
140
+ const modalUpgrade = config?.upgrade ?? null;
141
+ const modalCount = modalUpgrade?.commitsBehind ?? count;
142
+ const lastUpgradeText = modalUpgrade?.lastSuccessfulUpgradeAt
143
+ ? new Date(modalUpgrade.lastSuccessfulUpgradeAt * 1000).toLocaleString()
144
+ : "never";
145
+
146
+ return (
147
+ <Dialog open={open} onOpenChange={setOpen}>
148
+ <DialogTrigger asChild>
149
+ <button
150
+ type="button"
151
+ aria-label={tooltip}
152
+ title={tooltip}
153
+ className={buttonClass}
154
+ onClick={(e) => {
155
+ e.preventDefault();
156
+ e.stopPropagation();
157
+ setOpen(true);
158
+ }}
159
+ >
160
+ <ArrowUpCircle className="h-3 w-3" aria-hidden />
161
+ <span>{label}</span>
162
+ </button>
163
+ </DialogTrigger>
164
+ <DialogContent className="sm:max-w-lg">
165
+ <DialogHeader>
166
+ <DialogTitle>Upgrade available</DialogTitle>
167
+ <DialogDescription>
168
+ {modalCount} commit{modalCount === 1 ? "" : "s"} ready to merge into{" "}
169
+ <code className="font-mono text-xs">
170
+ {config?.config?.branchName ?? "…"}
171
+ </code>
172
+ </DialogDescription>
173
+ </DialogHeader>
174
+
175
+ {loading && (
176
+ <div className="py-4 text-sm text-muted-foreground">Loading instance state…</div>
177
+ )}
178
+
179
+ {error && (
180
+ <div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
181
+ {error}
182
+ </div>
183
+ )}
184
+
185
+ {config && !loading && (
186
+ <div className="space-y-3 py-2">
187
+ <div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
188
+ <span className="text-muted-foreground">Branch</span>
189
+ <code className="font-mono text-xs">{config.config?.branchName ?? "—"}</code>
190
+ <span className="text-muted-foreground">Data directory</span>
191
+ <code className="font-mono text-xs break-all">
192
+ {config.config?.isPrivateInstance ? "custom" : "default"}
193
+ </code>
194
+ <span className="text-muted-foreground">Commits behind</span>
195
+ <span>{modalCount}</span>
196
+ <span className="text-muted-foreground">Last successful upgrade</span>
197
+ <span>{lastUpgradeText}</span>
198
+ </div>
199
+
200
+ <p className="text-sm text-muted-foreground leading-relaxed">
201
+ Stagent will stash any uncommitted work, merge <code className="font-mono">main</code> into{" "}
202
+ <code className="font-mono">{config.config?.branchName ?? "your branch"}</code>, install any new
203
+ dependencies, and ask you to resolve conflicts if any appear.
204
+ </p>
205
+ </div>
206
+ )}
207
+
208
+ <DialogFooter>
209
+ <Button variant="ghost" onClick={() => setOpen(false)} disabled={starting}>
210
+ Cancel
211
+ </Button>
212
+ <Button onClick={startUpgrade} disabled={loading || starting || !config?.config}>
213
+ {starting ? "Starting…" : "Start upgrade"}
214
+ </Button>
215
+ </DialogFooter>
216
+ </DialogContent>
217
+ </Dialog>
218
+ );
219
+ }
@@ -0,0 +1,95 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
3
+
4
+ import { BatchProposalReview } from "@/components/notifications/batch-proposal-review";
5
+
6
+ const { toastError } = vi.hoisted(() => ({
7
+ toastError: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("sonner", () => ({
11
+ toast: {
12
+ error: toastError,
13
+ },
14
+ }));
15
+
16
+ describe("batch proposal review", () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ });
20
+
21
+ afterEach(() => {
22
+ vi.unstubAllGlobals();
23
+ });
24
+
25
+ it("optimistically resolves the batch before the request finishes", async () => {
26
+ const onResponded = vi.fn();
27
+ let resolveFetch: ((value: Response) => void) | null = null;
28
+
29
+ vi.stubGlobal(
30
+ "fetch",
31
+ vi.fn().mockImplementation(
32
+ () =>
33
+ new Promise<Response>((resolve) => {
34
+ resolveFetch = resolve;
35
+ })
36
+ )
37
+ );
38
+
39
+ render(
40
+ <BatchProposalReview
41
+ proposalIds={["p1", "p2"]}
42
+ profileIds={["general"]}
43
+ body="Batch summary"
44
+ onResponded={onResponded}
45
+ />
46
+ );
47
+
48
+ fireEvent.click(screen.getByRole("button", { name: /approve all/i }));
49
+
50
+ expect(onResponded).toHaveBeenCalledTimes(1);
51
+
52
+ resolveFetch?.(
53
+ new Response(JSON.stringify({ action: "approve", count: 2 }), {
54
+ status: 200,
55
+ headers: { "Content-Type": "application/json" },
56
+ })
57
+ );
58
+
59
+ await waitFor(() => {
60
+ expect(screen.getByText("2 proposals approved")).toBeInTheDocument();
61
+ });
62
+ });
63
+
64
+ it("restores server truth when the batch request fails", async () => {
65
+ const onResponded = vi.fn();
66
+ const onRequestFailed = vi.fn();
67
+
68
+ vi.stubGlobal(
69
+ "fetch",
70
+ vi.fn().mockRejectedValue(new Error("Batch approval failed"))
71
+ );
72
+
73
+ render(
74
+ <BatchProposalReview
75
+ proposalIds={["p1", "p2"]}
76
+ profileIds={["general"]}
77
+ body="Batch summary"
78
+ onResponded={onResponded}
79
+ onRequestFailed={onRequestFailed}
80
+ />
81
+ );
82
+
83
+ fireEvent.click(screen.getByRole("button", { name: /approve all/i }));
84
+
85
+ expect(onResponded).toHaveBeenCalledTimes(1);
86
+
87
+ await waitFor(() => {
88
+ expect(onRequestFailed).toHaveBeenCalledTimes(1);
89
+ });
90
+ expect(toastError).toHaveBeenCalledWith("Batch approval failed");
91
+ expect(
92
+ screen.getByRole("button", { name: /approve all \(2\)/i })
93
+ ).toBeInTheDocument();
94
+ });
95
+ });
@@ -0,0 +1,106 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { NotificationItem } from "@/components/notifications/notification-item";
5
+
6
+ const { push, contextReviewSpy, batchReviewSpy } = vi.hoisted(() => ({
7
+ push: vi.fn(),
8
+ contextReviewSpy: vi.fn(),
9
+ batchReviewSpy: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("next/navigation", () => ({
13
+ useRouter: () => ({ push }),
14
+ }));
15
+
16
+ vi.mock("@/components/profiles/context-proposal-review", () => ({
17
+ ContextProposalReview: (props: {
18
+ notificationId: string;
19
+ profileId: string;
20
+ proposedAdditions: string;
21
+ onResponded: () => void;
22
+ }) => {
23
+ contextReviewSpy(props);
24
+ return <div>Context proposal review</div>;
25
+ },
26
+ }));
27
+
28
+ vi.mock("@/components/notifications/batch-proposal-review", () => ({
29
+ BatchProposalReview: (props: {
30
+ proposalIds: string[];
31
+ profileIds: string[];
32
+ body: string;
33
+ onResponded?: () => void;
34
+ }) => {
35
+ batchReviewSpy(props);
36
+ return <div>Batch proposal review</div>;
37
+ },
38
+ }));
39
+
40
+ describe("notification item", () => {
41
+ it("renders context proposal review actions using the full additions payload", () => {
42
+ render(
43
+ <NotificationItem
44
+ notification={{
45
+ id: "notif-1",
46
+ taskId: null,
47
+ type: "context_proposal",
48
+ title: "Context proposal",
49
+ body: "truncated body",
50
+ read: false,
51
+ toolName: "general",
52
+ toolInput: JSON.stringify({
53
+ profileId: "general",
54
+ additions: "Full learned additions",
55
+ }),
56
+ response: null,
57
+ respondedAt: null,
58
+ createdAt: "2026-04-10T00:00:00.000Z",
59
+ }}
60
+ onUpdated={vi.fn()}
61
+ />
62
+ );
63
+
64
+ expect(screen.getByText("Context proposal review")).toBeInTheDocument();
65
+ expect(contextReviewSpy).toHaveBeenCalledWith(
66
+ expect.objectContaining({
67
+ notificationId: "notif-1",
68
+ profileId: "general",
69
+ proposedAdditions: "Full learned additions",
70
+ })
71
+ );
72
+ });
73
+
74
+ it("renders batch proposal review actions for workflow learning notifications", () => {
75
+ render(
76
+ <NotificationItem
77
+ notification={{
78
+ id: "notif-2",
79
+ taskId: null,
80
+ type: "context_proposal_batch",
81
+ title: "Workflow learning batch",
82
+ body: "Batch summary",
83
+ read: false,
84
+ toolName: "workflow-context-batch",
85
+ toolInput: JSON.stringify({
86
+ proposalIds: ["p1", "p2"],
87
+ profileIds: ["general", "researcher"],
88
+ }),
89
+ response: null,
90
+ respondedAt: null,
91
+ createdAt: "2026-04-10T00:00:00.000Z",
92
+ }}
93
+ onUpdated={vi.fn()}
94
+ />
95
+ );
96
+
97
+ expect(screen.getByText("Batch proposal review")).toBeInTheDocument();
98
+ expect(batchReviewSpy).toHaveBeenCalledWith(
99
+ expect.objectContaining({
100
+ proposalIds: ["p1", "p2"],
101
+ profileIds: ["general", "researcher"],
102
+ body: "Batch summary",
103
+ })
104
+ );
105
+ });
106
+ });
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
+ import { toast } from "sonner";
4
5
  import { Button } from "@/components/ui/button";
5
6
  import { Badge } from "@/components/ui/badge";
6
7
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,6 +13,7 @@ interface BatchProposalReviewProps {
12
13
  profileIds: string[];
13
14
  body: string;
14
15
  onResponded?: () => void;
16
+ onRequestFailed?: () => void;
15
17
  compact?: boolean;
16
18
  }
17
19
 
@@ -20,6 +22,7 @@ export function BatchProposalReview({
20
22
  profileIds,
21
23
  body,
22
24
  onResponded,
25
+ onRequestFailed,
23
26
  compact = false,
24
27
  }: BatchProposalReviewProps) {
25
28
  const [loading, setLoading] = useState<"approve" | "reject" | null>(null);
@@ -31,18 +34,30 @@ export function BatchProposalReview({
31
34
 
32
35
  async function handleBatchAction(action: "approve" | "reject") {
33
36
  setLoading(action);
37
+ setResponded(true);
38
+ onResponded?.();
39
+
34
40
  try {
35
41
  const res = await fetch("/api/context/batch", {
36
42
  method: "POST",
37
43
  headers: { "Content-Type": "application/json" },
38
44
  body: JSON.stringify({ proposalIds, action }),
39
45
  });
40
- if (res.ok) {
41
- const data = await res.json();
42
- setResult({ action: data.action, count: data.count });
43
- setResponded(true);
44
- onResponded?.();
46
+
47
+ if (!res.ok) {
48
+ const data = await res.json().catch(() => null);
49
+ throw new Error(data?.error ?? `Failed to ${action} batch proposal`);
45
50
  }
51
+
52
+ const data = await res.json();
53
+ setResult({ action: data.action, count: data.count });
54
+ } catch (error) {
55
+ setResponded(false);
56
+ setResult(null);
57
+ toast.error(
58
+ error instanceof Error ? error.message : "Batch approval failed"
59
+ );
60
+ onRequestFailed?.();
46
61
  } finally {
47
62
  setLoading(null);
48
63
  }
@@ -7,6 +7,7 @@ import { Eye, Inbox, RefreshCw, Trash2 } from "lucide-react";
7
7
  import { toast } from "sonner";
8
8
  import { NotificationItem } from "./notification-item";
9
9
  import { EmptyState } from "@/components/shared/empty-state";
10
+ import { filterDefaultVisibleNotifications } from "@/lib/notifications/visibility";
10
11
 
11
12
  interface Notification {
12
13
  id: string;
@@ -28,12 +29,15 @@ export function InboxList({
28
29
  initialNotifications: Notification[];
29
30
  }) {
30
31
  const [notifications, setNotifications] =
31
- useState<Notification[]>(initialNotifications);
32
+ useState<Notification[]>(() => filterDefaultVisibleNotifications(initialNotifications));
32
33
  const [tab, setTab] = useState("all");
33
34
 
34
35
  const refresh = useCallback(async () => {
35
36
  const res = await fetch("/api/notifications");
36
- if (res.ok) setNotifications(await res.json());
37
+ if (res.ok) {
38
+ const next = (await res.json()) as Notification[];
39
+ setNotifications(filterDefaultVisibleNotifications(next));
40
+ }
37
41
  }, []);
38
42
 
39
43
  // Poll every 10 seconds (consolidated from 3s inbox + 5s badge)
@@ -154,6 +158,11 @@ export function InboxList({
154
158
  <NotificationItem
155
159
  key={n.id}
156
160
  notification={n}
161
+ onRemoved={(notificationId) =>
162
+ setNotifications((current) =>
163
+ current.filter((item) => item.id !== notificationId)
164
+ )
165
+ }
157
166
  onUpdated={refresh}
158
167
  />
159
168
  ))
@@ -18,6 +18,8 @@ import {
18
18
  parseNotificationToolInput,
19
19
  type PermissionToolInput,
20
20
  } from "@/lib/notifications/permissions";
21
+ import { ContextProposalReview } from "@/components/profiles/context-proposal-review";
22
+ import { BatchProposalReview } from "./batch-proposal-review";
21
23
 
22
24
  interface Notification {
23
25
  id: string;
@@ -35,6 +37,7 @@ interface Notification {
35
37
 
36
38
  interface NotificationItemProps {
37
39
  notification: Notification;
40
+ onRemoved?: (notificationId: string) => void;
38
41
  onUpdated: () => void;
39
42
  }
40
43
 
@@ -104,7 +107,25 @@ function formatToolInput(
104
107
 
105
108
  const navigableTypes = new Set(["task_completed", "task_failed", "permission_required", "agent_message"]);
106
109
 
107
- export function NotificationItem({ notification, onUpdated }: NotificationItemProps) {
110
+ function parseBatchToolInput(toolInput: PermissionToolInput | null): {
111
+ proposalIds: string[];
112
+ profileIds: string[];
113
+ } {
114
+ return {
115
+ proposalIds: Array.isArray(toolInput?.proposalIds)
116
+ ? toolInput.proposalIds.filter((id): id is string => typeof id === "string")
117
+ : [],
118
+ profileIds: Array.isArray(toolInput?.profileIds)
119
+ ? toolInput.profileIds.filter((id): id is string => typeof id === "string")
120
+ : [],
121
+ };
122
+ }
123
+
124
+ export function NotificationItem({
125
+ notification,
126
+ onRemoved,
127
+ onUpdated,
128
+ }: NotificationItemProps) {
108
129
  const router = useRouter();
109
130
  const [toggling, setToggling] = useState(false);
110
131
  const [dismissing, setDismissing] = useState(false);
@@ -212,7 +233,9 @@ export function NotificationItem({ notification, onUpdated }: NotificationItemPr
212
233
  {/* Body for non-tool notifications */}
213
234
  {notification.body &&
214
235
  notification.type !== "permission_required" &&
215
- notification.type !== "agent_message" && (
236
+ notification.type !== "agent_message" &&
237
+ notification.type !== "context_proposal" &&
238
+ notification.type !== "context_proposal_batch" && (
216
239
  <div className="mt-1" onClick={(e) => e.stopPropagation()}>
217
240
  <div
218
241
  className={`${PROSE_NOTIFICATION} ${
@@ -270,6 +293,37 @@ export function NotificationItem({ notification, onUpdated }: NotificationItemPr
270
293
  <FailureAction taskId={notification.taskId} onRetried={onUpdated} />
271
294
  )}
272
295
 
296
+ {notification.type === "context_proposal" && (
297
+ <div className="mt-3" onClick={(e) => e.stopPropagation()}>
298
+ <ContextProposalReview
299
+ notificationId={notification.id}
300
+ profileId={
301
+ typeof parsedToolInput?.profileId === "string"
302
+ ? parsedToolInput.profileId
303
+ : (notification.toolName ?? "")
304
+ }
305
+ proposedAdditions={
306
+ typeof parsedToolInput?.additions === "string"
307
+ ? parsedToolInput.additions
308
+ : (notification.body ?? "")
309
+ }
310
+ onResponded={onUpdated}
311
+ />
312
+ </div>
313
+ )}
314
+
315
+ {notification.type === "context_proposal_batch" && (
316
+ <div className="mt-3" onClick={(e) => e.stopPropagation()}>
317
+ <BatchProposalReview
318
+ proposalIds={parseBatchToolInput(parsedToolInput).proposalIds}
319
+ profileIds={parseBatchToolInput(parsedToolInput).profileIds}
320
+ body={notification.body ?? ""}
321
+ onResponded={() => onRemoved?.(notification.id)}
322
+ onRequestFailed={onUpdated}
323
+ />
324
+ </div>
325
+ )}
326
+
273
327
  <p className="text-xs text-muted-foreground mt-2">
274
328
  {formatTimestamp(notification.createdAt)}
275
329
  {notification.respondedAt && (