clay-server 5.2.0-beta.9 → 5.2.1-beta.1

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 (109) 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 +30 -18
  14. package/lib/public/css/command-palette.css +12 -2
  15. package/lib/public/css/contextual-clay.css +1 -1
  16. package/lib/public/css/filebrowser.css +4 -5
  17. package/lib/public/css/mate-loading-scene.css +21 -0
  18. package/lib/public/css/messages.css +30 -11
  19. package/lib/public/css/overlays.css +67 -256
  20. package/lib/public/css/project-mate-creation.css +5 -6
  21. package/lib/public/css/project-start.css +108 -0
  22. package/lib/public/css/projects-hub.css +53 -14
  23. package/lib/public/css/right-workbench.css +28 -0
  24. package/lib/public/css/settings-shell.css +103 -0
  25. package/lib/public/css/sidebar.css +7 -0
  26. package/lib/public/css/title-bar.css +99 -0
  27. package/lib/public/index.html +54 -66
  28. package/lib/public/modules/account-transport-settings.js +8 -2
  29. package/lib/public/modules/admin-settings-email.js +7 -2
  30. package/lib/public/modules/admin-settings-invitations.js +2 -2
  31. package/lib/public/modules/admin-settings-projects.js +2 -2
  32. package/lib/public/modules/admin-settings-ui.js +20 -4
  33. package/lib/public/modules/admin-settings-users.js +4 -4
  34. package/lib/public/modules/app-home-hub.js +2 -1
  35. package/lib/public/modules/app-messages.js +10 -2
  36. package/lib/public/modules/app-projects.js +1 -407
  37. package/lib/public/modules/app-rate-limit.js +12 -1
  38. package/lib/public/modules/clay-tool-presentation.js +1 -0
  39. package/lib/public/modules/contextual-clay.js +3 -1
  40. package/lib/public/modules/github-star.js +66 -0
  41. package/lib/public/modules/home-surface-boot.js +0 -1
  42. package/lib/public/modules/mate-loading-scene.js +17 -0
  43. package/lib/public/modules/pair-result-status.js +89 -39
  44. package/lib/public/modules/pane-appearance-bridge.js +59 -0
  45. package/lib/public/modules/profile.js +11 -3
  46. package/lib/public/modules/project-mate-creation-workspace.js +13 -4
  47. package/lib/public/modules/project-session-visibility-settings.js +19 -16
  48. package/lib/public/modules/project-settings-environment.js +12 -1
  49. package/lib/public/modules/project-settings.js +119 -129
  50. package/lib/public/modules/project-start-dialog.js +130 -0
  51. package/lib/public/modules/project-start-folder-state.js +78 -0
  52. package/lib/public/modules/project-start-folder-view.js +190 -0
  53. package/lib/public/modules/project-start-forms.js +66 -0
  54. package/lib/public/modules/project-start-path.js +60 -0
  55. package/lib/public/modules/project-start-state.js +54 -0
  56. package/lib/public/modules/project-start-submit.js +67 -0
  57. package/lib/public/modules/projects-hub-view.js +32 -44
  58. package/lib/public/modules/projects-hub.js +39 -28
  59. package/lib/public/modules/server-settings.js +36 -91
  60. package/lib/public/modules/settings-project-target.js +56 -0
  61. package/lib/public/modules/settings-select.js +1 -1
  62. package/lib/public/modules/settings-shell-registry.js +88 -0
  63. package/lib/public/modules/settings-shell-view.js +104 -0
  64. package/lib/public/modules/settings-shell.js +342 -0
  65. package/lib/public/modules/sidebar-mobile.js +8 -30
  66. package/lib/public/modules/sidebar-projects.js +2 -1
  67. package/lib/public/modules/theme.js +31 -3
  68. package/lib/public/modules/topbar-workbench-controls.js +102 -0
  69. package/lib/public/modules/user-settings-integrations.js +2 -1
  70. package/lib/public/modules/user-settings-layout.js +12 -34
  71. package/lib/public/modules/user-settings-profile.js +1 -1
  72. package/lib/public/modules/user-settings.js +18 -85
  73. package/lib/public/modules/vendor-usage-format.js +93 -0
  74. package/lib/public/modules/vendor-usage-list.js +45 -0
  75. package/lib/public/modules/vendor-usage-model.js +215 -0
  76. package/lib/public/modules/vendor-usage-overview.js +323 -0
  77. package/lib/public/style.css +13 -10
  78. package/lib/public/welcome-art/conversation-desk.png +0 -0
  79. package/lib/public/welcome-art/mate-clay.png +0 -0
  80. package/lib/public/welcome-art/project-clay.png +0 -0
  81. package/lib/sdk-bridge.js +6 -1
  82. package/lib/sdk-message-processor.js +9 -1
  83. package/lib/sdk-quota-snapshot.js +20 -2
  84. package/lib/server-github-stars.js +39 -0
  85. package/lib/server-global-ws.js +21 -17
  86. package/lib/server.js +11 -0
  87. package/lib/session-notes-mcp-server.js +26 -4
  88. package/lib/session-notes-writes.js +88 -0
  89. package/lib/session-visibility.js +20 -8
  90. package/lib/sessions.js +12 -2
  91. package/lib/vendor-usage-acp-readers.js +97 -0
  92. package/lib/vendor-usage-capabilities.js +88 -0
  93. package/lib/vendor-usage-http-readers.js +214 -0
  94. package/lib/vendor-usage-metrics.js +47 -0
  95. package/lib/vendor-usage-native-readers.js +106 -0
  96. package/lib/vendor-usage-overview.js +406 -0
  97. package/lib/vendor-usage-service.js +281 -0
  98. package/lib/ws-schema.js +4 -2
  99. package/lib/yoke/adapters/claude.js +12 -0
  100. package/lib/yoke/adapters/codex-query-contract.js +1 -0
  101. package/lib/yoke/adapters/codex.js +45 -12
  102. package/lib/yoke/claude-hook-installer.js +1 -0
  103. package/lib/yoke/codex-dynamic-tool-names.js +80 -0
  104. package/lib/yoke/codex-model-catalog.js +37 -3
  105. package/lib/yoke/muse-services.js +16 -3
  106. package/lib/yoke/providers/claude-model-descriptions.js +22 -2
  107. package/lib/yoke/vendor-registry.js +6 -2
  108. package/package.json +2 -1
  109. package/lib/public/modules/server-settings-navigation.js +0 -39
