@app-vitals/agent-admin 0.0.1 → 0.1.1
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/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,3489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/admin-ui-pages.ts
|
|
3
|
+
* Pure HTML rendering functions for admin UI pages.
|
|
4
|
+
* No Hono dependencies — pure string → string functions.
|
|
5
|
+
*
|
|
6
|
+
* Follows the same inline HTML template string pattern as
|
|
7
|
+
* metrics/src/dashboard/dashboard-page.ts.
|
|
8
|
+
*
|
|
9
|
+
* Platform pages only: the coding agent's task/PR/session/queue pages live in
|
|
10
|
+
* coding/ (AEP-3.2) and are mounted by the coding AdminExtension.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { PROGRESS_LABELS } from "@app-vitals/agent-lib/progress-phases";
|
|
14
|
+
import { renderAdminPage } from "./admin-ui-layout.ts";
|
|
15
|
+
import {
|
|
16
|
+
BREAKPOINT_MOBILE_MAX,
|
|
17
|
+
escapeHtml,
|
|
18
|
+
renderAdminToolbar,
|
|
19
|
+
} from "./admin-ui-styles.ts";
|
|
20
|
+
import type { ManualStep } from "./agent-deletion-checklist.ts";
|
|
21
|
+
import type { SpecialtyOption } from "./specialty-manifest-loader.ts";
|
|
22
|
+
import { isAudioFilename } from "./attachment-validation.ts";
|
|
23
|
+
import { parseChatMarkers } from "./chat-markers.ts";
|
|
24
|
+
import type {
|
|
25
|
+
ChatMessage,
|
|
26
|
+
ChatThread,
|
|
27
|
+
MessageTokens,
|
|
28
|
+
ThreadStats,
|
|
29
|
+
} from "./http-chat-client.ts";
|
|
30
|
+
import { renderPushToggle } from "./push-toggle.ts";
|
|
31
|
+
|
|
32
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns a human-friendly relative timestamp.
|
|
36
|
+
* Examples: "just now", "5 minutes ago", "2 hours ago", "3 days ago", "1 week ago"
|
|
37
|
+
*
|
|
38
|
+
* @param date - the timestamp to describe
|
|
39
|
+
* @param now - the reference "current" time (defaults to wall clock; override in tests for determinism)
|
|
40
|
+
*/
|
|
41
|
+
export function relativeTime(date: Date, now: Date = new Date()): string {
|
|
42
|
+
const diffMs = now.getTime() - date.getTime();
|
|
43
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
44
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
45
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
46
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
47
|
+
const diffWeek = Math.floor(diffDay / 7);
|
|
48
|
+
|
|
49
|
+
if (diffSec < 60) return "just now";
|
|
50
|
+
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? "" : "s"} ago`;
|
|
51
|
+
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? "" : "s"} ago`;
|
|
52
|
+
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? "" : "s"} ago`;
|
|
53
|
+
return `${diffWeek} week${diffWeek === 1 ? "" : "s"} ago`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export interface AgentListItem {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
slackId: string | null;
|
|
62
|
+
createdAt: Date;
|
|
63
|
+
selfHosted?: boolean;
|
|
64
|
+
accountId?: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AgentDetail {
|
|
68
|
+
id: string;
|
|
69
|
+
name: string;
|
|
70
|
+
slackId: string | null;
|
|
71
|
+
selfHosted: boolean;
|
|
72
|
+
createdAt: Date;
|
|
73
|
+
updatedAt: Date;
|
|
74
|
+
repos: string[];
|
|
75
|
+
authorAllowlist: string[];
|
|
76
|
+
patchAuthorAllowlist: string[];
|
|
77
|
+
/**
|
|
78
|
+
* When true, only AgentMember emails may message this agent over Slack.
|
|
79
|
+
* Rendered as a checkbox on both the create and edit forms.
|
|
80
|
+
*/
|
|
81
|
+
restrictSlackToMembers: boolean;
|
|
82
|
+
/** The agent's type name (e.g. "coding"), resolved at creation time. */
|
|
83
|
+
typeName: string;
|
|
84
|
+
/**
|
|
85
|
+
* Required env keys declared by the agent's type manifest with no
|
|
86
|
+
* corresponding AgentEnv row yet — key names only, never values. Always
|
|
87
|
+
* present; purely informational (ATS-4.2), see renderAgentDetailPage's Env
|
|
88
|
+
* Vars card.
|
|
89
|
+
*/
|
|
90
|
+
missingRequiredEnv: string[];
|
|
91
|
+
/**
|
|
92
|
+
* APM-1.3: the six real agent-policy fields (APM-1.1 DB columns), surfaced
|
|
93
|
+
* in the "Agent Policy" card. Callers resolve
|
|
94
|
+
* Prisma-column defaults (`?? true`/`?? false`/`?? 75`/`?? 5`/`?? 14`)
|
|
95
|
+
* before building this literal — see admin-ui.ts's two `agentDetail:
|
|
96
|
+
* AgentDetail = {...}` build sites.
|
|
97
|
+
*/
|
|
98
|
+
autoPostReviews: boolean;
|
|
99
|
+
allowSelfReview: boolean;
|
|
100
|
+
minConfidence: number;
|
|
101
|
+
maxFindings: number;
|
|
102
|
+
cleanupMergedWorktrees: boolean;
|
|
103
|
+
cleanupAfterDays: number;
|
|
104
|
+
/**
|
|
105
|
+
* PAI-2.5: owner and Anthropic identity, shown in the "Owner & Anthropic
|
|
106
|
+
* identity" card. Optional so literal builds that predate the fields stay
|
|
107
|
+
* valid; null or absent renders an em dash placeholder.
|
|
108
|
+
*/
|
|
109
|
+
ownerEmail?: string | null;
|
|
110
|
+
anthropicIdentityName?: string | null;
|
|
111
|
+
anthropicServiceAccountId?: string | null;
|
|
112
|
+
anthropicFederationRuleId?: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface CronJobItem {
|
|
116
|
+
id: string;
|
|
117
|
+
schedule: string;
|
|
118
|
+
prompt: string;
|
|
119
|
+
channel: string | null;
|
|
120
|
+
user: string | null;
|
|
121
|
+
enabled: boolean;
|
|
122
|
+
name: string | null;
|
|
123
|
+
system: boolean;
|
|
124
|
+
parentCronId: string | null;
|
|
125
|
+
preCheck?: string | null;
|
|
126
|
+
lastRun?: {
|
|
127
|
+
startedAt: Date;
|
|
128
|
+
completedAt: Date | null;
|
|
129
|
+
skipped: boolean;
|
|
130
|
+
outcome: string | null;
|
|
131
|
+
} | null;
|
|
132
|
+
runCountToday?: number;
|
|
133
|
+
createdAt: Date;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Inline CSS for cron-run outcome badges, keyed by outcome string.
|
|
137
|
+
// Shared by the cron rows on the agent detail page and the cron runs page.
|
|
138
|
+
const CRON_OUTCOME_STYLE: Record<string, string> = {
|
|
139
|
+
posted: "background:#22c55e;color:white",
|
|
140
|
+
dm: "background:#3b82f6;color:white",
|
|
141
|
+
silent: "background:#9ca3af;color:white",
|
|
142
|
+
skipped: "background:#f59e0b;color:white",
|
|
143
|
+
error: "background:#ef4444;color:white",
|
|
144
|
+
};
|
|
145
|
+
const CRON_OUTCOME_STYLE_DEFAULT = "background:#9ca3af;color:white";
|
|
146
|
+
|
|
147
|
+
/** Resolve the badge style for a cron outcome, falling back to a neutral gray. */
|
|
148
|
+
export function cronOutcomeStyle(outcome: string | null | undefined): string {
|
|
149
|
+
return CRON_OUTCOME_STYLE[outcome ?? ""] ?? CRON_OUTCOME_STYLE_DEFAULT;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Label for a cron run's outcome badge — "skipped" takes priority over outcome. */
|
|
153
|
+
export function cronRunOutcomeLabel(run: {
|
|
154
|
+
skipped: boolean;
|
|
155
|
+
outcome: string | null;
|
|
156
|
+
}): string {
|
|
157
|
+
return run.skipped ? "skipped" : (run.outcome ?? "unknown");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface ToolItem {
|
|
161
|
+
id: string;
|
|
162
|
+
pattern: string;
|
|
163
|
+
enabled: boolean;
|
|
164
|
+
createdAt: Date;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface TokenItem {
|
|
168
|
+
id: string;
|
|
169
|
+
label: string | null;
|
|
170
|
+
createdAt: Date;
|
|
171
|
+
revokedAt: Date | null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface PluginItem {
|
|
175
|
+
id: string;
|
|
176
|
+
name: string;
|
|
177
|
+
version: string | null;
|
|
178
|
+
enabled: boolean;
|
|
179
|
+
createdAt: Date;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface MemberItem {
|
|
183
|
+
id: string;
|
|
184
|
+
email: string;
|
|
185
|
+
createdAt: Date;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Inline markdown renderer ─────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Render a markdown string to safe HTML.
|
|
192
|
+
* HTML is escaped FIRST to prevent XSS, then markdown patterns are applied
|
|
193
|
+
* to generate a known-safe set of HTML tags.
|
|
194
|
+
*/
|
|
195
|
+
export function renderMarkdown(text: string): string {
|
|
196
|
+
// Step 1: escape all HTML entities so raw user input can't inject tags
|
|
197
|
+
let out = escapeHtml(text);
|
|
198
|
+
|
|
199
|
+
// Step 2: extract code blocks into placeholder tokens before line processing
|
|
200
|
+
// so that interior lines of a fenced block are never handed to the line loop.
|
|
201
|
+
const codeBlocks: string[] = [];
|
|
202
|
+
// Use Unicode Private Use Area sentinels — never appear in HTML-escaped markdown,
|
|
203
|
+
// and are not control characters (biome noControlCharactersInRegex safe).
|
|
204
|
+
const PLACEHOLDER_PREFIX = "CODE_BLOCK_";
|
|
205
|
+
const PLACEHOLDER_SUFFIX = "";
|
|
206
|
+
const placeholder = (n: number) =>
|
|
207
|
+
`${PLACEHOLDER_PREFIX}${n}${PLACEHOLDER_SUFFIX}`;
|
|
208
|
+
const PLACEHOLDER_RE = /^CODE_BLOCK_(\d+)$/;
|
|
209
|
+
|
|
210
|
+
// Multi-line fenced blocks: ```\n...\n```
|
|
211
|
+
out = out.replace(/```[\r\n]([\s\S]*?)[\r\n]```/g, (_m, code) => {
|
|
212
|
+
const idx = codeBlocks.length;
|
|
213
|
+
codeBlocks.push(`<pre><code>${code}</code></pre>`);
|
|
214
|
+
return placeholder(idx);
|
|
215
|
+
});
|
|
216
|
+
// Same-line fenced blocks: ```code```
|
|
217
|
+
out = out.replace(/```([^`\n]+)```/g, (_m, code) => {
|
|
218
|
+
const idx = codeBlocks.length;
|
|
219
|
+
codeBlocks.push(`<pre><code>${code}</code></pre>`);
|
|
220
|
+
return placeholder(idx);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Step 3: process line-by-line for block-level elements
|
|
224
|
+
const lines = out.split("\n");
|
|
225
|
+
const result: string[] = [];
|
|
226
|
+
let inUl = false;
|
|
227
|
+
let inOl = false;
|
|
228
|
+
|
|
229
|
+
const closeList = () => {
|
|
230
|
+
if (inUl) {
|
|
231
|
+
result.push("</ul>");
|
|
232
|
+
inUl = false;
|
|
233
|
+
}
|
|
234
|
+
if (inOl) {
|
|
235
|
+
result.push("</ol>");
|
|
236
|
+
inOl = false;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
for (const line of lines) {
|
|
241
|
+
// Placeholder lines are pre-rendered code blocks — emit as-is
|
|
242
|
+
const placeholderMatch = PLACEHOLDER_RE.exec(line);
|
|
243
|
+
if (placeholderMatch) {
|
|
244
|
+
closeList();
|
|
245
|
+
result.push(codeBlocks[Number.parseInt(placeholderMatch[1], 10)]);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Headings
|
|
250
|
+
const h3 = line.match(/^### (.+)$/);
|
|
251
|
+
const h2 = line.match(/^## (.+)$/);
|
|
252
|
+
const h1 = line.match(/^# (.+)$/);
|
|
253
|
+
|
|
254
|
+
if (h3) {
|
|
255
|
+
closeList();
|
|
256
|
+
result.push(`<h3>${applyInline(h3[1])}</h3>`);
|
|
257
|
+
} else if (h2) {
|
|
258
|
+
closeList();
|
|
259
|
+
result.push(`<h2>${applyInline(h2[1])}</h2>`);
|
|
260
|
+
} else if (h1) {
|
|
261
|
+
closeList();
|
|
262
|
+
result.push(`<h1>${applyInline(h1[1])}</h1>`);
|
|
263
|
+
} else if (/^[-*] /.test(line)) {
|
|
264
|
+
// Unordered list item
|
|
265
|
+
if (inOl) {
|
|
266
|
+
closeList();
|
|
267
|
+
}
|
|
268
|
+
if (!inUl) {
|
|
269
|
+
result.push("<ul>");
|
|
270
|
+
inUl = true;
|
|
271
|
+
}
|
|
272
|
+
result.push(`<li>${applyInline(line.replace(/^[-*] /, ""))}</li>`);
|
|
273
|
+
} else if (/^\d+\. /.test(line)) {
|
|
274
|
+
// Ordered list item
|
|
275
|
+
if (inUl) {
|
|
276
|
+
closeList();
|
|
277
|
+
}
|
|
278
|
+
if (!inOl) {
|
|
279
|
+
result.push("<ol>");
|
|
280
|
+
inOl = true;
|
|
281
|
+
}
|
|
282
|
+
result.push(`<li>${applyInline(line.replace(/^\d+\. /, ""))}</li>`);
|
|
283
|
+
} else if (line.trim() === "") {
|
|
284
|
+
closeList();
|
|
285
|
+
result.push("");
|
|
286
|
+
} else {
|
|
287
|
+
closeList();
|
|
288
|
+
result.push(applyInline(line));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
closeList();
|
|
292
|
+
|
|
293
|
+
return result.join("\n");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Apply inline markdown transforms (bold, inline code) to an already-escaped string. */
|
|
297
|
+
function applyInline(s: string): string {
|
|
298
|
+
// Inline code: `code` — must come before bold to avoid double-processing
|
|
299
|
+
const withCode = s.replace(
|
|
300
|
+
/`([^`]+)`/g,
|
|
301
|
+
(_m, code) => `<code>${code}</code>`,
|
|
302
|
+
);
|
|
303
|
+
// Bold: **text**
|
|
304
|
+
return withCode.replace(
|
|
305
|
+
/\*\*([^*]+)\*\*/g,
|
|
306
|
+
(_m, text) => `<strong>${text}</strong>`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ─── Login page ───────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
export function renderLoginPage(opts?: {
|
|
313
|
+
error?: string;
|
|
314
|
+
returnTo?: string;
|
|
315
|
+
googleEnabled?: boolean;
|
|
316
|
+
oktaEnabled?: boolean;
|
|
317
|
+
}): string {
|
|
318
|
+
const googleEnabled = opts?.googleEnabled ?? true;
|
|
319
|
+
const errorHtml = opts?.error
|
|
320
|
+
? `<div class="alert alert-error">${escapeHtml(opts.error)}</div>`
|
|
321
|
+
: "";
|
|
322
|
+
|
|
323
|
+
const googleHref = opts?.returnTo
|
|
324
|
+
? `/admin/auth/google?returnTo=${encodeURIComponent(opts.returnTo)}`
|
|
325
|
+
: "/admin/auth/google";
|
|
326
|
+
|
|
327
|
+
const oktaHref = opts?.returnTo
|
|
328
|
+
? `/admin/auth/okta?returnTo=${encodeURIComponent(opts.returnTo)}`
|
|
329
|
+
: "/admin/auth/okta";
|
|
330
|
+
|
|
331
|
+
const googleButtonHtml = googleEnabled
|
|
332
|
+
? `<a href="${googleHref}" class="btn btn-primary" style="width:100%;justify-content:center;text-decoration:none">Sign in with Google</a>`
|
|
333
|
+
: "";
|
|
334
|
+
|
|
335
|
+
const oktaButtonHtml = opts?.oktaEnabled
|
|
336
|
+
? `<a href="${oktaHref}" class="btn btn-primary" style="width:100%;justify-content:center;text-decoration:none">Sign in with Okta</a>`
|
|
337
|
+
: "";
|
|
338
|
+
|
|
339
|
+
const noProviderHtml =
|
|
340
|
+
!googleEnabled && !opts?.oktaEnabled
|
|
341
|
+
? `<div class="alert alert-error">No sign-in provider is configured. Set GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET or OKTA_*.</div>`
|
|
342
|
+
: "";
|
|
343
|
+
|
|
344
|
+
return renderAdminPage({
|
|
345
|
+
title: "Admin Login — Shipwright",
|
|
346
|
+
body: `<div class="login-wrapper">
|
|
347
|
+
<div class="login-card">
|
|
348
|
+
<h1 class="login-title">Shipwright Admin</h1>
|
|
349
|
+
<p class="login-subtitle">Sign in to manage your agents.</p>
|
|
350
|
+
${errorHtml}
|
|
351
|
+
${noProviderHtml}
|
|
352
|
+
${googleButtonHtml}
|
|
353
|
+
${oktaButtonHtml}
|
|
354
|
+
</div>
|
|
355
|
+
</div>`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ─── Agents list page ─────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
export function renderAgentsPage(
|
|
362
|
+
agents: AgentListItem[],
|
|
363
|
+
userName: string,
|
|
364
|
+
isAdmin: boolean,
|
|
365
|
+
timezone: string,
|
|
366
|
+
opts?: {
|
|
367
|
+
successMsg?: string;
|
|
368
|
+
manualSteps?: ManualStep[];
|
|
369
|
+
/** Pre-rendered zero-quota notice + disabled button (replaces the CTA). */
|
|
370
|
+
zeroQuotaNotice?: string;
|
|
371
|
+
/**
|
|
372
|
+
* accountId -> name. Admin-only (SSP-5.2): when set AND isAdmin, an
|
|
373
|
+
* Account column is rendered. Ignored for non-admins.
|
|
374
|
+
*/
|
|
375
|
+
accountNames?: Map<string, string>;
|
|
376
|
+
/**
|
|
377
|
+
* Render the Queue & Activity button (AEP-3.6). Only the coding extension
|
|
378
|
+
* mounts /admin/agents/:id/queue-activity; absent/false omits the link.
|
|
379
|
+
*/
|
|
380
|
+
queueActivityLinks?: boolean;
|
|
381
|
+
},
|
|
382
|
+
): string {
|
|
383
|
+
const showAccount = isAdmin && opts?.accountNames !== undefined;
|
|
384
|
+
const accountCell = (a: AgentListItem): string => {
|
|
385
|
+
if (!showAccount) return "";
|
|
386
|
+
const name = a.accountId ? opts?.accountNames?.get(a.accountId) : undefined;
|
|
387
|
+
return `<td>${a.accountId && name ? `<a href="/admin/accounts/${escapeHtml(a.accountId)}">${escapeHtml(name)}</a>` : '<span style="color:#9ca3af">—</span>'}</td>`;
|
|
388
|
+
};
|
|
389
|
+
const successHtml = opts?.successMsg
|
|
390
|
+
? `<div class="alert alert-success">${escapeHtml(opts.successMsg)}</div>`
|
|
391
|
+
: "";
|
|
392
|
+
|
|
393
|
+
const manualStepsHtml =
|
|
394
|
+
opts?.manualSteps && opts.manualSteps.length > 0
|
|
395
|
+
? `<div class="alert alert-warning" id="manual-steps-panel" style="position:relative;padding-right:32px">
|
|
396
|
+
<button type="button" onclick="document.getElementById('manual-steps-panel').style.display='none'"
|
|
397
|
+
style="position:absolute;top:8px;right:10px;background:none;border:none;cursor:pointer;font-size:14px;color:inherit" aria-label="Dismiss">×</button>
|
|
398
|
+
<strong>Manual cleanup required:</strong>
|
|
399
|
+
<ul style="margin:8px 0 0 20px">
|
|
400
|
+
${opts.manualSteps.map((s) => `<li>${escapeHtml(s.message)}</li>`).join("\n ")}
|
|
401
|
+
</ul>
|
|
402
|
+
</div>`
|
|
403
|
+
: "";
|
|
404
|
+
|
|
405
|
+
const rows =
|
|
406
|
+
agents.length === 0
|
|
407
|
+
? `<tr><td colspan="${showAccount ? 5 : 4}" class="empty-state">${isAdmin ? 'No agents yet. <a href="/admin/agents/new">Create one →</a>' : "No agents."}</td></tr>`
|
|
408
|
+
: agents
|
|
409
|
+
.map(
|
|
410
|
+
(a) => `<tr>
|
|
411
|
+
<td><a href="/admin/agents/${escapeHtml(a.id)}" class="agent-link">${escapeHtml(a.name)}</a></td>
|
|
412
|
+
<td class="mono">${a.slackId ? escapeHtml(a.slackId) : '<span style="color:#9ca3af">—</span>'}</td>
|
|
413
|
+
${accountCell(a)}
|
|
414
|
+
<td>${escapeHtml(new Date(a.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
|
|
415
|
+
<td>
|
|
416
|
+
<a href="/admin/agents/${escapeHtml(a.id)}" class="btn btn-secondary" style="font-size:12px;padding:4px 10px">Manage</a>${
|
|
417
|
+
opts?.queueActivityLinks
|
|
418
|
+
? `
|
|
419
|
+
<a href="/admin/agents/${escapeHtml(a.id)}/queue-activity" class="btn btn-secondary" style="font-size:12px;padding:4px 10px;margin-left:4px">Queue & Activity</a>`
|
|
420
|
+
: ""
|
|
421
|
+
}
|
|
422
|
+
</td>
|
|
423
|
+
</tr>`,
|
|
424
|
+
)
|
|
425
|
+
.join("\n");
|
|
426
|
+
|
|
427
|
+
// Single CTA to create an agent — the /admin/agents/new page handles agent
|
|
428
|
+
// creation and provides inline options to connect Slack/GitHub if desired.
|
|
429
|
+
const createAgentButtons = opts?.zeroQuotaNotice
|
|
430
|
+
? opts.zeroQuotaNotice
|
|
431
|
+
: isAdmin
|
|
432
|
+
? `<a href="/admin/agents/new" class="btn btn-primary">+ New agent</a>`
|
|
433
|
+
: "";
|
|
434
|
+
|
|
435
|
+
return renderAdminPage({
|
|
436
|
+
title: "Agents — Shipwright Admin",
|
|
437
|
+
body: `${renderAdminToolbar(userName, "/admin/agents")}
|
|
438
|
+
<div class="vos-page">
|
|
439
|
+
<div class="page-header">
|
|
440
|
+
<h1 class="page-title">Agents</h1>
|
|
441
|
+
${createAgentButtons}
|
|
442
|
+
</div>
|
|
443
|
+
${successHtml}
|
|
444
|
+
${manualStepsHtml}
|
|
445
|
+
<div class="card">
|
|
446
|
+
<table class="data-table">
|
|
447
|
+
<thead>
|
|
448
|
+
<tr>
|
|
449
|
+
<th>Name</th>
|
|
450
|
+
<th>Slack ID</th>
|
|
451
|
+
${showAccount ? "<th>Account</th>" : ""}
|
|
452
|
+
<th>Created</th>
|
|
453
|
+
<th></th>
|
|
454
|
+
</tr>
|
|
455
|
+
</thead>
|
|
456
|
+
<tbody>
|
|
457
|
+
${rows}
|
|
458
|
+
</tbody>
|
|
459
|
+
</table>
|
|
460
|
+
</div>
|
|
461
|
+
</div>`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─── New agent page ───────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
// The three Slack/GitHub sections (restrict-slack-group, slack-section,
|
|
468
|
+
// github-section) are meaningless for self-hosted agents — self-hosted uses
|
|
469
|
+
// local git config for GitHub auth, not admin-managed provisioning — so they
|
|
470
|
+
// are shown only when "Provisioned in-cluster" is selected. `display` is
|
|
471
|
+
// either "" (visible) or "none" (hidden).
|
|
472
|
+
function runtimeSectionsOnchange(display: "" | "none"): string {
|
|
473
|
+
const ids = ["restrict-slack-group", "slack-section", "github-section"];
|
|
474
|
+
return ids
|
|
475
|
+
.map((id) => `document.getElementById('${id}').style.display='${display}'`)
|
|
476
|
+
.join(";");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function renderNewLocalAgentPage(
|
|
480
|
+
userName: string,
|
|
481
|
+
types: SpecialtyOption[],
|
|
482
|
+
opts?: {
|
|
483
|
+
error?: string;
|
|
484
|
+
canProvision?: boolean;
|
|
485
|
+
/** WIF-2.1: hide the Claude credential fields (federation supplies auth). */
|
|
486
|
+
anthropicFederationEnabled?: boolean;
|
|
487
|
+
/**
|
|
488
|
+
* Set for self-serve account users (SSP-4.2): shows the account's
|
|
489
|
+
* remaining agent quota and the prerequisites, and pins the type to
|
|
490
|
+
* "coding" (the server forces it regardless).
|
|
491
|
+
*/
|
|
492
|
+
selfServe?: { remaining: number; max: number; contactEmail: string };
|
|
493
|
+
},
|
|
494
|
+
): string {
|
|
495
|
+
const error = opts?.error;
|
|
496
|
+
const selfServe = opts?.selfServe;
|
|
497
|
+
// WIF-2.1: with Anthropic federation enabled the server rejects these
|
|
498
|
+
// credentials for in-cluster agents, so don't offer the fields at all.
|
|
499
|
+
const claudeCredentialFields = opts?.anthropicFederationEnabled
|
|
500
|
+
? ""
|
|
501
|
+
: ` <div class="form-group">
|
|
502
|
+
<label class="form-label" for="claudeCodeOauthToken">Claude Code OAuth Token (optional)</label>
|
|
503
|
+
<input
|
|
504
|
+
id="claudeCodeOauthToken"
|
|
505
|
+
name="claudeCodeOauthToken"
|
|
506
|
+
type="password"
|
|
507
|
+
class="form-input"
|
|
508
|
+
placeholder="sk-ant-oat01-..."
|
|
509
|
+
/>
|
|
510
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">
|
|
511
|
+
Stored as the agent's <span class="mono">CLAUDE_CODE_OAUTH_TOKEN</span>. Required for the agent
|
|
512
|
+
to run — set it here or from the agent detail page.
|
|
513
|
+
</p>
|
|
514
|
+
</div>
|
|
515
|
+
<div class="form-group">
|
|
516
|
+
<label class="form-label" for="anthropicApiKey">Anthropic API Key (optional)</label>
|
|
517
|
+
<input
|
|
518
|
+
id="anthropicApiKey"
|
|
519
|
+
name="anthropicApiKey"
|
|
520
|
+
type="password"
|
|
521
|
+
class="form-input"
|
|
522
|
+
placeholder="sk-ant-..."
|
|
523
|
+
/>
|
|
524
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">
|
|
525
|
+
Stored as the agent's <span class="mono">ANTHROPIC_API_KEY</span>.
|
|
526
|
+
</p>
|
|
527
|
+
</div>`;
|
|
528
|
+
// Whether the admin service can actually create cluster resources. When it
|
|
529
|
+
// can't (NoopAgentProvisioner — SHIPWRIGHT_K8S_PROVISIONING unset), offering
|
|
530
|
+
// "in-cluster" would silently produce an agent row with no pod, so the option
|
|
531
|
+
// is rendered disabled and self-hosted is preselected.
|
|
532
|
+
const canProvision = opts?.canProvision ?? false;
|
|
533
|
+
// Slack/GitHub sections are only relevant to in-cluster (admin-provisioned)
|
|
534
|
+
// agents — self-hosted agents use local git config for GitHub auth. Their
|
|
535
|
+
// initial visibility must match whichever runtime radio is preselected
|
|
536
|
+
// above (canProvision===true → in-cluster preselected → visible).
|
|
537
|
+
const runtimeSectionsDisplay: "" | "none" = canProvision ? "" : "none";
|
|
538
|
+
const errorHtml = error
|
|
539
|
+
? `<div class="alert alert-error">${escapeHtml(error)}</div>`
|
|
540
|
+
: "";
|
|
541
|
+
const typeOptions = types
|
|
542
|
+
.map(
|
|
543
|
+
(t) =>
|
|
544
|
+
`<option value="${escapeHtml(t.name)}">${escapeHtml(t.displayName)}</option>`,
|
|
545
|
+
)
|
|
546
|
+
.join("\n");
|
|
547
|
+
const selfServeHtml = selfServe
|
|
548
|
+
? `<div class="card" style="margin-bottom:16px">
|
|
549
|
+
<p style="font-size:14px;font-weight:600;margin-bottom:8px">
|
|
550
|
+
Agent quota: ${selfServe.remaining} of ${selfServe.max} remaining
|
|
551
|
+
</p>
|
|
552
|
+
${
|
|
553
|
+
selfServe.remaining <= 0
|
|
554
|
+
? `<p style="font-size:13px;color:#b91c1c;margin-bottom:8px">
|
|
555
|
+
This account has reached its agent limit. Delete an agent to free a slot,
|
|
556
|
+
or contact <a href="mailto:${escapeHtml(selfServe.contactEmail)}">${escapeHtml(selfServe.contactEmail)}</a> for more.
|
|
557
|
+
</p>`
|
|
558
|
+
: ""
|
|
559
|
+
}
|
|
560
|
+
<p style="font-size:13px;color:#6b7280;margin-bottom:4px">Have these ready before you start:</p>
|
|
561
|
+
<ul style="font-size:13px;color:#6b7280;margin-left:20px">
|
|
562
|
+
<li>A Slack App Configuration Token</li>
|
|
563
|
+
<li>A GitHub org with an App, or a personal access token (PAT)</li>
|
|
564
|
+
<li>A Claude API key or OAuth token</li>
|
|
565
|
+
</ul>
|
|
566
|
+
</div>`
|
|
567
|
+
: "";
|
|
568
|
+
const typeFieldHtml = selfServe
|
|
569
|
+
? `<input type="hidden" name="type" value="coding" />`
|
|
570
|
+
: `<div class="form-group">
|
|
571
|
+
<label class="form-label" for="type">Specialty <span style="color:#ef4444">*</span></label>
|
|
572
|
+
<select id="type" name="type" class="form-input" required>
|
|
573
|
+
${typeOptions}
|
|
574
|
+
</select>
|
|
575
|
+
</div>`;
|
|
576
|
+
return renderAdminPage({
|
|
577
|
+
title: "New Agent — Shipwright Admin",
|
|
578
|
+
body: `${renderAdminToolbar(userName, "/admin/agents")}
|
|
579
|
+
<div class="vos-page">
|
|
580
|
+
<div class="page-header">
|
|
581
|
+
<div>
|
|
582
|
+
<a href="/admin/agents" style="font-size:13px;color:#6b7280;text-decoration:none">← Agents</a>
|
|
583
|
+
<h1 class="page-title" style="margin-top:4px">New Agent</h1>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
${errorHtml}
|
|
587
|
+
${selfServeHtml}
|
|
588
|
+
<div class="card">
|
|
589
|
+
<p style="font-size:14px;color:#6b7280;margin-bottom:20px">
|
|
590
|
+
Create an agent and choose where it runs. Slack and GitHub connections
|
|
591
|
+
below are optional — you can always talk to the agent from the
|
|
592
|
+
<a href="/admin/chat">Chat</a> tab first and connect them later from
|
|
593
|
+
the agent detail page.
|
|
594
|
+
</p>
|
|
595
|
+
<form method="POST" action="/admin/agents" enctype="multipart/form-data" style="display:flex;flex-direction:column;gap:16px">
|
|
596
|
+
<div class="form-group">
|
|
597
|
+
<label class="form-label" for="name">Agent name <span style="color:#ef4444">*</span></label>
|
|
598
|
+
<input
|
|
599
|
+
id="name"
|
|
600
|
+
name="name"
|
|
601
|
+
type="text"
|
|
602
|
+
class="form-input"
|
|
603
|
+
placeholder="my-agent"
|
|
604
|
+
required
|
|
605
|
+
autofocus
|
|
606
|
+
/>
|
|
607
|
+
</div>
|
|
608
|
+
${typeFieldHtml}
|
|
609
|
+
<fieldset style="border:1px solid #e8e8ee;border-radius:8px;padding:16px">
|
|
610
|
+
<legend style="font-size:13px;font-weight:600;padding:0 8px">Runtime</legend>
|
|
611
|
+
<div class="form-group" style="margin-bottom:0">
|
|
612
|
+
<label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">
|
|
613
|
+
<input type="radio" name="runtime" value="in-cluster" ${canProvision ? "checked" : "disabled"}
|
|
614
|
+
onchange="${runtimeSectionsOnchange("")}"
|
|
615
|
+
/>
|
|
616
|
+
Provisioned in-cluster
|
|
617
|
+
<span style="font-weight:400;color:#6b7280">
|
|
618
|
+
— the admin service creates the Deployment, Secret, and PVC for you.
|
|
619
|
+
</span>
|
|
620
|
+
</label>
|
|
621
|
+
<label style="display:block;font-size:13px;font-weight:500">
|
|
622
|
+
<input type="radio" name="runtime" value="self-hosted" ${canProvision ? "" : "checked"}
|
|
623
|
+
onchange="${runtimeSectionsOnchange("none")}"
|
|
624
|
+
/>
|
|
625
|
+
Self-hosted
|
|
626
|
+
<span style="font-weight:400;color:#6b7280">
|
|
627
|
+
— you run the container yourself (<span class="mono">task stack</span>, local Docker).
|
|
628
|
+
</span>
|
|
629
|
+
</label>
|
|
630
|
+
${
|
|
631
|
+
canProvision
|
|
632
|
+
? ""
|
|
633
|
+
: `<p style="font-size:12px;color:#6b7280;margin-top:8px">
|
|
634
|
+
In-cluster provisioning is unavailable — <span class="mono">SHIPWRIGHT_K8S_PROVISIONING</span> is not enabled on this admin service.
|
|
635
|
+
</p>`
|
|
636
|
+
}
|
|
637
|
+
</div>
|
|
638
|
+
</fieldset>
|
|
639
|
+
${claudeCredentialFields}
|
|
640
|
+
<div class="form-group">
|
|
641
|
+
<label class="form-label" for="repos">Repos (optional, one per line)</label>
|
|
642
|
+
<textarea
|
|
643
|
+
id="repos"
|
|
644
|
+
name="repos"
|
|
645
|
+
class="form-input"
|
|
646
|
+
rows="4"
|
|
647
|
+
placeholder="my-org/repo-one my-org/repo-two"
|
|
648
|
+
></textarea>
|
|
649
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">Format: <span class="mono">org/repo</span></p>
|
|
650
|
+
</div>
|
|
651
|
+
<div class="form-group">
|
|
652
|
+
<label class="form-label" for="authorAllowlist">Author allowlist (review) (optional, one GitHub login per line)</label>
|
|
653
|
+
<textarea
|
|
654
|
+
id="authorAllowlist"
|
|
655
|
+
name="authorAllowlist"
|
|
656
|
+
class="form-input"
|
|
657
|
+
rows="4"
|
|
658
|
+
placeholder="octocat another-user"
|
|
659
|
+
></textarea>
|
|
660
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">GitHub login, one per line</p>
|
|
661
|
+
</div>
|
|
662
|
+
<div class="form-group">
|
|
663
|
+
<label class="form-label" for="patchAuthorAllowlist">Patch author allowlist (optional, one GitHub login per line)</label>
|
|
664
|
+
<textarea
|
|
665
|
+
id="patchAuthorAllowlist"
|
|
666
|
+
name="patchAuthorAllowlist"
|
|
667
|
+
class="form-input"
|
|
668
|
+
rows="4"
|
|
669
|
+
placeholder="octocat another-user"
|
|
670
|
+
></textarea>
|
|
671
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">GitHub login, one per line</p>
|
|
672
|
+
</div>
|
|
673
|
+
<div id="restrict-slack-group" style="display:${runtimeSectionsDisplay}">
|
|
674
|
+
<div class="form-group" style="display:flex;align-items:center;gap:6px">
|
|
675
|
+
<input
|
|
676
|
+
id="restrictSlackToMembers"
|
|
677
|
+
name="restrictSlackToMembers"
|
|
678
|
+
type="checkbox"
|
|
679
|
+
value="true"
|
|
680
|
+
onchange="document.getElementById('member-emails-fields').style.display=this.checked?'block':'none'"
|
|
681
|
+
/>
|
|
682
|
+
<label class="form-label" for="restrictSlackToMembers" style="margin-bottom:0">Restrict Slack to members</label>
|
|
683
|
+
</div>
|
|
684
|
+
<p style="font-size:12px;color:#6b7280;margin-top:-12px">
|
|
685
|
+
When enabled, only AgentMember emails may message this agent over Slack. If the agent has no
|
|
686
|
+
members yet, enabling this will block all Slack senders.
|
|
687
|
+
</p>
|
|
688
|
+
<div id="member-emails-fields" style="display:none;margin-top:12px">
|
|
689
|
+
<div class="form-group">
|
|
690
|
+
<label class="form-label" for="memberEmails">Member emails (optional, one per line)</label>
|
|
691
|
+
<textarea
|
|
692
|
+
id="memberEmails"
|
|
693
|
+
name="memberEmails"
|
|
694
|
+
class="form-input"
|
|
695
|
+
rows="4"
|
|
696
|
+
placeholder="alice@example.com bob@example.com"
|
|
697
|
+
></textarea>
|
|
698
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">Email address, one per line</p>
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
</div>
|
|
702
|
+
<fieldset id="slack-section" style="display:${runtimeSectionsDisplay};border:1px solid #e8e8ee;border-radius:8px;padding:16px">
|
|
703
|
+
<legend style="font-size:13px;font-weight:600;padding:0 8px">Slack (optional)</legend>
|
|
704
|
+
<div class="form-group" style="display:flex;align-items:center;gap:6px;margin-bottom:0">
|
|
705
|
+
<input
|
|
706
|
+
id="connectSlack"
|
|
707
|
+
name="connectSlack"
|
|
708
|
+
type="checkbox"
|
|
709
|
+
value="true"
|
|
710
|
+
onchange="document.getElementById('slack-fields').style.display=this.checked?'block':'none'"
|
|
711
|
+
/>
|
|
712
|
+
<label class="form-label" for="connectSlack" style="margin-bottom:0">Connect Slack</label>
|
|
713
|
+
</div>
|
|
714
|
+
<div id="slack-fields" style="display:none;margin-top:12px">
|
|
715
|
+
<div class="form-group">
|
|
716
|
+
<label class="form-label" for="xoxpToken">Slack App Configuration Token</label>
|
|
717
|
+
<input
|
|
718
|
+
id="xoxpToken"
|
|
719
|
+
name="xoxpToken"
|
|
720
|
+
type="password"
|
|
721
|
+
class="form-input"
|
|
722
|
+
placeholder="xoxe.xoxp-..."
|
|
723
|
+
/>
|
|
724
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">
|
|
725
|
+
This token is used once to create the Slack app manifest. It is not stored. After
|
|
726
|
+
creating the agent you'll be redirected to authorize the Slack app.
|
|
727
|
+
</p>
|
|
728
|
+
</div>
|
|
729
|
+
</div>
|
|
730
|
+
</fieldset>
|
|
731
|
+
<fieldset id="github-section" style="display:${runtimeSectionsDisplay};border:1px solid #e8e8ee;border-radius:8px;padding:16px">
|
|
732
|
+
<legend style="font-size:13px;font-weight:600;padding:0 8px">GitHub Authentication (optional)</legend>
|
|
733
|
+
<!--
|
|
734
|
+
ghAppMode disambiguates the two ghAuthMode="app" radios below
|
|
735
|
+
(Create GitHub App = auto, Use existing GitHub App = manual).
|
|
736
|
+
Each app-mode radio's onchange sets this hidden field alongside
|
|
737
|
+
showing/hiding its own fields block; it defaults to "auto" so a
|
|
738
|
+
plain ghAuthMode=app submission (the historical default) is
|
|
739
|
+
unaffected. See UAP-5.3.
|
|
740
|
+
-->
|
|
741
|
+
<input type="hidden" id="ghAppMode" name="ghAppMode" value="auto" />
|
|
742
|
+
<div class="form-group" style="margin-bottom:12px">
|
|
743
|
+
<label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">
|
|
744
|
+
<input type="radio" name="ghAuthMode" value="skip" checked
|
|
745
|
+
onchange="document.getElementById('gh-pat-fields').style.display='none';document.getElementById('gh-app-fields').style.display='none';document.getElementById('gh-app-manual-fields').style.display='none'"
|
|
746
|
+
/> Skip
|
|
747
|
+
<span style="font-weight:400;color:#6b7280">
|
|
748
|
+
— connect GitHub later from the agent detail page.
|
|
749
|
+
</span>
|
|
750
|
+
</label>
|
|
751
|
+
<label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">
|
|
752
|
+
<input type="radio" name="ghAuthMode" value="pat"
|
|
753
|
+
onchange="document.getElementById('gh-pat-fields').style.display='block';document.getElementById('gh-app-fields').style.display='none';document.getElementById('gh-app-manual-fields').style.display='none'"
|
|
754
|
+
/> Personal Access Token
|
|
755
|
+
</label>
|
|
756
|
+
<label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">
|
|
757
|
+
<input type="radio" name="ghAuthMode" value="app"
|
|
758
|
+
onchange="document.getElementById('ghAppMode').value='auto';document.getElementById('gh-pat-fields').style.display='none';document.getElementById('gh-app-fields').style.display='block';document.getElementById('gh-app-manual-fields').style.display='none'"
|
|
759
|
+
/> Create GitHub App
|
|
760
|
+
</label>
|
|
761
|
+
<label style="display:block;font-size:13px;font-weight:500">
|
|
762
|
+
<input type="radio" name="ghAuthMode" value="app"
|
|
763
|
+
onchange="document.getElementById('ghAppMode').value='manual';document.getElementById('gh-pat-fields').style.display='none';document.getElementById('gh-app-fields').style.display='none';document.getElementById('gh-app-manual-fields').style.display='block'"
|
|
764
|
+
/> Use existing GitHub App
|
|
765
|
+
<span style="font-weight:400;color:#6b7280">
|
|
766
|
+
— connect a GitHub App you've already created.
|
|
767
|
+
</span>
|
|
768
|
+
</label>
|
|
769
|
+
</div>
|
|
770
|
+
<div id="gh-pat-fields" style="display:none">
|
|
771
|
+
<div class="form-group">
|
|
772
|
+
<label class="form-label" for="ghPat">Personal Access Token</label>
|
|
773
|
+
<input id="ghPat" name="ghPat" type="password" class="form-input" placeholder="ghp_..." />
|
|
774
|
+
</div>
|
|
775
|
+
</div>
|
|
776
|
+
<div id="gh-app-fields" style="display:none">
|
|
777
|
+
<div class="form-group">
|
|
778
|
+
<label class="form-label" for="githubOrg">GitHub org</label>
|
|
779
|
+
<input id="githubOrg" name="githubOrg" type="text" class="form-input" placeholder="my-org" />
|
|
780
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">
|
|
781
|
+
You'll be redirected to GitHub to create the App under this organization from a manifest.
|
|
782
|
+
</p>
|
|
783
|
+
</div>
|
|
784
|
+
</div>
|
|
785
|
+
<div id="gh-app-manual-fields" style="display:none">
|
|
786
|
+
<div class="form-group">
|
|
787
|
+
<label class="form-label" for="ghAppId">GitHub App ID</label>
|
|
788
|
+
<input id="ghAppId" name="ghAppId" type="text" class="form-input" placeholder="App ID" />
|
|
789
|
+
</div>
|
|
790
|
+
<div class="form-group">
|
|
791
|
+
<label class="form-label" for="ghAppInstallationId">Installation ID</label>
|
|
792
|
+
<input id="ghAppInstallationId" name="ghAppInstallationId" type="text" class="form-input" placeholder="Installation ID" />
|
|
793
|
+
</div>
|
|
794
|
+
<div class="form-group">
|
|
795
|
+
<label class="form-label" for="ghAppPrivateKeyFile">Private Key (.pem)</label>
|
|
796
|
+
<input id="ghAppPrivateKeyFile" name="ghAppPrivateKeyFile" type="file" accept=".pem" class="form-input" />
|
|
797
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">
|
|
798
|
+
Upload the private key <span class="mono">.pem</span> file you downloaded when creating the GitHub App.
|
|
799
|
+
</p>
|
|
800
|
+
</div>
|
|
801
|
+
</div>
|
|
802
|
+
</fieldset>
|
|
803
|
+
<div>
|
|
804
|
+
<button type="submit" class="btn btn-primary">Create agent →</button>
|
|
805
|
+
<a href="/admin/agents" class="btn btn-secondary" style="margin-left:8px">Cancel</a>
|
|
806
|
+
</div>
|
|
807
|
+
</form>
|
|
808
|
+
</div>
|
|
809
|
+
</div>`,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ─── Agent detail page ────────────────────────────────────────────────────────
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* A single "connect later" action on the agent detail page (Connect Slack /
|
|
817
|
+
* Set up GitHub App / Add GitHub PAT / Use existing GitHub App) — a
|
|
818
|
+
* `<details>/<summary>` popover mirroring the "Sync Manifest" pattern.
|
|
819
|
+
* `envKey` is the per-agent AgentEnv key whose presence in `envVars` means
|
|
820
|
+
* this integration is already configured, hiding the action.
|
|
821
|
+
*
|
|
822
|
+
* `inputs` is an array so an action can collect more than one field (e.g.
|
|
823
|
+
* the manual GitHub App connect needs App ID + Installation ID + a Private
|
|
824
|
+
* Key file upload) — every existing action still supplies exactly one entry.
|
|
825
|
+
*/
|
|
826
|
+
interface ConnectActionConfig {
|
|
827
|
+
envKey: string;
|
|
828
|
+
label: string;
|
|
829
|
+
description: string;
|
|
830
|
+
action: string;
|
|
831
|
+
hiddenFields?: Record<string, string>;
|
|
832
|
+
inputs: Array<{
|
|
833
|
+
name: string;
|
|
834
|
+
type: "text" | "password" | "file" | "checkbox";
|
|
835
|
+
placeholder?: string;
|
|
836
|
+
mono?: boolean;
|
|
837
|
+
/** Checkbox only: visible label text; `value` is submitted when checked. */
|
|
838
|
+
label?: string;
|
|
839
|
+
value?: string;
|
|
840
|
+
}>;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function renderConnectAction(
|
|
844
|
+
envVars: Record<string, string>,
|
|
845
|
+
cfg: ConnectActionConfig,
|
|
846
|
+
): string {
|
|
847
|
+
if (cfg.envKey in envVars) return "";
|
|
848
|
+
const hiddenInputs = Object.entries(cfg.hiddenFields ?? {})
|
|
849
|
+
.map(
|
|
850
|
+
([k, v]) =>
|
|
851
|
+
`<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}" />`,
|
|
852
|
+
)
|
|
853
|
+
.join("\n ");
|
|
854
|
+
const hasFileInput = cfg.inputs.some((input) => input.type === "file");
|
|
855
|
+
const visibleInputs = cfg.inputs
|
|
856
|
+
.map((input) =>
|
|
857
|
+
input.type === "checkbox"
|
|
858
|
+
? `<label style="font-size:12px;display:flex;gap:6px;align-items:center">
|
|
859
|
+
<input name="${escapeHtml(input.name)}" type="checkbox" value="${escapeHtml(input.value ?? "true")}" />
|
|
860
|
+
${escapeHtml(input.label ?? input.name)}
|
|
861
|
+
</label>`
|
|
862
|
+
: `<input
|
|
863
|
+
name="${escapeHtml(input.name)}"
|
|
864
|
+
type="${input.type}"
|
|
865
|
+
class="form-input${input.mono ? " mono" : ""}"
|
|
866
|
+
${input.placeholder ? `placeholder="${escapeHtml(input.placeholder)}"` : ""}
|
|
867
|
+
required
|
|
868
|
+
style="font-size:12px"
|
|
869
|
+
/>`,
|
|
870
|
+
)
|
|
871
|
+
.join("\n ");
|
|
872
|
+
return `<details style="position:relative">
|
|
873
|
+
<summary class="btn btn-secondary" style="cursor:pointer;font-size:12px;list-style:none">${escapeHtml(cfg.label)}</summary>
|
|
874
|
+
<div style="position:absolute;left:0;margin-top:6px;background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.08);z-index:10;min-width:320px">
|
|
875
|
+
<p style="font-size:12px;color:#6b7280;margin:0 0 10px">${cfg.description}</p>
|
|
876
|
+
<form method="POST" action="${cfg.action}"${hasFileInput ? ' enctype="multipart/form-data"' : ""} style="display:flex;flex-direction:column;gap:8px">
|
|
877
|
+
${hiddenInputs}
|
|
878
|
+
${visibleInputs}
|
|
879
|
+
<button type="submit" class="btn btn-primary" style="font-size:12px;align-self:flex-start">${escapeHtml(cfg.label)}</button>
|
|
880
|
+
</form>
|
|
881
|
+
</div>
|
|
882
|
+
</details>`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Renders one collapsible accordion section on the agent detail page
|
|
887
|
+
* (AGA-1.1) — a `<details class="group">` wrapping a fixed subset of the
|
|
888
|
+
* page's existing cards. Always renders collapsed (no `open` attribute),
|
|
889
|
+
* including when `agent.missingRequiredEnv` is non-empty — there is no
|
|
890
|
+
* auto-open behavior for any group.
|
|
891
|
+
*
|
|
892
|
+
* `stat` is a short, pre-formatted, trusted (non-user-controlled) string
|
|
893
|
+
* (e.g. "5 system · 7 custom") rendered flush-right in the summary row.
|
|
894
|
+
* `name` (also trusted/static) is the group title, rendered flush-left.
|
|
895
|
+
* The toggle indicator (▸/▾) is a `::before` on the `.group-title` span
|
|
896
|
+
* itself — NOT on the flex `.group-summary` — so it stays part of that one
|
|
897
|
+
* flex child instead of becoming its own third flex item, which would break
|
|
898
|
+
* the flush-left/flush-right 2-sided layout into an uneven 3-way split.
|
|
899
|
+
*/
|
|
900
|
+
function renderGroup(name: string, stat: string, bodyHtml: string): string {
|
|
901
|
+
return `<details class="group">
|
|
902
|
+
<summary class="group-summary">
|
|
903
|
+
<span class="group-title">${name}</span>
|
|
904
|
+
<span class="group-sub">${stat}</span>
|
|
905
|
+
</summary>
|
|
906
|
+
<div class="group-body">
|
|
907
|
+
${bodyHtml}
|
|
908
|
+
</div>
|
|
909
|
+
</details>`;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export function renderAgentDetailPage(
|
|
913
|
+
agent: AgentDetail,
|
|
914
|
+
envResult:
|
|
915
|
+
| { env: Record<string, string>; secretKeys: string[] }
|
|
916
|
+
| Record<string, string>,
|
|
917
|
+
crons: CronJobItem[],
|
|
918
|
+
tools: ToolItem[],
|
|
919
|
+
tokens: TokenItem[],
|
|
920
|
+
plugins: PluginItem[],
|
|
921
|
+
members: MemberItem[],
|
|
922
|
+
userName: string,
|
|
923
|
+
isAdmin: boolean,
|
|
924
|
+
opts?: {
|
|
925
|
+
/**
|
|
926
|
+
* SSP-4.3: render the Members / Slack access / Danger Zone cards. Defaults
|
|
927
|
+
* to isAdmin; the caller passes true for agent-access holders when
|
|
928
|
+
* self-serve is enabled.
|
|
929
|
+
*/
|
|
930
|
+
canManage?: boolean;
|
|
931
|
+
error?: string;
|
|
932
|
+
newToken?: string;
|
|
933
|
+
successMsg?: string;
|
|
934
|
+
/**
|
|
935
|
+
* Non-blocking warning surfaced when restrictSlackToMembers was just set
|
|
936
|
+
* to true on an agent with zero AgentMember rows — the save already
|
|
937
|
+
* succeeded, this is purely informational.
|
|
938
|
+
*/
|
|
939
|
+
warning?: string;
|
|
940
|
+
now?: Date;
|
|
941
|
+
timezone?: string;
|
|
942
|
+
/**
|
|
943
|
+
* Pre-rendered extension card HTML for the "header" placement (AEP-2.1,
|
|
944
|
+
* admin-extension.ts), rendered in order above the stat strip. Absent or
|
|
945
|
+
* empty adds no card.
|
|
946
|
+
*/
|
|
947
|
+
extensionCardsHtml?: readonly string[];
|
|
948
|
+
/**
|
|
949
|
+
* Pre-rendered extension card HTML for the "configuration" placement
|
|
950
|
+
* (AEP-3.2), rendered in order inside the Configuration group between
|
|
951
|
+
* Repos and Agent Policy. Absent or empty adds no card.
|
|
952
|
+
*/
|
|
953
|
+
configurationCardsHtml?: readonly string[];
|
|
954
|
+
/**
|
|
955
|
+
* Render each cron's Logs link (AEP-3.6). Only the coding extension mounts
|
|
956
|
+
* /admin/agents/:id/queue-activity; absent/false omits the link.
|
|
957
|
+
*/
|
|
958
|
+
queueActivityLinks?: boolean;
|
|
959
|
+
},
|
|
960
|
+
): string {
|
|
961
|
+
// Reference time for relative timestamps — injected by tests for determinism,
|
|
962
|
+
// defaults to wall clock in production.
|
|
963
|
+
const now = opts?.now ?? new Date();
|
|
964
|
+
const timezone = opts?.timezone ?? "America/Los_Angeles";
|
|
965
|
+
|
|
966
|
+
// Normalise envResult — accept both the new {env, secretKeys} shape and the
|
|
967
|
+
// legacy Record<string,string> shape for backward compatibility.
|
|
968
|
+
const envVars =
|
|
969
|
+
envResult &&
|
|
970
|
+
"env" in envResult &&
|
|
971
|
+
typeof (envResult as { env: unknown }).env === "object"
|
|
972
|
+
? (envResult as { env: Record<string, string>; secretKeys: string[] }).env
|
|
973
|
+
: (envResult as Record<string, string>);
|
|
974
|
+
const secretKeySet = new Set<string>(
|
|
975
|
+
envResult && "secretKeys" in envResult
|
|
976
|
+
? (envResult as { secretKeys: string[] }).secretKeys
|
|
977
|
+
: [],
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
const envRows =
|
|
981
|
+
Object.keys(envVars).length === 0
|
|
982
|
+
? `<tr><td colspan="3" class="empty-state">No env vars set.</td></tr>`
|
|
983
|
+
: Object.entries(envVars)
|
|
984
|
+
.map(
|
|
985
|
+
([k]) => `<tr>
|
|
986
|
+
<td class="mono">${escapeHtml(k)}${secretKeySet.has(k) ? ' <span title="Secret">🔒</span>' : ""}</td>
|
|
987
|
+
<td class="mono" style="color:#6b7280">••••••••</td>
|
|
988
|
+
<td>
|
|
989
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/envs/delete" style="display:inline">
|
|
990
|
+
<input type="hidden" name="key" value="${escapeHtml(k)}" />
|
|
991
|
+
<button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Delete</button>
|
|
992
|
+
</form>
|
|
993
|
+
</td>
|
|
994
|
+
</tr>`,
|
|
995
|
+
)
|
|
996
|
+
.join("\n");
|
|
997
|
+
|
|
998
|
+
// "Connect later" actions (UAP-2.3) — each hidden once its AgentEnv key is
|
|
999
|
+
// already set, mirroring the "Sync Manifest" visibility pattern.
|
|
1000
|
+
const connectActionConfigs: ConnectActionConfig[] = [
|
|
1001
|
+
{
|
|
1002
|
+
envKey: "SLACK_APP_TOKEN",
|
|
1003
|
+
label: "Connect Slack",
|
|
1004
|
+
description: `Connects this agent to a Slack app. Requires a Slack app configuration token (<span class="mono">xoxe.xoxp-</span>).`,
|
|
1005
|
+
action: `/admin/agents/${escapeHtml(agent.id)}/connect-slack`,
|
|
1006
|
+
inputs: [
|
|
1007
|
+
{
|
|
1008
|
+
name: "xoxpToken",
|
|
1009
|
+
type: "password",
|
|
1010
|
+
placeholder: "xoxe.xoxp-...",
|
|
1011
|
+
mono: true,
|
|
1012
|
+
},
|
|
1013
|
+
],
|
|
1014
|
+
},
|
|
1015
|
+
{
|
|
1016
|
+
envKey: "GH_APP_ID",
|
|
1017
|
+
label: "Set up GitHub App",
|
|
1018
|
+
description:
|
|
1019
|
+
"Creates a GitHub App for this agent from a manifest. You'll be redirected to GitHub to finish creating it under the chosen org.",
|
|
1020
|
+
action: `/admin/agents/${escapeHtml(agent.id)}/connect-github`,
|
|
1021
|
+
hiddenFields: { ghAuthMode: "app", ghAppMode: "auto" },
|
|
1022
|
+
inputs: [
|
|
1023
|
+
{ name: "githubOrg", type: "text", placeholder: "my-org" },
|
|
1024
|
+
{
|
|
1025
|
+
name: "ghAppPublic",
|
|
1026
|
+
type: "checkbox",
|
|
1027
|
+
value: "true",
|
|
1028
|
+
label: "Installable on multiple orgs (public App)",
|
|
1029
|
+
},
|
|
1030
|
+
],
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
envKey: "GH_APP_ID",
|
|
1034
|
+
label: "Use existing GitHub App",
|
|
1035
|
+
description:
|
|
1036
|
+
"Connects an existing GitHub App you've already created — paste its App ID and Installation ID, and upload its private key (PEM file).",
|
|
1037
|
+
action: `/admin/agents/${escapeHtml(agent.id)}/connect-github`,
|
|
1038
|
+
hiddenFields: { ghAuthMode: "app", ghAppMode: "manual" },
|
|
1039
|
+
inputs: [
|
|
1040
|
+
{ name: "ghAppId", type: "text", placeholder: "App ID" },
|
|
1041
|
+
{
|
|
1042
|
+
name: "ghAppInstallationId",
|
|
1043
|
+
type: "text",
|
|
1044
|
+
placeholder: "Installation ID",
|
|
1045
|
+
},
|
|
1046
|
+
{ name: "ghAppPrivateKeyFile", type: "file" },
|
|
1047
|
+
],
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
envKey: "GH_TOKEN",
|
|
1051
|
+
label: "Add GitHub PAT",
|
|
1052
|
+
description:
|
|
1053
|
+
"Connects this agent to GitHub using a personal access token.",
|
|
1054
|
+
action: `/admin/agents/${escapeHtml(agent.id)}/connect-github`,
|
|
1055
|
+
hiddenFields: { ghAuthMode: "pat" },
|
|
1056
|
+
inputs: [
|
|
1057
|
+
{
|
|
1058
|
+
name: "ghPat",
|
|
1059
|
+
type: "password",
|
|
1060
|
+
placeholder: "ghp_...",
|
|
1061
|
+
mono: true,
|
|
1062
|
+
},
|
|
1063
|
+
],
|
|
1064
|
+
},
|
|
1065
|
+
];
|
|
1066
|
+
const connectActions = [
|
|
1067
|
+
...connectActionConfigs.map((cfg) => renderConnectAction(envVars, cfg)),
|
|
1068
|
+
renderAddAnotherOrgAction(agent.id, envVars),
|
|
1069
|
+
].filter(Boolean);
|
|
1070
|
+
|
|
1071
|
+
const connectActionsHtml =
|
|
1072
|
+
connectActions.length === 0
|
|
1073
|
+
? ""
|
|
1074
|
+
: `<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
|
|
1075
|
+
${connectActions.join("\n ")}
|
|
1076
|
+
</div>`;
|
|
1077
|
+
|
|
1078
|
+
// Top-level rows exclude any cron parented under another (e.g. shipwright-loop's
|
|
1079
|
+
// dev-task/review/patch/deploy phases) — those render nested beneath their
|
|
1080
|
+
// parent instead. `crons` (unfiltered) stays available below to look up each
|
|
1081
|
+
// top-level cron's children by parentCronId.
|
|
1082
|
+
const topLevelCrons = crons.filter((c) => !c.parentCronId);
|
|
1083
|
+
const systemCrons = topLevelCrons.filter((c) => c.system);
|
|
1084
|
+
const customCrons = topLevelCrons.filter((c) => !c.system);
|
|
1085
|
+
|
|
1086
|
+
/** Renders the reusable Logs/Toggle[/Delete] action cell shared by top-level and nested cron rows. */
|
|
1087
|
+
function renderCronActions(c: CronJobItem): string {
|
|
1088
|
+
const toggleLabel = c.enabled ? "Disable" : "Enable";
|
|
1089
|
+
const toggleTarget = c.enabled ? "false" : "true";
|
|
1090
|
+
const logsLink = opts?.queueActivityLinks
|
|
1091
|
+
? `
|
|
1092
|
+
<a href="/admin/agents/${escapeHtml(agent.id)}/queue-activity?cronId=${escapeHtml(c.id)}" class="btn btn-secondary" style="font-size:11px;padding:3px 8px;margin-right:4px;text-decoration:none">Logs</a>`
|
|
1093
|
+
: "";
|
|
1094
|
+
return `${logsLink}
|
|
1095
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/crons/${escapeHtml(c.id)}/toggle" style="display:inline">
|
|
1096
|
+
<input type="hidden" name="enabled" value="${toggleTarget}" />
|
|
1097
|
+
<button type="submit" class="btn btn-secondary" style="font-size:11px;padding:3px 8px">${toggleLabel}</button>
|
|
1098
|
+
</form>
|
|
1099
|
+
${
|
|
1100
|
+
!c.system
|
|
1101
|
+
? `<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/crons/${escapeHtml(c.id)}/delete" style="display:inline;margin-left:4px">
|
|
1102
|
+
<button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Delete</button>
|
|
1103
|
+
</form>`
|
|
1104
|
+
: ""
|
|
1105
|
+
}`;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Renders the nested "Phases of <parent>" block for a top-level cron's
|
|
1110
|
+
* children (e.g. shipwright-loop's dev-task/review/patch/deploy phases).
|
|
1111
|
+
* Returns "" when there are no children, so parents without phases (all
|
|
1112
|
+
* custom crons, and any system cron other than shipwright-loop) render
|
|
1113
|
+
* exactly as before — no empty block.
|
|
1114
|
+
*/
|
|
1115
|
+
function renderNestedPhasesRow(
|
|
1116
|
+
parent: CronJobItem,
|
|
1117
|
+
children: CronJobItem[],
|
|
1118
|
+
): string {
|
|
1119
|
+
if (children.length === 0) return "";
|
|
1120
|
+
const parentLabel = parent.name ?? parent.id;
|
|
1121
|
+
const childRows = children
|
|
1122
|
+
.map((child) => {
|
|
1123
|
+
const lastRunOutcomeLabel = child.lastRun
|
|
1124
|
+
? cronRunOutcomeLabel(child.lastRun)
|
|
1125
|
+
: null;
|
|
1126
|
+
return `<tr>
|
|
1127
|
+
<td style="padding-left:20px">${escapeHtml(child.name ? `${child.name}: ${child.prompt}` : child.prompt)}</td>
|
|
1128
|
+
<td class="mono">${escapeHtml(child.schedule)}</td>
|
|
1129
|
+
<td><span class="badge ${child.enabled ? "badge-green" : "badge-gray"}">${child.enabled ? "enabled" : "disabled"}</span></td>
|
|
1130
|
+
<td style="font-size:11px">${
|
|
1131
|
+
child.lastRun?.startedAt
|
|
1132
|
+
? `<span class="badge" style="${cronOutcomeStyle(lastRunOutcomeLabel)}">${escapeHtml(lastRunOutcomeLabel ?? "unknown")}</span>`
|
|
1133
|
+
: `<span style="color:#d1d5db">never</span>`
|
|
1134
|
+
}</td>
|
|
1135
|
+
<td style="white-space:nowrap">${renderCronActions(child)}</td>
|
|
1136
|
+
</tr>`;
|
|
1137
|
+
})
|
|
1138
|
+
.join("\n");
|
|
1139
|
+
return `<tr>
|
|
1140
|
+
<td colspan="8" style="background:#f9fafb;padding:12px 12px 12px 32px">
|
|
1141
|
+
<div style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">Phases of ${escapeHtml(parentLabel)}</div>
|
|
1142
|
+
<table class="data-table">
|
|
1143
|
+
<thead>
|
|
1144
|
+
<tr>
|
|
1145
|
+
<th>Phase</th>
|
|
1146
|
+
<th>Schedule</th>
|
|
1147
|
+
<th>Status</th>
|
|
1148
|
+
<th>Last run</th>
|
|
1149
|
+
<th></th>
|
|
1150
|
+
</tr>
|
|
1151
|
+
</thead>
|
|
1152
|
+
<tbody>
|
|
1153
|
+
${childRows}
|
|
1154
|
+
</tbody>
|
|
1155
|
+
</table>
|
|
1156
|
+
</td>
|
|
1157
|
+
</tr>`;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function renderCronRow(c: CronJobItem): string {
|
|
1161
|
+
const actions = renderCronActions(c);
|
|
1162
|
+
// Full inline edit (schedule, prompt, channel, preCheck), collapsed behind a
|
|
1163
|
+
// <details> so the table stays readable. Posts to /update → cronService.update.
|
|
1164
|
+
// System crons get NO edit form — their contents are owned by
|
|
1165
|
+
// reconcileSystemCrons and the /update route rejects them (mirrors delete).
|
|
1166
|
+
const editForm = c.system
|
|
1167
|
+
? ""
|
|
1168
|
+
: `
|
|
1169
|
+
<details style="margin-top:6px">
|
|
1170
|
+
<summary style="cursor:pointer;font-size:11px;color:#6b7280">Edit</summary>
|
|
1171
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/crons/${escapeHtml(c.id)}/update" style="display:flex;flex-direction:column;gap:4px;margin-top:6px;min-width:240px">
|
|
1172
|
+
<input name="schedule" type="text" class="form-input mono" style="font-size:11px;padding:3px 6px" value="${escapeHtml(c.schedule)}" placeholder="0 * * * *" required title="Cron expression (5 fields)" />
|
|
1173
|
+
<textarea name="prompt" class="form-input" style="font-size:11px;padding:3px 6px;min-height:48px" placeholder="Prompt" required>${escapeHtml(c.prompt)}</textarea>
|
|
1174
|
+
<input name="channel" type="text" class="form-input" style="font-size:11px;padding:3px 6px" value="${escapeHtml(c.channel ?? "")}" placeholder="Channel ID (optional)" />
|
|
1175
|
+
<input name="preCheck" type="text" class="form-input mono" style="font-size:11px;padding:3px 6px" value="${escapeHtml(c.preCheck ?? "")}" placeholder="plugin:check.ts or ./path.ts (optional)" />
|
|
1176
|
+
<button type="submit" class="btn btn-primary" style="font-size:11px;padding:3px 8px;align-self:flex-start">Save</button>
|
|
1177
|
+
</form>
|
|
1178
|
+
</details>`;
|
|
1179
|
+
const lastRunOutcomeLabel = c.lastRun
|
|
1180
|
+
? cronRunOutcomeLabel(c.lastRun)
|
|
1181
|
+
: null;
|
|
1182
|
+
const lastRunHtml = c.lastRun?.startedAt
|
|
1183
|
+
? `
|
|
1184
|
+
<div style="font-size:11px;color:#6b7280;margin-bottom:4px">${relativeTime(c.lastRun.startedAt, now)}</div>
|
|
1185
|
+
<div>
|
|
1186
|
+
<span class="badge" style="${cronOutcomeStyle(lastRunOutcomeLabel)}">${escapeHtml(lastRunOutcomeLabel ?? "unknown")}</span>
|
|
1187
|
+
</div>
|
|
1188
|
+
<div style="font-size:11px;color:#6b7280;margin-top:4px">${c.runCountToday ?? 0} run${(c.runCountToday ?? 0) === 1 ? "" : "s"}</div>`
|
|
1189
|
+
: `<div style="color:#d1d5db">never</div>`;
|
|
1190
|
+
|
|
1191
|
+
const children = crons.filter((child) => child.parentCronId === c.id);
|
|
1192
|
+
const nestedPhasesRow = renderNestedPhasesRow(c, children);
|
|
1193
|
+
|
|
1194
|
+
return `<tr>
|
|
1195
|
+
<td class="mono">${escapeHtml(c.schedule)}</td>
|
|
1196
|
+
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis">${escapeHtml(c.name ? `${c.name}: ${c.prompt}` : c.prompt)}</td>
|
|
1197
|
+
<td class="mono" style="font-size:11px;color:#6b7280;max-width:170px;overflow:hidden;text-overflow:ellipsis">${c.preCheck ? escapeHtml(c.preCheck) : "—"}</td>
|
|
1198
|
+
<td>${c.channel ? escapeHtml(c.channel) : c.user ? escapeHtml(c.user) : "—"}</td>
|
|
1199
|
+
<td><span class="badge ${c.enabled ? "badge-green" : "badge-gray"}">${c.enabled ? "enabled" : "disabled"}</span></td>
|
|
1200
|
+
<td style="font-size:11px">${lastRunHtml}</td>
|
|
1201
|
+
<td class="col-created">${escapeHtml(new Date(c.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
|
|
1202
|
+
<td style="white-space:nowrap">${actions}${editForm}</td>
|
|
1203
|
+
</tr>
|
|
1204
|
+
${nestedPhasesRow}`;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const systemCronRows =
|
|
1208
|
+
systemCrons.length === 0
|
|
1209
|
+
? `<tr><td colspan="8" class="empty-state">No system crons configured.</td></tr>`
|
|
1210
|
+
: systemCrons.map(renderCronRow).join("\n");
|
|
1211
|
+
|
|
1212
|
+
const customCronRows =
|
|
1213
|
+
customCrons.length === 0
|
|
1214
|
+
? `<tr><td colspan="8" class="empty-state">No custom crons yet.</td></tr>`
|
|
1215
|
+
: customCrons.map(renderCronRow).join("\n");
|
|
1216
|
+
|
|
1217
|
+
const toolRows =
|
|
1218
|
+
tools.length === 0
|
|
1219
|
+
? `<tr><td colspan="4" class="empty-state">No tools configured.</td></tr>`
|
|
1220
|
+
: tools
|
|
1221
|
+
.map(
|
|
1222
|
+
(t) => `<tr>
|
|
1223
|
+
<td class="mono">${escapeHtml(t.pattern)}</td>
|
|
1224
|
+
<td><span class="badge ${t.enabled ? "badge-green" : "badge-gray"}">${t.enabled ? "enabled" : "disabled"}</span></td>
|
|
1225
|
+
<td>${escapeHtml(new Date(t.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
|
|
1226
|
+
<td style="white-space:nowrap">
|
|
1227
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/tools/${escapeHtml(t.id)}/toggle" style="display:inline">
|
|
1228
|
+
<input type="hidden" name="enabled" value="${t.enabled ? "false" : "true"}" />
|
|
1229
|
+
<button type="submit" class="btn btn-secondary" style="font-size:11px;padding:3px 8px">${t.enabled ? "Disable" : "Enable"}</button>
|
|
1230
|
+
</form>
|
|
1231
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/tools/${escapeHtml(t.id)}/delete" style="display:inline;margin-left:4px">
|
|
1232
|
+
<button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Delete</button>
|
|
1233
|
+
</form>
|
|
1234
|
+
</td>
|
|
1235
|
+
</tr>`,
|
|
1236
|
+
)
|
|
1237
|
+
.join("\n");
|
|
1238
|
+
|
|
1239
|
+
const tokenRows =
|
|
1240
|
+
tokens.length === 0
|
|
1241
|
+
? `<tr><td colspan="4" class="empty-state">No tokens created.</td></tr>`
|
|
1242
|
+
: tokens
|
|
1243
|
+
.map(
|
|
1244
|
+
(t) => `<tr>
|
|
1245
|
+
<td>${t.label ? escapeHtml(t.label) : '<span style="color:#9ca3af">—</span>'}</td>
|
|
1246
|
+
<td>${escapeHtml(new Date(t.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
|
|
1247
|
+
<td>${t.revokedAt ? `<span class="badge badge-gray">Revoked ${escapeHtml(new Date(t.revokedAt).toLocaleDateString("en-US", { timeZone: timezone }))}</span>` : '<span class="badge badge-green">Active</span>'}</td>
|
|
1248
|
+
<td>${
|
|
1249
|
+
!t.revokedAt
|
|
1250
|
+
? `<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/tokens/${escapeHtml(t.id)}/revoke" style="display:inline">
|
|
1251
|
+
<button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Revoke</button>
|
|
1252
|
+
</form>`
|
|
1253
|
+
: ""
|
|
1254
|
+
}</td>
|
|
1255
|
+
</tr>`,
|
|
1256
|
+
)
|
|
1257
|
+
.join("\n");
|
|
1258
|
+
|
|
1259
|
+
const pluginRows =
|
|
1260
|
+
plugins.length === 0
|
|
1261
|
+
? `<tr><td colspan="4" class="empty-state">No plugins installed.</td></tr>`
|
|
1262
|
+
: plugins
|
|
1263
|
+
.map(
|
|
1264
|
+
(p) => `<tr>
|
|
1265
|
+
<td class="mono">${escapeHtml(p.name)}</td>
|
|
1266
|
+
<td class="mono">${p.version ? escapeHtml(p.version) : "latest"}</td>
|
|
1267
|
+
<td><span class="badge ${p.enabled ? "badge-green" : "badge-gray"}">${p.enabled ? "enabled" : "disabled"}</span></td>
|
|
1268
|
+
<td>${escapeHtml(new Date(p.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
|
|
1269
|
+
</tr>`,
|
|
1270
|
+
)
|
|
1271
|
+
.join("\n");
|
|
1272
|
+
|
|
1273
|
+
const memberRows =
|
|
1274
|
+
members.length === 0
|
|
1275
|
+
? `<tr><td colspan="3" class="empty-state">No members yet.</td></tr>`
|
|
1276
|
+
: members
|
|
1277
|
+
.map(
|
|
1278
|
+
(m) => `<tr>
|
|
1279
|
+
<td>${escapeHtml(m.email)}</td>
|
|
1280
|
+
<td>${escapeHtml(new Date(m.createdAt).toLocaleDateString("en-US", { timeZone: timezone }))}</td>
|
|
1281
|
+
<td>
|
|
1282
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/members/delete" style="display:inline">
|
|
1283
|
+
<input type="hidden" name="memberId" value="${escapeHtml(m.id)}" />
|
|
1284
|
+
<button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Remove</button>
|
|
1285
|
+
</form>
|
|
1286
|
+
</td>
|
|
1287
|
+
</tr>`,
|
|
1288
|
+
)
|
|
1289
|
+
.join("\n");
|
|
1290
|
+
|
|
1291
|
+
const membersSection = `
|
|
1292
|
+
<div class="card">
|
|
1293
|
+
<div class="card-title">Members</div>
|
|
1294
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/members" style="margin-bottom:16px">
|
|
1295
|
+
<div class="form-row">
|
|
1296
|
+
<div class="form-group" style="flex:1">
|
|
1297
|
+
<input name="email" type="email" class="form-input" placeholder="user@example.com" required />
|
|
1298
|
+
</div>
|
|
1299
|
+
<button type="submit" class="btn btn-primary">Add</button>
|
|
1300
|
+
</div>
|
|
1301
|
+
</form>
|
|
1302
|
+
<div class="data-table-wrapper">
|
|
1303
|
+
<table class="data-table">
|
|
1304
|
+
<thead>
|
|
1305
|
+
<tr>
|
|
1306
|
+
<th>Email</th>
|
|
1307
|
+
<th>Added</th>
|
|
1308
|
+
<th></th>
|
|
1309
|
+
</tr>
|
|
1310
|
+
</thead>
|
|
1311
|
+
<tbody>
|
|
1312
|
+
${memberRows}
|
|
1313
|
+
</tbody>
|
|
1314
|
+
</table>
|
|
1315
|
+
</div>
|
|
1316
|
+
</div>`;
|
|
1317
|
+
|
|
1318
|
+
const errorHtml = opts?.error
|
|
1319
|
+
? `<div class="alert alert-error">${escapeHtml(opts.error)}</div>`
|
|
1320
|
+
: "";
|
|
1321
|
+
|
|
1322
|
+
const successHtml = opts?.successMsg
|
|
1323
|
+
? `<div class="alert alert-success">${escapeHtml(opts.successMsg)}</div>`
|
|
1324
|
+
: "";
|
|
1325
|
+
|
|
1326
|
+
const warningHtml = opts?.warning
|
|
1327
|
+
? `<div class="alert alert-warning">${escapeHtml(opts.warning)}</div>`
|
|
1328
|
+
: "";
|
|
1329
|
+
|
|
1330
|
+
const newTokenHtml = opts?.newToken
|
|
1331
|
+
? `<div class="alert alert-success">
|
|
1332
|
+
<strong>Token created.</strong> Copy it now — it will not be shown again.<br />
|
|
1333
|
+
<code class="mono" style="display:block;margin-top:8px;font-size:13px;word-break:break-all">${escapeHtml(opts.newToken)}</code>
|
|
1334
|
+
</div>`
|
|
1335
|
+
: "";
|
|
1336
|
+
|
|
1337
|
+
const envVarsSection = !agent.selfHosted
|
|
1338
|
+
? `<div class="card" id="env-vars">
|
|
1339
|
+
<div class="card-title">Env Vars</div>
|
|
1340
|
+
${
|
|
1341
|
+
agent.missingRequiredEnv.length > 0
|
|
1342
|
+
? `<div class="alert alert-warning">
|
|
1343
|
+
Missing required env var${agent.missingRequiredEnv.length === 1 ? "" : "s"}:
|
|
1344
|
+
${agent.missingRequiredEnv.map((key) => `<span class="badge badge-warning">${escapeHtml(key)}</span>`).join(" ")}
|
|
1345
|
+
— <a href="#env-vars">set ${agent.missingRequiredEnv.length === 1 ? "it" : "them"} below</a>.
|
|
1346
|
+
</div>`
|
|
1347
|
+
: ""
|
|
1348
|
+
}
|
|
1349
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/envs" style="margin-bottom:16px">
|
|
1350
|
+
<div class="form-row" style="flex-wrap:wrap;gap:8px">
|
|
1351
|
+
<div class="form-group">
|
|
1352
|
+
<input name="key" type="text" class="form-input" placeholder="KEY" required />
|
|
1353
|
+
</div>
|
|
1354
|
+
<div class="form-group">
|
|
1355
|
+
<input name="value" type="password" class="form-input" placeholder="value" required />
|
|
1356
|
+
</div>
|
|
1357
|
+
<div class="form-group" style="display:flex;align-items:center;gap:6px;padding-bottom:2px">
|
|
1358
|
+
<input name="secret" type="checkbox" id="env-secret" value="true" checked />
|
|
1359
|
+
<label for="env-secret" class="form-label" style="margin-bottom:0">Mark as secret</label>
|
|
1360
|
+
</div>
|
|
1361
|
+
<button type="submit" class="btn btn-primary">Add</button>
|
|
1362
|
+
</div>
|
|
1363
|
+
</form>
|
|
1364
|
+
<div class="data-table-wrapper">
|
|
1365
|
+
<table class="data-table">
|
|
1366
|
+
<thead>
|
|
1367
|
+
<tr>
|
|
1368
|
+
<th>Key</th>
|
|
1369
|
+
<th>Value</th>
|
|
1370
|
+
<th></th>
|
|
1371
|
+
</tr>
|
|
1372
|
+
</thead>
|
|
1373
|
+
<tbody>
|
|
1374
|
+
${envRows}
|
|
1375
|
+
</tbody>
|
|
1376
|
+
</table>
|
|
1377
|
+
</div>
|
|
1378
|
+
</div>`
|
|
1379
|
+
: "";
|
|
1380
|
+
|
|
1381
|
+
const reposSection = `<div class="card">
|
|
1382
|
+
<div class="card-title">Repos</div>
|
|
1383
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/repos/add" style="margin-bottom:16px">
|
|
1384
|
+
<div class="form-row">
|
|
1385
|
+
<div class="form-group" style="flex:1">
|
|
1386
|
+
<input name="repo" type="text" class="form-input" placeholder="org/repo" required />
|
|
1387
|
+
</div>
|
|
1388
|
+
<button type="submit" class="btn btn-primary">Add</button>
|
|
1389
|
+
</div>
|
|
1390
|
+
</form>
|
|
1391
|
+
<div class="data-table-wrapper">
|
|
1392
|
+
<table class="data-table">
|
|
1393
|
+
<thead>
|
|
1394
|
+
<tr>
|
|
1395
|
+
<th>Repo</th>
|
|
1396
|
+
<th></th>
|
|
1397
|
+
</tr>
|
|
1398
|
+
</thead>
|
|
1399
|
+
<tbody>
|
|
1400
|
+
${
|
|
1401
|
+
agent.repos.length === 0
|
|
1402
|
+
? `<tr><td colspan="2" class="empty-state">No repos configured.</td></tr>`
|
|
1403
|
+
: agent.repos
|
|
1404
|
+
.map(
|
|
1405
|
+
(repo) => `<tr>
|
|
1406
|
+
<td class="mono">${escapeHtml(repo)}</td>
|
|
1407
|
+
<td>
|
|
1408
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/repos/delete" style="display:inline">
|
|
1409
|
+
<input type="hidden" name="repo" value="${escapeHtml(repo)}" />
|
|
1410
|
+
<button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Remove</button>
|
|
1411
|
+
</form>
|
|
1412
|
+
</td>
|
|
1413
|
+
</tr>`,
|
|
1414
|
+
)
|
|
1415
|
+
.join("\n")
|
|
1416
|
+
}
|
|
1417
|
+
</tbody>
|
|
1418
|
+
</table>
|
|
1419
|
+
</div>
|
|
1420
|
+
</div>`;
|
|
1421
|
+
|
|
1422
|
+
const authorAllowlistReviewSection = `<div class="card">
|
|
1423
|
+
<div class="card-title">Author allowlist (review)</div>
|
|
1424
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/review-author-allowlist/add" style="margin-bottom:16px">
|
|
1425
|
+
<div class="form-row">
|
|
1426
|
+
<div class="form-group" style="flex:1">
|
|
1427
|
+
<input name="login" type="text" class="form-input" placeholder="octocat" required />
|
|
1428
|
+
</div>
|
|
1429
|
+
<button type="submit" class="btn btn-primary">Add</button>
|
|
1430
|
+
</div>
|
|
1431
|
+
</form>
|
|
1432
|
+
<div class="data-table-wrapper">
|
|
1433
|
+
<table class="data-table">
|
|
1434
|
+
<thead>
|
|
1435
|
+
<tr>
|
|
1436
|
+
<th>GitHub login</th>
|
|
1437
|
+
<th></th>
|
|
1438
|
+
</tr>
|
|
1439
|
+
</thead>
|
|
1440
|
+
<tbody>
|
|
1441
|
+
${
|
|
1442
|
+
agent.authorAllowlist.length === 0
|
|
1443
|
+
? `<tr><td colspan="2" class="empty-state">No author allowlist entries configured.</td></tr>`
|
|
1444
|
+
: agent.authorAllowlist
|
|
1445
|
+
.map(
|
|
1446
|
+
(login) => `<tr>
|
|
1447
|
+
<td class="mono">${escapeHtml(login)}</td>
|
|
1448
|
+
<td>
|
|
1449
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/review-author-allowlist/delete" style="display:inline">
|
|
1450
|
+
<input type="hidden" name="login" value="${escapeHtml(login)}" />
|
|
1451
|
+
<button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Remove</button>
|
|
1452
|
+
</form>
|
|
1453
|
+
</td>
|
|
1454
|
+
</tr>`,
|
|
1455
|
+
)
|
|
1456
|
+
.join("\n")
|
|
1457
|
+
}
|
|
1458
|
+
</tbody>
|
|
1459
|
+
</table>
|
|
1460
|
+
</div>
|
|
1461
|
+
</div>`;
|
|
1462
|
+
|
|
1463
|
+
const authorAllowlistPatchSection = `<div class="card">
|
|
1464
|
+
<div class="card-title">Author allowlist (patch)</div>
|
|
1465
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/patch-author-allowlist/add" style="margin-bottom:16px">
|
|
1466
|
+
<div class="form-row">
|
|
1467
|
+
<div class="form-group" style="flex:1">
|
|
1468
|
+
<input name="login" type="text" class="form-input" placeholder="octocat" required />
|
|
1469
|
+
</div>
|
|
1470
|
+
<button type="submit" class="btn btn-primary">Add</button>
|
|
1471
|
+
</div>
|
|
1472
|
+
</form>
|
|
1473
|
+
<div class="data-table-wrapper">
|
|
1474
|
+
<table class="data-table">
|
|
1475
|
+
<thead>
|
|
1476
|
+
<tr>
|
|
1477
|
+
<th>GitHub login</th>
|
|
1478
|
+
<th></th>
|
|
1479
|
+
</tr>
|
|
1480
|
+
</thead>
|
|
1481
|
+
<tbody>
|
|
1482
|
+
${
|
|
1483
|
+
(agent.patchAuthorAllowlist ?? []).length === 0
|
|
1484
|
+
? `<tr><td colspan="2" class="empty-state">No patch author allowlist entries configured.</td></tr>`
|
|
1485
|
+
: (agent.patchAuthorAllowlist ?? [])
|
|
1486
|
+
.map(
|
|
1487
|
+
(login) => `<tr>
|
|
1488
|
+
<td class="mono">${escapeHtml(login)}</td>
|
|
1489
|
+
<td>
|
|
1490
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/patch-author-allowlist/delete" style="display:inline">
|
|
1491
|
+
<input type="hidden" name="login" value="${escapeHtml(login)}" />
|
|
1492
|
+
<button type="submit" class="btn btn-danger" style="font-size:11px;padding:3px 8px">Remove</button>
|
|
1493
|
+
</form>
|
|
1494
|
+
</td>
|
|
1495
|
+
</tr>`,
|
|
1496
|
+
)
|
|
1497
|
+
.join("\n")
|
|
1498
|
+
}
|
|
1499
|
+
</tbody>
|
|
1500
|
+
</table>
|
|
1501
|
+
</div>
|
|
1502
|
+
</div>`;
|
|
1503
|
+
|
|
1504
|
+
const canManage = opts?.canManage ?? isAdmin;
|
|
1505
|
+
|
|
1506
|
+
const slackAccessSection = canManage
|
|
1507
|
+
? `<div class="card">
|
|
1508
|
+
<div class="card-title">Slack access</div>
|
|
1509
|
+
${connectActionsHtml}
|
|
1510
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/settings">
|
|
1511
|
+
<div class="form-group" style="display:flex;align-items:center;gap:6px">
|
|
1512
|
+
<input
|
|
1513
|
+
id="restrictSlackToMembers"
|
|
1514
|
+
name="restrictSlackToMembers"
|
|
1515
|
+
type="checkbox"
|
|
1516
|
+
value="true"
|
|
1517
|
+
${agent.restrictSlackToMembers ? "checked" : ""}
|
|
1518
|
+
/>
|
|
1519
|
+
<!-- Hidden fallback: unchecked checkboxes are omitted from FormData
|
|
1520
|
+
entirely, so this "false" entry is what a submission of THIS
|
|
1521
|
+
form sends when unchecked. It also lets the shared /settings
|
|
1522
|
+
route (posted to by both this form and the Agent Policy form
|
|
1523
|
+
below) tell "this form was submitted with the box unchecked"
|
|
1524
|
+
apart from "a different form was submitted, this field is
|
|
1525
|
+
absent" — see admin-ui.ts's POST /admin/agents/:id/settings. -->
|
|
1526
|
+
<input type="hidden" name="restrictSlackToMembers" value="false" />
|
|
1527
|
+
<label class="form-label" for="restrictSlackToMembers" style="margin-bottom:0">Restrict Slack to members</label>
|
|
1528
|
+
</div>
|
|
1529
|
+
<p style="font-size:12px;color:#6b7280;margin:4px 0 12px">
|
|
1530
|
+
When enabled, only AgentMember emails may message this agent over Slack. If the agent has no
|
|
1531
|
+
members yet, enabling this will block all Slack senders.
|
|
1532
|
+
</p>
|
|
1533
|
+
<button type="submit" class="btn btn-primary">Save</button>
|
|
1534
|
+
</form>
|
|
1535
|
+
</div>`
|
|
1536
|
+
: "";
|
|
1537
|
+
|
|
1538
|
+
const cronJobsSection = `<div class="card">
|
|
1539
|
+
<div class="card-title">Cron Jobs</div>
|
|
1540
|
+
|
|
1541
|
+
${
|
|
1542
|
+
agent.selfHosted
|
|
1543
|
+
? `<p style="font-size:13px;color:#6b7280;margin:0 0 16px">Crons fire only while the local agent service is running. Make sure your service is active before enabling crons.</p>`
|
|
1544
|
+
: ""
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
<div style="margin-bottom:20px">
|
|
1548
|
+
<div style="font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">System</div>
|
|
1549
|
+
<div class="data-table-wrapper">
|
|
1550
|
+
<table class="data-table">
|
|
1551
|
+
<thead>
|
|
1552
|
+
<tr>
|
|
1553
|
+
<th>Schedule</th>
|
|
1554
|
+
<th>Prompt</th>
|
|
1555
|
+
<th>Pre-check</th>
|
|
1556
|
+
<th>Target</th>
|
|
1557
|
+
<th>Status</th>
|
|
1558
|
+
<th>Last run</th>
|
|
1559
|
+
<th class="col-created">Created</th>
|
|
1560
|
+
<th></th>
|
|
1561
|
+
</tr>
|
|
1562
|
+
</thead>
|
|
1563
|
+
<tbody>
|
|
1564
|
+
${systemCronRows}
|
|
1565
|
+
</tbody>
|
|
1566
|
+
</table>
|
|
1567
|
+
</div>
|
|
1568
|
+
</div>
|
|
1569
|
+
|
|
1570
|
+
<div>
|
|
1571
|
+
<div style="font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">Custom</div>
|
|
1572
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/crons" style="margin-bottom:12px">
|
|
1573
|
+
<div class="form-row" style="flex-wrap:wrap;gap:8px">
|
|
1574
|
+
<div class="form-group">
|
|
1575
|
+
<input name="schedule" type="text" class="form-input" placeholder="0 * * * *" required title="Cron expression (5 fields)" />
|
|
1576
|
+
</div>
|
|
1577
|
+
<div class="form-group" style="flex:1;min-width:200px">
|
|
1578
|
+
<input name="prompt" type="text" class="form-input" placeholder="Prompt" required />
|
|
1579
|
+
</div>
|
|
1580
|
+
<div class="form-group">
|
|
1581
|
+
<input name="channel" type="text" class="form-input" placeholder="Channel ID" />
|
|
1582
|
+
</div>
|
|
1583
|
+
<div class="form-group" style="display:flex;align-items:center;gap:6px;padding-bottom:2px">
|
|
1584
|
+
<input name="enabled" type="checkbox" id="cron-enabled" value="true" checked />
|
|
1585
|
+
<label for="cron-enabled" class="form-label" style="margin-bottom:0">Enabled</label>
|
|
1586
|
+
</div>
|
|
1587
|
+
<button type="submit" class="btn btn-primary">Add Cron</button>
|
|
1588
|
+
</div>
|
|
1589
|
+
</form>
|
|
1590
|
+
<div class="data-table-wrapper">
|
|
1591
|
+
<table class="data-table">
|
|
1592
|
+
<thead>
|
|
1593
|
+
<tr>
|
|
1594
|
+
<th>Schedule</th>
|
|
1595
|
+
<th>Prompt</th>
|
|
1596
|
+
<th>Pre-check</th>
|
|
1597
|
+
<th>Target</th>
|
|
1598
|
+
<th>Status</th>
|
|
1599
|
+
<th>Last run</th>
|
|
1600
|
+
<th class="col-created">Created</th>
|
|
1601
|
+
<th></th>
|
|
1602
|
+
</tr>
|
|
1603
|
+
</thead>
|
|
1604
|
+
<tbody>
|
|
1605
|
+
${customCronRows}
|
|
1606
|
+
</tbody>
|
|
1607
|
+
</table>
|
|
1608
|
+
</div>
|
|
1609
|
+
</div>
|
|
1610
|
+
</div>`;
|
|
1611
|
+
|
|
1612
|
+
const toolsSection = `<div class="card">
|
|
1613
|
+
<div class="card-title">Tools</div>
|
|
1614
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/tools" style="margin-bottom:16px">
|
|
1615
|
+
<div class="form-row">
|
|
1616
|
+
<div class="form-group" style="flex:1">
|
|
1617
|
+
<input name="pattern" type="text" class="form-input" placeholder="Bash(git:*)" required />
|
|
1618
|
+
</div>
|
|
1619
|
+
<button type="submit" class="btn btn-primary">Add</button>
|
|
1620
|
+
</div>
|
|
1621
|
+
</form>
|
|
1622
|
+
<div class="data-table-wrapper">
|
|
1623
|
+
<table class="data-table">
|
|
1624
|
+
<thead>
|
|
1625
|
+
<tr>
|
|
1626
|
+
<th>Pattern</th>
|
|
1627
|
+
<th>Status</th>
|
|
1628
|
+
<th>Created</th>
|
|
1629
|
+
<th></th>
|
|
1630
|
+
</tr>
|
|
1631
|
+
</thead>
|
|
1632
|
+
<tbody>
|
|
1633
|
+
${toolRows}
|
|
1634
|
+
</tbody>
|
|
1635
|
+
</table>
|
|
1636
|
+
</div>
|
|
1637
|
+
</div>`;
|
|
1638
|
+
|
|
1639
|
+
const tokensSection = `<div class="card">
|
|
1640
|
+
<div class="card-title">Task Store Tokens</div>
|
|
1641
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/tokens" style="margin-bottom:16px">
|
|
1642
|
+
<div class="form-row">
|
|
1643
|
+
<div class="form-group" style="flex:1">
|
|
1644
|
+
<input name="label" type="text" class="form-input" placeholder="Label (optional)" />
|
|
1645
|
+
</div>
|
|
1646
|
+
<button type="submit" class="btn btn-primary">Create Token</button>
|
|
1647
|
+
</div>
|
|
1648
|
+
</form>
|
|
1649
|
+
<div class="data-table-wrapper">
|
|
1650
|
+
<table class="data-table">
|
|
1651
|
+
<thead>
|
|
1652
|
+
<tr>
|
|
1653
|
+
<th>Label</th>
|
|
1654
|
+
<th>Created</th>
|
|
1655
|
+
<th>Status</th>
|
|
1656
|
+
<th></th>
|
|
1657
|
+
</tr>
|
|
1658
|
+
</thead>
|
|
1659
|
+
<tbody>
|
|
1660
|
+
${tokenRows}
|
|
1661
|
+
</tbody>
|
|
1662
|
+
</table>
|
|
1663
|
+
</div>
|
|
1664
|
+
</div>`;
|
|
1665
|
+
|
|
1666
|
+
const pluginsSection = `<div class="card">
|
|
1667
|
+
<div class="card-title">Plugins</div>
|
|
1668
|
+
<div class="data-table-wrapper">
|
|
1669
|
+
<table class="data-table">
|
|
1670
|
+
<thead>
|
|
1671
|
+
<tr>
|
|
1672
|
+
<th>Package</th>
|
|
1673
|
+
<th>Version</th>
|
|
1674
|
+
<th>Status</th>
|
|
1675
|
+
<th>Created</th>
|
|
1676
|
+
</tr>
|
|
1677
|
+
</thead>
|
|
1678
|
+
<tbody>
|
|
1679
|
+
${pluginRows}
|
|
1680
|
+
</tbody>
|
|
1681
|
+
</table>
|
|
1682
|
+
</div>
|
|
1683
|
+
</div>`;
|
|
1684
|
+
|
|
1685
|
+
// PAI-2.5: read-only trace from this agent to its Anthropic identity. Values
|
|
1686
|
+
// are user data (owner email) or provider IDs, so every one is escaped.
|
|
1687
|
+
const identityPlaceholder = '<span style="color:#9ca3af">—</span>';
|
|
1688
|
+
const identityRow = (
|
|
1689
|
+
label: string,
|
|
1690
|
+
value: string | null | undefined,
|
|
1691
|
+
mono: boolean,
|
|
1692
|
+
): string =>
|
|
1693
|
+
`<tr><td style="color:#6b7280;width:200px">${label}</td><td${mono ? ' class="mono"' : ""}>${value ? escapeHtml(value) : identityPlaceholder}</td></tr>`;
|
|
1694
|
+
const identitySection = `<div class="card">
|
|
1695
|
+
<div class="card-title">Owner & Anthropic identity</div>
|
|
1696
|
+
<table class="detail-table"><tbody>
|
|
1697
|
+
${identityRow("Owner email", agent.ownerEmail, false)}
|
|
1698
|
+
${identityRow("Anthropic identity name", agent.anthropicIdentityName, true)}
|
|
1699
|
+
${identityRow("Service account ID", agent.anthropicServiceAccountId, true)}
|
|
1700
|
+
${identityRow("Federation rule ID", agent.anthropicFederationRuleId, true)}
|
|
1701
|
+
</tbody></table>
|
|
1702
|
+
</div>`;
|
|
1703
|
+
|
|
1704
|
+
// Only agents with a GitHub App can make API-signed commits. Checked state
|
|
1705
|
+
// is the SHIPWRIGHT_SIGNED_COMMITS env row; when the box is hidden the field
|
|
1706
|
+
// is absent from the submission, so the settings route leaves it untouched.
|
|
1707
|
+
const signedCommitsHtml =
|
|
1708
|
+
"GH_APP_ID" in envVars
|
|
1709
|
+
? `<div class="form-group" style="display:flex;align-items:center;gap:6px">
|
|
1710
|
+
<input
|
|
1711
|
+
id="signedCommits"
|
|
1712
|
+
name="signedCommits"
|
|
1713
|
+
type="checkbox"
|
|
1714
|
+
value="true"
|
|
1715
|
+
${envVars.SHIPWRIGHT_SIGNED_COMMITS === "1" ? "checked" : ""}
|
|
1716
|
+
/>
|
|
1717
|
+
<input type="hidden" name="signedCommits" value="false" />
|
|
1718
|
+
<label class="form-label" for="signedCommits" style="margin-bottom:0">Signed commits</label>
|
|
1719
|
+
</div>
|
|
1720
|
+
<p style="font-size:12px;color:#6b7280;margin:4px 0 12px">
|
|
1721
|
+
Commits are made through the GitHub API so they show Verified. Executable bits and symlinks need the per-call bypass.
|
|
1722
|
+
</p>`
|
|
1723
|
+
: "";
|
|
1724
|
+
|
|
1725
|
+
const agentPolicySection = `<div class="card">
|
|
1726
|
+
<div class="card-title">Agent Policy</div>
|
|
1727
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/settings">
|
|
1728
|
+
<div class="form-group" style="display:flex;align-items:center;gap:6px">
|
|
1729
|
+
<input
|
|
1730
|
+
id="autoPostReviews"
|
|
1731
|
+
name="autoPostReviews"
|
|
1732
|
+
type="checkbox"
|
|
1733
|
+
value="true"
|
|
1734
|
+
${agent.autoPostReviews ? "checked" : ""}
|
|
1735
|
+
/>
|
|
1736
|
+
<!-- Hidden "false" fallback so an unchecked box still submits a
|
|
1737
|
+
value (see the Slack access card's checkbox above for why). -->
|
|
1738
|
+
<input type="hidden" name="autoPostReviews" value="false" />
|
|
1739
|
+
<label class="form-label" for="autoPostReviews" style="margin-bottom:0">Auto-post reviews</label>
|
|
1740
|
+
</div>
|
|
1741
|
+
<p style="font-size:12px;color:#6b7280;margin:4px 0 12px">
|
|
1742
|
+
Off stages reviews to state/reviews/ + Slack notify instead of posting directly to GitHub.
|
|
1743
|
+
</p>
|
|
1744
|
+
<div class="form-group" style="display:flex;align-items:center;gap:6px">
|
|
1745
|
+
<input
|
|
1746
|
+
id="allowSelfReview"
|
|
1747
|
+
name="allowSelfReview"
|
|
1748
|
+
type="checkbox"
|
|
1749
|
+
value="true"
|
|
1750
|
+
${agent.allowSelfReview ? "checked" : ""}
|
|
1751
|
+
/>
|
|
1752
|
+
<input type="hidden" name="allowSelfReview" value="false" />
|
|
1753
|
+
<label class="form-label" for="allowSelfReview" style="margin-bottom:0">Allow self-review</label>
|
|
1754
|
+
</div>
|
|
1755
|
+
<p style="font-size:12px;color:#6b7280;margin:4px 0 12px">
|
|
1756
|
+
Whether the agent may review its own open PRs.
|
|
1757
|
+
</p>
|
|
1758
|
+
<div class="form-group" style="display:flex;align-items:center;gap:6px">
|
|
1759
|
+
<input
|
|
1760
|
+
id="cleanupMergedWorktrees"
|
|
1761
|
+
name="cleanupMergedWorktrees"
|
|
1762
|
+
type="checkbox"
|
|
1763
|
+
value="true"
|
|
1764
|
+
${agent.cleanupMergedWorktrees ? "checked" : ""}
|
|
1765
|
+
/>
|
|
1766
|
+
<input type="hidden" name="cleanupMergedWorktrees" value="false" />
|
|
1767
|
+
<label class="form-label" for="cleanupMergedWorktrees" style="margin-bottom:0">Cleanup merged worktrees</label>
|
|
1768
|
+
</div>
|
|
1769
|
+
<p style="font-size:12px;color:#6b7280;margin:4px 0 12px">
|
|
1770
|
+
Remove worktrees for merged PRs each cron cycle.
|
|
1771
|
+
</p>
|
|
1772
|
+
<div class="form-group">
|
|
1773
|
+
<label class="form-label" for="minConfidence">Min confidence (0-100)</label>
|
|
1774
|
+
<input id="minConfidence" name="minConfidence" type="number" value="${agent.minConfidence}" class="form-input" />
|
|
1775
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">Minimum finding confidence to surface in a review.</p>
|
|
1776
|
+
</div>
|
|
1777
|
+
<div class="form-group">
|
|
1778
|
+
<label class="form-label" for="maxFindings">Max findings</label>
|
|
1779
|
+
<input id="maxFindings" name="maxFindings" type="number" value="${agent.maxFindings}" class="form-input" />
|
|
1780
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">Max findings surfaced per review.</p>
|
|
1781
|
+
</div>
|
|
1782
|
+
<div class="form-group">
|
|
1783
|
+
<label class="form-label" for="cleanupAfterDays">Cleanup after days</label>
|
|
1784
|
+
<input id="cleanupAfterDays" name="cleanupAfterDays" type="number" value="${agent.cleanupAfterDays}" class="form-input" />
|
|
1785
|
+
<p style="font-size:12px;color:#6b7280;margin-top:4px">Remove worktrees older than N days even if the PR is still open.</p>
|
|
1786
|
+
</div>
|
|
1787
|
+
${signedCommitsHtml}
|
|
1788
|
+
<button type="submit" class="btn btn-primary">Save</button>
|
|
1789
|
+
</form>
|
|
1790
|
+
</div>`;
|
|
1791
|
+
|
|
1792
|
+
const dangerZoneSection = canManage
|
|
1793
|
+
? `<!-- Danger Zone -->
|
|
1794
|
+
<div class="card" style="border:1px solid #fca5a5">
|
|
1795
|
+
<div class="card-title" style="color:#dc2626">Danger Zone</div>
|
|
1796
|
+
<p style="font-size:13px;color:#6b7280;margin-bottom:16px">
|
|
1797
|
+
Deleting this agent permanently removes all its data (env vars, crons, tools, tokens, plugins, members)
|
|
1798
|
+
and terminates its pod. This action cannot be undone.
|
|
1799
|
+
</p>
|
|
1800
|
+
<form id="delete-agent-form" method="POST" action="/admin/agents/${escapeHtml(agent.id)}/delete"
|
|
1801
|
+
data-agent-name="${escapeHtml(agent.name)}">
|
|
1802
|
+
<button type="submit" class="btn btn-danger">Delete agent</button>
|
|
1803
|
+
</form>
|
|
1804
|
+
<script>
|
|
1805
|
+
document.getElementById('delete-agent-form').addEventListener('submit', function(e) {
|
|
1806
|
+
var name = this.dataset.agentName;
|
|
1807
|
+
if (!confirm('Delete agent ' + name + '? This cannot be undone.')) {
|
|
1808
|
+
e.preventDefault();
|
|
1809
|
+
}
|
|
1810
|
+
});
|
|
1811
|
+
</script>
|
|
1812
|
+
</div>`
|
|
1813
|
+
: "";
|
|
1814
|
+
|
|
1815
|
+
// ─── Accordion groups (AGA-1.1) ────────────────────────────────────────
|
|
1816
|
+
// The cards below are grouped into 4 collapsed-by-default
|
|
1817
|
+
// <details class="group"> sections. Grouping is purely presentational —
|
|
1818
|
+
// every card's internal markup/ids/form actions are unchanged, they're
|
|
1819
|
+
// just composed into a new order. Agent Policy (added after this task was
|
|
1820
|
+
// originally scoped) lands in Configuration alongside Env Vars/Repos since
|
|
1821
|
+
// it is agent-level configuration, as do extension cards placed there
|
|
1822
|
+
// (the coding extension's Phase Methodology card) — this keeps every card
|
|
1823
|
+
// inside exactly one of the 4 groups, with no stray bare cards besides
|
|
1824
|
+
// Danger Zone.
|
|
1825
|
+
const automationStat = `${systemCrons.length} system · ${customCrons.length} custom`;
|
|
1826
|
+
const configurationStat = `${Object.keys(envVars).length} env vars · ${agent.repos.length} repos`;
|
|
1827
|
+
const activeTokenCount = tokens.filter((t) => !t.revokedAt).length;
|
|
1828
|
+
const accessStat = `${members.length} members · ${activeTokenCount} active tokens`;
|
|
1829
|
+
const pluginsToolsStat = `${tools.length} tools · ${plugins.length} plugins`;
|
|
1830
|
+
|
|
1831
|
+
// ─── Stat strip (AGA-1.2) ──────────────────────────────────────────────
|
|
1832
|
+
// 4-tile stat strip rendered above the 4 accordion groups. Each tile
|
|
1833
|
+
// derives its count from parameters already passed into this function.
|
|
1834
|
+
const cronsActiveCount = topLevelCrons.filter((c) => c.enabled).length;
|
|
1835
|
+
const missingEnvVarsCount = agent.missingRequiredEnv.length;
|
|
1836
|
+
const membersCount = members.length;
|
|
1837
|
+
const reposCount = agent.repos.length;
|
|
1838
|
+
|
|
1839
|
+
const statStripHtml = `<div class="stat-strip">
|
|
1840
|
+
<div class="stat-tile">
|
|
1841
|
+
<div class="stat-tile-label">crons active</div>
|
|
1842
|
+
<div class="stat-tile-value"><span>${cronsActiveCount}</span></div>
|
|
1843
|
+
</div>
|
|
1844
|
+
<div class="stat-tile${missingEnvVarsCount > 0 ? " stat-tile-warning" : ""}">
|
|
1845
|
+
<div class="stat-tile-label">missing env vars</div>
|
|
1846
|
+
<div class="stat-tile-value"><span>${missingEnvVarsCount}</span></div>
|
|
1847
|
+
</div>
|
|
1848
|
+
<div class="stat-tile">
|
|
1849
|
+
<div class="stat-tile-label">members</div>
|
|
1850
|
+
<div class="stat-tile-value"><span>${membersCount}</span></div>
|
|
1851
|
+
</div>
|
|
1852
|
+
<div class="stat-tile">
|
|
1853
|
+
<div class="stat-tile-label">repos</div>
|
|
1854
|
+
<div class="stat-tile-value"><span>${reposCount}</span></div>
|
|
1855
|
+
</div>
|
|
1856
|
+
</div>`;
|
|
1857
|
+
|
|
1858
|
+
const automationGroup = renderGroup(
|
|
1859
|
+
"Automation",
|
|
1860
|
+
automationStat,
|
|
1861
|
+
cronJobsSection,
|
|
1862
|
+
);
|
|
1863
|
+
const configurationGroup = renderGroup(
|
|
1864
|
+
"Configuration",
|
|
1865
|
+
configurationStat,
|
|
1866
|
+
`${envVarsSection}${reposSection}${(opts?.configurationCardsHtml ?? []).join("")}${agentPolicySection}`,
|
|
1867
|
+
);
|
|
1868
|
+
const accessGroup = renderGroup(
|
|
1869
|
+
"Access",
|
|
1870
|
+
accessStat,
|
|
1871
|
+
`${identitySection}${canManage ? membersSection : ""}${authorAllowlistReviewSection}${authorAllowlistPatchSection}${slackAccessSection}${tokensSection}`,
|
|
1872
|
+
);
|
|
1873
|
+
const pluginsToolsGroup = renderGroup(
|
|
1874
|
+
"Plugins & Tools",
|
|
1875
|
+
pluginsToolsStat,
|
|
1876
|
+
`${toolsSection}${pluginsSection}`,
|
|
1877
|
+
);
|
|
1878
|
+
|
|
1879
|
+
const expandCollapseControls = `
|
|
1880
|
+
<div style="display:flex;gap:8px;margin-bottom:16px">
|
|
1881
|
+
<button type="button" class="btn btn-secondary" id="expand-all-btn">Expand all</button>
|
|
1882
|
+
<button type="button" class="btn btn-secondary" id="collapse-all-btn">Collapse all</button>
|
|
1883
|
+
</div>
|
|
1884
|
+
<script>
|
|
1885
|
+
(function() {
|
|
1886
|
+
var expandBtn = document.getElementById('expand-all-btn');
|
|
1887
|
+
var collapseBtn = document.getElementById('collapse-all-btn');
|
|
1888
|
+
if (expandBtn) {
|
|
1889
|
+
expandBtn.addEventListener('click', function() {
|
|
1890
|
+
document.querySelectorAll('.group').forEach(function(el) {
|
|
1891
|
+
el.open = true;
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
if (collapseBtn) {
|
|
1896
|
+
collapseBtn.addEventListener('click', function() {
|
|
1897
|
+
document.querySelectorAll('.group').forEach(function(el) {
|
|
1898
|
+
el.open = false;
|
|
1899
|
+
});
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
})();
|
|
1903
|
+
</script>
|
|
1904
|
+
`;
|
|
1905
|
+
|
|
1906
|
+
return renderAdminPage({
|
|
1907
|
+
title: `${agent.name} — Shipwright Admin`,
|
|
1908
|
+
body: `${renderAdminToolbar(userName, "/admin/agents")}
|
|
1909
|
+
<div class="vos-page">
|
|
1910
|
+
<div class="page-header">
|
|
1911
|
+
<div>
|
|
1912
|
+
<a href="/admin/agents" style="font-size:13px;color:#6b7280;text-decoration:none">← Agents</a>
|
|
1913
|
+
<h1 class="page-title" style="margin-top:4px">${escapeHtml(agent.name)}</h1>
|
|
1914
|
+
<span style="font-size:13px;color:#6b7280">Type: <span class="mono">${escapeHtml(agent.typeName)}</span></span>
|
|
1915
|
+
${agent.slackId ? `<span style="font-size:13px;color:#6b7280">Slack ID: <span class="mono">${escapeHtml(agent.slackId)}</span></span>` : ""}
|
|
1916
|
+
</div>
|
|
1917
|
+
${
|
|
1918
|
+
agent.slackId
|
|
1919
|
+
? `<div>
|
|
1920
|
+
<details>
|
|
1921
|
+
<summary class="btn btn-secondary" style="cursor:pointer;font-size:12px;list-style:none">Sync Manifest</summary>
|
|
1922
|
+
<div style="position:absolute;right:24px;margin-top:6px;background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.08);z-index:10;min-width:320px">
|
|
1923
|
+
<p style="font-size:12px;color:#6b7280;margin:0 0 10px">Syncs the current manifest to the provisioned Slack app. Requires a Slack app configuration token (<span class="mono">xoxe.xoxp-</span>).</p>
|
|
1924
|
+
<form method="POST" action="/admin/agents/${escapeHtml(agent.id)}/sync-manifest" style="display:flex;flex-direction:column;gap:8px">
|
|
1925
|
+
<input
|
|
1926
|
+
name="xoxpToken"
|
|
1927
|
+
type="password"
|
|
1928
|
+
class="form-input mono"
|
|
1929
|
+
placeholder="xoxe.xoxp-..."
|
|
1930
|
+
required
|
|
1931
|
+
style="font-size:12px"
|
|
1932
|
+
/>
|
|
1933
|
+
<button type="submit" class="btn btn-primary" style="font-size:12px;align-self:flex-start">Confirm Sync</button>
|
|
1934
|
+
</form>
|
|
1935
|
+
</div>
|
|
1936
|
+
</details>
|
|
1937
|
+
</div>`
|
|
1938
|
+
: ""
|
|
1939
|
+
}
|
|
1940
|
+
</div>
|
|
1941
|
+
${errorHtml}
|
|
1942
|
+
${successHtml}
|
|
1943
|
+
${warningHtml}
|
|
1944
|
+
${newTokenHtml}
|
|
1945
|
+
${(opts?.extensionCardsHtml ?? []).join("\n ")}
|
|
1946
|
+
|
|
1947
|
+
${statStripHtml}
|
|
1948
|
+
|
|
1949
|
+
${expandCollapseControls}
|
|
1950
|
+
${automationGroup}
|
|
1951
|
+
${configurationGroup}
|
|
1952
|
+
${accessGroup}
|
|
1953
|
+
${pluginsToolsGroup}
|
|
1954
|
+
|
|
1955
|
+
${dangerZoneSection}
|
|
1956
|
+
|
|
1957
|
+
</div>`,
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// ─── Provision pages ──────────────────────────────────────────────────────────
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* Auto-submitting POST-redirect page for the GitHub "create a GitHub App
|
|
1965
|
+
* from a manifest" flow: https://docs.github.com/apps/creating-github-apps/setting-up-a-github-app/creating-a-github-app-from-a-manifest
|
|
1966
|
+
*
|
|
1967
|
+
* `githubOrg` must already be validated by the caller against the org-name
|
|
1968
|
+
* pattern before this function is called — it's interpolated directly into
|
|
1969
|
+
* the form's `action` URL.
|
|
1970
|
+
*/
|
|
1971
|
+
export function renderGithubAppManifestRedirectPage(
|
|
1972
|
+
userName: string,
|
|
1973
|
+
opts: { githubOrg: string; manifest: unknown },
|
|
1974
|
+
): string {
|
|
1975
|
+
const actionUrl = `https://github.com/organizations/${encodeURIComponent(opts.githubOrg)}/settings/apps/new`;
|
|
1976
|
+
const manifestJson = JSON.stringify(opts.manifest);
|
|
1977
|
+
|
|
1978
|
+
return renderAdminPage({
|
|
1979
|
+
title: "Provision Agent — Shipwright Admin",
|
|
1980
|
+
body: `${renderAdminToolbar(userName, "/admin/provision")}
|
|
1981
|
+
<div class="vos-page">
|
|
1982
|
+
<div class="page-header">
|
|
1983
|
+
<h1 class="page-title">Provision Agent</h1>
|
|
1984
|
+
</div>
|
|
1985
|
+
<div class="provision-steps">
|
|
1986
|
+
<span class="provision-step active">1. Create Slack App</span>
|
|
1987
|
+
<span class="provision-step">2. Authorize</span>
|
|
1988
|
+
<span class="provision-step">3. Complete</span>
|
|
1989
|
+
</div>
|
|
1990
|
+
<div class="card">
|
|
1991
|
+
<p style="font-size:14px;margin-bottom:16px;color:#6b7280">
|
|
1992
|
+
Redirecting to GitHub to create the GitHub App under <strong>${escapeHtml(opts.githubOrg)}</strong>…
|
|
1993
|
+
</p>
|
|
1994
|
+
<form id="github-app-manifest-form" method="POST" action="${escapeHtml(actionUrl)}">
|
|
1995
|
+
<input type="hidden" name="manifest" value="${escapeHtml(manifestJson)}" />
|
|
1996
|
+
<button type="submit" class="btn btn-primary">Continue to GitHub →</button>
|
|
1997
|
+
</form>
|
|
1998
|
+
<script>
|
|
1999
|
+
document.getElementById('github-app-manifest-form').submit();
|
|
2000
|
+
</script>
|
|
2001
|
+
</div>
|
|
2002
|
+
</div>`,
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/**
|
|
2007
|
+
* Shown after GET /admin/provision/github-app/complete succeeds — links the
|
|
2008
|
+
* operator to GitHub's installations/new page for the newly created app.
|
|
2009
|
+
*/
|
|
2010
|
+
export function renderGithubAppInstallPage(
|
|
2011
|
+
userName: string,
|
|
2012
|
+
opts: { installUrl: string },
|
|
2013
|
+
): string {
|
|
2014
|
+
return renderAdminPage({
|
|
2015
|
+
title: "Provision Agent — Shipwright Admin",
|
|
2016
|
+
body: `${renderAdminToolbar(userName, "/admin/provision")}
|
|
2017
|
+
<div class="vos-page">
|
|
2018
|
+
<div class="page-header">
|
|
2019
|
+
<h1 class="page-title">Provision Agent</h1>
|
|
2020
|
+
</div>
|
|
2021
|
+
<div class="provision-steps">
|
|
2022
|
+
<span class="provision-step">1. Create Slack App</span>
|
|
2023
|
+
<span class="provision-step active">2. Install GitHub App</span>
|
|
2024
|
+
<span class="provision-step">3. Complete</span>
|
|
2025
|
+
</div>
|
|
2026
|
+
<div class="card">
|
|
2027
|
+
<div class="alert alert-success">
|
|
2028
|
+
<strong>GitHub App created!</strong> Click the link below to install it.
|
|
2029
|
+
</div>
|
|
2030
|
+
<a href="${escapeHtml(opts.installUrl)}" class="btn btn-primary" rel="noopener noreferrer">
|
|
2031
|
+
Install GitHub App →
|
|
2032
|
+
</a>
|
|
2033
|
+
</div>
|
|
2034
|
+
</div>`,
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* "Add another org" action for the agent detail page. Hidden until the agent
|
|
2040
|
+
* has a GitHub App (GH_APP_ID); links to the add-org redirect when GH_APP_SLUG
|
|
2041
|
+
* is stored, otherwise points at the docs.
|
|
2042
|
+
*/
|
|
2043
|
+
export function renderAddAnotherOrgAction(
|
|
2044
|
+
agentId: string,
|
|
2045
|
+
envVars: Record<string, string>,
|
|
2046
|
+
): string {
|
|
2047
|
+
if (!("GH_APP_ID" in envVars)) return "";
|
|
2048
|
+
if (envVars.GH_APP_SLUG) {
|
|
2049
|
+
return `<a href="/admin/agents/${escapeHtml(agentId)}/connect-github/add-org" class="btn btn-secondary" style="font-size:12px" rel="noopener noreferrer">Add another org</a>`;
|
|
2050
|
+
}
|
|
2051
|
+
return `<span style="font-size:12px;color:#6b7280">To install this App on another org, see <a href="https://github.com/app-vitals/shipwright/blob/main/docs/configuration-agent.md" rel="noopener noreferrer">the docs</a> (GH_APP_SLUG).</span>`;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Shown after GET /admin/provision/github-app/installed succeeds or fails —
|
|
2056
|
+
* the manifest flow's `setup_url` target.
|
|
2057
|
+
*/
|
|
2058
|
+
export function renderGithubAppInstalledPage(
|
|
2059
|
+
userName: string,
|
|
2060
|
+
opts: {
|
|
2061
|
+
success: boolean;
|
|
2062
|
+
error?: string;
|
|
2063
|
+
/** Stored installation ID was kept; this was an additional-org install. */
|
|
2064
|
+
alreadyStored?: boolean;
|
|
2065
|
+
},
|
|
2066
|
+
): string {
|
|
2067
|
+
const bodyHtml = opts.success
|
|
2068
|
+
? `<div class="alert alert-success">
|
|
2069
|
+
${
|
|
2070
|
+
opts.alreadyStored
|
|
2071
|
+
? "<strong>Install received.</strong> — the agent's existing installation ID was left unchanged."
|
|
2072
|
+
: "<strong>GitHub App installed!</strong> — installation ID stored."
|
|
2073
|
+
}
|
|
2074
|
+
</div>
|
|
2075
|
+
<a href="/admin/provision" class="btn btn-secondary">Back to Provisioning</a>`
|
|
2076
|
+
: `<div class="alert alert-error">${escapeHtml(opts.error ?? "GitHub App installation failed.")}</div>
|
|
2077
|
+
<a href="/admin/provision" class="btn btn-secondary">Try again</a>`;
|
|
2078
|
+
|
|
2079
|
+
return renderAdminPage({
|
|
2080
|
+
title: "Provision Agent — Shipwright Admin",
|
|
2081
|
+
body: `${renderAdminToolbar(userName, "/admin/provision")}
|
|
2082
|
+
<div class="vos-page">
|
|
2083
|
+
<div class="page-header">
|
|
2084
|
+
<h1 class="page-title">Provision Agent</h1>
|
|
2085
|
+
</div>
|
|
2086
|
+
<div class="provision-steps">
|
|
2087
|
+
<span class="provision-step">1. Create Slack App</span>
|
|
2088
|
+
<span class="provision-step">2. Install GitHub App</span>
|
|
2089
|
+
<span class="provision-step active">3. Complete</span>
|
|
2090
|
+
</div>
|
|
2091
|
+
<div class="card">
|
|
2092
|
+
${bodyHtml}
|
|
2093
|
+
</div>
|
|
2094
|
+
</div>`,
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// xapp-token page shown after OAuth callback completes — user pastes the Socket Mode app token.
|
|
2099
|
+
export function renderProvisionXappTokenPage(
|
|
2100
|
+
userName: string,
|
|
2101
|
+
opts: { agentId: string; error?: string; warning?: string },
|
|
2102
|
+
): string {
|
|
2103
|
+
const errorHtml = opts.error
|
|
2104
|
+
? `<div class="alert alert-error">${escapeHtml(opts.error)}</div>`
|
|
2105
|
+
: "";
|
|
2106
|
+
// Non-fatal warning (UAP-2.1) — e.g. a GitHub PAT that failed to store
|
|
2107
|
+
// during the combined create+connect flow. Surfaced here so the operator
|
|
2108
|
+
// isn't misled into believing GH_TOKEN was connected after finishing Slack.
|
|
2109
|
+
const warningHtml = opts.warning
|
|
2110
|
+
? `<div class="alert alert-warning">${escapeHtml(opts.warning)}</div>`
|
|
2111
|
+
: "";
|
|
2112
|
+
|
|
2113
|
+
return renderAdminPage({
|
|
2114
|
+
title: "Complete Provisioning — Shipwright Admin",
|
|
2115
|
+
body: `${renderAdminToolbar(userName, "/admin/provision")}
|
|
2116
|
+
<div class="vos-page">
|
|
2117
|
+
<div class="page-header">
|
|
2118
|
+
<h1 class="page-title">Provision Agent</h1>
|
|
2119
|
+
</div>
|
|
2120
|
+
<div class="provision-steps">
|
|
2121
|
+
<span class="provision-step">1. Create Slack App</span>
|
|
2122
|
+
<span class="provision-step">2. Authorize</span>
|
|
2123
|
+
<span class="provision-step">3. Bot Token</span>
|
|
2124
|
+
<span class="provision-step active">4. Add Socket Token</span>
|
|
2125
|
+
</div>
|
|
2126
|
+
<div class="card">
|
|
2127
|
+
<p style="font-size:14px;margin-bottom:16px;color:#6b7280">
|
|
2128
|
+
Open your Slack App's <strong>Socket Mode</strong> settings, enable Socket Mode,
|
|
2129
|
+
and generate an <strong>App-Level Token</strong> with <code class="mono">connections:write</code> scope.
|
|
2130
|
+
Paste the <code class="mono">xapp-</code> token below.
|
|
2131
|
+
</p>
|
|
2132
|
+
${warningHtml}
|
|
2133
|
+
${errorHtml}
|
|
2134
|
+
<form method="POST" action="/admin/agents/${escapeHtml(opts.agentId)}/connect-slack/app-token">
|
|
2135
|
+
<div class="form-group">
|
|
2136
|
+
<label class="form-label" for="xappToken">App-Level Token (xapp-)</label>
|
|
2137
|
+
<input
|
|
2138
|
+
id="xappToken"
|
|
2139
|
+
name="xappToken"
|
|
2140
|
+
type="password"
|
|
2141
|
+
class="form-input"
|
|
2142
|
+
placeholder="xapp-..."
|
|
2143
|
+
required
|
|
2144
|
+
/>
|
|
2145
|
+
</div>
|
|
2146
|
+
<button type="submit" class="btn btn-primary">Save Token →</button>
|
|
2147
|
+
</form>
|
|
2148
|
+
</div>
|
|
2149
|
+
</div>`,
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
export interface AgentOption {
|
|
2154
|
+
id: string;
|
|
2155
|
+
name: string;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
export function renderProvisionCompletePage(
|
|
2159
|
+
userName: string,
|
|
2160
|
+
opts: {
|
|
2161
|
+
success: boolean;
|
|
2162
|
+
agentId?: string;
|
|
2163
|
+
error?: string;
|
|
2164
|
+
},
|
|
2165
|
+
): string {
|
|
2166
|
+
const bodyHtml = opts.success
|
|
2167
|
+
? `<div class="alert alert-success">
|
|
2168
|
+
<strong>Provisioning complete!</strong> — Slack app credentials and tokens stored.
|
|
2169
|
+
</div>
|
|
2170
|
+
<p style="font-size:14px;margin-bottom:16px;margin-top:16px">
|
|
2171
|
+
All credentials have been saved to the agent's env vars and system crons have been seeded.
|
|
2172
|
+
For a self-hosted agent, mint its own API key from the agent's detail page.
|
|
2173
|
+
</p>
|
|
2174
|
+
${opts.agentId ? `<a href="/admin/agents/${escapeHtml(opts.agentId)}" class="btn btn-primary">View Agent →</a>` : ""}
|
|
2175
|
+
<a href="/admin/agents" class="btn btn-secondary" style="margin-left:8px">Back to Agents</a>`
|
|
2176
|
+
: `<div class="alert alert-error">${escapeHtml(opts.error ?? "Provisioning failed.")}</div>
|
|
2177
|
+
<a href="/admin/provision" class="btn btn-secondary">Try again</a>`;
|
|
2178
|
+
|
|
2179
|
+
return renderAdminPage({
|
|
2180
|
+
title: "Provisioning Complete — Shipwright Admin",
|
|
2181
|
+
body: `${renderAdminToolbar(userName, "/admin/provision")}
|
|
2182
|
+
<div class="vos-page">
|
|
2183
|
+
<div class="page-header">
|
|
2184
|
+
<h1 class="page-title">Provision Agent</h1>
|
|
2185
|
+
</div>
|
|
2186
|
+
<div class="provision-steps">
|
|
2187
|
+
<span class="provision-step">1. Create Slack App</span>
|
|
2188
|
+
<span class="provision-step">2. Authorize</span>
|
|
2189
|
+
<span class="provision-step">3. Bot Token</span>
|
|
2190
|
+
<span class="provision-step active">4. Complete</span>
|
|
2191
|
+
</div>
|
|
2192
|
+
<div class="card">
|
|
2193
|
+
${bodyHtml}
|
|
2194
|
+
</div>
|
|
2195
|
+
</div>`,
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// ─── Chat page ─────────────────────────────────────────────────────────────────
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* Renders the top-level /admin/chat page.
|
|
2203
|
+
*
|
|
2204
|
+
* @param agents - list of agents for the dropdown
|
|
2205
|
+
* @param selectedAgentId - the currently selected agent (from ?agentId=X)
|
|
2206
|
+
* @param threads - thread list (null = chatClient absent → degraded mode)
|
|
2207
|
+
* @param userName - logged-in user's email for the toolbar
|
|
2208
|
+
*/
|
|
2209
|
+
|
|
2210
|
+
const threadPaneStyles = `
|
|
2211
|
+
.thread-pane-list { display:flex;flex-direction:column;gap:4px;margin-top:8px }
|
|
2212
|
+
.thread-pane-link { display:block;padding:8px 12px;border-radius:6px;font-size:13px;color:#374151;text-decoration:none;background:#f9fafb;border:1px solid #e5e7eb }
|
|
2213
|
+
.thread-pane-link:hover { background:#eef2ff;color:#4f46e5 }
|
|
2214
|
+
.thread-pane-link.active { background:#eef2ff;color:#4f46e5;font-weight:600 }`;
|
|
2215
|
+
|
|
2216
|
+
const chatPageStyles = `
|
|
2217
|
+
.chat-sidebar-new-thread-input { font-size:12px }
|
|
2218
|
+
@media (max-width:${BREAKPOINT_MOBILE_MAX}px) {
|
|
2219
|
+
.chat-sidebar-new-thread-input { font-size:16px }
|
|
2220
|
+
.chat-list-layout { flex-direction:column }
|
|
2221
|
+
.chat-list-sidebar { width:100%;max-width:100%;min-width:0 }
|
|
2222
|
+
/* chat-thread-layout: flex wrapper for thread+message area; stacks to column on mobile */
|
|
2223
|
+
.chat-thread-layout { flex-direction:column }
|
|
2224
|
+
/* ─── Mobile thread drawer (CFB-3.2) ──────────────────────────────────
|
|
2225
|
+
The sidebar becomes an off-canvas drawer instead of display:none, so
|
|
2226
|
+
the thread list is reachable on mobile. It is translated off the left
|
|
2227
|
+
edge by default and slid in when the checkbox is :checked. A scrim
|
|
2228
|
+
dims/blocks the background and closes the drawer on tap. */
|
|
2229
|
+
.chat-thread-sidebar {
|
|
2230
|
+
position:fixed;top:0;left:0;bottom:0;z-index:102;
|
|
2231
|
+
width:80%;max-width:300px;min-width:0;
|
|
2232
|
+
margin:0;border-radius:0;overflow-y:auto;
|
|
2233
|
+
transform:translateX(-100%);
|
|
2234
|
+
transition:transform 0.2s ease;
|
|
2235
|
+
}
|
|
2236
|
+
.chat-drawer-toggle:checked ~ .chat-thread-sidebar { transform:translateX(0) }
|
|
2237
|
+
/* Scrim toggles display (not just opacity) so it's genuinely absent from
|
|
2238
|
+
the a11y/visibility tree when closed — Playwright's toBeVisible() (and
|
|
2239
|
+
assistive tech) treat an opacity:0 element as still visible. */
|
|
2240
|
+
.chat-drawer-scrim {
|
|
2241
|
+
display:none;position:fixed;inset:0;z-index:101;
|
|
2242
|
+
background:rgba(0,0,0,0.4);cursor:pointer;
|
|
2243
|
+
}
|
|
2244
|
+
.chat-drawer-toggle:checked ~ .chat-drawer-scrim { display:block }
|
|
2245
|
+
.chat-drawer-hamburger { display:inline-block }
|
|
2246
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2247
|
+
.chat-thread-sidebar { transition:none }
|
|
2248
|
+
}
|
|
2249
|
+
/* Collapse the header to a compact ~48px control row + 16px meta line so
|
|
2250
|
+
the message stream sits above the fold at 375px (criterion #4). The
|
|
2251
|
+
title rides inline in the control row; the thread-id meta stays as the
|
|
2252
|
+
single 16px line. Per-thread token stats and the rename/delete
|
|
2253
|
+
disclosure are pushed off-header on mobile (still reachable via the
|
|
2254
|
+
drawer / desktop) to keep the header under 96px. */
|
|
2255
|
+
.chat-thread-header { padding-top:2px;padding-bottom:2px }
|
|
2256
|
+
.chat-thread-header-top { margin-bottom:2px !important }
|
|
2257
|
+
.chat-thread-header .chat-drawer-hamburger { padding:4px 8px }
|
|
2258
|
+
.chat-thread-header .btn { margin-bottom:0 !important;padding:4px 10px }
|
|
2259
|
+
.chat-thread-header .page-title { font-size:15px;line-height:1.2;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis }
|
|
2260
|
+
.chat-thread-header .chat-thread-meta { margin-top:2px !important }
|
|
2261
|
+
.chat-thread-header .chat-thread-stats { display:none }
|
|
2262
|
+
.chat-thread-header .chat-thread-actions { display:none }
|
|
2263
|
+
.chat-bubble-inner { max-width:90% }
|
|
2264
|
+
#message-input { font-size:16px }
|
|
2265
|
+
/* Composer row: let the textarea keep adequate width instead of being
|
|
2266
|
+
squeezed to ~160px next to the Attach/Send buttons at narrow widths. */
|
|
2267
|
+
.chat-composer-row { flex-wrap:wrap }
|
|
2268
|
+
.chat-message-input { flex-basis:100%; }
|
|
2269
|
+
/* .chat-thread-page combines with .vos-page (specificity 0,2,0), which
|
|
2270
|
+
otherwise beats .vos-page's own mobile padding rule (0,1,0) — pin the
|
|
2271
|
+
effective mobile padding here. The bottom padding is intentionally
|
|
2272
|
+
small: the sticky composer carries its own safe-area bottom inset, and
|
|
2273
|
+
a large page bottom padding would steal scarce vertical space when the
|
|
2274
|
+
keyboard shrinks the viewport (CFB-3.2 criterion #3). */
|
|
2275
|
+
.chat-thread-page { padding:8px 12px 8px }
|
|
2276
|
+
/* Tighten the gap between the header and the message column so the
|
|
2277
|
+
message stream starts higher (criterion #4). */
|
|
2278
|
+
.chat-thread-layout { margin-top:8px !important }
|
|
2279
|
+
}`;
|
|
2280
|
+
|
|
2281
|
+
// ─── Chat thread page — class-driven bubble/composer styling (CFB-1.3) ────────
|
|
2282
|
+
// These class names are shared verbatim between the server-rendered HTML
|
|
2283
|
+
// (renderMessageBubble()) and the inline JS bubble builder (addBubble()) so
|
|
2284
|
+
// the two renderers physically cannot drift apart — see the interpolation of
|
|
2285
|
+
// CHAT_BUBBLE_CLASS/CHAT_BUBBLE_INNER_CLASS into `inlineScript` below.
|
|
2286
|
+
const CHAT_BUBBLE_CLASS = "chat-bubble";
|
|
2287
|
+
const CHAT_BUBBLE_INNER_CLASS = "chat-bubble-inner";
|
|
2288
|
+
const CHAT_BUBBLE_INNER_WIDE_CLASS = "chat-bubble-inner--wide";
|
|
2289
|
+
|
|
2290
|
+
/** e.g. "chat-bubble--user" — role is attacker-controlled only via server data, never used unescaped in an attribute here since roles are a closed set (user/assistant/system/other). */
|
|
2291
|
+
function chatBubbleRoleClass(role: string): string {
|
|
2292
|
+
return `${CHAT_BUBBLE_CLASS}--${role}`;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// ─── Live progress (CFB-2.3) ──────────────────────────────────────────────────
|
|
2296
|
+
|
|
2297
|
+
/**
|
|
2298
|
+
* Human-readable label per errorKind. Single source of truth: used by the
|
|
2299
|
+
* server renderer (renderChatMessageBubble) AND serialized into the inline JS
|
|
2300
|
+
* (JSON.stringify(ERROR_KIND_LABELS)) so the client copy is generated from
|
|
2301
|
+
* this exact object and cannot drift — same pattern as CHAT_BUBBLE_CLASS.
|
|
2302
|
+
* A kind not present here falls back to DEFAULT_ERROR_LABEL.
|
|
2303
|
+
* `cancelled`/`incomplete`/`stalled` (CFB-2.4) are also retryable — see
|
|
2304
|
+
* RETRYABLE_ERROR_KINDS below.
|
|
2305
|
+
*/
|
|
2306
|
+
export const ERROR_KIND_LABELS: Record<string, string> = {
|
|
2307
|
+
"rate-limited": "Rate limited",
|
|
2308
|
+
upstream: "Request failed",
|
|
2309
|
+
timeout: "Timed out",
|
|
2310
|
+
cancelled: "Cancelled",
|
|
2311
|
+
incomplete: "Incomplete",
|
|
2312
|
+
stalled: "Stalled",
|
|
2313
|
+
};
|
|
2314
|
+
const DEFAULT_ERROR_LABEL = "Error";
|
|
2315
|
+
|
|
2316
|
+
/** Resolve an errorKind to its label, with the shared default fallback. */
|
|
2317
|
+
function errorKindLabel(kind: string): string {
|
|
2318
|
+
return ERROR_KIND_LABELS[kind] ?? DEFAULT_ERROR_LABEL;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* Error kinds recoverable via the Retry button (CFB-2.4) — the reply never
|
|
2323
|
+
* really completed (cancelled via heartbeat tick, cut off mid-stream, or
|
|
2324
|
+
* stalled out), so re-sending the originating user message is safe and
|
|
2325
|
+
* likely to succeed. Kinds like rate-limited/timeout/upstream are excluded:
|
|
2326
|
+
* those already represent a completed, failed round-trip.
|
|
2327
|
+
*/
|
|
2328
|
+
const RETRYABLE_ERROR_KINDS: ReadonlySet<string> = new Set([
|
|
2329
|
+
"cancelled",
|
|
2330
|
+
"incomplete",
|
|
2331
|
+
"stalled",
|
|
2332
|
+
]);
|
|
2333
|
+
|
|
2334
|
+
// Stable DOM id/class for the live status bubble (elapsed + milestone). The
|
|
2335
|
+
// server renders one when the last user message is unreplied, and the inline
|
|
2336
|
+
// ticker JS finds/updates/creates it by this exact id so the "just loaded,
|
|
2337
|
+
// agent still working" and "just sent, agent now working" bubbles are one and
|
|
2338
|
+
// the same element.
|
|
2339
|
+
const LIVE_STATUS_BUBBLE_ID = "live-status-bubble";
|
|
2340
|
+
const LIVE_STATUS_ELAPSED_ID = "live-status-elapsed";
|
|
2341
|
+
const LIVE_STATUS_MILESTONE_ID = "live-status-milestone";
|
|
2342
|
+
const STALL_INDICATOR_CLASS = "chat-stall-indicator";
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* Layer-1 elapsed-timer guarantee: the client-side 1s ticker computes
|
|
2346
|
+
* `now - createdAt` with ZERO network dependency. Layer-2 milestone text
|
|
2347
|
+
* refreshes off the existing messages.json poll (adaptive 2s pending / 10s
|
|
2348
|
+
* idle). STALL_WARN_AFTER_MS is a *visible warning* (not a giveup) shown once
|
|
2349
|
+
* progressSeq has been unchanged for this long; ABSOLUTE_MAX_MS is the only
|
|
2350
|
+
* hard stop.
|
|
2351
|
+
*/
|
|
2352
|
+
export const STALL_WARN_AFTER_MS = 120_000;
|
|
2353
|
+
// 65min — mirrors lib/claim-ttl.ts's DEFAULT_CLAIM_TTL_MS
|
|
2354
|
+
// (DEFAULT_CLAUDE_TIMEOUT_MS 1hr + CLAIM_TTL_BUFFER_MS 5min). Inline JS can't
|
|
2355
|
+
// import the lib file, so the value is hardcoded here with this reference.
|
|
2356
|
+
export const ABSOLUTE_MAX_MS = 3_900_000;
|
|
2357
|
+
|
|
2358
|
+
/**
|
|
2359
|
+
* Module-level pure bubble renderer (hoisted out of renderChatThreadPage in
|
|
2360
|
+
* CFB-2.3). Returns the exact HTML the server emits on a full page load; the
|
|
2361
|
+
* same string is returned as `bubbleHtml` from messages.json so polled bubbles
|
|
2362
|
+
* are byte-identical to reloaded ones. Every message bubble carries a stable
|
|
2363
|
+
* `data-message-id` so the client can dedupe via an id-based renderedIds set.
|
|
2364
|
+
*
|
|
2365
|
+
* `retryBody` (CFB-2.4) is the body of the most recent preceding user message
|
|
2366
|
+
* — when set and `m.errorKind` is one of RETRYABLE_ERROR_KINDS, the error
|
|
2367
|
+
* badge also renders a Retry button that resends that exact text. Callers
|
|
2368
|
+
* iterating a message list should track the last-seen user body and pass it
|
|
2369
|
+
* through per-message (see renderChatThreadPage and the messages.json route).
|
|
2370
|
+
*
|
|
2371
|
+
* `agentId` (VM-3.1) is needed to build the attachment-proxy URL
|
|
2372
|
+
* (`/admin/chat/:agentId/threads/:threadId/messages/:id/attachment`) used as
|
|
2373
|
+
* an audio attachment's `<audio src>` — defaults to "" for callers/tests that
|
|
2374
|
+
* don't pass it and don't care about attachment rendering.
|
|
2375
|
+
*/
|
|
2376
|
+
export function renderChatMessageBubble(
|
|
2377
|
+
m: ChatMessage,
|
|
2378
|
+
retryBody: string | null = null,
|
|
2379
|
+
agentId = "",
|
|
2380
|
+
): string {
|
|
2381
|
+
const isUser = m.role === "user";
|
|
2382
|
+
const isAssistant = m.role === "assistant";
|
|
2383
|
+
const isSystem = m.role === "system";
|
|
2384
|
+
|
|
2385
|
+
const bubbleRoleClass = chatBubbleRoleClass(
|
|
2386
|
+
isUser ? "user" : isAssistant ? "assistant" : isSystem ? "system" : "other",
|
|
2387
|
+
);
|
|
2388
|
+
const bubbleColor = isUser
|
|
2389
|
+
? "#4f46e5"
|
|
2390
|
+
: isAssistant
|
|
2391
|
+
? "#166534"
|
|
2392
|
+
: isSystem
|
|
2393
|
+
? "#854d0e"
|
|
2394
|
+
: "#374151";
|
|
2395
|
+
const bubbleInnerClass = isSystem
|
|
2396
|
+
? `${CHAT_BUBBLE_INNER_CLASS} ${CHAT_BUBBLE_INNER_WIDE_CLASS}`
|
|
2397
|
+
: CHAT_BUBBLE_INNER_CLASS;
|
|
2398
|
+
|
|
2399
|
+
// Render error badge if errorKind is set. cancelled/incomplete/stalled are
|
|
2400
|
+
// recoverable states (CFB-2.4), so they also render a Retry action that
|
|
2401
|
+
// re-sends the originating user message.
|
|
2402
|
+
let errorBadge = "";
|
|
2403
|
+
if (m.errorKind) {
|
|
2404
|
+
const retryable =
|
|
2405
|
+
RETRYABLE_ERROR_KINDS.has(m.errorKind) && retryBody !== null;
|
|
2406
|
+
const retryAction = retryable
|
|
2407
|
+
? `<button type="button" class="chat-retry-btn" data-retry-body="${escapeHtml(retryBody as string)}" style="margin-left:8px;padding:2px 8px;background:#fff;color:#b91c1c;border:1px solid #b91c1c;border-radius:4px;font-size:12px;font-weight:600;cursor:pointer">Retry</button>`
|
|
2408
|
+
: "";
|
|
2409
|
+
errorBadge = `<div style="margin-top:6px;padding:4px 8px;background:#fee2e2;color:#b91c1c;border-radius:4px;font-size:12px;font-weight:600;display:inline-flex;align-items:center">${errorKindLabel(m.errorKind)}${retryAction}</div>`;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// Parse markers from assistant messages to extract URLs/paths and clean text
|
|
2413
|
+
let cleanedBody = m.body;
|
|
2414
|
+
let markerBadges = "";
|
|
2415
|
+
if (isAssistant) {
|
|
2416
|
+
const { cleaned, uploads, planUrls } = parseChatMarkers(m.body);
|
|
2417
|
+
cleanedBody = cleaned;
|
|
2418
|
+
|
|
2419
|
+
// Render upload badges
|
|
2420
|
+
const uploadBadges = uploads
|
|
2421
|
+
.map((path) => {
|
|
2422
|
+
const filename = path.split("/").pop() || path;
|
|
2423
|
+
return `<div style="display:inline-block;margin-right:6px;margin-top:8px;padding:3px 8px;background:#e5e7eb;color:#374151;border-radius:6px;font-size:12px">📎 ${escapeHtml(filename)}</div>`;
|
|
2424
|
+
})
|
|
2425
|
+
.join("");
|
|
2426
|
+
|
|
2427
|
+
// Render plan links
|
|
2428
|
+
const planLinks = planUrls
|
|
2429
|
+
.map(
|
|
2430
|
+
(url) =>
|
|
2431
|
+
`<a href="${escapeHtml(url)}" target="_blank" style="display:inline-block;margin-right:6px;margin-top:8px;padding:3px 8px;background:#dbeafe;color:#1e40af;border-radius:6px;font-size:12px;text-decoration:none">View plan →</a>`,
|
|
2432
|
+
)
|
|
2433
|
+
.join("");
|
|
2434
|
+
|
|
2435
|
+
markerBadges = uploadBadges + planLinks;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// Render body: assistant messages get markdown, others get escaped text
|
|
2439
|
+
const bodyHtml = isAssistant
|
|
2440
|
+
? `<div style="font-size:14px;line-height:1.6;color:${bubbleColor}">${renderMarkdown(cleanedBody)}</div>`
|
|
2441
|
+
: `<div style="font-size:14px;white-space:pre-wrap;color:${bubbleColor}">${escapeHtml(m.body)}</div>`;
|
|
2442
|
+
|
|
2443
|
+
// Attachment: an inline <audio> player for audio attachments (VM-3.1) —
|
|
2444
|
+
// mic-recorded user messages and VM-2.1's TTS-authored assistant replies —
|
|
2445
|
+
// otherwise the plain filename badge. Audio-ness is inferred from the
|
|
2446
|
+
// filename extension (see attachment-validation.ts's isAudioFilename); the
|
|
2447
|
+
// src hits the admin attachment-proxy route, which streams the message's
|
|
2448
|
+
// bytes from the chat service with a real audio/* Content-Type.
|
|
2449
|
+
const attachmentMedia = m.attachmentFilename
|
|
2450
|
+
? isAudioFilename(m.attachmentFilename)
|
|
2451
|
+
? `<audio controls style="display:block;margin-top:8px;max-width:100%" src="${escapeHtml(`/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(m.threadId)}/messages/${encodeURIComponent(m.id)}/attachment`)}"></audio>`
|
|
2452
|
+
: `<div style="display:inline-block;margin-top:8px;padding:3px 8px;background:#e5e7eb;color:#374151;border-radius:6px;font-size:12px">📎 ${escapeHtml(m.attachmentFilename)}</div>`
|
|
2453
|
+
: "";
|
|
2454
|
+
|
|
2455
|
+
let tokenBadge = "";
|
|
2456
|
+
if (isAssistant && m.tokens !== null && typeof m.tokens === "object") {
|
|
2457
|
+
const t = m.tokens as MessageTokens;
|
|
2458
|
+
const inTok = t.input_tokens ?? 0;
|
|
2459
|
+
const outTok = t.output_tokens ?? 0;
|
|
2460
|
+
const costPart = m.costUsd !== null ? ` · $${m.costUsd.toFixed(4)}` : "";
|
|
2461
|
+
tokenBadge = `<div style="font-size:11px;color:#6b7280;margin-top:4px">${escapeHtml(`${inTok} in / ${outTok} out${costPart}`)}</div>`;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
return `<div class="${CHAT_BUBBLE_CLASS} ${bubbleRoleClass}" data-message-id="${escapeHtml(m.id)}">
|
|
2465
|
+
<div class="${bubbleInnerClass}">
|
|
2466
|
+
<div style="font-size:11px;font-weight:600;color:${bubbleColor};margin-bottom:4px;text-transform:uppercase;letter-spacing:0.05em">${escapeHtml(m.role)}</div>
|
|
2467
|
+
${bodyHtml}
|
|
2468
|
+
${markerBadges}
|
|
2469
|
+
${attachmentMedia}
|
|
2470
|
+
${errorBadge}
|
|
2471
|
+
${tokenBadge}
|
|
2472
|
+
<div style="font-size:11px;color:#9ca3af;margin-top:6px">${escapeHtml(new Date(m.createdAt).toLocaleString())}</div>
|
|
2473
|
+
</div>
|
|
2474
|
+
</div>`;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
/**
|
|
2478
|
+
* Server-rendered live status bubble shown when the last user message is
|
|
2479
|
+
* unreplied (`role === 'user' && !repliedAt`). Renders the elapsed timer seed
|
|
2480
|
+
* and (optionally) the current milestone. Uses stable ids so the inline
|
|
2481
|
+
* ticker finds and updates it on load without fabricating one. `progressPhase`
|
|
2482
|
+
* null ⇒ no milestone text (heartbeat hasn't reported a phase yet). The
|
|
2483
|
+
* elapsed seed is 0s server-side; the client ticker takes over immediately
|
|
2484
|
+
* from `data-created-at` with zero network dependency.
|
|
2485
|
+
*/
|
|
2486
|
+
function renderLiveStatusBubble(m: ChatMessage): string {
|
|
2487
|
+
const milestone =
|
|
2488
|
+
m.progressPhase &&
|
|
2489
|
+
PROGRESS_LABELS[m.progressPhase as keyof typeof PROGRESS_LABELS]
|
|
2490
|
+
? PROGRESS_LABELS[m.progressPhase as keyof typeof PROGRESS_LABELS]
|
|
2491
|
+
: "";
|
|
2492
|
+
const milestoneHtml = `<span id="${LIVE_STATUS_MILESTONE_ID}">${escapeHtml(milestone)}</span>`;
|
|
2493
|
+
const createdAtMs = new Date(m.createdAt).getTime();
|
|
2494
|
+
const seq = m.progressSeq ?? 0;
|
|
2495
|
+
return `<div id="${LIVE_STATUS_BUBBLE_ID}" class="${CHAT_BUBBLE_CLASS} ${chatBubbleRoleClass("assistant")}" data-created-at="${createdAtMs}" data-progress-seq="${seq}">
|
|
2496
|
+
<div class="${CHAT_BUBBLE_INNER_CLASS}">
|
|
2497
|
+
<div style="font-size:14px;color:#166534;font-style:italic">
|
|
2498
|
+
${milestoneHtml}
|
|
2499
|
+
<span id="${LIVE_STATUS_ELAPSED_ID}">working… (0s)</span>
|
|
2500
|
+
</div>
|
|
2501
|
+
</div>
|
|
2502
|
+
</div>`;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
const chatThreadStyles = `
|
|
2506
|
+
/* The toolbar (.vos-toolbar) is a normal-flow sibling above .chat-thread-page,
|
|
2507
|
+
not fixed/absolute — so the page doesn't need to subtract a hardcoded
|
|
2508
|
+
toolbar height. Instead it grows to fill the remaining viewport via a
|
|
2509
|
+
flex-column body: min-height:100vh as a baseline, then min-height:100dvh
|
|
2510
|
+
as a progressive enhancement (dynamic viewport height accounts for
|
|
2511
|
+
mobile browser chrome that can grow the toolbar without breaking layout). */
|
|
2512
|
+
body { display:flex;flex-direction:column;min-height:100vh;min-height:100dvh;height:100dvh }
|
|
2513
|
+
.chat-thread-page { display:flex;flex-direction:column;flex:1;min-height:0;max-width:900px;margin:0 auto;padding:0 24px;width:100% }
|
|
2514
|
+
.chat-thread-header { padding-top:20px;padding-bottom:16px;flex-shrink:0 }
|
|
2515
|
+
/* Collapsed-by-default rename/delete disclosure (CFB-3.1 #5) — keeps the
|
|
2516
|
+
header compact above the fold regardless of viewport width. */
|
|
2517
|
+
.chat-thread-actions { margin-top:12px }
|
|
2518
|
+
.chat-thread-actions-summary { cursor:pointer;font-size:13px;color:#6b7280;user-select:none }
|
|
2519
|
+
.chat-thread-actions-summary:hover { color:#374151 }
|
|
2520
|
+
.chat-messages-container { flex:1;overflow-y:auto;overscroll-behavior:contain;padding:8px 0;min-height:0 }
|
|
2521
|
+
.${CHAT_BUBBLE_CLASS} { display:flex;margin-bottom:12px }
|
|
2522
|
+
.${chatBubbleRoleClass("user")} { justify-content:flex-end }
|
|
2523
|
+
.${chatBubbleRoleClass("assistant")} { justify-content:flex-start }
|
|
2524
|
+
.${chatBubbleRoleClass("system")} { justify-content:center }
|
|
2525
|
+
.${chatBubbleRoleClass("other")} { justify-content:flex-start }
|
|
2526
|
+
.${CHAT_BUBBLE_INNER_CLASS} { max-width:70%;border-radius:12px;padding:12px 16px;box-shadow:0 1px 2px rgba(0,0,0,0.06);overflow-wrap:anywhere }
|
|
2527
|
+
/* renderMarkdown emits <pre><code> for fenced blocks — let long code lines
|
|
2528
|
+
scroll horizontally inside the bubble instead of forcing the whole page
|
|
2529
|
+
to overflow. Scoped to the bubble so it doesn't affect other <pre>. */
|
|
2530
|
+
.${CHAT_BUBBLE_INNER_CLASS} pre { overflow-x:auto }
|
|
2531
|
+
.${CHAT_BUBBLE_INNER_CLASS}.${CHAT_BUBBLE_INNER_WIDE_CLASS} { max-width:80% }
|
|
2532
|
+
.${chatBubbleRoleClass("user")} .${CHAT_BUBBLE_INNER_CLASS} { background:#eef2ff }
|
|
2533
|
+
.${chatBubbleRoleClass("assistant")} .${CHAT_BUBBLE_INNER_CLASS} { background:#f0fdf4 }
|
|
2534
|
+
.${chatBubbleRoleClass("system")} .${CHAT_BUBBLE_INNER_CLASS} { background:#fef9c3 }
|
|
2535
|
+
.${chatBubbleRoleClass("other")} .${CHAT_BUBBLE_INNER_CLASS} { background:#f3f4f6 }
|
|
2536
|
+
/* Sticky composer: pins to the bottom of the scroll container so it stays
|
|
2537
|
+
reachable as messages grow. padding-bottom folds in the iOS safe-area
|
|
2538
|
+
inset plus --kb-inset (set by the visualViewport listener when the
|
|
2539
|
+
on-screen keyboard shrinks the visual viewport) so the composer isn't
|
|
2540
|
+
slid under the keyboard on iOS, where dvh does NOT shrink the layout. */
|
|
2541
|
+
.chat-composer-form { flex-shrink:0;position:sticky;bottom:0;background:#f7f7fb;padding:16px 0;padding-bottom:calc(12px + env(safe-area-inset-bottom) + var(--kb-inset, 0px));border-top:1px solid #e5e7eb;margin-top:8px }
|
|
2542
|
+
.chat-composer-row { display:flex;gap:8px;align-items:flex-end }
|
|
2543
|
+
.chat-message-input { flex:1;resize:vertical;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;font-family:inherit;line-height:1.5;outline:none }
|
|
2544
|
+
.chat-composer-btn { flex-shrink:0;height:44px }
|
|
2545
|
+
.chat-composer-btn--attach { padding:0 16px }
|
|
2546
|
+
.chat-composer-btn--mic { padding:0 16px }
|
|
2547
|
+
.chat-composer-btn--mic.chat-mic-recording { background:#fee2e2;color:#b91c1c;border-color:#b91c1c }
|
|
2548
|
+
.chat-composer-btn--send { padding:0 20px }
|
|
2549
|
+
/* Live-progress stall state (CFB-2.3): when progressSeq hasn't advanced for
|
|
2550
|
+
STALL_WARN_AFTER_MS the status bubble gains .${STALL_INDICATOR_CLASS},
|
|
2551
|
+
which pulses to draw the eye. Motion is suppressed for users who ask for
|
|
2552
|
+
reduced motion — the amber colour still conveys the state statically. */
|
|
2553
|
+
.${STALL_INDICATOR_CLASS} { color:#b45309 }
|
|
2554
|
+
.${STALL_INDICATOR_CLASS} .${CHAT_BUBBLE_INNER_CLASS} { background:#fffbeb;animation:chat-stall-pulse 1.4s ease-in-out infinite }
|
|
2555
|
+
@keyframes chat-stall-pulse { 0%,100% { opacity:1 } 50% { opacity:0.55 } }
|
|
2556
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2557
|
+
.${STALL_INDICATOR_CLASS} .${CHAT_BUBBLE_INNER_CLASS} { animation:none }
|
|
2558
|
+
}
|
|
2559
|
+
/* ─── Thread drawer (CFB-3.2) ────────────────────────────────────────────
|
|
2560
|
+
CSS-only collapsible sidebar mirroring the toolbar hamburger idiom. On
|
|
2561
|
+
desktop the sidebar is an ordinary flex column and the drawer chrome
|
|
2562
|
+
(toggle checkbox, scrim, hamburger) is inert/hidden — everything below
|
|
2563
|
+
only activates inside the mobile @media block in chatPageStyles. DOM
|
|
2564
|
+
order is load-bearing: the checkbox precedes the sidebar so the ~ sibling
|
|
2565
|
+
combinator can reach it. */
|
|
2566
|
+
.chat-drawer-toggle { position:absolute;opacity:0;width:1px;height:1px;pointer-events:none }
|
|
2567
|
+
.chat-drawer-hamburger { display:none;background:none;border:1px solid #e5e7eb;border-radius:6px;font-size:18px;line-height:1;padding:6px 10px;cursor:pointer;color:#374151 }
|
|
2568
|
+
.chat-drawer-hamburger:hover { color:#4f46e5 }
|
|
2569
|
+
.chat-drawer-scrim { display:none }`;
|
|
2570
|
+
|
|
2571
|
+
export function renderChatPage(
|
|
2572
|
+
agents: AgentOption[],
|
|
2573
|
+
selectedAgentId: string | undefined,
|
|
2574
|
+
threads: ChatThread[] | null,
|
|
2575
|
+
userName: string,
|
|
2576
|
+
q?: string,
|
|
2577
|
+
): string {
|
|
2578
|
+
const activePath = "/admin/chat";
|
|
2579
|
+
|
|
2580
|
+
const agentOptions = agents
|
|
2581
|
+
.map(
|
|
2582
|
+
(a) =>
|
|
2583
|
+
`<option value="${escapeHtml(a.id)}"${a.id === selectedAgentId ? " selected" : ""}>${escapeHtml(a.name)}</option>`,
|
|
2584
|
+
)
|
|
2585
|
+
.join("\n");
|
|
2586
|
+
|
|
2587
|
+
const agentSelector = `
|
|
2588
|
+
<form method="GET" action="/admin/chat" class="form-row" style="margin-bottom:24px">
|
|
2589
|
+
<div class="form-group" style="max-width:320px">
|
|
2590
|
+
<label class="form-label" for="agentId">Agent</label>
|
|
2591
|
+
<select name="agentId" id="agentId" class="form-input" onchange="this.form.submit()">
|
|
2592
|
+
<option value="">— select an agent —</option>
|
|
2593
|
+
${agentOptions}
|
|
2594
|
+
</select>
|
|
2595
|
+
</div>
|
|
2596
|
+
</form>`;
|
|
2597
|
+
|
|
2598
|
+
let content: string;
|
|
2599
|
+
|
|
2600
|
+
if (threads === null) {
|
|
2601
|
+
// Degraded mode — chat service not configured
|
|
2602
|
+
content = `
|
|
2603
|
+
<div class="alert alert-error">
|
|
2604
|
+
Chat service not configured. Set <code>SHIPWRIGHT_CHAT_SERVICE_URL</code> and
|
|
2605
|
+
<code>SHIPWRIGHT_CHAT_SERVICE_ADMIN_TOKEN</code> to enable this feature.
|
|
2606
|
+
</div>`;
|
|
2607
|
+
} else if (!selectedAgentId) {
|
|
2608
|
+
// No agent selected yet
|
|
2609
|
+
content = `
|
|
2610
|
+
<div class="empty-state">
|
|
2611
|
+
Select an agent above to view its threads.
|
|
2612
|
+
</div>`;
|
|
2613
|
+
} else {
|
|
2614
|
+
// Search box
|
|
2615
|
+
const searchForm = `
|
|
2616
|
+
<form method="GET" action="/admin/chat" class="form-row" style="margin-bottom:16px">
|
|
2617
|
+
<input type="hidden" name="agentId" value="${escapeHtml(selectedAgentId)}">
|
|
2618
|
+
<div class="form-group" style="max-width:320px">
|
|
2619
|
+
<input type="text" name="q" class="form-input" placeholder="Search threads…" value="${escapeHtml(q ?? "")}">
|
|
2620
|
+
</div>
|
|
2621
|
+
<button type="submit" class="btn btn-secondary">Search</button>
|
|
2622
|
+
</form>`;
|
|
2623
|
+
|
|
2624
|
+
// New thread form (above the thread list)
|
|
2625
|
+
const newThreadForm = `
|
|
2626
|
+
<form method="POST" action="/admin/chat/${escapeHtml(selectedAgentId)}/threads" style="margin-bottom:16px">
|
|
2627
|
+
<div class="form-row">
|
|
2628
|
+
<div class="form-group">
|
|
2629
|
+
<input type="text" name="title" class="form-input" placeholder="Thread title (optional)">
|
|
2630
|
+
</div>
|
|
2631
|
+
<button type="submit" class="btn btn-primary">New Thread</button>
|
|
2632
|
+
</div>
|
|
2633
|
+
</form>`;
|
|
2634
|
+
|
|
2635
|
+
// Thread list pane
|
|
2636
|
+
const threadLinks =
|
|
2637
|
+
threads.length === 0
|
|
2638
|
+
? `<div class="empty-state" style="padding:12px">No threads found.</div>`
|
|
2639
|
+
: threads
|
|
2640
|
+
.map((t) => {
|
|
2641
|
+
const title = escapeHtml(t.title ?? "Untitled");
|
|
2642
|
+
return `<a href="/admin/chat/${escapeHtml(selectedAgentId)}/threads/${escapeHtml(t.id)}" class="thread-pane-link">${title}</a>`;
|
|
2643
|
+
})
|
|
2644
|
+
.join("\n");
|
|
2645
|
+
|
|
2646
|
+
content = `
|
|
2647
|
+
${searchForm}
|
|
2648
|
+
<div class="chat-list-layout" style="display:flex;gap:24px;align-items:flex-start">
|
|
2649
|
+
<div class="card chat-list-sidebar" style="min-width:240px;max-width:300px;flex-shrink:0">
|
|
2650
|
+
<div class="card-title">Threads</div>
|
|
2651
|
+
${newThreadForm}
|
|
2652
|
+
<div class="thread-pane-list">
|
|
2653
|
+
${threadLinks}
|
|
2654
|
+
</div>
|
|
2655
|
+
</div>
|
|
2656
|
+
<div style="flex:1">
|
|
2657
|
+
<div class="empty-state">Select a thread from the list to view messages.</div>
|
|
2658
|
+
</div>
|
|
2659
|
+
</div>`;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
return renderAdminPage({
|
|
2663
|
+
title: "Chat — Shipwright Admin",
|
|
2664
|
+
extraStyles: `${threadPaneStyles}${chatPageStyles}
|
|
2665
|
+
`,
|
|
2666
|
+
body: `${renderAdminToolbar(userName, activePath)}
|
|
2667
|
+
<div class="vos-page">
|
|
2668
|
+
<div class="page-header">
|
|
2669
|
+
<h1 class="page-title">Chat</h1>
|
|
2670
|
+
</div>
|
|
2671
|
+
${agentSelector}
|
|
2672
|
+
${content}
|
|
2673
|
+
</div>`,
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
function formatTokenCount(n: number): string {
|
|
2678
|
+
if (n >= 1000) {
|
|
2679
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
2680
|
+
}
|
|
2681
|
+
return String(n);
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
/**
|
|
2685
|
+
* Renders a thread detail page at /admin/chat/:agentId/threads/:threadId.
|
|
2686
|
+
*
|
|
2687
|
+
* @param agentId - agent ID
|
|
2688
|
+
* @param thread - thread object (null = chatClient absent -> degraded mode)
|
|
2689
|
+
* @param messages - messages for the thread (null = degraded mode)
|
|
2690
|
+
* @param threads - list of all threads for the sidebar pane (null = not available)
|
|
2691
|
+
* @param userName - logged-in user's email for the toolbar
|
|
2692
|
+
*/
|
|
2693
|
+
export function renderChatThreadPage(
|
|
2694
|
+
agentId: string,
|
|
2695
|
+
thread: ChatThread | null,
|
|
2696
|
+
messages: ChatMessage[] | null,
|
|
2697
|
+
threadsOrUserName: ChatThread[] | null | string,
|
|
2698
|
+
userNameArg?: string,
|
|
2699
|
+
stats?: ThreadStats | null,
|
|
2700
|
+
opts?: {
|
|
2701
|
+
stallWarnAfterMs?: number;
|
|
2702
|
+
// Push-notification toggle (CFB-4.2). When push is disabled server-side
|
|
2703
|
+
// (VAPID not fully configured), pushEnabled is false / vapidPublicKey is
|
|
2704
|
+
// absent, renderPushToggle returns "", and the page degrades to exactly the
|
|
2705
|
+
// CFB-3.2 page (acceptance criterion 6).
|
|
2706
|
+
pushEnabled?: boolean;
|
|
2707
|
+
vapidPublicKey?: string;
|
|
2708
|
+
},
|
|
2709
|
+
): string {
|
|
2710
|
+
// The stall-warning threshold is normally the 120s production default, but
|
|
2711
|
+
// the e2e suite overrides it to a tiny value (via a query param the route
|
|
2712
|
+
// handler forwards here) so the stall-state assertion doesn't need a real
|
|
2713
|
+
// two-minute wait. Production behaviour is unaffected.
|
|
2714
|
+
const stallWarnAfterMs = opts?.stallWarnAfterMs ?? STALL_WARN_AFTER_MS;
|
|
2715
|
+
// Support both 4-arg (threads omitted) and 5-arg call signatures
|
|
2716
|
+
const threads: ChatThread[] | null =
|
|
2717
|
+
typeof threadsOrUserName === "string" ? null : threadsOrUserName;
|
|
2718
|
+
const userName: string =
|
|
2719
|
+
typeof threadsOrUserName === "string"
|
|
2720
|
+
? threadsOrUserName
|
|
2721
|
+
: (userNameArg ?? "");
|
|
2722
|
+
const activePath = "/admin/chat";
|
|
2723
|
+
|
|
2724
|
+
if (thread === null || messages === null) {
|
|
2725
|
+
return renderAdminPage({
|
|
2726
|
+
title: "Thread - Shipwright Admin",
|
|
2727
|
+
body: `${renderAdminToolbar(userName, activePath)}
|
|
2728
|
+
<div class="vos-page">
|
|
2729
|
+
<div class="page-header">
|
|
2730
|
+
<h1 class="page-title">Thread</h1>
|
|
2731
|
+
</div>
|
|
2732
|
+
<div class="alert alert-error">
|
|
2733
|
+
Chat service not configured. Set <code>SHIPWRIGHT_CHAT_SERVICE_URL</code> and
|
|
2734
|
+
<code>SHIPWRIGHT_CHAT_SERVICE_ADMIN_TOKEN</code> to enable this feature.
|
|
2735
|
+
</div>
|
|
2736
|
+
</div>`,
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
const threadId = thread.id;
|
|
2741
|
+
// rawTitle feeds renderAdminPage(), which HTML-escapes it itself; `title`
|
|
2742
|
+
// is the pre-escaped display string used inline in body markup below —
|
|
2743
|
+
// keep both to avoid double-escaping the <title> tag.
|
|
2744
|
+
const rawTitle = thread.title ?? "Untitled Thread";
|
|
2745
|
+
const title = thread.title ? escapeHtml(thread.title) : "Untitled Thread";
|
|
2746
|
+
|
|
2747
|
+
// Track the most recent user message body while iterating so an error
|
|
2748
|
+
// reply's Retry button (CFB-2.4) can resend the exact text that triggered
|
|
2749
|
+
// it. renderChatMessageBubble is the module-level renderer (hoisted in
|
|
2750
|
+
// CFB-2.3) so the same retryBody-aware logic is shared with the
|
|
2751
|
+
// messages.json poll route.
|
|
2752
|
+
let lastUserBody: string | null = null;
|
|
2753
|
+
const messageBubbles = messages
|
|
2754
|
+
.map((m) => {
|
|
2755
|
+
const html = renderChatMessageBubble(m, lastUserBody, agentId);
|
|
2756
|
+
if (m.role === "user") lastUserBody = m.body;
|
|
2757
|
+
return html;
|
|
2758
|
+
})
|
|
2759
|
+
.join("\n");
|
|
2760
|
+
|
|
2761
|
+
// Server-render the live status bubble when the last user message has no
|
|
2762
|
+
// reply yet — so a full-page reload mid-run shows the elapsed/milestone
|
|
2763
|
+
// immediately instead of a 3s-later blank until the first poll ticks in.
|
|
2764
|
+
const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null;
|
|
2765
|
+
const pendingMsg =
|
|
2766
|
+
lastMsg && lastMsg.role === "user" && !lastMsg.repliedAt ? lastMsg : null;
|
|
2767
|
+
const liveStatusBubble = pendingMsg ? renderLiveStatusBubble(pendingMsg) : "";
|
|
2768
|
+
|
|
2769
|
+
const emptyState =
|
|
2770
|
+
messages.length === 0
|
|
2771
|
+
? `<div class="empty-state" style="text-align:center;padding:48px 24px;color:#9ca3af">No messages in this thread yet. Send a message to get started.</div>`
|
|
2772
|
+
: "";
|
|
2773
|
+
|
|
2774
|
+
const safeAgentId = escapeHtml(agentId);
|
|
2775
|
+
const safeThreadId = escapeHtml(threadId);
|
|
2776
|
+
|
|
2777
|
+
const renameForm = `
|
|
2778
|
+
<form method="POST" action="/admin/chat/${escapeHtml(agentId)}/threads/${escapeHtml(threadId)}/rename" style="margin-top:12px">
|
|
2779
|
+
<div class="form-row" style="align-items:center;gap:8px">
|
|
2780
|
+
<input type="text" name="title" class="form-input" placeholder="New title…" style="max-width:240px" required>
|
|
2781
|
+
<button type="submit" class="btn btn-secondary" style="white-space:nowrap">Rename</button>
|
|
2782
|
+
</div>
|
|
2783
|
+
</form>`;
|
|
2784
|
+
|
|
2785
|
+
const deleteForm = `
|
|
2786
|
+
<form method="POST" action="/admin/chat/${escapeHtml(agentId)}/threads/${escapeHtml(threadId)}/delete" style="margin-top:8px" onsubmit="return confirm('Delete this thread?')">
|
|
2787
|
+
<button type="submit" class="btn btn-danger">Delete Thread</button>
|
|
2788
|
+
</form>`;
|
|
2789
|
+
|
|
2790
|
+
// Collapsed by default so rename/delete controls don't eat vertical space
|
|
2791
|
+
// above the fold before any conversation is visible on a short mobile
|
|
2792
|
+
// viewport — a plain <details>/<summary> disclosure, no JS required.
|
|
2793
|
+
const threadActionsDetails = `
|
|
2794
|
+
<details class="chat-thread-actions">
|
|
2795
|
+
<summary class="chat-thread-actions-summary">Thread actions</summary>
|
|
2796
|
+
${renameForm}
|
|
2797
|
+
${deleteForm}
|
|
2798
|
+
</details>`;
|
|
2799
|
+
|
|
2800
|
+
// Inline JS for the send/poll/live-progress flow (CFB-2.3).
|
|
2801
|
+
//
|
|
2802
|
+
// Two layers, per the brief:
|
|
2803
|
+
// Layer 1 — a 1s client-side ticker computing now - createdAt with ZERO
|
|
2804
|
+
// network dependency. A dead agent, dead chat service or offline laptop
|
|
2805
|
+
// still ticks; that is the actual "never go silent" guarantee.
|
|
2806
|
+
// Layer 2 — milestone text refreshed off the messages.json poll, adaptive
|
|
2807
|
+
// 2s while pending / 10s idle. The milestone is content, not the
|
|
2808
|
+
// guarantee.
|
|
2809
|
+
//
|
|
2810
|
+
// The live status bubble (${LIVE_STATUS_BUBBLE_ID}) is a single element the
|
|
2811
|
+
// server may have rendered (mid-run reload) or the client creates on send.
|
|
2812
|
+
// Its data-created-at drives the ticker; data-progress-seq drives skew-free
|
|
2813
|
+
// stall detection (compare poll counts since progressSeq last changed, never
|
|
2814
|
+
// wall clocks).
|
|
2815
|
+
const inlineScript = `
|
|
2816
|
+
<script>
|
|
2817
|
+
(function() {
|
|
2818
|
+
var form = document.getElementById('send-form');
|
|
2819
|
+
var input = document.getElementById('message-input');
|
|
2820
|
+
var sendBtn = document.getElementById('send-btn');
|
|
2821
|
+
var attachBtn = document.getElementById('attach-btn');
|
|
2822
|
+
var fileInput = document.getElementById('file-input');
|
|
2823
|
+
var fileName = document.getElementById('file-name');
|
|
2824
|
+
var micBtn = document.getElementById('mic-btn');
|
|
2825
|
+
var container = document.getElementById('messages-container');
|
|
2826
|
+
var agentId = ${JSON.stringify(agentId)};
|
|
2827
|
+
var threadId = ${JSON.stringify(thread.id)};
|
|
2828
|
+
var messagesJsonUrl = '/admin/chat/' + encodeURIComponent(agentId) + '/threads/' + encodeURIComponent(threadId) + '/messages.json';
|
|
2829
|
+
var uploadUrl = '/admin/chat/' + encodeURIComponent(agentId) + '/threads/' + encodeURIComponent(threadId) + '/messages/upload';
|
|
2830
|
+
|
|
2831
|
+
// errorKind → label, generated from the server's single source of truth so
|
|
2832
|
+
// the two copies cannot drift.
|
|
2833
|
+
var ERROR_KIND_LABELS = ${JSON.stringify(ERROR_KIND_LABELS)};
|
|
2834
|
+
var DEFAULT_ERROR_LABEL = ${JSON.stringify(DEFAULT_ERROR_LABEL)};
|
|
2835
|
+
function errorKindLabel(kind) {
|
|
2836
|
+
return ERROR_KIND_LABELS[kind] || DEFAULT_ERROR_LABEL;
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// Milestone labels, likewise serialized from lib/progress-phases.ts.
|
|
2840
|
+
var PROGRESS_LABELS = ${JSON.stringify(PROGRESS_LABELS)};
|
|
2841
|
+
|
|
2842
|
+
// Stall-warning threshold — 120s in prod; e2e overrides to a tiny value.
|
|
2843
|
+
var STALL_WARN_AFTER_MS = ${stallWarnAfterMs};
|
|
2844
|
+
// Hard ceiling: 65min, mirrors lib/claim-ttl.ts DEFAULT_CLAIM_TTL_MS
|
|
2845
|
+
// (1hr DEFAULT_CLAUDE_TIMEOUT_MS + 5min CLAIM_TTL_BUFFER_MS). Only hard stop.
|
|
2846
|
+
var ABSOLUTE_MAX_MS = ${ABSOLUTE_MAX_MS};
|
|
2847
|
+
|
|
2848
|
+
var LIVE_STATUS_BUBBLE_ID = ${JSON.stringify(LIVE_STATUS_BUBBLE_ID)};
|
|
2849
|
+
var LIVE_STATUS_ELAPSED_ID = ${JSON.stringify(LIVE_STATUS_ELAPSED_ID)};
|
|
2850
|
+
var LIVE_STATUS_MILESTONE_ID = ${JSON.stringify(LIVE_STATUS_MILESTONE_ID)};
|
|
2851
|
+
var STALL_INDICATOR_CLASS = ${JSON.stringify(STALL_INDICATOR_CLASS)};
|
|
2852
|
+
var ASSISTANT_ROLE_CLASS = '${CHAT_BUBBLE_CLASS} ${chatBubbleRoleClass("assistant")}';
|
|
2853
|
+
|
|
2854
|
+
// Poll cadence.
|
|
2855
|
+
var POLL_PENDING_MS = 2000; // adaptive fast poll while a reply is pending
|
|
2856
|
+
var POLL_IDLE_MS = 10000; // slow idle poll for agent-initiated messages
|
|
2857
|
+
|
|
2858
|
+
// Rendered-message dedupe: every server bubble carries data-message-id;
|
|
2859
|
+
// seed the set from what's already in the DOM so polls never double-render.
|
|
2860
|
+
var renderedIds = {};
|
|
2861
|
+
Array.prototype.forEach.call(
|
|
2862
|
+
container.querySelectorAll('[data-message-id]'),
|
|
2863
|
+
function(el) { renderedIds[el.getAttribute('data-message-id')] = true; }
|
|
2864
|
+
);
|
|
2865
|
+
|
|
2866
|
+
var pollTimer = null;
|
|
2867
|
+
var tickerTimer = null;
|
|
2868
|
+
// Skew-free stall tracking: remember the progressSeq we last saw and the
|
|
2869
|
+
// wall-clock ms at which it last CHANGED (client clock only — never compared
|
|
2870
|
+
// against server timestamps).
|
|
2871
|
+
var lastProgressSeq = null;
|
|
2872
|
+
var lastProgressChangeMs = null;
|
|
2873
|
+
|
|
2874
|
+
function escHtml(str) {
|
|
2875
|
+
return String(str)
|
|
2876
|
+
.replace(/&/g, '&')
|
|
2877
|
+
.replace(/</g, '<')
|
|
2878
|
+
.replace(/>/g, '>')
|
|
2879
|
+
.replace(/"/g, '"');
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// Optimistic user bubble (pre-round-trip; body is plain text, so no markdown
|
|
2883
|
+
// — matches the server's white-space:pre-wrap user rendering). Assistant and
|
|
2884
|
+
// reloaded bubbles are NEVER built here: they come from server bubbleHtml.
|
|
2885
|
+
// \`file\` (VM-3.1) may be a picked file OR a mic-recorded Blob/File — for
|
|
2886
|
+
// an audio file this renders an inline <audio> player immediately, using a
|
|
2887
|
+
// local object URL (no round-trip needed to preview what was just
|
|
2888
|
+
// recorded); everything else still gets the plain filename badge.
|
|
2889
|
+
function addUserBubble(body, file) {
|
|
2890
|
+
var color = '#4f46e5';
|
|
2891
|
+
var attachmentHtml = '';
|
|
2892
|
+
if (file) {
|
|
2893
|
+
if (file.type && file.type.indexOf('audio/') === 0) {
|
|
2894
|
+
attachmentHtml = '<audio controls style="display:block;margin-top:8px;max-width:100%" src="' + URL.createObjectURL(file) + '"></audio>';
|
|
2895
|
+
} else {
|
|
2896
|
+
attachmentHtml = '<div style="display:inline-block;margin-top:8px;padding:3px 8px;background:#e5e7eb;color:#374151;border-radius:6px;font-size:12px">📎 ' + escHtml(file.name) + '</div>';
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
var bubble = document.createElement('div');
|
|
2900
|
+
bubble.className = '${CHAT_BUBBLE_CLASS} ${CHAT_BUBBLE_CLASS}--user';
|
|
2901
|
+
bubble.innerHTML = '<div class="${CHAT_BUBBLE_INNER_CLASS}">'
|
|
2902
|
+
+ '<div style="font-size:11px;font-weight:600;color:' + color + ';margin-bottom:4px;text-transform:uppercase;letter-spacing:0.05em">user</div>'
|
|
2903
|
+
+ '<div style="font-size:14px;white-space:pre-wrap;color:' + color + '">' + escHtml(body) + '</div>'
|
|
2904
|
+
+ attachmentHtml
|
|
2905
|
+
+ '</div>';
|
|
2906
|
+
insertBeforeLiveStatus(bubble);
|
|
2907
|
+
container.scrollTop = container.scrollHeight;
|
|
2908
|
+
return bubble;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
// Insert a bubble before the live-status bubble (so status stays at the
|
|
2912
|
+
// bottom) or append if there's no live-status bubble.
|
|
2913
|
+
function insertBeforeLiveStatus(node) {
|
|
2914
|
+
var live = document.getElementById(LIVE_STATUS_BUBBLE_ID);
|
|
2915
|
+
if (live) container.insertBefore(node, live);
|
|
2916
|
+
else container.appendChild(node);
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
// Insert a server-rendered bubbleHtml string, deduped by id.
|
|
2920
|
+
function renderServerBubble(m) {
|
|
2921
|
+
if (!m || !m.id || renderedIds[m.id]) return false;
|
|
2922
|
+
renderedIds[m.id] = true;
|
|
2923
|
+
if (!m.bubbleHtml) return false;
|
|
2924
|
+
var tpl = document.createElement('template');
|
|
2925
|
+
tpl.innerHTML = m.bubbleHtml.trim();
|
|
2926
|
+
var node = tpl.content.firstChild;
|
|
2927
|
+
if (node) insertBeforeLiveStatus(node);
|
|
2928
|
+
return true;
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// True when the user is within ~80px of the bottom, i.e. hasn't scrolled up
|
|
2932
|
+
// to read history. Polls only auto-scroll in that case.
|
|
2933
|
+
function isNearBottom() {
|
|
2934
|
+
return container.scrollHeight - container.scrollTop - container.clientHeight < 80;
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
// Ensure the live status bubble exists (creating it on the client when the
|
|
2938
|
+
// user just sent a message and the server hasn't rendered one). Scrolls to
|
|
2939
|
+
// the bottom unless noScroll is true (poll with the user scrolled up).
|
|
2940
|
+
function ensureLiveStatusBubble(createdAtMs, noScroll) {
|
|
2941
|
+
var live = document.getElementById(LIVE_STATUS_BUBBLE_ID);
|
|
2942
|
+
if (live) return live;
|
|
2943
|
+
live = document.createElement('div');
|
|
2944
|
+
live.id = LIVE_STATUS_BUBBLE_ID;
|
|
2945
|
+
live.className = ASSISTANT_ROLE_CLASS;
|
|
2946
|
+
live.setAttribute('data-created-at', String(createdAtMs));
|
|
2947
|
+
live.setAttribute('data-progress-seq', '0');
|
|
2948
|
+
live.innerHTML = '<div class="${CHAT_BUBBLE_INNER_CLASS}">'
|
|
2949
|
+
+ '<div style="font-size:14px;color:#166534;font-style:italic">'
|
|
2950
|
+
+ '<span id="' + LIVE_STATUS_MILESTONE_ID + '"></span> '
|
|
2951
|
+
+ '<span id="' + LIVE_STATUS_ELAPSED_ID + '">working… (0s)</span>'
|
|
2952
|
+
+ '</div></div>';
|
|
2953
|
+
container.appendChild(live);
|
|
2954
|
+
if (!noScroll) container.scrollTop = container.scrollHeight;
|
|
2955
|
+
return live;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
function removeLiveStatusBubble() {
|
|
2959
|
+
var live = document.getElementById(LIVE_STATUS_BUBBLE_ID);
|
|
2960
|
+
if (live && live.parentNode) live.parentNode.removeChild(live);
|
|
2961
|
+
lastProgressSeq = null;
|
|
2962
|
+
lastProgressChangeMs = null;
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
// Layer 1: the ZERO-network ticker. Runs every 1s off the live bubble's
|
|
2966
|
+
// data-created-at. Also applies the stall class when progressSeq has been
|
|
2967
|
+
// frozen past STALL_WARN_AFTER_MS, and hard-stops at ABSOLUTE_MAX_MS.
|
|
2968
|
+
function tick() {
|
|
2969
|
+
var live = document.getElementById(LIVE_STATUS_BUBBLE_ID);
|
|
2970
|
+
if (!live) return;
|
|
2971
|
+
var createdAtMs = Number(live.getAttribute('data-created-at')) || Date.now();
|
|
2972
|
+
var elapsedMs = Date.now() - createdAtMs;
|
|
2973
|
+
var elapsedSec = Math.max(0, Math.round(elapsedMs / 1000));
|
|
2974
|
+
var elapsedEl = document.getElementById(LIVE_STATUS_ELAPSED_ID);
|
|
2975
|
+
if (elapsedEl) elapsedEl.textContent = 'working… (' + elapsedSec + 's)';
|
|
2976
|
+
|
|
2977
|
+
// Stall detection is time-since-progressSeq-last-changed, on the client
|
|
2978
|
+
// clock only. Seed the change time on first sight.
|
|
2979
|
+
if (lastProgressChangeMs === null) lastProgressChangeMs = Date.now();
|
|
2980
|
+
var stalledFor = Date.now() - lastProgressChangeMs;
|
|
2981
|
+
if (stalledFor >= STALL_WARN_AFTER_MS) {
|
|
2982
|
+
live.classList.add(STALL_INDICATOR_CLASS);
|
|
2983
|
+
} else {
|
|
2984
|
+
live.classList.remove(STALL_INDICATOR_CLASS);
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
if (elapsedMs >= ABSOLUTE_MAX_MS) {
|
|
2988
|
+
// Only hard stop: replace the live bubble with a terminal error and
|
|
2989
|
+
// stop everything.
|
|
2990
|
+
stopAll();
|
|
2991
|
+
removeLiveStatusBubble();
|
|
2992
|
+
var errBubble = document.createElement('div');
|
|
2993
|
+
errBubble.className = ASSISTANT_ROLE_CLASS;
|
|
2994
|
+
errBubble.innerHTML = '<div class="${CHAT_BUBBLE_INNER_CLASS}">'
|
|
2995
|
+
+ '<div style="margin-top:0;padding:4px 8px;background:#fee2e2;color:#b91c1c;border-radius:4px;font-size:12px;font-weight:600">Request timed out. Please try again.</div>'
|
|
2996
|
+
+ '</div>';
|
|
2997
|
+
container.appendChild(errBubble);
|
|
2998
|
+
enableSend();
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
function startTicker() {
|
|
3003
|
+
if (tickerTimer) return;
|
|
3004
|
+
tick();
|
|
3005
|
+
tickerTimer = setInterval(tick, 1000);
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
function updateMilestone(phase) {
|
|
3009
|
+
var el = document.getElementById(LIVE_STATUS_MILESTONE_ID);
|
|
3010
|
+
if (!el) return;
|
|
3011
|
+
el.textContent = phase && PROGRESS_LABELS[phase] ? PROGRESS_LABELS[phase] : '';
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
function stopPolling() {
|
|
3015
|
+
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
3016
|
+
}
|
|
3017
|
+
function stopTicker() {
|
|
3018
|
+
if (tickerTimer) { clearInterval(tickerTimer); tickerTimer = null; }
|
|
3019
|
+
}
|
|
3020
|
+
function stopAll() { stopPolling(); stopTicker(); }
|
|
3021
|
+
|
|
3022
|
+
function enableSend() {
|
|
3023
|
+
sendBtn.disabled = false;
|
|
3024
|
+
sendBtn.textContent = 'Send';
|
|
3025
|
+
if (micBtn) micBtn.disabled = false;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
// Adaptive poll: fast while a reply is pending, slow while idle (so
|
|
3029
|
+
// agent-initiated messages still appear without a reload).
|
|
3030
|
+
function schedulePoll(delay) {
|
|
3031
|
+
stopPolling();
|
|
3032
|
+
pollTimer = setTimeout(poll, delay);
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
function poll() {
|
|
3036
|
+
fetch(messagesJsonUrl)
|
|
3037
|
+
.then(function(r) { return r.json(); })
|
|
3038
|
+
.then(function(data) {
|
|
3039
|
+
var msgs = data.messages || [];
|
|
3040
|
+
|
|
3041
|
+
// Capture before rendering new bubbles grows scrollHeight.
|
|
3042
|
+
var stickToBottom = isNearBottom();
|
|
3043
|
+
|
|
3044
|
+
// Render every new (un-rendered) message from the server, in order —
|
|
3045
|
+
// fixes "only the last reply appears" and "agent-initiated messages
|
|
3046
|
+
// need a reload". Assistant/user/system all go through server
|
|
3047
|
+
// bubbleHtml, which already includes any Retry button (CFB-2.4) since
|
|
3048
|
+
// the server computes retryBody per-message; wire up any new buttons
|
|
3049
|
+
// after insertion (see wireRetryButtons below).
|
|
3050
|
+
for (var i = 0; i < msgs.length; i++) {
|
|
3051
|
+
renderServerBubble(msgs[i]);
|
|
3052
|
+
}
|
|
3053
|
+
wireRetryButtons();
|
|
3054
|
+
|
|
3055
|
+
// Determine whether a reply is still pending (last message is an
|
|
3056
|
+
// unreplied user message).
|
|
3057
|
+
var last = msgs.length > 0 ? msgs[msgs.length - 1] : null;
|
|
3058
|
+
var pending = last && last.role === 'user' && !last.repliedAt ? last : null;
|
|
3059
|
+
|
|
3060
|
+
if (pending) {
|
|
3061
|
+
// Keep the live status bubble present and in sync.
|
|
3062
|
+
var createdAtMs = pending.createdAt ? new Date(pending.createdAt).getTime() : Date.now();
|
|
3063
|
+
var live = ensureLiveStatusBubble(createdAtMs, !stickToBottom);
|
|
3064
|
+
live.setAttribute('data-created-at', String(createdAtMs));
|
|
3065
|
+
startTicker();
|
|
3066
|
+
updateMilestone(pending.progressPhase);
|
|
3067
|
+
|
|
3068
|
+
// Skew-free stall tracking: reset the change clock only when
|
|
3069
|
+
// progressSeq actually advances.
|
|
3070
|
+
var seq = typeof pending.progressSeq === 'number' ? pending.progressSeq : 0;
|
|
3071
|
+
if (lastProgressSeq === null || seq !== lastProgressSeq) {
|
|
3072
|
+
lastProgressSeq = seq;
|
|
3073
|
+
lastProgressChangeMs = Date.now();
|
|
3074
|
+
}
|
|
3075
|
+
live.setAttribute('data-progress-seq', String(seq));
|
|
3076
|
+
|
|
3077
|
+
if (stickToBottom) container.scrollTop = container.scrollHeight;
|
|
3078
|
+
schedulePoll(POLL_PENDING_MS);
|
|
3079
|
+
} else {
|
|
3080
|
+
// No reply pending: reply landed (or nothing in flight). Tear down
|
|
3081
|
+
// the live status bubble and re-enable send, then keep a slow idle
|
|
3082
|
+
// poll running so agent-initiated messages still show up live.
|
|
3083
|
+
removeLiveStatusBubble();
|
|
3084
|
+
stopTicker();
|
|
3085
|
+
enableSend();
|
|
3086
|
+
if (stickToBottom) container.scrollTop = container.scrollHeight;
|
|
3087
|
+
schedulePoll(POLL_IDLE_MS);
|
|
3088
|
+
}
|
|
3089
|
+
})
|
|
3090
|
+
.catch(function() {
|
|
3091
|
+
// Network error — Layer 1 ticker keeps going untouched; just retry the
|
|
3092
|
+
// poll on the pending cadence.
|
|
3093
|
+
schedulePoll(POLL_PENDING_MS);
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
// Attach-file button opens the hidden file input; show the chosen name.
|
|
3098
|
+
if (attachBtn && fileInput) {
|
|
3099
|
+
attachBtn.addEventListener('click', function() {
|
|
3100
|
+
fileInput.click();
|
|
3101
|
+
});
|
|
3102
|
+
fileInput.addEventListener('change', function() {
|
|
3103
|
+
var f = fileInput.files && fileInput.files[0];
|
|
3104
|
+
fileName.textContent = f ? ('📎 ' + f.name) : '';
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
// Mic-record button (VM-3.1): records via MediaRecorder and submits the
|
|
3109
|
+
// resulting Blob through the SAME sendText()/upload flow as a picked file
|
|
3110
|
+
// — no separate endpoint. Click once to start, click again (or the button
|
|
3111
|
+
// relabels to a stop icon) to stop and send. Candidate MIME types are
|
|
3112
|
+
// ordered by preference; attachment-validation.ts's allowlist accepts all
|
|
3113
|
+
// three so whichever the browser actually supports still uploads cleanly.
|
|
3114
|
+
var MIC_MIME_CANDIDATES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg', 'audio/mp4'];
|
|
3115
|
+
var mediaRecorder = null;
|
|
3116
|
+
var micStream = null;
|
|
3117
|
+
var recordedChunks = [];
|
|
3118
|
+
|
|
3119
|
+
function micSupported() {
|
|
3120
|
+
return !!(window.MediaRecorder && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
function stopMicStream() {
|
|
3124
|
+
if (micStream) {
|
|
3125
|
+
micStream.getTracks().forEach(function(t) { t.stop(); });
|
|
3126
|
+
micStream = null;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
function setMicRecordingUI(recording) {
|
|
3131
|
+
if (!micBtn) return;
|
|
3132
|
+
micBtn.textContent = recording ? '⏹' : '🎤';
|
|
3133
|
+
micBtn.classList.toggle('chat-mic-recording', recording);
|
|
3134
|
+
micBtn.setAttribute('aria-label', recording ? 'Stop recording' : 'Record a voice message');
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
if (micBtn) {
|
|
3138
|
+
if (!micSupported()) {
|
|
3139
|
+
// Graceful degradation: no MediaRecorder/getUserMedia in this browser
|
|
3140
|
+
// (or non-HTTPS context) — hide the button rather than offer a dead
|
|
3141
|
+
// control.
|
|
3142
|
+
micBtn.style.display = 'none';
|
|
3143
|
+
} else {
|
|
3144
|
+
micBtn.addEventListener('click', function() {
|
|
3145
|
+
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
3146
|
+
mediaRecorder.stop();
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
|
|
3150
|
+
micStream = stream;
|
|
3151
|
+
recordedChunks = [];
|
|
3152
|
+
var mimeType = '';
|
|
3153
|
+
for (var i = 0; i < MIC_MIME_CANDIDATES.length; i++) {
|
|
3154
|
+
if (window.MediaRecorder.isTypeSupported && window.MediaRecorder.isTypeSupported(MIC_MIME_CANDIDATES[i])) {
|
|
3155
|
+
mimeType = MIC_MIME_CANDIDATES[i];
|
|
3156
|
+
break;
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
try {
|
|
3160
|
+
mediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType: mimeType }) : new MediaRecorder(stream);
|
|
3161
|
+
} catch (e) {
|
|
3162
|
+
stopMicStream();
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
3165
|
+
mediaRecorder.addEventListener('dataavailable', function(e) {
|
|
3166
|
+
if (e.data && e.data.size > 0) recordedChunks.push(e.data);
|
|
3167
|
+
});
|
|
3168
|
+
mediaRecorder.addEventListener('stop', function() {
|
|
3169
|
+
stopMicStream();
|
|
3170
|
+
setMicRecordingUI(false);
|
|
3171
|
+
var rawType = mediaRecorder.mimeType || mimeType || 'audio/webm';
|
|
3172
|
+
// Chromium reports audio-only recordings as video/webm; normalize so
|
|
3173
|
+
// the File is never non-audio/*.
|
|
3174
|
+
var blobType = rawType.indexOf('audio/') === 0 ? rawType : rawType.replace(/^[^;/]*/, 'audio');
|
|
3175
|
+
var blob = new Blob(recordedChunks, { type: blobType });
|
|
3176
|
+
recordedChunks = [];
|
|
3177
|
+
if (blob.size === 0) return;
|
|
3178
|
+
var ext = blobType.indexOf('ogg') !== -1 ? 'ogg' : blobType.indexOf('mp4') !== -1 ? 'm4a' : 'webm';
|
|
3179
|
+
var file = new File([blob], 'recording-' + Date.now() + '.' + ext, { type: blobType });
|
|
3180
|
+
sendText('', file);
|
|
3181
|
+
});
|
|
3182
|
+
mediaRecorder.start();
|
|
3183
|
+
setMicRecordingUI(true);
|
|
3184
|
+
}).catch(function() {
|
|
3185
|
+
// Permission denied, no mic, or blocked context — no-op; the
|
|
3186
|
+
// button simply stays in its idle state.
|
|
3187
|
+
});
|
|
3188
|
+
});
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
function clearFile() {
|
|
3193
|
+
if (fileInput) fileInput.value = '';
|
|
3194
|
+
if (fileName) fileName.textContent = '';
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
function sendText(text, file) {
|
|
3198
|
+
if (!text && !file) return;
|
|
3199
|
+
|
|
3200
|
+
// Disable send button (and mic — no recording mid-send)
|
|
3201
|
+
sendBtn.disabled = true;
|
|
3202
|
+
sendBtn.textContent = 'Sending…';
|
|
3203
|
+
if (micBtn) micBtn.disabled = true;
|
|
3204
|
+
|
|
3205
|
+
// Build multipart body before clearing the inputs
|
|
3206
|
+
var fd = new FormData();
|
|
3207
|
+
fd.append('body', text);
|
|
3208
|
+
if (file) fd.append('file', file);
|
|
3209
|
+
|
|
3210
|
+
// Add user bubble optimistically (with attachment preview if present).
|
|
3211
|
+
addUserBubble(text, file);
|
|
3212
|
+
|
|
3213
|
+
// Show the live status bubble + start the ticker immediately — Layer 1
|
|
3214
|
+
// begins ticking with zero network dependency.
|
|
3215
|
+
ensureLiveStatusBubble(Date.now());
|
|
3216
|
+
lastProgressSeq = null;
|
|
3217
|
+
lastProgressChangeMs = Date.now();
|
|
3218
|
+
startTicker();
|
|
3219
|
+
|
|
3220
|
+
// POST multipart to the upload endpoint
|
|
3221
|
+
fetch(uploadUrl, {
|
|
3222
|
+
method: 'POST',
|
|
3223
|
+
body: fd
|
|
3224
|
+
}).then(function(r) {
|
|
3225
|
+
if (!r.ok) {
|
|
3226
|
+
return r.json().then(function(data) {
|
|
3227
|
+
stopAll();
|
|
3228
|
+
removeLiveStatusBubble();
|
|
3229
|
+
var errBubble = document.createElement('div');
|
|
3230
|
+
errBubble.className = ASSISTANT_ROLE_CLASS;
|
|
3231
|
+
errBubble.innerHTML = '<div class="${CHAT_BUBBLE_INNER_CLASS}">'
|
|
3232
|
+
+ '<div style="margin-top:0;padding:4px 8px;background:#fee2e2;color:#b91c1c;border-radius:4px;font-size:12px;font-weight:600">' + escHtml((data && data.error) || 'Upload failed.') + '</div>'
|
|
3233
|
+
+ '</div>';
|
|
3234
|
+
container.appendChild(errBubble);
|
|
3235
|
+
enableSend();
|
|
3236
|
+
});
|
|
3237
|
+
} else {
|
|
3238
|
+
// Success: parse response and seed renderedIds to prevent duplicate rendering
|
|
3239
|
+
return r.json().then(function(data) {
|
|
3240
|
+
if (data && data.message && data.message.id) {
|
|
3241
|
+
renderedIds[data.message.id] = true;
|
|
3242
|
+
}
|
|
3243
|
+
});
|
|
3244
|
+
}
|
|
3245
|
+
}).catch(function() {
|
|
3246
|
+
// POST failed — Layer 1 ticker keeps going; poll loop still starts.
|
|
3247
|
+
});
|
|
3248
|
+
|
|
3249
|
+
// Start the fast poll loop.
|
|
3250
|
+
schedulePoll(POLL_PENDING_MS);
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
form.addEventListener('submit', function(e) {
|
|
3254
|
+
e.preventDefault();
|
|
3255
|
+
var text = input.value.trim();
|
|
3256
|
+
var file = fileInput && fileInput.files && fileInput.files[0];
|
|
3257
|
+
if (!text && !file) return;
|
|
3258
|
+
input.value = '';
|
|
3259
|
+
clearFile();
|
|
3260
|
+
sendText(text, file);
|
|
3261
|
+
});
|
|
3262
|
+
|
|
3263
|
+
// Retry buttons (CFB-2.4) resend the exact user message body that
|
|
3264
|
+
// triggered the errored/cancelled/incomplete/stalled reply — rendered
|
|
3265
|
+
// server-side as a data attribute on each button (see
|
|
3266
|
+
// renderChatMessageBubble). Buttons can arrive either on initial page load
|
|
3267
|
+
// or later via renderServerBubble during a poll (which dedupes by
|
|
3268
|
+
// data-message-id, so a given button is only ever inserted into the DOM
|
|
3269
|
+
// once); re-running this after every render pass is therefore safe and
|
|
3270
|
+
// never double-wires a button.
|
|
3271
|
+
function wireRetryButtons() {
|
|
3272
|
+
document.querySelectorAll('.chat-retry-btn').forEach(function(btn) {
|
|
3273
|
+
if (btn.dataset.wired) return;
|
|
3274
|
+
btn.dataset.wired = '1';
|
|
3275
|
+
btn.addEventListener('click', function() {
|
|
3276
|
+
if (btn.disabled) return;
|
|
3277
|
+
var body = btn.getAttribute('data-retry-body');
|
|
3278
|
+
if (!body) return;
|
|
3279
|
+
btn.disabled = true;
|
|
3280
|
+
sendText(body, null);
|
|
3281
|
+
});
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
wireRetryButtons();
|
|
3285
|
+
|
|
3286
|
+
// On load: if the server rendered a live status bubble (mid-run reload),
|
|
3287
|
+
// start the ticker immediately and begin polling on the pending cadence.
|
|
3288
|
+
// Otherwise begin a slow idle poll so agent-initiated messages appear live.
|
|
3289
|
+
var serverLive = document.getElementById(LIVE_STATUS_BUBBLE_ID);
|
|
3290
|
+
if (serverLive) {
|
|
3291
|
+
var seedSeq = Number(serverLive.getAttribute('data-progress-seq'));
|
|
3292
|
+
lastProgressSeq = isNaN(seedSeq) ? 0 : seedSeq;
|
|
3293
|
+
lastProgressChangeMs = Date.now();
|
|
3294
|
+
startTicker();
|
|
3295
|
+
schedulePoll(POLL_PENDING_MS);
|
|
3296
|
+
} else {
|
|
3297
|
+
schedulePoll(POLL_IDLE_MS);
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
// Scroll to bottom on load
|
|
3301
|
+
container.scrollTop = container.scrollHeight;
|
|
3302
|
+
})();
|
|
3303
|
+
</script>`;
|
|
3304
|
+
|
|
3305
|
+
// Standalone drawer + keyboard-inset script (CFB-3.2). Deliberately emitted
|
|
3306
|
+
// as its OWN <script> block — NOT inside the progress IIFE above — so the two
|
|
3307
|
+
// concerns don't fight the ongoing live-progress rewrite. Three tiny jobs:
|
|
3308
|
+
// 1. visualViewport → --kb-inset: on iOS the on-screen keyboard shrinks the
|
|
3309
|
+
// VISUAL viewport but leaves the LAYOUT viewport (and dvh) alone, sliding
|
|
3310
|
+
// the sticky composer under the keyboard. Setting --kb-inset to the
|
|
3311
|
+
// occluded height lets CSS pad the composer back into view. Guarded by
|
|
3312
|
+
// `if (!vv) return` — no speculative fallback for browsers without it.
|
|
3313
|
+
// 2. aria-expanded sync on the hamburger, mirroring the toolbar idiom.
|
|
3314
|
+
// 3. Escape closes the drawer (a bare checkbox can't listen for keys).
|
|
3315
|
+
const drawerScript = `
|
|
3316
|
+
<script>
|
|
3317
|
+
(function() {
|
|
3318
|
+
var vv = window.visualViewport;
|
|
3319
|
+
if (!vv) return;
|
|
3320
|
+
var root = document.documentElement;
|
|
3321
|
+
function updateKbInset() {
|
|
3322
|
+
// Occluded height = layout viewport height minus the visual viewport
|
|
3323
|
+
// height (plus its offset). Clamp to >= 0. On Android where dvh already
|
|
3324
|
+
// shrinks the layout this stays ~0, which is correct.
|
|
3325
|
+
var occluded = window.innerHeight - vv.height - vv.offsetTop;
|
|
3326
|
+
root.style.setProperty('--kb-inset', (occluded > 0 ? occluded : 0) + 'px');
|
|
3327
|
+
}
|
|
3328
|
+
vv.addEventListener('resize', updateKbInset);
|
|
3329
|
+
vv.addEventListener('scroll', updateKbInset);
|
|
3330
|
+
updateKbInset();
|
|
3331
|
+
})();
|
|
3332
|
+
(function() {
|
|
3333
|
+
var toggle = document.getElementById('chat-drawer-toggle');
|
|
3334
|
+
if (!toggle) return;
|
|
3335
|
+
var burger = document.querySelector('.chat-drawer-hamburger');
|
|
3336
|
+
function syncAria() {
|
|
3337
|
+
if (burger) burger.setAttribute('aria-expanded', toggle.checked ? 'true' : 'false');
|
|
3338
|
+
}
|
|
3339
|
+
toggle.addEventListener('change', syncAria);
|
|
3340
|
+
document.addEventListener('keydown', function(e) {
|
|
3341
|
+
if ((e.key === 'Escape' || e.key === 'Esc') && toggle.checked) {
|
|
3342
|
+
toggle.checked = false;
|
|
3343
|
+
syncAria();
|
|
3344
|
+
}
|
|
3345
|
+
});
|
|
3346
|
+
})();
|
|
3347
|
+
</script>`;
|
|
3348
|
+
|
|
3349
|
+
// Thread list sidebar pane
|
|
3350
|
+
// NOTE: no inline font-size here — an inline style would beat the
|
|
3351
|
+
// .form-input mobile @media override by specificity and reintroduce the
|
|
3352
|
+
// iOS Safari zoom-on-focus defect. Sizing is handled entirely via the
|
|
3353
|
+
// .chat-sidebar-new-thread-input class below.
|
|
3354
|
+
const newThreadForm = `
|
|
3355
|
+
<form method="POST" action="/admin/chat/${escapeHtml(agentId)}/threads" style="margin-bottom:12px">
|
|
3356
|
+
<div class="form-row" style="gap:6px">
|
|
3357
|
+
<input type="text" name="title" class="form-input chat-sidebar-new-thread-input" placeholder="New thread title…">
|
|
3358
|
+
<button type="submit" class="btn btn-primary" style="white-space:nowrap;padding:6px 10px">New Thread</button>
|
|
3359
|
+
</div>
|
|
3360
|
+
</form>`;
|
|
3361
|
+
|
|
3362
|
+
const threadLinks = threads
|
|
3363
|
+
? threads.length === 0
|
|
3364
|
+
? `<div class="empty-state" style="padding:12px">No threads.</div>`
|
|
3365
|
+
: threads
|
|
3366
|
+
.map((t) => {
|
|
3367
|
+
const tTitle = escapeHtml(t.title ?? "Untitled");
|
|
3368
|
+
const isActive = t.id === threadId;
|
|
3369
|
+
return `<a href="/admin/chat/${escapeHtml(agentId)}/threads/${escapeHtml(t.id)}" class="thread-pane-link${isActive ? " active" : ""}">${tTitle}</a>`;
|
|
3370
|
+
})
|
|
3371
|
+
.join("\n")
|
|
3372
|
+
: "";
|
|
3373
|
+
|
|
3374
|
+
const sidebar =
|
|
3375
|
+
threads !== null
|
|
3376
|
+
? `<div id="chat-thread-sidebar-panel" class="card chat-thread-sidebar" style="min-width:220px;max-width:280px;flex-shrink:0">
|
|
3377
|
+
<div class="card-title">Threads</div>
|
|
3378
|
+
${newThreadForm}
|
|
3379
|
+
<div class="thread-pane-list">
|
|
3380
|
+
${threadLinks}
|
|
3381
|
+
</div>
|
|
3382
|
+
</div>`
|
|
3383
|
+
: "";
|
|
3384
|
+
|
|
3385
|
+
// Drawer chrome (CFB-3.2). Only render the CSS-only toggle + scrim + hamburger
|
|
3386
|
+
// when there's actually a sidebar to reveal — in degraded/no-threads mode the
|
|
3387
|
+
// sidebar ternary yields "" and the drawer would be an empty shell. DOM order
|
|
3388
|
+
// is load-bearing: the checkbox must precede the sidebar and scrim so the
|
|
3389
|
+
// `~` sibling combinator can reach them from `:checked`.
|
|
3390
|
+
const drawerToggle = sidebar
|
|
3391
|
+
? `<input type="checkbox" id="chat-drawer-toggle" class="chat-drawer-toggle" aria-hidden="true">`
|
|
3392
|
+
: "";
|
|
3393
|
+
const drawerScrim = sidebar
|
|
3394
|
+
? `<label for="chat-drawer-toggle" class="chat-drawer-scrim" aria-label="Close thread list"></label>`
|
|
3395
|
+
: "";
|
|
3396
|
+
// aria-controls references the revealed panel's own id (not the checkbox) —
|
|
3397
|
+
// matches the aria-controls="vos-nav-content" pattern in lib/web/toolbar.ts.
|
|
3398
|
+
const drawerHamburger = sidebar
|
|
3399
|
+
? `<label for="chat-drawer-toggle" class="chat-drawer-hamburger" role="button" tabindex="0" aria-label="Toggle thread list" aria-expanded="false" aria-controls="chat-thread-sidebar-panel">☰</label>`
|
|
3400
|
+
: "";
|
|
3401
|
+
|
|
3402
|
+
return renderAdminPage({
|
|
3403
|
+
title: `${rawTitle} - Shipwright Admin`,
|
|
3404
|
+
// NOTE: chatThreadStyles must be concatenated before chatPageStyles here —
|
|
3405
|
+
// chatPageStyles's mobile @media (max-width:640px) override for
|
|
3406
|
+
// .chat-bubble-inner has the same specificity as chatThreadStyles's
|
|
3407
|
+
// unconditional base rule, so whichever is concatenated *last* wins the
|
|
3408
|
+
// cascade. Putting chatPageStyles last ensures the mobile override
|
|
3409
|
+
// actually takes effect at ≤640px instead of being silently shadowed.
|
|
3410
|
+
extraStyles: `${threadPaneStyles}${chatThreadStyles}${chatPageStyles}
|
|
3411
|
+
`,
|
|
3412
|
+
body: `${renderAdminToolbar(userName, activePath)}
|
|
3413
|
+
<div class="vos-page chat-thread-page">
|
|
3414
|
+
<div class="page-header chat-thread-header">
|
|
3415
|
+
<div>
|
|
3416
|
+
<div class="chat-thread-header-top" style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
|
3417
|
+
${drawerHamburger}
|
|
3418
|
+
<a href="/admin/chat?agentId=${safeAgentId}" class="btn btn-secondary">← Back to threads</a>
|
|
3419
|
+
</div>
|
|
3420
|
+
<h1 class="page-title">${title}</h1>
|
|
3421
|
+
<div class="chat-thread-meta" style="font-size:12px;color:#9ca3af;margin-top:4px">Thread <span class="mono">${safeThreadId}</span></div>
|
|
3422
|
+
${
|
|
3423
|
+
stats &&
|
|
3424
|
+
(
|
|
3425
|
+
stats.totalInputTokens > 0 ||
|
|
3426
|
+
stats.totalOutputTokens > 0 ||
|
|
3427
|
+
stats.totalCostUsd > 0
|
|
3428
|
+
)
|
|
3429
|
+
? `<div class="chat-thread-stats" style="font-size:12px;color:#6b7280;margin-top:4px">${escapeHtml(`${formatTokenCount(stats.totalInputTokens)} in / ${formatTokenCount(stats.totalOutputTokens)} out | $${stats.totalCostUsd.toFixed(4)}`)}</div>`
|
|
3430
|
+
: ""
|
|
3431
|
+
}
|
|
3432
|
+
${threadActionsDetails}
|
|
3433
|
+
${renderPushToggle({
|
|
3434
|
+
pushEnabled: opts?.pushEnabled ?? false,
|
|
3435
|
+
vapidPublicKey: opts?.vapidPublicKey ?? "",
|
|
3436
|
+
agentId,
|
|
3437
|
+
threadId,
|
|
3438
|
+
})}
|
|
3439
|
+
</div>
|
|
3440
|
+
</div>
|
|
3441
|
+
<div class="chat-thread-layout" style="display:flex;gap:24px;flex:1;min-height:0;margin-top:16px">
|
|
3442
|
+
${drawerToggle}
|
|
3443
|
+
${sidebar}
|
|
3444
|
+
${drawerScrim}
|
|
3445
|
+
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
|
|
3446
|
+
<!-- Messages area (scrollable) -->
|
|
3447
|
+
<div id="messages-container" class="chat-messages-container">
|
|
3448
|
+
${messageBubbles}
|
|
3449
|
+
${liveStatusBubble}
|
|
3450
|
+
${emptyState}
|
|
3451
|
+
</div>
|
|
3452
|
+
|
|
3453
|
+
<!-- Send form -->
|
|
3454
|
+
<form id="send-form" enctype="multipart/form-data" class="chat-composer-form">
|
|
3455
|
+
<div class="chat-composer-row">
|
|
3456
|
+
<textarea
|
|
3457
|
+
id="message-input"
|
|
3458
|
+
name="body"
|
|
3459
|
+
rows="3"
|
|
3460
|
+
placeholder="Type a message..."
|
|
3461
|
+
class="chat-message-input"
|
|
3462
|
+
></textarea>
|
|
3463
|
+
<input type="file" id="file-input" name="file" style="display:none" accept="text/*,image/*,application/pdf,application/json">
|
|
3464
|
+
<button
|
|
3465
|
+
type="button"
|
|
3466
|
+
id="mic-btn"
|
|
3467
|
+
class="btn btn-secondary chat-composer-btn chat-composer-btn--mic"
|
|
3468
|
+
aria-label="Record a voice message"
|
|
3469
|
+
title="Record a voice message"
|
|
3470
|
+
>🎤</button>
|
|
3471
|
+
<button
|
|
3472
|
+
type="button"
|
|
3473
|
+
id="attach-btn"
|
|
3474
|
+
class="btn btn-secondary chat-composer-btn chat-composer-btn--attach"
|
|
3475
|
+
>Attach file</button>
|
|
3476
|
+
<button
|
|
3477
|
+
type="submit"
|
|
3478
|
+
id="send-btn"
|
|
3479
|
+
class="btn btn-primary chat-composer-btn chat-composer-btn--send"
|
|
3480
|
+
>Send</button>
|
|
3481
|
+
</div>
|
|
3482
|
+
<div id="file-name" style="font-size:12px;color:#6b7280;margin-top:6px;min-height:16px"></div>
|
|
3483
|
+
</form>
|
|
3484
|
+
</div>
|
|
3485
|
+
</div>
|
|
3486
|
+
</div>`,
|
|
3487
|
+
bodyEnd: `${inlineScript}\n${drawerScript}`,
|
|
3488
|
+
});
|
|
3489
|
+
}
|