clay-server 5.2.0-beta.9 → 5.2.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.
Files changed (104) hide show
  1. package/README.md +0 -4
  2. package/lib/clay-product-control-tools.js +1 -0
  3. package/lib/clay-product-control.js +14 -11
  4. package/lib/clay-vendor-usage.js +44 -0
  5. package/lib/github-star-count.js +147 -0
  6. package/lib/notes-checklist.js +43 -1
  7. package/lib/notes.js +2 -1
  8. package/lib/project-session-notes.js +33 -8
  9. package/lib/project-sessions.js +63 -24
  10. package/lib/project-start-protocol.js +29 -0
  11. package/lib/project-vendor-login.js +2 -0
  12. package/lib/project.js +5 -1
  13. package/lib/public/app.js +27 -18
  14. package/lib/public/css/contextual-clay.css +1 -1
  15. package/lib/public/css/filebrowser.css +1 -1
  16. package/lib/public/css/mate-loading-scene.css +21 -0
  17. package/lib/public/css/overlays.css +64 -256
  18. package/lib/public/css/project-mate-creation.css +5 -6
  19. package/lib/public/css/project-start.css +108 -0
  20. package/lib/public/css/projects-hub.css +53 -14
  21. package/lib/public/css/settings-shell.css +103 -0
  22. package/lib/public/css/sidebar.css +7 -0
  23. package/lib/public/css/title-bar.css +83 -0
  24. package/lib/public/index.html +50 -63
  25. package/lib/public/modules/account-transport-settings.js +8 -2
  26. package/lib/public/modules/admin-settings-email.js +7 -2
  27. package/lib/public/modules/admin-settings-invitations.js +2 -2
  28. package/lib/public/modules/admin-settings-projects.js +2 -2
  29. package/lib/public/modules/admin-settings-ui.js +20 -4
  30. package/lib/public/modules/admin-settings-users.js +4 -4
  31. package/lib/public/modules/app-home-hub.js +2 -1
  32. package/lib/public/modules/app-messages.js +10 -2
  33. package/lib/public/modules/app-projects.js +1 -407
  34. package/lib/public/modules/app-rate-limit.js +12 -1
  35. package/lib/public/modules/clay-tool-presentation.js +1 -0
  36. package/lib/public/modules/contextual-clay.js +3 -1
  37. package/lib/public/modules/github-star.js +66 -0
  38. package/lib/public/modules/home-surface-boot.js +0 -1
  39. package/lib/public/modules/mate-loading-scene.js +17 -0
  40. package/lib/public/modules/pane-appearance-bridge.js +59 -0
  41. package/lib/public/modules/profile.js +11 -3
  42. package/lib/public/modules/project-mate-creation-workspace.js +13 -4
  43. package/lib/public/modules/project-session-visibility-settings.js +19 -16
  44. package/lib/public/modules/project-settings-environment.js +12 -1
  45. package/lib/public/modules/project-settings.js +119 -129
  46. package/lib/public/modules/project-start-dialog.js +130 -0
  47. package/lib/public/modules/project-start-folder-state.js +78 -0
  48. package/lib/public/modules/project-start-folder-view.js +190 -0
  49. package/lib/public/modules/project-start-forms.js +66 -0
  50. package/lib/public/modules/project-start-path.js +60 -0
  51. package/lib/public/modules/project-start-state.js +54 -0
  52. package/lib/public/modules/project-start-submit.js +67 -0
  53. package/lib/public/modules/projects-hub-view.js +32 -44
  54. package/lib/public/modules/projects-hub.js +39 -28
  55. package/lib/public/modules/server-settings.js +36 -91
  56. package/lib/public/modules/settings-project-target.js +56 -0
  57. package/lib/public/modules/settings-select.js +1 -1
  58. package/lib/public/modules/settings-shell-registry.js +88 -0
  59. package/lib/public/modules/settings-shell-view.js +104 -0
  60. package/lib/public/modules/settings-shell.js +342 -0
  61. package/lib/public/modules/sidebar-mobile.js +8 -30
  62. package/lib/public/modules/sidebar-projects.js +2 -1
  63. package/lib/public/modules/theme.js +31 -3
  64. package/lib/public/modules/user-settings-integrations.js +2 -1
  65. package/lib/public/modules/user-settings-layout.js +12 -34
  66. package/lib/public/modules/user-settings-profile.js +1 -1
  67. package/lib/public/modules/user-settings.js +18 -85
  68. package/lib/public/modules/vendor-usage-format.js +93 -0
  69. package/lib/public/modules/vendor-usage-list.js +45 -0
  70. package/lib/public/modules/vendor-usage-model.js +215 -0
  71. package/lib/public/modules/vendor-usage-overview.js +323 -0
  72. package/lib/public/style.css +10 -7
  73. package/lib/public/welcome-art/conversation-desk.png +0 -0
  74. package/lib/public/welcome-art/mate-clay.png +0 -0
  75. package/lib/public/welcome-art/project-clay.png +0 -0
  76. package/lib/sdk-bridge.js +6 -1
  77. package/lib/sdk-message-processor.js +9 -1
  78. package/lib/sdk-quota-snapshot.js +20 -2
  79. package/lib/server-github-stars.js +39 -0
  80. package/lib/server-global-ws.js +21 -17
  81. package/lib/server.js +11 -0
  82. package/lib/session-notes-mcp-server.js +26 -4
  83. package/lib/session-notes-writes.js +88 -0
  84. package/lib/session-visibility.js +20 -8
  85. package/lib/sessions.js +12 -2
  86. package/lib/vendor-usage-acp-readers.js +97 -0
  87. package/lib/vendor-usage-capabilities.js +88 -0
  88. package/lib/vendor-usage-http-readers.js +214 -0
  89. package/lib/vendor-usage-metrics.js +47 -0
  90. package/lib/vendor-usage-native-readers.js +106 -0
  91. package/lib/vendor-usage-overview.js +406 -0
  92. package/lib/vendor-usage-service.js +281 -0
  93. package/lib/ws-schema.js +4 -2
  94. package/lib/yoke/adapters/claude.js +12 -0
  95. package/lib/yoke/adapters/codex-query-contract.js +1 -0
  96. package/lib/yoke/adapters/codex.js +45 -12
  97. package/lib/yoke/claude-hook-installer.js +1 -0
  98. package/lib/yoke/codex-dynamic-tool-names.js +80 -0
  99. package/lib/yoke/codex-model-catalog.js +37 -3
  100. package/lib/yoke/muse-services.js +16 -3
  101. package/lib/yoke/providers/claude-model-descriptions.js +22 -2
  102. package/lib/yoke/vendor-registry.js +6 -2
  103. package/package.json +2 -1
  104. package/lib/public/modules/server-settings-navigation.js +0 -39
