@taskforcehq/taskforce 0.3.303 → 0.3.305

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 (237) hide show
  1. package/README.md +140 -247
  2. package/dist/Taskforce.module.css +102 -5
  3. package/dist/TaskforceCore.js +635 -238
  4. package/dist/TaskforceCore.test.js +2243 -495
  5. package/dist/annotatedAttachments/service.d.ts +1 -1
  6. package/dist/annotatedAttachments/types.d.ts +1 -1
  7. package/dist/compat/workspaceSyncCompat.js +6 -0
  8. package/dist/components/features/AgentsModule.js +1 -1
  9. package/dist/components/features/AgentsModule.test.js +1 -1
  10. package/dist/components/features/DocumentIndex.d.ts +1 -1
  11. package/dist/components/features/DocumentIndex.js +1 -1
  12. package/dist/components/features/DocumentViewer.d.ts +1 -1
  13. package/dist/components/features/DocumentViewer.js +32 -9
  14. package/dist/components/features/DocumentWorkspace.d.ts +5 -22
  15. package/dist/components/features/DocumentWorkspace.js +12 -155
  16. package/dist/components/features/DocumentWorkspace.test.js +193 -6
  17. package/dist/components/features/TaskSettings.js +87 -41
  18. package/dist/components/features/TaskSettings.test.js +180 -40
  19. package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
  20. package/dist/components/features/documentWorkspaceModel.js +57 -0
  21. package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
  22. package/dist/components/features/useDocumentWorkspaceController.js +113 -0
  23. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  24. package/dist/components/task/TaskActionHeader.js +3 -3
  25. package/dist/components/task/TaskActionHeader.test.js +8 -0
  26. package/dist/components/task/TaskCard.js +16 -1
  27. package/dist/components/task/TaskCard.test.js +31 -0
  28. package/dist/components/task/TaskForm.d.ts +9 -8
  29. package/dist/components/task/TaskForm.js +374 -219
  30. package/dist/components/task/TaskForm.test.js +482 -361
  31. package/dist/components/task/TaskKanban.d.ts +1 -1
  32. package/dist/components/task/TaskKanban.js +33 -161
  33. package/dist/components/task/TaskStatusActions.d.ts +6 -1
  34. package/dist/components/task/TaskStatusActions.js +8 -3
  35. package/dist/components/task/TaskStatusActions.test.js +9 -0
  36. package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
  37. package/dist/components/task/taskKanbanDropRules.js +187 -0
  38. package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
  39. package/dist/components/task/taskKanbanDropRules.test.js +114 -0
  40. package/dist/components/ui/Modal.d.ts +1 -1
  41. package/dist/components/ui/Modal.js +1 -0
  42. package/dist/components/views/AppShellHeader.js +5 -1
  43. package/dist/components/views/AppShellHeader.test.js +4 -0
  44. package/dist/components/views/PlanComparisonPage.d.ts +3 -2
  45. package/dist/components/views/PlanComparisonPage.js +9 -10
  46. package/dist/components/views/PlanComparisonPage.test.js +55 -41
  47. package/dist/components/views/PlansPage.d.ts +5 -1
  48. package/dist/components/views/PlansPage.js +150 -81
  49. package/dist/components/views/PlansPage.test.js +367 -41
  50. package/dist/components/views/StandaloneLayout.js +135 -54
  51. package/dist/components/views/WidgetView.js +11 -2
  52. package/dist/components/views/panels/FilterToolbar.test.js +6 -4
  53. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
  54. package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
  55. package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
  56. package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
  57. package/dist/config/envSchema.js +1 -1
  58. package/dist/core/AiProfileService.d.ts +6 -1
  59. package/dist/core/AiProfileService.js +161 -16
  60. package/dist/core/AiProfileService.test.js +3 -1
  61. package/dist/core/AiProfiles.test.js +200 -0
  62. package/dist/core/AuthTokenService.test.d.ts +1 -0
  63. package/dist/core/AuthTokenService.test.js +78 -0
  64. package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
  65. package/dist/core/EntitlementsPolicy.test.js +75 -13
  66. package/dist/core/PlanEntitlementService.d.ts +9 -1
  67. package/dist/core/PlanEntitlementService.js +144 -19
  68. package/dist/core/PlanVersionPolicy.test.js +19 -26
  69. package/dist/core/SignupMonetizationSettings.test.js +75 -15
  70. package/dist/core/SystemAdmin.test.js +212 -56
  71. package/dist/core/TaskActivityService.d.ts +4 -1
  72. package/dist/core/TaskActivityService.js +45 -0
  73. package/dist/core/TaskAuditTimeline.test.js +52 -1
  74. package/dist/core/TaskTaxonomyValidation.test.js +53 -0
  75. package/dist/core/Taskforce.d.ts +18 -11
  76. package/dist/core/Taskforce.js +93 -12
  77. package/dist/core/WorkspacePlanMode.test.js +73 -8
  78. package/dist/core/shared.d.ts +2 -0
  79. package/dist/core/shared.js +11 -0
  80. package/dist/documentReviews/service.d.ts +1 -1
  81. package/dist/documentReviews/types.d.ts +1 -1
  82. package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
  83. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
  84. package/dist/hooks/sync/auth.d.ts +37 -0
  85. package/dist/hooks/sync/auth.js +63 -0
  86. package/dist/hooks/sync/bootstrap.d.ts +34 -0
  87. package/dist/hooks/sync/bootstrap.js +52 -0
  88. package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
  89. package/dist/hooks/sync/orchestratorShared.js +209 -0
  90. package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
  91. package/dist/hooks/sync/orchestratorShared.test.js +49 -0
  92. package/dist/hooks/sync/realtime.d.ts +25 -0
  93. package/dist/hooks/sync/realtime.js +60 -0
  94. package/dist/hooks/sync/recovery.d.ts +69 -0
  95. package/dist/hooks/sync/recovery.js +118 -0
  96. package/dist/hooks/sync/transfers.d.ts +123 -0
  97. package/dist/hooks/sync/transfers.js +435 -0
  98. package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
  99. package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
  100. package/dist/hooks/ui/useResourceExport.d.ts +3 -1
  101. package/dist/hooks/ui/useResourceExport.js +5 -2
  102. package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
  103. package/dist/hooks/useRealtimeSync.js +2 -1
  104. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
  105. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
  106. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
  107. package/dist/hooks/useSyncOrchestrator.d.ts +12 -8
  108. package/dist/hooks/useSyncOrchestrator.js +388 -879
  109. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +641 -6
  110. package/dist/hooks/useTaskMutations.d.ts +2 -1
  111. package/dist/hooks/useTaskMutations.js +57 -7
  112. package/dist/hooks/useTaskMutations.test.js +176 -0
  113. package/dist/hooks/useTaskforce.d.ts +13 -4
  114. package/dist/hooks/useTaskforce.js +302 -817
  115. package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
  116. package/dist/hooks/useWorkspaceSyncController.d.ts +4 -1
  117. package/dist/hooks/useWorkspaceSyncController.js +33 -11
  118. package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
  119. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
  120. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
  121. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
  122. package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
  123. package/dist/hooks/workspace/workspaceLocalState.js +100 -0
  124. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
  125. package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
  126. package/dist/localization/locales/en-US.js +2 -2
  127. package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
  128. package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
  129. package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
  130. package/dist/mcp/canonicalAssetHelpers.js +259 -0
  131. package/dist/mcp/clientIntegrations.d.ts +12 -1
  132. package/dist/mcp/clientIntegrations.js +161 -6
  133. package/dist/mcp/clientIntegrations.test.js +76 -4
  134. package/dist/mcp/collaborationRegistrar.d.ts +2 -0
  135. package/dist/mcp/collaborationRegistrar.js +71 -0
  136. package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
  137. package/dist/mcp/documentAssetRegistrar.js +346 -0
  138. package/dist/mcp/documentHelpers.d.ts +100 -0
  139. package/dist/mcp/documentHelpers.js +161 -0
  140. package/dist/mcp/runtime.js +2455 -3925
  141. package/dist/mcp/runtime.test.js +240 -8
  142. package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
  143. package/dist/mcp/taskAttachmentHelpers.js +60 -0
  144. package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
  145. package/dist/mcp/taskAttachmentTypes.js +1 -0
  146. package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
  147. package/dist/mcp/taskPlanningRegistrar.js +418 -0
  148. package/dist/mcp/toolCatalog.d.ts +35 -0
  149. package/dist/mcp/toolCatalog.js +3 -0
  150. package/dist/plugins/vite.js +1 -1
  151. package/dist/runtime/appGateDefinitions.d.ts +1 -1
  152. package/dist/runtime/appGateDefinitions.js +1 -1
  153. package/dist/server/index.d.ts +1 -0
  154. package/dist/server/index.js +46 -3
  155. package/dist/server/index.test.js +12 -1
  156. package/dist/server/routes/admin.d.ts +1 -5
  157. package/dist/server/routes/admin.js +151 -80
  158. package/dist/server/routes/annotatedAttachments.d.ts +1 -1
  159. package/dist/server/routes/annotatedAttachments.js +1 -1
  160. package/dist/server/routes/auth.d.ts +1 -4
  161. package/dist/server/routes/auth.js +59 -64
  162. package/dist/server/routes/authSupport.d.ts +30 -0
  163. package/dist/server/routes/authSupport.js +81 -0
  164. package/dist/server/routes/billing.d.ts +1 -1
  165. package/dist/server/routes/billing.js +122 -255
  166. package/dist/server/routes/billing.test.js +257 -86
  167. package/dist/server/routes/documentReviews.d.ts +1 -1
  168. package/dist/server/routes/documentReviews.js +1 -1
  169. package/dist/server/routes/documents.d.ts +4 -2
  170. package/dist/server/routes/documents.js +73 -7
  171. package/dist/server/routes/primitives.d.ts +11 -0
  172. package/dist/server/routes/primitives.js +73 -0
  173. package/dist/server/routes/resources.d.ts +1 -1
  174. package/dist/server/routes/resources.js +1 -1
  175. package/dist/server/routes/shared.d.ts +31 -1
  176. package/dist/server/routes/shared.js +26 -0
  177. package/dist/server/routes/sync.d.ts +1 -1
  178. package/dist/server/routes/sync.integration.test.js +20 -0
  179. package/dist/server/routes/sync.js +111 -238
  180. package/dist/server/routes/tasks.d.ts +1 -1
  181. package/dist/server/routes/tasks.js +50 -8
  182. package/dist/server/routes/workspaces.d.ts +1 -1
  183. package/dist/server/routes/workspaces.js +1 -1
  184. package/dist/server/routes.d.ts +2 -9
  185. package/dist/server/routes.js +84 -181
  186. package/dist/server/routes.test.js +912 -32
  187. package/dist/shared/taskActor.d.ts +10 -0
  188. package/dist/shared/taskActor.js +1 -0
  189. package/dist/sync/collaborationSyncPayload.js +4 -0
  190. package/dist/sync/collaborationSyncPayload.test.js +8 -0
  191. package/dist/sync/collaborationSyncState.d.ts +3 -1
  192. package/dist/sync/collaborationSyncState.js +19 -0
  193. package/dist/sync/contentSyncState.js +28 -17
  194. package/dist/sync/syncApplyHandlers.d.ts +87 -1
  195. package/dist/sync/syncApplyHandlers.js +370 -8
  196. package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
  197. package/dist/sync/syncApplyHandlers.test.js +379 -0
  198. package/dist/sync/syncService.d.ts +9 -1
  199. package/dist/sync/syncService.js +44 -2
  200. package/dist/sync/syncService.test.js +62 -0
  201. package/dist/sync/workspacePullFeed.d.ts +1 -0
  202. package/dist/sync/workspacePullFeed.js +66 -59
  203. package/dist/sync/workspacePullFeed.test.js +72 -4
  204. package/dist/sync/workspaceSyncModel.d.ts +25 -1
  205. package/dist/sync/workspaceSyncModel.js +77 -3
  206. package/dist/sync/workspaceSyncModel.test.js +153 -0
  207. package/dist/sync/workspaceSyncState.d.ts +2 -0
  208. package/dist/sync/workspaceSyncState.js +1 -0
  209. package/dist/types.d.ts +2 -10
  210. package/dist/ui/assets/AgentsModule-N2MnQBZk.js +1 -0
  211. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-C_TmXZwA.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
  212. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
  213. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
  214. package/dist/ui/assets/{InitiativesModule-4-Rh2K2n.js → InitiativesModule-Dhm7M8e7.js} +1 -1
  215. package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
  216. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
  217. package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
  218. package/dist/ui/assets/{WorkflowsModule-CGk9s3NJ.js → WorkflowsModule-BcS4afRn.js} +1 -1
  219. package/dist/ui/assets/index-C2-OXZV7.css +1 -0
  220. package/dist/ui/assets/index-DcSI5F86.js +6 -0
  221. package/dist/ui/assets/{vendor-icons-pWocEipT.js → vendor-icons-BSUaRwF8.js} +1 -1
  222. package/dist/ui/index.html +4 -4
  223. package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
  224. package/dist/utils/commercialLifecyclePresentation.js +196 -0
  225. package/dist/utils/taskActivity.js +4 -0
  226. package/dist/utils/taskActivity.test.js +25 -1
  227. package/package.json +10 -1
  228. package/scripts/check-cloud-mcp.mjs +83 -0
  229. package/scripts/playwright-auth.sh +9 -1
  230. package/scripts/run-billing-performance-smoke.sh +24 -0
  231. package/dist/ui/assets/AgentsModule-CpsTmrIW.js +0 -1
  232. package/dist/ui/assets/DocumentWorkspace-C3GyzrWm.js +0 -250
  233. package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
  234. package/dist/ui/assets/PlansPage-BP7AYOqr.js +0 -1
  235. package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +0 -8
  236. package/dist/ui/assets/index-CLIMgR6g.js +0 -6
  237. package/dist/ui/assets/index-DneETxcu.css +0 -1
