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,1219 @@
1
+ # Chat Polish Bundle v1 Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Ship three small UX polish items on already-shipped chat surfaces — filter hint, saved-search rename/delete CRUD, and empty-group suppression in the mention popover.
6
+
7
+ **Architecture:** Pure addition pattern. No schema changes, no new API routes — the existing `PUT /api/settings/chat/saved-searches` full-list replacement is reused for `rename`. Two new leaf components (`FilterHint`, `SavedSearchesManager`), one hook method (`rename`), and a localized edit to the entity-group render loop in `chat-command-popover.tsx`.
8
+
9
+ **Tech Stack:** Next.js 16, React 19, Tailwind v4, shadcn/ui `Command` (cmdk-based), `Dialog`, Sonner toasts, Vitest, React Testing Library.
10
+
11
+ **Spec:** `features/chat-polish-bundle-v1.md`
12
+
13
+ ---
14
+
15
+ ## File Map
16
+
17
+ **Create:**
18
+ - `src/components/shared/filter-hint.tsx` — passive hint row, consumed by both filter surfaces
19
+ - `src/components/shared/saved-searches-manager.tsx` — dialog with rename + deliberate delete
20
+ - `src/components/shared/__tests__/filter-hint.test.tsx` — visibility + dismissal tests
21
+ - `src/components/shared/__tests__/saved-searches-manager.test.tsx` — validation + rename + delete tests
22
+ - `src/hooks/__tests__/use-saved-searches.test.ts` — add `rename` method tests (extend if file exists, otherwise create)
23
+
24
+ **Modify:**
25
+ - `src/hooks/use-saved-searches.ts` — add `rename(id, label)` method
26
+ - `src/components/shared/command-palette.tsx` — inline delete icon + undo toast + manager entry + `rename` wiring
27
+ - `src/components/chat/chat-command-popover.tsx` — empty-group suppression + filter-aware `CommandEmpty` + `FilterHint` mount
28
+ - `src/components/shared/filter-input.tsx` — mount `FilterHint` below input
29
+
30
+ **Do NOT touch:**
31
+ - `src/app/api/settings/chat/saved-searches/route.ts` — no API changes
32
+ - `src/lib/chat/clean-filter-input.ts` — no changes
33
+ - `src/lib/filters/parse.ts` — no changes
34
+
35
+ ---
36
+
37
+ ## Task 1: Extend `useSavedSearches` hook with `rename`
38
+
39
+ **Files:**
40
+ - Modify: `src/hooks/use-saved-searches.ts`
41
+ - Test: `src/hooks/__tests__/use-saved-searches.test.ts` (create if missing)
42
+
43
+ - [ ] **Step 1: Check whether a test file exists**
44
+
45
+ Run: `ls src/hooks/__tests__/use-saved-searches.test.ts 2>&1`
46
+
47
+ If the file exists, open it. Otherwise create a new one with the skeleton in Step 2.
48
+
49
+ - [ ] **Step 2: Write failing test for `rename`**
50
+
51
+ Add to `src/hooks/__tests__/use-saved-searches.test.ts`:
52
+
53
+ ```typescript
54
+ import { renderHook, act, waitFor } from "@testing-library/react";
55
+ import { useSavedSearches } from "../use-saved-searches";
56
+ import { beforeEach, describe, expect, it, vi, afterEach } from "vitest";
57
+
58
+ describe("useSavedSearches — rename", () => {
59
+ const originalFetch = global.fetch;
60
+
61
+ beforeEach(() => {
62
+ global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
63
+ const u = String(url);
64
+ if (u.endsWith("/api/settings/chat/saved-searches") && (!init || init.method === undefined || init.method === "GET")) {
65
+ return new Response(
66
+ JSON.stringify({
67
+ searches: [
68
+ {
69
+ id: "s1",
70
+ surface: "task",
71
+ label: "Old label",
72
+ filterInput: "#status:blocked",
73
+ createdAt: "2026-04-14T00:00:00.000Z",
74
+ },
75
+ ],
76
+ }),
77
+ { status: 200, headers: { "Content-Type": "application/json" } }
78
+ );
79
+ }
80
+ if (init?.method === "PUT") {
81
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
82
+ }
83
+ return new Response("{}", { status: 200 });
84
+ }) as unknown as typeof fetch;
85
+ });
86
+
87
+ afterEach(() => {
88
+ global.fetch = originalFetch;
89
+ vi.restoreAllMocks();
90
+ });
91
+
92
+ it("renames a saved search optimistically and persists via PUT", async () => {
93
+ const { result } = renderHook(() => useSavedSearches());
94
+ await waitFor(() => expect(result.current.loading).toBe(false));
95
+ expect(result.current.searches[0].label).toBe("Old label");
96
+
97
+ act(() => {
98
+ result.current.rename("s1", "New label");
99
+ });
100
+
101
+ expect(result.current.searches[0].label).toBe("New label");
102
+
103
+ const putCall = (global.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.find(
104
+ ([, init]) => init?.method === "PUT"
105
+ );
106
+ expect(putCall).toBeDefined();
107
+ const body = JSON.parse((putCall![1] as RequestInit).body as string);
108
+ expect(body.searches[0].label).toBe("New label");
109
+ expect(body.searches[0].id).toBe("s1");
110
+ });
111
+
112
+ it("no-ops when id is not found", async () => {
113
+ const { result } = renderHook(() => useSavedSearches());
114
+ await waitFor(() => expect(result.current.loading).toBe(false));
115
+ const before = result.current.searches;
116
+
117
+ act(() => {
118
+ result.current.rename("does-not-exist", "Whatever");
119
+ });
120
+
121
+ expect(result.current.searches).toEqual(before);
122
+ });
123
+ });
124
+ ```
125
+
126
+ - [ ] **Step 3: Run test to verify failure**
127
+
128
+ Run: `npx vitest run src/hooks/__tests__/use-saved-searches.test.ts`
129
+ Expected: FAIL — `result.current.rename is not a function`.
130
+
131
+ - [ ] **Step 4: Implement `rename`**
132
+
133
+ Modify `src/hooks/use-saved-searches.ts`:
134
+
135
+ ```typescript
136
+ // Add to UseSavedSearchesReturn interface (after `refetch`):
137
+ rename: (id: string, label: string) => void;
138
+ ```
139
+
140
+ Add implementation after `remove`:
141
+
142
+ ```typescript
143
+ const rename = useCallback(
144
+ (id: string, label: string) => {
145
+ setSearches((prev) => {
146
+ const idx = prev.findIndex((s) => s.id === id);
147
+ if (idx === -1) return prev;
148
+ const next = prev.slice();
149
+ next[idx] = { ...next[idx], label };
150
+ void persist(next);
151
+ return next;
152
+ });
153
+ },
154
+ [persist]
155
+ );
156
+ ```
157
+
158
+ Return it:
159
+
160
+ ```typescript
161
+ return { searches, loading, save, remove, forSurface, refetch, rename };
162
+ ```
163
+
164
+ - [ ] **Step 5: Run test to verify pass**
165
+
166
+ Run: `npx vitest run src/hooks/__tests__/use-saved-searches.test.ts`
167
+ Expected: PASS (2 tests).
168
+
169
+ - [ ] **Step 6: Commit**
170
+
171
+ ```bash
172
+ git add src/hooks/use-saved-searches.ts src/hooks/__tests__/use-saved-searches.test.ts
173
+ git commit -m "feat(chat): useSavedSearches rename method
174
+
175
+ Optimistic state update + full-list PUT persistence. No API change
176
+ required — existing route accepts the full list on every write.
177
+
178
+ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Task 2: `FilterHint` component
184
+
185
+ **Files:**
186
+ - Create: `src/components/shared/filter-hint.tsx`
187
+ - Test: `src/components/shared/__tests__/filter-hint.test.tsx`
188
+
189
+ - [ ] **Step 1: Write failing test**
190
+
191
+ Create `src/components/shared/__tests__/filter-hint.test.tsx`:
192
+
193
+ ```typescript
194
+ import { render, screen } from "@testing-library/react";
195
+ import { describe, it, expect, beforeEach } from "vitest";
196
+ import { FilterHint } from "../filter-hint";
197
+
198
+ const KEY = "stagent.filter-hint.dismissed";
199
+
200
+ describe("FilterHint", () => {
201
+ beforeEach(() => {
202
+ localStorage.removeItem(KEY);
203
+ });
204
+
205
+ it("renders when input is empty and not dismissed", () => {
206
+ render(<FilterHint inputValue="" storageKey={KEY} />);
207
+ expect(screen.getByText(/#key:value/i)).toBeInTheDocument();
208
+ });
209
+
210
+ it("renders when input has no # character", () => {
211
+ render(<FilterHint inputValue="some search" storageKey={KEY} />);
212
+ expect(screen.getByText(/#key:value/i)).toBeInTheDocument();
213
+ });
214
+
215
+ it("hides when input contains #", () => {
216
+ render(<FilterHint inputValue="#status:blocked" storageKey={KEY} />);
217
+ expect(screen.queryByText(/#key:value/i)).toBeNull();
218
+ });
219
+
220
+ it("sets dismissal flag when input parses a valid clause", () => {
221
+ render(<FilterHint inputValue="#type:pdf" storageKey={KEY} />);
222
+ expect(localStorage.getItem(KEY)).toBe("1");
223
+ });
224
+
225
+ it("stays hidden on subsequent mounts once dismissed", () => {
226
+ localStorage.setItem(KEY, "1");
227
+ render(<FilterHint inputValue="" storageKey={KEY} />);
228
+ expect(screen.queryByText(/#key:value/i)).toBeNull();
229
+ });
230
+ });
231
+ ```
232
+
233
+ - [ ] **Step 2: Run test to verify failure**
234
+
235
+ Run: `npx vitest run src/components/shared/__tests__/filter-hint.test.tsx`
236
+ Expected: FAIL — module not found.
237
+
238
+ - [ ] **Step 3: Implement `FilterHint`**
239
+
240
+ Create `src/components/shared/filter-hint.tsx`:
241
+
242
+ ```tsx
243
+ "use client";
244
+
245
+ import { useEffect, useMemo, useState } from "react";
246
+ import { Lightbulb } from "lucide-react";
247
+ import { parseFilterInput } from "@/lib/filters/parse";
248
+
249
+ interface FilterHintProps {
250
+ inputValue: string;
251
+ storageKey: string;
252
+ /** Optional copy override; defaults to the #key:value tip. */
253
+ message?: string;
254
+ }
255
+
256
+ /**
257
+ * FilterHint — passive discovery row for the `#key:value` filter syntax.
258
+ *
259
+ * Visibility rules:
260
+ * - Hidden once the dismissal flag is set in localStorage.
261
+ * - Hidden when the input contains `#` (user has discovered the syntax).
262
+ * - The flag is set the first time parseFilterInput returns ≥1 clause.
263
+ *
264
+ * Consumers: chat-command-popover, filter-input (list pages).
265
+ */
266
+ export function FilterHint({ inputValue, storageKey, message }: FilterHintProps) {
267
+ const [dismissed, setDismissed] = useState<boolean>(() => {
268
+ if (typeof window === "undefined") return false;
269
+ return window.localStorage.getItem(storageKey) === "1";
270
+ });
271
+
272
+ const parsed = useMemo(() => parseFilterInput(inputValue), [inputValue]);
273
+
274
+ useEffect(() => {
275
+ if (dismissed) return;
276
+ if (parsed.clauses.length > 0) {
277
+ try {
278
+ window.localStorage.setItem(storageKey, "1");
279
+ } catch {
280
+ // Private-mode or disabled storage — hint stays visible, no-op.
281
+ }
282
+ setDismissed(true);
283
+ }
284
+ }, [parsed.clauses.length, dismissed, storageKey]);
285
+
286
+ if (dismissed) return null;
287
+ if (inputValue.includes("#")) return null;
288
+
289
+ return (
290
+ <div
291
+ role="note"
292
+ className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground border-t border-border/50"
293
+ >
294
+ <Lightbulb className="h-3 w-3 shrink-0" aria-hidden />
295
+ <span>
296
+ {message ?? (
297
+ <>
298
+ Tip: use <code className="font-mono text-foreground">#key:value</code> to filter (e.g.{" "}
299
+ <code className="font-mono text-foreground">#status:blocked</code>)
300
+ </>
301
+ )}
302
+ </span>
303
+ </div>
304
+ );
305
+ }
306
+ ```
307
+
308
+ - [ ] **Step 4: Run test to verify pass**
309
+
310
+ Run: `npx vitest run src/components/shared/__tests__/filter-hint.test.tsx`
311
+ Expected: PASS (5 tests).
312
+
313
+ - [ ] **Step 5: Commit**
314
+
315
+ ```bash
316
+ git add src/components/shared/filter-hint.tsx src/components/shared/__tests__/filter-hint.test.tsx
317
+ git commit -m "feat(chat): FilterHint component for #key:value discoverability
318
+
319
+ Passive hint row that auto-dismisses on first successful filter use.
320
+ Shared between chat popover and list-page FilterInput.
321
+
322
+ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
323
+ ```
324
+
325
+ ---
326
+
327
+ ## Task 3: Wire `FilterHint` into `FilterInput` and the chat popover
328
+
329
+ **Files:**
330
+ - Modify: `src/components/shared/filter-input.tsx`
331
+ - Modify: `src/components/chat/chat-command-popover.tsx`
332
+
333
+ - [ ] **Step 1: Mount `FilterHint` inside `FilterInput`**
334
+
335
+ Edit `src/components/shared/filter-input.tsx`. Add import:
336
+
337
+ ```tsx
338
+ import { FilterHint } from "./filter-hint";
339
+ ```
340
+
341
+ Wrap the existing return value so the hint renders below the input + clauses. Replace the existing `return (...)` with:
342
+
343
+ ```tsx
344
+ return (
345
+ <div className="flex flex-col gap-1 flex-1 min-w-0">
346
+ <div className="flex flex-wrap items-center gap-2">
347
+ <div className="relative flex-1 min-w-[16rem]">
348
+ <Hash className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
349
+ <Input
350
+ value={local}
351
+ onChange={(e) => {
352
+ const next = e.target.value;
353
+ setLocal(next);
354
+ const p = parseFilterInput(next);
355
+ onChange({ raw: next, clauses: p.clauses, rawQuery: p.rawQuery });
356
+ }}
357
+ placeholder={placeholder ?? "#status:blocked or search…"}
358
+ className="pl-7 h-8"
359
+ />
360
+ </div>
361
+ {parsed.clauses.map((c, i) => (
362
+ <Badge key={`${c.key}-${i}`} variant="outline" className="text-xs font-mono">
363
+ #{c.key}:{c.value}
364
+ </Badge>
365
+ ))}
366
+ </div>
367
+ <FilterHint inputValue={local} storageKey="stagent.filter-hint.dismissed" />
368
+ </div>
369
+ );
370
+ ```
371
+
372
+ - [ ] **Step 2: Mount `FilterHint` inside the chat popover**
373
+
374
+ Edit `src/components/chat/chat-command-popover.tsx`. Add import near the other shared imports:
375
+
376
+ ```tsx
377
+ import { FilterHint } from "@/components/shared/filter-hint";
378
+ ```
379
+
380
+ Locate the popover's `CommandList` rendering (around line 325, inside the `<div id={...tabpanel}>`). Add `<FilterHint>` just below the `CommandInput` (or at the top of `CommandList` — whichever is consistent with the cmdk layout you find). Use the same storage key as `FilterInput`:
381
+
382
+ ```tsx
383
+ <FilterHint inputValue={query} storageKey="stagent.filter-hint.dismissed" />
384
+ ```
385
+
386
+ > **Implementer note:** The popover today does not include a visible `CommandInput` (input is the chat textarea itself). If that is still the case, mount `FilterHint` at the top of the `CommandList` so it appears above the first group. Do NOT duplicate the hint into multiple tabs — one mount per popover instance.
387
+
388
+ - [ ] **Step 3: Verify dev build**
389
+
390
+ Run: `npx tsc --noEmit 2>&1 | grep -E "(filter-hint|filter-input|chat-command-popover)" | head`
391
+ Expected: empty output.
392
+
393
+ Run: `npx vitest run src/components/shared/__tests__/filter-hint.test.tsx`
394
+ Expected: PASS.
395
+
396
+ - [ ] **Step 4: Commit**
397
+
398
+ ```bash
399
+ git add src/components/shared/filter-input.tsx src/components/chat/chat-command-popover.tsx
400
+ git commit -m "feat(chat): mount FilterHint in FilterInput and chat popover
401
+
402
+ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
403
+ ```
404
+
405
+ ---
406
+
407
+ ## Task 4: `SavedSearchesManager` dialog
408
+
409
+ **Files:**
410
+ - Create: `src/components/shared/saved-searches-manager.tsx`
411
+ - Test: `src/components/shared/__tests__/saved-searches-manager.test.tsx`
412
+
413
+ - [ ] **Step 1: Write failing test**
414
+
415
+ Create `src/components/shared/__tests__/saved-searches-manager.test.tsx`:
416
+
417
+ ```tsx
418
+ import { render, screen, fireEvent } from "@testing-library/react";
419
+ import { describe, it, expect, vi } from "vitest";
420
+ import { SavedSearchesManager } from "../saved-searches-manager";
421
+ import type { SavedSearch } from "@/hooks/use-saved-searches";
422
+
423
+ const search = (over: Partial<SavedSearch> = {}): SavedSearch => ({
424
+ id: "s1",
425
+ surface: "task",
426
+ label: "Blocked tasks",
427
+ filterInput: "#status:blocked",
428
+ createdAt: "2026-04-14T00:00:00.000Z",
429
+ ...over,
430
+ });
431
+
432
+ describe("SavedSearchesManager", () => {
433
+ it("lists all saved searches", () => {
434
+ const items = [
435
+ search({ id: "s1", label: "Blocked tasks" }),
436
+ search({ id: "s2", label: "Pdf docs", surface: "document", filterInput: "#type:pdf" }),
437
+ ];
438
+ render(
439
+ <SavedSearchesManager
440
+ open
441
+ onOpenChange={() => {}}
442
+ searches={items}
443
+ onRename={() => {}}
444
+ onRemove={() => {}}
445
+ />
446
+ );
447
+ expect(screen.getByText("Blocked tasks")).toBeInTheDocument();
448
+ expect(screen.getByText("Pdf docs")).toBeInTheDocument();
449
+ });
450
+
451
+ it("renames on blur with non-empty trimmed label", () => {
452
+ const onRename = vi.fn();
453
+ render(
454
+ <SavedSearchesManager
455
+ open
456
+ onOpenChange={() => {}}
457
+ searches={[search()]}
458
+ onRename={onRename}
459
+ onRemove={() => {}}
460
+ />
461
+ );
462
+ fireEvent.click(screen.getByRole("button", { name: /rename blocked tasks/i }));
463
+ const input = screen.getByRole("textbox", { name: /rename/i });
464
+ fireEvent.change(input, { target: { value: " Renamed " } });
465
+ fireEvent.blur(input);
466
+ expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
467
+ });
468
+
469
+ it("rejects empty label with inline error", () => {
470
+ const onRename = vi.fn();
471
+ render(
472
+ <SavedSearchesManager
473
+ open
474
+ onOpenChange={() => {}}
475
+ searches={[search()]}
476
+ onRename={onRename}
477
+ onRemove={() => {}}
478
+ />
479
+ );
480
+ fireEvent.click(screen.getByRole("button", { name: /rename blocked tasks/i }));
481
+ const input = screen.getByRole("textbox", { name: /rename/i });
482
+ fireEvent.change(input, { target: { value: " " } });
483
+ fireEvent.blur(input);
484
+ expect(onRename).not.toHaveBeenCalled();
485
+ expect(screen.getByText(/cannot be empty/i)).toBeInTheDocument();
486
+ });
487
+
488
+ it("rejects duplicate label within same surface (case-insensitive)", () => {
489
+ const onRename = vi.fn();
490
+ render(
491
+ <SavedSearchesManager
492
+ open
493
+ onOpenChange={() => {}}
494
+ searches={[
495
+ search({ id: "s1", label: "Blocked tasks" }),
496
+ search({ id: "s2", label: "Another" }),
497
+ ]}
498
+ onRename={onRename}
499
+ onRemove={() => {}}
500
+ />
501
+ );
502
+ fireEvent.click(screen.getByRole("button", { name: /rename another/i }));
503
+ const input = screen.getByRole("textbox", { name: /rename/i });
504
+ fireEvent.change(input, { target: { value: "blocked TASKS" } });
505
+ fireEvent.blur(input);
506
+ expect(onRename).not.toHaveBeenCalled();
507
+ expect(screen.getByText(/already exists/i)).toBeInTheDocument();
508
+ });
509
+
510
+ it("rejects label longer than 120 chars", () => {
511
+ const onRename = vi.fn();
512
+ render(
513
+ <SavedSearchesManager
514
+ open
515
+ onOpenChange={() => {}}
516
+ searches={[search()]}
517
+ onRename={onRename}
518
+ onRemove={() => {}}
519
+ />
520
+ );
521
+ fireEvent.click(screen.getByRole("button", { name: /rename blocked tasks/i }));
522
+ const input = screen.getByRole("textbox", { name: /rename/i });
523
+ fireEvent.change(input, { target: { value: "x".repeat(121) } });
524
+ fireEvent.blur(input);
525
+ expect(onRename).not.toHaveBeenCalled();
526
+ expect(screen.getByText(/too long/i)).toBeInTheDocument();
527
+ });
528
+
529
+ it("Escape cancels rename without persisting", () => {
530
+ const onRename = vi.fn();
531
+ render(
532
+ <SavedSearchesManager
533
+ open
534
+ onOpenChange={() => {}}
535
+ searches={[search()]}
536
+ onRename={onRename}
537
+ onRemove={() => {}}
538
+ />
539
+ );
540
+ fireEvent.click(screen.getByRole("button", { name: /rename blocked tasks/i }));
541
+ const input = screen.getByRole("textbox", { name: /rename/i });
542
+ fireEvent.change(input, { target: { value: "Changed" } });
543
+ fireEvent.keyDown(input, { key: "Escape" });
544
+ expect(onRename).not.toHaveBeenCalled();
545
+ });
546
+
547
+ it("delete requires explicit confirm", () => {
548
+ const onRemove = vi.fn();
549
+ render(
550
+ <SavedSearchesManager
551
+ open
552
+ onOpenChange={() => {}}
553
+ searches={[search()]}
554
+ onRename={() => {}}
555
+ onRemove={onRemove}
556
+ />
557
+ );
558
+ fireEvent.click(screen.getByRole("button", { name: /delete blocked tasks/i }));
559
+ expect(onRemove).not.toHaveBeenCalled();
560
+ fireEvent.click(screen.getByRole("button", { name: /confirm delete/i }));
561
+ expect(onRemove).toHaveBeenCalledWith("s1");
562
+ });
563
+ });
564
+ ```
565
+
566
+ - [ ] **Step 2: Run test to verify failure**
567
+
568
+ Run: `npx vitest run src/components/shared/__tests__/saved-searches-manager.test.tsx`
569
+ Expected: FAIL — module not found.
570
+
571
+ - [ ] **Step 3: Implement `SavedSearchesManager`**
572
+
573
+ Create `src/components/shared/saved-searches-manager.tsx`:
574
+
575
+ ```tsx
576
+ "use client";
577
+
578
+ import { useState } from "react";
579
+ import { Pencil, Trash2, Check, X } from "lucide-react";
580
+ import {
581
+ Dialog,
582
+ DialogContent,
583
+ DialogDescription,
584
+ DialogHeader,
585
+ DialogTitle,
586
+ } from "@/components/ui/dialog";
587
+ import { Input } from "@/components/ui/input";
588
+ import { Button } from "@/components/ui/button";
589
+ import { Badge } from "@/components/ui/badge";
590
+ import type { SavedSearch } from "@/hooks/use-saved-searches";
591
+
592
+ const LABEL_MAX = 120;
593
+
594
+ interface SavedSearchesManagerProps {
595
+ open: boolean;
596
+ onOpenChange: (open: boolean) => void;
597
+ searches: SavedSearch[];
598
+ onRename: (id: string, label: string) => void;
599
+ onRemove: (id: string) => void;
600
+ }
601
+
602
+ /**
603
+ * SavedSearchesManager — dialog for renaming or deleting saved searches.
604
+ *
605
+ * Distinct from the inline palette delete (which is one-click with a 5s
606
+ * undo toast). This dialog is a deliberate management context, so delete
607
+ * requires an explicit "Confirm" click (no undo).
608
+ */
609
+ export function SavedSearchesManager({
610
+ open,
611
+ onOpenChange,
612
+ searches,
613
+ onRename,
614
+ onRemove,
615
+ }: SavedSearchesManagerProps) {
616
+ const [renamingId, setRenamingId] = useState<string | null>(null);
617
+ const [draft, setDraft] = useState<string>("");
618
+ const [error, setError] = useState<string | null>(null);
619
+ const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
620
+
621
+ function startRename(s: SavedSearch) {
622
+ setRenamingId(s.id);
623
+ setDraft(s.label);
624
+ setError(null);
625
+ }
626
+
627
+ function cancelRename() {
628
+ setRenamingId(null);
629
+ setDraft("");
630
+ setError(null);
631
+ }
632
+
633
+ function commitRename(s: SavedSearch) {
634
+ const next = draft.trim();
635
+ if (next.length === 0) {
636
+ setError("Label cannot be empty");
637
+ return;
638
+ }
639
+ if (next.length > LABEL_MAX) {
640
+ setError(`Label too long (max ${LABEL_MAX} chars)`);
641
+ return;
642
+ }
643
+ const dupe = searches.find(
644
+ (other) =>
645
+ other.id !== s.id &&
646
+ other.surface === s.surface &&
647
+ other.label.toLowerCase() === next.toLowerCase()
648
+ );
649
+ if (dupe) {
650
+ setError("A saved search with that label already exists for this surface");
651
+ return;
652
+ }
653
+ if (next !== s.label) onRename(s.id, next);
654
+ cancelRename();
655
+ }
656
+
657
+ return (
658
+ <Dialog open={open} onOpenChange={onOpenChange}>
659
+ <DialogContent className="max-w-lg">
660
+ <DialogHeader>
661
+ <DialogTitle>Manage saved searches</DialogTitle>
662
+ <DialogDescription>Rename or delete your saved filter combinations.</DialogDescription>
663
+ </DialogHeader>
664
+ <div className="px-6 pb-6 space-y-2 overflow-y-auto max-h-[60vh]">
665
+ {searches.length === 0 ? (
666
+ <p className="text-sm text-muted-foreground">No saved searches yet.</p>
667
+ ) : (
668
+ searches.map((s) => {
669
+ const isRenaming = renamingId === s.id;
670
+ const isPendingDelete = pendingDeleteId === s.id;
671
+ return (
672
+ <div
673
+ key={s.id}
674
+ className="flex items-center gap-2 rounded-md border border-border/60 px-3 py-2"
675
+ >
676
+ <div className="flex-1 min-w-0">
677
+ {isRenaming ? (
678
+ <div className="space-y-1">
679
+ <Input
680
+ aria-label="Rename"
681
+ autoFocus
682
+ value={draft}
683
+ onChange={(e) => {
684
+ setDraft(e.target.value);
685
+ setError(null);
686
+ }}
687
+ onKeyDown={(e) => {
688
+ if (e.key === "Escape") {
689
+ e.preventDefault();
690
+ cancelRename();
691
+ } else if (e.key === "Enter") {
692
+ e.preventDefault();
693
+ commitRename(s);
694
+ }
695
+ }}
696
+ onBlur={() => commitRename(s)}
697
+ className="h-7"
698
+ />
699
+ {error && (
700
+ <p className="text-xs text-destructive">{error}</p>
701
+ )}
702
+ </div>
703
+ ) : (
704
+ <div className="flex items-center gap-2">
705
+ <span className="truncate text-sm font-medium">{s.label}</span>
706
+ <Badge variant="outline" className="text-[10px] uppercase">
707
+ {s.surface}
708
+ </Badge>
709
+ </div>
710
+ )}
711
+ <p className="truncate text-xs font-mono text-muted-foreground">
712
+ {s.filterInput}
713
+ </p>
714
+ </div>
715
+ {!isRenaming && !isPendingDelete && (
716
+ <>
717
+ <Button
718
+ variant="ghost"
719
+ size="icon"
720
+ className="h-7 w-7"
721
+ aria-label={`Rename ${s.label}`}
722
+ onClick={() => startRename(s)}
723
+ >
724
+ <Pencil className="h-3.5 w-3.5" />
725
+ </Button>
726
+ <Button
727
+ variant="ghost"
728
+ size="icon"
729
+ className="h-7 w-7 text-destructive hover:text-destructive"
730
+ aria-label={`Delete ${s.label}`}
731
+ onClick={() => setPendingDeleteId(s.id)}
732
+ >
733
+ <Trash2 className="h-3.5 w-3.5" />
734
+ </Button>
735
+ </>
736
+ )}
737
+ {isPendingDelete && (
738
+ <div className="flex items-center gap-1">
739
+ <Button
740
+ variant="destructive"
741
+ size="sm"
742
+ className="h-7"
743
+ aria-label={`Confirm delete ${s.label}`}
744
+ onClick={() => {
745
+ onRemove(s.id);
746
+ setPendingDeleteId(null);
747
+ }}
748
+ >
749
+ <Check className="h-3.5 w-3.5" /> Confirm delete
750
+ </Button>
751
+ <Button
752
+ variant="ghost"
753
+ size="sm"
754
+ className="h-7"
755
+ aria-label="Cancel delete"
756
+ onClick={() => setPendingDeleteId(null)}
757
+ >
758
+ <X className="h-3.5 w-3.5" />
759
+ </Button>
760
+ </div>
761
+ )}
762
+ </div>
763
+ );
764
+ })
765
+ )}
766
+ </div>
767
+ </DialogContent>
768
+ </Dialog>
769
+ );
770
+ }
771
+ ```
772
+
773
+ - [ ] **Step 4: Run test to verify pass**
774
+
775
+ Run: `npx vitest run src/components/shared/__tests__/saved-searches-manager.test.tsx`
776
+ Expected: PASS (7 tests).
777
+
778
+ - [ ] **Step 5: Commit**
779
+
780
+ ```bash
781
+ git add src/components/shared/saved-searches-manager.tsx src/components/shared/__tests__/saved-searches-manager.test.tsx
782
+ git commit -m "feat(chat): SavedSearchesManager dialog — rename + deliberate delete
783
+
784
+ Rename via inline input (blur commits, Esc cancels). Delete requires
785
+ explicit confirm click (distinct from palette inline delete which uses
786
+ a 5s undo toast).
787
+
788
+ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
789
+ ```
790
+
791
+ ---
792
+
793
+ ## Task 5: Wire inline delete + manager entry into `⌘K` palette
794
+
795
+ **Files:**
796
+ - Modify: `src/components/shared/command-palette.tsx`
797
+
798
+ - [ ] **Step 1: Add imports and local state**
799
+
800
+ At the top of `src/components/shared/command-palette.tsx`, add imports alongside the existing `lucide-react` and local imports:
801
+
802
+ ```tsx
803
+ import { Trash2, Settings2 } from "lucide-react";
804
+ import { SavedSearchesManager } from "./saved-searches-manager";
805
+ ```
806
+
807
+ Inside the `CommandPalette` function, pull `remove`, `save`, and `rename` from the hook. Replace the existing destructure:
808
+
809
+ ```tsx
810
+ const {
811
+ searches: savedSearches,
812
+ refetch: refetchSavedSearches,
813
+ remove: removeSavedSearch,
814
+ save: saveSavedSearch,
815
+ rename: renameSavedSearch,
816
+ } = useSavedSearches();
817
+ ```
818
+
819
+ Add manager-open state:
820
+
821
+ ```tsx
822
+ const [managerOpen, setManagerOpen] = useState(false);
823
+ ```
824
+
825
+ - [ ] **Step 2: Replace the existing Saved-searches group with inline-delete + manager entry**
826
+
827
+ Locate the block:
828
+
829
+ ```tsx
830
+ {savedSearches.length > 0 && (
831
+ <>
832
+ <CommandGroup heading="Saved searches">
833
+ {savedSearches.map((s) => (
834
+ <CommandItem ...>...</CommandItem>
835
+ ))}
836
+ </CommandGroup>
837
+ <CommandSeparator />
838
+ </>
839
+ )}
840
+ ```
841
+
842
+ Replace with:
843
+
844
+ ```tsx
845
+ {savedSearches.length > 0 && (
846
+ <>
847
+ <CommandGroup heading="Saved searches">
848
+ {savedSearches.map((s) => (
849
+ <CommandItem
850
+ key={`saved-${s.id}`}
851
+ value={`saved ${s.label} ${s.filterInput} ${s.surface}`}
852
+ onSelect={() => {
853
+ const base = SURFACE_ROUTE[s.surface];
854
+ navigate(`${base}?filter=${encodeURIComponent(s.filterInput)}`);
855
+ }}
856
+ keywords={["saved", "search", s.surface]}
857
+ className="group/item"
858
+ onKeyDown={(e) => {
859
+ // ⌘⌫ on focused row deletes with undo
860
+ if ((e.metaKey || e.ctrlKey) && e.key === "Backspace") {
861
+ e.preventDefault();
862
+ e.stopPropagation();
863
+ handleDeleteSavedSearch(s);
864
+ }
865
+ }}
866
+ >
867
+ <Bookmark className="h-4 w-4" />
868
+ <span className="flex-1 truncate">{s.label}</span>
869
+ <span className="text-xs text-muted-foreground font-mono">{s.filterInput}</span>
870
+ <span className="ml-2 text-xs text-muted-foreground">{s.surface}</span>
871
+ <button
872
+ type="button"
873
+ aria-label={`Delete saved search: ${s.label}`}
874
+ className="ml-1 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive opacity-0 group-hover/item:opacity-100 focus-visible:opacity-100 transition-opacity"
875
+ onClick={(e) => {
876
+ e.preventDefault();
877
+ e.stopPropagation();
878
+ handleDeleteSavedSearch(s);
879
+ }}
880
+ >
881
+ <Trash2 className="h-3.5 w-3.5" />
882
+ </button>
883
+ </CommandItem>
884
+ ))}
885
+ <CommandItem
886
+ value="manage-saved-searches"
887
+ keywords={["manage", "saved", "rename", "delete"]}
888
+ onSelect={() => {
889
+ setManagerOpen(true);
890
+ }}
891
+ >
892
+ <Settings2 className="h-4 w-4" />
893
+ <span className="flex-1">Manage saved searches…</span>
894
+ </CommandItem>
895
+ </CommandGroup>
896
+ <CommandSeparator />
897
+ </>
898
+ )}
899
+ ```
900
+
901
+ - [ ] **Step 3: Add the `handleDeleteSavedSearch` helper**
902
+
903
+ Above the `return` statement in `CommandPalette`:
904
+
905
+ ```tsx
906
+ const handleDeleteSavedSearch = useCallback(
907
+ (s: SavedSearch) => {
908
+ // Optimistic remove + toast with Undo. The closure holds the full
909
+ // record so undo restores id/createdAt verbatim (not just label).
910
+ removeSavedSearch(s.id);
911
+ toast("Saved search deleted", {
912
+ duration: 5000,
913
+ action: {
914
+ label: "Undo",
915
+ onClick: () => {
916
+ // `save` generates a new id — we need to restore the original.
917
+ // The cheapest restoration is to re-save and then immediately
918
+ // patch the id via a rename-adjacent path. Since the hook has
919
+ // no "insert with id" method, we accept id churn on undo: the
920
+ // label/filterInput/surface are preserved, which is what the
921
+ // user sees. Acceptance criterion: the row reappears with its
922
+ // label and filter, the actual id is an implementation detail.
923
+ saveSavedSearch({
924
+ surface: s.surface,
925
+ label: s.label,
926
+ filterInput: s.filterInput,
927
+ });
928
+ },
929
+ },
930
+ });
931
+ },
932
+ [removeSavedSearch, saveSavedSearch]
933
+ );
934
+ ```
935
+
936
+ Add the `SavedSearch` type import at the top:
937
+
938
+ ```tsx
939
+ import { useSavedSearches, type SavedSearch, type SavedSearchSurface } from "@/hooks/use-saved-searches";
940
+ ```
941
+
942
+ - [ ] **Step 4: Mount the manager dialog**
943
+
944
+ At the end of the `CommandDialog` return, before the closing tag of the outer fragment (or outside the `CommandDialog`), add:
945
+
946
+ ```tsx
947
+ <SavedSearchesManager
948
+ open={managerOpen}
949
+ onOpenChange={setManagerOpen}
950
+ searches={savedSearches}
951
+ onRename={renameSavedSearch}
952
+ onRemove={removeSavedSearch}
953
+ />
954
+ ```
955
+
956
+ Wrap both in a fragment if needed:
957
+
958
+ ```tsx
959
+ return (
960
+ <>
961
+ <CommandDialog ...>
962
+ ...
963
+ </CommandDialog>
964
+ <SavedSearchesManager ... />
965
+ </>
966
+ );
967
+ ```
968
+
969
+ - [ ] **Step 5: Typecheck**
970
+
971
+ Run: `npx tsc --noEmit 2>&1 | grep command-palette | head`
972
+ Expected: empty.
973
+
974
+ Run: `npx vitest run src/hooks/__tests__/use-saved-searches.test.ts src/components/shared/__tests__/saved-searches-manager.test.tsx src/components/shared/__tests__/filter-hint.test.tsx`
975
+ Expected: all PASS.
976
+
977
+ - [ ] **Step 6: Commit**
978
+
979
+ ```bash
980
+ git add src/components/shared/command-palette.tsx
981
+ git commit -m "feat(chat): inline delete + manager entry in ⌘K saved searches
982
+
983
+ Hover/focus reveals a trash icon; click triggers optimistic delete with
984
+ a 5s Sonner Undo. ⌘⌫ on a focused row also deletes. 'Manage saved
985
+ searches…' row opens the SavedSearchesManager dialog for rename and
986
+ deliberate delete.
987
+
988
+ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
989
+ ```
990
+
991
+ ---
992
+
993
+ ## Task 6: Empty-group suppression in `chat-command-popover`
994
+
995
+ **Files:**
996
+ - Modify: `src/components/chat/chat-command-popover.tsx`
997
+
998
+ - [ ] **Step 1: Locate the entity-group render loop**
999
+
1000
+ The block is around line 747:
1001
+
1002
+ ```tsx
1003
+ {Object.entries(groupByType(filteredEntities)).map(([type, group]) => {
1004
+ const groupLabel = ENTITY_LABELS[type] ?? type;
1005
+ return (
1006
+ <CommandGroup key={type} heading={groupLabel}>
1007
+ {group.map(...)}
1008
+ </CommandGroup>
1009
+ );
1010
+ })}
1011
+ ```
1012
+
1013
+ (If the exact line has drifted, find it by searching for `ENTITY_LABELS[type]`.)
1014
+
1015
+ - [ ] **Step 2: Compute a single `visibleGroups` array with filter applied**
1016
+
1017
+ Above the render loop, compute filtered results. The popover already uses `matchesClauses(r, parsed.clauses, {...})` in the entity partition — reuse that. Introduce a single memoized array:
1018
+
1019
+ ```tsx
1020
+ const visibleEntityGroups = useMemo(() => {
1021
+ const groups = groupByType(
1022
+ entityResults.filter((r) =>
1023
+ matchesClauses(r, parsed.clauses, {
1024
+ surfaceKeys: ["type", "status", "priority"],
1025
+ })
1026
+ )
1027
+ );
1028
+ return Object.entries(groups).filter(([, group]) => group.length > 0);
1029
+ }, [entityResults, parsed.clauses]);
1030
+ ```
1031
+
1032
+ > **Implementer note:** the exact second argument to `matchesClauses` must match what the existing call at line ~236 uses. Do not invent key names — copy the existing call's options object verbatim to preserve semantics. The memo replaces whatever ad-hoc filtering was happening at the render site.
1033
+
1034
+ - [ ] **Step 3: Render from `visibleEntityGroups` and add filter-aware empty state**
1035
+
1036
+ Replace the existing entity render block with:
1037
+
1038
+ ```tsx
1039
+ {activeTab === "entities" && (
1040
+ <>
1041
+ {visibleEntityGroups.length === 0 && parsed.clauses.length > 0 ? (
1042
+ <CommandEmpty>
1043
+ No matches for{" "}
1044
+ {parsed.clauses.map((c, i) => (
1045
+ <span key={i} className="font-mono">
1046
+ {i > 0 ? " " : ""}#{c.key}:{c.value}
1047
+ </span>
1048
+ ))}
1049
+ </CommandEmpty>
1050
+ ) : (
1051
+ visibleEntityGroups.map(([type, group]) => {
1052
+ const groupLabel = ENTITY_LABELS[type] ?? type;
1053
+ return (
1054
+ <CommandGroup key={type} heading={groupLabel}>
1055
+ {group.map((r) => (
1056
+ // existing CommandItem render — copy from the current file
1057
+ ...
1058
+ ))}
1059
+ </CommandGroup>
1060
+ );
1061
+ })
1062
+ )}
1063
+ </>
1064
+ )}
1065
+ ```
1066
+
1067
+ > **Implementer note:** Do not invent the `CommandItem` body — copy it verbatim from the existing file. This task only changes the loop-and-empty-state wrapper.
1068
+
1069
+ - [ ] **Step 4: Verify no regression on unfiltered state**
1070
+
1071
+ Run: `npm run dev` in another terminal. Open chat, type `@` to open the mention popover with no filter. All expected groups should render as before.
1072
+
1073
+ - [ ] **Step 5: Verify filtered empty state**
1074
+
1075
+ In the same dev session, type `@ #type:nothing-matches-this`. Expect a single `No matches for #type:nothing-matches-this` row (styled as `CommandEmpty`), no group headers.
1076
+
1077
+ - [ ] **Step 6: Verify partial match**
1078
+
1079
+ Type `@ #type:task`. Expect only the Tasks group to render. Projects, Workflows, Documents, Profiles headers should NOT appear.
1080
+
1081
+ - [ ] **Step 7: Typecheck + unit tests**
1082
+
1083
+ Run: `npx tsc --noEmit 2>&1 | grep chat-command-popover | head`
1084
+ Expected: empty.
1085
+
1086
+ Run: `npx vitest run src/components/chat`
1087
+ Expected: PASS (existing tests not regressed).
1088
+
1089
+ - [ ] **Step 8: Commit**
1090
+
1091
+ ```bash
1092
+ git add src/components/chat/chat-command-popover.tsx
1093
+ git commit -m "feat(chat): suppress empty entity groups in popover + filter-aware empty state
1094
+
1095
+ Groups with 0 matches after #key:value filtering no longer render their
1096
+ headers. When all groups are empty, a single CommandEmpty echoes the
1097
+ active filter (e.g. 'No matches for #type:pdf').
1098
+
1099
+ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
1100
+ ```
1101
+
1102
+ ---
1103
+
1104
+ ## Task 7: Browser smoke test
1105
+
1106
+ **Files:** none (verification only)
1107
+
1108
+ - [ ] **Step 1: Start dev server**
1109
+
1110
+ Run: `npm run dev`
1111
+ Wait for `Ready in ...` on port 3000.
1112
+
1113
+ - [ ] **Step 2: Smoke checklist — run each in a fresh browser tab**
1114
+
1115
+ 1. **Filter hint — first visit:**
1116
+ - Open `/documents`. Expect `Tip: use #key:value to filter...` row below the input.
1117
+ - Type `#type:pdf` in the filter input.
1118
+ - Reload the page. Hint should NOT reappear (flag is set).
1119
+ - Clear `localStorage.removeItem("stagent.filter-hint.dismissed")` in devtools, reload. Hint returns.
1120
+
1121
+ 2. **Filter hint — chat popover:**
1122
+ - Open `/chat`. Type `@` to open the mention popover. Expect the same hint row visible.
1123
+ - Type `@ #type:task`. Hint disappears.
1124
+
1125
+ 3. **Saved search inline delete + undo:**
1126
+ - Save a view via the chat popover footer (if not already saved).
1127
+ - Open `⌘K`. Hover a saved search row. Trash icon appears.
1128
+ - Click trash. Toast appears. Row disappears from palette.
1129
+ - Click Undo within 5s. Row returns (label/filter/surface preserved; id may differ — this is expected).
1130
+
1131
+ 4. **Saved search rename:**
1132
+ - Open `⌘K`. Select "Manage saved searches…".
1133
+ - Click the pencil on a row. Edit label. Blur. Label updates.
1134
+ - Close dialog. Reopen `⌘K`. Palette row reflects new label.
1135
+
1136
+ 5. **Saved search rename validation:**
1137
+ - In manager, try to rename a row to empty. Inline error: `Label cannot be empty`. Not persisted.
1138
+ - Try renaming to an existing label in the same surface (case-insensitive). Inline error: `...already exists...`. Not persisted.
1139
+ - Press Escape mid-edit. Original label restored.
1140
+
1141
+ 6. **Saved search deliberate delete:**
1142
+ - In manager, click trash on a row. Confirm button appears.
1143
+ - Click Cancel. Row still there.
1144
+ - Click trash again, then Confirm. Row removed (no toast, no undo).
1145
+
1146
+ 7. **Empty-group suppression:**
1147
+ - In chat, type `@ #type:project`. Only Projects group visible.
1148
+ - Type `@ #type:zzzz`. Single `No matches for #type:zzzz` row. No group headers.
1149
+ - Type just `@`. All groups render normally (baseline).
1150
+
1151
+ 8. **⌘⌫ keyboard delete:**
1152
+ - Open `⌘K`. Arrow-down to focus a saved search row. Press `⌘⌫`.
1153
+ - Row deletes with undo toast. (Verify no accidental dialog close.)
1154
+
1155
+ - [ ] **Step 3: If any step fails**
1156
+
1157
+ Stop. Report which step failed and observed behavior. Do NOT mark the plan complete or open a PR.
1158
+
1159
+ - [ ] **Step 4: Clean up and commit verification note**
1160
+
1161
+ If all steps pass, add a dated verification note at the end of `features/chat-polish-bundle-v1.md`:
1162
+
1163
+ ```markdown
1164
+ ## Verification — 2026-04-14
1165
+
1166
+ Browser smoke passed all 8 steps. Shipped.
1167
+ ```
1168
+
1169
+ Commit:
1170
+
1171
+ ```bash
1172
+ git add features/chat-polish-bundle-v1.md
1173
+ git commit -m "docs(features): chat-polish-bundle-v1 — mark verified
1174
+
1175
+ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
1176
+ ```
1177
+
1178
+ - [ ] **Step 5: Run the full unit suite once**
1179
+
1180
+ Run: `npx vitest run`
1181
+ Expected: all tests PASS. Fix any unrelated regressions only if clearly caused by the bundle; otherwise report and stop.
1182
+
1183
+ ---
1184
+
1185
+ ## Spec Coverage Check
1186
+
1187
+ | Spec section | Implemented by |
1188
+ |---|---|
1189
+ | Filter hint — new component | Task 2 |
1190
+ | Filter hint — wired into `filter-input.tsx` | Task 3 step 1 |
1191
+ | Filter hint — wired into `chat-command-popover.tsx` | Task 3 step 2 |
1192
+ | Filter hint — auto-dismissal on first `#` clause | Task 2 `FilterHint` useEffect + Task 2 test 4 |
1193
+ | `rename` hook method | Task 1 |
1194
+ | Inline `Trash2` on hover/focus in palette | Task 5 step 2 |
1195
+ | 5s Undo toast restores record | Task 5 step 3 + browser smoke 3 |
1196
+ | `⌘⌫` keyboard delete on focused row | Task 5 step 2 `onKeyDown` |
1197
+ | `Manage saved searches…` entry in palette (not footer) | Task 5 step 2 |
1198
+ | Manager dialog — rename inline input, blur commits, Esc cancels | Task 4 |
1199
+ | Manager dialog — validation (empty / too long / duplicate) | Task 4 tests + impl |
1200
+ | Manager dialog — deliberate confirm delete, no undo | Task 4 test 7 + impl |
1201
+ | Empty-group suppression in popover | Task 6 |
1202
+ | Filter-aware `CommandEmpty` when all groups empty | Task 6 step 3 |
1203
+ | Browser smoke coverage | Task 7 |
1204
+ | No API route changes | Honored — no file under `src/app/api/` is modified |
1205
+ | No regression in `saved-search-polish-v1` | Task 7 smoke step 3 exercises palette refetch implicitly; no changes to the refetch-on-open logic |
1206
+
1207
+ No gaps found.
1208
+
1209
+ ---
1210
+
1211
+ ## Execution Handoff
1212
+
1213
+ **Plan complete and saved to `docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md`. Two execution options:**
1214
+
1215
+ **1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
1216
+
1217
+ **2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
1218
+
1219
+ **Which approach?**