clay-server 5.1.0-beta.2 → 5.1.0-beta.3

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 (101) hide show
  1. package/bin/cli.js +9 -91
  2. package/lib/builtin-mates.js +7 -7
  3. package/lib/daemon-settings.js +11 -0
  4. package/lib/daemon.js +8 -37
  5. package/lib/linear-store.js +35 -8
  6. package/lib/mate-identity-file.js +39 -0
  7. package/lib/mate-knowledge-sync.js +1 -1
  8. package/lib/mate-ready-creation.js +2 -1
  9. package/lib/mates-identity.js +4 -4
  10. package/lib/mates-prompts.js +4 -3
  11. package/lib/mates.js +12 -11
  12. package/lib/os-users.js +5 -16
  13. package/lib/project-connection.js +0 -7
  14. package/lib/project-debate.js +3 -2
  15. package/lib/project-filesystem.js +21 -39
  16. package/lib/project-mate-interaction.js +7 -6
  17. package/lib/project-server-settings.js +31 -0
  18. package/lib/project-sessions.js +57 -184
  19. package/lib/project.js +3 -3
  20. package/lib/public/app.js +6 -37
  21. package/lib/public/css/input.css +3 -1
  22. package/lib/public/css/menus.css +3 -0
  23. package/lib/public/css/overlays.css +7 -0
  24. package/lib/public/css/project-settings-modal.css +56 -0
  25. package/lib/public/css/server-settings-system.css +125 -0
  26. package/lib/public/css/session-github.css +25 -16
  27. package/lib/public/css/settings-ui.css +247 -22
  28. package/lib/public/css/shared-browser.css +7 -3
  29. package/lib/public/css/title-bar.css +5 -1
  30. package/lib/public/index.html +173 -447
  31. package/lib/public/modules/account-island.js +9 -7
  32. package/lib/public/modules/admin-settings-email.js +197 -0
  33. package/lib/public/modules/admin-settings-invitations.js +183 -0
  34. package/lib/public/modules/admin-settings-projects.js +198 -0
  35. package/lib/public/modules/admin-settings-ui.js +100 -0
  36. package/lib/public/modules/admin-settings-users.js +331 -0
  37. package/lib/public/modules/admin.js +19 -949
  38. package/lib/public/modules/app-dm.js +0 -2
  39. package/lib/public/modules/app-messages.js +13 -52
  40. package/lib/public/modules/app-rate-limit.js +70 -12
  41. package/lib/public/modules/avatar.js +6 -1
  42. package/lib/public/modules/context-sources.js +27 -18
  43. package/lib/public/modules/default-ai.js +14 -7
  44. package/lib/public/modules/header-tui-font.js +10 -20
  45. package/lib/public/modules/home-mate-avatar-editor.js +2 -2
  46. package/lib/public/modules/linear-settings.js +3 -2
  47. package/lib/public/modules/markdown.js +5 -6
  48. package/lib/public/modules/notification-settings.js +81 -0
  49. package/lib/public/modules/notifications.js +44 -28
  50. package/lib/public/modules/personal-settings-dialog.js +51 -0
  51. package/lib/public/modules/profile.js +15 -6
  52. package/lib/public/modules/project-settings-environment.js +106 -0
  53. package/lib/public/modules/project-settings.js +72 -628
  54. package/lib/public/modules/server-settings-controls.js +316 -0
  55. package/lib/public/modules/server-settings-environment.js +194 -0
  56. package/lib/public/modules/server-settings-navigation.js +39 -0
  57. package/lib/public/modules/server-settings-state.js +54 -0
  58. package/lib/public/modules/server-settings.js +116 -788
  59. package/lib/public/modules/server-storage-settings.js +84 -0
  60. package/lib/public/modules/session-github.js +2 -1
  61. package/lib/public/modules/session-presence-ui.js +1 -1
  62. package/lib/public/modules/settings-env-values.js +34 -0
  63. package/lib/public/modules/settings-select.js +121 -0
  64. package/lib/public/modules/shared-browser.js +22 -12
  65. package/lib/public/modules/stt-picker.js +14 -5
  66. package/lib/public/modules/terminal-prefs.js +22 -0
  67. package/lib/public/modules/theme.js +88 -52
  68. package/lib/public/modules/user-settings-account.js +318 -0
  69. package/lib/public/modules/user-settings-appearance.js +55 -0
  70. package/lib/public/modules/user-settings-editors.js +24 -0
  71. package/lib/public/modules/user-settings-integrations.js +104 -0
  72. package/lib/public/modules/user-settings-layout.js +80 -0
  73. package/lib/public/modules/user-settings-preferences.js +48 -0
  74. package/lib/public/modules/user-settings-profile.js +53 -0
  75. package/lib/public/modules/user-settings-themes.js +38 -0
  76. package/lib/public/modules/user-settings-voice.js +75 -0
  77. package/lib/public/modules/user-settings.js +53 -480
  78. package/lib/public/style.css +4 -0
  79. package/lib/sdk-message-processor.js +16 -4
  80. package/lib/sdk-quota-snapshot.js +37 -0
  81. package/lib/server-appearance-settings.js +71 -0
  82. package/lib/server-linear.js +14 -8
  83. package/lib/server-mate-instructions.js +2 -2
  84. package/lib/server-mates.js +2 -1
  85. package/lib/server-notification-settings.js +67 -0
  86. package/lib/server-settings.js +4 -0
  87. package/lib/server-storage.js +57 -0
  88. package/lib/server.js +10 -2
  89. package/lib/sessions.js +3 -4
  90. package/lib/startup-projects.js +40 -0
  91. package/lib/storage-native-walk.js +85 -0
  92. package/lib/storage-usage.js +144 -0
  93. package/lib/users-preferences.js +0 -74
  94. package/lib/users.js +0 -4
  95. package/lib/ws-schema.js +2 -4
  96. package/lib/yoke/acp-process-manager.js +2 -2
  97. package/lib/yoke/adapters/codex.js +12 -52
  98. package/lib/yoke/codex-app-server.js +2 -2
  99. package/lib/yoke/codex-model-catalog.js +52 -0
  100. package/lib/yoke/codex-rate-limits.js +26 -0
  101. package/package.json +3 -3
