@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,455 @@
|
|
|
1
|
+
// Provider CLI install / update rows inside Settings → Providers.
|
|
2
|
+
//
|
|
3
|
+
// The relay runs the vendors' own install one-liners on the host, so a missing
|
|
4
|
+
// `grok` (the relay.grok-cli-missing turn failure) is fixable from a phone
|
|
5
|
+
// instead of from a shell on the host — the exact thing the relay exists to
|
|
6
|
+
// avoid. One renderer serves every provider sub-tab; which buttons a row offers
|
|
7
|
+
// is read straight off the payload's `commands` keys and never re-derived here,
|
|
8
|
+
// so the frozen server-side descriptor table stays the single source of truth
|
|
9
|
+
// for what can be run.
|
|
10
|
+
//
|
|
11
|
+
// Every visible state comes from the `cli_install_state` socket broadcast (same
|
|
12
|
+
// payload as GET /api/cli/status), so an install survives closing the modal,
|
|
13
|
+
// switching tabs, or being started from another device. Rendering is therefore
|
|
14
|
+
// idempotent: it reads the latest payload and never assumes a local step
|
|
15
|
+
// sequence — the log is redrawn from the whole retained buffer whenever the
|
|
16
|
+
// monotonic `logSeq` moves, rather than appended chunk by chunk.
|
|
17
|
+
//
|
|
18
|
+
// Output hygiene: the log is host process output. The service escape-strips it
|
|
19
|
+
// before broadcasting; this module writes it with textContent (never innerHTML)
|
|
20
|
+
// and escapes everything it does put through innerHTML with escHtml.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
escHtml,
|
|
24
|
+
openSummaryModal,
|
|
25
|
+
closeSummaryModal,
|
|
26
|
+
setSummaryModalLoading,
|
|
27
|
+
showTransientRelayNotice,
|
|
28
|
+
} from './store.js';
|
|
29
|
+
import { getCliStatus, startCliInstall, cancelCliInstall } from './api-client.js';
|
|
30
|
+
|
|
31
|
+
// The panels that carry a row. Copilot is detect-only (npm-global on this host),
|
|
32
|
+
// so its payload arrives with an empty `commands` and the buttons stay hidden.
|
|
33
|
+
export const CLI_PROVIDER_IDS = ['grok', 'claude', 'copilot'];
|
|
34
|
+
const INSTALL_STATES = ['idle', 'running', 'success', 'error', 'cancelled'];
|
|
35
|
+
const INSTALL_ACTIONS = ['install', 'update', 'migrate'];
|
|
36
|
+
const ACTION_BUTTON_LABELS = {
|
|
37
|
+
install: 'Install',
|
|
38
|
+
update: 'Update',
|
|
39
|
+
migrate: 'Switch to native installer',
|
|
40
|
+
};
|
|
41
|
+
const ACTION_TITLES = {
|
|
42
|
+
install: 'Install',
|
|
43
|
+
update: 'Update',
|
|
44
|
+
migrate: 'Switch to the native installer',
|
|
45
|
+
};
|
|
46
|
+
const STATE_WORDS = {
|
|
47
|
+
running: 'Running',
|
|
48
|
+
success: 'Finished',
|
|
49
|
+
error: 'Failed',
|
|
50
|
+
cancelled: 'Cancelled',
|
|
51
|
+
};
|
|
52
|
+
// Anthropic's own prescribed fix for an unwritable npm global folder, quoted
|
|
53
|
+
// with its consequence rather than paraphrased.
|
|
54
|
+
const MIGRATE_NOTE = 'The npm copy stays where it is; the native build takes PATH precedence.';
|
|
55
|
+
|
|
56
|
+
const EMPTY_INSTALL = Object.freeze({
|
|
57
|
+
state: 'idle',
|
|
58
|
+
providerId: null,
|
|
59
|
+
action: null,
|
|
60
|
+
command: null,
|
|
61
|
+
log: '',
|
|
62
|
+
logSeq: 0,
|
|
63
|
+
error: null,
|
|
64
|
+
startedAt: null,
|
|
65
|
+
finishedAt: null,
|
|
66
|
+
active: false,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let cliState = { providers: {}, install: { ...EMPTY_INSTALL } };
|
|
70
|
+
// Redraw key for the log body: `logSeq` alone is not enough, because it is
|
|
71
|
+
// monotonic across the whole relay lifetime and a fresh install starts with an
|
|
72
|
+
// empty buffer at the sequence the previous one ended on.
|
|
73
|
+
let renderedLogKey = '';
|
|
74
|
+
// One-shot: a failed install expands its own log, but a later re-render of the
|
|
75
|
+
// same sticky payload must not fight the user closing it again.
|
|
76
|
+
let autoExpandProviderId = '';
|
|
77
|
+
let lastInstallSignature = '';
|
|
78
|
+
// Client-side failures (request rejected before any broadcast), shown on the
|
|
79
|
+
// row that asked for them and cleared by the next real state transition.
|
|
80
|
+
let localErrors = {};
|
|
81
|
+
let installInFlight = false;
|
|
82
|
+
let cancelInFlight = false;
|
|
83
|
+
// The status read currently in flight, as `{ promise, force }`, so a second
|
|
84
|
+
// caller can *join* it instead of being dropped — see refreshCliInstallSections.
|
|
85
|
+
let statusRefresh = null;
|
|
86
|
+
|
|
87
|
+
function normalizeInstallState(raw) {
|
|
88
|
+
const value = String(raw || '').trim().toLowerCase();
|
|
89
|
+
return INSTALL_STATES.includes(value) ? value : 'idle';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeProviderId(raw) {
|
|
93
|
+
const value = String(raw || '').trim().toLowerCase();
|
|
94
|
+
return CLI_PROVIDER_IDS.includes(value) ? value : '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeAction(raw) {
|
|
98
|
+
const value = String(raw || '').trim().toLowerCase();
|
|
99
|
+
return INSTALL_ACTIONS.includes(value) ? value : '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The row's status line (§3): `not installed` / `1.0.13 · ~/.grok/bin/grok ·
|
|
104
|
+
* native · update available`. Every segment degrades on its own — an
|
|
105
|
+
* unparseable version reads as "version unknown" rather than hiding the
|
|
106
|
+
* install, and `updateAvailable: null` (unknown) simply drops that segment.
|
|
107
|
+
*/
|
|
108
|
+
export function cliRowText(row) {
|
|
109
|
+
if (!row) return 'CLI status unavailable.';
|
|
110
|
+
// `checkedAt: null` is "never probed", which is not the same as "not there".
|
|
111
|
+
if (!row.checkedAt) return 'Checking…';
|
|
112
|
+
if (row.installed !== true) {
|
|
113
|
+
const error = String(row.error || '').trim();
|
|
114
|
+
return error ? `Not installed — ${error}` : 'Not installed';
|
|
115
|
+
}
|
|
116
|
+
const parts = [String(row.version || '').trim() || 'installed (version unknown)'];
|
|
117
|
+
const path = String(row.path || '').trim();
|
|
118
|
+
if (path) parts.push(path);
|
|
119
|
+
const method = String(row.installMethod || '').trim();
|
|
120
|
+
if (method) parts.push(method);
|
|
121
|
+
if (row.updateAvailable === true) {
|
|
122
|
+
const latest = String(row.latestVersion || '').trim();
|
|
123
|
+
parts.push(latest ? `update available (${latest})` : 'update available');
|
|
124
|
+
} else if (row.updateAvailable === false) {
|
|
125
|
+
parts.push('up to date');
|
|
126
|
+
}
|
|
127
|
+
return parts.join(' · ');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function cliRowState(row) {
|
|
131
|
+
if (!row) return 'error';
|
|
132
|
+
if (!row.checkedAt) return 'pending';
|
|
133
|
+
if (row.installed === true) return 'active';
|
|
134
|
+
return String(row.error || '').trim() ? 'error' : 'unconfigured';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The line under the row: why a provider cannot be installed from here, and the
|
|
139
|
+
* doctor warnings verbatim (they are the CLI's own words, not a paraphrase).
|
|
140
|
+
*/
|
|
141
|
+
export function cliRowNote(row) {
|
|
142
|
+
if (!row) return '';
|
|
143
|
+
const parts = [];
|
|
144
|
+
const blocked = String(row.blockedReason || '').trim();
|
|
145
|
+
if (blocked) parts.push(blocked);
|
|
146
|
+
const warnings = Array.isArray(row.doctor?.warnings) ? row.doctor.warnings : [];
|
|
147
|
+
for (const warning of warnings) {
|
|
148
|
+
const text = String(warning || '').trim();
|
|
149
|
+
if (text) parts.push(text);
|
|
150
|
+
}
|
|
151
|
+
if (row.doctor?.npmGlobalNotWritable === true && row.commands?.migrate) parts.push(MIGRATE_NOTE);
|
|
152
|
+
return parts.join(' ');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Which action buttons this row offers, in the order they are rendered. */
|
|
156
|
+
export function cliRowActions(row) {
|
|
157
|
+
const commands = row?.commands && typeof row.commands === 'object' ? row.commands : {};
|
|
158
|
+
return INSTALL_ACTIONS.filter((action) => {
|
|
159
|
+
if (!commands[action]) return false;
|
|
160
|
+
// The migration is offered only in the case it fixes: an npm-global install
|
|
161
|
+
// that can no longer update itself. Otherwise it would install a second
|
|
162
|
+
// Claude that silently shadows the first.
|
|
163
|
+
if (action === 'migrate') return row?.doctor?.npmGlobalNotWritable === true;
|
|
164
|
+
return true;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function installLogSummary(install) {
|
|
169
|
+
const state = normalizeInstallState(install?.state);
|
|
170
|
+
const word = STATE_WORDS[state] || 'Install';
|
|
171
|
+
const command = String(install?.command || '').trim();
|
|
172
|
+
return command ? `${word} · ${command}` : `${word} · install log`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function setHidden(element, hidden) {
|
|
176
|
+
if (element) element.hidden = !!hidden;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function setDisabled(element, disabled) {
|
|
180
|
+
if (element) element.disabled = !!disabled;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function el(providerId, suffix) {
|
|
184
|
+
return document.getElementById(`cli-${providerId}-${suffix}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function currentInstall() {
|
|
188
|
+
return cliState.install || EMPTY_INSTALL;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** True while any install owns the host: the single flight is relay-wide. */
|
|
192
|
+
export function isCliInstallBusy() {
|
|
193
|
+
return normalizeInstallState(currentInstall().state) === 'running';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function renderCliInstallRow(providerId) {
|
|
197
|
+
const section = document.getElementById(`cli-${providerId}-section`);
|
|
198
|
+
if (!section) return;
|
|
199
|
+
const row = cliState.providers?.[providerId] || null;
|
|
200
|
+
const install = currentInstall();
|
|
201
|
+
const state = normalizeInstallState(install.state);
|
|
202
|
+
const mine = normalizeProviderId(install.providerId) === providerId;
|
|
203
|
+
const busy = state === 'running';
|
|
204
|
+
|
|
205
|
+
const label = el(providerId, 'label');
|
|
206
|
+
if (label && String(row?.label || '').trim()) label.textContent = String(row.label).trim();
|
|
207
|
+
|
|
208
|
+
const status = el(providerId, 'status');
|
|
209
|
+
if (status) {
|
|
210
|
+
status.textContent = localErrors[providerId] || cliRowText(row);
|
|
211
|
+
status.dataset.state = localErrors[providerId] ? 'error' : cliRowState(row);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const offered = cliRowActions(row);
|
|
215
|
+
for (const action of INSTALL_ACTIONS) {
|
|
216
|
+
const button = el(providerId, `${action}-btn`);
|
|
217
|
+
if (!button) continue;
|
|
218
|
+
setHidden(button, !offered.includes(action));
|
|
219
|
+
button.textContent = ACTION_BUTTON_LABELS[action];
|
|
220
|
+
setDisabled(button, busy || installInFlight || Boolean(statusRefresh));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Cancel while this row's install runs; Dismiss once it has settled — the
|
|
224
|
+
// same route, which is also how the terminal state is cleared.
|
|
225
|
+
setHidden(el(providerId, 'cancel-btn'), !(mine && busy));
|
|
226
|
+
setDisabled(el(providerId, 'cancel-btn'), cancelInFlight);
|
|
227
|
+
const settled = mine && (state === 'success' || state === 'error' || state === 'cancelled');
|
|
228
|
+
setHidden(el(providerId, 'dismiss-btn'), !settled);
|
|
229
|
+
setDisabled(el(providerId, 'dismiss-btn'), cancelInFlight);
|
|
230
|
+
|
|
231
|
+
const note = el(providerId, 'note');
|
|
232
|
+
const noteText = mine && install.error && state === 'error'
|
|
233
|
+
? String(install.error).trim()
|
|
234
|
+
: cliRowNote(row);
|
|
235
|
+
if (note) {
|
|
236
|
+
note.textContent = noteText;
|
|
237
|
+
note.dataset.state = (mine && state === 'error') ? 'error' : '';
|
|
238
|
+
}
|
|
239
|
+
setHidden(note, !noteText);
|
|
240
|
+
|
|
241
|
+
const details = el(providerId, 'log-details');
|
|
242
|
+
const showLog = mine && state !== 'idle';
|
|
243
|
+
setHidden(details, !showLog);
|
|
244
|
+
const summary = el(providerId, 'log-summary');
|
|
245
|
+
if (summary) summary.textContent = installLogSummary(install);
|
|
246
|
+
const logBox = el(providerId, 'log');
|
|
247
|
+
// Keyed on the session, not on logSeq alone: logSeq never resets, so a new
|
|
248
|
+
// install would otherwise redraw nothing and leave the previous log on screen.
|
|
249
|
+
const logKey = `${providerId}|${install.startedAt || ''}|${install.logSeq || 0}`;
|
|
250
|
+
if (logBox && showLog && logKey !== renderedLogKey) {
|
|
251
|
+
// textContent, never innerHTML: this is raw host process output.
|
|
252
|
+
logBox.textContent = String(install.log || '');
|
|
253
|
+
logBox.scrollTop = logBox.scrollHeight;
|
|
254
|
+
renderedLogKey = logKey;
|
|
255
|
+
}
|
|
256
|
+
if (details && showLog && autoExpandProviderId === providerId) {
|
|
257
|
+
details.open = true;
|
|
258
|
+
autoExpandProviderId = '';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function renderCliInstallSections() {
|
|
263
|
+
for (const providerId of CLI_PROVIDER_IDS) renderCliInstallRow(providerId);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// A broadcast can outrun the HTTP response that triggered it (the first log
|
|
267
|
+
// chunk lands before POST /api/cli/install returns its "running" snapshot).
|
|
268
|
+
// Within one install session (same startedAt) the sequence only moves forward,
|
|
269
|
+
// so an older snapshot must never regress the log or the state.
|
|
270
|
+
function isStaleInstallPayload(current, incoming) {
|
|
271
|
+
if (!current || !incoming) return false;
|
|
272
|
+
// The service never leaves `running` for `idle` — a cancel lands on
|
|
273
|
+
// `cancelled` — so an idle snapshot arriving against a running one is a
|
|
274
|
+
// status read that started before this install did.
|
|
275
|
+
if (normalizeInstallState(current.state) === 'running'
|
|
276
|
+
&& normalizeInstallState(incoming.state) === 'idle') return true;
|
|
277
|
+
// Different sessions: ISO stamps compare lexicographically, and an older one
|
|
278
|
+
// can only have snapshotted a previous install.
|
|
279
|
+
if (current.startedAt && incoming.startedAt && incoming.startedAt < current.startedAt) return true;
|
|
280
|
+
if (!current.startedAt || current.startedAt !== incoming.startedAt) return false;
|
|
281
|
+
const currentSeq = Number(current.logSeq || 0);
|
|
282
|
+
const incomingSeq = Number(incoming.logSeq || 0);
|
|
283
|
+
if (incomingSeq < currentSeq) return true;
|
|
284
|
+
// Equal sequences: a settled install must not be reopened by the slower
|
|
285
|
+
// response that snapshotted its start.
|
|
286
|
+
return incomingSeq === currentSeq
|
|
287
|
+
&& normalizeInstallState(current.state) !== 'running'
|
|
288
|
+
&& normalizeInstallState(incoming.state) === 'running';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Single entry point for every payload (GET response, POST response, socket
|
|
293
|
+
* broadcast). Both keys are optional so an error body that only carries one of
|
|
294
|
+
* them cannot blank the other half of the panel.
|
|
295
|
+
*/
|
|
296
|
+
export function applyCliInstallState(payload) {
|
|
297
|
+
if (!payload || typeof payload !== 'object') return cliState;
|
|
298
|
+
const providers = (payload.providers && typeof payload.providers === 'object')
|
|
299
|
+
? payload.providers
|
|
300
|
+
: cliState.providers;
|
|
301
|
+
const incoming = (payload.install && typeof payload.install === 'object') ? payload.install : null;
|
|
302
|
+
if (incoming && isStaleInstallPayload(cliState.install, incoming)) {
|
|
303
|
+
cliState = { ...cliState, providers };
|
|
304
|
+
renderCliInstallSections();
|
|
305
|
+
return cliState;
|
|
306
|
+
}
|
|
307
|
+
const install = incoming
|
|
308
|
+
? { ...EMPTY_INSTALL, ...incoming, state: normalizeInstallState(incoming.state) }
|
|
309
|
+
: cliState.install;
|
|
310
|
+
cliState = { providers, install };
|
|
311
|
+
|
|
312
|
+
const signature = `${install.providerId || ''}|${install.startedAt || ''}|${install.state}`;
|
|
313
|
+
if (signature !== lastInstallSignature) {
|
|
314
|
+
lastInstallSignature = signature;
|
|
315
|
+
localErrors = {};
|
|
316
|
+
// Only a failure opens the log on its own; a running install stays folded
|
|
317
|
+
// until the user asks for it.
|
|
318
|
+
if (install.state === 'error') autoExpandProviderId = normalizeProviderId(install.providerId);
|
|
319
|
+
}
|
|
320
|
+
renderCliInstallSections();
|
|
321
|
+
return cliState;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Reads the provider rows, joining a read already in flight rather than
|
|
326
|
+
* dropping the second caller.
|
|
327
|
+
*
|
|
328
|
+
* The joining matters because callers arrive in pairs: the chat "Install Grok
|
|
329
|
+
* CLI" CTA opens the settings modal — which starts a forced refresh — and then
|
|
330
|
+
* opens the confirm sheet in the same tick, and that sheet needs the very rows
|
|
331
|
+
* the refresh is fetching. Returning null to the second caller left it looking
|
|
332
|
+
* at an empty `cliState`, so the first click after the failure that prompted it
|
|
333
|
+
* showed "that CLI has no install command on this host" instead of the sheet.
|
|
334
|
+
*
|
|
335
|
+
* A forced caller only joins a *forced* read: joining a cached one could hand it
|
|
336
|
+
* rows up to the service's 30s TTL out of date, which is exactly what the force
|
|
337
|
+
* exists to avoid.
|
|
338
|
+
*/
|
|
339
|
+
export function refreshCliInstallSections({ force = false } = {}) {
|
|
340
|
+
if (statusRefresh && (statusRefresh.force || !force)) return statusRefresh.promise;
|
|
341
|
+
const previous = statusRefresh?.promise || null;
|
|
342
|
+
// Published before the read starts, so the `finally` below always sees the
|
|
343
|
+
// entry it is meant to clear however early it runs.
|
|
344
|
+
const entry = { force, promise: null };
|
|
345
|
+
statusRefresh = entry;
|
|
346
|
+
entry.promise = (async () => {
|
|
347
|
+
// Never two GETs at once: a forced read that could not join queues behind
|
|
348
|
+
// the one in flight instead of racing it.
|
|
349
|
+
if (previous) await previous.catch(() => {});
|
|
350
|
+
try {
|
|
351
|
+
const payload = await getCliStatus({ force });
|
|
352
|
+
if (!payload) {
|
|
353
|
+
for (const providerId of CLI_PROVIDER_IDS) {
|
|
354
|
+
const status = el(providerId, 'status');
|
|
355
|
+
if (!status) continue;
|
|
356
|
+
status.textContent = 'Unable to load the CLI status.';
|
|
357
|
+
status.dataset.state = 'error';
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
return applyCliInstallState(payload);
|
|
362
|
+
} finally {
|
|
363
|
+
if (statusRefresh === entry) statusRefresh = null;
|
|
364
|
+
renderCliInstallSections();
|
|
365
|
+
}
|
|
366
|
+
})();
|
|
367
|
+
renderCliInstallSections();
|
|
368
|
+
return entry.promise;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The confirm sheet. It names the literal command and the directory it writes
|
|
373
|
+
* into, because `curl … | bash` is a trust decision and the user is entitled to
|
|
374
|
+
* read the thing before it runs. The command shown is the server's own
|
|
375
|
+
* `commands[action].display` — this module never composes one.
|
|
376
|
+
*/
|
|
377
|
+
export async function confirmCliInstall(providerId, action) {
|
|
378
|
+
const id = normalizeProviderId(providerId);
|
|
379
|
+
const wanted = normalizeAction(action);
|
|
380
|
+
if (!id || !wanted) return;
|
|
381
|
+
if (isCliInstallBusy()) {
|
|
382
|
+
showTransientRelayNotice('Another CLI install is already running on the relay host.');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// The chat-error CTA can fire before the settings panel has ever loaded, so
|
|
386
|
+
// the sheet reads a fresh status rather than an empty one.
|
|
387
|
+
if (!cliState.providers?.[id]?.checkedAt) await refreshCliInstallSections({ force: true });
|
|
388
|
+
const row = cliState.providers?.[id] || null;
|
|
389
|
+
const command = row?.commands?.[wanted] || null;
|
|
390
|
+
if (!command) {
|
|
391
|
+
const blocked = String(row?.blockedReason || '').trim();
|
|
392
|
+
showTransientRelayNotice(blocked || `${row?.label || 'That CLI'} has no ${wanted} command on this host.`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const label = String(row?.label || 'the CLI').trim();
|
|
396
|
+
const display = String(command.display || '').trim();
|
|
397
|
+
const targetDir = String(command.targetDir || '').trim();
|
|
398
|
+
const migrateNote = wanted === 'migrate' ? `<p>${escHtml(MIGRATE_NOTE)}</p>` : '';
|
|
399
|
+
openSummaryModal({
|
|
400
|
+
title: `${ACTION_TITLES[wanted]} ${label}`,
|
|
401
|
+
subtitle: label,
|
|
402
|
+
kind: 'cli-install',
|
|
403
|
+
bodyHtml: `
|
|
404
|
+
<p>Run this on the relay host, as the relay user and without sudo?</p>
|
|
405
|
+
<pre class="cli-install-command">${escHtml(display)}</pre>
|
|
406
|
+
${targetDir ? `<p>Installs into <code>${escHtml(targetDir)}</code>.</p>` : ''}
|
|
407
|
+
${migrateNote}
|
|
408
|
+
<p>Sessions already running keep the binary they launched with.</p>
|
|
409
|
+
<div class="summary-modal-actions">
|
|
410
|
+
<button class="chat-title-action-btn" type="button" onclick="runCliInstall('${escHtml(id)}', '${escHtml(wanted)}')">${escHtml(ACTION_TITLES[wanted])}</button>
|
|
411
|
+
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
|
|
412
|
+
</div>
|
|
413
|
+
`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function runCliInstall(providerId, action) {
|
|
418
|
+
const id = normalizeProviderId(providerId);
|
|
419
|
+
const wanted = normalizeAction(action);
|
|
420
|
+
if (!id || !wanted || installInFlight) return;
|
|
421
|
+
installInFlight = true;
|
|
422
|
+
setSummaryModalLoading(true);
|
|
423
|
+
renderCliInstallSections();
|
|
424
|
+
try {
|
|
425
|
+
const payload = await startCliInstall(id, wanted);
|
|
426
|
+
closeSummaryModal();
|
|
427
|
+
applyCliInstallState(payload);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
closeSummaryModal();
|
|
430
|
+
localErrors = { [id]: String(error?.message || 'Failed to start the CLI install.') };
|
|
431
|
+
// The refusal body carries the current rows too, but it arrived as a
|
|
432
|
+
// throw; re-read so the row is not left describing a state that changed.
|
|
433
|
+
await refreshCliInstallSections().catch(() => {});
|
|
434
|
+
} finally {
|
|
435
|
+
installInFlight = false;
|
|
436
|
+
setSummaryModalLoading(false);
|
|
437
|
+
renderCliInstallSections();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Cancel a running install, and the dismiss path for a settled one. */
|
|
442
|
+
export async function cancelCliInstallRun() {
|
|
443
|
+
if (cancelInFlight) return;
|
|
444
|
+
cancelInFlight = true;
|
|
445
|
+
renderCliInstallSections();
|
|
446
|
+
try {
|
|
447
|
+
applyCliInstallState(await cancelCliInstall());
|
|
448
|
+
} catch (error) {
|
|
449
|
+
showTransientRelayNotice(String(error?.message || 'Failed to cancel the CLI install.'));
|
|
450
|
+
await refreshCliInstallSections().catch(() => {});
|
|
451
|
+
} finally {
|
|
452
|
+
cancelInFlight = false;
|
|
453
|
+
renderCliInstallSections();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copy buttons for rendered code blocks. Selecting a long <pre> by hand on a
|
|
2
|
+
// phone is miserable, so every fenced block gets a small overlay button that
|
|
3
|
+
// copies the block's text in one tap.
|
|
4
|
+
//
|
|
5
|
+
// Buttons are injected by the same enhancement passes that run hljs (the
|
|
6
|
+
// rendered nodes are already being touched there), while the click handling
|
|
7
|
+
// is one document-level delegated listener — transcript bubbles are re-rendered
|
|
8
|
+
// and streamed-patched constantly, so per-button listeners would die with the
|
|
9
|
+
// first re-render.
|
|
10
|
+
|
|
11
|
+
const BUTTON_CLASS = 'code-copy-btn';
|
|
12
|
+
const RESET_MS = 1500;
|
|
13
|
+
|
|
14
|
+
async function copyText(text) {
|
|
15
|
+
// Clipboard API first (requires a secure context — localhost and the
|
|
16
|
+
// cloudflared tunnel both qualify), execCommand as the plain-http fallback.
|
|
17
|
+
try {
|
|
18
|
+
if (navigator.clipboard?.writeText) {
|
|
19
|
+
await navigator.clipboard.writeText(text);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
} catch {}
|
|
23
|
+
try {
|
|
24
|
+
const holder = document.createElement('textarea');
|
|
25
|
+
holder.value = text;
|
|
26
|
+
holder.setAttribute('readonly', '');
|
|
27
|
+
holder.style.position = 'fixed';
|
|
28
|
+
holder.style.opacity = '0';
|
|
29
|
+
document.body.appendChild(holder);
|
|
30
|
+
holder.select();
|
|
31
|
+
const copied = document.execCommand('copy');
|
|
32
|
+
holder.remove();
|
|
33
|
+
return copied;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let delegatedBound = false;
|
|
40
|
+
|
|
41
|
+
function bindDelegatedClick() {
|
|
42
|
+
if (delegatedBound || typeof document === 'undefined') return;
|
|
43
|
+
delegatedBound = true;
|
|
44
|
+
document.addEventListener('click', async (event) => {
|
|
45
|
+
const btn = event.target?.closest?.(`.${BUTTON_CLASS}`);
|
|
46
|
+
if (!btn) return;
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
event.stopPropagation();
|
|
49
|
+
// The button lives inside the <pre> but the copied text comes from the
|
|
50
|
+
// <code> element, so the button's own label never rides along.
|
|
51
|
+
const code = btn.parentElement?.querySelector('code');
|
|
52
|
+
const copied = await copyText(code ? code.textContent : '');
|
|
53
|
+
btn.textContent = copied ? '✓ Copied' : 'Copy failed';
|
|
54
|
+
btn.classList.toggle('is-copied', copied);
|
|
55
|
+
clearTimeout(btn.__copyResetTimer);
|
|
56
|
+
btn.__copyResetTimer = setTimeout(() => {
|
|
57
|
+
btn.textContent = 'Copy';
|
|
58
|
+
btn.classList.remove('is-copied');
|
|
59
|
+
}, RESET_MS);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Add a copy button to every `<pre><code>` block under `root` that doesn't
|
|
65
|
+
* have one yet. Idempotent, so the streaming re-enhancement passes can call
|
|
66
|
+
* it repeatedly on the same nodes.
|
|
67
|
+
*/
|
|
68
|
+
export function attachCodeCopyButtons(root) {
|
|
69
|
+
if (!(root instanceof Element)) return;
|
|
70
|
+
bindDelegatedClick();
|
|
71
|
+
const scopes = root.matches?.('pre') ? [root] : root.querySelectorAll('pre');
|
|
72
|
+
for (const pre of scopes) {
|
|
73
|
+
if (!pre.querySelector('code')) continue;
|
|
74
|
+
if (pre.querySelector(`:scope > .${BUTTON_CLASS}`)) continue;
|
|
75
|
+
const btn = document.createElement('button');
|
|
76
|
+
btn.type = 'button';
|
|
77
|
+
btn.className = BUTTON_CLASS;
|
|
78
|
+
btn.textContent = 'Copy';
|
|
79
|
+
btn.setAttribute('aria-label', 'Copy code block');
|
|
80
|
+
// The class (not :has()) anchors the absolutely-positioned button, so the
|
|
81
|
+
// rule works on every browser that renders the transcript.
|
|
82
|
+
pre.classList.add('has-code-copy');
|
|
83
|
+
pre.appendChild(btn);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Serialization rules for the per-conversation composer attachment cache.
|
|
2
|
+
// Only already-uploaded attachments are persisted: the draft row stores content
|
|
3
|
+
// hashes, never file bytes, so switching conversations is cheap and reload-safe.
|
|
4
|
+
|
|
5
|
+
import { normalizeDraftTimestampMs, isIncomingDraftTimestampStale } from './conversation-draft-timestamp-utils.mjs';
|
|
6
|
+
|
|
7
|
+
// Attachments added before a conversation exists live under this key and are
|
|
8
|
+
// migrated onto the real conversation id the first time a message is sent.
|
|
9
|
+
export const PENDING_CONVERSATION_KEY = '__new__';
|
|
10
|
+
|
|
11
|
+
export const DRAFT_UPLOAD_MESSAGE_ID = '__draft__';
|
|
12
|
+
|
|
13
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
14
|
+
|
|
15
|
+
export function isSha256(value) {
|
|
16
|
+
return SHA256_PATTERN.test(String(value || '').trim().toLowerCase());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isUploadedAttachment(attachment) {
|
|
20
|
+
if (!attachment) return false;
|
|
21
|
+
if (attachment.uploadState && attachment.uploadState !== 'uploaded') return false;
|
|
22
|
+
return isSha256(attachment.sha256 || attachment.uploaded?.sha256);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function attachmentSha(attachment) {
|
|
26
|
+
return String(attachment?.sha256 || attachment?.uploaded?.sha256 || '').trim().toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reduces composer attachments to the minimal persistable descriptor. Anything
|
|
31
|
+
* still uploading or failed is skipped, because a draft must only ever reference
|
|
32
|
+
* blobs that actually exist on the server.
|
|
33
|
+
*/
|
|
34
|
+
export function serializeDraftAttachments(attachments = [], { max = 6 } = {}) {
|
|
35
|
+
const list = Array.isArray(attachments) ? attachments : [];
|
|
36
|
+
const limit = Math.max(0, Number(max) || 0);
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const rows = [];
|
|
39
|
+
|
|
40
|
+
for (const attachment of list) {
|
|
41
|
+
if (rows.length >= limit) break;
|
|
42
|
+
if (!isUploadedAttachment(attachment)) continue;
|
|
43
|
+
const sha256 = attachmentSha(attachment);
|
|
44
|
+
if (seen.has(sha256)) continue;
|
|
45
|
+
seen.add(sha256);
|
|
46
|
+
const source = attachment.uploaded && typeof attachment.uploaded === 'object' ? attachment.uploaded : attachment;
|
|
47
|
+
rows.push({
|
|
48
|
+
sha256,
|
|
49
|
+
name: String(source.name || attachment.name || '').trim().slice(0, 255) || `upload-${sha256.slice(0, 12)}`,
|
|
50
|
+
type: String(source.type || attachment.type || 'application/octet-stream').trim().toLowerCase().slice(0, 127),
|
|
51
|
+
size: Math.max(0, Number(source.size || attachment.size || 0) || 0),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return rows;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Rebuilds composer records from persisted rows. The server already serves the
|
|
60
|
+
* bytes, so previews point at the content URL instead of an object URL that
|
|
61
|
+
* would have to be revoked.
|
|
62
|
+
*/
|
|
63
|
+
export function hydrateDraftAttachments(rows = [], { contentUrlFor } = {}) {
|
|
64
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
65
|
+
const resolveUrl = typeof contentUrlFor === 'function'
|
|
66
|
+
? contentUrlFor
|
|
67
|
+
: (sha256) => `/api/upload/${sha256}/content`;
|
|
68
|
+
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const hydrated = [];
|
|
71
|
+
|
|
72
|
+
for (const row of list) {
|
|
73
|
+
const sha256 = String(row?.sha256 || '').trim().toLowerCase();
|
|
74
|
+
if (!isSha256(sha256) || seen.has(sha256)) continue;
|
|
75
|
+
seen.add(sha256);
|
|
76
|
+
|
|
77
|
+
const type = String(row?.type || 'application/octet-stream').trim().toLowerCase();
|
|
78
|
+
const name = String(row?.name || '').trim() || `upload-${sha256.slice(0, 12)}`;
|
|
79
|
+
const size = Math.max(0, Number(row?.size || 0) || 0);
|
|
80
|
+
const contentUrl = resolveUrl(sha256);
|
|
81
|
+
const isImage = type.startsWith('image/');
|
|
82
|
+
|
|
83
|
+
hydrated.push({
|
|
84
|
+
id: `draft-${sha256}`,
|
|
85
|
+
name,
|
|
86
|
+
type,
|
|
87
|
+
size,
|
|
88
|
+
sha256,
|
|
89
|
+
file: null,
|
|
90
|
+
isImage,
|
|
91
|
+
// Server-hosted, so there is no object URL to revoke on removal.
|
|
92
|
+
previewUrl: isImage ? contentUrl : '',
|
|
93
|
+
previewUrlIsObjectUrl: false,
|
|
94
|
+
uploadState: 'uploaded',
|
|
95
|
+
uploaded: { sha256, name, type, size, contentUrl },
|
|
96
|
+
error: '',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return hydrated;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function draftAttachmentsEqual(a = [], b = []) {
|
|
104
|
+
const left = serializeDraftAttachments(a, { max: Number.MAX_SAFE_INTEGER });
|
|
105
|
+
const right = serializeDraftAttachments(b, { max: Number.MAX_SAFE_INTEGER });
|
|
106
|
+
if (left.length !== right.length) return false;
|
|
107
|
+
return left.every((row, index) => row.sha256 === right[index].sha256);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Decides whether a pushed draft update should replace local composer state.
|
|
112
|
+
* Local echoes and out-of-order updates are ignored so a slow broadcast cannot
|
|
113
|
+
* resurrect an attachment the user just removed.
|
|
114
|
+
*/
|
|
115
|
+
export function mergeDraftAttachmentUpdate({
|
|
116
|
+
existing = [],
|
|
117
|
+
incoming = [],
|
|
118
|
+
existingUpdatedAt = null,
|
|
119
|
+
incomingUpdatedAt = null,
|
|
120
|
+
isLocalEcho = false,
|
|
121
|
+
} = {}) {
|
|
122
|
+
if (isLocalEcho) {
|
|
123
|
+
return { attachments: existing, changed: false, reason: 'local-echo' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const stale = isIncomingDraftTimestampStale({
|
|
127
|
+
existingMs: normalizeDraftTimestampMs(existingUpdatedAt),
|
|
128
|
+
incomingMs: normalizeDraftTimestampMs(incomingUpdatedAt),
|
|
129
|
+
});
|
|
130
|
+
if (stale) {
|
|
131
|
+
return { attachments: existing, changed: false, reason: 'stale' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (draftAttachmentsEqual(existing, incoming)) {
|
|
135
|
+
return { attachments: existing, changed: false, reason: 'unchanged' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { attachments: incoming, changed: true, reason: 'applied' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function parseDraftAttachmentsColumn(value) {
|
|
142
|
+
if (Array.isArray(value)) return value;
|
|
143
|
+
const raw = String(value || '').trim();
|
|
144
|
+
if (!raw) return [];
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(raw);
|
|
147
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|