@@ -0,0 +1,104 @@
1
+ // DOM rendering for the unified settings shell chrome: scope tabs, page
2
+ // navigation (desktop list + narrow select) and the project selector.
3
+ import { SETTINGS_SCOPES } from './settings-shell-registry.js';
4
+ import { optionLabel } from './settings-project-target.js';
5
+ import { enhanceSettingsSelects } from './settings-select.js';
6
+
7
+ function el(id) { return document.getElementById(id); }
8
+
9
+ export function setShellStatus(text, state) {
10
+ var status = el('settings-shell-status');
11
+ status.textContent = text || '';
12
+ status.hidden = !text;
13
+ if (state) status.dataset.state = state; else delete status.dataset.state;
14
+ }
15
+
16
+ export function renderScopeTabs(access, activeScope, onSelect) {
17
+ var tabs = el('settings-scope-tabs');
18
+ tabs.replaceChildren();
19
+ SETTINGS_SCOPES.forEach(function (scope) {
20
+ if (scope.id === 'server' && !access.server) return;
21
+ if (scope.id === 'project' && !access.project) return;
22
+ var button = document.createElement('button');
23
+ button.type = 'button'; button.className = 'settings-scope-tab'; button.dataset.scope = scope.id;
24
+ button.textContent = scope.label; button.title = scope.hint;
25
+ if (scope.id === activeScope) button.setAttribute('aria-current', 'page');
26
+ button.classList.toggle('active', scope.id === activeScope);
27
+ button.onclick = function () { onSelect(scope.id); };
28
+ tabs.appendChild(button);
29
+ });
30
+ var active = SETTINGS_SCOPES.filter(function (scope) { return scope.id === activeScope; })[0];
31
+ el('settings-scope-title').textContent = active ? active.label + ' settings' : 'Settings';
32
+ el('settings-scope-hint').textContent = active ? active.hint : '';
33
+ el('settings-server-name').hidden = activeScope !== 'server';
34
+ }
35
+
36
+ export function renderPageNav(pages, activePage, onSelect) {
37
+ var nav = el('settings-page-nav');
38
+ var select = el('settings-page-select');
39
+ nav.replaceChildren(); select.replaceChildren();
40
+ var category; var group = select;
41
+ pages.forEach(function (page) {
42
+ if (page.category && page.category !== category) {
43
+ category = page.category;
44
+ var heading = document.createElement('div'); heading.className = 'settings-nav-category'; heading.textContent = category; nav.appendChild(heading);
45
+ group = document.createElement('optgroup'); group.label = category; select.appendChild(group);
46
+ }
47
+ var button = document.createElement('button');
48
+ button.type = 'button'; button.className = 'settings-nav-item'; button.dataset.section = page.id; button.textContent = page.label;
49
+ button.onclick = function () { onSelect(page.id); };
50
+ nav.appendChild(button);
51
+ var option = document.createElement('option'); option.value = page.id; option.textContent = page.label; group.appendChild(option);
52
+ });
53
+ select.onchange = function () { onSelect(this.value); };
54
+ nav.onkeydown = function (event) {
55
+ if (['ArrowUp', 'ArrowDown', 'Home', 'End'].indexOf(event.key) < 0) return;
56
+ var items = Array.from(nav.querySelectorAll('button'));
57
+ var index = items.indexOf(document.activeElement);
58
+ if (index < 0) return;
59
+ event.preventDefault();
60
+ index = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
61
+ items[index].focus();
62
+ };
63
+ markActivePage(activePage);
64
+ }
65
+
66
+ export function markActivePage(activePage) {
67
+ Array.from(el('settings-page-nav').querySelectorAll('button')).forEach(function (button) {
68
+ var active = button.dataset.section === activePage;
69
+ button.classList.toggle('active', active);
70
+ if (active) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
71
+ });
72
+ var select = el('settings-page-select');
73
+ if (activePage && select.value !== activePage) select.value = activePage;
74
+ }
75
+
76
+ // The selector is a plain select; settings-select upgrades it to the shared
77
+ // desktop picker and leaves the native control on narrow screens.
78
+ export function renderProjectSelect(choices, selectedSlug, onChange, placeholder) {
79
+ var wrap = el('settings-project-picker');
80
+ var select = el('settings-project-select');
81
+ select.replaceChildren();
82
+ choices.forEach(function (choice) {
83
+ var option = document.createElement('option');
84
+ option.value = choice.slug; option.textContent = optionLabel(choice);
85
+ select.appendChild(option);
86
+ });
87
+ if (!choices.length) {
88
+ var none = document.createElement('option'); none.value = ''; none.textContent = placeholder || 'No projects available';
89
+ select.appendChild(none);
90
+ }
91
+ select.disabled = !choices.length;
92
+ select.value = selectedSlug || (choices[0] && choices[0].slug) || '';
93
+ select.onchange = function () { onChange(this.value); };
94
+ enhanceSettingsSelects(wrap);
95
+ select.dispatchEvent(new Event('settings-sync'));
96
+ }
97
+
98
+ export function showProjectPicker(visible) { el('settings-project-picker').hidden = !visible; }
99
+ export function selectedProjectValue() { return el('settings-project-select').value; }
100
+ export function syncProjectSelect(slug) {
101
+ var select = el('settings-project-select');
102
+ select.value = slug || '';
103
+ select.dispatchEvent(new Event('settings-sync'));
104
+ }
@@ -0,0 +1,342 @@
1
+ // Unified full-screen settings: one shell for Personal, Project and Server
2
+ // settings. Scope modules register a controller; the shell owns routing,
3
+ // access, project target binding, focus, close restoration and draft guards.
4
+ import { store } from './store.js';
5
+ import { refreshIcons } from './icons.js';
6
+ import { closeFileViewer } from './filebrowser.js';
7
+ import { holdNotesForSettings, releaseNotesForSettings } from './sticky-notes.js';
8
+ import { openNotesBrowser, closeNotesBrowser, isNotesBrowserOpen } from './sticky-notes-browser.js';
9
+ import { confirmSettingsAction, isolateSettingsDialog, trapSettingsFocus } from './server-settings-state.js';
10
+ import { showToast } from './utils.js';
11
+ import { enhanceSettingsSelects } from './settings-select.js';
12
+ import { accessFromMe, defaultAccess, pagesForScope, resolveRoute, scopeAvailable } from './settings-shell-registry.js';
13
+ import { listSettingsProjects, resolveTargetSlug, findChoice, createTargetBinding } from './settings-project-target.js';
14
+ import { setShellStatus, renderScopeTabs, renderPageNav, markActivePage, renderProjectSelect, showProjectPicker, syncProjectSelect } from './settings-shell-view.js';
15
+
16
+ var controllers = {};
17
+ var binding = createTargetBinding();
18
+ var root = null;
19
+ var lastChoices = '';
20
+ var OFFLINE = 'Offline. The project list and your project access may be out of date until the connection returns.';
21
+ var shell = { verified: false, pending: null, waiting: null, offline: false, open: false, scope: null, pages: {}, slug: null, access: defaultAccess(), returnFocus: null, restoreInert: null, notesReopen: false, token: 0, pending: null };
22
+ var OVERLAYS = '.admin-modal-overlay, #settings-confirm, .confirm-above-settings, .avatar-positioner-overlay, .email-setup-overlay, .settings-subview';
23
+
24
+ export function registerSettingsScope(id, controller) { controllers[id] = controller; }
25
+ export function isSettingsShellOpen() { return !!shell.open; }
26
+ export function activeSettingsScope() { return shell.scope; }
27
+ export function settingsTargetSlug() { return shell.slug; }
28
+ // Async replies compare their captured token with the live target.
29
+ export function settingsTargetToken() { return binding.generation(); }
30
+ export function isSettingsTargetCurrent(token, slug) { return shell.open && binding.isCurrent(token, slug); }
31
+
32
+ // Fail closed: until a fresh server answer is bound to this open, no
33
+ // administrator scope is offered, whatever an earlier session learned.
34
+ function provisionalAccess() {
35
+ var perms = store.get('permissions');
36
+ return Object.assign(defaultAccess(), { project: !perms || perms.projectSettings !== false });
37
+ }
38
+
39
+ // Reads only; the caller applies the answer after validating that it still
40
+ // belongs to the current open and signed-in user.
41
+ function fetchMe() {
42
+ return fetch('/api/me', { credentials: 'same-origin' }).then(function (response) {
43
+ if (!response.ok) throw new Error('Could not verify access.');
44
+ return response.json();
45
+ });
46
+ }
47
+
48
+ function setGears(open) {
49
+ ['server-settings-btn', 'user-settings-btn'].forEach(function (id) {
50
+ var button = document.getElementById(id);
51
+ if (!button) return;
52
+ button.classList.toggle('active', open);
53
+ button.setAttribute('aria-expanded', String(open));
54
+ });
55
+ }
56
+
57
+ function projectChoices() {
58
+ return listSettingsProjects(store.get('projectsHubList'), store.get('currentSlug'), store.get('permissions'));
59
+ }
60
+
61
+ function focusHeading(ctrl) {
62
+ var heading = ctrl && ctrl.pane.querySelector('.active h2');
63
+ if (heading) { heading.tabIndex = -1; heading.focus(); }
64
+ }
65
+
66
+ function setPaneVisibility(scope) {
67
+ Object.keys(controllers).forEach(function (id) { controllers[id].pane.classList.toggle('hidden', id !== scope); });
68
+ }
69
+
70
+ function controller() { return controllers[shell.scope]; }
71
+
72
+ // Ask before leaving drafts behind. kind: page | scope | project | close.
73
+ function guard(kind, proceed, pageId) {
74
+ var ctrl = controller();
75
+ var busy = ctrl && ctrl.busy && ctrl.busy(kind);
76
+ if (busy) { showToast(busy); return; }
77
+ var prompt = ctrl && ctrl.leavePrompt && ctrl.leavePrompt(kind, pageId);
78
+ if (!prompt) { proceed(); return; }
79
+ confirmSettingsAction(prompt.message, prompt.action, function () { if (prompt.apply) prompt.apply(); proceed(); });
80
+ }
81
+
82
+ function choicesSignature(choices, slug) {
83
+ return choices.map(function (choice) { return choice.slug + '|' + choice.label + '|' + choice.isCurrent; }).join('\n') + '#' + (slug || '');
84
+ }
85
+
86
+ function applyProject(ctrl, route, page) {
87
+ var requested = route.slug || shell.slug;
88
+ lastChoices = '';
89
+ // An unloaded list is never "no projects": wait for the authoritative list.
90
+ if (store.get('projectListLoaded') !== true) {
91
+ shell.waiting = { slug: requested };
92
+ binding.bind(null); shell.slug = null;
93
+ renderProjectSelect([], '', onProjectSelect, 'Loading projects…'); showProjectPicker(true);
94
+ renderPageNav(pagesForScope('project', shell.access), page, onPageSelect);
95
+ ctrl.setEmpty('Loading your projects…', 'loading');
96
+ var token = shell.token;
97
+ setTimeout(function () {
98
+ if (shell.open && token === shell.token && shell.waiting) {
99
+ ctrl.setEmpty('Your projects could not be loaded. Check the connection and reopen Settings to try again.', 'error');
100
+ setShellStatus('Your projects could not be loaded.', 'error');
101
+ }
102
+ }, 10000);
103
+ return;
104
+ }
105
+ shell.waiting = null;
106
+ var choices = projectChoices();
107
+ var slug = resolveTargetSlug(requested, choices);
108
+ var lost = !!requested && slug !== requested;
109
+ renderProjectSelect(choices, slug, onProjectSelect);
110
+ lastChoices = choicesSignature(choices, slug);
111
+ showProjectPicker(true);
112
+ binding.bind(slug);
113
+ shell.slug = slug;
114
+ if (!slug) {
115
+ ctrl.setEmpty('You do not have a project to configure yet. Open or create a project, then return here.', 'empty');
116
+ renderPageNav(pagesForScope('project', shell.access), page, onPageSelect);
117
+ return;
118
+ }
119
+ ctrl.setEmpty(null);
120
+ ctrl.retarget(slug, findChoice(choices, slug).project, binding.generation());
121
+ setShellStatus(lost ? 'That project is not available for your account. Showing ' + findChoice(choices, slug).label + '.' : '', lost ? 'error' : undefined);
122
+ }
123
+
124
+ function redirectNote(resolved) {
125
+ if (!resolved.redirected) return '';
126
+ return resolved.requestedScope === 'server' ? 'Server settings require an administrator. Your personal settings are shown.' : 'That settings area is not available for your account.';
127
+ }
128
+
129
+ function currentRoute() {
130
+ return { scope: shell.scope, page: shell.pages[shell.scope], slug: shell.slug || (shell.waiting && shell.waiting.slug) || null };
131
+ }
132
+
133
+ function applyRoute(route, focus) {
134
+ var resolved = resolveRoute(route, shell.access);
135
+ var ctrl = controllers[resolved.scope];
136
+ if (!ctrl) return;
137
+ if (shell.scope && shell.scope !== resolved.scope && controllers[shell.scope].leave) controllers[shell.scope].leave();
138
+ var entering = shell.scope !== resolved.scope;
139
+ shell.scope = resolved.scope;
140
+ binding.bind(null);
141
+ setPaneVisibility(resolved.scope);
142
+ renderScopeTabs(shell.access, resolved.scope, onScopeSelect);
143
+ setShellStatus(redirectNote(resolved), resolved.redirected ? 'error' : undefined);
144
+ var ctx = { access: shell.access, scope: resolved.scope };
145
+ if (entering && ctrl.enter) ctrl.enter(ctx);
146
+ if (resolved.scope === 'project') applyProject(ctrl, resolved, resolved.page);
147
+ else { showProjectPicker(false); shell.slug = null; shell.waiting = null; }
148
+ var page = resolved.page;
149
+ if (resolved.scope !== 'project' || shell.slug) renderPageNav(pagesForScope(resolved.scope, shell.access), page, onPageSelect);
150
+ shell.pages[resolved.scope] = page;
151
+ if (resolved.scope !== 'project' || shell.slug) ctrl.activate(page, ctx);
152
+ markActivePage(page);
153
+ store.set({ settingsLastRoute: { scope: resolved.scope, page: page, slug: shell.slug } });
154
+ enhanceSettingsSelects(root); refreshIcons(root);
155
+ if (focus) focusHeading(ctrl);
156
+ }
157
+
158
+ function onScopeSelect(scope) {
159
+ if (scope === shell.scope) return;
160
+ guard('scope', function () { applyRoute({ scope: scope, page: shell.pages[scope], slug: scope === 'project' ? currentRoute().slug : null }, true); });
161
+ }
162
+ function onPageSelect(page) {
163
+ if (page === shell.pages[shell.scope]) { focusHeading(controller()); return; }
164
+ guard('page', function () {
165
+ var ctrl = controller();
166
+ shell.pages[shell.scope] = page; ctrl.activate(page, { access: shell.access, scope: shell.scope });
167
+ markActivePage(page); store.set({ settingsLastRoute: { scope: shell.scope, page: page, slug: shell.slug } });
168
+ focusHeading(ctrl);
169
+ }, shell.pages[shell.scope]);
170
+ }
171
+ // A project switch retargets only the settings view, never the workspace.
172
+ function onProjectSelect(slug) {
173
+ syncProjectSelect(shell.slug);
174
+ if (!slug || slug === shell.slug) return;
175
+ guard('project', function () { applyRoute({ scope: 'project', page: shell.pages.project, slug: slug }, false); });
176
+ }
177
+
178
+ export function navigateSettings(route) {
179
+ // Until access is verified, remember the latest request and apply it afterwards.
180
+ if (!shell.verified) { shell.pending = route; return; }
181
+ var resolved = resolveRoute(route, shell.access);
182
+ if (resolved.scope === shell.scope && resolved.page === shell.pages[shell.scope] && (resolved.scope !== 'project' || !route.slug || route.slug === shell.slug)) {
183
+ setShellStatus(redirectNote(resolved), resolved.redirected ? 'error' : undefined);
184
+ focusHeading(controller()); return;
185
+ }
186
+ guard(resolved.scope === shell.scope ? (route.slug && route.slug !== shell.slug ? 'project' : 'page') : 'scope', function () { applyRoute(route, true); }, shell.pages[shell.scope]);
187
+ }
188
+
189
+ // Entry for every legacy opener. Returns once the exact destination is shown.
190
+ export function openSettings(route) {
191
+ if (!root) return Promise.resolve();
192
+ route = route || store.get('settingsLastRoute') || { scope: 'personal' };
193
+ if (shell.open) { navigateSettings(route); return Promise.resolve(); }
194
+ shell.open = true;
195
+ shell.token += 1;
196
+ var token = shell.token;
197
+ shell.returnFocus = document.activeElement;
198
+ shell.scope = null; shell.pages = {}; shell.slug = null;
199
+ closeFileViewer();
200
+ shell.notesReopen = isNotesBrowserOpen();
201
+ if (shell.notesReopen) closeNotesBrowser();
202
+ holdNotesForSettings();
203
+ root.classList.remove('hidden');
204
+ shell.restoreInert = isolateSettingsDialog(root);
205
+ setGears(true);
206
+ store.set({ settingsOpen: true });
207
+ var userAtOpen = store.get('myUserId');
208
+ shell.verified = false; shell.pending = null;
209
+ shell.access = provisionalAccess();
210
+ setShellStatus('');
211
+ if (route.scope !== 'server') applyRoute(route, true);
212
+ else { renderScopeTabs(shell.access, 'personal', onScopeSelect); setShellStatus('Checking administrator access…'); }
213
+ function current(me) {
214
+ // Stale after close/reopen, or a different signed-in user: ignore entirely.
215
+ return shell.open && token === shell.token && store.get('myUserId') === userAtOpen && (!me || !me.user || !userAtOpen || me.user.id === userAtOpen);
216
+ }
217
+ return fetchMe().then(function (me) {
218
+ if (!current(me)) return;
219
+ var access = accessFromMe(me, me.permissions || null);
220
+ store.set({ adminMe: me, permissions: me.permissions || null, projectsAccessLoaded: true });
221
+ var needsRoute = !shell.scope;
222
+ var changed = access.server !== shell.access.server || access.multiUser !== shell.access.multiUser || access.project !== shell.access.project;
223
+ shell.access = access;
224
+ shell.verified = true;
225
+ var pending = shell.pending; shell.pending = null;
226
+ if (pending) applyRoute(pending, true);
227
+ else if (needsRoute) applyRoute(route, true);
228
+ else if (changed) applyRoute(scopeAvailable(shell.scope, access) ? currentRoute() : { scope: 'personal' }, false);
229
+ }, function () {
230
+ if (!current()) return;
231
+ // Verification failed: Server stays unavailable and the view is rebuilt without it.
232
+ shell.access = Object.assign({}, shell.access, { server: false });
233
+ shell.verified = true;
234
+ var queued = shell.pending; shell.pending = null;
235
+ applyRoute(queued || (shell.scope && shell.scope !== 'server' ? currentRoute() : { scope: 'personal' }), false);
236
+ setShellStatus('Could not verify administrator access, so Server settings are unavailable. Close and reopen Settings to retry.', 'error');
237
+ });
238
+ }
239
+
240
+ function closePrompt() {
241
+ var withDrafts = Object.keys(controllers).filter(function (id) { return controllers[id].leavePrompt && controllers[id].leavePrompt('close'); });
242
+ if (!withDrafts.length) return null;
243
+ if (withDrafts.length === 1 && withDrafts[0] === shell.scope) return controllers[shell.scope].leavePrompt('close');
244
+ return { message: 'You have unsaved changes in another settings area. Closing keeps these drafts in this tab, but reloading the page will lose them.', action: 'Close with drafts' };
245
+ }
246
+
247
+ export function closeSettings(opts) {
248
+ opts = opts || {};
249
+ if (!shell.open) return;
250
+ if (opts.onlyScope && shell.scope !== opts.onlyScope) return;
251
+ function finish() {
252
+ shell.token += 1; binding.invalidate();
253
+ var ctrl = controller();
254
+ if (ctrl && ctrl.leave) ctrl.leave();
255
+ Object.keys(controllers).forEach(function (id) { if (controllers[id].close) controllers[id].close(); });
256
+ root.classList.add('hidden');
257
+ if (shell.restoreInert) { shell.restoreInert(); shell.restoreInert = null; }
258
+ setGears(false);
259
+ shell.open = false; shell.scope = null; shell.slug = null; shell.waiting = null; shell.offline = false; shell.verified = false; shell.pending = null;
260
+ store.set({ settingsOpen: false });
261
+ releaseNotesForSettings();
262
+ if (!opts.cleanup) {
263
+ if (shell.notesReopen) openNotesBrowser();
264
+ var focus = shell.returnFocus;
265
+ if (!focus || !focus.isConnected || focus === document.body) focus = document.getElementById('server-settings-btn');
266
+ if (focus) focus.focus();
267
+ }
268
+ shell.notesReopen = false; shell.returnFocus = null;
269
+ }
270
+ if (opts.cleanup || opts.force) { finish(); return; }
271
+ var ctrl = controller();
272
+ var busy = ctrl && ctrl.busy && ctrl.busy('close');
273
+ if (busy) { showToast(busy); return; }
274
+ var prompt = closePrompt();
275
+ if (!prompt) { finish(); return; }
276
+ confirmSettingsAction(prompt.message, prompt.action, function () { if (prompt.apply) prompt.apply(); finish(); });
277
+ }
278
+
279
+ // Authorisation, ordering and the open project can change while settings
280
+ // are open; an unavailable target is cleared rather than silently replaced.
281
+ function refreshLive() {
282
+ if (!shell.open || !shell.scope) return;
283
+ var perms = store.get('permissions');
284
+ var project = !perms || perms.projectSettings !== false;
285
+ if (project !== shell.access.project) {
286
+ shell.access = Object.assign({}, shell.access, { project: project });
287
+ if (shell.scope === 'project' && !project) { applyRoute({ scope: 'personal' }, false); setShellStatus('Project settings are no longer permitted for your account.', 'error'); return; }
288
+ renderScopeTabs(shell.access, shell.scope, onScopeSelect);
289
+ }
290
+ if (shell.scope !== 'project') return;
291
+ syncConnectivity();
292
+ if (shell.waiting && store.get('projectListLoaded') === true) {
293
+ var waiting = shell.waiting; shell.waiting = null;
294
+ applyRoute({ scope: 'project', page: shell.pages.project, slug: waiting.slug }, false);
295
+ return;
296
+ }
297
+ if (shell.waiting) return;
298
+ var choices = projectChoices();
299
+ var ctrl = controller();
300
+ var target = findChoice(choices, shell.slug);
301
+ var signature = choicesSignature(choices, target ? shell.slug : '');
302
+ if (signature !== lastChoices) { lastChoices = signature; renderProjectSelect(choices, target ? shell.slug : '', onProjectSelect); }
303
+ if (shell.slug && !target) {
304
+ var lostSlug = shell.slug;
305
+ binding.invalidate(); shell.slug = null;
306
+ ctrl.clearTarget(lostSlug);
307
+ ctrl.setEmpty('That project is no longer available for your account. Choose another project.', 'lost');
308
+ syncProjectSelect('');
309
+ setShellStatus('The selected project is no longer available. Unsaved edits for it were not applied.', 'error');
310
+ } else if (target) ctrl.refreshTarget(shell.slug, target.project);
311
+ }
312
+
313
+ function syncConnectivity() {
314
+ var offline = store.get('connected') === false;
315
+ if (offline && !shell.offline) { shell.offline = true; setShellStatus(OFFLINE, 'error'); }
316
+ else if (!offline && shell.offline) { shell.offline = false; setShellStatus(''); }
317
+ }
318
+
319
+ export function initSettingsShell() {
320
+ root = document.getElementById('settings-shell');
321
+ if (!root) return;
322
+ var content = document.getElementById('settings-shell-content');
323
+ ['user-settings', 'project-settings', 'server-settings'].forEach(function (id) {
324
+ var pane = document.getElementById(id);
325
+ if (pane) { pane.classList.add('settings-pane', 'hidden'); content.appendChild(pane); }
326
+ });
327
+ document.getElementById('settings-shell-close').onclick = function () { closeSettings(); };
328
+ var server = document.getElementById('server-settings-btn');
329
+ if (server) server.addEventListener('click', function () { if (shell.open) closeSettings(); else openSettings(); });
330
+ var personal = document.getElementById('user-settings-btn');
331
+ if (personal) personal.addEventListener('click', function () { openSettings({ scope: 'personal' }); });
332
+ document.addEventListener('keydown', function (event) {
333
+ if (shell.open && event.key === 'Tab' && !document.querySelector('#settings-confirm, .admin-modal-overlay')) trapSettingsFocus(event, root);
334
+ if (!shell.open || event.key !== 'Escape' || event.defaultPrevented) return;
335
+ if (store.get('speechPopup') || document.querySelector(OVERLAYS)) return;
336
+ event.preventDefault(); closeSettings();
337
+ });
338
+ store.subscribe(function (state, previous) {
339
+ if (state.myUserId !== previous.myUserId) { closeSettings({ cleanup: true }); return; }
340
+ if (state.projectsHubList !== previous.projectsHubList || state.currentSlug !== previous.currentSlug || state.permissions !== previous.permissions || state.projectListLoaded !== previous.projectListLoaded || state.connected !== previous.connected) refreshLive();
341
+ });
342
+ }
@@ -10,7 +10,7 @@ import { iconHtml, refreshIcons } from './icons.js';
10
10
  import { parseEmojis } from './markdown.js';