@@ -0,0 +1,84 @@
1
+ import { store } from './store.js';
2
+
3
+ function size(bytes) {
4
+ if (bytes < 1024) return bytes + ' B';
5
+ var units = ['KiB', 'MiB', 'GiB', 'TiB'];
6
+ var index = -1;
7
+ do { bytes /= 1024; index++; } while (bytes >= 1024 && index < units.length - 1);
8
+ return bytes.toFixed(1) + ' ' + units[index];
9
+ }
10
+ function cell(row, text, header) {
11
+ var el = document.createElement(header ? 'th' : 'td');
12
+ el.textContent = text;
13
+ if (header) el.scope = 'row';
14
+ row.appendChild(el);
15
+ }
16
+ function render(data) {
17
+ var projectMode = data.attribution === 'project';
18
+ document.getElementById('settings-storage-description').textContent = projectMode ? 'Project disk usage grouped by project owner, alongside shared Clay data.' : 'Disk usage across Clay data, registered projects and mapped Linux home directories.';
19
+ document.getElementById('settings-storage-owner-heading').textContent = projectMode ? 'Projects' : 'Linux account';
20
+ document.querySelector('.settings-storage-table').setAttribute('aria-label', projectMode ? 'Allocated disk usage by project owner' : 'Allocated disk usage by filesystem owner');
21
+ document.getElementById('settings-storage-explanation').textContent = (projectMode ? 'Project files are attributed to their registered project owner. Nested projects use their own owner; conflicting shared paths or hard links are unattributed. ' : 'Allocated space is attributed by Linux file owner, including directory metadata. ') + 'Shared paths count once. Symlinks and nested mounts are skipped. Results may be cached for 5 minutes. Refresh usage requests a new measurement.';
22
+ document.getElementById('settings-storage-attribution-note').textContent = projectMode ? 'System / unattributed includes Clay data outside projects, unowned projects and conflicting shared files. Home directories are included only if registered as projects.' : 'System / unattributed includes unmapped or shared Linux identities. Unavailable means the account cannot be uniquely mapped; it does not mean zero usage.';
23
+ var body = document.getElementById('settings-storage-users');
24
+ body.replaceChildren();
25
+ data.users.slice().sort(function (a, b) { return b.bytes - a.bytes; }).forEach(function (user) {
26
+ var row = document.createElement('tr');
27
+ cell(row, user.name, true);
28
+ cell(row, projectMode ? String(user.projects || 0) : user.linuxUser || 'No Linux account');
29
+ cell(row, user.mapped ? (data.scanning || data.partial ? 'At least ' : '') + size(user.bytes) : 'Unavailable');
30
+ cell(row, user.mapped ? user.files.toLocaleString() : '—');
31
+ body.appendChild(row);
32
+ });
33
+ var shared = document.createElement('tr');
34
+ cell(shared, 'System / unattributed', true); cell(shared, projectMode ? 'Shared / unowned' : 'Other filesystem owners');
35
+ cell(shared, size(data.unattributedBytes)); cell(shared, data.unattributedFiles.toLocaleString());
36
+ body.appendChild(shared);
37
+ document.getElementById('settings-storage-total').textContent = (data.partial || data.scanning ? 'At least ' : '') + size(data.totalBytes);
38
+ var status = document.getElementById('settings-storage-status');
39
+ status.textContent = (data.scanning ? 'Scan in progress; these totals are not final. ' : '') + (data.partial ? 'Partial scan: some paths were unreadable, on another filesystem, or exceeded the scan limit. ' : '') +
40
+ 'Checked ' + new Date(data.checkedAt).toLocaleTimeString() + '. ' + data.roots + ' storage roots scanned.';
41
+ status.dataset.state = data.scanning ? 'pending' : data.partial ? 'error' : 'saved';
42
+ document.getElementById('settings-storage-results').hidden = false;
43
+ }
44
+ export async function loadStorageUsage(refresh) {
45
+ if (store.get('serverStorageLoading')) return;
46
+ clearTimeout(store.get('serverStorageTimer'));
47
+ var button = document.getElementById('settings-storage-refresh');
48
+ var status = document.getElementById('settings-storage-status');
49
+ store.set({ serverStorageLoading: true });
50
+ button.disabled = true;
51
+ button.textContent = 'Measuring…';
52
+ if (!store.get('serverStorageScanning')) status.textContent = 'Measuring disk usage. Large directories can take several seconds.';
53
+ status.dataset.state = 'pending';
54
+ try {
55
+ var response = await fetch('/api/server/storage' + (refresh === true ? '?refresh=1' : ''), { credentials: 'same-origin' });
56
+ var data = await response.json();
57
+ if (!response.ok) {
58
+ if (response.status === 401 || response.status === 403) {
59
+ store.set({ serverStorageUsage: null });
60
+ document.getElementById('settings-storage-results').hidden = true;
61
+ }
62
+ throw new Error(data.error || 'Disk usage is unavailable. Try refreshing.');
63
+ }
64
+ store.set({ serverStorageUsage: data, serverStorageScanning: !!data.scanning });
65
+ if (data.users) render(data);
66
+ if (data.scanning) {
67
+ status.textContent = 'Scan in progress. Project files, including node_modules, are being measured; totals are not final.';
68
+ store.set({ serverStorageTimer: setTimeout(function () {
69
+ if (store.get('serverSettingsPage') === 'storage' && !document.getElementById('server-settings').classList.contains('hidden')) loadStorageUsage();
70
+ }, 1500) });
71
+ }
72
+ } catch (error) {
73
+ store.set({ serverStorageScanning: false });
74
+ status.textContent = error.message + (store.get('serverStorageUsage') ? ' Previous measurements remain below.' : '');
75
+ status.dataset.state = 'error';
76
+ } finally {
77
+ store.set({ serverStorageLoading: false });
78
+ button.disabled = !!store.get('serverStorageScanning');
79
+ button.textContent = button.disabled ? 'Measuring…' : 'Refresh usage';
80
+ }
81
+ }
82
+ export function initStorageUsage() {
83
+ document.getElementById('settings-storage-refresh').addEventListener('click', function () { loadStorageUsage(true); });
84
+ }
@@ -76,10 +76,11 @@ export function appendGithubWork(row, session, title) {
76
76
  row.insertBefore(column, title);
77
77
  column.appendChild(title);
78
78
  var links = document.createElement('span');
79
+ links.className = 'session-work-links-slot';
79
80
  links.innerHTML = html;
80
81
  links.addEventListener('click', function (event) { if (event.target.closest('a')) event.stopPropagation(); });
81
82
  links.addEventListener('contextmenu', function (event) { event.stopPropagation(); });
82
- column.appendChild(links);
83
+ row.appendChild(links);
83
84
  }
