@taskforcehq/taskforce 0.3.303 → 0.3.304

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/Taskforce.module.css +102 -5
  2. package/dist/TaskforceCore.js +514 -212
  3. package/dist/TaskforceCore.test.js +1172 -133
  4. package/dist/annotatedAttachments/service.d.ts +1 -1
  5. package/dist/annotatedAttachments/types.d.ts +1 -1
  6. package/dist/components/features/AgentsModule.js +1 -1
  7. package/dist/components/features/AgentsModule.test.js +1 -1
  8. package/dist/components/features/TaskSettings.js +58 -38
  9. package/dist/components/features/TaskSettings.test.js +139 -40
  10. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  11. package/dist/components/task/TaskActionHeader.js +3 -3
  12. package/dist/components/task/TaskActionHeader.test.js +8 -0
  13. package/dist/components/task/TaskCard.js +16 -1
  14. package/dist/components/task/TaskCard.test.js +31 -0
  15. package/dist/components/task/TaskForm.d.ts +2 -1
  16. package/dist/components/task/TaskForm.js +22 -4
  17. package/dist/components/task/TaskForm.test.js +46 -0
  18. package/dist/components/task/TaskKanban.d.ts +1 -1
  19. package/dist/components/task/TaskKanban.js +31 -160
  20. package/dist/components/task/TaskStatusActions.d.ts +6 -1
  21. package/dist/components/task/TaskStatusActions.js +8 -3
  22. package/dist/components/task/TaskStatusActions.test.js +9 -0
  23. package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
  24. package/dist/components/task/taskKanbanDropRules.js +187 -0
  25. package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
  26. package/dist/components/task/taskKanbanDropRules.test.js +114 -0
  27. package/dist/components/ui/Modal.d.ts +1 -1
  28. package/dist/components/ui/Modal.js +1 -0
  29. package/dist/components/views/AppShellHeader.js +5 -1
  30. package/dist/components/views/AppShellHeader.test.js +4 -0
  31. package/dist/components/views/PlanComparisonPage.d.ts +3 -2
  32. package/dist/components/views/PlanComparisonPage.js +4 -2
  33. package/dist/components/views/PlanComparisonPage.test.js +4 -0
  34. package/dist/components/views/PlansPage.d.ts +5 -1
  35. package/dist/components/views/PlansPage.js +39 -12
  36. package/dist/components/views/PlansPage.test.js +59 -2
  37. package/dist/components/views/StandaloneLayout.js +113 -38
  38. package/dist/components/views/WidgetView.js +11 -2
  39. package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
  40. package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
  41. package/dist/core/PlanEntitlementService.d.ts +3 -1
  42. package/dist/core/PlanEntitlementService.js +61 -11
  43. package/dist/core/SignupMonetizationSettings.test.js +37 -2
  44. package/dist/core/TaskActivityService.d.ts +4 -1
  45. package/dist/core/TaskActivityService.js +45 -0
  46. package/dist/core/TaskAuditTimeline.test.js +52 -1
  47. package/dist/core/Taskforce.d.ts +15 -11
  48. package/dist/core/Taskforce.js +43 -2
  49. package/dist/core/WorkspacePlanMode.test.js +65 -0
  50. package/dist/documentReviews/service.d.ts +1 -1
  51. package/dist/documentReviews/types.d.ts +1 -1
  52. package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
  53. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
  54. package/dist/hooks/sync/auth.d.ts +37 -0
  55. package/dist/hooks/sync/auth.js +61 -0
  56. package/dist/hooks/sync/bootstrap.d.ts +34 -0
  57. package/dist/hooks/sync/bootstrap.js +52 -0
  58. package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
  59. package/dist/hooks/sync/orchestratorShared.js +209 -0
  60. package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
  61. package/dist/hooks/sync/orchestratorShared.test.js +49 -0
  62. package/dist/hooks/sync/realtime.d.ts +25 -0
  63. package/dist/hooks/sync/realtime.js +60 -0
  64. package/dist/hooks/sync/recovery.d.ts +69 -0
  65. package/dist/hooks/sync/recovery.js +118 -0
  66. package/dist/hooks/sync/transfers.d.ts +122 -0
  67. package/dist/hooks/sync/transfers.js +431 -0
  68. package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
  69. package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
  70. package/dist/hooks/ui/useResourceExport.d.ts +3 -1
  71. package/dist/hooks/ui/useResourceExport.js +5 -2
  72. package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
  73. package/dist/hooks/useRealtimeSync.js +2 -1
  74. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
  75. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
  76. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
  77. package/dist/hooks/useSyncOrchestrator.d.ts +5 -4
  78. package/dist/hooks/useSyncOrchestrator.js +314 -873
  79. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +568 -3
  80. package/dist/hooks/useTaskMutations.js +45 -6
  81. package/dist/hooks/useTaskMutations.test.js +147 -0
  82. package/dist/hooks/useTaskforce.d.ts +8 -2
  83. package/dist/hooks/useTaskforce.js +285 -812
  84. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  85. package/dist/hooks/useWorkspaceSyncController.js +22 -10
  86. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
  87. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
  88. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
  89. package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
  90. package/dist/hooks/workspace/workspaceLocalState.js +100 -0
  91. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
  92. package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
  93. package/dist/localization/locales/en-US.js +2 -2
  94. package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
  95. package/dist/mcp/canonicalAssetHelpers.js +259 -0
  96. package/dist/mcp/clientIntegrations.d.ts +12 -1
  97. package/dist/mcp/clientIntegrations.js +161 -6
  98. package/dist/mcp/clientIntegrations.test.js +76 -4
  99. package/dist/mcp/documentHelpers.d.ts +100 -0
  100. package/dist/mcp/documentHelpers.js +161 -0
  101. package/dist/mcp/runtime.js +174 -476
  102. package/dist/mcp/runtime.test.js +81 -8
  103. package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
  104. package/dist/mcp/taskAttachmentHelpers.js +60 -0
  105. package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
  106. package/dist/mcp/taskAttachmentTypes.js +1 -0
  107. package/dist/plugins/vite.js +1 -1
  108. package/dist/runtime/appGateDefinitions.d.ts +1 -1
  109. package/dist/runtime/appGateDefinitions.js +1 -1
  110. package/dist/server/index.d.ts +1 -0
  111. package/dist/server/index.js +46 -3
  112. package/dist/server/index.test.js +12 -1
  113. package/dist/server/routes/admin.js +13 -19
  114. package/dist/server/routes/auth.js +37 -2
  115. package/dist/server/routes/billing.js +18 -67
  116. package/dist/server/routes/billing.test.js +43 -0
  117. package/dist/server/routes/shared.d.ts +30 -0
  118. package/dist/server/routes/shared.js +26 -0
  119. package/dist/server/routes/sync.integration.test.js +20 -0
  120. package/dist/server/routes/sync.js +110 -237
  121. package/dist/server/routes/tasks.js +49 -7
  122. package/dist/server/routes.js +8 -19
  123. package/dist/server/routes.test.js +243 -5
  124. package/dist/shared/taskActor.d.ts +10 -0
  125. package/dist/shared/taskActor.js +1 -0
  126. package/dist/sync/collaborationSyncPayload.js +4 -0
  127. package/dist/sync/collaborationSyncPayload.test.js +8 -0
  128. package/dist/sync/collaborationSyncState.d.ts +3 -1
  129. package/dist/sync/collaborationSyncState.js +19 -0
  130. package/dist/sync/contentSyncState.js +12 -10
  131. package/dist/sync/syncApplyHandlers.d.ts +87 -1
  132. package/dist/sync/syncApplyHandlers.js +370 -8
  133. package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
  134. package/dist/sync/syncApplyHandlers.test.js +377 -0
  135. package/dist/sync/syncService.d.ts +8 -1
  136. package/dist/sync/syncService.js +36 -2
  137. package/dist/sync/syncService.test.js +48 -0
  138. package/dist/sync/workspacePullFeed.d.ts +1 -0
  139. package/dist/sync/workspacePullFeed.js +66 -59
  140. package/dist/sync/workspacePullFeed.test.js +72 -4
  141. package/dist/sync/workspaceSyncModel.d.ts +22 -1
  142. package/dist/sync/workspaceSyncModel.js +65 -0
  143. package/dist/sync/workspaceSyncModel.test.js +119 -0
  144. package/dist/types.d.ts +2 -10
  145. package/dist/ui/assets/AgentsModule-BLWVbuKP.js +1 -0
  146. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-C_TmXZwA.js → AnnotatedAttachmentWorkspace-BlS-cqnl.js} +1 -1
  147. package/dist/ui/assets/{DocumentWorkspace-C3GyzrWm.js → DocumentWorkspace-BH_6DF6x.js} +1 -1
  148. package/dist/ui/assets/{InitiativesModule-4-Rh2K2n.js → InitiativesModule-BjR6iBf9.js} +1 -1
  149. package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
  150. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +1 -0
  151. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +9 -0
  152. package/dist/ui/assets/{WorkflowsModule-CGk9s3NJ.js → WorkflowsModule-DnFqdUME.js} +1 -1
  153. package/dist/ui/assets/index-DXUdtH1B.js +6 -0
  154. package/dist/ui/assets/index-Ii0B0rTO.css +1 -0
  155. package/dist/ui/assets/{vendor-icons-pWocEipT.js → vendor-icons-I7ZwG1z_.js} +1 -1
  156. package/dist/ui/index.html +4 -4
  157. package/dist/utils/taskActivity.js +4 -0
  158. package/dist/utils/taskActivity.test.js +25 -1
  159. package/package.json +3 -1
  160. package/scripts/playwright-auth.sh +4 -0
  161. package/dist/ui/assets/AgentsModule-CpsTmrIW.js +0 -1
  162. package/dist/ui/assets/PlansPage-BP7AYOqr.js +0 -1
  163. package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +0 -8
  164. package/dist/ui/assets/index-CLIMgR6g.js +0 -6
  165. package/dist/ui/assets/index-DneETxcu.css +0 -1
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildBacklogDropUpdates, buildScheduleDropResult, buildScheduledDayDropUpdates, buildStatusTransitionUpdates, } from './taskKanbanDropRules';
3
+ const makeTask = (overrides = {}) => ({
4
+ id: 'task-1',
5
+ title: 'Task',
6
+ description: null,
7
+ category: 'default',
8
+ type: 'feature',
9
+ priority: 2,
10
+ status: 'task',
11
+ createdAt: '2026-01-01T00:00:00.000Z',
12
+ ...overrides,
13
+ });
14
+ const toMap = (pairs) => new Map(pairs.map((pair) => [pair.taskId, pair.updates]));
15
+ describe('taskKanbanDropRules', () => {
16
+ it('reorders backlog tasks and recalculates orderInDay', () => {
17
+ const activeTask = makeTask({ id: 'task-2' });
18
+ const result = toMap(buildBacklogDropUpdates({
19
+ activeTask,
20
+ sourceColumnId: 'backlog',
21
+ targetTasks: [makeTask({ id: 'task-1' }), makeTask({ id: 'task-3' })],
22
+ sourceTasks: [makeTask({ id: 'task-1' }), makeTask({ id: 'task-3' })],
23
+ insertAt: 1,
24
+ }));
25
+ expect(result.get('task-1')).toEqual({ orderInDay: 0 });
26
+ expect(result.get('task-2')).toEqual({ orderInDay: 1 });
27
+ expect(result.get('task-3')).toEqual({ orderInDay: 2 });
28
+ });
29
+ it('moving scheduled task to backlog clears scheduled fields', () => {
30
+ const activeTask = makeTask({ id: 'task-2', scheduledDate: '2026-04-14', scheduledWeekKey: '2026-W16' });
31
+ const result = toMap(buildBacklogDropUpdates({
32
+ activeTask,
33
+ sourceColumnId: 'mon',
34
+ targetTasks: [makeTask({ id: 'task-1' })],
35
+ sourceTasks: [],
36
+ insertAt: 1,
37
+ }));
38
+ expect(result.get('task-2')).toEqual({
39
+ scheduledDate: null,
40
+ scheduledWeekKey: null,
41
+ orderInDay: 1,
42
+ });
43
+ });
44
+ it('moving task into a scheduled day sets scheduled fields and reindexes source column', () => {
45
+ const activeTask = makeTask({ id: 'task-3' });
46
+ const result = toMap(buildScheduledDayDropUpdates({
47
+ activeTask,
48
+ sourceColumnId: 'backlog',
49
+ targetColumnId: 'mon',
50
+ targetTasks: [makeTask({ id: 'task-1' })],
51
+ sourceTasks: [makeTask({ id: 'task-2' })],
52
+ insertAt: 1,
53
+ scheduledDate: '2026-04-14',
54
+ scheduledWeekKey: '2026-W16',
55
+ }));
56
+ expect(result.get('task-3')).toEqual({
57
+ scheduledDate: '2026-04-14',
58
+ scheduledWeekKey: '2026-W16',
59
+ orderInDay: 1,
60
+ });
61
+ expect(result.get('task-2')).toEqual({ orderInDay: 0 });
62
+ });
63
+ it('blocks non-terminal tasks from being scheduled into the past', () => {
64
+ const result = buildScheduleDropResult({
65
+ activeTask: makeTask({ id: 'task-3', status: 'task' }),
66
+ sourceColumnId: 'backlog',
67
+ targetColumnId: 'mon',
68
+ targetTasks: [],
69
+ sourceTasks: [],
70
+ overTaskId: null,
71
+ isBelowOverItem: false,
72
+ scheduleDates: {
73
+ mon: '2026-04-13',
74
+ tue: '2026-04-14',
75
+ wed: '2026-04-15',
76
+ thu: '2026-04-16',
77
+ fri: '2026-04-17',
78
+ sat: '2026-04-18',
79
+ sun: '2026-04-19',
80
+ },
81
+ parseToIsoWeekKey: () => '2026-W16',
82
+ todayDate: '2026-04-14',
83
+ });
84
+ expect(result).toEqual({ kind: 'blocked', reason: 'Only completed work can be scheduled in the past.' });
85
+ });
86
+ it('preserves cancelled grouping as a no-op for completed column drops', () => {
87
+ const task = makeTask({ id: 'task-cancelled', status: 'cancelled', completedAt: '2026-04-01T00:00:00.000Z' });
88
+ expect(buildStatusTransitionUpdates(task, 'completed', '2026-04-14T07:00:00.000Z')).toBeNull();
89
+ });
90
+ it('stamps completedAt when moving into completed', () => {
91
+ const nowIso = '2026-04-14T07:00:00.000Z';
92
+ const result = buildStatusTransitionUpdates(makeTask({ id: 'task-done' }), 'completed', nowIso);
93
+ expect(result).toEqual({
94
+ status: 'done',
95
+ inProgress: false,
96
+ readyForReview: false,
97
+ completed: true,
98
+ cancelled: false,
99
+ completedAt: nowIso,
100
+ });
101
+ });
102
+ it('resets review and progress flags when moving back to reviewable workflow states', () => {
103
+ const nowIso = '2026-04-14T07:00:00.000Z';
104
+ const result = buildStatusTransitionUpdates(makeTask({ id: 'task-review', status: 'done', inProgress: true, readyForReview: true, completed: true }), 'review', nowIso);
105
+ expect(result).toEqual({
106
+ status: 'review',
107
+ inProgress: false,
108
+ readyForReview: true,
109
+ completed: false,
110
+ cancelled: false,
111
+ completedAt: null,
112
+ });
113
+ });
114
+ });
@@ -5,7 +5,7 @@ interface ModalProps {
5
5
  title: React.ReactNode;
6
6
  children: React.ReactNode;
7
7
  footer?: React.ReactNode;
8
- size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
8
+ size?: 'sm' | 'md' | 'mdWide' | 'lg' | 'xl' | 'full';
9
9
  theme?: 'light' | 'dawn' | 'dark' | 'midnight';
10
10
  className?: string;
11
11
  headerActions?: React.ReactNode;
@@ -36,6 +36,7 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md', t
36
36
  const sizeClassMap = {
37
37
  sm: styles.modalSizeSm,
38
38
  md: styles.modalSizeMd,
39
+ mdWide: styles.modalSizeMdWide,
39
40
  lg: styles.modalSizeLg,
40
41
  xl: styles.modalSizeXl,
41
42
  full: styles.modalSizeFull
@@ -7,6 +7,10 @@ import favicon from '../../assets/favicon/favicon.svg';
7
7
  import { DEFAULT_TASKFORCE_THEME, isLightThemeFamily } from '../../utils/theme';
8
8
  export function AppShellHeader({ projectName, currentWorkspaceId, brandLabel = 'TaskForce', runtimeMode = 'local', theme = DEFAULT_TASKFORCE_THEME, meta, actions }) {
9
9
  const logo = isLightThemeFamily(theme) ? logoDark : logoLight;
10
+ const normalizedProjectName = String(projectName || '').trim();
11
+ const normalizedWorkspaceId = String(currentWorkspaceId || '').trim().toLowerCase();
12
+ const showProjectName = Boolean(normalizedProjectName
13
+ && (runtimeMode !== 'cloud' || (normalizedWorkspaceId && normalizedWorkspaceId !== 'default')));
10
14
  return (_jsxs("div", { className: `${styles.header} ${shellStyles.standaloneHeader}`, children: [_jsxs("div", { className: `${styles.headerTitle} ${shellStyles.standaloneTitle}`, children: [_jsx("img", { src: logo, alt: "Taskforce Logo", className: styles.brandIcon, onError: (event) => {
11
15
  const target = event.currentTarget;
12
16
  if (target.src !== favicon) {
@@ -15,5 +19,5 @@ export function AppShellHeader({ projectName, currentWorkspaceId, brandLabel = '
15
19
  else {
16
20
  target.style.display = 'none';
17
21
  }
18
- } }), _jsx("span", { children: brandLabel }), runtimeMode === 'cloud' && (_jsx("span", { className: styles.brandCloudSuffix, children: "HQ" })), projectName && (_jsxs(_Fragment, { children: [_jsx("span", { className: styles.projectSlash, children: "/" }), _jsx("span", { className: styles.projectName, title: currentWorkspaceId ? `Workspace ID: ${currentWorkspaceId}` : undefined, children: projectName })] })), meta] }), _jsx("div", { className: styles.headerActions, style: { gap: '16px' }, children: actions })] }));
22
+ } }), _jsx("span", { children: brandLabel }), runtimeMode === 'cloud' && (_jsx("span", { className: styles.brandCloudSuffix, children: "HQ" })), showProjectName && (_jsxs(_Fragment, { children: [_jsx("span", { className: styles.projectSlash, children: "/" }), _jsx("span", { className: styles.projectName, title: currentWorkspaceId ? `Workspace ID: ${currentWorkspaceId}` : undefined, children: normalizedProjectName })] })), meta] }), _jsx("div", { className: styles.headerActions, style: { gap: '16px' }, children: actions })] }));
19
23
  }
