Files
crank/apps/ui/js/onboarding.js
T

489 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function() {
var STEP_ORDER = ['operation', 'test', 'publish_operation', 'agent', 'key', 'mcp_connection', 'first_call'];
var state = {
epoch: 0,
workspaceId: null,
snapshot: null,
loading: false,
error: null,
open: false,
dismissed: false,
controller: null,
refreshPromise: null,
refreshQueued: false,
startedRequested: false,
};
var channel = null;
var trigger = null;
var panel = null;
var heading = null;
var subtitle = null;
var collapseButton = null;
var lastFocused = null;
function tKey(key) { return window.t ? window.t(key) : key; }
function currentWorkspace() { return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; }
function preferenceKey() { return 'crank_onboarding_ui:' + (state.workspaceId || 'none'); }
function safePreference() {
try { return JSON.parse(localStorage.getItem(preferenceKey())) || {}; } catch (_error) { return {}; }
}
function writePreference(value) {
try { localStorage.setItem(preferenceKey(), JSON.stringify(value)); } catch (_error) {}
}
function updatePreference(value) {
writePreference(Object.assign({}, safePreference(), value));
}
function injectStylesheet() {
if (document.querySelector('link[data-crank-onboarding-css]')) return;
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = (window.APP_BASE || '/') + 'css/onboarding.css';
link.dataset.crankOnboardingCss = 'true';
document.head.appendChild(link);
}
function button(label, testId, handler, className) {
var node = document.createElement('button');
node.type = 'button';
node.textContent = label;
if (testId) node.dataset.testid = testId;
node.className = className || 'onboarding-icon-button';
node.addEventListener('click', handler);
return node;
}
function normalizeStep(raw) {
var id = raw.id || raw.kind;
if (id === 'publish') id = 'publish_operation';
if (id === 'connection') id = 'mcp_connection';
var completed = raw.completed === true || raw.status === 'complete';
return {
id: id,
completed: completed,
status: raw.status || (completed ? 'complete' : 'pending'),
actionCode: raw.action_code || raw.action || '',
reasonCode: raw.reason_code || '',
};
}
function normalizedSteps() {
var raw = state.snapshot && Array.isArray(state.snapshot.steps) ? state.snapshot.steps : [];
var mapped = raw.map(normalizeStep);
return STEP_ORDER.map(function(id) {
return mapped.find(function(item) { return item.id === id; }) || { id: id, completed: false, status: 'pending', actionCode: '', reasonCode: '' };
});
}
function completedCount() { return normalizedSteps().filter(function(step) { return step.completed; }).length; }
function currentStep(steps) {
return steps.find(function(step) { return !step.completed && step.status === 'current'; })
|| steps.find(function(step) { return !step.completed; }) || null;
}
function stepLabel(id) { return tKey('onboarding.step.' + id); }
function actionLabel(id) { return tKey('onboarding.action.' + id); }
function deepLink(step) {
var snapshot = state.snapshot || {};
var returnTo = encodeURIComponent(window.location.pathname + window.location.search);
if (step.id === 'operation') return '/wizard/?onboarding=1&return=' + returnTo;
if (step.id === 'test' || step.id === 'publish_operation') {
if (!snapshot.operation_id) return '/wizard/?onboarding=1&return=' + returnTo;
return '/wizard/?mode=edit&operationId=' + encodeURIComponent(snapshot.operation_id)
+ '&onboarding=1&step=5&return=' + returnTo;
}
if (step.id === 'agent') {
return '/agents?onboarding=1&action=create&operationId=' + encodeURIComponent(snapshot.operation_id || '')
+ '&operationVersion=' + encodeURIComponent(snapshot.operation_version || '') + '&return=' + returnTo;
}
if (step.id === 'key' || step.id === 'mcp_connection') {
return '/api-keys?onboarding=1&action=create&agentId=' + encodeURIComponent(snapshot.agent_id || '')
+ '&agentRevision=' + encodeURIComponent(snapshot.catalog_revision || '') + '&return=' + returnTo;
}
if (step.id === 'first_call' && snapshot.first_call && snapshot.first_call.log_id) {
return '/logs?log_id=' + encodeURIComponent(snapshot.first_call.log_id);
}
return '/logs?agent_id=' + encodeURIComponent(snapshot.agent_id || '');
}
function navigate(step) { window.location.href = deepLink(step); }
function recordStarted() {
if (state.startedRequested || !state.snapshot || state.snapshot.completed) return;
state.startedRequested = true;
recordPresentation('started', { stable: true }).then(function(response) {
if (!response) state.startedRequested = false;
});
}
function setOpen(open) {
state.open = open;
panel.hidden = !open;
trigger.setAttribute('aria-expanded', String(open));
if (open) {
lastFocused = document.activeElement;
render();
var focusTarget = panel.querySelector('[aria-current="step"] .onboarding-step-action') || panel.querySelector('button');
if (focusTarget) focusTarget.focus();
recordStarted();
} else if (lastFocused && typeof lastFocused.focus === 'function') {
lastFocused.focus();
}
}
function render() {
if (!trigger || !panel) return;
var count = completedCount();
trigger.textContent = state.dismissed
? tKey('onboarding.resume')
: tKey('onboarding.title') + ' · ' + count + '/7';
trigger.setAttribute('aria-label', state.dismissed ? tKey('onboarding.resume') : trigger.textContent);
if (heading) heading.textContent = tKey('onboarding.title');
if (subtitle) subtitle.textContent = tKey('onboarding.subtitle');
if (collapseButton) collapseButton.setAttribute('aria-label', tKey('onboarding.collapse'));
var body = panel.querySelector('.onboarding-body');
var hadActionFocus = state.open
&& document.activeElement
&& body.contains(document.activeElement)
&& document.activeElement.classList.contains('onboarding-step-action');
body.replaceChildren();
var status = document.createElement('div');
status.className = 'onboarding-status';
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
status.id = 'crank-onboarding-progress';
status.textContent = state.loading ? tKey('onboarding.loading') : tKey('onboarding.progress').replace('{count}', String(count));
body.appendChild(status);
if (state.error) {
var error = document.createElement('div');
error.className = 'onboarding-error';
error.setAttribute('role', 'alert');
var message = document.createElement('div');
message.textContent = tKey('onboarding.error');
error.appendChild(message);
var errorCode = state.error.code
|| (state.error.payload && state.error.payload.error && (state.error.payload.error.code || state.error.payload.error.error_code))
|| (state.error.payload && (state.error.payload.code || state.error.payload.error_code));
var support = [
errorCode ? tKey('onboarding.error_code') + ': ' + errorCode : '',
state.error.requestId ? tKey('onboarding.request_id') + ': ' + state.error.requestId : '',
state.error.traceId ? tKey('onboarding.trace_id') + ': ' + state.error.traceId : '',
].filter(Boolean);
if (support.length) {
var supportIds = document.createElement('div');
supportIds.className = 'onboarding-error-ids';
supportIds.textContent = support.join(' · ');
error.appendChild(supportIds);
}
error.appendChild(button(tKey('onboarding.retry'), 'onboarding-retry', refresh, 'onboarding-step-action'));
body.appendChild(error);
if (state.focusError && state.open) {
state.focusError = false;
var retry = error.querySelector('[data-testid="onboarding-retry"]');
if (retry) retry.focus();
}
return;
}
if (!state.snapshot) return;
var steps = normalizedSteps();
var active = currentStep(steps);
if (state.snapshot.completed || !active) {
var complete = document.createElement('div');
complete.className = 'onboarding-completion';
complete.dataset.testid = 'onboarding-completion';
complete.textContent = tKey('onboarding.completed');
var firstCall = state.snapshot.first_call;
if (firstCall) {
var evidence = document.createElement('dl');
evidence.className = 'onboarding-first-call-evidence';
[
[tKey('onboarding.tool'), firstCall.tool_name],
[tKey('onboarding.timestamp'), firstCall.occurred_at],
[tKey('onboarding.request_id'), firstCall.request_id],
[tKey('onboarding.trace_id'), firstCall.trace_id],
].forEach(function(entry) {
if (!entry[1]) return;
var term = document.createElement('dt');
term.textContent = entry[0];
var value = document.createElement('dd');
value.textContent = entry[1];
evidence.appendChild(term);
evidence.appendChild(value);
});
complete.appendChild(evidence);
if (firstCall.log_id) {
var link = document.createElement('a');
link.href = '/logs?log_id=' + encodeURIComponent(firstCall.log_id);
link.textContent = tKey('onboarding.open_invocation');
complete.appendChild(link);
}
}
body.appendChild(complete);
} else {
var list = document.createElement('ol');
list.className = 'onboarding-list';
list.setAttribute('aria-label', tKey('onboarding.progress_label'));
list.setAttribute('aria-describedby', 'crank-onboarding-progress');
steps.forEach(function(step, index) {
var item = document.createElement('li');
item.className = 'onboarding-step' + (step.completed ? ' is-complete' : '') + (step.status === 'regressed' ? ' is-regressed' : '');
if (step === active) item.setAttribute('aria-current', 'step');
var marker = document.createElement('span');
marker.className = 'onboarding-step-marker';
marker.textContent = step.completed ? '✓' : String(index + 1);
marker.setAttribute('aria-hidden', 'true');
var content = document.createElement('div');
var title = document.createElement('div');
title.className = 'onboarding-step-title';
title.textContent = stepLabel(step.id);
content.appendChild(title);
if (step === active) {
var reason = document.createElement('div');
reason.className = 'onboarding-step-reason';
reason.textContent = tKey(step.status === 'regressed' ? 'onboarding.regressed' : 'onboarding.action_needed');
content.appendChild(reason);
content.appendChild(button(actionLabel(step.id), null, function() { navigate(step); }, 'onboarding-step-action'));
}
item.appendChild(marker);
item.appendChild(content);
list.appendChild(item);
});
body.appendChild(list);
}
var footer = document.createElement('div');
footer.className = 'onboarding-footer';
footer.appendChild(button(tKey('onboarding.refresh'), 'onboarding-refresh', refresh, 'onboarding-link-button'));
footer.appendChild(button(tKey('onboarding.dismiss'), 'onboarding-dismiss', dismiss, 'onboarding-link-button'));
body.appendChild(footer);
if (hadActionFocus) {
var replacementAction = panel.querySelector('[aria-current="step"] .onboarding-step-action');
if (replacementAction) replacementAction.focus();
}
}
function refresh() {
var workspace = currentWorkspace();
var workspaceId = workspace ? workspace.id : null;
if (state.refreshPromise && workspaceId === state.workspaceId) {
state.refreshQueued = true;
return state.refreshPromise;
}
if (workspaceId !== state.workspaceId) state.refreshQueued = false;
var promise = performRefresh(workspaceId);
state.refreshPromise = promise;
promise.finally(function() {
if (state.refreshPromise !== promise) return;
state.refreshPromise = null;
if (state.refreshQueued) {
state.refreshQueued = false;
refresh();
}
});
return promise;
}
async function performRefresh(workspaceId) {
var workspaceChanged = workspaceId !== state.workspaceId;
var epoch = ++state.epoch;
if (state.controller) state.controller.abort();
state.controller = typeof AbortController === 'function' ? new AbortController() : null;
state.workspaceId = workspaceId;
if (workspaceChanged) {
state.dismissed = Boolean(safePreference().dismissed);
state.startedRequested = false;
if (state.open) setOpen(false);
}
state.loading = true;
state.error = null;
state.focusError = false;
render();
if (!workspaceId || !window.CrankApi || typeof window.CrankApi.getOnboarding !== 'function') {
state.loading = false;
state.error = new Error(tKey('onboarding.error'));
render();
return;
}
try {
var snapshot = await window.CrankApi.getOnboarding(workspaceId);
if (epoch !== state.epoch || workspaceId !== (currentWorkspace() && currentWorkspace().id)) return;
state.snapshot = snapshot;
var preference = safePreference();
if (window.location.pathname === '/'
&& !preference.opened
&& !preference.dismissed
&& !snapshot.completed) {
updatePreference({ opened: true });
setOpen(true);
}
renderDeepLinkRecovery(snapshot);
} catch (error) {
if (epoch !== state.epoch) return;
state.error = error;
state.focusError = state.open;
} finally {
if (epoch === state.epoch) {
state.loading = false;
render();
}
}
}
async function recordPresentation(eventName, options) {
if (!state.snapshot || !state.workspaceId || !window.CrankApi.recordOnboardingEvent) return;
var expectedRevision = Number(state.snapshot.revision || 0);
var expectedEpoch = state.epoch;
var expectedWorkspaceId = state.workspaceId;
var key = options && options.stable
? ['ui', eventName, state.workspaceId, 'v1'].join(':')
: ['ui', eventName, state.workspaceId, expectedRevision].join(':');
try {
var response = await window.CrankApi.recordOnboardingEvent(state.workspaceId, {
event: eventName,
idempotency_key: key,
expected_revision: expectedRevision,
}, options && options.keepalive ? { keepalive: true } : null);
var currentRevision = Number(state.snapshot && state.snapshot.revision || 0);
if (response
&& expectedEpoch === state.epoch
&& expectedWorkspaceId === state.workspaceId
&& expectedWorkspaceId === (currentWorkspace() && currentWorkspace().id)
&& currentRevision === expectedRevision) {
state.snapshot = response;
render();
}
return response;
} catch (error) {
if (error.status === 409) await refresh();
return null;
}
}
async function dismiss() {
updatePreference({ dismissed: true, opened: true });
state.dismissed = true;
setOpen(false);
render();
await recordPresentation('dismissed');
await recordPresentation('abandoned', { stable: true });
}
function resume() {
updatePreference({ dismissed: false, opened: true });
state.dismissed = false;
setOpen(true);
recordPresentation('resumed');
}
function renderDeepLinkRecovery(snapshot) {
var params = new URLSearchParams(window.location.search);
if (params.get('onboarding') !== '1') return;
var stale = false;
if (window.location.pathname === '/agents') {
var expectedOperation = params.get('operationId') || '';
var expectedVersion = Number(params.get('operationVersion') || 0);
stale = Boolean(expectedOperation) && (
snapshot.operation_id !== expectedOperation
|| (expectedVersion && snapshot.operation_version !== expectedVersion)
);
} else if (window.location.pathname === '/api-keys') {
var expectedAgent = params.get('agentId') || '';
var expectedRevision = Number(params.get('agentRevision') || 0);
stale = Boolean(expectedAgent) && (
snapshot.agent_id !== expectedAgent
|| (expectedRevision && snapshot.catalog_revision !== expectedRevision)
);
}
var existing = document.getElementById('onboarding-deep-link-stale');
if (!stale) {
if (existing) existing.remove();
return;
}
if (existing) return;
var recovery = document.createElement('div');
recovery.id = 'onboarding-deep-link-stale';
recovery.className = 'onboarding-deep-link-stale';
recovery.setAttribute('role', 'alert');
recovery.dataset.testid = 'onboarding-deep-link-stale';
var message = document.createElement('div');
message.textContent = tKey('onboarding.deep_link_stale');
recovery.appendChild(message);
recovery.appendChild(button(tKey('onboarding.reselect'), 'onboarding-reselect', async function() {
var resetButton = this;
resetButton.disabled = true;
try {
if (window.CrankApi && typeof window.CrankApi.resetOnboardingSelection === 'function') {
await window.CrankApi.resetOnboardingSelection(state.workspaceId, Number(snapshot.revision || 0));
}
var clean = new URLSearchParams(window.location.search);
['onboarding', 'action', 'operationId', 'operationVersion', 'agentId', 'agentRevision', 'return'].forEach(function(key) {
clean.delete(key);
});
window.location.replace(window.location.pathname + (clean.toString() ? '?' + clean.toString() : ''));
} catch (error) {
resetButton.disabled = false;
state.error = error;
if (!state.open) setOpen(true);
render();
}
}, 'onboarding-step-action'));
document.body.appendChild(recovery);
}
function mount() {
if (document.getElementById('crank-onboarding-root')) return;
injectStylesheet();
var root = document.createElement('div');
root.id = 'crank-onboarding-root';
trigger = button(tKey('onboarding.title'), 'onboarding-trigger', function() {
if (state.dismissed) {
resume();
return;
}
setOpen(!state.open);
}, 'onboarding-trigger');
trigger.setAttribute('aria-controls', 'crank-onboarding-panel');
trigger.setAttribute('aria-expanded', 'false');
panel = document.createElement('aside');
panel.id = 'crank-onboarding-panel';
panel.className = 'onboarding-panel';
panel.dataset.testid = 'onboarding-checklist';
panel.setAttribute('aria-labelledby', 'crank-onboarding-title');
panel.hidden = true;
var head = document.createElement('div');
head.className = 'onboarding-head';
var headingWrap = document.createElement('div');
heading = document.createElement('h2');
heading.id = 'crank-onboarding-title';
heading.className = 'onboarding-title';
heading.textContent = tKey('onboarding.title');
subtitle = document.createElement('p');
subtitle.className = 'onboarding-subtitle';
subtitle.textContent = tKey('onboarding.subtitle');
headingWrap.appendChild(heading);
headingWrap.appendChild(subtitle);
var actions = document.createElement('div');
actions.className = 'onboarding-head-actions';
collapseButton = button('', 'onboarding-collapse', function() { setOpen(false); }, 'onboarding-icon-button');
collapseButton.setAttribute('aria-label', tKey('onboarding.collapse'));
actions.appendChild(collapseButton);
head.appendChild(headingWrap);
head.appendChild(actions);
var body = document.createElement('div');
body.className = 'onboarding-body';
panel.appendChild(head);
panel.appendChild(body);
root.appendChild(trigger);
root.appendChild(panel);
document.body.appendChild(root);
refresh();
}
function signalRefresh() {
if (channel) channel.postMessage({ type: 'refresh' });
try { localStorage.setItem('crank_onboarding_signal', String(Date.now())); } catch (_error) {}
}
window.CrankOnboarding = { refresh: refresh, signalRefresh: signalRefresh };
document.addEventListener('DOMContentLoaded', async function() {
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
mount();
if (typeof BroadcastChannel === 'function') {
channel = new BroadcastChannel('crank-onboarding');
channel.addEventListener('message', function(event) {
if (event && event.data && event.data.type !== 'refresh') return;
refresh();
});
}
});
window.addEventListener('crank:workspacechange', function() {
state.snapshot = null;
refresh();
});
window.addEventListener('crank:sessionchange', refresh);
window.addEventListener('crank:langchange', function() { render(); });
window.addEventListener('storage', function(event) { if (event.key === 'crank_onboarding_signal') refresh(); });
document.addEventListener('visibilitychange', function() { if (!document.hidden) refresh(); });
document.addEventListener('keydown', function(event) { if (event.key === 'Escape' && state.open) setOpen(false); });
}());