@@ -0,0 +1,434 @@
1
+ import { useCallback, useEffect } from 'react';
2
+ import { createDefaultAssigneeOptions, createWorkspaceAssigneeOptions, mergeAssigneeOptions } from '../../utils/assignees';
3
+ import { hasWorkspaceTaxonomyStatePayload, normalizeWorkspaceTaxonomyStatePayload } from '../../shared/taxonomyState.js';
4
+ import { logBootstrapDebug, readPersistedWorkspaceId } from './workspaceLocalState';
5
+ export function useTaskforceWorkspaceBootstrap(args) {
6
+ const { shouldDeferProtectedApiCalls, shouldBlockProtectedApiCalls, getWorkspaceRequestState, isWorkspaceRequestStale, isWorkspaceEpochStale, isAbortError, isBootstrapTimeoutError, fetchWithTimeout, handleUnauthorized, applyBootstrapConfig, loadPersistedUiState, fetchBuildInfo, markBootstrapPhase, markBootstrapStalled, fetchWorkflows, fetchInitiativeTemplates, loadAvailableEnvironments, setReferenceDataLoaded, setCustomCategories, setCustomTypes, setPriorities, setTaxonomies, setTaxonomyDisplayLabels, setConfigLoaded, hasLoadedUiStateRef, currentWorkspaceIdRef, referenceDataRequestRef, referenceDataAbortRef, bootstrapConfigAbortRef, workspaceListAbortRef, assigneeOptionsAbortRef, workspaceListHydratedRef, setAvailableWorkspaces, runtimeMode, applyResolvedWorkspaceId, workspaceSwitchingEnabled, authSessionResolved, isAuthenticated, authUserId, setHydratedWorkspaceRole, setAssigneeOptions, setAssigneeOptionsLoaded, commitCurrentWorkspaceId, currentWorkspaceId, workspaceSelectionScope, abortWorkspaceScopedRequests, setWorkspaceBootstrapPending, checkAuthSession, lastConfigRefreshKeyRef, lastAssigneeOptionsLoadKeyRef, lastWorkspaceSyncStateLoadKeyRef, lastTaskSurfaceLoadKeyRef, lastSettingsSurfaceLoadKeyRef, isOpen, activeTab, showArchive, taskScope, settingsSection, fetchTasks, fetchArchive, fetchPlanningEntities, refreshTaskCollections, loadWorkspaceSyncState, archiveBootstrapTimerRef, hasBootstrappedDataRef } = args;
7
+ const fetchReferenceData = useCallback(async (options) => {
8
+ const ignoreAuthGuard = options?.ignoreAuthGuard === true;
9
+ const force = options?.force === true;
10
+ if (!ignoreAuthGuard && (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls))
11
+ return;
12
+ const requestState = getWorkspaceRequestState();
13
+ if (!force && referenceDataRequestRef.current?.workspaceId === requestState.workspaceId) {
14
+ await referenceDataRequestRef.current.promise;
15
+ return;
16
+ }
17
+ setReferenceDataLoaded(false);
18
+ const abortController = new AbortController();
19
+ referenceDataAbortRef.current = abortController;
20
+ const request = (async () => {
21
+ await Promise.allSettled([
22
+ (async () => {
23
+ const taxonomyRes = await fetch('/api/taskforce/taxonomy-state', { signal: abortController.signal });
24
+ if (!taxonomyRes.ok)
25
+ return;
26
+ const taxonomyData = await taxonomyRes.json().catch(() => ({}));
27
+ if (!hasWorkspaceTaxonomyStatePayload(taxonomyData)) {
28
+ const [catRes, typeRes, priorityRes, taxRes] = await Promise.all([
29
+ fetch('/api/taskforce/categories', { signal: abortController.signal }),
30
+ fetch('/api/taskforce/types', { signal: abortController.signal }),
31
+ fetch('/api/taskforce/priorities', { signal: abortController.signal }),
32
+ fetch('/api/taskforce/taxonomies', { signal: abortController.signal })
33
+ ]);
34
+ const [catData, typeData, priorityData, taxData] = await Promise.all([
35
+ catRes.ok ? catRes.json().catch(() => ({})) : Promise.resolve({}),
36
+ typeRes.ok ? typeRes.json().catch(() => ({})) : Promise.resolve({}),
37
+ priorityRes.ok ? priorityRes.json().catch(() => ({})) : Promise.resolve({}),
38
+ taxRes.ok ? taxRes.json().catch(() => ({})) : Promise.resolve({})
39
+ ]);
40
+ taxonomyData.categories = catData.categories;
41
+ taxonomyData.types = typeData.types;
42
+ taxonomyData.priorities = priorityData.priorities;
43
+ taxonomyData.taxonomies = taxData.taxonomies;
44
+ }
45
+ const normalized = normalizeWorkspaceTaxonomyStatePayload(taxonomyData);
46
+ if (isWorkspaceRequestStale(requestState))
47
+ return;
48
+ if (normalized.categories.length > 0)
49
+ setCustomCategories(normalized.categories);
50
+ if (normalized.types.length > 0)
51
+ setCustomTypes(normalized.types);
52
+ if (normalized.priorities.length > 0)
53
+ setPriorities(normalized.priorities);
54
+ setTaxonomies(normalized.taxonomies);
55
+ setTaxonomyDisplayLabels(normalized.displayLabels || {});
56
+ })(),
57
+ fetchWorkflows(),
58
+ fetchInitiativeTemplates(),
59
+ loadAvailableEnvironments()
60
+ ]);
61
+ if (isWorkspaceRequestStale(requestState))
62
+ return;
63
+ setReferenceDataLoaded(true);
64
+ })();
65
+ referenceDataRequestRef.current = { workspaceId: requestState.workspaceId, promise: request };
66
+ try {
67
+ await request;
68
+ }
69
+ finally {
70
+ if (referenceDataRequestRef.current?.promise === request)
71
+ referenceDataRequestRef.current = null;
72
+ if (referenceDataAbortRef.current === abortController)
73
+ referenceDataAbortRef.current = null;
74
+ }
75
+ }, [fetchInitiativeTemplates, fetchWorkflows, getWorkspaceRequestState, isWorkspaceRequestStale, loadAvailableEnvironments, referenceDataAbortRef, referenceDataRequestRef, setCustomCategories, setCustomTypes, setPriorities, setReferenceDataLoaded, setTaxonomies, setTaxonomyDisplayLabels, shouldBlockProtectedApiCalls, shouldDeferProtectedApiCalls]);
76
+ const fetchBootstrapConfig = useCallback(async (options) => {
77
+ const ignoreAuthGuard = options?.ignoreAuthGuard === true;
78
+ if (!ignoreAuthGuard && (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls))
79
+ return;
80
+ markBootstrapPhase('config');
81
+ const requestState = getWorkspaceRequestState();
82
+ const abortController = new AbortController();
83
+ bootstrapConfigAbortRef.current = abortController;
84
+ try {
85
+ const res = await fetchWithTimeout('/api/taskforce/config', undefined, 12000, { signal: abortController.signal });
86
+ if (isWorkspaceEpochStale(requestState))
87
+ return;
88
+ if (res.status === 401) {
89
+ handleUnauthorized();
90
+ setConfigLoaded(true);
91
+ return;
92
+ }
93
+ let configResolvedWorkspaceId = '';
94
+ if (res.ok) {
95
+ const data = await res.json();
96
+ if (isWorkspaceEpochStale(requestState))
97
+ return;
98
+ configResolvedWorkspaceId = typeof data?.workspaceId === 'string' && data.workspaceId.trim().length > 0 ? data.workspaceId.trim() : '';
99
+ applyBootstrapConfig(data);
100
+ }
101
+ if (isWorkspaceEpochStale(requestState)) {
102
+ const liveWorkspaceId = String(currentWorkspaceIdRef.current || 'default').trim() || 'default';
103
+ if (!configResolvedWorkspaceId || liveWorkspaceId !== configResolvedWorkspaceId)
104
+ return;
105
+ }
106
+ if (!hasLoadedUiStateRef.current) {
107
+ hasLoadedUiStateRef.current = true;
108
+ await loadPersistedUiState(configResolvedWorkspaceId || requestState.workspaceId);
109
+ }
110
+ if (isWorkspaceEpochStale(requestState)) {
111
+ const liveWorkspaceId = String(currentWorkspaceIdRef.current || 'default').trim() || 'default';
112
+ if (!configResolvedWorkspaceId || liveWorkspaceId !== configResolvedWorkspaceId)
113
+ return;
114
+ }
115
+ setConfigLoaded(true);
116
+ markBootstrapPhase('ready');
117
+ void fetchBuildInfo();
118
+ }
119
+ catch (error) {
120
+ if (isAbortError(error) || isWorkspaceEpochStale(requestState))
121
+ return;
122
+ if (!hasLoadedUiStateRef.current) {
123
+ hasLoadedUiStateRef.current = true;
124
+ await loadPersistedUiState(requestState.workspaceId);
125
+ }
126
+ markBootstrapStalled(isBootstrapTimeoutError(error) ? 'Startup checks timed out.' : 'Unable to finish startup checks.');
127
+ setConfigLoaded(true);
128
+ }
129
+ finally {
130
+ if (bootstrapConfigAbortRef.current === abortController)
131
+ bootstrapConfigAbortRef.current = null;
132
+ }
133
+ }, [applyBootstrapConfig, bootstrapConfigAbortRef, currentWorkspaceIdRef, fetchBuildInfo, fetchWithTimeout, getWorkspaceRequestState, handleUnauthorized, hasLoadedUiStateRef, isAbortError, isBootstrapTimeoutError, isWorkspaceEpochStale, loadPersistedUiState, markBootstrapPhase, markBootstrapStalled, setConfigLoaded, shouldBlockProtectedApiCalls, shouldDeferProtectedApiCalls]);
134
+ const fetchConfig = useCallback(async (options) => {
135
+ await fetchBootstrapConfig(options);
136
+ await fetchReferenceData(options);
137
+ }, [fetchBootstrapConfig, fetchReferenceData]);
138
+ const fetchWorkspaces = useCallback(async () => {
139
+ if (!workspaceSwitchingEnabled) {
140
+ setAvailableWorkspaces([]);
141
+ workspaceListHydratedRef.current = false;
142
+ return { success: true, workspaces: [] };
143
+ }
144
+ const requestState = getWorkspaceRequestState();
145
+ const abortController = new AbortController();
146
+ workspaceListAbortRef.current = abortController;
147
+ try {
148
+ const res = await fetchWithTimeout('/api/taskforce/workspaces', { method: 'GET', credentials: 'include' }, 12000, { signal: abortController.signal });
149
+ if (isWorkspaceEpochStale(requestState))
150
+ return { success: false, error: 'Workspace changed while loading workspaces.' };
151
+ if (res.status === 401) {
152
+ setAvailableWorkspaces([]);
153
+ workspaceListHydratedRef.current = false;
154
+ return { success: false, error: 'Authentication required.' };
155
+ }
156
+ const data = await res.json().catch(() => ({}));
157
+ if (isWorkspaceEpochStale(requestState))
158
+ return { success: false, error: 'Workspace changed while loading workspaces.' };
159
+ if (!res.ok || data?.success === false) {
160
+ setAvailableWorkspaces([]);
161
+ workspaceListHydratedRef.current = false;
162
+ return { success: false, error: data?.error || `Failed to load workspaces (${res.status})` };
163
+ }
164
+ const workspaces = Array.isArray(data?.workspaces)
165
+ ? data.workspaces.map((workspace) => ({
166
+ id: String(workspace?.id || '').trim(),
167
+ name: String(workspace?.name || '').trim(),
168
+ role: String(workspace?.role || 'member').trim().toLowerCase(),
169
+ slug: typeof workspace?.slug === 'string' ? workspace.slug : null,
170
+ description: typeof workspace?.description === 'string' ? workspace.description : null,
171
+ status: typeof workspace?.status === 'string' ? workspace.status : '',
172
+ betaAccess: workspace?.betaAccess === true
173
+ })).filter((workspace) => workspace.id && workspace.name)
174
+ : [];
175
+ setAvailableWorkspaces(workspaces);
176
+ workspaceListHydratedRef.current = true;
177
+ if (runtimeMode !== 'local' && typeof data?.currentWorkspaceId === 'string' && data.currentWorkspaceId.trim().length > 0) {
178
+ applyResolvedWorkspaceId(data.currentWorkspaceId.trim(), { preserveNonDefaultDefault: true });
179
+ }
180
+ return { success: true, workspaces };
181
+ }
182
+ catch (error) {
183
+ if (isBootstrapTimeoutError(error))
184
+ markBootstrapStalled('Startup checks timed out.');
185
+ setAvailableWorkspaces([]);
186
+ workspaceListHydratedRef.current = false;
187
+ return { success: false, error: isAbortError(error) ? 'Workspace list request aborted.' : 'Failed to load workspaces.' };
188
+ }
189
+ finally {
190
+ if (workspaceListAbortRef.current === abortController)
191
+ workspaceListAbortRef.current = null;
192
+ }
193
+ }, [applyResolvedWorkspaceId, fetchWithTimeout, getWorkspaceRequestState, isAbortError, isBootstrapTimeoutError, isWorkspaceEpochStale, markBootstrapStalled, runtimeMode, setAvailableWorkspaces, workspaceListAbortRef, workspaceListHydratedRef, workspaceSwitchingEnabled]);
194
+ const fetchAssigneeOptions = useCallback(async () => {
195
+ const requestState = getWorkspaceRequestState();
196
+ const abortController = new AbortController();
197
+ assigneeOptionsAbortRef.current = abortController;
198
+ setAssigneeOptionsLoaded(false);
199
+ try {
200
+ const [assigneeResult, workspaceMembersResult] = await Promise.all([
201
+ fetchWithTimeout('/api/taskforce/workspace/assignee-options', { method: 'GET', credentials: 'include' }, 12000, { signal: abortController.signal }),
202
+ (authSessionResolved && isAuthenticated)
203
+ ? fetchWithTimeout('/api/taskforce/auth/workspace-members', { method: 'GET', credentials: 'include' }, 12000, { signal: abortController.signal }).catch(() => null)
204
+ : Promise.resolve(null)
205
+ ]);
206
+ if (isWorkspaceRequestStale(requestState))
207
+ return { success: false, error: 'Workspace changed while loading assignee options.' };
208
+ if (assigneeResult.status === 401) {
209
+ setAssigneeOptions(createDefaultAssigneeOptions());
210
+ setAssigneeOptionsLoaded(true);
211
+ return { success: false, error: 'Authentication required.' };
212
+ }
213
+ const data = await assigneeResult.json().catch(() => ({}));
214
+ if (isWorkspaceRequestStale(requestState))
215
+ return { success: false, error: 'Workspace changed while loading assignee options.' };
216
+ if (!assigneeResult.ok) {
217
+ setAssigneeOptions(createDefaultAssigneeOptions());
218
+ setAssigneeOptionsLoaded(true);
219
+ return { success: false, error: data?.error || `Failed to load assignee options (${assigneeResult.status})` };
220
+ }
221
+ const assignees = Array.isArray(data?.assignees) ? data.assignees : [];
222
+ const directOptions = assignees.map((entry) => {
223
+ const value = String(entry?.value || '').trim();
224
+ const kindRaw = String(entry?.kind || '').trim().toLowerCase();
225
+ if (!value)
226
+ return null;
227
+ if (kindRaw === 'agent' || kindRaw === 'ai')
228
+ return { value, label: String(entry?.label || value).trim() || value, icon: String(entry?.icon || 'Bot'), color: String(entry?.color || '#8b5cf6'), kind: 'agent' };
229
+ if (kindRaw === 'member' || kindRaw === 'human')
230
+ return { value, label: String(entry?.label || value).trim() || value, icon: String(entry?.icon || 'User'), color: String(entry?.color || '#22c55e'), kind: 'member' };
231
+ return null;
232
+ }).filter(Boolean);
233
+ const fallbackCandidates = assignees.map((candidate) => ({
234
+ userId: String(candidate?.userId || '').trim(),
235
+ email: String(candidate?.email || '').trim().toLowerCase(),
236
+ displayName: typeof candidate?.displayName === 'string' ? candidate.displayName : null
237
+ })).filter((candidate) => candidate.userId);
238
+ let memberCandidates = fallbackCandidates;
239
+ if (workspaceMembersResult?.ok) {
240
+ const workspaceMembersData = await workspaceMembersResult.json().catch(() => ({}));
241
+ const users = Array.isArray(workspaceMembersData?.users) ? workspaceMembersData.users : [];
242
+ const normalizedAuthUserId = String(authUserId || '').trim();
243
+ const currentMember = users.find((candidate) => String(candidate?.userId || '').trim() === normalizedAuthUserId);
244
+ const currentMemberRole = String(currentMember?.role || '').trim().toLowerCase();
245
+ if (currentMemberRole === 'owner' || currentMemberRole === 'admin' || currentMemberRole === 'member' || currentMemberRole === 'read-only') {
246
+ setHydratedWorkspaceRole(currentMemberRole);
247
+ }
248
+ else if (!workspaceSwitchingEnabled && users.length === 0) {
249
+ setHydratedWorkspaceRole(null);
250
+ }
251
+ const workspaceMemberCandidates = users.map((candidate) => ({
252
+ userId: String(candidate?.userId || '').trim(),
253
+ email: String(candidate?.email || '').trim().toLowerCase(),
254
+ displayName: typeof candidate?.displayName === 'string' ? candidate.displayName : null
255
+ })).filter((candidate) => candidate.userId);
256
+ memberCandidates = Array.from(new Map([...fallbackCandidates, ...workspaceMemberCandidates].map((candidate) => [candidate.userId, candidate])).values());
257
+ }
258
+ else if (!workspaceSwitchingEnabled && !fallbackCandidates.length) {
259
+ setHydratedWorkspaceRole(null);
260
+ }
261
+ const merged = mergeAssigneeOptions([...directOptions, ...createWorkspaceAssigneeOptions(memberCandidates).filter((option) => option.kind === 'member')]);
262
+ if (isWorkspaceRequestStale(requestState))
263
+ return { success: false, error: 'Workspace changed while loading assignee options.' };
264
+ setAssigneeOptions(merged);
265
+ setAssigneeOptionsLoaded(true);
266
+ return { success: true };
267
+ }
268
+ catch (error) {
269
+ if (!isWorkspaceRequestStale(requestState)) {
270
+ setAssigneeOptions(createDefaultAssigneeOptions());
271
+ setAssigneeOptionsLoaded(true);
272
+ }
273
+ return { success: false, error: isAbortError(error) ? 'Assignee options request aborted.' : 'Failed to load assignee options.' };
274
+ }
275
+ finally {
276
+ if (assigneeOptionsAbortRef.current === abortController)
277
+ assigneeOptionsAbortRef.current = null;
278
+ }
279
+ }, [assigneeOptionsAbortRef, authSessionResolved, authUserId, fetchWithTimeout, getWorkspaceRequestState, isAbortError, isAuthenticated, isWorkspaceRequestStale, setAssigneeOptions, setAssigneeOptionsLoaded, setHydratedWorkspaceRole, workspaceSwitchingEnabled]);
280
+ const switchWorkspace = useCallback(async (workspaceId, options) => {
281
+ if (!workspaceSwitchingEnabled)
282
+ return { success: false, error: 'Workspace switching is unavailable in local mode.', code: 'WORKSPACE_SWITCHING_DISABLED' };
283
+ const normalizedWorkspaceId = String(workspaceId || '').trim();
284
+ if (!normalizedWorkspaceId)
285
+ return { success: false, error: 'workspaceId is required.' };
286
+ try {
287
+ const res = await fetch('/api/taskforce/session/workspace', {
288
+ method: 'POST',
289
+ headers: { 'Content-Type': 'application/json' },
290
+ credentials: 'include',
291
+ body: JSON.stringify({ workspaceId: normalizedWorkspaceId })
292
+ });
293
+ const data = await res.json().catch(() => ({}));
294
+ if (!res.ok || data?.success === false)
295
+ return { success: false, error: data?.error || `Failed to switch workspace (${res.status})`, code: data?.code };
296
+ commitCurrentWorkspaceId(normalizedWorkspaceId, { clearExplicitSelection: false });
297
+ abortWorkspaceScopedRequests();
298
+ setWorkspaceBootstrapPending(true);
299
+ markBootstrapPhase('workspace');
300
+ const authenticated = await checkAuthSession({ force: true });
301
+ if (options?.hydrate === false) {
302
+ await fetchWorkspaces();
303
+ }
304
+ else {
305
+ lastConfigRefreshKeyRef.current = `${runtimeMode}:${normalizedWorkspaceId}:${workspaceSelectionScope}`;
306
+ lastAssigneeOptionsLoadKeyRef.current = `${runtimeMode}:${authenticated ? 'auth' : 'guest'}:${normalizedWorkspaceId}`;
307
+ lastWorkspaceSyncStateLoadKeyRef.current = normalizedWorkspaceId;
308
+ if (isOpen && activeTab === 'tasks')
309
+ lastTaskSurfaceLoadKeyRef.current = `${normalizedWorkspaceId}:${activeTab}:${showArchive ? 'archive' : 'active'}:${taskScope}`;
310
+ if (isOpen && activeTab === 'settings')
311
+ lastSettingsSurfaceLoadKeyRef.current = `${normalizedWorkspaceId}:${activeTab}:${settingsSection}`;
312
+ await Promise.all([fetchConfig(), fetchWorkspaces(), refreshTaskCollections({ isSilent: false }), fetchAssigneeOptions(), loadWorkspaceSyncState()]);
313
+ }
314
+ return { success: true };
315
+ }
316
+ catch {
317
+ return { success: false, error: 'Failed to switch workspace.' };
318
+ }
319
+ finally {
320
+ setWorkspaceBootstrapPending(false);
321
+ }
322
+ }, [abortWorkspaceScopedRequests, activeTab, checkAuthSession, commitCurrentWorkspaceId, fetchAssigneeOptions, fetchConfig, fetchWorkspaces, isOpen, lastAssigneeOptionsLoadKeyRef, lastConfigRefreshKeyRef, lastSettingsSurfaceLoadKeyRef, lastTaskSurfaceLoadKeyRef, lastWorkspaceSyncStateLoadKeyRef, loadWorkspaceSyncState, markBootstrapPhase, refreshTaskCollections, runtimeMode, settingsSection, setWorkspaceBootstrapPending, showArchive, taskScope, workspaceSelectionScope, workspaceSwitchingEnabled]);
323
+ const resolveWorkspaceAfterAuth = useCallback(async (options) => {
324
+ markBootstrapPhase('workspace');
325
+ setWorkspaceBootstrapPending(true);
326
+ try {
327
+ if (!workspaceSwitchingEnabled) {
328
+ const localWorkspaceId = String(currentWorkspaceId || '').trim() || 'default';
329
+ commitCurrentWorkspaceId(localWorkspaceId);
330
+ return { success: true, workspaceSetupRequired: false, workspaceId: localWorkspaceId };
331
+ }
332
+ const preferredWorkspaceId = String(options?.preferredWorkspaceId || '').trim();
333
+ const persistedWorkspaceId = readPersistedWorkspaceId(workspaceSelectionScope);
334
+ const workspacesResult = await fetchWorkspaces();
335
+ if (!workspacesResult.success)
336
+ return { success: false, workspaceSetupRequired: false, error: workspacesResult.error || 'Failed to load workspaces after authentication.' };
337
+ const workspaces = Array.isArray(workspacesResult.workspaces) ? workspacesResult.workspaces : [];
338
+ if (workspaces.length === 0) {
339
+ commitCurrentWorkspaceId('default');
340
+ return { success: true, workspaceSetupRequired: true };
341
+ }
342
+ const ids = new Set(workspaces.map((workspace) => String(workspace.id || '').trim()).filter(Boolean));
343
+ const selectedWorkspaceId = (preferredWorkspaceId && ids.has(preferredWorkspaceId) ? preferredWorkspaceId : '') || (persistedWorkspaceId && ids.has(persistedWorkspaceId) ? persistedWorkspaceId : '') || String(workspaces[0]?.id || '').trim();
344
+ if (!selectedWorkspaceId) {
345
+ commitCurrentWorkspaceId('default');
346
+ return { success: true, workspaceSetupRequired: true };
347
+ }
348
+ const current = String(currentWorkspaceIdRef.current || '').trim();
349
+ if (current !== selectedWorkspaceId) {
350
+ const switched = await switchWorkspace(selectedWorkspaceId, { hydrate: false });
351
+ if (!switched.success)
352
+ return { success: false, workspaceSetupRequired: false, error: switched.error || 'Failed to set workspace after authentication.', code: switched.code };
353
+ }
354
+ else {
355
+ commitCurrentWorkspaceId(selectedWorkspaceId);
356
+ }
357
+ return { success: true, workspaceSetupRequired: false, workspaceId: selectedWorkspaceId };
358
+ }
359
+ catch {
360
+ markBootstrapStalled('Unable to resolve workspace access.');
361
+ return { success: false, workspaceSetupRequired: false, error: 'Failed to resolve workspace access.' };
362
+ }
363
+ finally {
364
+ setWorkspaceBootstrapPending(false);
365
+ }
366
+ }, [commitCurrentWorkspaceId, currentWorkspaceId, currentWorkspaceIdRef, fetchWorkspaces, markBootstrapPhase, markBootstrapStalled, setWorkspaceBootstrapPending, switchWorkspace, workspaceSelectionScope, workspaceSwitchingEnabled]);
367
+ const retryBootstrapChecks = useCallback(async () => {
368
+ markBootstrapPhase('auth');
369
+ const authenticated = await checkAuthSession({ force: true });
370
+ if (workspaceSwitchingEnabled && authenticated) {
371
+ const workspaceResolution = await resolveWorkspaceAfterAuth();
372
+ if (!workspaceResolution.success || workspaceResolution.workspaceSetupRequired) {
373
+ await fetchConfig({ ignoreAuthGuard: true });
374
+ return;
375
+ }
376
+ }
377
+ await fetchConfig({ ignoreAuthGuard: true });
378
+ }, [checkAuthSession, fetchConfig, markBootstrapPhase, resolveWorkspaceAfterAuth, workspaceSwitchingEnabled]);
379
+ useEffect(() => {
380
+ if (!shouldDeferProtectedApiCalls && !shouldBlockProtectedApiCalls && !hasBootstrappedDataRef.current) {
381
+ hasBootstrappedDataRef.current = true;
382
+ void (async () => {
383
+ if (runtimeMode === 'cloud' && authSessionResolved && isAuthenticated) {
384
+ const workspaceResolution = await resolveWorkspaceAfterAuth();
385
+ if (!workspaceResolution.success || workspaceResolution.workspaceSetupRequired) {
386
+ await fetchBootstrapConfig();
387
+ void fetchReferenceData();
388
+ return;
389
+ }
390
+ }
391
+ await fetchBootstrapConfig();
392
+ await Promise.all([fetchTasks(false), fetchReferenceData(), fetchPlanningEntities()]);
393
+ if (typeof window !== 'undefined') {
394
+ if (archiveBootstrapTimerRef.current !== null)
395
+ window.clearTimeout(archiveBootstrapTimerRef.current);
396
+ archiveBootstrapTimerRef.current = window.setTimeout(() => {
397
+ archiveBootstrapTimerRef.current = null;
398
+ void fetchArchive(true).then(() => logBootstrapDebug('bootstrap_archive_loaded_deferred', {}));
399
+ }, 1500);
400
+ }
401
+ })();
402
+ }
403
+ }, [archiveBootstrapTimerRef, authSessionResolved, fetchArchive, fetchBootstrapConfig, fetchPlanningEntities, fetchReferenceData, fetchTasks, hasBootstrappedDataRef, isAuthenticated, resolveWorkspaceAfterAuth, runtimeMode, shouldBlockProtectedApiCalls, shouldDeferProtectedApiCalls]);
404
+ useEffect(() => {
405
+ if (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls || workspaceSelectionScope === 'bootstrap' || !hasBootstrappedDataRef.current)
406
+ return;
407
+ const loadKey = `${runtimeMode}:${currentWorkspaceId}:${workspaceSelectionScope}`;
408
+ if (lastConfigRefreshKeyRef.current === loadKey)
409
+ return;
410
+ lastConfigRefreshKeyRef.current = loadKey;
411
+ void fetchConfig();
412
+ }, [currentWorkspaceId, fetchConfig, hasBootstrappedDataRef, lastConfigRefreshKeyRef, runtimeMode, shouldBlockProtectedApiCalls, shouldDeferProtectedApiCalls, workspaceSelectionScope]);
413
+ useEffect(() => {
414
+ if (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls)
415
+ return;
416
+ const loadKey = currentWorkspaceId;
417
+ if (lastWorkspaceSyncStateLoadKeyRef.current === loadKey)
418
+ return;
419
+ lastWorkspaceSyncStateLoadKeyRef.current = loadKey;
420
+ void loadWorkspaceSyncState();
421
+ }, [currentWorkspaceId, lastWorkspaceSyncStateLoadKeyRef, loadWorkspaceSyncState, shouldBlockProtectedApiCalls, shouldDeferProtectedApiCalls]);
422
+ useEffect(() => {
423
+ if (runtimeMode !== 'cloud' || !authSessionResolved || !isAuthenticated) {
424
+ if (runtimeMode !== 'cloud')
425
+ setAvailableWorkspaces([]);
426
+ workspaceListHydratedRef.current = false;
427
+ return;
428
+ }
429
+ if (!hasBootstrappedDataRef.current || workspaceListHydratedRef.current)
430
+ return;
431
+ void fetchWorkspaces();
432
+ }, [authSessionResolved, fetchWorkspaces, hasBootstrappedDataRef, isAuthenticated, runtimeMode, setAvailableWorkspaces, workspaceListHydratedRef]);
433
+ return { fetchReferenceData, fetchBootstrapConfig, fetchConfig, fetchWorkspaces, fetchAssigneeOptions, switchWorkspace, resolveWorkspaceAfterAuth, retryBootstrapChecks };
434
+ }
@@ -22,9 +22,28 @@ export interface PersistedUiState {
22
22
  lastCategory?: string;
23
23
  exportEnvironment?: string;
24
24
  }
