tinywebui 0.1.4 → 0.1.5

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.
Files changed (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -53
  3. package/package.json +25 -8
  4. package/public/app.js +40 -34
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -53
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +84 -21
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -136
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -454
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +90 -72
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -111
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +426 -415
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
@@ -4,7 +4,8 @@ import { addError } from '../chat/transcript.js';
4
4
  import { whoami, loadAccess } from '../core/access.js';
5
5
  import { api } from '../core/api.js';
6
6
  import { PANEL, closePanel } from './panels.js';
7
- import { initConnectors, renderConnectors, collectConnectors, lockConnectors } from './connectors.js';
7
+ import { initModelEditor, renderCatalog, collectCatalog, setLocked, newConnector, newModel, modelPages, usesDefaultEndpoint } from './model-editor.js';
8
+ import { renderHome } from './settings-home.js';
8
9
 
9
10
  /**
10
11
  * The tools panel: built-ins first, then every MCP server with its own health
@@ -23,6 +24,10 @@ export const isReadOnly = () => readOnly;
23
24
  // The deployment's model as last loaded; the composer's model menu starts from it.
24
25
  let model = '';
25
26
  export const configuredModel = () => model;
27
+ // An admin of an instance whose default model has no endpoint yet: the page
28
+ // opens settings on load, since nothing else works until one is connected.
29
+ let setupNeeded = false;
30
+ export const needsSetup = () => setupNeeded;
26
31
 
27
32
  function renderToolPanel(data) {
28
33
  const box = $('tools');
@@ -231,11 +236,60 @@ export async function loadMcp() {
231
236
  : locked ? 'set in code (read-only)' : path;
232
237
  }
233
238
 
239
+ /* ---------- pages ----------
240
+ * Settings is a home page (endpoints and their models) and a page per thing
241
+ * opened from it. Every page edits the same unsaved draft, so moving between
242
+ * them loses nothing and one Save writes it all. */
243
+
244
+ let view = 'home';
245
+ // Whether this person edits the deployment's endpoints and catalog.
246
+ let admin = false;
247
+ let defaultKey = { set: false, hint: '' };
248
+ let modelField = null; // the default-model field, moved between its two homes
249
+
250
+ function showView(name, page) {
251
+ view = name;
252
+ for (const v of document.querySelectorAll('#settings .set-view')) v.hidden = v.dataset.view !== name;
253
+ const current = document.querySelector(`#settings .set-view[data-view="${name}"]`);
254
+ let title = current.dataset.title;
255
+ if (name === 'connector' || name === 'model') {
256
+ $(`${name}Slot`).replaceChildren(page);
257
+ page.refresh?.();
258
+ const s = page.summary();
259
+ title = s.label || s.id || (name === 'model' ? 'New model' : 'New connector');
260
+ }
261
+ if (name === 'home' && admin) renderHome({ open: showView, defaultModel: $('model').value.trim(), defaultKey, defaultModelField: modelField });
262
+ // Off the home card (catalog not empty, or not an admin) the field lives on
263
+ // the defaults page; an admin with a catalog picks the default per model.
264
+ if (!modelField.isConnected || name !== 'home') $('defaultModelGroup').appendChild(modelField);
265
+ $('defaultModelGroup').hidden = admin;
266
+ $('setTitle').textContent = title;
267
+ $('setBack').hidden = name === 'home' || readOnly;
268
+ document.querySelector('#settings .settings-content').scrollTop = 0;
269
+ }
270
+
271
+ /** Esc and Back: one page up, or false when already at the top. */
272
+ export function settingsBack() {
273
+ if (view === 'home' || readOnly) return false;
274
+ showView('home');
275
+ return true;
276
+ }
277
+
234
278
  export function initSettings() {
235
279
  $('saveMcp').onclick = saveMcp;
236
280
  $('save').onclick = saveConfig;
237
281
  $('testDefault').onclick = testDefault;
238
- initConnectors();
282
+ modelField = $('defaultModelField');
283
+ $('setBack').onclick = settingsBack;
284
+ for (const b of document.querySelectorAll('#settings [data-open]')) b.onclick = () => showView(b.dataset.open);
285
+ $('addConnector').onclick = () => showView('connector', newConnector());
286
+ initModelEditor({
287
+ isDefault: (id) => Boolean(id) && $('model').value.trim() === id,
288
+ makeDefault: (id) => { $('model').value = id; },
289
+ renameDefault: (from, to) => { if (from && $('model').value.trim() === from) $('model').value = to; },
290
+ removed: () => showView('home'),
291
+ addModel: (cid) => showView('model', newModel(cid))
292
+ });
239
293
  }
240
294
 
241
295
  async function testDefault() {
@@ -303,7 +357,7 @@ export async function loadConfig() {
303
357
  $('myInstructionsGroup').hidden = !mine.includes('instructions');
304
358
  $('myInstructions').value = whoami().instructions || '';
305
359
  $('themeGroup').hidden = readOnly && !mine.includes('theme');
306
- $('myPrefsHead').hidden = readOnly ? !mine.length : false;
360
+ $('myPrefsLink').hidden = readOnly ? !mine.length : false;
307
361
  for (const id of FORM_KEYS) {
308
362
  const locked = readOnly || lockedKeys.has(id);
309
363
  $(id).disabled = locked;
@@ -332,9 +386,11 @@ export async function loadConfig() {
332
386
  $('timezone').value = cfg.timezone ?? '';
333
387
  $('toolApproval').value = cfg.toolApproval ?? 'writes';
334
388
  // The admin block is only sent to (and only meaningful for) the sole user or an admin.
335
- const admin = Boolean(whoami().isAdmin) && !readOnly && cfg.baseUrl !== undefined;
336
- $('adminSettings').hidden = !admin;
389
+ admin = Boolean(whoami().isAdmin) && !readOnly && cfg.baseUrl !== undefined;
390
+ $('adminConnection').hidden = !admin;
391
+ for (const n of document.querySelectorAll('#settings .admin-only')) n.hidden = !admin;
337
392
  if (admin) {
393
+ defaultKey = { set: Boolean(cfg.hasApiKey), hint: cfg.keyHint || '' };
338
394
  $('baseUrl').value = cfg.baseUrl ?? '';
339
395
  $('apiKey').value = '';
340
396
  $('apiKey').placeholder = cfg.hasApiKey ? `set (…${cfg.keyHint || ''}) — blank keeps it` : 'not set';
@@ -344,29 +400,25 @@ export async function loadConfig() {
344
400
  $('cache').value = String(cfg.cache !== false);
345
401
  $('reasoningReplay').value = cfg.reasoningReplay ?? 'auto';
346
402
  $('extraBody').value = Object.keys(cfg.extraBody || {}).length ? JSON.stringify(cfg.extraBody, null, 2) : '';
347
- renderConnectors(cfg);
348
- lockConnectors(Boolean(cfg.frozen) || lockedKeys.has('models') || lockedKeys.has('connectors'));
403
+ renderCatalog(cfg);
404
+ setLocked(Boolean(cfg.frozen) || lockedKeys.has('models') || lockedKeys.has('connectors'));
405
+ $('addConnector').hidden = Boolean(cfg.frozen) || lockedKeys.has('connectors');
349
406
  }
407
+ showView(readOnly ? 'prefs' : 'home');
408
+ saved = draftState();
350
409
  // The chip is the model picker's button now; the tool count lives in the
351
410
  // + menu, where the tools themselves are.
352
411
  // Tier 3 shows your own model; the configured one is only the default.
353
412
  const me = whoami();
354
- const bits = [me.authMode === 'trusted-header' && me.model ? me.modelLabel || me.model : cfg.modelLabel || cfg.model];
355
- if (!cfg.hasApiKey) bits.push('NO API KEY');
413
+ const bits = [['multiuser', 'trusted-header'].includes(me.authMode) && me.model ? me.modelLabel || me.model : cfg.modelLabel || cfg.model].filter(Boolean);
414
+ setupNeeded = admin && !cfg.ready;
415
+ if (!cfg.ready) bits.push('NO MODEL CONNECTED');
356
416
  if (cfg.mcpErrors?.length) bits.push(`${cfg.mcpErrors.length} mcp err`);
357
417
  $('status').textContent = bits.join(' · ');
358
418
  }
359
419
 
360
- async function saveConfig() {
361
- // Personal instructions go to the person's own prefs, whatever else they may edit.
362
- if (!$('myInstructionsGroup').hidden) {
363
- try { await api.post('/api/me/prefs', { instructions: $('myInstructions').value.trim() || null }); } catch (err) {
364
- $('saveMsg').textContent = `save failed (${err.message})`;
365
- return;
366
- }
367
- await loadAccess();
368
- if (readOnly) { $('saveMsg').textContent = 'saved'; closePanel(PANEL.SETTINGS); return; }
369
- }
420
+ /** The config patch the pages currently hold. Throws on bad JSON. */
421
+ function buildPatch() {
370
422
  const patch = {
371
423
  systemPrompt: $('systemPrompt').value,
372
424
  model: $('model').value.trim(),
@@ -385,26 +437,61 @@ async function saveConfig() {
385
437
  timezone: $('timezone').value,
386
438
  toolApproval: $('toolApproval').value
387
439
  };
388
- if (!$('adminSettings').hidden) {
389
- try {
390
- const apiKey = $('apiKey').value;
391
- Object.assign(patch, {
392
- baseUrl: $('baseUrl').value.trim(),
393
- ...(apiKey ? { apiKey } : {}),
394
- llmCompaction: $('llmCompaction').value === 'true',
395
- compactionMaxTokens: Number($('compactionMaxTokens').value) || 8192,
396
- expandCharBudget: Number($('expandCharBudget').value) || 0,
397
- cache: $('cache').value === 'true',
398
- reasoningReplay: $('reasoningReplay').value,
399
- extraBody: $('extraBody').value.trim() ? JSON.parse($('extraBody').value) : {},
400
- ...collectConnectors()
401
- });
402
- } catch (err) {
440
+ if (admin) {
441
+ const apiKey = $('apiKey').value;
442
+ const catalog = collectCatalog();
443
+ // A default that is no longer an enabled entry (renamed, removed, or none
444
+ // picked yet) becomes the first enabled one, as the server would read it.
445
+ const on = catalog.models.filter((m) => m.enabled !== false);
446
+ if (on.length && !on.some((m) => m.id === patch.model || (m.model || m.id) === patch.model)) patch.model = on[0].id;
447
+ Object.assign(patch, {
448
+ // The top-level endpoint only while it is shown; saving it otherwise
449
+ // would bring the card back with nothing on it.
450
+ ...(usesDefaultEndpoint() ? { baseUrl: $('baseUrl').value.trim(), ...(apiKey ? { apiKey } : {}) } : {}),
451
+ llmCompaction: $('llmCompaction').value === 'true',
452
+ compactionMaxTokens: Number($('compactionMaxTokens').value) || 8192,
453
+ expandCharBudget: Number($('expandCharBudget').value) || 0,
454
+ cache: $('cache').value === 'true',
455
+ reasoningReplay: $('reasoningReplay').value,
456
+ extraBody: $('extraBody').value.trim() ? JSON.parse($('extraBody').value) : {},
457
+ ...catalog
458
+ });
459
+ }
460
+ for (const k of lockedKeys) delete patch[k];
461
+ return patch;
462
+ }
463
+
464
+ // The draft as of the last load, to tell whether closing would lose edits.
465
+ let saved = '';
466
+ function draftState() {
467
+ if (readOnly) return $('myInstructions').value;
468
+ try { return JSON.stringify([buildPatch(), $('myInstructions').value]); } catch { return null; }
469
+ }
470
+
471
+ /** Whether closing now would drop unsaved edits; asks, and on yes reloads the saved settings. */
472
+ export function settingsCanClose() {
473
+ const now = draftState();
474
+ if (now === saved) return true;
475
+ if (!window.confirm('Discard unsaved settings changes?')) return false;
476
+ loadConfig();
477
+ return true;
478
+ }
479
+
480
+ async function saveConfig() {
481
+ // Personal instructions go to the person's own prefs, whatever else they may edit.
482
+ if (!$('myInstructionsGroup').hidden) {
483
+ try { await api.post('/api/me/prefs', { instructions: $('myInstructions').value.trim() || null }); } catch (err) {
403
484
  $('saveMsg').textContent = `save failed (${err.message})`;
404
485
  return;
405
486
  }
487
+ await loadAccess();
488
+ if (readOnly) { $('saveMsg').textContent = 'saved'; saved = draftState(); closePanel(PANEL.SETTINGS, { force: true }); return; }
489
+ }
490
+ let patch;
491
+ try { patch = buildPatch(); } catch (err) {
492
+ $('saveMsg').textContent = `save failed (${err.message})`;
493
+ return;
406
494
  }
407
- for (const k of lockedKeys) delete patch[k];
408
495
  try { await api.post('/api/config', patch); } catch (err) {
409
496
  $('saveMsg').textContent = `save failed (${err.message})`;
410
497
  return;
@@ -413,12 +500,13 @@ async function saveConfig() {
413
500
  await loadAccess(); // a changed model changes what it can take
414
501
  await loadTools(); // the per-tool "default" labels follow the global mode
415
502
  $('saveMsg').textContent = 'saved';
416
- closePanel(PANEL.SETTINGS);
503
+ closePanel(PANEL.SETTINGS, { force: true });
417
504
  }
418
505
 
419
506
  /** The panel's onOpen: a stale "saved" would read as this visit's. */
420
507
  export function onSettingsOpen() {
421
508
  if (!$('save').disabled) $('saveMsg').textContent = '';
509
+ showView(readOnly ? 'prefs' : 'home');
422
510
  }
423
511
 
424
512
  /** The panel's onClose. */
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * The inline note that tells the model a document's id ("read_document" needs
3
4
  * it). It lives in the stored message text, so the page strips it back out to
@@ -5,6 +6,7 @@
5
6
  * note, the transcript reads it, and they cannot drift apart.
6
7
  */
7
8
 
9
+ /** @param {{ id: string, filename: string, char_len: number }} doc */
8
10
  export function attachmentNote(doc) {
9
11
  return `\n\n[Attached document: "${doc.filename}" (id: ${doc.id}, ${doc.char_len.toLocaleString('en-US')} chars). Use read_document to search or read it.]`;
10
12
  }
@@ -14,7 +16,11 @@ export function attachmentNote(doc) {
14
16
  const NOTES_AT_END = /(?:\n\n\[Attached document: ".*?" \(id: ([\w.-]+), [\d,]+ chars\)\. Use read_document to search or read it\.\])+$/;
15
17
  const ONE_NOTE = /\[Attached document: ".*?" \(id: ([\w.-]+), /g;
16
18
 
17
- /** A stored message's text without its notes, and the document ids they named. */
19
+ /**
20
+ * A stored message's text without its notes, and the document ids they named.
21
+ * @param {unknown} content
22
+ * @returns {{ text: string, ids: string[] }}
23
+ */
18
24
  export function splitAttachmentNotes(content) {
19
25
  const text = String(content ?? '');
20
26
  const tail = NOTES_AT_END.exec(text);
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * The events a running turn streams to the browser, as `data: {"type": ...}`
3
4
  * lines. Shared by both sides: the server imports this file to emit them
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * Every feature a role can be granted (access.roles.*.features). Shared by
3
4
  * both sides: the server checks them on each API route (src/routes/*,
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * Where a stored message came from, when it was not typed by the user. The
3
4
  * server stamps it (src/automations/scheduler.js) and the page draws such a
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * Reading a provider's usage object. Shared by both sides so the transcript's
3
4
  * per-turn line and the Statistics panel count the same tokens.
@@ -11,15 +12,24 @@
11
12
  * DeepSeek : prompt_cache_hit_tokens / prompt_cache_miss_tokens
12
13
  */
13
14
 
15
+ /**
16
+ * A usage object as some provider sent it: any shape, every field optional.
17
+ * @typedef {{ [field: string]: any }} RawUsage
18
+ */
19
+
20
+ /** @param {unknown} v @returns {number | null} */
14
21
  const num = (v) => {
15
22
  if (v == null) return null;
16
23
  const n = Number(v);
17
24
  return Number.isFinite(n) && n >= 0 ? n : null;
18
25
  };
19
26
 
27
+ /** @param {RawUsage | null | undefined} u */
20
28
  export const inputTokens = (u) => num(u?.prompt_tokens ?? u?.input_tokens);
29
+ /** @param {RawUsage | null | undefined} u */
21
30
  export const outputTokens = (u) => num(u?.completion_tokens ?? u?.output_tokens);
22
31
 
32
+ /** @param {RawUsage | null | undefined} u */
23
33
  export const cacheReads = (u) => num(
24
34
  u?.prompt_tokens_details?.cached_tokens
25
35
  ?? u?.input_tokens_details?.cached_tokens
@@ -28,6 +38,7 @@ export const cacheReads = (u) => num(
28
38
  ?? u?.cached_tokens
29
39
  );
30
40
 
41
+ /** @param {RawUsage | null | undefined} u */
31
42
  export const cacheWrites = (u) => num(
32
43
  u?.cache_creation_input_tokens
33
44
  ?? u?.cache_write_tokens
@@ -36,8 +47,12 @@ export const cacheWrites = (u) => num(
36
47
  ?? u?.input_tokens_details?.cache_write_tokens
37
48
  );
38
49
 
39
- /** Folds one or more raw usage objects into the numbers a turn displays. */
50
+ /**
51
+ * Folds one or more raw usage objects into the numbers a turn displays.
52
+ * @param {RawUsage[]} raws
53
+ */
40
54
  export function tally(raws) {
55
+ /** @type {{ in: number, out: number, cached: number, written: number, discount: number, reported: boolean, raw: RawUsage[], provider: string | null, providers: string[] }} */
41
56
  const t = { in: 0, out: 0, cached: 0, written: 0, discount: 0, reported: false, raw: raws, provider: null, providers: [] };
42
57
  for (const u of raws) {
43
58
  if (u.provider) {
package/public/styles.css CHANGED
@@ -84,7 +84,6 @@ header .grow { flex: 1; }
84
84
  font-size: var(--fs-meta); color: var(--muted); font-family: var(--mono);
85
85
  min-width: 3rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
86
86
  }
87
- #menu { display: none; }
88
87
 
89
88
  /* One focus ring for everything, drawn outside the element so a dense row does
90
89
  not clip it. Keyboard only -- the mouse never sees it. */
@@ -108,6 +107,10 @@ input, textarea, select {
108
107
  background: var(--raise); border: 1px solid var(--line); border-radius: var(--r-sm); resize: vertical;
109
108
  transition: border-color .12s, box-shadow .12s;
110
109
  }
110
+ input[type="checkbox"] {
111
+ width: 1rem; height: 1rem; min-height: 0; margin: 0; padding: 0;
112
+ flex: none; accent-color: var(--accent); cursor: pointer;
113
+ }
111
114
  input:focus, textarea:focus, select:focus {
112
115
  outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
113
116
  }
@@ -295,7 +298,10 @@ main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height:
295
298
  #login[hidden] { display:none; }
296
299
  .login-card { width:min(22rem,100%); display:flex; flex-direction:column; gap:.6rem; padding:1.5rem; border:1px solid var(--line); border-radius:var(--r-md); background:var(--raise); }
297
300
  .login-card h2 { margin:0 0 .5rem; }
298
- #loginMsg:empty, .login-card[hidden] { display:none; }
301
+ .invite-form { display:flex; flex-direction:column; gap:.5rem; padding:.75rem 0 1rem; }
302
+ .invite-form > div:first-of-type { display:flex; gap:.5rem; }
303
+ .invite-form input { flex:1 1 14rem; }
304
+ #loginMsg:empty { display:none; }
299
305
  .admin-users { display:flex; flex-direction:column; gap:.4rem; }
300
306
  .admin-row { display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; padding:.5rem 0; border-top:1px solid var(--line); font-size:var(--fs-meta); }
301
307
  .admin-row:first-child { border-top:0; }
@@ -390,7 +396,7 @@ main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height:
390
396
  .automation-switch input:focus-visible + .switch-track { box-shadow:0 0 0 3px var(--accent-soft); }
391
397
 
392
398
  /* Inline create/edit form: one column, instructions full width. */
393
- .automation-editor { display:flex; flex-direction:column; align-items:stretch; gap:.75rem; margin:0; max-width:none; }
399
+ .automation-editor { display:flex; flex-direction:column; gap:.75rem; }
394
400
  .automation-card .automation-editor { border-top:1px solid var(--line); padding-top:.75rem; }
395
401
  .automation-card > h3 + .automation-editor { border-top:0; padding-top:0; }
396
402
  .automation-row { display:flex; flex-wrap:wrap; gap:.75rem; }
@@ -530,13 +536,13 @@ details.tool.off .name, details.tool.off .desc { opacity: .6; }
530
536
  }
531
537
  .tool .params { margin-top: .5rem; }
532
538
  .tool .param {
533
- display: flex; gap: .5rem; font-family: var(--mono);
539
+ display: flex; flex-wrap: wrap; gap: 0 .5rem; font-family: var(--mono);
534
540
  font-size: var(--fs-meta); padding: .125rem 0;
535
541
  }
536
542
  .tool .param b { color: var(--fg); font-weight: 500; }
537
543
  .tool .param .ty { color: var(--faint); }
538
544
  .tool .param .req { color: var(--err); }
539
- .tool .param .d { color: var(--muted); flex: 1; min-width: 0; }
545
+ .tool .param .d { color: var(--muted); flex: 1 1 12rem; min-width: 0; }
540
546
  .row { display: flex; gap: .75rem; flex-wrap: wrap; align-items: flex-end; }
541
547
  .row > * { flex: 1 1 11rem; }
542
548
 
@@ -809,7 +815,7 @@ details.step > summary:hover { background: var(--panel); }
809
815
  /* The composer: one raised card holding attach, input and send, rather than
810
816
  three loose controls on a ruled strip. The page's one shadow lives here. */
811
817
  footer { padding: .5rem .875rem 1rem; flex-shrink: 0; }
812
- form {
818
+ #form {
813
819
  max-width: var(--measure); margin: 0 auto;
814
820
  display: flex; gap: .5rem; align-items: flex-end;
815
821
  }
@@ -1181,11 +1187,14 @@ button.nav-item:active:not(:disabled) { transform: none; }
1181
1187
  .chat-folder-heading { background: var(--panel); padding: .5rem; }
1182
1188
  #side-resize { right: -.3125rem; }
1183
1189
 
1184
- /* Collapsed: the sidebar leaves, and the same button that opens the mobile
1185
- drawer brings it back. */
1186
- #menu { position: absolute; top: .25rem; left: .25rem; z-index: 8; display: none; }
1190
+ /* One sidebar toggle at every width: it collapses the desktop sidebar and
1191
+ opens the mobile drawer. It sits above the full-column panels because the
1192
+ sidebar is the way out of them -- they have no close buttons of their own --
1193
+ so every header in the column leaves room for it. */
1194
+ #menu { position: absolute; top: .25rem; left: .25rem; z-index: 11; display: inline-flex; }
1187
1195
  body.side-collapsed #side { display: none; }
1188
- body.side-collapsed #menu { display: inline-flex; }
1196
+ .automation-head { padding-left: 3rem; }
1197
+ #log { padding-top: 2.75rem; }
1189
1198
 
1190
1199
  /* Full-column panels clip themselves to this frame. Keeping main overflow
1191
1200
  visible lets focus rings and composer popovers extend where they need to. */
@@ -1381,7 +1390,11 @@ button.pop-item:active:not(:disabled) { transform: none; }
1381
1390
  width: min(46rem, 100%); margin: 0 auto 1rem; padding: 1.125rem 1.25rem 1.25rem;
1382
1391
  background: var(--raise); border: 1px solid var(--line); border-radius: var(--r-md);
1383
1392
  }
1384
- .set-group > header { margin-bottom: .875rem; }
1393
+ .set-group > header {
1394
+ /* Not the page's top bar: plain block, no bar height, padding or blur. */
1395
+ display: block; height: auto; padding: 0; border: 0; background: none; backdrop-filter: none;
1396
+ margin-bottom: .875rem;
1397
+ }
1385
1398
  .set-group h3 { margin: 0; font-size: 1rem; font-weight: 600; letter-spacing: -.005em; }
1386
1399
  .set-group > header p { margin: .125rem 0 0; color: var(--muted); font-size: var(--fs-small); }
1387
1400
  .set-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem 1rem; }
@@ -1397,15 +1410,56 @@ button.pop-item:active:not(:disabled) { transform: none; }
1397
1410
  .policy-checks { display: flex; flex-wrap: wrap; gap: .25rem 1rem; margin-top: .25rem; }
1398
1411
  .policy-checks label { display: inline-flex; gap: .3rem; align-items: center; }
1399
1412
  /* A person without the settings feature sees only what an admin lets them customise. */
1400
- #settings.prefs-only .set-group:not(.mine), #settings.prefs-only .set-divider:not(.mine), #settings.prefs-only #adminSettings { display: none; }
1401
- #settings .mine[hidden] { display: none; }
1402
- /* Connector and model editors: one bordered row per entry. */
1403
- .cfg-row { margin: 0 0 .75rem; padding: .75rem; border: 1px solid var(--line); border-radius: var(--r-sm, 6px); }
1404
- .cfg-row .set-grid { gap: .75rem; }
1405
- .cfg-row textarea { font-family: var(--mono, monospace); font-size: var(--fs-small); }
1406
- .cfg-row .cfg-actions { display: flex; gap: .5rem; align-items: center; margin-top: .5rem; }
1407
- .cfg-row .cfg-tags { display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; }
1408
- .cfg-row .cfg-tags label { display: inline-flex; gap: .3rem; align-items: center; margin: 0; }
1413
+ #settings.prefs-only .set-group:not(.mine), #settings.prefs-only .set-link:not(.mine), #settings.prefs-only #adminConnection { display: none; }
1414
+ .set-divider.set-first { margin-top: 0; padding-top: 0; border-top: 0; }
1415
+ #settings .mine[hidden], #settings .set-view[hidden], #settings .set-group[hidden] { display: none; }
1416
+ .automation-head .set-back { margin-left: -.375rem; }
1417
+ .automation-head .set-back + h2 { margin-right: auto; }
1418
+
1419
+ /* Home: one card per endpoint with its models under it, then links to the other pages. */
1420
+ .ep-head { display: flex; align-items: flex-start; gap: .75rem; }
1421
+ .ep-head > div { flex: 1; min-width: 0; }
1422
+ .ep-head p { margin: .125rem 0 0; color: var(--muted); font-size: var(--fs-small); overflow-wrap: anywhere; }
1423
+ .model-list { margin: .25rem -.5rem .5rem; display: flex; flex-direction: column; }
1424
+ .model-link {
1425
+ display: flex; align-items: center; gap: .75rem; width: 100%; padding: .5rem; text-align: left;
1426
+ background: none; border: 0; border-radius: var(--r-sm); color: var(--fg);
1427
+ }
1428
+ .model-link:hover { background: var(--accent-soft); }
1429
+ .model-link.off { opacity: .55; }
1430
+ .model-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
1431
+ .model-name { font-weight: 500; }
1432
+ .model-sub { color: var(--faint); font-size: var(--fs-meta); font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1433
+ .model-chips { display: flex; gap: .25rem; flex-wrap: wrap; justify-content: flex-end; }
1434
+ .chip { padding: .0625rem .5rem; border: 1px solid var(--line); border-radius: var(--r-pill); font-size: var(--fs-meta); color: var(--muted); }
1435
+ .chip-default { border-color: currentColor; color: var(--fg); font-weight: 500; }
1436
+ .chev { color: var(--faint); font-size: 1.25rem; line-height: 1; }
1437
+ .add-model { margin-top: .25rem; }
1438
+ .ep-open { margin: .25rem 0 .75rem; }
1439
+ .ep-open small { display: block; }
1440
+ .ep-empty { margin: .25rem 0 .5rem; color: var(--muted); font-size: var(--fs-small); }
1441
+ /* A fresh instance: the first step of setting one up. */
1442
+ .ep-setup p { margin: .25rem 0 1rem; color: var(--muted); font-size: var(--fs-small); }
1443
+ .set-add { width: min(46rem, 100%); margin: 0 auto 1.5rem; }
1444
+ .set-nav { width: min(46rem, 100%); margin: 0 auto; display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--raise); overflow: hidden; }
1445
+ .set-link {
1446
+ display: flex; align-items: center; width: 100%; padding: .875rem 1.25rem; text-align: left;
1447
+ background: none; border: 0; border-radius: 0; color: var(--fg);
1448
+ }
1449
+ .set-link + .set-link { border-top: 1px solid var(--line); }
1450
+ .set-link:hover { background: var(--accent-soft); }
1451
+ .set-link::after { content: '›'; margin-left: auto; padding-left: .75rem; color: var(--faint); font-size: 1.25rem; }
1452
+ .set-link b { display: block; font-weight: 600; }
1453
+ .set-link small { display: block; color: var(--muted); font-size: var(--fs-small); }
1454
+ .set-link[hidden] { display: none; }
1455
+ .set-foot { width: min(46rem, 100%); margin: 0 auto 1rem; display: flex; justify-content: flex-end; gap: .5rem; }
1456
+ .danger { color: var(--err); }
1457
+ .inline-check { display: inline-flex !important; gap: .375rem; align-items: center; margin: 0 !important; font-weight: 400 !important; }
1458
+ /* Editor fields inside the model and connector pages. */
1459
+ #modelSlot textarea.mono { font-family: var(--mono, monospace); font-size: var(--fs-small); }
1460
+ .cfg-actions { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; }
1461
+ .cfg-tags { display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; }
1462
+ .cfg-tags label { display: inline-flex; gap: .3rem; align-items: center; margin: 0; font-weight: 400; }
1409
1463
 
1410
1464
  .brand-home {
1411
1465
  flex: 1; min-width: 0; display: flex; align-items: center; gap: .5rem;
@@ -1447,7 +1501,6 @@ button.pop-item:active:not(:disabled) { transform: none; }
1447
1501
  .usage-composition-track { display: flex; height: .7rem; overflow: hidden; border-radius: var(--r-xs); margin: .5rem 0; }
1448
1502
  #statisticsFilter { margin-left: auto; min-width: 0; }
1449
1503
  #statisticsFilter select { max-width: 100%; }
1450
- #statisticsFilter + button { margin-left: 0; }
1451
1504
  .task-card {
1452
1505
  max-width: var(--measure); margin: 0 auto .5rem; padding: .5rem .75rem;
1453
1506
  border: 1px solid var(--line); border-radius: var(--r-md); background: var(--panel);
@@ -1469,3 +1522,13 @@ button.pop-item:active:not(:disabled) { transform: none; }
1469
1522
  .task-completed { opacity: .6; }
1470
1523
  .task-completed span:last-child { text-decoration: line-through; }
1471
1524
  .no-shrink { flex: 0 0 auto; }
1525
+
1526
+ /* Text from outside -- links, paths, emails, model and tool ids -- can be one
1527
+ unbroken token wider than a phone. As a flex item it would set the item's
1528
+ minimum width and push the row past the edge (or, centred, past the left
1529
+ edge where nothing scrolls to it), so let it break anywhere instead. */
1530
+ #inviteResult, #mcpPath, .model-name, .policy-checks label, .policy-group b,
1531
+ .tool .name, .tool .doc, .tool .param b, .tool .param .d, .tool-group-h .name,
1532
+ .tool-group-h .meta, .sec-h .usage {
1533
+ overflow-wrap: anywhere; min-width: 0;
1534
+ }