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,573 @@
|
|
|
1
|
+
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import type { ChatMessageRow } from "@/lib/db/schema";
|
|
6
|
+
import {
|
|
7
|
+
ChatSessionProvider,
|
|
8
|
+
useChatSession,
|
|
9
|
+
} from "@/components/chat/chat-session-provider";
|
|
10
|
+
|
|
11
|
+
// Satisfy the type import linter — we use ChatMessageRow in the Consumer
|
|
12
|
+
// probes below but through inference from session.messages.
|
|
13
|
+
void ({} as ChatMessageRow | undefined);
|
|
14
|
+
|
|
15
|
+
// ── Next.js router mock ──────────────────────────────────────────────
|
|
16
|
+
vi.mock("next/navigation", () => ({
|
|
17
|
+
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// ── Sonner mock ──────────────────────────────────────────────────────
|
|
21
|
+
const toastErrorSpy = vi.fn();
|
|
22
|
+
vi.mock("sonner", () => ({
|
|
23
|
+
toast: {
|
|
24
|
+
error: (...args: unknown[]) => toastErrorSpy(...args),
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// ── Test helpers ─────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Small consumer component that exposes the session value via test ids.
|
|
32
|
+
* Text probes let us assert state without wiring up the full ChatShell.
|
|
33
|
+
*/
|
|
34
|
+
function Consumer({ label }: { label?: string }) {
|
|
35
|
+
const session = useChatSession();
|
|
36
|
+
return (
|
|
37
|
+
<div>
|
|
38
|
+
<div data-testid={`${label ?? "c"}-active`}>{session.activeId ?? ""}</div>
|
|
39
|
+
<div data-testid={`${label ?? "c"}-isStreaming`}>
|
|
40
|
+
{String(session.isStreaming)}
|
|
41
|
+
</div>
|
|
42
|
+
<div data-testid={`${label ?? "c"}-messageCount`}>
|
|
43
|
+
{session.messages.length}
|
|
44
|
+
</div>
|
|
45
|
+
<div data-testid={`${label ?? "c"}-assistantContent`}>
|
|
46
|
+
{session.messages
|
|
47
|
+
.filter((m: ChatMessageRow) => m.role === "assistant")
|
|
48
|
+
.map((m: ChatMessageRow) => m.content)
|
|
49
|
+
.join("|")}
|
|
50
|
+
</div>
|
|
51
|
+
<button
|
|
52
|
+
data-testid={`${label ?? "c"}-send`}
|
|
53
|
+
onClick={() => void session.sendMessage("hello")}
|
|
54
|
+
>
|
|
55
|
+
send
|
|
56
|
+
</button>
|
|
57
|
+
<button
|
|
58
|
+
data-testid={`${label ?? "c"}-stop`}
|
|
59
|
+
onClick={() => session.stopStreaming()}
|
|
60
|
+
>
|
|
61
|
+
stop
|
|
62
|
+
</button>
|
|
63
|
+
<button
|
|
64
|
+
data-testid={`${label ?? "c"}-select`}
|
|
65
|
+
onClick={() => session.setActiveConversation("conv-1")}
|
|
66
|
+
>
|
|
67
|
+
select
|
|
68
|
+
</button>
|
|
69
|
+
<button
|
|
70
|
+
data-testid={`${label ?? "c"}-hydrate`}
|
|
71
|
+
onClick={() =>
|
|
72
|
+
session.hydrate({
|
|
73
|
+
conversations: [
|
|
74
|
+
{
|
|
75
|
+
id: "conv-1",
|
|
76
|
+
projectId: null,
|
|
77
|
+
title: "Test conv",
|
|
78
|
+
status: "active",
|
|
79
|
+
runtimeId: "claude-code",
|
|
80
|
+
modelId: "sonnet",
|
|
81
|
+
createdAt: new Date(),
|
|
82
|
+
updatedAt: new Date(),
|
|
83
|
+
archivedAt: null,
|
|
84
|
+
} as unknown as never,
|
|
85
|
+
],
|
|
86
|
+
initialActiveId: "conv-1",
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
hydrate
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A wrapper that keeps the provider mounted while letting tests mount and
|
|
98
|
+
* unmount a child consumer on demand. This is how we verify that state
|
|
99
|
+
* survives a consumer unmount/remount cycle — the provider is stable, only
|
|
100
|
+
* the child toggles.
|
|
101
|
+
*/
|
|
102
|
+
function ProviderWithToggle() {
|
|
103
|
+
const [show, setShow] = useState(true);
|
|
104
|
+
return (
|
|
105
|
+
<ChatSessionProvider>
|
|
106
|
+
<button data-testid="toggle" onClick={() => setShow((v) => !v)}>
|
|
107
|
+
toggle
|
|
108
|
+
</button>
|
|
109
|
+
<div data-testid="consumer-visible">{String(show)}</div>
|
|
110
|
+
{show && <Consumer />}
|
|
111
|
+
</ChatSessionProvider>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build a ReadableStream that emits the given SSE chunks as `data: ...` lines.
|
|
117
|
+
* Each chunk is JSON-serialized and prefixed with `data: ` + newline.
|
|
118
|
+
*/
|
|
119
|
+
function makeSSEStream(
|
|
120
|
+
chunks: unknown[],
|
|
121
|
+
opts: { closeAfterMs?: number } = {}
|
|
122
|
+
): ReadableStream<Uint8Array> {
|
|
123
|
+
const encoder = new TextEncoder();
|
|
124
|
+
return new ReadableStream({
|
|
125
|
+
async start(controller) {
|
|
126
|
+
for (const chunk of chunks) {
|
|
127
|
+
const line = `data: ${JSON.stringify(chunk)}\n`;
|
|
128
|
+
controller.enqueue(encoder.encode(line));
|
|
129
|
+
// Tiny yield so React can flush state between chunks.
|
|
130
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
131
|
+
}
|
|
132
|
+
if (opts.closeAfterMs) {
|
|
133
|
+
await new Promise((r) => setTimeout(r, opts.closeAfterMs));
|
|
134
|
+
}
|
|
135
|
+
controller.close();
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build a ReadableStream that waits indefinitely (useful for testing
|
|
142
|
+
* abort behavior). Signal-aware: closes early if signal aborts.
|
|
143
|
+
*/
|
|
144
|
+
function makeHangingStream(signal: AbortSignal): ReadableStream<Uint8Array> {
|
|
145
|
+
return new ReadableStream({
|
|
146
|
+
start(controller) {
|
|
147
|
+
signal.addEventListener("abort", () => {
|
|
148
|
+
controller.error(
|
|
149
|
+
Object.assign(new Error("aborted"), { name: "AbortError" })
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Suites ───────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("ChatSessionProvider", () => {
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
toastErrorSpy.mockReset();
|
|
161
|
+
vi.stubGlobal("crypto", {
|
|
162
|
+
randomUUID: () => `uuid-${Math.random().toString(36).slice(2, 10)}`,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
vi.unstubAllGlobals();
|
|
168
|
+
vi.restoreAllMocks();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("sendMessage accumulates SSE deltas into the assistant message", async () => {
|
|
172
|
+
const fetchMock = vi.fn(async (url: RequestInfo | URL) => {
|
|
173
|
+
const u = url.toString();
|
|
174
|
+
if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
|
|
175
|
+
if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
|
|
176
|
+
if (u === "/api/chat/conversations" || u.endsWith("/api/chat/conversations")) {
|
|
177
|
+
return new Response(
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
id: "conv-new",
|
|
180
|
+
projectId: null,
|
|
181
|
+
title: "New Chat",
|
|
182
|
+
status: "active",
|
|
183
|
+
runtimeId: "claude-code",
|
|
184
|
+
modelId: "haiku",
|
|
185
|
+
createdAt: new Date().toISOString(),
|
|
186
|
+
updatedAt: new Date().toISOString(),
|
|
187
|
+
}),
|
|
188
|
+
{ status: 200 }
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (u.match(/\/api\/chat\/conversations\/conv-new\/messages$/)) {
|
|
192
|
+
return new Response(
|
|
193
|
+
makeSSEStream([
|
|
194
|
+
{ type: "delta", content: "Hello" },
|
|
195
|
+
{ type: "delta", content: " world" },
|
|
196
|
+
{ type: "done", messageId: "msg-final", quickAccess: [] },
|
|
197
|
+
]),
|
|
198
|
+
{ status: 200 }
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (u.startsWith("/api/chat/conversations/conv-new")) {
|
|
202
|
+
// GET metadata refresh after "done" event
|
|
203
|
+
return new Response(
|
|
204
|
+
JSON.stringify({ id: "conv-new", title: "Auto Title" }),
|
|
205
|
+
{ status: 200 }
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return new Response(null, { status: 404 });
|
|
209
|
+
});
|
|
210
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
211
|
+
|
|
212
|
+
render(
|
|
213
|
+
<ChatSessionProvider>
|
|
214
|
+
<Consumer />
|
|
215
|
+
</ChatSessionProvider>
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
await act(async () => {
|
|
219
|
+
screen.getByTestId("c-send").click();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await waitFor(() => {
|
|
223
|
+
expect(screen.getByTestId("c-assistantContent").textContent).toBe(
|
|
224
|
+
"Hello world"
|
|
225
|
+
);
|
|
226
|
+
expect(screen.getByTestId("c-isStreaming").textContent).toBe("false");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("preserves messages across consumer unmount/remount", async () => {
|
|
231
|
+
// Seed state: hydrate with conv-1 (fetch returns empty message list),
|
|
232
|
+
// then send a message and verify it's visible. Then toggle the consumer
|
|
233
|
+
// off and back on and verify the messages are still there.
|
|
234
|
+
const fetchMock = vi.fn(async (url: RequestInfo | URL) => {
|
|
235
|
+
const u = url.toString();
|
|
236
|
+
if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
|
|
237
|
+
if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
|
|
238
|
+
if (u.match(/\/api\/chat\/conversations\/conv-1\/messages$/)) {
|
|
239
|
+
// Support both GET (select refresh) and POST (send)
|
|
240
|
+
// We can distinguish in a real test but here both return empty/delta
|
|
241
|
+
// If POST, return the SSE stream. Differentiate by checking if there's a body.
|
|
242
|
+
return new Response(
|
|
243
|
+
makeSSEStream([
|
|
244
|
+
{ type: "delta", content: "persisted" },
|
|
245
|
+
{ type: "done", messageId: "msg-a", quickAccess: [] },
|
|
246
|
+
]),
|
|
247
|
+
{ status: 200 }
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (u.startsWith("/api/chat/conversations/conv-1")) {
|
|
251
|
+
return new Response(
|
|
252
|
+
JSON.stringify({ id: "conv-1", title: "T" }),
|
|
253
|
+
{ status: 200 }
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return new Response(null, { status: 404 });
|
|
257
|
+
});
|
|
258
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
259
|
+
|
|
260
|
+
render(<ProviderWithToggle />);
|
|
261
|
+
|
|
262
|
+
// Hydrate (sets conv-1 as active) and select
|
|
263
|
+
await act(async () => {
|
|
264
|
+
screen.getByTestId("c-hydrate").click();
|
|
265
|
+
});
|
|
266
|
+
await act(async () => {
|
|
267
|
+
screen.getByTestId("c-send").click();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await waitFor(() => {
|
|
271
|
+
expect(screen.getByTestId("c-assistantContent").textContent).toBe(
|
|
272
|
+
"persisted"
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Unmount the consumer
|
|
277
|
+
await act(async () => {
|
|
278
|
+
screen.getByTestId("toggle").click();
|
|
279
|
+
});
|
|
280
|
+
expect(screen.queryByTestId("c-assistantContent")).toBeNull();
|
|
281
|
+
expect(screen.getByTestId("consumer-visible").textContent).toBe("false");
|
|
282
|
+
|
|
283
|
+
// Remount the consumer — provider state should still be there
|
|
284
|
+
await act(async () => {
|
|
285
|
+
screen.getByTestId("toggle").click();
|
|
286
|
+
});
|
|
287
|
+
await waitFor(() => {
|
|
288
|
+
expect(screen.getByTestId("c-assistantContent").textContent).toBe(
|
|
289
|
+
"persisted"
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("selectConversation fetch failure calls toast.error and does not clear state", async () => {
|
|
295
|
+
// The bug this test pins down: `handleSelectConversation`'s old catch
|
|
296
|
+
// block was `setMessages([])`, which wiped all prior turns on any
|
|
297
|
+
// fetch hiccup. The fix: on failure, call toast.error and leave
|
|
298
|
+
// messagesByConversation untouched.
|
|
299
|
+
const fetchMock = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
300
|
+
const u = url.toString();
|
|
301
|
+
const method = init?.method ?? "GET";
|
|
302
|
+
if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
|
|
303
|
+
if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
|
|
304
|
+
if (u.match(/\/api\/chat\/conversations\/conv-missing\/messages$/) && method === "GET") {
|
|
305
|
+
return new Response("boom", { status: 500 });
|
|
306
|
+
}
|
|
307
|
+
if (u.startsWith("/api/chat/conversations/conv-missing")) {
|
|
308
|
+
return new Response(JSON.stringify({ id: "conv-missing" }), { status: 200 });
|
|
309
|
+
}
|
|
310
|
+
return new Response(null, { status: 404 });
|
|
311
|
+
});
|
|
312
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
313
|
+
|
|
314
|
+
// Custom consumer that exposes a button to select a specific (failing) conversation
|
|
315
|
+
function FailingSelectConsumer() {
|
|
316
|
+
const session = useChatSession();
|
|
317
|
+
return (
|
|
318
|
+
<div>
|
|
319
|
+
<div data-testid="cache-keys">
|
|
320
|
+
{Object.keys(session.conversations.length ? { placeholder: 1 } : {}).join(",")}
|
|
321
|
+
</div>
|
|
322
|
+
<button
|
|
323
|
+
data-testid="select-failing"
|
|
324
|
+
onClick={() => {
|
|
325
|
+
// Directly call setActiveConversation with an id that has no
|
|
326
|
+
// cache entry — this triggers loadMessagesForConversation,
|
|
327
|
+
// which will hit the failing mock.
|
|
328
|
+
session.setActiveConversation("conv-missing");
|
|
329
|
+
}}
|
|
330
|
+
>
|
|
331
|
+
select failing
|
|
332
|
+
</button>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
render(
|
|
338
|
+
<ChatSessionProvider>
|
|
339
|
+
<FailingSelectConsumer />
|
|
340
|
+
</ChatSessionProvider>
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
await act(async () => {
|
|
344
|
+
screen.getByTestId("select-failing").click();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// The fetch fails → toast.error must be called. Prior to the fix,
|
|
348
|
+
// the code would have called `setMessages([])`. Now it calls toast and
|
|
349
|
+
// leaves state alone.
|
|
350
|
+
await waitFor(() => {
|
|
351
|
+
expect(toastErrorSpy).toHaveBeenCalledWith(
|
|
352
|
+
"Failed to load conversation messages"
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("stopStreaming aborts an in-flight stream", async () => {
|
|
358
|
+
const fetchMock = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
359
|
+
const u = url.toString();
|
|
360
|
+
if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
|
|
361
|
+
if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
|
|
362
|
+
if (u === "/api/chat/conversations" || u.endsWith("/api/chat/conversations")) {
|
|
363
|
+
return new Response(
|
|
364
|
+
JSON.stringify({
|
|
365
|
+
id: "conv-abort",
|
|
366
|
+
projectId: null,
|
|
367
|
+
title: "T",
|
|
368
|
+
status: "active",
|
|
369
|
+
runtimeId: "claude-code",
|
|
370
|
+
modelId: "haiku",
|
|
371
|
+
createdAt: new Date().toISOString(),
|
|
372
|
+
updatedAt: new Date().toISOString(),
|
|
373
|
+
}),
|
|
374
|
+
{ status: 200 }
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (u.match(/\/api\/chat\/conversations\/conv-abort\/messages$/)) {
|
|
378
|
+
const signal = init?.signal as AbortSignal;
|
|
379
|
+
return new Response(makeHangingStream(signal), { status: 200 });
|
|
380
|
+
}
|
|
381
|
+
return new Response(null, { status: 404 });
|
|
382
|
+
});
|
|
383
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
384
|
+
|
|
385
|
+
render(
|
|
386
|
+
<ChatSessionProvider>
|
|
387
|
+
<Consumer />
|
|
388
|
+
</ChatSessionProvider>
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
await act(async () => {
|
|
392
|
+
screen.getByTestId("c-send").click();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Give the fetch a microtask to kick off
|
|
396
|
+
await waitFor(() => {
|
|
397
|
+
expect(screen.getByTestId("c-isStreaming").textContent).toBe("true");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await act(async () => {
|
|
401
|
+
screen.getByTestId("c-stop").click();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
await waitFor(() => {
|
|
405
|
+
expect(screen.getByTestId("c-isStreaming").textContent).toBe("false");
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("view-remount telemetry pattern logs on unmount when streaming", async () => {
|
|
410
|
+
// Contract test for the `client.stream.view-remount` telemetry code.
|
|
411
|
+
// Mirrors the pattern ChatShell implements: track isStreaming in a ref,
|
|
412
|
+
// then log on unmount iff the ref was true. The ref is necessary because
|
|
413
|
+
// a stale closure would see isStreaming at effect-setup time, not at
|
|
414
|
+
// unmount time.
|
|
415
|
+
const consoleInfoSpy = vi
|
|
416
|
+
.spyOn(console, "info")
|
|
417
|
+
.mockImplementation(() => {});
|
|
418
|
+
|
|
419
|
+
function ViewRemountConsumer() {
|
|
420
|
+
const { isStreaming, activeId, sendMessage } = useChatSession();
|
|
421
|
+
const isStreamingRef = useRef(isStreaming);
|
|
422
|
+
const activeIdRef = useRef(activeId);
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
isStreamingRef.current = isStreaming;
|
|
425
|
+
}, [isStreaming]);
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
activeIdRef.current = activeId;
|
|
428
|
+
}, [activeId]);
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
return () => {
|
|
431
|
+
if (isStreamingRef.current) {
|
|
432
|
+
// eslint-disable-next-line no-console
|
|
433
|
+
console.info("[chat-stream] client.stream.view-remount", {
|
|
434
|
+
conversationId: activeIdRef.current,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
// Empty deps: run-once cleanup on unmount.
|
|
439
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
440
|
+
}, []);
|
|
441
|
+
return (
|
|
442
|
+
<div>
|
|
443
|
+
<div data-testid="vr-isStreaming">{String(isStreaming)}</div>
|
|
444
|
+
<button
|
|
445
|
+
data-testid="vr-send"
|
|
446
|
+
onClick={() => void sendMessage("hello")}
|
|
447
|
+
>
|
|
448
|
+
send
|
|
449
|
+
</button>
|
|
450
|
+
</div>
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function ViewRemountWrapper() {
|
|
455
|
+
const [mounted, setMounted] = useState(true);
|
|
456
|
+
return (
|
|
457
|
+
<ChatSessionProvider>
|
|
458
|
+
<button data-testid="vr-unmount" onClick={() => setMounted(false)}>
|
|
459
|
+
unmount
|
|
460
|
+
</button>
|
|
461
|
+
{mounted && <ViewRemountConsumer />}
|
|
462
|
+
</ChatSessionProvider>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const fetchMock = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
467
|
+
const u = url.toString();
|
|
468
|
+
if (u.startsWith("/api/settings/chat")) return new Response(null, { status: 204 });
|
|
469
|
+
if (u.startsWith("/api/chat/models")) return new Response(null, { status: 204 });
|
|
470
|
+
if (u === "/api/chat/conversations" || u.endsWith("/api/chat/conversations")) {
|
|
471
|
+
return new Response(
|
|
472
|
+
JSON.stringify({
|
|
473
|
+
id: "conv-vr",
|
|
474
|
+
projectId: null,
|
|
475
|
+
title: "T",
|
|
476
|
+
status: "active",
|
|
477
|
+
runtimeId: "claude-code",
|
|
478
|
+
modelId: "haiku",
|
|
479
|
+
createdAt: new Date().toISOString(),
|
|
480
|
+
updatedAt: new Date().toISOString(),
|
|
481
|
+
}),
|
|
482
|
+
{ status: 200 }
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
if (u.match(/\/api\/chat\/conversations\/conv-vr\/messages$/)) {
|
|
486
|
+
const signal = init?.signal as AbortSignal;
|
|
487
|
+
return new Response(makeHangingStream(signal), { status: 200 });
|
|
488
|
+
}
|
|
489
|
+
return new Response(null, { status: 404 });
|
|
490
|
+
});
|
|
491
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
492
|
+
|
|
493
|
+
render(<ViewRemountWrapper />);
|
|
494
|
+
|
|
495
|
+
await act(async () => {
|
|
496
|
+
screen.getByTestId("vr-send").click();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
await waitFor(() => {
|
|
500
|
+
expect(screen.getByTestId("vr-isStreaming").textContent).toBe("true");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// Unmount the consumer while streaming is in flight.
|
|
504
|
+
await act(async () => {
|
|
505
|
+
screen.getByTestId("vr-unmount").click();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
|
509
|
+
"[chat-stream] client.stream.view-remount",
|
|
510
|
+
expect.objectContaining({ conversationId: "conv-vr" })
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
consoleInfoSpy.mockRestore();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("view-remount telemetry pattern does NOT log when not streaming", async () => {
|
|
517
|
+
// Guard case: unmounting without an active stream must not emit.
|
|
518
|
+
const consoleInfoSpy = vi
|
|
519
|
+
.spyOn(console, "info")
|
|
520
|
+
.mockImplementation(() => {});
|
|
521
|
+
|
|
522
|
+
function ViewRemountConsumer() {
|
|
523
|
+
const { isStreaming } = useChatSession();
|
|
524
|
+
const isStreamingRef = useRef(isStreaming);
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
isStreamingRef.current = isStreaming;
|
|
527
|
+
}, [isStreaming]);
|
|
528
|
+
useEffect(() => {
|
|
529
|
+
return () => {
|
|
530
|
+
if (isStreamingRef.current) {
|
|
531
|
+
// eslint-disable-next-line no-console
|
|
532
|
+
console.info("[chat-stream] client.stream.view-remount", {
|
|
533
|
+
conversationId: null,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
538
|
+
}, []);
|
|
539
|
+
return <div />;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function Wrapper() {
|
|
543
|
+
const [mounted, setMounted] = useState(true);
|
|
544
|
+
return (
|
|
545
|
+
<ChatSessionProvider>
|
|
546
|
+
<button data-testid="toggle" onClick={() => setMounted(false)}>
|
|
547
|
+
toggle
|
|
548
|
+
</button>
|
|
549
|
+
{mounted && <ViewRemountConsumer />}
|
|
550
|
+
</ChatSessionProvider>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
vi.stubGlobal(
|
|
555
|
+
"fetch",
|
|
556
|
+
vi.fn(async () => new Response(null, { status: 204 }))
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
render(<Wrapper />);
|
|
560
|
+
|
|
561
|
+
await act(async () => {
|
|
562
|
+
screen.getByTestId("toggle").click();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const viewRemountCalls = consoleInfoSpy.mock.calls.filter(
|
|
566
|
+
([msg]) =>
|
|
567
|
+
typeof msg === "string" && msg.includes("client.stream.view-remount")
|
|
568
|
+
);
|
|
569
|
+
expect(viewRemountCalls).toHaveLength(0);
|
|
570
|
+
|
|
571
|
+
consoleInfoSpy.mockRestore();
|
|
572
|
+
});
|
|
573
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { Command, CommandList } from "@/components/ui/command";
|
|
4
|
+
import { SkillRow } from "../skill-row";
|
|
5
|
+
import type { EnrichedSkill } from "@/lib/environment/skill-enrichment";
|
|
6
|
+
|
|
7
|
+
const base: EnrichedSkill = {
|
|
8
|
+
id: "code-reviewer",
|
|
9
|
+
name: "code-reviewer",
|
|
10
|
+
tool: "claude-code",
|
|
11
|
+
scope: "user",
|
|
12
|
+
preview: "Review PRs for security",
|
|
13
|
+
sizeBytes: 100,
|
|
14
|
+
absPath: "/p",
|
|
15
|
+
absPaths: ["/p"],
|
|
16
|
+
healthScore: "healthy",
|
|
17
|
+
syncStatus: "synced",
|
|
18
|
+
linkedProfileId: "code-reviewer-profile",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// SkillRow uses CommandItem, which must live inside a cmdk Command/CommandList
|
|
22
|
+
function renderRow(skill: EnrichedSkill, recommended = false) {
|
|
23
|
+
return render(
|
|
24
|
+
<Command>
|
|
25
|
+
<CommandList>
|
|
26
|
+
<SkillRow skill={skill} recommended={recommended} onSelect={() => {}} />
|
|
27
|
+
</CommandList>
|
|
28
|
+
</Command>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("SkillRow", () => {
|
|
33
|
+
it("renders skill name and description", () => {
|
|
34
|
+
renderRow(base);
|
|
35
|
+
expect(screen.getByText("code-reviewer")).toBeInTheDocument();
|
|
36
|
+
expect(screen.getByText(/Review PRs/)).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("shows synced badge when syncStatus is synced", () => {
|
|
40
|
+
renderRow(base);
|
|
41
|
+
expect(screen.getByText(/synced/i)).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("shows profile linkage badge", () => {
|
|
45
|
+
renderRow(base);
|
|
46
|
+
expect(screen.getByText(/code-reviewer-profile/)).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("shows 'stale' badge for stale health", () => {
|
|
50
|
+
renderRow({ ...base, healthScore: "stale" });
|
|
51
|
+
expect(screen.getByText(/stale/i)).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("shows a recommended indicator when recommended=true", () => {
|
|
55
|
+
renderRow(base, true);
|
|
56
|
+
expect(screen.getByLabelText(/recommended/i)).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("calls onDismissRecommendation when X is clicked", () => {
|
|
60
|
+
const onDismiss = vi.fn();
|
|
61
|
+
render(
|
|
62
|
+
<Command>
|
|
63
|
+
<CommandList>
|
|
64
|
+
<SkillRow
|
|
65
|
+
skill={base}
|
|
66
|
+
recommended
|
|
67
|
+
onSelect={() => {}}
|
|
68
|
+
onDismissRecommendation={onDismiss}
|
|
69
|
+
/>
|
|
70
|
+
</CommandList>
|
|
71
|
+
</Command>
|
|
72
|
+
);
|
|
73
|
+
fireEvent.click(screen.getByLabelText("Dismiss recommendation"));
|
|
74
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not render dismiss button when not recommended", () => {
|
|
78
|
+
render(
|
|
79
|
+
<Command>
|
|
80
|
+
<CommandList>
|
|
81
|
+
<SkillRow
|
|
82
|
+
skill={base}
|
|
83
|
+
onSelect={() => {}}
|
|
84
|
+
onDismissRecommendation={() => {}}
|
|
85
|
+
/>
|
|
86
|
+
</CommandList>
|
|
87
|
+
</Command>
|
|
88
|
+
);
|
|
89
|
+
expect(screen.queryByLabelText("Dismiss recommendation")).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|