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.
- package/README.md +20 -44
- package/dist/cli.js +66 -18
- package/docs/.coverage-gaps.json +144 -56
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +12 -2
- package/docs/features/chat.md +40 -5
- package/docs/features/cost-usage.md +1 -1
- package/docs/features/documents.md +5 -2
- package/docs/features/inbox-notifications.md +10 -2
- package/docs/features/keyboard-navigation.md +12 -3
- package/docs/features/provider-runtimes.md +20 -2
- package/docs/features/schedules.md +32 -4
- package/docs/features/settings.md +28 -5
- package/docs/features/shared-components.md +7 -3
- package/docs/features/tables.md +11 -2
- package/docs/features/tool-permissions.md +6 -2
- package/docs/features/workflows.md +14 -4
- package/docs/index.md +1 -1
- package/docs/journeys/developer.md +39 -2
- package/docs/journeys/personal-use.md +32 -8
- package/docs/journeys/power-user.md +45 -14
- package/docs/journeys/work-use.md +17 -8
- package/docs/manifest.json +15 -15
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
- package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
- package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
- package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
- package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
- package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
- package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
- package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
- package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
- package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
- package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
- package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
- package/next.config.mjs +1 -0
- package/package.json +3 -2
- package/src/__tests__/instrumentation-smoke.test.ts +15 -0
- package/src/app/analytics/page.tsx +1 -21
- package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
- package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
- package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
- package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
- package/src/app/api/chat/export/route.ts +52 -0
- package/src/app/api/chat/files/search/route.ts +50 -0
- package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
- package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
- package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
- package/src/app/api/environment/skills/route.ts +13 -0
- package/src/app/api/instance/config/route.ts +41 -0
- package/src/app/api/instance/init/route.ts +34 -0
- package/src/app/api/instance/upgrade/check/route.ts +26 -0
- package/src/app/api/instance/upgrade/route.ts +96 -0
- package/src/app/api/instance/upgrade/status/route.ts +35 -0
- package/src/app/api/memory/route.ts +0 -11
- package/src/app/api/notifications/route.ts +4 -2
- package/src/app/api/projects/[id]/route.ts +5 -155
- package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
- package/src/app/api/schedules/[id]/execute/route.ts +111 -0
- package/src/app/api/schedules/[id]/route.ts +9 -1
- package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
- package/src/app/api/schedules/route.ts +3 -12
- package/src/app/api/settings/chat/pins/route.ts +94 -0
- package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
- package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
- package/src/app/api/settings/environment/route.ts +26 -0
- package/src/app/api/settings/openai/login/route.ts +22 -0
- package/src/app/api/settings/openai/logout/route.ts +7 -0
- package/src/app/api/settings/openai/route.ts +21 -1
- package/src/app/api/settings/providers/route.ts +35 -8
- package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
- package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
- package/src/app/api/tables/[id]/enrich/route.ts +147 -0
- package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
- package/src/app/api/tasks/[id]/execute/route.ts +52 -33
- package/src/app/api/tasks/[id]/respond/route.ts +31 -15
- package/src/app/api/tasks/[id]/resume/route.ts +24 -3
- package/src/app/api/workflows/[id]/resume/route.ts +59 -0
- package/src/app/api/workflows/[id]/status/route.ts +22 -8
- package/src/app/api/workspace/context/route.ts +2 -0
- package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
- package/src/app/chat/page.tsx +11 -0
- package/src/app/documents/page.tsx +4 -1
- package/src/app/inbox/page.tsx +12 -5
- package/src/app/layout.tsx +42 -21
- package/src/app/page.tsx +0 -2
- package/src/app/settings/page.tsx +8 -9
- package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +573 -0
- package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
- package/src/components/chat/capability-banner.tsx +68 -0
- package/src/components/chat/chat-command-popover.tsx +670 -49
- package/src/components/chat/chat-input.tsx +104 -10
- package/src/components/chat/chat-message.tsx +12 -3
- package/src/components/chat/chat-session-provider.tsx +790 -0
- package/src/components/chat/chat-shell.tsx +151 -401
- package/src/components/chat/command-tab-bar.tsx +68 -0
- package/src/components/chat/conversation-template-picker.tsx +421 -0
- package/src/components/chat/help-dialog.tsx +39 -0
- package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
- package/src/components/chat/skill-row.tsx +147 -0
- package/src/components/documents/document-browser.tsx +37 -19
- package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
- package/src/components/instance/instance-section.tsx +382 -0
- package/src/components/instance/upgrade-badge.tsx +219 -0
- package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
- package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
- package/src/components/notifications/batch-proposal-review.tsx +20 -5
- package/src/components/notifications/inbox-list.tsx +11 -2
- package/src/components/notifications/notification-item.tsx +56 -2
- package/src/components/notifications/pending-approval-host.tsx +56 -37
- package/src/components/notifications/permission-response-actions.tsx +155 -1
- package/src/components/schedules/schedule-create-sheet.tsx +19 -1
- package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
- package/src/components/schedules/schedule-form.tsx +31 -0
- package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
- package/src/components/settings/auth-method-selector.tsx +19 -4
- package/src/components/settings/auth-status-badge.tsx +28 -3
- package/src/components/settings/environment-section.tsx +102 -0
- package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
- package/src/components/settings/openai-runtime-section.tsx +7 -1
- package/src/components/settings/providers-runtimes-section.tsx +138 -19
- package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
- package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
- package/src/components/shared/app-sidebar.tsx +4 -3
- package/src/components/shared/command-palette.tsx +266 -7
- package/src/components/shared/filter-hint.tsx +70 -0
- package/src/components/shared/filter-input.tsx +59 -0
- package/src/components/shared/saved-searches-manager.tsx +199 -0
- package/src/components/shared/theme-toggle.tsx +5 -24
- package/src/components/shared/workspace-indicator.tsx +61 -2
- package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
- package/src/components/tables/table-create-sheet.tsx +4 -0
- package/src/components/tables/table-enrichment-runs.tsx +103 -0
- package/src/components/tables/table-enrichment-sheet.tsx +538 -0
- package/src/components/tables/table-spreadsheet.tsx +29 -5
- package/src/components/tables/table-toolbar.tsx +10 -1
- package/src/components/tasks/kanban-board.tsx +1 -0
- package/src/components/tasks/kanban-column.tsx +53 -14
- package/src/components/tasks/task-bento-grid.tsx +31 -2
- package/src/components/tasks/task-card.tsx +29 -3
- package/src/components/tasks/task-chip-bar.tsx +54 -1
- package/src/components/tasks/task-result-renderer.tsx +1 -1
- package/src/components/workflows/delay-step-body.tsx +109 -0
- package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
- package/src/components/workflows/loop-status-view.tsx +1 -1
- package/src/components/workflows/shared/step-result.tsx +78 -0
- package/src/components/workflows/shared/workflow-header.tsx +141 -0
- package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
- package/src/components/workflows/swarm-dashboard.tsx +2 -15
- package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
- package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
- package/src/components/workflows/workflow-form-view.tsx +133 -16
- package/src/components/workflows/workflow-status-view.tsx +30 -740
- package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
- package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
- package/src/hooks/use-active-skills.ts +110 -0
- package/src/hooks/use-chat-autocomplete.ts +120 -7
- package/src/hooks/use-enriched-skills.ts +19 -0
- package/src/hooks/use-pinned-entries.ts +104 -0
- package/src/hooks/use-recent-user-messages.ts +19 -0
- package/src/hooks/use-saved-searches.ts +142 -0
- package/src/instrumentation-node.ts +94 -0
- package/src/instrumentation.ts +4 -48
- package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +212 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
- package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
- package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
- package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
- package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
- package/src/lib/agents/claude-agent.ts +217 -21
- package/src/lib/agents/execution-manager.ts +0 -35
- package/src/lib/agents/handoff/bus.ts +2 -2
- package/src/lib/agents/learned-context.ts +0 -12
- package/src/lib/agents/learning-session.ts +18 -5
- package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +53 -4
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +97 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +36 -0
- package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
- package/src/lib/agents/profiles/registry.ts +18 -0
- package/src/lib/agents/profiles/types.ts +7 -1
- package/src/lib/agents/router.ts +3 -6
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
- package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
- package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
- package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
- package/src/lib/agents/runtime/catalog.ts +121 -0
- package/src/lib/agents/runtime/claude-sdk.ts +32 -0
- package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
- package/src/lib/agents/runtime/execution-target.ts +456 -0
- package/src/lib/agents/runtime/index.ts +4 -0
- package/src/lib/agents/runtime/launch-failure.ts +101 -0
- package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
- package/src/lib/agents/runtime/openai-codex.ts +64 -60
- package/src/lib/agents/runtime/openai-direct.ts +8 -0
- package/src/lib/agents/runtime/types.ts +8 -0
- package/src/lib/agents/task-dispatch.ts +220 -0
- package/src/lib/agents/tool-permissions.ts +16 -1
- package/src/lib/book/chapter-mapping.ts +11 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
- package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
- package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
- package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
- package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
- package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
- package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
- package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
- package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
- package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
- package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
- package/src/lib/chat/__tests__/types.test.ts +28 -0
- package/src/lib/chat/active-skills.ts +31 -0
- package/src/lib/chat/active-streams.ts +27 -0
- package/src/lib/chat/clean-filter-input.ts +30 -0
- package/src/lib/chat/codex-engine.ts +46 -24
- package/src/lib/chat/command-tabs.ts +61 -0
- package/src/lib/chat/context-builder.ts +146 -4
- package/src/lib/chat/dismissals.ts +73 -0
- package/src/lib/chat/engine.ts +159 -18
- package/src/lib/chat/files/__tests__/search.test.ts +135 -0
- package/src/lib/chat/files/expand-mention.ts +76 -0
- package/src/lib/chat/files/search.ts +99 -0
- package/src/lib/chat/reconcile.ts +117 -0
- package/src/lib/chat/skill-composition.ts +210 -0
- package/src/lib/chat/skill-conflict.ts +105 -0
- package/src/lib/chat/stagent-tools.ts +7 -19
- package/src/lib/chat/stream-telemetry.ts +137 -0
- package/src/lib/chat/suggested-prompts.ts +28 -1
- package/src/lib/chat/system-prompt.ts +48 -1
- package/src/lib/chat/tool-catalog.ts +35 -4
- package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
- package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
- package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
- package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
- package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +399 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +351 -0
- package/src/lib/chat/tools/blueprint-tools.ts +190 -0
- package/src/lib/chat/tools/document-tools.ts +29 -13
- package/src/lib/chat/tools/helpers.ts +41 -0
- package/src/lib/chat/tools/notification-tools.ts +9 -5
- package/src/lib/chat/tools/profile-tools.ts +120 -23
- package/src/lib/chat/tools/project-tools.ts +33 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -11
- package/src/lib/chat/tools/skill-tools.ts +183 -0
- package/src/lib/chat/tools/table-tools.ts +71 -0
- package/src/lib/chat/tools/task-tools.ts +89 -21
- package/src/lib/chat/tools/workflow-tools.ts +275 -32
- package/src/lib/chat/types.ts +15 -0
- package/src/lib/constants/settings.ts +10 -18
- package/src/lib/data/__tests__/clear.test.ts +56 -2
- package/src/lib/data/clear.ts +17 -16
- package/src/lib/data/delete-project.ts +171 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
- package/src/lib/db/bootstrap.ts +62 -16
- package/src/lib/db/index.ts +5 -0
- package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
- package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
- package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
- package/src/lib/db/migrations/0026_drop_license.sql +3 -0
- package/src/lib/db/migrations/meta/_journal.json +21 -0
- package/src/lib/db/schema.ts +94 -23
- package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
- package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
- package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
- package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
- package/src/lib/environment/data.ts +9 -0
- package/src/lib/environment/list-skills.ts +176 -0
- package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
- package/src/lib/environment/parsers/skill.ts +26 -5
- package/src/lib/environment/profile-generator.ts +54 -0
- package/src/lib/environment/skill-enrichment.ts +106 -0
- package/src/lib/environment/skill-recommendations.ts +66 -0
- package/src/lib/environment/workspace-context.ts +13 -1
- package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
- package/src/lib/filters/__tests__/parse.test.ts +135 -0
- package/src/lib/filters/parse.ts +86 -0
- package/src/lib/import/dedup.ts +4 -54
- package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
- package/src/lib/instance/__tests__/detect.test.ts +115 -0
- package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
- package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
- package/src/lib/instance/__tests__/settings.test.ts +83 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +181 -0
- package/src/lib/instance/bootstrap.ts +270 -0
- package/src/lib/instance/detect.ts +49 -0
- package/src/lib/instance/fingerprint.ts +76 -0
- package/src/lib/instance/git-ops.ts +95 -0
- package/src/lib/instance/settings.ts +61 -0
- package/src/lib/instance/types.ts +77 -0
- package/src/lib/instance/upgrade-poller.ts +205 -0
- package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
- package/src/lib/notifications/visibility.ts +33 -0
- package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
- package/src/lib/schedules/__tests__/config.test.ts +62 -0
- package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
- package/src/lib/schedules/__tests__/integration.test.ts +82 -0
- package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
- package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
- package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
- package/src/lib/schedules/collision-check.ts +105 -0
- package/src/lib/schedules/config.ts +53 -0
- package/src/lib/schedules/scheduler.ts +236 -17
- package/src/lib/schedules/slot-claim.ts +105 -0
- package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
- package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
- package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
- package/src/lib/settings/openai-auth.ts +105 -10
- package/src/lib/settings/openai-login-manager.ts +260 -0
- package/src/lib/settings/runtime-setup.ts +14 -4
- package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
- package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
- package/src/lib/tables/enrichment-planner.ts +454 -0
- package/src/lib/tables/enrichment.ts +328 -0
- package/src/lib/tables/query-builder.ts +5 -2
- package/src/lib/tables/trigger-evaluator.ts +3 -2
- package/src/lib/theme.ts +71 -0
- package/src/lib/usage/ledger.ts +2 -18
- package/src/lib/util/__tests__/similarity.test.ts +106 -0
- package/src/lib/util/similarity.ts +77 -0
- package/src/lib/utils/format-timestamp.ts +24 -0
- package/src/lib/utils/stagent-paths.ts +12 -0
- package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
- package/src/lib/validators/__tests__/settings.test.ts +10 -0
- package/src/lib/validators/blueprint.ts +70 -9
- package/src/lib/validators/profile.ts +2 -2
- package/src/lib/validators/settings.ts +3 -1
- package/src/lib/workflows/__tests__/delay.test.ts +196 -0
- package/src/lib/workflows/__tests__/engine.test.ts +8 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
- package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
- package/src/lib/workflows/blueprints/instantiator.ts +22 -1
- package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
- package/src/lib/workflows/blueprints/types.ts +16 -2
- package/src/lib/workflows/delay.ts +106 -0
- package/src/lib/workflows/engine.ts +212 -7
- package/src/lib/workflows/loop-executor.ts +349 -24
- package/src/lib/workflows/post-action.ts +91 -0
- package/src/lib/workflows/types.ts +166 -1
- package/src/test/setup.ts +10 -0
- package/src/app/api/license/checkout/route.ts +0 -28
- package/src/app/api/license/portal/route.ts +0 -26
- package/src/app/api/license/route.ts +0 -89
- package/src/app/api/license/usage/route.ts +0 -63
- package/src/app/api/marketplace/browse/route.ts +0 -15
- package/src/app/api/marketplace/import/route.ts +0 -28
- package/src/app/api/marketplace/publish/route.ts +0 -40
- package/src/app/api/onboarding/email/route.ts +0 -53
- package/src/app/api/settings/telemetry/route.ts +0 -14
- package/src/app/api/sync/export/route.ts +0 -54
- package/src/app/api/sync/restore/route.ts +0 -37
- package/src/app/api/sync/sessions/route.ts +0 -24
- package/src/app/auth/callback/route.ts +0 -73
- package/src/app/marketplace/page.tsx +0 -19
- package/src/components/analytics/analytics-gate-card.tsx +0 -101
- package/src/components/marketplace/blueprint-card.tsx +0 -61
- package/src/components/marketplace/marketplace-browser.tsx +0 -131
- package/src/components/onboarding/email-capture-card.tsx +0 -104
- package/src/components/settings/activation-form.tsx +0 -95
- package/src/components/settings/cloud-account-section.tsx +0 -147
- package/src/components/settings/cloud-sync-section.tsx +0 -155
- package/src/components/settings/subscription-section.tsx +0 -410
- package/src/components/settings/telemetry-section.tsx +0 -80
- package/src/components/shared/premium-gate-overlay.tsx +0 -50
- package/src/components/shared/schedule-gate-dialog.tsx +0 -64
- package/src/components/shared/upgrade-banner.tsx +0 -112
- package/src/hooks/use-supabase-auth.ts +0 -79
- package/src/lib/billing/email.ts +0 -54
- package/src/lib/billing/products.ts +0 -80
- package/src/lib/billing/stripe.ts +0 -101
- package/src/lib/cloud/supabase-browser.ts +0 -32
- package/src/lib/cloud/supabase-client.ts +0 -56
- package/src/lib/license/__tests__/features.test.ts +0 -56
- package/src/lib/license/__tests__/key-format.test.ts +0 -88
- package/src/lib/license/__tests__/manager.test.ts +0 -64
- package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
- package/src/lib/license/cloud-validation.ts +0 -60
- package/src/lib/license/features.ts +0 -44
- package/src/lib/license/key-format.ts +0 -101
- package/src/lib/license/limit-check.ts +0 -111
- package/src/lib/license/limit-queries.ts +0 -51
- package/src/lib/license/manager.ts +0 -345
- package/src/lib/license/notifications.ts +0 -59
- package/src/lib/license/tier-limits.ts +0 -71
- package/src/lib/marketplace/marketplace-client.ts +0 -107
- package/src/lib/sync/cloud-sync.ts +0 -235
- package/src/lib/telemetry/conversion-events.ts +0 -71
- package/src/lib/telemetry/queue.ts +0 -122
- 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?**
|