25
+ type RemoteUiStatePersistResult = {
26
+ skipped: boolean;
27
+ reason?: 'unchanged' | 'cooldown';
28
+ response?: Response;
29
+ payload?: any;
30
+ };
25
31
  export declare function generateClientUuid(): string;
26
32
  export declare function logBootstrapDebug(event: string, details?: Record<string, unknown>): void;
33
+ export declare function resetRemoteUiStatePersistCache(): void;
34
+ export declare function persistRemoteUiState(input: {
35
+ stateKey?: string;
36
+ workspaceId?: string | null;
37
+ patch: object;
38
+ headers?: Record<string, string>;
39
+ throwOnError?: boolean;
40
+ failureRetryMs?: number;
41
+ skipIfUnchanged?: boolean;
42
+ respectFailureCooldown?: boolean;
43
+ }): Promise<RemoteUiStatePersistResult>;
27
44
  export declare function readPersistedWorkspaceId(scopeRaw?: string): string;
28
45
  export declare function writePersistedWorkspaceId(workspaceId: string, scopeRaw?: string): void;
46
+ export declare function clearPersistedWorkspaceId(scopeRaw?: string): void;
29
47
  export declare function readPersistedAppUiState(scopeRaw?: string): PersistedUiState | null;
30
48
  export declare function writePersistedAppUiState(state: PersistedUiState, scopeRaw?: string): void;
