@taskforcehq/taskforce 0.3.168

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 (492) hide show
  1. package/README.md +241 -0
  2. package/dist/Taskforce.d.ts +3 -0
  3. package/dist/Taskforce.js +3 -0
  4. package/dist/Taskforce.module.css +7587 -0
  5. package/dist/TaskforceCore.CategoryFallback.test.d.ts +1 -0
  6. package/dist/TaskforceCore.CategoryFallback.test.js +98 -0
  7. package/dist/TaskforceCore.d.ts +12 -0
  8. package/dist/TaskforceCore.js +729 -0
  9. package/dist/TaskforceCore.test.d.ts +1 -0
  10. package/dist/TaskforceCore.test.js +2132 -0
  11. package/dist/TaskforceScreenshot.d.ts +11 -0
  12. package/dist/TaskforceScreenshot.js +113 -0
  13. package/dist/TaskforceWidget.d.ts +6 -0
  14. package/dist/TaskforceWidget.js +101 -0
  15. package/dist/TaskforceWidget.test.d.ts +1 -0
  16. package/dist/TaskforceWidget.test.js +92 -0
  17. package/dist/UnsavedChangesRelaxed.test.d.ts +1 -0
  18. package/dist/UnsavedChangesRelaxed.test.js +16 -0
  19. package/dist/__tests__/localizationCatalogs.test.d.ts +1 -0
  20. package/dist/__tests__/localizationCatalogs.test.js +37 -0
  21. package/dist/__tests__/planParser.test.d.ts +1 -0
  22. package/dist/__tests__/planParser.test.js +183 -0
  23. package/dist/__tests__/releaseVersion.test.d.ts +1 -0
  24. package/dist/__tests__/releaseVersion.test.js +32 -0
  25. package/dist/__tests__/structuredCommentValidation.test.d.ts +1 -0
  26. package/dist/__tests__/structuredCommentValidation.test.js +31 -0
  27. package/dist/__tests__/structuredDocValidation.test.d.ts +1 -0
  28. package/dist/__tests__/structuredDocValidation.test.js +30 -0
  29. package/dist/__tests__/templateEngine.test.d.ts +1 -0
  30. package/dist/__tests__/templateEngine.test.js +150 -0
  31. package/dist/__tests__/templates.test.d.ts +1 -0
  32. package/dist/__tests__/templates.test.js +45 -0
  33. package/dist/__tests__/workflowDocumentFixtures.test.d.ts +1 -0
  34. package/dist/__tests__/workflowDocumentFixtures.test.js +78 -0
  35. package/dist/__tests__/workflowExportMatrix.test.d.ts +1 -0
  36. package/dist/__tests__/workflowExportMatrix.test.js +113 -0
  37. package/dist/__tests__/workflowExportSnapshots.test.d.ts +1 -0
  38. package/dist/__tests__/workflowExportSnapshots.test.js +28 -0
  39. package/dist/__tests__/workflowGeneration.test.d.ts +1 -0
  40. package/dist/__tests__/workflowGeneration.test.js +12 -0
  41. package/dist/__tests__/workflowSimulation.test.d.ts +1 -0
  42. package/dist/__tests__/workflowSimulation.test.js +113 -0
  43. package/dist/__tests__/workflowV2Policy.test.d.ts +1 -0
  44. package/dist/__tests__/workflowV2Policy.test.js +80 -0
  45. package/dist/api.d.ts +30 -0
  46. package/dist/api.js +85 -0
  47. package/dist/cli.d.ts +2 -0
  48. package/dist/cli.js +217 -0
  49. package/dist/compat/priorityCompat.d.ts +2 -0
  50. package/dist/compat/priorityCompat.js +24 -0
  51. package/dist/components/features/DocumentGenerateModal.d.ts +9 -0
  52. package/dist/components/features/DocumentGenerateModal.js +114 -0
  53. package/dist/components/features/DocumentIndex.d.ts +19 -0
  54. package/dist/components/features/DocumentIndex.js +51 -0
  55. package/dist/components/features/DocumentViewer.d.ts +9 -0
  56. package/dist/components/features/DocumentViewer.js +253 -0
  57. package/dist/components/features/DocumentWorkspace.d.ts +21 -0
  58. package/dist/components/features/DocumentWorkspace.js +88 -0
  59. package/dist/components/features/TaskSettings.d.ts +209 -0
  60. package/dist/components/features/TaskSettings.js +500 -0
  61. package/dist/components/features/TaskSettings.test.d.ts +1 -0
  62. package/dist/components/features/TaskSettings.test.js +308 -0
  63. package/dist/components/features/settings/CategoryManager.d.ts +19 -0
  64. package/dist/components/features/settings/CategoryManager.js +113 -0
  65. package/dist/components/features/settings/TaxonomyEditor.d.ts +8 -0
  66. package/dist/components/features/settings/TaxonomyEditor.js +98 -0
  67. package/dist/components/features/settings/TaxonomyManager 2.d.ts +52 -0
  68. package/dist/components/features/settings/TaxonomyManager 2.js +75 -0
  69. package/dist/components/features/settings/TaxonomyManager.d.ts +54 -0
  70. package/dist/components/features/settings/TaxonomyManager.js +77 -0
  71. package/dist/components/features/settings/TypeManager.d.ts +10 -0
  72. package/dist/components/features/settings/TypeManager.js +50 -0
  73. package/dist/components/features/settings/WorkflowEditorPrototype.d.ts +15 -0
  74. package/dist/components/features/settings/WorkflowEditorPrototype.js +245 -0
  75. package/dist/components/task/TaskActionHeader.d.ts +20 -0
  76. package/dist/components/task/TaskActionHeader.js +13 -0
  77. package/dist/components/task/TaskCard.d.ts +47 -0
  78. package/dist/components/task/TaskCard.js +326 -0
  79. package/dist/components/task/TaskCard.test.d.ts +1 -0
  80. package/dist/components/task/TaskCard.test.js +95 -0
  81. package/dist/components/task/TaskContextUpload.d.ts +12 -0
  82. package/dist/components/task/TaskContextUpload.js +393 -0
  83. package/dist/components/task/TaskContextUpload.test.d.ts +1 -0
  84. package/dist/components/task/TaskContextUpload.test.js +189 -0
  85. package/dist/components/task/TaskForm.d.ts +105 -0
  86. package/dist/components/task/TaskForm.js +241 -0
  87. package/dist/components/task/TaskForm.test.d.ts +1 -0
  88. package/dist/components/task/TaskForm.test.js +288 -0
  89. package/dist/components/task/TaskKanban 2.d.ts +76 -0
  90. package/dist/components/task/TaskKanban 2.js +1004 -0
  91. package/dist/components/task/TaskKanban.d.ts +76 -0
  92. package/dist/components/task/TaskKanban.empty-column-drop.test.d.ts +1 -0
  93. package/dist/components/task/TaskKanban.empty-column-drop.test.js +66 -0
  94. package/dist/components/task/TaskKanban.js +1026 -0
  95. package/dist/components/task/TaskKanban.test.d.ts +1 -0
  96. package/dist/components/task/TaskKanban.test.js +46 -0
  97. package/dist/components/task/TaskList.d.ts +74 -0
  98. package/dist/components/task/TaskList.js +38 -0
  99. package/dist/components/task/TaskList.test.d.ts +1 -0
  100. package/dist/components/task/TaskList.test.js +270 -0
  101. package/dist/components/task/TaskScreenshotSection.d.ts +15 -0
  102. package/dist/components/task/TaskScreenshotSection.js +22 -0
  103. package/dist/components/ui/LevelSelector.d.ts +13 -0
  104. package/dist/components/ui/LevelSelector.js +25 -0
  105. package/dist/components/ui/LevelSelector.test.d.ts +1 -0
  106. package/dist/components/ui/LevelSelector.test.js +55 -0
  107. package/dist/components/ui/Markdown.d.ts +6 -0
  108. package/dist/components/ui/Markdown.js +94 -0
  109. package/dist/components/ui/Markdown.test.d.ts +1 -0
  110. package/dist/components/ui/Markdown.test.js +38 -0
  111. package/dist/components/ui/Modal.d.ts +16 -0
  112. package/dist/components/ui/Modal.js +49 -0
  113. package/dist/components/ui/MultiSelectFilter.d.ts +10 -0
  114. package/dist/components/ui/MultiSelectFilter.js +71 -0
  115. package/dist/components/ui/MultiSelectFilter.test.d.ts +1 -0
  116. package/dist/components/ui/MultiSelectFilter.test.js +74 -0
  117. package/dist/components/ui/TaxonomyDropdown.d.ts +17 -0
  118. package/dist/components/ui/TaxonomyDropdown.js +145 -0
  119. package/dist/components/ui/TaxonomyDropdown.test.d.ts +1 -0
  120. package/dist/components/ui/TaxonomyDropdown.test.js +130 -0
  121. package/dist/components/views/StandaloneLayout 2.d.ts +8 -0
  122. package/dist/components/views/StandaloneLayout 2.js +1660 -0
  123. package/dist/components/views/StandaloneLayout.d.ts +8 -0
  124. package/dist/components/views/StandaloneLayout.js +2117 -0
  125. package/dist/components/views/WidgetView.d.ts +8 -0
  126. package/dist/components/views/WidgetView.js +221 -0
  127. package/dist/config/envSchema.d.ts +28 -0
  128. package/dist/config/envSchema.js +374 -0
  129. package/dist/config/envSchema.test.d.ts +1 -0
  130. package/dist/config/envSchema.test.js +97 -0
  131. package/dist/config/surfaceBase.d.ts +2 -0
  132. package/dist/config/surfaceBase.js +8 -0
  133. package/dist/core/BulkUpdateFieldsPreview.test.d.ts +1 -0
  134. package/dist/core/BulkUpdateFieldsPreview.test.js +62 -0
  135. package/dist/core/CompletedAt.test.d.ts +1 -0
  136. package/dist/core/CompletedAt.test.js +44 -0
  137. package/dist/core/CreatedBy.test.d.ts +1 -0
  138. package/dist/core/CreatedBy.test.js +60 -0
  139. package/dist/core/EntitlementsPolicy.test.d.ts +1 -0
  140. package/dist/core/EntitlementsPolicy.test.js +145 -0
  141. package/dist/core/InitiativeTemplates.test.d.ts +1 -0
  142. package/dist/core/InitiativeTemplates.test.js +50 -0
  143. package/dist/core/ParentLifecycle.test.d.ts +1 -0
  144. package/dist/core/ParentLifecycle.test.js +98 -0
  145. package/dist/core/ScheduleFields.test.d.ts +1 -0
  146. package/dist/core/ScheduleFields.test.js +197 -0
  147. package/dist/core/SetupState.test.d.ts +1 -0
  148. package/dist/core/SetupState.test.js +113 -0
  149. package/dist/core/SystemAdmin.test.d.ts +1 -0
  150. package/dist/core/SystemAdmin.test.js +295 -0
  151. package/dist/core/TaskTaxonomyValidation.test.d.ts +1 -0
  152. package/dist/core/TaskTaxonomyValidation.test.js +64 -0
  153. package/dist/core/Taskforce.d.ts +1063 -0
  154. package/dist/core/Taskforce.ids.test.d.ts +1 -0
  155. package/dist/core/Taskforce.ids.test.js +168 -0
  156. package/dist/core/Taskforce.js +5981 -0
  157. package/dist/core/Taskforce.realtimeMutation.test.d.ts +1 -0
  158. package/dist/core/Taskforce.realtimeMutation.test.js +76 -0
  159. package/dist/core/WorkspaceDelete.test.d.ts +1 -0
  160. package/dist/core/WorkspaceDelete.test.js +140 -0
  161. package/dist/core/WorkspacePermissions.test.d.ts +1 -0
  162. package/dist/core/WorkspacePermissions.test.js +151 -0
  163. package/dist/core/WorkspacePlanMode.test.d.ts +1 -0
  164. package/dist/core/WorkspacePlanMode.test.js +104 -0
  165. package/dist/hooks/authGuardPolicy.d.ts +26 -0
  166. package/dist/hooks/authGuardPolicy.js +32 -0
  167. package/dist/hooks/authGuardPolicy.test.d.ts +1 -0
  168. package/dist/hooks/authGuardPolicy.test.js +76 -0
  169. package/dist/hooks/useDraggable.d.ts +12 -0
  170. package/dist/hooks/useDraggable.js +65 -0
  171. package/dist/hooks/useRealtimeSync.d.ts +24 -0
  172. package/dist/hooks/useRealtimeSync.js +275 -0
  173. package/dist/hooks/useRealtimeSync.test.d.ts +1 -0
  174. package/dist/hooks/useRealtimeSync.test.js +284 -0
  175. package/dist/hooks/useSyncOrchestrator.d.ts +164 -0
  176. package/dist/hooks/useSyncOrchestrator.js +1427 -0
  177. package/dist/hooks/useSyncOrchestrator.retry-closure.test.d.ts +1 -0
  178. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +367 -0
  179. package/dist/hooks/useTaskforce.d.ts +727 -0
  180. package/dist/hooks/useTaskforce.js +4396 -0
  181. package/dist/hooks/useTaskforce.priority-filters.test.d.ts +1 -0
  182. package/dist/hooks/useTaskforce.priority-filters.test.js +33 -0
  183. package/dist/hooks/useWorkspaceSyncController.d.ts +80 -0
  184. package/dist/hooks/useWorkspaceSyncController.js +598 -0
  185. package/dist/hooks/useWorkspaceSyncController.test.d.ts +1 -0
  186. package/dist/hooks/useWorkspaceSyncController.test.js +236 -0
  187. package/dist/index.d.ts +12 -0
  188. package/dist/index.js +11 -0
  189. package/dist/localization/index.d.ts +8 -0
  190. package/dist/localization/index.js +81 -0
  191. package/dist/localization/locales/en-US.d.ts +151 -0
  192. package/dist/localization/locales/en-US.js +150 -0
  193. package/dist/localization/locales/es-419.d.ts +2 -0
  194. package/dist/localization/locales/es-419.js +150 -0
  195. package/dist/localization/locales/pt-BR.d.ts +2 -0
  196. package/dist/localization/locales/pt-BR.js +150 -0
  197. package/dist/main.d.ts +1 -0
  198. package/dist/main.js +33 -0
  199. package/dist/mcp/optionResolution.d.ts +14 -0
  200. package/dist/mcp/optionResolution.js +126 -0
  201. package/dist/mcp/optionResolution.test.d.ts +1 -0
  202. package/dist/mcp/optionResolution.test.js +39 -0
  203. package/dist/mcp/server.d.ts +1 -0
  204. package/dist/mcp/server.js +1793 -0
  205. package/dist/mcp/structuredCommentValidation.d.ts +6 -0
  206. package/dist/mcp/structuredCommentValidation.js +56 -0
  207. package/dist/mcp/structuredDocValidation.d.ts +6 -0
  208. package/dist/mcp/structuredDocValidation.js +75 -0
  209. package/dist/migrations/billingSchemaParity.test.d.ts +1 -0
  210. package/dist/migrations/billingSchemaParity.test.js +46 -0
  211. package/dist/migrations/clientMigrations.d.ts +9 -0
  212. package/dist/migrations/clientMigrations.js +74 -0
  213. package/dist/migrations/flags.d.ts +6 -0
  214. package/dist/migrations/flags.js +8 -0
  215. package/dist/migrations/taskMigrations.d.ts +71 -0
  216. package/dist/migrations/taskMigrations.js +236 -0
  217. package/dist/migrations/taskMigrations.test.d.ts +1 -0
  218. package/dist/migrations/taskMigrations.test.js +34 -0
  219. package/dist/migrations/taskSchemaMigrations.d.ts +11 -0
  220. package/dist/migrations/taskSchemaMigrations.js +453 -0
  221. package/dist/plugins/index.d.ts +10 -0
  222. package/dist/plugins/index.js +10 -0
  223. package/dist/plugins/vite.d.ts +19 -0
  224. package/dist/plugins/vite.js +271 -0
  225. package/dist/public/android-chrome-192x192.png +0 -0
  226. package/dist/public/android-chrome-512x512.png +0 -0
  227. package/dist/public/apple-touch-icon.png +0 -0
  228. package/dist/public/favicon-16x16.png +0 -0
  229. package/dist/public/favicon-32x32.png +0 -0
  230. package/dist/public/favicon.ico +0 -0
  231. package/dist/public/favicon.png +0 -0
  232. package/dist/public/site.webmanifest +1 -0
  233. package/dist/public/taskforce.png +0 -0
  234. package/dist/resources/templates/WORKFLOW_V2_POLICY.md +55 -0
  235. package/dist/resources/templates/environments/aider.yaml +14 -0
  236. package/dist/resources/templates/environments/antigravity.yaml +10 -0
  237. package/dist/resources/templates/environments/claude.yaml +10 -0
  238. package/dist/resources/templates/environments/cline.yaml +10 -0
  239. package/dist/resources/templates/environments/codex.yaml +13 -0
  240. package/dist/resources/templates/environments/copilot.yaml +10 -0
  241. package/dist/resources/templates/environments/cursor.yaml +9 -0
  242. package/dist/resources/templates/environments/gemini.yaml +9 -0
  243. package/dist/resources/templates/environments/windsurf.yaml +10 -0
  244. package/dist/resources/templates/workflow-sources/README.md +9 -0
  245. package/dist/resources/templates/workflow-sources/workflowDocs.mjs +520 -0
  246. package/dist/resources/templates/workflow-sources/workflowEditorOverrides.json +1 -0
  247. package/dist/resources/templates/workflows/collaborate.yaml +104 -0
  248. package/dist/resources/templates/workflows/do.yaml +82 -0
  249. package/dist/resources/templates/workflows/evaluate.yaml +111 -0
  250. package/dist/resources/templates/workflows/execute.yaml +142 -0
  251. package/dist/resources/templates/workflows/plan.yaml +112 -0
  252. package/dist/resources/templates/workflows/review.yaml +109 -0
  253. package/dist/runtime/capabilities.d.ts +8 -0
  254. package/dist/runtime/capabilities.js +16 -0
  255. package/dist/security/virusScan.d.ts +16 -0
  256. package/dist/security/virusScan.js +83 -0
  257. package/dist/server/auth.d.ts +51 -0
  258. package/dist/server/auth.js +266 -0
  259. package/dist/server/auth.test.d.ts +1 -0
  260. package/dist/server/auth.test.js +111 -0
  261. package/dist/server/buildInfo.d.ts +7 -0
  262. package/dist/server/buildInfo.js +100 -0
  263. package/dist/server/buildInfo.test.d.ts +1 -0
  264. package/dist/server/buildInfo.test.js +29 -0
  265. package/dist/server/cors.d.ts +7 -0
  266. package/dist/server/cors.js +76 -0
  267. package/dist/server/cors.test.d.ts +1 -0
  268. package/dist/server/cors.test.js +71 -0
  269. package/dist/server/email.d.ts +28 -0
  270. package/dist/server/email.js +138 -0
  271. package/dist/server/email.test.d.ts +1 -0
  272. package/dist/server/email.test.js +113 -0
  273. package/dist/server/index.cookieProxy.test.d.ts +1 -0
  274. package/dist/server/index.cookieProxy.test.js +19 -0
  275. package/dist/server/index.d.ts +17 -0
  276. package/dist/server/index.js +757 -0
  277. package/dist/server/index.rateLimit.test.d.ts +1 -0
  278. package/dist/server/index.rateLimit.test.js +13 -0
  279. package/dist/server/index.test.d.ts +1 -0
  280. package/dist/server/index.test.js +19 -0
  281. package/dist/server/rateLimit.d.ts +29 -0
  282. package/dist/server/rateLimit.js +99 -0
  283. package/dist/server/rateLimit.test.d.ts +1 -0
  284. package/dist/server/rateLimit.test.js +107 -0
  285. package/dist/server/realtimeSync.auth.test.d.ts +1 -0
  286. package/dist/server/realtimeSync.auth.test.js +62 -0
  287. package/dist/server/realtimeSync.replay.test.d.ts +1 -0
  288. package/dist/server/realtimeSync.replay.test.js +192 -0
  289. package/dist/server/realtimeSync.workspaceIsolation.test.d.ts +1 -0
  290. package/dist/server/realtimeSync.workspaceIsolation.test.js +101 -0
  291. package/dist/server/realtimeSyncWs.d.ts +30 -0
  292. package/dist/server/realtimeSyncWs.js +257 -0
  293. package/dist/server/routes.billing.d.ts +15 -0
  294. package/dist/server/routes.billing.js +991 -0
  295. package/dist/server/routes.billing.test.d.ts +1 -0
  296. package/dist/server/routes.billing.test.js +387 -0
  297. package/dist/server/routes.d.ts +72 -0
  298. package/dist/server/routes.js +4820 -0
  299. package/dist/server/routes.sync.d.ts +17 -0
  300. package/dist/server/routes.sync.integration.test.d.ts +1 -0
  301. package/dist/server/routes.sync.integration.test.js +470 -0
  302. package/dist/server/routes.sync.js +1141 -0
  303. package/dist/server/routes.test.d.ts +1 -0
  304. package/dist/server/routes.test.js +4945 -0
  305. package/dist/server/runtimeMode.d.ts +4 -0
  306. package/dist/server/runtimeMode.js +25 -0
  307. package/dist/server/runtimeMode.test.d.ts +1 -0
  308. package/dist/server/runtimeMode.test.js +26 -0
  309. package/dist/server/securityHeaders.d.ts +3 -0
  310. package/dist/server/securityHeaders.js +25 -0
  311. package/dist/server/securityHeaders.test.d.ts +1 -0
  312. package/dist/server/securityHeaders.test.js +32 -0
  313. package/dist/server/smtpTransport.d.ts +21 -0
  314. package/dist/server/smtpTransport.js +11 -0
  315. package/dist/server/start.d.ts +1 -0
  316. package/dist/server/start.js +17 -0
  317. package/dist/services/formatTransformers.d.ts +29 -0
  318. package/dist/services/formatTransformers.js +56 -0
  319. package/dist/services/templateEngine.d.ts +46 -0
  320. package/dist/services/templateEngine.js +148 -0
  321. package/dist/shared/runtimeContract.d.ts +12 -0
  322. package/dist/shared/runtimeContract.js +23 -0
  323. package/dist/shared/runtimeContract.test.d.ts +1 -0
  324. package/dist/shared/runtimeContract.test.js +23 -0
  325. package/dist/soak/preflight.cli.d.ts +1 -0
  326. package/dist/soak/preflight.cli.js +28 -0
  327. package/dist/soak/preflight.d.ts +26 -0
  328. package/dist/soak/preflight.js +113 -0
  329. package/dist/soak/preflight.test.d.ts +1 -0
  330. package/dist/soak/preflight.test.js +39 -0
  331. package/dist/soak/syncSoakHarness.d.ts +6 -0
  332. package/dist/soak/syncSoakHarness.js +10 -0
  333. package/dist/storage/createDatabaseAdapter.d.ts +2 -0
  334. package/dist/storage/createDatabaseAdapter.js +9 -0
  335. package/dist/storage/databaseAdapter.d.ts +22 -0
  336. package/dist/storage/databaseAdapter.js +1 -0
  337. package/dist/storage/documentIntegrity.d.ts +28 -0
  338. package/dist/storage/documentIntegrity.js +103 -0
  339. package/dist/storage/documentPurge.d.ts +14 -0
  340. package/dist/storage/documentPurge.js +46 -0
  341. package/dist/storage/documentRegistry.d.ts +39 -0
  342. package/dist/storage/documentRegistry.js +115 -0
  343. package/dist/storage/integritySchedule.d.ts +17 -0
  344. package/dist/storage/integritySchedule.js +62 -0
  345. package/dist/storage/objectStorage.d.ts +20 -0
  346. package/dist/storage/objectStorage.js +15 -0
  347. package/dist/storage/objectStorageClient.d.ts +18 -0
  348. package/dist/storage/objectStorageClient.js +301 -0
  349. package/dist/storage/objectStorageConfig.d.ts +2 -0
  350. package/dist/storage/objectStorageConfig.js +45 -0
  351. package/dist/storage/postgresAdapter.d.ts +20 -0
  352. package/dist/storage/postgresAdapter.js +204 -0
  353. package/dist/storage/postgresWorker.d.ts +1 -0
  354. package/dist/storage/postgresWorker.js +110 -0
  355. package/dist/storage/providerConfig.d.ts +6 -0
  356. package/dist/storage/providerConfig.js +11 -0
  357. package/dist/storage/sqliteAdapter.d.ts +14 -0
  358. package/dist/storage/sqliteAdapter.js +28 -0
  359. package/dist/storage/taskAssetStore.d.ts +56 -0
  360. package/dist/storage/taskAssetStore.js +297 -0
  361. package/dist/sync/cloudSyncApi.d.ts +47 -0
  362. package/dist/sync/cloudSyncApi.js +138 -0
  363. package/dist/sync/cloudSyncApi.test.d.ts +1 -0
  364. package/dist/sync/cloudSyncApi.test.js +54 -0
  365. package/dist/sync/encryptionService.d.ts +11 -0
  366. package/dist/sync/encryptionService.js +61 -0
  367. package/dist/sync/encryptionService.test.d.ts +1 -0
  368. package/dist/sync/encryptionService.test.js +52 -0
  369. package/dist/sync/realtimeFeature.d.ts +6 -0
  370. package/dist/sync/realtimeFeature.js +20 -0
  371. package/dist/sync/realtimeFeature.test.d.ts +1 -0
  372. package/dist/sync/realtimeFeature.test.js +30 -0
  373. package/dist/sync/realtimeReconcile.d.ts +30 -0
  374. package/dist/sync/realtimeReconcile.js +90 -0
  375. package/dist/sync/realtimeReconcile.test.d.ts +1 -0
  376. package/dist/sync/realtimeReconcile.test.js +43 -0
  377. package/dist/sync/syncService.d.ts +96 -0
  378. package/dist/sync/syncService.js +423 -0
  379. package/dist/sync/syncService.test.d.ts +1 -0
  380. package/dist/sync/syncService.test.js +484 -0
  381. package/dist/sync/workspaceSyncModel.d.ts +61 -0
  382. package/dist/sync/workspaceSyncModel.js +136 -0
  383. package/dist/sync/workspaceSyncModel.test.d.ts +1 -0
  384. package/dist/sync/workspaceSyncModel.test.js +73 -0
  385. package/dist/test/setup.d.ts +1 -0
  386. package/dist/test/setup.js +15 -0
  387. package/dist/types.d.ts +157 -0
  388. package/dist/types.js +41 -0
  389. package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
  390. package/dist/ui/assets/index-0W5vyCpn.css +1 -0
  391. package/dist/ui/assets/index-MCZuNNfn.js +23 -0
  392. package/dist/ui/assets/logo_white_01-0Vx-iIIv.png +0 -0
  393. package/dist/ui/assets/taskforce-BxLPokNB.png +0 -0
  394. package/dist/ui/assets/vendor-dnd-BI1K77C1.js +5 -0
  395. package/dist/ui/assets/vendor-icons-ZFG3SGwo.js +1 -0
  396. package/dist/ui/assets/vendor-markdown-CKYkQHfj.js +29 -0
  397. package/dist/ui/assets/vendor-react-DdIDsh7f.js +9 -0
  398. package/dist/ui/assets/vendor-router-BtiNU7Dl.js +3 -0
  399. package/dist/ui/branding/logos/logo_black_01.png +0 -0
  400. package/dist/ui/branding/logos/logo_black_02.png +0 -0
  401. package/dist/ui/branding/logos/logo_white_01.png +0 -0
  402. package/dist/ui/branding/logos/logo_white_02.png +0 -0
  403. package/dist/ui/favicon/android-chrome-192x192.png +0 -0
  404. package/dist/ui/favicon/android-chrome-512x512.png +0 -0
  405. package/dist/ui/favicon/apple-touch-icon.png +0 -0
  406. package/dist/ui/favicon/favicon-16x16.png +0 -0
  407. package/dist/ui/favicon/favicon-32x32.png +0 -0
  408. package/dist/ui/favicon/favicon.ico +0 -0
  409. package/dist/ui/favicon/favicon.png +0 -0
  410. package/dist/ui/favicon/site.webmanifest +1 -0
  411. package/dist/ui/fonts/RussoOne-Regular.ttf +0 -0
  412. package/dist/ui/images/taskforce.png +0 -0
  413. package/dist/ui/index.html +19 -0
  414. package/dist/utils/checklistMarkdown.d.ts +9 -0
  415. package/dist/utils/checklistMarkdown.js +46 -0
  416. package/dist/utils/constants.d.ts +21 -0
  417. package/dist/utils/constants.icons.test.d.ts +1 -0
  418. package/dist/utils/constants.icons.test.js +40 -0
  419. package/dist/utils/constants.js +100 -0
  420. package/dist/utils/contextFiles.d.ts +8 -0
  421. package/dist/utils/contextFiles.js +54 -0
  422. package/dist/utils/formatting.d.ts +5 -0
  423. package/dist/utils/formatting.js +30 -0
  424. package/dist/utils/planParser.d.ts +22 -0
  425. package/dist/utils/planParser.js +151 -0
  426. package/dist/utils/taskDragZones.d.ts +8 -0
  427. package/dist/utils/taskDragZones.js +14 -0
  428. package/dist/utils/taskDragZones.test.d.ts +1 -0
  429. package/dist/utils/taskDragZones.test.js +14 -0
  430. package/dist/utils/taskHierarchy.d.ts +4 -0
  431. package/dist/utils/taskHierarchy.js +69 -0
  432. package/dist/utils/taskHierarchy.test.d.ts +1 -0
  433. package/dist/utils/taskHierarchy.test.js +38 -0
  434. package/dist/utils/taskParenting.d.ts +3 -0
  435. package/dist/utils/taskParenting.js +24 -0
  436. package/dist/utils/taskParenting.test.d.ts +1 -0
  437. package/dist/utils/taskParenting.test.js +21 -0
  438. package/dist/utils/taskRelations.d.ts +6 -0
  439. package/dist/utils/taskRelations.js +24 -0
  440. package/dist/utils/taskRelations.test.d.ts +1 -0
  441. package/dist/utils/taskRelations.test.js +34 -0
  442. package/dist/utils/taskUtils.d.ts +2 -0
  443. package/dist/utils/taskUtils.js +20 -0
  444. package/dist/utils/taskforceApiClient.d.ts +7 -0
  445. package/dist/utils/taskforceApiClient.js +42 -0
  446. package/dist/utils/workspaceIdentity.d.ts +8 -0
  447. package/dist/utils/workspaceIdentity.js +47 -0
  448. package/dist/utils/workspaceIdentity.test.d.ts +1 -0
  449. package/dist/utils/workspaceIdentity.test.js +32 -0
  450. package/dist/utils/workspaceLifecycle.d.ts +26 -0
  451. package/dist/utils/workspaceLifecycle.js +69 -0
  452. package/dist/utils/workspaceLifecycle.test.d.ts +1 -0
  453. package/dist/utils/workspaceLifecycle.test.js +64 -0
  454. package/package.json +133 -0
  455. package/scripts/check-compat-boundaries.js +48 -0
  456. package/scripts/check-env.ts +24 -0
  457. package/scripts/check-localization-strings.mjs +49 -0
  458. package/scripts/check-runtime-boundaries.mjs +44 -0
  459. package/scripts/debug-global.mjs +34 -0
  460. package/scripts/debug-server.mjs +36 -0
  461. package/scripts/export-sqlite-to-postgres.mjs +125 -0
  462. package/scripts/generate-env-example.ts +8 -0
  463. package/scripts/generate-workflow-templates.mjs +134 -0
  464. package/scripts/mcp-stdio-adapter.mjs +160 -0
  465. package/scripts/postinstall.js +60 -0
  466. package/scripts/release-version-lib.mjs +88 -0
  467. package/scripts/release-version.mjs +72 -0
  468. package/scripts/run-dev-proxy.sh +49 -0
  469. package/scripts/run-e2e-realtime.sh +16 -0
  470. package/scripts/run-smoke.sh +23 -0
  471. package/scripts/run-soak.sh +29 -0
  472. package/scripts/test-global.js +143 -0
  473. package/scripts/update-homebrew-formula.sh +38 -0
  474. package/src/resources/templates/WORKFLOW_V2_POLICY.md +55 -0
  475. package/src/resources/templates/environments/aider.yaml +14 -0
  476. package/src/resources/templates/environments/antigravity.yaml +10 -0
  477. package/src/resources/templates/environments/claude.yaml +10 -0
  478. package/src/resources/templates/environments/cline.yaml +10 -0
  479. package/src/resources/templates/environments/codex.yaml +13 -0
  480. package/src/resources/templates/environments/copilot.yaml +10 -0
  481. package/src/resources/templates/environments/cursor.yaml +9 -0
  482. package/src/resources/templates/environments/gemini.yaml +9 -0
  483. package/src/resources/templates/environments/windsurf.yaml +10 -0
  484. package/src/resources/templates/workflow-sources/README.md +9 -0
  485. package/src/resources/templates/workflow-sources/workflowDocs.mjs +520 -0
  486. package/src/resources/templates/workflow-sources/workflowEditorOverrides.json +1 -0
  487. package/src/resources/templates/workflows/collaborate.yaml +104 -0
  488. package/src/resources/templates/workflows/do.yaml +82 -0
  489. package/src/resources/templates/workflows/evaluate.yaml +111 -0
  490. package/src/resources/templates/workflows/execute.yaml +142 -0
  491. package/src/resources/templates/workflows/plan.yaml +112 -0
  492. package/src/resources/templates/workflows/review.yaml +109 -0
