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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sidebud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Sidebud companion: talk to the agents on your computer from your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sidebud",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
34
34
|
"@noble/curves": "^2.0.1",
|
|
35
35
|
"@noble/hashes": "^2.0.1",
|
|
36
|
+
"@sentry/node": "10.75.3",
|
|
36
37
|
"diff": "^8.0.4",
|
|
37
38
|
"qrcode": "^1.5.4",
|
|
38
39
|
"ws": "^8.18.3",
|
|
@@ -20,7 +20,7 @@ A pack folder holds:
|
|
|
20
20
|
|
|
21
21
|
1. **Find the server.** Prefer an MCP server that already exists: the app's own (`hermes mcp serve`, `openclaw mcp serve`), the vendor's hosted endpoint, or a maintained community server. Note its transport (a stdio command, or an HTTP URL) and credentials: an API key is a pack **secret**; OAuth is an `auth` block; files on disk are a **setting** holding the path.
|
|
22
22
|
2. **If none exists, write one** in `server/` (see [Writing a pack server](#writing-a-pack-server)). Wrap a CLI or a local API; keep it small and read-mostly.
|
|
23
|
-
3. **Draft in a working folder** the user owns (for example `~/sidebud-packs/<name>/`): `pack.json` with identity and `mcp` only, then `packs__probe` to list the server's tools with their read-only hints and input schemas.
|
|
23
|
+
3. **Draft in a working folder** the user owns (for example `~/sidebud-packs/<name>/`): `pack.json` with identity and `mcp` only, then `packs__probe` to list the server's tools with their read-only hints and input schemas. Declare **every** tool it lists in `permissions.tools`, not only the ones the widget uses: the owner sees everything the pack can do, and the fast path and the execution agent can then use any of it. `packs__preview` and the connection check report tools the pack leaves out.
|
|
24
24
|
4. **Look at real results.** `packs__try` calls one read-only tool and returns the result, so you map the shape you actually get.
|
|
25
25
|
5. **Map results onto the UI library**: up to two glance stats (or one line), detail blocks, an optional tapped-row detail, spoken updates, and controls. Templates are lookups (below).
|
|
26
26
|
6. **See it as the phone will.** `packs__preview` validates the draft, runs its data queries against its server, and renders the widget exactly as the phone receives it. It returns a text rendering of the tile and every block, any query errors, and `previewUrl`: the companion's **Phone preview** page showing the draft first on the phone's home screen, beside the draft opened. Open `previewUrl` with your browser tools and look at it (a screenshot shows both phones at once). Or give the link to the user when they want to see it. Fix what looks wrong (empty lists, raw ids, cut-off titles, statuses on every row, the wrong tile size) and preview again after each change, until it reads well at a glance.
|
|
@@ -47,7 +47,7 @@ A pack folder holds:
|
|
|
47
47
|
| `platforms` | A different launch per OS: `{ "win32": { …launch… }, "darwin": …, "linux": … }`. Use it when an app installs to different places, e.g. `{{env.LOCALAPPDATA}}\\Programs\\…` on Windows. |
|
|
48
48
|
| `auth` | OAuth 2.0: `authorizationUrl`, `tokenUrl`, `scopes`, `clientIdSecret`, optional `clientSecretSecret` (both declared in `secrets`), `authorizationParams`. The companion runs consent on this computer, keeps tokens in the keychain, and refreshes them. An http server gets `Authorization: Bearer`; a stdio server must read `{{oauth.accessToken}}` from its `env` and is restarted when the token refreshes. |
|
|
49
49
|
| `secrets` | `[{ "name": "API_KEY", "description": "…" }]`: names only; values live in the keychain. |
|
|
50
|
-
| `permissions.tools` | **Every** tool the pack
|
|
50
|
+
| `permissions.tools` | **Every** tool the server offers (nothing undeclared is ever called, and a pack that leaves some out fails its connection check), with `access: read \| write`, optional `description`, and optional `argumentSettings` (`{ "repo": "repo" }` pins that argument to a required setting on every call), and `optional: true` for a tool the server offers only in some setups (for example only when the user has that kind of device), so checking the connection does not require it. A server whose tool list depends on the user's setup (one tool per device kind or user script) sets `permissions.toolsVary: true` and declares the tools every setup has; the ones it adds are never called. Nothing else is ever called. `permissions.network` lists hosts, for review. `permissions.reach` declares what the pack reaches beyond its tools, shown first when someone adds or turns it on: `computer` when its tools start agents, run commands, or otherwise act anywhere on the computer (an agents app, a shell, computer control); `files` for the user's files; `messages` for messages it reads or sends as the user; `devices` for home devices. Declare every one that applies; understating it gets a pack rejected in library review. |
|
|
51
51
|
| `settings` | `text`, `boolean`, or `select` fields with defaults; `required` and `format` (`directory`, `http-url`) for text. An `http-url` must use HTTPS unless it points at this computer or the local network (private addresses, Tailscale, `.local`). |
|
|
52
52
|
| `setup` | `steps` (what you or the user do to set it up), `prerequisites` (`[{ "name": "Node.js", "command": "node" }]`), and `detect.anyPath` (paths that exist when the app is installed; `~` and `{{env.NAME}}` allowed), which lets setup suggest the pack. |
|
|
53
53
|
| `skill` | `{ "path": "SKILL.md" }`. |
|
package/src/manage/guide.md
CHANGED
|
@@ -19,6 +19,10 @@ A widget comes from a **widget pack**: a folder on this computer that says which
|
|
|
19
19
|
|
|
20
20
|
A library pack that has a newer version shows **Update to …**. Updating keeps your settings and keys. If what the pack runs changed, it stays off until setup turns it on again. A pack installed before a format change shows **Reinstall**, which also keeps your settings and keys.
|
|
21
21
|
|
|
22
|
+
Sidebud installs a library pack only when the library's signature checks out and its files match what was signed. If an update lets a pack do more (a new tool, a tool that now changes things, a new address, key, or account access, or new server code), the page lists those changes and asks you first; your agent can never approve that for you. An update never replaces a pack you edited on this computer: customize a copy to keep your changes. After an update, **Roll back** returns to the version it replaced.
|
|
23
|
+
|
|
24
|
+
If Sidebud pulls a release from the library (for example, it turned out to be unsafe), a computer with that version turns the pack off, says why on this page and in your phone's conversation, and keeps it off until you update or roll back.
|
|
25
|
+
|
|
22
26
|
## Ask your agent to build one
|
|
23
27
|
|
|
24
28
|
You can ask for a new widget, or a change to one, by voice or typed chat. For example:
|
|
@@ -136,4 +140,5 @@ Besides packs, your agent can put a simple widget of its own on the phone ("add
|
|
|
136
140
|
- **Needs setup** on a tile: open the pack's page and choose **Set up with my agent**, or fill in what it lists.
|
|
137
141
|
- **An error in the widget**: the pack's server or account had a problem, and the widget shows its last good data with the error. **Check connection** on the pack's page tests it. Turning off a tool the widget reads also shows an error there.
|
|
138
142
|
- **The library is empty**: this computer cannot reach the widget library. Packs you already installed keep working.
|
|
143
|
+
- **Sidebud did not install a pack**: its download failed the signature or file check. Try again later; if it keeps failing, update the companion.
|
|
139
144
|
- **A widget looks different on the phone than here**: update the Sidebud app on the phone. An older app skips widget parts it does not know yet.
|
package/src/manage/manage.css
CHANGED
|
@@ -412,6 +412,27 @@ button.theme-toggle {
|
|
|
412
412
|
align-items: center;
|
|
413
413
|
gap: 8px;
|
|
414
414
|
}
|
|
415
|
+
/* A setting's source (ADR 0014): Account, This computer, or Default, and its pin toggle. */
|
|
416
|
+
.badge.source {
|
|
417
|
+
font-size: 11px;
|
|
418
|
+
padding: 4px 8px;
|
|
419
|
+
}
|
|
420
|
+
.badge.source.account {
|
|
421
|
+
background: var(--amber-soft);
|
|
422
|
+
color: var(--amber-text);
|
|
423
|
+
}
|
|
424
|
+
.badge.source.override {
|
|
425
|
+
background: var(--green-soft);
|
|
426
|
+
color: var(--green);
|
|
427
|
+
}
|
|
428
|
+
button.link.pin-toggle {
|
|
429
|
+
font-size: 12px;
|
|
430
|
+
font-weight: 500;
|
|
431
|
+
color: var(--muted);
|
|
432
|
+
}
|
|
433
|
+
button.link.pin-toggle:hover {
|
|
434
|
+
color: var(--text);
|
|
435
|
+
}
|
|
415
436
|
.tag-beta {
|
|
416
437
|
display: inline-block;
|
|
417
438
|
margin-left: 8px;
|
|
@@ -1053,6 +1074,16 @@ a.button {
|
|
|
1053
1074
|
a.button:hover {
|
|
1054
1075
|
background: var(--amber-hover);
|
|
1055
1076
|
}
|
|
1077
|
+
/* A link that leads to a review step rather than acting (a full-control or community pack): not the page's call to action. */
|
|
1078
|
+
a.button.quiet {
|
|
1079
|
+
background: var(--raised);
|
|
1080
|
+
border: 1px solid var(--border);
|
|
1081
|
+
color: var(--text);
|
|
1082
|
+
text-align: center;
|
|
1083
|
+
}
|
|
1084
|
+
a.button.quiet:hover {
|
|
1085
|
+
background: var(--hover);
|
|
1086
|
+
}
|
|
1056
1087
|
|
|
1057
1088
|
/* Setup */
|
|
1058
1089
|
.setup-steps {
|
|
@@ -1246,6 +1277,24 @@ ol.steps {
|
|
|
1246
1277
|
margin: 0;
|
|
1247
1278
|
padding-left: 20px;
|
|
1248
1279
|
}
|
|
1280
|
+
/* Account settings sync */
|
|
1281
|
+
.sync-block {
|
|
1282
|
+
border-top: 1px solid var(--border-soft);
|
|
1283
|
+
padding-top: 14px;
|
|
1284
|
+
}
|
|
1285
|
+
.sync-list {
|
|
1286
|
+
margin: 4px 0 8px;
|
|
1287
|
+
padding-left: 20px;
|
|
1288
|
+
}
|
|
1289
|
+
.sync-conflict {
|
|
1290
|
+
border-top: 1px solid var(--border-soft);
|
|
1291
|
+
padding-top: 12px;
|
|
1292
|
+
}
|
|
1293
|
+
.sync-conflict .hint {
|
|
1294
|
+
margin: 0;
|
|
1295
|
+
overflow-wrap: anywhere;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1249
1298
|
.segmented {
|
|
1250
1299
|
display: inline-flex;
|
|
1251
1300
|
gap: 4px;
|
|
@@ -2026,3 +2075,45 @@ pre.skill {
|
|
|
2026
2075
|
gap: 8px;
|
|
2027
2076
|
font-weight: 500;
|
|
2028
2077
|
}
|
|
2078
|
+
|
|
2079
|
+
/* How much a pack can control (packAccessOverview): shown first before it is added or turned on. */
|
|
2080
|
+
.access-overview {
|
|
2081
|
+
margin: 16px 0;
|
|
2082
|
+
}
|
|
2083
|
+
.access-overview.level-computer {
|
|
2084
|
+
border-color: var(--red);
|
|
2085
|
+
background: var(--red-soft);
|
|
2086
|
+
}
|
|
2087
|
+
.access-overview.level-computer h3 {
|
|
2088
|
+
color: var(--red);
|
|
2089
|
+
}
|
|
2090
|
+
.access-points {
|
|
2091
|
+
display: grid;
|
|
2092
|
+
gap: 8px;
|
|
2093
|
+
margin: 0;
|
|
2094
|
+
padding-left: 20px;
|
|
2095
|
+
font-size: 14px;
|
|
2096
|
+
line-height: 1.5;
|
|
2097
|
+
}
|
|
2098
|
+
.access-points li.danger {
|
|
2099
|
+
color: var(--text);
|
|
2100
|
+
font-weight: 600;
|
|
2101
|
+
}
|
|
2102
|
+
.access-points li.danger::marker {
|
|
2103
|
+
color: var(--red);
|
|
2104
|
+
}
|
|
2105
|
+
.access-points li.caution::marker {
|
|
2106
|
+
color: var(--amber-text);
|
|
2107
|
+
}
|
|
2108
|
+
.access-points li.info {
|
|
2109
|
+
color: var(--muted);
|
|
2110
|
+
}
|
|
2111
|
+
.access-line {
|
|
2112
|
+
display: grid;
|
|
2113
|
+
justify-items: start;
|
|
2114
|
+
gap: 6px;
|
|
2115
|
+
margin-top: 6px;
|
|
2116
|
+
}
|
|
2117
|
+
.access-line:empty {
|
|
2118
|
+
display: none;
|
|
2119
|
+
}
|
package/src/manage/manage.js
CHANGED
|
@@ -59,7 +59,8 @@ async function api(path, payload) {
|
|
|
59
59
|
}).catch(() => { throw new Error(RESTARTED); });
|
|
60
60
|
const data = await response.json();
|
|
61
61
|
if (!response.ok)
|
|
62
|
-
|
|
62
|
+
// The body rides along for answers that ask something of the owner (an update that needs consent).
|
|
63
|
+
throw Object.assign(new Error(data.error || `Request failed (${response.status})`), { data });
|
|
63
64
|
return data;
|
|
64
65
|
}
|
|
65
66
|
/** Re-reads companion state; background polls re-render only when it changed, so forms keep their input. */
|
|
@@ -70,8 +71,9 @@ async function reload(force = true) {
|
|
|
70
71
|
state = next;
|
|
71
72
|
if (changed) render();
|
|
72
73
|
clearTimeout(reload.timer);
|
|
73
|
-
// Poll only while something is in progress: an account connection, a setup check,
|
|
74
|
-
|
|
74
|
+
// Poll only while something is in progress: an account connection, a setup check, pairing during setup, a pack's setup, or a sync.
|
|
75
|
+
const packSetupRunning = Object.values(state.packSetup || {}).some((setup) => setup.running);
|
|
76
|
+
if (state.connecting.length || setupWaiting() || packSetupRunning || state.account.status === 'pending' || state.sync?.state === 'syncing') reload.timer = setTimeout(() => reload(false), 1500);
|
|
75
77
|
} catch (error) {
|
|
76
78
|
$('#app').innerHTML = `<div class="card">${esc(error.message)}</div>`;
|
|
77
79
|
}
|
|
@@ -93,7 +95,7 @@ async function applyChanges() {
|
|
|
93
95
|
await pause(500);
|
|
94
96
|
try {
|
|
95
97
|
const next = await api('state');
|
|
96
|
-
if (!next.restart.pending) {
|
|
98
|
+
if (!next.restart.pending && !next.sync?.restartPending) {
|
|
97
99
|
state = next;
|
|
98
100
|
notice('Changes applied.');
|
|
99
101
|
return true;
|
|
@@ -118,8 +120,21 @@ function showsExecution() {
|
|
|
118
120
|
return section() === 'execution' || (section() === 'setup' && currentSetupStep() === 'execution' && Boolean(state.providers));
|
|
119
121
|
}
|
|
120
122
|
function restartBanner() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
// Changes from the account (ADR 0014) that the running companion reads only at start apply the same way.
|
|
124
|
+
if (!state.restart.pending && !state.sync?.restartPending) return '';
|
|
125
|
+
return `<div class="banner"><div><strong>${state.restart.pending ? 'Saved changes apply after a restart.' : 'Changes from your account apply after a restart.'}</strong><p class="hint">Restarting takes a few seconds and ends any call in progress.</p></div><button class="primary" data-action="apply-changes" ${restarting ? 'disabled' : ''}>${restarting ? 'Applying…' : 'Apply now'}</button></div>`;
|
|
126
|
+
}
|
|
127
|
+
/** A newer release is on npm; updating is the user's command (shown exactly), never automatic. */
|
|
128
|
+
function updateBanner() {
|
|
129
|
+
const updates = state.install?.updates;
|
|
130
|
+
const update = state.install?.update;
|
|
131
|
+
if (!updates?.available) return '';
|
|
132
|
+
if (update?.scheduledAt)
|
|
133
|
+
return `<div class="banner"><div><strong>Sidebud ${esc(updates.latest)} installs ${esc(new Date(update.scheduledAt).toLocaleString())}</strong><p class="hint">It waits until no call or job is running, then restarts the companion.</p></div><div class="actions"><button data-action="update" data-when="now">Update now</button><button data-action="update" data-when="cancel">Cancel</button></div></div>`;
|
|
134
|
+
if (update?.canApply)
|
|
135
|
+
return `<div class="banner"><div><strong>Update available: Sidebud ${esc(updates.latest)}</strong><p class="hint">This computer has ${esc(updates.current)}. Updating restarts the companion and ends any call in progress.${update.lastError ? ` The last attempt failed: ${esc(update.lastError.message)}` : ''}</p></div><div class="actions"><button class="primary" data-action="update" data-when="now" ${update.applying ? 'disabled' : ''}>${update.applying ? 'Updating…' : 'Update now'}</button><button data-action="update" data-when="idle" title="As soon as no call is live and no agent work runs here">When idle</button><button data-action="update" data-when="tonight">Tonight</button></div></div>`;
|
|
136
|
+
const restart = state.install.kind === 'npx' ? 'Quit Sidebud first; the command starts the new version.' : state.install.loginItem.running ? 'The restart ends any call in progress.' : 'Then quit and start Sidebud again.';
|
|
137
|
+
return `<div class="banner"><div><strong>Update available: Sidebud ${esc(updates.latest)}</strong><p class="hint">This computer has ${esc(updates.current)}. Run <span class="code">${esc(updates.command)}</span> in a terminal. ${restart}</p></div></div>`;
|
|
123
138
|
}
|
|
124
139
|
function setupBanner() {
|
|
125
140
|
if (state.setup.completedAt || section() === 'setup') return '';
|
|
@@ -134,7 +149,7 @@ function render() {
|
|
|
134
149
|
.querySelectorAll('nav a')
|
|
135
150
|
.forEach((a) => a.classList.toggle('active', a.hash === `#${current}`));
|
|
136
151
|
const scrollTo = location.hash.startsWith('#guide/');
|
|
137
|
-
$('#app').innerHTML = restartBanner() + setupBanner() + {
|
|
152
|
+
$('#app').innerHTML = restartBanner() + updateBanner() + setupBanner() + {
|
|
138
153
|
setup: setupView,
|
|
139
154
|
connection: connectionView,
|
|
140
155
|
voice: voiceView,
|
|
@@ -195,13 +210,35 @@ function connectionView() {
|
|
|
195
210
|
const connected = new Set(state.connectedDeviceIds || []);
|
|
196
211
|
return `<section class="section"><div><h2>Connect a phone</h2><p class="lede">Pair your phone with ${esc(state.name)}. This page manages the companion on this computer; choose among your computers in the phone app. Rename this computer under <a href="#settings">Settings</a>.</p></div><div class="card stack"><div><h3>Pairing code</h3><p class="hint">On your phone, choose Add computer → Scan pairing code. Each code works once and expires shortly.</p></div>${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.' : `Expires ${esc(new Date(pairing.expiresAt).toLocaleString())}.`}</p>` : `<p class="muted">${pairingLoading ? 'Preparing pairing code…' : 'Generate a code to connect this phone.'}</p>`}${pairingError ? `<p role="alert" class="hint error-text">Could not create a pairing code: ${esc(pairingError)} Check this computer’s reachable address in config.json, then try again.</p>` : ''}<div class="actions"><button class="primary" data-action="pair-phone" ${pairingLoading ? 'disabled' : ''}>${pairingLoading ? 'Preparing…' : pairing ? 'Generate a new code' : pairingError ? 'Try again' : 'Generate pairing code'}</button></div></div><div><h3>Paired phones</h3><p class="lede">${active.length ? 'Phones authorized to use this computer.' : 'No phone is paired yet. Scan the code above to connect your first phone.'}</p></div><div class="grid">${active.map((device) => `<div class="card"><div class="row"><div><h3>${esc(device.name)}</h3><p class="hint">${esc(device.platform)} · Paired ${esc(new Date(device.createdAt).toLocaleDateString())}</p></div><span class="badge ${connected.has(device.id) ? 'good' : ''}">${connected.has(device.id) ? 'Connected now' : 'Offline'}</span></div><p class="hint">${device.lastSeenAt ? `Last seen ${esc(new Date(device.lastSeenAt).toLocaleString())}` : 'Has not connected yet'} · Credential expires ${esc(new Date(device.expiresAt).toLocaleDateString())}</p><div class="actions"><button class="danger" data-action="revoke-phone" data-id="${esc(device.id)}">Remove phone</button></div></div>`).join('') || '<div class="card muted">Your paired phones will appear here.</div>'}</div><div class="actions"><button data-action="refresh">Refresh status</button></div></section>`;
|
|
197
212
|
}
|
|
198
|
-
|
|
213
|
+
const SOURCES = {
|
|
214
|
+
account: { label: 'Account', title: 'From your Sidebud account' },
|
|
215
|
+
override: { label: 'This computer', title: 'Kept on this computer when your account’s settings change' },
|
|
216
|
+
default: { label: 'Default', title: 'Sidebud’s default; neither your account nor this computer sets it' },
|
|
217
|
+
};
|
|
218
|
+
/** A value's source by dotted path: the path itself, else the nearest enclosing field, else the default. */
|
|
219
|
+
function settingSource(section, path) {
|
|
220
|
+
const sources = state.settingSources?.[section] || {};
|
|
221
|
+
const keys = path.split('.');
|
|
222
|
+
for (let length = keys.length; length > 0; length -= 1) {
|
|
223
|
+
const source = sources[keys.slice(0, length).join('.')];
|
|
224
|
+
if (source) return source;
|
|
225
|
+
}
|
|
226
|
+
return 'default';
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Where a synced value comes from (ADR 0014). While sync is on: Account, This computer, or Default, with a
|
|
230
|
+
* toggle to pin it here or follow the account again. While sync is off, only values pinned here are marked.
|
|
231
|
+
*/
|
|
199
232
|
function sourceBadge(section, path) {
|
|
200
|
-
return state.settingSources?.[section]?.[path] === 'override' ?
|
|
233
|
+
if (!state.sync?.enabled) return state.settingSources?.[section]?.[path] === 'override' ? `<span class="badge source" title="${SOURCES.override.title}">${SOURCES.override.label}</span>` : '';
|
|
234
|
+
const source = settingSource(section, path);
|
|
235
|
+
const pinned = source === 'override';
|
|
236
|
+
return `<span class="badge source ${source}" title="${SOURCES[source].title}">${SOURCES[source].label}</span><button type="button" class="link pin-toggle" data-action="setting-pin" data-section="${esc(section)}" data-path="${esc(path)}" data-pinned="${pinned}">${pinned ? 'Follow account' : 'Keep on this computer'}</button>`;
|
|
201
237
|
}
|
|
202
|
-
/** A field label with its source badge on the same line. */
|
|
238
|
+
/** A field label with its source badge on the same line; while sync is on, one badge per field (a pinned path first). */
|
|
203
239
|
function sourceLabel(text, section, ...paths) {
|
|
204
|
-
const
|
|
240
|
+
const shown = state.sync?.enabled ? [paths.find((path) => state.settingSources?.[section]?.[path] === 'override') || paths[0]] : paths;
|
|
241
|
+
const badges = shown.map((path) => sourceBadge(section, path)).join('');
|
|
205
242
|
return badges ? `<span class="label-text">${text}${badges}</span>` : text;
|
|
206
243
|
}
|
|
207
244
|
/** A synced voice choice can name a provider whose key is not on this computer; keys never come from the phone. */
|
|
@@ -236,7 +273,7 @@ const voiceSearchSupported = (provider) => Boolean(provider?.capabilities.webSea
|
|
|
236
273
|
/** Region and workspace fields, for providers whose endpoint needs them. */
|
|
237
274
|
function voiceEndpointFields(provider, voice) {
|
|
238
275
|
const regions = provider?.regions || [];
|
|
239
|
-
return `${regions.length ? `<label
|
|
276
|
+
return `${regions.length ? `<label>${sourceLabel('Region', 'voice', 'region')}<select id="voice-region">${regions.map((region) => `<option value="${esc(region.id)}" ${(voice.region || regions[0].id) === region.id ? 'selected' : ''}>${esc(region.label)}</option>`).join('')}</select></label>` : ''}${provider?.needsWorkspaceId ? `<label>${sourceLabel('Workspace ID', 'voice', 'workspaceId')}<input id="voice-workspace" maxlength="200" autocomplete="off" spellcheck="false" value="${esc(voice.workspaceId || '')}" placeholder="ws-… or paste the API Host"></label><p class="hint">In Model Studio, paste the <strong>API Host</strong> (or base URL) shown with your API key, such as <span class="code">ws-abc123.ap-southeast-1.maas.aliyuncs.com</span>; the workspace ID and region are read from it. It is not a secret; the API key below is.</p>` : ''}`;
|
|
240
277
|
}
|
|
241
278
|
/** Model Studio shows an API host (`ws-….ap-southeast-1.maas.aliyuncs.com`) or base URL, not a bare ID; both carry the ID and region. */
|
|
242
279
|
const MODEL_STUDIO_HOST = /^(?:[a-z]+:\/\/)?([a-z0-9-]+)\.(ap-southeast-1|cn-beijing)\.maas\.aliyuncs\.com/i;
|
|
@@ -286,12 +323,23 @@ function jevView() {
|
|
|
286
323
|
<label>Model<input id="jev-model" value="${esc(config.model)}"></label>
|
|
287
324
|
<label>Act only at this confidence or higher (0.5–0.99)<input id="jev-confidence" type="number" min="0.5" max="0.99" step="0.01" value="${config.minConfidence}"></label>
|
|
288
325
|
<label>Give up after (ms)<input id="jev-timeout" type="number" min="200" max="10000" step="100" value="${config.timeoutMs}"></label>
|
|
289
|
-
${jev.computerUse ?
|
|
326
|
+
${jev.computerUse ? computerUseView(jev.computerUse) : ''}
|
|
290
327
|
<div class="actions"><button class="primary" data-action="save-jev">Save fast decisions</button><button data-action="test-jev" ${status.state === 'ready' ? '' : 'disabled'}>Test</button></div>
|
|
291
328
|
<h4>TypeSafe API key</h4><p class="hint">${keyHint}</p>
|
|
292
329
|
<div class="actions"><input id="jev-key" type="password" autocomplete="new-password" aria-label="TypeSafe API key" placeholder="Paste a key to set or replace"><button data-action="save-jev-key">Save key</button>${key.isSet && !key.fromEnv ? '<button class="danger" data-action="clear-jev-key">Clear key</button>' : ''}</div>
|
|
293
330
|
<p class="hint">Changes apply after a restart; you will be prompted.</p></div>`;
|
|
294
331
|
}
|
|
332
|
+
function computerUseView({ available, access }) {
|
|
333
|
+
if (!available) return '<div class="stack"><h4>Computer use</h4><p class="hint">Off: turn on fast decisions to let Jev drive the screen.</p></div>';
|
|
334
|
+
const host = access?.host || 'The app running the companion';
|
|
335
|
+
const badge = !access ? '' : access.accessibility ? `<span class="badge good">Accessibility allowed</span>` : `<span class="badge warn">Needs Accessibility</span>`;
|
|
336
|
+
const note = !access
|
|
337
|
+
? 'The app running the companion needs Accessibility access in System Settings.'
|
|
338
|
+
: access.accessibility
|
|
339
|
+
? `${esc(host)} (the app running the companion) has Accessibility access.`
|
|
340
|
+
: `${esc(host)} (the app running the companion) needs Accessibility access: open System Settings › Privacy & Security › Accessibility, turn on ${esc(host)} (add it with + if it is not listed).`;
|
|
341
|
+
return `<div class="stack"><div class="row"><h4>Computer use</h4>${badge}</div><p class="hint">On: for click, scroll, and type requests that no integration covers, Jev moves the cursor, clicks, scrolls, and types on this computer. ${note}</p></div>`;
|
|
342
|
+
}
|
|
295
343
|
function voiceView() {
|
|
296
344
|
const { voice, catalog } = state.voice;
|
|
297
345
|
const selected = catalog[voice.provider];
|
|
@@ -556,9 +604,11 @@ function executionView({ embedded = false } = {}) {
|
|
|
556
604
|
const readiness = instance => !instance.enabled ? 'Disabled' : !instance.status ? 'Not checked' : !instance.status.installed ? 'Not installed' : instance.status.auth === 'unauthenticated' ? 'Sign-in needed' : instance.status.auth === 'authenticated' ? 'Ready' : 'Installed · sign-in not verified';
|
|
557
605
|
const profiles = providers?.profiles.filter(profile => instances.some(instance => [instance.instanceId, instance.displayName, instance.driver].includes(profile.provider))) || [];
|
|
558
606
|
const promptFor = executor => executor.connector === 'direct' ? state.profilePrompts?.[executor.id.slice('direct:'.length)] : undefined;
|
|
559
|
-
const
|
|
607
|
+
const needsFor = executor => executor.connector === 'direct' ? needsBadge(executor.id.slice('direct:'.length)) : '';
|
|
608
|
+
const agentRows = group.agents.map(executor => `<div class="agent-choice ${executor.isDefault ? 'chosen' : ''}"><div><strong>${esc(executor.label)}</strong><p class="hint">${esc(executor.detail || 'Uses the provider’s default model')}${promptFor(executor) ? ' · Custom system prompt' : ''}</p></div><div class="agent-actions">${needsFor(executor)}${executor.connector === 'direct' ? `<button data-action="edit-profile" data-id="${esc(executor.id.slice('direct:'.length))}" aria-label="Edit ${esc(executor.label)} model, thinking level, permissions, and system prompt">Edit</button>` : ''}${executor.isDefault ? '<span class="badge good">Default</span>' : `<button data-action="set-default-executor" data-id="${esc(executor.id)}" aria-label="Use ${esc(executor.label)} by default">Use by default</button>`}</div></div>`).join('');
|
|
560
609
|
return `<section class="section execution">${embedded ? '<p class="lede">Choose the agent on this computer that does the work you ask for. Voice hands requests to it.</p>' : '<div><h2>Who does the work?</h2><p class="lede">Choose the agent that handles tasks you send from your phone.</p></div>'}
|
|
561
610
|
<div class="default-agent"><div><small>YOUR DEFAULT AGENT</small><h3>${sourceLabel(esc(selected?.label || 'Choose an agent below'), 'agents', 'defaultProfileId')}</h3><p>${esc(selected?.detail || 'Add an agent, then choose it as your default.')}</p></div><span class="badge">${selected ? 'Used unless you name another agent' : 'Not set up yet'}</span></div>
|
|
611
|
+
${waitingProfilesCard(providers)}
|
|
562
612
|
<div class="row"><h3>Choose a provider</h3><button data-action="refresh-executors">Check availability</button></div>
|
|
563
613
|
<div class="provider-tabs" role="tablist" aria-label="Execution providers">${groups.map(candidate => `<button role="tab" aria-selected="${candidate.id === group.id}" class="provider-tab ${candidate.id === group.id ? 'selected' : ''}" data-action="select-execution-provider" data-id="${esc(candidate.id)}"><strong>${esc(candidate.label)}</strong><span>${candidate.agents.length ? `${candidate.agents.length} agent${candidate.agents.length === 1 ? '' : 's'}` : 'Set up'}</span></button>`).join('')}</div>
|
|
564
614
|
<div class="card stack" role="tabpanel" aria-label="${esc(group.label)} agents"><div class="row"><div><h3>${esc(group.label)}</h3><p class="hint">Agents and models running on this computer.</p></div></div>
|
|
@@ -574,12 +624,24 @@ function executionView({ embedded = false } = {}) {
|
|
|
574
624
|
<details><summary>Permissions</summary><label>When the agent changes files or runs commands<select id="profile-mode">${runtimeModeChoices('approval-required')}</select></label></details>
|
|
575
625
|
<button class="primary" data-action="quick-add-agent" data-driver="${esc(group.id)}">Add agent and use by default</button><p class="hint">Uses this provider’s local installation and sign-in. Adding an agent does not install the provider or grant access to additional folders.</p>
|
|
576
626
|
</div></details>
|
|
577
|
-
${instances.length ? `<details><summary>Manage ${esc(group.label)} installation and agents</summary><div class="stack">${instances.map(instance => `<div class="row"><strong>${esc(instance.displayName)}</strong><div class="actions"><button data-action="edit-provider" data-id="${esc(instance.instanceId)}">Settings</button><button data-action="toggle-provider" data-id="${esc(instance.instanceId)}" data-driver="${esc(instance.driver)}" data-enabled="${instance.enabled}">${instance.enabled ? 'Disable' : 'Enable'}</button><button class="danger" data-action="remove-provider" data-id="${esc(instance.instanceId)}">Remove installation</button></div></div>`).join('')}${profiles.map(profile => `<div class="row"><span>${esc(profile.name)}${profile.available ? '' : ' · unavailable'}</span><button class="danger" data-action="remove-profile" data-id="${esc(profile.id)}">Remove agent</button></div>`).join('')}</div></details>` : ''}`}
|
|
627
|
+
${instances.length ? `<details><summary>Manage ${esc(group.label)} installation and agents</summary><div class="stack">${instances.map(instance => `<div class="row"><strong>${esc(instance.displayName)}</strong><div class="actions"><button data-action="edit-provider" data-id="${esc(instance.instanceId)}">Settings</button><button data-action="toggle-provider" data-id="${esc(instance.instanceId)}" data-driver="${esc(instance.driver)}" data-enabled="${instance.enabled}">${instance.enabled ? 'Disable' : 'Enable'}</button><button class="danger" data-action="remove-provider" data-id="${esc(instance.instanceId)}">Remove installation</button></div></div>`).join('')}${profiles.map(profile => `<div class="row"><span>${esc(profile.name)}${profile.available ? '' : ' · unavailable'} ${needsBadge(profile.id)}</span><button class="danger" data-action="remove-profile" data-id="${esc(profile.id)}">Remove agent</button></div>`).join('')}</div></details>` : ''}`}
|
|
578
628
|
</div><div id="profile-editor"></div><div id="provider-editor"></div>
|
|
579
629
|
${providers ? `<div class="card stack"><h3>Where local agents work</h3><p class="hint">${providers.folderRoots?.length ? `Any folder inside ${providers.folderRoots.map(root => `<span class="code">${esc(root)}</span>`).join(', ')}. Ask for a project by name or path; the companion lists the git projects it finds there.` : 'Only the named folders below.'} Set <span class="code">providers.folderRoots</span> in config.json to change this.</p>${providers.workspaces.length ? `<details><summary>Named folders · ${providers.workspaces.length}</summary>${providers.workspaces.map(workspace => `<p><strong>${esc(workspace.name)}</strong><br><span class="code">${esc(workspace.path)}</span></p>`).join('')}</details>` : ''}</div>` : ''}
|
|
580
630
|
${defaultAgentsView()}
|
|
581
631
|
</section>`;
|
|
582
632
|
}
|
|
633
|
+
/** A profile whose app (driver) is not set up here, e.g. one from another computer: "Needs Claude Code on this computer". */
|
|
634
|
+
function needsBadge(profileId) {
|
|
635
|
+
const needs = state.profileNeeds?.[profileId];
|
|
636
|
+
return needs ? `<span class="badge warn">${esc(needs)}</span>` : '';
|
|
637
|
+
}
|
|
638
|
+
/** Profiles with no installation here at all, which no provider tab lists. */
|
|
639
|
+
function waitingProfilesCard(providers) {
|
|
640
|
+
const waiting = Object.keys(state.profileNeeds || {}).map(id => providers?.profiles.find(profile => profile.id === id) || { id, name: id, provider: null })
|
|
641
|
+
.filter(profile => !providers?.instances.some(instance => [instance.instanceId, instance.displayName, instance.driver].includes(profile.provider)));
|
|
642
|
+
if (!waiting.length) return '';
|
|
643
|
+
return `<div class="card stack"><div><h3>Agents waiting for their app</h3><p class="hint">Install and sign in to the app each agent uses on this computer, then check availability.</p></div>${waiting.map(profile => `<div class="row"><strong>${esc(profile.name)}</strong>${needsBadge(profile.id)}</div>`).join('')}</div>`;
|
|
644
|
+
}
|
|
583
645
|
const RUNTIME_MODES = [['approval-required', 'Ask me first'], ['read-only', 'Read only'], ['auto-accept-edits', 'Accept edits automatically'], ['auto', 'Automatic'], ['full-access', 'Full access']];
|
|
584
646
|
function runtimeModeChoices(selected) {
|
|
585
647
|
return RUNTIME_MODES.map(([value, label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
|
|
@@ -612,18 +674,50 @@ function accountLabel(value) {
|
|
|
612
674
|
}[value] || 'No OAuth sign-in required'
|
|
613
675
|
);
|
|
614
676
|
}
|
|
677
|
+
/** Packs the library pulled: turned off here, and said so at the top of the page until updated, rolled back, or lifted. */
|
|
678
|
+
function pulledBanner() {
|
|
679
|
+
const pulled = state.packs.packs.filter((pack) => pack.pulled);
|
|
680
|
+
return pulled.length ? `<div class="card trust-notice community" role="alert"><strong>${pulled.length === 1 ? `${esc(pulled[0].name)} was turned off` : `${pulled.length} integrations were turned off`}</strong>${pulled.map((pack) => `<p>Sidebud pulled ${esc(pack.name)} ${esc(pack.pulled.version)} from the widget library: ${esc(pack.pulled.reason)}. It stays off until you update it${pack.rollbackVersion ? ` or roll back to ${esc(pack.rollbackVersion)}` : ''}.</p>`).join('')}</div>` : '';
|
|
681
|
+
}
|
|
615
682
|
function packStatusDetail(pack) {
|
|
616
683
|
const detail = pack.status.detail || '';
|
|
617
684
|
return detail.startsWith('Connect your account: run `rva-companion pack auth')
|
|
618
685
|
? 'Connect the account in Settings & permissions.'
|
|
619
686
|
: detail;
|
|
620
687
|
}
|
|
688
|
+
/** How the computer's agent's setup of a pack went: working on it, or its reply once done. */
|
|
689
|
+
function packSetupLine(packId) {
|
|
690
|
+
const setup = (state.packSetup || {})[packId];
|
|
691
|
+
if (!setup) return '';
|
|
692
|
+
if (setup.running) return '<p class="hint" role="status">Setup: this computer\'s agent is working on it…</p>';
|
|
693
|
+
// The agent answers in Markdown; the card shows plain text.
|
|
694
|
+
const text = (setup.reply || '').replace(/\*\*|__|`/g, '').replace(/^#+\s*/gm, '');
|
|
695
|
+
const reply = text.length > 600 ? `${text.slice(0, 600)}…` : text;
|
|
696
|
+
return setup.failed
|
|
697
|
+
? `<p class="hint bad" role="status">Setup failed: ${esc(setup.blocker || reply || 'the agent stopped.')}</p>`
|
|
698
|
+
: `<p class="hint" role="status">Setup finished: ${esc(reply || 'no reply.')}</p>`;
|
|
699
|
+
}
|
|
700
|
+
/** A library card's state line: deprecated, pulled, or edited here, each with what to do about it. */
|
|
701
|
+
function catalogNotes(entry) {
|
|
702
|
+
return [
|
|
703
|
+
entry.pulled ? `<p class="hint"><span class="badge bad">Pulled</span> Sidebud pulled the installed version from the library: ${esc(entry.pulled)}. It is off until you update it${entry.canRollback ? ' or roll back' : ''}.</p>` : '',
|
|
704
|
+
entry.deprecated ? `<p class="hint"><span class="badge warn">Deprecated</span> ${esc(entry.deprecated)}</p>` : '',
|
|
705
|
+
entry.customized ? `<p class="hint"><span class="badge">Changed here</span> This copy was edited on this computer, so library updates are not installed over it. Customize a copy to keep your changes, or remove it and install again.</p>` : '',
|
|
706
|
+
].join('');
|
|
707
|
+
}
|
|
708
|
+
function catalogAction(entry) {
|
|
709
|
+
if ((entry.publisher.source === 'community' || controlsComputer(entry.access)) && !entry.installed) return `<a class="button quiet" href="#mcp/library/${encodeURIComponent(entry.id)}">Review and install</a>`;
|
|
710
|
+
const rollback = entry.canRollback ? `<button data-action="rollback-pack" data-id="${esc(entry.packId)}">Roll back</button>` : '';
|
|
711
|
+
if (entry.updateAvailable) return `<div class="actions"><button data-action="install-library-pack" data-id="${esc(entry.id)}" data-replace="true" ${entry.customized ? 'disabled title="Edited on this computer: customize a copy to keep your changes, or remove it and install again."' : ''} aria-label="Update ${esc(entry.name)} from version ${esc(entry.installedVersion)} to ${esc(entry.version)}">Update to ${esc(entry.version)}</button>${rollback}</div>`;
|
|
712
|
+
const install = `<button data-action="install-library-pack" data-id="${esc(entry.id)}" ${entry.installed ? 'disabled' : ''} ${entry.needsReinstall ? `title="The installed copy no longer loads. Reinstalling keeps its settings and keys."` : ''}>${entry.installed ? `Installed · ${esc(entry.installedVersion)}` : entry.needsReinstall ? `Reinstall ${esc(entry.name)}` : `Install ${esc(entry.name)}`}</button>`;
|
|
713
|
+
return rollback ? `<div class="actions">${install}${rollback}</div>` : install;
|
|
714
|
+
}
|
|
621
715
|
function catalogView() {
|
|
622
716
|
const intro = `<div><h3>Widget library</h3><p class="hint">Widget packs from the Sidebud widget library. Install one and this computer's agent sets it up: it finds settings and keys already on this computer and reports what needs you. Every new install starts disabled until setup enables it. Your agent can also build new packs, or change any installed one, when you ask.</p>${state.libraryError ? `<p class="hint">The library could not be reached: ${esc(state.libraryError)}</p>` : ''}</div>`;
|
|
623
|
-
return `${intro}<div class="grid">${state.integrationCatalog.map(entry => `<article class="card stack"><div class="row"><h3><a href="#mcp/library/${encodeURIComponent(entry.id)}">${esc(entry.name)}</a></h3><span class="badge ${entry.publisher.source === 'community' && !entry.publisher.official ? 'warn' : ''}">${entry.installed ? 'Installed' : entry.detected ? 'App found' : entry.publisher.official ? `Official · ${esc(entry.publisher.official.domain)}` : entry.publisher.source === 'community' ? 'Community' : esc(entry.publisher.name)}</span></div><p>${esc(entry.description)}</p><p class="hint">${esc(entry.account)} · ${esc(entry.connectsTo)}</p>${entry.
|
|
717
|
+
return `${intro}<div class="grid">${state.integrationCatalog.map(entry => `<article class="card stack"><div class="row"><h3><a href="#mcp/library/${encodeURIComponent(entry.id)}">${esc(entry.name)}</a></h3><span class="badge ${entry.publisher.source === 'community' && !entry.publisher.official ? 'warn' : ''}">${entry.installed ? 'Installed' : entry.detected ? 'App found' : entry.publisher.official ? `Official · ${esc(entry.publisher.official.domain)}` : entry.publisher.source === 'community' ? 'Community' : esc(entry.publisher.name)}</span></div><p>${esc(entry.description)}</p><p class="hint">${esc(entry.account)} · ${esc(entry.connectsTo)}</p><div class="access-line">${accessBadge(entry.access)}${controlsComputer(entry.access) ? `<p class="hint">${esc(entry.access.points[0].text)}</p>` : ''}</div>${catalogNotes(entry)}<a class="hint" href="#mcp/library/${encodeURIComponent(entry.id)}">What it can do and how it looks →</a>${catalogAction(entry)}</article>`).join('')}</div>`;
|
|
624
718
|
}
|
|
625
719
|
function mcpView() {
|
|
626
|
-
return `<section class="section"><div><h2>Integrations & widgets</h2><p class="lede">Connect tools to Sidebud and choose which ones appear on your phone. Credentials and connections stay on this computer. New here? Start with the <a href="#guide">Guide</a>.</p></div
|
|
720
|
+
return `<section class="section"><div><h2>Integrations & widgets</h2><p class="lede">Connect tools to Sidebud and choose which ones appear on your phone. Credentials and connections stay on this computer. New here? Start with the <a href="#guide">Guide</a>.</p></div>${pulledBanner()}<h3>Installed integrations</h3><div class="grid">${state.packs.packs.map(p => `<div class="card"><div class="row"><h3><a href="#mcp/${encodeURIComponent(p.id)}">${esc(p.name)}</a></h3>${p.pulled ? '<span class="badge bad">Pulled</span>' : `<span class="badge ${{ ok: 'good', needs_setup: 'warn', error: 'bad' }[p.status.state] || ''}">${esc({ ok: 'Enabled', needs_setup: 'Needs setup', error: 'Error', disabled: 'Disabled' }[p.status.state] || p.status.state)}</span>`}</div><p class="hint">${esc(p.description)}</p><div class="access-line">${accessBadge(state.packAccess[p.id])}</div><p class="hint">${esc(packStatusDetail(p))}</p>${packSetupLine(p.id)}${state.packAuth[p.id] ? `<p class="hint">Account: ${esc(accountLabel(state.packAuth[p.id]))}</p>` : ''}${state.connectionErrors[p.id] ? `<p class="hint">Connection failed: ${esc(state.connectionErrors[p.id])}</p>` : ''}${p.refresh ? `<label class="hint">Refresh every <select data-refresh-pack="${esc(p.id)}" aria-label="Refresh ${esc(p.name)} every">${[...new Set([...p.refresh.options, p.refresh.defaultSeconds, p.refresh.seconds])].sort((a, b) => a - b).map((seconds) => `<option value="${seconds}" ${seconds === p.refresh.seconds ? 'selected' : ''}>${esc(interval(seconds))}${seconds === p.refresh.defaultSeconds ? ' (default)' : ''}</option>`).join('')}</select></label>` : ''}${p.announce ? `<p class="hint">Spoken updates: ${p.announce.on ? 'on' : 'off'}${p.announce.custom ? '' : ' (default)'} · <button class="link" data-action="toggle-announce" data-id="${esc(p.id)}" data-on="${p.announce.on}">${p.announce.on ? 'Turn off' : 'Turn on'}</button>${p.announce.custom ? ` · <button class="link" data-action="default-announce" data-id="${esc(p.id)}">Use default</button>` : ''}</p>` : ''}<div class="actions pack-actions">${p.enabled ? '' : `<button class="primary" data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="false">Turn on</button>`}<button ${p.enabled ? '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="share-pack" disabled title="Publishing to your Sidebud account arrives with accounts">Share</button><details class="menu"><summary aria-label="More actions for ${esc(p.name)}">…</summary><div class="menu-list" role="menu"><button role="menuitem" data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Disable' : 'Enable'}</button><a role="menuitem" class="button" href="#mcp/${encodeURIComponent(p.id)}">Settings & permissions</a><button role="menuitem" data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button role="menuitem" data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button><button role="menuitem" data-action="export-pack" data-id="${esc(p.id)}">Export</button><button role="menuitem" data-action="remove-pack" data-id="${esc(p.id)}">Remove</button></div></details></div></div>`).join('') || '<div class="card muted">No integrations installed. Choose one from the widget library below, or ask your agent to build one.</div>'}</div><div id="pack-editor"></div>${catalogView()}<div class="card"><h3>Import a widget pack</h3><p class="hint">Choose a pack file exported from Sidebud or downloaded from the widget library. It installs disabled, then this computer's agent sets it up. Pack files hold the manifest, logo, skill, and any server files, never settings, keys, or account tokens.</p><div class="actions"><input id="pack-import" type="file" accept=".json,application/json" aria-label="Widget pack export file"><button data-action="import-pack">Import</button></div></div><div class="card"><h3>Install your own widget pack</h3><p class="hint">Provide the absolute folder containing pack.json. Existing installations are never overwritten.</p><div class="actions"><input id="pack-folder" aria-label="Widget pack folder" placeholder="/path/to/widget-pack"><button data-action="install-pack">Validate & install</button></div></div><div class="card"><h3>Build or change a pack</h3><p class="hint">Ask your agent: "make a widget that shows …" or "change my Gmail widget to …". It follows the widget-packs skill, builds or edits the pack, and asks before turning it on. See how it will look under <a href="#phone">Phone preview</a>, and how it all works in the <a href="#guide">Guide</a>.</p><p class="hint">Community submissions and reviewed release distribution are planned. Local packs can be installed now.</p></div></section>`;
|
|
627
721
|
}
|
|
628
722
|
function customizePackEditor(p) {
|
|
629
723
|
return `<div class="card stack"><h3>Customize ${esc(p.name)}</h3><p class="hint">Creates a disabled copy with its own settings and credentials. The original is preserved.</p><label>New integration ID<input id="copy-pack-id" placeholder="dev.yourname.notes"></label><label>New display name<input id="copy-pack-name" placeholder="My notes"></label><div class="actions"><button data-action="save-pack-copy" data-id="${esc(p.id)}">Create copy</button><button data-action="close-pack">Cancel</button></div></div>`;
|
|
@@ -791,6 +885,13 @@ document.addEventListener('click', async (event) => {
|
|
|
791
885
|
await applyChanges();
|
|
792
886
|
return;
|
|
793
887
|
}
|
|
888
|
+
if (action === 'setting-pin') {
|
|
889
|
+
const pinned = button.dataset.pinned !== 'true';
|
|
890
|
+
await api('settings/pin', { section: button.dataset.section, path: button.dataset.path.split('.'), pinned });
|
|
891
|
+
notice(pinned ? 'Kept on this computer. Changes from your account no longer replace it.' : 'This setting follows your account again.');
|
|
892
|
+
await reload();
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
794
895
|
if (await handleSetupAction(action, button)) return;
|
|
795
896
|
if (await handleSettingsAction(action, button)) return;
|
|
796
897
|
if (action === 'pair-phone') {
|
|
@@ -983,6 +1084,10 @@ document.addEventListener('click', async (event) => {
|
|
|
983
1084
|
return;
|
|
984
1085
|
}
|
|
985
1086
|
if (action === 'toggle-pack') {
|
|
1087
|
+
const turningOn = button.dataset.enabled !== 'true';
|
|
1088
|
+
const access = state.packAccess[id];
|
|
1089
|
+
const pack = state.packs.packs.find((candidate) => candidate.id === id);
|
|
1090
|
+
if (turningOn && controlsComputer(access) && !confirm(`Turn on ${pack?.name || id}?\n\n${computerConfirmText(pack?.name || id, access)}`)) return;
|
|
986
1091
|
await api('pack', {
|
|
987
1092
|
packId: id,
|
|
988
1093
|
enabled: button.dataset.enabled !== 'true',
|
|
@@ -1032,7 +1137,8 @@ document.addEventListener('click', async (event) => {
|
|
|
1032
1137
|
}
|
|
1033
1138
|
if (action === 'setup-pack') {
|
|
1034
1139
|
await api('pack/setup', { packId: id });
|
|
1035
|
-
notice("This computer's agent is setting it up
|
|
1140
|
+
notice("This computer's agent is setting it up; how it goes shows on its card.");
|
|
1141
|
+
await reload();
|
|
1036
1142
|
return;
|
|
1037
1143
|
}
|
|
1038
1144
|
if (action === 'check-pack') {
|
|
@@ -1094,18 +1200,42 @@ document.addEventListener('click', async (event) => {
|
|
|
1094
1200
|
return;
|
|
1095
1201
|
}
|
|
1096
1202
|
if (action === 'install-library-pack') {
|
|
1203
|
+
const replace = button.dataset.replace === 'true';
|
|
1204
|
+
const entry = state.integrationCatalog.find((candidate) => candidate.id === button.dataset.id);
|
|
1205
|
+
const installed = entry && state.packs.packs.find((pack) => pack.id === entry.packId);
|
|
1206
|
+
// An update keeps a pack that is on turned on, so a pack that controls the computer is confirmed first, as when turning it on.
|
|
1207
|
+
if (replace && installed?.enabled && controlsComputer(entry.access) && !confirm(`Update ${entry.name} to ${entry.version} and keep it on?\n\n${computerConfirmText(entry.name, entry.access)}`)) return;
|
|
1097
1208
|
libraryPage = null;
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
result
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1209
|
+
button.disabled = true;
|
|
1210
|
+
button.textContent = replace ? 'Updating…' : 'Installing…';
|
|
1211
|
+
const request = { id: button.dataset.id, ...(replace ? { replace: true } : {}) };
|
|
1212
|
+
let result;
|
|
1213
|
+
try {
|
|
1214
|
+
result = await api('pack/install-library', request);
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
// An update that lets the pack do more waits for the owner: show what grows, in words, and ask.
|
|
1217
|
+
const asked = error.data?.consentRequired ? error.data : null;
|
|
1218
|
+
if (!asked) throw error;
|
|
1219
|
+
const lines = asked.changes.map((change) => `${change.grows ? '•' : '◦'} ${change.text}`).join('\n');
|
|
1220
|
+
if (!confirm(`Updating ${asked.name} to ${asked.version} lets it do more on this computer:\n\n${lines}\n\nInstall the update?`)) {
|
|
1221
|
+
notice(`${asked.name} was not updated. The installed version keeps working.`);
|
|
1222
|
+
await reload();
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
result = await api('pack/install-library', { ...request, acceptChanges: true });
|
|
1226
|
+
}
|
|
1227
|
+
if (result.keptOn && result.check && !result.check.ok) notice(`${result.name} updated to ${result.version} and turned back on, but its connection check failed: ${result.check.error}`, true);
|
|
1228
|
+
else if (result.keptOn) notice(`${result.name} updated to ${result.version} and still on${result.check ? '; its connection check passed' : ''}. Its settings and keys were kept.`);
|
|
1229
|
+
else if (result.setup.started) notice(`${result.name} ${result.updated ? `updated to ${result.version}` : 'installed'}. This computer's agent is setting it up; how it goes shows on its card.`);
|
|
1230
|
+
else notice(`${result.name} ${result.updated ? 'updated' : 'installed'}, but setup did not start: ${result.setup.note} Set it up in Settings & permissions.`, true);
|
|
1231
|
+
await reload();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (action === 'rollback-pack') {
|
|
1235
|
+
const pack = state.packs.packs.find((candidate) => candidate.id === id);
|
|
1236
|
+
if (!confirm(`Go back to ${pack?.name || id} ${pack?.rollbackVersion || 'its previous version'}? Its settings and keys are kept.`)) return;
|
|
1237
|
+
const result = await api('pack/rollback', { packId: id });
|
|
1238
|
+
notice(`${result.name} is back to version ${result.version}${result.enabled ? ' and still on.' : '. Turn it on when you are ready.'}`);
|
|
1109
1239
|
await reload();
|
|
1110
1240
|
return;
|
|
1111
1241
|
}
|