@@ -1,8 +1,13 @@
1
1
  import { store } from './store.js';
2
2
  import { getWs } from './ws-ref.js';
3
3
  import { showConfirm } from './app-misc.js';
4
+ import { iconHtml, refreshIcons } from './icons.js';
4
5
 
5
6
  var statusGeneration = 0;
7
+ // Whether the review tray is open lives in the store (pairResultTrayOpen), so it
8
+ // survives server re-renders and resets on clear or session switch.
9
+ function isTrayOpen() { return !!store.get("pairResultTrayOpen"); }
10
+ function setTrayOpen(open) { if (isTrayOpen() !== open) store.set({ pairResultTrayOpen: open }); }
6
11
 
7
12
  function retrySource(item) {
8
13
  return { item: item, generation: statusGeneration, projectSlug: store.get("currentSlug") || store.get("activeProjectSlug") || null,
@@ -43,56 +48,100 @@ function dismiss(items) {
43
48
  });
44
49
  }
45
50
 
51
+ function worstState(items) {
52
+ if (items.some(function (item) { return item.state === "blocked"; })) return "blocked";
53
+ if (items.some(function (item) { return item.state === "uncertain"; })) return "uncertain";
54
+ return "pending";
55
+ }
56
+
57
+ function stateWords(state) {
58
+ return state === "blocked" ? "blocked" : (state === "uncertain" ? "delivery uncertain" : "pending");
59
+ }
60
+
61
+ function button(className, label, icon) {
62
+ var node = document.createElement("button");
63
+ node.type = "button"; node.className = className;
64
+ if (label) node.setAttribute("aria-label", label);
65
+ if (icon) node.innerHTML = iconHtml(icon);
66
+ return node;
67
+ }
68
+
69
+ function renderCard(item) {
70
+ var card = document.createElement("div");
71
+ card.className = "pair-result-status-card pair-result-status-" + item.state;
72
+ var title = document.createElement("strong");
73
+ title.textContent = item.state === "blocked" ? "Split Worker result blocked" :
74
+ (item.state === "uncertain" ? (item.hasCompletedResult === false ? "Split Worker recovery uncertain" : "Split Worker result delivery uncertain") : "Split Worker result pending");
75
+ var message = document.createElement("span");
76
+ message.textContent = item.message || "The completed result is retained.";
77
+ card.appendChild(title); card.appendChild(message);
78
+ if (item.hasCompletedResult !== false) {
79
+ var details = document.createElement("details"), summary = document.createElement("summary"), body = document.createElement("pre");
80
+ summary.textContent = "View completed result";
81
+ body.textContent = item.error ? "Error: " + item.error : (item.response || "No text response was recorded.");
82
+ details.appendChild(summary); details.appendChild(body); card.appendChild(details);
83
+ }
84
+ if (item.canRetry) {
85
+ var retryButton = button("pair-result-status-retry", null, null);
86
+ retryButton.textContent = "Retry delivery";
87
+ retryButton.addEventListener("click", retry.bind(null, item));
88
+ card.appendChild(retryButton);
89
+ }
90
+ return card;
91
+ }
92
+
46
93
  function renderPairResultStatus() {
47
94
  var existing = document.getElementById("pair-result-status");
95
+ var focusedToggle = !!(existing && existing.ownerDocument.activeElement && existing.ownerDocument.activeElement.className === "pair-result-status-toggle");
48
96
  if (existing) existing.remove();
49
97
  var items = store.get("pairResultStatus") || [];
50
- if (!items.length) return;
98
+ if (!items.length) { setTrayOpen(false); return; }
51
99
  var messages = document.getElementById("messages");
52
100
  if (!messages || !messages.parentElement) return;
101
+ var trayOpen = isTrayOpen();
102
+ var state = worstState(items);
103
+ var label = items.length === 1 ? "Worker update" : items.length + " Worker updates";
53
104
  var section = document.createElement("section");
54
105
  section.id = "pair-result-status";
55
- section.className = "pair-result-status";
56
- section.setAttribute("role", "status");
57
- var header = document.createElement("div");
58
- header.className = "pair-result-status-header";
59
- var heading = document.createElement("span");
60
- heading.textContent = items.length === 1 ? "Worker update needs attention" : items.length + " Worker updates need attention";
61
- var close = document.createElement("button");
62
- close.type = "button"; close.className = "pair-result-status-dismiss";
63
- close.textContent = "×";
64
- close.setAttribute("aria-label", "Dismiss Worker notices");
106
+ section.className = "pair-result-status pair-result-status-tone-" + state + (trayOpen ? " is-open" : "");
107
+ var bar = document.createElement("div");
108
+ bar.className = "pair-result-status-bar";
109
+ var toggle = button("pair-result-status-toggle", null, null);
110
+ toggle.setAttribute("aria-expanded", trayOpen ? "true" : "false");
111
+ toggle.setAttribute("aria-controls", "pair-result-status-tray");
112
+ toggle.innerHTML = '<span class="pair-result-status-icon" aria-hidden="true">' + iconHtml(state === "pending" ? "clock" : "triangle-alert") + '</span>' +
113
+ '<span class="pair-result-status-label">' + label + '</span>' +
114
+ '<span class="pair-result-status-state">, ' + stateWords(state) + '.</span>' +
115
+ '<span class="pair-result-status-review">Review</span>' +
116
+ '<span class="pair-result-status-chevron" aria-hidden="true">' + iconHtml("chevron-down") + '</span>';
117
+ // One short polite announcement; the details stay in the tray.
118
+ var live = document.createElement("span");
119
+ live.className = "pair-result-status-live"; live.setAttribute("role", "status"); live.textContent = label;
120
+ bar.appendChild(live);
121
+ var close = button("pair-result-status-dismiss", "Dismiss Worker notices", "x");
65
122
  close.title = "Dismiss notices. Retained tasks and results are kept.";
66
123
  close.addEventListener("click", dismiss.bind(null, items));
67
- header.appendChild(heading); header.appendChild(close); section.appendChild(header);
68
- var content = document.createElement("details"), toggle = document.createElement("summary");
69
- content.className = "pair-result-status-details";
70
- toggle.textContent = "Review updates";
71
- content.appendChild(toggle); section.appendChild(content);
72
- for (var i = 0; i < items.length; i++) {
73
- var item = items[i], card = document.createElement("div");
74
- card.className = "pair-result-status-card pair-result-status-" + item.state;
75
- var title = document.createElement("strong");
76
- title.textContent = item.state === "blocked" ? "Split Worker result blocked" :
77
- (item.state === "uncertain" ? (item.hasCompletedResult === false ? "Split Worker recovery uncertain" : "Split Worker result delivery uncertain") : "Split Worker result pending");
78
- var message = document.createElement("span");
79
- message.textContent = item.message || "The completed result is retained.";
80
- card.appendChild(title); card.appendChild(message);
81
- if (item.hasCompletedResult !== false) {
82
- var details = document.createElement("details"), summary = document.createElement("summary"), body = document.createElement("pre");
83
- summary.textContent = "View completed result";
84
- body.textContent = item.error ? "Error: " + item.error : (item.response || "No text response was recorded.");
85
- details.appendChild(summary); details.appendChild(body); card.appendChild(details);
86
- }
87
- if (item.canRetry) {
88
- var button = document.createElement("button");
89
- button.type = "button"; button.className = "pair-result-status-retry"; button.textContent = "Retry delivery";
90
- button.addEventListener("click", retry.bind(null, item));
91
- card.appendChild(button);
92
- }
93
- content.appendChild(card);
94
- }
124
+ bar.appendChild(toggle); bar.appendChild(close); section.appendChild(bar);
125
+ var tray = document.createElement("div");
126
+ tray.id = "pair-result-status-tray"; tray.className = "pair-result-status-tray";
127
+ tray.hidden = !trayOpen;
128
+ for (var i = 0; i < items.length; i++) tray.appendChild(renderCard(items[i]));
129
+ section.appendChild(tray);
130
+ toggle.addEventListener("click", function () {
131
+ trayOpen = !isTrayOpen(); setTrayOpen(trayOpen);
132
+ toggle.setAttribute("aria-expanded", trayOpen ? "true" : "false");
133
+ tray.hidden = !trayOpen;
134
+ section.className = section.className.replace(/ is-open/g, "") + (trayOpen ? " is-open" : "");
135
+ });
136
+ section.addEventListener("keydown", function (event) {
137
+ if (event.key !== "Escape" || !isTrayOpen()) return;
138
+ trayOpen = false; setTrayOpen(false); toggle.setAttribute("aria-expanded", "false"); tray.hidden = true;
139
+ section.className = section.className.replace(/ is-open/g, "");
140
+ if (toggle.focus) toggle.focus();
141
+ });
95
142
  messages.parentElement.insertBefore(section, messages);
143
+ refreshIcons();
144
+ if (focusedToggle && toggle.focus) toggle.focus();
96
145
  }