@@ -0,0 +1,4396 @@
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
2
+ import { DEFAULT_CONFIG } from '../types';
3
+ import { STATUS_OPTIONS } from '../utils/constants';
4
+ import { sortChildTasksForDisplay } from '../utils/taskRelations';
5
+ import { shouldPromptReparent, wouldCreateParentCycle } from '../utils/taskParenting';
6
+ import { buildHierarchyDepthMap, collectSubtreeTaskIds } from '../utils/taskHierarchy';
7
+ import { PRIORITY_LOGIC } from '../compat/priorityCompat';
8
+ import { migrateCategoryFilterLabels, matchesPriorityFilter, normalizePriorityFilterValues, normalizeTaskFromApi } from '../migrations/clientMigrations';
9
+ import { getSupportedLocales, initializeLocale, setLocale as setAppLocale, t } from '../localization';
10
+ import { WORKSPACE_BOOTSTRAP_SCOPE, isReservedWorkspaceId, normalizeWorkspaceStorageScopeLegacy, normalizeWorkspaceStorageScope, resolveWorkspaceStorageScopeFromProjectRoot } from '../utils/workspaceIdentity';
11
+ import { resolveRuntimeModeContract } from '../shared/runtimeContract';
12
+ import { useSyncOrchestrator } from './useSyncOrchestrator';
13
+ import { deriveAuthGuardPolicy, shouldSkipDataVersionPolling, shouldSkipDataVersionTick } from './authGuardPolicy';
14
+ const ASSIGNEE_OPTIONS = [
15
+ { value: 'agent', label: 'AI' },
16
+ { value: 'user', label: 'User' },
17
+ { value: 'unassigned', label: 'Unassigned' }
18
+ ];
19
+ function generateClientUuid() {
20
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
21
+ return crypto.randomUUID();
22
+ }
23
+ return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 14)}`;
24
+ }
25
+ const DEFAULT_CLOUD_APP_ORIGIN = (() => {
26
+ const configuredBase = typeof import.meta.env.VITE_TASKFORCE_BASE_URL === 'string'
27
+ ? import.meta.env.VITE_TASKFORCE_BASE_URL.trim().replace(/\/+$/, '')
28
+ : '';
29
+ const configuredCloudAuth = typeof import.meta.env.VITE_TASKFORCE_CLOUD_AUTH_BASE_URL === 'string'
30
+ ? import.meta.env.VITE_TASKFORCE_CLOUD_AUTH_BASE_URL.trim().replace(/\/+$/, '')
31
+ : '';
32
+ if (configuredCloudAuth)
33
+ return configuredCloudAuth;
34
+ const configuredApi = typeof import.meta.env.VITE_TASKFORCE_API_BASE_URL === 'string'
35
+ ? import.meta.env.VITE_TASKFORCE_API_BASE_URL.trim().replace(/\/+$/, '')
36
+ : '';
37
+ if (configuredApi)
38
+ return configuredApi;
39
+ if (configuredBase)
40
+ return configuredBase;
41
+ // Safety default for local runtime cloud auth/sync fallback.
42
+ return 'https://app.taskforcehq.ai';
43
+ })();
44
+ const WORKSPACE_CONTEXT_KEY_PREFIX = 'taskforce.workspaceContext.v2';
45
+ const BOOTSTRAP_REQUEST_TIMEOUT_MS = 12_000;
46
+ function buildWorkspaceContextStorageKey(scopeRaw) {
47
+ const scope = normalizeWorkspaceStorageScope(scopeRaw);
48
+ return `${WORKSPACE_CONTEXT_KEY_PREFIX}.${scope}`;
49
+ }
50
+ function buildLegacyWorkspaceContextStorageKey(scopeRaw) {
51
+ const scope = normalizeWorkspaceStorageScopeLegacy(scopeRaw);
52
+ return `${WORKSPACE_CONTEXT_KEY_PREFIX}.${scope}`;
53
+ }
54
+ function readPersistedWorkspaceId(scopeRaw) {
55
+ if (typeof window === 'undefined')
56
+ return '';
57
+ try {
58
+ const scopedKey = buildWorkspaceContextStorageKey(scopeRaw);
59
+ const scopedValue = String(window.localStorage.getItem(scopedKey) || '').trim();
60
+ if (scopedValue && scopedValue.length <= 120 && !/\s/.test(scopedValue))
61
+ return scopedValue;
62
+ const legacyScopedKey = buildLegacyWorkspaceContextStorageKey(scopeRaw);
63
+ const legacyScopedValue = String(window.localStorage.getItem(legacyScopedKey) || '').trim();
64
+ if (legacyScopedValue && legacyScopedValue.length <= 120 && !/\s/.test(legacyScopedValue)) {
65
+ // Migration bridge: promote legacy scope key into hash-scoped key.
66
+ window.localStorage.setItem(scopedKey, legacyScopedValue);
67
+ return legacyScopedValue;
68
+ }
69
+ return '';
70
+ }
71
+ catch {
72
+ return '';
73
+ }
74
+ }
75
+ function writePersistedWorkspaceId(workspaceId, scopeRaw) {
76
+ if (typeof window === 'undefined')
77
+ return;
78
+ const next = String(workspaceId || '').trim();
79
+ try {
80
+ if (!next || isReservedWorkspaceId(next))
81
+ return;
82
+ const storageKey = buildWorkspaceContextStorageKey(scopeRaw);
83
+ const existing = String(window.localStorage.getItem(storageKey) || '').trim();
84
+ if (!next && existing && existing.toLowerCase() !== 'default') {
85
+ return;
86
+ }
87
+ window.localStorage.setItem(storageKey, next);
88
+ }
89
+ catch {
90
+ // best-effort only
91
+ }
92
+ }
93
+ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, onClose }) {
94
+ const mergedConfig = { ...DEFAULT_CONFIG, ...config };
95
+ const { categories, types, apiEndpoint, apiBaseUrl, cloudAuthBaseUrl, wsBaseUrl, shortcut: configShortcut } = mergedConfig;
96
+ const runtimeHost = typeof window !== 'undefined'
97
+ ? String(window.location.hostname || '').trim().toLowerCase()
98
+ : '';
99
+ const isLoopbackHost = runtimeHost === 'localhost' || runtimeHost === '127.0.0.1' || runtimeHost === '::1';
100
+ const isCloudHost = typeof window !== 'undefined' && !isLoopbackHost;
101
+ const [runtimeConfigOverride, setRuntimeConfigOverride] = useState({
102
+ baseUrl: '',
103
+ apiBaseUrl: '',
104
+ cloudAuthBaseUrl: '',
105
+ wsBaseUrl: '',
106
+ cloudAuthViaLocalProxy: false,
107
+ authSource: '',
108
+ workspaceMode: '',
109
+ workspaceSwitchingEnabled: null
110
+ });
111
+ const [runtimeConfigReady, setRuntimeConfigReady] = useState(false);
112
+ const normalizeBaseUrl = useCallback((value) => {
113
+ return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : '';
114
+ }, []);
115
+ useEffect(() => {
116
+ if (typeof window === 'undefined') {
117
+ setRuntimeConfigReady(true);
118
+ return;
119
+ }
120
+ let cancelled = false;
121
+ (async () => {
122
+ try {
123
+ const res = await fetch('/api/taskforce/auth/runtime-config', {
124
+ method: 'GET',
125
+ credentials: 'include'
126
+ });
127
+ if (!res.ok)
128
+ return;
129
+ const payload = await res.json().catch(() => ({}));
130
+ const cfg = payload?.config && typeof payload.config === 'object' ? payload.config : {};
131
+ if (cancelled)
132
+ return;
133
+ const runtimeModeRaw = String(cfg.runtimeMode || '').trim().toLowerCase();
134
+ const runtimeModeHint = runtimeModeRaw === 'cloud' ? 'cloud' : 'local';
135
+ const workspaceModeRaw = String(cfg.workspaceMode || '').trim();
136
+ const workspaceMode = workspaceModeRaw === 'single-local' || workspaceModeRaw === 'multi-cloud'
137
+ ? workspaceModeRaw
138
+ : (runtimeModeHint === 'cloud' ? 'multi-cloud' : 'single-local');
139
+ const authSourceRaw = String(cfg.authSource || '').trim().toLowerCase();
140
+ const authSource = authSourceRaw === 'local' ? 'local' : 'cloud';
141
+ setRuntimeConfigOverride({
142
+ baseUrl: normalizeBaseUrl(cfg.baseUrl),
143
+ apiBaseUrl: normalizeBaseUrl(cfg.apiBaseUrl),
144
+ cloudAuthBaseUrl: normalizeBaseUrl(cfg.cloudAuthBaseUrl),
145
+ wsBaseUrl: normalizeBaseUrl(cfg.wsBaseUrl),
146
+ cloudAuthViaLocalProxy: Boolean(cfg.cloudAuthViaLocalProxy),
147
+ authSource,
148
+ workspaceMode,
149
+ workspaceSwitchingEnabled: typeof cfg.workspaceSwitchingEnabled === 'boolean'
150
+ ? Boolean(cfg.workspaceSwitchingEnabled)
151
+ : (workspaceMode === 'multi-cloud')
152
+ });
153
+ }
154
+ catch {
155
+ // Optional endpoint; ignore failures.
156
+ }
157
+ finally {
158
+ if (!cancelled)
159
+ setRuntimeConfigReady(true);
160
+ }
161
+ })();
162
+ return () => {
163
+ cancelled = true;
164
+ };
165
+ }, [normalizeBaseUrl]);
166
+ const defaultCloudAuthBaseUrl = (() => {
167
+ if (typeof window === 'undefined')
168
+ return '';
169
+ return isLoopbackHost ? DEFAULT_CLOUD_APP_ORIGIN : '';
170
+ })();
171
+ const envBaseUrl = normalizeBaseUrl(import.meta.env.VITE_TASKFORCE_BASE_URL);
172
+ const envApiBaseUrl = normalizeBaseUrl(import.meta.env.VITE_TASKFORCE_API_BASE_URL);
173
+ const envCloudAuthBaseUrl = normalizeBaseUrl(import.meta.env.VITE_TASKFORCE_CLOUD_AUTH_BASE_URL);
174
+ const propApiBaseUrl = normalizeBaseUrl(apiBaseUrl);
175
+ const propCloudAuthBaseUrl = normalizeBaseUrl(cloudAuthBaseUrl);
176
+ const normalizedApiBaseUrl = propApiBaseUrl
177
+ || runtimeConfigOverride.apiBaseUrl
178
+ || runtimeConfigOverride.baseUrl
179
+ || envApiBaseUrl
180
+ || envBaseUrl
181
+ || '';
182
+ const normalizedCloudAuthBaseUrl = (propCloudAuthBaseUrl
183
+ || runtimeConfigOverride.cloudAuthBaseUrl
184
+ || runtimeConfigOverride.baseUrl
185
+ || envCloudAuthBaseUrl
186
+ || envBaseUrl
187
+ || normalizedApiBaseUrl
188
+ || defaultCloudAuthBaseUrl).trim().replace(/\/+$/, '');
189
+ const authOnlyCloudMode = Boolean(normalizedCloudAuthBaseUrl) && !normalizedApiBaseUrl;
190
+ const shouldProbeCloudAuth = runtimeConfigReady && (Boolean(normalizedCloudAuthBaseUrl || normalizedApiBaseUrl) || isCloudHost);
191
+ const shouldGateProtectedApiCalls = shouldProbeCloudAuth && !authOnlyCloudMode;
192
+ const cloudAuthConfigured = Boolean(normalizedCloudAuthBaseUrl || normalizedApiBaseUrl || isCloudHost);
193
+ const initialRuntimeMode = isCloudHost ? 'cloud' : 'local';
194
+ const resolveApiUrl = useCallback((path) => {
195
+ if (!path)
196
+ return path;
197
+ if (/^https?:\/\//i.test(path))
198
+ return path;
199
+ if (!normalizedApiBaseUrl || !path.startsWith('/'))
200
+ return path;
201
+ return `${normalizedApiBaseUrl}${path}`;
202
+ }, [normalizedApiBaseUrl]);
203
+ const resolveCloudAuthUrl = useCallback((path) => {
204
+ if (!path)
205
+ return path;
206
+ if (/^https?:\/\//i.test(path))
207
+ return path;
208
+ if (!path.startsWith('/'))
209
+ return path;
210
+ const isCloudScopedPath = path.startsWith('/api/taskforce/auth/') || path.startsWith('/api/taskforce/sync/');
211
+ if (!isCloudHost && isCloudScopedPath && (!runtimeConfigReady || runtimeConfigOverride.cloudAuthViaLocalProxy)) {
212
+ return path;
213
+ }
214
+ const base = normalizedCloudAuthBaseUrl || normalizedApiBaseUrl;
215
+ if (base) {
216
+ const candidate = `${base}${path}`;
217
+ if (typeof window !== 'undefined' && !isCloudHost && isCloudScopedPath) {
218
+ try {
219
+ const resolved = new URL(candidate, window.location.origin);
220
+ // In local runtime, never route cloud auth/sync to the local origin.
221
+ if (resolved.origin === window.location.origin) {
222
+ return `${DEFAULT_CLOUD_APP_ORIGIN}${path}`;
223
+ }
224
+ }
225
+ catch {
226
+ return `${DEFAULT_CLOUD_APP_ORIGIN}${path}`;
227
+ }
228
+ }
229
+ return candidate;
230
+ }
231
+ if (!isCloudHost && isCloudScopedPath)
232
+ return path;
233
+ return path;
234
+ }, [runtimeConfigReady, runtimeConfigOverride.cloudAuthViaLocalProxy, normalizedCloudAuthBaseUrl, normalizedApiBaseUrl, isCloudHost]);
235
+ const resolveWebSocketUrl = useCallback((path) => {
236
+ const wsPath = String(path || '').trim() || '/taskforce-ws';
237
+ const normalizedPath = wsPath.startsWith('/') ? wsPath : `/${wsPath}`;
238
+ const configuredBase = String(wsBaseUrl
239
+ || runtimeConfigOverride.wsBaseUrl
240
+ || normalizedApiBaseUrl
241
+ || '').trim().replace(/\/+$/, '');
242
+ const fallbackBase = typeof window !== 'undefined' ? window.location.origin : '';
243
+ const base = configuredBase || fallbackBase;
244
+ if (!base)
245
+ return '';
246
+ try {
247
+ const resolved = new URL(normalizedPath, base);
248
+ if (resolved.protocol === 'https:')
249
+ resolved.protocol = 'wss:';
250
+ if (resolved.protocol === 'http:')
251
+ resolved.protocol = 'ws:';
252
+ return resolved.toString();
253
+ }
254
+ catch {
255
+ return '';
256
+ }
257
+ }, [wsBaseUrl, runtimeConfigOverride.wsBaseUrl, normalizedApiBaseUrl]);
258
+ // Tab state
259
+ const [activeTab, setActiveTab__internal] = useState('tasks');
260
+ const [isOpen, setIsOpen] = useState(false);
261
+ const [showArchive, setShowArchive] = useState(false);
262
+ // Settings state
263
+ const [currentTheme, setCurrentTheme] = useState(mergedConfig.theme || 'dark');
264
+ const [globalTheme, setGlobalTheme] = useState(mergedConfig.theme || 'dark');
265
+ const [themeUseGlobalDefault, setThemeUseGlobalDefault] = useState(true);
266
+ const [storagePath] = useState('.taskforce');
267
+ const pathSaved = false;
268
+ const [showFolderBrowser, setShowFolderBrowser] = useState(false);
269
+ const [folders, setFolders] = useState([]);
270
+ const [files, setFiles] = useState([]);
271
+ const [currentBrowsePath, setCurrentBrowsePath] = useState('');
272
+ const [browserTarget, setBrowserTarget] = useState(null);
273
+ const [contextLinkBrowsePath, setContextLinkBrowsePath] = useState('');
274
+ const [settingsSection, setSettingsSection] = useState(null);
275
+ const [projectRoot, setProjectRoot] = useState('');
276
+ const [projectName, setProjectName] = useState('');
277
+ const workspaceStorageScope = useMemo(() => resolveWorkspaceStorageScopeFromProjectRoot(projectRoot), [projectRoot]);
278
+ const [mcpScriptPath, setMcpScriptPath] = useState('');
279
+ const [tenantId, setTenantId] = useState('');
280
+ const [availableSpecialists, setAvailableSpecialists] = useState([]);
281
+ const [runtimeMode, setRuntimeMode] = useState(() => initialRuntimeMode);
282
+ const runtimeContractFallback = resolveRuntimeModeContract(runtimeMode);
283
+ const workspaceMode = runtimeConfigOverride.workspaceMode === 'single-local' || runtimeConfigOverride.workspaceMode === 'multi-cloud'
284
+ ? runtimeConfigOverride.workspaceMode
285
+ : runtimeContractFallback.workspaceMode;
286
+ const workspaceSwitchingEnabled = typeof runtimeConfigOverride.workspaceSwitchingEnabled === 'boolean'
287
+ ? runtimeConfigOverride.workspaceSwitchingEnabled
288
+ : runtimeContractFallback.workspaceSwitchingEnabled;
289
+ const [authRequiredForApi, setAuthRequiredForApi] = useState(false);
290
+ const [authBlocked, setAuthBlocked] = useState(false);
291
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
292
+ const [authUserId, setAuthUserId] = useState('anonymous');
293
+ const [authUserEmail, setAuthUserEmail] = useState('');
294
+ const [hasBetaAccess, setHasBetaAccess] = useState(true);
295
+ const [currentWorkspaceId, setCurrentWorkspaceId] = useState(() => {
296
+ // Local runtime workspace identity must be sourced from local DB (/api/taskforce/config),
297
+ // not from persisted browser context or cloud session payloads.
298
+ if (initialRuntimeMode === 'local')
299
+ return 'default';
300
+ const persisted = readPersistedWorkspaceId(workspaceStorageScope);
301
+ return persisted && persisted.toLowerCase() !== 'default' ? persisted : 'default';
302
+ });
303
+ const [availableWorkspaces, setAvailableWorkspaces] = useState([]);
304
+ const [workspaceBootstrapPending, setWorkspaceBootstrapPending] = useState(false);
305
+ const [authSessionResolved, setAuthSessionResolved] = useState(false);
306
+ const [bootstrapPhase, setBootstrapPhase] = useState('idle');
307
+ const [bootstrapError, setBootstrapError] = useState(null);
308
+ const [bootstrapStartedAt, setBootstrapStartedAt] = useState(null);
309
+ const authSessionRequestRef = useRef(null);
310
+ const authSessionLastCheckedAtRef = useRef(0);
311
+ const authSessionLastResultRef = useRef(false);
312
+ // Local runtime APIs must stay available immediately on app start, even when cloud
313
+ // auth probing is still in-flight. Only cloud runtime should defer/block protected APIs.
314
+ const authGuardPolicy = useMemo(() => deriveAuthGuardPolicy({
315
+ shouldGateProtectedApiCalls,
316
+ runtimeMode,
317
+ authSessionResolved,
318
+ authRequiredForApi,
319
+ isAuthenticated
320
+ }), [shouldGateProtectedApiCalls, runtimeMode, authSessionResolved, authRequiredForApi, isAuthenticated]);
321
+ const shouldDeferProtectedApiCalls = authGuardPolicy.shouldDeferProtectedApiCalls;
322
+ const shouldBlockProtectedApiCalls = authGuardPolicy.shouldBlockProtectedApiCalls;
323
+ const [setupState, setSetupState] = useState(null);
324
+ const [runtimeCapabilities, setRuntimeCapabilities] = useState(null);
325
+ const [realtimeSyncEnabled, setRealtimeSyncEnabled] = useState(false);
326
+ const [realtimeSyncFlagSource, setRealtimeSyncFlagSource] = useState('unknown');
327
+ const [buildInfo, setBuildInfo] = useState(null);
328
+ const [keyShortcut, setKeyShortcut] = useState(mergedConfig.shortcut || 'Alt+T');
329
+ const [priorities, setPriorities] = useState(mergedConfig.priorities || []);
330
+ const [approaches, setApproaches] = useState(mergedConfig.approaches || []);
331
+ const [mcpHostRoot, setMcpHostRoot] = useState('');
332
+ const [jsonBackupEnabled, setJsonBackupEnabled] = useState(false);
333
+ const [globalJsonBackupEnabled, setGlobalJsonBackupEnabled] = useState(false);
334
+ const [jsonBackupUseGlobalDefault, setJsonBackupUseGlobalDefault] = useState(true);
335
+ const [locale, setLocale] = useState(() => initializeLocale());
336
+ const supportedLocales = useMemo(() => getSupportedLocales(), []);
337
+ const [globalWeekStartsOn, setGlobalWeekStartsOn] = useState(() => {
338
+ try {
339
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale || '';
340
+ return locale.toLowerCase().startsWith('en-us') ? 'sunday' : 'monday';
341
+ }
342
+ catch {
343
+ return 'monday';
344
+ }
345
+ });
346
+ const [manualComplexityEnabled, setManualComplexityEnabled] = useState(false);
347
+ const [checklistDropdownEnabled, setChecklistDropdownEnabled] = useState(false);
348
+ const [assigneeFilterEnabled, setAssigneeFilterEnabled] = useState(false);
349
+ const [serverHostRoot, setServerHostRoot] = useState('');
350
+ const [returnTab, setReturnTab] = useState(null);
351
+ useEffect(() => {
352
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function')
353
+ return;
354
+ const originalFetch = window.fetch.bind(window);
355
+ const isLocalOnlyTaskforcePath = (pathname) => (pathname === '/api/taskforce/sync/workspace/apply-local');
356
+ const rewriteTaskforceApiRequest = (input) => {
357
+ if (typeof input === 'string') {
358
+ if (runtimeMode !== 'cloud')
359
+ return input;
360
+ return (input.startsWith('/api/taskforce') && !isLocalOnlyTaskforcePath(input)) ? resolveApiUrl(input) : input;
361
+ }
362
+ if (input instanceof URL) {
363
+ if (runtimeMode !== 'cloud')
364
+ return input;
365
+ if (input.origin === window.location.origin && input.pathname.startsWith('/api/taskforce')) {
366
+ if (isLocalOnlyTaskforcePath(input.pathname))
367
+ return input;
368
+ return new URL(resolveApiUrl(`${input.pathname}${input.search}${input.hash}`));
369
+ }
370
+ return input;
371
+ }
372
+ if (typeof Request !== 'undefined' && input instanceof Request) {
373
+ if (runtimeMode !== 'cloud')
374
+ return input;
375
+ const parsed = new URL(input.url, window.location.origin);
376
+ if (parsed.origin === window.location.origin && parsed.pathname.startsWith('/api/taskforce')) {
377
+ if (isLocalOnlyTaskforcePath(parsed.pathname))
378
+ return input;
379
+ return resolveApiUrl(`${parsed.pathname}${parsed.search}${parsed.hash}`);
380
+ }
381
+ }
382
+ return input;
383
+ };
384
+ const resolveInputUrl = (input) => {
385
+ try {
386
+ if (typeof input === 'string')
387
+ return new URL(input, window.location.origin);
388
+ if (input instanceof URL)
389
+ return input;
390
+ if (typeof Request !== 'undefined' && input instanceof Request) {
391
+ return new URL(input.url, window.location.origin);
392
+ }
393
+ return null;
394
+ }
395
+ catch {
396
+ return null;
397
+ }
398
+ };
399
+ window.fetch = ((input, init) => {
400
+ const nextInput = rewriteTaskforceApiRequest(input);
401
+ const resolvedUrl = resolveInputUrl(nextInput);
402
+ const shouldInjectWorkspaceHeader = Boolean(resolvedUrl
403
+ && resolvedUrl.origin === window.location.origin
404
+ && resolvedUrl.pathname.startsWith('/api/taskforce'));
405
+ if (!shouldInjectWorkspaceHeader) {
406
+ return originalFetch(nextInput, init);
407
+ }
408
+ const workspaceIdHeader = String(currentWorkspaceId || '').trim();
409
+ if (typeof Request !== 'undefined' && nextInput instanceof Request) {
410
+ const headers = new Headers(nextInput.headers);
411
+ if (init?.headers) {
412
+ const initHeaders = new Headers(init.headers);
413
+ initHeaders.forEach((value, key) => headers.set(key, value));
414
+ }
415
+ if (runtimeMode === 'cloud' && workspaceIdHeader && workspaceIdHeader !== 'default' && !headers.has('x-taskforce-workspace-id')) {
416
+ headers.set('x-taskforce-workspace-id', workspaceIdHeader);
417
+ }
418
+ const request = new Request(nextInput, { ...init, headers });
419
+ return originalFetch(request);
420
+ }
421
+ const headers = new Headers(init?.headers);
422
+ if (runtimeMode === 'cloud' && workspaceIdHeader && workspaceIdHeader !== 'default' && !headers.has('x-taskforce-workspace-id')) {
423
+ headers.set('x-taskforce-workspace-id', workspaceIdHeader);
424
+ }
425
+ return originalFetch(nextInput, { ...init, headers });
426
+ });
427
+ return () => {
428
+ window.fetch = originalFetch;
429
+ };
430
+ }, [currentWorkspaceId, normalizedApiBaseUrl, resolveApiUrl, runtimeMode]);
431
+ // Available Workflows
432
+ const [availableWorkflows, setAvailableWorkflows] = useState([]);
433
+ const [initiativeTemplates, setInitiativeTemplates] = useState([]);
434
+ const fetchWorkflows = useCallback(async () => {
435
+ if (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls)
436
+ return;
437
+ try {
438
+ const res = await fetch('/api/taskforce/workflow-templates');
439
+ if (res.ok) {
440
+ const data = await res.json();
441
+ setAvailableWorkflows(data.templates || []);
442
+ }
443
+ }
444
+ catch {
445
+ // Ignore error
446
+ }
447
+ }, [shouldDeferProtectedApiCalls, shouldBlockProtectedApiCalls]);
448
+ const fetchInitiativeTemplates = useCallback(async () => {
449
+ try {
450
+ const res = await fetch('/api/taskforce/initiative-templates');
451
+ if (!res.ok)
452
+ return [];
453
+ const data = await res.json();
454
+ const templates = Array.isArray(data?.templates) ? data.templates : [];
455
+ setInitiativeTemplates(templates);
456
+ return templates;
457
+ }
458
+ catch {
459
+ return [];
460
+ }
461
+ }, []);
462
+ const fetchWorkflowTemplate = useCallback(async (name) => {
463
+ if (!name)
464
+ return null;
465
+ const paths = [
466
+ `/api/taskforce/workflow-template/${encodeURIComponent(name)}`,
467
+ `/api/taskforce/workflow-templates/${encodeURIComponent(name)}`
468
+ ];
469
+ try {
470
+ for (const url of paths) {
471
+ const res = await fetch(url);
472
+ if (!res.ok)
473
+ continue;
474
+ const data = await res.json();
475
+ if (data?.template)
476
+ return data.template;
477
+ }
478
+ return null;
479
+ }
480
+ catch {
481
+ return null;
482
+ }
483
+ }, []);
484
+ const fetchWorkflowOverrideNames = useCallback(async () => {
485
+ try {
486
+ const res = await fetch('/api/taskforce/workflow-editor/overrides');
487
+ if (!res.ok)
488
+ return [];
489
+ const data = await res.json();
490
+ return Array.isArray(data?.names) ? data.names : [];
491
+ }
492
+ catch {
493
+ return [];
494
+ }
495
+ }, []);
496
+ const saveWorkflowTemplateDraft = useCallback(async (name, draft) => {
497
+ try {
498
+ const res = await fetch('/api/taskforce/workflow-editor/save', {
499
+ method: 'POST',
500
+ headers: { 'Content-Type': 'application/json' },
501
+ body: JSON.stringify({ name, draft })
502
+ });
503
+ const data = await res.json().catch(() => ({}));
504
+ if (!res.ok || !data.success) {
505
+ return { success: false, error: data.error || `Save failed (${res.status})` };
506
+ }
507
+ fetchWorkflows();
508
+ return { success: true };
509
+ }
510
+ catch (error) {
511
+ return { success: false, error: error.message };
512
+ }
513
+ }, [fetchWorkflows]);
514
+ const resetWorkflowTemplateDraft = useCallback(async (name) => {
515
+ try {
516
+ const res = await fetch(`/api/taskforce/workflow-editor/reset/${encodeURIComponent(name)}`, {
517
+ method: 'POST'
518
+ });
519
+ const data = await res.json().catch(() => ({}));
520
+ if (!res.ok || !data.success) {
521
+ return { success: false, error: data.error || `Reset failed (${res.status})` };
522
+ }
523
+ fetchWorkflows();
524
+ return { success: true };
525
+ }
526
+ catch (error) {
527
+ return { success: false, error: error.message };
528
+ }
529
+ }, [fetchWorkflows]);
530
+ // Resource Export state
531
+ const [exportEnvironment, setExportEnvironment] = useState('antigravity');
532
+ const [availableEnvironments, setAvailableEnvironments] = useState([]);
533
+ const [groupBy, setGroupBy] = useState('category');
534
+ const [emptyColumnMode, setEmptyColumnMode] = useState('show');
535
+ const [zenMode, setZenMode] = useState(false);
536
+ const [exportWorkflowsPath, setExportWorkflowsPath] = useState('.agent/workflows');
537
+ const [exportSpecialistsPath, setExportSpecialistsPath] = useState('.tasknexus/agents');
538
+ const [exportingResource, setExportingResource] = useState(null);
539
+ const [exportResult, setExportResult] = useState(null);
540
+ // Update paths when environment changes
541
+ useEffect(() => {
542
+ const env = availableEnvironments.find(e => e.id === exportEnvironment);
543
+ if (env) {
544
+ setExportWorkflowsPath(env.directory);
545
+ // Standardized specialists export path across all environments
546
+ setExportSpecialistsPath('.tasknexus/agents');
547
+ }
548
+ }, [exportEnvironment, availableEnvironments]);
549
+ // Unsaved Changes Modal State
550
+ const [unsavedModalOpen, setUnsavedModalOpen] = useState(false);
551
+ const [pendingNavigation, setPendingNavigation] = useState(null);
552
+ const [successBanner, setSuccessBanner] = useState(null);
553
+ const [returnToParentTaskId, setReturnToParentTaskId] = useState(null);
554
+ const [returnFromChildTaskId, setReturnFromChildTaskId] = useState(null);
555
+ const tasksScrollRef = useRef(null);
556
+ const [tasksScrollPos, setTasksScrollPos] = useState(0);
557
+ // Path normalization helper
558
+ const normalizePath = useCallback((fullPath) => {
559
+ if (!fullPath || !projectRoot)
560
+ return fullPath;
561
+ if (fullPath.startsWith(projectRoot)) {
562
+ return fullPath.slice(projectRoot.length).replace(/^[/\\]+/, '');
563
+ }
564
+ return fullPath;
565
+ }, [projectRoot]);
566
+ // Fullscreen / Zen Mode logic
567
+ const toggleZenMode = useCallback(async () => {
568
+ if (!document.fullscreenElement) {
569
+ try {
570
+ await document.documentElement.requestFullscreen();
571
+ setZenMode(true);
572
+ }
573
+ catch (err) {
574
+ console.error(`Error attempting to enable full-screen mode: ${err}`);
575
+ }
576
+ }
577
+ else {
578
+ if (document.exitFullscreen) {
579
+ await document.exitFullscreen();
580
+ setZenMode(false);
581
+ }
582
+ }
583
+ }, [zenMode]);
584
+ useEffect(() => {
585
+ const handleFullscreenChange = () => {
586
+ setZenMode(!!document.fullscreenElement);
587
+ };
588
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
589
+ return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
590
+ }, []);
591
+ const handleSaveSettings = useCallback(async () => {
592
+ // Save keyboard shortcut only
593
+ try {
594
+ const globalRes = await fetch('/api/taskforce/global-settings', {
595
+ method: 'POST',
596
+ headers: { 'Content-Type': 'application/json' },
597
+ body: JSON.stringify({
598
+ shortcut: keyShortcut
599
+ }),
600
+ });
601
+ if (!globalRes.ok) {
602
+ throw new Error(`Failed to save global settings (${globalRes.status})`);
603
+ }
604
+ return true;
605
+ }
606
+ catch {
607
+ console.error('[Taskforce] Failed to save shortcut');
608
+ return false;
609
+ }
610
+ }, [keyShortcut]);
611
+ const handleSaveTheme = useCallback(async (theme) => {
612
+ setCurrentTheme(theme);
613
+ setThemeUseGlobalDefault(false);
614
+ try {
615
+ const configRes = await fetch('/api/taskforce/config', {
616
+ method: 'POST',
617
+ headers: { 'Content-Type': 'application/json' },
618
+ body: JSON.stringify({ theme }),
619
+ });
620
+ if (!configRes.ok) {
621
+ throw new Error(`Failed to save config (${configRes.status})`);
622
+ }
623
+ return true;
624
+ }
625
+ catch {
626
+ console.error('[Taskforce] Failed to save project theme');
627
+ return false;
628
+ }
629
+ }, []);
630
+ const handleSaveGlobalTheme = useCallback(async (theme) => {
631
+ setGlobalTheme(theme);
632
+ if (themeUseGlobalDefault) {
633
+ setCurrentTheme(theme);
634
+ }
635
+ try {
636
+ const globalRes = await fetch('/api/taskforce/global-settings', {
637
+ method: 'POST',
638
+ headers: { 'Content-Type': 'application/json' },
639
+ body: JSON.stringify({ theme }),
640
+ });
641
+ if (!globalRes.ok) {
642
+ throw new Error(`Failed to save global settings (${globalRes.status})`);
643
+ }
644
+ return true;
645
+ }
646
+ catch {
647
+ console.error('[Taskforce] Failed to save global theme');
648
+ return false;
649
+ }
650
+ }, [themeUseGlobalDefault]);
651
+ const handleJsonBackupEnabledChange = useCallback(async (enabled) => {
652
+ setJsonBackupEnabled(enabled);
653
+ setJsonBackupUseGlobalDefault(false);
654
+ try {
655
+ const configRes = await fetch('/api/taskforce/config', {
656
+ method: 'POST',
657
+ headers: { 'Content-Type': 'application/json' },
658
+ body: JSON.stringify({ jsonBackupEnabled: enabled }),
659
+ });
660
+ if (!configRes.ok) {
661
+ throw new Error(`Failed to save config (${configRes.status})`);
662
+ }
663
+ return true;
664
+ }
665
+ catch {
666
+ console.error('[Taskforce] Failed to save project backup setting');
667
+ return false;
668
+ }
669
+ }, []);
670
+ const handleSaveGlobalJsonBackupEnabled = useCallback(async (enabled) => {
671
+ setGlobalJsonBackupEnabled(enabled);
672
+ if (jsonBackupUseGlobalDefault) {
673
+ setJsonBackupEnabled(enabled);
674
+ }
675
+ try {
676
+ const globalRes = await fetch('/api/taskforce/global-settings', {
677
+ method: 'POST',
678
+ headers: { 'Content-Type': 'application/json' },
679
+ body: JSON.stringify({ jsonBackupEnabled: enabled }),
680
+ });
681
+ if (!globalRes.ok) {
682
+ throw new Error(`Failed to save global settings (${globalRes.status})`);
683
+ }
684
+ return true;
685
+ }
686
+ catch {
687
+ console.error('[Taskforce] Failed to save global backup setting');
688
+ return false;
689
+ }
690
+ }, [jsonBackupUseGlobalDefault]);
691
+ const handleSaveGlobalWeekStartsOn = useCallback(async (weekStartsOn) => {
692
+ setGlobalWeekStartsOn(weekStartsOn);
693
+ try {
694
+ const globalRes = await fetch('/api/taskforce/global-settings', {
695
+ method: 'POST',
696
+ headers: { 'Content-Type': 'application/json' },
697
+ body: JSON.stringify({
698
+ schedulePreferences: {
699
+ weekStartsOn
700
+ }
701
+ }),
702
+ });
703
+ if (!globalRes.ok) {
704
+ throw new Error(`Failed to save global settings (${globalRes.status})`);
705
+ }
706
+ return true;
707
+ }
708
+ catch {
709
+ console.error('[Taskforce] Failed to save regional week start setting');
710
+ return false;
711
+ }
712
+ }, []);
713
+ const handleSaveLocale = useCallback(async (nextLocale) => {
714
+ const applied = setAppLocale(nextLocale);
715
+ setLocale(applied);
716
+ return true;
717
+ }, []);
718
+ const handleManualComplexityEnabledChange = useCallback(async (enabled) => {
719
+ setManualComplexityEnabled(enabled);
720
+ try {
721
+ const configRes = await fetch('/api/taskforce/config', {
722
+ method: 'POST',
723
+ headers: { 'Content-Type': 'application/json' },
724
+ body: JSON.stringify({ manualComplexityEnabled: enabled }),
725
+ });
726
+ if (!configRes.ok) {
727
+ throw new Error(`Failed to save config (${configRes.status})`);
728
+ }
729
+ return true;
730
+ }
731
+ catch {
732
+ console.error('[Taskforce] Failed to save manual complexity setting');
733
+ return false;
734
+ }
735
+ }, []);
736
+ const handleChecklistDropdownEnabledChange = useCallback(async (enabled) => {
737
+ setChecklistDropdownEnabled(enabled);
738
+ if (!enabled)
739
+ setShowChecklist(false);
740
+ try {
741
+ const configRes = await fetch('/api/taskforce/config', {
742
+ method: 'POST',
743
+ headers: { 'Content-Type': 'application/json' },
744
+ body: JSON.stringify({ checklistDropdownEnabled: enabled }),
745
+ });
746
+ if (!configRes.ok) {
747
+ throw new Error(`Failed to save config (${configRes.status})`);
748
+ }
749
+ return true;
750
+ }
751
+ catch {
752
+ console.error('[Taskforce] Failed to save checklist dropdown setting');
753
+ return false;
754
+ }
755
+ }, []);
756
+ const handleAssigneeFilterEnabledChange = useCallback(async (enabled) => {
757
+ setAssigneeFilterEnabled(enabled);
758
+ try {
759
+ const configRes = await fetch('/api/taskforce/config', {
760
+ method: 'POST',
761
+ headers: { 'Content-Type': 'application/json' },
762
+ body: JSON.stringify({ assigneeFilterEnabled: enabled }),
763
+ });
764
+ if (!configRes.ok) {
765
+ throw new Error(`Failed to save config (${configRes.status})`);
766
+ }
767
+ return true;
768
+ }
769
+ catch {
770
+ console.error('[Taskforce] Failed to save assignee filter setting');
771
+ return false;
772
+ }
773
+ }, []);
774
+ const handleResetProjectToGlobal = useCallback(async () => {
775
+ setThemeUseGlobalDefault(true);
776
+ setJsonBackupUseGlobalDefault(true);
777
+ setCurrentTheme(globalTheme);
778
+ setJsonBackupEnabled(globalJsonBackupEnabled);
779
+ setManualComplexityEnabled(false);
780
+ setChecklistDropdownEnabled(false);
781
+ setAssigneeFilterEnabled(false);
782
+ setShowChecklist(false);
783
+ try {
784
+ const configRes = await fetch('/api/taskforce/config', {
785
+ method: 'POST',
786
+ headers: { 'Content-Type': 'application/json' },
787
+ body: JSON.stringify({
788
+ theme: null,
789
+ jsonBackupEnabled: null,
790
+ manualComplexityEnabled: null,
791
+ checklistDropdownEnabled: null,
792
+ assigneeFilterEnabled: null
793
+ }),
794
+ });
795
+ if (!configRes.ok) {
796
+ throw new Error(`Failed to save config (${configRes.status})`);
797
+ }
798
+ return true;
799
+ }
800
+ catch {
801
+ console.error('[Taskforce] Failed to reset project settings');
802
+ return false;
803
+ }
804
+ }, [globalTheme, globalJsonBackupEnabled]);
805
+ const handleExportResources = useCallback(async (type, path) => {
806
+ setExportingResource(type);
807
+ setExportResult(null);
808
+ try {
809
+ const res = await fetch('/api/taskforce/export-resources', {
810
+ method: 'POST',
811
+ headers: { 'Content-Type': 'application/json' },
812
+ body: JSON.stringify({
813
+ type,
814
+ path,
815
+ variables: {
816
+ STORAGE_PATH: storagePath.replace(/\/$/, '') || '.Taskforce',
817
+ WORKFLOWS_PATH: exportWorkflowsPath.replace(/\/$/, ''),
818
+ SPECIALISTS_PATH: exportSpecialistsPath.replace(/\/$/, '')
819
+ },
820
+ environment: exportEnvironment
821
+ })
822
+ });
823
+ const data = await res.json();
824
+ if (data.success) {
825
+ setExportResult({ type: 'success', message: `Successfully exported ${data.count} ${type} to ${path}` });
826
+ }
827
+ else {
828
+ setExportResult({ type: 'error', message: data.error || `Failed to export ${type}` });
829
+ }
830
+ }
831
+ catch (error) {
832
+ setExportResult({ type: 'error', message: `Network error exporting ${type}` });
833
+ }
834
+ finally {
835
+ setExportingResource(null);
836
+ setTimeout(() => setExportResult(null), 5000);
837
+ }
838
+ }, [storagePath, exportWorkflowsPath, exportSpecialistsPath, exportEnvironment]);
839
+ const handleExportWorkflows = useCallback(async (environment, selected) => {
840
+ setExportingResource('workflows');
841
+ setExportResult(null);
842
+ try {
843
+ const res = await fetch('/api/taskforce/export-resources', {
844
+ method: 'POST',
845
+ headers: { 'Content-Type': 'application/json' },
846
+ body: JSON.stringify({
847
+ type: 'workflows',
848
+ environment,
849
+ workflowNames: selected,
850
+ variables: {
851
+ STORAGE_PATH: storagePath.replace(/\/$/, '') || '.Taskforce'
852
+ }
853
+ })
854
+ });
855
+ const data = await res.json();
856
+ if (data.success) {
857
+ setExportResult({ type: 'success', message: `Successfully exported ${data.count} workflows to ${environment}` });
858
+ }
859
+ else {
860
+ setExportResult({ type: 'error', message: data.error || 'Export failed' });
861
+ }
862
+ }
863
+ catch {
864
+ setExportResult({ type: 'error', message: 'Network error exporting workflows' });
865
+ }
866
+ finally {
867
+ setExportingResource(null);
868
+ setTimeout(() => setExportResult(null), 5000);
869
+ }
870
+ }, [storagePath]);
871
+ // Tasks state
872
+ const [tasks, setTasks] = useState([]);
873
+ const [archivedTasks, setArchivedTasks] = useState([]);
874
+ const [loadingTasks, setLoadingTasks] = useState(false);
875
+ const [editingTaskId, setEditingTaskId] = useState(null);
876
+ const [showMarkdownHelp, setShowMarkdownHelp] = useState(false);
877
+ const [copiedId, setCopiedId] = useState(null);
878
+ const [cancellationPromptTask, setCancellationPromptTask] = useState(null);
879
+ const [cancellationOpenDescendantIds, setCancellationOpenDescendantIds] = useState([]);
880
+ const [cancellationMode, setCancellationMode] = useState(null);
881
+ // Custom categories
882
+ const [customCategories, setCustomCategories] = useState([]);
883
+ const [configLoaded, setConfigLoaded] = useState(false); // Track if API config has been fetched
884
+ const [pathValidation, setPathValidation] = useState({}); // Validation results
885
+ const bootstrapGuardRef = useRef({
886
+ authSessionResolved: false,
887
+ configLoaded: false,
888
+ workspaceBootstrapPending: false
889
+ });
890
+ useEffect(() => {
891
+ bootstrapGuardRef.current = {
892
+ authSessionResolved,
893
+ configLoaded,
894
+ workspaceBootstrapPending
895
+ };
896
+ }, [authSessionResolved, configLoaded, workspaceBootstrapPending]);
897
+ const markBootstrapPhase = useCallback((phase) => {
898
+ setBootstrapPhase(phase);
899
+ if (phase !== 'ready') {
900
+ setBootstrapError(null);
901
+ setBootstrapStartedAt(Date.now());
902
+ }
903
+ }, []);
904
+ const markBootstrapStalled = useCallback((message) => {
905
+ // Only surface stalled bootstrap state while initial app bootstrap is unresolved.
906
+ const guard = bootstrapGuardRef.current;
907
+ if (guard.authSessionResolved && guard.configLoaded && !guard.workspaceBootstrapPending)
908
+ return;
909
+ setBootstrapPhase('stalled');
910
+ setBootstrapError(message);
911
+ setBootstrapStartedAt((prev) => prev ?? Date.now());
912
+ }, []);
913
+ const fetchWithTimeout = useCallback(async (url, init, timeoutMs = BOOTSTRAP_REQUEST_TIMEOUT_MS) => {
914
+ const abortController = new AbortController();
915
+ const timeoutLabel = Number(timeoutMs) > 0 ? Number(timeoutMs) : BOOTSTRAP_REQUEST_TIMEOUT_MS;
916
+ const timerId = typeof window !== 'undefined'
917
+ ? window.setTimeout(() => abortController.abort('bootstrap-timeout'), timeoutLabel)
918
+ : null;
919
+ try {
920
+ return await fetch(url, {
921
+ ...(init || {}),
922
+ signal: abortController.signal
923
+ });
924
+ }
925
+ catch (error) {
926
+ const isAbortError = error instanceof DOMException
927
+ ? error.name === 'AbortError'
928
+ : String(error?.name || '').toLowerCase() === 'aborterror';
929
+ if (isAbortError) {
930
+ const timeoutError = new Error(`Startup checks timed out after ${timeoutLabel}ms.`);
931
+ timeoutError.name = 'BootstrapTimeoutError';
932
+ throw timeoutError;
933
+ }
934
+ throw error;
935
+ }
936
+ finally {
937
+ if (timerId !== null && typeof window !== 'undefined') {
938
+ window.clearTimeout(timerId);
939
+ }
940
+ }
941
+ }, []);
942
+ const isBootstrapTimeoutError = useCallback((error) => {
943
+ return String(error?.name || '') === 'BootstrapTimeoutError';
944
+ }, []);
945
+ // Custom types
946
+ const [customTypes, setCustomTypes] = useState([]);
947
+ // Generic Taxonomies (Phase 1)
948
+ const [taxonomies, setTaxonomies] = useState(mergedConfig.taxonomies || []);
949
+ // Use custom categories if available, otherwise default
950
+ const activeCategories = useMemo(() => {
951
+ const cats = customCategories.length > 0 ? customCategories : (categories || []);
952
+ return cats
953
+ .map(cat => typeof cat === 'string' ? { value: cat.toLowerCase().replace(/\s+/g, '-'), label: cat } : cat)
954
+ .sort((a, b) => a.label.localeCompare(b.label));
955
+ }, [customCategories, categories]);
956
+ // Categories that are not disabled (for filter and assignment dropdowns)
957
+ const visibleCategories = useMemo(() => {
958
+ return activeCategories.filter(cat => !cat.disabled);
959
+ }, [activeCategories]);
960
+ const getPreferredCategoryValue = useCallback((cats) => {
961
+ const preferred = cats.find(c => c.value === 'default' ||
962
+ c.value === 'general' ||
963
+ c.label === 'General' ||
964
+ c.value === 'Taskforce' ||
965
+ c.label === 'Taskforce');
966
+ return preferred?.value || cats[0]?.value || 'default';
967
+ }, []);
968
+ const activeTypes = customTypes.length > 0 ? customTypes : types;
969
+ // Form state
970
+ const [loading, setLoading] = useState(false);
971
+ const [error, setError] = useState('');
972
+ const handleUnauthorized = useCallback(() => {
973
+ // Local runtime supports guest usage; do not force login screen there.
974
+ setAuthBlocked(runtimeMode === 'cloud');
975
+ setIsAuthenticated(false);
976
+ setAuthUserEmail('');
977
+ setError('Authentication required. Sign in to continue.');
978
+ }, [runtimeMode]);
979
+ const checkAuthSession = useCallback(async () => {
980
+ markBootstrapPhase('auth');
981
+ if (!runtimeConfigReady) {
982
+ // Keep prior auth identity during bootstrap until runtime auth config is resolved.
983
+ // This avoids transient "signed out" UI flicker on refresh.
984
+ return authSessionLastResultRef.current;
985
+ }
986
+ if (!shouldProbeCloudAuth) {
987
+ setRuntimeMode('local');
988
+ setAuthRequiredForApi(false);
989
+ setIsAuthenticated(false);
990
+ setAuthUserId('anonymous');
991
+ setAuthUserEmail('');
992
+ setUserGlobalSyncStatus('disconnected');
993
+ setUserGlobalSyncPendingChanges(0);
994
+ setUserGlobalSyncError(null);
995
+ setHasBetaAccess(true);
996
+ setAvailableWorkspaces([]);
997
+ setAuthBlocked(false);
998
+ setAuthSessionResolved(true);
999
+ authSessionLastResultRef.current = false;
1000
+ authSessionLastCheckedAtRef.current = Date.now();
1001
+ return false;
1002
+ }
1003
+ const sessionUrl = resolveCloudAuthUrl('/api/taskforce/auth/session');
1004
+ const now = Date.now();
1005
+ if (authSessionRequestRef.current)
1006
+ return authSessionRequestRef.current;
1007
+ if (authSessionResolved && (now - authSessionLastCheckedAtRef.current) < 1500) {
1008
+ return authSessionLastResultRef.current;
1009
+ }
1010
+ const request = (async () => {
1011
+ const abortController = new AbortController();
1012
+ const timeoutId = typeof window !== 'undefined'
1013
+ ? window.setTimeout(() => abortController.abort(), 8000)
1014
+ : null;
1015
+ try {
1016
+ const res = await fetch(sessionUrl, {
1017
+ method: 'GET',
1018
+ credentials: 'include',
1019
+ mode: 'cors',
1020
+ signal: abortController.signal
1021
+ });
1022
+ if (res.status === 429) {
1023
+ setAuthSessionResolved(true);
1024
+ return authSessionLastResultRef.current;
1025
+ }
1026
+ if (!res.ok) {
1027
+ setAuthSessionResolved(true);
1028
+ authSessionLastResultRef.current = false;
1029
+ return false;
1030
+ }
1031
+ const data = await res.json();
1032
+ const required = Boolean(data.authRequiredForApi);
1033
+ const authenticated = Boolean(data.authenticated);
1034
+ const betaAccess = authenticated ? (data.betaAccess !== false) : true;
1035
+ const workspaceId = typeof data.workspaceId === 'string' && data.workspaceId.trim().length > 0
1036
+ ? data.workspaceId.trim()
1037
+ : '';
1038
+ const userId = typeof data.userId === 'string' && data.userId.trim().length > 0
1039
+ ? data.userId.trim()
1040
+ : 'anonymous';
1041
+ const userEmail = typeof data.email === 'string' && data.email.trim().length > 0
1042
+ ? data.email.trim().toLowerCase()
1043
+ : '';
1044
+ const isLocalRuntime = runtimeMode === 'local';
1045
+ const keepLocalWorkspaceContext = runtimeMode === 'local' || authOnlyCloudMode;
1046
+ setAuthRequiredForApi(authOnlyCloudMode ? false : (isLocalRuntime ? false : required));
1047
+ setIsAuthenticated(authenticated);
1048
+ setAuthUserId(authenticated ? userId : 'anonymous');
1049
+ setAuthUserEmail(authenticated ? userEmail : '');
1050
+ setUserGlobalSyncStatus(authenticated ? 'idle' : 'disconnected');
1051
+ setUserGlobalSyncError(null);
1052
+ if (!authenticated)
1053
+ setUserGlobalSyncPendingChanges(0);
1054
+ setHasBetaAccess(betaAccess);
1055
+ if (keepLocalWorkspaceContext) {
1056
+ // Local runtime resolves workspace identity from local DB via /api/taskforce/config.
1057
+ // Do not override it from cloud auth session payloads.
1058
+ }
1059
+ else {
1060
+ setCurrentWorkspaceId(workspaceId || readPersistedWorkspaceId(workspaceStorageScope) || 'default');
1061
+ }
1062
+ setAuthBlocked(authOnlyCloudMode ? false : (isLocalRuntime ? false : (required && !authenticated)));
1063
+ // Runtime mode should be sourced from /api/taskforce/config, not
1064
+ // from auth-session responses which may come from a separate cloud-auth origin.
1065
+ setAuthSessionResolved(true);
1066
+ authSessionLastResultRef.current = authenticated;
1067
+ if (authenticated)
1068
+ markBootstrapPhase('ready');
1069
+ return authenticated;
1070
+ }
1071
+ catch {
1072
+ setAuthSessionResolved(true);
1073
+ authSessionLastResultRef.current = false;
1074
+ markBootstrapStalled('Unable to verify your session.');
1075
+ return authSessionLastResultRef.current;
1076
+ }
1077
+ finally {
1078
+ if (timeoutId !== null && typeof window !== 'undefined') {
1079
+ window.clearTimeout(timeoutId);
1080
+ }
1081
+ authSessionLastCheckedAtRef.current = Date.now();
1082
+ authSessionRequestRef.current = null;
1083
+ }
1084
+ })();
1085
+ authSessionRequestRef.current = request;
1086
+ return request;
1087
+ }, [
1088
+ runtimeConfigReady,
1089
+ shouldProbeCloudAuth,
1090
+ authSessionResolved,
1091
+ resolveCloudAuthUrl,
1092
+ authOnlyCloudMode,
1093
+ runtimeMode,
1094
+ workspaceStorageScope,
1095
+ markBootstrapPhase,
1096
+ markBootstrapStalled
1097
+ ]);
1098
+ const { userGlobalSyncStatus, setUserGlobalSyncStatus, userGlobalLastSyncedAt, setUserGlobalLastSyncedAt, userGlobalSyncPendingChanges, setUserGlobalSyncPendingChanges, userGlobalSyncError, setUserGlobalSyncError, workspaceLastPullAt, setWorkspaceLastPullAt, workspaceLastPushAt, setWorkspaceLastPushAt, workspaceLastErrorAt, setWorkspaceLastErrorAt, workspaceLastErrorMessage, setWorkspaceLastErrorMessage, workspaceLastWarningMessage, workspaceCloudSyncEnabled, realtimeConnectionState, realtimeTelemetry, setWorkspaceCloudSyncEnabled, workspaceSyncSourceOfTruth, setWorkspaceSyncSourceOfTruth, workspaceSyncOnboardingCompleted, setWorkspaceSyncOnboardingCompleted, workspaceSyncReady, setWorkspaceSyncReady, workspaceCloudHydrated, setWorkspaceCloudHydrated, workspaceSyncDocuments, setWorkspaceSyncDocuments, workspaceSyncDocumentsFingerprint, setWorkspaceSyncDocumentsFingerprint, syncInFlightRef, workspaceRetryAt, isWorkspacePullRetryPending, workspaceBootstrapSyncInProgress, setWorkspaceBootstrapSyncInProgress, workspaceBootstrapSyncPages, setWorkspaceBootstrapSyncPages, workspaceBootstrapSyncAppliedChanges, setWorkspaceBootstrapSyncAppliedChanges, workspaceSyncFailureCount, workspaceSyncRetryScheduledCount, workspaceSyncAuthFailureCount, workspaceSyncLastFailureSource, workspaceSyncLastFailureStatusCode, workspacePushInFlightRef, workspacePullInFlightRef, workspaceLastPushedSignatureRef, workspacePendingSignatureRef, workspaceLastPushedTaskIdsRef, workspaceDeletedTaskIdsRef, workspacePullCursorRef, workspacePullBootstrapDoneRef, workspaceBackfillForcedRef, workspaceSyncReadyRef, workspaceCloudHydratedRef, clearWorkspaceRetry, scheduleWorkspacePullRetry, isWorkspacePullCooldownActive, markWorkspacePullCooldown, tryAcquireWorkspacePullLease, releaseWorkspacePullLease, recordWorkspaceSyncFailure, incrementWorkspaceSyncAuthFailure, persistWorkspaceSyncPatch, loadWorkspaceSyncState, applyWorkspaceSyncStateSnapshot, syncUserGlobalSettings, getLocalUserSyncUpdatedAt, setLocalUserSyncUpdatedAt, buildUserGlobalSyncPayload, applyRemoteUserGlobalSyncPayload, ensureCloudWorkspaceReadyForSync, buildWorkspaceSyncPayload, buildWorkspaceSyncSignature, refreshWorkspaceSyncDocumentsSnapshot, handleSyncAuthFailure, ensureWorkspaceSyncReady, pushWorkspaceChangesToCloud, pullWorkspaceChangesFromCloud, fetchLocalWorkspaceSnapshot, saveWorkspaceCloudSyncSettings, retryUserGlobalSettingsSync, retryWorkspaceCloudSync, resetWorkspaceSyncCursorAndPull } = useSyncOrchestrator({
1099
+ currentWorkspaceId,
1100
+ cloudAuthConfigured,
1101
+ runtimeMode,
1102
+ isAuthenticated,
1103
+ authUserId,
1104
+ projectName,
1105
+ resolveCloudAuthUrl,
1106
+ resolveWebSocketUrl,
1107
+ realtimeSyncEnabled,
1108
+ tasks,
1109
+ archivedTasks,
1110
+ taxonomies,
1111
+ setupState,
1112
+ globalTheme,
1113
+ locale,
1114
+ globalWeekStartsOn,
1115
+ themeUseGlobalDefault,
1116
+ setTasks,
1117
+ setArchivedTasks,
1118
+ setAuthBlocked,
1119
+ setIsAuthenticated,
1120
+ checkAuthSession,
1121
+ setGlobalTheme,
1122
+ setCurrentTheme,
1123
+ setSetupState,
1124
+ setLocale,
1125
+ setGlobalWeekStartsOn
1126
+ });
1127
+ const [category, setCategory] = useState(() => {
1128
+ return getPreferredCategoryValue(activeCategories);
1129
+ });
1130
+ const [type, setType] = useState('feature');
1131
+ const [priority, setPriority] = useState(2);
1132
+ const [complexity, setComplexity] = useState(3);
1133
+ const [approach, setApproach] = useState('default');
1134
+ const [assignee, setAssignee] = useState('agent');
1135
+ const [scheduledDate, setScheduledDate] = useState('');
1136
+ const [dueDate, setDueDate] = useState('');
1137
+ const [parentTaskIdInput, setParentTaskIdInput] = useState('');
1138
+ const [addChildTaskIdInput, setAddChildTaskIdInput] = useState('');
1139
+ const [title, setTitle] = useState('');
1140
+ const [description, setDescription] = useState('');
1141
+ const [comments, setComments] = useState([]);
1142
+ const [newCommentText, setNewCommentText] = useState('');
1143
+ const [formTaxonomies, setFormTaxonomies] = useState({});
1144
+ const commentsEndRef = useRef(null);
1145
+ const [selectedSpecialists, setSelectedSpecialists] = useState([]);
1146
+ const [showSpecialists, setShowSpecialists] = useState(false);
1147
+ const [showChecklist, setShowChecklist] = useState(false);
1148
+ const [showTaskRelationships, setShowTaskRelationships] = useState(false);
1149
+ const [showChildTasks, setShowChildTasks] = useState(false);
1150
+ const [lastUsedCategory, setLastUsedCategory] = useState('');
1151
+ const [searchQuery, setSearchQuery] = useState('');
1152
+ const [filterCategories, setFilterCategories] = useState([]);
1153
+ const [filterPriorities, setFilterPriorities] = useState([]);
1154
+ const [filterTypes, setFilterTypes] = useState([]);
1155
+ const [hasInitedFilters, setHasInitedFilters] = useState(false);
1156
+ const [filterStatus, setFilterStatus] = useState(STATUS_OPTIONS.map(s => s.value));
1157
+ const [filterAssignees, setFilterAssignees] = useState(ASSIGNEE_OPTIONS.map((opt) => opt.value));
1158
+ const [parentTaskFilterMode, setParentTaskFilterMode] = useState('all');
1159
+ const [filterTaxonomies, setFilterTaxonomies] = useState({});
1160
+ const [sortBy, setSortBy] = useState('created');
1161
+ const [sortOrder, setSortOrder] = useState('desc');
1162
+ const [showComments, setShowComments] = useState(false);
1163
+ const [descriptionFocused, setDescriptionFocused] = useState(false);
1164
+ const [isCapturingScreenshot, setIsCapturingScreenshot] = useState(false);
1165
+ const [screenshots, setScreenshots] = useState([]);
1166
+ const [recentlyChangedTaskIds, setRecentlyChangedTaskIds] = useState([]);
1167
+ const [scheduleWarningPrompt, setScheduleWarningPrompt] = useState(null);
1168
+ const taskRevisionMapRef = useRef(new Map());
1169
+ const hasFetchedTasksRef = useRef(false);
1170
+ const highlightTimersRef = useRef(new Map());
1171
+ const dataVersionRef = useRef(null);
1172
+ const authSessionResolvedRef = useRef(authSessionResolved);
1173
+ const authRequiredForApiRef = useRef(authRequiredForApi);
1174
+ const isAuthenticatedRef = useRef(isAuthenticated);
1175
+ const hasLoadedUiStateRef = useRef(false);
1176
+ const [uiStateReady, setUiStateReady] = useState(false);
1177
+ useEffect(() => {
1178
+ if (runtimeMode !== 'cloud')
1179
+ return;
1180
+ writePersistedWorkspaceId(currentWorkspaceId, workspaceStorageScope);
1181
+ }, [runtimeMode, currentWorkspaceId, workspaceStorageScope]);
1182
+ useEffect(() => {
1183
+ authSessionResolvedRef.current = authSessionResolved;
1184
+ authRequiredForApiRef.current = authRequiredForApi;
1185
+ isAuthenticatedRef.current = isAuthenticated;
1186
+ }, [authSessionResolved, authRequiredForApi, isAuthenticated]);
1187
+ const getTaskRevisionKey = useCallback((task) => {
1188
+ return `${task.updatedAt || task.createdAt || ''}|${task.status}|${task.priority}|${task.title}`;
1189
+ }, []);
1190
+ const markRecentlyChangedTasks = useCallback((taskIds) => {
1191
+ if (!taskIds.length || typeof window === 'undefined')
1192
+ return;
1193
+ setRecentlyChangedTaskIds(prev => Array.from(new Set([...prev, ...taskIds])));
1194
+ taskIds.forEach((taskId) => {
1195
+ const existing = highlightTimersRef.current.get(taskId);
1196
+ if (existing) {
1197
+ window.clearTimeout(existing);
1198
+ }
1199
+ const timerId = window.setTimeout(() => {
1200
+ highlightTimersRef.current.delete(taskId);
1201
+ setRecentlyChangedTaskIds(prev => prev.filter(id => id !== taskId));
1202
+ }, 4000);
1203
+ highlightTimersRef.current.set(taskId, timerId);
1204
+ });
1205
+ }, []);
1206
+ const loadPersistedUiState = useCallback(async () => {
1207
+ try {
1208
+ const res = await fetch('/api/taskforce/ui-state?key=app', {
1209
+ method: 'GET',
1210
+ credentials: 'include'
1211
+ });
1212
+ if (!res.ok) {
1213
+ setUiStateReady(true);
1214
+ return;
1215
+ }
1216
+ const data = await res.json().catch(() => ({}));
1217
+ const state = (data?.state && typeof data.state === 'object')
1218
+ ? data.state
1219
+ : null;
1220
+ if (!state) {
1221
+ setUiStateReady(true);
1222
+ return;
1223
+ }
1224
+ if (typeof state.groupBy === 'string')
1225
+ setGroupBy(state.groupBy);
1226
+ if (state.emptyColumnMode === 'show' || state.emptyColumnMode === 'collapse' || state.emptyColumnMode === 'hide')
1227
+ setEmptyColumnMode(state.emptyColumnMode);
1228
+ if (typeof state.zenMode === 'boolean')
1229
+ setZenMode(state.zenMode);
1230
+ if (typeof state.searchQuery === 'string')
1231
+ setSearchQuery(state.searchQuery);
1232
+ if (Array.isArray(state.filterCategories))
1233
+ setFilterCategories(state.filterCategories);
1234
+ if (Array.isArray(state.filterPriorities))
1235
+ setFilterPriorities(state.filterPriorities);
1236
+ if (Array.isArray(state.filterTypes))
1237
+ setFilterTypes(state.filterTypes);
1238
+ if (Array.isArray(state.filterStatus))
1239
+ setFilterStatus(state.filterStatus);
1240
+ if (Array.isArray(state.filterAssignees))
1241
+ setFilterAssignees(state.filterAssignees);
1242
+ if (state.filterTaxonomies && typeof state.filterTaxonomies === 'object')
1243
+ setFilterTaxonomies(state.filterTaxonomies);
1244
+ if (state.parentTaskFilterMode === 'all' || state.parentTaskFilterMode === 'parents' || state.parentTaskFilterMode === 'linked' || state.parentTaskFilterMode === 'unlinked') {
1245
+ setParentTaskFilterMode(state.parentTaskFilterMode);
1246
+ }
1247
+ if (state.sortBy === 'created' || state.sortBy === 'priority' || state.sortBy === 'updated' || state.sortBy === 'complexity')
1248
+ setSortBy(state.sortBy);
1249
+ if (state.sortOrder === 'asc' || state.sortOrder === 'desc')
1250
+ setSortOrder(state.sortOrder);
1251
+ if (typeof state.hasInitedFilters === 'boolean')
1252
+ setHasInitedFilters(state.hasInitedFilters);
1253
+ if (typeof state.showChecklist === 'boolean')
1254
+ setShowChecklist(state.showChecklist);
1255
+ if (typeof state.showTaskRelationships === 'boolean')
1256
+ setShowTaskRelationships(state.showTaskRelationships);
1257
+ if (typeof state.showChildTasks === 'boolean')
1258
+ setShowChildTasks(state.showChildTasks);
1259
+ if (typeof state.lastCategory === 'string')
1260
+ setLastUsedCategory(state.lastCategory);
1261
+ if (typeof state.exportEnvironment === 'string' && state.exportEnvironment.trim().length > 0) {
1262
+ setExportEnvironment(state.exportEnvironment.trim());
1263
+ }
1264
+ }
1265
+ catch {
1266
+ // ignore
1267
+ }
1268
+ finally {
1269
+ setUiStateReady(true);
1270
+ }
1271
+ }, []);
1272
+ useEffect(() => {
1273
+ if (!uiStateReady)
1274
+ return;
1275
+ const timeoutId = window.setTimeout(async () => {
1276
+ try {
1277
+ await fetch('/api/taskforce/ui-state', {
1278
+ method: 'POST',
1279
+ headers: { 'Content-Type': 'application/json' },
1280
+ credentials: 'include',
1281
+ body: JSON.stringify({
1282
+ stateKey: 'app',
1283
+ patch: {
1284
+ groupBy,
1285
+ emptyColumnMode,
1286
+ zenMode,
1287
+ searchQuery,
1288
+ filterCategories,
1289
+ filterPriorities,
1290
+ filterTypes,
1291
+ filterStatus,
1292
+ filterAssignees,
1293
+ filterTaxonomies,
1294
+ parentTaskFilterMode,
1295
+ sortBy,
1296
+ sortOrder,
1297
+ hasInitedFilters,
1298
+ showChecklist,
1299
+ showTaskRelationships,
1300
+ showChildTasks,
1301
+ lastCategory: lastUsedCategory || '',
1302
+ exportEnvironment
1303
+ }
1304
+ })
1305
+ });
1306
+ }
1307
+ catch {
1308
+ // ignore persistence errors
1309
+ }
1310
+ }, 250);
1311
+ return () => window.clearTimeout(timeoutId);
1312
+ }, [
1313
+ uiStateReady,
1314
+ groupBy,
1315
+ emptyColumnMode,
1316
+ zenMode,
1317
+ searchQuery,
1318
+ filterCategories,
1319
+ filterPriorities,
1320
+ filterTypes,
1321
+ filterStatus,
1322
+ filterAssignees,
1323
+ filterTaxonomies,
1324
+ parentTaskFilterMode,
1325
+ sortBy,
1326
+ sortOrder,
1327
+ hasInitedFilters,
1328
+ showChecklist,
1329
+ showTaskRelationships,
1330
+ showChildTasks,
1331
+ lastUsedCategory,
1332
+ exportEnvironment
1333
+ ]);
1334
+ // Fetch current config from server
1335
+ const fetchConfig = useCallback(async (options) => {
1336
+ const ignoreAuthGuard = options?.ignoreAuthGuard === true;
1337
+ if (!ignoreAuthGuard && (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls))
1338
+ return;
1339
+ markBootstrapPhase('config');
1340
+ try {
1341
+ // Fetch core config
1342
+ const res = await fetchWithTimeout('/api/taskforce/config', undefined, BOOTSTRAP_REQUEST_TIMEOUT_MS);
1343
+ if (res.status === 401) {
1344
+ handleUnauthorized();
1345
+ setConfigLoaded(true);
1346
+ return;
1347
+ }
1348
+ if (res.ok) {
1349
+ const data = await res.json();
1350
+ setRuntimeMode(data.runtimeMode === 'cloud' ? 'cloud' : 'local');
1351
+ setAuthRequiredForApi(Boolean(data.authRequiredForApi));
1352
+ if (typeof data.workspaceId === 'string' && data.workspaceId.trim().length > 0) {
1353
+ const nextWorkspaceId = data.workspaceId.trim();
1354
+ const nextRuntimeMode = data.runtimeMode === 'cloud' ? 'cloud' : 'local';
1355
+ if (nextRuntimeMode === 'local') {
1356
+ // Local runtime source of truth is server-resolved DB workspace identity.
1357
+ setCurrentWorkspaceId(nextWorkspaceId);
1358
+ }
1359
+ else {
1360
+ setCurrentWorkspaceId((prev) => {
1361
+ const prevValue = String(prev || '').trim();
1362
+ if (nextWorkspaceId === 'default' && prevValue && prevValue.toLowerCase() !== 'default') {
1363
+ return prevValue;
1364
+ }
1365
+ return nextWorkspaceId;
1366
+ });
1367
+ }
1368
+ }
1369
+ if (data.runtimeMode === 'cloud' && data.authRequiredForApi) {
1370
+ setAuthBlocked(!isAuthenticated);
1371
+ }
1372
+ else {
1373
+ setAuthBlocked(false);
1374
+ }
1375
+ if (data.setupState && typeof data.setupState === 'object') {
1376
+ setSetupState(data.setupState);
1377
+ }
1378
+ else {
1379
+ setSetupState(null);
1380
+ }
1381
+ if (data.runtimeCapabilities && typeof data.runtimeCapabilities === 'object') {
1382
+ setRuntimeCapabilities(data.runtimeCapabilities);
1383
+ }
1384
+ else {
1385
+ setRuntimeCapabilities(null);
1386
+ }
1387
+ setRealtimeSyncEnabled(Boolean(data.realtimeSyncEnabled));
1388
+ const realtimeFlagSource = String(data.realtimeSyncFlagSource || '').trim().toLowerCase();
1389
+ if (realtimeFlagSource === 'env' || realtimeFlagSource === 'settings' || realtimeFlagSource === 'default') {
1390
+ setRealtimeSyncFlagSource(realtimeFlagSource);
1391
+ }
1392
+ else {
1393
+ setRealtimeSyncFlagSource('unknown');
1394
+ }
1395
+ if (typeof data.shortcut === 'string' && data.shortcut.trim().length > 0) {
1396
+ setKeyShortcut(data.shortcut);
1397
+ }
1398
+ if (data.theme === 'dark' || data.theme === 'light' || data.theme === 'midnight') {
1399
+ setCurrentTheme(data.theme);
1400
+ }
1401
+ if (data.globalTheme === 'dark' || data.globalTheme === 'light' || data.globalTheme === 'midnight') {
1402
+ setGlobalTheme(data.globalTheme);
1403
+ }
1404
+ if (typeof data.themeUseGlobalDefault === 'boolean') {
1405
+ setThemeUseGlobalDefault(data.themeUseGlobalDefault);
1406
+ }
1407
+ if (data.projectRoot) {
1408
+ setProjectRoot(data.projectRoot);
1409
+ }
1410
+ if (typeof data.tenantId === 'string' && data.tenantId.trim().length > 0) {
1411
+ setTenantId(data.tenantId.trim());
1412
+ }
1413
+ const resolvedRuntimeMode = data.runtimeMode === 'cloud' ? 'cloud' : 'local';
1414
+ const detectedProjectName = resolvedRuntimeMode === 'cloud'
1415
+ ? String(data.projectName || '').trim()
1416
+ : (data.projectName || data.paths?.projectName || '');
1417
+ if (String(detectedProjectName || '').trim().length > 0 || resolvedRuntimeMode === 'cloud') {
1418
+ setProjectName(detectedProjectName);
1419
+ }
1420
+ if (data.mcpScript) {
1421
+ setMcpScriptPath(data.mcpScript);
1422
+ }
1423
+ if (data.hostRoot) {
1424
+ setServerHostRoot(data.hostRoot);
1425
+ }
1426
+ if (typeof data.jsonBackupEnabled === 'boolean') {
1427
+ setJsonBackupEnabled(data.jsonBackupEnabled);
1428
+ }
1429
+ if (typeof data.globalJsonBackupEnabled === 'boolean') {
1430
+ setGlobalJsonBackupEnabled(data.globalJsonBackupEnabled);
1431
+ }
1432
+ if (typeof data.jsonBackupUseGlobalDefault === 'boolean') {
1433
+ setJsonBackupUseGlobalDefault(data.jsonBackupUseGlobalDefault);
1434
+ }
1435
+ const configuredWeekStartsOn = data?.schedulePreferences?.weekStartsOn;
1436
+ if (configuredWeekStartsOn === 'sunday' || configuredWeekStartsOn === 'monday') {
1437
+ setGlobalWeekStartsOn(configuredWeekStartsOn);
1438
+ }
1439
+ if (typeof data.manualComplexityEnabled === 'boolean') {
1440
+ setManualComplexityEnabled(data.manualComplexityEnabled);
1441
+ }
1442
+ if (typeof data.checklistDropdownEnabled === 'boolean') {
1443
+ setChecklistDropdownEnabled(data.checklistDropdownEnabled);
1444
+ }
1445
+ else {
1446
+ setChecklistDropdownEnabled(false);
1447
+ }
1448
+ if (typeof data.assigneeFilterEnabled === 'boolean') {
1449
+ setAssigneeFilterEnabled(data.assigneeFilterEnabled);
1450
+ }
1451
+ else {
1452
+ setAssigneeFilterEnabled(false);
1453
+ }
1454
+ }
1455
+ // Fetch deployment/build metadata (best effort)
1456
+ try {
1457
+ const versionRes = await fetchWithTimeout('/api/taskforce/version', undefined, BOOTSTRAP_REQUEST_TIMEOUT_MS);
1458
+ if (versionRes.ok) {
1459
+ const versionData = await versionRes.json().catch(() => ({}));
1460
+ if (versionData?.build && typeof versionData.build === 'object') {
1461
+ setBuildInfo({
1462
+ version: String(versionData.build.version || ''),
1463
+ gitSha: versionData.build.gitSha ? String(versionData.build.gitSha) : null,
1464
+ buildTime: versionData.build.buildTime ? String(versionData.build.buildTime) : null,
1465
+ deployId: versionData.build.deployId ? String(versionData.build.deployId) : null
1466
+ });
1467
+ }
1468
+ }
1469
+ }
1470
+ catch (versionError) {
1471
+ if (isBootstrapTimeoutError(versionError)) {
1472
+ markBootstrapStalled('Startup checks timed out.');
1473
+ }
1474
+ // ignore build-info errors
1475
+ }
1476
+ // Fetch categories
1477
+ const catRes = await fetch('/api/taskforce/categories');
1478
+ if (catRes.ok) {
1479
+ const catData = await catRes.json();
1480
+ if (catData.categories && catData.categories.length > 0) {
1481
+ // Map to config objects if they come back as strings
1482
+ const mapped = catData.categories.map((c) => typeof c === 'string' ? { value: c.toLowerCase().replace(/\s+/g, '-'), label: c } : c);
1483
+ setCustomCategories(mapped);
1484
+ }
1485
+ }
1486
+ // Fetch types
1487
+ const typeRes = await fetch('/api/taskforce/types');
1488
+ if (typeRes.ok) {
1489
+ const typeData = await typeRes.json();
1490
+ if (typeData.types && typeData.types.length > 0) {
1491
+ setCustomTypes(typeData.types);
1492
+ }
1493
+ }
1494
+ // Fetch priorities
1495
+ const priorityRes = await fetch('/api/taskforce/priorities');
1496
+ if (priorityRes.ok) {
1497
+ const priorityData = await priorityRes.json();
1498
+ if (priorityData.priorities)
1499
+ setPriorities(priorityData.priorities);
1500
+ }
1501
+ // Fetch templates
1502
+ const templateRes = await fetch('/api/taskforce/workflow-templates');
1503
+ if (templateRes.ok) {
1504
+ const templateData = await templateRes.json();
1505
+ if (templateData.templates)
1506
+ setAvailableWorkflows(templateData.templates);
1507
+ }
1508
+ // Fetch initiative templates
1509
+ await fetchInitiativeTemplates();
1510
+ // Fetch environments
1511
+ const envRes = await fetch('/api/taskforce/environments');
1512
+ if (envRes.ok) {
1513
+ const envData = await envRes.json();
1514
+ if (envData.environments)
1515
+ setAvailableEnvironments(envData.environments);
1516
+ }
1517
+ // Fetch approaches
1518
+ const approachRes = await fetch('/api/taskforce/approaches');
1519
+ if (approachRes.ok) {
1520
+ const approachData = await approachRes.json();
1521
+ if (approachData.approaches)
1522
+ setApproaches(approachData.approaches);
1523
+ }
1524
+ // Fetch generic taxonomies
1525
+ const taxRes = await fetch('/api/taskforce/taxonomies');
1526
+ if (taxRes.ok) {
1527
+ const taxData = await taxRes.json();
1528
+ if (taxData.taxonomies) {
1529
+ setTaxonomies(taxData.taxonomies);
1530
+ }
1531
+ }
1532
+ // Fetch specialists
1533
+ const specialistRes = await fetch('/api/taskforce/specialists');
1534
+ if (specialistRes.ok) {
1535
+ const specialistData = await specialistRes.json();
1536
+ if (specialistData.specialists) {
1537
+ setAvailableSpecialists(specialistData.specialists);
1538
+ }
1539
+ }
1540
+ if (!hasLoadedUiStateRef.current) {
1541
+ hasLoadedUiStateRef.current = true;
1542
+ await loadPersistedUiState();
1543
+ }
1544
+ // Mark config as loaded (even if some fetches failed)
1545
+ setConfigLoaded(true);
1546
+ markBootstrapPhase('ready');
1547
+ }
1548
+ catch (error) {
1549
+ if (!hasLoadedUiStateRef.current) {
1550
+ hasLoadedUiStateRef.current = true;
1551
+ await loadPersistedUiState();
1552
+ }
1553
+ if (isBootstrapTimeoutError(error)) {
1554
+ markBootstrapStalled('Startup checks timed out.');
1555
+ }
1556
+ else {
1557
+ markBootstrapStalled('Unable to finish startup checks.');
1558
+ }
1559
+ // Still mark as loaded so we can use default config
1560
+ setConfigLoaded(true);
1561
+ }
1562
+ }, [
1563
+ currentWorkspaceId,
1564
+ fetchWithTimeout,
1565
+ fetchInitiativeTemplates,
1566
+ handleUnauthorized,
1567
+ isAuthenticated,
1568
+ isBootstrapTimeoutError,
1569
+ loadPersistedUiState,
1570
+ markBootstrapPhase,
1571
+ markBootstrapStalled,
1572
+ shouldDeferProtectedApiCalls,
1573
+ shouldBlockProtectedApiCalls
1574
+ ]);
1575
+ const refreshSetupContext = useCallback(async () => {
1576
+ await checkAuthSession();
1577
+ await fetchConfig();
1578
+ }, [checkAuthSession, fetchConfig]);
1579
+ const fetchWorkspaces = useCallback(async () => {
1580
+ if (!workspaceSwitchingEnabled) {
1581
+ setAvailableWorkspaces([]);
1582
+ return { success: true, workspaces: [] };
1583
+ }
1584
+ try {
1585
+ const res = await fetchWithTimeout('/api/taskforce/workspaces', {
1586
+ method: 'GET',
1587
+ credentials: 'include'
1588
+ }, BOOTSTRAP_REQUEST_TIMEOUT_MS);
1589
+ if (res.status === 401) {
1590
+ setAvailableWorkspaces([]);
1591
+ return { success: false, error: 'Authentication required.' };
1592
+ }
1593
+ const data = await res.json().catch(() => ({}));
1594
+ if (!res.ok || data?.success === false) {
1595
+ setAvailableWorkspaces([]);
1596
+ return { success: false, error: data?.error || `Failed to load workspaces (${res.status})` };
1597
+ }
1598
+ const workspaces = Array.isArray(data?.workspaces) ? data.workspaces : [];
1599
+ setAvailableWorkspaces(workspaces);
1600
+ if (runtimeMode !== 'local' && typeof data?.currentWorkspaceId === 'string' && data.currentWorkspaceId.trim().length > 0) {
1601
+ const nextWorkspaceId = data.currentWorkspaceId.trim();
1602
+ setCurrentWorkspaceId((prev) => {
1603
+ const prevValue = String(prev || '').trim();
1604
+ if (nextWorkspaceId === 'default' && prevValue && prevValue.toLowerCase() !== 'default')
1605
+ return prevValue;
1606
+ return nextWorkspaceId;
1607
+ });
1608
+ }
1609
+ return { success: true, workspaces };
1610
+ }
1611
+ catch (error) {
1612
+ if (isBootstrapTimeoutError(error)) {
1613
+ markBootstrapStalled('Startup checks timed out.');
1614
+ }
1615
+ setAvailableWorkspaces([]);
1616
+ return { success: false, error: 'Failed to load workspaces.' };
1617
+ }
1618
+ }, [
1619
+ fetchWithTimeout,
1620
+ isBootstrapTimeoutError,
1621
+ markBootstrapStalled,
1622
+ runtimeMode,
1623
+ workspaceStorageScope,
1624
+ workspaceSwitchingEnabled
1625
+ ]);
1626
+ const switchWorkspace = useCallback(async (workspaceId, options) => {
1627
+ if (!workspaceSwitchingEnabled) {
1628
+ return { success: false, error: 'Workspace switching is unavailable in local mode.', code: 'WORKSPACE_SWITCHING_DISABLED' };
1629
+ }
1630
+ const normalizedWorkspaceId = String(workspaceId || '').trim();
1631
+ if (!normalizedWorkspaceId)
1632
+ return { success: false, error: 'workspaceId is required.' };
1633
+ try {
1634
+ const res = await fetch('/api/taskforce/session/workspace', {
1635
+ method: 'POST',
1636
+ headers: { 'Content-Type': 'application/json' },
1637
+ credentials: 'include',
1638
+ body: JSON.stringify({ workspaceId: normalizedWorkspaceId })
1639
+ });
1640
+ const data = await res.json().catch(() => ({}));
1641
+ if (!res.ok || data?.success === false) {
1642
+ return { success: false, error: data?.error || `Failed to switch workspace (${res.status})`, code: data?.code };
1643
+ }
1644
+ await checkAuthSession();
1645
+ if (options?.hydrate === false) {
1646
+ await fetchWorkspaces();
1647
+ }
1648
+ else {
1649
+ await Promise.all([fetchConfig(), fetchWorkspaces()]);
1650
+ }
1651
+ return { success: true };
1652
+ }
1653
+ catch {
1654
+ return { success: false, error: 'Failed to switch workspace.' };
1655
+ }
1656
+ }, [checkAuthSession, fetchConfig, fetchWorkspaces, workspaceSwitchingEnabled]);
1657
+ const resolveWorkspaceAfterAuth = useCallback(async (options) => {
1658
+ markBootstrapPhase('workspace');
1659
+ setWorkspaceBootstrapPending(true);
1660
+ try {
1661
+ if (!workspaceSwitchingEnabled) {
1662
+ const localWorkspaceId = String(currentWorkspaceId || '').trim() || 'default';
1663
+ setCurrentWorkspaceId(localWorkspaceId);
1664
+ return { success: true, workspaceSetupRequired: false, workspaceId: localWorkspaceId };
1665
+ }
1666
+ const preferredWorkspaceId = String(options?.preferredWorkspaceId || '').trim();
1667
+ const persistedWorkspaceId = readPersistedWorkspaceId(workspaceStorageScope);
1668
+ const workspacesResult = await fetchWorkspaces();
1669
+ if (!workspacesResult.success) {
1670
+ return {
1671
+ success: false,
1672
+ workspaceSetupRequired: false,
1673
+ error: workspacesResult.error || 'Failed to load workspaces after authentication.'
1674
+ };
1675
+ }
1676
+ const workspaces = Array.isArray(workspacesResult.workspaces) ? workspacesResult.workspaces : [];
1677
+ if (workspaces.length === 0) {
1678
+ setCurrentWorkspaceId('default');
1679
+ return { success: true, workspaceSetupRequired: true };
1680
+ }
1681
+ const ids = new Set(workspaces.map((workspace) => String(workspace.id || '').trim()).filter(Boolean));
1682
+ const selectedWorkspaceId = ((preferredWorkspaceId && ids.has(preferredWorkspaceId) ? preferredWorkspaceId : '')
1683
+ || (persistedWorkspaceId && ids.has(persistedWorkspaceId) ? persistedWorkspaceId : '')
1684
+ || String(workspaces[0]?.id || '').trim());
1685
+ if (!selectedWorkspaceId) {
1686
+ setCurrentWorkspaceId('default');
1687
+ return { success: true, workspaceSetupRequired: true };
1688
+ }
1689
+ const current = String(currentWorkspaceId || '').trim();
1690
+ if (current !== selectedWorkspaceId) {
1691
+ const switched = await switchWorkspace(selectedWorkspaceId, { hydrate: false });
1692
+ if (!switched.success) {
1693
+ return {
1694
+ success: false,
1695
+ workspaceSetupRequired: false,
1696
+ error: switched.error || 'Failed to set workspace after authentication.',
1697
+ code: switched.code
1698
+ };
1699
+ }
1700
+ }
1701
+ else {
1702
+ setCurrentWorkspaceId(selectedWorkspaceId);
1703
+ }
1704
+ return { success: true, workspaceSetupRequired: false, workspaceId: selectedWorkspaceId };
1705
+ }
1706
+ catch {
1707
+ markBootstrapStalled('Unable to resolve workspace access.');
1708
+ return { success: false, workspaceSetupRequired: false, error: 'Failed to resolve workspace access.' };
1709
+ }
1710
+ finally {
1711
+ setWorkspaceBootstrapPending(false);
1712
+ }
1713
+ }, [
1714
+ currentWorkspaceId,
1715
+ fetchWorkspaces,
1716
+ markBootstrapPhase,
1717
+ markBootstrapStalled,
1718
+ switchWorkspace,
1719
+ workspaceStorageScope,
1720
+ workspaceSwitchingEnabled
1721
+ ]);
1722
+ const retryBootstrapChecks = useCallback(async () => {
1723
+ setBootstrapError(null);
1724
+ markBootstrapPhase('auth');
1725
+ const authenticated = await checkAuthSession();
1726
+ if (workspaceSwitchingEnabled && authenticated) {
1727
+ const workspaceResolution = await resolveWorkspaceAfterAuth();
1728
+ if (!workspaceResolution.success || workspaceResolution.workspaceSetupRequired) {
1729
+ await fetchConfig({ ignoreAuthGuard: true });
1730
+ return;
1731
+ }
1732
+ }
1733
+ await fetchConfig({ ignoreAuthGuard: true });
1734
+ }, [
1735
+ checkAuthSession,
1736
+ fetchConfig,
1737
+ markBootstrapPhase,
1738
+ resolveWorkspaceAfterAuth,
1739
+ workspaceSwitchingEnabled
1740
+ ]);
1741
+ const createWorkspace = useCallback(async (name, description) => {
1742
+ if (!workspaceSwitchingEnabled) {
1743
+ return { success: false, error: 'Workspace management is unavailable in local mode.', code: 'WORKSPACE_MANAGEMENT_DISABLED' };
1744
+ }
1745
+ const trimmedName = String(name || '').trim();
1746
+ if (!trimmedName)
1747
+ return { success: false, error: 'Workspace name is required.' };
1748
+ try {
1749
+ const res = await fetch('/api/taskforce/workspaces', {
1750
+ method: 'POST',
1751
+ headers: { 'Content-Type': 'application/json' },
1752
+ credentials: 'include',
1753
+ body: JSON.stringify({
1754
+ name: trimmedName,
1755
+ description: typeof description === 'string' ? description : undefined
1756
+ })
1757
+ });
1758
+ const data = await res.json().catch(() => ({}));
1759
+ if (!res.ok || data?.success === false) {
1760
+ return { success: false, error: data?.error || `Failed to create workspace (${res.status})`, code: data?.code };
1761
+ }
1762
+ const workspace = data?.workspace;
1763
+ await fetchWorkspaces();
1764
+ if (workspace?.id) {
1765
+ const switched = await switchWorkspace(workspace.id);
1766
+ if (!switched.success) {
1767
+ return { success: false, error: switched.error || 'Workspace created but failed to activate.', code: switched.code };
1768
+ }
1769
+ }
1770
+ return { success: true, workspace };
1771
+ }
1772
+ catch {
1773
+ return { success: false, error: 'Failed to create workspace.' };
1774
+ }
1775
+ }, [fetchWorkspaces, switchWorkspace, workspaceSwitchingEnabled]);
1776
+ const deleteWorkspace = useCallback(async (workspaceId) => {
1777
+ if (!workspaceSwitchingEnabled) {
1778
+ return { success: false, error: 'Workspace management is unavailable in local mode.', code: 'WORKSPACE_MANAGEMENT_DISABLED' };
1779
+ }
1780
+ const normalizedWorkspaceId = String(workspaceId || '').trim();
1781
+ if (!normalizedWorkspaceId)
1782
+ return { success: false, error: 'workspaceId is required.', code: 'WORKSPACE_ID_REQUIRED' };
1783
+ try {
1784
+ const res = await fetch(`/api/taskforce/workspaces/${encodeURIComponent(normalizedWorkspaceId)}`, {
1785
+ method: 'DELETE',
1786
+ credentials: 'include'
1787
+ });
1788
+ const data = await res.json().catch(() => ({}));
1789
+ if (!res.ok || data?.success === false) {
1790
+ return { success: false, error: data?.error || `Failed to delete workspace (${res.status})`, code: data?.code };
1791
+ }
1792
+ await checkAuthSession();
1793
+ await Promise.all([fetchConfig(), fetchWorkspaces()]);
1794
+ const nextWorkspaceId = typeof data?.nextWorkspaceId === 'string' ? data.nextWorkspaceId.trim() : '';
1795
+ if (nextWorkspaceId) {
1796
+ setCurrentWorkspaceId(nextWorkspaceId);
1797
+ }
1798
+ return {
1799
+ success: true,
1800
+ nextWorkspaceId: nextWorkspaceId || undefined,
1801
+ workspaceSetupRequired: data?.workspaceSetupRequired === true,
1802
+ cleanupWarnings: Array.isArray(data?.cleanupWarnings) ? data.cleanupWarnings.map((item) => String(item || '')) : []
1803
+ };
1804
+ }
1805
+ catch {
1806
+ return { success: false, error: 'Failed to delete workspace.' };
1807
+ }
1808
+ }, [checkAuthSession, fetchConfig, fetchWorkspaces, workspaceSwitchingEnabled]);
1809
+ const saveSetupMode = useCallback(async (mode) => {
1810
+ const nextMode = mode === 'operations' ? 'operations' : 'core';
1811
+ try {
1812
+ const res = await fetch('/api/taskforce/global-settings', {
1813
+ method: 'POST',
1814
+ headers: { 'Content-Type': 'application/json' },
1815
+ credentials: 'include',
1816
+ body: JSON.stringify({ setup: { mode: nextMode } })
1817
+ });
1818
+ const data = await res.json().catch(() => ({}));
1819
+ if (!res.ok || data?.success === false)
1820
+ return false;
1821
+ await refreshSetupContext();
1822
+ return true;
1823
+ }
1824
+ catch {
1825
+ return false;
1826
+ }
1827
+ }, [refreshSetupContext]);
1828
+ const saveWorkspaceProfile = useCallback(async (input) => {
1829
+ const name = String(input?.name || '').trim();
1830
+ if (!name)
1831
+ return { success: false, error: 'Workspace name is required.', code: 'WORKSPACE_NAME_REQUIRED' };
1832
+ try {
1833
+ const res = await fetch('/api/taskforce/workspace-profile', {
1834
+ method: 'POST',
1835
+ headers: {
1836
+ 'Content-Type': 'application/json',
1837
+ 'x-taskforce-runtime-mode': runtimeMode
1838
+ },
1839
+ credentials: 'include',
1840
+ body: JSON.stringify({
1841
+ workspaceId: input.workspaceId,
1842
+ name,
1843
+ description: typeof input.description === 'string' ? input.description : undefined,
1844
+ allowCreate: true
1845
+ })
1846
+ });
1847
+ const data = await res.json().catch(() => ({}));
1848
+ if (!res.ok || data?.success === false) {
1849
+ return { success: false, error: data?.error || `Failed to save workspace (${res.status})`, code: data?.code };
1850
+ }
1851
+ const savedWorkspaceId = String(data?.workspace?.id || '').trim();
1852
+ if (savedWorkspaceId) {
1853
+ setCurrentWorkspaceId(savedWorkspaceId);
1854
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
1855
+ if (runtimeMode === 'cloud' && isAuthenticated && savedWorkspaceId !== currentWorkspaceId) {
1856
+ const switched = await switchWorkspace(savedWorkspaceId);
1857
+ if (!switched.success) {
1858
+ return {
1859
+ success: false,
1860
+ error: switched.error || 'Workspace saved but failed to activate session workspace.',
1861
+ code: 'WORKSPACE_SWITCH_FAILED'
1862
+ };
1863
+ }
1864
+ }
1865
+ }
1866
+ await refreshSetupContext();
1867
+ return { success: true, workspaceId: savedWorkspaceId || undefined };
1868
+ }
1869
+ catch {
1870
+ return { success: false, error: 'Failed to save workspace profile.' };
1871
+ }
1872
+ }, [refreshSetupContext, runtimeMode, isAuthenticated, currentWorkspaceId, switchWorkspace]);
1873
+ // Fetch tasks
1874
+ const fetchTasks = useCallback(async (isSilent = false, options) => {
1875
+ const ignoreAuthGuard = options?.ignoreAuthGuard === true;
1876
+ if (!ignoreAuthGuard && (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls))
1877
+ return;
1878
+ if (!isSilent)
1879
+ setLoadingTasks(true);
1880
+ try {
1881
+ const res = await fetch('/api/taskforce/tasks');
1882
+ if (res.status === 401) {
1883
+ handleUnauthorized();
1884
+ setTasks([]);
1885
+ if (!isSilent)
1886
+ setLoadingTasks(false);
1887
+ return;
1888
+ }
1889
+ if (res.ok) {
1890
+ const data = await res.json();
1891
+ const sanitized = (data.tasks || []).map((t) => normalizeTaskFromApi(t, { mapLegacyTodoStatus: true }));
1892
+ const nextRevisionMap = new Map();
1893
+ const changedTaskIds = [];
1894
+ for (const task of sanitized) {
1895
+ const revision = getTaskRevisionKey(task);
1896
+ nextRevisionMap.set(task.id, revision);
1897
+ if (!hasFetchedTasksRef.current)
1898
+ continue;
1899
+ const previousRevision = taskRevisionMapRef.current.get(task.id);
1900
+ if (!previousRevision || previousRevision !== revision) {
1901
+ changedTaskIds.push(task.id);
1902
+ }
1903
+ }
1904
+ taskRevisionMapRef.current = nextRevisionMap;
1905
+ if (hasFetchedTasksRef.current) {
1906
+ markRecentlyChangedTasks(changedTaskIds);
1907
+ }
1908
+ else {
1909
+ hasFetchedTasksRef.current = true;
1910
+ }
1911
+ setTasks(sanitized);
1912
+ }
1913
+ }
1914
+ catch (err) {
1915
+ console.error('[Taskforce] Failed to fetch tasks:', err);
1916
+ }
1917
+ if (!isSilent)
1918
+ setLoadingTasks(false);
1919
+ }, [
1920
+ getTaskRevisionKey,
1921
+ markRecentlyChangedTasks,
1922
+ handleUnauthorized,
1923
+ authRequiredForApi,
1924
+ shouldDeferProtectedApiCalls,
1925
+ shouldBlockProtectedApiCalls
1926
+ ]);
1927
+ const loginWithCredentials = useCallback(async (email, password) => {
1928
+ if (!cloudAuthConfigured) {
1929
+ return { success: false, error: 'Cloud auth endpoint is not configured.' };
1930
+ }
1931
+ const trimmedEmail = email.trim().toLowerCase();
1932
+ const rawPassword = password;
1933
+ if (!trimmedEmail || !rawPassword) {
1934
+ return { success: false, error: 'Email and password are required.' };
1935
+ }
1936
+ try {
1937
+ const res = await fetch(resolveCloudAuthUrl('/api/taskforce/auth/login'), {
1938
+ method: 'POST',
1939
+ headers: { 'Content-Type': 'application/json' },
1940
+ credentials: 'include',
1941
+ body: JSON.stringify({ email: trimmedEmail, password: rawPassword })
1942
+ });
1943
+ const data = await res.json().catch(() => ({}));
1944
+ if (!res.ok || !data?.success) {
1945
+ return { success: false, error: data?.error || 'Sign in failed.', code: data?.code };
1946
+ }
1947
+ setError('');
1948
+ await checkAuthSession();
1949
+ const workspaceResolution = await resolveWorkspaceAfterAuth();
1950
+ if (!workspaceResolution.success) {
1951
+ return {
1952
+ success: false,
1953
+ error: workspaceResolution.error || 'Unable to resolve workspace after sign in.',
1954
+ code: workspaceResolution.code
1955
+ };
1956
+ }
1957
+ if (workspaceResolution.workspaceSetupRequired) {
1958
+ return { success: true, workspaceSetupRequired: true };
1959
+ }
1960
+ await Promise.all([
1961
+ fetchConfig({ ignoreAuthGuard: true }),
1962
+ fetchTasks(true, { ignoreAuthGuard: true })
1963
+ ]);
1964
+ void syncUserGlobalSettings({ preferCloudOnFirstSync: true });
1965
+ return { success: true };
1966
+ }
1967
+ catch {
1968
+ return { success: false, error: 'Sign in failed.' };
1969
+ }
1970
+ }, [checkAuthSession, fetchConfig, fetchTasks, resolveWorkspaceAfterAuth, syncUserGlobalSettings, resolveCloudAuthUrl, cloudAuthConfigured]);
1971
+ const registerWithCredentials = useCallback(async (email, password) => {
1972
+ if (!cloudAuthConfigured) {
1973
+ return { success: false, error: 'Cloud auth endpoint is not configured.' };
1974
+ }
1975
+ const trimmedEmail = email.trim().toLowerCase();
1976
+ const rawPassword = password;
1977
+ if (!trimmedEmail || !rawPassword) {
1978
+ return { success: false, error: 'Email and password are required.' };
1979
+ }
1980
+ try {
1981
+ const res = await fetch(resolveCloudAuthUrl('/api/taskforce/auth/register'), {
1982
+ method: 'POST',
1983
+ headers: { 'Content-Type': 'application/json' },
1984
+ credentials: 'include',
1985
+ body: JSON.stringify({ email: trimmedEmail, password: rawPassword })
1986
+ });
1987
+ const data = await res.json().catch(() => ({}));
1988
+ if (!res.ok || !data?.success) {
1989
+ return { success: false, error: data?.error || 'Create account failed.', code: data?.code };
1990
+ }
1991
+ return {
1992
+ success: true,
1993
+ workspaceSetupRequired: Boolean(data?.workspaceSetupRequired),
1994
+ verificationRequired: Boolean(data?.verificationRequired),
1995
+ verificationToken: typeof data?.verificationToken === 'string' ? data.verificationToken : undefined,
1996
+ emailSent: data?.emailSent !== false,
1997
+ emailError: typeof data?.emailError === 'string' ? data.emailError : undefined
1998
+ };
1999
+ }
2000
+ catch {
2001
+ return { success: false, error: 'Create account failed.' };
2002
+ }
2003
+ }, [resolveCloudAuthUrl, cloudAuthConfigured]);
2004
+ const requestEmailVerification = useCallback(async (email) => {
2005
+ if (!cloudAuthConfigured) {
2006
+ return { success: false, error: 'Cloud auth endpoint is not configured.' };
2007
+ }
2008
+ const trimmedEmail = email.trim().toLowerCase();
2009
+ if (!trimmedEmail)
2010
+ return { success: false, error: 'Email is required.' };
2011
+ try {
2012
+ const res = await fetch(resolveCloudAuthUrl('/api/taskforce/auth/verify-email/request'), {
2013
+ method: 'POST',
2014
+ headers: { 'Content-Type': 'application/json' },
2015
+ credentials: 'include',
2016
+ body: JSON.stringify({ email: trimmedEmail })
2017
+ });
2018
+ const data = await res.json().catch(() => ({}));
2019
+ if (!res.ok || !data?.success) {
2020
+ return { success: false, error: data?.error || 'Failed to request verification email.', code: data?.code };
2021
+ }
2022
+ return {
2023
+ success: true,
2024
+ verificationToken: data?.verificationToken ?? null,
2025
+ emailSent: data?.emailSent !== false,
2026
+ emailError: typeof data?.emailError === 'string' ? data.emailError : undefined
2027
+ };
2028
+ }
2029
+ catch {
2030
+ return { success: false, error: 'Failed to request verification email.' };
2031
+ }
2032
+ }, [resolveCloudAuthUrl, cloudAuthConfigured]);
2033
+ const confirmEmailVerification = useCallback(async (token) => {
2034
+ if (!cloudAuthConfigured) {
2035
+ return { success: false, error: 'Cloud auth endpoint is not configured.' };
2036
+ }
2037
+ const trimmedToken = token.trim();
2038
+ if (!trimmedToken)
2039
+ return { success: false, error: 'Token is required.' };
2040
+ try {
2041
+ const res = await fetch(resolveCloudAuthUrl('/api/taskforce/auth/verify-email/confirm'), {
2042
+ method: 'POST',
2043
+ headers: { 'Content-Type': 'application/json' },
2044
+ credentials: 'include',
2045
+ body: JSON.stringify({ token: trimmedToken })
2046
+ });
2047
+ const data = await res.json().catch(() => ({}));
2048
+ if (!res.ok || !data?.success) {
2049
+ return { success: false, error: data?.error || 'Verification failed.', code: data?.code };
2050
+ }
2051
+ return { success: true };
2052
+ }
2053
+ catch {
2054
+ return { success: false, error: 'Verification failed.' };
2055
+ }
2056
+ }, [resolveCloudAuthUrl, cloudAuthConfigured]);
2057
+ const requestPasswordReset = useCallback(async (email) => {
2058
+ if (!cloudAuthConfigured) {
2059
+ return { success: false, error: 'Cloud auth endpoint is not configured.' };
2060
+ }
2061
+ const trimmedEmail = email.trim().toLowerCase();
2062
+ if (!trimmedEmail)
2063
+ return { success: false, error: 'Email is required.' };
2064
+ try {
2065
+ const res = await fetch(resolveCloudAuthUrl('/api/taskforce/auth/password-reset/request'), {
2066
+ method: 'POST',
2067
+ headers: { 'Content-Type': 'application/json' },
2068
+ credentials: 'include',
2069
+ body: JSON.stringify({ email: trimmedEmail })
2070
+ });
2071
+ const data = await res.json().catch(() => ({}));
2072
+ if (!res.ok || !data?.success) {
2073
+ return { success: false, error: data?.error || 'Failed to request password reset.', code: data?.code };
2074
+ }
2075
+ return {
2076
+ success: true,
2077
+ resetToken: data?.resetToken ?? null,
2078
+ emailSent: data?.emailSent !== false,
2079
+ emailError: typeof data?.emailError === 'string' ? data.emailError : undefined
2080
+ };
2081
+ }
2082
+ catch {
2083
+ return { success: false, error: 'Failed to request password reset.' };
2084
+ }
2085
+ }, [resolveCloudAuthUrl, cloudAuthConfigured]);
2086
+ const confirmPasswordReset = useCallback(async (token, password) => {
2087
+ if (!cloudAuthConfigured) {
2088
+ return { success: false, error: 'Cloud auth endpoint is not configured.' };
2089
+ }
2090
+ const trimmedToken = token.trim();
2091
+ const rawPassword = password;
2092
+ if (!trimmedToken || !rawPassword) {
2093
+ return { success: false, error: 'Token and password are required.' };
2094
+ }
2095
+ try {
2096
+ const res = await fetch(resolveCloudAuthUrl('/api/taskforce/auth/password-reset/confirm'), {
2097
+ method: 'POST',
2098
+ headers: { 'Content-Type': 'application/json' },
2099
+ credentials: 'include',
2100
+ body: JSON.stringify({ token: trimmedToken, password: rawPassword })
2101
+ });
2102
+ const data = await res.json().catch(() => ({}));
2103
+ if (!res.ok || !data?.success) {
2104
+ return { success: false, error: data?.error || 'Failed to reset password.', code: data?.code };
2105
+ }
2106
+ return { success: true };
2107
+ }
2108
+ catch {
2109
+ return { success: false, error: 'Failed to reset password.' };
2110
+ }
2111
+ }, [resolveCloudAuthUrl, cloudAuthConfigured]);
2112
+ const inspectInviteAcceptance = useCallback(async (token) => {
2113
+ if (!cloudAuthConfigured) {
2114
+ return { success: false, error: 'Cloud auth endpoint is not configured.' };
2115
+ }
2116
+ const trimmedToken = token.trim();
2117
+ if (!trimmedToken)
2118
+ return { success: false, error: 'Token is required.' };
2119
+ try {
2120
+ const res = await fetch(resolveCloudAuthUrl('/api/taskforce/auth/invite/inspect'), {
2121
+ method: 'POST',
2122
+ headers: { 'Content-Type': 'application/json' },
2123
+ credentials: 'include',
2124
+ body: JSON.stringify({ token: trimmedToken })
2125
+ });
2126
+ const data = await res.json().catch(() => ({}));
2127
+ if (!res.ok || !data?.success) {
2128
+ return {
2129
+ success: false,
2130
+ error: data?.error || 'Invite inspection failed.',
2131
+ code: data?.code,
2132
+ state: data?.state
2133
+ };
2134
+ }
2135
+ return {
2136
+ success: true,
2137
+ state: data?.state,
2138
+ email: typeof data?.email === 'string' ? data.email : undefined,
2139
+ workspaceId: typeof data?.workspaceId === 'string' ? data.workspaceId : undefined
2140
+ };
2141
+ }
2142
+ catch {
2143
+ return { success: false, error: 'Invite inspection failed.' };
2144
+ }
2145
+ }, [resolveCloudAuthUrl, cloudAuthConfigured]);
2146
+ const acceptInviteWithToken = useCallback(async (token, password) => {
2147
+ if (!cloudAuthConfigured) {
2148
+ return { success: false, error: 'Cloud auth endpoint is not configured.' };
2149
+ }
2150
+ const trimmedToken = token.trim();
2151
+ const rawPassword = password;
2152
+ if (!trimmedToken || !rawPassword) {
2153
+ return { success: false, error: 'Token and password are required.' };
2154
+ }
2155
+ try {
2156
+ const res = await fetch(resolveCloudAuthUrl('/api/taskforce/auth/invite/accept'), {
2157
+ method: 'POST',
2158
+ headers: { 'Content-Type': 'application/json' },
2159
+ credentials: 'include',
2160
+ body: JSON.stringify({ token: trimmedToken, password: rawPassword })
2161
+ });
2162
+ const data = await res.json().catch(() => ({}));
2163
+ if (!res.ok || !data?.success) {
2164
+ return { success: false, error: data?.error || 'Invite acceptance failed.', code: data?.code };
2165
+ }
2166
+ setError('');
2167
+ await checkAuthSession();
2168
+ const preferredWorkspaceId = typeof data?.workspaceId === 'string' ? data.workspaceId : null;
2169
+ const workspaceResolution = await resolveWorkspaceAfterAuth({ preferredWorkspaceId });
2170
+ if (!workspaceResolution.success) {
2171
+ return {
2172
+ success: false,
2173
+ error: workspaceResolution.error || 'Unable to resolve workspace after invite acceptance.',
2174
+ code: workspaceResolution.code
2175
+ };
2176
+ }
2177
+ if (workspaceResolution.workspaceSetupRequired) {
2178
+ return { success: true, workspaceSetupRequired: true };
2179
+ }
2180
+ await Promise.all([
2181
+ fetchConfig({ ignoreAuthGuard: true }),
2182
+ fetchTasks(true, { ignoreAuthGuard: true })
2183
+ ]);
2184
+ void syncUserGlobalSettings({ preferCloudOnFirstSync: true });
2185
+ return { success: true };
2186
+ }
2187
+ catch {
2188
+ return { success: false, error: 'Invite acceptance failed.' };
2189
+ }
2190
+ }, [checkAuthSession, fetchConfig, fetchTasks, resolveWorkspaceAfterAuth, syncUserGlobalSettings, resolveCloudAuthUrl, cloudAuthConfigured]);
2191
+ const logout = useCallback(async () => {
2192
+ try {
2193
+ if (cloudAuthConfigured) {
2194
+ await fetch(resolveCloudAuthUrl('/api/taskforce/auth/logout'), {
2195
+ method: 'POST',
2196
+ credentials: 'include'
2197
+ });
2198
+ }
2199
+ }
2200
+ catch {
2201
+ // no-op
2202
+ }
2203
+ finally {
2204
+ setIsAuthenticated(false);
2205
+ setAuthUserId('anonymous');
2206
+ setAuthUserEmail('');
2207
+ setUserGlobalSyncStatus('disconnected');
2208
+ setUserGlobalSyncPendingChanges(0);
2209
+ setUserGlobalSyncError(null);
2210
+ setAuthBlocked(runtimeMode === 'cloud' && authRequiredForApi);
2211
+ setCurrentWorkspaceId((prev) => {
2212
+ const existing = String(prev || '').trim();
2213
+ if (existing && existing.toLowerCase() !== 'default')
2214
+ return existing;
2215
+ if (runtimeMode === 'local')
2216
+ return 'default';
2217
+ return readPersistedWorkspaceId(workspaceStorageScope) || 'default';
2218
+ });
2219
+ setAvailableWorkspaces([]);
2220
+ setTasks([]);
2221
+ }
2222
+ }, [authRequiredForApi, resolveCloudAuthUrl, cloudAuthConfigured, workspaceStorageScope, runtimeMode]);
2223
+ const createInitiativeFromTemplate = useCallback(async (payload) => {
2224
+ try {
2225
+ const res = await fetch('/api/taskforce/initiative-templates/create', {
2226
+ method: 'POST',
2227
+ headers: { 'Content-Type': 'application/json' },
2228
+ body: JSON.stringify(payload)
2229
+ });
2230
+ const data = await res.json().catch(() => ({}));
2231
+ if (!res.ok || !data?.success) {
2232
+ return { success: false, error: data?.error || `Create failed (${res.status})` };
2233
+ }
2234
+ await fetchTasks(true);
2235
+ return { success: true, result: data?.results };
2236
+ }
2237
+ catch (error) {
2238
+ return { success: false, error: error.message };
2239
+ }
2240
+ }, [fetchTasks]);
2241
+ const cloneChecklistBlocksToChildren = useCallback(async (payload) => {
2242
+ try {
2243
+ const res = await fetch('/api/taskforce/initiative-templates/clone-checklists', {
2244
+ method: 'POST',
2245
+ headers: { 'Content-Type': 'application/json' },
2246
+ body: JSON.stringify(payload)
2247
+ });
2248
+ const data = await res.json().catch(() => ({}));
2249
+ if (!res.ok || !data?.success) {
2250
+ return { success: false, error: data?.error || `Clone failed (${res.status})` };
2251
+ }
2252
+ await fetchTasks(true);
2253
+ return { success: true, updated: Number(data?.results?.updated || 0) };
2254
+ }
2255
+ catch (error) {
2256
+ return { success: false, error: error.message };
2257
+ }
2258
+ }, [fetchTasks]);
2259
+ // Initialize auth once, then bootstrap protected data once auth gates are settled.
2260
+ const hasInitializedAuthRef = useRef(false);
2261
+ const hasBootstrappedDataRef = useRef(false);
2262
+ useEffect(() => {
2263
+ setIsOpen(true); // Always open in standalone-first
2264
+ if (hasInitializedAuthRef.current)
2265
+ return;
2266
+ hasInitializedAuthRef.current = true;
2267
+ checkAuthSession();
2268
+ }, [checkAuthSession]);
2269
+ useEffect(() => {
2270
+ if (!runtimeConfigReady)
2271
+ return;
2272
+ void checkAuthSession();
2273
+ }, [runtimeConfigReady, checkAuthSession]);
2274
+ // Keep local runtime auth state aligned with cloud session changes
2275
+ // (e.g. user signs in/out in another tab or cloud-hosted app).
2276
+ useEffect(() => {
2277
+ if (runtimeMode !== 'local' || !shouldProbeCloudAuth)
2278
+ return;
2279
+ if (typeof window === 'undefined')
2280
+ return;
2281
+ const recheck = () => { void checkAuthSession(); };
2282
+ const onVisibilityChange = () => {
2283
+ if (document.visibilityState === 'visible')
2284
+ recheck();
2285
+ };
2286
+ window.addEventListener('focus', recheck);
2287
+ window.addEventListener('online', recheck);
2288
+ document.addEventListener('visibilitychange', onVisibilityChange);
2289
+ const intervalId = window.setInterval(recheck, 30_000);
2290
+ return () => {
2291
+ window.removeEventListener('focus', recheck);
2292
+ window.removeEventListener('online', recheck);
2293
+ document.removeEventListener('visibilitychange', onVisibilityChange);
2294
+ window.clearInterval(intervalId);
2295
+ };
2296
+ }, [runtimeMode, shouldProbeCloudAuth, checkAuthSession]);
2297
+ useEffect(() => {
2298
+ if (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls)
2299
+ return;
2300
+ if (hasBootstrappedDataRef.current)
2301
+ return;
2302
+ hasBootstrappedDataRef.current = true;
2303
+ void (async () => {
2304
+ fetchWorkflows();
2305
+ if (runtimeMode === 'cloud' && authSessionResolved && isAuthenticated) {
2306
+ const workspaceResolution = await resolveWorkspaceAfterAuth();
2307
+ if (!workspaceResolution.success || workspaceResolution.workspaceSetupRequired) {
2308
+ await fetchConfig();
2309
+ return;
2310
+ }
2311
+ }
2312
+ await fetchConfig();
2313
+ await fetchTasks();
2314
+ })();
2315
+ }, [
2316
+ shouldDeferProtectedApiCalls,
2317
+ shouldBlockProtectedApiCalls,
2318
+ runtimeMode,
2319
+ authSessionResolved,
2320
+ isAuthenticated,
2321
+ resolveWorkspaceAfterAuth,
2322
+ fetchConfig,
2323
+ fetchWorkflows,
2324
+ fetchTasks
2325
+ ]);
2326
+ useEffect(() => {
2327
+ if (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls)
2328
+ return;
2329
+ if (workspaceStorageScope === WORKSPACE_BOOTSTRAP_SCOPE)
2330
+ return;
2331
+ if (!hasBootstrappedDataRef.current)
2332
+ return;
2333
+ void fetchConfig();
2334
+ }, [
2335
+ shouldDeferProtectedApiCalls,
2336
+ shouldBlockProtectedApiCalls,
2337
+ workspaceStorageScope,
2338
+ fetchConfig
2339
+ ]);
2340
+ useEffect(() => {
2341
+ if (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls)
2342
+ return;
2343
+ void loadWorkspaceSyncState();
2344
+ }, [shouldDeferProtectedApiCalls, shouldBlockProtectedApiCalls, currentWorkspaceId, loadWorkspaceSyncState]);
2345
+ useEffect(() => {
2346
+ if (runtimeMode !== 'cloud' || !authSessionResolved || !isAuthenticated) {
2347
+ if (runtimeMode !== 'cloud') {
2348
+ setAvailableWorkspaces([]);
2349
+ }
2350
+ return;
2351
+ }
2352
+ fetchWorkspaces();
2353
+ }, [runtimeMode, authSessionResolved, isAuthenticated, fetchWorkspaces]);
2354
+ // Report task count to parent
2355
+ useEffect(() => {
2356
+ if (onTaskCountChange) {
2357
+ onTaskCountChange(tasks.length);
2358
+ }
2359
+ }, [tasks.length, onTaskCountChange]);
2360
+ // Deep linking handler
2361
+ useEffect(() => {
2362
+ if (initialTaskId && tasks.length > 0) {
2363
+ const task = tasks.find(t => t.id.endsWith(initialTaskId) || t.id === initialTaskId);
2364
+ if (task) {
2365
+ handleEdit(task);
2366
+ setActiveTab('add');
2367
+ }
2368
+ }
2369
+ }, [initialTaskId, tasks]);
2370
+ // Update active category logic
2371
+ const hasInitedCategoryRef = useRef(false);
2372
+ useEffect(() => {
2373
+ if (!configLoaded)
2374
+ return;
2375
+ if (!hasInitedCategoryRef.current && activeCategories.length > 0) {
2376
+ hasInitedCategoryRef.current = true;
2377
+ const lastCat = lastUsedCategory;
2378
+ const lastCatExists = activeCategories.some(c => c.label === lastCat || c.value === lastCat);
2379
+ if (lastCat && lastCatExists) {
2380
+ const found = activeCategories.find(c => c.label === lastCat || c.value === lastCat);
2381
+ setCategory(found?.value || lastCat);
2382
+ }
2383
+ else {
2384
+ setCategory(getPreferredCategoryValue(activeCategories));
2385
+ }
2386
+ return;
2387
+ }
2388
+ const categoryExists = activeCategories.some(c => c.value === category);
2389
+ if (activeCategories.length > 0 && !categoryExists) {
2390
+ setCategory(getPreferredCategoryValue(activeCategories));
2391
+ }
2392
+ }, [configLoaded, activeCategories, category, getPreferredCategoryValue, lastUsedCategory]);
2393
+ // Sanitize tasks with invalid categories
2394
+ useEffect(() => {
2395
+ if (!configLoaded || activeCategories.length === 0)
2396
+ return;
2397
+ const needsSanitization = (t) => {
2398
+ return !activeCategories.some(c => c.value === t.category);
2399
+ };
2400
+ const invalidTasks = tasks.some(needsSanitization);
2401
+ const invalidArchived = archivedTasks.some(needsSanitization);
2402
+ if (invalidTasks || invalidArchived) {
2403
+ const defaultCategory = getPreferredCategoryValue(activeCategories);
2404
+ // console.warn(`[Taskforce] Found tasks with invalid categories, using "${defaultCategory}"`);
2405
+ if (invalidTasks) {
2406
+ setTasks(prev => prev.map(t => needsSanitization(t) ? { ...t, category: defaultCategory } : t));
2407
+ }
2408
+ if (invalidArchived) {
2409
+ setArchivedTasks(prev => prev.map(t => needsSanitization(t) ? { ...t, category: defaultCategory } : t));
2410
+ }
2411
+ }
2412
+ }, [configLoaded, activeCategories, tasks, archivedTasks, getPreferredCategoryValue]);
2413
+ // Fetch archive
2414
+ const fetchArchive = useCallback(async (isSilent = false) => {
2415
+ if (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls)
2416
+ return;
2417
+ try {
2418
+ const res = await fetch('/api/taskforce/archive');
2419
+ if (res.ok) {
2420
+ const data = await res.json();
2421
+ const sanitized = (data.archived || []).map((t) => ({
2422
+ ...normalizeTaskFromApi(t),
2423
+ isArchived: true
2424
+ }));
2425
+ setArchivedTasks(sanitized);
2426
+ }
2427
+ }
2428
+ catch (err) {
2429
+ console.error('[Taskforce] Failed to fetch archive:', err);
2430
+ }
2431
+ }, [shouldDeferProtectedApiCalls, shouldBlockProtectedApiCalls]);
2432
+ // HMR Listener for Real-Time Updates
2433
+ useEffect(() => {
2434
+ if (import.meta.hot) {
2435
+ import.meta.hot.on('taskforce:update', () => {
2436
+ // console.log('[Taskforce] Received HMR update, refreshing data...');
2437
+ fetchTasks(true);
2438
+ fetchArchive(true);
2439
+ });
2440
+ }
2441
+ }, [fetchTasks, fetchArchive]);
2442
+ useEffect(() => {
2443
+ return () => {
2444
+ if (typeof window === 'undefined')
2445
+ return;
2446
+ highlightTimersRef.current.forEach((timerId) => window.clearTimeout(timerId));
2447
+ highlightTimersRef.current.clear();
2448
+ };
2449
+ }, []);
2450
+ // Fallback sync for cross-process writes (e.g., MCP tools) that may miss watcher/websocket events.
2451
+ useEffect(() => {
2452
+ if (typeof window === 'undefined' || typeof document === 'undefined')
2453
+ return;
2454
+ if (shouldSkipDataVersionPolling({
2455
+ isOpen,
2456
+ authBlocked,
2457
+ authRequiredForApi,
2458
+ isAuthenticated,
2459
+ canCallProtectedApi: authGuardPolicy.canCallProtectedApi
2460
+ }))
2461
+ return;
2462
+ let cancelled = false;
2463
+ const checkDataVersion = async () => {
2464
+ if (cancelled || document.visibilityState !== 'visible' || syncInFlightRef.current)
2465
+ return;
2466
+ if (shouldSkipDataVersionTick({
2467
+ shouldGateProtectedApiCalls,
2468
+ authSessionResolved: authSessionResolvedRef.current,
2469
+ authRequiredForApi: authRequiredForApiRef.current,
2470
+ isAuthenticated: isAuthenticatedRef.current
2471
+ }))
2472
+ return;
2473
+ syncInFlightRef.current = true;
2474
+ try {
2475
+ const res = await fetch('/api/taskforce/data-version');
2476
+ if (!res.ok)
2477
+ return;
2478
+ const data = await res.json();
2479
+ const version = Number(data?.dataVersion);
2480
+ if (!Number.isFinite(version))
2481
+ return;
2482
+ if (dataVersionRef.current === null) {
2483
+ dataVersionRef.current = version;
2484
+ return;
2485
+ }
2486
+ if (version !== dataVersionRef.current) {
2487
+ dataVersionRef.current = version;
2488
+ await Promise.all([fetchTasks(true), fetchArchive(true)]);
2489
+ }
2490
+ }
2491
+ catch {
2492
+ // Keep polling best-effort and silent.
2493
+ }
2494
+ finally {
2495
+ syncInFlightRef.current = false;
2496
+ }
2497
+ };
2498
+ const onVisibilityChange = () => {
2499
+ if (document.visibilityState === 'visible') {
2500
+ checkDataVersion();
2501
+ }
2502
+ };
2503
+ const intervalId = window.setInterval(checkDataVersion, 3000);
2504
+ document.addEventListener('visibilitychange', onVisibilityChange);
2505
+ checkDataVersion();
2506
+ return () => {
2507
+ cancelled = true;
2508
+ window.clearInterval(intervalId);
2509
+ document.removeEventListener('visibilitychange', onVisibilityChange);
2510
+ };
2511
+ }, [
2512
+ isOpen,
2513
+ fetchTasks,
2514
+ fetchArchive,
2515
+ authBlocked,
2516
+ authRequiredForApi,
2517
+ isAuthenticated,
2518
+ authGuardPolicy,
2519
+ shouldDeferProtectedApiCalls,
2520
+ shouldBlockProtectedApiCalls,
2521
+ shouldGateProtectedApiCalls
2522
+ ]);
2523
+ // Toggle sort order
2524
+ const toggleSortOrder = useCallback(() => {
2525
+ setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
2526
+ }, []);
2527
+ // Initialize filters once config is loaded
2528
+ useEffect(() => {
2529
+ if (!configLoaded)
2530
+ return;
2531
+ if (!hasInitedFilters && activeCategories.length > 0 && activeTypes.length > 0 && priorities.length > 0) {
2532
+ setFilterCategories(activeCategories.map(c => c.value));
2533
+ setFilterPriorities(priorities.map(p => p.value));
2534
+ setFilterTypes(activeTypes.map(t => t.value));
2535
+ setFilterAssignees(ASSIGNEE_OPTIONS.map((opt) => opt.value));
2536
+ const initialTaxFilters = {};
2537
+ taxonomies.forEach(tax => {
2538
+ initialTaxFilters[tax.id] = tax.options.map(o => o.value);
2539
+ });
2540
+ setFilterTaxonomies(initialTaxFilters);
2541
+ setHasInitedFilters(true);
2542
+ }
2543
+ }, [configLoaded, activeCategories, activeTypes, priorities, taxonomies, hasInitedFilters]);
2544
+ // Migration: Categorization labels -> values in filters
2545
+ useEffect(() => {
2546
+ if (!configLoaded || !hasInitedFilters || activeCategories.length === 0)
2547
+ return;
2548
+ const needsMigration = filterCategories.some(f => activeCategories.some(c => c.label === f && c.value !== f));
2549
+ if (needsMigration) {
2550
+ setFilterCategories(prev => migrateCategoryFilterLabels(prev, activeCategories));
2551
+ }
2552
+ }, [configLoaded, activeCategories, hasInitedFilters, filterCategories]);
2553
+ // Migration: Legacy priority filters (0..4) and string/number mismatch -> current priority values
2554
+ useEffect(() => {
2555
+ if (!configLoaded || !hasInitedFilters || priorities.length === 0 || filterPriorities.length === 0)
2556
+ return;
2557
+ const normalized = normalizePriorityFilterValues(filterPriorities, priorities);
2558
+ const current = Array.from(new Set(filterPriorities
2559
+ .map(p => Number(p))
2560
+ .filter(n => Number.isFinite(n))));
2561
+ const changed = normalized.length !== current.length ||
2562
+ normalized.some((v, idx) => v !== current[idx]);
2563
+ if (changed) {
2564
+ setFilterPriorities(normalized);
2565
+ }
2566
+ }, [configLoaded, hasInitedFilters, priorities, filterPriorities]);
2567
+ const clearFilters = () => {
2568
+ setSearchQuery('');
2569
+ setFilterCategories(activeCategories.map(c => c.value));
2570
+ setFilterPriorities(priorities.map(p => p.value));
2571
+ setFilterTypes(activeTypes.map(t => t.value));
2572
+ setFilterStatus(STATUS_OPTIONS.map(s => s.value));
2573
+ setFilterAssignees(ASSIGNEE_OPTIONS.map((opt) => opt.value));
2574
+ setParentTaskFilterMode('all');
2575
+ const initialTaxFilters = {};
2576
+ taxonomies.forEach(tax => {
2577
+ initialTaxFilters[tax.id] = tax.options.map(o => o.value);
2578
+ });
2579
+ setFilterTaxonomies(initialTaxFilters);
2580
+ setSortBy('created');
2581
+ setSortOrder('desc');
2582
+ };
2583
+ const filteredTasks = useMemo(() => {
2584
+ const parentTaskIdSet = new Set();
2585
+ for (const task of tasks) {
2586
+ if (task.parentTaskId)
2587
+ parentTaskIdSet.add(task.parentTaskId);
2588
+ }
2589
+ for (const task of archivedTasks) {
2590
+ if (task.parentTaskId)
2591
+ parentTaskIdSet.add(task.parentTaskId);
2592
+ }
2593
+ return tasks.filter(t => {
2594
+ const matchSearch = (t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
2595
+ (t.description?.toLowerCase() || '').includes(searchQuery.toLowerCase()) ||
2596
+ t.id.toLowerCase().includes(searchQuery.toLowerCase()));
2597
+ const matchCategory = !hasInitedFilters || filterCategories.includes(t.category);
2598
+ const matchPriority = !hasInitedFilters || matchesPriorityFilter(t.priority, filterPriorities);
2599
+ const matchType = !hasInitedFilters || filterTypes.includes(t.type || 'feature');
2600
+ const matchStatus = !hasInitedFilters || filterStatus.includes(t.status);
2601
+ const matchAssignee = !assigneeFilterEnabled || !hasInitedFilters || filterAssignees.includes((t.assignee || 'unassigned'));
2602
+ const hasParent = Boolean(t.parentTaskId);
2603
+ const hasChildren = parentTaskIdSet.has(t.id);
2604
+ const matchParentFilter = parentTaskFilterMode === 'all'
2605
+ ? true
2606
+ : parentTaskFilterMode === 'parents'
2607
+ ? hasChildren
2608
+ : parentTaskFilterMode === 'linked'
2609
+ ? (hasParent || hasChildren)
2610
+ : (!hasParent && !hasChildren);
2611
+ const matchTaxonomies = Object.entries(filterTaxonomies).every(([taxId, selectedValues]) => {
2612
+ const taskValue = t.taxonomies?.[taxId];
2613
+ if (!taskValue) {
2614
+ const taxDef = taxonomies.find(tax => tax.id === taxId);
2615
+ const isAllSelected = taxDef?.options.every((opt) => selectedValues.includes(opt.value)) ?? true;
2616
+ return isAllSelected || selectedValues.includes('');
2617
+ }
2618
+ if (Array.isArray(taskValue)) {
2619
+ return taskValue.some(v => selectedValues.includes(v));
2620
+ }
2621
+ return selectedValues.includes(taskValue);
2622
+ });
2623
+ return matchSearch && matchCategory && matchPriority && matchType && matchStatus && matchAssignee && matchTaxonomies && matchParentFilter;
2624
+ }).sort((a, b) => {
2625
+ let comparison = 0;
2626
+ if (sortBy === 'created')
2627
+ comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
2628
+ else if (sortBy === 'updated') {
2629
+ const aUpdated = a.updatedAt || a.createdAt;
2630
+ const bUpdated = b.updatedAt || b.createdAt;
2631
+ comparison = new Date(bUpdated).getTime() - new Date(aUpdated).getTime();
2632
+ }
2633
+ else if (sortBy === 'priority') {
2634
+ const weightA = typeof a.priority === 'number' ? a.priority : (PRIORITY_LOGIC[a.priority] ?? 2);
2635
+ const weightB = typeof b.priority === 'number' ? b.priority : (PRIORITY_LOGIC[b.priority] ?? 2);
2636
+ if (weightA !== weightB)
2637
+ comparison = weightB - weightA;
2638
+ else
2639
+ comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
2640
+ }
2641
+ else if (sortBy === 'complexity') {
2642
+ const complexityA = typeof a.complexity === 'number' ? a.complexity : 3;
2643
+ const complexityB = typeof b.complexity === 'number' ? b.complexity : 3;
2644
+ if (complexityA !== complexityB)
2645
+ comparison = complexityB - complexityA;
2646
+ else
2647
+ comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
2648
+ }
2649
+ else {
2650
+ comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
2651
+ }
2652
+ // Invert if ascending (default is usually descending for newest/priority)
2653
+ // But we want 'asc' to mean literal "First" (Oldest, Low Priority) and 'desc' to mean "Last" (Newest, High Priority)
2654
+ // Currently, 'newest' comparison is (b - a) which is DESCENDING (highest/newest time first).
2655
+ // So if sortOrder is 'asc', we want to invert it.
2656
+ return sortOrder === 'desc' ? comparison : -comparison;
2657
+ });
2658
+ }, [tasks, archivedTasks, searchQuery, filterCategories, filterPriorities, filterTypes, filterStatus, filterAssignees, assigneeFilterEnabled, filterTaxonomies, sortBy, sortOrder, hasInitedFilters, parentTaskFilterMode]);
2659
+ const searchAgnosticTasks = useMemo(() => {
2660
+ const parentTaskIdSet = new Set();
2661
+ for (const task of tasks) {
2662
+ if (task.parentTaskId)
2663
+ parentTaskIdSet.add(task.parentTaskId);
2664
+ }
2665
+ for (const task of archivedTasks) {
2666
+ if (task.parentTaskId)
2667
+ parentTaskIdSet.add(task.parentTaskId);
2668
+ }
2669
+ return tasks.filter(t => {
2670
+ const matchCategory = !hasInitedFilters || filterCategories.includes(t.category);
2671
+ const matchPriority = !hasInitedFilters || matchesPriorityFilter(t.priority, filterPriorities);
2672
+ const matchType = !hasInitedFilters || filterTypes.includes(t.type || 'feature');
2673
+ const matchStatus = !hasInitedFilters || filterStatus.includes(t.status);
2674
+ const matchAssignee = !assigneeFilterEnabled || !hasInitedFilters || filterAssignees.includes((t.assignee || 'unassigned'));
2675
+ const hasParent = Boolean(t.parentTaskId);
2676
+ const hasChildren = parentTaskIdSet.has(t.id);
2677
+ const matchParentFilter = parentTaskFilterMode === 'all'
2678
+ ? true
2679
+ : parentTaskFilterMode === 'parents'
2680
+ ? hasChildren
2681
+ : parentTaskFilterMode === 'linked'
2682
+ ? (hasParent || hasChildren)
2683
+ : (!hasParent && !hasChildren);
2684
+ const matchTaxonomies = Object.entries(filterTaxonomies).every(([taxId, selectedValues]) => {
2685
+ const taskValue = t.taxonomies?.[taxId];
2686
+ if (!taskValue) {
2687
+ const taxDef = taxonomies.find(tax => tax.id === taxId);
2688
+ const isAllSelected = taxDef?.options.every((opt) => selectedValues.includes(opt.value)) ?? true;
2689
+ return isAllSelected || selectedValues.includes('');
2690
+ }
2691
+ if (Array.isArray(taskValue))
2692
+ return taskValue.some(v => selectedValues.includes(v));
2693
+ return selectedValues.includes(taskValue);
2694
+ });
2695
+ return matchCategory && matchPriority && matchType && matchStatus && matchAssignee && matchTaxonomies && matchParentFilter;
2696
+ });
2697
+ }, [tasks, archivedTasks, filterCategories, filterPriorities, filterTypes, filterStatus, filterAssignees, assigneeFilterEnabled, filterTaxonomies, hasInitedFilters, taxonomies, parentTaskFilterMode]);
2698
+ const filteredArchive = useMemo(() => {
2699
+ const isAllCategoriesSelected = activeCategories.every(c => filterCategories.includes(c.value));
2700
+ return archivedTasks.filter(t => {
2701
+ const matchSearch = (t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
2702
+ (t.description?.toLowerCase() || '').includes(searchQuery.toLowerCase()) ||
2703
+ t.id.toLowerCase().includes(searchQuery.toLowerCase()));
2704
+ const matchCategory = !hasInitedFilters || isAllCategoriesSelected || filterCategories.includes(t.category);
2705
+ const matchPriority = !hasInitedFilters || matchesPriorityFilter(t.priority, filterPriorities);
2706
+ const matchType = !hasInitedFilters || filterTypes.includes(t.type || 'feature');
2707
+ const matchAssignee = !assigneeFilterEnabled || !hasInitedFilters || filterAssignees.includes((t.assignee || 'unassigned'));
2708
+ const matchTaxonomies = Object.entries(filterTaxonomies).every(([taxId, selectedValues]) => {
2709
+ const taskValue = t.taxonomies?.[taxId];
2710
+ if (!taskValue) {
2711
+ const taxDef = taxonomies.find(tax => tax.id === taxId);
2712
+ const isAllSelected = taxDef?.options.every((opt) => selectedValues.includes(opt.value)) ?? true;
2713
+ return isAllSelected || selectedValues.includes('');
2714
+ }
2715
+ if (Array.isArray(taskValue))
2716
+ return taskValue.some(v => selectedValues.includes(v));
2717
+ return selectedValues.includes(taskValue);
2718
+ });
2719
+ return matchSearch && matchCategory && matchPriority && matchType && matchAssignee && matchTaxonomies;
2720
+ }).sort((a, b) => {
2721
+ const comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
2722
+ return sortBy === 'created' ? (sortOrder === 'desc' ? comparison : -comparison) : 0;
2723
+ });
2724
+ }, [archivedTasks, searchQuery, filterCategories, filterPriorities, filterTypes, filterAssignees, assigneeFilterEnabled, filterTaxonomies, sortBy, hasInitedFilters, activeCategories]);
2725
+ const relationshipTasks = useMemo(() => {
2726
+ const byId = new Map();
2727
+ for (const task of archivedTasks)
2728
+ byId.set(task.id, task);
2729
+ for (const task of tasks)
2730
+ byId.set(task.id, task);
2731
+ return Array.from(byId.values());
2732
+ }, [tasks, archivedTasks]);
2733
+ const [collapsedCategories, setCollapsedCategories] = useState({});
2734
+ const groupedTasks = useMemo(() => {
2735
+ const groups = {};
2736
+ filteredTasks.forEach(task => {
2737
+ const catConfig = activeCategories.find(c => c.value === task.category);
2738
+ const catLabel = catConfig ? catConfig.label : (task.category || 'General');
2739
+ if (!groups[catLabel])
2740
+ groups[catLabel] = [];
2741
+ groups[catLabel].push(task);
2742
+ });
2743
+ return groups;
2744
+ }, [filteredTasks, activeCategories]);
2745
+ // Load tasks when modal opens
2746
+ useEffect(() => {
2747
+ if (shouldDeferProtectedApiCalls || shouldBlockProtectedApiCalls)
2748
+ return;
2749
+ if (isOpen) {
2750
+ if (activeTab === 'tasks') {
2751
+ fetchTasks();
2752
+ fetchArchive(true);
2753
+ }
2754
+ else if (activeTab === 'settings') {
2755
+ fetchConfig();
2756
+ if (settingsSection === 'commands' || settingsSection === 'resources') {
2757
+ fetchWorkflows();
2758
+ }
2759
+ }
2760
+ }
2761
+ }, [
2762
+ isOpen,
2763
+ activeTab,
2764
+ settingsSection,
2765
+ showArchive,
2766
+ fetchTasks,
2767
+ fetchArchive,
2768
+ fetchConfig,
2769
+ fetchWorkflows,
2770
+ shouldDeferProtectedApiCalls,
2771
+ shouldBlockProtectedApiCalls
2772
+ ]);
2773
+ const fetchFolders = useCallback(async (path = '') => {
2774
+ try {
2775
+ const res = await fetch(`/api/taskforce/folders?path=${encodeURIComponent(path)}`);
2776
+ if (res.ok) {
2777
+ const data = await res.json();
2778
+ setFolders(data.folders || []);
2779
+ setFiles(data.files || []);
2780
+ setCurrentBrowsePath(path);
2781
+ }
2782
+ }
2783
+ catch (err) {
2784
+ console.error('[Taskforce] Failed to fetch folders:', err);
2785
+ }
2786
+ }, []);
2787
+ const getCategoryPaths = useCallback((cat) => {
2788
+ const paths = [];
2789
+ if (cat.path) {
2790
+ paths.push(cat.path);
2791
+ }
2792
+ if (cat.paths && cat.paths.length > 0) {
2793
+ for (const p of cat.paths) {
2794
+ if (!paths.includes(p)) {
2795
+ paths.push(p);
2796
+ }
2797
+ }
2798
+ }
2799
+ return paths;
2800
+ }, []);
2801
+ const validatePaths = useCallback(async (paths) => {
2802
+ if (paths.length === 0)
2803
+ return;
2804
+ try {
2805
+ const res = await fetch('/api/taskforce/validate-paths', {
2806
+ method: 'POST',
2807
+ headers: { 'Content-Type': 'application/json' },
2808
+ body: JSON.stringify({ paths }),
2809
+ });
2810
+ if (res.ok) {
2811
+ const data = await res.json();
2812
+ setPathValidation(prev => ({ ...prev, ...data.results }));
2813
+ }
2814
+ }
2815
+ catch (err) {
2816
+ console.error('[Taskforce] Failed to validate paths:', err);
2817
+ }
2818
+ }, []);
2819
+ useEffect(() => {
2820
+ if (activeTab === 'settings' && configLoaded) {
2821
+ const allPaths = [];
2822
+ activeCategories.forEach(cat => {
2823
+ const catPaths = getCategoryPaths(cat);
2824
+ catPaths.forEach(p => {
2825
+ if (!allPaths.includes(p))
2826
+ allPaths.push(p);
2827
+ });
2828
+ });
2829
+ const pathsToValidate = allPaths.filter(p => !pathValidation[p]);
2830
+ if (pathsToValidate.length > 0) {
2831
+ validatePaths(pathsToValidate);
2832
+ }
2833
+ }
2834
+ }, [activeTab, configLoaded, activeCategories, getCategoryPaths, validatePaths, pathValidation]);
2835
+ const handleUpdateCategory = useCallback(async (updatedCat, oldLabel) => {
2836
+ if (!configLoaded)
2837
+ return;
2838
+ setCustomCategories(prev => {
2839
+ const current = prev.length > 0 ? prev : activeCategories;
2840
+ return current.map(c => c.value === updatedCat.value ? updatedCat : c);
2841
+ });
2842
+ if (oldLabel && oldLabel !== updatedCat.label) {
2843
+ if (category === oldLabel) {
2844
+ setCategory(updatedCat.label);
2845
+ }
2846
+ if (filterCategories.includes(oldLabel)) {
2847
+ setFilterCategories(prev => prev.map(c => c === oldLabel ? updatedCat.label : c));
2848
+ }
2849
+ }
2850
+ try {
2851
+ // Get the latest updated list for the API call
2852
+ // Since we just called setCustomCategories, we can't easily get the 'new' state here
2853
+ // but we can compute it again or use activeCategories if we know it's fresh.
2854
+ // Best is to use the same logic as the functional update above.
2855
+ const currentCats = customCategories.length > 0 ? customCategories : activeCategories;
2856
+ const updatedList = currentCats.map(c => c.value === updatedCat.value ? updatedCat : c);
2857
+ const body = { categories: updatedList };
2858
+ if (oldLabel && oldLabel !== updatedCat.label) {
2859
+ body.reassignFrom = oldLabel;
2860
+ body.reassignTo = updatedCat.label;
2861
+ }
2862
+ await fetch('/api/taskforce/categories', {
2863
+ method: 'POST',
2864
+ headers: { 'Content-Type': 'application/json' },
2865
+ body: JSON.stringify(body),
2866
+ });
2867
+ if (oldLabel)
2868
+ fetchTasks();
2869
+ }
2870
+ catch (err) {
2871
+ console.error('[Taskforce] Failed to update category', err);
2872
+ }
2873
+ }, [configLoaded, activeCategories, customCategories, category, filterCategories, fetchTasks]);
2874
+ const handleSaveCategory = useCallback(async (newCategoryName) => {
2875
+ if (!configLoaded)
2876
+ return;
2877
+ const generatedId = newCategoryName.trim().toLowerCase().replace(/\s+/g, '-');
2878
+ // Check in activeCategories to be sure
2879
+ if (activeCategories.some(c => c.value === generatedId)) {
2880
+ // Already exists
2881
+ return;
2882
+ }
2883
+ const newCatConfig = {
2884
+ value: generatedId,
2885
+ label: newCategoryName.trim(),
2886
+ color: 'blue-200', // Default light shade
2887
+ icon: 'Folder'
2888
+ };
2889
+ setCustomCategories(prev => {
2890
+ const current = prev.length > 0 ? prev : activeCategories;
2891
+ return [...current, newCatConfig];
2892
+ });
2893
+ try {
2894
+ const currentCats = customCategories.length > 0 ? customCategories : activeCategories;
2895
+ const updated = [...currentCats, newCatConfig];
2896
+ await fetch('/api/taskforce/categories', {
2897
+ method: 'POST',
2898
+ headers: { 'Content-Type': 'application/json' },
2899
+ body: JSON.stringify({ categories: updated }),
2900
+ });
2901
+ }
2902
+ catch (err) {
2903
+ console.error('[Taskforce] Failed to create category', err);
2904
+ }
2905
+ }, [configLoaded, activeCategories, customCategories]);
2906
+ // ... Implement other helpers similarly by copying/refactoring from TaskforceCore ...
2907
+ // Note: Due to size, I am implementing the core parts first. The rest of the event handlers need to be extracted.
2908
+ // I will include ALL handlers in this file.
2909
+ // Navigation and Tab Management
2910
+ const setActiveTab = useCallback((tab) => {
2911
+ // Record scroll position before switching away from tasks
2912
+ if (activeTab === 'tasks' && tasksScrollRef.current) {
2913
+ setTasksScrollPos(tasksScrollRef.current.scrollTop);
2914
+ }
2915
+ setActiveTab__internal(tab);
2916
+ }, [activeTab]);
2917
+ const handleNavigation = useCallback((action) => {
2918
+ // Check for unsaved changes...
2919
+ let hasChanges = false;
2920
+ if (editingTaskId) {
2921
+ // Existing Logic for Editing Tasks: Compare against original
2922
+ const original = tasks.find(t => t.id === editingTaskId);
2923
+ if (original) {
2924
+ const isDescriptionEqual = (original.description || '') === (description || '');
2925
+ const isPriorityEqual = Number(original.priority) === Number(priority);
2926
+ const isComplexityEqual = Number(original.complexity ?? 3) === Number(complexity ?? 3);
2927
+ const isApproachEqual = (original.approach || '') === (approach || '');
2928
+ const isAssigneeEqual = (original.assignee || 'unassigned') === (assignee || 'unassigned');
2929
+ const isParentEqual = (original.parentTaskId || '') === (parentTaskIdInput || '').trim();
2930
+ const isTaxonomiesEqual = JSON.stringify(original.taxonomies || {}) === JSON.stringify(formTaxonomies);
2931
+ const isSpecialistsEqual = JSON.stringify(original.specialists || []) === JSON.stringify(selectedSpecialists);
2932
+ const isScreenshotsEqual = JSON.stringify(original.attachments || original.screenshots || []) === JSON.stringify(screenshots);
2933
+ if (original.title !== title ||
2934
+ !isDescriptionEqual ||
2935
+ original.category !== category ||
2936
+ original.type !== type ||
2937
+ !isPriorityEqual ||
2938
+ !isComplexityEqual ||
2939
+ !isApproachEqual ||
2940
+ !isAssigneeEqual ||
2941
+ !isParentEqual ||
2942
+ !isTaxonomiesEqual ||
2943
+ !isSpecialistsEqual ||
2944
+ !isScreenshotsEqual ||
2945
+ newCommentText.trim() !== '') {
2946
+ hasChanges = true;
2947
+ }
2948
+ }
2949
+ }
2950
+ else {
2951
+ // New Task Logic: Relaxed check
2952
+ // Only consider it "dirty" if the user has actually typed content or added attachments.
2953
+ // We IGNORE: category, type, priority, approach, taxonomies (as they have defaults)
2954
+ hasChanges = title.trim() !== '' ||
2955
+ description.trim() !== '' ||
2956
+ newCommentText.trim() !== '' ||
2957
+ comments.length > 0 ||
2958
+ screenshots.length > 0;
2959
+ }
2960
+ // If adding and empty, allow
2961
+ if (!hasChanges) {
2962
+ action();
2963
+ return;
2964
+ }
2965
+ // Otherwise prompt
2966
+ setPendingNavigation(() => action);
2967
+ setUnsavedModalOpen(true);
2968
+ }, [title, description, category, type, priority, complexity, approach, assignee, scheduledDate, dueDate, parentTaskIdInput, editingTaskId, tasks, formTaxonomies, selectedSpecialists, screenshots, activeCategories]);
2969
+ const handleClose = useCallback(() => {
2970
+ setIsOpen(false);
2971
+ // Reset state after close animation?
2972
+ }, []);
2973
+ const resetForm = useCallback(() => {
2974
+ setEditingTaskId(null);
2975
+ setTitle('');
2976
+ setDescription('');
2977
+ // Reset category to last used or default
2978
+ const lastCat = lastUsedCategory;
2979
+ const lastCatExists = activeCategories.some(c => c.label === lastCat || c.value === lastCat);
2980
+ if (lastCat && lastCatExists) {
2981
+ const found = activeCategories.find(c => c.label === lastCat || c.value === lastCat);
2982
+ setCategory(found?.value || lastCat);
2983
+ }
2984
+ else {
2985
+ setCategory(getPreferredCategoryValue(activeCategories));
2986
+ }
2987
+ setType('feature');
2988
+ setPriority(2);
2989
+ setComplexity(3);
2990
+ setApproach('default');
2991
+ setAssignee('agent');
2992
+ setScheduledDate('');
2993
+ setDueDate('');
2994
+ setParentTaskIdInput('');
2995
+ setAddChildTaskIdInput('');
2996
+ setFormTaxonomies({});
2997
+ setComments([]);
2998
+ setNewCommentText('');
2999
+ setSelectedSpecialists([]);
3000
+ setScreenshots([]);
3001
+ setError('');
3002
+ }, [activeCategories, getPreferredCategoryValue, lastUsedCategory]);
3003
+ const resolveTaskIdInput = useCallback((value) => {
3004
+ const needle = value.trim();
3005
+ if (!needle)
3006
+ return null;
3007
+ const exact = relationshipTasks.find(t => t.id === needle);
3008
+ if (exact)
3009
+ return exact;
3010
+ const suffixMatches = relationshipTasks.filter(t => t.id.endsWith(needle));
3011
+ if (suffixMatches.length === 1)
3012
+ return suffixMatches[0];
3013
+ return null;
3014
+ }, [relationshipTasks]);
3015
+ const handleEdit = useCallback((task) => {
3016
+ if (returnFromChildTaskId && task.id !== returnFromChildTaskId) {
3017
+ setReturnToParentTaskId(null);
3018
+ setReturnFromChildTaskId(null);
3019
+ }
3020
+ setEditingTaskId(task.id);
3021
+ setTitle(task.title);
3022
+ setDescription(task.description || '');
3023
+ setCategory(task.category || getPreferredCategoryValue(activeCategories));
3024
+ setType(task.type || 'feature');
3025
+ setPriority(typeof task.priority === 'number' ? task.priority : 2);
3026
+ setComplexity(typeof task.complexity === 'number' ? task.complexity : 3);
3027
+ setApproach(task.approach || 'default');
3028
+ setAssignee((task.assignee || 'unassigned'));
3029
+ setScheduledDate(task.scheduledDate || '');
3030
+ setDueDate(task.dueDate || '');
3031
+ setParentTaskIdInput(task.parentTaskId || '');
3032
+ setAddChildTaskIdInput('');
3033
+ setComments(task.comments || []);
3034
+ // Handle taxonomies
3035
+ setFormTaxonomies(task.taxonomies || {});
3036
+ // Also sync approach if it's in taxonomies
3037
+ if (task.taxonomies?.approach) {
3038
+ setApproach(task.taxonomies.approach);
3039
+ }
3040
+ setSelectedSpecialists(task.specialists || []);
3041
+ setScreenshots(task.attachments || task.screenshots || []);
3042
+ // Switch tab
3043
+ setActiveTab('add');
3044
+ // Scroll to top of form
3045
+ }, [activeCategories, returnFromChildTaskId, getPreferredCategoryValue]);
3046
+ const handleOpenTaskById = useCallback((taskId) => {
3047
+ const target = tasks.find(t => t.id === taskId);
3048
+ if (!target)
3049
+ return;
3050
+ if (activeTab === 'add' && editingTaskId && editingTaskId !== target.id) {
3051
+ setReturnToParentTaskId(editingTaskId);
3052
+ setReturnFromChildTaskId(target.id);
3053
+ }
3054
+ handleEdit(target);
3055
+ }, [tasks, handleEdit, activeTab, editingTaskId]);
3056
+ // CRUD Operations
3057
+ const saveTask = async () => {
3058
+ if (!title.trim()) {
3059
+ setError('Title is required');
3060
+ return false;
3061
+ }
3062
+ if (editingTaskId) {
3063
+ const existing = relationshipTasks.find((t) => t.id === editingTaskId);
3064
+ if (existing?.isArchived) {
3065
+ setError('Archived tasks are read-only. Unarchive first to make changes.');
3066
+ return false;
3067
+ }
3068
+ }
3069
+ setLoading(true);
3070
+ setError('');
3071
+ try {
3072
+ const url = editingTaskId
3073
+ ? `/api/taskforce/task/${editingTaskId}`
3074
+ : apiEndpoint.replace('/api/dev/task', '/api/taskforce/task').replace('/api/taskforce/task', '/api/taskforce/task');
3075
+ const method = editingTaskId ? 'PATCH' : 'POST';
3076
+ const sanitizedCategory = typeof category === 'string'
3077
+ ? category
3078
+ : category.label || getPreferredCategoryValue(activeCategories);
3079
+ const sanitizedType = typeof type === 'string' ? type : type.label || 'feature';
3080
+ const res = await fetch(url, {
3081
+ method,
3082
+ headers: { 'Content-Type': 'application/json' },
3083
+ body: JSON.stringify({
3084
+ title,
3085
+ description: description || null,
3086
+ category: sanitizedCategory || getPreferredCategoryValue(activeCategories),
3087
+ type: sanitizedType,
3088
+ priority,
3089
+ complexity: Number(complexity) || 3,
3090
+ approach: approach || 'default',
3091
+ assignee: assignee || 'agent',
3092
+ scheduledDate: scheduledDate || null,
3093
+ dueDate: dueDate || null,
3094
+ parentTaskId: parentTaskIdInput.trim() || null,
3095
+ comments,
3096
+ attachments: screenshots,
3097
+ taxonomies: formTaxonomies,
3098
+ specialists: selectedSpecialists,
3099
+ createdAt: new Date().toISOString(),
3100
+ createdBy: 'user',
3101
+ }),
3102
+ });
3103
+ if (res.ok) {
3104
+ // Save last used category
3105
+ setLastUsedCategory(sanitizedCategory);
3106
+ setLoading(false);
3107
+ return true;
3108
+ }
3109
+ else {
3110
+ const data = await res.json();
3111
+ setError(data.message || `Failed to ${editingTaskId ? 'update' : 'save'} task`);
3112
+ setLoading(false);
3113
+ return false;
3114
+ }
3115
+ }
3116
+ catch {
3117
+ setError('Failed to connect to server');
3118
+ setLoading(false);
3119
+ return false;
3120
+ }
3121
+ };
3122
+ const runSaveFlow = useCallback(async () => {
3123
+ const success = await saveTask();
3124
+ if (success) {
3125
+ setSuccessBanner({
3126
+ message: `Task ${editingTaskId ? 'updated' : 'added'} successfully`,
3127
+ type: editingTaskId ? 'updated' : 'added'
3128
+ });
3129
+ setTimeout(() => setSuccessBanner(null), 3000);
3130
+ if (activeTab === 'add') {
3131
+ resetForm();
3132
+ setActiveTab('tasks');
3133
+ fetchTasks(); // Refresh list
3134
+ }
3135
+ }
3136
+ }, [saveTask, editingTaskId, activeTab, resetForm, fetchTasks]);
3137
+ const handleSubmit = async (e) => {
3138
+ e.preventDefault();
3139
+ if (dueDate && scheduledDate && dueDate < scheduledDate) {
3140
+ setScheduleWarningPrompt({ dueDate, scheduledDate });
3141
+ return;
3142
+ }
3143
+ await runSaveFlow();
3144
+ };
3145
+ const confirmScheduleWarning = useCallback(async () => {
3146
+ setScheduleWarningPrompt(null);
3147
+ await runSaveFlow();
3148
+ }, [runSaveFlow]);
3149
+ const cancelScheduleWarning = useCallback(() => {
3150
+ setScheduleWarningPrompt(null);
3151
+ }, []);
3152
+ const handleDelete = async (id, archive = false, options = {}) => {
3153
+ if (!options.skipConfirm && !confirm('Are you sure you want to delete this task permanently?'))
3154
+ return false;
3155
+ const isArchivedTask = archive || archivedTasks.some((task) => task.id === id);
3156
+ try {
3157
+ const res = await fetch(`/api/taskforce/task/${id}`, { method: 'DELETE' });
3158
+ if (!res.ok) {
3159
+ const data = await res.json().catch(() => ({}));
3160
+ setError(data.error || 'Failed to delete task.');
3161
+ return false;
3162
+ }
3163
+ if (window.location.search.includes(id)) {
3164
+ // remove query param if we deleted the deep linked task
3165
+ const url = new URL(window.location.href);
3166
+ url.searchParams.delete('task');
3167
+ window.history.pushState({}, '', url.toString());
3168
+ }
3169
+ if (isArchivedTask) {
3170
+ setArchivedTasks(prev => prev.filter(t => t.id !== id));
3171
+ }
3172
+ else {
3173
+ setTasks(prev => prev.filter(t => t.id !== id));
3174
+ }
3175
+ workspaceDeletedTaskIdsRef.current.add(id);
3176
+ if (cloudAuthConfigured && runtimeMode === 'local' && isAuthenticated && workspaceCloudSyncEnabled) {
3177
+ const signature = buildWorkspaceSyncSignature();
3178
+ workspacePendingSignatureRef.current = signature;
3179
+ void pushWorkspaceChangesToCloud(signature);
3180
+ }
3181
+ if (editingTaskId === id) {
3182
+ resetForm();
3183
+ setActiveTab('tasks');
3184
+ }
3185
+ setError('');
3186
+ return true;
3187
+ }
3188
+ catch (err) {
3189
+ console.error('Failed to delete task:', err);
3190
+ setError('Failed to delete task.');
3191
+ return false;
3192
+ }
3193
+ };
3194
+ // Generic update handler for drag-and-drop
3195
+ const handleUpdateTask = useCallback(async (id, updates) => {
3196
+ const previousTasks = tasks;
3197
+ const previousArchivedTasks = archivedTasks;
3198
+ // Optimistic update
3199
+ setTasks(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t));
3200
+ setArchivedTasks(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t));
3201
+ try {
3202
+ const res = await fetch(`/api/taskforce/task/${id}`, {
3203
+ method: 'PATCH',
3204
+ headers: { 'Content-Type': 'application/json' },
3205
+ body: JSON.stringify(updates),
3206
+ });
3207
+ if (!res.ok) {
3208
+ const data = await res.json().catch(() => ({}));
3209
+ setError(data.error || `Failed to update task ${id}.`);
3210
+ setTasks(previousTasks);
3211
+ setArchivedTasks(previousArchivedTasks);
3212
+ return;
3213
+ }
3214
+ setError('');
3215
+ }
3216
+ catch (err) {
3217
+ console.error("Failed to update task", err);
3218
+ // Revert on error
3219
+ setError(`Failed to update task ${id}.`);
3220
+ setTasks(previousTasks);
3221
+ setArchivedTasks(previousArchivedTasks);
3222
+ }
3223
+ }, [tasks, archivedTasks]);
3224
+ // Other handlers (Archive, Status Toggles, etc.)
3225
+ const handleToggleComplete = async (task) => {
3226
+ const newStatus = task.status === 'done' ? 'task' : 'done';
3227
+ const isDone = newStatus === 'done';
3228
+ const hierarchySource = [...tasks, ...archivedTasks];
3229
+ const hasDescendants = Array.from(collectSubtreeTaskIds(hierarchySource, task.id)).some((id) => id !== task.id);
3230
+ if (isDone && hasDescendants) {
3231
+ setError('Parent tasks complete automatically when all descendant tasks are done or cancelled.');
3232
+ return;
3233
+ }
3234
+ try {
3235
+ const completedAt = isDone ? new Date().toISOString() : undefined;
3236
+ const res = await fetch(`/api/taskforce/task/${task.id}`, {
3237
+ method: 'PATCH',
3238
+ headers: { 'Content-Type': 'application/json' },
3239
+ body: JSON.stringify({ status: newStatus, completedAt }),
3240
+ });
3241
+ if (!res.ok) {
3242
+ const data = await res.json().catch(() => ({}));
3243
+ setError(data.error || 'Failed to update task status.');
3244
+ await fetchTasks(true);
3245
+ return;
3246
+ }
3247
+ await fetchTasks(true);
3248
+ setError('');
3249
+ }
3250
+ catch (err) {
3251
+ console.error("Failed to toggle complete", err);
3252
+ setError('Failed to update task status.');
3253
+ fetchTasks();
3254
+ }
3255
+ };
3256
+ const handleToggleCancel = async (task) => {
3257
+ const newStatus = task.status === 'cancelled' ? 'task' : 'cancelled';
3258
+ const isCancelled = newStatus === 'cancelled';
3259
+ if (isCancelled) {
3260
+ const taskById = new Map();
3261
+ tasks.forEach((item) => taskById.set(item.id, item));
3262
+ const depthById = buildHierarchyDepthMap(tasks);
3263
+ const activeDescendantIds = Array.from(collectSubtreeTaskIds(tasks, task.id))
3264
+ .filter((id) => id !== task.id)
3265
+ .filter((id) => {
3266
+ const child = taskById.get(id);
3267
+ return Boolean(child) && !child?.isArchived && child?.status !== 'done' && child?.status !== 'cancelled';
3268
+ })
3269
+ .sort((a, b) => (depthById.get(b) || 0) - (depthById.get(a) || 0));
3270
+ setCancellationOpenDescendantIds(activeDescendantIds);
3271
+ setCancellationMode(null);
3272
+ setCancellationPromptTask(task);
3273
+ return;
3274
+ }
3275
+ try {
3276
+ const res = await fetch(`/api/taskforce/task/${task.id}`, {
3277
+ method: 'PATCH',
3278
+ headers: { 'Content-Type': 'application/json' },
3279
+ body: JSON.stringify({ status: newStatus }),
3280
+ });
3281
+ if (!res.ok) {
3282
+ const data = await res.json().catch(() => ({}));
3283
+ setError(data.error || 'Failed to update task status.');
3284
+ await fetchTasks(true);
3285
+ return;
3286
+ }
3287
+ await fetchTasks(true);
3288
+ setError('');
3289
+ }
3290
+ catch (err) {
3291
+ console.error("Failed to toggle cancel", err);
3292
+ setError('Failed to update task status.');
3293
+ fetchTasks();
3294
+ }
3295
+ };
3296
+ const confirmCancellation = useCallback(async (reason, mode) => {
3297
+ if (!cancellationPromptTask)
3298
+ return false;
3299
+ const task = cancellationPromptTask;
3300
+ const selectedMode = mode || cancellationMode;
3301
+ if (cancellationOpenDescendantIds.length > 0 && !selectedMode) {
3302
+ setError('Choose how to handle open descendant tasks before confirming cancellation.');
3303
+ return false;
3304
+ }
3305
+ try {
3306
+ const res = await fetch(`/api/taskforce/task/${task.id}/cancel-parent`, {
3307
+ method: 'POST',
3308
+ headers: { 'Content-Type': 'application/json' },
3309
+ body: JSON.stringify({
3310
+ reason,
3311
+ mode: selectedMode || 'cascade'
3312
+ }),
3313
+ });
3314
+ if (!res.ok) {
3315
+ const data = await res.json().catch(() => ({}));
3316
+ setError(data.error || 'Failed to update task status.');
3317
+ await fetchTasks(true);
3318
+ return false;
3319
+ }
3320
+ setError('');
3321
+ setCancellationPromptTask(null);
3322
+ setCancellationOpenDescendantIds([]);
3323
+ setCancellationMode(null);
3324
+ await fetchTasks(true);
3325
+ return true;
3326
+ }
3327
+ catch (err) {
3328
+ console.error("Failed to confirm cancellation", err);
3329
+ setError('Failed to update task status.');
3330
+ fetchTasks();
3331
+ return false;
3332
+ }
3333
+ }, [cancellationPromptTask, cancellationOpenDescendantIds, cancellationMode, fetchTasks]);
3334
+ const cancelCancellationPrompt = useCallback(() => {
3335
+ setCancellationPromptTask(null);
3336
+ setCancellationOpenDescendantIds([]);
3337
+ setCancellationMode(null);
3338
+ }, []);
3339
+ const handleToggleInProgress = async (task) => {
3340
+ const newStatus = task.status === 'in-progress' ? 'task' : 'in-progress';
3341
+ const isInProgress = newStatus === 'in-progress';
3342
+ setTasks(prev => prev.map(t => t.id === task.id ? {
3343
+ ...t,
3344
+ status: newStatus
3345
+ } : t));
3346
+ try {
3347
+ await fetch(`/api/taskforce/task/${task.id}`, {
3348
+ method: 'PATCH',
3349
+ headers: { 'Content-Type': 'application/json' },
3350
+ body: JSON.stringify({ status: newStatus }),
3351
+ });
3352
+ }
3353
+ catch (err) {
3354
+ fetchTasks();
3355
+ }
3356
+ };
3357
+ const handleToggleReview = async (task) => {
3358
+ const newStatus = task.status === 'review' ? 'task' : 'review';
3359
+ const isReview = newStatus === 'review';
3360
+ setTasks(prev => prev.map(t => t.id === task.id ? {
3361
+ ...t,
3362
+ status: newStatus
3363
+ } : t));
3364
+ try {
3365
+ await fetch(`/api/taskforce/task/${task.id}`, {
3366
+ method: 'PATCH',
3367
+ headers: { 'Content-Type': 'application/json' },
3368
+ body: JSON.stringify({ status: newStatus }),
3369
+ });
3370
+ }
3371
+ catch (err) {
3372
+ fetchTasks();
3373
+ }
3374
+ };
3375
+ const handleArchiveTask = async (task) => {
3376
+ try {
3377
+ const taskById = new Map();
3378
+ tasks.forEach((item) => taskById.set(item.id, item));
3379
+ const depthById = buildHierarchyDepthMap(tasks);
3380
+ const descendantIds = Array.from(collectSubtreeTaskIds(tasks, task.id))
3381
+ .filter((id) => id !== task.id)
3382
+ .filter((id) => {
3383
+ const child = taskById.get(id);
3384
+ return Boolean(child) && !child?.isArchived;
3385
+ })
3386
+ .sort((a, b) => (depthById.get(b) || 0) - (depthById.get(a) || 0));
3387
+ const shouldCascadeArchive = descendantIds.length > 0
3388
+ ? window.confirm(t('actionHeader.confirmCascadeArchive', { count: descendantIds.length }))
3389
+ : false;
3390
+ if (descendantIds.length > 0 && shouldCascadeArchive) {
3391
+ for (const childId of descendantIds) {
3392
+ const child = taskById.get(childId);
3393
+ if (!child)
3394
+ continue;
3395
+ const childEndpoint = child.status === 'cancelled' ? 'cancel' : 'complete';
3396
+ const childRes = await fetch(`/api/taskforce/task/${childId}/${childEndpoint}`, { method: 'POST' });
3397
+ if (!childRes.ok) {
3398
+ const data = await childRes.json().catch(() => ({}));
3399
+ setError(data.error || 'Failed to archive task.');
3400
+ await fetchTasks(true);
3401
+ return;
3402
+ }
3403
+ }
3404
+ }
3405
+ const isCancelled = task.status === 'cancelled';
3406
+ const endpoint = isCancelled ? 'cancel' : 'complete';
3407
+ const res = await fetch(`/api/taskforce/task/${task.id}/${endpoint}`, { method: 'POST' });
3408
+ if (res.ok) {
3409
+ const archivedIdSet = new Set([task.id, ...(shouldCascadeArchive ? descendantIds : [])]);
3410
+ setTasks(prev => prev.filter(t => !archivedIdSet.has(t.id)));
3411
+ // backend returns task via data result or we can just fetch
3412
+ fetchArchive();
3413
+ // If archiving from edit modal, close edit and return to task list.
3414
+ if (editingTaskId && archivedIdSet.has(editingTaskId)) {
3415
+ resetForm();
3416
+ setActiveTab('tasks');
3417
+ }
3418
+ }
3419
+ else {
3420
+ const data = await res.json().catch(() => ({}));
3421
+ setError(data.error || 'Failed to archive task.');
3422
+ }
3423
+ }
3424
+ catch (err) {
3425
+ console.error("Failed to archive", err);
3426
+ setError('Failed to archive task.');
3427
+ }
3428
+ };
3429
+ const handleBulkArchive = async () => {
3430
+ const completedOrCancelled = tasks.filter(t => t.status === 'done' || t.status === 'cancelled');
3431
+ if (completedOrCancelled.length === 0)
3432
+ return;
3433
+ if (!confirm(`Archive ${completedOrCancelled.length} completed/cancelled tasks?`))
3434
+ return;
3435
+ try {
3436
+ const res = await fetch('/api/taskforce/bulk-archive', {
3437
+ method: 'POST',
3438
+ headers: { 'Content-Type': 'application/json' },
3439
+ body: JSON.stringify({ ids: completedOrCancelled.map(t => t.id) })
3440
+ });
3441
+ if (res.ok) {
3442
+ const data = await res.json();
3443
+ setTasks(prev => prev.filter(t => !(['done', 'cancelled'].includes(t.status))));
3444
+ fetchArchive();
3445
+ setSuccessBanner({
3446
+ message: `${data.archived || completedOrCancelled.length} tasks archived`,
3447
+ type: 'info'
3448
+ });
3449
+ setTimeout(() => setSuccessBanner(null), 3000);
3450
+ setError('');
3451
+ }
3452
+ else {
3453
+ const data = await res.json().catch(() => ({}));
3454
+ setError(data.error || 'Failed to bulk archive tasks.');
3455
+ }
3456
+ }
3457
+ catch (err) {
3458
+ console.error("Failed to bulk archive", err);
3459
+ setError('Failed to bulk archive tasks.');
3460
+ }
3461
+ };
3462
+ const handleUnarchive = async (id) => {
3463
+ try {
3464
+ const res = await fetch(`/api/taskforce/task/${id}/unarchive`, { method: 'POST' });
3465
+ if (res.ok) {
3466
+ const data = await res.json();
3467
+ setArchivedTasks(prev => prev.filter(t => t.id !== id));
3468
+ fetchTasks(); // Refresh list to get the unarchived task
3469
+ }
3470
+ }
3471
+ catch (err) {
3472
+ console.error("Failed to unarchive", err);
3473
+ }
3474
+ };
3475
+ const handleAddComment = async (text) => {
3476
+ if (!editingTaskId || !text.trim())
3477
+ return;
3478
+ const comment = {
3479
+ id: `comment-${generateClientUuid()}`,
3480
+ author: 'user',
3481
+ text: text,
3482
+ timestamp: new Date().toISOString()
3483
+ };
3484
+ const updatedComments = [...comments, comment];
3485
+ setComments(updatedComments);
3486
+ setNewCommentText('');
3487
+ try {
3488
+ await fetch(`/api/taskforce/task/${editingTaskId}`, {
3489
+ method: 'PATCH',
3490
+ headers: { 'Content-Type': 'application/json' },
3491
+ body: JSON.stringify({ comments: updatedComments })
3492
+ });
3493
+ // Update local task
3494
+ setTasks(prev => prev.map(t => t.id === editingTaskId ? { ...t, comments: updatedComments } : t));
3495
+ }
3496
+ catch (err) {
3497
+ console.error("Failed to add comment", err);
3498
+ }
3499
+ };
3500
+ const handleSetParentForCurrentTask = useCallback(async (parentTaskIdOverride) => {
3501
+ if (!editingTaskId)
3502
+ return;
3503
+ const current = relationshipTasks.find(t => t.id === editingTaskId);
3504
+ if (!current)
3505
+ return;
3506
+ const nextParentRaw = typeof parentTaskIdOverride === 'string'
3507
+ ? parentTaskIdOverride.trim()
3508
+ : parentTaskIdOverride === null
3509
+ ? ''
3510
+ : parentTaskIdInput.trim();
3511
+ const resolvedParent = nextParentRaw ? resolveTaskIdInput(nextParentRaw) : null;
3512
+ const normalizedParentId = nextParentRaw.length > 0 ? (resolvedParent?.id || nextParentRaw) : null;
3513
+ if (normalizedParentId === editingTaskId) {
3514
+ setError('A task cannot be its own parent.');
3515
+ return;
3516
+ }
3517
+ if (wouldCreateParentCycle(editingTaskId, normalizedParentId, relationshipTasks)) {
3518
+ setError(`Cannot set parent ${normalizedParentId} for ${editingTaskId}: link would create a cycle.`);
3519
+ return;
3520
+ }
3521
+ if (shouldPromptReparent(current.parentTaskId, normalizedParentId)) {
3522
+ const confirmed = window.confirm(`This task already has parent ${current.parentTaskId}. Re-parent to ${normalizedParentId}?`);
3523
+ if (!confirmed)
3524
+ return;
3525
+ }
3526
+ try {
3527
+ const res = await fetch(`/api/taskforce/task/${editingTaskId}`, {
3528
+ method: 'PATCH',
3529
+ headers: { 'Content-Type': 'application/json' },
3530
+ body: JSON.stringify({ parentTaskId: normalizedParentId })
3531
+ });
3532
+ if (!res.ok) {
3533
+ const data = await res.json().catch(() => ({}));
3534
+ setError(data.error || 'Failed to set parent task.');
3535
+ return;
3536
+ }
3537
+ setError('');
3538
+ setSuccessBanner({ message: normalizedParentId ? 'Parent set' : 'Parent removed', type: 'info' });
3539
+ setTimeout(() => setSuccessBanner(null), 2500);
3540
+ await fetchTasks(true);
3541
+ }
3542
+ catch {
3543
+ setError('Failed to set parent task.');
3544
+ }
3545
+ }, [editingTaskId, parentTaskIdInput, relationshipTasks, fetchTasks, resolveTaskIdInput]);
3546
+ const handleAddChildToCurrentTask = useCallback(async () => {
3547
+ if (!editingTaskId)
3548
+ return;
3549
+ const typed = addChildTaskIdInput.trim();
3550
+ if (!typed)
3551
+ return;
3552
+ const childTask = resolveTaskIdInput(typed);
3553
+ if (!childTask) {
3554
+ setError('Child task not found by that ID.');
3555
+ return;
3556
+ }
3557
+ if (childTask.id === editingTaskId) {
3558
+ setError('A task cannot be a child of itself.');
3559
+ return;
3560
+ }
3561
+ if (wouldCreateParentCycle(childTask.id, editingTaskId, relationshipTasks)) {
3562
+ setError(`Cannot link child ${childTask.id} to parent ${editingTaskId}: link would create a cycle.`);
3563
+ return;
3564
+ }
3565
+ if (shouldPromptReparent(childTask.parentTaskId, editingTaskId)) {
3566
+ const confirmed = window.confirm(`Task ${childTask.id} is already parented to ${childTask.parentTaskId}. Re-parent it to ${editingTaskId}?`);
3567
+ if (!confirmed)
3568
+ return;
3569
+ }
3570
+ try {
3571
+ const res = await fetch(`/api/taskforce/task/${childTask.id}`, {
3572
+ method: 'PATCH',
3573
+ headers: { 'Content-Type': 'application/json' },
3574
+ body: JSON.stringify({ parentTaskId: editingTaskId })
3575
+ });
3576
+ if (!res.ok) {
3577
+ const data = await res.json().catch(() => ({}));
3578
+ setError(data.error || 'Failed to add child task.');
3579
+ return;
3580
+ }
3581
+ setError('');
3582
+ setAddChildTaskIdInput('');
3583
+ setSuccessBanner({ message: `Child linked: ${childTask.id}`, type: 'info' });
3584
+ setTimeout(() => setSuccessBanner(null), 2500);
3585
+ await fetchTasks(true);
3586
+ }
3587
+ catch {
3588
+ setError('Failed to add child task.');
3589
+ }
3590
+ }, [editingTaskId, addChildTaskIdInput, resolveTaskIdInput, relationshipTasks, fetchTasks]);
3591
+ const handleCreateChildForCurrentTask = useCallback(async () => {
3592
+ if (!editingTaskId)
3593
+ return;
3594
+ const parent = relationshipTasks.find(t => t.id === editingTaskId);
3595
+ if (!parent) {
3596
+ setError('Parent task not found.');
3597
+ return;
3598
+ }
3599
+ const childTitle = `Child of ${parent.title}`;
3600
+ const payload = {
3601
+ title: childTitle,
3602
+ description: null,
3603
+ category: parent.category || category || getPreferredCategoryValue(activeCategories),
3604
+ type: parent.type || type || 'feature',
3605
+ priority: typeof parent.priority === 'number' ? parent.priority : 2,
3606
+ complexity: typeof parent.complexity === 'number' ? parent.complexity : 3,
3607
+ approach: parent.approach || null,
3608
+ assignee: parent.assignee || 'agent',
3609
+ parentTaskId: editingTaskId,
3610
+ comments: [],
3611
+ attachments: [],
3612
+ taxonomies: {},
3613
+ specialists: [],
3614
+ createdAt: new Date().toISOString(),
3615
+ createdBy: 'user'
3616
+ };
3617
+ try {
3618
+ const res = await fetch('/api/taskforce/task', {
3619
+ method: 'POST',
3620
+ headers: { 'Content-Type': 'application/json' },
3621
+ body: JSON.stringify(payload)
3622
+ });
3623
+ if (!res.ok) {
3624
+ const data = await res.json().catch(() => ({}));
3625
+ setError(data.error || 'Failed to create child task.');
3626
+ return;
3627
+ }
3628
+ const createdRaw = await res.json();
3629
+ const created = normalizeTaskFromApi(createdRaw, { mapLegacyTodoStatus: true });
3630
+ setTasks(prev => [created, ...prev]);
3631
+ setError('');
3632
+ setSuccessBanner({ message: `Child task created: ${created.id}`, type: 'added' });
3633
+ setTimeout(() => setSuccessBanner(null), 3000);
3634
+ setReturnToParentTaskId(editingTaskId);
3635
+ setReturnFromChildTaskId(created.id);
3636
+ handleEdit(created);
3637
+ await fetchTasks(true);
3638
+ }
3639
+ catch {
3640
+ setError('Failed to create child task.');
3641
+ }
3642
+ }, [editingTaskId, relationshipTasks, category, type, manualComplexityEnabled, handleEdit, fetchTasks, getPreferredCategoryValue, activeCategories]);
3643
+ const clearReturnToParentTask = useCallback(() => {
3644
+ setReturnToParentTaskId(null);
3645
+ setReturnFromChildTaskId(null);
3646
+ }, []);
3647
+ const handleUnlinkChildTask = useCallback(async (childId) => {
3648
+ const confirmed = window.confirm(`Remove child link for ${childId}?`);
3649
+ if (!confirmed)
3650
+ return;
3651
+ try {
3652
+ const res = await fetch(`/api/taskforce/task/${childId}`, {
3653
+ method: 'PATCH',
3654
+ headers: { 'Content-Type': 'application/json' },
3655
+ body: JSON.stringify({ parentTaskId: null })
3656
+ });
3657
+ if (!res.ok) {
3658
+ const data = await res.json().catch(() => ({}));
3659
+ setError(data.error || 'Failed to unlink child task.');
3660
+ return;
3661
+ }
3662
+ setError('');
3663
+ setSuccessBanner({ message: `Child unlinked: ${childId}`, type: 'info' });
3664
+ setTimeout(() => setSuccessBanner(null), 2500);
3665
+ await fetchTasks(true);
3666
+ }
3667
+ catch {
3668
+ setError('Failed to unlink child task.');
3669
+ }
3670
+ }, [fetchTasks]);
3671
+ const handleReorderChildTasks = useCallback(async (parentTaskId, orderedChildIds) => {
3672
+ if (!parentTaskId || orderedChildIds.length === 0)
3673
+ return;
3674
+ const updates = orderedChildIds.map((id, index) => ({ id, childDisplayOrder: index }));
3675
+ const updateMap = new Map(updates.map(update => [update.id, update.childDisplayOrder]));
3676
+ setTasks(prev => prev.map(task => {
3677
+ const nextOrder = updateMap.get(task.id);
3678
+ if (nextOrder === undefined)
3679
+ return task;
3680
+ return { ...task, childDisplayOrder: nextOrder };
3681
+ }));
3682
+ setArchivedTasks(prev => prev.map(task => {
3683
+ const nextOrder = updateMap.get(task.id);
3684
+ if (nextOrder === undefined)
3685
+ return task;
3686
+ return { ...task, childDisplayOrder: nextOrder };
3687
+ }));
3688
+ try {
3689
+ const res = await fetch('/api/taskforce/bulk-update-fields', {
3690
+ method: 'POST',
3691
+ headers: { 'Content-Type': 'application/json' },
3692
+ body: JSON.stringify({ updates }),
3693
+ });
3694
+ if (!res.ok) {
3695
+ const data = await res.json().catch(() => ({}));
3696
+ setError(data.error || 'Failed to save child task order.');
3697
+ await fetchTasks(true);
3698
+ return;
3699
+ }
3700
+ setError('');
3701
+ }
3702
+ catch {
3703
+ setError('Failed to save child task order.');
3704
+ await fetchTasks(true);
3705
+ }
3706
+ }, [fetchTasks]);
3707
+ const handleClearParentTask = useCallback(async (taskId) => {
3708
+ const confirmed = window.confirm(`Remove parent link for ${taskId}?`);
3709
+ if (!confirmed)
3710
+ return;
3711
+ try {
3712
+ const res = await fetch(`/api/taskforce/task/${taskId}`, {
3713
+ method: 'PATCH',
3714
+ headers: { 'Content-Type': 'application/json' },
3715
+ body: JSON.stringify({ parentTaskId: null })
3716
+ });
3717
+ if (!res.ok) {
3718
+ const data = await res.json().catch(() => ({}));
3719
+ setError(data.error || 'Failed to remove parent task.');
3720
+ return;
3721
+ }
3722
+ setError('');
3723
+ setSuccessBanner({ message: `Parent removed: ${taskId}`, type: 'info' });
3724
+ setTimeout(() => setSuccessBanner(null), 2500);
3725
+ await fetchTasks(true);
3726
+ }
3727
+ catch {
3728
+ setError('Failed to remove parent task.');
3729
+ }
3730
+ }, [fetchTasks]);
3731
+ // Copy ID helper
3732
+ const handleCopyId = (e, id) => {
3733
+ e.stopPropagation();
3734
+ navigator.clipboard.writeText(id);
3735
+ setCopiedId(id);
3736
+ setTimeout(() => setCopiedId(null), 2000);
3737
+ };
3738
+ // --- Missing Path Handlers ---
3739
+ const handleAddPath = useCallback((catValue, pathInput) => {
3740
+ if (!pathInput.trim())
3741
+ return;
3742
+ const catToUpdate = activeCategories.find(c => c.value === catValue);
3743
+ if (catToUpdate) {
3744
+ const currentPaths = getCategoryPaths(catToUpdate);
3745
+ const trimmedPath = pathInput.trim();
3746
+ if (!currentPaths.includes(trimmedPath)) {
3747
+ const newPaths = [...currentPaths, trimmedPath];
3748
+ handleUpdateCategory({ ...catToUpdate, path: undefined, paths: newPaths });
3749
+ validatePaths(newPaths); // Validate the new set
3750
+ }
3751
+ }
3752
+ }, [activeCategories, getCategoryPaths, validatePaths, handleUpdateCategory]);
3753
+ const handleUpdateCategoryIcon = useCallback((catValue, iconName) => {
3754
+ const catToUpdate = activeCategories.find(c => c.value === catValue);
3755
+ if (catToUpdate) {
3756
+ handleUpdateCategory({ ...catToUpdate, icon: iconName });
3757
+ }
3758
+ }, [activeCategories, handleUpdateCategory]);
3759
+ const handleUpdateCategoryColor = useCallback((catValue, color) => {
3760
+ const catToUpdate = activeCategories.find(c => c.value === catValue);
3761
+ if (catToUpdate) {
3762
+ handleUpdateCategory({ ...catToUpdate, color });
3763
+ }
3764
+ }, [activeCategories, handleUpdateCategory]);
3765
+ const handleRemovePath = useCallback((catValue, pathToRemove) => {
3766
+ const catToUpdate = activeCategories.find(c => c.value === catValue);
3767
+ if (catToUpdate) {
3768
+ const currentPaths = getCategoryPaths(catToUpdate);
3769
+ const newPaths = currentPaths.filter(p => p !== pathToRemove);
3770
+ handleUpdateCategory({ ...catToUpdate, path: undefined, paths: newPaths });
3771
+ }
3772
+ }, [activeCategories, getCategoryPaths]);
3773
+ // Browser selection handler
3774
+ const handleSelectPath = useCallback((path) => {
3775
+ const relativePath = normalizePath(path);
3776
+ if (browserTarget && typeof browserTarget === 'object' && browserTarget.type === 'category') {
3777
+ const catToUpdate = activeCategories.find(c => c.value === browserTarget.value);
3778
+ if (catToUpdate) {
3779
+ const currentPaths = getCategoryPaths(catToUpdate);
3780
+ if (!currentPaths.includes(relativePath)) {
3781
+ const newPaths = [...currentPaths, relativePath];
3782
+ handleUpdateCategory({ ...catToUpdate, path: undefined, paths: newPaths });
3783
+ }
3784
+ setShowFolderBrowser(false);
3785
+ }
3786
+ }
3787
+ if (browserTarget && typeof browserTarget === 'object' && browserTarget.type === 'context-link') {
3788
+ setContextLinkBrowsePath(relativePath);
3789
+ setShowFolderBrowser(false);
3790
+ }
3791
+ setShowFolderBrowser(false);
3792
+ setBrowserTarget(null);
3793
+ }, [browserTarget, currentTheme, keyShortcut, mcpHostRoot, activeCategories, getCategoryPaths, normalizePath]);
3794
+ const handleRemoveCategory = useCallback(async (catValue) => {
3795
+ if (!configLoaded)
3796
+ return;
3797
+ const fallbackValue = 'default';
3798
+ const fallbackLabel = 'Default';
3799
+ const catToRemove = activeCategories.find(c => c.value === catValue);
3800
+ setCustomCategories(prev => {
3801
+ const current = prev.length > 0 ? prev : activeCategories;
3802
+ let updated = current.filter(c => c.value !== catValue);
3803
+ // Ensure fallback exists
3804
+ if (!updated.some(c => c.value === fallbackValue)) {
3805
+ updated.unshift({ value: fallbackValue, label: fallbackLabel });
3806
+ }
3807
+ return updated;
3808
+ });
3809
+ // Synchronize state if necessary
3810
+ if (catToRemove) {
3811
+ if (category === catToRemove.label || category === catToRemove.value) {
3812
+ setCategory(fallbackValue);
3813
+ }
3814
+ if (filterCategories.includes(catToRemove.label) || filterCategories.includes(catToRemove.value)) {
3815
+ setFilterCategories(prev => {
3816
+ const filtered = prev.filter(c => c !== catToRemove.label && c !== catToRemove.value);
3817
+ // Add fallback if not already in the filter to ensure reassigned tasks show up
3818
+ const fallback = filtered.includes(fallbackValue) ? [] : [fallbackValue];
3819
+ return [...filtered, ...fallback];
3820
+ });
3821
+ }
3822
+ }
3823
+ try {
3824
+ const currentCats = customCategories.length > 0 ? customCategories : activeCategories;
3825
+ let updatedList = currentCats.filter(c => c.value !== catValue);
3826
+ if (!updatedList.some(c => c.value === fallbackValue)) {
3827
+ updatedList.unshift({ value: fallbackValue, label: fallbackLabel });
3828
+ }
3829
+ await fetch('/api/taskforce/categories', {
3830
+ method: 'POST',
3831
+ headers: { 'Content-Type': 'application/json' },
3832
+ body: JSON.stringify({
3833
+ categories: updatedList,
3834
+ reassignFrom: catToRemove?.label || catValue,
3835
+ reassignTo: fallbackLabel
3836
+ }),
3837
+ });
3838
+ // Refresh tasks locally to reflect reassignment
3839
+ fetchTasks();
3840
+ }
3841
+ catch (err) {
3842
+ console.error('[Taskforce] Failed to remove category', err);
3843
+ }
3844
+ }, [configLoaded, activeCategories, customCategories, category, filterCategories, fetchTasks]);
3845
+ // Type Management
3846
+ const handleSaveType = async (labelInput) => {
3847
+ if (!labelInput.trim())
3848
+ return;
3849
+ const val = labelInput.trim().toLowerCase().replace(/\s+/g, '-');
3850
+ if (activeTypes.some(t => t.value === val))
3851
+ return;
3852
+ const updated = [...activeTypes, { value: val, label: labelInput.trim() }];
3853
+ setCustomTypes(updated);
3854
+ try {
3855
+ await fetch('/api/taskforce/types', {
3856
+ method: 'POST',
3857
+ headers: { 'Content-Type': 'application/json' },
3858
+ body: JSON.stringify({ types: updated }),
3859
+ });
3860
+ }
3861
+ catch (err) {
3862
+ console.error('Failed to save type', err);
3863
+ }
3864
+ };
3865
+ const handleRemoveType = async (typeToRemove) => {
3866
+ const fallback = 'feature';
3867
+ const updated = activeTypes.filter(t => t.value !== typeToRemove);
3868
+ if (!updated.some(t => t.value === fallback) && typeToRemove !== fallback) {
3869
+ updated.unshift({ value: 'feature', label: 'Feature' });
3870
+ }
3871
+ setCustomTypes(updated);
3872
+ try {
3873
+ await fetch('/api/taskforce/types', {
3874
+ method: 'POST',
3875
+ headers: { 'Content-Type': 'application/json' },
3876
+ body: JSON.stringify({
3877
+ types: updated,
3878
+ reassignFrom: typeToRemove,
3879
+ reassignTo: fallback
3880
+ }),
3881
+ });
3882
+ fetchTasks();
3883
+ }
3884
+ catch (err) {
3885
+ console.error('Failed to save type', err);
3886
+ }
3887
+ };
3888
+ const handleUpdateTaxonomies = useCallback(async (updated) => {
3889
+ if (!configLoaded)
3890
+ return;
3891
+ setTaxonomies(updated);
3892
+ try {
3893
+ await fetch('/api/taskforce/taxonomies', {
3894
+ method: 'POST',
3895
+ headers: { 'Content-Type': 'application/json' },
3896
+ body: JSON.stringify({ taxonomies: updated }),
3897
+ });
3898
+ }
3899
+ catch (err) {
3900
+ console.error('[Taskforce] Failed to save taxonomies', err);
3901
+ }
3902
+ }, [configLoaded]);
3903
+ const handleUpdatePriorities = useCallback(async (updated) => {
3904
+ if (!configLoaded)
3905
+ return;
3906
+ setPriorities(updated);
3907
+ try {
3908
+ await fetch('/api/taskforce/priorities', {
3909
+ method: 'POST',
3910
+ headers: { 'Content-Type': 'application/json' },
3911
+ body: JSON.stringify({ priorities: updated }),
3912
+ });
3913
+ }
3914
+ catch (err) {
3915
+ console.error('[Taskforce] Failed to save priorities', err);
3916
+ }
3917
+ }, [configLoaded]);
3918
+ const handleUpdateApproaches = useCallback(async (updated) => {
3919
+ if (!configLoaded)
3920
+ return;
3921
+ setApproaches(updated);
3922
+ try {
3923
+ await fetch('/api/taskforce/approaches', {
3924
+ method: 'POST',
3925
+ headers: { 'Content-Type': 'application/json' },
3926
+ body: JSON.stringify({ approaches: updated }),
3927
+ });
3928
+ }
3929
+ catch (err) {
3930
+ console.error('[Taskforce] Failed to save approaches', err);
3931
+ }
3932
+ }, [configLoaded]);
3933
+ const currentTask = useMemo(() => {
3934
+ if (!editingTaskId)
3935
+ return undefined;
3936
+ return relationshipTasks.find(t => t.id === editingTaskId);
3937
+ }, [relationshipTasks, editingTaskId]);
3938
+ const currentTaskParent = useMemo(() => {
3939
+ if (!currentTask?.parentTaskId)
3940
+ return undefined;
3941
+ return relationshipTasks.find(t => t.id === currentTask.parentTaskId);
3942
+ }, [relationshipTasks, currentTask]);
3943
+ const currentTaskChildren = useMemo(() => {
3944
+ if (!editingTaskId)
3945
+ return [];
3946
+ return sortChildTasksForDisplay(relationshipTasks.filter(t => t.parentTaskId === editingTaskId));
3947
+ }, [relationshipTasks, editingTaskId]);
3948
+ const settingsModel = {
3949
+ currentTheme,
3950
+ configLoaded,
3951
+ globalTheme,
3952
+ themeUseGlobalDefault,
3953
+ keyShortcut,
3954
+ jsonBackupEnabled,
3955
+ globalJsonBackupEnabled,
3956
+ globalWeekStartsOn,
3957
+ locale,
3958
+ supportedLocales,
3959
+ jsonBackupUseGlobalDefault,
3960
+ manualComplexityEnabled,
3961
+ checklistDropdownEnabled,
3962
+ assigneeFilterEnabled,
3963
+ exportWorkflowsPath,
3964
+ exportSpecialistsPath,
3965
+ exportingResource,
3966
+ exportResult,
3967
+ pathSaved,
3968
+ initialSection: settingsSection,
3969
+ exportEnvironment,
3970
+ setupState,
3971
+ buildInfo,
3972
+ onSaveSetupMode: saveSetupMode,
3973
+ onSaveWorkspaceProfile: saveWorkspaceProfile,
3974
+ onThemeChange: setCurrentTheme,
3975
+ onSaveTheme: handleSaveTheme,
3976
+ onSaveGlobalTheme: handleSaveGlobalTheme,
3977
+ onKeyShortcutChange: setKeyShortcut,
3978
+ onJsonBackupEnabledChange: handleJsonBackupEnabledChange,
3979
+ onSaveGlobalJsonBackupEnabled: handleSaveGlobalJsonBackupEnabled,
3980
+ onSaveGlobalWeekStartsOn: handleSaveGlobalWeekStartsOn,
3981
+ onSaveLocale: handleSaveLocale,
3982
+ onManualComplexityEnabledChange: handleManualComplexityEnabledChange,
3983
+ onChecklistDropdownEnabledChange: handleChecklistDropdownEnabledChange,
3984
+ onAssigneeFilterEnabledChange: handleAssigneeFilterEnabledChange,
3985
+ onResetProjectToGlobal: handleResetProjectToGlobal,
3986
+ onSaveSettings: handleSaveSettings,
3987
+ onExportWorkflowsPathChange: setExportWorkflowsPath,
3988
+ onExportSpecialistsPathChange: setExportSpecialistsPath,
3989
+ onExportEnvironmentChange: setExportEnvironment,
3990
+ onExportResources: handleExportResources,
3991
+ availableWorkflows,
3992
+ initiativeTemplates,
3993
+ availableEnvironments,
3994
+ onRefreshWorkflows: fetchWorkflows,
3995
+ onRefreshInitiativeTemplates: fetchInitiativeTemplates,
3996
+ onCreateInitiativeFromTemplate: createInitiativeFromTemplate,
3997
+ onCloneChecklistBlocksToChildren: cloneChecklistBlocksToChildren,
3998
+ onFetchWorkflowTemplate: fetchWorkflowTemplate,
3999
+ onFetchWorkflowOverrideNames: fetchWorkflowOverrideNames,
4000
+ onSaveWorkflowTemplateDraft: saveWorkflowTemplateDraft,
4001
+ onResetWorkflowTemplateDraft: resetWorkflowTemplateDraft,
4002
+ onExportWorkflows: handleExportWorkflows,
4003
+ onShowFolderBrowserChange: setShowFolderBrowser,
4004
+ onBrowserTargetChange: setBrowserTarget,
4005
+ onFetchFolders: fetchFolders,
4006
+ categories: activeCategories,
4007
+ pathValidation,
4008
+ onUpdateCategory: handleUpdateCategory,
4009
+ onRemoveCategory: handleRemoveCategory,
4010
+ onSaveCategory: handleSaveCategory,
4011
+ onAddPath: handleAddPath,
4012
+ onRemovePath: handleRemovePath,
4013
+ onUpdateCategoryIcon: handleUpdateCategoryIcon,
4014
+ onUpdateCategoryColor: handleUpdateCategoryColor,
4015
+ types: activeTypes,
4016
+ onSaveType: handleSaveType,
4017
+ onRemoveType: handleRemoveType,
4018
+ taxonomies,
4019
+ onUpdateTaxonomies: handleUpdateTaxonomies,
4020
+ priorities,
4021
+ onUpdatePriorities: handleUpdatePriorities,
4022
+ approaches,
4023
+ onUpdateApproaches: handleUpdateApproaches,
4024
+ projectRoot,
4025
+ projectName,
4026
+ mcpHostRoot,
4027
+ serverHostRoot,
4028
+ mcpScriptPath,
4029
+ tenantId,
4030
+ workspaceId: currentWorkspaceId,
4031
+ runtimeMode,
4032
+ workspaceSwitchingEnabled,
4033
+ currentWorkspaceRole: (availableWorkspaces.find((workspace) => workspace.id === currentWorkspaceId)?.role || 'member'),
4034
+ currentWorkspaceName: String(availableWorkspaces.find((workspace) => workspace.id === currentWorkspaceId)?.name || ''),
4035
+ onDeleteWorkspace: deleteWorkspace,
4036
+ onMcpHostRootChange: setMcpHostRoot,
4037
+ isAuthenticated
4038
+ };
4039
+ const showParentsOnly = parentTaskFilterMode === 'parents';
4040
+ const setShowParentsOnly = useCallback((show) => {
4041
+ setParentTaskFilterMode(show ? 'parents' : 'all');
4042
+ }, []);
4043
+ useEffect(() => {
4044
+ if (typeof window !== 'undefined') {
4045
+ window.__TASKFORCE_DEBUG__ = {
4046
+ tasks,
4047
+ archivedTasks,
4048
+ runtimeMode,
4049
+ currentWorkspaceId,
4050
+ isAuthenticated,
4051
+ workspaceCloudSyncEnabled,
4052
+ realtimeSyncEnabled,
4053
+ fetchTasks,
4054
+ fetchArchive,
4055
+ retryWorkspaceCloudSync,
4056
+ resetWorkspaceSyncCursorAndPull,
4057
+ __dispatch: {
4058
+ handleUpdateTask,
4059
+ handleToggleComplete,
4060
+ handleArchiveTask,
4061
+ handleToggleCancel,
4062
+ handleToggleInProgress,
4063
+ handleToggleReview,
4064
+ handleSubmit,
4065
+ handleDelete
4066
+ }
4067
+ };
4068
+ }
4069
+ }, [
4070
+ tasks,
4071
+ archivedTasks,
4072
+ runtimeMode,
4073
+ currentWorkspaceId,
4074
+ isAuthenticated,
4075
+ workspaceCloudSyncEnabled,
4076
+ realtimeSyncEnabled,
4077
+ fetchTasks,
4078
+ fetchArchive,
4079
+ retryWorkspaceCloudSync,
4080
+ resetWorkspaceSyncCursorAndPull,
4081
+ handleUpdateTask,
4082
+ handleToggleComplete,
4083
+ handleArchiveTask,
4084
+ handleToggleCancel,
4085
+ handleToggleInProgress,
4086
+ handleToggleReview,
4087
+ handleSubmit,
4088
+ handleDelete
4089
+ ]);
4090
+ // Return the massive context object
4091
+ return {
4092
+ // Config & State
4093
+ config: mergedConfig,
4094
+ isOpen,
4095
+ setIsOpen,
4096
+ activeTab,
4097
+ setActiveTab,
4098
+ currentTheme,
4099
+ setCurrentTheme,
4100
+ configLoaded,
4101
+ // Settings
4102
+ storagePath,
4103
+ saveSettings: handleSaveSettings,
4104
+ pathSaved,
4105
+ keyShortcut,
4106
+ setKeyShortcut,
4107
+ globalWeekStartsOn,
4108
+ locale,
4109
+ supportedLocales,
4110
+ saveLocale: handleSaveLocale,
4111
+ jsonBackupEnabled,
4112
+ setJsonBackupEnabled,
4113
+ manualComplexityEnabled,
4114
+ checklistDropdownEnabled,
4115
+ assigneeFilterEnabled,
4116
+ setManualComplexityEnabled,
4117
+ mcpHostRoot,
4118
+ setMcpHostRoot,
4119
+ settingsSection, // Export this
4120
+ setSettingsSection, // Export this (needed for settings nav)
4121
+ runtimeMode,
4122
+ workspaceMode,
4123
+ workspaceSwitchingEnabled,
4124
+ cloudAuthConfigured,
4125
+ authRequiredForApi,
4126
+ authBlocked,
4127
+ isAuthenticated,
4128
+ authUserEmail,
4129
+ userGlobalSyncStatus,
4130
+ userGlobalLastSyncedAt,
4131
+ workspaceLastPullAt,
4132
+ workspaceLastPushAt,
4133
+ workspaceLastErrorAt,
4134
+ workspaceRetryAt,
4135
+ userGlobalSyncPendingChanges,
4136
+ userGlobalSyncError,
4137
+ workspaceLastErrorMessage,
4138
+ workspaceLastWarningMessage,
4139
+ workspaceSyncFailureCount,
4140
+ workspaceSyncRetryScheduledCount,
4141
+ workspaceSyncAuthFailureCount,
4142
+ workspaceSyncLastFailureSource,
4143
+ workspaceSyncLastFailureStatusCode,
4144
+ workspaceBootstrapSyncInProgress,
4145
+ workspaceBootstrapSyncPages,
4146
+ workspaceBootstrapSyncAppliedChanges,
4147
+ retryUserGlobalSettingsSync,
4148
+ retryWorkspaceCloudSync,
4149
+ resetWorkspaceSyncCursorAndPull,
4150
+ hasBetaAccess,
4151
+ realtimeSyncEnabled,
4152
+ realtimeSyncFlagSource,
4153
+ currentWorkspaceId,
4154
+ availableWorkspaces,
4155
+ workspaceCloudSyncEnabled,
4156
+ realtimeConnectionState,
4157
+ realtimeTelemetry,
4158
+ workspaceSyncSourceOfTruth,
4159
+ workspaceSyncOnboardingCompleted,
4160
+ workspaceSyncReady,
4161
+ workspaceCloudHydrated,
4162
+ saveWorkspaceCloudSyncSettings,
4163
+ applyWorkspaceSyncStateSnapshot,
4164
+ authSessionResolved,
4165
+ workspaceBootstrapPending,
4166
+ bootstrapPhase,
4167
+ bootstrapError,
4168
+ bootstrapStartedAt,
4169
+ setupState,
4170
+ runtimeCapabilities,
4171
+ refreshSetupContext,
4172
+ retryBootstrapChecks,
4173
+ saveWorkspaceProfile,
4174
+ fetchWorkspaces,
4175
+ createWorkspace,
4176
+ deleteWorkspace,
4177
+ switchWorkspace,
4178
+ loginWithCredentials,
4179
+ registerWithCredentials,
4180
+ requestEmailVerification,
4181
+ confirmEmailVerification,
4182
+ requestPasswordReset,
4183
+ confirmPasswordReset,
4184
+ inspectInviteAcceptance,
4185
+ acceptInviteWithToken,
4186
+ logout,
4187
+ // Browser
4188
+ showFolderBrowser,
4189
+ setShowFolderBrowser,
4190
+ folders,
4191
+ files,
4192
+ currentBrowsePath,
4193
+ fetchFolders,
4194
+ browserTarget,
4195
+ setBrowserTarget,
4196
+ contextLinkBrowsePath,
4197
+ groupBy, setGroupBy,
4198
+ emptyColumnMode, setEmptyColumnMode,
4199
+ zenMode, setZenMode: toggleZenMode,
4200
+ handleSelectPath,
4201
+ handleAddPath,
4202
+ handleRemovePath,
4203
+ // Data
4204
+ tasks,
4205
+ loadingTasks,
4206
+ archivedTasks,
4207
+ activeCategories,
4208
+ activeTypes,
4209
+ priorities,
4210
+ approaches,
4211
+ availableSpecialists,
4212
+ taxonomies,
4213
+ // Filtering
4214
+ searchQuery,
4215
+ setSearchQuery,
4216
+ filterCategories,
4217
+ setFilterCategories,
4218
+ filterTypes,
4219
+ setFilterTypes,
4220
+ filterPriorities,
4221
+ setFilterPriorities,
4222
+ filterStatus,
4223
+ setFilterStatus,
4224
+ filterAssignees,
4225
+ setFilterAssignees,
4226
+ parentTaskFilterMode,
4227
+ setParentTaskFilterMode,
4228
+ showParentsOnly,
4229
+ setShowParentsOnly,
4230
+ filterTaxonomies,
4231
+ setFilterTaxonomies,
4232
+ sortBy,
4233
+ setSortBy,
4234
+ sortOrder,
4235
+ setSortOrder,
4236
+ toggleSortOrder,
4237
+ showArchive,
4238
+ setShowArchive,
4239
+ clearFilters,
4240
+ filteredTasks,
4241
+ searchAgnosticTasks,
4242
+ filteredArchive,
4243
+ groupedTasks,
4244
+ collapsedCategories,
4245
+ setCollapsedCategories,
4246
+ // Actions
4247
+ fetchTasks,
4248
+ fetchArchive,
4249
+ handleEdit,
4250
+ handleDelete,
4251
+ handleCopyId,
4252
+ handleToggleComplete,
4253
+ handleToggleCancel,
4254
+ handleToggleInProgress,
4255
+ handleToggleReview,
4256
+ handleArchiveTask,
4257
+ handleBulkArchive,
4258
+ handleUnarchive,
4259
+ handleUpdateTask,
4260
+ // Form State
4261
+ editingTaskId,
4262
+ loading,
4263
+ error,
4264
+ title,
4265
+ setTitle,
4266
+ description,
4267
+ setDescription,
4268
+ category,
4269
+ setCategory,
4270
+ type,
4271
+ setType,
4272
+ priority,
4273
+ setPriority,
4274
+ complexity,
4275
+ setComplexity,
4276
+ approach,
4277
+ setApproach,
4278
+ assignee,
4279
+ setAssignee,
4280
+ scheduledDate,
4281
+ setScheduledDate,
4282
+ dueDate,
4283
+ setDueDate,
4284
+ parentTaskIdInput,
4285
+ setParentTaskIdInput,
4286
+ addChildTaskIdInput,
4287
+ setAddChildTaskIdInput,
4288
+ formTaxonomies,
4289
+ setFormTaxonomies,
4290
+ selectedSpecialists,
4291
+ setSelectedSpecialists,
4292
+ comments,
4293
+ newCommentText,
4294
+ setNewCommentText,
4295
+ screenshots,
4296
+ setScreenshots,
4297
+ descriptionFocused,
4298
+ setDescriptionFocused,
4299
+ showMarkdownHelp,
4300
+ setShowMarkdownHelp,
4301
+ showSpecialists,
4302
+ setShowSpecialists,
4303
+ showChecklist,
4304
+ setShowChecklist,
4305
+ showTaskRelationships,
4306
+ setShowTaskRelationships,
4307
+ showChildTasks,
4308
+ setShowChildTasks,
4309
+ showComments,
4310
+ setShowComments,
4311
+ isCapturingScreenshot,
4312
+ setIsCapturingScreenshot,
4313
+ // Form Actions
4314
+ handleSubmit,
4315
+ resetForm,
4316
+ handleAddComment,
4317
+ handleSetParentForCurrentTask,
4318
+ handleAddChildToCurrentTask,
4319
+ handleCreateChildForCurrentTask,
4320
+ handleUnlinkChildTask,
4321
+ handleReorderChildTasks,
4322
+ handleClearParentTask,
4323
+ handleOpenTaskById,
4324
+ // Modals
4325
+ unsavedModalOpen,
4326
+ setUnsavedModalOpen,
4327
+ pendingNavigation,
4328
+ handleNavigation,
4329
+ handleClose,
4330
+ // Misc
4331
+ successBanner,
4332
+ returnToParentTaskId,
4333
+ returnFromChildTaskId,
4334
+ clearReturnToParentTask,
4335
+ copiedId,
4336
+ recentlyChangedTaskIds,
4337
+ cancellationPromptTask,
4338
+ cancellationOpenDescendantIds,
4339
+ cancellationMode,
4340
+ setCancellationMode,
4341
+ confirmCancellation,
4342
+ cancelCancellationPrompt,
4343
+ scheduleWarningPrompt,
4344
+ confirmScheduleWarning,
4345
+ cancelScheduleWarning,
4346
+ tasksScrollRef,
4347
+ setTasksScrollPos, // Export if needed
4348
+ // Export
4349
+ exportEnvironment,
4350
+ setExportEnvironment,
4351
+ exportWorkflowsPath,
4352
+ setExportWorkflowsPath,
4353
+ exportSpecialistsPath,
4354
+ setExportSpecialistsPath,
4355
+ exportResult,
4356
+ handleExportResources,
4357
+ exportingResource,
4358
+ // Categories & Types & Taxonomies mgmt
4359
+ handleUpdateCategory,
4360
+ handleRemoveCategory,
4361
+ handleSaveCategory, // Expose manually
4362
+ handleUpdateCategoryIcon,
4363
+ handleUpdateCategoryColor,
4364
+ handleSaveType,
4365
+ handleRemoveType,
4366
+ handleUpdateTaxonomies,
4367
+ handleUpdatePriorities,
4368
+ handleUpdateApproaches,
4369
+ pathValidation,
4370
+ validatePaths,
4371
+ getCategoryPaths,
4372
+ customCategories,
4373
+ setCustomCategories,
4374
+ projectRoot, // Export (needed for settings)
4375
+ projectName, // Export
4376
+ mcpScriptPath, // Export
4377
+ serverHostRoot, // Export
4378
+ // Refs
4379
+ commentsEndRef,
4380
+ // Memoized Derived State
4381
+ currentTask,
4382
+ currentTaskParent,
4383
+ currentTaskChildren,
4384
+ availableWorkflows,
4385
+ initiativeTemplates,
4386
+ availableEnvironments,
4387
+ fetchWorkflows,
4388
+ fetchInitiativeTemplates,
4389
+ fetchWorkflowTemplate,
4390
+ fetchWorkflowOverrideNames,
4391
+ saveWorkflowTemplateDraft,
4392
+ resetWorkflowTemplateDraft,
4393
+ onExportWorkflows: handleExportWorkflows,
4394
+ settingsModel
4395
+ };
4396
+ }