sidebud 0.4.0 → 0.5.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/CHANGELOG.md +70 -0
- package/README.md +37 -0
- package/dist/main.js +5206 -1241
- package/package.json +2 -1
- package/skills/widget-packs/SKILL.md +2 -2
- package/src/manage/guide.md +5 -0
- package/src/manage/manage.css +91 -0
- package/src/manage/manage.js +159 -29
- package/src/manage/packs.js +27 -3
- package/src/manage/settings.js +232 -6
- package/src/manage/setup.js +61 -17
package/src/manage/packs.js
CHANGED
|
@@ -31,6 +31,24 @@ let libraryConfirmed = false;
|
|
|
31
31
|
/** What a tool may do, as the pack pages show it. */
|
|
32
32
|
const accessPill = (access) => `<span class="pill ${access === 'write' ? 'warn' : ''}">${access === 'write' ? 'changes things' : 'reads'}</span>`;
|
|
33
33
|
|
|
34
|
+
/** Whether a pack can control the whole computer; adding or turning one on then needs an explicit OK. */
|
|
35
|
+
const controlsComputer = (overview) => overview?.level === 'computer';
|
|
36
|
+
|
|
37
|
+
/** How much a pack can control, as a badge for lists: red for the whole computer, amber for changes. */
|
|
38
|
+
function accessBadge(overview) {
|
|
39
|
+
if (!overview) return '';
|
|
40
|
+
return `<span class="badge ${{ computer: 'bad', changes: 'warn' }[overview.level] || ''}">${esc(overview.headline)}</span>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The first thing shown about a pack: how much it can control, then each point, most serious first. */
|
|
44
|
+
function accessOverviewHtml(overview) {
|
|
45
|
+
if (!overview) return '';
|
|
46
|
+
return `<div class="card stack access-overview level-${esc(overview.level)}" role="${controlsComputer(overview) ? 'alert' : 'note'}"><div class="row"><h3>What it can control</h3>${accessBadge(overview)}</div><ul class="access-points">${overview.points.map((point) => `<li class="${esc(point.tone)}">${esc(point.text)}</li>`).join('')}</ul></div>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The question asked before a full-control pack is added or turned on. */
|
|
50
|
+
const computerConfirmText = (name, overview) => `${name} can control this whole computer.\n\n${overview.points.filter((point) => point.tone !== 'info').map((point) => `• ${point.text}`).join('\n')}\n\nContinue only if you trust it with everything on this computer.`;
|
|
51
|
+
|
|
34
52
|
/** Who stands behind a pack, and what installing any pack means. Community packs get a warning. */
|
|
35
53
|
function trustNoticeHtml(publisher) {
|
|
36
54
|
const official = publisher?.official;
|
|
@@ -53,14 +71,20 @@ function libraryPackView() {
|
|
|
53
71
|
if (!page) return '<section class="section"><a href="#mcp">← Integrations</a><div class="card muted">Loading…</div></section>';
|
|
54
72
|
const a = page.access;
|
|
55
73
|
const community = page.publisher.source === 'community';
|
|
74
|
+
const computer = controlsComputer(page.overview);
|
|
56
75
|
const installed = page.installedVersion !== null;
|
|
76
|
+
const confirmText = computer
|
|
77
|
+
? community
|
|
78
|
+
? `I trust ${esc(page.publisher.name)} and understand ${esc(page.name)} can control this whole computer`
|
|
79
|
+
: `I understand ${esc(page.name)} can control this whole computer`
|
|
80
|
+
: `I trust ${esc(page.publisher.name)} and have read what this widget can do`;
|
|
57
81
|
const tools = `<table class="tool-table"><thead><tr><th>Tool</th><th>Access</th><th>Used by</th></tr></thead><tbody>${a.tools.map((tool) => `<tr><td>${code(tool.name)}${tool.description ? `<div class="hint">${esc(tool.description)}</div>` : ''}</td><td>${accessPill(tool.access)}</td><td class="hint">${tool.usedBy.map(esc).join(', ') || 'Your agent, when you ask'}</td></tr>`).join('')}</tbody></table><p class="hint">Your computer only ever calls these tools. After installing, you can turn off any of them on the pack's page; a tool that is off is never called by the widget, a control, or your agent.</p>`;
|
|
58
82
|
const runs = a.launch.transport === 'stdio' ? `<p>${code(a.launch.command)}</p><p class="hint">${a.launch.ownServer ? 'A small server that comes with this pack, run with Node.js while the pack is on. Its code is in the pack folder.' : 'A program already on this computer, started while the pack is on.'}${a.launch.env.length ? ` It receives ${a.launch.env.map(code).join(', ')}.` : ''}</p>` : `<p>${code(a.launch.url)}</p><p class="hint">A service on the internet this computer connects to. Keys go only to it.</p>`;
|
|
59
83
|
const keys = `${a.accountSignIn ? '<p>You sign in to an account in this computer\'s browser.</p>' : ''}${a.secrets.map((secret) => `<p>${code(secret.name)} <span class="hint">${esc(secret.description)}</span></p>`).join('')}${!a.accountSignIn && !a.secrets.length ? '<p class="hint">No keys or sign-in.</p>' : ''}<p class="hint">Keys and tokens are saved in this computer's keychain and never shown again.</p>`;
|
|
60
84
|
const action = installed
|
|
61
85
|
? `<a class="button" href="#mcp/${encodeURIComponent(page.id)}">Open its page · installed ${esc(page.installedVersion)}</a>`
|
|
62
|
-
: `${community ? `<label class="confirm-line"><input type="checkbox" data-library-confirm ${libraryConfirmed ? 'checked' : ''}>
|
|
63
|
-
return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(page.name)}</h2><p class="hint">${esc(page.author)} · ${esc(page.license)} · version ${esc(page.version)}${page.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(page.description)}</p></div><span class="badge ${community && !page.publisher.official ? 'warn' : ''}">${page.publisher.official ? `Official · ${esc(page.publisher.official.domain)}` : community ? 'Community' : 'Sidebud'}</span></div>${trustNoticeHtml(page.publisher)}<div class="actions library-install">${action}</div><div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>
|
|
86
|
+
: `${community || computer ? `<label class="confirm-line"><input type="checkbox" data-library-confirm ${libraryConfirmed ? 'checked' : ''}> ${confirmText}</label>` : ''}<button class="primary" data-action="install-library-pack" data-id="${esc(page.slug)}" ${(community || computer) && !libraryConfirmed ? 'disabled' : ''}>${page.needsReinstall ? 'Reinstall' : 'Install'} ${esc(page.name)}</button>`;
|
|
87
|
+
return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(page.name)}</h2><p class="hint">${esc(page.author)} · ${esc(page.license)} · version ${esc(page.version)}${page.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(page.description)}</p></div><span class="badge ${community && !page.publisher.official ? 'warn' : ''}">${page.publisher.official ? `Official · ${esc(page.publisher.official.domain)}` : community ? 'Community' : 'Sidebud'}</span></div>${accessOverviewHtml(page.overview)}${trustNoticeHtml(page.publisher)}${page.deprecated ? `<div class="card"><strong>Deprecated</strong><p class="hint">${esc(page.deprecated)}</p></div>` : ''}<div class="actions library-install">${action}</div><div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>Tools it may call</h3>${tools}</div><div class="card stack"><h3>What it runs</h3>${runs}</div><div class="card stack"><h3>Keys and accounts</h3>${keys}</div><div class="card stack"><h3>Network</h3><p class="hint">${esc(a.network.join(', ') || 'None declared beyond what it runs.')}</p></div>${a.setup.steps.length ? `<div class="card stack"><h3>Setting it up</h3><ol class="plain-steps">${a.setup.steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol>${a.setup.prerequisites.length ? `<p class="hint">Needs: ${a.setup.prerequisites.map(esc).join(', ')}</p>` : ''}</div>` : ''}</div><aside class="pack-preview"><h3>On your phone</h3><p class="hint">${page.hasPreview ? 'Made-up sample data from the pack. Tap the tile to open it.' : 'This pack has no sample data, so its widget is drawn empty.'}</p><div id="library-preview">${libraryPreviewBody(page)}</div></aside></div></section>`;
|
|
64
88
|
}
|
|
65
89
|
|
|
66
90
|
function libraryPreviewBody(page) {
|
|
@@ -281,7 +305,7 @@ function packPageView() {
|
|
|
281
305
|
const controls = page?.controls.length ? `<ul class="plain-list">${page.controls.map((control) => `<li><strong>${esc(control.label)}</strong> ${control.default ? '<span class="pill">shown by default</span>' : ''} ${control.consequential ? '<span class="pill warn">asks first</span>' : ''}<div class="hint">${control.prompt ? `Says: “${esc(control.prompt)}”` : `Runs ${code(control.tool)}`}</div></li>`).join('')}</ul><p class="hint">Each phone chooses which controls appear on the widget.</p>` : '<p class="hint">No controls.</p>';
|
|
282
306
|
const setup = page ? `${page.setup.steps.length ? `<ol class="plain-steps">${page.setup.steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol>` : '<p class="hint">No setup steps.</p>'}${page.setup.prerequisites.length ? `<p class="hint">Needs: ${page.setup.prerequisites.map((item) => `${esc(item.name)}${item.found === null ? '' : item.found ? ' ✓' : ' (not found on this computer)'}`).join(', ')}</p>` : ''}` : '';
|
|
283
307
|
const runs = page ? `<p>${page.launch.transport === 'stdio' ? `Starts ${code(page.launch.command)}${page.ownServer ? ', the small server this pack ships' : ''}${page.launch.env.length ? ` with ${page.launch.env.map(code).join(', ')} set` : ''}.` : `Connects to ${code(page.launch.url)}.`}</p><p class="hint">Pack folder: ${code(page.folder)}. Keys and tokens are masked here and never leave this computer.</p>` : '';
|
|
284
|
-
return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(p.name)}</h2><p class="hint">${esc(p.author)} · ${esc(p.license)} · version ${esc(p.version)}${page?.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(p.description)}</p></div><div class="stack"><span class="badge ${statusClass[p.status.state] || ''}">${esc(statusLabel[p.status.state] || p.status.state)}</span>${p.status.detail ? `<p class="hint">${esc(packStatusDetail(p))}</p>` : ''}</div></div><div class="actions"><button data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Turn off' : 'Turn on'}</button><button class="primary" data-action="setup-pack" data-id="${esc(p.id)}">${p.status.state === 'ok' ? 'Check setup with my agent' : 'Set up with my agent'}</button><button data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button data-action="export-pack" data-id="${esc(p.id)}">Export</button><button data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button
|
|
308
|
+
return `<section class="section pack-page"><a href="#mcp">← Integrations</a><div class="pack-page-head"><div><h2>${esc(p.name)}</h2><p class="hint">${esc(p.author)} · ${esc(p.license)} · version ${esc(p.version)}${page?.homepage ? ` · <a href="${esc(page.homepage)}" target="_blank" rel="noreferrer">Homepage</a>` : ''}</p><p>${esc(p.description)}</p></div><div class="stack"><span class="badge ${statusClass[p.status.state] || ''}">${esc(statusLabel[p.status.state] || p.status.state)}</span>${p.status.detail ? `<p class="hint">${esc(packStatusDetail(p))}</p>` : ''}</div></div><div class="actions"><button data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Turn off' : 'Turn on'}</button><button class="primary" data-action="setup-pack" data-id="${esc(p.id)}">${p.status.state === 'ok' ? 'Check setup with my agent' : 'Set up with my agent'}</button><button data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button data-action="export-pack" data-id="${esc(p.id)}">Export</button><button data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button>${p.rollbackVersion ? `<button data-action="rollback-pack" data-id="${esc(p.id)}">Roll back to ${esc(p.rollbackVersion)}</button>` : ''}<button class="danger" data-action="remove-pack" data-id="${esc(p.id)}">Remove</button></div>${p.pulled ? `<div class="card trust-notice community" role="alert"><strong>Pulled from the widget library</strong><p>Sidebud pulled version ${esc(p.pulled.version)}: ${esc(p.pulled.reason)}. It was turned off and stays off until you update it${p.rollbackVersion ? ` or roll back to ${esc(p.rollbackVersion)}` : ''}.</p></div>` : ''}<div id="pack-editor"></div>${packPageError ? `<div class="card muted">${esc(packPageError)}</div>` : ''}${accessOverviewHtml(page?.overview)}<div class="pack-page-grid"><div class="stack"><div class="card stack"><h3>Settings and keys</h3>${settings || '<p class="hint">No settings.</p>'}${keys}${settings || keys ? `<div class="actions"><button class="primary" data-action="save-pack" data-id="${esc(p.id)}">Save settings</button></div>` : ''}${account}</div><div class="card stack"><h3>Permissions</h3>${tools}</div><div class="card stack"><h3>Widget and voice</h3>${refresh}${announce}<h4>Controls</h4>${controls}</div><div class="card stack"><h3>Setup</h3>${setup}</div><div class="card stack"><h3>What it runs</h3>${runs}</div>${page?.skill ? `<details class="card"><summary><strong>Skill for the execution agent</strong></summary><p class="hint">Your agent reads this whenever the integration is on. Change it by editing ${code('SKILL.md')} in a copy of the pack, or ask your agent to.</p><pre class="skill">${esc(page.skill)}</pre></details>` : ''}</div><aside class="pack-preview"><h3>On your phone</h3><div id="pack-preview">${packPreviewBody(packId)}</div></aside></div></section>`;
|
|
285
309
|
}
|
|
286
310
|
|
|
287
311
|
async function loadPackPage() {
|
package/src/manage/settings.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Settings: the Sidebud account, this computer's name, and appearance. Loaded after manage.js and shares its globals.
|
|
1
|
+
// Settings: the Sidebud account and its settings sync, the network, this computer's name, crash reports, and appearance. Loaded after manage.js and shares its globals.
|
|
2
2
|
let renaming = false;
|
|
3
3
|
|
|
4
4
|
/** The sidebar's computer card: the saved name, and who this computer is signed in as. */
|
|
@@ -47,13 +47,78 @@ async function saveComputerName(value) {
|
|
|
47
47
|
await reload();
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// Account settings sync (ADR 0014): what each section carries, in the owner's words.
|
|
51
|
+
const SYNC_SECTIONS = [
|
|
52
|
+
{ id: 'voice', short: 'Voice', label: 'Voice choices' },
|
|
53
|
+
{ id: 'alerts', short: 'Alerts', label: 'Task and message alert choices and contacts' },
|
|
54
|
+
{ id: 'agents', short: 'Agents', label: 'Agent profiles, inline skills, and your default agent' },
|
|
55
|
+
{ id: 'packs', short: 'Integrations', label: 'Installed integrations and their non-folder settings' },
|
|
56
|
+
{ id: 'packCopies', short: 'Customized integrations', label: 'Customized integrations (as files, without keys)' },
|
|
57
|
+
{ id: 'surfaces', short: 'Built-in widgets', label: 'Built-in widgets on or off' },
|
|
58
|
+
];
|
|
59
|
+
const syncSectionName = (id) => SYNC_SECTIONS.find((section) => section.id === id)?.short || id;
|
|
60
|
+
|
|
61
|
+
/** The opt-in question, in Settings and setup's last step. Nothing is sent until the button. */
|
|
62
|
+
function syncOptInBlock() {
|
|
63
|
+
const { sync } = state;
|
|
64
|
+
const switches = SYNC_SECTIONS.map((section) => `<div class="check"><input id="sync-choice-${section.id}" type="checkbox" data-sync-choice="${section.id}" ${sync.sections?.[section.id] === false ? '' : 'checked'}><label for="sync-choice-${section.id}">${esc(section.label)}</label></div>`).join('');
|
|
65
|
+
return `<div class="stack sync-block"><div class="check"><input id="sync-opt-in" type="checkbox" checked><label for="sync-opt-in">Sync settings to my account</label></div><div><p class="hint">What syncs:</p><ul class="hint sync-list">${SYNC_SECTIONS.map((section) => `<li>${esc(section.label)}</li>`).join('')}</ul><p class="hint">Keys and sign-ins, this computer's name, folders, and paths stay here.</p></div><details><summary>Advanced</summary><div class="stack">${switches}</div></details><div class="actions"><button class="primary" data-action="sync-opt-in">${sync.decided ? 'Turn on sync' : 'Continue'}</button>${sync.decided ? '<button class="danger" data-action="sync-delete">Delete account copy</button>' : ''}</div></div>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function syncStatusText(sync) {
|
|
69
|
+
if (sync.state === 'syncing') return 'Syncing…';
|
|
70
|
+
if (sync.state === 'offline') return 'Offline, will retry';
|
|
71
|
+
if (sync.state === 'error') return sync.lastError || 'Sync failed. It will try again.';
|
|
72
|
+
return sync.lastSyncedAt ? `Synced ${new Date(sync.lastSyncedAt).toLocaleString()}` : 'Not synced yet';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Sync while it is on: status, what waits to send, and the ways to stop or remove the account copy. */
|
|
76
|
+
function syncOnBlock() {
|
|
77
|
+
const { sync } = state;
|
|
78
|
+
const switches = SYNC_SECTIONS.map((section) => `<div class="check"><input id="sync-section-${section.id}" type="checkbox" data-sync-section="${section.id}" ${sync.sections[section.id] ? 'checked' : ''}><label for="sync-section-${section.id}">${esc(section.label)}</label></div>`).join('');
|
|
79
|
+
return `<div class="stack sync-block"><div class="row"><div><strong>Settings sync to your account</strong><p class="hint ${sync.state === 'error' ? 'error-text' : ''}" role="status">${esc(syncStatusText(sync))}</p></div><span class="badge ${{ idle: 'good', syncing: '', offline: 'warn', error: 'bad' }[sync.state] ?? ''}">${esc({ idle: 'On', syncing: 'Syncing', offline: 'Offline', error: 'Error' }[sync.state] || 'On')}</span></div>${sync.pending.length ? `<p class="hint">Waiting to send: ${esc(sync.pending.map(syncSectionName).join(', '))}.</p>` : ''}<details><summary>Advanced</summary><div class="stack"><p class="hint">Turn off a part to keep it on this computer only.</p>${switches}</div></details><div class="actions"><button data-action="sync-now" ${sync.state === 'syncing' ? 'disabled' : ''}>Sync now</button><button data-action="sync-stop">Stop syncing</button><button class="danger" data-action="sync-delete">Delete account copy</button></div></div>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The account card's sync part; setup shows only the question. */
|
|
83
|
+
function syncBlock({ setup = false } = {}) {
|
|
84
|
+
const { sync } = state;
|
|
85
|
+
if (!sync?.available) return '';
|
|
86
|
+
// A restart the account's changes need shows in the page's restart banner.
|
|
87
|
+
const notes = sync.notices.map((text) => `<p class="hint">${esc(text)}</p>`).join('');
|
|
88
|
+
if (!sync.enabled) return syncOptInBlock() + notes;
|
|
89
|
+
return (setup ? '<p class="hint">Your settings sync to your account. Manage sync under <a href="#settings">Settings</a>.</p>' : syncOnBlock()) + notes;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Customized packs from the account that run code here wait for consent on each computer (ADR 0012). */
|
|
93
|
+
function syncConsentsCard() {
|
|
94
|
+
const consents = state.sync?.available ? state.sync.consents : [];
|
|
95
|
+
if (!consents.length) return '';
|
|
96
|
+
return `<div class="card stack"><div><h3>Integrations from your account</h3><p class="hint">These run their own code or use this computer’s keys, so each computer asks first. They stay off here until you allow them.</p></div>${consents.map((consent) => `<div class="row"><p>${esc(consent.name)} ${esc(consent.version)} from your account runs code on this computer.</p><div class="actions"><button class="primary" data-action="sync-consent" data-id="${esc(consent.packId)}" data-allow="true">Allow</button><button data-action="sync-consent" data-id="${esc(consent.packId)}" data-allow="false">Not on this computer</button></div></div>`).join('')}</div>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A synced value in one line: JSON for objects, cut to about 120 characters. */
|
|
100
|
+
function syncValue(value) {
|
|
101
|
+
if (value === undefined) return '(not set)';
|
|
102
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
103
|
+
return text.length > 120 ? `${text.slice(0, 119)}…` : text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** When this computer and the account changed the same setting, the account's value won; this is the way back. */
|
|
107
|
+
function syncConflictsCard() {
|
|
108
|
+
const conflicts = state.sync?.available ? state.sync.conflicts : [];
|
|
109
|
+
if (!conflicts.length) return '';
|
|
110
|
+
return `<div class="card stack"><div><h3>Changed in two places</h3><p class="hint">This computer and your account both changed these settings. Your account's value is in use; choose this computer's instead, or dismiss.</p></div>${conflicts.map((conflict) => `<div class="stack sync-conflict"><strong>${esc(conflict.label)}</strong><p class="hint">Your account's value: <span class="code">${esc(syncValue(conflict.remote))}</span></p><p class="hint">This computer had: <span class="code">${esc(syncValue(conflict.local))}</span></p><div class="actions"><button data-action="sync-conflict" data-id="${conflict.id}" data-choice="use-local">Use this instead</button><button data-action="sync-conflict" data-id="${conflict.id}" data-choice="dismiss">Dismiss</button></div></div>`).join('')}</div>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `setup` is setup's last step: the account is optional there, and signed in it asks only the sync question. */
|
|
114
|
+
function accountCard({ setup = false } = {}) {
|
|
51
115
|
const { account } = state;
|
|
52
116
|
const site = esc(account.server.replace(/^https?:\/\//, ''));
|
|
53
|
-
const head = (text) => `<div><h3>Sidebud account</h3><p class="hint">${text}</p></div>`;
|
|
117
|
+
const head = (text) => `<div><h3>Sidebud account${setup ? ' <span class="optional">· Optional</span>' : ''}</h3><p class="hint">${text}</p></div>`;
|
|
54
118
|
if (account.status === 'signed_in') {
|
|
55
119
|
const who = account.user.name && account.user.email ? `${esc(account.user.name)} · ${esc(account.user.email)}` : esc(account.user.email || account.user.name || 'Your account');
|
|
56
|
-
return `<div class="card stack">${head(`
|
|
120
|
+
if (setup) return `<div class="card stack">${head(`Signed in as ${who}.`)}${syncBlock({ setup })}</div>`;
|
|
121
|
+
return `<div class="card stack">${head(`This computer is signed in to ${site} and shows in your account's list of computers.`)}<div class="account-row"><span class="avatar" aria-hidden="true">${esc((account.user.name || account.user.email || 'S').slice(0, 1).toUpperCase())}</span><div><strong>${who}</strong><p class="hint">Signed in ${esc(new Date(account.signedInAt).toLocaleString())}</p></div></div>${syncBlock()}<div class="actions"><button class="danger" data-action="account-sign-out">Sign out</button></div></div>`;
|
|
57
122
|
}
|
|
58
123
|
if (account.status === 'pending') {
|
|
59
124
|
const link = account.verificationUriComplete || account.verificationUri;
|
|
@@ -61,13 +126,81 @@ function accountCard() {
|
|
|
61
126
|
}
|
|
62
127
|
if (account.status === 'unavailable')
|
|
63
128
|
return `<div class="card stack">${head('Sign in to connect this computer to your account.')}<p class="hint">${esc(account.error)} Everything on this page keeps working without an account.</p><div class="actions"><button data-action="account-sign-in">Try again</button></div></div>`;
|
|
64
|
-
return `<div class="card stack">${head(`Sign in to ${site} to connect this computer to your account. You approve it in your browser; no password is typed here, and the sign-in token stays on this computer.`)}${account.error ? `<p role="alert" class="hint error-text">${esc(account.error)}</p>` : ''}<div class="actions"><button class="primary" data-action="account-sign-in">Sign in with ${site}</button></div></div>`;
|
|
129
|
+
return `<div class="card stack">${head(`Sign in to ${site} to connect this computer to your account${setup ? ' and keep your settings in it. Setup finishes without an account' : ''}. You approve it in your browser; no password is typed here, and the sign-in token stays on this computer.`)}${account.error ? `<p role="alert" class="hint error-text">${esc(account.error)}</p>` : ''}<div class="actions"><button class="primary" data-action="account-sign-in">Sign in with ${site}</button></div></div>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Only while the site sells plans (`billing`); the plan is managed on the site, so there are no prices here. */
|
|
133
|
+
function planCard() {
|
|
134
|
+
if (!state.capabilities.flags.billing || !state.plan.url) return '';
|
|
135
|
+
const inactive = state.capabilities.flags.paywall && state.plan.status === 'inactive';
|
|
136
|
+
const text = inactive
|
|
137
|
+
? "Your Sidebud plan isn't active, so calls, requests, and new tasks are paused on this computer. Choose or renew a plan on the Sidebud site. This computer checks again every hour and whenever you try to use it."
|
|
138
|
+
: 'Your Sidebud plan is managed on the Sidebud site.';
|
|
139
|
+
return `<div class="card stack"><div><h3>Plan</h3><p class="hint ${inactive ? 'error-text' : ''}"${inactive ? ' role="alert"' : ''}>${esc(text)}</p></div><div class="actions"><a class="button ${inactive ? 'primary' : ''}" href="${esc(state.plan.url)}" target="_blank" rel="noopener">Manage plan</a></div></div>`;
|
|
65
140
|
}
|
|
66
141
|
|
|
142
|
+
const NETWORK_CHOICES = [
|
|
143
|
+
{ id: 'private', label: 'Only this computer and my tailnet', hint: 'Phones reach this computer over Tailscale. Other devices on your Wi-Fi cannot connect. Recommended.' },
|
|
144
|
+
{ id: 'lan', label: 'This network (LAN)', hint: 'Any phone on the same Wi-Fi or network can reach this computer, as well as over Tailscale. Pairing still needs a code from this page.' },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
/** Who can reach this computer's phone listener: the choice, its addresses, and a change that waits for a restart. */
|
|
148
|
+
function networkChoice({ setup = false } = {}) {
|
|
149
|
+
const { exposure, saved, listening, port } = state.network;
|
|
150
|
+
const pending = saved !== exposure;
|
|
151
|
+
const current = exposure === 'custom' ? `a custom address (${listening.join(', ')})` : NETWORK_CHOICES.find((choice) => choice.id === exposure).label.toLowerCase();
|
|
152
|
+
const tailnet = listening.some((host) => host.startsWith('100.') || host.startsWith('fd7a:')) || exposure === 'lan';
|
|
153
|
+
return `<div class="stack" role="radiogroup" aria-label="Who can reach this computer">${NETWORK_CHOICES.map((choice) => `<label class="check"><input type="radio" name="network-exposure" value="${choice.id}" ${(pending ? saved : exposure) === choice.id ? 'checked' : ''}><span><strong>${esc(choice.label)}</strong><br><span class="hint">${esc(choice.hint)}</span></span></label>`).join('')}</div>${setup ? '' : `<p class="hint">Now: ${esc(current)}, port ${port}.${listening.length ? ` Listening on ${esc(listening.join(', '))}.` : ''}</p>`}${exposure === 'private' && !tailnet ? '<p class="hint">Tailscale is not connected on this computer, so only this computer can reach Sidebud. Connect Tailscale on both devices; the companion picks it up within a minute.</p>' : ''}${pending ? '<p class="hint">Your new choice applies after you apply changes.</p>' : ''}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function saveNetworkChoice() {
|
|
157
|
+
const exposure = document.querySelector('input[name="network-exposure"]:checked')?.value;
|
|
158
|
+
if (!exposure) throw new Error('Choose who can reach this computer.');
|
|
159
|
+
if (exposure !== state.network.saved) await api('network', { exposure });
|
|
160
|
+
await reload();
|
|
161
|
+
return !state.restart.pending || (await applyChanges());
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Opt-in crash reports, in Settings and on setup's last step. Saved as it
|
|
166
|
+
* changes and applied at once. Builds without a crash report address (running
|
|
167
|
+
* from source) show it off and disabled; setup leaves it out.
|
|
168
|
+
*/
|
|
169
|
+
function crashReportsCard({ setup = false } = {}) {
|
|
170
|
+
const { available, enabled } = state.crashReports;
|
|
171
|
+
if (setup && !available) return '';
|
|
172
|
+
return `<div class="card stack"><div><h3>Crash reports${setup ? ' <span class="optional">· Optional</span>' : ''}</h3></div><div class="check"><input id="crash-reports" type="checkbox" data-crash-reports ${enabled && available ? 'checked' : ''} ${available ? '' : 'disabled'}><label for="crash-reports">Send crash reports to Sidebud</label></div><p class="hint">Sends error details when something breaks, with your prompts, transcripts, audio, messages, and keys removed and your home folder hidden in file paths. Off unless you turn it on.</p>${available ? '' : '<p class="hint">Not available in this copy of Sidebud (running from source).</p>'}</div>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* "Start Sidebud when I log in" (a macOS launchd agent). The page only writes or removes the login item: this
|
|
177
|
+
* companion already holds the ports, so it takes effect at the next login; `sidebud service install` starts it now.
|
|
178
|
+
*/
|
|
179
|
+
function loginItemCard({ setup = false } = {}) {
|
|
180
|
+
const item = state.install?.loginItem;
|
|
181
|
+
if (!item || item.platform !== 'supported') return '';
|
|
182
|
+
const head = `<div><h3>${setup ? 'Keep Sidebud running <span class="optional">· Recommended</span>' : 'Start Sidebud when I log in'}</h3><p class="hint">Sidebud runs in the background on this Mac, so your phone can reach it without a terminal window. It restarts by itself if it stops unexpectedly.</p></div>`;
|
|
183
|
+
if (item.installed)
|
|
184
|
+
return `<div class="card stack">${head}<p>${item.running ? 'On. This companion is running from the login item.' : 'On. Sidebud starts by itself the next time you log in. To switch now, quit this companion and run <span class="code">sidebud service install</span>.'}</p><p class="hint">Logs: <span class="code">${esc(item.logPath)}</span></p><div class="actions"><button data-action="login-item" data-enabled="false">Stop starting at login</button></div></div>`;
|
|
185
|
+
if (item.unavailable) return `<div class="card stack">${head}<p class="hint">${esc(item.unavailable).replace(/`([^`]+)`/g, '<span class="code">$1</span>')}</p></div>`;
|
|
186
|
+
return `<div class="card stack">${head}<div class="actions"><button ${setup ? 'class="primary" ' : ''}data-action="login-item" data-enabled="true">Start Sidebud when I log in</button></div></div>`;
|
|
187
|
+
}
|
|
188
|
+
/** Installed version and the daily update check (`updates.check` in config.json). */
|
|
189
|
+
function versionCard() {
|
|
190
|
+
const install = state.install;
|
|
191
|
+
if (!install) return '';
|
|
192
|
+
const { updates } = install;
|
|
193
|
+
const checked = updates.checkedAt ? `Last checked ${esc(new Date(updates.checkedAt).toLocaleString())}.` : 'Not checked yet.';
|
|
194
|
+
const status = !updates.enabled
|
|
195
|
+
? install.kind === 'source' ? 'Running from source; update checks are off.' : 'Update checks are off (<span class="code">updates.check</span> in config.json).'
|
|
196
|
+
: updates.available ? `Sidebud ${esc(updates.latest)} is available. Update with <span class="code">${esc(updates.command)}</span>.`
|
|
197
|
+
: `${updates.latest ? 'Up to date.' : ''} ${checked}${updates.error ? ` The last check failed: ${esc(updates.error)}.` : ''}`;
|
|
198
|
+
return `<div class="card stack"><div><h3>Version</h3><p class="hint">Sidebud ${esc(updates.current)}. Checks npm for a new release once a day and never updates by itself.</p></div><p>${status}</p></div>`;
|
|
199
|
+
}
|
|
67
200
|
function settingsView() {
|
|
68
201
|
const theme = document.documentElement.dataset.theme;
|
|
69
202
|
const renamed = state.savedName !== state.name;
|
|
70
|
-
return `<section class="section"><div><h2>Settings</h2><p class="lede">Your account and preferences for ${esc(state.name)}.</p></div>${accountCard()}<div class="card stack"><div><h3>Computer name</h3><p class="hint">Shown on your phones, on the system call screen, and to the voice assistant.</p></div><div class="actions"><input id="computer-name" maxlength="100" aria-label="Computer name" value="${esc(state.savedName)}"><button data-action="save-name">Save name</button></div>${renamed ? '<p class="hint">The new name is used after you apply changes.</p>' : ''}</div
|
|
203
|
+
return `<section class="section"><div><h2>Settings</h2><p class="lede">Your account and preferences for ${esc(state.name)}.</p></div>${accountCard()}${planCard()}${syncConsentsCard()}${syncConflictsCard()}<div class="card stack"><div><h3>Network</h3><p class="hint">Who can reach this computer to pair and connect a phone. This page itself is only ever available on this computer.</p></div>${networkChoice()}<div class="actions"><button data-action="save-network">Save network choice</button></div></div><div class="card stack"><div><h3>Computer name</h3><p class="hint">Shown on your phones, on the system call screen, and to the voice assistant.</p></div><div class="actions"><input id="computer-name" maxlength="100" aria-label="Computer name" value="${esc(state.savedName)}"><button data-action="save-name">Save name</button></div>${renamed ? '<p class="hint">The new name is used after you apply changes.</p>' : ''}</div>${crashReportsCard()}<div class="card stack"><div><h3>Appearance</h3><p class="hint">Remembered in this browser.</p></div><div class="segmented" role="group" aria-label="Theme"><button data-action="set-theme" data-id="dark" aria-pressed="${theme === 'dark'}">Dark</button><button data-action="set-theme" data-id="light" aria-pressed="${theme === 'light'}">Light</button></div></div>${loginItemCard()}${versionCard()}</section>`;
|
|
71
204
|
}
|
|
72
205
|
|
|
73
206
|
/** Handles Settings and sidebar actions; returns true when it took the action. */
|
|
@@ -76,10 +209,32 @@ async function handleSettingsAction(action, button) {
|
|
|
76
209
|
startInlineRename();
|
|
77
210
|
return true;
|
|
78
211
|
}
|
|
212
|
+
if (action === 'save-network') {
|
|
213
|
+
if (await saveNetworkChoice()) notice('Network choice saved.');
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
79
216
|
if (action === 'save-name') {
|
|
80
217
|
await saveComputerName($('#computer-name').value);
|
|
81
218
|
return true;
|
|
82
219
|
}
|
|
220
|
+
if (action === 'update') {
|
|
221
|
+
button.disabled = true;
|
|
222
|
+
try {
|
|
223
|
+
const result = await api('update', { when: button.dataset.when });
|
|
224
|
+
notice(result.message);
|
|
225
|
+
await reload();
|
|
226
|
+
} finally { button.disabled = false; }
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
if (action === 'login-item') {
|
|
230
|
+
button.disabled = true;
|
|
231
|
+
try {
|
|
232
|
+
const result = await api('login-item', { enabled: button.dataset.enabled === 'true' });
|
|
233
|
+
notice(result.message);
|
|
234
|
+
await reload();
|
|
235
|
+
} finally { button.disabled = false; }
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
83
238
|
if (action === 'set-theme') {
|
|
84
239
|
localStorage.setItem('rvaManageTheme', button.dataset.id);
|
|
85
240
|
applyTheme(button.dataset.id);
|
|
@@ -101,6 +256,54 @@ async function handleSettingsAction(action, button) {
|
|
|
101
256
|
await reload();
|
|
102
257
|
return true;
|
|
103
258
|
}
|
|
259
|
+
if (action === 'sync-opt-in') {
|
|
260
|
+
const enabled = $('#sync-opt-in').checked;
|
|
261
|
+
const sections = Object.fromEntries([...document.querySelectorAll('[data-sync-choice]')].map((box) => [box.dataset.syncChoice, box.checked]));
|
|
262
|
+
button.disabled = true;
|
|
263
|
+
try {
|
|
264
|
+
await api('sync/opt-in', { enabled, sections });
|
|
265
|
+
notice(enabled ? 'Sync is on. Your settings are on their way to your account.' : 'Settings stay on this computer only. Turn on sync any time under Settings.');
|
|
266
|
+
await reload();
|
|
267
|
+
} finally { button.disabled = false; }
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (action === 'sync-now') {
|
|
271
|
+
button.disabled = true;
|
|
272
|
+
try {
|
|
273
|
+
await api('sync/now', {});
|
|
274
|
+
await reload();
|
|
275
|
+
} finally { button.disabled = false; }
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
if (action === 'sync-stop') {
|
|
279
|
+
if (!confirm('Stop syncing? Settings on this computer stay as they are.')) return true;
|
|
280
|
+
await api('sync/stop', {});
|
|
281
|
+
notice('Sync stopped. Settings on this computer stay as they are.');
|
|
282
|
+
await reload();
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
if (action === 'sync-delete') {
|
|
286
|
+
if (!confirm('Delete the copy of your settings stored in your Sidebud account? Every computer keeps its own settings; sync stops.')) return true;
|
|
287
|
+
// A failed delete answers with the sync status and its error; the account copy is still there.
|
|
288
|
+
const next = await api('sync/delete-account-copy', {});
|
|
289
|
+
if (next.state === 'error' || next.state === 'offline') notice(`The account copy was not deleted: ${next.lastError || 'your account could not be reached'}. Try again.`, true);
|
|
290
|
+
else notice('The copy in your account is deleted. Settings on this computer stay as they are.');
|
|
291
|
+
await reload();
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
if (action === 'sync-conflict') {
|
|
295
|
+
await api('sync/conflict', { id: Number(button.dataset.id), action: button.dataset.choice });
|
|
296
|
+
if (button.dataset.choice === 'use-local') notice('This computer’s value is back in use and goes to your account.');
|
|
297
|
+
await reload();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
if (action === 'sync-consent') {
|
|
301
|
+
const allow = button.dataset.allow === 'true';
|
|
302
|
+
await api('sync/consent', { packId: button.dataset.id, allow });
|
|
303
|
+
notice(allow ? 'Allowed on this computer.' : 'It stays off on this computer.');
|
|
304
|
+
await reload();
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
104
307
|
if (action === 'account-sign-out') {
|
|
105
308
|
if (!confirm('Sign this computer out of your Sidebud account?')) return true;
|
|
106
309
|
await api('account/sign-out', {});
|
|
@@ -110,3 +313,26 @@ async function handleSettingsAction(action, button) {
|
|
|
110
313
|
}
|
|
111
314
|
return false;
|
|
112
315
|
}
|
|
316
|
+
|
|
317
|
+
// The crash report switch saves as it changes; no restart is needed.
|
|
318
|
+
document.addEventListener('change', (event) => {
|
|
319
|
+
if (!event.target.matches?.('[data-crash-reports]') || !state) return;
|
|
320
|
+
const enabled = event.target.checked;
|
|
321
|
+
void api('crash-reports', { enabled }).then(() => {
|
|
322
|
+
notice(enabled ? 'Crash reports are on. Thank you.' : 'Crash reports are off. Nothing more is sent.');
|
|
323
|
+
return reload();
|
|
324
|
+
}, (error) => {
|
|
325
|
+
notice(error.message, true);
|
|
326
|
+
return reload();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Sync's per-part switches (while sync is on) save as they change.
|
|
331
|
+
document.addEventListener('change', (event) => {
|
|
332
|
+
const section = event.target.dataset?.syncSection;
|
|
333
|
+
if (!section || !state) return;
|
|
334
|
+
void api('sync/section', { section, enabled: event.target.checked }).then(() => reload(), (error) => {
|
|
335
|
+
notice(error.message, true);
|
|
336
|
+
return reload();
|
|
337
|
+
});
|
|
338
|
+
});
|
package/src/manage/setup.js
CHANGED
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
// First-run setup. Loaded after manage.js and shares its state and helpers (state, api, esc, render, reload, notice…).
|
|
2
|
-
const
|
|
2
|
+
const ALL_SETUP_STEPS = [
|
|
3
3
|
{ id: 'app', title: 'Get the app', short: 'Phone app' },
|
|
4
|
+
// Only while the Sidebud site's paywall is on (the companion then reports this step): the plan comes with the account.
|
|
5
|
+
{ id: 'account', title: 'Sign in to Sidebud', short: 'Sidebud account' },
|
|
4
6
|
{ id: 'execution', title: 'Choose who does the work', short: 'Execution agent' },
|
|
5
7
|
{ id: 'voice', title: 'Choose a voice', short: 'Voice agent' },
|
|
6
8
|
{ id: 'integrations', title: 'Add integrations', short: 'Integrations' },
|
|
7
9
|
{ id: 'agents', title: 'Choose your default agents', short: 'Default agents' },
|
|
8
10
|
{ id: 'decisions', title: 'Fast decisions', short: 'Fast decisions', beta: true, optional: true },
|
|
11
|
+
{ id: 'network', title: 'Who can reach this computer', short: 'Network' },
|
|
9
12
|
{ id: 'phone', title: 'Pair your phone', short: 'Pair phone' },
|
|
13
|
+
// macOS, an installed companion: the companion reports this step only there.
|
|
14
|
+
{ id: 'startup', title: 'Start Sidebud when you log in', short: 'Start at login', optional: true },
|
|
10
15
|
{ id: 'done', title: 'All set', short: 'Finish' },
|
|
11
16
|
];
|
|
17
|
+
/** The steps the companion reports, in order, plus the finish page. */
|
|
18
|
+
function setupSteps() {
|
|
19
|
+
return ALL_SETUP_STEPS.filter((step) => step.id === 'done' || (state && state.setup.steps[step.id]));
|
|
20
|
+
}
|
|
12
21
|
let setupSelections = null;
|
|
13
22
|
let setupBusy = false;
|
|
14
23
|
|
|
15
24
|
function currentSetupStep() {
|
|
16
25
|
const wanted = location.hash.slice(1).split('/')[1];
|
|
17
|
-
|
|
26
|
+
const steps = setupSteps();
|
|
27
|
+
// A required sign-in cannot be skipped: later steps wait for it.
|
|
28
|
+
const account = state?.setup.steps.account;
|
|
29
|
+
if (account && !account.done && steps.findIndex((step) => step.id === wanted) > steps.findIndex((step) => step.id === 'account')) return 'account';
|
|
30
|
+
if (steps.some((step) => step.id === wanted)) return wanted;
|
|
18
31
|
if (!state) return 'app';
|
|
19
32
|
// Once finished, Setup opens on the completion page; each step stays reachable from the stepper.
|
|
20
33
|
if (state.setup.completedAt) return 'done';
|
|
21
|
-
return
|
|
34
|
+
return steps.find((step) => step.id !== 'done' && !state.setup.steps[step.id].done)?.id || 'done';
|
|
22
35
|
}
|
|
23
36
|
function goToSetupStep(step) {
|
|
24
37
|
location.hash = `#setup/${step}`;
|
|
@@ -62,17 +75,26 @@ function checkCard(kind, intro) {
|
|
|
62
75
|
return `<div class="card stack check-card"><div class="row"><div><h3>${kind === 'execution' ? 'Test the execution agent' : 'Test voice to agent'}</h3><p class="hint">${intro}</p></div><span class="badge ${tone}">${esc(status)}</span></div>${check ? `<p class="${check.status === 'failed' ? 'error-text' : 'hint'}" role="status" aria-live="polite">${esc(check.detail)}${!check.current && check.status !== 'running' ? ' Your selection changed since this ran.' : ''}</p>` : ''}<div class="actions"><button class="primary" data-action="setup-check" data-kind="${kind}" ${check?.status === 'running' || !ready ? 'disabled' : ''}>${blocked ? 'Apply changes and run the test' : check ? 'Run the test again' : 'Run the test'}</button></div></div>`;
|
|
63
76
|
}
|
|
64
77
|
function setupStepper(current) {
|
|
65
|
-
return `<ol class="setup-steps">${
|
|
78
|
+
return `<ol class="setup-steps">${setupSteps().map((step, index) => {
|
|
66
79
|
const done = step.id === 'done' ? Boolean(state.setup.completedAt) : state.setup.steps[step.id].done;
|
|
67
80
|
return `<li><button data-action="setup-step" data-step="${step.id}" class="${step.id === current ? 'current' : ''} ${done ? 'done' : ''}" ${step.id === current ? 'aria-current="step"' : ''}><span class="step-mark" aria-hidden="true">${done ? '✓' : index + 1}</span><span>${esc(step.short)}${step.beta ? '<span class="tag-beta">Beta</span>' : ''}</span></button></li>`;
|
|
68
81
|
}).join('')}</ol>`;
|
|
69
82
|
}
|
|
70
|
-
function setupNav(current, { next = 'Continue', nextAction = 'setup-next', skip = true, skipAction = 'setup-next' } = {}) {
|
|
71
|
-
const
|
|
72
|
-
|
|
83
|
+
function setupNav(current, { next = 'Continue', nextAction = 'setup-next', skip = true, skipAction = 'setup-next', disabled = false } = {}) {
|
|
84
|
+
const steps = setupSteps();
|
|
85
|
+
const index = steps.findIndex((step) => step.id === current);
|
|
86
|
+
return `<div class="setup-nav">${index > 0 ? `<button data-action="setup-step" data-step="${steps[index - 1].id}">Back</button>` : '<span></span>'}<div class="actions">${skip ? `<button data-action="${skipAction}">Skip for now</button>` : ''}<button class="primary" data-action="${nextAction}" ${disabled ? 'disabled' : ''}>${esc(next)}</button></div></div>`;
|
|
73
87
|
}
|
|
74
88
|
function appStep() {
|
|
75
|
-
return `<div class="setup-grid"><div class="card stack"><h3>Install Sidebud on your phone</h3><p>Scan this code with your phone’s camera. It opens the right store for your phone.</p><img class="download-qr" src="${esc(state.download.qr)}" alt="QR code to download the Sidebud app"><p class="hint">Or open <a href="${esc(state.download.url)}" target="_blank" rel="noopener">${esc(state.download.url.replace(/^https:\/\//, ''))}</a> on your phone. iPhone builds are available through TestFlight during the beta.</p></div><div class="card stack"><h3>What happens next</h3><ol class="plain-steps"><li><strong>Pick an execution agent.</strong> It does the work on this computer, and we test it with a real prompt.</li><li><strong>Pick a voice.</strong> We test that the voice can hand requests to your agent.</li><li><strong>Add integrations</strong> from the widget library, or have your agent build your own.</li><li><strong>Choose your default agents</strong>: the agents app with your main work agents, such as T3 Agents.</li><li><strong>Turn on fast decisions</strong> (optional, beta) so simple requests are answered in about a second.</li><li><strong>Pair your phone</strong> by scanning a code in the app. You can <a href="#setup/phone">pair now</a> if the app is already installed.</li
|
|
89
|
+
return `<div class="setup-grid"><div class="card stack"><h3>Install Sidebud on your phone</h3><p>Scan this code with your phone’s camera. It opens the right store for your phone.</p><img class="download-qr" src="${esc(state.download.qr)}" alt="QR code to download the Sidebud app"><p class="hint">Or open <a href="${esc(state.download.url)}" target="_blank" rel="noopener">${esc(state.download.url.replace(/^https:\/\//, ''))}</a> on your phone. iPhone builds are available through TestFlight during the beta.</p></div><div class="card stack"><h3>What happens next</h3><ol class="plain-steps"><li><strong>Pick an execution agent.</strong> It does the work on this computer, and we test it with a real prompt.</li><li><strong>Pick a voice.</strong> We test that the voice can hand requests to your agent.</li><li><strong>Add integrations</strong> from the widget library, or have your agent build your own.</li><li><strong>Choose your default agents</strong>: the agents app with your main work agents, such as T3 Agents.</li><li><strong>Turn on fast decisions</strong> (optional, beta) so simple requests are answered in about a second.</li><li><strong>Choose who can reach this computer</strong>: your Wi-Fi network, or only your tailnet.</li><li><strong>Pair your phone</strong> by scanning a code in the app. You can <a href="#setup/phone">pair now</a> if the app is already installed.</li>${state.setup.steps.startup ? '<li><strong>Start Sidebud when you log in</strong> (optional), so it keeps running without a terminal window.</li>' : ''}</ol></div></div>${setupNav('app', { next: 'I have the app', nextAction: 'setup-app-done', skip: false })}`;
|
|
90
|
+
}
|
|
91
|
+
/** The same sign-in card as Settings; Continue stays off until this computer is signed in. */
|
|
92
|
+
function accountStep() {
|
|
93
|
+
const signedIn = state.setup.steps.account.done;
|
|
94
|
+
const plan = signedIn && state.plan.status === 'inactive'
|
|
95
|
+
? `<div class="card stack"><h3>Plan</h3><p class="hint">Your Sidebud plan isn't active yet. Calls, requests, and agent tests start working once it is.${state.plan.url ? '' : ' Check your plan on the Sidebud site.'}</p>${state.plan.url ? `<div class="actions"><a class="button primary" href="${esc(state.plan.url)}" target="_blank" rel="noopener">Manage plan</a></div>` : ''}</div>`
|
|
96
|
+
: '';
|
|
97
|
+
return `<p class="hint">Sidebud needs your account on this computer. You approve the sign-in in your browser; no password is typed here.</p>${accountCard()}${plan}${setupNav('account', { skip: false, disabled: !signedIn, next: signedIn ? 'Continue' : 'Sign in to continue' })}`;
|
|
76
98
|
}
|
|
77
99
|
function executionStep() {
|
|
78
100
|
if (!state.providers || state.connector.kind === 'memory')
|
|
@@ -101,7 +123,7 @@ function integrationsStep() {
|
|
|
101
123
|
const selected = integrationSelections();
|
|
102
124
|
const option = (entry) => {
|
|
103
125
|
const note = entry.installed ? 'Added' : entry.account;
|
|
104
|
-
return `<label class="integration-option"><input type="checkbox" data-integration="${esc(entry.id)}" ${entry.installed ? 'checked disabled' : selected.has(entry.id) ? 'checked' : ''}><span><strong>${esc(entry.name)}</strong>${entry.detected ? ' <span class="pill">Found on this computer</span>' : ''}<br><span class="hint">${esc(entry.description)}</span><br><span class="hint">${esc(note)}</span></span></label>`;
|
|
126
|
+
return `<label class="integration-option"><input type="checkbox" data-integration="${esc(entry.id)}" ${entry.installed ? 'checked disabled' : selected.has(entry.id) ? 'checked' : ''}><span><strong>${esc(entry.name)}</strong>${entry.detected ? ' <span class="pill">Found on this computer</span>' : ''}<br><span class="hint">${esc(entry.description)}</span><br><span class="hint">${esc(note)}</span><span class="access-line">${accessBadge(entry.access)}${controlsComputer(entry.access) ? `<span class="hint">${esc(entry.access.points[0].text)}</span>` : ''}</span></span></label>`;
|
|
105
127
|
};
|
|
106
128
|
// Apps found on this computer first, then the rest of the library.
|
|
107
129
|
const packs = [...state.integrationCatalog].sort((a, b) => Number(b.detected) - Number(a.detected));
|
|
@@ -129,11 +151,18 @@ function decisionsStep() {
|
|
|
129
151
|
<label>Or a local Jev-compatible server (optional)<input id="setup-jev-url" type="url" placeholder="Empty: TypeSafe’s hosted API" value="${esc(jev.config.url ?? '')}"></label>
|
|
130
152
|
<p class="hint">Your key stays in this computer’s keychain and is only sent to TypeSafe. A local server gets no key.</p></div>${setupNav('decisions', { next: on ? 'Test and continue' : 'Save and test', nextAction: 'setup-save-decisions', skipAction: 'setup-skip-decisions' })}`;
|
|
131
153
|
}
|
|
154
|
+
function startupStep() {
|
|
155
|
+
const installed = state.install?.loginItem.installed;
|
|
156
|
+
return `${loginItemCard({ setup: true })}${setupNav('startup', { next: installed ? 'Continue' : 'Not now', nextAction: installed ? 'setup-next' : 'setup-ack-startup', skip: false })}`;
|
|
157
|
+
}
|
|
158
|
+
function networkStep() {
|
|
159
|
+
return `<div class="card stack"><h3>Where will your phone connect from?</h3><p class="hint">Your phone talks to this computer directly. Choose who can reach it; you can change this later in Settings.</p>${networkChoice({ setup: true })}</div>${setupNav('network', { next: 'Save and continue', nextAction: 'setup-save-network', skip: false })}`;
|
|
160
|
+
}
|
|
132
161
|
function phoneStep() {
|
|
133
162
|
const active = state.devices.filter((device) => !device.revokedAt);
|
|
134
163
|
if (active.length)
|
|
135
164
|
return `<div class="card stack"><h3>Your phone is paired</h3><p>${active.map((device) => `<strong>${esc(device.name)}</strong> (${esc(device.platform)})`).join(', ')} can use ${esc(state.name)}. Open Sidebud on your phone and check that it shows this computer as connected.</p><p class="hint">Pair more phones, or remove one, under <a href="#connection">Connection</a>.</p></div>${setupNav('phone', { skip: false })}`;
|
|
136
|
-
return `<div class="setup-grid"><div class="card stack"><h3>Scan with the Sidebud app</h3>${pairing ? `<img class="pairing-qr" src="${esc(pairing.qr)}" alt="Pairing QR code"><p class="hint">${Date.parse(pairing.expiresAt) <= Date.now() ? 'This code has expired. Generate a new one.' : `Works once. Expires ${esc(new Date(pairing.expiresAt).toLocaleTimeString())}.`}</p>` : `<p class="muted">${pairingLoading ? 'Preparing pairing code…' : 'Generate a code to pair your phone.'}</p>`}${pairingError ? `<p role="alert" class="hint error-text">Could not create a pairing code: ${esc(pairingError)}</p>` : ''}<div class="actions"><button data-action="pair-phone" ${pairingLoading ? 'disabled' : ''}>${pairing ? 'Generate a new code' : 'Generate pairing code'}</button></div></div><div class="card stack"><h3>On your phone</h3><ol class="plain-steps"><li>Open Sidebud. No app yet? <a href="#setup/app">Get it here</a>.</li><li>Choose <strong>Add computer</strong>, then <strong>Scan pairing code</strong>.</li><li>Point the camera at this code. This page updates when the phone is paired.</li></ol><p class="hint">Your phone must reach this computer on the same network or over Tailscale.</p></div></div>${setupNav('phone', { next: 'Continue', skip: true })}`;
|
|
165
|
+
return `<div class="setup-grid"><div class="card stack"><h3>Scan with the Sidebud app</h3>${pairing ? `<img class="pairing-qr" src="${esc(pairing.qr)}" alt="Pairing QR code"><p class="hint">${Date.parse(pairing.expiresAt) <= Date.now() ? 'This code has expired. Generate a new one.' : `Works once. Expires ${esc(new Date(pairing.expiresAt).toLocaleTimeString())}.`}</p>` : `<p class="muted">${pairingLoading ? 'Preparing pairing code…' : 'Generate a code to pair your phone.'}</p>`}${pairingError ? `<p role="alert" class="hint error-text">Could not create a pairing code: ${esc(pairingError)}</p>` : ''}<div class="actions"><button data-action="pair-phone" ${pairingLoading ? 'disabled' : ''}>${pairing ? 'Generate a new code' : 'Generate pairing code'}</button></div></div><div class="card stack"><h3>On your phone</h3><ol class="plain-steps"><li>Open Sidebud. No app yet? <a href="#setup/app">Get it here</a>.</li><li>Choose <strong>Add computer</strong>, then <strong>Scan pairing code</strong>.</li><li>Point the camera at this code. This page updates when the phone is paired.</li></ol><p class="hint">Your phone must reach this computer ${state.network.exposure === 'private' ? 'over Tailscale' : 'on the same network or over Tailscale'} (<a href="#setup/network">change</a>).</p></div></div>${setupNav('phone', { next: 'Continue', skip: true })}`;
|
|
137
166
|
}
|
|
138
167
|
/** Shown once setup is finished: carry on in the phone app; this page stays for managing the computer. */
|
|
139
168
|
function completedView() {
|
|
@@ -144,21 +173,25 @@ function completedView() {
|
|
|
144
173
|
{ hash: 'decisions', title: 'Fast decisions (Beta)', text: 'Add or change your Jev key, or a local server, and test it.' },
|
|
145
174
|
{ hash: 'connection', title: 'Connection', text: 'Pair another phone or remove one.' },
|
|
146
175
|
];
|
|
147
|
-
return `<div class="card stack setup-complete"><div class="row"><span class="done-badge" aria-hidden="true">✓</span><div><h3>Well done. Sidebud is set up on ${esc(state.name)}.</h3><p>Continue in the Sidebud app on your phone from here: start a voice chat, send a message, and check on your widgets and running work.</p></div></div></div><div class="card stack"><div><h3>Manage everything from this dashboard, any time</h3><p class="hint">Keep the companion running on this computer. Open this page again with <span class="code">sidebud manage</span> or <span class="code">sidebud setup</span>.</p></div><div class="manage-links">${manage.map((item) => `<a class="manage-link" href="#${item.hash}"><strong>${esc(item.title)}</strong><span class="hint">${esc(item.text)}</span></a>`).join('')}</div></div
|
|
176
|
+
return `<div class="card stack setup-complete"><div class="row"><span class="done-badge" aria-hidden="true">✓</span><div><h3>Well done. Sidebud is set up on ${esc(state.name)}.</h3><p>Continue in the Sidebud app on your phone from here: start a voice chat, send a message, and check on your widgets and running work.</p></div></div></div><div class="card stack"><div><h3>Manage everything from this dashboard, any time</h3><p class="hint">Keep the companion running on this computer. Open this page again with <span class="code">sidebud manage</span> or <span class="code">sidebud setup</span>.</p></div><div class="manage-links">${manage.map((item) => `<a class="manage-link" href="#${item.hash}"><strong>${esc(item.title)}</strong><span class="hint">${esc(item.text)}</span></a>`).join('')}</div></div>${setupAccountCard()}<div class="actions"><button data-action="setup-reopen">Run setup again</button></div>`;
|
|
177
|
+
}
|
|
178
|
+
/** Optional on the last step: sign in, then choose whether settings follow the account. Setup finishes without it. */
|
|
179
|
+
function setupAccountCard() {
|
|
180
|
+
return state.capabilities.flags.companionSignIn ? accountCard({ setup: true }) : '';
|
|
148
181
|
}
|
|
149
182
|
function doneStep() {
|
|
150
183
|
if (state.setup.completedAt) return completedView();
|
|
151
|
-
const rows =
|
|
184
|
+
const rows = setupSteps().filter((step) => step.id !== 'done').map((step) => {
|
|
152
185
|
const done = state.setup.steps[step.id].done;
|
|
153
186
|
return `<li class="${done ? 'done' : ''}"><span class="step-mark" aria-hidden="true">${done ? '✓' : '•'}</span><span>${esc(step.title)}${step.optional ? ' <span class="hint">(optional)</span>' : ''}</span>${done ? '' : `<button data-action="setup-step" data-step="${step.id}">Finish this</button>`}</li>`;
|
|
154
187
|
}).join('');
|
|
155
|
-
const ready =
|
|
156
|
-
return `<div class="card stack"><h3>${state.setup.completedAt ? 'Setup is finished' : ready ? 'Everything is ready' : 'Almost there'}</h3><ul class="setup-summary">${rows}</ul><p class="hint">Change any of this later from the sections on the left. Your phone shows your widgets, running tasks, and a button to start a voice chat.</p><div class="actions">${state.setup.completedAt ? '<button data-action="setup-reopen">Run setup again</button>' : '<button class="primary" data-action="setup-complete">Finish setup</button>'}</div></div
|
|
188
|
+
const ready = setupSteps().every((step) => step.id === 'done' || step.optional || state.setup.steps[step.id].done);
|
|
189
|
+
return `<div class="card stack"><h3>${state.setup.completedAt ? 'Setup is finished' : ready ? 'Everything is ready' : 'Almost there'}</h3><ul class="setup-summary">${rows}</ul><p class="hint">Change any of this later from the sections on the left. Your phone shows your widgets, running tasks, and a button to start a voice chat.</p><div class="actions">${state.setup.completedAt ? '<button data-action="setup-reopen">Run setup again</button>' : '<button class="primary" data-action="setup-complete">Finish setup</button>'}</div></div>${setupAccountCard()}${crashReportsCard({ setup: true })}`;
|
|
157
190
|
}
|
|
158
191
|
function setupView() {
|
|
159
192
|
const current = currentSetupStep();
|
|
160
|
-
const step =
|
|
161
|
-
const body = { app: appStep, execution: executionStep, voice: voiceStep, integrations: integrationsStep, agents: agentsStep, decisions: decisionsStep, phone: phoneStep, done: doneStep }[current]();
|
|
193
|
+
const step = setupSteps().find((candidate) => candidate.id === current);
|
|
194
|
+
const body = { app: appStep, account: accountStep, execution: executionStep, voice: voiceStep, integrations: integrationsStep, agents: agentsStep, decisions: decisionsStep, network: networkStep, phone: phoneStep, startup: startupStep, done: doneStep }[current]();
|
|
162
195
|
const title = current === 'done' && state.setup.completedAt ? 'Well done' : step.title;
|
|
163
196
|
return `<section class="section setup"><div><h2>${esc(title)}${step.beta ? ' <span class="tag-beta">Beta</span>' : ''}</h2><p class="lede">Set up Sidebud on ${esc(state.name)}. Keys, accounts, and agents stay on this computer.</p></div>${setupStepper(current)}${body}</section>`;
|
|
164
197
|
}
|
|
@@ -166,7 +199,8 @@ function setupView() {
|
|
|
166
199
|
async function handleSetupAction(action, button) {
|
|
167
200
|
if (!action.startsWith('setup-')) return false;
|
|
168
201
|
const current = currentSetupStep();
|
|
169
|
-
const
|
|
202
|
+
const steps = setupSteps();
|
|
203
|
+
const next = () => goToSetupStep(steps[steps.findIndex((step) => step.id === current) + 1]?.id || 'done');
|
|
170
204
|
if (action === 'setup-step') goToSetupStep(button.dataset.step);
|
|
171
205
|
else if (action === 'setup-next') next();
|
|
172
206
|
else if (action === 'setup-app-done') {
|
|
@@ -203,6 +237,9 @@ async function handleSetupAction(action, button) {
|
|
|
203
237
|
await reload();
|
|
204
238
|
} else if (action === 'setup-add-integrations') {
|
|
205
239
|
const chosen = [...document.querySelectorAll('[data-integration]')].filter((box) => box.checked && !box.disabled).map((box) => box.dataset.integration);
|
|
240
|
+
// Packs that can control the whole computer are named, and need an explicit OK, before anything is added.
|
|
241
|
+
const full = state.integrationCatalog.filter((entry) => chosen.includes(entry.id) && controlsComputer(entry.access));
|
|
242
|
+
if (full.length && !confirm(`${full.map((entry) => entry.name).join(', ')} can control this whole computer: ${full.length === 1 ? 'it starts' : 'they start'} agents or run commands that can do anything you can here, including changing and deleting your files and using your signed-in accounts.\n\nAdd ${full.length === 1 ? 'it' : 'them'} anyway? Each is added switched off.`)) return true;
|
|
206
243
|
const failures = [];
|
|
207
244
|
button.disabled = true;
|
|
208
245
|
// Each pack installs disabled and starts the execution agent on its setup, the same as from MCP & widgets.
|
|
@@ -231,6 +268,10 @@ async function handleSetupAction(action, button) {
|
|
|
231
268
|
await api('setup/ack', { step: 'agents' });
|
|
232
269
|
await reload();
|
|
233
270
|
next();
|
|
271
|
+
} else if (action === 'setup-ack-startup') {
|
|
272
|
+
await api('setup/ack', { step: 'startup' });
|
|
273
|
+
await reload();
|
|
274
|
+
next();
|
|
234
275
|
} else if (action === 'setup-skip-decisions') {
|
|
235
276
|
// Optional while in beta: skipping counts as done, so it never holds up finishing setup.
|
|
236
277
|
await api('setup/ack', { step: 'decisions' });
|
|
@@ -250,6 +291,9 @@ async function handleSetupAction(action, button) {
|
|
|
250
291
|
await reload();
|
|
251
292
|
notice(`Fast decisions work: ${result.model} answered in ${result.ms} ms.`);
|
|
252
293
|
next();
|
|
294
|
+
} else if (action === 'setup-save-network') {
|
|
295
|
+
// The listener moves on restart, so the pairing code on the next step advertises the right addresses.
|
|
296
|
+
if (await saveNetworkChoice()) next();
|
|
253
297
|
} else if (action === 'setup-complete') {
|
|
254
298
|
await api('setup/complete', {});
|
|
255
299
|
history.pushState(null, '', '#setup/done');
|