clay-server 5.2.0-beta.11 → 5.2.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/public/app.js CHANGED
@@ -64,6 +64,7 @@ import { initProjectLogs, closeProjectLogs } from './modules/project-logs.js';
64
64
  import { initProjectSwitcher } from './modules/project-switcher.js';
65
65
  import { initSplitView } from './modules/split-view.js';
66
66
  import { initPaneBridge } from './modules/pane-bridge.js';
67
+ import { initPaneAppearanceBridge } from './modules/pane-appearance-bridge.js';
67
68
  import { initAdmin, checkAdminAccess } from './modules/admin.js';
68
69
  import { initSessionSearch, toggleSearch, closeSearch, isSearchOpen, handleFindInSessionResults, onHistoryPrepended as onSessionSearchHistoryPrepended } from './modules/session-search.js';
69
70
  import { initTooltips, registerTooltip } from './modules/tooltip.js';
@@ -427,7 +428,6 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
427
428
  projectListLoaded: false,
428
429
  projectsAccessLoaded: false,
429
430
  projectsHubRestoreRequested: false,
430
- projectsShowGuide: false,
431
431
  projectStart: initialProjectStart(),
432
432
  projectStartFolder: emptyFolderState(),
433
433
  onboardingStep: 'welcome',
@@ -709,6 +709,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
709
709
 
710
710
  // --- Theme (module) ---
711
711
  initTheme();
712
+ initPaneAppearanceBridge();
712
713
 
713
714
  // --- Tooltips ---
714
715
  initTooltips();
@@ -93,6 +93,10 @@
93
93
  right: 10px;
94
94
  top: 50%;
95
95
  transform: translateY(-50%);
96
+ /* The transform makes this group its own stacking context, so its popovers
97
+ are ordered by this value. Sit above chat layers but below contextual Clay
98
+ (160), the mobile overlays and modals. */
99
+ z-index: 150;
96
100
  display: flex;
97
101
  align-items: center;
98
102
  gap: 2px;