84
85
  export function syncGithubSessions(sessions) {
85
86
  bindGithubWorkPopover();
@@ -38,7 +38,7 @@ export function renderSessionPresence(row, sessionId) {
38
38
  container.appendChild(more);
39
39
  }
40
40
 
41
- var anchor = row.querySelector(".session-item-age, .mobile-worker-generation, .mobile-session-unread, .session-row-actions, .mobile-session-star");
41
+ var anchor = row.querySelector(".session-row-trailing, .session-item-age, .mobile-worker-generation, .mobile-session-unread, .session-row-actions, .mobile-session-star");
42
42
  // Desktop age/actions now live inside one trailing slot. insertBefore only
43
43
  // accepts a direct child, so promote a nested match to its row-level owner.
44
44
  while (anchor && anchor.parentNode !== row) anchor = anchor.parentNode;
@@ -0,0 +1,34 @@
1
+ // Check if text looks like env format: first line starts with a valid VAR_NAME=
2
+ export function looksLikeEnv(text) {
3
+ var first = text.split("\n")[0].trim();
4
+ if (first.indexOf("export ") === 0) first = first.substring(7);
5
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(first);
6
+ }
7
+
8
+ export function parseEnvString(str) {
9
+ var pairs = [];
10
+ if (!str) return pairs;
11
+ var lines = str.split("\n");
12
+ for (var i = 0; i < lines.length; i++) {
13
+ var line = lines[i].trim();
14
+ if (!line || line.charAt(0) === "#") continue;
15
+ // Strip leading "export "
16
+ if (line.indexOf("export ") === 0) line = line.substring(7);
17
+ var eq = line.indexOf("=");
18
+ if (eq === -1) continue;
19
+ var key = line.substring(0, eq).trim();
20
+ var val = line.substring(eq + 1).trim();
21
+ // Saved values are JSON-quoted so shell metacharacters remain data.
22
+ if (val.charAt(0) === '"' && val.charAt(val.length - 1) === '"') {
23
+ try { val = JSON.parse(val); } catch (e) { val = val.substring(1, val.length - 1); }
24
+ } else if (val.charAt(0) === "'" && val.charAt(val.length - 1) === "'") {
25
+ val = val.substring(1, val.length - 1);
26
+ }
27
+ if (key) pairs.push({ key: key, value: val });
28
+ }
29
+ return pairs;
30
+ }
31
+
32
+ export function serializeEnvValue(value) {
33
+ return JSON.stringify(value == null ? "" : String(value));
34
+ }
@@ -0,0 +1,121 @@
1
+ // Native selects own values and change events; this view adds desktop navigation.
2
+ export function enhanceSettingsSelects(root) {
3
+ if (!root) return;
4
+ root.querySelectorAll('select:not(.settings-nav-dropdown)').forEach(function (select) {
5
+ if (select.dataset.settingsPicker) return;
6
+ select.dataset.settingsPicker = 'true';
7
+ select.classList.add('settings-native-select');
8
+ var wrap = document.createElement('span');
9
+ wrap.className = 'settings-picker';
10
+ var button = document.createElement('button');
11
+ button.type = 'button';
12
+ button.className = 'settings-picker-trigger';
13
+ button.setAttribute('aria-haspopup', 'listbox');
14
+ button.setAttribute('aria-expanded', 'false');
15
+ var label = document.createElement('span');
16
+ label.className = 'settings-picker-value';
17
+ button.appendChild(label);
18
+ button.insertAdjacentHTML('beforeend', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>');
19
+ var menu = document.createElement('span');
20
+ menu.className = 'settings-picker-menu';
21
+ menu.id = (select.id || 'settings-select-' + document.querySelectorAll('.settings-picker').length) + '-menu';
22
+ menu.setAttribute('role', 'listbox');
23
+ menu.hidden = true;
24
+ button.setAttribute('aria-controls', menu.id);
25
+ var enclosingLabel = select.closest('label');
26
+ var associatedLabel = select.labels && select.labels[0];
27
+ var name = select.getAttribute('aria-label') || (enclosingLabel && enclosingLabel.querySelector('.settings-label') || {}).textContent || (associatedLabel && associatedLabel.textContent) || 'Choose a value';
28
+ name = name.trim();
29
+ menu.setAttribute('aria-label', name);
30
+ select.insertAdjacentElement('afterend', wrap);
31
+ wrap.append(button, menu);
32
+ var search = '';
33
+ var searchAt = 0;
34
+ function outside(event) { if (!wrap.contains(event.target)) close(false); }
35
+ function close(focus) {
36
+ document.removeEventListener('pointerdown', outside);
37
+ menu.hidden = true;
38
+ button.setAttribute('aria-expanded', 'false');
39
+ if (focus) button.focus();
40
+ }
41
+ function sync() {
42
+ label.textContent = (select.selectedOptions[0] || {}).textContent || 'Choose…';
43
+ button.setAttribute('aria-label', name + ': ' + label.textContent);
44
+ button.disabled = select.disabled;
45
+ if (button.disabled) close(false);
46
+ menu.replaceChildren();
47
+ Array.from(select.options).forEach(function (option) {
48
+ var item = document.createElement('button');
49
+ item.type = 'button';
50
+ item.tabIndex = -1;
51
+ item.setAttribute('role', 'option');
52
+ item.textContent = option.textContent;
53
+ item.setAttribute('aria-selected', String(option.selected));
54
+ item.disabled = option.disabled || (option.parentElement.tagName === 'OPTGROUP' && option.parentElement.disabled);
55
+ item.hidden = option.hidden;
56
+ item.onclick = function (event) {
57
+ event.preventDefault();
58
+ select.value = option.value;
59
+ select.dispatchEvent(new Event('change', { bubbles: true }));
60
+ sync();
61
+ close(true);
62
+ };
63
+ menu.appendChild(item);
64
+ });
65
+ }
66
+ function items() { return Array.from(menu.querySelectorAll('button:not(:disabled):not([hidden])')); }
67
+ function open() {
68
+ sync();
69
+ var choices = items();
70
+ if (!choices.length || button.disabled) return;
71
+ menu.hidden = false;
72
+ button.setAttribute('aria-expanded', 'true');
73
+ document.addEventListener('pointerdown', outside);
74
+ var bounds = button.getBoundingClientRect();
75
+ var boundary = wrap.closest('.server-settings-content, .us-modal-content, .us-content, .ps-content, .admin-modal');
76
+ var bottom = Math.min(window.innerHeight - 8, boundary ? boundary.getBoundingClientRect().bottom - 8 : window.innerHeight - 8);
77
+ var above = bounds.top - Math.max(8, boundary ? boundary.getBoundingClientRect().top + 8 : 8);
78
+ var below = bottom - bounds.bottom - 4;
79
+ var flip = below < 180 && above > below;
80
+ menu.classList.toggle('settings-picker-menu-above', flip);
81
+ menu.style.maxHeight = Math.max(64, Math.min(240, flip ? above : below)) + 'px';
82
+ var selected = menu.querySelector('[aria-selected="true"]:not(:disabled):not([hidden])');
83
+ (selected || choices[0]).focus();
84
+ }
85
+ button.onclick = function (event) {
86
+ event.preventDefault();
87
+ if (menu.hidden) open(); else close(false);
88
+ };
89
+ wrap.addEventListener('keydown', function (event) {
90
+ if (event.key === 'Escape' && !menu.hidden) {
91
+ event.preventDefault(); event.stopPropagation(); close(true); return;
92
+ }
93
+ // Return to the trigger before native Tab traversal leaves this control.
94
+ if (event.key === 'Tab' && !menu.hidden) { close(true); return; }
95
+ var navigation = ['ArrowDown', 'ArrowUp', 'Home', 'End'].indexOf(event.key) >= 0;
96
+ var typing = event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;
97
+ if (!navigation && !typing) return;
98
+ event.preventDefault();
99
+ var wasClosed = menu.hidden;
100
+ if (wasClosed) open();
101
+ var choices = items();
102
+ if (!choices.length) return;
103
+ var index = choices.indexOf(document.activeElement);
104
+ if (typing) {
105
+ search = Date.now() - searchAt > 700 ? event.key : search + event.key;
106
+ searchAt = Date.now();
107
+ var match = choices.find(function (item) { return item.textContent.trim().toLowerCase().indexOf(search.toLowerCase()) === 0; });
108
+ if (match) match.focus();
109
+ } else if (!wasClosed || event.key === 'Home' || event.key === 'End') {
110
+ index = event.key === 'Home' ? 0 : event.key === 'End' ? choices.length - 1 : (index + (event.key === 'ArrowDown' ? 1 : -1) + choices.length) % choices.length;
111
+ choices[index].focus();
112
+ }
113
+ });
114
+ wrap.addEventListener('focusout', function (event) { if (!wrap.contains(event.relatedTarget)) close(false); });
115
+ select.addEventListener('change', sync);
116
+ select.addEventListener('settings-sync', sync);
117
+ // Async option loading and pending/failed saves retain native control behavior.
118
+ new MutationObserver(sync).observe(select, { attributes: true, attributeFilter: ['disabled'], childList: true, subtree: true, characterData: true });
119
+ sync();
120
+ });
121
+ }
@@ -8,10 +8,11 @@ import { activityHtml, renderBrowserActivity } from './shared-browser-activity.j
8
8
  import { viewportMenuHtml, bindViewportMenu, renderViewportMenu, closeViewportMenu } from './shared-browser-viewport-menu.js';
9
9
 
10
10
  function desktop() { return window.matchMedia('(min-width: 1024px)').matches; }
11
- function eligible() {
11
+ function visibleEntry() {
12
12
  var permissions = store.get('permissions');
13
- return desktop() && !!store.get('currentSlug') && !!store.get('activeSessionId') && !store.get('dmMode') && (!permissions || permissions.terminal !== false);
13
+ return desktop() && !!store.get('currentSlug') && !store.get('dmMode') && (!permissions || permissions.terminal !== false);
14
14
  }
15
+ function eligible() { return visibleEntry() && !!store.get('activeSessionId'); }
15
16
  function panel() { return document.getElementById('shared-browser-panel'); }
16
17
  function ui(patch) { store.set({ sharedBrowserUi: Object.assign({}, store.get('sharedBrowserUi'), patch) }); }
17
18
  function send(type, fields) {
@@ -76,14 +77,19 @@ function ensurePanel() {
76
77
  refreshIcons();
77
78
  }
78
79
  function renderEntry() {
79
- var button = document.getElementById('shared-browser-btn');
80
- if (!button) return;
81
- button.classList.toggle('hidden', !eligible());
82
80
  var browser = store.get('sharedBrowser');
83
- button.title = browser ? 'View browser · ' + (store.get('connected') ? browser.phase : 'Disconnected') : 'Open browser';
84
- button.setAttribute('aria-label', browser ? 'View browser' : 'Open browser');
85
- button.setAttribute('aria-expanded', String(!!store.get('sharedBrowserOpen')));
86
- button.disabled = !store.get('connected');
81
+ var ready = !!store.get('connected') && !!store.get('activeSessionId');
82
+ var label = !store.get('connected') ? 'Browser · Connecting…' : !store.get('activeSessionId') ? 'Browser · Loading session…' : browser ? 'View browser' : 'Open browser';
83
+ ['shared-browser-btn', 'shared-browser-toggle-btn'].forEach(function (id) {
84
+ var button = document.getElementById(id);
85
+ if (!button) return;
86
+ var title = id === 'shared-browser-toggle-btn' && ready && store.get('sharedBrowserOpen') ? 'Hide browser' : label;
87
+ button.classList.toggle('hidden', !visibleEntry());
88
+ button.title = ready && browser ? title + ' · ' + browser.phase : title;
89
+ button.setAttribute('aria-label', title);
90
+ button.setAttribute('aria-expanded', String(!!store.get('sharedBrowserOpen')));
91
+ button.disabled = !ready;
92
+ });
87
93
  }
88
94
  function controlText(browser, live, connected) {
89
95
  if (!connected) return 'Disconnected. Status returns on reconnect.';
@@ -158,7 +164,7 @@ function selectBrowserTab(id) {
158
164
  send('view', { visible: desktop() && !document.hidden }); render();
159
165
  }
160
166
  export function openSharedBrowser() {
161
- if (!eligible()) return;
167
+ if (!eligible() || !store.get('connected')) return;
162
168
  ensurePanel();
163
169
  claimRightWorkbench('browser');
164
170
  store.set({ sharedBrowserOpen: true });
@@ -212,8 +218,12 @@ export function handleSharedBrowserMessage(msg) {
212
218
  }
213
219
  export function initSharedBrowser() {
214
220
  var button = document.getElementById('shared-browser-btn');
215
- if (!button) return;
216
- button.onclick = openSharedBrowser;
221
+ if (button) button.onclick = openSharedBrowser;
222
+ var toggle = document.getElementById('shared-browser-toggle-btn');
223
+ if (toggle) toggle.onclick = function () {
224
+ if (store.get('sharedBrowserOpen')) hideSharedBrowser();
225
+ else openSharedBrowser();
226
+ };
217
227
  window.matchMedia('(min-width: 1024px)').addEventListener('change', function () { if (!desktop()) hideSharedBrowser(); renderEntry(); });
218
228
  document.addEventListener('visibilitychange', function () { if (store.get('sharedBrowserOpen')) send('view', { visible: !document.hidden && desktop() }); });
219
229
  refreshIcons(); renderEntry();
@@ -1,3 +1,5 @@
1
+ import { confirmSettingsAction as showConfirmDialog } from './server-settings-state.js';
2
+ import { enhanceSettingsSelects } from './settings-select.js';
1
3
  import { store } from './store.js';
2
4
  import { escapeHtml } from './utils.js';
3
5
  import { iconHtml, refreshIcons } from './icons.js';
@@ -5,7 +7,10 @@ import { speechLanguages, voiceModel, loadSpeechSettings, saveSpeechSettings, sa
5
7
  import { stopSTT } from './stt-controller.js';
6
8
  import { browserSpeechInfo, compareSpeechModels } from './stt-browser.js';
7
9
 
8
- export function closeSpeechPicker() {
10
+ export function closeSpeechPicker(force) {
11
+ if(document.querySelector('.admin-modal-overlay, #settings-confirm') && !force)return;
12
+ var input = document.querySelector("#speech-api-key");
13
+ if (!force && input && input.value) {showConfirmDialog("Discard the unsaved voice API key?", "Discard", function () {closeSpeechPicker(true);});return;}
9
14
  var state = store.get('speechPopup');
10
15
  store.set({ speechPopup: null });
11
16
  if (state && state.anchor.isConnected) { state.anchor.setAttribute('aria-expanded', 'false'); state.anchor.focus(); }
@@ -59,8 +64,10 @@ function render() {
59
64
  popup.style.left = Math.max(8, Math.min(bounds.right - 324, window.innerWidth - 332)) + 'px';
60
65
  popup.style.bottom = Math.max(8, window.innerHeight - bounds.top + 8) + 'px';
61
66
  popup.style.maxHeight = Math.max(150, bounds.top - 16) + 'px';
67
+ if (state.anchor.closest('#user-settings')) {popup.style.bottom = 'auto';popup.style.top = 'max(16px, calc(50dvh - 260px))';popup.style.maxHeight = 'calc(100dvh - 48px)';}
62
68
  popup.addEventListener('click', function (event) {
63
69
  var button = event.target.closest('button'); if (!button || button.disabled) return;
70
+ if (popup.querySelector("#speech-api-key") && popup.querySelector("#speech-api-key").value && (button.dataset.mode || button.dataset.provider || button.dataset.model)) {showConfirmDialog('Discard the unsaved voice API key?', 'Discard', function () {form.elements.key.value = '';button.click();});return;}
64
71
  if (button.dataset.mode) patch({ mode: button.dataset.mode, provider: null, keyModel: null });
65
72
  else if (button.dataset.provider) {
66
73
  var keyModel = button.parentElement.querySelector('[data-model]').dataset.model;
@@ -80,7 +87,7 @@ function render() {
80
87
  var form = popup.querySelector('form');
81
88
  if (form && previousProvider === state.provider) form.elements.key.value = unsavedKey;
82
89
  if (form) form.addEventListener('submit', function (event) {
83
- event.preventDefault(); var key = form.elements.key.value; form.elements.key.value = '';
90
+ event.preventDefault(); var key = form.elements.key.value;
84
91
  save({ provider: state.provider, key: key }, false);
85
92
  });
86
93
  var select = popup.querySelector('select');
@@ -89,6 +96,7 @@ function render() {
89
96
  try { await saveSpeechLanguage(code); if (store.get('speechPopup')) patch({ busy: false, error: '' }); }
90
97
  catch (error) { if (store.get('speechPopup')) patch({ busy: false, error: error.message }); }
91
98
  });
99
+ enhanceSettingsSelects(popup);
92
100
  var focus = Array.from(popup.querySelectorAll('[data-focus]')).find(function (el) { return el.dataset.focus === focusKey && !el.disabled; });
93
101
  (form && previousProvider !== state.provider ? form.elements.key : (focus || popup.querySelector('input,button'))).focus();
94
102
  }
@@ -108,14 +116,15 @@ export function initSpeechPicker() {
108
116
  });
109
117
  document.addEventListener('pointerdown', function (event) {
110
118
  var state = store.get('speechPopup'); var popup = document.getElementById('speech-picker');
119
+ if(event.target.closest('.admin-modal-overlay, #settings-confirm'))return;
111
120
  if (state && popup && !popup.contains(event.target) && !state.anchor.contains(event.target)) closeSpeechPicker();
112
121
  });
113
122
  document.addEventListener('keydown', function (event) {
114
- if (!store.get('speechPopup')) return;
115
- if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeSpeechPicker(); }
123
+ if (!store.get('speechPopup') || document.querySelector('.admin-modal-overlay, #settings-confirm')) return;
124
+ if (event.key === 'Escape') { if(document.activeElement.closest('.settings-picker') && document.activeElement.closest('.settings-picker').querySelector('[aria-expanded=true]'))return; event.preventDefault(); event.stopPropagation(); closeSpeechPicker(); }
116
125
  if (event.key === 'Tab') {
117
126
  var popup = document.getElementById('speech-picker');
118
- var items = popup.querySelectorAll('button:not(:disabled),input:not(:disabled),select:not(:disabled),a[href]');
127
+ var items = Array.from(popup.querySelectorAll('button:not(:disabled),input:not(:disabled),select:not(:disabled),a[href]')).filter(function(el){return el.getClientRects().length && el.tabIndex!==-1;});
119
128
  var first = items[0]; var last = items[items.length - 1];
120
129
  if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
121
130
  else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
@@ -1,3 +1,15 @@
1
+ export var TERMINAL_FONT_OPTIONS = [
2
+ { label: "SF Mono", family: "'SF Mono', Menlo, Monaco, 'Courier New', monospace" },
3
+ { label: "JetBrains Mono", family: "'JetBrains Mono', 'SF Mono', Menlo, monospace" },
4
+ { label: "Fira Code", family: "'Fira Code', 'SF Mono', Menlo, monospace" },
5
+ { label: "Cascadia Code", family: "'Cascadia Code', 'SF Mono', Menlo, monospace" },
6
+ { label: "IBM Plex Mono", family: "'IBM Plex Mono', 'SF Mono', Menlo, monospace" },
7
+ { label: "Source Code Pro", family: "'Source Code Pro', 'SF Mono', Menlo, monospace" },
8
+ { label: "Roboto Mono", family: "'Roboto Mono', 'SF Mono', Menlo, monospace" },
9
+ { label: "D2 Coding", family: "'D2 coding', 'D2Coding', 'SF Mono', Menlo, monospace" },
10
+ { label: "System mono", family: "ui-monospace, monospace" },
11
+ ];
12
+
1
13
  // terminal-prefs.js
2
14
  //
3
15
  // Single source of truth for terminal font preferences (family + size).
@@ -98,3 +110,13 @@ export function applyTerminalFont(family, size) {
98
110
  export function onTerminalFontChange(fn) {
99
111
  if (typeof fn === "function") listeners.push(fn);
100
112
  }
113
+
114
+ // Publish only acknowledged values, shared by settings and the terminal toolbar.
115
+ export async function saveTerminalFont(family, size) {
116
+ var response = await fetch('/api/user/terminal-font', {
117
+ method: 'PUT', headers: {'Content-Type':'application/json'},
118
+ body: JSON.stringify({family: family, size: size})
119
+ });
120
+ if (!response.ok) throw new Error('Could not save terminal typography. Try again.');
121
+ applyTerminalFont(family, size);
122
+ }
@@ -1,3 +1,4 @@
1
+ import { store } from './store.js';
1
2
  import { refreshIcons } from './icons.js';
2
3
  import { setTerminalTheme } from './terminal.js';
3
4
  import { setTuiSessionTheme } from './session-tui-view.js';
@@ -315,19 +316,7 @@ export function applyTheme(themeId, fromPicker) {
315
316
  var mermaidVars = computeMermaidVars(theme);
316
317
  try { updateMermaidTheme(mermaidVars); } catch (e) {}
317
318
 
318
- try {
319
- localStorage.setItem(STORAGE_KEY, themeId);
320
- localStorage.setItem(STORAGE_KEY + "-vars", JSON.stringify(vars));
321
- localStorage.setItem(STORAGE_KEY + "-variant", theme.variant || "dark");
322
- } catch (e) {}
323
-
324
- // When picked from skin selector, save as skin preference and sync mode
325
- if (fromPicker) {
326
- try {
327
- localStorage.setItem(SKIN_KEY, themeId);
328
- localStorage.setItem(MODE_KEY, isLight ? "light" : "dark");
329
- } catch (e) {}
330
- }
319
+ if (fromPicker) saveAppearance(themeId, isLight ? 'light' : 'dark');
331
320
 
332
321
  updateToggleIcon();
333
322
 
@@ -417,7 +406,7 @@ function getSystemMode() {
417
406
  // Returns the effective mode: user override or system
418
407
  function getEffectiveMode() {
419
408
  var saved = null;
420
- try { saved = localStorage.getItem(MODE_KEY); } catch (e) {}
409
+ saved = (store.get('appearanceDraft') || store.get('appearancePreference') || {}).mode;
421
410
  if (saved === "light" || saved === "dark") return saved;
422
411
  return getSystemMode();
423
412
  }
@@ -426,7 +415,10 @@ function getEffectiveMode() {
426
415
  // If user has a custom skin selected, find its dark/light counterpart
427
416
  function themeIdForMode(mode) {
428
417
  var skin = null;
429
- try { skin = localStorage.getItem(SKIN_KEY); } catch (e) {}
418
+ var preference = store.get('appearanceDraft') || store.get('appearancePreference') || {};
419
+ var paired = preference[mode + 'Theme'];
420
+ if (paired && getTheme(paired) && getTheme(paired).variant === mode) return paired;
421
+ skin = preference.theme;
430
422
 
431
423
  // Default Clay Studio skin pair.
432
424
  if (!skin) {
@@ -457,24 +449,12 @@ function themeIdForMode(mode) {
457
449
 
458
450
  // --- Wide view ---
459
451
 
460
- export function isWideView() {
461
- try {
462
- var v = localStorage.getItem(WIDE_KEY);
463
- return v === null ? true : v !== "bubble"; // default: Channel (wide)
464
- } catch (e) { return true; }
465
- }
466
-
467
- export function getChatLayout() {
468
- try {
469
- var v = localStorage.getItem(WIDE_KEY);
470
- return v === "bubble" ? "bubble" : "channel";
471
- } catch (e) { return "channel"; }
472
- }
473
-
452
+ export function isWideView() { return getChatLayout() === 'channel'; }
453
+ export function getChatLayout() { return store.get('personalChatLayout') || 'channel'; }
474
454
  export function setChatLayout(layout) {
475
- var val = (layout === "bubble") ? "bubble" : "channel";
476
- try { localStorage.setItem(WIDE_KEY, val); } catch (e) {}
477
- document.body.classList.toggle("wide-view", val === "channel");
455
+ var val = layout === 'bubble' ? 'bubble' : 'channel';
456
+ store.set({ personalChatLayout: val });
457
+ document.body.classList.toggle('wide-view', val === 'channel');
478
458
  }
479
459
 
480
460
  export function setWideView(enabled) {
@@ -485,9 +465,10 @@ export function setWideView(enabled) {
485
465
  export function toggleDarkMode() {
486
466
  var current = getEffectiveMode();
487
467
  var next = current === "dark" ? "light" : "dark";
488
- try { localStorage.setItem(MODE_KEY, next); } catch (e) {}
468
+
489
469
  var tid = themeIdForMode(next);
490
470
  applyTheme(tid);
471
+ saveAppearance(tid, next);
491
472
  updateToggleIcon();
492
473
  }
493
474
 
@@ -633,26 +614,11 @@ function rebuildPicker() {
633
614
 
634
615
  // --- Init ---
635
616
  export function initTheme() {
636
- // Preserve an explicitly selected skin. Previously auto-applied Ayu Light
637
- // and Dracula values have no SKIN_KEY, so they migrate to the Clay pair.
638
- var saved = null;
639
- var savedSkin = null;
640
- try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
641
- try { savedSkin = localStorage.getItem(SKIN_KEY); } catch (e) {}
642
-
643
- if (saved && savedSkin) {
644
- currentThemeId = saved;
645
- } else {
646
- // No explicit skin — use the Clay Studio pair in the system mode.
647
- var mode = getSystemMode();
648
- currentThemeId = mode === "light" ? DEFAULT_LIGHT_THEME_ID : DEFAULT_DARK_THEME_ID;
649
- }
650
-
651
- // Apply wide view state from localStorage
652
- document.body.classList.toggle("wide-view", isWideView());
617
+ currentThemeId = getSystemMode() === 'light' ? DEFAULT_LIGHT_THEME_ID : DEFAULT_DARK_THEME_ID;
618
+ document.body.classList.toggle('wide-view', isWideView());
653
619
 
654
620
  // Load all themes from server, then apply properly
655
- loadThemes();
621
+ loadThemes().then(loadAppearance);
656
622
 
657
623
  // The theme picker is mounted in User Settings by user-settings.js.
658
624
 
@@ -661,7 +627,7 @@ export function initTheme() {
661
627
  var mq = window.matchMedia("(prefers-color-scheme: dark)");
662
628
  var handler = function () {
663
629
  var userMode = null;
664
- try { userMode = localStorage.getItem(MODE_KEY); } catch (e) {}
630
+ userMode = (store.get('appearanceDraft') || store.get('appearancePreference') || {}).mode;
665
631
  if (!userMode) {
666
632
  // No manual override — follow system
667
633
  var sysMode = getSystemMode();
@@ -680,3 +646,73 @@ export function initTheme() {
680
646
  updateToggleIcon();
681
647
 
682
648
  }
649
+
650
+ function appearanceStatus(text, error) {
651
+ store.set({ appearanceSaveStatus: text, appearanceSaveError: !!error });
652
+ var status = document.getElementById('us-appearance-status');
653
+ if (status) { status.textContent = text; status.dataset.state = error ? 'error' : ''; }
654
+ }
655
+ export function getAppearanceChoices() {
656
+ return { mode: (store.get('appearanceDraft') || store.get('appearancePreference') || {}).mode || 'system', lightTheme: themeIdForMode('light'), darkTheme: themeIdForMode('dark') };
657
+ }
658
+ export function retryAppearanceSave() {
659
+ var draft = store.get('appearanceDraft');
660
+ if (draft) saveAppearance(draft.theme, draft.mode, draft);
661
+ }
662
+ export function selectAppearanceMode(mode) {
663
+ var effective = mode === 'system' ? getSystemMode() : mode;
664
+ var id = themeIdForMode(effective);
665
+ saveAppearance(id, mode === 'system' ? null : mode);
666
+ applyTheme(id);
667
+ }
668
+ export function selectAppearanceTheme(mode, id) {
669
+ if (!getTheme(id) || getTheme(id).variant !== mode) return;
670
+ var effective = getEffectiveMode();
671
+ var current = store.get('appearanceDraft') || store.get('appearancePreference') || {};
672
+ var change = {}; change[mode + 'Theme'] = id;
673
+ saveAppearance(mode === effective ? id : currentThemeId, current.mode || null, change);
674
+ if (mode === effective) applyTheme(id);
675
+ }
676
+ function saveAppearance(theme, mode, overrides) {
677
+ var values = Object.assign({}, store.get('appearanceDraft') || store.get('appearancePreference') || {}, {theme:theme, mode:mode}, overrides || {});
678
+ delete values.configured;
679
+ var selected = getTheme(theme);
680
+ if (selected) values[selected.variant + 'Theme'] = theme;
681
+ store.set({appearanceDraft:values});
682
+ var queue = store.get('appearanceSaveQueue') || Promise.resolve();
683
+ var next = queue.catch(function () {}).then(function () {return persistAppearance(values);});
684
+ store.set({appearanceSaveQueue:next});
685
+ return next;
686
+ }
687
+ async function persistAppearance(values) {
688
+ var request = (store.get('appearanceSaveRequest') || 0) + 1;
689
+ store.set({ appearanceSaveRequest: request });
690
+ appearanceStatus('Saving appearance…');
691
+ try {
692
+ var response = await fetch('/api/user/appearance', {method:'PUT',headers:{'Content-Type':'application/json','X-Clay-Settings':'1'},body:JSON.stringify(values)});
693
+ if (!response.ok) throw new Error('Could not save appearance. Your preview remains. Retry to save it.');
694
+ if (store.get('appearanceSaveRequest') !== request) return;
695
+ var saved = await response.json();
696
+ store.set({ appearancePreference: saved, appearanceDraft: store.get('appearanceDraft') === values ? null : store.get('appearanceDraft') });
697
+ [STORAGE_KEY,SKIN_KEY,MODE_KEY,STORAGE_KEY+'-vars',STORAGE_KEY+'-variant'].forEach(function (key) {try {localStorage.removeItem(key);}catch(error){}});
698
+ appearanceStatus('Saved. Applied to this view; other devices use it when they reload.');
699
+ } catch(error) {if(store.get('appearanceSaveRequest') === request) appearanceStatus(error.message,true);}
700
+ }
701
+ async function loadAppearance() {
702
+ try {
703
+ var response = await fetch('/api/user/appearance');
704
+ if (!response.ok) throw new Error('Appearance preferences unavailable.');
705
+ var data = await response.json();
706
+ if(store.get('appearanceSaveRequest'))return;
707
+ if (data.configured) {store.set({appearancePreference:data});applyTheme(themeIdForMode(data.mode || getSystemMode()));}
708
+ else {
709
+ var legacy = null;try {legacy = localStorage.getItem(STORAGE_KEY);}catch(error){}
710
+ if(legacy && getTheme(legacy))store.set({legacyAppearanceAvailable:legacy});
711
+ }
712
+ } catch(error) {appearanceStatus(error.message,true);}
713
+ }
714
+
715
+ export function importLegacyAppearance() {
716
+ var legacy=store.get('legacyAppearanceAvailable');
717
+ if(legacy) {applyTheme(legacy,true);store.set({legacyAppearanceAvailable:null});}
718
+ }