97
146
 
98
147
  function handlePairResultStatus(msg) {
@@ -105,6 +154,7 @@ function handlePairResultStatus(msg) {
105
154
 
106
155
  function clearPairResultStatus() {
107
156
  statusGeneration++;
157
+ setTrayOpen(false);
108
158
  store.set({ pairResultStatus: [] });
109
159
  renderPairResultStatus();
110
160
  }
@@ -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
+ }
@@ -135,9 +135,12 @@ export function uploadAvatarBlob(blob) {
135
135
  }
136
136
 
137
137
  // --- Avatar position picker ---
138
- export function showAvatarPositioner(img, objectUrl, onDone) {
138
+ // With a host element the editor mounts inline as a settings subview instead
139
+ // of a page-level overlay.
140
+ export function showAvatarPositioner(img, objectUrl, onDone, host) {
139
141
  var outputSize = 256;
140
142
  var viewSize = 220;
143
+ var priorFocus = document.activeElement;
141
144
 
142
145
  // State
143
146
  var scale = 1;
@@ -169,7 +172,7 @@ export function showAvatarPositioner(img, objectUrl, onDone) {
169
172
 
170
173
  // Build overlay
171
174
  var overlay = document.createElement('div');
172
- overlay.className = 'avatar-positioner-overlay';
175
+ overlay.className = host ? 'avatar-positioner-inline settings-subview' : 'avatar-positioner-overlay';
173
176
 
174
177
  var container = document.createElement('div');
175
178
  container.className = 'avatar-positioner-container';
@@ -225,8 +228,10 @@ export function showAvatarPositioner(img, objectUrl, onDone) {
225
228
  container.appendChild(btnRow);
226
229
 
227
230
  overlay.appendChild(container);
228
- document.body.appendChild(overlay);
231
+ if (host) { host.classList.add('has-subview'); host.appendChild(overlay); overlay.setAttribute('role', 'region'); overlay.setAttribute('aria-label', 'Position your avatar'); }
232
+ else document.body.appendChild(overlay);
229
233
  updatePreview();
234
+ if (host) doneFocus();
230
235
 
231
236
  // Drag
232
237
  var dragging = false;
@@ -288,9 +293,12 @@ export function showAvatarPositioner(img, objectUrl, onDone) {
288
293
  updatePreview();
289
294
  }, { passive: false });
290
295
 
296
+ function doneFocus() { closeBtn.focus(); }
297
+
291
298
  function cleanup(revoke) {
292
299
  document.removeEventListener('keydown', onOverlayKeydown);
293
300
  overlay.remove();
301
+ if (host) { host.classList.remove('has-subview'); if (priorFocus && priorFocus.isConnected) priorFocus.focus(); }
294
302
  if (revoke) URL.revokeObjectURL(objectUrl);
295
303
  }
296
304
 
@@ -81,6 +81,9 @@ function startMateCreation() {
81
81
  maybeStartPendingCreation();
82
82
  }
83
83
 
84
+ // Entry for the zero-project welcome: the same Clay-guided flow the add button's Mate choice runs.
85
+ export function startClayMateCreation() { startMateCreation(); }
86
+
84
87
  function maybeStartPendingCreation() {
85
88
  var state = store.snap();
86
89
  if (!state.projectMateCreationPendingStart || !state.connected || state.activeProjectSlug !== 'mate-' + state.projectMateCreationMateId) return;
@@ -89,11 +92,17 @@ function maybeStartPendingCreation() {
89
92
  store.set({ projectMateCreationPendingStart: false, projectMateCreationAwaitingHistory: true, projectMateCreationRequestId: requestId });
90
93
  }
91
94
 
92
- function choiceButton(icon, title, description) {
95
+ function choiceButton(artwork, title, description) {
93
96
  var button = document.createElement('button');
94
97
  button.type = 'button';
95
98
  button.className = 'project-create-choice-option';
96
- button.innerHTML = iconHtml(icon);
99
+ var image = document.createElement('img');
100
+ image.src = '/welcome-art/' + artwork + '.png';
101
+ image.alt = '';
102
+ image.width = 1536;
103
+ image.height = 1024;
104
+ image.decoding = 'async';
105
+ button.appendChild(image);
97
106
  var copy = document.createElement('span');
98
107
  var heading = document.createElement('strong');
99
108
  heading.textContent = title;
@@ -121,9 +130,9 @@ export function openProjectCreateChoice(opener, openProject) {
121
130
  var title = document.createElement('h2');
122
131
  title.id = 'project-create-choice-title';
123
132
  title.textContent = 'What would you like to add?';
124
- var mate = choiceButton('users', 'Mate', 'Shape a new collaborator with Clay');
133
+ var mate = choiceButton('mate-clay', 'Create a Mate', 'A specialist to work alongside you.');
125
134
  mate.addEventListener('click', startMateCreation);
126
- var project = choiceButton('folder-plus', 'Project', 'Add a local workspace');
135
+ var project = choiceButton('project-clay', 'Start a project', 'Work on code or files together.');
127
136
  project.addEventListener('click', function () {
128
137
  closeChoice(false);
129
138
  openProject();
@@ -27,12 +27,10 @@ function setSelectState(select, value, disabled) {
27
27
  select.disabled = !!disabled;
28
28
  }
29
29
 
30
- function isCurrentProject(active) {
31
- return !!(active && active.slug && active.slug === store.get('currentSlug'));
32
- }
33
-
30
+ // Editable for any authorised selected project: the server re-authorises the
31
+ // exact slug (owner, permission, access, worktree) and echoes it back.
34
32
  function isReadOnly(active) {
35
- return !active || !active.authorized || active.isWorktree || !isCurrentProject(active);
33
+ return !active || !active.authorized || active.isWorktree;
36
34
  }
37
35
 
38
36
  function renderActiveState() {
@@ -40,23 +38,26 @@ function renderActiveState() {
40
38
  var pending = pendingState();
41
39
  var select = selectElement();
42
40
  var inheritedHint = document.getElementById("ps-session-visibility-default-inherited-hint");
43
- var nonCurrentHint = document.getElementById("ps-session-visibility-default-non-current-hint");
44
41
  if (inheritedHint) inheritedHint.classList.toggle("hidden", !(active && active.isWorktree));
45
- if (nonCurrentHint) nonCurrentHint.classList.toggle("hidden", !(active && active.authorized && !active.isWorktree && !isCurrentProject(active)));
46
42
  if (!select || !active) return;
47
- setSelectState(select, active.effectiveValue, isReadOnly(active) || !!(pending && pending.slug === active.slug));
43
+ // One request at a time: a pending change for any project holds the control.
44
+ setSelectState(select, active.effectiveValue, isReadOnly(active) || !!pending);
48
45
  }
49
46
 
50
- function isAuthorized(project) {
47
+ // The server requires the project owner plus the projectSettings permission.
48
+ // An administrator who is not the owner may see the value but not change it.
49
+ function visibilityAccess(project) {
51
50
  var users = store.get('cachedAllUsers') || [];
52
51
  var userId = store.get('myUserId');
53
52
  var currentUser = null;
54
53
  for (var i = 0; i < users.length; i++) {
55
54
  if (users[i].id === userId) { currentUser = users[i]; break; }
56
55
  }
57
- return !!(store.get('isMultiUserMode') && project &&
58
- ((project.projectOwnerId && project.projectOwnerId === userId) || (currentUser && currentUser.role === "admin")) &&
59
- (!store.get('permissions') || store.get('permissions').projectSettings !== false));
56
+ var permitted = !store.get('permissions') || store.get('permissions').projectSettings !== false;
57
+ var owner = !!(project && project.projectOwnerId && project.projectOwnerId === userId);
58
+ var admin = !!(currentUser && currentUser.role === "admin");
59
+ var multi = !!(store.get('isMultiUserMode') && project && permitted);
60
+ return { visible: multi && (owner || admin), editable: multi && owner };
60
61
  }
61
62
 
62
63
  export function initProjectSessionVisibilitySettings() {
@@ -80,7 +81,6 @@ export function initProjectSessionVisibilitySettings() {
80
81
  });
81
82
 
82
83
  store.subscribe(function (state, previous) {
83
- if (state.currentSlug !== previous.currentSlug) renderActiveState();
84
84
  if (state.connected || previous.connected !== true) return;
85
85
  var pending = pendingState();
86
86
  if (!pending) return;
@@ -95,10 +95,13 @@ export function initProjectSessionVisibilitySettings() {
95
95
  export function populateProjectSessionVisibilitySettings(slug, project) {
96
96
  var field = document.getElementById("ps-session-visibility-default-field");
97
97
  var select = selectElement();
98
- if (field) field.style.display = isAuthorized(project) ? "" : "none";
98
+ var access = visibilityAccess(project);
99
+ if (field) field.style.display = access.visible ? "" : "none";
100
+ var ownerHint = document.getElementById("ps-session-visibility-default-owner-hint");
101
+ if (ownerHint) ownerHint.classList.toggle("hidden", !(access.visible && !access.editable));
99
102
  if (!select) return;
100
103
  var value = project && project.sessionVisibilityDefault === "shared" ? "shared" : "private";
101
- var active = { slug: slug || "", effectiveValue: value, isWorktree: !!(project && project.isWorktree), authorized: isAuthorized(project) };
104
+ var active = { slug: slug || "", effectiveValue: value, isWorktree: !!(project && project.isWorktree), authorized: access.editable };
102
105
  var pending = pendingState();
103
106
  setVisibilityState(active, pending);
104
107
  renderActiveState();
@@ -124,7 +127,7 @@ export function handleProjectSessionVisibilityDefault(msg) {
124
127
  var active = activeState();
125
128
  setVisibilityState(active, null);
126
129
  var select = selectElement();
127
- if (!select || !active || active.slug !== msg.slug) return;
130
+ if (!select || !active || active.slug !== msg.slug) { renderActiveState(); return; }
128
131
  if (!msg.ok) {
129
132
  active = Object.assign({}, active, { effectiveValue: pending.previous });
130
133
  setVisibilityState(active, null);
@@ -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();