@@ -18,4 +18,8 @@ describe('AppShellHeader', () => {
18
18
  render(_jsx(AppShellHeader, { runtimeMode: "local", theme: "dawn" }));
19
19
  expect(screen.getByAltText('Taskforce Logo')).toHaveAttribute('src', logoDark);
20
20
  });
21
+ it('hides a stale project label while cloud runtime is still on the default workspace', () => {
22
+ render(_jsx(AppShellHeader, { runtimeMode: "cloud", projectName: "Old Workspace", currentWorkspaceId: "default" }));
23
+ expect(screen.queryByText('Old Workspace')).not.toBeInTheDocument();
24
+ });
21
25
  });
@@ -58,16 +58,17 @@ interface PlanComparisonPageProps {
58
58
  canManageCurrentPlan?: boolean;
59
59
  pricingEndpoint?: string;
60
60
  statusBanner?: {
61
- type: 'success' | 'error';
61
+ type: 'success' | 'error' | 'info';
62
62
  message: string;
63
63
  } | null;
64
64
  onBack: () => void;
65
65
  backLabel: string;
66
+ backDisabled?: boolean;
66
67
  topActions?: React.ReactNode;
67
68
  shellOwnsScroll?: boolean;
68
69
  showPageBackButton?: boolean;
69
70
  fallbackPricingSource?: PricingSourceDetails | null;
70
71
  onSelectPlan: (intent: PlanSelectionIntent) => void | Promise<void>;
71
72
  }