@@ -33,8 +33,14 @@ export function initAccountTransportSettings() {
33
33
  '<div class="settings-form-actions"><button class="settings-btn-sm">Save connection settings</button></div></form><p class="settings-hint" role="status"></p>';
34
34
  anchor.after(section);
35
35
  section.querySelectorAll('[name=origin],[name=proxies]').forEach(function (input) { input.oninput = function () {
36
- var state = Object.assign({}, store.get('accountTransport'), { dirty: true, error: '' });
37
- setSettingsDraft('accountTransport', true); store.set({ accountTransport: state }); render(state);
36
+ // A draft is the form differing from the saved values, so a manual revert clears it.
37
+ var saved = store.get('accountTransport') || {};
38
+ var origin = section.querySelector('[name=origin]').value.trim();
39
+ var proxies = section.querySelector('[name=proxies]').value.split(/\s+/).filter(Boolean);
40
+ var dirty = origin !== (saved.origin || '') || proxies.join('\n') !== (saved.trustedProxies || []).join('\n');
41
+ var state = Object.assign({}, saved, { dirty: dirty, error: '' });
42
+ if (dirty) setSettingsDraft('accountTransport', true); else clearSettingsDraft('accountTransport');
43
+ store.set({ accountTransport: state }); render(state);
38
44
  }; });
39
45
  section.querySelector('form').onsubmit = async function (event) {
40
46
  event.preventDefault(); var password = section.querySelector('[name=password]');
@@ -96,8 +96,13 @@ function renderSmtpTab(body, cfg) {
96
96
 
97
97
  body.innerHTML = html;
98
98
  clearSettingsDraft('smtp');
99
- body.oninput = function () { setSettingsDraft('smtp', true); };
100
- body.onchange = function () { setSettingsDraft('smtp', true); };
99
+ // The draft is the form differing from what was rendered, so reverting a field clears it.
100
+ var fields = Array.from(body.querySelectorAll('input, select, textarea'));
101
+ function fieldValue(field) { return field.type === 'checkbox' ? field.checked : field.value; }
102
+ var baseline = fields.map(fieldValue);
103
+ function syncDraft() { setSettingsDraft('smtp', fields.some(function (field, index) { return fieldValue(field) !== baseline[index]; })); }
104
+ body.oninput = syncDraft;
105
+ body.onchange = syncDraft;
101
106
  refreshIcons(body);
102
107
 
103
108
  var errorEl = body.querySelector("#smtp-error");
@@ -11,7 +11,6 @@ function explicitProjectPath(pathname) {
11
11
  export function resolveHomeBootDestination(options) {
12
12
  if (!options) return "wait";
13
13
  if (options.paneMode || explicitProjectPath(options.pathname)) return "project";
14
- if (/^\/projects\/?$/.test(options.pathname || "")) return "projects";
15
14
  // The server-selected project embedded in the root shell is authoritative.
16
15
  // Home is entered only through an explicit in-app action; reloading `/`
17
16
  // returns to the project workspace even if Home was the last open surface.
@@ -0,0 +1,59 @@
1
+ // Live appearance sync between the app shell and its pane iframes (split
2
+ // Driver/Worker panes and the contextual Mate window). Each iframe runs its own
3
+ // theme module and only reads the persisted preference at load, so the shell
4
+ // pushes the applied appearance down on every change and when a frame loads.
5
+ // Same-origin only, shell to child only; nothing is saved from either side.
6
+
7
+ import { store } from './store.js';
8
+ import { onThemeChange, getAppearanceSnapshot, applyExternalAppearance } from './theme.js';
9
+
10
+ var ID = /^[a-zA-Z0-9_-]{1,120}$/;
11
+
12
+ function paneFrames() {
13
+ return Array.prototype.filter.call(document.querySelectorAll('iframe'), function (frame) {
14
+ try { var url = new URL(frame.src, window.location.href); return url.origin === window.location.origin && url.searchParams.get('pane') === '1'; }
15
+ catch (error) { return false; }
16
+ });
17
+ }
18
+
19
+ function push(frame) {
20
+ var snapshot = getAppearanceSnapshot();
21
+ try { if (frame.contentWindow) frame.contentWindow.postMessage({ type: 'clay-appearance', snapshot: snapshot }, window.location.origin); } catch (error) {}
22
+ }
23
+
24
+ function isId(value) { return typeof value === 'string' && ID.test(value); }
25
+ function isIdOrNull(value) { return value === null || isId(value); }
26
+
27
+ // Exact shape only: strings or null, never coerced values.
28
+ function validSnapshot(value) {
29
+ var pref = value && typeof value === 'object' ? value.preference : null;
30
+ if (!pref || typeof pref !== 'object' || !isId(value.themeId)) return false;
31
+ if (value.userId !== null && (typeof value.userId !== 'string' || !value.userId || value.userId.length > 200)) return false;
32
+ if (typeof pref.configured !== 'boolean') return false;
33
+ if (pref.mode !== null && pref.mode !== 'light' && pref.mode !== 'dark') return false;
34
+ return isIdOrNull(pref.theme) && isIdOrNull(pref.lightTheme) && isIdOrNull(pref.darkTheme);
35
+ }
36
+
37
+ export function initPaneAppearanceBridge() {
38
+ if (store.get('paneMode')) {
39
+ if (window.parent === window) return;
40
+ window.addEventListener('message', function (event) {
41
+ if (event.origin !== window.location.origin || event.source !== window.parent) return;
42
+ var msg = event.data;
43
+ if (!msg || msg.type !== 'clay-appearance' || !validSnapshot(msg.snapshot)) return;
44
+ // Once this frame knows its owner the snapshot must name exactly that owner (null is a mismatch).
45
+ // Before identity is known, or in solo mode without one, any well-formed snapshot applies.
46
+ var owner = store.get('myUserId') || null;
47
+ if (owner !== null && msg.snapshot.userId !== owner) return;
48
+ applyExternalAppearance(msg.snapshot);
49
+ });
50
+ return;
51
+ }
52
+ onThemeChange(function () { paneFrames().forEach(push); });
53
+ // Frames that rejected an early snapshot (identity not yet known here) get it again once it is.
54
+ store.subscribe(function (state, previous) { if (state.myUserId !== previous.myUserId) paneFrames().forEach(push); });
55
+ // A frame that finished loading may have been seeded before a pending save.
56
+ document.addEventListener('load', function (event) {
57
+ if (event.target && event.target.tagName === 'IFRAME' && paneFrames().indexOf(event.target) >= 0) push(event.target);
58
+ }, true);
59
+ }
@@ -31,7 +31,18 @@ export function handleProjectEnv(msg) {
31
31
  update({ loaded: true, saved: msg.envrc || '', rows: parseEnvString(msg.envrc || '') });
32
32
  render(); status('');
33
33
  }
34
- function changeRows(rows) { update({ rows: rows, dirty: true }); document.getElementById('ps-env-save').disabled = false; status('Unsaved changes'); }
34
+ // A draft exists only while the rows differ from the saved variables; blank rows and edits that are
35
+ // reverted by hand leave nothing to save.
36
+ function rowsSignature(rows) {
37
+ return (rows || []).filter(function (row) { return row.key.trim() || row.value; }).map(function (row) { return row.key.trim() + '\u0000' + row.value; }).join('\n');
38
+ }
39
+ function changeRows(rows) {
40
+ var dirty = rowsSignature(rows) !== rowsSignature(parseEnvString(state().saved || ''));
41
+ update({ rows: rows, dirty: dirty });
42
+ document.getElementById('ps-env-save').disabled = !dirty || !!state().saving;
43
+ if (!dirty) clearCachedDraft();
44
+ status(dirty ? 'Unsaved changes' : '');
45
+ }
35
46
  function render() {
36
47
  var editor = state();
37
48
  var list = document.getElementById('ps-env-list'); list.replaceChildren();
@@ -1,4 +1,3 @@
1
- import { escapeHtml } from './utils.js';
2
1
  import { iconHtml } from './icons.js';
3
2
 
4
3
  export function ordinaryProjects(projects) {
@@ -28,53 +27,23 @@ function welcomeCard(label, title, text, action, artwork) {
28
27
  '<span class="projects-intro-label">' + label + '</span><h2>' + title + '</h2><p>' + text + '</p><div class="projects-intro-action">' + action + '</div></div>';
29
28
  }
30
29
 
31
- function setupAction(mode, icon, title, text, disabled) {
32
- return '<button type="button" class="projects-setup-action" data-projects-mode="' + mode + '"' + (disabled ? ' disabled' : '') + '>' +
33
- '<span class="projects-action-icon">' + iconHtml(icon) + '</span><span><strong>' + title + '</strong><span>' + text + '</span></span>' + iconHtml('arrow-up-right') + '</button>';
34
- }
35
-
30
+ // The standalone project list no longer exists: the hub renders only the loading state
31
+ // and the zero-project welcome. Once ordinary projects are known the app opens a workspace.
36
32
  export function projectsHubContent(state) {
37
33
  var projects = ordinaryProjects(state.projectsHubList);
38
- var ready = state.projectListLoaded;
39
- var welcome = ready && (state.projectsShowGuide || !projects.length);
34
+ if (!state.projectListLoaded || projects.length) {
35
+ return '<main class="projects-main" aria-labelledby="projects-title"><div class="projects-loading" role="status"><h1 id="projects-title" tabindex="-1">Your workspace</h1><p>Loading your workspace…</p></div></main>';
36
+ }
40
37
  var allowed = state.projectsAccessLoaded && (!state.permissions || state.permissions.createProject === true);
41
38
  var available = allowed && state.connected && !state.pendingHomeProjectSlug;
42
- var html = isShellWelcome(state) ? '' : '<header class="projects-header"><a class="projects-brand" href="/projects"><img src="/clay-studio-symbol.png" alt="">Clay Studio</a>' +
43
- '<nav aria-label="Workspace navigation">' + button('settings', 'Settings', 'projects-button-quiet') + '</nav></header>';
44
- html += '<main class="projects-main" aria-labelledby="projects-title">';
45
- if (!ready) return html + '<div class="projects-loading" role="status"><h1 id="projects-title" tabindex="-1">Your projects</h1><p>Loading your workspace…</p></div></main>';
46
- if (welcome) {
47
- var mateReady = !!state.connected && clayMateReady(state);
48
- return html + '<section class="projects-welcome"><div class="projects-eyebrow">WELCOME TO</div>' +
49
- '<h1 id="projects-title" tabindex="-1"><span class="projects-wordmark" role="img" aria-label="Clay Studio"></span></h1>' +
50
- '<p class="projects-lead">Where the minds work together</p>' +
51
- '<div class="projects-intro-grid">' +
52
- welcomeCard('01 / PROJECTS', 'Work together on a project.', 'Bring a codebase or a folder of files, and work through it together with Clay Studio.',
53
- button(projects.length ? 'start' : 'create-project', projects.length ? 'Explore your projects ' + iconHtml('arrow-right') : 'Start a project ' + iconHtml('arrow-right'), 'projects-button-primary', !projects.length && !available), 'project-clay') +
54
- welcomeCard('02 / MATES', 'Create your own specialist.', 'Create a Mate with the expertise you need, and work together on your ideas and tasks.',
55
- button('mate', 'Create a Mate ' + iconHtml('arrow-right'), 'projects-button-secondary', !mateReady), 'mate-clay') +
56
- '</div><p class="projects-footnote">You can revisit this guide from Projects at any time.</p></section></main>';
57
- }
58
- html += '<div class="projects-heading"><div><div class="projects-eyebrow">YOUR WORKSPACE</div><h1 id="projects-title" tabindex="-1">Projects</h1>' +
59
- '<p class="projects-lead">Pick up where you left off, or begin something new.</p></div>' + button('guide', 'Getting started', 'projects-button-quiet') + '</div>';
60
- if (state.pendingHomeProjectSlug) html += '<p class="projects-status" role="status">Opening your project…</p>';
61
- if (projects.length) {
62
- html += '<div class="projects-list" aria-label="Your projects">';
63
- projects.forEach(function (project) {
64
- html += '<button type="button" class="projects-list-item" data-project-slug="' + escapeHtml(project.slug) + '"' + (!state.connected || state.pendingHomeProjectSlug ? ' disabled' : '') + '>' +
65
- '<span class="projects-action-icon">' + iconHtml(project.isWorktree ? 'git-branch' : 'folder') + '</span><span><strong>' + escapeHtml(project.title || project.project || project.slug) + '</strong>' +
66
- '<span>' + escapeHtml(project.branch || project.path || project.slug) + '</span></span>' + iconHtml('arrow-right') + '</button>';
67
- });
68
- html += '</div><h2 class="projects-section-title">Add a project</h2>';
69
- }
70
- if (!state.projectsAccessLoaded) html += '<p class="projects-status" role="status">Checking project access…</p>';
71
- else if (!allowed) html += '<p class="projects-status">Ask an administrator to create or share a project with you.</p>';
72
- else {
73
- html += '<div class="projects-setup-actions">' +
74
- setupAction('existing', 'folder-open', 'Open a folder', 'Use a directory on the machine running Clay.', !available) +
75
- setupAction('create', 'plus', 'Create a project', 'Start in a new, empty project directory.', !available) +
76
- setupAction('clone', 'git-branch', 'Clone a repository', 'Bring an existing Git repository into Clay.', !available) + '</div>';
77
- }
78
- if (!state.connected) html += '<p class="projects-status" role="status">Reconnecting… Project actions will be available when the connection returns.</p>';
79
- return html + '</main><footer class="projects-footer">Your projects, conversations, and tools. One workspace.</footer>';
39
+ var mateReady = !!state.connected && clayMateReady(state);
40
+ return '<main class="projects-main" aria-labelledby="projects-title"><section class="projects-welcome"><div class="projects-eyebrow">WELCOME TO</div>' +
41
+ '<h1 id="projects-title" tabindex="-1"><span class="projects-wordmark" role="img" aria-label="Clay Studio"></span></h1>' +
42
+ '<p class="projects-lead">Where the minds work together</p>' +
43
+ '<div class="projects-intro-grid">' +
44
+ welcomeCard('01 / PROJECTS', 'Work together on a project.', 'Bring a codebase or a folder of files, and work through it together with Clay Studio.',
45
+ button('create-project', 'Start a project ' + iconHtml('arrow-right'), 'projects-button-primary', !available), 'project-clay') +
46
+ welcomeCard('02 / MATES', 'Create your own specialist.', 'Create a Mate with the expertise you need, and work together on your ideas and tasks.',
47
+ button('mate', 'Create a Mate ' + iconHtml('arrow-right'), 'projects-button-secondary', !mateReady), 'mate-clay') +
48
+ '</div></section></main>';
80
49
  }
@@ -3,9 +3,8 @@ import { getWs } from './ws-ref.js';
3
3
  import { switchProject } from './app-projects.js';
4
4
  import { openAddProjectModal } from './project-start-dialog.js';
5
5
  import { startClayMateCreation } from './project-mate-creation-workspace.js';
6
- import { openUserSettings } from './user-settings.js';
7
- import { rememberHomePrimarySurface, updateHomeSurfacePreference } from './home-surface.js';
8
- import { projectsHubContent, isShellWelcome, clayMateReady } from './projects-hub-view.js';
6
+ import { updateHomeSurfacePreference } from './home-surface.js';
7
+ import { projectsHubContent, isShellWelcome, clayMateReady, ordinaryProjects } from './projects-hub-view.js';
9
8
  import { refreshIcons } from './icons.js';
10
9
 
11
10
  function root() { return document.getElementById('projects-hub'); }
@@ -18,13 +17,13 @@ function render() {
18
17
  if (store.get('projectsHubMarkup') === html) return;
19
18
  store.set({ projectsHubMarkup: html });
20
19
  var active = element.contains(document.activeElement) ? document.activeElement : null;
21
- var action = active && (active.dataset.projectsAction || active.dataset.projectsMode || active.dataset.projectSlug);
20
+ var action = active && (active.dataset.projectsAction);
22
21
  element.innerHTML = html;
23
22
  refreshIcons();
24
23
  if (action) {
25
24
  var buttons = element.querySelectorAll('button, a');
26
25
  for (var i = 0; i < buttons.length; i++) {
27
- if ((buttons[i].dataset.projectsAction || buttons[i].dataset.projectsMode || buttons[i].dataset.projectSlug) === action && !buttons[i].disabled) {
26
+ if (buttons[i].dataset.projectsAction === action && !buttons[i].disabled) {
28
27
  buttons[i].focus({ preventScroll: true });
29
28
  break;
30
29
  }
@@ -38,22 +37,38 @@ function focusHeading() {
38
37
  if (title) title.focus({ preventScroll: true });
39
38
  }
40
39
 
40
+ // The authorised project to open when no standalone list exists: the server-saved
41
+ // last project, then the current one, then the first ordinary project. Only
42
+ // projects present in the server-provided list qualify.
43
+ function workspaceFallback() {
44
+ var known = ordinaryProjects(store.get('projectsHubList')).map(function (project) { return project.slug; });
45
+ var choices = [store.get('homeSurfaceProjectSlug'), store.get('currentSlug'), known[0]];
46
+ for (var i = 0; i < choices.length; i++) if (choices[i] && known.indexOf(choices[i]) !== -1) return choices[i];
47
+ return null;
48
+ }
49
+
50
+ // Shows the loading or zero-project welcome; with projects present it opens a workspace instead.
41
51
  export function showProjectsHub(fromHistory) {
52
+ if (store.get('projectListLoaded') && ordinaryProjects(store.get('projectsHubList')).length) {
53
+ var slug = workspaceFallback();
54
+ if (slug) {
55
+ hideProjectsHub();
56
+ if (fromHistory) history.replaceState(null, '', '/p/' + slug + '/');
57
+ switchProject(slug);
58
+ return;
59
+ }
60
+ }
42
61
  store.set({ projectsHubVisible: true, pendingHomeProjectSlug: null });
43
62
  document.body.classList.add('projects-active');
44
63
  root().classList.remove('hidden');
45
- rememberHomePrimarySurface('projects');
46
- if (!fromHistory && location.pathname !== '/projects') {
47
- var method = document.documentElement.classList.contains('pwa-standalone') ? 'replaceState' : 'pushState';
48
- history[method](null, '', '/projects');
49
- }
64
+ if (location.pathname === '/projects' || location.pathname === '/projects/') history.replaceState(null, '', '/');
50
65
  render();
51
66
  focusHeading();
52
67
  }
53
68
 
54
69
  export function hideProjectsHub() {
55
70
  if (!store.get('projectsHubVisible')) return;
56
- store.set({ projectsHubVisible: false, projectsShowGuide: false });
71
+ store.set({ projectsHubVisible: false });
57
72
  document.body.classList.remove('projects-active', 'projects-shell');
58
73
  if (root()) root().classList.add('hidden');
59
74
  }
@@ -64,26 +79,17 @@ export function completeProjectsOnboarding() {
64
79
  }
65
80
 
66
81
  function handleClick(event) {
67
- var button = event.target.closest('[data-projects-action], [data-projects-mode], [data-project-slug]');
82
+ var button = event.target.closest('[data-projects-action]');
68
83
  if (!button || button.disabled) return;
69
84
  event.preventDefault();
70
85
  var action = button.dataset.projectsAction;
71
- if (action === 'settings') { openUserSettings(); return; }
72
- if (action === 'guide') { store.set({ projectsShowGuide: true }); render(); focusHeading(); return; }
73
- if (action === 'start') {
74
- store.set({ projectsShowGuide: false });
75
- updateHomeSurfacePreference({ onboardingStep: 'project' });
76
- render(); focusHeading();
77
- return;
78
- }
79
86
  var ws = getWs();
80
87
  if (!ws || ws.readyState !== 1) return;
81
88
  if (action === 'mate') { if (clayMateReady(store.snap())) startClayMateCreation(); return; }
82
- if (button.dataset.projectSlug) { switchProject(button.dataset.projectSlug); return; }
83
- if (action === 'create-project' || button.dataset.projectsMode) {
89
+ if (action === 'create-project') {
84
90
  var permissions = store.get('permissions');
85
91
  if (!store.get('projectsAccessLoaded') || store.get('pendingHomeProjectSlug') || (permissions && permissions.createProject !== true)) return;
86
- openAddProjectModal(button.dataset.projectsMode);
92
+ openAddProjectModal();
87
93
  }
88
94
  }
89
95
 
@@ -92,11 +98,12 @@ export function initProjectsHub() {
92
98
  store.subscribe(function (state, previous) {
93
99
  if (state.projectsHubRestoreRequested && !previous.projectsHubRestoreRequested) {
94
100
  showProjectsHub(true);
95
- if (location.pathname === '/') history.replaceState(null, '', '/projects');
96
101
  store.set({ projectsHubRestoreRequested: false });
97
102
  return;
98
103
  }
99
104
  if (!state.projectsHubVisible) return;
105
+ // A project already being opened keeps its own activation; otherwise known projects replace the hub.
106
+ if (state.projectListLoaded && !state.pendingHomeProjectSlug && ordinaryProjects(state.projectsHubList).length) { showProjectsHub(true); return; }
100
107
  if (state.projectsHubList !== previous.projectsHubList || state.projectListLoaded !== previous.projectListLoaded ||
101
108
  state.homeSurfaceLoaded !== previous.homeSurfaceLoaded || state.onboardingStep !== previous.onboardingStep ||
102
109
  state.projectsAccessLoaded !== previous.projectsAccessLoaded || state.permissions !== previous.permissions ||
@@ -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;
@@ -719,6 +737,8 @@ async function loadAppearance() {
719
737
  if (!response.ok) throw new Error('Appearance preferences unavailable.');
720
738
  var data = await response.json();
721
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;
722
742
  if (data.configured) {store.set({appearancePreference:data});applyTheme(themeIdForMode(data.mode || getSystemMode()));}
723
743
  else {
724
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');
@@ -52,8 +52,11 @@ export function personalLeavePrompt(pageId) {
52
52
  function leaving(id) {return !pageId || pageId === id;}
53
53
  var pin = document.getElementById('us-pin-form');
54
54
  var pinDirty = pin && !pin.classList.contains('hidden') && Array.from(pin.querySelectorAll('input')).some(function (input) {return !!input.value;});
55
+ // A value left in the hidden key field (browser autofill or form restore) is
56
+ // not an edit; only an open connection editor can hold a draft key.
55
57
  var key = document.getElementById('us-linear-key');
56
- var dirty = leaving('us-integrations') && (store.get('personalEmailDirty') || store.get('personalLinearDirty') || key && key.value) ||
58
+ var keyDraft = key && key.value && store.get('personalLinearEditing') && !(key.form && key.form.hidden);
59
+ var dirty = leaving('us-integrations') && (store.get('personalEmailDirty') || store.get('personalLinearDirty') || keyDraft) ||
57
60
  leaving('us-chat') && store.get('userVoiceDirty') ||
58
61
  leaving('us-notifications') && store.get('notificationDraft') ||
59
62
  leaving('us-appearance') && (store.get('appearanceDraft') || store.get('userTerminalDirty')) ||
@@ -17,6 +17,7 @@ var records = {};
17
17
  var tickTimer = null;
18
18
  var outsideHandler = null;
19
19
  var keyHandler = null;
20
+ var blurHandler = null;
20
21
  var requestCounter = 0;
21
22
  var pendingRequestId = null;
22
23
 
@@ -135,6 +136,8 @@ function placePopover(pop) {
135
136
  if (shift) pop.style.marginRight = (-shift) + "px";
136
137
  }
137
138
 
139
+ function shellWindow() { return typeof window !== "undefined" ? window : null; }
140
+
138
141
  function bindDismissal(open) {
139
142
  if (open && !outsideHandler) {
140
143
  outsideHandler = function (event) {
@@ -143,13 +146,21 @@ function bindDismissal(open) {
143
146
  keyHandler = function (event) {
144
147
  if (event.key === "Escape") { closePopover(true); }
145
148
  };
149
+ // A click inside a split pane or contextual iframe never reaches this
150
+ // document; focus moving into the frame blurs the shell window instead.
151
+ blurHandler = function () {
152
+ if (document.activeElement && document.activeElement.tagName === "IFRAME") closePopover(false);
153
+ };
146
154
  document.addEventListener("pointerdown", outsideHandler, true);
147
155
  document.addEventListener("keydown", keyHandler, true);
156
+ if (shellWindow()) shellWindow().addEventListener("blur", blurHandler);
148
157
  } else if (!open && outsideHandler) {
149
158
  document.removeEventListener("pointerdown", outsideHandler, true);
150
159
  document.removeEventListener("keydown", keyHandler, true);
160
+ if (blurHandler && shellWindow()) shellWindow().removeEventListener("blur", blurHandler);
151
161
  outsideHandler = null;
152
162
  keyHandler = null;
163
+ blurHandler = null;
153
164
  }
154
165
  }
155
166
 
@@ -6,7 +6,7 @@
6
6
  @import url("css/linear.css");
7
7
  @import url("css/base.css?v=20260828-type1");
8
8
  @import url("css/icon-strip.css?v=20260828-surfaces4");
9
- @import url("css/title-bar.css?v=20261017-star4");
9
+ @import url("css/title-bar.css?v=20261018-actions-layer");
10
10
  @import url("css/clay-workspace.css?v=20261010-tint");
11
11
  @import url("css/pane.css");
12
12
  @import url("css/worker-proposal.css");
package/lib/server.js CHANGED
@@ -970,6 +970,13 @@ function createServer(opts) {
970
970
  res.end(auth.getAuthPage());
971
971
  return;
972
972
  }
973
+ // The standalone project list was removed. A legacy /projects link resolves
974
+ // through the same server-backed workspace selection as the root path.
975
+ if (fullUrl !== "/") {
976
+ res.writeHead(302, { "Location": "/", "Cache-Control": "no-store" });
977
+ res.end();
978
+ return;
979
+ }
973
980
  if (projects.size > 0) {
974
981
  var targetSlug = null;
975
982
  var reqUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "5.2.0-beta.11",
3
+ "version": "5.2.0-beta.12",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"