@oar-sh/oar 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the plan-usage ("Check Usage") modal body from the multi-provider
|
|
3
|
+
* payload `/api/usage` returns.
|
|
4
|
+
*
|
|
5
|
+
* Plan-first by design: subscription credits, limits and resets lead, and the
|
|
6
|
+
* token/cost diagnostics sit in collapsed sections underneath. Kept DOM-free so
|
|
7
|
+
* it can be unit tested directly, mirroring `context-usage-view.mjs` — whose
|
|
8
|
+
* bar/table vocabulary this deliberately reuses so the two modals look related.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { escapeHtml } from './context-usage-view.mjs';
|
|
12
|
+
|
|
13
|
+
// Null-prototype so an unrecognized `status`/`source` can only ever miss —
|
|
14
|
+
// a plain literal would resolve keys like `constructor` off the prototype and
|
|
15
|
+
// stringify a function into a badge.
|
|
16
|
+
const STATUS_BADGES = Object.freeze(Object.assign(Object.create(null), {
|
|
17
|
+
ok: null,
|
|
18
|
+
partial: 'Partial',
|
|
19
|
+
unavailable: 'Unavailable',
|
|
20
|
+
'not-configured': 'Not configured',
|
|
21
|
+
error: 'Error',
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const SOURCE_LABELS = Object.freeze(Object.assign(Object.create(null), {
|
|
25
|
+
live: 'Live',
|
|
26
|
+
cache: 'Cached',
|
|
27
|
+
worker: 'From last turn',
|
|
28
|
+
manual: 'Estimated',
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
/** Short tab captions; the card keeps the long label ("GitHub Copilot"). */
|
|
32
|
+
const TAB_LABELS = Object.freeze(Object.assign(Object.create(null), {
|
|
33
|
+
github: 'Copilot',
|
|
34
|
+
claude: 'Claude',
|
|
35
|
+
cursor: 'Cursor',
|
|
36
|
+
grok: 'Grok',
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// The composer speaks 'github'/'openai'/… while the usage report keys Copilot as
|
|
40
|
+
// 'github'; fold the spellings that mean the same provider onto one id so a
|
|
41
|
+
// session's provider can be matched against a card.
|
|
42
|
+
const PROVIDER_ALIASES = Object.freeze(Object.assign(Object.create(null), {
|
|
43
|
+
copilot: 'github',
|
|
44
|
+
'github-copilot': 'github',
|
|
45
|
+
'copilot-github': 'github',
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
/** @returns {string} the canonical usage-card id for a provider spelling. */
|
|
49
|
+
export function normalizeUsageProvider(value) {
|
|
50
|
+
const key = String(value ?? '').trim().toLowerCase();
|
|
51
|
+
if (!key) return '';
|
|
52
|
+
return PROVIDER_ALIASES[key] || key;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which tab opens selected: the requested provider when it has a card (so a
|
|
57
|
+
* Claude session lands on Claude), otherwise the first card — 'openai' sessions
|
|
58
|
+
* have no card of their own and must not open onto an empty modal.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveActiveUsageProvider(providers, activeProvider = '') {
|
|
61
|
+
const ids = (Array.isArray(providers) ? providers : [])
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map((card) => normalizeUsageProvider(card.provider));
|
|
64
|
+
const wanted = normalizeUsageProvider(activeProvider);
|
|
65
|
+
if (wanted && ids.includes(wanted)) return wanted;
|
|
66
|
+
return ids[0] || '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toNullableNumber(value) {
|
|
70
|
+
if (value === null || value === undefined || value === '') return null;
|
|
71
|
+
const n = Number(value);
|
|
72
|
+
return Number.isFinite(n) ? n : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Green → amber → red, matching the composer's context-usage indicator ramp. */
|
|
76
|
+
export function utilizationColor(utilization) {
|
|
77
|
+
const value = toNullableNumber(utilization);
|
|
78
|
+
if (value === null) return '#6e7681';
|
|
79
|
+
if (value >= 90) return '#f85149';
|
|
80
|
+
if (value >= 75) return '#e3b341';
|
|
81
|
+
if (value >= 50) return '#d29922';
|
|
82
|
+
return '#3fb950';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function formatAmount(value, unit) {
|
|
86
|
+
const n = toNullableNumber(value);
|
|
87
|
+
if (n === null) return null;
|
|
88
|
+
if (unit === 'usd') {
|
|
89
|
+
const rounded = Math.round(n * 100) / 100;
|
|
90
|
+
return `${rounded < 0 ? '-' : ''}$${Math.abs(rounded).toFixed(2)}`;
|
|
91
|
+
}
|
|
92
|
+
if (unit === 'percent') return `${Math.round(n * 10) / 10}%`;
|
|
93
|
+
const abs = Math.abs(n);
|
|
94
|
+
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
95
|
+
if (abs >= 10_000) return `${(n / 1000).toFixed(1)}k`;
|
|
96
|
+
return String(Math.round(n * 100) / 100);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function formatResetCountdown(resetAt, now = new Date()) {
|
|
100
|
+
if (!resetAt) return null;
|
|
101
|
+
const target = new Date(resetAt);
|
|
102
|
+
if (Number.isNaN(target.getTime())) return null;
|
|
103
|
+
const reference = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
|
|
104
|
+
const diffMs = target.getTime() - reference.getTime();
|
|
105
|
+
if (diffMs <= 0) return 'resets now';
|
|
106
|
+
const minutes = Math.floor(diffMs / 60000);
|
|
107
|
+
if (minutes < 60) return `resets in ${Math.max(1, minutes)} min`;
|
|
108
|
+
const hours = Math.floor(minutes / 60);
|
|
109
|
+
if (hours < 24) {
|
|
110
|
+
const remainderMinutes = minutes % 60;
|
|
111
|
+
return remainderMinutes ? `resets in ${hours} h ${remainderMinutes} min` : `resets in ${hours} h`;
|
|
112
|
+
}
|
|
113
|
+
const days = Math.floor(hours / 24);
|
|
114
|
+
if (days <= 14) {
|
|
115
|
+
const remainderHours = hours % 24;
|
|
116
|
+
return remainderHours ? `resets in ${days} d ${remainderHours} h` : `resets in ${days} d`;
|
|
117
|
+
}
|
|
118
|
+
return `resets ${target.toISOString().slice(0, 10)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The headline under a meter label: what is left, out of what, and how it was
|
|
123
|
+
* derived. Providers that only report a percentage get just the percentage.
|
|
124
|
+
*/
|
|
125
|
+
export function meterSummaryText(meter) {
|
|
126
|
+
if (!meter || typeof meter !== 'object') return '';
|
|
127
|
+
if (meter.unlimited) return 'Unlimited';
|
|
128
|
+
const unit = meter.unit || 'credits';
|
|
129
|
+
const used = formatAmount(meter.used, unit);
|
|
130
|
+
const allowance = formatAmount(meter.allowance, unit);
|
|
131
|
+
const remaining = formatAmount(meter.remaining, unit);
|
|
132
|
+
const utilization = toNullableNumber(meter.utilization);
|
|
133
|
+
|
|
134
|
+
if (allowance !== null && used !== null) {
|
|
135
|
+
const parts = [`${used} of ${allowance} used`];
|
|
136
|
+
if (remaining !== null) {
|
|
137
|
+
const remainingNumber = toNullableNumber(meter.remaining);
|
|
138
|
+
// Overage reads as a magnitude ("9989 over"), not a signed value.
|
|
139
|
+
parts.push(remainingNumber < 0
|
|
140
|
+
? `${formatAmount(Math.abs(remainingNumber), unit)} over`
|
|
141
|
+
: `${remaining} left`);
|
|
142
|
+
}
|
|
143
|
+
return parts.join(' · ');
|
|
144
|
+
}
|
|
145
|
+
if (remaining !== null && allowance !== null) return `${remaining} of ${allowance} left`;
|
|
146
|
+
if (used !== null) return `${used} used`;
|
|
147
|
+
if (utilization !== null) return `${Math.round(utilization * 10) / 10}% used`;
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderMeter(meter, now) {
|
|
152
|
+
const utilization = toNullableNumber(meter.utilization);
|
|
153
|
+
const width = meter.unlimited ? 100 : Math.max(0, Math.min(100, utilization ?? 0));
|
|
154
|
+
const color = meter.unlimited ? '#3fb950' : utilizationColor(utilization);
|
|
155
|
+
const summary = meterSummaryText(meter);
|
|
156
|
+
const countdown = formatResetCountdown(meter.resetAt, now);
|
|
157
|
+
const percentLabel = meter.unlimited
|
|
158
|
+
? '∞'
|
|
159
|
+
: (utilization === null ? '—' : `${Math.round(utilization)}%`);
|
|
160
|
+
const flags = [
|
|
161
|
+
meter.estimated ? '<span class="plan-usage-flag">estimated</span>' : '',
|
|
162
|
+
].filter(Boolean).join('');
|
|
163
|
+
const meta = [summary, countdown].filter(Boolean).join(' · ');
|
|
164
|
+
|
|
165
|
+
return `
|
|
166
|
+
<div class="plan-usage-meter plan-usage-meter-${escapeHtml(meter.emphasis || 'primary')}" data-meter-id="${escapeHtml(meter.id)}">
|
|
167
|
+
<div class="plan-usage-meter-head">
|
|
168
|
+
<span class="plan-usage-meter-label">${escapeHtml(meter.label)}${flags}</span>
|
|
169
|
+
<span class="plan-usage-meter-pct">${escapeHtml(percentLabel)}</span>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="plan-usage-bar" role="img" aria-label="${escapeHtml(`${meter.label}: ${percentLabel} used`)}">
|
|
172
|
+
<span class="plan-usage-bar-fill${utilization === null && !meter.unlimited ? ' plan-usage-bar-unknown' : ''}" style="width:${width}%;background:${color}"></span>
|
|
173
|
+
</div>
|
|
174
|
+
${meta ? `<div class="plan-usage-meter-meta">${escapeHtml(meta)}</div>` : ''}
|
|
175
|
+
${meter.note ? `<div class="plan-usage-meter-note">${escapeHtml(meter.note)}</div>` : ''}
|
|
176
|
+
</div>
|
|
177
|
+
`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderDetailSection(section) {
|
|
181
|
+
const rows = section.rows.map((row) => `
|
|
182
|
+
<tr>
|
|
183
|
+
<td class="plan-usage-detail-label">${escapeHtml(row.label)}${row.hint ? `<span class="plan-usage-detail-hint">${escapeHtml(row.hint)}</span>` : ''}</td>
|
|
184
|
+
<td class="plan-usage-detail-value">${escapeHtml(row.value)}</td>
|
|
185
|
+
</tr>
|
|
186
|
+
`).join('');
|
|
187
|
+
return `
|
|
188
|
+
<details class="plan-usage-details" data-section-id="${escapeHtml(section.id)}">
|
|
189
|
+
<summary>${escapeHtml(section.label)}</summary>
|
|
190
|
+
${section.note ? `<div class="plan-usage-detail-note">${escapeHtml(section.note)}</div>` : ''}
|
|
191
|
+
<table class="plan-usage-detail-table"><tbody>${rows}</tbody></table>
|
|
192
|
+
</details>
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The signed-in account under the card title. Three distinct states, because
|
|
198
|
+
* "we could not ask" must not be drawn as "you are logged out": no `account`
|
|
199
|
+
* key renders nothing at all, `{loggedIn:false}` says so, anything else shows
|
|
200
|
+
* whatever identity we have.
|
|
201
|
+
*/
|
|
202
|
+
function renderAccountLine(account) {
|
|
203
|
+
if (!account || typeof account !== 'object') return '';
|
|
204
|
+
if (account.loggedIn === false) {
|
|
205
|
+
return '<span class="plan-usage-account plan-usage-account-signed-out">Not logged in</span>';
|
|
206
|
+
}
|
|
207
|
+
const parts = [account.email, account.plan]
|
|
208
|
+
.map((value) => String(value ?? '').trim())
|
|
209
|
+
.filter(Boolean);
|
|
210
|
+
if (!parts.length) {
|
|
211
|
+
return account.loggedIn === true
|
|
212
|
+
? '<span class="plan-usage-account">Logged in</span>'
|
|
213
|
+
: '';
|
|
214
|
+
}
|
|
215
|
+
return `<span class="plan-usage-account">${parts.map(escapeHtml).join(' · ')}</span>`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderCard(card, now, { active = true } = {}) {
|
|
219
|
+
const meters = Array.isArray(card.meters) ? card.meters : [];
|
|
220
|
+
const details = Array.isArray(card.details) ? card.details : [];
|
|
221
|
+
const links = Array.isArray(card.links) ? card.links : [];
|
|
222
|
+
|
|
223
|
+
const statusBadge = STATUS_BADGES[card.status];
|
|
224
|
+
const sourceBadge = SOURCE_LABELS[card.source];
|
|
225
|
+
const badges = [
|
|
226
|
+
card.planName ? `<span class="plan-usage-badge plan-usage-badge-plan">${escapeHtml(card.planName)}</span>` : '',
|
|
227
|
+
statusBadge ? `<span class="plan-usage-badge plan-usage-badge-status">${escapeHtml(statusBadge)}</span>` : '',
|
|
228
|
+
sourceBadge ? `<span class="plan-usage-badge">${escapeHtml(sourceBadge)}</span>` : '',
|
|
229
|
+
].filter(Boolean).join('');
|
|
230
|
+
|
|
231
|
+
const primary = meters.filter((meter) => meter.emphasis !== 'secondary');
|
|
232
|
+
const secondary = meters.filter((meter) => meter.emphasis === 'secondary');
|
|
233
|
+
|
|
234
|
+
const metersHtml = meters.length
|
|
235
|
+
? `${primary.map((meter) => renderMeter(meter, now)).join('')}${
|
|
236
|
+
secondary.length
|
|
237
|
+
? `<div class="plan-usage-secondary">${secondary.map((meter) => renderMeter(meter, now)).join('')}</div>`
|
|
238
|
+
: ''
|
|
239
|
+
}`
|
|
240
|
+
: '';
|
|
241
|
+
|
|
242
|
+
const capturedLabel = card.capturedAt
|
|
243
|
+
? `Updated ${escapeHtml(String(card.capturedAt).replace('T', ' ').slice(0, 16))} UTC`
|
|
244
|
+
: '';
|
|
245
|
+
|
|
246
|
+
const panelId = normalizeUsageProvider(card.provider);
|
|
247
|
+
return `
|
|
248
|
+
<section class="plan-usage-card plan-usage-card-${escapeHtml(card.provider)}" data-provider="${escapeHtml(card.provider)}" data-usage-panel="${escapeHtml(panelId)}" data-status="${escapeHtml(card.status)}" role="tabpanel" id="plan-usage-panel-${escapeHtml(panelId)}" aria-labelledby="plan-usage-tab-${escapeHtml(panelId)}" tabindex="0"${active ? '' : ' hidden'}>
|
|
249
|
+
<header class="plan-usage-card-head">
|
|
250
|
+
<span class="plan-usage-card-titles">
|
|
251
|
+
<span class="plan-usage-card-title">${escapeHtml(card.label)}</span>
|
|
252
|
+
${renderAccountLine(card.account)}
|
|
253
|
+
</span>
|
|
254
|
+
<span class="plan-usage-badges">${badges}</span>
|
|
255
|
+
</header>
|
|
256
|
+
${card.message ? `<div class="plan-usage-message">${escapeHtml(card.message)}</div>` : ''}
|
|
257
|
+
${metersHtml}
|
|
258
|
+
${details.map(renderDetailSection).join('')}
|
|
259
|
+
<div class="plan-usage-card-foot">
|
|
260
|
+
${capturedLabel ? `<span class="plan-usage-captured">${capturedLabel}</span>` : ''}
|
|
261
|
+
${links.map((link) => `<a class="plan-usage-link" href="${escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(link.label)}</a>`).join('')}
|
|
262
|
+
</div>
|
|
263
|
+
</section>
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function renderTab(card, activeProvider) {
|
|
268
|
+
const id = normalizeUsageProvider(card.provider);
|
|
269
|
+
const active = id === activeProvider;
|
|
270
|
+
const label = TAB_LABELS[id] || card.label || id;
|
|
271
|
+
return `<button type="button" class="plan-usage-tab${active ? ' active' : ''}" role="tab" id="plan-usage-tab-${escapeHtml(id)}" aria-controls="plan-usage-panel-${escapeHtml(id)}" aria-selected="${active ? 'true' : 'false'}" tabindex="${active ? '0' : '-1'}" data-usage-tab="${escapeHtml(id)}">${escapeHtml(label)}</button>`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {object|null} report the `/api/usage` payload
|
|
276
|
+
* @param {object} [options]
|
|
277
|
+
* @param {Date} [options.now] injectable clock so countdowns are testable
|
|
278
|
+
* @param {string} [options.activeProvider] the session's provider; selects its
|
|
279
|
+
* tab when that provider has a card
|
|
280
|
+
* @returns {string} modal body HTML, or '' when there is nothing to render
|
|
281
|
+
*/
|
|
282
|
+
export function renderPlanUsageHtml(report, { now = new Date(), activeProvider = '' } = {}) {
|
|
283
|
+
const providers = Array.isArray(report?.providers) ? report.providers.filter(Boolean) : [];
|
|
284
|
+
if (!providers.length) return '';
|
|
285
|
+
const active = resolveActiveUsageProvider(providers, activeProvider);
|
|
286
|
+
const tabs = providers.map((card) => renderTab(card, active)).join('');
|
|
287
|
+
const cards = providers
|
|
288
|
+
.map((card) => renderCard(card, now, { active: normalizeUsageProvider(card.provider) === active }))
|
|
289
|
+
.join('');
|
|
290
|
+
return `<div class="plan-usage"><div class="plan-usage-tab-strip" role="tablist" aria-label="Usage providers">${tabs}</div>${cards}</div>`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Subtitle for the modal header: the healthiest available "what matters now" line. */
|
|
294
|
+
export function planUsageSubtitle(report) {
|
|
295
|
+
const providers = Array.isArray(report?.providers) ? report.providers.filter(Boolean) : [];
|
|
296
|
+
if (!providers.length) return 'No usage data available';
|
|
297
|
+
const available = providers.filter((card) => Array.isArray(card.meters) && card.meters.length);
|
|
298
|
+
if (!available.length) return 'No plan limits reported yet';
|
|
299
|
+
const soonest = available
|
|
300
|
+
.flatMap((card) => card.meters.map((meter) => meter.resetAt))
|
|
301
|
+
.filter(Boolean)
|
|
302
|
+
.sort()[0];
|
|
303
|
+
const countdown = formatResetCountdown(soonest);
|
|
304
|
+
return countdown
|
|
305
|
+
? `${available.length} provider${available.length === 1 ? '' : 's'} reporting · next reset ${countdown.replace(/^resets /, '')}`
|
|
306
|
+
: `${available.length} provider${available.length === 1 ? '' : 's'} reporting`;
|
|
307
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Preview cards: live dev servers published through the preview lane. Fed by
|
|
2
|
+
// the `previews` socket event (REPLACE semantics for the whole set, since the
|
|
3
|
+
// registry is relay-owned and global) and by the conversation payload's
|
|
4
|
+
// `previews` on reload.
|
|
5
|
+
//
|
|
6
|
+
// The same row builder serves both surfaces: the composer's background-task
|
|
7
|
+
// panel (this conversation's previews, beside the Bash task running the dev
|
|
8
|
+
// server) and the settings modal's "Live previews" list (every session's).
|
|
9
|
+
// Previews never expire, so the settings list is the one place that can always
|
|
10
|
+
// close a link left behind in a conversation you have moved on from.
|
|
11
|
+
|
|
12
|
+
import { apiFetch } from './api-client.js';
|
|
13
|
+
|
|
14
|
+
let previews = [];
|
|
15
|
+
const closesInFlight = new Set();
|
|
16
|
+
const subscribers = new Set();
|
|
17
|
+
|
|
18
|
+
export function subscribePreviews(callback) {
|
|
19
|
+
if (typeof callback !== 'function') return () => {};
|
|
20
|
+
subscribers.add(callback);
|
|
21
|
+
return () => subscribers.delete(callback);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function notify() {
|
|
25
|
+
for (const callback of subscribers) {
|
|
26
|
+
try { callback(previews); } catch {}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizePreview(entry) {
|
|
31
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
32
|
+
const token = String(entry.token || '').trim();
|
|
33
|
+
if (!token) return null;
|
|
34
|
+
return {
|
|
35
|
+
token,
|
|
36
|
+
conversationId: String(entry.conversationId || '').trim() || null,
|
|
37
|
+
label: String(entry.label || '').trim() || 'Preview',
|
|
38
|
+
targetHost: String(entry.targetHost || '').trim(),
|
|
39
|
+
targetPort: Number(entry.targetPort) || null,
|
|
40
|
+
url: String(entry.url || '').trim(),
|
|
41
|
+
basePath: String(entry.basePath || '').trim(),
|
|
42
|
+
createdAt: Number(entry.createdAt) || null,
|
|
43
|
+
// null = not probed yet; the badge stays absent rather than guessing.
|
|
44
|
+
online: entry.online === true ? true : (entry.online === false ? false : null),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setPreviews(nextPreviews) {
|
|
49
|
+
previews = (Array.isArray(nextPreviews) ? nextPreviews : [])
|
|
50
|
+
.map(normalizePreview)
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
notify();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The conversation payload only carries that conversation's previews, so it
|
|
56
|
+
// merges into the global set rather than replacing it — otherwise opening a
|
|
57
|
+
// conversation would erase every other session's cards until the next socket
|
|
58
|
+
// event.
|
|
59
|
+
export function mergeConversationPreviews(conversationId, conversationPreviews) {
|
|
60
|
+
const id = String(conversationId || '').trim();
|
|
61
|
+
if (!id) return;
|
|
62
|
+
const incoming = (Array.isArray(conversationPreviews) ? conversationPreviews : [])
|
|
63
|
+
.map(normalizePreview)
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
previews = previews.filter((entry) => entry.conversationId !== id).concat(incoming);
|
|
66
|
+
notify();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getPreviews() {
|
|
70
|
+
return previews;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getPreviewsForConversation(conversationId) {
|
|
74
|
+
const id = String(conversationId || '').trim();
|
|
75
|
+
if (!id) return [];
|
|
76
|
+
return previews.filter((entry) => entry.conversationId === id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function closePreview(token) {
|
|
80
|
+
const id = String(token || '').trim();
|
|
81
|
+
if (!id || closesInFlight.has(id)) return;
|
|
82
|
+
closesInFlight.add(id);
|
|
83
|
+
notify();
|
|
84
|
+
try {
|
|
85
|
+
// apiFetch resolves to null on a network error or a non-2xx status rather
|
|
86
|
+
// than throwing, so success has to be checked, not assumed: a card removed
|
|
87
|
+
// on a failed DELETE would hide a link that is still publicly live.
|
|
88
|
+
const response = await apiFetch(`/api/previews/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
89
|
+
if (response?.ok === true) {
|
|
90
|
+
// Optimistic removal: the socket event will confirm, but the card should
|
|
91
|
+
// not linger while the round trip completes.
|
|
92
|
+
previews = previews.filter((entry) => entry.token !== id);
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Left in place on failure so the user can retry rather than lose the link.
|
|
96
|
+
} finally {
|
|
97
|
+
closesInFlight.delete(id);
|
|
98
|
+
notify();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function copyPreviewUrl(url, button) {
|
|
103
|
+
try {
|
|
104
|
+
await navigator.clipboard.writeText(url);
|
|
105
|
+
const previousText = button.textContent;
|
|
106
|
+
button.textContent = 'Copied';
|
|
107
|
+
setTimeout(() => { button.textContent = previousText; }, 1500);
|
|
108
|
+
} catch {
|
|
109
|
+
button.textContent = 'Copy failed';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function previewDetail(preview, { withConversation = false } = {}) {
|
|
114
|
+
const parts = [];
|
|
115
|
+
if (preview.targetPort) parts.push(`:${preview.targetPort}`);
|
|
116
|
+
if (preview.online === false) parts.push('offline — dev server not responding');
|
|
117
|
+
if (withConversation && preview.conversationId) {
|
|
118
|
+
parts.push(`session ${preview.conversationId.slice(0, 8)}`);
|
|
119
|
+
}
|
|
120
|
+
return parts.join(' · ');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One preview row. Built with createElement/textContent throughout: a label is
|
|
125
|
+
* agent-supplied text and must never be able to execute as markup.
|
|
126
|
+
*/
|
|
127
|
+
export function buildPreviewRow(preview, { withConversation = false } = {}) {
|
|
128
|
+
const row = document.createElement('div');
|
|
129
|
+
row.className = 'preview-row';
|
|
130
|
+
row.dataset.token = preview.token;
|
|
131
|
+
if (preview.online === false) row.classList.add('preview-row-offline');
|
|
132
|
+
|
|
133
|
+
const icon = document.createElement('span');
|
|
134
|
+
icon.className = 'preview-icon';
|
|
135
|
+
icon.textContent = '🌐';
|
|
136
|
+
icon.title = 'Preview server';
|
|
137
|
+
row.appendChild(icon);
|
|
138
|
+
|
|
139
|
+
const main = document.createElement('span');
|
|
140
|
+
main.className = 'preview-main';
|
|
141
|
+
row.appendChild(main);
|
|
142
|
+
|
|
143
|
+
const label = document.createElement('span');
|
|
144
|
+
label.className = 'preview-label';
|
|
145
|
+
label.textContent = preview.label;
|
|
146
|
+
main.appendChild(label);
|
|
147
|
+
|
|
148
|
+
const detailText = previewDetail(preview, { withConversation });
|
|
149
|
+
if (detailText) {
|
|
150
|
+
const detail = document.createElement('span');
|
|
151
|
+
detail.className = 'preview-detail';
|
|
152
|
+
detail.textContent = detailText;
|
|
153
|
+
main.appendChild(detail);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The link is public: anyone who has it reaches the dev server without
|
|
157
|
+
// logging in, so the card says so rather than leaving it to be discovered.
|
|
158
|
+
const warning = document.createElement('span');
|
|
159
|
+
warning.className = 'preview-warning';
|
|
160
|
+
warning.textContent = 'public link — anyone with the URL can reach this app';
|
|
161
|
+
main.appendChild(warning);
|
|
162
|
+
|
|
163
|
+
const side = document.createElement('span');
|
|
164
|
+
side.className = 'preview-side';
|
|
165
|
+
row.appendChild(side);
|
|
166
|
+
|
|
167
|
+
if (preview.online !== null) {
|
|
168
|
+
const badge = document.createElement('span');
|
|
169
|
+
badge.className = preview.online
|
|
170
|
+
? 'preview-badge preview-badge-online'
|
|
171
|
+
: 'preview-badge preview-badge-offline';
|
|
172
|
+
badge.textContent = preview.online ? 'online' : 'offline';
|
|
173
|
+
side.appendChild(badge);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const actions = document.createElement('span');
|
|
177
|
+
actions.className = 'preview-actions';
|
|
178
|
+
side.appendChild(actions);
|
|
179
|
+
|
|
180
|
+
const open = document.createElement('a');
|
|
181
|
+
open.className = 'preview-open';
|
|
182
|
+
open.href = preview.url;
|
|
183
|
+
open.target = '_blank';
|
|
184
|
+
// noopener/noreferrer: the preview is a different origin running app code we
|
|
185
|
+
// do not control, and it has no business holding a handle on this window.
|
|
186
|
+
open.rel = 'noopener noreferrer';
|
|
187
|
+
open.textContent = 'Open';
|
|
188
|
+
actions.appendChild(open);
|
|
189
|
+
|
|
190
|
+
const copy = document.createElement('button');
|
|
191
|
+
copy.type = 'button';
|
|
192
|
+
copy.className = 'preview-copy';
|
|
193
|
+
copy.textContent = 'Copy link';
|
|
194
|
+
copy.addEventListener('click', (event) => {
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
void copyPreviewUrl(preview.url, copy);
|
|
197
|
+
});
|
|
198
|
+
actions.appendChild(copy);
|
|
199
|
+
|
|
200
|
+
const closing = closesInFlight.has(preview.token);
|
|
201
|
+
const close = document.createElement('button');
|
|
202
|
+
close.type = 'button';
|
|
203
|
+
close.className = 'preview-close';
|
|
204
|
+
close.disabled = closing;
|
|
205
|
+
close.textContent = closing ? 'Closing…' : 'Close';
|
|
206
|
+
close.addEventListener('click', (event) => {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
void closePreview(preview.token);
|
|
209
|
+
});
|
|
210
|
+
actions.appendChild(close);
|
|
211
|
+
|
|
212
|
+
return row;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Transcript card for a preview published during a turn. Renders from the
|
|
217
|
+
* persisted snapshot (the registry may be long gone), overlaying live state
|
|
218
|
+
* from the current store: a token still registered gets a working row, a
|
|
219
|
+
* vanished one keeps the label but drops the dead link for a `closed` badge.
|
|
220
|
+
*/
|
|
221
|
+
export function buildTranscriptPreviewCard(snapshot) {
|
|
222
|
+
const preview = normalizePreview(snapshot);
|
|
223
|
+
if (!preview) return null;
|
|
224
|
+
const live = previews.some((entry) => entry.token === preview.token);
|
|
225
|
+
|
|
226
|
+
const card = document.createElement('div');
|
|
227
|
+
card.className = live ? 'msg-preview-card' : 'msg-preview-card msg-preview-card-closed';
|
|
228
|
+
|
|
229
|
+
const icon = document.createElement('span');
|
|
230
|
+
icon.className = 'preview-icon';
|
|
231
|
+
icon.textContent = '🌐';
|
|
232
|
+
card.appendChild(icon);
|
|
233
|
+
|
|
234
|
+
const label = document.createElement('span');
|
|
235
|
+
label.className = 'preview-label';
|
|
236
|
+
label.textContent = preview.label;
|
|
237
|
+
card.appendChild(label);
|
|
238
|
+
|
|
239
|
+
if (live) {
|
|
240
|
+
const open = document.createElement('a');
|
|
241
|
+
open.className = 'preview-open';
|
|
242
|
+
open.href = preview.url;
|
|
243
|
+
open.target = '_blank';
|
|
244
|
+
open.rel = 'noopener noreferrer';
|
|
245
|
+
open.textContent = 'Open';
|
|
246
|
+
card.appendChild(open);
|
|
247
|
+
|
|
248
|
+
const copy = document.createElement('button');
|
|
249
|
+
copy.type = 'button';
|
|
250
|
+
copy.className = 'preview-copy';
|
|
251
|
+
copy.textContent = 'Copy link';
|
|
252
|
+
copy.addEventListener('click', (event) => {
|
|
253
|
+
event.preventDefault();
|
|
254
|
+
void copyPreviewUrl(preview.url, copy);
|
|
255
|
+
});
|
|
256
|
+
card.appendChild(copy);
|
|
257
|
+
} else {
|
|
258
|
+
const badge = document.createElement('span');
|
|
259
|
+
badge.className = 'preview-badge preview-badge-offline';
|
|
260
|
+
badge.textContent = 'closed';
|
|
261
|
+
card.appendChild(badge);
|
|
262
|
+
}
|
|
263
|
+
return card;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function renderPreviewRowsInto(container, rows, options = {}) {
|
|
267
|
+
if (!container) return 0;
|
|
268
|
+
container.textContent = '';
|
|
269
|
+
for (const preview of rows) {
|
|
270
|
+
container.appendChild(buildPreviewRow(preview, options));
|
|
271
|
+
}
|
|
272
|
+
return rows.length;
|
|
273
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The /preview composer command: publish a port or directory, list, or close —
|
|
2
|
+
// entirely client-side against the relay API, so it works identically on every
|
|
3
|
+
// provider and never costs an agent turn. Mirrors the /compact intercept in
|
|
4
|
+
// conversation-view.js: parsed before anything is queued, unknown syntax falls
|
|
5
|
+
// through to a help line, never to the model.
|
|
6
|
+
|
|
7
|
+
import { apiFetch } from './api-client.js';
|
|
8
|
+
|
|
9
|
+
export const PREVIEW_COMMAND_HELP =
|
|
10
|
+
'Usage: /preview <port> [label] · /preview <dir> [label] · /preview list · /preview close [token-prefix]';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns null when the text is not a /preview command at all (the composer
|
|
14
|
+
* sends it as a normal message); otherwise a parsed command, with unparseable
|
|
15
|
+
* input mapped to {kind:'help'} so it never reaches the model by accident.
|
|
16
|
+
*/
|
|
17
|
+
export function parsePreviewCommand(text) {
|
|
18
|
+
const trimmed = String(text || '').trim();
|
|
19
|
+
if (!/^\/preview(\s|$)/i.test(trimmed)) return null;
|
|
20
|
+
const rest = trimmed.replace(/^\/preview\s*/i, '').trim();
|
|
21
|
+
if (!rest) return { kind: 'help' };
|
|
22
|
+
|
|
23
|
+
const [first, ...restParts] = rest.split(/\s+/);
|
|
24
|
+
const remainder = restParts.join(' ').trim();
|
|
25
|
+
|
|
26
|
+
if (/^list$/i.test(first)) return { kind: 'list' };
|
|
27
|
+
if (/^close$/i.test(first)) {
|
|
28
|
+
const prefix = remainder.toLowerCase();
|
|
29
|
+
if (prefix && !/^[0-9a-f]{4,32}$/.test(prefix)) return { kind: 'help' };
|
|
30
|
+
return { kind: 'close', tokenPrefix: prefix || null };
|
|
31
|
+
}
|
|
32
|
+
if (/^\d+$/.test(first)) {
|
|
33
|
+
const port = Number(first);
|
|
34
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return { kind: 'help' };
|
|
35
|
+
return { kind: 'create-port', port, label: remainder || null };
|
|
36
|
+
}
|
|
37
|
+
// Anything path-shaped is a static preview. Windows drive letters ("C:\x")
|
|
38
|
+
// and dotted relatives all land here; the server does the real validation.
|
|
39
|
+
return { kind: 'create-dir', dir: first, label: remainder || null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function closeByPrefix(tokenPrefix, conversationId) {
|
|
43
|
+
const listed = await apiFetch(`/api/previews${tokenPrefix ? '' : `?conversationId=${encodeURIComponent(conversationId || '')}`}`);
|
|
44
|
+
const previews = listed?.previews || [];
|
|
45
|
+
const matches = tokenPrefix
|
|
46
|
+
? previews.filter((entry) => String(entry.token || '').startsWith(tokenPrefix))
|
|
47
|
+
: previews;
|
|
48
|
+
if (matches.length === 0) {
|
|
49
|
+
return { ok: false, notice: tokenPrefix ? `No preview matches "${tokenPrefix}".` : 'No live previews in this conversation.' };
|
|
50
|
+
}
|
|
51
|
+
if (matches.length > 1) {
|
|
52
|
+
const names = matches.map((entry) => `${entry.token.slice(0, 8)} (${entry.label})`).join(', ');
|
|
53
|
+
return { ok: false, notice: `Ambiguous — matches: ${names}. Use a longer prefix.` };
|
|
54
|
+
}
|
|
55
|
+
const closed = await apiFetch(`/api/previews/${encodeURIComponent(matches[0].token)}`, { method: 'DELETE' });
|
|
56
|
+
if (closed?.ok !== true) return { ok: false, notice: 'Close failed — the preview may already be gone.' };
|
|
57
|
+
return { ok: true, notice: `Preview closed: ${matches[0].label}` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Executes a parsed command. Returns `{ ok, notice }`; the caller renders the
|
|
62
|
+
* notice. Creation results also surface as panel cards via the `previews`
|
|
63
|
+
* socket event, so the notice only needs to carry the URL once.
|
|
64
|
+
*/
|
|
65
|
+
export async function runPreviewCommand(parsed, { conversationId = '' } = {}) {
|
|
66
|
+
if (!parsed || parsed.kind === 'help') return { ok: true, notice: PREVIEW_COMMAND_HELP };
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
if (parsed.kind === 'list') {
|
|
70
|
+
const listed = await apiFetch('/api/previews');
|
|
71
|
+
if (!listed) return { ok: false, notice: 'Could not reach the relay.' };
|
|
72
|
+
if (listed.enabled !== true) return { ok: false, notice: 'The preview lane is disabled on this relay.' };
|
|
73
|
+
const previews = listed.previews || [];
|
|
74
|
+
if (!previews.length) return { ok: true, notice: 'No live previews.' };
|
|
75
|
+
const lines = previews.map((entry) => {
|
|
76
|
+
const target = entry.mode === 'static' ? entry.rootDir : `:${entry.targetPort}`;
|
|
77
|
+
const state = entry.online === false ? ' — offline' : '';
|
|
78
|
+
return `${entry.token.slice(0, 8)} · ${entry.label} (${target})${state} → ${entry.url}`;
|
|
79
|
+
});
|
|
80
|
+
return { ok: true, notice: lines.join('\n') };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (parsed.kind === 'close') {
|
|
84
|
+
return await closeByPrefix(parsed.tokenPrefix, conversationId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const body = {
|
|
88
|
+
conversationId: conversationId || undefined,
|
|
89
|
+
label: parsed.label || undefined,
|
|
90
|
+
...(parsed.kind === 'create-port' ? { port: parsed.port } : { dir: parsed.dir }),
|
|
91
|
+
};
|
|
92
|
+
const created = await apiFetch('/api/previews', { method: 'POST', body: JSON.stringify(body) });
|
|
93
|
+
if (created?.ok !== true) {
|
|
94
|
+
// apiFetch swallows the response body on non-2xx, so the specific server
|
|
95
|
+
// error (bad port, jail refusal, lane disabled) is not recoverable here;
|
|
96
|
+
// point at the panel where the states are visible instead.
|
|
97
|
+
return { ok: false, notice: 'Preview failed — check the port/path, or whether the preview lane is enabled (Settings → Live previews).' };
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, notice: `Preview published: ${created.url}` };
|
|
100
|
+
} catch {
|
|
101
|
+
return { ok: false, notice: 'Preview command failed — could not reach the relay.' };
|
|
102
|
+
}
|
|
103
|
+
}
|