72
- export declare function PlanComparisonPage({ heading, subtitle, variant, chrome, footer, isAuthenticated, authSessionResolved, currentPlanId, currentPlanVersionId, currentEntitlementState, canManageCurrentPlan, pricingEndpoint, statusBanner, onBack, backLabel, topActions, shellOwnsScroll, showPageBackButton, fallbackPricingSource, onSelectPlan }: PlanComparisonPageProps): import("react/jsx-runtime").JSX.Element;
73
+ export declare function PlanComparisonPage({ heading, subtitle, variant, chrome, footer, isAuthenticated, authSessionResolved, currentPlanId, currentPlanVersionId, currentEntitlementState, canManageCurrentPlan, pricingEndpoint, statusBanner, onBack, backLabel, backDisabled, topActions, shellOwnsScroll, showPageBackButton, fallbackPricingSource, onSelectPlan }: PlanComparisonPageProps): import("react/jsx-runtime").JSX.Element;
73
74
  export {};
@@ -64,7 +64,7 @@ function isActiveEntitlementState(raw) {
64
64
  const value = String(raw || '').trim().toLowerCase();
65
65
  return value === 'active' || value === 'trialing' || value === 'grace';
66
66
  }
67
- export function PlanComparisonPage({ heading, subtitle, variant = 'app', chrome, footer, isAuthenticated, authSessionResolved = true, currentPlanId, currentPlanVersionId, currentEntitlementState, canManageCurrentPlan = true, pricingEndpoint, statusBanner, onBack, backLabel, topActions, shellOwnsScroll = false, showPageBackButton = true, fallbackPricingSource = null, onSelectPlan }) {
67
+ export function PlanComparisonPage({ heading, subtitle, variant = 'app', chrome, footer, isAuthenticated, authSessionResolved = true, currentPlanId, currentPlanVersionId, currentEntitlementState, canManageCurrentPlan = true, pricingEndpoint, statusBanner, onBack, backLabel, backDisabled = false, topActions, shellOwnsScroll = false, showPageBackButton = true, fallbackPricingSource = null, onSelectPlan }) {
68
68
  const [plans, setPlans] = useState([]);
69
69
  const [pricingSource, setPricingSource] = useState(null);
70
70
  const [loading, setLoading] = useState(true);
@@ -130,7 +130,9 @@ export function PlanComparisonPage({ heading, subtitle, variant = 'app', chrome,
130
130
  if (error) {
131
131
  return wrapPage(_jsx(_Fragment, { children: _jsx("div", { className: contentClassName, children: _jsx("div", { className: styles.contentFrame, children: _jsxs("div", { className: styles.emptyState, children: [_jsx("h2", { children: "Connecting to plan pricing..." }), _jsx("p", { children: error }), _jsx("button", { className: styles.backBtn, onClick: () => setRetryNonce((value) => value + 1), children: "Retry" })] }) }) }) }));
132
132
  }
133
- return wrapPage(_jsx(_Fragment, { children: _jsx("div", { className: contentClassName, children: _jsxs("div", { className: styles.contentFrame, children: [_jsxs("div", { className: styles.header, children: [!chrome && (showPageBackButton || topActions) && (_jsxs("div", { className: styles.headerBar, children: [_jsx("div", { children: showPageBackButton ? (_jsx("button", { className: styles.backBtn, onClick: onBack, children: backLabel })) : null }), _jsx("div", { className: styles.headerActions, children: topActions })] })), _jsx("h1", { className: styles.title, children: heading }), subtitle && _jsx("p", { className: styles.subtitle, children: subtitle }), isDebugModeEnabled() && pricingSourceLabel ? (_jsxs("p", { className: styles.debugMeta, children: ["Pricing source: ", pricingSourceLabel] })) : null, statusBanner && (_jsx("div", { className: `${styles.statusBanner} ${statusBanner.type === 'success' ? styles.successBanner : styles.errorBanner}`, children: statusBanner.message }))] }), sortedPlans.length === 0 ? (_jsxs("div", { className: `${styles.emptyState} ${styles.marketingEmptyState}`, children: [_jsx("span", { className: styles.emptyBadge, children: "Coming Soon" }), _jsx("h2", { children: "Paid plans are getting their final polish." }), _jsx("p", { children: "Taskforce pricing is on the way with flexible options for solo operators, teams, and larger rollouts. Check back soon for launch tiers, feature bundles, and early access details." }), _jsxs("div", { className: styles.emptyHighlights, "aria-label": "Upcoming pricing highlights", children: [_jsx("span", { children: "Launch-ready tiers" }), _jsx("span", { children: "Team billing controls" }), _jsx("span", { children: "Feature-based packaging" })] })] })) : (_jsx("div", { className: styles.grid, children: sortedPlans.map((plan) => {
133
+ return wrapPage(_jsx(_Fragment, { children: _jsx("div", { className: contentClassName, children: _jsxs("div", { className: styles.contentFrame, children: [_jsxs("div", { className: styles.header, children: [!chrome && (showPageBackButton || topActions) && (_jsxs("div", { className: styles.headerBar, children: [_jsx("div", { children: showPageBackButton ? (_jsx("button", { className: styles.backBtn, onClick: onBack, disabled: backDisabled, children: backLabel })) : null }), _jsx("div", { className: styles.headerActions, children: topActions })] })), _jsx("h1", { className: styles.title, children: heading }), subtitle && _jsx("p", { className: styles.subtitle, children: subtitle }), isDebugModeEnabled() && pricingSourceLabel ? (_jsxs("p", { className: styles.debugMeta, children: ["Pricing source: ", pricingSourceLabel] })) : null, statusBanner && (_jsx("div", { className: `${styles.statusBanner} ${statusBanner.type === 'success'
134
+ ? styles.successBanner
135
+ : (statusBanner.type === 'info' ? styles.infoBanner : styles.errorBanner)}`, children: statusBanner.message }))] }), sortedPlans.length === 0 ? (_jsxs("div", { className: `${styles.emptyState} ${styles.marketingEmptyState}`, children: [_jsx("span", { className: styles.emptyBadge, children: "Coming Soon" }), _jsx("h2", { children: "Paid plans are getting their final polish." }), _jsx("p", { children: "Taskforce pricing is on the way with flexible options for solo operators, teams, and larger rollouts. Check back soon for launch tiers, feature bundles, and early access details." }), _jsxs("div", { className: styles.emptyHighlights, "aria-label": "Upcoming pricing highlights", children: [_jsx("span", { children: "Launch-ready tiers" }), _jsx("span", { children: "Team billing controls" }), _jsx("span", { children: "Feature-based packaging" })] })] })) : (_jsx("div", { className: styles.grid, children: sortedPlans.map((plan) => {
134
136
  const monthPrice = plan.pricing.month;
135
137
  const yearPrice = plan.pricing.year;
136
138
  const hasMonth = isSelectablePrice(monthPrice);
@@ -97,6 +97,10 @@ describe('PlanComparisonPage', () => {
97
97
  expect(await screen.findByRole('heading', { name: 'Subscription Plans' })).toBeInTheDocument();
98
98
  expect(screen.queryByText('Current Plan')).not.toBeInTheDocument();
99
99
  });
100
+ it('can disable the page back button while onboarding remains blocked', async () => {
101
+ render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: true, onBack: () => { }, backLabel: "Back to App", backDisabled: true, onSelectPlan: () => { } }));
102
+ expect(await screen.findByRole('button', { name: 'Back to App' })).toBeDisabled();
103
+ });
100
104
  it('selects the whole plan card using the default available interval', async () => {
101
105
  const onSelectPlan = vi.fn();
102
106
  render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: onSelectPlan }));
@@ -1,3 +1,4 @@
1
+ import { type AccountProfileSummary } from '../../utils/accountProfileSummaryCache';
1
2
  interface PlansPageProps {
2
3
  isAuthenticated: boolean;
3
4
  authUserId?: string | null;
@@ -11,6 +12,9 @@ interface PlansPageProps {
11
12
  resolveCloudAuthUrl?: (path: string) => string;
12
13
  embedded?: boolean;
13
14
  shellOwnsScroll?: boolean;
15
+ onAccountProfileSummaryChange?: (summary: AccountProfileSummary | null) => void;
16
+ onContinueToTaskforce?: () => void | Promise<void>;
17
+ continueBusy?: boolean;
14
18
  }
15
- export declare function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessionResolved, currentTheme, projectName, currentWorkspaceId, apiBaseUrl, connectedEnvironmentSource, resolveCloudAuthUrl, embedded, shellOwnsScroll }: PlansPageProps): import("react/jsx-runtime").JSX.Element;
19
+ export declare function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessionResolved, currentTheme, projectName, currentWorkspaceId, apiBaseUrl, connectedEnvironmentSource, resolveCloudAuthUrl, embedded, shellOwnsScroll, onAccountProfileSummaryChange, onContinueToTaskforce, continueBusy }: PlansPageProps): import("react/jsx-runtime").JSX.Element;
16
20
  export {};
@@ -38,7 +38,7 @@ function buildEmbeddedPlansPath(params = {}) {
38
38
  }
39
39
  return `/?${query.toString()}`;
40
40
  }
41
- export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessionResolved, currentTheme = 'dark', projectName, currentWorkspaceId, apiBaseUrl, connectedEnvironmentSource, resolveCloudAuthUrl, embedded = false, shellOwnsScroll = false }) {
41
+ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessionResolved, currentTheme = 'dark', projectName, currentWorkspaceId, apiBaseUrl, connectedEnvironmentSource, resolveCloudAuthUrl, embedded = false, shellOwnsScroll = false, onAccountProfileSummaryChange, onContinueToTaskforce, continueBusy = false }) {
42
42
  const navigate = useNavigate();
43
43
  const location = useLocation();
44
44
  const [accountProfileSummary, setAccountProfileSummary] = useState(null);
@@ -148,13 +148,14 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
148
148
  force: options?.force === true
149
149
  });
150
150
  setAccountProfileSummary(payload);
151
+ onAccountProfileSummaryChange?.(payload);
151
152
  return payload;
152
153
  }
153
154
  catch {
154
155
  // best effort only
155
156
  return null;
156
157
  }
157
- }, [authUserId, buildCloudBillingUrl, isAuthenticated]);
158
+ }, [authUserId, buildCloudBillingUrl, isAuthenticated, onAccountProfileSummaryChange]);
158
159
  const resolveRecoveryMessage = useCallback((summary, fallback) => {
159
160
  const billingConflictCode = String(summary?.billingConflictCode || '').trim().toUpperCase();
160
161
  if (billingConflictCode === 'CHECKOUT_SESSION_EXPIRED')
@@ -166,11 +167,20 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
166
167
  return summaryMessage;
167
168
  return String(fallback || 'Unable to confirm checkout completion.').trim() || 'Unable to confirm checkout completion.';
168
169
  }, []);
170
+ const resolveGateFeedbackType = useCallback((message) => {
171
+ const normalized = String(message || '').trim();
172
+ if (normalized === GATE_MESSAGES.planSelectionRequired
173
+ || normalized === GATE_MESSAGES.checkoutPending) {
174
+ return 'info';
175
+ }
176
+ return 'error';
177
+ }, []);
169
178
  const startCheckout = useCallback(async (planVersionId, interval) => {
170
179
  if (!planVersionId) {
171
180
  setFeedback({ type: 'error', message: 'Selected plan is missing a default plan version.' });
172
181
  return;
173
182
  }
183
+ const hadActiveEntitlement = hasResolvedActiveEntitlement;
174
184
  setActionBusy(true);
175
185
  setFeedback(null);
176
186
  try {
@@ -189,13 +199,21 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
189
199
  if (checkoutState === 'free') {
190
200
  setFeedback({ type: 'success', message: 'Plan selected. Your access is ready.' });
191
201
  invalidateAccountProfileSummaryCache(buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH), { identityKey: authUserId });
192
- await loadAccountProfileSummary();
202
+ const summary = await loadAccountProfileSummary({ force: true });
203
+ const summaryState = String(summary?.entitlementState || '').trim().toLowerCase();
204
+ if (!hadActiveEntitlement && ACTIVE_ENTITLEMENT_STATES.has(summaryState)) {
205
+ await onContinueToTaskforce?.();
206
+ }
193
207
  return;
194
208
  }
195
209
  if (checkoutState === 'updated') {
196
210
  setFeedback({ type: 'success', message: String(payload?.message || 'Your subscription was updated successfully.') });
197
211
  invalidateAccountProfileSummaryCache(buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH), { identityKey: authUserId });
198
- await loadAccountProfileSummary();
212
+ const summary = await loadAccountProfileSummary({ force: true });
213
+ const summaryState = String(summary?.entitlementState || '').trim().toLowerCase();
214
+ if (!hadActiveEntitlement && ACTIVE_ENTITLEMENT_STATES.has(summaryState)) {
215
+ await onContinueToTaskforce?.();
216
+ }
199
217
  return;
200
218
  }
201
219
  if (checkoutState === 'already_current_plan') {
@@ -235,7 +253,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
235
253
  finally {
236
254
  setActionBusy(false);
237
255
  }
238
- }, [authUserId, buildCloudBillingUrl, loadAccountProfileSummary, runtimeMode]);
256
+ }, [authUserId, buildCloudBillingUrl, hasResolvedActiveEntitlement, loadAccountProfileSummary, onContinueToTaskforce, runtimeMode]);
239
257
  const confirmCheckoutSession = useCallback(async (sessionId) => {
240
258
  if (!sessionId) {
241
259
  return {
@@ -258,7 +276,11 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
258
276
  };
259
277
  }
260
278
  invalidateAccountProfileSummaryCache(buildCloudBillingUrl(ACCOUNT_PROFILE_SUMMARY_PATH), { identityKey: authUserId });
261
- await loadAccountProfileSummary();
279
+ const summary = await loadAccountProfileSummary({ force: true });
280
+ const summaryState = String(summary?.entitlementState || '').trim().toLowerCase();
281
+ if (ACTIVE_ENTITLEMENT_STATES.has(summaryState)) {
282
+ await onContinueToTaskforce?.();
283
+ }
262
284
  return { confirmed: true };
263
285
  }
264
286
  catch {
@@ -267,7 +289,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
267
289
  errorMessage: 'Unable to confirm checkout completion.'
268
290
  };
269
291
  }
270
- }, [authUserId, buildCloudBillingUrl, loadAccountProfileSummary]);
292
+ }, [authUserId, buildCloudBillingUrl, loadAccountProfileSummary, onContinueToTaskforce]);
271
293
  const openBillingPortal = useCallback(async () => {
272
294
  if (!isAuthenticated) {
273
295
  navigate(`/login?mode=login&next=${encodeURIComponent(buildEmbeddedPlansPath())}`);
@@ -353,7 +375,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
353
375
  return;
354
376
  }
355
377
  if (gateMessage) {
356
- setFeedback({ type: 'error', message: gateMessage });
378
+ setFeedback({ type: resolveGateFeedbackType(gateMessage), message: gateMessage });
357
379
  return;
358
380
  }
359
381
  setFeedback((current) => {
@@ -371,7 +393,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
371
393
  return null;
372
394
  return current;
373
395
  });
374
- }, [checkoutState, confirmCheckoutSession, gateMessage, isAuthenticated, loadAccountProfileSummary, queryInterval, queryPlanVersionId, querySessionId, resolveRecoveryMessage, scheduleTransientQueryClear, startCheckout]);
396
+ }, [checkoutState, confirmCheckoutSession, gateMessage, isAuthenticated, loadAccountProfileSummary, queryInterval, queryPlanVersionId, querySessionId, resolveGateFeedbackType, resolveRecoveryMessage, scheduleTransientQueryClear, startCheckout]);
375
397
  const handleSelectPlan = useCallback(async (intent) => {
376
398
  if (!isAuthenticated) {
377
399
  const nextCheckoutState = intent.pricingType === 'free' ? 'free' : 'start';
@@ -399,6 +421,10 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
399
421
  await startCheckout(intent.planVersionId, intent.interval);
400
422
  }, [isAuthenticated, navigate, openBillingPortal, startCheckout]);
401
423
  const handleBack = useCallback(() => {
424
+ if (hasResolvedActiveEntitlement && onContinueToTaskforce) {
425
+ void onContinueToTaskforce();
426
+ return;
427
+ }
402
428
  if (embedded) {
403
429
  closeEmbeddedPlansScreen();
404
430
  return;
@@ -408,8 +434,9 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
408
434
  return;
409
435
  }
410
436
  navigate('/');
411
- }, [closeEmbeddedPlansScreen, embedded, navigate]);
412
- const topActions = embedded ? (_jsx(_Fragment, { children: isAuthenticated && accountProfileSummary?.stripeCustomerId && (_jsx("button", { className: styles.navAction, onClick: () => { void openBillingPortal(); }, disabled: actionBusy, children: "Manage billing" })) })) : (_jsxs(_Fragment, { children: [_jsx("button", { className: styles.navAction, onClick: handleBack, children: "Back" }), isAuthenticated && accountProfileSummary?.stripeCustomerId && (_jsx("button", { className: styles.navAction, onClick: () => { void openBillingPortal(); }, disabled: actionBusy, children: "Manage billing" })), !isAuthenticated && (_jsxs("button", { className: styles.navAction, onClick: () => navigate(`/login?mode=login&next=${encodeURIComponent(buildEmbeddedPlansPath())}`), children: [_jsx(User, { size: 16, style: { marginRight: '8px', verticalAlign: 'text-bottom' } }), "Sign In"] }))] }));
437
+ }, [closeEmbeddedPlansScreen, embedded, hasResolvedActiveEntitlement, navigate, onContinueToTaskforce]);
438
+ const continueAction = hasResolvedActiveEntitlement && onContinueToTaskforce ? (_jsx("button", { className: styles.navAction, onClick: () => { void onContinueToTaskforce(); }, disabled: continueBusy || actionBusy, children: "Take Me to Taskforce" })) : null;
439
+ const topActions = embedded ? (_jsxs(_Fragment, { children: [continueAction, isAuthenticated && accountProfileSummary?.stripeCustomerId && (_jsx("button", { className: styles.navAction, onClick: () => { void openBillingPortal(); }, disabled: actionBusy, children: "Manage billing" }))] })) : (_jsxs(_Fragment, { children: [_jsx("button", { className: styles.navAction, onClick: handleBack, disabled: continueBusy || (isAuthenticated && !hasResolvedActiveEntitlement), children: hasResolvedActiveEntitlement ? 'Take Me to Taskforce' : 'Back' }), isAuthenticated && accountProfileSummary?.stripeCustomerId && (_jsx("button", { className: styles.navAction, onClick: () => { void openBillingPortal(); }, disabled: actionBusy, children: "Manage billing" })), !isAuthenticated && (_jsxs("button", { className: styles.navAction, onClick: () => navigate(`/login?mode=login&next=${encodeURIComponent(buildEmbeddedPlansPath())}`), children: [_jsx(User, { size: 16, style: { marginRight: '8px', verticalAlign: 'text-bottom' } }), "Sign In"] }))] }));
413
440
  const chrome = embedded ? null : (_jsx(AppShellHeader, { projectName: projectName, currentWorkspaceId: currentWorkspaceId, runtimeMode: runtimeMode === 'cloud' ? 'cloud' : 'local', theme: currentTheme, actions: topActions }));
414
- return (_jsx(PlanComparisonPage, { chrome: chrome, heading: "Subscription Plans", subtitle: "Manage your subscription and explore available features. Your current plan is highlighted below.", isAuthenticated: isAuthenticated, authSessionResolved: authSessionResolved, currentPlanId: String(accountProfileSummary?.planId || queryPlanId || '').trim() || null, currentPlanVersionId: String(accountProfileSummary?.planVersionId || queryPlanVersionId || '').trim() || null, currentEntitlementState: String(accountProfileSummary?.entitlementState || '').trim() || null, canManageCurrentPlan: Boolean(accountProfileSummary?.stripeCustomerId), pricingEndpoint: pricingEndpoints[0], statusBanner: feedback, onBack: handleBack, backLabel: embedded ? 'Back to Board' : 'Back to App', topActions: embedded ? topActions : null, shellOwnsScroll: shellOwnsScroll, showPageBackButton: !embedded, fallbackPricingSource: fallbackPricingSource, onSelectPlan: handleSelectPlan }));
441
+ return (_jsx(PlanComparisonPage, { chrome: chrome, heading: "Subscription Plans", subtitle: "Manage your subscription and explore available features. Your current plan is highlighted below.", isAuthenticated: isAuthenticated, authSessionResolved: authSessionResolved, currentPlanId: String(accountProfileSummary?.planId || queryPlanId || '').trim() || null, currentPlanVersionId: String(accountProfileSummary?.planVersionId || queryPlanVersionId || '').trim() || null, currentEntitlementState: String(accountProfileSummary?.entitlementState || '').trim() || null, canManageCurrentPlan: Boolean(accountProfileSummary?.stripeCustomerId), pricingEndpoint: pricingEndpoints[0], statusBanner: feedback, onBack: handleBack, backLabel: hasResolvedActiveEntitlement ? 'Take Me to Taskforce' : (embedded ? 'Back to Board' : 'Back to App'), backDisabled: continueBusy || (isAuthenticated && !hasResolvedActiveEntitlement), topActions: embedded ? topActions : null, shellOwnsScroll: shellOwnsScroll, showPageBackButton: !embedded, fallbackPricingSource: fallbackPricingSource, onSelectPlan: handleSelectPlan }));
415
442
  }
@@ -1,10 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { render, screen, waitFor } from '@testing-library/react';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest';
4
4
  import { MemoryRouter } from 'react-router-dom';
5
5
  import { PlansPage } from './PlansPage';
6
6
  vi.mock('./PlanComparisonPage', () => ({
7
- PlanComparisonPage: ({ heading, statusBanner }) => (_jsxs("div", { children: [_jsx("h1", { children: heading }), statusBanner?.message ? _jsx("div", { children: statusBanner.message }) : null] }))
7
+ PlanComparisonPage: ({ heading, statusBanner, topActions, onSelectPlan, onBack, backLabel, backDisabled }) => (_jsxs("div", { children: [_jsx("h1", { children: heading }), statusBanner?.message ? _jsx("div", { "data-status-type": statusBanner.type, children: statusBanner.message }) : null, topActions, onSelectPlan ? (_jsx("button", { onClick: () => onSelectPlan({
8
+ planId: 'free',
9
+ planVersionId: 'free-v1',
10
+ interval: 'month',
11
+ pricingType: 'free',
12
+ isCurrentPlan: false
13
+ }), children: "Select Free Plan" })) : null, onBack ? (_jsx("button", { onClick: onBack, disabled: backDisabled, children: backLabel || 'Back' })) : null] }))
8
14
  }));
9
15
  describe('PlansPage', () => {
10
16
  afterEach(() => {
@@ -97,4 +103,55 @@ describe('PlansPage', () => {
97
103
  render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&checkout=cancel'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u3", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
98
104
  expect(await screen.findByText('Your previous checkout expired. Choose a plan to continue.')).toBeInTheDocument();
99
105
  });
106
+ it('auto-continues after a free plan grant and exposes a taskforce CTA once access is active', async () => {
107
+ const onContinueToTaskforce = vi.fn();
108
+ const fetchMock = vi.fn()
109
+ .mockResolvedValueOnce({
110
+ ok: true,
111
+ json: async () => ({
112
+ entitlementState: 'pending_plan_selection',
113
+ gate: 'plan_selection_required',
114
+ message: 'Choose a plan to continue.'
115
+ })
116
+ })
117
+ .mockResolvedValueOnce({
118
+ ok: true,
119
+ json: async () => ({
120
+ checkoutState: 'free'
121
+ })
122
+ })
123
+ .mockResolvedValueOnce({
124
+ ok: true,
125
+ json: async () => ({
126
+ entitlementState: 'active',
127
+ planId: 'free',
128
+ planVersionId: 'free-v1',
129
+ gate: 'ok',
130
+ message: null
131
+ })
132
+ });
133
+ vi.stubGlobal('fetch', fetchMock);
134
+ render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&gate=plan_selection_required'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u4", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path, onContinueToTaskforce: onContinueToTaskforce }) }));
135
+ expect(await screen.findByText('Choose a plan to continue.')).toBeInTheDocument();
136
+ fireEvent.click(screen.getByRole('button', { name: 'Select Free Plan' }));
137
+ await waitFor(() => {
138
+ expect(onContinueToTaskforce).toHaveBeenCalledTimes(1);
139
+ });
140
+ expect(screen.getByRole('button', { name: 'Take Me to Taskforce' })).toBeInTheDocument();
141
+ });
142
+ it('disables the back action while the account is still entitlement-blocked', async () => {
143
+ const fetchMock = vi.fn()
144
+ .mockResolvedValueOnce({
145
+ ok: true,
146
+ json: async () => ({
147
+ entitlementState: 'pending_plan_selection',
148
+ gate: 'plan_selection_required',
149
+ message: 'Choose a plan to continue.'
150
+ })
151
+ });
152
+ vi.stubGlobal('fetch', fetchMock);
153
+ render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans&gate=plan_selection_required'], children: _jsx(PlansPage, { isAuthenticated: true, authUserId: "u5", runtimeMode: "cloud", authSessionResolved: true, resolveCloudAuthUrl: (path) => path }) }));
154
+ expect(await screen.findByRole('button', { name: 'Back to App' })).toBeDisabled();
155
+ expect(screen.getByText('Choose a plan to continue.')).toHaveAttribute('data-status-type', 'info');
156
+ });
100
157
  });