11
11
  import { getCurrentTheme, getChatLayout, setChatLayout } from './theme.js';
12
12
  import { openCommandPalette } from './command-palette.js';
13
- import { openProjectSettings } from './project-settings.js';
13
+ import { openSettings } from './settings-shell.js';
14
14
  import { getCachedSessions, openSessionContextMenu } from './sidebar-sessions.js';
15
15
  import { renderSessionPresence } from './session-presence-ui.js';
16
16
  import {
@@ -1033,6 +1033,7 @@ export function renderSheetTools(listEl) {
1033
1033
 
1034
1034
  function renderSheetSettings(listEl) {
1035
1035
  var items = [
1036
+ { icon: "user-cog", label: "Personal Settings", action: "personal-settings" },
1036
1037
  { icon: "folder-cog", label: "Project Settings", action: "project-settings" },
1037
1038
  { icon: "settings", label: "Server Settings", action: "server-settings" }
1038
1039
  ];
@@ -1044,35 +1045,12 @@ function renderSheetSettings(listEl) {
1044
1045
  btn.innerHTML = '<i data-lucide="' + item.icon + '"></i><span class="mobile-more-item-label">' + item.label + '</span>';
1045
1046
  btn.addEventListener("click", function () {
1046
1047
  closeMobileSheet();
1047
- if (item.action === "project-settings") {
1048
- setTimeout(function () {
1049
- // Find current project data
1050
- var proj = null;
1051
- for (var pi = 0; pi < getCachedProjectList().length; pi++) {
1052
- if (getCachedProjectList()[pi].slug === getCachedCurrentSlug()) {
1053
- proj = getCachedProjectList()[pi];
1054
- break;
1055
- }
1056
- }
1057
- // For mate projects, use mate display name instead of slug
1058
- if (proj && proj.isMate && getCachedMates().length > 0) {
1059
- var mateId = getCachedCurrentSlug().replace("mate-", "");
1060
- var _mates = getCachedMates();
1061
- for (var mi = 0; mi < _mates.length; mi++) {
1062
- var mp = _mates[mi].profile || {};
1063
- if (_mates[mi].id === mateId) {
1064
- proj = Object.assign({}, proj, { name: mp.displayName || _mates[mi].name || proj.name });
1065
- break;
1066
- }
1067
- }
1068
- }
1069
- if (proj && store.get('ownerLocked')) proj = Object.assign({}, proj, { ownerLocked: true });
1070
- openProjectSettings(getCachedCurrentSlug(), proj);
1071
- }, 250);
1072
- } else if (item.action === "server-settings") {
1073
- var settingsBtn = document.getElementById("server-settings-btn");
1074
- if (settingsBtn) setTimeout(function () { settingsBtn.click(); }, 250);
1075
- }
1048
+ // The shell resolves each scope to its exact page and falls back for
1049
+ // accounts that may not use it. Project settings target the open
1050
+ // workspace first.
1051
+ var route = item.action === "project-settings" ? { scope: "project", slug: getCachedCurrentSlug() }
1052
+ : item.action === "server-settings" ? { scope: "server" } : { scope: "personal" };
1053
+ setTimeout(function () { openSettings(route); }, 250);
1076
1054
  });
1077
1055
  listEl.appendChild(btn);
1078
1056
  })(items[i]);
@@ -10,7 +10,8 @@ import { store } from './store.js';
10
10
  import { getWs } from './ws-ref.js';
11
11
  import { closeSidebar } from './sidebar.js';
12
12
  import { showIconTooltip, hideIconTooltip, bindIconTooltip, closeUserCtxMenu, getCurrentDmUserId } from './sidebar-mates.js';
13
- import { switchProject, openAddProjectModal, getCachedProjects } from './app-projects.js';
13
+ import { switchProject, getCachedProjects } from './app-projects.js';
14
+ import { openAddProjectModal } from './project-start-dialog.js';
14
15
  import { showHomeHub } from './app-home-hub.js';
15
16
  import { getPendingTuiAttention } from './app-notifications.js';
16
17
  import { isExternalWorktree, externalWorktreeTooltip, appendExternalWorktreeBadge } from './worktree-location.js';
@@ -222,6 +222,7 @@ var customSet = {}; // ids that came from ~/.clay/themes/
222
222
  var themesLoaded = false;
223
223
  var currentThemeId = DEFAULT_DARK_THEME_ID;
224
224
  var changeCallbacks = [];
225
+ var externalThemeId = null; // set by the parent shell for pane iframes
225
226
  var STORAGE_KEY = "clay-theme";
226
227
  var MODE_KEY = "clay-mode"; // "light" | "dark" | null (system)
227
228
  var SKIN_KEY = "clay-skin"; // theme id within current variant pair
@@ -274,6 +275,23 @@ export function onThemeChange(fn) {
274
275
  changeCallbacks.push(fn);
275
276
  }
276
277
 
278
+ // The appearance this frame currently shows, for the parent shell to hand to
279
+ // its pane iframes. Never persisted from here.
280
+ export function getAppearanceSnapshot() {
281
+ var pref = store.get('appearanceDraft') || store.get('appearancePreference') || {};
282
+ return { themeId: currentThemeId, userId: store.get('myUserId') || null,
283
+ preference: { configured: !!pref.configured || !!pref.mode || !!pref.theme, theme: pref.theme || null, mode: pref.mode || null,
284
+ lightTheme: pref.lightTheme || null, darkTheme: pref.darkTheme || null } };
285
+ }
286
+
287
+ // A pane iframe mirrors the parent's live appearance. The server remains the
288
+ // only persisted source, so this applies the view without saving anything.
289
+ export function applyExternalAppearance(snapshot) {
290
+ externalThemeId = snapshot.themeId;
291
+ store.set({ appearancePreference: snapshot.preference, appearanceDraft: null });
292
+ applyTheme(snapshot.themeId);
293
+ }
294
+
277
295
  export function getThemes() {
278
296
  // Return a copy
279
297
  var all = {};
@@ -367,7 +385,7 @@ function loadThemes() {
367
385
 
368
386
  // Always apply the current theme now that real data is loaded
369
387
  // (before this, only defaultExactVars was used as fallback)
370
- applyTheme(currentThemeId);
388
+ applyTheme(externalThemeId && getTheme(externalThemeId) ? externalThemeId : currentThemeId);
371
389
  }).catch(function () {
372
390
  // API unavailable — keep the embedded Clay Studio pair.
373
391
  themes[DEFAULT_DARK_THEME_ID] = defaultDarkFallback;
@@ -482,8 +500,13 @@ function updateToggleIcon() {
482
500
  // User settings toggle
483
501
  var usToggle = document.getElementById("us-theme-toggle");
484
502
  if (usToggle) usToggle.checked = isLight;
485
- // User island button is now a static "skins / themes" trigger; no
486
- // icon swap on mode change.
503
+ var quickToggle = document.getElementById("appearance-mode-toggle");
504
+ if (quickToggle) {
505
+ var label = isLight ? "Switch to dark mode" : "Switch to light mode";
506
+ quickToggle.setAttribute("aria-label", label);
507
+ quickToggle.title = label;
508
+ quickToggle.innerHTML = '<i data-lucide="' + (isLight ? "moon" : "sun") + '" class="cursor-share-icon" aria-hidden="true"></i>';
509
+ }
487
510
  refreshIcons();
488
511
  }
489
512
 
@@ -649,6 +672,9 @@ export function initTheme() {
649
672
  }
650
673
  }
651
674
 
675
+ var quickToggle = document.getElementById("appearance-mode-toggle");
676
+ if (quickToggle) quickToggle.addEventListener("click", toggleDarkMode);
677
+
652
678
  // Set initial toggle icon
653
679
  updateToggleIcon();
654
680
 
@@ -711,6 +737,8 @@ async function loadAppearance() {
711
737
  if (!response.ok) throw new Error('Appearance preferences unavailable.');
712
738
  var data = await response.json();
713
739
  if(store.get('appearanceSaveRequest'))return;
740
+ // A pane that already mirrors its parent's live view is newer than this GET, which may predate a pending save.
741
+ if(externalThemeId)return;
714
742
  if (data.configured) {store.set({appearancePreference:data});applyTheme(themeIdForMode(data.mode || getSystemMode()));}
715
743
  else {
716
744
  var legacy = null;try {legacy = localStorage.getItem(STORAGE_KEY);}catch(error){}
@@ -54,7 +54,8 @@ export function initPersonalIntegrations() {
54
54
  if (!store.get('personalLinearDirty')) { action(); return; }
55
55
  confirmSettingsAction('Discard this unsaved connection draft?', 'Discard', action);
56
56
  }
57
- form.oninput = function () { store.set({personalLinearDirty: !!key.value || name.value !== store.get('personalLinearOriginalName')}); };
57
+ // Only an open editor holds a draft; autofill or restore into the hidden form is not an edit.
58
+ form.oninput = function () { if (!store.get('personalLinearEditing')) return; store.set({personalLinearDirty: !!key.value || name.value !== store.get('personalLinearOriginalName')}); };
58
59
  async function save(disconnect) {
59
60
  if (store.get('personalLinearSaving')) return;
60
61
  var actor = store.get('myUserId'), generation = store.get('personalLinearGeneration');