49
+ export {};
@@ -2,6 +2,8 @@ import { isReservedWorkspaceId, normalizeWorkspaceStorageScope, normalizeWorkspa
2
2
  const WORKSPACE_CONTEXT_KEY_PREFIX = 'taskforce.workspaceContext.v2';
3
3
  const APP_UI_STATE_STORAGE_KEY_PREFIX = 'taskforce.uiState.app.v1';
4
4
  const BOOTSTRAP_DEBUG_LOG_KEY = 'taskforce.bootstrapDebug.v1';
5
+ const REMOTE_UI_STATE_FAILURE_RETRY_MS = 5000;
6
+ const remoteUiStatePersistCache = new Map();
5
7
  export function generateClientUuid() {
6
8
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
7
9
  return crypto.randomUUID();
@@ -25,6 +27,93 @@ export function logBootstrapDebug(event, details) {
25
27
  const payload = details && typeof details === 'object' ? details : {};
26
28
  console.info('[Taskforce Bootstrap]', event, payload);
27
29
  }
30
+ function buildRemoteUiStatePersistKey(stateKeyRaw, workspaceIdRaw) {
31
+ const stateKey = String(stateKeyRaw || 'default').trim() || 'default';
32
+ const workspaceId = String(workspaceIdRaw || 'default').trim() || 'default';
33
+ return `${workspaceId}::${stateKey}`;
34
+ }
35
+ export function resetRemoteUiStatePersistCache() {
36
+ remoteUiStatePersistCache.clear();
37
+ }
38
+ export async function persistRemoteUiState(input) {
39
+ const stateKey = String(input.stateKey || 'default').trim() || 'default';
40
+ const workspaceId = String(input.workspaceId || '').trim();
41
+ const patch = input.patch && typeof input.patch === 'object' ? input.patch : {};
42
+ const serializedPatch = JSON.stringify(patch);
43
+ const persistKey = buildRemoteUiStatePersistKey(stateKey, workspaceId || 'default');
44
+ const now = Date.now();
45
+ const cached = remoteUiStatePersistCache.get(persistKey);
46
+ if (cached && cached.serializedPatch === serializedPatch) {
47
+ if (cached.status === 'success' && input.skipIfUnchanged !== false) {
48
+ logBootstrapDebug('ui_state_persist_skipped', {
49
+ stateKey,
50
+ workspaceId: workspaceId || 'default',
51
+ reason: 'unchanged'
52
+ });
53
+ return { skipped: true, reason: 'unchanged' };
54
+ }
55
+ if (input.respectFailureCooldown !== false && cached.retryAfter > now) {
56
+ logBootstrapDebug('ui_state_persist_skipped', {
57
+ stateKey,
58
+ workspaceId: workspaceId || 'default',
59
+ reason: 'cooldown',
60
+ retryAfterMs: cached.retryAfter - now
61
+ });
62
+ return { skipped: true, reason: 'cooldown' };
63
+ }
64
+ }
65
+ try {
66
+ const response = await fetch('/api/taskforce/ui-state', {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json', ...(input.headers || {}) },
69
+ credentials: 'include',
70
+ body: JSON.stringify({
71
+ stateKey,
72
+ ...(workspaceId ? { workspaceId } : {}),
73
+ patch
74
+ })
75
+ });
76
+ const payload = await response.json().catch(() => ({}));
77
+ if (response.ok && payload?.success !== false) {
78
+ remoteUiStatePersistCache.set(persistKey, {
79
+ serializedPatch,
80
+ status: 'success',
81
+ retryAfter: 0
82
+ });
83
+ return { skipped: false, response, payload };
84
+ }
85
+ remoteUiStatePersistCache.set(persistKey, {
86
+ serializedPatch,
87
+ status: 'failure',
88
+ retryAfter: now + (input.failureRetryMs ?? REMOTE_UI_STATE_FAILURE_RETRY_MS)
89
+ });
90
+ logBootstrapDebug('ui_state_persist_failed', {
91
+ stateKey,
92
+ workspaceId: workspaceId || 'default',
93
+ status: response.status,
94
+ error: String(payload?.error || '')
95
+ });
96
+ if (input.throwOnError) {
97
+ throw new Error(String(payload?.error || `Failed to persist UI state (${response.status})`));
98
+ }
99
+ return { skipped: false, response, payload };
100
+ }
101
+ catch (error) {
102
+ remoteUiStatePersistCache.set(persistKey, {
103
+ serializedPatch,
104
+ status: 'failure',
105
+ retryAfter: now + (input.failureRetryMs ?? REMOTE_UI_STATE_FAILURE_RETRY_MS)
106
+ });
107
+ logBootstrapDebug('ui_state_persist_failed', {
108
+ stateKey,
109
+ workspaceId: workspaceId || 'default',
110
+ error: error instanceof Error ? error.message : String(error)
111
+ });
112
+ if (input.throwOnError)
113
+ throw error;
114
+ return { skipped: false, payload: null };
115
+ }
116
+ }
28
117
  function buildWorkspaceContextStorageKey(scopeRaw) {
29
118
  const scope = normalizeWorkspaceStorageScope(scopeRaw);
30
119
  return `${WORKSPACE_CONTEXT_KEY_PREFIX}.${scope}`;
@@ -71,6 +160,17 @@ export function writePersistedWorkspaceId(workspaceId, scopeRaw) {
71
160
  // best-effort only
72
161
  }
73
162
  }
163
+ export function clearPersistedWorkspaceId(scopeRaw) {
164
+ if (typeof window === 'undefined')
165
+ return;
166
+ try {
167
+ window.localStorage.removeItem(buildWorkspaceContextStorageKey(scopeRaw));
168
+ window.localStorage.removeItem(buildLegacyWorkspaceContextStorageKey(scopeRaw));
169
+ }
170
+ catch {
171
+ // best-effort only
172
+ }
173
+ }
74
174
  function buildAppUiStateStorageKey(scopeRaw) {
75
175
  const scope = normalizeWorkspaceStorageScopeLegacy(scopeRaw);
76
176
  return `${APP_UI_STATE_STORAGE_KEY_PREFIX}.${scope}`;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,59 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { persistRemoteUiState, resetRemoteUiStatePersistCache } from './workspaceLocalState';
3
+ describe('persistRemoteUiState', () => {
4
+ afterEach(() => {
5
+ resetRemoteUiStatePersistCache();
6
+ vi.unstubAllGlobals();
7
+ vi.clearAllMocks();
8
+ vi.useRealTimers();
9
+ });
10
+ it('skips identical payloads after a successful persist', async () => {
11
+ const fetchMock = vi.fn(async () => ({
12
+ ok: true,
13
+ status: 200,
14
+ json: async () => ({ success: true })
15
+ }));
16
+ vi.stubGlobal('fetch', fetchMock);
17
+ const first = await persistRemoteUiState({
18
+ stateKey: 'app',
19
+ workspaceId: 'workspace-1',
20
+ patch: { activeWorkspaceModule: 'tasks' }
21
+ });
22
+ const second = await persistRemoteUiState({
23
+ stateKey: 'app',
24
+ workspaceId: 'workspace-1',
25
+ patch: { activeWorkspaceModule: 'tasks' }
26
+ });
27
+ expect(first.skipped).toBe(false);
28
+ expect(second).toEqual({ skipped: true, reason: 'unchanged' });
29
+ expect(fetchMock).toHaveBeenCalledTimes(1);
30
+ });
31
+ it('backs off identical failed payloads briefly before retrying', async () => {
32
+ vi.useFakeTimers();
33
+ const fetchMock = vi.fn(async () => {
34
+ throw new Error('gateway');
35
+ });
36
+ vi.stubGlobal('fetch', fetchMock);
37
+ const first = await persistRemoteUiState({
38
+ stateKey: 'layout',
39
+ workspaceId: 'workspace-1',
40
+ patch: { planningDrawerOpen: true }
41
+ });
42
+ const second = await persistRemoteUiState({
43
+ stateKey: 'layout',
44
+ workspaceId: 'workspace-1',
45
+ patch: { planningDrawerOpen: true }
46
+ });
47
+ expect(first.skipped).toBe(false);
48
+ expect(second).toEqual({ skipped: true, reason: 'cooldown' });
49
+ expect(fetchMock).toHaveBeenCalledTimes(1);
50
+ vi.advanceTimersByTime(5001);
51
+ const third = await persistRemoteUiState({
52
+ stateKey: 'layout',
53
+ workspaceId: 'workspace-1',
54
+ patch: { planningDrawerOpen: true }
55
+ });
56
+ expect(third.skipped).toBe(false);
57
+ expect(fetchMock).toHaveBeenCalledTimes(2);
58
+ });
59
+ });
@@ -118,10 +118,10 @@ export const enUS = {
118
118
  setup: {
119
119
  headingCheckFailed: 'Setup Check Failed',
120
120
  headingGlobalRequired: 'Global Setup Required',
121
- headingWorkspaceRequired: 'Workspace Setup Required',
121
+ headingWorkspaceRequired: 'Configure your new workspace.',
122
122
  subtitleUnreadable: 'Taskforce could not read setup state. Retry to continue.',
123
123
  subtitleGlobalRequired: 'Choose your default operating mode to continue.',
124
- subtitleWorkspaceRequired: 'Set up your workspace profile to continue.',
124
+ subtitleWorkspaceRequired: '',
125
125
  runtimeLabel: 'Runtime',
126
126
  workspaceTermProject: 'Project',
127
127
  workspaceTermMission: 'Mission',