slotlock 0.7.0 → 0.8.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/dashboard.js CHANGED
@@ -25,6 +25,9 @@ import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
25
25
  import { ignoreRejection } from './hooks.js';
26
26
  import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
27
27
  import { SLOTLOCK_DASHBOARD_ICON_SVG } from './dashboard-icon.js';
28
+ import { SLOTLOCK_SETTINGS_CSS } from './dashboard-settings-ui.js';
29
+ import { renderSlotlockMarkdown } from './dashboard-markdown.js';
30
+ import { SLOTLOCK_ASSISTANT_CSS, slotlockAssistantActionVerb, slotlockAssistantScript, slotlockAssistantStepLabel, } from './dashboard-assistant-ui.js';
28
31
  import { SLOTLOCK_PLAN_LIMITS, SLOTLOCK_PLAN_QUOTAS } from './plans.js';
29
32
  import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
30
33
  import { slotlockQuotaPeriod } from './quotas.js';
@@ -89,6 +92,7 @@ const POST_ROUTES = new Set([
89
92
  '/teams/enable',
90
93
  '/teams/disable',
91
94
  '/assistant',
95
+ '/assistant/stream',
92
96
  '/assistant/approve',
93
97
  '/assistant/dismiss',
94
98
  '/assistant/reset',
@@ -132,7 +136,7 @@ const KEY_EXPIRY_DAYS = Object.freeze({
132
136
  * still sends no referrer to any other site, so the callback URL's code never leaves.
133
137
  */
134
138
  const REFERRER_POLICY = 'same-origin';
135
- const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
139
+ const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
136
140
  /** The policy, with billing's origins allowed as form targets so Upgrade and Manage can redirect. */
137
141
  function policyFor(billing) {
138
142
  if (!billing)
@@ -660,6 +664,20 @@ const CHANGE_LABELS = Object.freeze({
660
664
  'approval.expired': { text: 'Approval not decided in time', tone: 'bad' },
661
665
  'webhook.disabled': { text: 'Webhook disabled', tone: 'bad' },
662
666
  'usage.threshold_reached': { text: 'Monthly quota nearly or fully used', tone: 'bad' },
667
+ 'usage.period_closed': { text: 'Month of use closed', tone: 'neutral' },
668
+ 'resource.created': { text: 'Resource added', tone: 'good' },
669
+ 'resource.updated': { text: 'Resource changed', tone: 'neutral' },
670
+ 'resource.archived': { text: 'Resource archived', tone: 'neutral' },
671
+ 'resource.restored': { text: 'Resource restored', tone: 'good' },
672
+ 'resource.deleted': { text: 'Resource deleted', tone: 'bad' },
673
+ 'key.created': { text: 'API key created', tone: 'good' },
674
+ 'key.rotated': { text: 'API key rotated', tone: 'neutral' },
675
+ 'key.revoked': { text: 'API key revoked', tone: 'bad' },
676
+ 'webhook.created': { text: 'Webhook added', tone: 'good' },
677
+ 'webhook.enabled': { text: 'Webhook enabled', tone: 'good' },
678
+ 'webhook.deleted': { text: 'Webhook deleted', tone: 'neutral' },
679
+ 'coverage.recorded': { text: 'Calendar synced', tone: 'neutral' },
680
+ 'coverage.deleted': { text: 'Calendar sync removed', tone: 'neutral' },
663
681
  });
664
682
  const zoned = (zone, parts) => {
665
683
  try {
@@ -765,6 +783,10 @@ function formatDate(date) {
765
783
  return '—';
766
784
  return `${date.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
767
785
  }
786
+ /** A UTC day as people write it: `9 Dec 2026`. */
787
+ function dayOf(date) {
788
+ return zoned('UTC', { day: 'numeric', month: 'short', year: 'numeric' }).format(date);
789
+ }
768
790
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
769
791
  /** A table's date: the day (`19 Oct 2026`), with the full UTC time on hover and for machines. */
770
792
  function dateCell(date) {
@@ -920,6 +942,20 @@ export function createSlotlockDashboard(options) {
920
942
  }
921
943
  // Teams on Cloud: organizations turned on in the database, beside every person's own tenant.
922
944
  const teams = options.organizations === 'enabled' ? (options.teams ?? null) : null;
945
+ // Plans whether or not billing sells them; billing's own reader when only it is given.
946
+ const plans = options.plans ??
947
+ (options.billing
948
+ ? {
949
+ plan: options.billing.plan,
950
+ ...(options.billing.enrol ? { enrol: options.billing.enrol } : {}),
951
+ contact: options.billing.contact,
952
+ }
953
+ : undefined);
954
+ if (plans?.contact) {
955
+ const protocol = new URL(plans.contact).protocol;
956
+ if (protocol !== 'https:' && protocol !== 'mailto:')
957
+ throw new Error('plans.contact must be an https or mailto URL');
958
+ }
923
959
  if (options.organizations === 'enabled' && !teams) {
924
960
  throw new Error('Slotlock dashboard organizations "enabled" needs the teams store');
925
961
  }
@@ -1106,9 +1142,7 @@ export function createSlotlockDashboard(options) {
1106
1142
  ...(assistantStore()
1107
1143
  ? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
1108
1144
  : []),
1109
- ...(options.billing
1110
- ? [{ section: 'plan', label: 'Plan', path: '/plan', icon: ICONS.plan }]
1111
- : []),
1145
+ ...(plans ? [{ section: 'plan', label: 'Usage', path: '/plan', icon: ICONS.plan }] : []),
1112
1146
  ...(teams ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
1113
1147
  { section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
1114
1148
  ];
@@ -1177,13 +1211,19 @@ ${live}
1177
1211
  </body>
1178
1212
  </html>`;
1179
1213
  }
1180
- const noticeOf = (notice) => notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``;
1214
+ // A notice is an alert only when the page answers with an error: a done or saved message is a
1215
+ // status, so success is never painted in the error colour.
1216
+ const noticeOf = (notice, status = 200) => notice
1217
+ ? status >= 400
1218
+ ? html `<p class="notice" role="alert">${notice}</p>`
1219
+ : html `<p class="notice done" role="status">${notice}</p>`
1220
+ : html ``;
1181
1221
  function signedOutPage(status, notice, cookies = []) {
1182
1222
  const content = html `<section class="sign-in">
1183
1223
  <span class="sign-in-mark">${ICONS.mark}</span>
1184
1224
  <h1>Sign in to ${brandName}</h1>
1185
1225
  <p>Create keys for your agents, add what they book, and watch what they do.</p>
1186
- ${noticeOf(notice)}
1226
+ ${noticeOf(notice, status)}
1187
1227
  <a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a>${google ? html `<a class="button wide" href="${root}/sign-in?with=google">${ICONS.google}Continue with Google</a>` : html ``}
1188
1228
  ${options.termsUrl
1189
1229
  ? html `<p class="fine">By continuing you agree to the <a href="${options.termsUrl}">Terms of Service</a>.${options.privacyUrl
@@ -1273,74 +1313,205 @@ ${options.termsUrl
1273
1313
  }
1274
1314
  return html `${[...groups.values()].map((group) => html `<h3 class="day">${group.label}</h3><ul class="slots">${group.rows}</ul>`)}${more ? html `<p class="fine">Showing the first ${String(AGENDA_SHOWN)}.</p>` : html ``}`;
1275
1315
  }
1316
+ /**
1317
+ * What each change says in the feed, as a sentence: who did it (`verb` then the resource), or what
1318
+ * happened (`passive`, the resource in place of {X}). Every change type the feed records has one,
1319
+ * so the feed never shows a raw type such as resource.updated.
1320
+ */
1321
+ const ACTIVITY = Object.freeze({
1322
+ 'hold.created': { verb: 'held' },
1323
+ 'hold.updated': { verb: 'changed the hold on' },
1324
+ 'hold.confirmed': { verb: 'booked', after: ' from a hold' },
1325
+ 'hold.released': { verb: 'released the hold on' },
1326
+ 'hold.expired': { passive: 'The hold on {X} expired' },
1327
+ 'reservation.created': { verb: 'booked' },
1328
+ 'reservation.updated': { verb: 'moved a booking on' },
1329
+ 'reservation.cancelled': { verb: 'cancelled a booking on' },
1330
+ 'reservation.checked_in': { verb: 'checked in to' },
1331
+ 'reservation.no_show': { passive: '{X} was released after a no-show' },
1332
+ 'event.created': { verb: 'added an event on' },
1333
+ 'event.updated': { verb: 'changed an event on' },
1334
+ 'event.cancelled': { verb: 'cancelled an event on' },
1335
+ 'event.timezone_shifted': { passive: 'An event on {X} moved with a time zone change' },
1336
+ 'event.timezone_conflict': { passive: 'An event on {X} clashes after a time zone change' },
1337
+ 'coverage.stale': { passive: 'The calendar sync for {X} is behind' },
1338
+ 'coverage.recorded': { passive: 'The calendar for {X} synced' },
1339
+ 'coverage.deleted': { verb: 'removed the calendar sync for' },
1340
+ 'waitlist.offered': { passive: 'A waitlist offer went out for {X}' },
1341
+ 'waitlist.expired': { passive: 'A waitlist offer for {X} lapsed' },
1342
+ 'approval.requested': { passive: 'A booking on {X} waits for approval' },
1343
+ 'approval.granted': { verb: 'approved a booking on' },
1344
+ 'approval.declined': { verb: 'declined a booking on' },
1345
+ 'approval.expired': { passive: 'A booking on {X} was not approved in time' },
1346
+ 'resource.created': { verb: 'added' },
1347
+ 'resource.updated': { verb: 'edited' },
1348
+ 'resource.archived': { verb: 'archived' },
1349
+ 'resource.restored': { verb: 'restored' },
1350
+ 'resource.deleted': { verb: 'deleted' },
1351
+ 'key.created': { verb: 'created an API key' },
1352
+ 'key.rotated': { verb: 'rotated an API key' },
1353
+ 'key.revoked': { verb: 'revoked an API key' },
1354
+ 'webhook.created': { verb: 'added a webhook' },
1355
+ 'webhook.enabled': { verb: 'turned a webhook back on' },
1356
+ 'webhook.disabled': { passive: 'A webhook was turned off' },
1357
+ 'webhook.deleted': { verb: 'deleted a webhook' },
1358
+ 'usage.period_closed': { passive: 'A month of use closed' },
1359
+ });
1360
+ /** Changes about a resource, which name it; the rest stand alone. */
1361
+ const ABOUT_A_RESOURCE = /^(hold|reservation|event|coverage|waitlist|approval|resource)\./;
1276
1362
  function activityPanel(changes, byId, keys, session) {
1277
1363
  if (changes.length === 0) {
1278
1364
  return html `<p class="empty">No activity yet. What agents book, move and cancel shows here.</p>`;
1279
1365
  }
1280
1366
  const keyNames = new Map(keys.map((key) => [`api_key:${key.id}`, key.name]));
1281
- return html `<ol class="feed">${changes.map((change) => {
1282
- const label = CHANGE_LABELS[change.type] ?? { text: change.type, tone: 'neutral' };
1283
- const who = change.actor === null
1284
- ? brandName
1285
- : change.actor === personOf(session)
1286
- ? 'You'
1287
- : (keyNames.get(change.actor) ??
1288
- (change.actor.startsWith('api_key:')
1289
- ? 'A deleted key'
1290
- : /^github:\d+$/.test(change.actor)
1291
- ? `GitHub user ${change.actor.slice(7)}`
1292
- : /^google:/.test(change.actor)
1293
- ? 'A Google user'
1294
- : /^dashboard-assistant:(github|google):/.test(change.actor)
1295
- ? change.actor === assistantActor(session)
1296
- ? `Assistant for ${shownAs(session)}`
1297
- : change.actor.startsWith('dashboard-assistant:google:')
1298
- ? 'Assistant for a Google user'
1299
- : `Assistant for GitHub user ${change.actor.slice(27)}`
1300
- : change.actor.slice(0, 60)));
1301
- const where = change.resourceId
1302
- ? resourceName(byId.get(change.resourceId), change.resourceId)
1303
- : null;
1304
- return html `<li class="event"><span class="dot ${label.tone}" aria-hidden="true"></span><span class="what" title="${where ? `${label.text} on ${where}` : label.text}"><span class="title">${label.text}${where ? html ` <span class="on">on</span> ${where}` : html ``}</span><span class="meta">${who} · <time datetime="${change.occurredAt.toISOString()}" title="${formatDate(change.occurredAt)}">${ago(change.occurredAt, now())}</time></span></span></li>`;
1367
+ const whoOf = (actor) => actor === null
1368
+ ? brandName
1369
+ : actor === personOf(session)
1370
+ ? 'You'
1371
+ : (keyNames.get(actor) ??
1372
+ (actor.startsWith('api_key:')
1373
+ ? 'A deleted key'
1374
+ : /^github:\d+$/.test(actor)
1375
+ ? `GitHub user ${actor.slice(7)}`
1376
+ : /^google:/.test(actor)
1377
+ ? 'A Google user'
1378
+ : /^dashboard-assistant:(github|google):/.test(actor)
1379
+ ? actor === assistantActor(session)
1380
+ ? `Assistant for ${shownAs(session)}`
1381
+ : actor.startsWith('dashboard-assistant:google:')
1382
+ ? 'Assistant for a Google user'
1383
+ : `Assistant for GitHub user ${actor.slice(27)}`
1384
+ : actor.slice(0, 60)));
1385
+ // Repeats in a row (one actor, one change, one resource) read as one line with a count.
1386
+ const runs = [];
1387
+ for (const change of changes) {
1388
+ const last = runs.at(-1);
1389
+ if (last && last.change.actor === change.actor && last.change.type === change.type && last.change.resourceId === change.resourceId) {
1390
+ last.times += 1;
1391
+ }
1392
+ else {
1393
+ runs.push({ change, times: 1 });
1394
+ }
1395
+ }
1396
+ return html `<ol class="feed">${runs.map(({ change, times }) => {
1397
+ const tone = CHANGE_LABELS[change.type]?.tone ?? 'neutral';
1398
+ const where = change.resourceId ? resourceName(byId.get(change.resourceId), change.resourceId) : null;
1399
+ const object = where ?? (ABOUT_A_RESOURCE.test(change.type) ? 'a resource' : null);
1400
+ const words = ACTIVITY[change.type];
1401
+ let sentence;
1402
+ if (change.type === 'usage.threshold_reached') {
1403
+ const metric = typeof change.data.metric === 'string' ? change.data.metric : 'use';
1404
+ const percent = typeof change.data.percent === 'number' ? change.data.percent : null;
1405
+ sentence = html `${percent === null ? 'A monthly quota' : `${String(percent)}% of this month's ${metric}`} used`;
1406
+ }
1407
+ else if (words?.verb) {
1408
+ sentence = html `<strong>${whoOf(change.actor)}</strong> ${words.verb}${object ? html ` <strong>${object}</strong>` : html ``}${words.after ?? ''}`;
1409
+ }
1410
+ else if (words?.passive) {
1411
+ const [before, rest] = words.passive.split('{X}');
1412
+ sentence = rest === undefined ? html `${before}` : html `${before}<strong>${object ?? 'a resource'}</strong>${rest}`;
1413
+ }
1414
+ else {
1415
+ // A change type this dashboard does not know yet: its label, or its name in words.
1416
+ const label = CHANGE_LABELS[change.type]?.text ?? change.type.replace(/[._]/g, ' ').replace(/^./, (first) => first.toUpperCase());
1417
+ sentence = html `${label}${object ? html ` <span class="on">on</span> <strong>${object}</strong>` : html ``}`;
1418
+ }
1419
+ return html `<li class="event"><span class="dot ${tone}" aria-hidden="true"></span><span class="what"><span class="title">${sentence}${times > 1 ? html ` <span class="times">${times === 2 ? 'twice' : `${String(times)} times`}</span>` : html ``}</span></span><time class="when" datetime="${change.occurredAt.toISOString()}" title="${formatDate(change.occurredAt)}">${ago(change.occurredAt, now())}</time></li>`;
1305
1420
  })}</ol>`;
1306
1421
  }
1422
+ /** The MCP and A2A endpoints with Copy, and the command that adds Slotlock to an MCP client. */
1423
+ const connectAgent = () => html `${endpoints()}<details class="command"><summary>Claude Code command</summary><div class="copy-block"><pre><code id="mcp-command">claude mcp add --transport http slotlock ${mcpUrl} --header "Authorization: Bearer YOUR_KEY"</code></pre><button class="quiet" type="button" data-copy="mcp-command">Copy</button></div></details>`;
1307
1424
  async function overviewPage(session, status = 200, notice) {
1308
1425
  const tenantRef = tenantOf(session);
1309
1426
  const start = new Date(now());
1310
1427
  const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
1311
- const [keys, resources, agenda, changes] = await Promise.all([
1428
+ const assistant = assistantStore();
1429
+ const [keys, resources, agenda, changes, approvals, waitlist, model] = await Promise.all([
1312
1430
  options.keys.list({ tenantRef }),
1313
1431
  options.resources.list(tenantRef),
1314
1432
  options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
1315
1433
  options.resources.recentChanges?.(tenantRef, ACTIVITY_LIMIT),
1434
+ options.resources.approvals?.list(tenantRef, APPROVALS_SHOWN),
1435
+ options.resources.waitlist?.list(tenantRef, WAITLIST_SHOWN),
1436
+ assistant?.getSettings(tenantRef),
1316
1437
  ]);
1438
+ // The plan is Live's: the test environment has none of its own.
1439
+ const standing = plans && session.env !== 'test' ? await planStanding(session, 'overview', keys) : null;
1317
1440
  const byId = new Map(resources.map((resource) => [resource.id, resource]));
1318
1441
  const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
1319
1442
  const counted = (count) => agenda && agenda.length >= AGENDA_LIMIT ? `${count}+` : String(count);
1320
1443
  const booked = agenda?.filter((entry) => entry.kind !== 'hold').length ?? 0;
1321
1444
  const held = agenda?.filter((entry) => entry.kind === 'hold').length ?? 0;
1322
- const stat = (label, value, href) => html `<a class="stat" href="${href}"><span class="stat-label">${label}</span><span class="stat-value">${value}</span></a>`;
1445
+ const waiting = approvals?.filter((approval) => approval.status === 'pending').length ?? 0;
1446
+ const attention = [
1447
+ ...(standing?.attention ?? []),
1448
+ ...(waiting > 0
1449
+ ? [
1450
+ attentionItem('info', `${String(waiting)} ${waiting === 1 ? 'booking waits' : 'bookings wait'} for your approval.`, {
1451
+ href: `${root}/approvals`,
1452
+ label: 'Review',
1453
+ }),
1454
+ ]
1455
+ : []),
1456
+ ];
1457
+ const admin = isAdmin(session);
1323
1458
  const steps = [
1324
- { done: resources.length > 0, text: 'Add what your agents book', href: `${root}/resources` },
1325
- { done: active.length > 0, text: 'Create a key for an agent', href: `${root}/keys` },
1326
- { done: (changes?.length ?? 0) > 0, text: 'Connect the agent and book', href: `${root}/keys` },
1459
+ { done: resources.length > 0, title: 'Add a resource', action: { href: `${root}/resources`, label: 'Add resource' } },
1460
+ { done: active.length > 0, title: 'Create an API key', action: { href: `${root}/keys#new-key`, label: 'Create key' } },
1461
+ // Done once an agent has used a key.
1462
+ { done: active.some((key) => key.lastUsedAt !== null), title: 'Connect an agent', action: { href: `${root}#connect`, label: 'Connect' } },
1463
+ ...(assistant
1464
+ ? [
1465
+ admin
1466
+ ? { done: Boolean(model), title: 'Connect a model to the assistant', action: { href: `${root}/settings`, label: 'Connect' } }
1467
+ : { done: Boolean(model), title: 'Try the assistant', action: { href: `${root}/assistant`, label: 'Open' } },
1468
+ ]
1469
+ : []),
1327
1470
  ];
1328
- const content = html `${noticeOf(notice)}
1329
- ${pageHead('Overview')}
1330
- ${steps.every((step) => step.done)
1471
+ const done = steps.filter((step) => step.done).length;
1472
+ const next = steps.findIndex((step) => !step.done);
1473
+ // Nothing made and nothing happened yet: only the way in.
1474
+ const fresh = resources.length === 0 && active.length === 0 && (changes?.length ?? 0) === 0;
1475
+ const setupCard = next === -1
1331
1476
  ? html ``
1332
- : html `<section class="panel setup"><h2>Get started</h2><ol class="steps">${steps.map((step) => html `<li class="${step.done ? 'done' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span>${step.done ? html `<span>${step.text}</span>` : html `<a href="${step.href}">${step.text}</a>`}${step.done ? html `<span class="visually-hidden">(done)</span>` : html ``}</li>`)}</ol></section>`}
1333
- <div class="stats">
1334
- ${agenda ? stat(`Booked, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`) : html ``}
1335
- ${agenda ? stat('Live holds', counted(held), `${root}#upcoming`) : html ``}
1336
- ${stat('Resources', String(resources.length), `${root}/resources`)}
1337
- ${stat('Active keys', String(active.length), `${root}/keys`)}
1338
- </div>
1477
+ : html `<section class="panel setup-card"><div class="panel-head"><h2>Get started</h2><span class="meta">${String(done)} of ${String(steps.length)}</span></div><div class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</div><ol class="setup-steps">${steps.map((step, index) => html `<li class="${step.done ? 'done' : index === next ? 'next' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span><span class="step-title">${step.title}${step.done ? html `<span class="visually-hidden"> (done)</span>` : html ``}</span>${index === next ? html `<a class="button primary" href="${step.action.href}">${step.action.label}</a>` : html ``}</li>`)}</ol></section>`;
1478
+ // Mostly set up: one line that leads to the next step.
1479
+ const setupRow = next === -1
1480
+ ? html ``
1481
+ : html `<a class="setup-row" href="${steps[next]?.action.href ?? root}"><span class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</span><span>Get started · ${String(done)} of ${String(steps.length)} · <strong>${steps[next]?.title ?? ''}</strong></span></a>`;
1482
+ const stat = (label, value, href, tone = '') => html `<a class="stat${tone}" href="${href}"><span class="stat-label">${label}</span><span class="stat-value">${value}</span></a>`;
1483
+ const tiles = [
1484
+ ...(agenda ? [stat(`Bookings, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`), stat('Live holds', counted(held), `${root}#upcoming`)] : []),
1485
+ ...(approvals ? [stat('Waiting for approval', String(waiting), `${root}/approvals`, waiting > 0 ? ' attention-stat' : '')] : []),
1486
+ ...(waitlist ? [stat('On the waitlist', String(waitlist.filter((entry) => entry.status !== 'expired').length), `${root}/waitlist`)] : []),
1487
+ stat('Resources', String(resources.length), `${root}/resources`),
1488
+ stat('Active keys', String(active.length), `${root}/keys`),
1489
+ ].slice(0, 4);
1490
+ const planCard = standing
1491
+ ? html `<section class="panel plan-card"><div class="panel-head"><h2>${standing.name}</h2><a href="${root}/plan">Usage</a></div><div class="meters stacked">${standing.meters}</div>${standing.canUpgrade && standing.pressing
1492
+ ? html `<a class="button primary" href="${root}/plan#upgrade">Upgrade</a>`
1493
+ : html ``}</section>`
1494
+ : html ``;
1495
+ const connectCard = html `<section class="panel" id="connect"><h2>Connect an agent</h2>${connectAgent()}</section>`;
1496
+ const body = fresh
1497
+ ? standing
1498
+ ? html `<div class="split">${setupCard}<div class="column">${planCard}</div></div>`
1499
+ : setupCard
1500
+ : html `${done * 2 >= steps.length ? setupRow : setupCard}
1501
+ <div class="stats">${tiles}</div>
1339
1502
  ${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
1340
1503
  <div class="split">
1504
+ <div class="column">
1341
1505
  ${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
1342
- ${changes ? html `<section class="panel"><h2>Agent activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
1506
+ ${changes ? html `<section class="panel"><h2>Activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
1507
+ </div>
1508
+ <div class="column">${planCard}${connectCard}</div>
1343
1509
  </div>`;
1510
+ const content = html `${noticeOf(notice, status)}
1511
+ ${pageHead('Overview')}
1512
+ ${attentionList(attention)}
1513
+ ${fresh && session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
1514
+ ${body}`;
1344
1515
  return respond(status, layout('Overview', content, session, 'overview'));
1345
1516
  }
1346
1517
  /** In Test: load a scenario to try an agent against, move waiting forward, or start again (RFC 0004 A6). */
@@ -1404,7 +1575,7 @@ ${resourceCount === 0
1404
1575
  const limitChoices = (locations?.length ?? 0) + (groups?.length ?? 0) > 0
1405
1576
  ? html `<details class="types"><summary>Limit to locations or groups (optional)</summary><div class="checks">${(locations ?? []).map((location) => html `<label class="choice"><input type="checkbox" name="limit_location" value="${location.ref}"> ${paths.get(location.ref) ?? location.name}</label>`)}${(groups ?? []).map((group) => html `<label class="choice"><input type="checkbox" name="limit_group" value="${group.ref}"> Group: ${group.name}</label>`)}</div><p class="fine">The key then reaches only the resources there (a location with every location in it), read and write only. Developer plan and up.</p></details>`
1406
1577
  : html ``;
1407
- const content = html `${noticeOf(notice)}
1578
+ const content = html `${noticeOf(notice, status)}
1408
1579
  ${pageHead('API keys')}
1409
1580
  <section class="panel">
1410
1581
  <h2>Connect an agent</h2>
@@ -1435,7 +1606,7 @@ ${keys.length === 0
1435
1606
  * dashboard's own limit, or its plan's when lower (archived resources count against a plan).
1436
1607
  */
1437
1608
  async function resourceAllowance(tenantRef, resources) {
1438
- const plan = options.billing ? await options.billing.plan(tenantRef) : null;
1609
+ const plan = plans ? await plans.plan(tenantRef) : null;
1439
1610
  if (!plan || plan.limits.resources >= SLOTLOCK_DASHBOARD_MAX_RESOURCES) {
1440
1611
  return { used: resources.length, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES, planned: false, archived: 0 };
1441
1612
  }
@@ -1451,7 +1622,7 @@ ${keys.length === 0
1451
1622
  const resources = await options.resources.list(tenantOf(session));
1452
1623
  const allowance = await resourceAllowance(tenantOf(session), resources);
1453
1624
  const token = csrfToken(session);
1454
- const content = html `${noticeOf(notice)}
1625
+ const content = html `${noticeOf(notice, status)}
1455
1626
  ${pageHead('Resources', html `<div class="row-actions">${options.resources.grouping ? html `<a class="button" href="${root}/locations">Locations and groups</a>` : html ``}${resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` : html ``}</div>`)}
1456
1627
  <section class="panel" id="add-resource">
1457
1628
  <h2>Add a resource</h2>
@@ -1510,7 +1681,7 @@ ${resources.length === 0
1510
1681
  <button type="submit">Save group</button></form></div></details><details class="act danger"><summary>Delete</summary><div class="pop"><p>Its resources stay as they are, in no group of this name.</p>
1511
1682
  <form method="post" action="${root}/groups/delete">${sent()}<input type="hidden" name="ref" value="${group.ref}"><button class="danger" type="submit">Delete group</button></form></div></details></td>
1512
1683
  </tr>`);
1513
- const content = html `${noticeOf(notice)}
1684
+ const content = html `${noticeOf(notice, status)}
1514
1685
  <p><a class="back" href="${root}/resources">Resources</a></p>
1515
1686
  ${pageHead('Locations and groups')}
1516
1687
  <section class="panel" id="locations">
@@ -1542,32 +1713,44 @@ ${groups.length === 0
1542
1713
  </section>`;
1543
1714
  return respond(status, layout('Locations and groups', content, session, 'resources'));
1544
1715
  }
1545
- async function settingsPage(session, status = 200, notice, draft) {
1546
- const rules = options.availability ?? [];
1547
- const current = session.orgs?.find((org) => org.id === session.org);
1548
- const content = html `${noticeOf(notice)}
1549
- ${pageHead('Settings')}
1550
- ${await assistantSettingsPanel(session, draft)}
1551
- <section class="panel">
1552
- <h2>Endpoints</h2>
1553
- ${endpoints()}
1554
- </section>
1555
- <section class="panel">
1556
- <h2>Server hours</h2>
1557
- ${rules.length === 0
1558
- ? html `<p class="empty">None: agents find a free slot only on resources with hours of their own.</p>`
1559
- : html `<ul class="hours">${rules.map((rule) => html `<li>${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}</li>`)}</ul>`}
1560
- <p class="fine">For resources without hours of their own, in each resource's time zone. Set by the server's operator.</p>
1561
- </section>
1562
- <section class="panel">
1563
- <h2>Account</h2>
1564
- <dl class="facts"><dt>Signed in as</dt><dd>${shownAs(session)}</dd>${current
1565
- ? html `<dt>Organization</dt><dd>${current.login}</dd>${(session.orgs?.length ?? 0) > 1 ? html `<dt>Also a member of</dt><dd>${(session.orgs ?? []).filter((org) => org.id !== current.id).map((org) => org.login).join(', ')}</dd>` : html ``}`
1566
- : html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
1567
- ${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
1568
- </section>`;
1716
+ /** Settings' sections, each its own page: the assistant's form apart from what is only read. */
1717
+ const SETTINGS_SECTIONS = Object.freeze([
1718
+ { id: 'assistant', label: 'Assistant', path: '/settings' },
1719
+ { id: 'endpoints', label: 'Endpoints', path: '/settings/endpoints' },
1720
+ { id: 'hours', label: 'Hours', path: '/settings/hours' },
1721
+ { id: 'account', label: 'Account', path: '/settings/account' },
1722
+ ]);
1723
+ async function settingsPage(session, status = 200, notice, draft, section = 'assistant') {
1724
+ const store = assistantStore();
1725
+ const sections = SETTINGS_SECTIONS.filter((entry) => entry.id !== 'assistant' || store);
1726
+ const shown = section === 'assistant' && !store ? 'endpoints' : section;
1727
+ const connected = store ? Boolean(await store.getSettings(tenantOf(session))) : false;
1728
+ const nav = html `<nav class="subnav" aria-label="Settings">${sections.map((entry) => html `<a href="${root}${entry.path}"${entry.id === shown ? html ` aria-current="page"` : html ``}>${entry.label}${entry.id === 'assistant' && connected ? html `<span class="nav-dot" aria-hidden="true"></span>` : html ``}</a>`)}</nav>`;
1729
+ let body;
1730
+ if (shown === 'assistant') {
1731
+ body = await assistantSettingsSection(session, status, notice, draft);
1732
+ }
1733
+ else if (shown === 'endpoints') {
1734
+ body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">MCP</span><div class="row-value">${copyField('mcp-url', mcpUrl)}</div></div><div class="row"><span class="row-label">A2A</span><div class="row-value">${copyField('a2a-url', a2aUrl)}</div></div></div>`;
1735
+ }
1736
+ else if (shown === 'hours') {
1737
+ const rules = options.availability ?? [];
1738
+ body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Default hours</span><div class="row-value${rules.length === 0 ? ' muted' : ''}">${rules.length === 0
1739
+ ? 'None. Agents find free time only on resources with hours of their own.'
1740
+ : html `${rules.map((rule, index) => html `${index > 0 ? html `<br>` : html ``}${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}`)}`}</div></div></div><p class="fine">In each resource's own time zone, for resources without hours of their own. Set by the server's operator.</p>`;
1741
+ }
1742
+ else {
1743
+ const current = session.orgs?.find((org) => org.id === session.org);
1744
+ const others = (session.orgs ?? []).filter((org) => org.id !== current?.id);
1745
+ body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Signed in as</span><div class="row-value">${shownAs(session)}</div></div><div class="row"><span class="row-label">${current ? 'Organization' : 'Workspace'}</span><div class="row-value">${current ? current.login : 'Personal'}</div></div>${current && others.length > 0
1746
+ ? html `<div class="row"><span class="row-label">Also a member of</span><div class="row-value">${others.map((org) => org.login).join(', ')}</div></div>`
1747
+ : html ``}<div class="row"><span class="row-label">Session ends</span><div class="row-value">${formatDate(new Date(session.exp))}</div></div></div>${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}`;
1748
+ }
1749
+ const content = html `${pageHead('Settings')}<div class="settings">${nav}<div class="settings-body">${body}</div></div>`;
1569
1750
  return respond(status, layout('Settings', content, session, 'settings'));
1570
1751
  }
1752
+ /** A value to copy: monospace, cut with an ellipsis, with a Copy button (app.js). */
1753
+ const copyField = (id, value) => html `<dl class="endpoints"><dd><code id="${id}">${value}</code><button class="quiet" type="button" data-copy="${id}">Copy</button></dd></dl>`;
1571
1754
  /** The tenant's resource with that id and every resource (for names), or null for anything else. */
1572
1755
  async function resourceDetailPage(session, id, status = 200, notice) {
1573
1756
  const tenantRef = tenantOf(session);
@@ -1612,7 +1795,7 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
1612
1795
  const fallback = from ? `As its default (${from.value ? 'on' : 'off'}, ${from.from})` : 'Off';
1613
1796
  return html `<label>${label}<select name="${key}"><option value=""${chosen(undefined)}>${fallback}</option><option value="1"${chosen(true)}>On</option><option value="0"${chosen(false)}>Off</option></select></label>`;
1614
1797
  };
1615
- const content = html `${noticeOf(notice)}
1798
+ const content = html `${noticeOf(notice, status)}
1616
1799
  <p><a class="back" href="${root}/resources">Resources</a></p>
1617
1800
  ${pageHead(resourceName(resource, resource.id))}
1618
1801
  ${resource.description ? html `<p>${resource.description}</p>` : html ``}
@@ -1960,7 +2143,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1960
2143
  <form method="post" action="${root}/webhooks/delete"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${endpoint.id}"><button class="danger" type="submit">Delete webhook</button></form></div></details></td>
1961
2144
  </tr>`;
1962
2145
  });
1963
- const content = html `${noticeOf(notice)}
2146
+ const content = html `${noticeOf(notice, status)}
1964
2147
  ${pageHead('Webhooks')}
1965
2148
  <div class="stats">
1966
2149
  <div class="stat"><span class="stat-label">Endpoints</span><span class="stat-value">${String(endpoints.length)}</span></div>
@@ -1991,7 +2174,7 @@ ${endpoints.length === 0
1991
2174
  const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
1992
2175
  if (!created.ok) {
1993
2176
  const message = created.code === 'webhook_limit_reached'
1994
- ? `You have ${String(created.limit)} webhooks, as many as ${options.billing ? 'your plan' : 'Slotlock'} allows. Delete one first${options.billing ? ', or see Plan' : ''}.`
2177
+ ? `You have ${String(created.limit)} webhooks, as many as ${plans ? 'your plan' : 'Slotlock'} allows. Delete one first${plans ? ', or see Usage' : ''}.`
1995
2178
  : created.code === 'invalid_webhook_url'
1996
2179
  ? 'Give an https URL, with no user name, password or #fragment.'
1997
2180
  : 'Choose events from the list.';
@@ -2060,7 +2243,7 @@ ${endpoints.length === 0
2060
2243
  });
2061
2244
  const waiting = entries.filter((entry) => entry.status === 'waiting').length;
2062
2245
  const offered = entries.filter((entry) => entry.status === 'offered').length;
2063
- const content = html `${noticeOf(notice)}
2246
+ const content = html `${noticeOf(notice, status)}
2064
2247
  ${pageHead('Waitlist')}
2065
2248
  <div class="stats">
2066
2249
  <div class="stat"><span class="stat-label">Waiting</span><span class="stat-value">${String(waiting)}</span></div>
@@ -2126,7 +2309,7 @@ ${entries.length === 0
2126
2309
  <td data-label="Decided by"><code class="truncate" title="${entry.decidedBy ?? ''}">${entry.decidedBy ?? '—'}</code></td>
2127
2310
  <td data-label="Asked">${ago(entry.createdAt, now())}</td>
2128
2311
  </tr>`);
2129
- const content = html `${noticeOf(notice)}
2312
+ const content = html `${noticeOf(notice, status)}
2130
2313
  ${pageHead('Approvals')}
2131
2314
  <p class="fine">Bookings an approval policy holds for a person. Their time stays held until you decide or the wait ends. Policies are set through the API (slotlock_put_approval_policy).</p>
2132
2315
  <div class="stats">
@@ -2367,38 +2550,126 @@ ${decided.length === 0
2367
2550
  model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
2368
2551
  };
2369
2552
  }
2553
+ /** When a change happens, in its resource's time zone when the card knows it, else UTC. */
2554
+ function whenOf(start, end, zone) {
2555
+ if (typeof start !== 'string')
2556
+ return null;
2557
+ const from = new Date(start);
2558
+ if (Number.isNaN(from.getTime()))
2559
+ return null;
2560
+ const timeZone = zone ?? 'UTC';
2561
+ let day;
2562
+ let time;
2563
+ try {
2564
+ day = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone });
2565
+ time = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone });
2566
+ }
2567
+ catch {
2568
+ return null;
2569
+ }
2570
+ const to = typeof end === 'string' ? new Date(end) : null;
2571
+ const span = to && !Number.isNaN(to.getTime())
2572
+ ? day.format(from) === day.format(to)
2573
+ ? `${time.format(from)}–${time.format(to)}`
2574
+ : `${time.format(from)} – ${day.format(to)}, ${time.format(to)}`
2575
+ : time.format(from);
2576
+ return `${day.format(from)}, ${span} ${timeZone}`;
2577
+ }
2578
+ /** What a proposed change's fields are called on its card. */
2579
+ const FACT_NAMES = Object.freeze({
2580
+ resource_id: 'Resource',
2581
+ resource_ids: 'Resources',
2582
+ pool_id: 'Pool',
2583
+ title: 'Title',
2584
+ external_ref: 'Reference',
2585
+ event_id: 'Booking',
2586
+ hold_id: 'Hold',
2587
+ ttl_seconds: 'Held for',
2588
+ notes: 'Note',
2589
+ description: 'Note',
2590
+ metadata: 'Details',
2591
+ slots: 'Times',
2592
+ });
2370
2593
  /**
2371
- * A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the
2372
- * proposal is bounded instead). A resource is named, with its id beside the name.
2594
+ * A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the proposal
2595
+ * is bounded instead). A resource is named; its id is the name's title.
2373
2596
  */
2374
2597
  function fieldValue(name, value, names) {
2375
- const named = (id) => (names.has(id) ? `${names.get(id)} (${id})` : id);
2376
- if (typeof value === 'string') {
2377
- return html `<span class="wrap">${name === 'resource_id' ? named(value) : value}</span>`;
2378
- }
2598
+ const named = (id) => names.has(id) ? html `<span title="${id}">${names.get(id)}</span>` : html `<code>${id}</code>`;
2599
+ if (name === 'resource_id' && typeof value === 'string')
2600
+ return named(value);
2601
+ if (name === 'resource_ids' && Array.isArray(value) && value.every((item) => typeof item === 'string')) {
2602
+ return html `${value.map((id, index) => html `${index > 0 ? ', ' : ''}${named(id)}`)}`;
2603
+ }
2604
+ if (name === 'ttl_seconds' && typeof value === 'number') {
2605
+ return html `${value >= 60 ? `${Math.round(value / 60)} min` : `${value} s`}`;
2606
+ }
2607
+ if ((name === 'event_id' || name === 'hold_id') && typeof value === 'string')
2608
+ return html `<code>${value}</code>`;
2609
+ if (typeof value === 'string')
2610
+ return html `<span class="wrap">${value}</span>`;
2379
2611
  if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
2380
- return html `<span class="wrap">${value.map((item) => (name === 'resource_ids' ? named(item) : item)).join(', ')}</span>`;
2612
+ return html `<span class="wrap">${value.join(', ')}</span>`;
2381
2613
  }
2382
- if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
2614
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null)
2383
2615
  return html `${String(value)}`;
2384
- }
2385
- return html `<pre class="wrap">${JSON.stringify(value, null, 2)}</pre>`;
2616
+ return html `<pre>${JSON.stringify(value, null, 2)}</pre>`;
2386
2617
  }
2387
- function actionCard(session, action, names) {
2618
+ /** Why a change or a step was refused, in words, for the codes a member meets. */
2619
+ const REFUSALS = Object.freeze({
2620
+ overlap: 'that time was just taken',
2621
+ pool_full: 'the pool is full then',
2622
+ not_found: 'it no longer exists',
2623
+ resource_not_found: 'the resource no longer exists',
2624
+ event_not_found: 'the booking no longer exists',
2625
+ invalid_input: 'the request was incomplete',
2626
+ invalid_arguments: 'the request was incomplete',
2627
+ validation_error: 'the request was incomplete',
2628
+ turn_read_budget_exceeded: 'one message may read only so much',
2629
+ too_many_pending_changes: 'too many changes are waiting for you',
2630
+ change_too_large_to_show: 'the change was too large to show',
2631
+ tool_not_available: 'the assistant may not do that',
2632
+ too_many_calls: 'too many at once',
2633
+ quota_exceeded: "this month's allowance is used up",
2634
+ approval_pending: 'it is waiting for an approval',
2635
+ });
2636
+ const refusal = (code) => REFUSALS[code] ?? code.replaceAll('_', ' ');
2637
+ function actionCard(session, action, names, zones) {
2638
+ const verb = slotlockAssistantActionVerb(action.operation);
2639
+ const title = action.summary || `${verb.verb}: ${toolTitles.get(action.operation) ?? action.operation}`;
2640
+ if (action.status !== 'pending') {
2641
+ const [tone, word] = action.status === 'done'
2642
+ ? ['ok', verb.done]
2643
+ : action.status === 'dismissed'
2644
+ ? ['', 'Declined']
2645
+ : action.status === 'approved'
2646
+ ? ['', 'Running…']
2647
+ : ['bad', `Refused: ${refusal(action.outcome ?? 'failed')}`];
2648
+ return html `<li class="turn action" id="action-${action.id}"><p class="receipt ${tone}"><span>${title}</span><span class="why">${word}</span></p></li>`;
2649
+ }
2388
2650
  const token = csrfToken(session);
2389
- const fields = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
2390
- const summary = action.summary ? html `<p class="action-summary">${action.summary}</p>` : html ``;
2391
- const state = {
2392
- pending: { text: 'Waiting for you', tone: 'held' },
2393
- approved: { text: 'Running', tone: 'held' },
2394
- dismissed: { text: 'Dismissed', tone: 'revoked' },
2395
- done: { text: 'Done', tone: 'active' },
2396
- failed: { text: 'Failed', tone: 'revoked' },
2397
- }[action.status] ?? { text: action.status, tone: 'revoked' };
2398
- const buttons = action.status === 'pending'
2399
- ? html `<div class="action-buttons"><form class="inline" method="post" action="${root}/assistant/approve"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${action.id}"><button class="primary" type="submit">Approve</button></form><form class="inline" method="post" action="${root}/assistant/dismiss"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${action.id}"><button type="submit">Dismiss</button></form></div>`
2400
- : html ``;
2401
- return html `<li class="action-card" id="action-${action.id}"><div class="action-head"><span class="title">${toolTitles.get(action.operation) ?? action.operation}</span><span class="pill ${state.tone}">${state.text}</span></div>${summary}<dl class="facts">${fields.map(([name, value]) => html `<dt>${name.replaceAll('_', ' ')}</dt><dd>${fieldValue(name, value, names)}</dd>`)}</dl>${action.status === 'failed' && action.outcome ? html `<p class="fine">Refused: <code>${action.outcome}</code></p>` : html ``}${buttons}</li>`;
2651
+ const input = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
2652
+ // The change's own time zone when it gives one, else its resource's.
2653
+ const zone = typeof action.input.timezone === 'string'
2654
+ ? action.input.timezone
2655
+ : typeof action.input.resource_id === 'string'
2656
+ ? zones.get(action.input.resource_id)
2657
+ : undefined;
2658
+ const when = whenOf(action.input.starts_at, action.input.ends_at, zone);
2659
+ const facts = [];
2660
+ const more = [];
2661
+ if (when)
2662
+ facts.push(html `<dt>When</dt><dd>${when}</dd>`);
2663
+ for (const [name, value] of input) {
2664
+ if (when && (name === 'starts_at' || name === 'ends_at' || name === 'timezone'))
2665
+ continue;
2666
+ const label = FACT_NAMES[name] ?? name.charAt(0).toUpperCase() + name.slice(1).replaceAll('_', ' ');
2667
+ (name in FACT_NAMES || name === 'attendees' ? facts : more).push(html `<dt>${label}</dt><dd>${fieldValue(name, value, names)}</dd>`);
2668
+ }
2669
+ const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${id}">`;
2670
+ return html `<li class="turn action" id="action-${action.id}"><div class="change"><p class="change-title">${title}</p>${facts.length > 0 ? html `<dl class="facts">${facts}</dl>` : html ``}${more.length > 0
2671
+ ? html `<details class="more"><summary>All details</summary><dl class="facts">${more}</dl></details>`
2672
+ : html ``}<div class="change-buttons"><form method="post" action="${root}/assistant/approve">${hidden(action.id)}<button class="${verb.danger ? 'danger' : 'primary'}" type="submit" data-busy="${verb.busy}">${verb.verb}</button></form><form method="post" action="${root}/assistant/dismiss">${hidden(action.id)}<button class="ghost" type="submit">Decline</button></form></div></div></li>`;
2402
2673
  }
2403
2674
  function readAction(id, value) {
2404
2675
  if (!isRecord(value) || typeof value.operation !== 'string')
@@ -2412,6 +2683,100 @@ ${decided.length === 0
2412
2683
  ...(typeof value.outcome === 'string' ? { outcome: value.outcome } : {}),
2413
2684
  };
2414
2685
  }
2686
+ /** The code a tool result was refused with, from the JSON the model was given. */
2687
+ function refusedCode(content) {
2688
+ try {
2689
+ const parsed = JSON.parse(content);
2690
+ const error = isRecord(parsed) && isRecord(parsed.error) ? parsed.error : null;
2691
+ return error && typeof error.code === 'string' ? error.code : null;
2692
+ }
2693
+ catch {
2694
+ return null;
2695
+ }
2696
+ }
2697
+ /** Notes the turn ends with that are not a failure: a member's Stop, and the step bound. */
2698
+ const quietNote = (text) => text === 'Stopped.' || text.startsWith('The assistant stopped after');
2699
+ /**
2700
+ * The conversation as the thread shows it: the member's messages as bubbles, the replies as
2701
+ * rendered Markdown, each run of tool steps folded into one line, proposed changes as cards, and
2702
+ * a failure that ends it with a way to send the last message again.
2703
+ */
2704
+ function assistantItems(session, entries, actions, names, zones) {
2705
+ const items = [];
2706
+ let steps = [];
2707
+ let cards = [];
2708
+ const flush = () => {
2709
+ if (steps.length > 0) {
2710
+ const bad = steps.some((step) => !step.ok);
2711
+ const seen = [];
2712
+ for (const step of steps)
2713
+ if (!seen.includes(step.label))
2714
+ seen.push(step.label);
2715
+ const summary = seen.map((label, index) => (index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1))).join(', ');
2716
+ items.push(html `<li class="turn steps"><details class="steps${bad ? ' has-bad' : ''}"><summary>${summary}</summary><ul class="step-list">${steps.map((step) => html `<li class="step ${step.ok ? 'ok' : 'bad'}">${step.label}${step.why ? html ` <span class="why">${step.why}</span>` : html ``}</li>`)}</ul></details></li>`);
2717
+ steps = [];
2718
+ }
2719
+ items.push(...cards);
2720
+ cards = [];
2721
+ };
2722
+ const lastUser = [...entries].reverse().find((entry) => entry.role === 'user');
2723
+ for (const [index, entry] of entries.entries()) {
2724
+ if (entry.role === 'user') {
2725
+ flush();
2726
+ items.push(html `<li class="turn mine"><div class="bubble">${entry.text}</div></li>`);
2727
+ }
2728
+ else if (entry.role === 'assistant') {
2729
+ if (entry.text.trim() === '')
2730
+ continue;
2731
+ flush();
2732
+ items.push(html `<li class="turn bot"><div class="prose">${new Html(renderSlotlockMarkdown(entry.text))}</div></li>`);
2733
+ }
2734
+ else if (entry.role === 'tool') {
2735
+ for (const result of entry.results) {
2736
+ const write = SLOTLOCK_ASSISTANT_TOOL_USE[result.name] === 'propose';
2737
+ const action = result.action ? readAction(result.action, actions[result.action]) : null;
2738
+ if (action) {
2739
+ cards.push(actionCard(session, action, names, zones));
2740
+ continue;
2741
+ }
2742
+ const title = toolTitles.get(result.name) ?? result.name;
2743
+ const code = result.error ? refusedCode(result.content) : null;
2744
+ steps.push({
2745
+ label: slotlockAssistantStepLabel(result.name, title, result.error ? 'bad' : 'ok', write),
2746
+ ok: !result.error,
2747
+ why: code ? refusal(code) : null,
2748
+ });
2749
+ }
2750
+ }
2751
+ else if (entry.role === 'note') {
2752
+ flush();
2753
+ const last = index === entries.length - 1;
2754
+ if (last && !quietNote(entry.text) && lastUser?.role === 'user') {
2755
+ items.push(html `<li class="turn error" role="alert"><span>${entry.text}</span><form method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="message" value="${lastUser.text}"><button class="text-button" type="submit">Retry</button></form></li>`);
2756
+ }
2757
+ else {
2758
+ items.push(html `<li class="turn note">${entry.text}</li>`);
2759
+ }
2760
+ }
2761
+ }
2762
+ flush();
2763
+ return items;
2764
+ }
2765
+ /** The tenant's resources as a proposed change's card names them, and each one's time zone. */
2766
+ async function resourceLookups(tenantRef) {
2767
+ const resources = await options.resources.list(tenantRef);
2768
+ return {
2769
+ resources,
2770
+ names: new Map(resources.map((resource) => [resource.id, resourceName(resource, resource.id)])),
2771
+ zones: new Map(resources.flatMap((resource) => (resource.timezone ? [[resource.id, resource.timezone]] : []))),
2772
+ };
2773
+ }
2774
+ const ASSISTANT_ICONS = {
2775
+ send: new Html('<svg class="arrow" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13V3M3.5 7.5 8 3l4.5 4.5"/></svg>'),
2776
+ stop: new Html('<svg class="stop" viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor"/></svg>'),
2777
+ down: new Html('<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v10M3.5 8.5 8 13l4.5-4.5"/></svg>'),
2778
+ plus: new Html('<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>'),
2779
+ };
2415
2780
  async function assistantPage(session, status = 200, notice) {
2416
2781
  const store = assistantStore();
2417
2782
  if (!store)
@@ -2421,91 +2786,100 @@ ${decided.length === 0
2421
2786
  store.getSettings(tenantRef),
2422
2787
  store.getConversation(tenantRef, personOf(session)),
2423
2788
  ]);
2789
+ if (!settings) {
2790
+ const content = html `${noticeOf(notice, status)}${pageHead('Assistant')}${emptyState(ICONS.assistant, isAdmin(session) ? 'Connect a model to use the assistant.' : 'An admin of this organization connects a model in Settings.', isAdmin(session) ? { href: `${root}/settings#assistant`, label: 'Connect a model' } : undefined)}`;
2791
+ return respond(status, layout('Assistant', content, session, 'assistant'));
2792
+ }
2424
2793
  const entries = readSlotlockAssistantEntries(conversation?.messages);
2425
2794
  const actions = conversation?.actions ?? {};
2426
- const names = new Map();
2427
- if (Object.keys(actions).length > 0) {
2428
- for (const resource of await options.resources.list(tenantRef)) {
2429
- names.set(resource.id, resourceName(resource, resource.id));
2430
- }
2431
- }
2795
+ const empty = entries.length === 0;
2796
+ const { resources, names, zones } = empty || Object.keys(actions).length > 0
2797
+ ? await resourceLookups(tenantRef)
2798
+ : { resources: [], names: new Map(), zones: new Map() };
2432
2799
  const token = csrfToken(session);
2433
- const items = [];
2434
- for (const entry of entries) {
2435
- if (entry.role === 'user')
2436
- items.push(html `<li class="msg mine"><p>${entry.text}</p></li>`);
2437
- else if (entry.role === 'assistant' && entry.text.trim() !== '') {
2438
- items.push(html `<li class="msg bot"><p>${entry.text}</p></li>`);
2439
- }
2440
- else if (entry.role === 'note')
2441
- items.push(html `<li class="notice">${entry.text}</li>`);
2442
- else if (entry.role === 'tool') {
2443
- for (const result of entry.results) {
2444
- const action = result.action ? readAction(result.action, actions[result.action]) : null;
2445
- if (action)
2446
- items.push(actionCard(session, action, names));
2447
- else {
2448
- items.push(html `<li class="tool-line">${result.error ? 'Could not run' : 'Read'}: ${toolTitles.get(result.name) ?? result.name}</li>`);
2449
- }
2450
- }
2451
- }
2452
- }
2453
- const composer = html `<form class="composer" method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="3" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="Find a free van tomorrow morning and book it for Ana"></textarea><button class="primary" type="submit">Send</button></form>`;
2454
- const reset = entries.length > 0
2455
- ? html `<form class="inline" method="post" action="${root}/assistant/reset"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">New conversation</button></form>`
2456
- : undefined;
2457
- const content = html `${noticeOf(notice)}
2458
- ${pageHead('Assistant', reset)}
2459
- ${settings
2460
- ? html `<section class="panel chat">${items.length === 0
2461
- ? html `<p class="empty">Ask about bookings and free time. Changes wait for your approval.</p>`
2462
- : html `<ol class="thread">${items}</ol>`}<span id="latest"></span>${composer}</section>`
2463
- : html `<p class="empty">${isAdmin(session)
2464
- ? html `No model is connected. <a href="${root}/settings#assistant">Connect one in Settings</a>.`
2465
- : 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
2800
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
2801
+ const first = resources.find((resource) => !resource.archivedAt);
2802
+ const named = first ? resourceName(first, first.id) : null;
2803
+ // A read chip asks at once; a write chip fills the box to finish (app.js), or asks (no script).
2804
+ const prompts = [
2805
+ { label: "What's on today", message: "What's booked today?" },
2806
+ { label: 'Find free time', message: named ? `When is ${named} free tomorrow morning?` : 'What is free tomorrow morning?' },
2807
+ { label: 'Book', message: named ? `Book ${named} tomorrow 9:00–10:00` : 'Book ', fill: true },
2808
+ { label: 'Move a booking', message: 'Move the next booking one hour later', fill: true },
2809
+ { label: 'Cancel a booking', message: 'Cancel the next booking', fill: true },
2810
+ ];
2811
+ const chips = html `<div class="assistant-start"><div class="chips">${prompts.map((prompt) => html `<form method="post" action="${root}/assistant">${hidden()}<button class="chip" type="submit" name="message" value="${prompt.message}"${prompt.fill ? html ` data-fill="${prompt.message}"` : html ``}>${prompt.label}</button></form>`)}</div></div>`;
2812
+ const composer = html `<div class="dock"><button class="jump" type="button" hidden aria-label="Jump to the latest message">${ASSISTANT_ICONS.down}</button><form class="composer" method="post" action="${root}/assistant">${hidden()}<label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="2" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="${empty ? 'Ask about bookings' : 'Reply…'}"></textarea><div class="composer-bar"><span class="count" hidden></span><button class="send" type="submit" aria-label="Send">${ASSISTANT_ICONS.send}${ASSISTANT_ICONS.stop}</button></div></form></div>`;
2813
+ const reset = html `<form class="inline new-chat" method="post" action="${root}/assistant/reset">${hidden()}<button class="ghost" type="submit">${ASSISTANT_ICONS.plus}New conversation</button></form>`;
2814
+ const flag = empty ? new Html(' data-empty') : html ``;
2815
+ const content = html `${noticeOf(notice, status)}<div class="assistant-page"${flag}>${pageHead('Assistant', reset)}<div class="assistant" data-assistant data-stream="${root}/assistant/stream" data-page="${root}/assistant"${flag}><ol class="thread">${assistantItems(session, entries, actions, names, zones)}</ol><span id="latest"></span>${chips}${composer}</div></div>`;
2466
2816
  return respond(status, layout('Assistant', content, session, 'assistant'));
2467
2817
  }
2468
- async function assistantSettingsPanel(session, draft) {
2818
+ /** The named provider a provider and base URL are, or none (Custom). */
2819
+ const presetOf = (provider, baseUrl) => SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id !== 'custom' && preset.provider === provider && preset.baseUrl === baseUrl);
2820
+ /**
2821
+ * The assistant's settings: what is connected and whether it answers (Test, its result in place),
2822
+ * then the form, one provider chosen from tiles, and Disconnect apart. A member sees only what is
2823
+ * connected, and where their messages go.
2824
+ */
2825
+ async function assistantSettingsSection(session, status, notice, draft) {
2469
2826
  const store = assistantStore();
2470
- if (!store)
2471
- return html ``;
2472
2827
  const settings = await store.getSettings(tenantOf(session));
2473
- const sentTo = html `<p class="fine">Members' messages, and the bookings and resources the assistant reads for them, are sent to this provider.</p>`;
2474
- if (!isAdmin(session)) {
2475
- return html `<section class="panel" id="assistant"><h2>Assistant</h2>${settings
2476
- ? html `<dl class="facts"><dt>Provider</dt><dd>${settings.provider === 'anthropic' ? 'Anthropic' : 'OpenAI-compatible'}</dd><dt>Model</dt><dd>${settings.model}</dd></dl>${sentTo}`
2477
- : html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
2478
- }
2479
2828
  const token = csrfToken(session);
2829
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
2830
+ const savedName = settings ? (presetOf(settings.provider, settings.baseUrl)?.name ?? 'Custom') : null;
2831
+ // Where messages go, in words: the provider's name, or a custom one's host.
2832
+ const hostOf = (url) => {
2833
+ try {
2834
+ return new URL(url).host;
2835
+ }
2836
+ catch {
2837
+ return url;
2838
+ }
2839
+ };
2840
+ const savedTo = settings ? (savedName === 'Custom' ? hostOf(settings.baseUrl) : savedName) : null;
2841
+ const admin = isAdmin(session);
2842
+ const badge = !settings
2843
+ ? html `<span class="badge">Not set up</span>`
2844
+ : notice && status === 200 && notice.startsWith('Connected')
2845
+ ? html `<span class="badge ok">Connected</span>`
2846
+ : notice && status === 502
2847
+ ? html `<span class="badge bad">Failing</span>`
2848
+ : html `<span class="badge ok">Set up</span>`;
2849
+ const test = settings && admin
2850
+ ? html `<form method="post" action="${root}/settings/assistant/test">${hidden()}<button type="submit">Test</button></form>`
2851
+ : html ``;
2852
+ const statusCard = html `<div class="set-card status-card" id="assistant"><div class="status-head"><span class="mono-tile" aria-hidden="true">${savedName ? savedName.charAt(0) : '–'}</span><div class="status-name"><strong>${savedName ?? 'No model connected'}</strong>${settings ? html `<code>${settings.model}</code>` : html ``}</div>${badge}${test}</div>${noticeOf(notice, status)}</div>`;
2853
+ const sentTo = (name) => html `<p class="fine">Members' messages, and the bookings they ask about, are sent to ${name ?? 'the provider you choose'}.</p>`;
2854
+ if (!admin) {
2855
+ return html `${statusCard}${settings ? sentTo(savedTo) : html `<p class="fine">An admin of this organization connects a model here.</p>`}`;
2856
+ }
2480
2857
  // What the form shows: a draft (a preset, or a model list) over what is saved.
2481
- const provider = draft?.provider ?? settings?.provider;
2858
+ const provider = draft?.provider ?? settings?.provider ?? 'anthropic';
2482
2859
  const baseUrl = draft ? draft.baseUrl : (settings?.baseUrl ?? '');
2483
2860
  const model = draft?.model ?? settings?.model ?? '';
2484
- const selected = (value) => (provider === value ? html ` selected` : html ``);
2861
+ const chosen = settings || draft ? (presetOf(provider, baseUrl)?.id ?? 'custom') : 'anthropic';
2862
+ const chosenName = chosen === 'custom' ? (baseUrl ? hostOf(baseUrl) : null) : (SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id === chosen)?.name ?? null);
2485
2863
  const models = draft?.models ?? [];
2486
- return html `<section class="panel" id="assistant">
2487
- <h2>Assistant</h2>
2488
- <form class="presets" method="get" action="${root}/settings"><span class="fine">Fill in for</span>${SLOTLOCK_ASSISTANT_PRESETS.map((preset) => html `<button type="submit" name="preset" value="${preset.id}">${preset.name}</button>`)}</form>
2489
- <form method="post" action="${root}/settings/assistant">
2490
- <input type="hidden" name="csrf" value="${token}">
2491
- <input type="hidden" name="once" value="${formNonce(session)}">
2492
- <div class="grid-form">
2493
- <label>Provider<select name="provider"><option value="anthropic"${selected('anthropic')}>Anthropic Messages API</option><option value="openai"${selected('openai')}>OpenAI-compatible</option></select></label>
2494
- <label>Base URL<input name="base_url" required maxlength="2048" value="${baseUrl}" placeholder="https://api.anthropic.com"></label>
2495
- <label>Model<input name="model" required maxlength="200" value="${model}" placeholder="your-model"${models.length > 0 ? html ` list="assistant-models"` : html ``}></label>${models.length > 0
2864
+ const tiles = html `<div class="tiles" role="radiogroup" aria-label="Provider">${SLOTLOCK_ASSISTANT_PRESETS.map((preset) => html `<label class="tile"><input type="radio" name="preset" value="${preset.id}"${preset.id === chosen ? html ` checked` : html ``}><span class="tile-mark" aria-hidden="true">${preset.name.charAt(0)}</span>${preset.name}</label>`)}</div>`;
2865
+ const format = (value, label) => html `<label><input type="radio" name="provider" value="${value}"${provider === value ? html ` checked` : html ``}>${label}</label>`;
2866
+ const keyField = settings?.keyHint
2867
+ ? html `<div class="key-set"><code>•••• ${settings.keyHint}</code><details><summary>Replace</summary><input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" aria-label="New API key"></details></div>`
2868
+ : html `<input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="Optional for a local model">`;
2869
+ const form = html `<form class="set-card provider-form" method="post" action="${root}/settings/assistant">${hidden()}<div class="row stack"><span class="row-label">Provider</span>${tiles}</div><div class="row custom-only"><span class="row-label">API format</span><div class="segmented">${format('anthropic', 'Anthropic Messages')}${format('openai', 'OpenAI-compatible')}</div></div><div class="row custom-only"><label class="row-label" for="base-url">Base URL</label><input id="base-url" class="mono" name="base_url" maxlength="2048" value="${baseUrl}" placeholder="https://host/v1"></div><div class="row"><label class="row-label" for="model">Model</label><div class="field-with-button"><input id="model" class="mono" name="model" required maxlength="200" value="${model}" placeholder="Model name"${models.length > 0 ? html ` list="assistant-models"` : html ``}><button type="submit" formaction="${root}/settings/assistant/models" formnovalidate>Load models</button></div>${models.length > 0
2496
2870
  ? html `<datalist id="assistant-models">${models.map((id) => html `<option value="${id}"></option>`)}</datalist>`
2497
- : html ``}
2498
- <label>API key<input name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="${settings?.keyHint ? 'Leave empty to keep it' : 'Optional for a local model'}"></label>
2499
- </div>
2500
- <p class="fine">Key: ${settings?.keyHint ? html `set, ending in <code>${settings.keyHint}</code>` : 'none'}. OpenAI-compatible covers OpenAI, OpenRouter, vLLM, Ollama and LM Studio; give the URL that ends in <code>/v1</code>.</p>
2501
- ${settings?.keyHint ? html `<label class="choice"><input type="checkbox" name="clear_key" value="1"> Remove the key</label>` : html ``}
2502
- <div class="row-actions"><button class="primary" type="submit">Save</button><button type="submit" formaction="${root}/settings/assistant/models">Load models</button></div>
2503
- </form>
2504
- ${sentTo}
2505
- ${settings
2506
- ? html `<div class="row-actions"><form class="inline" method="post" action="${root}/settings/assistant/test"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Test connection</button></form><details class="act danger"><summary>Disconnect</summary><div class="pop"><p>Members can no longer chat. The key is deleted.</p><form method="post" action="${root}/settings/assistant/remove"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button class="danger" type="submit">Disconnect</button></form></div></details></div>`
2507
- : html ``}
2508
- </section>`;
2871
+ : html ``}</div><div class="row"><label class="row-label" for="api-key">API key</label>${keyField}</div><div class="card-foot">${sentTo(chosenName)}<button class="primary" type="submit">Save</button></div></form>`;
2872
+ const disconnect = settings
2873
+ ? html `<div class="set-card"><div class="danger-row"><strong>Disconnect ${savedName}</strong><details class="act danger"><summary>Disconnect…</summary><div class="pop"><p>The key is deleted. Members can't use the assistant until a model is connected again.</p><form method="post" action="${root}/settings/assistant/remove">${hidden()}<button class="danger" type="submit">Disconnect</button></form></div></details></div></div>`
2874
+ : html ``;
2875
+ return html `${statusCard}${form}${disconnect}`;
2876
+ }
2877
+ /** The provider and base URL a form names: a named provider's own, or Custom's two fields. */
2878
+ function formEndpoint(form) {
2879
+ const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === form.get('preset'));
2880
+ if (preset && preset.id !== 'custom')
2881
+ return { provider: preset.provider, baseUrl: preset.baseUrl };
2882
+ return { provider: form.get('provider'), baseUrl: (form.get('base_url') ?? '').trim() };
2509
2883
  }
2510
2884
  const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this organization can change the assistant.');
2511
2885
  async function saveAssistantSettings(session, form) {
@@ -2514,8 +2888,8 @@ ${settings
2514
2888
  return notFound(session);
2515
2889
  if (!isAdmin(session))
2516
2890
  return adminOnly(session);
2517
- const provider = form.get('provider');
2518
- const checkedUrl = readSlotlockAssistantBaseUrl(form.get('base_url') ?? '', { privateUrls });
2891
+ const { provider, baseUrl: givenUrl } = formEndpoint(form);
2892
+ const checkedUrl = readSlotlockAssistantBaseUrl(givenUrl, { privateUrls });
2519
2893
  const model = (form.get('model') ?? '').trim();
2520
2894
  const apiKey = (form.get('api_key') ?? '').trim();
2521
2895
  if (provider !== 'anthropic' && provider !== 'openai') {
@@ -2566,14 +2940,14 @@ ${settings
2566
2940
  if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
2567
2941
  return tooManyMessages(session, settingsPage);
2568
2942
  }
2569
- const provider = form.get('provider');
2943
+ const { provider, baseUrl: givenUrl } = formEndpoint(form);
2570
2944
  if (provider !== 'anthropic' && provider !== 'openai') {
2571
2945
  return settingsPage(session, 400, 'Choose a provider from the list.');
2572
2946
  }
2573
2947
  const typedModel = (form.get('model') ?? '').trim();
2574
2948
  const draft = {
2575
2949
  provider,
2576
- baseUrl: (form.get('base_url') ?? '').trim(),
2950
+ baseUrl: givenUrl,
2577
2951
  ...(typedModel !== '' ? { model: typedModel } : {}),
2578
2952
  };
2579
2953
  const checkedUrl = readSlotlockAssistantBaseUrl(draft.baseUrl, { privateUrls });
@@ -2655,22 +3029,34 @@ ${settings
2655
3029
  return settingsPage(session, 502, slotlockAssistantProviderMessage(error));
2656
3030
  }
2657
3031
  }
2658
- async function sendToAssistant(session, form) {
3032
+ /**
3033
+ * One member message: check it, run the turn (streaming its text and steps to `onEvent` when
3034
+ * given, stopping when `signal` aborts), and keep the conversation. Both ways to send share it.
3035
+ */
3036
+ async function runAssistantMessage(session, form, hooks = {}) {
2659
3037
  const store = assistantStore();
2660
3038
  if (!store || !assistantOptions)
2661
- return notFound(session);
3039
+ return { ok: false, status: 404, message: 'Nothing is here.' };
2662
3040
  const text = (form.get('message') ?? '').trim();
2663
3041
  if (text === '' || text.length > SLOTLOCK_ASSISTANT_MAX_MESSAGE) {
2664
- return assistantPage(session, 400, `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`);
3042
+ return {
3043
+ ok: false,
3044
+ status: 400,
3045
+ message: `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`,
3046
+ };
2665
3047
  }
2666
3048
  if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
2667
- return tooManyMessages(session, assistantPage);
3049
+ return {
3050
+ ok: false,
3051
+ status: 429,
3052
+ message: 'You have sent the assistant a lot in the last minute. Wait a moment and try again.',
3053
+ };
2668
3054
  }
2669
3055
  const found = await assistantModel(session);
2670
3056
  if (found === null)
2671
- return assistantPage(session, 409, 'No model is connected.');
3057
+ return { ok: false, status: 409, message: 'No model is connected.' };
2672
3058
  if ('problem' in found)
2673
- return assistantPage(session, 409, found.problem);
3059
+ return { ok: false, status: 409, message: found.problem };
2674
3060
  const tenantRef = tenantOf(session);
2675
3061
  const member = personOf(session);
2676
3062
  try {
@@ -2702,6 +3088,8 @@ ${settings
2702
3088
  timeoutMs: assistantTimeout,
2703
3089
  turnTimeoutMs: assistantTurnTimeout,
2704
3090
  ...(assistantOptions.maxSteps !== undefined ? { maxSteps: assistantOptions.maxSteps } : {}),
3091
+ ...(hooks.onEvent ? { onEvent: hooks.onEvent } : {}),
3092
+ ...(hooks.signal ? { signal: hooks.signal } : {}),
2705
3093
  read: async (operation, input) => {
2706
3094
  // A turn's reads share one budget of resource-days, so 8 steps of wide reads cannot each
2707
3095
  // scan a hundred resources over years.
@@ -2758,8 +3146,85 @@ ${settings
2758
3146
  added: Object.fromEntries(Object.entries(proposed).filter(([id]) => id in kept)),
2759
3147
  keep: Object.keys(kept),
2760
3148
  }, conversationTtl);
3149
+ return { ok: true, messages, actions: kept };
3150
+ }
3151
+ /** The form's way (no script): run the turn, then show the conversation at its latest message. */
3152
+ async function sendToAssistant(session, form) {
3153
+ const run = await runAssistantMessage(session, form);
3154
+ if (!run.ok)
3155
+ return run.status === 404 ? notFound(session) : assistantPage(session, run.status, run.message);
2761
3156
  return redirect(303, `${root}/assistant#latest`);
2762
3157
  }
3158
+ /**
3159
+ * app.js's way: the same turn, answered as it happens, one JSON object a line. `text` carries a
3160
+ * piece of the reply, `step` a tool step as it starts and ends, and the last line is `done` (the
3161
+ * turn as the page renders it, which replaces what was streamed) or `error`, each with a fresh
3162
+ * single-use value for the next send. The member's Stop aborts the request, and the turn with it.
3163
+ */
3164
+ async function streamToAssistant(session, form, request) {
3165
+ const encoder = new TextEncoder();
3166
+ const pipe = new TransformStream();
3167
+ const writer = pipe.writable.getWriter();
3168
+ let open = true;
3169
+ const write = (event) => {
3170
+ if (!open)
3171
+ return;
3172
+ writer.write(encoder.encode(`${JSON.stringify(event)}\n`)).catch(() => {
3173
+ open = false;
3174
+ });
3175
+ };
3176
+ const label = (name, state) => slotlockAssistantStepLabel(name, toolTitles.get(name) ?? name, state, SLOTLOCK_ASSISTANT_TOOL_USE[name] === 'propose');
3177
+ const failed = (message) => write({
3178
+ t: 'error',
3179
+ html: html `<li class="turn error" role="alert"><span>${message}</span></li>`.value,
3180
+ once: formNonce(session),
3181
+ });
3182
+ const work = (async () => {
3183
+ try {
3184
+ const run = await runAssistantMessage(session, form, {
3185
+ signal: request.signal,
3186
+ onEvent: (event) => {
3187
+ if (event.type === 'text')
3188
+ write({ t: 'text', d: event.delta });
3189
+ else if (event.type === 'call')
3190
+ write({ t: 'step', id: event.id, s: 'run', label: label(event.name, 'run') });
3191
+ else
3192
+ write({ t: 'step', id: event.id, s: event.ok ? 'ok' : 'bad', label: label(event.name, event.ok ? 'ok' : 'bad') });
3193
+ },
3194
+ });
3195
+ if (!run.ok) {
3196
+ failed(run.message);
3197
+ return;
3198
+ }
3199
+ // The turn from the member's message on, as a reload would show it.
3200
+ const start = run.messages.map((entry) => entry.role).lastIndexOf('user');
3201
+ const turn = run.messages.slice(Math.max(0, start));
3202
+ const lookups = Object.keys(run.actions).length > 0
3203
+ ? await resourceLookups(tenantOf(session))
3204
+ : { names: new Map(), zones: new Map() };
3205
+ write({
3206
+ t: 'done',
3207
+ html: assistantItems(session, turn, run.actions, lookups.names, lookups.zones).join(''),
3208
+ once: formNonce(session),
3209
+ });
3210
+ }
3211
+ catch (error) {
3212
+ report(error instanceof Error ? error.message : 'assistant stream failed');
3213
+ failed('Something went wrong. Reload the page and try again.');
3214
+ }
3215
+ finally {
3216
+ await writer.close().catch(() => undefined);
3217
+ open = false;
3218
+ }
3219
+ })();
3220
+ ignoreRejection(work);
3221
+ const page = respond(200, '');
3222
+ const headers = new Headers(page.headers);
3223
+ headers.set('Content-Type', 'application/x-ndjson; charset=utf-8');
3224
+ // Proxies that buffer (nginx) send each line as it is written.
3225
+ headers.set('X-Accel-Buffering', 'no');
3226
+ return new Response(pipe.readable, { status: 200, headers });
3227
+ }
2763
3228
  async function settleAction(session, form, decision) {
2764
3229
  const store = assistantStore();
2765
3230
  if (!store || !assistantOptions)
@@ -2807,80 +3272,173 @@ ${settings
2807
3272
  await store.deleteConversation(tenantOf(session), personOf(session));
2808
3273
  return redirect(303, `${root}/assistant`);
2809
3274
  }
2810
- async function planPage(session, status = 200, notice, thanks = false) {
3275
+ /**
3276
+ * A workspace's plan as both the Usage page and the overview show it: the plan (or the default it
3277
+ * moves to), what it has against the limits, this month's use against the quotas, and what needs a
3278
+ * person: a failed payment, a quota used up or nearly, more than the coming plan allows.
3279
+ * `here` is the page asking, so an item's action points to the page that resolves it.
3280
+ */
3281
+ async function planStanding(session, here, given) {
3282
+ const active = plans;
2811
3283
  const billing = options.billing;
2812
- if (!billing)
2813
- return notFound(session);
2814
- if (session.env === 'test') {
2815
- // Its own use, counted apart from Live's and never billed.
2816
- const range = slotlockUsageMonthToDate(now());
2817
- const tested = options.usage && options.resources.usage
2818
- ? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
2819
- : null;
2820
- const stat = (label, value) => html `<div class="stat"><span class="stat-label">${label}</span><span class="stat-value">${value.toLocaleString('en-GB')}</span></div>`;
2821
- return respond(status, layout('Plan', html `${pageHead('Plan')}${noticeOf(notice)}<p>The test environment is on your live plan's limits and is never billed. Switch to Live to see your plan, or to change it.</p>${tested
2822
- ? html `<section class="panel" id="usage"><h2>Test usage this month</h2><div class="stats">${stat('Operations', tested.totals.operations)}${stat('Bookings', tested.totals.bookings)}${stat('Holds', tested.totals.holds)}</div><p class="fine">Since ${range.from}, in UTC, counted apart from Live, against the same monthly quotas.</p></section>`
2823
- : html ``}`, session, 'plan'));
2824
- }
2825
3284
  const tenantRef = tenantOf(session);
2826
- const [plan, resourceCount, keys, webhooks] = await Promise.all([
2827
- billing.plan(tenantRef),
3285
+ const [plan, planDefault, resourceCount, keys, webhooks] = await Promise.all([
3286
+ active.plan(tenantRef),
3287
+ active.planDefault ? active.planDefault() : Promise.resolve(null),
2828
3288
  // As the limit counts them: archived resources included.
2829
3289
  options.resources.count
2830
3290
  ? options.resources.count(tenantRef)
2831
3291
  : options.resources.list(tenantRef).then((listed) => listed.length),
2832
- options.keys.list({ tenantRef }),
3292
+ given ?? options.keys.list({ tenantRef }),
2833
3293
  options.resources.webhooks?.list(tenantRef),
2834
3294
  ]);
2835
3295
  const month = slotlockUsageMonthToDate(now());
2836
3296
  const used = options.usage && options.resources.usage
2837
3297
  ? summarizeSlotlockUsage(await options.resources.usage(tenantRef, month.from, month.to))
2838
3298
  : null;
2839
- const keyName = new Map(keys.map((key) => [key.id, key.name]));
2840
- const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
2841
- const token = csrfToken(session);
3299
+ const keyCount = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now())).length;
3300
+ const admin = isAdmin(session);
2842
3301
  const paying = plan !== null && plan.plan !== 'free';
2843
- const name = plan ? PLAN_NAMES[plan.plan] : 'No plan';
2844
- // An organization is offered Team where the deployment sells it; a person, Developer.
2845
- const offered = session.org !== undefined && billing.teamPrices ? 'team' : 'developer';
2846
- const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
2847
- const counted = (value) => value.toLocaleString('en-GB');
2848
- const use = (label, used, limit) => html `<div class="stat"><span class="stat-label">${label}</span><span class="stat-value">${counted(used)}${limit === undefined ? html `` : html `<span class="meta"> of ${counted(limit)}</span>`}</span></div>`;
2849
- // This month's quotas, when the deployment holds tenants to them.
3302
+ const upcoming = !plan && planDefault && planDefault.appliesFrom.getTime() > now() ? planDefault : null;
3303
+ const limits = plan?.limits ?? upcoming?.limits;
2850
3304
  const quotas = options.quotas && plan ? SLOTLOCK_PLAN_QUOTAS[plan.plan] : null;
2851
3305
  const resets = formatDate(slotlockQuotaPeriod(now()).resetsAt);
2852
- const quotaWarnings = quotas && used
2853
- ? ['operations', 'bookings'].flatMap((metric) => {
3306
+ const counted = (value) => value.toLocaleString('en-GB');
3307
+ const usagePage = `${root}/plan`;
3308
+ const upgradeAt = here === 'usage' ? '#upgrade' : `${usagePage}#upgrade`;
3309
+ const canUpgrade = !paying && admin && Boolean(billing || active.contact);
3310
+ const upgradeHint = paying ? '' : admin ? ' Upgrade for more.' : ' An owner of this organization can upgrade.';
3311
+ const attention = [];
3312
+ // Whether anything is near or past a limit: the overview then offers the upgrade.
3313
+ let pressing = false;
3314
+ if (plan?.status === 'past_due') {
3315
+ attention.push(attentionItem('bad', `Your last payment failed. ${admin && billing && plan.stripeCustomer ? 'Update your payment method in Manage billing' : 'An owner updates the payment method'} to keep the ${PLAN_NAMES[plan.plan]} plan.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
3316
+ }
3317
+ if (quotas && used) {
3318
+ for (const metric of ['operations', 'bookings']) {
2854
3319
  const quota = quotas[metric];
2855
3320
  const stop = Math.floor((quota * quotas.stopPercent) / 100);
2856
3321
  const spent = used.totals[metric];
2857
- const what = metric === 'operations' ? 'operations' : 'bookings';
2858
3322
  const refused = metric === 'operations' ? 'every call but reading usage' : 'new bookings and holds';
3323
+ const upgrade = canUpgrade ? { href: upgradeAt, label: 'Upgrade' } : here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined;
3324
+ if (spent * 100 >= quota * 80)
3325
+ pressing = true;
2859
3326
  if (spent >= stop) {
2860
- return [html `<p class="notice warn" role="alert">This month's ${what} are used up: Slotlock refuses ${refused} with quota_exceeded until ${resets}.${paying ? html `` : html ` Upgrade for more.`}</p>`];
3327
+ attention.push(attentionItem('bad', `This month's ${metric} are used up: Slotlock refuses ${refused} with quota_exceeded until ${resets}.${upgradeHint}`, upgrade));
2861
3328
  }
2862
- if (spent >= quota) {
2863
- const billed = metric === 'operations' && billing.overage && plan?.plan === 'developer' && paying;
2864
- return [html `<p class="notice warn" role="alert">You have used this month's ${counted(quota)} ${what}.${billed ? ` Those past it are billed at ${billing.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.</p>`];
3329
+ else if (spent >= quota) {
3330
+ const billed = metric === 'operations' && billing?.overage && plan?.plan === 'developer' && paying;
3331
+ attention.push(attentionItem('warn', `You have used this month's ${counted(quota)} ${metric}.${billed ? ` Those past it are billed at ${billing?.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.${billed ? '' : upgradeHint}`, billed ? undefined : upgrade));
2865
3332
  }
2866
- if (spent * 100 >= quota * 80) {
2867
- return [html `<p class="notice warn" role="status">You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${what}. They start again on ${resets}.</p>`];
3333
+ else if (spent * 100 >= quota * 80) {
3334
+ attention.push(attentionItem('warn', `You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${metric}. They start again on ${resets}.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
2868
3335
  }
2869
- return [];
2870
- })
2871
- : [];
2872
- const warning = html `${plan?.status === 'past_due'
2873
- ? html `<p class="notice warn" role="alert">Your last payment failed. Update your payment method to keep the ${name} plan.</p>`
2874
- : html ``}${quotaWarnings}`;
2875
- const free = SLOTLOCK_PLAN_LIMITS.free;
2876
- const developer = SLOTLOCK_PLAN_LIMITS[offered];
2877
- const upgrade = paying || !isAdmin(session)
2878
- ? html ``
2879
- : html `<section class="panel" id="upgrade">
3336
+ }
3337
+ }
3338
+ if (upcoming) {
3339
+ // What the workspace has that its coming plan will not allow: nothing is removed; adding stops.
3340
+ const over = [
3341
+ ['resources', resourceCount, upcoming.limits.resources],
3342
+ ['active API keys', keyCount, upcoming.limits.apiKeys],
3343
+ ['webhooks', webhooks?.length ?? 0, upcoming.limits.webhookEndpoints],
3344
+ ].filter(([, have, allowed]) => have > allowed);
3345
+ if (over.length > 0)
3346
+ pressing = true;
3347
+ attention.push(attentionItem(over.length > 0 ? 'warn' : 'info', `From ${formatDate(upcoming.appliesFrom)}, this workspace is on ${PLAN_NAMES[upcoming.plan]}: ${String(upcoming.limits.resources)} resources, ${String(upcoming.limits.apiKeys)} API keys and ${String(upcoming.limits.webhookEndpoints)} webhooks.${over.length > 0
3348
+ ? ` You have ${over.map(([what, have]) => `${String(have)} ${what}`).join(', ')}: nothing is removed, but you cannot add more until you have fewer${paying || !admin ? '' : ', or upgrade'}.`
3349
+ : ''}`, canUpgrade && over.length > 0 ? { href: upgradeAt, label: 'See plans' } : undefined));
3350
+ }
3351
+ /** One use against its allowance: a bar, amber from 80%, red at the limit. */
3352
+ const meter = (label, value, limit) => {
3353
+ if (limit === undefined) {
3354
+ return html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
3355
+ }
3356
+ const tone = value >= limit ? ' bad' : value * 100 >= limit * 80 ? ' warn' : '';
3357
+ return html `<div class="meter${tone}"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}<span class="meta"> of ${counted(limit)}</span></span></div><progress value="${String(Math.min(value, limit))}" max="${String(limit)}" aria-label="${label}: ${counted(value)} of ${counted(limit)}"></progress></div>`;
3358
+ };
3359
+ const limitMeters = html `${meter('Resources', resourceCount, limits?.resources)}${meter('Active API keys', keyCount, limits?.apiKeys)}${webhooks ? meter('Webhooks', webhooks.length, limits?.webhookEndpoints) : html ``}`;
3360
+ // Under a "This month" heading, or beside the limits with the month in each label.
3361
+ const monthMeters = (suffix) => used
3362
+ ? html `${meter(`Operations${suffix}`, used.totals.operations, quotas?.operations)}${meter(`Bookings${suffix}`, used.totals.bookings, quotas?.bookings)}${meter(`Holds${suffix}`, used.totals.holds, undefined)}`
3363
+ : html ``;
3364
+ const name = plan
3365
+ ? PLAN_NAMES[plan.plan]
3366
+ : upcoming
3367
+ ? `${PLAN_NAMES[upcoming.plan]} from ${dayOf(upcoming.appliesFrom)}`
3368
+ : 'No plan';
3369
+ return {
3370
+ plan,
3371
+ upcoming,
3372
+ paying,
3373
+ admin,
3374
+ used,
3375
+ month,
3376
+ resets,
3377
+ keys,
3378
+ name,
3379
+ limitMeters,
3380
+ monthMeters,
3381
+ meters: html `${limitMeters}${monthMeters(' this month')}`,
3382
+ attention,
3383
+ canUpgrade,
3384
+ pressing,
3385
+ };
3386
+ }
3387
+ /** One thing that needs a person: a sentence, its tone, and what resolves it. */
3388
+ const attentionItem = (tone, text, action) => html `<li class="attention ${tone}"><span class="attention-text">${text}</span>${action ? html `<a class="attention-action" href="${action.href}">${action.label}</a>` : html ``}</li>`;
3389
+ /** The list of things that need a person, or nothing. */
3390
+ const attentionList = (items) => items.length > 0 ? html `<ul class="attention-list" aria-label="Needs attention">${items}</ul>` : html ``;
3391
+ async function planPage(session, status = 200, notice, thanks = false) {
3392
+ if (!plans)
3393
+ return notFound(session);
3394
+ const billing = options.billing;
3395
+ const counted = (value) => value.toLocaleString('en-GB');
3396
+ if (session.env === 'test') {
3397
+ // Its own use, counted apart from Live's and never billed.
3398
+ const range = slotlockUsageMonthToDate(now());
3399
+ const tested = options.usage && options.resources.usage
3400
+ ? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
3401
+ : null;
3402
+ const meter = (label, value) => html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
3403
+ return respond(status, layout('Usage', html `${pageHead('Usage')}${noticeOf(notice, status)}<p>The test environment is on your live plan's limits and is never billed. Switch to Live to see your plan, or to change it.</p>${tested
3404
+ ? html `<section class="panel" id="usage"><h2>This month, in Test</h2><div class="meters">${meter('Operations', tested.totals.operations)}${meter('Bookings', tested.totals.bookings)}${meter('Holds', tested.totals.holds)}</div><p class="fine">Since ${range.from}, in UTC, counted apart from Live, against the same monthly quotas.</p></section>`
3405
+ : html ``}`, session, 'plan'));
3406
+ }
3407
+ const standing = await planStanding(session, 'usage');
3408
+ const { plan, paying, admin, used, month, resets, keys } = standing;
3409
+ const token = csrfToken(session);
3410
+ const keyName = new Map(keys.map((key) => [key.id, key.name]));
3411
+ // An organization is offered Team (where billing sells it, or by asking); a person, Developer.
3412
+ const offered = session.org !== undefined && (billing ? Boolean(billing.teamPrices) : true) ? 'team' : 'developer';
3413
+ const contact = plans.contact ?? billing?.contact;
3414
+ const manage = billing && plan?.stripeCustomer && admin
3415
+ ? html `<form class="inline" method="post" action="${root}/plan/manage"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Manage billing</button></form>`
3416
+ : html ``;
3417
+ const renews = paying && plan?.currentPeriodEnd
3418
+ ? html `<span class="meta">${plan.status === 'past_due' ? 'Payment due since' : 'Renews'} ${formatDate(plan.currentPeriodEnd)}</span>`
3419
+ : html ``;
3420
+ const planCard = html `<section class="panel plan-card" id="plan"><div class="plan-head"><h2>${standing.name}</h2>${renews}</div><div class="meters">${standing.limitMeters}</div>${used ? html `` : html `<p class="fine">This server does not count use yet.</p>`}</section>${used
3421
+ ? html `<section class="panel" id="month"><h2>This month</h2><div class="meters">${standing.monthMeters('')}</div><p class="fine">Since ${month.from} (UTC); the month starts again on ${resets}. An operation is a call that reached Slotlock, whatever it answered.</p></section>`
3422
+ : html ``}`;
3423
+ const byKey = used && used.keys.length > 0
3424
+ ? html `<section class="panel" id="usage"><h2>Used by</h2><div class="table"><table><thead><tr><th>Key</th><th>Operations</th><th>Bookings</th><th>Holds</th></tr></thead><tbody>${used.keys.map((row) => html `<tr><td data-label="Key">${row.keyId === null ? 'The dashboard and its assistant' : (keyName.get(row.keyId) ?? 'A deleted key')}</td><td data-label="Operations">${counted(row.operations)}</td><td data-label="Bookings">${counted(row.bookings)}</td><td data-label="Holds">${counted(row.holds)}</td></tr>`)}</tbody></table></div><p class="fine">Webhook deliveries ${counted(used.totals.webhook_deliveries)} · assistant messages ${counted(used.totals.assistant_messages)}.</p></section>`
3425
+ : html ``;
3426
+ const offeredLimits = SLOTLOCK_PLAN_LIMITS[offered];
3427
+ const offeredText = html `${String(offeredLimits.resources)} resources, ${String(offeredLimits.apiKeys)} API keys and ${String(offeredLimits.webhookEndpoints)} webhook endpoints${options.quotas
3428
+ ? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS[offered].operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS[offered].bookings)} bookings a month${offered === 'team' ? ' shared by the organization' : ''}`
3429
+ : html ``}.`;
3430
+ let upgrade = html ``;
3431
+ if (!paying) {
3432
+ if (!admin) {
3433
+ upgrade = html `<p class="fine">An owner of this organization can change its plan.</p>`;
3434
+ }
3435
+ else if (billing) {
3436
+ const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
3437
+ upgrade = html `<section class="panel" id="upgrade">
2880
3438
  <h2>${PLAN_NAMES[offered]}</h2>
2881
- <p>${String(developer.resources)} resources, ${String(developer.apiKeys)} API keys and ${String(developer.webhookEndpoints)} webhook endpoints${options.quotas ? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS[offered].operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS[offered].bookings)} bookings a month${offered === 'team' ? ' shared by the organization' : ''}` : html ``}.${offered === 'developer' && billing.overage && options.quotas
2882
- ? ` Operations past them are billed at ${billing.overage.price}, up to ${String(billing.overage.stopPercent)}% of them.`
2883
- : ''}</p>
3439
+ <p>${offeredText}${offered === 'developer' && billing.overage && options.quotas
3440
+ ? ` Operations past them are billed at ${billing.overage.price}, up to ${String(billing.overage.stopPercent)}% of them.`
3441
+ : ''}</p>
2884
3442
  <form method="post" action="${root}/plan/upgrade">
2885
3443
  <input type="hidden" name="csrf" value="${token}">
2886
3444
  <input type="hidden" name="once" value="${formNonce(session)}">
@@ -2892,46 +3450,22 @@ ${settings
2892
3450
  </form>
2893
3451
  <p class="fine">Paid through Stripe, in your currency, with tax added where it applies. Cancel at any time.</p>
2894
3452
  </section>`;
2895
- const manage = plan?.stripeCustomer && isAdmin(session)
2896
- ? html `<form class="inline" method="post" action="${root}/plan/manage"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Manage billing</button></form>`
2897
- : html ``;
2898
- const renews = paying && plan?.currentPeriodEnd
2899
- ? html `<p class="fine">${plan.status === 'past_due' ? 'Payment due since' : 'Renews'} ${formatDate(plan.currentPeriodEnd)}.</p>`
2900
- : html ``;
2901
- const usageSection = used
2902
- ? html `<section class="panel" id="usage">
2903
- <h2>Usage this month</h2>
2904
- <div class="stats">
2905
- ${use('Operations', used.totals.operations, quotas?.operations)}
2906
- ${use('Bookings', used.totals.bookings, quotas?.bookings)}
2907
- ${use('Holds', used.totals.holds, undefined)}
2908
- ${use('Webhook deliveries', used.totals.webhook_deliveries, undefined)}
2909
- ${use('Assistant messages', used.totals.assistant_messages, undefined)}
2910
- </div>
2911
- <p class="fine">Since ${month.from}, in UTC. An operation is a call that reached Slotlock, whatever it answered; it shows here within 15 seconds. A booking is a reservation, a calendar event or a confirmed hold, and with holds shows within the hour.</p>
2912
- ${used.keys.length === 0
2913
- ? html ``
2914
- : html `<div class="table"><table><thead><tr><th>Used by</th><th>Operations</th><th>Bookings</th><th>Holds</th></tr></thead><tbody>${used.keys.map((row) => html `<tr><td data-label="Used by">${row.keyId === null ? 'The dashboard and its assistant' : (keyName.get(row.keyId) ?? 'A deleted key')}</td><td data-label="Operations">${String(row.operations)}</td><td data-label="Bookings">${String(row.bookings)}</td><td data-label="Holds">${String(row.holds)}</td></tr>`)}</tbody></table></div>`}
2915
- </section>`
2916
- : html ``;
2917
- const content = html `${thanks ? html `<p class="notice">${notice ?? ''}</p>` : noticeOf(notice)}
2918
- ${warning}
2919
- ${pageHead(`Plan: ${name}`, manage)}
2920
- <div class="stats">
2921
- ${use('Resources', resourceCount, plan?.limits.resources)}
2922
- ${use('Active API keys', active.length, plan?.limits.apiKeys)}
2923
- ${webhooks ? use('Webhooks', webhooks.length, plan?.limits.webhookEndpoints) : html ``}
2924
- </div>
2925
- ${renews}
2926
- ${usageSection}
2927
- ${plan ? html `` : html `<p class="fine">Free allows ${String(free.resources)} resources, ${String(free.apiKeys)} API keys and ${String(free.webhookEndpoints)} webhook endpoints${options.quotas ? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS.free.operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS.free.bookings)} bookings a month` : html ``}.</p>`}
3453
+ }
3454
+ else if (contact) {
3455
+ // No payment here yet: the plan is arranged by asking.
3456
+ upgrade = html `<section class="panel" id="upgrade"><h2>${PLAN_NAMES[offered]}</h2><p>${offeredText}</p><a class="button primary" href="${contact}">Talk to us about ${PLAN_NAMES[offered]}</a></section>`;
3457
+ }
3458
+ }
3459
+ const content = html `${thanks ? html `<p class="notice done" role="status">${notice ?? ''}</p>` : noticeOf(notice, status)}
3460
+ ${pageHead('Usage', manage)}
3461
+ ${attentionList(standing.attention)}
3462
+ ${planCard}
3463
+ ${byKey}
2928
3464
  ${upgrade}
2929
- <section class="panel" id="custom">
2930
- <h2>Custom</h2>
2931
- <p>Slotlock on your own servers, with support from the people who build it.</p>
2932
- <a class="button" href="${billing.contact}">Talk to the founder</a>
2933
- </section>`;
2934
- return respond(status, layout('Plan', content, session, 'plan'));
3465
+ ${contact
3466
+ ? html `<section class="panel" id="custom"><h2>Custom</h2><p>Slotlock on your own servers, with support from the people who build it.</p><a class="button" href="${contact}">Talk to the founder</a></section>`
3467
+ : html ``}`;
3468
+ return respond(status, layout('Usage', content, session, 'plan'));
2935
3469
  }
2936
3470
  async function upgradePlan(session, form) {
2937
3471
  const billing = options.billing;
@@ -3007,7 +3541,7 @@ ${upgrade}
3007
3541
  return assistantPage;
3008
3542
  if (route.startsWith('/settings'))
3009
3543
  return settingsPage;
3010
- if (route.startsWith('/plan') && options.billing)
3544
+ if (route.startsWith('/plan') && plans)
3011
3545
  return planPage;
3012
3546
  if (route.startsWith('/teams') && teams)
3013
3547
  return teamsPage;
@@ -3188,7 +3722,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
3188
3722
  : html `<span class="muted">An owner can turn it on</span>`;
3189
3723
  return html `<tr><td data-label="Organization" class="strong">${org.login}</td><td data-label="You are">${owner ? 'Owner' : 'Member'}</td><td data-label="Slotlock"><span class="pill ${on ? 'active' : 'revoked'}">${on ? 'On' : 'Off'}</span></td><td class="actions">${actions}</td></tr>`;
3190
3724
  });
3191
- const content = html `${noticeOf(notice)}${pageHead('Teams')}
3725
+ const content = html `${noticeOf(notice, status)}${pageHead('Teams')}
3192
3726
  <p>Work with the people in a GitHub organization: once an owner turns Slotlock on for it, every owner and member shares its resources, keys and history, and picks it from the workspace menu. Your own workspace stays yours.</p>
3193
3727
  ${rows.length === 0
3194
3728
  ? emptyState(ICONS.teams, 'Slotlock sees none of your GitHub organizations. It reads them as you sign in: after joining one, sign out and in again. An organization that restricts OAuth apps needs an owner to approve this one.')
@@ -3632,9 +4166,9 @@ ${rows.length === 0
3632
4166
  }
3633
4167
  // Keys limited to part of the tenant come with Developer (RFC 0003 pricing); a server without
3634
4168
  // plans allows them.
3635
- const plan = options.billing ? await options.billing.plan(tenantOf(session)) : null;
4169
+ const plan = plans ? await plans.plan(tenantOf(session)) : null;
3636
4170
  if (plan?.plan === 'free') {
3637
- return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See Plan.');
4171
+ return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See Usage.');
3638
4172
  }
3639
4173
  }
3640
4174
  try {
@@ -3659,8 +4193,8 @@ ${rows.length === 0
3659
4193
  return keysPage(session, 400, 'Choose up to 20 locations and 20 groups from the lists.');
3660
4194
  }
3661
4195
  if (code === 'api_key_limit_reached') {
3662
- return keysPage(session, 409, options.billing
3663
- ? 'You have as many active keys as your plan allows. Revoke one first, or see Plan.'
4196
+ return keysPage(session, 409, plans
4197
+ ? 'You have as many active keys as your plan allows. Revoke one first, or see Usage.'
3664
4198
  : 'You have as many keys as Slotlock allows. Revoke one first.');
3665
4199
  }
3666
4200
  throw error;
@@ -3735,7 +4269,7 @@ ${rows.length === 0
3735
4269
  if (code === 'resource_limit_reached') {
3736
4270
  const limit = error.limit;
3737
4271
  return resourcesPage(session, 409, typeof limit === 'number' && limit < SLOTLOCK_DASHBOARD_MAX_RESOURCES
3738
- ? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Plan.`
4272
+ ? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Usage.`
3739
4273
  : `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
3740
4274
  }
3741
4275
  if (code === 'invalid_timezone') {
@@ -3756,7 +4290,7 @@ ${rows.length === 0
3756
4290
  const session = await readSession(request);
3757
4291
  if (!session)
3758
4292
  return redirect(303, root);
3759
- const form = await readForm(request, route === '/assistant' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
4293
+ const form = await readForm(request, route === '/assistant' || route === '/assistant/stream' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
3760
4294
  if (form === 'too_large') {
3761
4295
  return respond(413, layout('Refused', html `<p class="notice">That form is too large.</p>`, session));
3762
4296
  }
@@ -3794,12 +4328,15 @@ ${rows.length === 0
3794
4328
  return pageFor(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
3795
4329
  }
3796
4330
  if (!first) {
4331
+ // The first one was taken: a second click, a reload or a resend shows what it did.
4332
+ if (route.startsWith('/assistant'))
4333
+ return redirect(303, `${root}/assistant#latest`);
3797
4334
  return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
3798
4335
  }
3799
4336
  }
3800
4337
  // A tenant new to plans is put on the default plan before it adds what plans limit.
3801
4338
  if (route === '/keys' || route === '/resources' || route === '/webhooks') {
3802
- await options.billing?.enrol?.(tenantOf(session));
4339
+ await plans?.enrol?.(tenantOf(session));
3803
4340
  }
3804
4341
  switch (route) {
3805
4342
  case '/sign-out':
@@ -3857,6 +4394,8 @@ ${rows.length === 0
3857
4394
  return sandboxAction(session, form, 'fast-forward');
3858
4395
  case '/assistant':
3859
4396
  return sendToAssistant(session, form);
4397
+ case '/assistant/stream':
4398
+ return streamToAssistant(session, form, request);
3860
4399
  case '/assistant/approve':
3861
4400
  return settleAction(session, form, 'approve');
3862
4401
  case '/assistant/dismiss':
@@ -3906,6 +4445,9 @@ ${rows.length === 0
3906
4445
  case '/assistant':
3907
4446
  case '/plan':
3908
4447
  case '/teams':
4448
+ case '/settings/endpoints':
4449
+ case '/settings/hours':
4450
+ case '/settings/account':
3909
4451
  case '/settings': {
3910
4452
  const session = await readSession(request);
3911
4453
  if (!session)
@@ -3929,14 +4471,20 @@ ${rows.length === 0
3929
4471
  return await approvalsPage(session);
3930
4472
  if (route === '/assistant')
3931
4473
  return await assistantPage(session);
4474
+ if (route === '/settings/endpoints')
4475
+ return await settingsPage(session, 200, undefined, undefined, 'endpoints');
4476
+ if (route === '/settings/hours')
4477
+ return await settingsPage(session, 200, undefined, undefined, 'hours');
4478
+ if (route === '/settings/account')
4479
+ return await settingsPage(session, 200, undefined, undefined, 'account');
3932
4480
  // A provider preset fills the assistant's form; nothing is saved until Save.
3933
4481
  const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
3934
4482
  return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
3935
4483
  }
3936
4484
  case '/style.css':
3937
- return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
4485
+ return asset(DASHBOARD_ASSETS.css, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
3938
4486
  case '/app.js':
3939
- return asset(DASHBOARD_JS, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
4487
+ return asset(DASHBOARD_ASSETS.js, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
3940
4488
  case '/favicon.svg':
3941
4489
  return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
3942
4490
  case '/sign-in':
@@ -4140,13 +4688,44 @@ a.stat:hover { background: var(--surface-2); text-decoration: none; }
4140
4688
  a.stat::after { content: ""; position: absolute; top: 18px; right: 18px; width: 6px; height: 6px; border-top: 1.5px solid var(--ink-3); border-right: 1.5px solid var(--ink-3); transform: rotate(45deg); opacity: 0; transition: opacity var(--fast) var(--ease); }
4141
4689
  a.stat:hover::after, a.stat:focus-visible::after { opacity: 1; }
4142
4690
  a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
4143
- .stat-label { font: 500 var(--text-xs)/1.4 var(--mono); color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; }
4691
+ .stat-label { font-size: var(--text-sm); line-height: 1.4; color: var(--ink-3); }
4692
+ .attention-stat .stat-value { color: var(--accent); }
4144
4693
  .stat-value { font-size: var(--text-2xl); line-height: 1.1; font-weight: 500; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
4145
4694
 
4146
- /* Setup checklist */
4147
- .setup .steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; counter-reset: step; }
4148
- .steps li { display: flex; align-items: center; gap: var(--space-3); min-height: 36px; color: var(--ink); font-weight: 500; }
4149
- .steps li.done { color: var(--ink-3); font-weight: 400; text-decoration: line-through; text-decoration-color: var(--line-strong); }
4695
+ /* Overview: what needs a person, getting started, the side column */
4696
+ .column { display: grid; gap: var(--space-5); align-content: start; min-width: 0; }
4697
+ .panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); }
4698
+ .panel-head h2 { margin: 0; }
4699
+ .panel-head .meta { flex: none; }
4700
+ .attention-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
4701
+ .attention { display: flex; align-items: center; gap: var(--space-4); padding: 10px var(--space-4); border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); font-size: var(--text-md); color: var(--ink); box-shadow: inset 3px 0 0 var(--ink-3); }
4702
+ .attention.bad { border-color: var(--bad-line); background: var(--bad-wash); box-shadow: inset 3px 0 0 var(--bad); }
4703
+ .attention.warn { border-color: var(--warn-line); background: var(--warn-wash); box-shadow: inset 3px 0 0 var(--warn); }
4704
+ .attention.info { box-shadow: inset 3px 0 0 var(--accent); }
4705
+ .attention-text { flex: 1; min-width: 0; }
4706
+ .attention-action { flex: none; font-weight: 600; white-space: nowrap; }
4707
+ .segments { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 4px; }
4708
+ .segments span { height: 4px; border-radius: var(--radius-full); background: var(--sunken); }
4709
+ .segments span.done { background: var(--good); }
4710
+ .setup-card .segments { margin-bottom: var(--space-3); }
4711
+ .setup-steps { list-style: none; margin: 0; padding: 0; display: grid; }
4712
+ .setup-steps li { display: flex; align-items: center; gap: var(--space-3); min-height: 44px; border-top: 1px solid var(--line-soft); color: var(--ink-3); }
4713
+ .setup-steps li:first-child { border-top: 0; }
4714
+ .setup-steps li.next { color: var(--ink); font-weight: 500; }
4715
+ .setup-steps li.done .step-title { text-decoration: line-through; text-decoration-color: var(--line-strong); }
4716
+ .setup-steps .step-title { flex: 1; min-width: 0; }
4717
+ .setup-row { display: flex; align-items: center; gap: var(--space-4); padding: 10px var(--space-4); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); color: var(--ink-2); font-size: var(--text-md); }
4718
+ .setup-row:hover { background: var(--surface-2); text-decoration: none; }
4719
+ .setup-row .segments { width: 96px; flex: none; }
4720
+ .plan-card .panel-head h2 { font-size: var(--text-lg); }
4721
+ .meters.stacked { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); margin: 0 0 var(--space-4); }
4722
+ .plan-card > .button { width: 100%; justify-content: center; }
4723
+ details.command { margin-top: var(--space-4); }
4724
+ details.command > summary { cursor: pointer; font-size: var(--text-md); color: var(--ink-2); }
4725
+ details.command[open] > summary { margin-bottom: var(--space-2); }
4726
+ .copy-block { position: relative; }
4727
+ .copy-block pre { padding-right: 72px; font-size: var(--text-sm); }
4728
+ .copy-block button { position: absolute; top: 6px; right: 6px; }
4150
4729
  .check { display: inline-grid; place-items: center; width: 20px; height: 20px; flex: none; border-radius: var(--radius-full); border: 1.5px dashed var(--line-strong); color: var(--surface); }
4151
4730
  .done .check { background: var(--good); border: 0; }
4152
4731
  .check .icon { width: 12px; height: 12px; stroke-width: 3; }
@@ -4164,7 +4743,11 @@ a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
4164
4743
  .on { color: var(--ink-3); font-weight: 400; }
4165
4744
  .meta { font-size: var(--text-sm); color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4166
4745
  .feed { position: relative; }
4167
- .event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr); gap: var(--space-3); align-items: start; padding: 7px 0; }
4746
+ .event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: var(--space-3); align-items: start; padding: 7px 0; }
4747
+ .event .title { font-weight: 400; color: var(--ink-2); white-space: normal; overflow-wrap: anywhere; }
4748
+ .event .title strong { font-weight: 600; color: var(--ink); }
4749
+ .event .when { font-size: var(--text-sm); font-weight: 400; color: var(--ink-3); white-space: nowrap; padding-top: 1px; }
4750
+ .times { font-size: var(--text-sm); color: var(--ink-3); }
4168
4751
  .event:not(:last-child)::before { content: ""; position: absolute; left: 5.5px; top: 22px; bottom: -8px; width: 1px; background: var(--line); }
4169
4752
  .dot { position: relative; width: 12px; height: 12px; margin-top: 4px; border-radius: var(--radius-full); background: var(--surface); box-shadow: inset 0 0 0 3px var(--ink-3); }
4170
4753
  .dot.good { box-shadow: inset 0 0 0 3px var(--good); }
@@ -4204,8 +4787,6 @@ textarea { padding: 10px var(--space-3); resize: vertical; min-height: 72px; lin
4204
4787
  .create .grow { flex: 1 1 220px; }
4205
4788
  label > input, label > select, label > .unit-input { width: 100%; }
4206
4789
  label > textarea { width: 100%; padding: var(--space-2) var(--space-3); resize: vertical; }
4207
- .presets { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin: 0 0 var(--space-3); }
4208
- .presets button { height: 28px; padding: 0 var(--space-3); font-size: var(--text-sm); }
4209
4790
  label.choice > input { width: 16px; }
4210
4791
  .create label:not(.grow) { flex: 0 1 auto; }
4211
4792
  form > label { margin-bottom: var(--space-3); }
@@ -4295,6 +4876,22 @@ form > .table { margin-bottom: var(--space-3); }
4295
4876
  /* Notices and empty states */
4296
4877
  .notice { display: block; border: 1px solid var(--line); border-radius: var(--radius); padding: var(--space-3) var(--space-4); background: var(--surface); color: var(--ink); margin: 0; font-size: var(--text-md); }
4297
4878
  .notice[role="alert"] { border-color: var(--bad-line); background: var(--bad-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--bad); }
4879
+ .meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-5) var(--space-6); margin: var(--space-4) 0; }
4880
+ .meter { display: grid; gap: 6px; min-width: 0; }
4881
+ .meter-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }
4882
+ .meter-value { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
4883
+ .meter progress { appearance: none; -webkit-appearance: none; width: 100%; height: 6px; border: 0; border-radius: var(--radius-full); background: var(--sunken); overflow: hidden; }
4884
+ .meter progress::-webkit-progress-bar { background: var(--sunken); border-radius: var(--radius-full); }
4885
+ .meter progress::-webkit-progress-value { background: var(--ink-2); border-radius: var(--radius-full); }
4886
+ .meter progress::-moz-progress-bar { background: var(--ink-2); border-radius: var(--radius-full); }
4887
+ .meter.warn progress::-webkit-progress-value { background: var(--warn); }
4888
+ .meter.warn progress::-moz-progress-bar { background: var(--warn); }
4889
+ .meter.bad progress::-webkit-progress-value { background: var(--bad); }
4890
+ .meter.bad progress::-moz-progress-bar { background: var(--bad); }
4891
+ .meter.bad .meter-value { color: var(--bad); }
4892
+ .plan-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
4893
+ .plan-head h2 { margin: 0; }
4894
+ .notice.done { border-color: color-mix(in srgb, var(--good) 35%, var(--line)); background: var(--good-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--good); }
4298
4895
  .notice.warn, .notice.warn[role="alert"] { border-color: var(--warn-line); background: var(--warn-wash); color: var(--warn); box-shadow: inset 3px 0 0 var(--warn); }
4299
4896
  .notice p { color: inherit; margin: 0 0 var(--space-2); }
4300
4897
  .empty { color: var(--ink-3); margin: 0; }
@@ -4331,23 +4928,6 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
4331
4928
  .bare-main > .notice { max-width: 480px; }
4332
4929
 
4333
4930
  /* Assistant */
4334
- .chat { display: grid; gap: var(--space-5); }
4335
- .thread { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3); }
4336
- .msg { max-width: 78%; padding: 10px 14px; border-radius: 14px; background: var(--surface-2); border: 1px solid var(--line); }
4337
- .msg p { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
4338
- .msg.mine { justify-self: end; background: var(--button); border-color: var(--button); border-bottom-right-radius: 4px; }
4339
- .msg.mine p { color: var(--button-ink); }
4340
- .msg.bot { justify-self: start; border-bottom-left-radius: 4px; }
4341
- .tool-line { font: 400 var(--text-sm)/1.5 var(--mono); color: var(--ink-3); padding-left: var(--space-1); }
4342
- .thread > .notice { justify-self: start; }
4343
- .action-card { justify-self: start; width: min(100%, 540px); padding: var(--space-4) var(--space-5); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--pop-shadow); display: grid; gap: var(--space-3); }
4344
- .action-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
4345
- .action-card .facts { font-size: var(--text-md); gap: 6px var(--space-4); }
4346
- .action-card .facts dt::first-letter { text-transform: uppercase; }
4347
- .action-card pre.wrap { white-space: pre-wrap; margin: 0; padding: var(--space-2); font-size: 12px; }
4348
- .action-summary { margin: 0; color: var(--ink); font-weight: 500; overflow-wrap: anywhere; }
4349
- .action-card .action-buttons { margin-top: var(--space-1); }
4350
- .composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: end; margin: 0; padding-top: var(--space-4); border-top: 1px solid var(--line-soft); }
4351
4931
 
4352
4932
  @media (max-width: 1080px) {
4353
4933
  .split { grid-template-columns: minmax(0, 1fr); }
@@ -4377,6 +4957,8 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
4377
4957
  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
4378
4958
  .stat { padding: 14px var(--space-4); box-shadow: -1px 0 0 var(--line), 0 -1px 0 var(--line); }
4379
4959
  .stat-value { font-size: 22px; }
4960
+ .attention { flex-wrap: wrap; gap: var(--space-1) var(--space-4); }
4961
+ .setup-row .segments { width: 56px; }
4380
4962
  .create > * { flex: 1 1 100%; }
4381
4963
  .create button { width: 100%; }
4382
4964
  .endpoints { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
@@ -4405,8 +4987,6 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
4405
4987
  .act.danger summary { border-color: var(--bad-line); }
4406
4988
  .pop { left: 0; right: auto; width: min(300px, calc(100vw - 48px)); transform-origin: top left; }
4407
4989
  .table .pop { margin-left: 0; }
4408
- .msg { max-width: 92%; }
4409
- .composer { grid-template-columns: minmax(0, 1fr); }
4410
4990
  .empty-state { padding: var(--space-8) var(--space-4); }
4411
4991
  .bare-main { padding-top: 6vh; }
4412
4992
  .sign-in { padding: var(--space-6); }
@@ -4425,10 +5005,15 @@ const COMPOSITE_PROBLEMS = Object.freeze({
4425
5005
  composite_parts_limit: 'A whole has at most 50 parts.',
4426
5006
  composite_busy: 'One of them has a booking or hold still to come. A composite changes only while neither does.',
4427
5007
  });
4428
- /** What the Plan page calls each plan. */
5008
+ /** What the Usage page calls each plan. */
4429
5009
  const PLAN_NAMES = Object.freeze({ free: 'Free', developer: 'Developer', team: 'Team' });
5010
+ const DASHBOARD_ASSETS = Object.freeze({
5011
+ css: DASHBOARD_CSS + SLOTLOCK_ASSISTANT_CSS + SLOTLOCK_SETTINGS_CSS,
5012
+ // The chat's script renders a reply with the server's own Markdown renderer, embedded.
5013
+ js: DASHBOARD_JS + slotlockAssistantScript(renderSlotlockMarkdown.toString()),
5014
+ });
4430
5015
  const ASSET_VERSIONS = Object.freeze({
4431
- css: createHash('sha256').update(DASHBOARD_CSS).digest('base64url').slice(0, 12),
4432
- js: createHash('sha256').update(DASHBOARD_JS).digest('base64url').slice(0, 12),
5016
+ css: createHash('sha256').update(DASHBOARD_ASSETS.css).digest('base64url').slice(0, 12),
5017
+ js: createHash('sha256').update(DASHBOARD_ASSETS.js).digest('base64url').slice(0, 12),
4433
5018
  icon: createHash('sha256').update(SLOTLOCK_DASHBOARD_ICON_SVG).digest('base64url').slice(0, 12),
4434
5019
  });