@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.
- package/README.md +241 -0
- package/dist/Taskforce.d.ts +3 -0
- package/dist/Taskforce.js +3 -0
- package/dist/Taskforce.module.css +7587 -0
- package/dist/TaskforceCore.CategoryFallback.test.d.ts +1 -0
- package/dist/TaskforceCore.CategoryFallback.test.js +98 -0
- package/dist/TaskforceCore.d.ts +12 -0
- package/dist/TaskforceCore.js +729 -0
- package/dist/TaskforceCore.test.d.ts +1 -0
- package/dist/TaskforceCore.test.js +2132 -0
- package/dist/TaskforceScreenshot.d.ts +11 -0
- package/dist/TaskforceScreenshot.js +113 -0
- package/dist/TaskforceWidget.d.ts +6 -0
- package/dist/TaskforceWidget.js +101 -0
- package/dist/TaskforceWidget.test.d.ts +1 -0
- package/dist/TaskforceWidget.test.js +92 -0
- package/dist/UnsavedChangesRelaxed.test.d.ts +1 -0
- package/dist/UnsavedChangesRelaxed.test.js +16 -0
- package/dist/__tests__/localizationCatalogs.test.d.ts +1 -0
- package/dist/__tests__/localizationCatalogs.test.js +37 -0
- package/dist/__tests__/planParser.test.d.ts +1 -0
- package/dist/__tests__/planParser.test.js +183 -0
- package/dist/__tests__/releaseVersion.test.d.ts +1 -0
- package/dist/__tests__/releaseVersion.test.js +32 -0
- package/dist/__tests__/structuredCommentValidation.test.d.ts +1 -0
- package/dist/__tests__/structuredCommentValidation.test.js +31 -0
- package/dist/__tests__/structuredDocValidation.test.d.ts +1 -0
- package/dist/__tests__/structuredDocValidation.test.js +30 -0
- package/dist/__tests__/templateEngine.test.d.ts +1 -0
- package/dist/__tests__/templateEngine.test.js +150 -0
- package/dist/__tests__/templates.test.d.ts +1 -0
- package/dist/__tests__/templates.test.js +45 -0
- package/dist/__tests__/workflowDocumentFixtures.test.d.ts +1 -0
- package/dist/__tests__/workflowDocumentFixtures.test.js +78 -0
- package/dist/__tests__/workflowExportMatrix.test.d.ts +1 -0
- package/dist/__tests__/workflowExportMatrix.test.js +113 -0
- package/dist/__tests__/workflowExportSnapshots.test.d.ts +1 -0
- package/dist/__tests__/workflowExportSnapshots.test.js +28 -0
- package/dist/__tests__/workflowGeneration.test.d.ts +1 -0
- package/dist/__tests__/workflowGeneration.test.js +12 -0
- package/dist/__tests__/workflowSimulation.test.d.ts +1 -0
- package/dist/__tests__/workflowSimulation.test.js +113 -0
- package/dist/__tests__/workflowV2Policy.test.d.ts +1 -0
- package/dist/__tests__/workflowV2Policy.test.js +80 -0
- package/dist/api.d.ts +30 -0
- package/dist/api.js +85 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +217 -0
- package/dist/compat/priorityCompat.d.ts +2 -0
- package/dist/compat/priorityCompat.js +24 -0
- package/dist/components/features/DocumentGenerateModal.d.ts +9 -0
- package/dist/components/features/DocumentGenerateModal.js +114 -0
- package/dist/components/features/DocumentIndex.d.ts +19 -0
- package/dist/components/features/DocumentIndex.js +51 -0
- package/dist/components/features/DocumentViewer.d.ts +9 -0
- package/dist/components/features/DocumentViewer.js +253 -0
- package/dist/components/features/DocumentWorkspace.d.ts +21 -0
- package/dist/components/features/DocumentWorkspace.js +88 -0
- package/dist/components/features/TaskSettings.d.ts +209 -0
- package/dist/components/features/TaskSettings.js +500 -0
- package/dist/components/features/TaskSettings.test.d.ts +1 -0
- package/dist/components/features/TaskSettings.test.js +308 -0
- package/dist/components/features/settings/CategoryManager.d.ts +19 -0
- package/dist/components/features/settings/CategoryManager.js +113 -0
- package/dist/components/features/settings/TaxonomyEditor.d.ts +8 -0
- package/dist/components/features/settings/TaxonomyEditor.js +98 -0
- package/dist/components/features/settings/TaxonomyManager 2.d.ts +52 -0
- package/dist/components/features/settings/TaxonomyManager 2.js +75 -0
- package/dist/components/features/settings/TaxonomyManager.d.ts +54 -0
- package/dist/components/features/settings/TaxonomyManager.js +77 -0
- package/dist/components/features/settings/TypeManager.d.ts +10 -0
- package/dist/components/features/settings/TypeManager.js +50 -0
- package/dist/components/features/settings/WorkflowEditorPrototype.d.ts +15 -0
- package/dist/components/features/settings/WorkflowEditorPrototype.js +245 -0
- package/dist/components/task/TaskActionHeader.d.ts +20 -0
- package/dist/components/task/TaskActionHeader.js +13 -0
- package/dist/components/task/TaskCard.d.ts +47 -0
- package/dist/components/task/TaskCard.js +326 -0
- package/dist/components/task/TaskCard.test.d.ts +1 -0
- package/dist/components/task/TaskCard.test.js +95 -0
- package/dist/components/task/TaskContextUpload.d.ts +12 -0
- package/dist/components/task/TaskContextUpload.js +393 -0
- package/dist/components/task/TaskContextUpload.test.d.ts +1 -0
- package/dist/components/task/TaskContextUpload.test.js +189 -0
- package/dist/components/task/TaskForm.d.ts +105 -0
- package/dist/components/task/TaskForm.js +241 -0
- package/dist/components/task/TaskForm.test.d.ts +1 -0
- package/dist/components/task/TaskForm.test.js +288 -0
- package/dist/components/task/TaskKanban 2.d.ts +76 -0
- package/dist/components/task/TaskKanban 2.js +1004 -0
- package/dist/components/task/TaskKanban.d.ts +76 -0
- package/dist/components/task/TaskKanban.empty-column-drop.test.d.ts +1 -0
- package/dist/components/task/TaskKanban.empty-column-drop.test.js +66 -0
- package/dist/components/task/TaskKanban.js +1026 -0
- package/dist/components/task/TaskKanban.test.d.ts +1 -0
- package/dist/components/task/TaskKanban.test.js +46 -0
- package/dist/components/task/TaskList.d.ts +74 -0
- package/dist/components/task/TaskList.js +38 -0
- package/dist/components/task/TaskList.test.d.ts +1 -0
- package/dist/components/task/TaskList.test.js +270 -0
- package/dist/components/task/TaskScreenshotSection.d.ts +15 -0
- package/dist/components/task/TaskScreenshotSection.js +22 -0
- package/dist/components/ui/LevelSelector.d.ts +13 -0
- package/dist/components/ui/LevelSelector.js +25 -0
- package/dist/components/ui/LevelSelector.test.d.ts +1 -0
- package/dist/components/ui/LevelSelector.test.js +55 -0
- package/dist/components/ui/Markdown.d.ts +6 -0
- package/dist/components/ui/Markdown.js +94 -0
- package/dist/components/ui/Markdown.test.d.ts +1 -0
- package/dist/components/ui/Markdown.test.js +38 -0
- package/dist/components/ui/Modal.d.ts +16 -0
- package/dist/components/ui/Modal.js +49 -0
- package/dist/components/ui/MultiSelectFilter.d.ts +10 -0
- package/dist/components/ui/MultiSelectFilter.js +71 -0
- package/dist/components/ui/MultiSelectFilter.test.d.ts +1 -0
- package/dist/components/ui/MultiSelectFilter.test.js +74 -0
- package/dist/components/ui/TaxonomyDropdown.d.ts +17 -0
- package/dist/components/ui/TaxonomyDropdown.js +145 -0
- package/dist/components/ui/TaxonomyDropdown.test.d.ts +1 -0
- package/dist/components/ui/TaxonomyDropdown.test.js +130 -0
- package/dist/components/views/StandaloneLayout 2.d.ts +8 -0
- package/dist/components/views/StandaloneLayout 2.js +1660 -0
- package/dist/components/views/StandaloneLayout.d.ts +8 -0
- package/dist/components/views/StandaloneLayout.js +2117 -0
- package/dist/components/views/WidgetView.d.ts +8 -0
- package/dist/components/views/WidgetView.js +221 -0
- package/dist/config/envSchema.d.ts +28 -0
- package/dist/config/envSchema.js +374 -0
- package/dist/config/envSchema.test.d.ts +1 -0
- package/dist/config/envSchema.test.js +97 -0
- package/dist/config/surfaceBase.d.ts +2 -0
- package/dist/config/surfaceBase.js +8 -0
- package/dist/core/BulkUpdateFieldsPreview.test.d.ts +1 -0
- package/dist/core/BulkUpdateFieldsPreview.test.js +62 -0
- package/dist/core/CompletedAt.test.d.ts +1 -0
- package/dist/core/CompletedAt.test.js +44 -0
- package/dist/core/CreatedBy.test.d.ts +1 -0
- package/dist/core/CreatedBy.test.js +60 -0
- package/dist/core/EntitlementsPolicy.test.d.ts +1 -0
- package/dist/core/EntitlementsPolicy.test.js +145 -0
- package/dist/core/InitiativeTemplates.test.d.ts +1 -0
- package/dist/core/InitiativeTemplates.test.js +50 -0
- package/dist/core/ParentLifecycle.test.d.ts +1 -0
- package/dist/core/ParentLifecycle.test.js +98 -0
- package/dist/core/ScheduleFields.test.d.ts +1 -0
- package/dist/core/ScheduleFields.test.js +197 -0
- package/dist/core/SetupState.test.d.ts +1 -0
- package/dist/core/SetupState.test.js +113 -0
- package/dist/core/SystemAdmin.test.d.ts +1 -0
- package/dist/core/SystemAdmin.test.js +295 -0
- package/dist/core/TaskTaxonomyValidation.test.d.ts +1 -0
- package/dist/core/TaskTaxonomyValidation.test.js +64 -0
- package/dist/core/Taskforce.d.ts +1063 -0
- package/dist/core/Taskforce.ids.test.d.ts +1 -0
- package/dist/core/Taskforce.ids.test.js +168 -0
- package/dist/core/Taskforce.js +5981 -0
- package/dist/core/Taskforce.realtimeMutation.test.d.ts +1 -0
- package/dist/core/Taskforce.realtimeMutation.test.js +76 -0
- package/dist/core/WorkspaceDelete.test.d.ts +1 -0
- package/dist/core/WorkspaceDelete.test.js +140 -0
- package/dist/core/WorkspacePermissions.test.d.ts +1 -0
- package/dist/core/WorkspacePermissions.test.js +151 -0
- package/dist/core/WorkspacePlanMode.test.d.ts +1 -0
- package/dist/core/WorkspacePlanMode.test.js +104 -0
- package/dist/hooks/authGuardPolicy.d.ts +26 -0
- package/dist/hooks/authGuardPolicy.js +32 -0
- package/dist/hooks/authGuardPolicy.test.d.ts +1 -0
- package/dist/hooks/authGuardPolicy.test.js +76 -0
- package/dist/hooks/useDraggable.d.ts +12 -0
- package/dist/hooks/useDraggable.js +65 -0
- package/dist/hooks/useRealtimeSync.d.ts +24 -0
- package/dist/hooks/useRealtimeSync.js +275 -0
- package/dist/hooks/useRealtimeSync.test.d.ts +1 -0
- package/dist/hooks/useRealtimeSync.test.js +284 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +164 -0
- package/dist/hooks/useSyncOrchestrator.js +1427 -0
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.d.ts +1 -0
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +367 -0
- package/dist/hooks/useTaskforce.d.ts +727 -0
- package/dist/hooks/useTaskforce.js +4396 -0
- package/dist/hooks/useTaskforce.priority-filters.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.priority-filters.test.js +33 -0
- package/dist/hooks/useWorkspaceSyncController.d.ts +80 -0
- package/dist/hooks/useWorkspaceSyncController.js +598 -0
- package/dist/hooks/useWorkspaceSyncController.test.d.ts +1 -0
- package/dist/hooks/useWorkspaceSyncController.test.js +236 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/localization/index.d.ts +8 -0
- package/dist/localization/index.js +81 -0
- package/dist/localization/locales/en-US.d.ts +151 -0
- package/dist/localization/locales/en-US.js +150 -0
- package/dist/localization/locales/es-419.d.ts +2 -0
- package/dist/localization/locales/es-419.js +150 -0
- package/dist/localization/locales/pt-BR.d.ts +2 -0
- package/dist/localization/locales/pt-BR.js +150 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +33 -0
- package/dist/mcp/optionResolution.d.ts +14 -0
- package/dist/mcp/optionResolution.js +126 -0
- package/dist/mcp/optionResolution.test.d.ts +1 -0
- package/dist/mcp/optionResolution.test.js +39 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +1793 -0
- package/dist/mcp/structuredCommentValidation.d.ts +6 -0
- package/dist/mcp/structuredCommentValidation.js +56 -0
- package/dist/mcp/structuredDocValidation.d.ts +6 -0
- package/dist/mcp/structuredDocValidation.js +75 -0
- package/dist/migrations/billingSchemaParity.test.d.ts +1 -0
- package/dist/migrations/billingSchemaParity.test.js +46 -0
- package/dist/migrations/clientMigrations.d.ts +9 -0
- package/dist/migrations/clientMigrations.js +74 -0
- package/dist/migrations/flags.d.ts +6 -0
- package/dist/migrations/flags.js +8 -0
- package/dist/migrations/taskMigrations.d.ts +71 -0
- package/dist/migrations/taskMigrations.js +236 -0
- package/dist/migrations/taskMigrations.test.d.ts +1 -0
- package/dist/migrations/taskMigrations.test.js +34 -0
- package/dist/migrations/taskSchemaMigrations.d.ts +11 -0
- package/dist/migrations/taskSchemaMigrations.js +453 -0
- package/dist/plugins/index.d.ts +10 -0
- package/dist/plugins/index.js +10 -0
- package/dist/plugins/vite.d.ts +19 -0
- package/dist/plugins/vite.js +271 -0
- package/dist/public/android-chrome-192x192.png +0 -0
- package/dist/public/android-chrome-512x512.png +0 -0
- package/dist/public/apple-touch-icon.png +0 -0
- package/dist/public/favicon-16x16.png +0 -0
- package/dist/public/favicon-32x32.png +0 -0
- package/dist/public/favicon.ico +0 -0
- package/dist/public/favicon.png +0 -0
- package/dist/public/site.webmanifest +1 -0
- package/dist/public/taskforce.png +0 -0
- package/dist/resources/templates/WORKFLOW_V2_POLICY.md +55 -0
- package/dist/resources/templates/environments/aider.yaml +14 -0
- package/dist/resources/templates/environments/antigravity.yaml +10 -0
- package/dist/resources/templates/environments/claude.yaml +10 -0
- package/dist/resources/templates/environments/cline.yaml +10 -0
- package/dist/resources/templates/environments/codex.yaml +13 -0
- package/dist/resources/templates/environments/copilot.yaml +10 -0
- package/dist/resources/templates/environments/cursor.yaml +9 -0
- package/dist/resources/templates/environments/gemini.yaml +9 -0
- package/dist/resources/templates/environments/windsurf.yaml +10 -0
- package/dist/resources/templates/workflow-sources/README.md +9 -0
- package/dist/resources/templates/workflow-sources/workflowDocs.mjs +520 -0
- package/dist/resources/templates/workflow-sources/workflowEditorOverrides.json +1 -0
- package/dist/resources/templates/workflows/collaborate.yaml +104 -0
- package/dist/resources/templates/workflows/do.yaml +82 -0
- package/dist/resources/templates/workflows/evaluate.yaml +111 -0
- package/dist/resources/templates/workflows/execute.yaml +142 -0
- package/dist/resources/templates/workflows/plan.yaml +112 -0
- package/dist/resources/templates/workflows/review.yaml +109 -0
- package/dist/runtime/capabilities.d.ts +8 -0
- package/dist/runtime/capabilities.js +16 -0
- package/dist/security/virusScan.d.ts +16 -0
- package/dist/security/virusScan.js +83 -0
- package/dist/server/auth.d.ts +51 -0
- package/dist/server/auth.js +266 -0
- package/dist/server/auth.test.d.ts +1 -0
- package/dist/server/auth.test.js +111 -0
- package/dist/server/buildInfo.d.ts +7 -0
- package/dist/server/buildInfo.js +100 -0
- package/dist/server/buildInfo.test.d.ts +1 -0
- package/dist/server/buildInfo.test.js +29 -0
- package/dist/server/cors.d.ts +7 -0
- package/dist/server/cors.js +76 -0
- package/dist/server/cors.test.d.ts +1 -0
- package/dist/server/cors.test.js +71 -0
- package/dist/server/email.d.ts +28 -0
- package/dist/server/email.js +138 -0
- package/dist/server/email.test.d.ts +1 -0
- package/dist/server/email.test.js +113 -0
- package/dist/server/index.cookieProxy.test.d.ts +1 -0
- package/dist/server/index.cookieProxy.test.js +19 -0
- package/dist/server/index.d.ts +17 -0
- package/dist/server/index.js +757 -0
- package/dist/server/index.rateLimit.test.d.ts +1 -0
- package/dist/server/index.rateLimit.test.js +13 -0
- package/dist/server/index.test.d.ts +1 -0
- package/dist/server/index.test.js +19 -0
- package/dist/server/rateLimit.d.ts +29 -0
- package/dist/server/rateLimit.js +99 -0
- package/dist/server/rateLimit.test.d.ts +1 -0
- package/dist/server/rateLimit.test.js +107 -0
- package/dist/server/realtimeSync.auth.test.d.ts +1 -0
- package/dist/server/realtimeSync.auth.test.js +62 -0
- package/dist/server/realtimeSync.replay.test.d.ts +1 -0
- package/dist/server/realtimeSync.replay.test.js +192 -0
- package/dist/server/realtimeSync.workspaceIsolation.test.d.ts +1 -0
- package/dist/server/realtimeSync.workspaceIsolation.test.js +101 -0
- package/dist/server/realtimeSyncWs.d.ts +30 -0
- package/dist/server/realtimeSyncWs.js +257 -0
- package/dist/server/routes.billing.d.ts +15 -0
- package/dist/server/routes.billing.js +991 -0
- package/dist/server/routes.billing.test.d.ts +1 -0
- package/dist/server/routes.billing.test.js +387 -0
- package/dist/server/routes.d.ts +72 -0
- package/dist/server/routes.js +4820 -0
- package/dist/server/routes.sync.d.ts +17 -0
- package/dist/server/routes.sync.integration.test.d.ts +1 -0
- package/dist/server/routes.sync.integration.test.js +470 -0
- package/dist/server/routes.sync.js +1141 -0
- package/dist/server/routes.test.d.ts +1 -0
- package/dist/server/routes.test.js +4945 -0
- package/dist/server/runtimeMode.d.ts +4 -0
- package/dist/server/runtimeMode.js +25 -0
- package/dist/server/runtimeMode.test.d.ts +1 -0
- package/dist/server/runtimeMode.test.js +26 -0
- package/dist/server/securityHeaders.d.ts +3 -0
- package/dist/server/securityHeaders.js +25 -0
- package/dist/server/securityHeaders.test.d.ts +1 -0
- package/dist/server/securityHeaders.test.js +32 -0
- package/dist/server/smtpTransport.d.ts +21 -0
- package/dist/server/smtpTransport.js +11 -0
- package/dist/server/start.d.ts +1 -0
- package/dist/server/start.js +17 -0
- package/dist/services/formatTransformers.d.ts +29 -0
- package/dist/services/formatTransformers.js +56 -0
- package/dist/services/templateEngine.d.ts +46 -0
- package/dist/services/templateEngine.js +148 -0
- package/dist/shared/runtimeContract.d.ts +12 -0
- package/dist/shared/runtimeContract.js +23 -0
- package/dist/shared/runtimeContract.test.d.ts +1 -0
- package/dist/shared/runtimeContract.test.js +23 -0
- package/dist/soak/preflight.cli.d.ts +1 -0
- package/dist/soak/preflight.cli.js +28 -0
- package/dist/soak/preflight.d.ts +26 -0
- package/dist/soak/preflight.js +113 -0
- package/dist/soak/preflight.test.d.ts +1 -0
- package/dist/soak/preflight.test.js +39 -0
- package/dist/soak/syncSoakHarness.d.ts +6 -0
- package/dist/soak/syncSoakHarness.js +10 -0
- package/dist/storage/createDatabaseAdapter.d.ts +2 -0
- package/dist/storage/createDatabaseAdapter.js +9 -0
- package/dist/storage/databaseAdapter.d.ts +22 -0
- package/dist/storage/databaseAdapter.js +1 -0
- package/dist/storage/documentIntegrity.d.ts +28 -0
- package/dist/storage/documentIntegrity.js +103 -0
- package/dist/storage/documentPurge.d.ts +14 -0
- package/dist/storage/documentPurge.js +46 -0
- package/dist/storage/documentRegistry.d.ts +39 -0
- package/dist/storage/documentRegistry.js +115 -0
- package/dist/storage/integritySchedule.d.ts +17 -0
- package/dist/storage/integritySchedule.js +62 -0
- package/dist/storage/objectStorage.d.ts +20 -0
- package/dist/storage/objectStorage.js +15 -0
- package/dist/storage/objectStorageClient.d.ts +18 -0
- package/dist/storage/objectStorageClient.js +301 -0
- package/dist/storage/objectStorageConfig.d.ts +2 -0
- package/dist/storage/objectStorageConfig.js +45 -0
- package/dist/storage/postgresAdapter.d.ts +20 -0
- package/dist/storage/postgresAdapter.js +204 -0
- package/dist/storage/postgresWorker.d.ts +1 -0
- package/dist/storage/postgresWorker.js +110 -0
- package/dist/storage/providerConfig.d.ts +6 -0
- package/dist/storage/providerConfig.js +11 -0
- package/dist/storage/sqliteAdapter.d.ts +14 -0
- package/dist/storage/sqliteAdapter.js +28 -0
- package/dist/storage/taskAssetStore.d.ts +56 -0
- package/dist/storage/taskAssetStore.js +297 -0
- package/dist/sync/cloudSyncApi.d.ts +47 -0
- package/dist/sync/cloudSyncApi.js +138 -0
- package/dist/sync/cloudSyncApi.test.d.ts +1 -0
- package/dist/sync/cloudSyncApi.test.js +54 -0
- package/dist/sync/encryptionService.d.ts +11 -0
- package/dist/sync/encryptionService.js +61 -0
- package/dist/sync/encryptionService.test.d.ts +1 -0
- package/dist/sync/encryptionService.test.js +52 -0
- package/dist/sync/realtimeFeature.d.ts +6 -0
- package/dist/sync/realtimeFeature.js +20 -0
- package/dist/sync/realtimeFeature.test.d.ts +1 -0
- package/dist/sync/realtimeFeature.test.js +30 -0
- package/dist/sync/realtimeReconcile.d.ts +30 -0
- package/dist/sync/realtimeReconcile.js +90 -0
- package/dist/sync/realtimeReconcile.test.d.ts +1 -0
- package/dist/sync/realtimeReconcile.test.js +43 -0
- package/dist/sync/syncService.d.ts +96 -0
- package/dist/sync/syncService.js +423 -0
- package/dist/sync/syncService.test.d.ts +1 -0
- package/dist/sync/syncService.test.js +484 -0
- package/dist/sync/workspaceSyncModel.d.ts +61 -0
- package/dist/sync/workspaceSyncModel.js +136 -0
- package/dist/sync/workspaceSyncModel.test.d.ts +1 -0
- package/dist/sync/workspaceSyncModel.test.js +73 -0
- package/dist/test/setup.d.ts +1 -0
- package/dist/test/setup.js +15 -0
- package/dist/types.d.ts +157 -0
- package/dist/types.js +41 -0
- package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
- package/dist/ui/assets/index-0W5vyCpn.css +1 -0
- package/dist/ui/assets/index-MCZuNNfn.js +23 -0
- package/dist/ui/assets/logo_white_01-0Vx-iIIv.png +0 -0
- package/dist/ui/assets/taskforce-BxLPokNB.png +0 -0
- package/dist/ui/assets/vendor-dnd-BI1K77C1.js +5 -0
- package/dist/ui/assets/vendor-icons-ZFG3SGwo.js +1 -0
- package/dist/ui/assets/vendor-markdown-CKYkQHfj.js +29 -0
- package/dist/ui/assets/vendor-react-DdIDsh7f.js +9 -0
- package/dist/ui/assets/vendor-router-BtiNU7Dl.js +3 -0
- package/dist/ui/branding/logos/logo_black_01.png +0 -0
- package/dist/ui/branding/logos/logo_black_02.png +0 -0
- package/dist/ui/branding/logos/logo_white_01.png +0 -0
- package/dist/ui/branding/logos/logo_white_02.png +0 -0
- package/dist/ui/favicon/android-chrome-192x192.png +0 -0
- package/dist/ui/favicon/android-chrome-512x512.png +0 -0
- package/dist/ui/favicon/apple-touch-icon.png +0 -0
- package/dist/ui/favicon/favicon-16x16.png +0 -0
- package/dist/ui/favicon/favicon-32x32.png +0 -0
- package/dist/ui/favicon/favicon.ico +0 -0
- package/dist/ui/favicon/favicon.png +0 -0
- package/dist/ui/favicon/site.webmanifest +1 -0
- package/dist/ui/fonts/RussoOne-Regular.ttf +0 -0
- package/dist/ui/images/taskforce.png +0 -0
- package/dist/ui/index.html +19 -0
- package/dist/utils/checklistMarkdown.d.ts +9 -0
- package/dist/utils/checklistMarkdown.js +46 -0
- package/dist/utils/constants.d.ts +21 -0
- package/dist/utils/constants.icons.test.d.ts +1 -0
- package/dist/utils/constants.icons.test.js +40 -0
- package/dist/utils/constants.js +100 -0
- package/dist/utils/contextFiles.d.ts +8 -0
- package/dist/utils/contextFiles.js +54 -0
- package/dist/utils/formatting.d.ts +5 -0
- package/dist/utils/formatting.js +30 -0
- package/dist/utils/planParser.d.ts +22 -0
- package/dist/utils/planParser.js +151 -0
- package/dist/utils/taskDragZones.d.ts +8 -0
- package/dist/utils/taskDragZones.js +14 -0
- package/dist/utils/taskDragZones.test.d.ts +1 -0
- package/dist/utils/taskDragZones.test.js +14 -0
- package/dist/utils/taskHierarchy.d.ts +4 -0
- package/dist/utils/taskHierarchy.js +69 -0
- package/dist/utils/taskHierarchy.test.d.ts +1 -0
- package/dist/utils/taskHierarchy.test.js +38 -0
- package/dist/utils/taskParenting.d.ts +3 -0
- package/dist/utils/taskParenting.js +24 -0
- package/dist/utils/taskParenting.test.d.ts +1 -0
- package/dist/utils/taskParenting.test.js +21 -0
- package/dist/utils/taskRelations.d.ts +6 -0
- package/dist/utils/taskRelations.js +24 -0
- package/dist/utils/taskRelations.test.d.ts +1 -0
- package/dist/utils/taskRelations.test.js +34 -0
- package/dist/utils/taskUtils.d.ts +2 -0
- package/dist/utils/taskUtils.js +20 -0
- package/dist/utils/taskforceApiClient.d.ts +7 -0
- package/dist/utils/taskforceApiClient.js +42 -0
- package/dist/utils/workspaceIdentity.d.ts +8 -0
- package/dist/utils/workspaceIdentity.js +47 -0
- package/dist/utils/workspaceIdentity.test.d.ts +1 -0
- package/dist/utils/workspaceIdentity.test.js +32 -0
- package/dist/utils/workspaceLifecycle.d.ts +26 -0
- package/dist/utils/workspaceLifecycle.js +69 -0
- package/dist/utils/workspaceLifecycle.test.d.ts +1 -0
- package/dist/utils/workspaceLifecycle.test.js +64 -0
- package/package.json +133 -0
- package/scripts/check-compat-boundaries.js +48 -0
- package/scripts/check-env.ts +24 -0
- package/scripts/check-localization-strings.mjs +49 -0
- package/scripts/check-runtime-boundaries.mjs +44 -0
- package/scripts/debug-global.mjs +34 -0
- package/scripts/debug-server.mjs +36 -0
- package/scripts/export-sqlite-to-postgres.mjs +125 -0
- package/scripts/generate-env-example.ts +8 -0
- package/scripts/generate-workflow-templates.mjs +134 -0
- package/scripts/mcp-stdio-adapter.mjs +160 -0
- package/scripts/postinstall.js +60 -0
- package/scripts/release-version-lib.mjs +88 -0
- package/scripts/release-version.mjs +72 -0
- package/scripts/run-dev-proxy.sh +49 -0
- package/scripts/run-e2e-realtime.sh +16 -0
- package/scripts/run-smoke.sh +23 -0
- package/scripts/run-soak.sh +29 -0
- package/scripts/test-global.js +143 -0
- package/scripts/update-homebrew-formula.sh +38 -0
- package/src/resources/templates/WORKFLOW_V2_POLICY.md +55 -0
- package/src/resources/templates/environments/aider.yaml +14 -0
- package/src/resources/templates/environments/antigravity.yaml +10 -0
- package/src/resources/templates/environments/claude.yaml +10 -0
- package/src/resources/templates/environments/cline.yaml +10 -0
- package/src/resources/templates/environments/codex.yaml +13 -0
- package/src/resources/templates/environments/copilot.yaml +10 -0
- package/src/resources/templates/environments/cursor.yaml +9 -0
- package/src/resources/templates/environments/gemini.yaml +9 -0
- package/src/resources/templates/environments/windsurf.yaml +10 -0
- package/src/resources/templates/workflow-sources/README.md +9 -0
- package/src/resources/templates/workflow-sources/workflowDocs.mjs +520 -0
- package/src/resources/templates/workflow-sources/workflowEditorOverrides.json +1 -0
- package/src/resources/templates/workflows/collaborate.yaml +104 -0
- package/src/resources/templates/workflows/do.yaml +82 -0
- package/src/resources/templates/workflows/evaluate.yaml +111 -0
- package/src/resources/templates/workflows/execute.yaml +142 -0
- package/src/resources/templates/workflows/plan.yaml +112 -0
- 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
|
+
}
|