reflectt-node 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +178 -0
- package/README.md +188 -0
- package/dist/activationEvents.d.ts +110 -0
- package/dist/activationEvents.d.ts.map +1 -0
- package/dist/activationEvents.js +378 -0
- package/dist/activationEvents.js.map +1 -0
- package/dist/activity-signal.d.ts +30 -0
- package/dist/activity-signal.d.ts.map +1 -0
- package/dist/activity-signal.js +93 -0
- package/dist/activity-signal.js.map +1 -0
- package/dist/alert-integrity.d.ts +100 -0
- package/dist/alert-integrity.d.ts.map +1 -0
- package/dist/alert-integrity.js +333 -0
- package/dist/alert-integrity.js.map +1 -0
- package/dist/alert-preflight.d.ts +40 -0
- package/dist/alert-preflight.d.ts.map +1 -0
- package/dist/alert-preflight.js +235 -0
- package/dist/alert-preflight.js.map +1 -0
- package/dist/analytics.d.ts +131 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +371 -0
- package/dist/analytics.js.map +1 -0
- package/dist/artifact-mirror.d.ts +26 -0
- package/dist/artifact-mirror.d.ts.map +1 -0
- package/dist/artifact-mirror.js +170 -0
- package/dist/artifact-mirror.js.map +1 -0
- package/dist/artifact-resolver.d.ts +48 -0
- package/dist/artifact-resolver.d.ts.map +1 -0
- package/dist/artifact-resolver.js +164 -0
- package/dist/artifact-resolver.js.map +1 -0
- package/dist/assignment.d.ts +116 -0
- package/dist/assignment.d.ts.map +1 -0
- package/dist/assignment.js +475 -0
- package/dist/assignment.js.map +1 -0
- package/dist/auditLedger.d.ts +50 -0
- package/dist/auditLedger.d.ts.map +1 -0
- package/dist/auditLedger.js +136 -0
- package/dist/auditLedger.js.map +1 -0
- package/dist/boardHealthWorker.d.ts +134 -0
- package/dist/boardHealthWorker.d.ts.map +1 -0
- package/dist/boardHealthWorker.js +882 -0
- package/dist/boardHealthWorker.js.map +1 -0
- package/dist/bootstrap-team.d.ts +42 -0
- package/dist/bootstrap-team.d.ts.map +1 -0
- package/dist/bootstrap-team.js +111 -0
- package/dist/bootstrap-team.js.map +1 -0
- package/dist/buildInfo.d.ts +17 -0
- package/dist/buildInfo.d.ts.map +1 -0
- package/dist/buildInfo.js +56 -0
- package/dist/buildInfo.js.map +1 -0
- package/dist/calendar-events.d.ts +133 -0
- package/dist/calendar-events.d.ts.map +1 -0
- package/dist/calendar-events.js +615 -0
- package/dist/calendar-events.js.map +1 -0
- package/dist/calendar-ical.d.ts +41 -0
- package/dist/calendar-ical.d.ts.map +1 -0
- package/dist/calendar-ical.js +413 -0
- package/dist/calendar-ical.js.map +1 -0
- package/dist/calendar-reminder-engine.d.ts +10 -0
- package/dist/calendar-reminder-engine.d.ts.map +1 -0
- package/dist/calendar-reminder-engine.js +143 -0
- package/dist/calendar-reminder-engine.js.map +1 -0
- package/dist/calendar.d.ts +75 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +391 -0
- package/dist/calendar.js.map +1 -0
- package/dist/canvas-multiplexer.d.ts +44 -0
- package/dist/canvas-multiplexer.d.ts.map +1 -0
- package/dist/canvas-multiplexer.js +150 -0
- package/dist/canvas-multiplexer.js.map +1 -0
- package/dist/canvas-slots.d.ts +83 -0
- package/dist/canvas-slots.d.ts.map +1 -0
- package/dist/canvas-slots.js +144 -0
- package/dist/canvas-slots.js.map +1 -0
- package/dist/canvas-types.d.ts +56 -0
- package/dist/canvas-types.d.ts.map +1 -0
- package/dist/canvas-types.js +54 -0
- package/dist/canvas-types.js.map +1 -0
- package/dist/cf-keepalive.d.ts +40 -0
- package/dist/cf-keepalive.d.ts.map +1 -0
- package/dist/cf-keepalive.js +153 -0
- package/dist/cf-keepalive.js.map +1 -0
- package/dist/changeFeed.d.ts +38 -0
- package/dist/changeFeed.d.ts.map +1 -0
- package/dist/changeFeed.js +324 -0
- package/dist/changeFeed.js.map +1 -0
- package/dist/channels.d.ts +28 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +23 -0
- package/dist/channels.js.map +1 -0
- package/dist/chat-approval-detector.d.ts +47 -0
- package/dist/chat-approval-detector.d.ts.map +1 -0
- package/dist/chat-approval-detector.js +224 -0
- package/dist/chat-approval-detector.js.map +1 -0
- package/dist/chat.d.ts +119 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +666 -0
- package/dist/chat.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1142 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud.d.ts +45 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +962 -0
- package/dist/cloud.js.map +1 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +33 -0
- package/dist/config.js.map +1 -0
- package/dist/connectivity.d.ts +59 -0
- package/dist/connectivity.d.ts.map +1 -0
- package/dist/connectivity.js +173 -0
- package/dist/connectivity.js.map +1 -0
- package/dist/contacts.d.ts +59 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +183 -0
- package/dist/contacts.js.map +1 -0
- package/dist/content.d.ts +130 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +186 -0
- package/dist/content.js.map +1 -0
- package/dist/context-budget.d.ts +87 -0
- package/dist/context-budget.d.ts.map +1 -0
- package/dist/context-budget.js +459 -0
- package/dist/context-budget.js.map +1 -0
- package/dist/continuity-loop.d.ts +55 -0
- package/dist/continuity-loop.d.ts.map +1 -0
- package/dist/continuity-loop.js +267 -0
- package/dist/continuity-loop.js.map +1 -0
- package/dist/dashboard.d.ts +6 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +2348 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/db.d.ts +44 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +648 -0
- package/dist/db.js.map +1 -0
- package/dist/doctor.d.ts +30 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +159 -0
- package/dist/doctor.js.map +1 -0
- package/dist/duplicateClosureGuard.d.ts +31 -0
- package/dist/duplicateClosureGuard.d.ts.map +1 -0
- package/dist/duplicateClosureGuard.js +83 -0
- package/dist/duplicateClosureGuard.js.map +1 -0
- package/dist/embeddings.d.ts +13 -0
- package/dist/embeddings.d.ts.map +1 -0
- package/dist/embeddings.js +78 -0
- package/dist/embeddings.js.map +1 -0
- package/dist/escalation.d.ts +80 -0
- package/dist/escalation.d.ts.map +1 -0
- package/dist/escalation.js +213 -0
- package/dist/escalation.js.map +1 -0
- package/dist/events.d.ts +130 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +382 -0
- package/dist/events.js.map +1 -0
- package/dist/executionSweeper.d.ts +97 -0
- package/dist/executionSweeper.d.ts.map +1 -0
- package/dist/executionSweeper.js +875 -0
- package/dist/executionSweeper.js.map +1 -0
- package/dist/experiments.d.ts +47 -0
- package/dist/experiments.d.ts.map +1 -0
- package/dist/experiments.js +133 -0
- package/dist/experiments.js.map +1 -0
- package/dist/feedback.d.ts +179 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +397 -0
- package/dist/feedback.js.map +1 -0
- package/dist/files.d.ts +52 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +172 -0
- package/dist/files.js.map +1 -0
- package/dist/format-duration.d.ts +19 -0
- package/dist/format-duration.d.ts.map +1 -0
- package/dist/format-duration.js +33 -0
- package/dist/format-duration.js.map +1 -0
- package/dist/github-actor-auth.d.ts +20 -0
- package/dist/github-actor-auth.d.ts.map +1 -0
- package/dist/github-actor-auth.js +54 -0
- package/dist/github-actor-auth.js.map +1 -0
- package/dist/github-ci.d.ts +16 -0
- package/dist/github-ci.d.ts.map +1 -0
- package/dist/github-ci.js +37 -0
- package/dist/github-ci.js.map +1 -0
- package/dist/github-identity.d.ts +30 -0
- package/dist/github-identity.d.ts.map +1 -0
- package/dist/github-identity.js +96 -0
- package/dist/github-identity.js.map +1 -0
- package/dist/github-reviews.d.ts +24 -0
- package/dist/github-reviews.d.ts.map +1 -0
- package/dist/github-reviews.js +56 -0
- package/dist/github-reviews.js.map +1 -0
- package/dist/health.d.ts +391 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +1841 -0
- package/dist/health.js.map +1 -0
- package/dist/host-keepalive.d.ts +22 -0
- package/dist/host-keepalive.d.ts.map +1 -0
- package/dist/host-keepalive.js +126 -0
- package/dist/host-keepalive.js.map +1 -0
- package/dist/host-registry.d.ts +43 -0
- package/dist/host-registry.d.ts.map +1 -0
- package/dist/host-registry.js +93 -0
- package/dist/host-registry.js.map +1 -0
- package/dist/inbox.d.ts +87 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +410 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +306 -0
- package/dist/index.js.map +1 -0
- package/dist/insight-mutation.d.ts +32 -0
- package/dist/insight-mutation.d.ts.map +1 -0
- package/dist/insight-mutation.js +160 -0
- package/dist/insight-mutation.js.map +1 -0
- package/dist/insight-promotion.d.ts +89 -0
- package/dist/insight-promotion.d.ts.map +1 -0
- package/dist/insight-promotion.js +278 -0
- package/dist/insight-promotion.js.map +1 -0
- package/dist/insight-task-bridge.d.ts +77 -0
- package/dist/insight-task-bridge.d.ts.map +1 -0
- package/dist/insight-task-bridge.js +556 -0
- package/dist/insight-task-bridge.js.map +1 -0
- package/dist/insights.d.ts +222 -0
- package/dist/insights.d.ts.map +1 -0
- package/dist/insights.js +871 -0
- package/dist/insights.js.map +1 -0
- package/dist/intake-pipeline.d.ts +74 -0
- package/dist/intake-pipeline.d.ts.map +1 -0
- package/dist/intake-pipeline.js +199 -0
- package/dist/intake-pipeline.js.map +1 -0
- package/dist/intensity.d.ts +31 -0
- package/dist/intensity.d.ts.map +1 -0
- package/dist/intensity.js +94 -0
- package/dist/intensity.js.map +1 -0
- package/dist/knowledge-auto-index.d.ts +37 -0
- package/dist/knowledge-auto-index.d.ts.map +1 -0
- package/dist/knowledge-auto-index.js +149 -0
- package/dist/knowledge-auto-index.js.map +1 -0
- package/dist/knowledge-docs.d.ts +45 -0
- package/dist/knowledge-docs.d.ts.map +1 -0
- package/dist/knowledge-docs.js +188 -0
- package/dist/knowledge-docs.js.map +1 -0
- package/dist/lane-config.d.ts +25 -0
- package/dist/lane-config.d.ts.map +1 -0
- package/dist/lane-config.js +105 -0
- package/dist/lane-config.js.map +1 -0
- package/dist/lineage.d.ts +86 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/lineage.js +303 -0
- package/dist/lineage.js.map +1 -0
- package/dist/logStore.d.ts +25 -0
- package/dist/logStore.d.ts.map +1 -0
- package/dist/logStore.js +83 -0
- package/dist/logStore.js.map +1 -0
- package/dist/manage.d.ts +12 -0
- package/dist/manage.d.ts.map +1 -0
- package/dist/manage.js +253 -0
- package/dist/manage.js.map +1 -0
- package/dist/mcp.d.ts +5 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +604 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +47 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +149 -0
- package/dist/memory.js.map +1 -0
- package/dist/mention-ack.d.ts +80 -0
- package/dist/mention-ack.d.ts.map +1 -0
- package/dist/mention-ack.js +175 -0
- package/dist/mention-ack.js.map +1 -0
- package/dist/messageRouter.d.ts +60 -0
- package/dist/messageRouter.d.ts.map +1 -0
- package/dist/messageRouter.js +309 -0
- package/dist/messageRouter.js.map +1 -0
- package/dist/mutationAlert.d.ts +44 -0
- package/dist/mutationAlert.d.ts.map +1 -0
- package/dist/mutationAlert.js +174 -0
- package/dist/mutationAlert.js.map +1 -0
- package/dist/noise-budget.d.ts +136 -0
- package/dist/noise-budget.d.ts.map +1 -0
- package/dist/noise-budget.js +340 -0
- package/dist/noise-budget.js.map +1 -0
- package/dist/notifications.d.ts +67 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +253 -0
- package/dist/notifications.js.map +1 -0
- package/dist/openclaw.d.ts +34 -0
- package/dist/openclaw.d.ts.map +1 -0
- package/dist/openclaw.js +208 -0
- package/dist/openclaw.js.map +1 -0
- package/dist/pause-controls.d.ts +31 -0
- package/dist/pause-controls.d.ts.map +1 -0
- package/dist/pause-controls.js +130 -0
- package/dist/pause-controls.js.map +1 -0
- package/dist/pidlock.d.ts +25 -0
- package/dist/pidlock.d.ts.map +1 -0
- package/dist/pidlock.js +179 -0
- package/dist/pidlock.js.map +1 -0
- package/dist/policy.d.ts +139 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +264 -0
- package/dist/policy.js.map +1 -0
- package/dist/polls.d.ts +47 -0
- package/dist/polls.d.ts.map +1 -0
- package/dist/polls.js +162 -0
- package/dist/polls.js.map +1 -0
- package/dist/portability.d.ts +55 -0
- package/dist/portability.d.ts.map +1 -0
- package/dist/portability.js +292 -0
- package/dist/portability.js.map +1 -0
- package/dist/pr-integrity.d.ts +45 -0
- package/dist/pr-integrity.d.ts.map +1 -0
- package/dist/pr-integrity.js +124 -0
- package/dist/pr-integrity.js.map +1 -0
- package/dist/prAutoMerge.d.ts +62 -0
- package/dist/prAutoMerge.d.ts.map +1 -0
- package/dist/prAutoMerge.js +493 -0
- package/dist/prAutoMerge.js.map +1 -0
- package/dist/preflight.d.ts +66 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +864 -0
- package/dist/preflight.js.map +1 -0
- package/dist/presence.d.ts +98 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +347 -0
- package/dist/presence.js.map +1 -0
- package/dist/provisioning.d.ts +101 -0
- package/dist/provisioning.d.ts.map +1 -0
- package/dist/provisioning.js +430 -0
- package/dist/provisioning.js.map +1 -0
- package/dist/reflection-automation.d.ts +59 -0
- package/dist/reflection-automation.d.ts.map +1 -0
- package/dist/reflection-automation.js +350 -0
- package/dist/reflection-automation.js.map +1 -0
- package/dist/reflections.d.ts +65 -0
- package/dist/reflections.d.ts.map +1 -0
- package/dist/reflections.js +306 -0
- package/dist/reflections.js.map +1 -0
- package/dist/release.d.ts +67 -0
- package/dist/release.d.ts.map +1 -0
- package/dist/release.js +275 -0
- package/dist/release.js.map +1 -0
- package/dist/request-tracker.d.ts +36 -0
- package/dist/request-tracker.d.ts.map +1 -0
- package/dist/request-tracker.js +109 -0
- package/dist/request-tracker.js.map +1 -0
- package/dist/research.d.ts +75 -0
- package/dist/research.d.ts.map +1 -0
- package/dist/research.js +171 -0
- package/dist/research.js.map +1 -0
- package/dist/routing-approvals.d.ts +73 -0
- package/dist/routing-approvals.d.ts.map +1 -0
- package/dist/routing-approvals.js +88 -0
- package/dist/routing-approvals.js.map +1 -0
- package/dist/routing-override.d.ts +94 -0
- package/dist/routing-override.d.ts.map +1 -0
- package/dist/routing-override.js +290 -0
- package/dist/routing-override.js.map +1 -0
- package/dist/scope-routing.d.ts +18 -0
- package/dist/scope-routing.d.ts.map +1 -0
- package/dist/scope-routing.js +29 -0
- package/dist/scope-routing.js.map +1 -0
- package/dist/secrets.d.ts +77 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +287 -0
- package/dist/secrets.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +10887 -0
- package/dist/server.js.map +1 -0
- package/dist/service-probe.d.ts +53 -0
- package/dist/service-probe.d.ts.map +1 -0
- package/dist/service-probe.js +225 -0
- package/dist/service-probe.js.map +1 -0
- package/dist/shared-workspace-api.d.ts +73 -0
- package/dist/shared-workspace-api.d.ts.map +1 -0
- package/dist/shared-workspace-api.js +281 -0
- package/dist/shared-workspace-api.js.map +1 -0
- package/dist/shipped-heartbeat.d.ts +91 -0
- package/dist/shipped-heartbeat.d.ts.map +1 -0
- package/dist/shipped-heartbeat.js +272 -0
- package/dist/shipped-heartbeat.js.map +1 -0
- package/dist/starter-team.d.ts +23 -0
- package/dist/starter-team.d.ts.map +1 -0
- package/dist/starter-team.js +88 -0
- package/dist/starter-team.js.map +1 -0
- package/dist/suppression-ledger.d.ts +73 -0
- package/dist/suppression-ledger.d.ts.map +1 -0
- package/dist/suppression-ledger.js +125 -0
- package/dist/suppression-ledger.js.map +1 -0
- package/dist/system-loop-state.d.ts +4 -0
- package/dist/system-loop-state.d.ts.map +1 -0
- package/dist/system-loop-state.js +40 -0
- package/dist/system-loop-state.js.map +1 -0
- package/dist/taskCommentIngest.d.ts +43 -0
- package/dist/taskCommentIngest.d.ts.map +1 -0
- package/dist/taskCommentIngest.js +59 -0
- package/dist/taskCommentIngest.js.map +1 -0
- package/dist/taskPrecheck.d.ts +20 -0
- package/dist/taskPrecheck.d.ts.map +1 -0
- package/dist/taskPrecheck.js +329 -0
- package/dist/taskPrecheck.js.map +1 -0
- package/dist/taskStateSync.d.ts +8 -0
- package/dist/taskStateSync.d.ts.map +1 -0
- package/dist/taskStateSync.js +79 -0
- package/dist/taskStateSync.js.map +1 -0
- package/dist/tasks.d.ts +140 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +1281 -0
- package/dist/tasks.js.map +1 -0
- package/dist/team-config.d.ts +24 -0
- package/dist/team-config.d.ts.map +1 -0
- package/dist/team-config.js +221 -0
- package/dist/team-config.js.map +1 -0
- package/dist/team-doctor.d.ts +22 -0
- package/dist/team-doctor.d.ts.map +1 -0
- package/dist/team-doctor.js +270 -0
- package/dist/team-doctor.js.map +1 -0
- package/dist/team-pulse.d.ts +52 -0
- package/dist/team-pulse.d.ts.map +1 -0
- package/dist/team-pulse.js +176 -0
- package/dist/team-pulse.js.map +1 -0
- package/dist/telemetry.d.ts +74 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +256 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/test-task-filter.d.ts +21 -0
- package/dist/test-task-filter.d.ts.map +1 -0
- package/dist/test-task-filter.js +48 -0
- package/dist/test-task-filter.js.map +1 -0
- package/dist/types.d.ts +126 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-tracking.d.ts +101 -0
- package/dist/usage-tracking.d.ts.map +1 -0
- package/dist/usage-tracking.js +325 -0
- package/dist/usage-tracking.js.map +1 -0
- package/dist/vector-store.d.ts +87 -0
- package/dist/vector-store.d.ts.map +1 -0
- package/dist/vector-store.js +247 -0
- package/dist/vector-store.js.map +1 -0
- package/dist/watchdog/idleNudgeLane.d.ts +22 -0
- package/dist/watchdog/idleNudgeLane.d.ts.map +1 -0
- package/dist/watchdog/idleNudgeLane.js +98 -0
- package/dist/watchdog/idleNudgeLane.js.map +1 -0
- package/dist/webhooks.d.ts +103 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +398 -0
- package/dist/webhooks.js.map +1 -0
- package/dist/working-contract.d.ts +42 -0
- package/dist/working-contract.d.ts.map +1 -0
- package/dist/working-contract.js +228 -0
- package/dist/working-contract.js.map +1 -0
- package/dist/ws-heartbeat.d.ts +66 -0
- package/dist/ws-heartbeat.d.ts.map +1 -0
- package/dist/ws-heartbeat.js +174 -0
- package/dist/ws-heartbeat.js.map +1 -0
- package/package.json +87 -0
- package/plugins/reflectt-channel/README.md +96 -0
- package/plugins/reflectt-channel/index.ts +789 -0
- package/plugins/reflectt-channel/openclaw.plugin.json +23 -0
- package/plugins/reflectt-channel/package.json +23 -0
- package/plugins/reflectt-channel/src/channel.ts +433 -0
- package/plugins/reflectt-channel/src/types.ts +29 -0
- package/public/avatars/echo.png +0 -0
- package/public/avatars/harmony.png +0 -0
- package/public/avatars/kai.png +0 -0
- package/public/avatars/link.png +0 -0
- package/public/avatars/pixel.png +0 -0
- package/public/avatars/rhythm.png +0 -0
- package/public/avatars/ryan.png +0 -0
- package/public/avatars/sage.png +0 -0
- package/public/avatars/scout.png +0 -0
- package/public/avatars/spark.png +0 -0
- package/public/dashboard-animations.css +381 -0
- package/public/dashboard.js +3479 -0
- package/public/docs.md +1062 -0
- package/public/file-upload-mock.html +1097 -0
- package/public/og-card.png +0 -0
- package/public/ui-kit.html +318 -0
- package/public/widget/feedback.js +194 -0
|
@@ -0,0 +1,3479 @@
|
|
|
1
|
+
const BASE = location.origin;
|
|
2
|
+
|
|
3
|
+
// Keyboard a11y: activate [role="button"] on Enter/Space
|
|
4
|
+
document.addEventListener('keydown', function(e) {
|
|
5
|
+
if ((e.key === 'Enter' || e.key === ' ') && e.target.getAttribute('role') === 'button') {
|
|
6
|
+
e.preventDefault();
|
|
7
|
+
e.target.click();
|
|
8
|
+
}
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/* ============================================================
|
|
12
|
+
SIDEBAR NAV — hash-based client-side routing
|
|
13
|
+
============================================================ */
|
|
14
|
+
const VALID_PAGES = ['overview', 'tasks', 'chat', 'reviews', 'health', 'outcomes', 'research', 'artifacts'];
|
|
15
|
+
|
|
16
|
+
function navigateTo(page) {
|
|
17
|
+
if (!VALID_PAGES.includes(page)) page = 'overview';
|
|
18
|
+
location.hash = page === 'overview' ? '' : page;
|
|
19
|
+
activatePage(page);
|
|
20
|
+
// Close mobile sidebar
|
|
21
|
+
const sidebar = document.getElementById('sidebar');
|
|
22
|
+
const overlay = document.getElementById('sidebar-overlay');
|
|
23
|
+
if (sidebar) sidebar.classList.remove('open');
|
|
24
|
+
if (overlay) overlay.classList.remove('open');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function activatePage(page) {
|
|
28
|
+
// Hide all pages, show target
|
|
29
|
+
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
|
30
|
+
const target = document.getElementById('page-' + page);
|
|
31
|
+
if (target) target.classList.add('active');
|
|
32
|
+
// Update sidebar active state
|
|
33
|
+
document.querySelectorAll('.sidebar-link[data-page]').forEach(link => {
|
|
34
|
+
link.classList.toggle('active', link.dataset.page === page);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toggleSidebar() {
|
|
39
|
+
const sidebar = document.getElementById('sidebar');
|
|
40
|
+
const overlay = document.getElementById('sidebar-overlay');
|
|
41
|
+
if (sidebar) sidebar.classList.toggle('open');
|
|
42
|
+
if (overlay) overlay.classList.toggle('open');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Init: read hash on load
|
|
46
|
+
function initRouter() {
|
|
47
|
+
const hash = location.hash.replace('#', '') || 'overview';
|
|
48
|
+
activatePage(VALID_PAGES.includes(hash) ? hash : 'overview');
|
|
49
|
+
}
|
|
50
|
+
window.addEventListener('hashchange', () => {
|
|
51
|
+
const hash = location.hash.replace('#', '') || 'overview';
|
|
52
|
+
activatePage(VALID_PAGES.includes(hash) ? hash : 'overview');
|
|
53
|
+
});
|
|
54
|
+
initRouter();
|
|
55
|
+
|
|
56
|
+
let currentChannel = 'all';
|
|
57
|
+
let currentProject = 'all';
|
|
58
|
+
let currentStatusFilter = localStorage.getItem('taskStatusFilter') || 'open'; // 'open' | 'all'
|
|
59
|
+
let hideTestTasks = localStorage.getItem('hideTestTasks') !== 'false'; // default true
|
|
60
|
+
let allMessages = [];
|
|
61
|
+
let allTasks = [];
|
|
62
|
+
let allEvents = [];
|
|
63
|
+
let taskById = new Map();
|
|
64
|
+
let healthAgentMap = new Map();
|
|
65
|
+
let focusModeActive = false;
|
|
66
|
+
|
|
67
|
+
const TASK_ID_PATTERN = /\b(task-[a-z0-9-]+)\b/gi;
|
|
68
|
+
|
|
69
|
+
// Delta cursors for lower payload refreshes
|
|
70
|
+
let lastTaskSync = 0;
|
|
71
|
+
let lastChatSync = 0;
|
|
72
|
+
let lastActivitySync = 0;
|
|
73
|
+
|
|
74
|
+
// Health caching: summary each refresh, detail every 60s
|
|
75
|
+
let cachedHealth = null;
|
|
76
|
+
let lastHealthDetailSync = 0;
|
|
77
|
+
let refreshCount = 0;
|
|
78
|
+
let lastReleaseStatusSync = 0;
|
|
79
|
+
|
|
80
|
+
// Agent registry — populated from /team/roles API, with static fallback
|
|
81
|
+
let AGENTS = [
|
|
82
|
+
{ name: 'ryan', emoji: '👤', role: 'Founder' },
|
|
83
|
+
{ name: 'kai', emoji: '🤖', role: 'Lead' },
|
|
84
|
+
{ name: 'link', emoji: '🔗', role: 'Builder' },
|
|
85
|
+
{ name: 'sage', emoji: '🧠', role: 'Strategy' },
|
|
86
|
+
{ name: 'rhythm', emoji: '🥁', role: 'Ops' },
|
|
87
|
+
{ name: 'pixel', emoji: '🎨', role: 'Design' },
|
|
88
|
+
{ name: 'echo', emoji: '📝', role: 'Content' },
|
|
89
|
+
{ name: 'scout', emoji: '🔍', role: 'Research' },
|
|
90
|
+
{ name: 'harmony', emoji: '🫶', role: 'Health' },
|
|
91
|
+
{ name: 'spark', emoji: '🚀', role: 'Growth' },
|
|
92
|
+
];
|
|
93
|
+
let AGENT_INDEX = new Map(AGENTS.map(a => [a.name, a]));
|
|
94
|
+
|
|
95
|
+
/** Fetch live agent roles (including display names) and merge into AGENT_INDEX. */
|
|
96
|
+
async function refreshAgentRegistry() {
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch('/team/roles');
|
|
99
|
+
if (!res.ok) return;
|
|
100
|
+
const data = await res.json();
|
|
101
|
+
if (!data.agents || !Array.isArray(data.agents)) return;
|
|
102
|
+
// Replace the registry entirely with what the server reports
|
|
103
|
+
AGENT_INDEX.clear();
|
|
104
|
+
AGENTS = [];
|
|
105
|
+
for (const agent of data.agents) {
|
|
106
|
+
const entry = {
|
|
107
|
+
name: agent.name,
|
|
108
|
+
displayName: agent.displayName || undefined,
|
|
109
|
+
emoji: agent.emoji || '',
|
|
110
|
+
role: agent.role || '',
|
|
111
|
+
};
|
|
112
|
+
AGENT_INDEX.set(agent.name, entry);
|
|
113
|
+
AGENTS.push(entry);
|
|
114
|
+
}
|
|
115
|
+
} catch { /* network error — use static fallback */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Get the display label for an agent (displayName if set, else name). */
|
|
119
|
+
function agentLabel(nameOrFrom) {
|
|
120
|
+
const agent = AGENT_INDEX.get(nameOrFrom);
|
|
121
|
+
return (agent && agent.displayName) ? agent.displayName : nameOrFrom;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const SSOT_LINKS = [
|
|
125
|
+
{ label: 'Promotion Evidence Index', url: 'https://github.com/reflectt/reflectt-node/blob/main/docs/TASK_LINKIFY_PROMOTION_EVIDENCE_INDEX.md' },
|
|
126
|
+
{ label: 'Promotion Day Quickstart', url: 'https://github.com/reflectt/reflectt-node/blob/main/docs/TASK_LINKIFY_PROMOTION_DAY_QUICKSTART.md' },
|
|
127
|
+
{ label: 'Live Promotion Checklist', url: 'https://github.com/reflectt/reflectt-node/blob/main/docs/TASK_LINKIFY_LIVE_PROMOTION_CHECKLIST_FINAL.md' },
|
|
128
|
+
{ label: 'Required-Check Runbook', url: 'https://github.com/reflectt/reflectt-node/blob/main/docs/TASK_LINKIFY_REQUIRED_CHECK_RUNBOOK.md' },
|
|
129
|
+
{ label: 'Promotion Run-Window + Comms', url: 'https://github.com/reflectt/reflectt-node/blob/main/docs/TASK_LINKIFY_PROMOTION_RUN_WINDOW_AND_COMMS.md' },
|
|
130
|
+
{ label: 'Promotion-Day Smoke Script', url: 'https://github.com/reflectt/reflectt-node/blob/main/tools/task-linkify-promotion-smoke.sh' },
|
|
131
|
+
{ label: 'Rollback Drill Notes (pending)', url: null },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const SSOT_INDEX_RAW_URL = 'https://raw.githubusercontent.com/reflectt/reflectt-node/main/docs/TASK_LINKIFY_PROMOTION_EVIDENCE_INDEX.md';
|
|
135
|
+
let ssotMetaCache = { fetchedAt: 0, lastVerifiedUtc: null };
|
|
136
|
+
const SSOT_META_CACHE_MS = 5 * 60 * 1000;
|
|
137
|
+
|
|
138
|
+
function ago(ts) {
|
|
139
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
140
|
+
if (s < 60) return s + 's';
|
|
141
|
+
if (s < 3600) return Math.floor(s / 60) + 'm';
|
|
142
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
|
143
|
+
return Math.floor(s / 86400) + 'd';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function formatProductiveText(agent) {
|
|
147
|
+
if (!agent || !agent.lastProductiveAt) return 'No recent shipped signal';
|
|
148
|
+
return 'Last shipped signal: ' + ago(agent.lastProductiveAt) + ' ago';
|
|
149
|
+
}
|
|
150
|
+
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
|
151
|
+
function formatBytes(b) { if (!b || b < 1024) return b + ' B'; if (b < 1048576) return (b/1024).toFixed(1) + ' KB'; return (b/1048576).toFixed(1) + ' MB'; }
|
|
152
|
+
function truncate(s, n) { return s && s.length > n ? s.slice(0, n) + '…' : (s || ''); }
|
|
153
|
+
function renderTaskTags(tags) {
|
|
154
|
+
if (!Array.isArray(tags) || tags.length === 0) return '';
|
|
155
|
+
const shown = tags.filter(Boolean).slice(0, 3);
|
|
156
|
+
if (shown.length === 0) return '';
|
|
157
|
+
return shown.map(tag => `<span class="assignee-tag" style="color:var(--purple)">#${esc(String(tag))}</span>`).join(' ');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function extractTaskPrLink(task) {
|
|
161
|
+
if (!task || !task.metadata || typeof task.metadata !== 'object') return null;
|
|
162
|
+
const metadata = task.metadata;
|
|
163
|
+
const candidates = [];
|
|
164
|
+
if (typeof metadata.pr_url === 'string') candidates.push(metadata.pr_url);
|
|
165
|
+
if (typeof metadata.pr_link === 'string') candidates.push(metadata.pr_link);
|
|
166
|
+
if (Array.isArray(metadata.artifacts)) {
|
|
167
|
+
metadata.artifacts.forEach(item => { if (typeof item === 'string') candidates.push(item); });
|
|
168
|
+
}
|
|
169
|
+
if (metadata.qa_bundle && typeof metadata.qa_bundle === 'object' && Array.isArray(metadata.qa_bundle.artifact_links)) {
|
|
170
|
+
metadata.qa_bundle.artifact_links.forEach(item => { if (typeof item === 'string') candidates.push(item); });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const regex = /https?:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+(?:[^\s]*)?/i;
|
|
174
|
+
for (const c of candidates) {
|
|
175
|
+
const m = String(c || '').match(regex);
|
|
176
|
+
if (m) return m[0];
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderBlockedByLinks(task, options = {}) {
|
|
182
|
+
const ids = Array.isArray(task?.blocked_by) ? task.blocked_by.filter(Boolean) : [];
|
|
183
|
+
if (ids.length === 0) return '';
|
|
184
|
+
|
|
185
|
+
const compact = Boolean(options.compact);
|
|
186
|
+
const blockerLinks = ids.slice(0, compact ? 2 : 6).map(blockerId => {
|
|
187
|
+
const blocker = taskById.get(blockerId);
|
|
188
|
+
const label = blocker?.title ? truncate(blocker.title, compact ? 28 : 60) : blockerId;
|
|
189
|
+
return `<button class="assignee-tag" style="cursor:pointer" onclick="event.stopPropagation(); openTaskModal('${esc(blockerId)}')">↳ ${esc(label)}</button>`;
|
|
190
|
+
}).join(' ');
|
|
191
|
+
|
|
192
|
+
const extraCount = ids.length - (compact ? 2 : 6);
|
|
193
|
+
const extraText = extraCount > 0 ? ` <span class="assignee-tag">+${extraCount} more</span>` : '';
|
|
194
|
+
return `<div class="task-meta" style="margin-top:6px">⛔ blocker${ids.length > 1 ? 's' : ''}: ${blockerLinks}${extraText}</div>`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getStatusContractWarnings(task) {
|
|
198
|
+
if (!task || !task.status) return [];
|
|
199
|
+
const warnings = [];
|
|
200
|
+
const eta = task?.metadata?.eta;
|
|
201
|
+
const artifactPath = task?.metadata?.artifact_path;
|
|
202
|
+
|
|
203
|
+
if (task.status === 'doing') {
|
|
204
|
+
if (!task.reviewer) warnings.push('doing: missing reviewer');
|
|
205
|
+
if (!eta) warnings.push('doing: missing ETA');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (task.status === 'validating') {
|
|
209
|
+
if (!artifactPath) warnings.push('validating: missing artifact_path');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return warnings;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function renderStatusContractWarning(task) {
|
|
216
|
+
const warnings = getStatusContractWarnings(task);
|
|
217
|
+
if (warnings.length === 0) return '';
|
|
218
|
+
return `<div style="margin-top:6px;font-size:11px;color:var(--yellow)">⚠ ${esc(warnings.join(' · '))}</div>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderLaneTransitionMeta(task) {
|
|
222
|
+
const laneState = task?.metadata?.lane_state;
|
|
223
|
+
const last = task?.metadata?.last_transition;
|
|
224
|
+
const actor = typeof last?.actor === 'string' ? last.actor : null;
|
|
225
|
+
const ts = typeof last?.timestamp === 'number' ? last.timestamp : null;
|
|
226
|
+
const type = typeof last?.type === 'string' ? last.type : null;
|
|
227
|
+
|
|
228
|
+
if (!laneState && !actor && !ts && !type) return '';
|
|
229
|
+
|
|
230
|
+
const parts = [];
|
|
231
|
+
if (laneState) parts.push(`lane:${laneState}`);
|
|
232
|
+
if (type) parts.push(type);
|
|
233
|
+
if (actor) parts.push(`by ${actor}`);
|
|
234
|
+
if (ts) parts.push(ago(ts) + ' ago');
|
|
235
|
+
|
|
236
|
+
return `<div style="margin-top:6px;font-size:11px;color:var(--text-muted)">🧭 ${esc(parts.join(' · '))}</div>`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function mentionsRyan(message) { return /@ryan\b/i.test(message || ''); }
|
|
240
|
+
|
|
241
|
+
function resolveSSOTState(lastVerifiedUtc) {
|
|
242
|
+
if (!lastVerifiedUtc) return { state: 'unknown', label: 'unknown', text: 'verification timestamp unavailable' };
|
|
243
|
+
const ts = Date.parse(lastVerifiedUtc);
|
|
244
|
+
if (!Number.isFinite(ts)) return { state: 'unknown', label: 'unknown', text: 'verification timestamp unavailable' };
|
|
245
|
+
|
|
246
|
+
const ageMs = Date.now() - ts;
|
|
247
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
248
|
+
if (ageMs <= dayMs) return { state: 'fresh', label: 'fresh', text: 'last verified ' + ago(ts) + ' ago' };
|
|
249
|
+
if (ageMs <= 3 * dayMs) return { state: 'warn', label: 'review soon', text: 'last verified ' + ago(ts) + ' ago' };
|
|
250
|
+
return { state: 'stale', label: 'stale evidence', text: 'last verified ' + ago(ts) + ' ago' };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function fetchSSOTMeta() {
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
if (now - ssotMetaCache.fetchedAt < SSOT_META_CACHE_MS) return ssotMetaCache;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const response = await fetch(SSOT_INDEX_RAW_URL, { cache: 'no-store' });
|
|
259
|
+
if (!response.ok) throw new Error('status ' + response.status);
|
|
260
|
+
const text = await response.text();
|
|
261
|
+
const match = text.match(/^-\s*last_verified_utc:\s*(.+)$/m);
|
|
262
|
+
ssotMetaCache = {
|
|
263
|
+
fetchedAt: now,
|
|
264
|
+
lastVerifiedUtc: match ? match[1].trim() : null,
|
|
265
|
+
};
|
|
266
|
+
} catch {
|
|
267
|
+
ssotMetaCache = {
|
|
268
|
+
fetchedAt: now,
|
|
269
|
+
lastVerifiedUtc: null,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return ssotMetaCache;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function renderPromotionSSOT() {
|
|
277
|
+
const body = document.getElementById('ssot-body');
|
|
278
|
+
const count = document.getElementById('ssot-count');
|
|
279
|
+
if (!body || !count) return;
|
|
280
|
+
|
|
281
|
+
const available = SSOT_LINKS.filter(item => Boolean(item.url));
|
|
282
|
+
count.textContent = available.length + '/' + SSOT_LINKS.length + ' links';
|
|
283
|
+
|
|
284
|
+
const meta = await fetchSSOTMeta();
|
|
285
|
+
const state = resolveSSOTState(meta.lastVerifiedUtc);
|
|
286
|
+
|
|
287
|
+
const metaHtml = '<div class="ssot-meta">'
|
|
288
|
+
+ '<span class="ssot-meta-text">' + esc(state.text) + '</span>'
|
|
289
|
+
+ '<span class="ssot-state-badge ' + state.state + '" aria-label="verification state ' + esc(state.label) + '">' + esc(state.label) + '</span>'
|
|
290
|
+
+ '</div>';
|
|
291
|
+
|
|
292
|
+
body.innerHTML = metaHtml + '<div class="ssot-list">' + SSOT_LINKS.map(item => {
|
|
293
|
+
const missing = !item.url;
|
|
294
|
+
const action = missing
|
|
295
|
+
? '<span class="ssot-missing" aria-label="missing target">missing</span>'
|
|
296
|
+
: '<a class="ssot-link" href="' + esc(item.url) + '" target="_blank" rel="noreferrer noopener" aria-label="Open ' + esc(item.label) + '">Open</a>';
|
|
297
|
+
return '<div class="ssot-item"><span class="ssot-item-label">' + esc(item.label) + '</span>' + action + '</div>';
|
|
298
|
+
}).join('') + '</div>';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isTaskTokenInsideUrl(text, start, end) {
|
|
302
|
+
let segStart = start;
|
|
303
|
+
while (segStart > 0 && !/\s/.test(text[segStart - 1])) segStart -= 1;
|
|
304
|
+
let segEnd = end;
|
|
305
|
+
while (segEnd < text.length && !/\s/.test(text[segEnd])) segEnd += 1;
|
|
306
|
+
const tokenSegment = text.slice(segStart, segEnd);
|
|
307
|
+
return /^(https?:\/\/|www\.)/i.test(tokenSegment);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isTaskTokenLinkable(text, start, end) {
|
|
311
|
+
const leftOk = start === 0 || /[^A-Za-z0-9_]/.test(text[start - 1]);
|
|
312
|
+
const rightOk = end >= text.length || /[^A-Za-z0-9_]/.test(text[end]);
|
|
313
|
+
if (!leftOk || !rightOk) return false;
|
|
314
|
+
if (isTaskTokenInsideUrl(text, start, end)) return false;
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function renderMessageContentWithTaskLinks(content) {
|
|
319
|
+
const text = typeof content === 'string' ? content : '';
|
|
320
|
+
if (!text) return '';
|
|
321
|
+
|
|
322
|
+
let html = '';
|
|
323
|
+
let cursor = 0;
|
|
324
|
+
TASK_ID_PATTERN.lastIndex = 0;
|
|
325
|
+
|
|
326
|
+
let match;
|
|
327
|
+
while ((match = TASK_ID_PATTERN.exec(text)) !== null) {
|
|
328
|
+
const taskId = match[1];
|
|
329
|
+
const start = match.index;
|
|
330
|
+
const end = start + taskId.length;
|
|
331
|
+
|
|
332
|
+
html += esc(text.slice(cursor, start));
|
|
333
|
+
|
|
334
|
+
if (isTaskTokenLinkable(text, start, end)) {
|
|
335
|
+
const task = taskById.get(taskId);
|
|
336
|
+
const linkText = task ? (task.title + ' (' + taskId + ')') : taskId;
|
|
337
|
+
const tooltip = task
|
|
338
|
+
? '<span class="task-preview-tooltip"><span class="tp-title">' + esc(task.title) + '</span><span class="tp-meta">' + esc(task.status || '?') + ' · ' + esc(task.assignee || '?') + '</span></span>'
|
|
339
|
+
: '<span class="task-preview-tooltip"><span class="tp-title">' + esc(taskId) + '</span><span class="tp-meta">task not found</span></span>';
|
|
340
|
+
html += '<a href="#" class="task-id-link" data-task-id="' + esc(taskId) + '" style="position:relative">' + esc(linkText) + tooltip + '</a>';
|
|
341
|
+
} else {
|
|
342
|
+
html += esc(taskId);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
cursor = end;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
html += esc(text.slice(cursor));
|
|
349
|
+
return html;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function toggleMessageContent(el) {
|
|
353
|
+
if (!el || el.dataset.collapsible !== 'true') return;
|
|
354
|
+
el.classList.toggle('collapsed');
|
|
355
|
+
el.classList.toggle('expanded');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function bindTaskLinkHandlers(el) {
|
|
359
|
+
if (!el || el.dataset.taskLinkBound === 'true') return;
|
|
360
|
+
|
|
361
|
+
el.addEventListener('click', (event) => {
|
|
362
|
+
const link = event.target && event.target.closest ? event.target.closest('.task-id-link') : null;
|
|
363
|
+
if (link) {
|
|
364
|
+
event.preventDefault();
|
|
365
|
+
event.stopPropagation();
|
|
366
|
+
openTaskModal(link.dataset.taskId || '');
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
el.addEventListener('keydown', (event) => {
|
|
371
|
+
const link = event.target && event.target.closest ? event.target.closest('.task-id-link') : null;
|
|
372
|
+
if (!link) return;
|
|
373
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
374
|
+
event.preventDefault();
|
|
375
|
+
openTaskModal(link.dataset.taskId || '');
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
el.dataset.taskLinkBound = 'true';
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function initChatInteractions() {
|
|
383
|
+
const body = document.getElementById('chat-body');
|
|
384
|
+
if (!body || body.dataset.taskLinkBound === 'true') return;
|
|
385
|
+
|
|
386
|
+
body.addEventListener('click', (event) => {
|
|
387
|
+
const link = event.target && event.target.closest ? event.target.closest('.task-id-link') : null;
|
|
388
|
+
if (link) {
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
event.stopPropagation();
|
|
391
|
+
openTaskModal(link.dataset.taskId || '');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const contentEl = event.target && event.target.closest ? event.target.closest('.msg-content') : null;
|
|
396
|
+
if (contentEl) toggleMessageContent(contentEl);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
body.addEventListener('keydown', (event) => {
|
|
400
|
+
const link = event.target && event.target.closest ? event.target.closest('.task-id-link') : null;
|
|
401
|
+
if (!link) return;
|
|
402
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
403
|
+
event.preventDefault();
|
|
404
|
+
openTaskModal(link.dataset.taskId || '');
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
body.dataset.taskLinkBound = 'true';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function initComplianceInteractions() {
|
|
412
|
+
bindTaskLinkHandlers(document.getElementById('compliance-body'));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function complianceState(value, threshold) {
|
|
416
|
+
if (value > threshold) return 'violation';
|
|
417
|
+
if (value >= Math.max(0, threshold - 10)) return 'warning';
|
|
418
|
+
return 'ok';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Format minutes into human-readable duration, capping extreme values */
|
|
422
|
+
function formatDurationMin(min) {
|
|
423
|
+
if (min == null || min < 0) return '—';
|
|
424
|
+
if (min >= 1440) return Math.floor(min / 1440) + 'd';
|
|
425
|
+
if (min >= 120) return Math.floor(min / 60) + 'h';
|
|
426
|
+
return min + 'm';
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function statusTemplateFor(agent, taskId) {
|
|
430
|
+
const mentions = agent === 'pixel'
|
|
431
|
+
? '@kai @link'
|
|
432
|
+
: agent === 'link'
|
|
433
|
+
? '@kai @pixel'
|
|
434
|
+
: agent === 'kai'
|
|
435
|
+
? '@link @pixel'
|
|
436
|
+
: '@kai @pixel';
|
|
437
|
+
return [
|
|
438
|
+
mentions,
|
|
439
|
+
'Task: ' + (taskId || '<task-id>'),
|
|
440
|
+
'1) Shipped: <artifact/commit/file>',
|
|
441
|
+
'2) Blocker: <none or explicit blocker>',
|
|
442
|
+
'3) Next: <next deliverable + ETA>',
|
|
443
|
+
].join('\n');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function copyStatusTemplate(agent, taskId) {
|
|
447
|
+
const text = statusTemplateFor(agent, taskId);
|
|
448
|
+
try {
|
|
449
|
+
await navigator.clipboard.writeText(text);
|
|
450
|
+
} catch {
|
|
451
|
+
const ta = document.createElement('textarea');
|
|
452
|
+
ta.value = text;
|
|
453
|
+
document.body.appendChild(ta);
|
|
454
|
+
ta.select();
|
|
455
|
+
document.execCommand('copy');
|
|
456
|
+
document.body.removeChild(ta);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function renderCompliance(compliance) {
|
|
461
|
+
const body = document.getElementById('compliance-body');
|
|
462
|
+
const count = document.getElementById('compliance-count');
|
|
463
|
+
|
|
464
|
+
if (!compliance) {
|
|
465
|
+
count.textContent = 'no data';
|
|
466
|
+
body.innerHTML = '<div class="empty">No compliance data available</div>';
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const s = compliance.summary || {};
|
|
471
|
+
const chips = [
|
|
472
|
+
{ label: 'Working updates <= 45m', value: s.workerWorstAgeMin || 0, threshold: s.workerCadenceMaxMin || 45 },
|
|
473
|
+
{ label: 'Lead watchdog <= 60m', value: s.leadAgeMin || 0, threshold: s.leadCadenceMaxMin || 60 },
|
|
474
|
+
{ label: 'Blocked unresolved > 20m', value: s.oldestBlockerMin || 0, threshold: s.blockedEscalationMin || 20 },
|
|
475
|
+
{ label: 'Trio silence <= 60m', value: s.trioSilenceMin || 0, threshold: s.trioSilenceMaxMin || 60 },
|
|
476
|
+
];
|
|
477
|
+
|
|
478
|
+
const agents = compliance.agents || [];
|
|
479
|
+
const incidents = compliance.incidents || [];
|
|
480
|
+
count.textContent = incidents.length + ' incident' + (incidents.length === 1 ? '' : 's');
|
|
481
|
+
|
|
482
|
+
const chipsHtml = chips.map(c => {
|
|
483
|
+
const state = complianceState(c.value, c.threshold);
|
|
484
|
+
return '<div class="sla-chip ' + state + '"><span>' + esc(c.label) + '</span><strong>' + formatDurationMin(c.value) + '</strong></div>';
|
|
485
|
+
}).join('');
|
|
486
|
+
|
|
487
|
+
const rows = agents.map(a => {
|
|
488
|
+
const taskValue = a.taskId || '';
|
|
489
|
+
const taskCell = taskValue ? renderMessageContentWithTaskLinks(taskValue) : '—';
|
|
490
|
+
return '<tr>' +
|
|
491
|
+
'<td>' + esc(a.agent) + '</td>' +
|
|
492
|
+
'<td>' + taskCell + '</td>' +
|
|
493
|
+
'<td>' + formatDurationMin(a.lastValidStatusAgeMin) + '</td>' +
|
|
494
|
+
'<td>' + a.expectedCadenceMin + 'm</td>' +
|
|
495
|
+
'<td><span class="state-pill ' + a.state + ' compliance-state-' + a.state + '">' + esc(a.state) + '</span></td>' +
|
|
496
|
+
'<td><button class="copy-template-btn" data-agent="' + esc(a.agent) + '" data-task="' + esc(taskValue) + '" onclick="copyStatusTemplate(this.dataset.agent, this.dataset.task)">Copy template</button></td>' +
|
|
497
|
+
'</tr>';
|
|
498
|
+
}).join('');
|
|
499
|
+
|
|
500
|
+
const incidentsHtml = incidents.length > 0
|
|
501
|
+
? incidents.map(i => '<div class="incident-item"><div class="incident-type">' + esc(i.type) + '</div><div>@' + esc(i.agent) + ' • ' + esc(i.taskId || 'no-task') + ' • ' + i.minutesOver + 'm over • escalate ' + esc((i.escalateTo || []).map(function(a){ return '@' + a; }).join(' ')) + '</div></div>').join('')
|
|
502
|
+
: '<div class="empty">No active compliance incidents</div>';
|
|
503
|
+
|
|
504
|
+
const linkRow = agents.find(function(a){ return a.agent === 'link'; });
|
|
505
|
+
const linkTemplate = statusTemplateFor('link', (linkRow && linkRow.taskId) || '<task-id>');
|
|
506
|
+
|
|
507
|
+
body.innerHTML =
|
|
508
|
+
'<div class="compliance-summary">' + chipsHtml + '</div>' +
|
|
509
|
+
'<table class="compliance-table">' +
|
|
510
|
+
'<thead><tr><th>Agent</th><th>Task</th><th>Last status age</th><th>Cadence</th><th>State</th><th>Action</th></tr></thead>' +
|
|
511
|
+
'<tbody>' + (rows || '<tr><td colspan="6" class="empty">No agent compliance data</td></tr>') + '</tbody>' +
|
|
512
|
+
'</table>' +
|
|
513
|
+
'<div class="health-section-title">Incident Queue</div>' +
|
|
514
|
+
incidentsHtml +
|
|
515
|
+
'<div class="health-section-title" style="margin-top:10px;">Status Template</div>' +
|
|
516
|
+
'<div class="template-box">' + esc(linkTemplate) + '</div>';
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function renderIdleNudgeSummary(idleNudgeDebug) {
|
|
520
|
+
if (!idleNudgeDebug || !idleNudgeDebug.summary) {
|
|
521
|
+
return '<div class="health-section"><div class="health-section-title">🔕 Idle Nudge Summary</div><div class="empty">No idle-nudge summary available</div></div>';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const reasonCounts = idleNudgeDebug.summary.reasonCounts || {};
|
|
525
|
+
const suppressedReasons = ['recent-activity-suppressed', 'validating-task-suppressed', 'missing-active-task'];
|
|
526
|
+
const rows = suppressedReasons
|
|
527
|
+
.map(reason => ({ reason, count: Number(reasonCounts[reason] || 0) }))
|
|
528
|
+
.filter(row => row.count > 0);
|
|
529
|
+
|
|
530
|
+
const totalSuppressed = rows.reduce((sum, row) => sum + row.count, 0);
|
|
531
|
+
const totalNudged = Number((idleNudgeDebug.summary.decisionCounts || {}).warn || 0) + Number((idleNudgeDebug.summary.decisionCounts || {}).escalate || 0);
|
|
532
|
+
|
|
533
|
+
const detail = rows.length > 0
|
|
534
|
+
? rows.map(row => `<div class="event-row"><span class="event-type">suppressed</span><span class="event-desc">${esc(row.reason)}: ${row.count}</span></div>`).join('')
|
|
535
|
+
: '<div class="empty">No suppressions in latest tick</div>';
|
|
536
|
+
|
|
537
|
+
return `<div class="health-section"><div class="health-section-title">🔕 Idle Nudge Summary</div><div class="event-row"><span class="event-type">nudged</span><span class="event-desc">warn/escalate: ${totalNudged}</span></div><div class="event-row"><span class="event-type">suppressed</span><span class="event-desc">total: ${totalSuppressed}</span></div>${detail}</div>`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function deriveHealthSignal(agent) {
|
|
541
|
+
if (agent.status !== 'blocked') return { status: agent.status, lowConfidence: false };
|
|
542
|
+
|
|
543
|
+
const blockers = agent.recentBlockers || [];
|
|
544
|
+
if (blockers.length === 0) return { status: 'blocked', lowConfidence: false };
|
|
545
|
+
|
|
546
|
+
const likelyNoise = blockers.some(b => /no blockers?|unblocked|not blocked|blocked-state|blocker tracking|false.?alarm|status update|dashboard/i.test(b));
|
|
547
|
+
if (likelyNoise || blockers.length === 1) {
|
|
548
|
+
return { status: agent.minutesSinceLastSeen >= 60 ? 'silent' : 'watch', lowConfidence: true };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return { status: 'blocked', lowConfidence: false };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function healthPriorityRank(agent) {
|
|
555
|
+
if (agent.idleWithActiveTask || agent.displayStatus === 'blocked') return 0;
|
|
556
|
+
if (agent.displayStatus === 'silent' || agent.displayStatus === 'watch' || agent.lowConfidence) return 1;
|
|
557
|
+
return 2;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function classifyProject(task) {
|
|
561
|
+
const text = ((task.title || '') + ' ' + (task.description || '')).toLowerCase();
|
|
562
|
+
if (/dashboard|reflectt-node|api|mcp|sse|persistence|event|server|cli|node/.test(text)) return 'reflectt-node';
|
|
563
|
+
if (/foragents|getting.?started|skills|directory|agents\\.dev/.test(text)) return 'forAgents.dev';
|
|
564
|
+
if (/heartbeat|health|roles|team|ops|cleanup|agent.?roles|monitoring|deploy/.test(text)) return 'Team Ops';
|
|
565
|
+
return 'Other';
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---- Presence ----
|
|
569
|
+
async function loadPresence() {
|
|
570
|
+
let presenceMap = {};
|
|
571
|
+
try {
|
|
572
|
+
const r = await fetch(BASE + '/presence');
|
|
573
|
+
const d = await r.json();
|
|
574
|
+
const list = d.presences || {};
|
|
575
|
+
if (Array.isArray(list)) list.forEach(p => { presenceMap[p.agent] = p; });
|
|
576
|
+
else Object.entries(list).forEach(([k, p]) => { presenceMap[k] = p; });
|
|
577
|
+
} catch (e) {}
|
|
578
|
+
|
|
579
|
+
const agentTasks = {};
|
|
580
|
+
allTasks.filter(t => t.status === 'doing').forEach(t => {
|
|
581
|
+
if (t.assignee) agentTasks[t.assignee] = t.title;
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const strip = document.getElementById('agent-strip');
|
|
585
|
+
// Build dynamic agent list from registry + presence (not hardcoded AGENTS)
|
|
586
|
+
const registeredAgents = Array.from(AGENT_INDEX.values());
|
|
587
|
+
// Add any agents from presence not already in registry
|
|
588
|
+
Object.keys(presenceMap).forEach(name => {
|
|
589
|
+
if (!AGENT_INDEX.has(name)) {
|
|
590
|
+
registeredAgents.push({ name, emoji: '🤖', role: '' });
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
// Add any agents with active tasks not already listed
|
|
594
|
+
Object.keys(agentTasks).forEach(name => {
|
|
595
|
+
if (!AGENT_INDEX.has(name) && !presenceMap[name]) {
|
|
596
|
+
registeredAgents.push({ name, emoji: '🤖', role: '' });
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
strip.innerHTML = registeredAgents.map(a => {
|
|
600
|
+
const p = presenceMap[a.name];
|
|
601
|
+
const taskTitle = agentTasks[a.name];
|
|
602
|
+
const healthRow = healthAgentMap.get(a.name);
|
|
603
|
+
const activeTaskTitle = healthRow?.activeTaskTitle || healthRow?.currentTask || taskTitle || '';
|
|
604
|
+
const activeTaskId = healthRow?.activeTaskId || null;
|
|
605
|
+
const activeTaskPr = healthRow?.activeTaskPrLink || null;
|
|
606
|
+
|
|
607
|
+
const isActive = p && p.status && p.status !== 'offline';
|
|
608
|
+
const isWorking = Boolean(activeTaskTitle);
|
|
609
|
+
const statusClass = isWorking ? 'active' : (isActive ? 'idle' : 'offline');
|
|
610
|
+
const badgeClass = isWorking ? 'working' : (isActive ? 'idle' : 'offline');
|
|
611
|
+
const badgeText = isWorking ? 'Working' : (isActive ? 'Idle' : 'Offline');
|
|
612
|
+
|
|
613
|
+
const lastSeenText = (p && p.lastUpdate) ? ago(p.lastUpdate) + ' ago' : '';
|
|
614
|
+
const taskText = activeTaskTitle ? truncate(activeTaskTitle, 40) : lastSeenText;
|
|
615
|
+
const prHtml = activeTaskPr
|
|
616
|
+
? `<a class="agent-pr-link" href="${esc(activeTaskPr)}" target="_blank" rel="noreferrer noopener" onclick="event.stopPropagation()">PR ↗</a>`
|
|
617
|
+
: '';
|
|
618
|
+
const taskIdHtml = activeTaskId ? `<span class="assignee-tag" style="margin-left:4px">${esc(activeTaskId.slice(0, 12))}</span>` : '';
|
|
619
|
+
|
|
620
|
+
return `<div class="agent-card ${statusClass}">
|
|
621
|
+
<img src="/avatars/${a.name}.png" alt="${a.emoji}" class="agent-avatar" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline';">
|
|
622
|
+
<span class="agent-emoji" style="display:none;">${a.emoji}</span>
|
|
623
|
+
<div class="agent-info">
|
|
624
|
+
<div class="agent-role">${esc(a.role)}</div>
|
|
625
|
+
<div class="agent-name">${esc(a.name)}</div>
|
|
626
|
+
<div class="agent-status-text">${esc(taskText)} ${taskIdHtml}</div>
|
|
627
|
+
${prHtml}
|
|
628
|
+
</div>
|
|
629
|
+
<span class="agent-badge ${badgeClass}">${badgeText}</span>
|
|
630
|
+
</div>`;
|
|
631
|
+
}).join('');
|
|
632
|
+
|
|
633
|
+
// Toggle scroll-fade indicator on agent strip wrapper
|
|
634
|
+
const wrapper = document.getElementById('agent-strip-wrapper');
|
|
635
|
+
if (wrapper && strip) {
|
|
636
|
+
const hasOverflow = strip.scrollWidth > strip.clientWidth;
|
|
637
|
+
wrapper.classList.toggle('has-overflow', hasOverflow);
|
|
638
|
+
// Update on scroll (hide fade when scrolled to end)
|
|
639
|
+
if (!strip._overflowListener) {
|
|
640
|
+
strip._overflowListener = true;
|
|
641
|
+
strip.addEventListener('scroll', () => {
|
|
642
|
+
const atEnd = strip.scrollLeft + strip.clientWidth >= strip.scrollWidth - 8;
|
|
643
|
+
wrapper.classList.toggle('has-overflow', !atEnd && strip.scrollWidth > strip.clientWidth);
|
|
644
|
+
}, { passive: true });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ---- Tasks ----
|
|
650
|
+
async function loadTasks(forceFull = false) {
|
|
651
|
+
try {
|
|
652
|
+
const useDelta = !forceFull && lastTaskSync > 0;
|
|
653
|
+
const qs = new URLSearchParams();
|
|
654
|
+
qs.set('limit', '80');
|
|
655
|
+
if (useDelta) qs.set('updatedSince', String(lastTaskSync));
|
|
656
|
+
|
|
657
|
+
const r = await fetch(BASE + '/tasks?' + qs.toString());
|
|
658
|
+
const d = await r.json();
|
|
659
|
+
const incoming = d.tasks || [];
|
|
660
|
+
|
|
661
|
+
if (useDelta && incoming.length === 0) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (useDelta) {
|
|
666
|
+
const byId = new Map(allTasks.map(t => [t.id, t]));
|
|
667
|
+
incoming.forEach(t => byId.set(t.id, t));
|
|
668
|
+
allTasks = Array.from(byId.values());
|
|
669
|
+
} else {
|
|
670
|
+
allTasks = incoming;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const maxUpdated = incoming.reduce((max, t) => Math.max(max, t.updatedAt || 0), 0);
|
|
674
|
+
if (maxUpdated > 0) lastTaskSync = Math.max(lastTaskSync, maxUpdated);
|
|
675
|
+
} catch (e) {
|
|
676
|
+
if (!allTasks.length) allTasks = [];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
taskById = new Map();
|
|
680
|
+
allTasks.forEach(task => {
|
|
681
|
+
if (task && task.id) taskById.set(task.id, task);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
renderProjectTabs();
|
|
685
|
+
renderStatusFilterTabs();
|
|
686
|
+
renderKanban();
|
|
687
|
+
renderBacklog();
|
|
688
|
+
renderOutcomeFeed();
|
|
689
|
+
const visibleTasks = getVisibleTasks();
|
|
690
|
+
const openCount = visibleTasks.filter(t => t.status !== 'done').length;
|
|
691
|
+
document.getElementById('task-count').textContent = currentStatusFilter === 'open'
|
|
692
|
+
? openCount + ' open tasks'
|
|
693
|
+
: visibleTasks.length + ' tasks';
|
|
694
|
+
// Update sidebar badge
|
|
695
|
+
const navTaskBadge = document.getElementById('nav-task-count');
|
|
696
|
+
if (navTaskBadge) navTaskBadge.textContent = allTasks.length;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function renderProjectTabs() {
|
|
700
|
+
const projects = ['All', 'reflectt-node', 'forAgents.dev', 'Team Ops', 'Other'];
|
|
701
|
+
const icons = { 'All': '📋', 'reflectt-node': '🔧', 'forAgents.dev': '🌐', 'Team Ops': '🏢', 'Other': '📦' };
|
|
702
|
+
const tabs = document.getElementById('project-tabs');
|
|
703
|
+
tabs.innerHTML = projects.map(p => {
|
|
704
|
+
const key = p === 'All' ? 'all' : p;
|
|
705
|
+
return `<button class="project-tab ${currentProject === key ? 'active' : ''}" onclick="switchProject('${key}')">${icons[p] || ''} ${p}</button>`;
|
|
706
|
+
}).join('');
|
|
707
|
+
}
|
|
708
|
+
function switchProject(p) { currentProject = p; renderProjectTabs(); renderKanban(); }
|
|
709
|
+
function switchStatusFilter(f) {
|
|
710
|
+
currentStatusFilter = f;
|
|
711
|
+
localStorage.setItem('taskStatusFilter', f);
|
|
712
|
+
renderStatusFilterTabs();
|
|
713
|
+
renderKanban();
|
|
714
|
+
}
|
|
715
|
+
function toggleTestTasks() {
|
|
716
|
+
hideTestTasks = !hideTestTasks;
|
|
717
|
+
localStorage.setItem('hideTestTasks', String(!hideTestTasks));
|
|
718
|
+
renderStatusFilterTabs();
|
|
719
|
+
renderKanban();
|
|
720
|
+
// Update task count
|
|
721
|
+
const openCount = getVisibleTasks().filter(t => t.status !== 'done').length;
|
|
722
|
+
document.getElementById('task-count').textContent = currentStatusFilter === 'open'
|
|
723
|
+
? openCount + ' open tasks'
|
|
724
|
+
: getVisibleTasks().length + ' tasks';
|
|
725
|
+
}
|
|
726
|
+
function isTestTask(t) {
|
|
727
|
+
const title = (t.title || '').trim();
|
|
728
|
+
if (/^TEST[:\s]/i.test(title)) return true;
|
|
729
|
+
if (/^tmp\b/i.test(title)) return true;
|
|
730
|
+
const mtype = (t.metadata?.type || '').toLowerCase();
|
|
731
|
+
if (mtype === 'test' || mtype === 'synthetic') return true;
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
function getVisibleTasks() {
|
|
735
|
+
return hideTestTasks ? allTasks.filter(t => !isTestTask(t)) : allTasks;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function renderStatusFilterTabs() {
|
|
739
|
+
const container = document.getElementById('status-filter-tabs');
|
|
740
|
+
if (!container) return;
|
|
741
|
+
const options = [
|
|
742
|
+
{ key: 'open', label: '🟢 Open', title: 'Todo, Doing, Blocked, Validating' },
|
|
743
|
+
{ key: 'all', label: '📋 All', title: 'All statuses including Done' },
|
|
744
|
+
];
|
|
745
|
+
const testCount = allTasks.filter(t => isTestTask(t)).length;
|
|
746
|
+
const testToggle = testCount > 0
|
|
747
|
+
? ` <button class="project-tab ${hideTestTasks ? '' : 'active'}" title="${hideTestTasks ? 'Show' : 'Hide'} ${testCount} test/synthetic tasks" onclick="toggleTestTasks()" style="font-size:10px;opacity:${hideTestTasks ? '0.6' : '1'}">🧪 ${hideTestTasks ? 'Show' : 'Hide'} test (${testCount})</button>`
|
|
748
|
+
: '';
|
|
749
|
+
container.innerHTML = options.map(o =>
|
|
750
|
+
`<button class="project-tab ${currentStatusFilter === o.key ? 'active' : ''}" title="${o.title}" onclick="switchStatusFilter('${o.key}')">${o.label}</button>`
|
|
751
|
+
).join('') + testToggle;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function renderKanban() {
|
|
755
|
+
const visible = getVisibleTasks();
|
|
756
|
+
const filtered = currentProject === 'all' ? visible : visible.filter(t => classifyProject(t) === currentProject);
|
|
757
|
+
const cols = currentStatusFilter === 'open'
|
|
758
|
+
? ['todo', 'doing', 'blocked', 'validating']
|
|
759
|
+
: ['todo', 'doing', 'blocked', 'validating', 'done'];
|
|
760
|
+
const grouped = {}; cols.forEach(c => grouped[c] = []);
|
|
761
|
+
filtered.forEach(t => { const s = t.status || 'todo'; if (grouped[s]) grouped[s].push(t); else grouped['todo'].push(t); });
|
|
762
|
+
const pOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
763
|
+
cols.forEach(c => grouped[c].sort((a, b) => (pOrder[a.priority] ?? 9) - (pOrder[b.priority] ?? 9)));
|
|
764
|
+
|
|
765
|
+
const kanban = document.getElementById('kanban');
|
|
766
|
+
kanban.innerHTML = cols.map(col => {
|
|
767
|
+
const items = grouped[col];
|
|
768
|
+
const isDone = col === 'done';
|
|
769
|
+
const shown = isDone ? items.slice(0, 3) : items;
|
|
770
|
+
const cards = shown.length === 0
|
|
771
|
+
? '<div class="empty">—</div>'
|
|
772
|
+
: shown.map(t => {
|
|
773
|
+
const assigneeAgent = t.assignee ? AGENTS.find(a => a.name === t.assignee) : null;
|
|
774
|
+
const assigneeDisplay = t.assignee
|
|
775
|
+
? `<span class="assignee-tag">👤 ${esc(t.assignee)}${assigneeAgent ? ' <span class="role-small">' + esc(assigneeAgent.role) + '</span>' : ''}</span>`
|
|
776
|
+
: '<span class="assignee-tag" style="color:var(--yellow)">unassigned</span>';
|
|
777
|
+
const branchDisplay = t.metadata?.branch && t.status === 'doing'
|
|
778
|
+
? `<div style="margin-top:4px"><span class="assignee-tag" style="font-family:monospace;font-size:10px;color:var(--accent)">🌿 ${esc(t.metadata.branch)}</span></div>`
|
|
779
|
+
: '';
|
|
780
|
+
return `
|
|
781
|
+
<div class="task-card" data-task-id="${t.id}">
|
|
782
|
+
<div class="task-title">${esc(truncate(t.title, 60))}</div>
|
|
783
|
+
<div class="task-meta">
|
|
784
|
+
${t.priority ? '<span class="priority-badge ' + t.priority + '">' + t.priority + '</span>' : ''}
|
|
785
|
+
${assigneeDisplay}
|
|
786
|
+
${(t.commentCount || 0) > 0 ? '<span class="assignee-tag">💬 ' + t.commentCount + '</span>' : ''}
|
|
787
|
+
${renderTaskTags(t.tags)}
|
|
788
|
+
</div>
|
|
789
|
+
${branchDisplay}
|
|
790
|
+
${renderBlockedByLinks(t, { compact: true })}
|
|
791
|
+
${renderStatusContractWarning(t)}
|
|
792
|
+
${renderLaneTransitionMeta(t)}
|
|
793
|
+
${renderQaContract(t)}
|
|
794
|
+
</div>`;
|
|
795
|
+
}).join('');
|
|
796
|
+
const extra = isDone && items.length > 3
|
|
797
|
+
? `<button class="done-toggle" onclick="this.parentElement.querySelectorAll('.task-card.hidden').forEach(c=>c.classList.remove('hidden'));this.remove()">+ ${items.length - 3} more</button>` : '';
|
|
798
|
+
return `<div class="kanban-col" data-status="${col}">
|
|
799
|
+
<div class="kanban-col-header">${col} <span class="cnt">${items.length}</span></div>
|
|
800
|
+
${cards}${extra}
|
|
801
|
+
</div>`;
|
|
802
|
+
}).join('');
|
|
803
|
+
|
|
804
|
+
// Add click/touch handlers for task cards (mobile-friendly)
|
|
805
|
+
setTimeout(() => {
|
|
806
|
+
document.querySelectorAll('.task-card').forEach(card => {
|
|
807
|
+
const taskId = card.getAttribute('data-task-id');
|
|
808
|
+
if (taskId) {
|
|
809
|
+
card.addEventListener('click', (e) => {
|
|
810
|
+
e.preventDefault();
|
|
811
|
+
e.stopPropagation();
|
|
812
|
+
openTaskModal(taskId);
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
}, 0);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ---- Backlog (Available Work) ----
|
|
820
|
+
function renderBacklog() {
|
|
821
|
+
const panel = document.getElementById('backlog-panel');
|
|
822
|
+
const body = document.getElementById('backlog-body');
|
|
823
|
+
const count = document.getElementById('backlog-count');
|
|
824
|
+
if (!body || !panel) return;
|
|
825
|
+
|
|
826
|
+
const pOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
827
|
+
const backlog = getVisibleTasks()
|
|
828
|
+
.filter(t => t.status === 'todo' && !t.assignee)
|
|
829
|
+
.sort((a, b) => {
|
|
830
|
+
const pa = pOrder[a.priority] ?? 9;
|
|
831
|
+
const pb = pOrder[b.priority] ?? 9;
|
|
832
|
+
if (pa !== pb) return pa - pb;
|
|
833
|
+
return a.createdAt - b.createdAt;
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
if (backlog.length === 0) {
|
|
837
|
+
panel.style.display = 'none';
|
|
838
|
+
if (count) count.textContent = '0 items';
|
|
839
|
+
body.innerHTML = '';
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
panel.style.display = '';
|
|
844
|
+
if (count) count.textContent = backlog.length + ' items';
|
|
845
|
+
|
|
846
|
+
body.innerHTML = backlog.map(t => {
|
|
847
|
+
const criteriaList = Array.isArray(t.done_criteria) ? t.done_criteria : [];
|
|
848
|
+
const criteriaCount = criteriaList.length;
|
|
849
|
+
const criteriaPreview = criteriaCount > 0 ? esc(truncate(criteriaList[0], 72)) : 'No done criteria listed';
|
|
850
|
+
|
|
851
|
+
return `<div class="backlog-item" role="button" tabindex="0" style="padding:10px 14px;border-bottom:1px solid var(--border-subtle);cursor:pointer" onclick="openTaskModal('${t.id}')">
|
|
852
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
|
853
|
+
${t.priority ? '<span class="priority-badge ' + t.priority + '">' + t.priority + '</span>' : ''}
|
|
854
|
+
<span style="color:var(--text-bright);font-size:13px;font-weight:500">${esc(truncate(t.title, 70))}</span>
|
|
855
|
+
</div>
|
|
856
|
+
<div style="font-size:11px;color:var(--text-muted)">
|
|
857
|
+
${criteriaCount} done criteria${t.reviewer ? ' · reviewer: ' + esc(t.reviewer) : ''}${(t.commentCount || 0) > 0 ? ' · 💬 ' + t.commentCount : ''}
|
|
858
|
+
</div>
|
|
859
|
+
${Array.isArray(t.tags) && t.tags.length > 0 ? `<div style="margin-top:4px;display:flex;flex-wrap:wrap;gap:6px">${renderTaskTags(t.tags)}</div>` : ''}
|
|
860
|
+
${renderStatusContractWarning(t)}
|
|
861
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:6px">
|
|
862
|
+
<div style="font-size:11px;color:var(--text-dim)">↳ ${criteriaPreview}</div>
|
|
863
|
+
<button onclick="claimBacklogTask('${t.id}', event)" style="background:var(--accent);border:0;border-radius:8px;color:white;font-size:11px;padding:4px 9px;cursor:pointer;white-space:nowrap">Claim</button>
|
|
864
|
+
</div>
|
|
865
|
+
</div>`;
|
|
866
|
+
}).join('');
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async function claimBacklogTask(taskId, event) {
|
|
870
|
+
if (event) {
|
|
871
|
+
event.preventDefault();
|
|
872
|
+
event.stopPropagation();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const defaultAgent = localStorage.getItem('reflectt-dashboard-agent') || 'scout';
|
|
876
|
+
const agent = (window.prompt('Claim this task as which agent?', defaultAgent) || '').trim().toLowerCase();
|
|
877
|
+
if (!agent) return;
|
|
878
|
+
|
|
879
|
+
localStorage.setItem('reflectt-dashboard-agent', agent);
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
const r = await fetch(`${BASE}/tasks/${taskId}/claim`, {
|
|
883
|
+
method: 'POST',
|
|
884
|
+
headers: { 'Content-Type': 'application/json' },
|
|
885
|
+
body: JSON.stringify({ agent }),
|
|
886
|
+
});
|
|
887
|
+
const d = await r.json();
|
|
888
|
+
if (!d.success) {
|
|
889
|
+
alert(d.error || 'Failed to claim task');
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
await loadTasks(true);
|
|
893
|
+
} catch (err) {
|
|
894
|
+
console.error('Claim failed:', err);
|
|
895
|
+
alert('Failed to claim task');
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function resolveOutcomeImpact(task) {
|
|
900
|
+
const priority = String(task?.priority || 'P3').toUpperCase();
|
|
901
|
+
const outcome = task?.metadata?.outcome_checkpoint || {};
|
|
902
|
+
const verdict = String(outcome.verdict || '').toUpperCase();
|
|
903
|
+
|
|
904
|
+
if (verdict === 'FAIL' || verdict === 'BLOCKED' || priority === 'P0') return 'high';
|
|
905
|
+
if (priority === 'P1' || priority === 'P2') return 'medium';
|
|
906
|
+
return 'low';
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function taskHasShippedProof(task) {
|
|
910
|
+
const metadata = task?.metadata || {};
|
|
911
|
+
const artifacts = Array.isArray(metadata.artifacts) ? metadata.artifacts : [];
|
|
912
|
+
const qaBundle = metadata.qa_bundle;
|
|
913
|
+
return artifacts.length > 0 || Boolean(metadata.artifact_path) || Boolean(qaBundle);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function renderOutcomeFeed() {
|
|
917
|
+
const body = document.getElementById('outcome-body');
|
|
918
|
+
const count = document.getElementById('outcome-count');
|
|
919
|
+
if (!body || !count) return;
|
|
920
|
+
|
|
921
|
+
const shippedDone = allTasks
|
|
922
|
+
.filter(task => task.status === 'done' && taskHasShippedProof(task))
|
|
923
|
+
.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
|
|
924
|
+
|
|
925
|
+
count.textContent = shippedDone.length + ' shipped';
|
|
926
|
+
|
|
927
|
+
if (shippedDone.length === 0) {
|
|
928
|
+
body.innerHTML = '<div class="empty">No shipped outcomes yet</div>';
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const rollup = { high: 0, medium: 0, low: 0 };
|
|
933
|
+
shippedDone.forEach(task => {
|
|
934
|
+
const impact = resolveOutcomeImpact(task);
|
|
935
|
+
rollup[impact] += 1;
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
const itemsHtml = shippedDone.slice(0, 8).map(task => {
|
|
939
|
+
const impact = resolveOutcomeImpact(task);
|
|
940
|
+
const outcome = task?.metadata?.outcome_checkpoint || {};
|
|
941
|
+
const verdict = outcome.verdict ? String(outcome.verdict).toUpperCase() : 'N/A';
|
|
942
|
+
const artifactPath = task?.metadata?.artifact_path;
|
|
943
|
+
const artifactLink = typeof artifactPath === 'string' && artifactPath.startsWith('http')
|
|
944
|
+
? `<a class="ssot-link" href="${esc(artifactPath)}" target="_blank" rel="noreferrer noopener">artifact</a>`
|
|
945
|
+
: (artifactPath ? `<span>${esc(truncate(String(artifactPath), 42))}</span>` : '<span>no artifact link</span>');
|
|
946
|
+
|
|
947
|
+
return `<div class="outcome-item">
|
|
948
|
+
<div class="outcome-item-title">${esc(truncate(task.title || task.id, 78))}</div>
|
|
949
|
+
<div class="outcome-item-meta">
|
|
950
|
+
<span class="outcome-impact-pill ${impact}">${impact}</span>
|
|
951
|
+
<span>${esc(task.priority || 'P3')}</span>
|
|
952
|
+
<span>verdict: ${esc(verdict)}</span>
|
|
953
|
+
<span>by @${esc(task.assignee || 'unknown')}</span>
|
|
954
|
+
<span>${ago(task.updatedAt || task.createdAt || Date.now())} ago</span>
|
|
955
|
+
</div>
|
|
956
|
+
<div class="outcome-item-meta">${artifactLink}</div>
|
|
957
|
+
</div>`;
|
|
958
|
+
}).join('');
|
|
959
|
+
|
|
960
|
+
body.innerHTML = `
|
|
961
|
+
<div class="outcome-rollup">
|
|
962
|
+
<div class="outcome-rollup-card high"><div class="label">high impact</div><div class="value">${rollup.high}</div></div>
|
|
963
|
+
<div class="outcome-rollup-card medium"><div class="label">medium impact</div><div class="value">${rollup.medium}</div></div>
|
|
964
|
+
<div class="outcome-rollup-card low"><div class="label">low impact</div><div class="value">${rollup.low}</div></div>
|
|
965
|
+
</div>
|
|
966
|
+
${itemsHtml}
|
|
967
|
+
`;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ---- Chat ----
|
|
971
|
+
async function loadChat(forceFull = false) {
|
|
972
|
+
try {
|
|
973
|
+
const qs = new URLSearchParams();
|
|
974
|
+
qs.set('limit', '80');
|
|
975
|
+
if (!forceFull && lastChatSync > 0) qs.set('since', String(lastChatSync));
|
|
976
|
+
|
|
977
|
+
const r = await fetch(BASE + '/chat/messages?' + qs.toString());
|
|
978
|
+
const d = await r.json();
|
|
979
|
+
const incoming = d.messages || [];
|
|
980
|
+
|
|
981
|
+
if (!forceFull && lastChatSync > 0 && incoming.length === 0) {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (!forceFull && lastChatSync > 0) {
|
|
986
|
+
const byId = new Map(allMessages.map(m => [m.id, m]));
|
|
987
|
+
incoming.forEach(m => byId.set(m.id, m));
|
|
988
|
+
allMessages = Array.from(byId.values())
|
|
989
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
990
|
+
.slice(0, 200);
|
|
991
|
+
} else {
|
|
992
|
+
allMessages = incoming.sort((a, b) => b.timestamp - a.timestamp);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const maxTs = incoming.reduce((max, m) => Math.max(max, m.timestamp || 0), 0);
|
|
996
|
+
if (maxTs > 0) lastChatSync = Math.max(lastChatSync, maxTs);
|
|
997
|
+
} catch (e) {
|
|
998
|
+
if (!allMessages.length) allMessages = [];
|
|
999
|
+
}
|
|
1000
|
+
const channels = new Set(['all']);
|
|
1001
|
+
allMessages.forEach(m => { if (m.channel) channels.add(m.channel); });
|
|
1002
|
+
|
|
1003
|
+
const channelStats = new Map();
|
|
1004
|
+
allMessages.forEach(m => {
|
|
1005
|
+
const ch = m.channel || 'general';
|
|
1006
|
+
if (!channelStats.has(ch)) channelStats.set(ch, { total: 0, mentions: 0 });
|
|
1007
|
+
const stats = channelStats.get(ch);
|
|
1008
|
+
stats.total += 1;
|
|
1009
|
+
if (mentionsRyan(m.content)) stats.mentions += 1;
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
const tabs = document.getElementById('channel-tabs');
|
|
1013
|
+
tabs.innerHTML = Array.from(channels).map(ch => {
|
|
1014
|
+
const stats = ch === 'all'
|
|
1015
|
+
? { total: allMessages.length, mentions: allMessages.filter(m => mentionsRyan(m.content)).length }
|
|
1016
|
+
: (channelStats.get(ch) || { total: 0, mentions: 0 });
|
|
1017
|
+
const label = ch === 'all' ? '🌐 all' : '#' + esc(ch);
|
|
1018
|
+
const countMeta = `<span class="meta">${stats.total}</span>`;
|
|
1019
|
+
const mentionDot = (stats.mentions > 0 && ch !== 'all') ? '<span class="mention-dot" title="mentions"></span>' : '';
|
|
1020
|
+
return `<button class="channel-tab ${ch === currentChannel ? 'active' : ''}" data-channel="${esc(ch)}" onclick="switchChannel('${ch}')">${label}${countMeta}${mentionDot}</button>`;
|
|
1021
|
+
}).join('');
|
|
1022
|
+
renderChat();
|
|
1023
|
+
}
|
|
1024
|
+
function switchChannel(ch) {
|
|
1025
|
+
currentChannel = ch;
|
|
1026
|
+
const sendChannel = document.getElementById('chat-channel');
|
|
1027
|
+
if (sendChannel && ch !== 'all' && Array.from(sendChannel.options).some(o => o.value === ch)) {
|
|
1028
|
+
sendChannel.value = ch;
|
|
1029
|
+
}
|
|
1030
|
+
document.querySelectorAll('.channel-tab').forEach(t => {
|
|
1031
|
+
const normalized = t.getAttribute('data-channel') || '';
|
|
1032
|
+
t.classList.toggle('active', normalized === ch);
|
|
1033
|
+
});
|
|
1034
|
+
renderChat();
|
|
1035
|
+
}
|
|
1036
|
+
function renderChat() {
|
|
1037
|
+
const filtered = currentChannel === 'all' ? allMessages : allMessages.filter(m => m.channel === currentChannel);
|
|
1038
|
+
const shown = filtered.slice(0, 40);
|
|
1039
|
+
document.getElementById('chat-count').textContent = filtered.length + ' messages';
|
|
1040
|
+
const body = document.getElementById('chat-body');
|
|
1041
|
+
initChatInteractions();
|
|
1042
|
+
if (shown.length === 0) { body.innerHTML = '<div class="empty">No messages</div>'; return; }
|
|
1043
|
+
body.innerHTML = shown.map(m => {
|
|
1044
|
+
const agent = AGENT_INDEX.get(m.from);
|
|
1045
|
+
const roleTag = agent ? `<span class="msg-role">${esc(agent.role)}</span>` : '';
|
|
1046
|
+
const mentioned = mentionsRyan(m.content);
|
|
1047
|
+
const channelTag = m.channel ? '<span class="msg-channel">#' + esc(m.channel) + '</span>' : '';
|
|
1048
|
+
const editedTag = m.metadata && m.metadata.editedAt ? '<span class="msg-edited">(edited)</span>' : '';
|
|
1049
|
+
return `
|
|
1050
|
+
<div class="msg ${mentioned ? 'mentioned' : ''}">
|
|
1051
|
+
<div class="msg-header">
|
|
1052
|
+
<span class="msg-from">${esc(agentLabel(m.from))}</span>
|
|
1053
|
+
${roleTag}
|
|
1054
|
+
${channelTag}
|
|
1055
|
+
<span class="msg-time">${ago(m.timestamp)}</span>
|
|
1056
|
+
${editedTag}
|
|
1057
|
+
</div>
|
|
1058
|
+
<div class="msg-content">${renderMessageContentWithTaskLinks(m.content)}</div>
|
|
1059
|
+
${m.attachments && m.attachments.length ? '<div class="msg-attachments">' + m.attachments.map(a => {
|
|
1060
|
+
const isImage = a.mimeType && a.mimeType.startsWith('image/');
|
|
1061
|
+
const url = a.url || ('/files/' + a.fileId);
|
|
1062
|
+
const name = a.name || a.fileId || 'file';
|
|
1063
|
+
const size = a.size ? ' <span class="att-size">(' + formatBytes(a.size) + ')</span>' : '';
|
|
1064
|
+
if (isImage) return '<a href="' + esc(url) + '" target="_blank" class="msg-att-img"><img src="' + esc(url) + '" alt="' + esc(name) + '" loading="lazy"></a>';
|
|
1065
|
+
return '<a href="' + esc(url) + '" target="_blank" class="msg-att-file">📎 ' + esc(name) + size + '</a>';
|
|
1066
|
+
}).join('') + '</div>' : ''}
|
|
1067
|
+
</div>`;
|
|
1068
|
+
}).join('');
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// ---- Send chat message ----
|
|
1072
|
+
async function sendChat() {
|
|
1073
|
+
const input = document.getElementById('chat-input');
|
|
1074
|
+
const channel = document.getElementById('chat-channel').value;
|
|
1075
|
+
const btn = document.getElementById('chat-send');
|
|
1076
|
+
const content = input.value.trim();
|
|
1077
|
+
const hasAttachments = typeof _pendingChatAttachments !== 'undefined' && _pendingChatAttachments.length > 0;
|
|
1078
|
+
if (!content && !hasAttachments) return;
|
|
1079
|
+
|
|
1080
|
+
btn.disabled = true;
|
|
1081
|
+
try {
|
|
1082
|
+
// Upload pending attachments first
|
|
1083
|
+
const attachments = [];
|
|
1084
|
+
if (hasAttachments) {
|
|
1085
|
+
for (const file of _pendingChatAttachments) {
|
|
1086
|
+
const fd = new FormData();
|
|
1087
|
+
fd.append('file', file);
|
|
1088
|
+
fd.append('uploadedBy', 'ryan');
|
|
1089
|
+
try {
|
|
1090
|
+
const res = await fetch('/files', { method: 'POST', body: fd });
|
|
1091
|
+
const data = await res.json();
|
|
1092
|
+
if (data.success && data.file) {
|
|
1093
|
+
attachments.push({
|
|
1094
|
+
id: data.file.id,
|
|
1095
|
+
name: data.file.originalName || data.file.filename,
|
|
1096
|
+
size: data.file.size,
|
|
1097
|
+
mimeType: data.file.mimeType,
|
|
1098
|
+
url: '/files/' + data.file.id,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
} catch (e) { console.error('Attachment upload failed:', e); }
|
|
1102
|
+
}
|
|
1103
|
+
_pendingChatAttachments = [];
|
|
1104
|
+
if (typeof renderChatAttachments === 'function') renderChatAttachments();
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Build message with attachments
|
|
1108
|
+
const payload = { from: 'ryan', content: content || '', channel };
|
|
1109
|
+
if (attachments.length) payload.attachments = attachments;
|
|
1110
|
+
|
|
1111
|
+
await fetch(BASE + '/chat/messages', {
|
|
1112
|
+
method: 'POST',
|
|
1113
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1114
|
+
body: JSON.stringify(payload),
|
|
1115
|
+
});
|
|
1116
|
+
input.value = '';
|
|
1117
|
+
await loadChat(true);
|
|
1118
|
+
} catch (e) { console.error('Send error:', e); }
|
|
1119
|
+
btn.disabled = false;
|
|
1120
|
+
input.focus();
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Enter key sends + quick channel switching
|
|
1124
|
+
function rotateChannel(direction) {
|
|
1125
|
+
const tabs = Array.from(document.querySelectorAll('.channel-tab'));
|
|
1126
|
+
if (!tabs.length) return;
|
|
1127
|
+
const channels = tabs.map(t => t.getAttribute('data-channel') || '').filter(Boolean);
|
|
1128
|
+
const currentIndex = Math.max(0, channels.indexOf(currentChannel));
|
|
1129
|
+
const nextIndex = (currentIndex + direction + channels.length) % channels.length;
|
|
1130
|
+
switchChannel(channels[nextIndex]);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1134
|
+
document.getElementById('chat-input').addEventListener('keydown', e => {
|
|
1135
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
document.addEventListener('keydown', e => {
|
|
1139
|
+
if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); rotateChannel(1); }
|
|
1140
|
+
if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); rotateChannel(-1); }
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// ---- Activity ----
|
|
1145
|
+
async function loadActivity(forceFull = false) {
|
|
1146
|
+
try {
|
|
1147
|
+
const qs = new URLSearchParams();
|
|
1148
|
+
qs.set('limit', '25');
|
|
1149
|
+
if (!forceFull && lastActivitySync > 0) qs.set('since', String(lastActivitySync));
|
|
1150
|
+
|
|
1151
|
+
const r = await fetch(BASE + '/activity?' + qs.toString());
|
|
1152
|
+
const d = await r.json();
|
|
1153
|
+
const incoming = d.events || [];
|
|
1154
|
+
|
|
1155
|
+
if (!forceFull && lastActivitySync > 0 && incoming.length === 0) {
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (!forceFull && lastActivitySync > 0) {
|
|
1160
|
+
const seen = new Set(allEvents.map(e => e.id));
|
|
1161
|
+
const merged = [...incoming.filter(e => !seen.has(e.id)), ...allEvents];
|
|
1162
|
+
allEvents = merged.sort((a, b) => b.timestamp - a.timestamp).slice(0, 120);
|
|
1163
|
+
} else {
|
|
1164
|
+
allEvents = incoming;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const maxTs = incoming.reduce((max, e) => Math.max(max, e.timestamp || 0), 0);
|
|
1168
|
+
if (maxTs > 0) lastActivitySync = Math.max(lastActivitySync, maxTs);
|
|
1169
|
+
|
|
1170
|
+
document.getElementById('activity-count').textContent = allEvents.length + ' events';
|
|
1171
|
+
const body = document.getElementById('activity-body');
|
|
1172
|
+
if (allEvents.length === 0) { body.innerHTML = '<div class="empty">No recent activity</div>'; return; }
|
|
1173
|
+
body.innerHTML = allEvents.slice(0, 20).map(e => `
|
|
1174
|
+
<div class="event-row">
|
|
1175
|
+
<span class="event-type">${esc(e.type || 'event')}</span>
|
|
1176
|
+
${e.agent ? '<span class="event-agent">' + esc(e.agent) + '</span>' : ''}
|
|
1177
|
+
<span class="event-desc">${esc(truncate(e.summary || e.description || '', 60))}</span>
|
|
1178
|
+
<span class="event-time">${ago(e.timestamp)}</span>
|
|
1179
|
+
</div>`).join('');
|
|
1180
|
+
} catch (e) {}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function getSlaBadge(dueAt, status) {
|
|
1184
|
+
if (!dueAt || status === 'answered' || status === 'archived') return '<span class="assignee-tag">no SLA</span>';
|
|
1185
|
+
const ms = dueAt - Date.now();
|
|
1186
|
+
if (ms <= 0) return '<span class="assignee-tag" style="color:var(--red)">overdue</span>';
|
|
1187
|
+
const hours = Math.ceil(ms / (60 * 60 * 1000));
|
|
1188
|
+
if (hours <= 24) return `<span class="assignee-tag" style="color:var(--yellow)">${hours}h left</span>`;
|
|
1189
|
+
const days = Math.ceil(hours / 24);
|
|
1190
|
+
return `<span class="assignee-tag" style="color:var(--green)">${days}d left</span>`;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// ---- Research Intake ----
|
|
1194
|
+
async function loadResearch() {
|
|
1195
|
+
try {
|
|
1196
|
+
const [reqRes, findingRes] = await Promise.all([
|
|
1197
|
+
fetch(BASE + '/research/requests?limit=12'),
|
|
1198
|
+
fetch(BASE + '/research/findings?limit=20'),
|
|
1199
|
+
]);
|
|
1200
|
+
|
|
1201
|
+
const reqData = await reqRes.json();
|
|
1202
|
+
const findingData = await findingRes.json();
|
|
1203
|
+
|
|
1204
|
+
const requests = reqData.requests || [];
|
|
1205
|
+
const findings = findingData.findings || [];
|
|
1206
|
+
const findingMap = new Map();
|
|
1207
|
+
findings.forEach(f => {
|
|
1208
|
+
findingMap.set(f.requestId, (findingMap.get(f.requestId) || 0) + 1);
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
const body = document.getElementById('research-body');
|
|
1212
|
+
const count = document.getElementById('research-count');
|
|
1213
|
+
if (!body || !count) return;
|
|
1214
|
+
|
|
1215
|
+
count.textContent = requests.length + ' requests';
|
|
1216
|
+
|
|
1217
|
+
if (requests.length === 0) {
|
|
1218
|
+
body.innerHTML = '<div class="empty">No research requests yet</div>';
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
body.innerHTML = requests.map(r => {
|
|
1223
|
+
const q = esc(truncate(r.question || '', 88));
|
|
1224
|
+
const findingCount = findingMap.get(r.id) || 0;
|
|
1225
|
+
const sla = getSlaBadge(r.dueAt, r.status);
|
|
1226
|
+
return `<div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
|
|
1227
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
|
|
1228
|
+
<div style="font-size:13px;color:var(--text-bright);font-weight:500">${esc(truncate(r.title || 'Untitled request', 58))}</div>
|
|
1229
|
+
${sla}
|
|
1230
|
+
</div>
|
|
1231
|
+
<div style="font-size:11px;color:var(--text-muted);margin-top:3px">${q}</div>
|
|
1232
|
+
<div style="font-size:11px;color:var(--text-dim);margin-top:5px">
|
|
1233
|
+
${r.category ? '#' + esc(r.category) + ' · ' : ''}${r.owner ? 'owner: ' + esc(r.owner) + ' · ' : ''}status: ${esc(r.status || 'open')} · findings: ${findingCount}
|
|
1234
|
+
</div>
|
|
1235
|
+
</div>`;
|
|
1236
|
+
}).join('');
|
|
1237
|
+
} catch (e) {
|
|
1238
|
+
const body = document.getElementById('research-body');
|
|
1239
|
+
if (body) body.innerHTML = '<div class="empty">Failed to load research requests</div>';
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// ---- Shared Artifacts (shared workspace: process/) ----
|
|
1244
|
+
async function loadSharedArtifacts() {
|
|
1245
|
+
const body = document.getElementById('shared-artifacts-body');
|
|
1246
|
+
const count = document.getElementById('shared-artifacts-count');
|
|
1247
|
+
if (!body || !count) return;
|
|
1248
|
+
|
|
1249
|
+
const fmtBytes = (n) => {
|
|
1250
|
+
if (typeof n !== 'number' || !isFinite(n)) return '';
|
|
1251
|
+
if (n < 1024) return n + ' B';
|
|
1252
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
1253
|
+
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
try {
|
|
1257
|
+
const res = await fetch(BASE + '/shared/list?path=process/&limit=80');
|
|
1258
|
+
const data = await res.json();
|
|
1259
|
+
|
|
1260
|
+
if (!data || data.success !== true) {
|
|
1261
|
+
count.textContent = 'unavailable';
|
|
1262
|
+
const msg = esc((data && data.error) ? data.error : 'Shared workspace not available');
|
|
1263
|
+
body.innerHTML = `<div class="empty" style="color:var(--text-muted)">${msg}</div>`;
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const entries = Array.isArray(data.entries) ? data.entries : [];
|
|
1268
|
+
const files = entries.filter(e => e && e.type === 'file');
|
|
1269
|
+
count.textContent = files.length + ' files';
|
|
1270
|
+
|
|
1271
|
+
// Pinned: Ryan's thoughts (if symlinked into shared workspace process/)
|
|
1272
|
+
const pinned = [
|
|
1273
|
+
{ name: "RYANS-THOUGHTS.md", label: "Ryan's thoughts" },
|
|
1274
|
+
];
|
|
1275
|
+
|
|
1276
|
+
const pinnedRows = pinned.map(p => {
|
|
1277
|
+
const found = files.find(f => (String(f.name || '')).toLowerCase() === p.name.toLowerCase());
|
|
1278
|
+
if (!found) {
|
|
1279
|
+
return `<div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
|
|
1280
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
|
|
1281
|
+
<div style="font-size:13px;color:var(--text-bright);font-weight:600">${esc(p.label)}</div>
|
|
1282
|
+
<span class="assignee-tag" style="color:var(--yellow)">missing</span>
|
|
1283
|
+
</div>
|
|
1284
|
+
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">
|
|
1285
|
+
To make this available here, create a symlink in the shared workspace:<br/>
|
|
1286
|
+
<code>ln -s ../RYANS-THOUGHTS.md ~/.openclaw/workspace-shared/process/RYANS-THOUGHTS.md</code>
|
|
1287
|
+
</div>
|
|
1288
|
+
</div>`;
|
|
1289
|
+
}
|
|
1290
|
+
const href = '/shared/view?path=' + encodeURIComponent(found.path);
|
|
1291
|
+
return `<div style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
|
|
1292
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
|
|
1293
|
+
<a href="${href}" target="_blank" style="font-size:13px;color:var(--accent);font-weight:600;text-decoration:none">${esc(p.label)} ↗</a>
|
|
1294
|
+
<span class="assignee-tag" style="color:var(--green)">ready</span>
|
|
1295
|
+
</div>
|
|
1296
|
+
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">path: <code>${esc(found.path)}</code></div>
|
|
1297
|
+
</div>`;
|
|
1298
|
+
}).join('');
|
|
1299
|
+
|
|
1300
|
+
// Listing: show most relevant files first (TASK- docs later)
|
|
1301
|
+
const nonTask = files.filter(f => !(String(f.name || '').startsWith('TASK-')));
|
|
1302
|
+
const taskDocs = files.filter(f => (String(f.name || '').startsWith('TASK-')));
|
|
1303
|
+
const ordered = [...nonTask, ...taskDocs].slice(0, 40);
|
|
1304
|
+
|
|
1305
|
+
const listRows = ordered.map(e => {
|
|
1306
|
+
const href = '/shared/view?path=' + encodeURIComponent(e.path);
|
|
1307
|
+
const size = fmtBytes(e.size);
|
|
1308
|
+
return `<div class="backlog-item" style="padding:10px 12px;border-bottom:1px solid var(--border-subtle)">
|
|
1309
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
|
|
1310
|
+
<a href="${href}" target="_blank" style="color:var(--text-bright);text-decoration:none;font-size:12px">${esc(truncate(e.name || e.path, 70))}</a>
|
|
1311
|
+
<span style="font-size:11px;color:var(--text-muted)">${esc(size)}</span>
|
|
1312
|
+
</div>
|
|
1313
|
+
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${esc(e.path)}</div>
|
|
1314
|
+
</div>`;
|
|
1315
|
+
}).join('');
|
|
1316
|
+
|
|
1317
|
+
body.innerHTML = `
|
|
1318
|
+
<div style="border:1px solid var(--border-subtle);border-radius:8px;overflow:hidden">${pinnedRows}</div>
|
|
1319
|
+
<div style="height:10px"></div>
|
|
1320
|
+
<div style="font-size:11px;color:var(--text-muted);margin:0 0 8px">Shared workspace directory: <code>process/</code></div>
|
|
1321
|
+
<div style="border:1px solid var(--border-subtle);border-radius:8px;overflow:hidden">${listRows}</div>
|
|
1322
|
+
`;
|
|
1323
|
+
} catch (e) {
|
|
1324
|
+
count.textContent = 'error';
|
|
1325
|
+
body.innerHTML = '<div class="empty" style="color:var(--red)">Failed to load shared artifacts</div>';
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// ---- Team Health ----
|
|
1330
|
+
async function loadHealth() {
|
|
1331
|
+
try {
|
|
1332
|
+
const now = Date.now();
|
|
1333
|
+
const shouldRefreshDetail = !cachedHealth || (now - lastHealthDetailSync) > 120000;
|
|
1334
|
+
|
|
1335
|
+
if (shouldRefreshDetail) {
|
|
1336
|
+
const [teamRes, agentsRes, idleNudgeRes, workflowRes] = await Promise.all([
|
|
1337
|
+
fetch(BASE + '/health/team'),
|
|
1338
|
+
fetch(BASE + '/health/agents'),
|
|
1339
|
+
fetch(BASE + '/health/idle-nudge/debug'),
|
|
1340
|
+
fetch(BASE + '/health/workflow'),
|
|
1341
|
+
]);
|
|
1342
|
+
const team = await teamRes.json();
|
|
1343
|
+
const agentsSummary = await agentsRes.json();
|
|
1344
|
+
const idleNudgeDebug = await idleNudgeRes.json();
|
|
1345
|
+
const workflow = await workflowRes.json();
|
|
1346
|
+
cachedHealth = { team, agentsSummary, idleNudgeDebug, workflow };
|
|
1347
|
+
lastHealthDetailSync = now;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const health = cachedHealth || { team: { blockers: [], overlaps: [], compliance: null }, agentsSummary: { agents: [] }, idleNudgeDebug: null, workflow: { agents: [] } };
|
|
1351
|
+
|
|
1352
|
+
const team = health.team || { blockers: [], overlaps: [], compliance: null, agents: [] };
|
|
1353
|
+
const agentsSummary = health.agentsSummary || { agents: [] };
|
|
1354
|
+
const idleNudgeDebug = health.idleNudgeDebug || null;
|
|
1355
|
+
|
|
1356
|
+
healthAgentMap = new Map((team.agents || []).map(a => [String(a.agent || '').toLowerCase(), a]));
|
|
1357
|
+
const workflow = health.workflow || { agents: [] };
|
|
1358
|
+
|
|
1359
|
+
const teamAgentsByName = new Map((team.agents || []).map(a => [a.agent, a]));
|
|
1360
|
+
const summaryRows = (agentsSummary.agents && agentsSummary.agents.length > 0)
|
|
1361
|
+
? agentsSummary.agents
|
|
1362
|
+
: (team.agents || []).map(a => ({
|
|
1363
|
+
agent: a.agent,
|
|
1364
|
+
state: a.idleWithActiveTask ? 'stuck' : (a.status === 'active' ? 'healthy' : (a.status === 'offline' ? 'offline' : 'idle')),
|
|
1365
|
+
last_seen: a.lastSeen,
|
|
1366
|
+
heartbeat_age_ms: Math.max(0, a.minutesSinceLastSeen || 0) * 60000,
|
|
1367
|
+
active_task: a.currentTask || null,
|
|
1368
|
+
last_shipped_at: a.lastProductiveAt || null,
|
|
1369
|
+
shipped_age_ms: a.minutesSinceProductive == null ? null : Math.max(0, a.minutesSinceProductive) * 60000,
|
|
1370
|
+
stale_reason: a.idleWithActiveTask ? 'active-task-idle-over-60m' : null,
|
|
1371
|
+
idle_with_active_task: Boolean(a.idleWithActiveTask),
|
|
1372
|
+
}));
|
|
1373
|
+
const agents = summaryRows.map(row => {
|
|
1374
|
+
const fromTeam = teamAgentsByName.get(row.agent) || {};
|
|
1375
|
+
const minutesSinceLastSeen = Math.floor((Number(row.heartbeat_age_ms || 0)) / 60000);
|
|
1376
|
+
const mappedStatus = row.state === 'stuck'
|
|
1377
|
+
? 'blocked'
|
|
1378
|
+
: (row.state === 'healthy' ? 'active' : (row.state === 'idle' ? 'silent' : 'offline'));
|
|
1379
|
+
return {
|
|
1380
|
+
agent: row.agent,
|
|
1381
|
+
status: mappedStatus,
|
|
1382
|
+
lastSeen: Number(row.last_seen || 0),
|
|
1383
|
+
minutesSinceLastSeen,
|
|
1384
|
+
currentTask: row.active_task || fromTeam.currentTask || null,
|
|
1385
|
+
recentBlockers: fromTeam.recentBlockers || [],
|
|
1386
|
+
messageCount24h: fromTeam.messageCount24h || 0,
|
|
1387
|
+
lastProductiveAt: row.last_shipped_at || row.last_productive_at || null,
|
|
1388
|
+
minutesSinceProductive: (row.shipped_age_ms ?? row.productive_age_ms) == null ? null : Math.floor(Number(row.shipped_age_ms ?? row.productive_age_ms) / 60000),
|
|
1389
|
+
staleReason: row.stale_reason || null,
|
|
1390
|
+
idleWithActiveTask: Boolean(row.idle_with_active_task),
|
|
1391
|
+
};
|
|
1392
|
+
});
|
|
1393
|
+
const blockers = team.blockers || [];
|
|
1394
|
+
const overlaps = team.overlaps || [];
|
|
1395
|
+
const compliance = team.compliance || null;
|
|
1396
|
+
|
|
1397
|
+
const statusCounts = { active: 0, idle: 0, silent: 0, blocked: 0, offline: 0, watch: 0 };
|
|
1398
|
+
let stuckActiveCount = 0;
|
|
1399
|
+
const displayAgents = agents.map(a => {
|
|
1400
|
+
const derived = deriveHealthSignal(a);
|
|
1401
|
+
const displayStatus = a.status === 'silent'
|
|
1402
|
+
? (a.minutesSinceLastSeen >= 120 ? 'blocked' : (a.minutesSinceLastSeen >= 60 ? 'silent' : 'watch'))
|
|
1403
|
+
: derived.status;
|
|
1404
|
+
statusCounts[displayStatus] = (statusCounts[displayStatus] || 0) + 1;
|
|
1405
|
+
if (a.idleWithActiveTask) stuckActiveCount += 1;
|
|
1406
|
+
return { ...a, displayStatus, lowConfidence: derived.lowConfidence };
|
|
1407
|
+
}).sort((a, b) => {
|
|
1408
|
+
const pa = healthPriorityRank(a);
|
|
1409
|
+
const pb = healthPriorityRank(b);
|
|
1410
|
+
if (pa !== pb) return pa - pb;
|
|
1411
|
+
if ((b.minutesSinceLastSeen || 0) !== (a.minutesSinceLastSeen || 0)) {
|
|
1412
|
+
return (b.minutesSinceLastSeen || 0) - (a.minutesSinceLastSeen || 0);
|
|
1413
|
+
}
|
|
1414
|
+
return a.agent.localeCompare(b.agent);
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
const healthSummary = `${statusCounts.active} active • ${statusCounts.watch + statusCounts.silent} quiet • ${statusCounts.blocked} blocked • ${stuckActiveCount} stuck`;
|
|
1418
|
+
document.getElementById('health-count').textContent = healthSummary;
|
|
1419
|
+
|
|
1420
|
+
const body = document.getElementById('health-body');
|
|
1421
|
+
let html = '';
|
|
1422
|
+
|
|
1423
|
+
// Agent Health Grid
|
|
1424
|
+
if (displayAgents.length > 0) {
|
|
1425
|
+
html += '<div class="health-section"><div class="health-section-title">Agent Status</div><div class="health-grid">';
|
|
1426
|
+
html += displayAgents.map(a => {
|
|
1427
|
+
const statusText = a.minutesSinceLastSeen < 1 ? 'just now' : ago(a.lastSeen) + ' ago';
|
|
1428
|
+
const taskDisplay = a.currentTask ? `<div class="health-task">📋 ${esc(truncate(a.currentTask, 35))}</div>` : '';
|
|
1429
|
+
const productiveText = `<div class="health-task">🧾 ${esc(formatProductiveText(a))}</div>`;
|
|
1430
|
+
const statusLabel = a.displayStatus === 'blocked'
|
|
1431
|
+
? ' • 🚫 blocked'
|
|
1432
|
+
: (a.displayStatus === 'silent' ? ' • ⚠️ quiet' : (a.displayStatus === 'watch' ? ' • 👀 watch' : ''));
|
|
1433
|
+
const confidenceLabel = a.lowConfidence ? ' • needs review' : '';
|
|
1434
|
+
const stuckLabel = a.idleWithActiveTask ? ' • ⛔ active-task idle>60m' : '';
|
|
1435
|
+
const staleReasonLabel = a.staleReason ? ' • ' + a.staleReason : '';
|
|
1436
|
+
const hierarchyClass = healthPriorityRank(a) === 0 ? 'health-critical' : (healthPriorityRank(a) === 1 ? 'health-warning' : 'health-info');
|
|
1437
|
+
const isStaleGhost = a.minutesSinceLastSeen > 43200; // >30 days
|
|
1438
|
+
const cardClasses = [
|
|
1439
|
+
'health-card',
|
|
1440
|
+
hierarchyClass,
|
|
1441
|
+
a.lowConfidence ? 'needs-review' : '',
|
|
1442
|
+
a.idleWithActiveTask ? 'stuck-active-task' : '',
|
|
1443
|
+
isStaleGhost ? 'stale-ghost' : '',
|
|
1444
|
+
].filter(Boolean).join(' ');
|
|
1445
|
+
return `
|
|
1446
|
+
<div class="${cardClasses}">
|
|
1447
|
+
<div class="health-indicator ${a.idleWithActiveTask ? 'blocked' : a.displayStatus}"></div>
|
|
1448
|
+
<div class="health-info">
|
|
1449
|
+
<div class="health-name">${esc(a.agent)}</div>
|
|
1450
|
+
<div class="health-status">${statusText}${statusLabel}${confidenceLabel}${stuckLabel}${staleReasonLabel}</div>
|
|
1451
|
+
${taskDisplay}
|
|
1452
|
+
${productiveText}
|
|
1453
|
+
</div>
|
|
1454
|
+
</div>`;
|
|
1455
|
+
}).join('');
|
|
1456
|
+
html += '</div></div>';
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// Blockers
|
|
1460
|
+
if (blockers.length > 0) {
|
|
1461
|
+
html += '<div class="health-section"><div class="health-section-title">🚫 Active Blockers</div>';
|
|
1462
|
+
html += blockers.slice(0, 5).map(b => `
|
|
1463
|
+
<div class="blocker-item">
|
|
1464
|
+
<div class="blocker-agent">${esc(b.agent)}</div>
|
|
1465
|
+
<div class="blocker-text">${esc(b.blocker)}</div>
|
|
1466
|
+
<div class="blocker-meta">Mentioned ${b.mentionCount}x • Last: ${ago(b.lastMentioned)}</div>
|
|
1467
|
+
</div>`).join('');
|
|
1468
|
+
html += '</div>';
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Overlaps
|
|
1472
|
+
if (overlaps.length > 0) {
|
|
1473
|
+
html += '<div class="health-section"><div class="health-section-title">⚠️ Overlapping Work</div>';
|
|
1474
|
+
html += overlaps.slice(0, 3).map(o => `
|
|
1475
|
+
<div class="overlap-item">
|
|
1476
|
+
<div class="overlap-agents">${o.agents.join(', ')}</div>
|
|
1477
|
+
<div class="overlap-topic">${esc(o.topic)} (${o.confidence} confidence)</div>
|
|
1478
|
+
</div>`).join('');
|
|
1479
|
+
html += '</div>';
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Unified workflow state (task + shipped + blocker + PR)
|
|
1483
|
+
if (Array.isArray(workflow.agents) && workflow.agents.length > 0) {
|
|
1484
|
+
html += '<div class="health-section"><div class="health-section-title">🧭 Workflow State</div>';
|
|
1485
|
+
html += workflow.agents.slice(0, 8).map(w => {
|
|
1486
|
+
const taskText = w.doingTaskId ? esc(truncate(w.doingTaskId, 28)) : 'no active task';
|
|
1487
|
+
const taskAge = w.doingTaskAgeMs == null ? 'n/a' : `${Math.floor(Number(w.doingTaskAgeMs) / 60000)}m`;
|
|
1488
|
+
const shipped = w.lastShippedAt ? ago(Number(w.lastShippedAt)) + ' ago' : 'none';
|
|
1489
|
+
const prState = w.prState || 'none';
|
|
1490
|
+
const prText = w.pr ? `<a href="${esc(w.pr)}" target="_blank" rel="noopener">PR</a>` : 'no PR';
|
|
1491
|
+
const blocker = w.blockerActive ? '🚫 blocker' : '✅ clear';
|
|
1492
|
+
return `<div class="blocker-item">
|
|
1493
|
+
<div class="blocker-agent">${esc(w.agent)}</div>
|
|
1494
|
+
<div class="blocker-meta">task: ${taskText} (${taskAge}) • shipped: ${esc(shipped)} • ${blocker}</div>
|
|
1495
|
+
<div class="blocker-meta">pr: ${prText} (${esc(prState)})${w.artifactPath ? ` • artifact: ${esc(truncate(w.artifactPath, 40))}` : ''}</div>
|
|
1496
|
+
</div>`;
|
|
1497
|
+
}).join('');
|
|
1498
|
+
html += '</div>';
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
html += renderIdleNudgeSummary(idleNudgeDebug);
|
|
1502
|
+
|
|
1503
|
+
if (agents.length === 0 && blockers.length === 0 && overlaps.length === 0 && !idleNudgeDebug) {
|
|
1504
|
+
html = '<div class="empty">No health data available</div>';
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
body.innerHTML = html;
|
|
1508
|
+
renderCompliance(compliance);
|
|
1509
|
+
initComplianceInteractions();
|
|
1510
|
+
} catch (e) {
|
|
1511
|
+
console.error('Health load error:', e);
|
|
1512
|
+
document.getElementById('health-body').innerHTML = '<div class="empty">Failed to load health data</div>';
|
|
1513
|
+
document.getElementById('compliance-body').innerHTML = '<div class="empty">Failed to load compliance data</div>';
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
async function loadReleaseStatus(force = false) {
|
|
1518
|
+
const badge = document.getElementById('release-badge');
|
|
1519
|
+
if (!badge) return;
|
|
1520
|
+
|
|
1521
|
+
const now = Date.now();
|
|
1522
|
+
if (!force && (now - lastReleaseStatusSync) < 30000) return;
|
|
1523
|
+
|
|
1524
|
+
try {
|
|
1525
|
+
const r = await fetch(BASE + '/release/status');
|
|
1526
|
+
const status = await r.json();
|
|
1527
|
+
|
|
1528
|
+
const stale = Boolean(status.stale);
|
|
1529
|
+
badge.classList.toggle('stale', stale);
|
|
1530
|
+
badge.classList.toggle('fresh', !stale);
|
|
1531
|
+
badge.textContent = stale ? 'deploy: stale' : 'deploy: in sync';
|
|
1532
|
+
|
|
1533
|
+
const reasons = Array.isArray(status.reasons) ? status.reasons : [];
|
|
1534
|
+
const startupCommit = status.startup && status.startup.commit ? status.startup.commit.slice(0, 8) : 'unknown';
|
|
1535
|
+
const currentCommit = status.current && status.current.commit ? status.current.commit.slice(0, 8) : 'unknown';
|
|
1536
|
+
const reasonText = reasons.length > 0 ? reasons.join('; ') : 'no mismatch detected';
|
|
1537
|
+
badge.title = `startup ${startupCommit} • current ${currentCommit} • ${reasonText}`;
|
|
1538
|
+
|
|
1539
|
+
lastReleaseStatusSync = now;
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
badge.classList.remove('fresh');
|
|
1542
|
+
badge.classList.add('stale');
|
|
1543
|
+
badge.textContent = 'deploy: unknown';
|
|
1544
|
+
badge.title = 'Failed to load deploy status';
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
async function loadBuildInfo() {
|
|
1549
|
+
const badge = document.getElementById('build-badge');
|
|
1550
|
+
if (!badge) return;
|
|
1551
|
+
|
|
1552
|
+
try {
|
|
1553
|
+
const r = await fetch(BASE + '/health/build');
|
|
1554
|
+
const info = await r.json();
|
|
1555
|
+
|
|
1556
|
+
const sha = info.gitShortSha || 'unknown';
|
|
1557
|
+
const branch = info.gitBranch || 'unknown';
|
|
1558
|
+
const uptime = info.uptime || 0;
|
|
1559
|
+
const uptimeStr = uptime < 60 ? `${uptime}s` :
|
|
1560
|
+
uptime < 3600 ? `${Math.floor(uptime / 60)}m` :
|
|
1561
|
+
`${Math.floor(uptime / 3600)}h${Math.floor((uptime % 3600) / 60)}m`;
|
|
1562
|
+
|
|
1563
|
+
badge.classList.toggle('fresh', branch === 'main');
|
|
1564
|
+
badge.classList.toggle('stale', branch !== 'main');
|
|
1565
|
+
badge.textContent = `${sha} • ${uptimeStr}`;
|
|
1566
|
+
badge.title = `SHA: ${info.gitSha}\nBranch: ${branch}\nCommit: ${info.gitMessage}\nAuthor: ${info.gitAuthor}\nPID: ${info.pid}\nNode: ${info.nodeVersion}\nStarted: ${info.startedAt}`;
|
|
1567
|
+
} catch (err) {
|
|
1568
|
+
badge.textContent = 'build: error';
|
|
1569
|
+
badge.title = 'Failed to load build info';
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
async function loadRuntimeTruthCard() {
|
|
1574
|
+
const body = document.getElementById('truth-body');
|
|
1575
|
+
const count = document.getElementById('truth-count');
|
|
1576
|
+
if (!body || !count) return;
|
|
1577
|
+
|
|
1578
|
+
try {
|
|
1579
|
+
const r = await fetch(BASE + '/runtime/truth');
|
|
1580
|
+
if (!r.ok) throw new Error('status ' + r.status);
|
|
1581
|
+
const truth = await r.json();
|
|
1582
|
+
|
|
1583
|
+
const deployLabel = truth?.deploy?.stale ? 'stale' : 'in sync';
|
|
1584
|
+
const cloudLabel = truth?.cloud?.registered
|
|
1585
|
+
? `registered • hb ${truth?.cloud?.heartbeatCount ?? 0}`
|
|
1586
|
+
: 'not registered';
|
|
1587
|
+
|
|
1588
|
+
count.textContent = `${truth?.repo?.shortSha || 'unknown'} • ${deployLabel}`;
|
|
1589
|
+
|
|
1590
|
+
body.innerHTML = `
|
|
1591
|
+
<div class="truth-grid">
|
|
1592
|
+
<div class="truth-item">
|
|
1593
|
+
<div class="truth-label">Repo</div>
|
|
1594
|
+
<div class="truth-value">${esc(truth?.repo?.name || 'reflectt/reflectt-node')}<br>${esc(truth?.repo?.branch || 'unknown')} • ${esc((truth?.repo?.shortSha || 'unknown'))}</div>
|
|
1595
|
+
</div>
|
|
1596
|
+
<div class="truth-item">
|
|
1597
|
+
<div class="truth-label">Runtime</div>
|
|
1598
|
+
<div class="truth-value">PID ${esc(String(truth?.runtime?.pid ?? 'n/a'))} • Node ${esc(truth?.runtime?.nodeVersion || 'n/a')}<br>${esc(String(truth?.runtime?.host || '0.0.0.0'))}:${esc(String(truth?.runtime?.port || 'n/a'))} • up ${esc(String(truth?.runtime?.uptimeSec ?? 0))}s</div>
|
|
1599
|
+
</div>
|
|
1600
|
+
<div class="truth-item">
|
|
1601
|
+
<div class="truth-label">Deploy</div>
|
|
1602
|
+
<div class="truth-value">${esc(deployLabel)}<br>startup ${esc((truth?.deploy?.startupCommit || 'unknown').slice(0, 8))} → current ${esc((truth?.deploy?.currentCommit || 'unknown').slice(0, 8))}</div>
|
|
1603
|
+
</div>
|
|
1604
|
+
<div class="truth-item">
|
|
1605
|
+
<div class="truth-label">Cloud</div>
|
|
1606
|
+
<div class="truth-value">${esc(cloudLabel)}<br>host ${esc(String(truth?.cloud?.hostId || 'none'))}</div>
|
|
1607
|
+
</div>
|
|
1608
|
+
<div class="truth-item">
|
|
1609
|
+
<div class="truth-label">Paths</div>
|
|
1610
|
+
<div class="truth-value">home ${esc(String(truth?.paths?.reflecttHome || 'n/a'))}</div>
|
|
1611
|
+
</div>
|
|
1612
|
+
</div>
|
|
1613
|
+
`;
|
|
1614
|
+
} catch (err) {
|
|
1615
|
+
count.textContent = 'unavailable';
|
|
1616
|
+
body.innerHTML = '<div class="empty">Failed to load runtime truth card</div>';
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function updateClock() {
|
|
1621
|
+
document.getElementById('clock').textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// ---- Review Queue Panel ----
|
|
1625
|
+
const REVIEW_SLA_HOURS = 4; // 4h default SLA for reviews
|
|
1626
|
+
const REVIEW_SLA_WARNING_HOURS = 2; // warning at 2h
|
|
1627
|
+
|
|
1628
|
+
function getReviewSlaState(timeInReviewMs) {
|
|
1629
|
+
const hours = timeInReviewMs / (1000 * 60 * 60);
|
|
1630
|
+
if (hours >= REVIEW_SLA_HOURS) return 'breach';
|
|
1631
|
+
if (hours >= REVIEW_SLA_WARNING_HOURS) return 'warning';
|
|
1632
|
+
return 'ok';
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
function getReviewSlaLabel(state) {
|
|
1636
|
+
if (state === 'breach') return '⏰ SLA BREACH';
|
|
1637
|
+
if (state === 'warning') return '⚠ Near SLA';
|
|
1638
|
+
return '✓ On track';
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function formatDuration(ms) {
|
|
1642
|
+
const totalMin = Math.floor(ms / 60000);
|
|
1643
|
+
if (totalMin < 60) return totalMin + 'm';
|
|
1644
|
+
const h = Math.floor(totalMin / 60);
|
|
1645
|
+
const m = totalMin % 60;
|
|
1646
|
+
if (h < 24) return h + 'h ' + m + 'm';
|
|
1647
|
+
const d = Math.floor(h / 24);
|
|
1648
|
+
return d + 'd ' + (h % 24) + 'h';
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Normalize epoch: detect seconds vs ms, clamp future values
|
|
1652
|
+
function normalizeEpochMs(v) {
|
|
1653
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return 0;
|
|
1654
|
+
// Values below ~2001-09-09 in ms are likely seconds
|
|
1655
|
+
if (v < 100000000000) return v * 1000;
|
|
1656
|
+
return v;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function renderReviewQueue() {
|
|
1660
|
+
const panel = document.getElementById('review-queue-panel');
|
|
1661
|
+
const body = document.getElementById('review-queue-body');
|
|
1662
|
+
const count = document.getElementById('review-queue-count');
|
|
1663
|
+
if (!body || !panel) return;
|
|
1664
|
+
|
|
1665
|
+
const now = Date.now();
|
|
1666
|
+
const MAX_REVIEW_MS = 30 * 24 * 60 * 60 * 1000; // 30 days clamp
|
|
1667
|
+
const validating = allTasks
|
|
1668
|
+
.filter(t => t.status === 'validating')
|
|
1669
|
+
.map(t => {
|
|
1670
|
+
const rawEntered = t.metadata?.entered_validating_at || t.updatedAt || t.createdAt;
|
|
1671
|
+
const enteredAt = normalizeEpochMs(rawEntered) || now;
|
|
1672
|
+
const timeInReview = Math.min(Math.max(0, now - enteredAt), MAX_REVIEW_MS);
|
|
1673
|
+
const slaState = getReviewSlaState(timeInReview);
|
|
1674
|
+
return { ...t, timeInReview, slaState, enteredAt };
|
|
1675
|
+
})
|
|
1676
|
+
.sort((a, b) => {
|
|
1677
|
+
// Breaches first, then by time descending
|
|
1678
|
+
const order = { breach: 0, warning: 1, ok: 2 };
|
|
1679
|
+
const diff = (order[a.slaState] || 2) - (order[b.slaState] || 2);
|
|
1680
|
+
if (diff !== 0) return diff;
|
|
1681
|
+
return b.timeInReview - a.timeInReview;
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
if (validating.length === 0) {
|
|
1685
|
+
panel.style.display = 'none';
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
panel.style.display = '';
|
|
1690
|
+
count.textContent = validating.length + ' awaiting review';
|
|
1691
|
+
// Update sidebar badge
|
|
1692
|
+
const navReviewBadge = document.getElementById('nav-review-count');
|
|
1693
|
+
if (navReviewBadge) navReviewBadge.textContent = validating.length;
|
|
1694
|
+
|
|
1695
|
+
const breachCount = validating.filter(t => t.slaState === 'breach').length;
|
|
1696
|
+
const headerExtra = breachCount > 0
|
|
1697
|
+
? ' <span style="color:var(--red);font-size:11px;font-weight:600">' + breachCount + ' breach' + (breachCount > 1 ? 'es' : '') + '</span>'
|
|
1698
|
+
: '';
|
|
1699
|
+
count.innerHTML = validating.length + ' awaiting review' + headerExtra;
|
|
1700
|
+
|
|
1701
|
+
body.innerHTML = validating.map(t => {
|
|
1702
|
+
const reviewer = t.reviewer || '<span style="color:var(--yellow)">unassigned</span>';
|
|
1703
|
+
const assignee = t.assignee || '?';
|
|
1704
|
+
const priority = t.priority || 'P3';
|
|
1705
|
+
const slaLabel = getReviewSlaLabel(t.slaState);
|
|
1706
|
+
const duration = formatDuration(t.timeInReview);
|
|
1707
|
+
const tags = renderTaskTags(t.tags);
|
|
1708
|
+
|
|
1709
|
+
return '<div class="review-item" role="button" tabindex="0" onclick="openTaskModal(\'' + esc(t.id) + '\')">'
|
|
1710
|
+
+ '<div class="review-item-left">'
|
|
1711
|
+
+ '<div class="review-item-title">' + esc(truncate(t.title, 70)) + '</div>'
|
|
1712
|
+
+ '<div class="review-item-meta">'
|
|
1713
|
+
+ '<span>👤 ' + reviewer + '</span>'
|
|
1714
|
+
+ '<span>⏱ ' + esc(duration) + '</span>'
|
|
1715
|
+
+ '<span class="assignee-tag">' + esc(priority) + '</span>'
|
|
1716
|
+
+ '<span>by ' + esc(assignee) + '</span>'
|
|
1717
|
+
+ (tags ? ' ' + tags : '')
|
|
1718
|
+
+ '</div>'
|
|
1719
|
+
+ '</div>'
|
|
1720
|
+
+ '<div class="review-item-right">'
|
|
1721
|
+
+ '<span class="sla-badge ' + t.slaState + '">' + slaLabel + '</span>'
|
|
1722
|
+
+ '</div>'
|
|
1723
|
+
+ '</div>';
|
|
1724
|
+
}).join('');
|
|
1725
|
+
|
|
1726
|
+
bindTaskLinkHandlers(body);
|
|
1727
|
+
|
|
1728
|
+
// SLA breach escalation: post to watchdog if any breach found
|
|
1729
|
+
if (breachCount > 0) {
|
|
1730
|
+
escalateReviewBreaches(validating.filter(t => t.slaState === 'breach'));
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
let lastReviewEscalationAt = 0;
|
|
1735
|
+
const REVIEW_ESCALATION_COOLDOWN = 20 * 60 * 1000; // 20m
|
|
1736
|
+
|
|
1737
|
+
async function escalateReviewBreaches(breachedTasks) {
|
|
1738
|
+
const now = Date.now();
|
|
1739
|
+
if (now - lastReviewEscalationAt < REVIEW_ESCALATION_COOLDOWN) return;
|
|
1740
|
+
lastReviewEscalationAt = now;
|
|
1741
|
+
|
|
1742
|
+
const lines = breachedTasks.slice(0, 5).map(t => {
|
|
1743
|
+
const reviewer = t.reviewer || 'unassigned';
|
|
1744
|
+
return '- ' + t.id + ' (' + (t.title || '').slice(0, 50) + ') — reviewer: @' + reviewer + ', waiting ' + formatDuration(t.timeInReview);
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
const content = '@kai Review SLA breach detected:\n' + lines.join('\n');
|
|
1748
|
+
|
|
1749
|
+
try {
|
|
1750
|
+
await fetch(BASE + '/chat/messages', {
|
|
1751
|
+
method: 'POST',
|
|
1752
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1753
|
+
body: JSON.stringify({
|
|
1754
|
+
from: 'system',
|
|
1755
|
+
content,
|
|
1756
|
+
channel: 'general',
|
|
1757
|
+
timestamp: now
|
|
1758
|
+
})
|
|
1759
|
+
});
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
console.error('Failed to escalate review breach:', err);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// ---- Feedback ----
|
|
1766
|
+
let feedbackData = null;
|
|
1767
|
+
|
|
1768
|
+
async function loadFeedback() {
|
|
1769
|
+
try {
|
|
1770
|
+
const res = await fetch(BASE + '/feedback?status=all&limit=50');
|
|
1771
|
+
feedbackData = await res.json();
|
|
1772
|
+
renderFeedback();
|
|
1773
|
+
} catch (e) {
|
|
1774
|
+
const body = document.getElementById('feedback-body');
|
|
1775
|
+
if (body) body.innerHTML = '<div class="empty">Failed to load feedback</div>';
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function renderFeedback() {
|
|
1780
|
+
const body = document.getElementById('feedback-body');
|
|
1781
|
+
const count = document.getElementById('feedback-count');
|
|
1782
|
+
if (!body || !feedbackData) return;
|
|
1783
|
+
|
|
1784
|
+
const items = feedbackData.items || [];
|
|
1785
|
+
const newCount = feedbackData.newCount || 0;
|
|
1786
|
+
count.textContent = newCount > 0 ? newCount + ' new' : items.length + ' total';
|
|
1787
|
+
|
|
1788
|
+
if (items.length === 0) {
|
|
1789
|
+
body.innerHTML = '<div class="empty" style="text-align:center;padding:20px;color:var(--text-dim)">💬 No feedback yet.<br><span style="font-size:11px">Embed the widget to start collecting.</span></div>';
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
body.innerHTML = items.map(function(fb) {
|
|
1794
|
+
var catIcon = fb.category === 'bug' ? '🐛' : fb.category === 'feature' ? '✨' : '💬';
|
|
1795
|
+
var catClass = fb.category || 'general';
|
|
1796
|
+
var domain = '';
|
|
1797
|
+
if (fb.url) { try { domain = new URL(fb.url).hostname; } catch(e) {} }
|
|
1798
|
+
return '<div class="feedback-card">' +
|
|
1799
|
+
'<div class="fb-header">' +
|
|
1800
|
+
'<span class="fb-category ' + catClass + '">' + catIcon + ' ' + esc(fb.category) + '</span>' +
|
|
1801
|
+
(domain ? '<span class="fb-source"> · ' + esc(domain) + '</span>' : '') +
|
|
1802
|
+
'<span class="fb-time">' + ago(fb.createdAt) + '</span>' +
|
|
1803
|
+
'</div>' +
|
|
1804
|
+
'<div class="fb-message">"' + esc(fb.messagePreview) + '"</div>' +
|
|
1805
|
+
'<div class="fb-footer">' +
|
|
1806
|
+
(fb.email ? '<span class="fb-email">' + esc(fb.email) + '</span>' : '') +
|
|
1807
|
+
(fb.votes > 0 ? '<span class="fb-votes" onclick="voteFeedback(\'' + esc(fb.id) + '\')">▲ ' + fb.votes + '</span>' : '<span class="fb-votes" onclick="voteFeedback(\'' + esc(fb.id) + '\')">▲ 0</span>') +
|
|
1808
|
+
'<span class="fb-actions">' +
|
|
1809
|
+
(fb.status === 'new' ? '<button onclick="triageFeedback(\'' + esc(fb.id) + '\')">Triage</button>' : '') +
|
|
1810
|
+
(fb.status !== 'archived' ? '<button onclick="archiveFeedback(\'' + esc(fb.id) + '\')">Archive</button>' : '<button onclick="unarchiveFeedback(\'' + esc(fb.id) + '\')">Unarchive</button>') +
|
|
1811
|
+
'</span>' +
|
|
1812
|
+
'</div>' +
|
|
1813
|
+
'</div>';
|
|
1814
|
+
}).join('');
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
async function triageFeedback(id) {
|
|
1818
|
+
var notes = prompt('Triage notes (optional):') || '';
|
|
1819
|
+
try {
|
|
1820
|
+
await fetch(BASE + '/feedback/' + encodeURIComponent(id), {
|
|
1821
|
+
method: 'PATCH',
|
|
1822
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1823
|
+
body: JSON.stringify({ status: 'triaged', notes: notes })
|
|
1824
|
+
});
|
|
1825
|
+
await loadFeedback();
|
|
1826
|
+
} catch (e) { console.error('Triage failed:', e); }
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
async function archiveFeedback(id) {
|
|
1830
|
+
try {
|
|
1831
|
+
await fetch(BASE + '/feedback/' + encodeURIComponent(id), {
|
|
1832
|
+
method: 'PATCH',
|
|
1833
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1834
|
+
body: JSON.stringify({ status: 'archived' })
|
|
1835
|
+
});
|
|
1836
|
+
await loadFeedback();
|
|
1837
|
+
} catch (e) { console.error('Archive failed:', e); }
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
async function unarchiveFeedback(id) {
|
|
1841
|
+
try {
|
|
1842
|
+
await fetch(BASE + '/feedback/' + encodeURIComponent(id), {
|
|
1843
|
+
method: 'PATCH',
|
|
1844
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1845
|
+
body: JSON.stringify({ status: 'triaged' })
|
|
1846
|
+
});
|
|
1847
|
+
await loadFeedback();
|
|
1848
|
+
} catch (e) { console.error('Unarchive failed:', e); }
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
async function voteFeedback(id) {
|
|
1852
|
+
try {
|
|
1853
|
+
await fetch(BASE + '/feedback/' + encodeURIComponent(id) + '/vote', { method: 'POST' });
|
|
1854
|
+
await loadFeedback();
|
|
1855
|
+
} catch (e) { console.error('Vote failed:', e); }
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// ---- Approval Queue ----
|
|
1859
|
+
let approvalQueueData = null;
|
|
1860
|
+
let routingPolicyVisible = false;
|
|
1861
|
+
let routingPolicyData = null;
|
|
1862
|
+
let policyEdits = {};
|
|
1863
|
+
|
|
1864
|
+
async function loadApprovalQueue() {
|
|
1865
|
+
try {
|
|
1866
|
+
const res = await fetch(BASE + '/approval-queue');
|
|
1867
|
+
approvalQueueData = await res.json();
|
|
1868
|
+
renderApprovalQueue();
|
|
1869
|
+
} catch (e) {
|
|
1870
|
+
const body = document.getElementById('approval-queue-body');
|
|
1871
|
+
if (body) body.innerHTML = '<div class="empty">Failed to load approval queue</div>';
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function renderApprovalQueue() {
|
|
1876
|
+
const body = document.getElementById('approval-queue-body');
|
|
1877
|
+
const count = document.getElementById('approval-queue-count');
|
|
1878
|
+
if (!body || !approvalQueueData) return;
|
|
1879
|
+
|
|
1880
|
+
const items = approvalQueueData.items || [];
|
|
1881
|
+
const highCount = approvalQueueData.highConfidenceCount || 0;
|
|
1882
|
+
const needsCount = approvalQueueData.needsReviewCount || 0;
|
|
1883
|
+
count.textContent = items.length + ' pending';
|
|
1884
|
+
|
|
1885
|
+
if (items.length === 0) {
|
|
1886
|
+
body.innerHTML = '<div class="empty" style="text-align:center;padding:20px;color:var(--text-dim)">✓ Queue is clear — no tasks waiting for approval.</div>';
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
let html = '';
|
|
1891
|
+
|
|
1892
|
+
// Batch approve bar
|
|
1893
|
+
if (highCount > 0) {
|
|
1894
|
+
html += '<div class="batch-approve-bar">';
|
|
1895
|
+
html += '<span>' + highCount + ' high-confidence · ' + needsCount + ' need review</span>';
|
|
1896
|
+
html += '<button onclick="batchApproveHighConfidence()">Approve All High-Confidence (' + highCount + ')</button>';
|
|
1897
|
+
html += '</div>';
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// High confidence section
|
|
1901
|
+
const highItems = items.filter(function(i) { return i.confidenceScore >= 0.85; });
|
|
1902
|
+
const lowItems = items.filter(function(i) { return i.confidenceScore < 0.85; });
|
|
1903
|
+
|
|
1904
|
+
if (highItems.length > 0) {
|
|
1905
|
+
html += '<div style="padding:6px 12px;font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin-top:4px">High Confidence (≥ 85%)</div>';
|
|
1906
|
+
highItems.forEach(function(item) { html += renderApprovalCard(item, true); });
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
if (lowItems.length > 0) {
|
|
1910
|
+
html += '<div style="padding:6px 12px;font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin-top:4px">Needs Review (< 85%)</div>';
|
|
1911
|
+
lowItems.forEach(function(item) { html += renderApprovalCard(item, false); });
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
body.innerHTML = html;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function renderApprovalCard(item, isHigh) {
|
|
1918
|
+
const icon = isHigh ? '✦' : '⚠';
|
|
1919
|
+
const pct = Math.round(item.confidenceScore * 100);
|
|
1920
|
+
const confClass = isHigh ? 'high' : 'low';
|
|
1921
|
+
return '<div class="approval-card">' +
|
|
1922
|
+
'<div class="approval-header">' +
|
|
1923
|
+
'<span>' + icon + '</span> ' +
|
|
1924
|
+
'<span class="approval-title">' + esc(item.title.substring(0, 60)) + '</span>' +
|
|
1925
|
+
'<span class="assignee-tag">' + esc(item.priority) + '</span>' +
|
|
1926
|
+
'<span class="confidence-score ' + confClass + '">' + pct + '%</span>' +
|
|
1927
|
+
'</div>' +
|
|
1928
|
+
'<div class="approval-meta">' +
|
|
1929
|
+
'Suggested: @' + esc(item.suggestedAgent || '?') + ' — ' + esc(item.confidenceReason || '') +
|
|
1930
|
+
'</div>' +
|
|
1931
|
+
'<div class="approval-actions">' +
|
|
1932
|
+
(isHigh ? '' : '<button class="btn-reject" onclick="rejectApproval(\'' + esc(item.taskId) + '\')">✗ Reject</button>') +
|
|
1933
|
+
'<button class="btn-edit" onclick="openTaskModal(\'' + esc(item.taskId) + '\')">Edit</button>' +
|
|
1934
|
+
'<button class="btn-approve" onclick="approveTask(\'' + esc(item.taskId) + '\', \'' + esc(item.suggestedAgent || '') + '\')">✓ Approve</button>' +
|
|
1935
|
+
'</div>' +
|
|
1936
|
+
'</div>';
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
async function approveTask(taskId, agent) {
|
|
1940
|
+
try {
|
|
1941
|
+
await fetch(BASE + '/approval-queue/' + encodeURIComponent(taskId) + '/approve', {
|
|
1942
|
+
method: 'POST',
|
|
1943
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1944
|
+
body: JSON.stringify({ assignedAgent: agent, reviewedBy: 'dashboard' })
|
|
1945
|
+
});
|
|
1946
|
+
await loadApprovalQueue();
|
|
1947
|
+
} catch (e) { console.error('Approve failed:', e); }
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
async function rejectApproval(taskId) {
|
|
1951
|
+
const reason = prompt('Rejection reason (optional):') || '';
|
|
1952
|
+
try {
|
|
1953
|
+
await fetch(BASE + '/approval-queue/' + encodeURIComponent(taskId) + '/reject', {
|
|
1954
|
+
method: 'POST',
|
|
1955
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1956
|
+
body: JSON.stringify({ reason: reason, reviewedBy: 'dashboard' })
|
|
1957
|
+
});
|
|
1958
|
+
await loadApprovalQueue();
|
|
1959
|
+
} catch (e) { console.error('Reject failed:', e); }
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
async function batchApproveHighConfidence() {
|
|
1963
|
+
if (!approvalQueueData) return;
|
|
1964
|
+
const highItems = (approvalQueueData.items || []).filter(function(i) { return i.confidenceScore >= 0.85; });
|
|
1965
|
+
if (highItems.length === 0) return;
|
|
1966
|
+
if (!confirm('Approve ' + highItems.length + ' high-confidence tasks? They will be assigned immediately.')) return;
|
|
1967
|
+
|
|
1968
|
+
try {
|
|
1969
|
+
await fetch(BASE + '/approval-queue/batch-approve', {
|
|
1970
|
+
method: 'POST',
|
|
1971
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1972
|
+
body: JSON.stringify({ taskIds: highItems.map(function(i) { return i.taskId; }), reviewedBy: 'dashboard' })
|
|
1973
|
+
});
|
|
1974
|
+
await loadApprovalQueue();
|
|
1975
|
+
} catch (e) { console.error('Batch approve failed:', e); }
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// ---- Routing Policy Editor ----
|
|
1979
|
+
function toggleRoutingPolicy() {
|
|
1980
|
+
routingPolicyVisible = !routingPolicyVisible;
|
|
1981
|
+
const panel = document.getElementById('routing-policy-panel');
|
|
1982
|
+
if (!panel) return;
|
|
1983
|
+
panel.style.display = routingPolicyVisible ? '' : 'none';
|
|
1984
|
+
if (routingPolicyVisible) loadRoutingPolicy();
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
async function loadRoutingPolicy() {
|
|
1988
|
+
try {
|
|
1989
|
+
const res = await fetch(BASE + '/routing-policy');
|
|
1990
|
+
routingPolicyData = await res.json();
|
|
1991
|
+
policyEdits = {};
|
|
1992
|
+
renderRoutingPolicy();
|
|
1993
|
+
} catch (e) {
|
|
1994
|
+
const panel = document.getElementById('routing-policy-panel');
|
|
1995
|
+
if (panel) panel.innerHTML = '<div class="empty">Failed to load routing policy</div>';
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
function renderRoutingPolicy() {
|
|
2000
|
+
const panel = document.getElementById('routing-policy-panel');
|
|
2001
|
+
if (!panel || !routingPolicyData) return;
|
|
2002
|
+
|
|
2003
|
+
const agents = routingPolicyData.agents || [];
|
|
2004
|
+
let html = '<div style="font-size:12px;font-weight:600;color:var(--text-bright);margin-bottom:8px">Agent Affinity Maps</div>';
|
|
2005
|
+
html += '<div style="font-size:10px;color:var(--text-muted);margin-bottom:12px">Edit which task types each agent is preferred for. Confidence scores are calculated from these affinities.</div>';
|
|
2006
|
+
|
|
2007
|
+
agents.forEach(function(agent, idx) {
|
|
2008
|
+
const edited = policyEdits[agent.agentId] || agent;
|
|
2009
|
+
const tags = edited.affinityTags || [];
|
|
2010
|
+
const weight = typeof edited.weight === 'number' ? edited.weight : 0.5;
|
|
2011
|
+
|
|
2012
|
+
html += '<div class="policy-agent-card">';
|
|
2013
|
+
html += '<div class="agent-name">@' + esc(agent.agentId) + '</div>';
|
|
2014
|
+
html += '<div class="tag-row">';
|
|
2015
|
+
tags.forEach(function(tag, ti) {
|
|
2016
|
+
html += '<span class="tag-chip">' + esc(tag) + ' <span class="tag-remove" onclick="removePolicyTag(\'' + esc(agent.agentId) + '\',' + ti + ')">×</span></span>';
|
|
2017
|
+
});
|
|
2018
|
+
html += '<input type="text" placeholder="+ tag" style="font-size:10px;width:60px;background:none;border:1px solid var(--border-subtle);color:var(--text-bright);padding:2px 6px;border-radius:10px" onkeydown="addPolicyTag(event,\'' + esc(agent.agentId) + '\')">';
|
|
2019
|
+
html += '</div>';
|
|
2020
|
+
html += '<div class="weight-row">';
|
|
2021
|
+
html += '<span style="color:var(--text-dim)">Weight:</span>';
|
|
2022
|
+
html += '<input type="range" min="0" max="10" value="' + Math.round(weight * 10) + '" oninput="updatePolicyWeight(\'' + esc(agent.agentId) + '\', this.value)">';
|
|
2023
|
+
html += '<span class="weight-val">' + weight.toFixed(1) + '</span>';
|
|
2024
|
+
html += '</div>';
|
|
2025
|
+
html += '</div>';
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
// Save bar
|
|
2029
|
+
const editCount = Object.keys(policyEdits).length;
|
|
2030
|
+
if (editCount > 0) {
|
|
2031
|
+
html += '<div class="policy-save-bar">';
|
|
2032
|
+
html += '<span style="font-size:10px;color:var(--text-muted)">' + editCount + ' unsaved change' + (editCount !== 1 ? 's' : '') + '</span>';
|
|
2033
|
+
html += '<button class="btn-discard" onclick="loadRoutingPolicy()">Discard</button>';
|
|
2034
|
+
html += '<button class="btn-save" onclick="saveRoutingPolicy()">Save</button>';
|
|
2035
|
+
html += '</div>';
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
panel.innerHTML = html;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function removePolicyTag(agentId, tagIndex) {
|
|
2042
|
+
if (!routingPolicyData) return;
|
|
2043
|
+
const agent = routingPolicyData.agents.find(function(a) { return a.agentId === agentId; });
|
|
2044
|
+
if (!agent) return;
|
|
2045
|
+
const edited = policyEdits[agentId] || JSON.parse(JSON.stringify(agent));
|
|
2046
|
+
edited.affinityTags.splice(tagIndex, 1);
|
|
2047
|
+
policyEdits[agentId] = edited;
|
|
2048
|
+
renderRoutingPolicy();
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function addPolicyTag(event, agentId) {
|
|
2052
|
+
if (event.key !== 'Enter') return;
|
|
2053
|
+
const val = event.target.value.trim();
|
|
2054
|
+
if (!val) return;
|
|
2055
|
+
if (!routingPolicyData) return;
|
|
2056
|
+
const agent = routingPolicyData.agents.find(function(a) { return a.agentId === agentId; });
|
|
2057
|
+
if (!agent) return;
|
|
2058
|
+
const edited = policyEdits[agentId] || JSON.parse(JSON.stringify(agent));
|
|
2059
|
+
if (!edited.affinityTags.includes(val)) {
|
|
2060
|
+
edited.affinityTags.push(val);
|
|
2061
|
+
}
|
|
2062
|
+
policyEdits[agentId] = edited;
|
|
2063
|
+
event.target.value = '';
|
|
2064
|
+
renderRoutingPolicy();
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function updatePolicyWeight(agentId, sliderVal) {
|
|
2068
|
+
if (!routingPolicyData) return;
|
|
2069
|
+
const agent = routingPolicyData.agents.find(function(a) { return a.agentId === agentId; });
|
|
2070
|
+
if (!agent) return;
|
|
2071
|
+
const edited = policyEdits[agentId] || JSON.parse(JSON.stringify(agent));
|
|
2072
|
+
edited.weight = Number(sliderVal) / 10;
|
|
2073
|
+
policyEdits[agentId] = edited;
|
|
2074
|
+
renderRoutingPolicy();
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
async function saveRoutingPolicy() {
|
|
2078
|
+
if (!routingPolicyData) return;
|
|
2079
|
+
const agents = routingPolicyData.agents.map(function(a) {
|
|
2080
|
+
return policyEdits[a.agentId] || a;
|
|
2081
|
+
});
|
|
2082
|
+
try {
|
|
2083
|
+
const res = await fetch(BASE + '/routing-policy', {
|
|
2084
|
+
method: 'PUT',
|
|
2085
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2086
|
+
body: JSON.stringify({ agents: agents, updatedBy: 'dashboard' })
|
|
2087
|
+
});
|
|
2088
|
+
const result = await res.json();
|
|
2089
|
+
if (result.success) {
|
|
2090
|
+
policyEdits = {};
|
|
2091
|
+
await loadRoutingPolicy();
|
|
2092
|
+
}
|
|
2093
|
+
} catch (e) { console.error('Save policy failed:', e); }
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// ---- Task Search (Office Suite Spine) ----
|
|
2097
|
+
async function runTaskSearch() {
|
|
2098
|
+
const input = document.getElementById('task-search-input');
|
|
2099
|
+
const resultsEl = document.getElementById('task-search-results');
|
|
2100
|
+
const countEl = document.getElementById('search-count');
|
|
2101
|
+
if (!input || !resultsEl || !countEl) return;
|
|
2102
|
+
|
|
2103
|
+
const q = (input.value || '').trim();
|
|
2104
|
+
if (!q) {
|
|
2105
|
+
countEl.textContent = '';
|
|
2106
|
+
resultsEl.innerHTML = '<div class="empty" style="color:var(--text-muted)">Type a query and press Enter…</div>';
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
resultsEl.innerHTML = '<div class="empty" style="color:var(--text-muted)">Searching…</div>';
|
|
2111
|
+
|
|
2112
|
+
try {
|
|
2113
|
+
const res = await fetch(BASE + '/tasks/search?q=' + encodeURIComponent(q) + '&limit=12');
|
|
2114
|
+
if (!res.ok) throw new Error('status ' + res.status);
|
|
2115
|
+
const data = await res.json();
|
|
2116
|
+
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
|
2117
|
+
|
|
2118
|
+
countEl.textContent = tasks.length + ' result' + (tasks.length === 1 ? '' : 's');
|
|
2119
|
+
|
|
2120
|
+
if (tasks.length === 0) {
|
|
2121
|
+
resultsEl.innerHTML = '<div class="empty" style="color:var(--text-muted)">No matches</div>';
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
resultsEl.innerHTML = tasks.map(t => {
|
|
2126
|
+
const assignee = t.assignee ? '@' + esc(t.assignee) : '<span style="color:var(--yellow)">unassigned</span>';
|
|
2127
|
+
const pri = t.priority ? '<span class="priority-badge ' + esc(t.priority) + '">' + esc(t.priority) + '</span>' : '';
|
|
2128
|
+
const title = esc(truncate(t.title || t.id, 80));
|
|
2129
|
+
const id = esc(t.id);
|
|
2130
|
+
const status = esc(t.status || 'todo');
|
|
2131
|
+
return '<div class="backlog-item" role="button" tabindex="0" style="padding:10px 14px;border-bottom:1px solid var(--border-subtle);cursor:pointer" onclick="openTaskModal(\'' + id + '\')">'
|
|
2132
|
+
+ '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">'
|
|
2133
|
+
+ pri
|
|
2134
|
+
+ '<span style="color:var(--text-bright);font-size:13px;font-weight:500">' + title + '</span>'
|
|
2135
|
+
+ '</div>'
|
|
2136
|
+
+ '<div style="font-size:11px;color:var(--text-muted)">'
|
|
2137
|
+
+ '<span>' + id + '</span> · <span>' + status + '</span> · <span>' + assignee + '</span>'
|
|
2138
|
+
+ '</div>'
|
|
2139
|
+
+ '</div>';
|
|
2140
|
+
}).join('');
|
|
2141
|
+
} catch (err) {
|
|
2142
|
+
countEl.textContent = 'error';
|
|
2143
|
+
resultsEl.innerHTML = '<div class="empty" style="color:var(--red)">Search failed</div>';
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
async function refresh() {
|
|
2148
|
+
refreshCount += 1;
|
|
2149
|
+
const forceFull = refreshCount % 12 === 0; // full sync less often with adaptive polling
|
|
2150
|
+
if (refreshCount === 1 || forceFull) await refreshAgentRegistry();
|
|
2151
|
+
await loadTasks(forceFull);
|
|
2152
|
+
renderReviewQueue();
|
|
2153
|
+
await Promise.all([loadPresence(), loadChat(forceFull), loadActivity(forceFull), loadResearch(), loadSharedArtifacts(), loadHealth(), loadReleaseStatus(forceFull), loadBuildInfo(), loadRuntimeTruthCard(), loadApprovalQueue(), loadFeedback(), loadPauseStatus(), loadIntensityControl(), loadPolls()]);
|
|
2154
|
+
await renderPromotionSSOT();
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
let refreshTimer = null;
|
|
2158
|
+
let refreshInFlight = false;
|
|
2159
|
+
|
|
2160
|
+
// SSE live updates
|
|
2161
|
+
let eventSource = null;
|
|
2162
|
+
let sseReconnectTimer = null;
|
|
2163
|
+
let sseRefreshTimer = null;
|
|
2164
|
+
let sseBackoffMs = 1500;
|
|
2165
|
+
const SSE_MAX_BACKOFF_MS = 20000;
|
|
2166
|
+
const SSE_TOPICS = 'task,message,presence,memory';
|
|
2167
|
+
|
|
2168
|
+
function getRefreshIntervalMs() {
|
|
2169
|
+
if (document.hidden) return 60000; // background tabs poll lightly
|
|
2170
|
+
const recentActivityMs = Date.now() - Math.max(lastChatSync || 0, lastActivitySync || 0, lastTaskSync || 0);
|
|
2171
|
+
if (recentActivityMs < 2 * 60 * 1000) return 20000; // active team chatter
|
|
2172
|
+
return 30000; // normal foreground cadence
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
async function scheduleNextRefresh() {
|
|
2176
|
+
if (refreshInFlight) return;
|
|
2177
|
+
refreshInFlight = true;
|
|
2178
|
+
try {
|
|
2179
|
+
await refresh();
|
|
2180
|
+
} finally {
|
|
2181
|
+
refreshInFlight = false;
|
|
2182
|
+
refreshTimer = setTimeout(scheduleNextRefresh, getRefreshIntervalMs());
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function startAdaptiveRefresh() {
|
|
2187
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
2188
|
+
refreshTimer = setTimeout(scheduleNextRefresh, getRefreshIntervalMs());
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
function queueSseRefresh() {
|
|
2192
|
+
if (sseRefreshTimer) return;
|
|
2193
|
+
sseRefreshTimer = setTimeout(async () => {
|
|
2194
|
+
sseRefreshTimer = null;
|
|
2195
|
+
try {
|
|
2196
|
+
await refresh();
|
|
2197
|
+
startAdaptiveRefresh();
|
|
2198
|
+
} catch (err) {
|
|
2199
|
+
console.error('SSE refresh failed:', err);
|
|
2200
|
+
}
|
|
2201
|
+
}, 250);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
function handleSsePayload(eventType, payload) {
|
|
2205
|
+
if (eventType === 'batch' && Array.isArray(payload)) {
|
|
2206
|
+
queueSseRefresh();
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
switch (eventType) {
|
|
2211
|
+
case 'message_posted':
|
|
2212
|
+
case 'task_created':
|
|
2213
|
+
case 'task_assigned':
|
|
2214
|
+
case 'task_updated':
|
|
2215
|
+
case 'presence_updated':
|
|
2216
|
+
case 'memory_written':
|
|
2217
|
+
queueSseRefresh();
|
|
2218
|
+
break;
|
|
2219
|
+
default:
|
|
2220
|
+
// ignore unknown event types
|
|
2221
|
+
break;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
function connectEventStream() {
|
|
2226
|
+
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return;
|
|
2227
|
+
if (eventSource) return;
|
|
2228
|
+
|
|
2229
|
+
const url = `${BASE}/events?topics=${encodeURIComponent(SSE_TOPICS)}`;
|
|
2230
|
+
const es = new EventSource(url);
|
|
2231
|
+
eventSource = es;
|
|
2232
|
+
|
|
2233
|
+
const onAnyEvent = (event) => {
|
|
2234
|
+
try {
|
|
2235
|
+
const payload = event && event.data ? JSON.parse(event.data) : null;
|
|
2236
|
+
handleSsePayload(event.type || 'message', payload);
|
|
2237
|
+
} catch {
|
|
2238
|
+
queueSseRefresh();
|
|
2239
|
+
}
|
|
2240
|
+
};
|
|
2241
|
+
|
|
2242
|
+
es.onopen = () => {
|
|
2243
|
+
sseBackoffMs = 1500;
|
|
2244
|
+
};
|
|
2245
|
+
|
|
2246
|
+
es.onerror = () => {
|
|
2247
|
+
if (eventSource) {
|
|
2248
|
+
eventSource.close();
|
|
2249
|
+
eventSource = null;
|
|
2250
|
+
}
|
|
2251
|
+
if (sseReconnectTimer) return;
|
|
2252
|
+
|
|
2253
|
+
sseReconnectTimer = setTimeout(() => {
|
|
2254
|
+
sseReconnectTimer = null;
|
|
2255
|
+
connectEventStream();
|
|
2256
|
+
}, sseBackoffMs);
|
|
2257
|
+
|
|
2258
|
+
sseBackoffMs = Math.min(SSE_MAX_BACKOFF_MS, Math.floor(sseBackoffMs * 1.8));
|
|
2259
|
+
};
|
|
2260
|
+
|
|
2261
|
+
['message_posted', 'task_created', 'task_assigned', 'task_updated', 'presence_updated', 'memory_written', 'batch']
|
|
2262
|
+
.forEach(type => es.addEventListener(type, onAnyEvent));
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
document.addEventListener('visibilitychange', () => {
|
|
2266
|
+
startAdaptiveRefresh();
|
|
2267
|
+
if (!document.hidden && !eventSource) connectEventStream();
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
window.addEventListener('beforeunload', () => {
|
|
2271
|
+
if (eventSource) {
|
|
2272
|
+
eventSource.close();
|
|
2273
|
+
eventSource = null;
|
|
2274
|
+
}
|
|
2275
|
+
if (sseReconnectTimer) {
|
|
2276
|
+
clearTimeout(sseReconnectTimer);
|
|
2277
|
+
sseReconnectTimer = null;
|
|
2278
|
+
}
|
|
2279
|
+
});
|
|
2280
|
+
|
|
2281
|
+
// ---- Task Modal ----
|
|
2282
|
+
let currentTask = null;
|
|
2283
|
+
|
|
2284
|
+
function setTaskModalInteractivity(enabled) {
|
|
2285
|
+
document.querySelectorAll('.status-btn').forEach(btn => {
|
|
2286
|
+
btn.disabled = !enabled;
|
|
2287
|
+
});
|
|
2288
|
+
const assigneeInput = document.getElementById('modal-task-assignee');
|
|
2289
|
+
if (assigneeInput) assigneeInput.disabled = !enabled;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// --- Artifacts (task modal) ---
|
|
2293
|
+
let taskArtifactsPreviewCache = new Map(); // taskId -> Map(path -> artifact)
|
|
2294
|
+
|
|
2295
|
+
function renderTaskArtifactsLoading() {
|
|
2296
|
+
const el = document.getElementById('modal-task-artifacts');
|
|
2297
|
+
if (!el) return;
|
|
2298
|
+
el.innerHTML = '<div class="empty" style="color:var(--text-muted)">Loading artifacts…</div>';
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
function renderTaskArtifactsEmpty(message) {
|
|
2302
|
+
const el = document.getElementById('modal-task-artifacts');
|
|
2303
|
+
if (!el) return;
|
|
2304
|
+
el.innerHTML = '<div class="empty" style="color:var(--text-muted)">' + esc(message || 'No artifacts attached') + '</div>';
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
function renderTaskArtifactsError(message) {
|
|
2308
|
+
const el = document.getElementById('modal-task-artifacts');
|
|
2309
|
+
if (!el) return;
|
|
2310
|
+
el.innerHTML = '<div class="empty" style="color:var(--red)">' + esc(message || 'Failed to load artifacts') + '</div>';
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
async function fetchTaskArtifacts(taskId, includeMode) {
|
|
2314
|
+
const qs = includeMode ? ('?include=' + encodeURIComponent(includeMode)) : '';
|
|
2315
|
+
const url = BASE + '/tasks/' + encodeURIComponent(taskId) + '/artifacts' + qs;
|
|
2316
|
+
const res = await fetch(url);
|
|
2317
|
+
if (!res.ok) {
|
|
2318
|
+
throw new Error('HTTP ' + res.status);
|
|
2319
|
+
}
|
|
2320
|
+
return await res.json();
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function renderArtifactRow(taskId, a, idx) {
|
|
2324
|
+
const path = String(a.path || '');
|
|
2325
|
+
const source = String(a.source || '');
|
|
2326
|
+
const type = String(a.type || '');
|
|
2327
|
+
const accessible = Boolean(a.accessible);
|
|
2328
|
+
|
|
2329
|
+
const pill = '<span class="artifact-pill ' + (accessible ? 'ok' : 'missing') + '">' + (accessible ? 'OK' : 'MISSING') + '</span>';
|
|
2330
|
+
|
|
2331
|
+
const metaParts = [];
|
|
2332
|
+
if (type) metaParts.push(type);
|
|
2333
|
+
if (source) metaParts.push(source);
|
|
2334
|
+
if (!accessible && a.error) metaParts.push(String(a.error));
|
|
2335
|
+
|
|
2336
|
+
const previewElId = 'artifact-preview-' + idx;
|
|
2337
|
+
const encTaskId = encodeURIComponent(taskId);
|
|
2338
|
+
const encPath = encodeURIComponent(path);
|
|
2339
|
+
|
|
2340
|
+
let actions = '';
|
|
2341
|
+
if (accessible) {
|
|
2342
|
+
if (type === 'file' && path.startsWith('process/')) {
|
|
2343
|
+
actions += '<button class="artifact-btn" onclick="toggleArtifactPreview(\'' + encTaskId + '\',\'' + encPath + '\',\'' + previewElId + '\')">Preview</button>';
|
|
2344
|
+
}
|
|
2345
|
+
if (type === 'url') {
|
|
2346
|
+
const url = String(a.resolvedPath || a.path || '');
|
|
2347
|
+
if (url) {
|
|
2348
|
+
actions += '<a class="artifact-btn" href="' + esc(url) + '" target="_blank" rel="noreferrer noopener">Open ↗</a>';
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
const actionsHtml = actions ? '<div class="artifact-actions">' + actions + '</div>' : '';
|
|
2354
|
+
|
|
2355
|
+
const previewBox = '<pre id="' + esc(previewElId) + '" class="artifact-preview" style="display:none;margin-top:10px;white-space:pre-wrap;word-break:break-word;background:#0f141a;border:1px solid var(--border-subtle);border-radius:10px;padding:10px;font-size:12px;line-height:1.5"></pre>';
|
|
2356
|
+
|
|
2357
|
+
return '<div class="artifact-row">'
|
|
2358
|
+
+ '<div class="artifact-top">'
|
|
2359
|
+
+ '<div class="artifact-path">' + esc(path || '(missing path)') + '</div>'
|
|
2360
|
+
+ pill
|
|
2361
|
+
+ '</div>'
|
|
2362
|
+
+ '<div class="artifact-meta">' + esc(metaParts.filter(Boolean).join(' · ') || '—') + '</div>'
|
|
2363
|
+
+ actionsHtml
|
|
2364
|
+
+ previewBox
|
|
2365
|
+
+ '</div>';
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
async function loadTaskArtifacts(taskId) {
|
|
2369
|
+
const el = document.getElementById('modal-task-artifacts');
|
|
2370
|
+
if (!el) return;
|
|
2371
|
+
|
|
2372
|
+
if (!taskId) {
|
|
2373
|
+
renderTaskArtifactsEmpty('Not available');
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
renderTaskArtifactsLoading();
|
|
2378
|
+
taskArtifactsPreviewCache.delete(taskId);
|
|
2379
|
+
|
|
2380
|
+
try {
|
|
2381
|
+
const data = await fetchTaskArtifacts(taskId);
|
|
2382
|
+
const artifacts = Array.isArray(data.artifacts) ? data.artifacts : [];
|
|
2383
|
+
|
|
2384
|
+
if (artifacts.length === 0) {
|
|
2385
|
+
renderTaskArtifactsEmpty();
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
el.innerHTML = artifacts.map((a, i) => renderArtifactRow(taskId, a, i)).join('');
|
|
2390
|
+
} catch (err) {
|
|
2391
|
+
renderTaskArtifactsError('Failed to load artifacts');
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
async function getPreviewMapForTask(taskId) {
|
|
2396
|
+
if (taskArtifactsPreviewCache.has(taskId)) return taskArtifactsPreviewCache.get(taskId);
|
|
2397
|
+
|
|
2398
|
+
const data = await fetchTaskArtifacts(taskId, 'preview');
|
|
2399
|
+
const artifacts = Array.isArray(data.artifacts) ? data.artifacts : [];
|
|
2400
|
+
const m = new Map();
|
|
2401
|
+
artifacts.forEach(a => {
|
|
2402
|
+
if (a && a.path) m.set(String(a.path), a);
|
|
2403
|
+
});
|
|
2404
|
+
taskArtifactsPreviewCache.set(taskId, m);
|
|
2405
|
+
return m;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
async function toggleArtifactPreview(encTaskId, encPath, previewElId) {
|
|
2409
|
+
const taskId = decodeURIComponent(encTaskId || '');
|
|
2410
|
+
const path = decodeURIComponent(encPath || '');
|
|
2411
|
+
const el = document.getElementById(previewElId);
|
|
2412
|
+
if (!el) return;
|
|
2413
|
+
|
|
2414
|
+
// toggle
|
|
2415
|
+
const isHidden = el.style.display === 'none' || !el.style.display;
|
|
2416
|
+
if (!isHidden) {
|
|
2417
|
+
el.style.display = 'none';
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
el.style.display = 'block';
|
|
2421
|
+
|
|
2422
|
+
// already loaded
|
|
2423
|
+
if (el.dataset.loaded === '1') return;
|
|
2424
|
+
|
|
2425
|
+
el.textContent = 'Loading preview…';
|
|
2426
|
+
|
|
2427
|
+
try {
|
|
2428
|
+
const m = await getPreviewMapForTask(taskId);
|
|
2429
|
+
const a = m.get(path);
|
|
2430
|
+
|
|
2431
|
+
if (!a || !a.preview) {
|
|
2432
|
+
el.textContent = 'Preview not available (only process/* file artifacts are previewable).';
|
|
2433
|
+
el.dataset.loaded = '1';
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
const truncated = Boolean(a.previewTruncated);
|
|
2438
|
+
el.textContent = String(a.preview) + (truncated ? '\n\n[truncated]' : '');
|
|
2439
|
+
el.dataset.loaded = '1';
|
|
2440
|
+
} catch (err) {
|
|
2441
|
+
el.textContent = 'Failed to load preview';
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function openTaskModal(taskId) {
|
|
2446
|
+
currentTask = allTasks.find(t => t.id === taskId);
|
|
2447
|
+
|
|
2448
|
+
if (!currentTask) {
|
|
2449
|
+
setTaskModalInteractivity(false);
|
|
2450
|
+
document.getElementById('modal-task-title').textContent = 'Task not found: ' + (taskId || '(missing id)');
|
|
2451
|
+
document.getElementById('modal-task-desc').textContent = 'This task ID was referenced in chat but is not present in the current task set. It may be archived, deleted, or not yet synced.';
|
|
2452
|
+
document.getElementById('modal-task-id').textContent = taskId || '(missing id)';
|
|
2453
|
+
document.getElementById('modal-task-assignee').value = '';
|
|
2454
|
+
document.getElementById('modal-task-priority').textContent = '—';
|
|
2455
|
+
document.getElementById('modal-task-created').textContent = 'Not available';
|
|
2456
|
+
const blockerEl = document.getElementById('modal-task-blockers');
|
|
2457
|
+
if (blockerEl) blockerEl.textContent = 'Not available';
|
|
2458
|
+
renderTaskArtifactsEmpty('Not available');
|
|
2459
|
+
document.querySelectorAll('.status-btn').forEach(btn => btn.classList.remove('active'));
|
|
2460
|
+
document.getElementById('task-modal').classList.add('show');
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
setTaskModalInteractivity(true);
|
|
2465
|
+
const creatorAgent = AGENTS.find(a => a.name === currentTask.createdBy);
|
|
2466
|
+
const createdText = creatorAgent
|
|
2467
|
+
? `${currentTask.createdBy} (${creatorAgent.role}) • ${ago(currentTask.createdAt)}`
|
|
2468
|
+
: `${currentTask.createdBy} • ${ago(currentTask.createdAt)}`;
|
|
2469
|
+
|
|
2470
|
+
document.getElementById('modal-task-title').textContent = currentTask.title;
|
|
2471
|
+
document.getElementById('modal-task-desc').textContent = currentTask.description || '(no description)';
|
|
2472
|
+
document.getElementById('modal-task-id').textContent = currentTask.id || '(missing id)';
|
|
2473
|
+
document.getElementById('modal-task-assignee').value = currentTask.assignee || '';
|
|
2474
|
+
document.getElementById('modal-task-priority').textContent = currentTask.priority || 'P3';
|
|
2475
|
+
|
|
2476
|
+
// Branch display
|
|
2477
|
+
const branchSection = document.getElementById('modal-branch-section');
|
|
2478
|
+
const branchEl = document.getElementById('modal-task-branch');
|
|
2479
|
+
if (branchSection && branchEl) {
|
|
2480
|
+
const branch = currentTask.metadata?.branch;
|
|
2481
|
+
if (branch) {
|
|
2482
|
+
branchEl.textContent = branch;
|
|
2483
|
+
branchSection.style.display = '';
|
|
2484
|
+
} else {
|
|
2485
|
+
branchSection.style.display = 'none';
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
document.getElementById('modal-task-created').textContent = createdText;
|
|
2490
|
+
|
|
2491
|
+
const blockerEl = document.getElementById('modal-task-blockers');
|
|
2492
|
+
if (blockerEl) {
|
|
2493
|
+
const blockedHtml = renderBlockedByLinks(currentTask) || '<span style="color:var(--text-dim)">None</span>';
|
|
2494
|
+
blockerEl.innerHTML = blockedHtml;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
// Set active status button
|
|
2498
|
+
document.querySelectorAll('.status-btn').forEach(btn => {
|
|
2499
|
+
btn.classList.toggle('active', btn.dataset.status === currentTask.status);
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
document.getElementById('task-modal').classList.add('show');
|
|
2503
|
+
|
|
2504
|
+
// Load artifacts section
|
|
2505
|
+
loadTaskArtifacts(currentTask.id);
|
|
2506
|
+
|
|
2507
|
+
// Load PR review quality panel
|
|
2508
|
+
loadPrReviewPanel(currentTask);
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
function formatDuration(sec) {
|
|
2512
|
+
if (sec == null) return '';
|
|
2513
|
+
if (sec < 60) return sec + 's';
|
|
2514
|
+
return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
async function loadPrReviewPanel(task) {
|
|
2518
|
+
const panel = document.getElementById('pr-review-panel');
|
|
2519
|
+
const loading = document.getElementById('pr-review-loading');
|
|
2520
|
+
const content = document.getElementById('pr-review-content');
|
|
2521
|
+
if (!panel || !loading || !content) return;
|
|
2522
|
+
|
|
2523
|
+
// Check if task might have PR data
|
|
2524
|
+
const prUrl = extractTaskPrLink(task);
|
|
2525
|
+
const isReviewable = task && (task.status === 'validating' || task.status === 'done' || prUrl);
|
|
2526
|
+
if (!isReviewable) {
|
|
2527
|
+
panel.style.display = 'none';
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
panel.style.display = '';
|
|
2532
|
+
loading.style.display = '';
|
|
2533
|
+
content.style.display = 'none';
|
|
2534
|
+
|
|
2535
|
+
try {
|
|
2536
|
+
const res = await fetch(BASE + '/tasks/' + encodeURIComponent(task.id) + '/pr-review');
|
|
2537
|
+
const data = await res.json();
|
|
2538
|
+
|
|
2539
|
+
if (!data.available) {
|
|
2540
|
+
panel.style.display = 'none';
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
loading.style.display = 'none';
|
|
2545
|
+
content.style.display = '';
|
|
2546
|
+
content.innerHTML = renderPrReviewPanel(data);
|
|
2547
|
+
} catch (e) {
|
|
2548
|
+
panel.style.display = 'none';
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
function renderPrReviewPanel(data) {
|
|
2553
|
+
const pr = data.pr || {};
|
|
2554
|
+
const diff = data.diffScope || {};
|
|
2555
|
+
const ci = data.ci || {};
|
|
2556
|
+
const alignment = data.doneCriteriaAlignment || {};
|
|
2557
|
+
|
|
2558
|
+
let html = '';
|
|
2559
|
+
|
|
2560
|
+
// PR Header
|
|
2561
|
+
html += '<div class="pr-review-header">';
|
|
2562
|
+
html += '<div class="pr-title">' + esc(pr.title || 'PR #' + pr.number) + '</div>';
|
|
2563
|
+
html += '<div class="pr-meta">';
|
|
2564
|
+
html += (pr.state === 'closed' && pr.merged ? '🟣 Merged' : pr.state === 'closed' ? '🔴 Closed' : '🟢 Open');
|
|
2565
|
+
html += ' · ' + esc(pr.author || 'unknown');
|
|
2566
|
+
if (pr.updatedAt) html += ' · Updated ' + ago(new Date(pr.updatedAt).getTime());
|
|
2567
|
+
html += ' · <a href="' + esc(pr.url) + '" target="_blank">View on GitHub ↗</a>';
|
|
2568
|
+
html += '</div></div>';
|
|
2569
|
+
|
|
2570
|
+
// Diff Scope
|
|
2571
|
+
html += '<div class="pr-review-section">';
|
|
2572
|
+
html += '<div class="pr-review-section-title">📊 Diff Scope <span class="risk-badge ' + esc(diff.riskLevel || 'small') + '">' + esc(diff.riskLevel || 'small') + ' change</span></div>';
|
|
2573
|
+
html += '<div class="diff-scope-grid">';
|
|
2574
|
+
html += '<div class="diff-stat-card"><div class="stat-value">' + (diff.changedFiles || 0) + '</div><div class="stat-label">Files</div></div>';
|
|
2575
|
+
html += '<div class="diff-stat-card"><div class="stat-value" style="color:var(--green)">+' + (diff.additions || 0) + '</div><div class="stat-label">Added</div></div>';
|
|
2576
|
+
html += '<div class="diff-stat-card"><div class="stat-value" style="color:var(--red)">-' + (diff.deletions || 0) + '</div><div class="stat-label">Deleted</div></div>';
|
|
2577
|
+
html += '<div class="diff-stat-card"><div class="stat-value">' + (diff.commits || 0) + '</div><div class="stat-label">Commits</div></div>';
|
|
2578
|
+
html += '</div>';
|
|
2579
|
+
|
|
2580
|
+
// Directory breakdown
|
|
2581
|
+
if (diff.directories && diff.directories.length > 0) {
|
|
2582
|
+
html += '<div style="margin-top:6px">';
|
|
2583
|
+
diff.directories.slice(0, 8).forEach(function(d) {
|
|
2584
|
+
html += '<div class="dir-row">';
|
|
2585
|
+
html += '<span class="dir-name">' + esc(d.dir) + '/</span>';
|
|
2586
|
+
html += '<span class="dir-stats">' + d.files + ' file' + (d.files !== 1 ? 's' : '') + ' <span style="color:var(--green)">+' + d.additions + '</span> / <span style="color:var(--red)">-' + d.deletions + '</span></span>';
|
|
2587
|
+
html += '</div>';
|
|
2588
|
+
});
|
|
2589
|
+
html += '</div>';
|
|
2590
|
+
}
|
|
2591
|
+
html += '</div>';
|
|
2592
|
+
|
|
2593
|
+
// CI Checks
|
|
2594
|
+
if (ci.total > 0 || (ci.qaBundleChecks && ci.qaBundleChecks.length > 0)) {
|
|
2595
|
+
html += '<div class="pr-review-section">';
|
|
2596
|
+
const allPass = ci.failed === 0 && ci.total > 0;
|
|
2597
|
+
html += '<div class="pr-review-section-title">' + (allPass ? '✅' : '❌') + ' CI Checks (' + ci.passed + '/' + ci.total + ' passed)</div>';
|
|
2598
|
+
|
|
2599
|
+
ci.checks.forEach(function(c) {
|
|
2600
|
+
const icon = c.conclusion === 'success' ? '✅' : c.conclusion === 'failure' ? '❌' : c.conclusion === 'skipped' ? '⏭️' : '⏳';
|
|
2601
|
+
html += '<div class="ci-check-row">';
|
|
2602
|
+
html += '<span class="check-icon">' + icon + '</span>';
|
|
2603
|
+
html += '<span class="check-name">' + esc(c.name) + '</span>';
|
|
2604
|
+
if (c.durationSec != null) html += '<span class="check-duration">' + formatDuration(c.durationSec) + '</span>';
|
|
2605
|
+
if (c.detailsUrl) html += '<a href="' + esc(c.detailsUrl) + '" target="_blank">logs</a>';
|
|
2606
|
+
html += '</div>';
|
|
2607
|
+
});
|
|
2608
|
+
|
|
2609
|
+
// QA bundle manual checks
|
|
2610
|
+
if (ci.qaBundleChecks && ci.qaBundleChecks.length > 0) {
|
|
2611
|
+
html += '<div style="margin-top:8px;font-size:11px;color:var(--text-muted);font-weight:600">Manual QA</div>';
|
|
2612
|
+
ci.qaBundleChecks.forEach(function(c) {
|
|
2613
|
+
html += '<div class="ci-check-row"><span class="check-icon">✓</span><span class="check-name" style="color:var(--text-muted)">' + esc(c) + '</span></div>';
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
html += '</div>';
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// Done Criteria Alignment
|
|
2621
|
+
if (alignment.criteria && alignment.criteria.length > 0) {
|
|
2622
|
+
const summary = alignment.summary || {};
|
|
2623
|
+
const coverageIcon = summary.none === 0 ? '✅' : summary.none <= 1 ? '⚠️' : '❌';
|
|
2624
|
+
html += '<div class="pr-review-section">';
|
|
2625
|
+
html += '<div class="pr-review-section-title">' + coverageIcon + ' Done Criteria (' + (summary.total - summary.none) + '/' + summary.total + ' aligned)</div>';
|
|
2626
|
+
|
|
2627
|
+
alignment.criteria.forEach(function(c, i) {
|
|
2628
|
+
const icon = c.confidence === 'high' ? '✅' : c.confidence === 'medium' ? '🟡' : c.confidence === 'low' ? '⚠️' : '❌';
|
|
2629
|
+
html += '<div class="criterion-row">';
|
|
2630
|
+
html += '<div class="criterion-text"><span>' + icon + '</span> <span>' + esc(c.criterion) + '</span></div>';
|
|
2631
|
+
html += '<div class="criterion-evidence">';
|
|
2632
|
+
html += '<span class="confidence-badge ' + esc(c.confidence) + '">' + esc(c.confidence) + '</span>';
|
|
2633
|
+
if (c.fileMatches && c.fileMatches.length > 0) {
|
|
2634
|
+
html += '<div class="evidence-item">Files: ' + c.fileMatches.map(function(f) { return '<code style="font-size:10px">' + esc(f) + '</code>'; }).join(', ') + '</div>';
|
|
2635
|
+
}
|
|
2636
|
+
if (c.testMatches && c.testMatches.length > 0) {
|
|
2637
|
+
html += '<div class="evidence-item">Tests: ' + c.testMatches.map(function(t) { return esc(t); }).join(', ') + '</div>';
|
|
2638
|
+
}
|
|
2639
|
+
if (c.hasArtifact) {
|
|
2640
|
+
html += '<div class="evidence-item">Artifact: present</div>';
|
|
2641
|
+
}
|
|
2642
|
+
if (c.confidence === 'none') {
|
|
2643
|
+
html += '<div class="evidence-item" style="color:var(--red)">⚠ No matching evidence — manual review needed</div>';
|
|
2644
|
+
}
|
|
2645
|
+
html += '</div></div>';
|
|
2646
|
+
});
|
|
2647
|
+
|
|
2648
|
+
html += '<div style="font-size:10px;color:var(--text-dim);margin-top:6px">Confidence: ' + summary.high + ' high, ' + summary.medium + ' medium, ' + summary.low + ' low, ' + summary.none + ' none</div>';
|
|
2649
|
+
html += '</div>';
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
return html;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
async function copyTaskId() {
|
|
2656
|
+
const taskId = currentTask && currentTask.id ? currentTask.id : document.getElementById('modal-task-id').textContent;
|
|
2657
|
+
if (!taskId) return;
|
|
2658
|
+
try {
|
|
2659
|
+
await navigator.clipboard.writeText(taskId);
|
|
2660
|
+
} catch (_e) {
|
|
2661
|
+
// Fallback for older browser contexts
|
|
2662
|
+
const ta = document.createElement('textarea');
|
|
2663
|
+
ta.value = taskId;
|
|
2664
|
+
ta.style.position = 'fixed';
|
|
2665
|
+
ta.style.opacity = '0';
|
|
2666
|
+
document.body.appendChild(ta);
|
|
2667
|
+
ta.focus();
|
|
2668
|
+
ta.select();
|
|
2669
|
+
document.execCommand('copy');
|
|
2670
|
+
document.body.removeChild(ta);
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function closeTaskModal() {
|
|
2675
|
+
document.getElementById('task-modal').classList.remove('show');
|
|
2676
|
+
currentTask = null;
|
|
2677
|
+
setTaskModalInteractivity(true);
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
async function updateTaskStatus(status) {
|
|
2681
|
+
if (!currentTask) return;
|
|
2682
|
+
try {
|
|
2683
|
+
const r = await fetch(`${BASE}/tasks/${currentTask.id}`, {
|
|
2684
|
+
method: 'PATCH',
|
|
2685
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2686
|
+
body: JSON.stringify({ status })
|
|
2687
|
+
});
|
|
2688
|
+
if (r.ok) {
|
|
2689
|
+
await loadTasks();
|
|
2690
|
+
closeTaskModal();
|
|
2691
|
+
}
|
|
2692
|
+
} catch (e) {
|
|
2693
|
+
console.error('Failed to update task status:', e);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
async function updateTaskAssignee() {
|
|
2698
|
+
if (!currentTask) return;
|
|
2699
|
+
const assignee = document.getElementById('modal-task-assignee').value.trim() || undefined;
|
|
2700
|
+
try {
|
|
2701
|
+
const r = await fetch(`${BASE}/tasks/${currentTask.id}`, {
|
|
2702
|
+
method: 'PATCH',
|
|
2703
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2704
|
+
body: JSON.stringify({ assignee })
|
|
2705
|
+
});
|
|
2706
|
+
if (r.ok) {
|
|
2707
|
+
await loadTasks();
|
|
2708
|
+
currentTask.assignee = assignee;
|
|
2709
|
+
}
|
|
2710
|
+
} catch (e) {
|
|
2711
|
+
console.error('Failed to update task assignee:', e);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// ============ FOCUS MODE ============
|
|
2716
|
+
|
|
2717
|
+
function toggleFocusMode() {
|
|
2718
|
+
focusModeActive = !focusModeActive;
|
|
2719
|
+
document.body.classList.toggle('focus-mode', focusModeActive);
|
|
2720
|
+
const btn = document.getElementById('focus-toggle');
|
|
2721
|
+
if (btn) btn.classList.toggle('active', focusModeActive);
|
|
2722
|
+
|
|
2723
|
+
// Persist preference
|
|
2724
|
+
try { localStorage.setItem('reflectt-focus-mode', focusModeActive ? '1' : '0'); } catch {}
|
|
2725
|
+
|
|
2726
|
+
// Re-render kanban to add/remove QA contract details
|
|
2727
|
+
renderKanban();
|
|
2728
|
+
|
|
2729
|
+
// Toggle collapsed panels — allow click to temporarily expand
|
|
2730
|
+
document.querySelectorAll('.panel.focus-collapse').forEach(panel => {
|
|
2731
|
+
if (!panel.dataset.focusClickBound) {
|
|
2732
|
+
panel.addEventListener('click', () => {
|
|
2733
|
+
if (!focusModeActive) return;
|
|
2734
|
+
panel.classList.toggle('focus-expanded');
|
|
2735
|
+
if (panel.classList.contains('focus-expanded')) {
|
|
2736
|
+
panel.style.opacity = '1';
|
|
2737
|
+
panel.querySelectorAll('.panel-body, .channel-tabs, .chat-input-bar, .project-tabs, .kanban').forEach(el => {
|
|
2738
|
+
el.style.display = '';
|
|
2739
|
+
});
|
|
2740
|
+
} else {
|
|
2741
|
+
panel.style.opacity = '';
|
|
2742
|
+
// CSS will re-hide via focus-collapse rules
|
|
2743
|
+
}
|
|
2744
|
+
});
|
|
2745
|
+
panel.dataset.focusClickBound = 'true';
|
|
2746
|
+
}
|
|
2747
|
+
// Reset expanded state when toggling focus mode
|
|
2748
|
+
panel.classList.remove('focus-expanded');
|
|
2749
|
+
panel.style.opacity = '';
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function renderQaContract(task) {
|
|
2754
|
+
if (!focusModeActive) return '';
|
|
2755
|
+
const meta = task.metadata || {};
|
|
2756
|
+
const reviewer = task.reviewer || null;
|
|
2757
|
+
const eta = meta.eta || null;
|
|
2758
|
+
const hasArtifact = !!(meta.artifact_path || (Array.isArray(meta.artifacts) && meta.artifacts.length > 0));
|
|
2759
|
+
const prUrl = extractTaskPrLink(task);
|
|
2760
|
+
|
|
2761
|
+
return `<div class="qa-contract">
|
|
2762
|
+
<div class="qa-row">
|
|
2763
|
+
<span class="qa-label">Owner</span>
|
|
2764
|
+
<span class="qa-value">${task.assignee ? esc(task.assignee) : '<span class="missing">unassigned</span>'}</span>
|
|
2765
|
+
</div>
|
|
2766
|
+
<div class="qa-row">
|
|
2767
|
+
<span class="qa-label">Reviewer</span>
|
|
2768
|
+
<span class="qa-value${!reviewer ? ' missing' : ''}">${reviewer ? esc(reviewer) : 'none'}</span>
|
|
2769
|
+
</div>
|
|
2770
|
+
<div class="qa-row">
|
|
2771
|
+
<span class="qa-label">ETA</span>
|
|
2772
|
+
<span class="qa-value${!eta ? ' missing' : ''}">${eta ? esc(String(eta)) : 'not set'}</span>
|
|
2773
|
+
</div>
|
|
2774
|
+
<div class="qa-row">
|
|
2775
|
+
<span class="qa-label">Artifact</span>
|
|
2776
|
+
<span class="qa-value${hasArtifact ? ' has-artifact' : ' missing'}">${hasArtifact ? (prUrl ? '<a href="' + esc(prUrl) + '" target="_blank" style="color:var(--green)">PR ↗</a>' : '✓ present') : 'none'}</span>
|
|
2777
|
+
</div>
|
|
2778
|
+
</div>`;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
// Restore focus mode from localStorage
|
|
2782
|
+
(function restoreFocusMode() {
|
|
2783
|
+
try {
|
|
2784
|
+
if (localStorage.getItem('reflectt-focus-mode') === '1') {
|
|
2785
|
+
focusModeActive = true;
|
|
2786
|
+
document.body.classList.add('focus-mode');
|
|
2787
|
+
const btn = document.getElementById('focus-toggle');
|
|
2788
|
+
if (btn) btn.classList.add('active');
|
|
2789
|
+
}
|
|
2790
|
+
} catch {}
|
|
2791
|
+
})();
|
|
2792
|
+
|
|
2793
|
+
// ── Getting Started panel ──────────────────────────────────
|
|
2794
|
+
async function checkGettingStarted() {
|
|
2795
|
+
const panel = document.getElementById('getting-started');
|
|
2796
|
+
if (!panel) return;
|
|
2797
|
+
|
|
2798
|
+
// Respect manual dismiss
|
|
2799
|
+
try {
|
|
2800
|
+
if (localStorage.getItem('reflectt-gs-dismissed') === '1') {
|
|
2801
|
+
panel.classList.add('hidden');
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
} catch {}
|
|
2805
|
+
|
|
2806
|
+
// Check system state to auto-hide and mark steps done
|
|
2807
|
+
try {
|
|
2808
|
+
const res = await fetch(BASE + '/health');
|
|
2809
|
+
if (!res.ok) return;
|
|
2810
|
+
const health = await res.json();
|
|
2811
|
+
|
|
2812
|
+
// Step 1 done: check if system health loops are ticking (not just uptime > 0)
|
|
2813
|
+
const hasHeartbeat = !!(health.system?.loops?.lastTickAt || (health.tasks?.total > 0));
|
|
2814
|
+
const hasTasks = (health.tasks?.total || 0) > 0;
|
|
2815
|
+
const hasMessages = (health.chat?.total || 0) > 0;
|
|
2816
|
+
|
|
2817
|
+
// Step 1: preflight — done if server is healthy
|
|
2818
|
+
const step1 = document.getElementById('gs-preflight');
|
|
2819
|
+
if (step1 && hasHeartbeat) {
|
|
2820
|
+
step1.classList.add('done');
|
|
2821
|
+
step1.querySelector('.gs-icon').textContent = '✓';
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// Step 2: connect — check if OpenClaw gateway is configured
|
|
2825
|
+
const step2 = document.getElementById('gs-connect');
|
|
2826
|
+
if (step2 && health.openclaw) {
|
|
2827
|
+
const ocStatus = typeof health.openclaw === 'string' ? health.openclaw : health.openclaw.status;
|
|
2828
|
+
if (ocStatus === 'configured') {
|
|
2829
|
+
step2.classList.add('done');
|
|
2830
|
+
step2.querySelector('.gs-icon').textContent = '✓';
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// Step 3: first task/message — done if any exist
|
|
2835
|
+
const step3 = document.getElementById('gs-task');
|
|
2836
|
+
if (step3 && (hasTasks || hasMessages)) {
|
|
2837
|
+
step3.classList.add('done');
|
|
2838
|
+
step3.querySelector('.gs-icon').textContent = '✓';
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
// Auto-hide if all steps done
|
|
2842
|
+
const allDone = panel.querySelectorAll('.gs-step.done').length === 3;
|
|
2843
|
+
if (allDone) {
|
|
2844
|
+
panel.classList.add('hidden');
|
|
2845
|
+
}
|
|
2846
|
+
} catch {}
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
function dismissGettingStarted() {
|
|
2850
|
+
const panel = document.getElementById('getting-started');
|
|
2851
|
+
if (panel) panel.classList.add('hidden');
|
|
2852
|
+
try { localStorage.setItem('reflectt-gs-dismissed', '1'); } catch {}
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
updateClock();
|
|
2856
|
+
setInterval(updateClock, 30000);
|
|
2857
|
+
checkGettingStarted();
|
|
2858
|
+
refresh();
|
|
2859
|
+
connectEventStream();
|
|
2860
|
+
startAdaptiveRefresh();
|
|
2861
|
+
// ── Pause banner ──
|
|
2862
|
+
async function checkPauseBanner() {
|
|
2863
|
+
try {
|
|
2864
|
+
const res = await fetch(BASE + '/pause/status');
|
|
2865
|
+
const data = await res.json();
|
|
2866
|
+
const banner = document.getElementById('pause-banner');
|
|
2867
|
+
const msgEl = document.getElementById('pause-message');
|
|
2868
|
+
if (!banner) return;
|
|
2869
|
+
|
|
2870
|
+
const activeEntries = (data.entries || []).filter(e => e.paused);
|
|
2871
|
+
if (activeEntries.length > 0) {
|
|
2872
|
+
const entry = activeEntries[0];
|
|
2873
|
+
const target = entry.target === '__team__' ? 'Team' : entry.target;
|
|
2874
|
+
let msg = `${target} paused by ${entry.pausedBy}: ${entry.reason}`;
|
|
2875
|
+
if (entry.pausedUntil) {
|
|
2876
|
+
const remaining = Math.max(0, Math.ceil((entry.pausedUntil - Date.now()) / 60000));
|
|
2877
|
+
msg += ` (${remaining}m remaining)`;
|
|
2878
|
+
} else {
|
|
2879
|
+
msg += ' (indefinite)';
|
|
2880
|
+
}
|
|
2881
|
+
msgEl.textContent = msg;
|
|
2882
|
+
banner.style.display = 'flex';
|
|
2883
|
+
} else if (data.paused) {
|
|
2884
|
+
msgEl.textContent = data.message || 'Paused';
|
|
2885
|
+
banner.style.display = 'flex';
|
|
2886
|
+
} else {
|
|
2887
|
+
banner.style.display = 'none';
|
|
2888
|
+
}
|
|
2889
|
+
} catch {}
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
async function resumeFromBanner() {
|
|
2893
|
+
try {
|
|
2894
|
+
// Try to unpause team first, then individual entries
|
|
2895
|
+
await fetch(BASE + '/pause?target=team', { method: 'DELETE' });
|
|
2896
|
+
const banner = document.getElementById('pause-banner');
|
|
2897
|
+
if (banner) banner.style.display = 'none';
|
|
2898
|
+
checkPauseBanner();
|
|
2899
|
+
} catch {}
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// ── Pause toggle button ──
|
|
2903
|
+
// Poll pause status every 30s
|
|
2904
|
+
setInterval(checkPauseBanner, 30000);
|
|
2905
|
+
checkPauseBanner();
|
|
2906
|
+
|
|
2907
|
+
// ═══ TEAM INTENSITY ═══
|
|
2908
|
+
|
|
2909
|
+
async function loadIntensityControl() {
|
|
2910
|
+
const control = document.getElementById('intensity-control');
|
|
2911
|
+
if (!control) return;
|
|
2912
|
+
try {
|
|
2913
|
+
const r = await fetch(BASE + '/policy/intensity');
|
|
2914
|
+
if (!r.ok) return;
|
|
2915
|
+
const data = await r.json();
|
|
2916
|
+
const preset = data.preset || 'normal';
|
|
2917
|
+
const btns = control.querySelectorAll('.intensity-btn');
|
|
2918
|
+
btns.forEach(btn => {
|
|
2919
|
+
const isActive = btn.dataset.preset === preset;
|
|
2920
|
+
btn.classList.toggle('intensity-active', isActive);
|
|
2921
|
+
btn.setAttribute('aria-checked', String(isActive));
|
|
2922
|
+
btn.setAttribute('tabindex', isActive ? '0' : '-1');
|
|
2923
|
+
});
|
|
2924
|
+
const info = document.getElementById('intensity-info');
|
|
2925
|
+
if (info) {
|
|
2926
|
+
const l = data.limits || {};
|
|
2927
|
+
info.textContent = `WIP ${l.wipLimit || '?'} · ${l.maxPullsPerHour || '?'} pulls/hr` +
|
|
2928
|
+
(l.batchIntervalMs > 0 ? ` · ${Math.round(l.batchIntervalMs / 60000)}m batch` : '');
|
|
2929
|
+
}
|
|
2930
|
+
} catch {}
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
async function setIntensity(preset) {
|
|
2934
|
+
try {
|
|
2935
|
+
const r = await fetch(BASE + '/policy/intensity', {
|
|
2936
|
+
method: 'PUT',
|
|
2937
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2938
|
+
body: JSON.stringify({ preset, updatedBy: 'dashboard' }),
|
|
2939
|
+
});
|
|
2940
|
+
if (r.ok) await loadIntensityControl();
|
|
2941
|
+
} catch {}
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
// Keyboard nav for intensity radiogroup (arrow keys)
|
|
2945
|
+
document.addEventListener('keydown', (e) => {
|
|
2946
|
+
const control = document.getElementById('intensity-control');
|
|
2947
|
+
if (!control || !control.contains(document.activeElement)) return;
|
|
2948
|
+
const btns = Array.from(control.querySelectorAll('.intensity-btn'));
|
|
2949
|
+
const idx = btns.indexOf(document.activeElement);
|
|
2950
|
+
if (idx < 0) return;
|
|
2951
|
+
let next = -1;
|
|
2952
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % btns.length;
|
|
2953
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (idx - 1 + btns.length) % btns.length;
|
|
2954
|
+
if (next >= 0) {
|
|
2955
|
+
e.preventDefault();
|
|
2956
|
+
btns[next].focus();
|
|
2957
|
+
btns[next].click();
|
|
2958
|
+
}
|
|
2959
|
+
});
|
|
2960
|
+
|
|
2961
|
+
loadIntensityControl();
|
|
2962
|
+
|
|
2963
|
+
// ═══ PAUSE TOGGLE ═══
|
|
2964
|
+
|
|
2965
|
+
async function toggleTeamPause() {
|
|
2966
|
+
const btn = document.getElementById('pause-toggle-btn');
|
|
2967
|
+
if (!btn) return;
|
|
2968
|
+
|
|
2969
|
+
const isPaused = btn.classList.contains('paused');
|
|
2970
|
+
if (isPaused) {
|
|
2971
|
+
// Resume
|
|
2972
|
+
await fetch(BASE + '/pause?target=team', { method: 'DELETE' });
|
|
2973
|
+
} else {
|
|
2974
|
+
// Pause — prompt for duration
|
|
2975
|
+
const durStr = prompt('Pause duration in minutes (leave empty for indefinite):');
|
|
2976
|
+
const body = { target: 'team', reason: 'Dashboard pause', pausedBy: 'dashboard' };
|
|
2977
|
+
if (durStr && parseInt(durStr, 10) > 0) body.durationMin = parseInt(durStr, 10);
|
|
2978
|
+
await fetch(BASE + '/pause', {
|
|
2979
|
+
method: 'POST',
|
|
2980
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2981
|
+
body: JSON.stringify(body),
|
|
2982
|
+
});
|
|
2983
|
+
}
|
|
2984
|
+
await syncPauseToggle();
|
|
2985
|
+
await checkPauseBanner();
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
async function syncPauseToggle() {
|
|
2989
|
+
const btn = document.getElementById('pause-toggle-btn');
|
|
2990
|
+
if (!btn) return;
|
|
2991
|
+
try {
|
|
2992
|
+
const r = await fetch(BASE + '/pause/status');
|
|
2993
|
+
const data = await r.json();
|
|
2994
|
+
const entries = data.entries || [];
|
|
2995
|
+
const teamPaused = entries.some(e => e.target === '__team__' && e.paused);
|
|
2996
|
+
btn.classList.toggle('paused', teamPaused);
|
|
2997
|
+
btn.textContent = teamPaused ? '▶️ Resume' : '⏸️ Pause';
|
|
2998
|
+
btn.setAttribute('aria-label', teamPaused ? 'Resume team' : 'Pause team');
|
|
2999
|
+
} catch {}
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
syncPauseToggle();
|
|
3003
|
+
setInterval(syncPauseToggle, 30000);
|
|
3004
|
+
|
|
3005
|
+
// ═══ TEAM POLLS ═══
|
|
3006
|
+
|
|
3007
|
+
// Agent color palette for voter dots
|
|
3008
|
+
const VOTER_COLORS = ['#60a5fa','#f472b6','#34d399','#fbbf24','#a78bfa','#fb923c','#22d3ee','#e879f9'];
|
|
3009
|
+
function voterColor(name) {
|
|
3010
|
+
let h = 0;
|
|
3011
|
+
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
|
|
3012
|
+
return VOTER_COLORS[Math.abs(h) % VOTER_COLORS.length];
|
|
3013
|
+
}
|
|
3014
|
+
function voterInitial(name) {
|
|
3015
|
+
return (name || '?')[0].toUpperCase();
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
async function loadPolls() {
|
|
3019
|
+
const body = document.getElementById('polls-body');
|
|
3020
|
+
const count = document.getElementById('polls-count');
|
|
3021
|
+
if (!body) return;
|
|
3022
|
+
|
|
3023
|
+
try {
|
|
3024
|
+
const res = await fetch(BASE + '/polls?limit=10');
|
|
3025
|
+
const data = await res.json();
|
|
3026
|
+
const polls = data.polls || [];
|
|
3027
|
+
if (count) count.textContent = polls.length ? `(${polls.length})` : '';
|
|
3028
|
+
|
|
3029
|
+
if (!polls.length) {
|
|
3030
|
+
body.innerHTML = '<div class="empty" style="padding:8px 0;font-size:12px;color:var(--text-muted)">No polls yet. Click + New Poll to create one.</div>';
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
let html = '';
|
|
3035
|
+
for (const poll of polls) {
|
|
3036
|
+
const rr = await fetch(BASE + '/polls/' + poll.id);
|
|
3037
|
+
const rd = await rr.json();
|
|
3038
|
+
const results = rd.poll || poll;
|
|
3039
|
+
const isActive = results.status === 'active';
|
|
3040
|
+
const totalVotes = results.total_votes || 0;
|
|
3041
|
+
const tally = results.tally || [];
|
|
3042
|
+
const maxCount = Math.max(...tally.map(t => t.count), 1);
|
|
3043
|
+
|
|
3044
|
+
// Meta row
|
|
3045
|
+
html += '<div class="poll-card">';
|
|
3046
|
+
html += '<div class="poll-meta">';
|
|
3047
|
+
html += '<span>' + esc(agentLabel(results.created_by || 'unknown')) + '</span>';
|
|
3048
|
+
html += '<span>·</span>';
|
|
3049
|
+
html += '<span>' + timeAgo(results.created_at) + '</span>';
|
|
3050
|
+
html += isActive
|
|
3051
|
+
? '<span class="poll-badge-open">Open</span>'
|
|
3052
|
+
: '<span class="poll-badge-closed">Closed</span>';
|
|
3053
|
+
html += '</div>';
|
|
3054
|
+
|
|
3055
|
+
// Question
|
|
3056
|
+
html += '<div class="poll-question">' + esc(results.question) + '</div>';
|
|
3057
|
+
|
|
3058
|
+
// Options as radiogroup
|
|
3059
|
+
html += '<div role="radiogroup" aria-label="' + esc(results.question) + '">';
|
|
3060
|
+
for (let i = 0; i < tally.length; i++) {
|
|
3061
|
+
const t = tally[i];
|
|
3062
|
+
const pct = totalVotes > 0 ? Math.round(t.count / totalVotes * 100) : 0;
|
|
3063
|
+
const barWidth = maxCount > 0 ? Math.round(t.count / maxCount * 100) : 0;
|
|
3064
|
+
const voters = t.voters || [];
|
|
3065
|
+
const clickable = isActive ? ' onclick="votePoll(\'' + esc(results.id) + '\',' + i + ')"' : '';
|
|
3066
|
+
const tabIdx = isActive ? (i === 0 ? '0' : '-1') : '-1';
|
|
3067
|
+
|
|
3068
|
+
html += '<div class="poll-option" role="radio" aria-checked="false" aria-label="' + esc(t.option) + ', ' + pct + '%" tabindex="' + tabIdx + '"' + clickable + '>';
|
|
3069
|
+
html += '<div class="poll-option-bar" style="width:' + barWidth + '%"></div>';
|
|
3070
|
+
html += '<div class="poll-option-content">';
|
|
3071
|
+
html += '<div class="poll-option-label">';
|
|
3072
|
+
html += '<div class="poll-option-check"></div>';
|
|
3073
|
+
html += '<span>' + esc(t.option) + '</span>';
|
|
3074
|
+
html += '</div>';
|
|
3075
|
+
html += '<div class="poll-option-stats">';
|
|
3076
|
+
|
|
3077
|
+
// Voter dots
|
|
3078
|
+
if (voters.length > 0) {
|
|
3079
|
+
html += '<div class="poll-voter-dots">';
|
|
3080
|
+
const shown = voters.slice(0, 5);
|
|
3081
|
+
for (const v of shown) {
|
|
3082
|
+
html += '<div class="poll-voter-dot" style="background:' + voterColor(v) + '" title="' + esc(v) + '">' + voterInitial(v) + '</div>';
|
|
3083
|
+
}
|
|
3084
|
+
if (voters.length > 5) html += '<div class="poll-voter-dot" style="background:var(--border)" title="' + (voters.length - 5) + ' more">+' + (voters.length - 5) + '</div>';
|
|
3085
|
+
html += '</div>';
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
html += '<span>' + t.count + ' (' + pct + '%)</span>';
|
|
3089
|
+
html += '</div>';
|
|
3090
|
+
html += '</div>';
|
|
3091
|
+
html += '</div>';
|
|
3092
|
+
}
|
|
3093
|
+
html += '</div>';
|
|
3094
|
+
|
|
3095
|
+
// Footer
|
|
3096
|
+
html += '<div class="poll-footer">';
|
|
3097
|
+
html += '<span>' + totalVotes + ' vote' + (totalVotes !== 1 ? 's' : '') + '</span>';
|
|
3098
|
+
if (isActive && results.expires_at) {
|
|
3099
|
+
const remaining = Math.max(0, Math.ceil((results.expires_at - Date.now()) / 60000));
|
|
3100
|
+
html += '<span>' + (remaining > 60 ? Math.ceil(remaining / 60) + 'h' : remaining + 'm') + ' remaining</span>';
|
|
3101
|
+
} else if (!isActive) {
|
|
3102
|
+
const winner = tally.reduce((a, b) => b.count > a.count ? b : a, tally[0]);
|
|
3103
|
+
if (winner && winner.count > 0) html += '<span>Winner: ' + esc(winner.option) + '</span>';
|
|
3104
|
+
}
|
|
3105
|
+
html += '</div>';
|
|
3106
|
+
html += '</div>';
|
|
3107
|
+
}
|
|
3108
|
+
body.innerHTML = html;
|
|
3109
|
+
} catch (err) {
|
|
3110
|
+
body.innerHTML = '<div style="color:var(--text-muted);font-size:12px">Failed to load polls</div>';
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
function timeAgo(ts) {
|
|
3115
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
3116
|
+
if (s < 60) return 'just now';
|
|
3117
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
3118
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
3119
|
+
return Math.floor(s / 86400) + 'd ago';
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
function showCreatePollForm() {
|
|
3123
|
+
const form = document.getElementById('create-poll-form');
|
|
3124
|
+
if (form) { form.style.display = 'block'; document.getElementById('poll-question')?.focus(); }
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
function hideCreatePollForm() {
|
|
3128
|
+
const form = document.getElementById('create-poll-form');
|
|
3129
|
+
if (form) form.style.display = 'none';
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
function addPollOption() {
|
|
3133
|
+
const container = document.getElementById('poll-options-inputs');
|
|
3134
|
+
if (!container) return;
|
|
3135
|
+
const count = container.querySelectorAll('.poll-option-input').length;
|
|
3136
|
+
if (count >= 6) return;
|
|
3137
|
+
const input = document.createElement('input');
|
|
3138
|
+
input.type = 'text';
|
|
3139
|
+
input.className = 'poll-option-input poll-input';
|
|
3140
|
+
input.placeholder = 'Option ' + (count + 1);
|
|
3141
|
+
input.setAttribute('aria-label', 'Poll option ' + (count + 1));
|
|
3142
|
+
container.appendChild(input);
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
async function submitPoll() {
|
|
3146
|
+
const question = document.getElementById('poll-question')?.value.trim();
|
|
3147
|
+
const optionInputs = document.querySelectorAll('.poll-option-input');
|
|
3148
|
+
const options = Array.from(optionInputs).map(i => i.value.trim()).filter(Boolean);
|
|
3149
|
+
const expiryEl = document.getElementById('poll-expiry');
|
|
3150
|
+
const expiryMin = expiryEl ? parseInt(expiryEl.value, 10) : 0;
|
|
3151
|
+
const anonymous = document.getElementById('poll-anonymous')?.checked || false;
|
|
3152
|
+
|
|
3153
|
+
if (!question || options.length < 2) {
|
|
3154
|
+
alert('Need a question and at least 2 options');
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
try {
|
|
3159
|
+
const body = { question, options, createdBy: 'dashboard', anonymous };
|
|
3160
|
+
if (expiryMin > 0) body.expiresInMinutes = expiryMin;
|
|
3161
|
+
const res = await fetch(BASE + '/polls', {
|
|
3162
|
+
method: 'POST',
|
|
3163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3164
|
+
body: JSON.stringify(body),
|
|
3165
|
+
});
|
|
3166
|
+
const data = await res.json();
|
|
3167
|
+
if (data.success) {
|
|
3168
|
+
hideCreatePollForm();
|
|
3169
|
+
document.getElementById('poll-question').value = '';
|
|
3170
|
+
document.querySelectorAll('.poll-option-input').forEach(i => { i.value = ''; });
|
|
3171
|
+
await loadPolls();
|
|
3172
|
+
} else {
|
|
3173
|
+
alert(data.error || 'Failed to create poll');
|
|
3174
|
+
}
|
|
3175
|
+
} catch (err) {
|
|
3176
|
+
alert('Failed to create poll');
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
async function votePoll(pollId, optionIndex) {
|
|
3181
|
+
const voter = prompt('Your name:');
|
|
3182
|
+
if (!voter) return;
|
|
3183
|
+
try {
|
|
3184
|
+
const res = await fetch(BASE + '/polls/' + pollId + '/vote', {
|
|
3185
|
+
method: 'POST',
|
|
3186
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3187
|
+
body: JSON.stringify({ voter, choice: optionIndex }),
|
|
3188
|
+
});
|
|
3189
|
+
const data = await res.json();
|
|
3190
|
+
if (data.success) {
|
|
3191
|
+
await loadPolls();
|
|
3192
|
+
}
|
|
3193
|
+
} catch {}
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
// Keyboard nav for poll options (arrow keys within radiogroup)
|
|
3197
|
+
document.addEventListener('keydown', (e) => {
|
|
3198
|
+
const opt = document.activeElement;
|
|
3199
|
+
if (!opt || !opt.classList.contains('poll-option')) return;
|
|
3200
|
+
const group = opt.closest('[role="radiogroup"]');
|
|
3201
|
+
if (!group) return;
|
|
3202
|
+
const opts = Array.from(group.querySelectorAll('.poll-option'));
|
|
3203
|
+
const idx = opts.indexOf(opt);
|
|
3204
|
+
if (idx < 0) return;
|
|
3205
|
+
let next = -1;
|
|
3206
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = (idx + 1) % opts.length;
|
|
3207
|
+
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = (idx - 1 + opts.length) % opts.length;
|
|
3208
|
+
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); opt.click(); return; }
|
|
3209
|
+
if (next >= 0) {
|
|
3210
|
+
e.preventDefault();
|
|
3211
|
+
opts.forEach(o => o.setAttribute('tabindex', '-1'));
|
|
3212
|
+
opts[next].setAttribute('tabindex', '0');
|
|
3213
|
+
opts[next].focus();
|
|
3214
|
+
}
|
|
3215
|
+
});
|
|
3216
|
+
|
|
3217
|
+
// Load polls on init
|
|
3218
|
+
if (document.getElementById('polls-body')) {
|
|
3219
|
+
loadPolls();
|
|
3220
|
+
setInterval(loadPolls, 30000);
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
// ── File Upload Integration ──
|
|
3224
|
+
let _allFiles = [];
|
|
3225
|
+
let _fileViewMode = 'grid';
|
|
3226
|
+
let _pendingChatAttachments = [];
|
|
3227
|
+
|
|
3228
|
+
function fileIcon(mimeType, name) {
|
|
3229
|
+
if (mimeType && mimeType.startsWith('image/')) return '🖼️';
|
|
3230
|
+
if (name && name.endsWith('.pdf')) return '📄';
|
|
3231
|
+
if (name && (name.endsWith('.csv') || name.endsWith('.xlsx'))) return '📊';
|
|
3232
|
+
if (name && (name.endsWith('.md') || name.endsWith('.txt'))) return '📝';
|
|
3233
|
+
return '📎';
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
function formatFileSize(bytes) {
|
|
3237
|
+
if (!bytes) return '0 B';
|
|
3238
|
+
if (bytes < 1024) return bytes + ' B';
|
|
3239
|
+
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
3240
|
+
return (bytes / 1048576).toFixed(1) + ' MB';
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
function timeAgo(ts) {
|
|
3244
|
+
const d = typeof ts === 'string' ? new Date(ts) : new Date(ts);
|
|
3245
|
+
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
|
3246
|
+
if (s < 60) return 'just now';
|
|
3247
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
3248
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
3249
|
+
return Math.floor(s / 86400) + 'd ago';
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
async function loadFiles() {
|
|
3253
|
+
try {
|
|
3254
|
+
const res = await fetch('/files?limit=100');
|
|
3255
|
+
const data = await res.json();
|
|
3256
|
+
_allFiles = data.files || [];
|
|
3257
|
+
renderFiles(_allFiles);
|
|
3258
|
+
} catch (e) {
|
|
3259
|
+
console.error('Failed to load files:', e);
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
function renderFiles(files) {
|
|
3264
|
+
const grid = document.getElementById('files-grid');
|
|
3265
|
+
const list = document.getElementById('files-list');
|
|
3266
|
+
const empty = document.getElementById('files-empty');
|
|
3267
|
+
const count = document.getElementById('files-count');
|
|
3268
|
+
if (!grid || !list) return;
|
|
3269
|
+
|
|
3270
|
+
if (count) count.textContent = files.length || '';
|
|
3271
|
+
|
|
3272
|
+
if (!files.length) {
|
|
3273
|
+
grid.style.display = 'none';
|
|
3274
|
+
list.style.display = 'none';
|
|
3275
|
+
if (empty) empty.style.display = '';
|
|
3276
|
+
return;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
if (empty) empty.style.display = 'none';
|
|
3280
|
+
grid.style.display = _fileViewMode === 'grid' ? '' : 'none';
|
|
3281
|
+
list.style.display = _fileViewMode === 'list' ? '' : 'none';
|
|
3282
|
+
|
|
3283
|
+
// Grid view
|
|
3284
|
+
grid.innerHTML = files.map(f => {
|
|
3285
|
+
const icon = fileIcon(f.mimeType, f.originalName || f.filename);
|
|
3286
|
+
const isImg = f.mimeType && f.mimeType.startsWith('image/');
|
|
3287
|
+
const thumb = isImg
|
|
3288
|
+
? '<img src="/files/' + f.id + '" alt="' + (f.originalName || f.filename) + '" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm)">'
|
|
3289
|
+
: '<span style="font-size:32px">' + icon + '</span>';
|
|
3290
|
+
return '<div class="file-card" tabindex="0" role="button" aria-label="' + (f.originalName || f.filename) + '">' +
|
|
3291
|
+
'<div class="thumb" style="display:flex;align-items:center;justify-content:center;height:80px;background:var(--surface-raised);border-radius:var(--radius-sm);overflow:hidden">' + thumb + '</div>' +
|
|
3292
|
+
'<div class="card-name">' + (f.originalName || f.filename) + '</div>' +
|
|
3293
|
+
'<div class="card-meta">' + formatFileSize(f.size) + ' · ' + timeAgo(f.uploadedAt || f.createdAt) + '</div>' +
|
|
3294
|
+
'<div class="card-actions">' +
|
|
3295
|
+
'<a href="/files/' + f.id + '" download="' + (f.originalName || f.filename) + '" class="action-btn" aria-label="Download" onclick="event.stopPropagation()">⬇</a>' +
|
|
3296
|
+
'<button class="action-btn delete" aria-label="Delete" onclick="event.stopPropagation();deleteFile(\'' + f.id + '\')">🗑</button>' +
|
|
3297
|
+
'</div></div>';
|
|
3298
|
+
}).join('');
|
|
3299
|
+
|
|
3300
|
+
// List view
|
|
3301
|
+
list.innerHTML = files.map(f => {
|
|
3302
|
+
const icon = fileIcon(f.mimeType, f.originalName || f.filename);
|
|
3303
|
+
return '<div class="file-list-item" tabindex="0" role="button">' +
|
|
3304
|
+
'<span class="list-icon">' + icon + '</span>' +
|
|
3305
|
+
'<span class="list-name">' + (f.originalName || f.filename) + '</span>' +
|
|
3306
|
+
'<span class="list-meta">' + formatFileSize(f.size) + '</span>' +
|
|
3307
|
+
'<span class="list-meta">' + timeAgo(f.uploadedAt || f.createdAt) + '</span>' +
|
|
3308
|
+
'<div class="list-actions">' +
|
|
3309
|
+
'<a href="/files/' + f.id + '" download="' + (f.originalName || f.filename) + '" class="action-btn" aria-label="Download" onclick="event.stopPropagation()">⬇</a>' +
|
|
3310
|
+
'<button class="action-btn delete" aria-label="Delete" onclick="event.stopPropagation();deleteFile(\'' + f.id + '\')">🗑</button>' +
|
|
3311
|
+
'</div></div>';
|
|
3312
|
+
}).join('');
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
function toggleFileView(mode) {
|
|
3316
|
+
_fileViewMode = mode;
|
|
3317
|
+
const gridBtn = document.getElementById('viewGridBtn');
|
|
3318
|
+
const listBtn = document.getElementById('viewListBtn');
|
|
3319
|
+
if (gridBtn) { gridBtn.classList.toggle('active', mode === 'grid'); gridBtn.setAttribute('aria-checked', mode === 'grid'); }
|
|
3320
|
+
if (listBtn) { listBtn.classList.toggle('active', mode === 'list'); listBtn.setAttribute('aria-checked', mode === 'list'); }
|
|
3321
|
+
renderFiles(_allFiles);
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
function filterFiles(query) {
|
|
3325
|
+
if (!query) return renderFiles(_allFiles);
|
|
3326
|
+
const q = query.toLowerCase();
|
|
3327
|
+
renderFiles(_allFiles.filter(f => ((f.originalName || f.filename) || '').toLowerCase().includes(q)));
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
async function deleteFile(id) {
|
|
3331
|
+
if (!confirm('Delete this file?')) return;
|
|
3332
|
+
try {
|
|
3333
|
+
await fetch('/files/' + id, { method: 'DELETE' });
|
|
3334
|
+
loadFiles();
|
|
3335
|
+
} catch (e) {
|
|
3336
|
+
console.error('Delete failed:', e);
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
function addProgressItem(name, size) {
|
|
3341
|
+
const q = document.getElementById('uploadQueue');
|
|
3342
|
+
if (!q) return;
|
|
3343
|
+
q.style.display = '';
|
|
3344
|
+
const item = document.createElement('div');
|
|
3345
|
+
item.className = 'upload-item';
|
|
3346
|
+
item.id = 'upload-' + name.replace(/[^a-zA-Z0-9]/g, '_');
|
|
3347
|
+
item.innerHTML =
|
|
3348
|
+
'<span class="file-icon">' + fileIcon(null, name) + '</span>' +
|
|
3349
|
+
'<div class="file-info"><div class="file-name">' + name + '</div><div class="file-size">' + formatFileSize(size) + '</div></div>' +
|
|
3350
|
+
'<div class="progress-bar"><div class="progress-fill uploading" style="width:30%"></div></div>' +
|
|
3351
|
+
'<span class="status-icon" aria-label="Uploading">⏳</span>';
|
|
3352
|
+
q.appendChild(item);
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
function updateProgressItem(name, status, error) {
|
|
3356
|
+
const id = 'upload-' + name.replace(/[^a-zA-Z0-9]/g, '_');
|
|
3357
|
+
const item = document.getElementById(id);
|
|
3358
|
+
if (!item) return;
|
|
3359
|
+
const fill = item.querySelector('.progress-fill');
|
|
3360
|
+
const icon = item.querySelector('.status-icon');
|
|
3361
|
+
if (status === 'done') {
|
|
3362
|
+
if (fill) { fill.style.width = '100%'; fill.className = 'progress-fill done'; }
|
|
3363
|
+
if (icon) { icon.textContent = '✓'; icon.className = 'status-icon done'; icon.setAttribute('aria-label', 'Complete'); }
|
|
3364
|
+
setTimeout(() => { item.style.opacity = '0.5'; }, 2000);
|
|
3365
|
+
} else if (status === 'error') {
|
|
3366
|
+
if (fill) { fill.style.width = '100%'; fill.className = 'progress-fill error'; }
|
|
3367
|
+
if (icon) { icon.textContent = '✕'; icon.className = 'status-icon error'; icon.setAttribute('aria-label', 'Failed: ' + (error || 'unknown')); }
|
|
3368
|
+
const sz = item.querySelector('.file-size');
|
|
3369
|
+
if (sz && error) sz.textContent = error;
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
async function uploadFiles(fileList) {
|
|
3374
|
+
for (const file of fileList) {
|
|
3375
|
+
addProgressItem(file.name, file.size);
|
|
3376
|
+
const fd = new FormData();
|
|
3377
|
+
fd.append('file', file);
|
|
3378
|
+
fd.append('uploadedBy', 'ryan');
|
|
3379
|
+
try {
|
|
3380
|
+
const res = await fetch('/files', { method: 'POST', body: fd });
|
|
3381
|
+
const data = await res.json();
|
|
3382
|
+
if (data.success) {
|
|
3383
|
+
updateProgressItem(file.name, 'done');
|
|
3384
|
+
} else {
|
|
3385
|
+
updateProgressItem(file.name, 'error', data.error || 'Upload failed');
|
|
3386
|
+
}
|
|
3387
|
+
} catch (err) {
|
|
3388
|
+
updateProgressItem(file.name, 'error', err.message);
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
loadFiles();
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
// Init drop zone
|
|
3395
|
+
function initDropZone() {
|
|
3396
|
+
const dz = document.getElementById('dropZone');
|
|
3397
|
+
const fi = document.getElementById('fileInput');
|
|
3398
|
+
if (!dz || !fi || dz._initialized) return;
|
|
3399
|
+
dz._initialized = true;
|
|
3400
|
+
|
|
3401
|
+
['dragenter', 'dragover'].forEach(e => dz.addEventListener(e, ev => {
|
|
3402
|
+
ev.preventDefault(); dz.classList.add('drag-over');
|
|
3403
|
+
}));
|
|
3404
|
+
['dragleave', 'drop'].forEach(e => dz.addEventListener(e, ev => {
|
|
3405
|
+
ev.preventDefault(); dz.classList.remove('drag-over');
|
|
3406
|
+
}));
|
|
3407
|
+
dz.addEventListener('drop', ev => { if (ev.dataTransfer.files.length) uploadFiles(ev.dataTransfer.files); });
|
|
3408
|
+
fi.addEventListener('change', () => { if (fi.files.length) uploadFiles(fi.files); fi.value = ''; });
|
|
3409
|
+
dz.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fi.click(); } });
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
// Init chat attachment
|
|
3413
|
+
function initChatAttach() {
|
|
3414
|
+
const fi = document.getElementById('chatFileInput');
|
|
3415
|
+
if (!fi || fi._initialized) return;
|
|
3416
|
+
fi._initialized = true;
|
|
3417
|
+
fi.addEventListener('change', () => {
|
|
3418
|
+
for (const file of fi.files) {
|
|
3419
|
+
_pendingChatAttachments.push(file);
|
|
3420
|
+
}
|
|
3421
|
+
renderChatAttachments();
|
|
3422
|
+
fi.value = '';
|
|
3423
|
+
});
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
function renderChatAttachments() {
|
|
3427
|
+
let strip = document.getElementById('chatAttachmentPreview');
|
|
3428
|
+
const bar = document.querySelector('.chat-input-bar');
|
|
3429
|
+
if (!bar) return;
|
|
3430
|
+
|
|
3431
|
+
if (!strip) {
|
|
3432
|
+
strip = document.createElement('div');
|
|
3433
|
+
strip.id = 'chatAttachmentPreview';
|
|
3434
|
+
strip.className = 'attachment-preview';
|
|
3435
|
+
bar.parentNode.insertBefore(strip, bar);
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
if (!_pendingChatAttachments.length) {
|
|
3439
|
+
strip.style.display = 'none';
|
|
3440
|
+
return;
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
strip.style.display = '';
|
|
3444
|
+
strip.innerHTML = _pendingChatAttachments.map((f, i) =>
|
|
3445
|
+
'<div class="attachment-chip">' +
|
|
3446
|
+
'<span>' + fileIcon(f.type, f.name) + '</span>' +
|
|
3447
|
+
'<span class="chip-name">' + f.name + '</span>' +
|
|
3448
|
+
'<button class="chip-remove" aria-label="Remove ' + f.name + '" onclick="removeChatAttachment(' + i + ')">✕</button>' +
|
|
3449
|
+
'</div>'
|
|
3450
|
+
).join('');
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
function removeChatAttachment(idx) {
|
|
3454
|
+
_pendingChatAttachments.splice(idx, 1);
|
|
3455
|
+
renderChatAttachments();
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
// Hook into page navigation — load files when artifacts page shows
|
|
3459
|
+
const _origActivatePage = typeof activatePage === 'function' ? activatePage : null;
|
|
3460
|
+
if (_origActivatePage) {
|
|
3461
|
+
activatePage = function(page) {
|
|
3462
|
+
_origActivatePage(page);
|
|
3463
|
+
if (page === 'artifacts') {
|
|
3464
|
+
initDropZone();
|
|
3465
|
+
loadFiles();
|
|
3466
|
+
}
|
|
3467
|
+
};
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
// Init on load
|
|
3471
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
3472
|
+
initChatAttach();
|
|
3473
|
+
// If starting on artifacts page, init immediately
|
|
3474
|
+
const hash = location.hash.replace('#', '') || 'overview';
|
|
3475
|
+
if (hash === 'artifacts') {
|
|
3476
|
+
initDropZone();
|
|
3477
|
+
loadFiles();
|
|
3478
|
+
}
|
|
3479
|
+
});
|