clay-server 5.1.1-beta.1 → 5.2.0-beta.2

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 (61) hide show
  1. package/lib/account-auth-policy.js +21 -0
  2. package/lib/account-auth-socket.js +29 -0
  3. package/lib/account-auth-throttle.js +44 -0
  4. package/lib/account-auth-transport.js +48 -0
  5. package/lib/account-login-page.js +28 -0
  6. package/lib/account-passkey-challenges.js +31 -0
  7. package/lib/account-passkey-page.js +89 -0
  8. package/lib/account-passkey-policy.js +39 -0
  9. package/lib/account-password-page.js +69 -0
  10. package/lib/account-passwords.js +36 -0
  11. package/lib/account-remembered.js +77 -0
  12. package/lib/account-session-page.js +51 -0
  13. package/lib/account-session-store.js +82 -0
  14. package/lib/daemon.js +2 -0
  15. package/lib/migrate-single-user.js +3 -23
  16. package/lib/project-connection.js +1 -1
  17. package/lib/project-server-settings.js +1 -0
  18. package/lib/project-sessions.js +3 -1
  19. package/lib/project-user-knowledge.js +2 -1
  20. package/lib/public/app.js +2 -0
  21. package/lib/public/auth/account-login.css +98 -0
  22. package/lib/public/auth/account-login.js +104 -0
  23. package/lib/public/css/command-palette.css +12 -33
  24. package/lib/public/css/user-settings-sessions.css +42 -0
  25. package/lib/public/index.html +17 -5
  26. package/lib/public/modules/account-auth-settings.js +71 -0
  27. package/lib/public/modules/account-transport-settings.js +51 -0
  28. package/lib/public/modules/app-header.js +3 -2
  29. package/lib/public/modules/app-messages.js +16 -4
  30. package/lib/public/modules/app-rendering.js +4 -3
  31. package/lib/public/modules/chat-render-runtime.js +8 -0
  32. package/lib/public/modules/clay-log-links.js +23 -0
  33. package/lib/public/modules/command-palette.js +94 -5
  34. package/lib/public/modules/markdown.js +1 -1
  35. package/lib/public/modules/passkey-browser.js +69 -0
  36. package/lib/public/modules/passkey-server-settings.js +57 -0
  37. package/lib/public/modules/project-logs-render.js +3 -2
  38. package/lib/public/modules/server-settings-controls.js +10 -0
  39. package/lib/public/modules/server-settings.js +7 -2
  40. package/lib/public/modules/user-settings-account.js +14 -1
  41. package/lib/public/modules/user-settings-sessions.js +120 -0
  42. package/lib/public/modules/user-settings.js +3 -0
  43. package/lib/public/modules/you-activity.js +3 -1
  44. package/lib/public/modules/you.js +91 -6
  45. package/lib/public/style.css +1 -0
  46. package/lib/server-account-passkeys.js +186 -0
  47. package/lib/server-account-passwords.js +127 -0
  48. package/lib/server-account-security.js +53 -0
  49. package/lib/server-auth.js +107 -79
  50. package/lib/server-home-chat.js +3 -3
  51. package/lib/server-home-clay-session-links.js +17 -9
  52. package/lib/server-settings.js +2 -1
  53. package/lib/server.js +3 -1
  54. package/lib/sessions.js +3 -1
  55. package/lib/user-knowledge-service.js +23 -1
  56. package/lib/users-auth.js +38 -3
  57. package/lib/users-create.js +97 -0
  58. package/lib/users-server-pin.js +37 -0
  59. package/lib/users.js +11 -84
  60. package/lib/workspace-query-service.js +13 -2
  61. package/package.json +2 -1
@@ -0,0 +1,71 @@
1
+ import { store } from './store.js';
2
+ import { enhanceSettingsSelects } from './settings-select.js';
3
+ import { confirmSettingsAction, setSettingsDraft, clearSettingsDraft } from './server-settings-state.js';
4
+ import { initPasskeyServerSettings, loadPasskeyServerSettings } from './passkey-server-settings.js';
5
+ import { initAccountTransportSettings, loadAccountTransportSettings } from './account-transport-settings.js';
6
+
7
+ function render(state) {
8
+ var section = document.getElementById('account-auth-policy');
9
+ if (!section) return;
10
+ var select = section.querySelector('select');
11
+ var save = section.querySelector('[data-policy-save]');
12
+ section.hidden = state.available === false;
13
+ select.value = state.draft || state.method || 'pin';
14
+ select.disabled = !!state.pending || state.method === 'password' || !state.method;
15
+ select.dispatchEvent(new Event('settings-sync'));
16
+ save.hidden = !state.method || state.method === select.value;
17
+ save.disabled = !!state.pending;
18
+ section.querySelector('[role=status]').textContent = state.error || (state.pending ? 'Saving…' : state.method === 'password' ? 'Password required. Existing account PINs only authorize password setup.' : 'Existing users verify their account PIN once, then set a password before continuing.');
19
+ }
20
+
21
+ export async function loadAccountAuthSettings() {
22
+ loadPasskeyServerSettings();
23
+ var section = document.getElementById('account-auth-policy');
24
+ if (!section) return;
25
+ if ((store.get('accountAuthPolicy') || {}).draft) return;
26
+ try {
27
+ var response = await fetch('/api/account-auth-policy');
28
+ var data = await response.json();
29
+ if (!response.ok) throw new Error(data.error || 'Could not load account policy.');
30
+ store.set({ accountAuthPolicy: data }); render(data); loadAccountTransportSettings();
31
+ } catch (error) {
32
+ var state = Object.assign({}, store.get('accountAuthPolicy'), { error: error.message });
33
+ store.set({ accountAuthPolicy: state }); render(state);
34
+ }
35
+ }
36
+
37
+ export function initAccountAuthSettings() {
38
+ var page = document.querySelector('.server-settings-section[data-section="security"]');
39
+ if (!page || document.getElementById('account-auth-policy')) return;
40
+ var section = document.createElement('section'); section.id = 'account-auth-policy';
41
+ section.className = 'settings-card';
42
+ section.innerHTML = '<h3>Account sign-in</h3><div class="settings-field-row"><div><label class="settings-label" for="account-auth-method">Authentication method</label><p class="settings-hint">Applies to all accounts on this server. Users manage their own credentials in Personal settings.</p></div><select class="settings-select" id="account-auth-method" disabled><option value="pin">Account PIN</option><option value="password">Password / passphrase</option></select></div><p class="settings-hint" role="status">Loading account policy…</p><div class="settings-form-actions"><button class="settings-btn-sm" data-policy-save hidden>Apply password policy</button></div>';
43
+ var description = page.querySelector('.settings-page-description');
44
+ description.textContent = 'Sign-in policies and connection security for this server. Manage your own password, passkeys and devices in Personal settings → Account.';
45
+ description.after(section);
46
+ var select = section.querySelector('select');
47
+ select.onchange = function () {
48
+ var state = Object.assign({}, store.get('accountAuthPolicy'), { draft: select.value, error: '' });
49
+ if (state.draft === state.method) { delete state.draft; clearSettingsDraft('accountAuth'); }
50
+ else setSettingsDraft('accountAuth', true);
51
+ store.set({ accountAuthPolicy: state }); render(state);
52
+ };
53
+ section.querySelector('[data-policy-save]').onclick = function () {
54
+ confirmSettingsAction('Require passwords for account users? Everyone using an account, including you, must verify their account PIN and set a password before continuing. Existing PINs remain valid for setup until each password is saved. This change does not alter solo access or the Server PIN.', 'Require passwords', async function () {
55
+ var state = Object.assign({}, store.get('accountAuthPolicy'), { pending: true, error: '' });
56
+ store.set({ accountAuthPolicy: state }); render(state);
57
+ try {
58
+ var response = await fetch('/api/account-auth-policy', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'password' }) });
59
+ var data = await response.json();
60
+ if (!response.ok || !data.ok) throw new Error(data.error || 'Could not save the policy.');
61
+ clearSettingsDraft('accountAuth'); window.location.reload();
62
+ } catch (error) {
63
+ state.pending = false; state.error = error.message;
64
+ store.set({ accountAuthPolicy: state }); render(state);
65
+ }
66
+ });
67
+ };
68
+ enhanceSettingsSelects(section);
69
+ initPasskeyServerSettings();
70
+ initAccountTransportSettings();
71
+ }
@@ -0,0 +1,51 @@
1
+ import { store } from './store.js';
2
+ import { setSettingsDraft, clearSettingsDraft } from './server-settings-state.js';
3
+ function render(state) {
4
+ var section = document.getElementById('account-transport'); if (!section) return;
5
+ section.hidden = !state.available;
6
+ if (!state.dirty) {
7
+ section.querySelector('[name=origin]').value = state.origin || '';
8
+ section.querySelector('[name=proxies]').value = (state.trustedProxies || []).join('\n');
9
+ }
10
+ section.querySelectorAll('input,textarea,button').forEach(function (element) { element.disabled = !!state.pending; });
11
+ section.querySelector('[role=status]').textContent = state.error || (state.pending ? 'Saving…' : state.origin ? 'Saved proxy HTTPS address: ' + state.origin : 'Direct HTTPS or localhost access. No proxy headers are trusted.');
12
+ }
13
+ export async function loadAccountTransportSettings() {
14
+ if ((store.get('accountTransport') || {}).dirty) return;
15
+ try {
16
+ var response = await fetch('/api/account-transport'); var data = await response.json();
17
+ if (!response.ok) throw new Error(data.error || 'Could not load connection settings.');
18
+ var state = Object.assign({}, data, { available: (store.get('accountAuthPolicy') || {}).method === 'password' });
19
+ store.set({ accountTransport: state }); render(state);
20
+ } catch (error) {
21
+ var state = Object.assign({}, store.get('accountTransport'), { error: error.message });
22
+ store.set({ accountTransport: state }); render(state);
23
+ }
24
+ }
25
+ export function initAccountTransportSettings() {
26
+ var anchor = document.getElementById('passkey-policy'); if (!anchor || document.getElementById('account-transport')) return;
27
+ var section = document.createElement('section'); section.id = 'account-transport'; section.className = 'settings-card'; section.hidden = true;
28
+ section.innerHTML = '<h3>HTTPS &amp; trusted proxies</h3><p class="settings-hint">Connection security for account users on this server. Account passwords require HTTPS for remote access. Localhost is available for setup and recovery.</p>' +
29
+ '<form><label class="settings-label" for="account-external-origin">Proxy HTTPS address</label><input class="us-text-input" id="account-external-origin" name="origin" type="url" placeholder="https://clay.example.com">' +
30
+ '<label class="settings-label" for="account-trusted-proxies">Trusted proxy IP addresses</label><textarea class="settings-network-origins" id="account-trusted-proxies" name="proxies" rows="2" placeholder="One exact IP address per line"></textarea>' +
31
+ '<p class="settings-hint">Leave empty for direct HTTPS. Each trusted proxy must overwrite X-Forwarded-Proto and X-Real-IP, prevent direct public access to the backend, and serve this HTTPS address. Forwarded host headers are ignored.</p>' +
32
+ '<label class="settings-label" for="account-transport-password">Administrator password</label><input class="us-text-input" id="account-transport-password" name="password" type="password" autocomplete="current-password" required>' +
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
+ anchor.after(section);
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);
38
+ }; });
39
+ section.querySelector('form').onsubmit = async function (event) {
40
+ event.preventDefault(); var password = section.querySelector('[name=password]');
41
+ var state = Object.assign({}, store.get('accountTransport'), { pending: true, error: '' }); store.set({ accountTransport: state }); render(state);
42
+ try {
43
+ var response = await fetch('/api/account-transport', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
44
+ origin: section.querySelector('[name=origin]').value.trim(), trustedProxies: section.querySelector('[name=proxies]').value.split(/\s+/).filter(Boolean), currentPassword: password.value,
45
+ }) });
46
+ var data = await response.json(); if (!response.ok) throw new Error(data.error || 'Could not save connection settings.');
47
+ state = Object.assign({}, data, { available: true }); clearSettingsDraft('accountTransport');
48
+ } catch (error) { state.error = error.message; state.pending = false; }
49
+ finally { password.value = ''; store.set({ accountTransport: state }); render(state); }
50
+ };
51
+ }
@@ -350,6 +350,7 @@ export function prependOlderHistory(items, meta) {
350
350
 
351
351
  // Save current rendering state
352
352
  var savedMsgEl = store.get('currentMsgEl');
353
+ var savedReplayingHistory = store.get('replayingHistory');
353
354
  var savedActivity = getActivityEl();
354
355
  var savedFullText = store.get('currentFullText');
355
356
  var savedTurnCounter = getTurnCounter();
@@ -359,7 +360,7 @@ export function prependOlderHistory(items, meta) {
359
360
  var savedUsage = JSON.parse(JSON.stringify(getSessionUsage()));
360
361
 
361
362
  // Reset to initial values for clean rendering
362
- store.set({ currentMsgEl: null, currentFullText: "" });
363
+ store.set({ currentMsgEl: null, currentFullText: "", replayingHistory: true });
363
364
  setActivityEl(null);
364
365
  setTurnCounter(0);
365
366
  resetToolState();
@@ -387,7 +388,7 @@ export function prependOlderHistory(items, meta) {
387
388
  setPrependAnchor(null);
388
389
 
389
390
  // Restore saved state
390
- store.set({ currentMsgEl: savedMsgEl, currentFullText: savedFullText });
391
+ store.set({ currentMsgEl: savedMsgEl, currentFullText: savedFullText, replayingHistory: savedReplayingHistory });
391
392
  setActivityEl(savedActivity);
392
393
  setTurnCounter(savedTurnCounter);
393
394
  restoreToolState(savedToolsState);
@@ -40,7 +40,7 @@ import { handleHomeMateMemoryState, handleHomeMateKnowledgeState, syncHomeMateSe
40
40
  import { renderKnowledgeList, handleKnowledgeContent, handleKnowledgeMutation } from './mate-knowledge.js';
41
41
  import { handleKnowledgeWorkbenchMessage } from './mate-knowledge-workbench.js';
42
42
  import { renderMemoryList } from './mate-memory.js';
43
- import { handlePaletteSessionSwitch, setPaletteVersion } from './command-palette.js';
43
+ import { handlePaletteSessionSwitch, handlePaletteHistoryDone, handlePaletteHistoryPrepended, setPaletteVersion } from './command-palette.js';
44
44
  import { handleFindInSessionResults } from './session-search.js';
45
45
  import { syncPaneTitles, maybeRestoreSplitGroup, openGroup } from './split-view.js';
46
46
  import { showPairDialog, handlePairCreated, handleSplitDelegation, showWorkerDelegationNotice, hideWorkerDelegationNotice } from './split-pair-ui.js';
@@ -224,6 +224,7 @@ export function processMessage(msg) {
224
224
  }
225
225
  }
226
226
 
227
+ store.set({ currentHistoryIndex: Number.isSafeInteger(msg._historyIndex) && msg._historyIndex >= 0 ? msg._historyIndex : null });
227
228
  switch (msg.type) {
228
229
  case "message_ack":
229
230
  var acknowledgedEntry = acknowledgeMessage(msg.clientMessageId);
@@ -250,6 +251,7 @@ export function processMessage(msg) {
250
251
 
251
252
  case "history_prepend":
252
253
  prependOlderHistory(msg.items, msg.meta);
254
+ handlePaletteHistoryPrepended();
253
255
  break;
254
256
 
255
257
  case "history_done":
@@ -311,11 +313,16 @@ export function processMessage(msg) {
311
313
  // re-pins on every height change while armed. Disarms early on
312
314
  // any real user scroll input.
313
315
  // Skip arming when we have a pending in-conversation navigate
314
- // target (file-edit deeplink) — the navigate block below scrolls
315
- // that element into view, and sticky-bottom would fight it.
316
+ // target (file-edit or Session-entry deeplink) — the navigate block
317
+ // below scrolls that element into view, and sticky-bottom would
318
+ // fight it.
316
319
  var nav = getPendingNavigate();
317
320
  var hasNavTarget = nav && (nav.toolId || nav.assistantUuid);
318
- if (hasNavTarget) {
321
+ var sessionEntryNav = store.get('pendingSessionEntryNavigation');
322
+ var hasSessionEntryNav = sessionEntryNav
323
+ && String(sessionEntryNav.projectSlug) === String(store.get('currentSlug'))
324
+ && String(sessionEntryNav.sessionId) === String(store.get('activeSessionId'));
325
+ if (hasNavTarget || hasSessionEntryNav) {
319
326
  // Navigate block below will scrollIntoView on the target — don't
320
327
  // arm sticky-bottom or it would fight that scroll.
321
328
  scrollToBottom();
@@ -343,6 +350,7 @@ export function processMessage(msg) {
343
350
  }
344
351
  });
345
352
  }
353
+ requestAnimationFrame(function () { handlePaletteHistoryDone(); });
346
354
  break;
347
355
 
348
356
  case "restore_mate_dm":
@@ -1483,6 +1491,10 @@ export function processMessage(msg) {
1483
1491
  addContextOverflowMessage(msg);
1484
1492
  break;
1485
1493
 
1494
+ case "account_auth_required":
1495
+ window.location.reload();
1496
+ break;
1497
+
1486
1498
  case "auth_required":
1487
1499
  removeMatePreThinking();
1488
1500
  setActivity(null);
@@ -8,10 +8,10 @@ import { refreshIcons } from './icons.js';
8
8
  import { closeToolGroup } from './tools.js';
9
9
  import { sendMessage, hasSendableContent } from './input.js';
10
10
  import { getChatLayout } from './theme.js';
11
- import { addToMessages, scrollToBottom, forceScrollToBottom, getMsgTime, shouldGroupMessage, getTurnCounter, getActivityEl } from './chat-render-runtime.js';
11
+ import { addToMessages, addHistoryIndex, scrollToBottom, forceScrollToBottom, getMsgTime, shouldGroupMessage, getTurnCounter, getActivityEl } from './chat-render-runtime.js';
12
12
  import { resolveProjectAssistantIdentity, applyProjectAssistantIdentity } from './project-chat-identity.js';
13
13
  export { VENDOR_ORDER, EXPERIMENTAL_VENDORS, isExperimentalVendor } from './vendor-priority.js';
14
- export { initRendering, addToMessages, scrollToBottom, forceScrollToBottom, getMsgTime, shouldGroupMessage, getTurnCounter, setTurnCounter, getPrependAnchor, setPrependAnchor, getActivityEl, setActivityEl, getIsUserScrolledUp, setIsUserScrolledUp, getStickyBottom, armStickyBottom, disarmStickyBottom, VENDOR_AVATARS, VENDOR_NAMES, VENDOR_HOMEPAGES } from './chat-render-runtime.js';
14
+ export { initRendering, addToMessages, addHistoryIndex, scrollToBottom, forceScrollToBottom, getMsgTime, shouldGroupMessage, getTurnCounter, setTurnCounter, getPrependAnchor, setPrependAnchor, getActivityEl, setActivityEl, getIsUserScrolledUp, setIsUserScrolledUp, getStickyBottom, armStickyBottom, disarmStickyBottom, VENDOR_AVATARS, VENDOR_NAMES, VENDOR_HOMEPAGES } from './chat-render-runtime.js';
15
15
  export { addUserMessage, addSystemMessage, addConflictMessage, addContextOverflowMessage } from './app-message-cards.js';
16
16
 
17
17
  // --- Module-owned state (not in store) ---
@@ -44,7 +44,8 @@ export function addCopyHandler(msgEl, rawText) {
44
44
  }
45
45
 
46
46
  export function appendDelta(text) {
47
- ensureAssistantBlock();
47
+ var assistantEl = ensureAssistantBlock();
48
+ if (store.get('replayingHistory')) addHistoryIndex(assistantEl, store.get('currentHistoryIndex'));
48
49
  streamBuffer += text;
49
50
  if (store.get('replayingHistory')) {
50
51
  store.set({ currentFullText: store.get('currentFullText') + streamBuffer });
@@ -155,11 +155,19 @@ export function setIsUserScrolledUp(value) { isUserScrolledUp = value; }
155
155
 
156
156
  export function addToMessages(element) {
157
157
  var messagesEl = getMessagesEl();
158
+ addHistoryIndex(element, store.get('currentHistoryIndex'));
158
159
  if (prependAnchor) messagesEl.insertBefore(element, prependAnchor);
159
160
  else messagesEl.appendChild(element);
160
161
  keepActiveThinkingAtTail();
161
162
  }
162
163
 
164
+ export function addHistoryIndex(element, historyIndex) {
165
+ if (!element || !Number.isSafeInteger(historyIndex) || historyIndex < 0) return;
166
+ var existing = element.dataset.historyIndexes || "";
167
+ if (existing.split(",").indexOf(String(historyIndex)) !== -1) return;
168
+ element.dataset.historyIndexes = existing ? existing + "," + historyIndex : String(historyIndex);
169
+ }
170
+
163
171
  // Thinking is a single accumulating record for the active turn. Other live
164
172
  // transcript entries may arrive between reasoning segments, so keep only the
165
173
  // currently active record after the tail activity indicator.
@@ -1,10 +1,33 @@
1
1
  import { enhanceClayIssueLinks, isExactClayIssueReference } from './clay-issue-links.js';
2
2
  import { enhanceGithubLinks } from './github-links.js';
3
+ import { escapeHtml } from './utils.js';
3
4
 
4
5
  export function renderClayRecordLink(href, text) {
6
+ if (isExactClaySessionReference(href)) return renderClaySessionLink(href.trim(), text);
5
7
  if (!isExactClayLogReference(href) && !isExactClayIssueReference(href)) return null;
6
8
  return '<a href="#" data-clay-record-ref="' + href.trim() + '">' + text + '</a>';
7
9
  }
10
+
11
+ export function isExactClaySessionReference(text) {
12
+ return parseClaySessionReference(text) !== null;
13
+ }
14
+
15
+ export function renderClaySessionLink(ref, text) {
16
+ var parsed = parseClaySessionReference(ref);
17
+ if (!parsed) return null;
18
+ var label = text ? String(text).trim() : "";
19
+ var safeLabel = escapeHtml(label);
20
+ var entryAttr = parsed.entryIndex === null ? "" : ' data-session-entry-index="' + parsed.entryIndex + '"';
21
+ return '<button type="button" class="clayos-session-link clayos-record-link" data-session-ref="' + parsed.ref + '"' + entryAttr + ' aria-label="Open session' + (safeLabel ? " from " + safeLabel : "") + '"><span>Session</span>' + (safeLabel ? '<small>' + safeLabel + '</small>' : '') + '</button>';
22
+ }
23
+
24
+ export function parseClaySessionReference(text) {
25
+ var match = /^((?:session:[A-Za-z0-9_-]{24}))(?:#entry-([0-9]+))?$/.exec((text || "").trim());
26
+ if (!match) return null;
27
+ var entryIndex = match[2] === undefined ? null : Number(match[2]);
28
+ if (entryIndex !== null && (!Number.isSafeInteger(entryIndex) || entryIndex < 0)) return null;
29
+ return { ref: match[1], entryIndex: entryIndex };
30
+ }
8
31
  export function parseClayLogReferences(text) {
9
32
  var pattern = /\[clayos\/(log:[A-Za-z0-9_-]{24})(?:\s+[\u2014-]\s+([^\]\n]{1,80}))?\]|(^|[^A-Za-z0-9_/:])(log:[A-Za-z0-9_-]{24})(?![A-Za-z0-9_-])/g;
10
33
  var results = [];
@@ -5,6 +5,8 @@ import { openSearch as openSessionSearch } from './session-search.js';
5
5
  import { startSearchClayChat, attachSearchClayChat, detachSearchClayChat, getSearchClayChatSummary } from './search-clay-chat.js';
6
6
  import { getWs } from './ws-ref.js';
7
7
  import { openProjectLog } from './project-logs.js';
8
+ import { store } from './store.js';
9
+ import { disarmStickyBottom } from './chat-render-runtime.js';
8
10
 
9
11
  function formatRelativeDate(ts) {
10
12
  if (!ts) return "";
@@ -70,6 +72,10 @@ function handleClaySessionLinkClick(event) {
70
72
  var requestId = "clayos-" + (isLog ? "log-" : "session-") + Date.now() + "-" + Math.random().toString(36).slice(2, 9);
71
73
  if (isLog) link.dataset.logRequest = requestId;
72
74
  else link.dataset.sessionRequest = requestId;
75
+ if (!isLog) store.set({ sessionNavigationRequestId: requestId, sessionNavigationRequestContext: {
76
+ projectSlug: store.get('currentSlug'),
77
+ sessionId: store.get('activeSessionId')
78
+ } });
73
79
  link.classList.remove("is-error");
74
80
  link.classList.add("is-loading");
75
81
  var request = { requestId: requestId, surface: link.closest(".search-clay-chat") ? "search" : "home" };
@@ -79,6 +85,7 @@ function handleClaySessionLinkClick(event) {
79
85
  } else {
80
86
  request.type = "home_clay_session_resolve";
81
87
  request.sessionRef = link.dataset.sessionRef;
88
+ if (link.dataset.sessionEntryIndex !== undefined) request.entryIndex = Number(link.dataset.sessionEntryIndex);
82
89
  }
83
90
  ws.send(JSON.stringify(request));
84
91
  }
@@ -91,12 +98,31 @@ export function handleClaySessionTarget(msg) {
91
98
  for (var i = 0; i < links.length; i++) {
92
99
  if ((isLog ? links[i].dataset.logRequest : links[i].dataset.sessionRequest) === msg.requestId) link = links[i];
93
100
  }
101
+ if (!isLog && store.get('sessionNavigationRequestId') !== msg.requestId) {
102
+ if (link) {
103
+ link.classList.remove("is-loading");
104
+ delete link.dataset.sessionRequest;
105
+ }
106
+ return true;
107
+ }
108
+ if (!isLog) {
109
+ var requestContext = store.get('sessionNavigationRequestContext');
110
+ if (requestContext && (String(store.get('currentSlug')) !== String(requestContext.projectSlug) || String(store.get('activeSessionId')) !== String(requestContext.sessionId))) {
111
+ if (link) {
112
+ link.classList.remove("is-loading");
113
+ delete link.dataset.sessionRequest;
114
+ }
115
+ store.set({ pendingSessionEntryNavigation: null, sessionNavigationRequestContext: null });
116
+ return true;
117
+ }
118
+ }
94
119
  if (link) {
95
120
  link.classList.remove("is-loading");
96
121
  if (isLog) delete link.dataset.logRequest;
97
122
  else delete link.dataset.sessionRequest;
98
123
  }
99
124
  if (msg.status !== "ready" || !msg.target) {
125
+ if (!isLog) store.set({ pendingSessionEntryNavigation: null });
100
126
  if (link) {
101
127
  link.classList.add("is-error");
102
128
  link.title = msg.error || "Item not available";
@@ -108,7 +134,7 @@ export function handleClaySessionTarget(msg) {
108
134
  navigateToLog(msg.target);
109
135
  return true;
110
136
  }
111
- navigateToSession({ projectSlug: msg.target.projectSlug, sessionId: msg.target.sessionId }, null);
137
+ navigateToSession({ projectSlug: msg.target.projectSlug, sessionId: msg.target.sessionId }, null, msg.target.entryIndex);
112
138
  return true;
113
139
  }
114
140
 
@@ -151,9 +177,61 @@ export function handlePaletteSessionSwitch() {
151
177
  var nav = pendingNav;
152
178
  pendingNav = null;
153
179
  if (ctx.currentSlug && ctx.currentSlug() === nav.slug) {
154
- ctx.selectSession(nav.sessionId);
155
- if (nav.query) setTimeout(function () { openSessionSearch(nav.query); }, 400);
180
+ navigateToSession({ projectSlug: nav.slug, sessionId: nav.sessionId }, nav.query, nav.entryIndex);
181
+ }
182
+ }
183
+
184
+ export function handlePaletteHistoryDone() {
185
+ var pending = store.get('pendingSessionEntryNavigation');
186
+ if (!pending) return;
187
+ if (!isActiveEntryNavigation(pending)) {
188
+ store.set({ pendingSessionEntryNavigation: null });
189
+ return;
190
+ }
191
+ var historyFrom = store.get('historyFrom') || 0;
192
+ if (pending.entryIndex < historyFrom) {
193
+ var ws = getWs();
194
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "load_more_history", before: historyFrom, target: pending.entryIndex }));
195
+ return;
156
196
  }
197
+ jumpToSessionEntry(pending.entryIndex);
198
+ }
199
+
200
+ export function handlePaletteHistoryPrepended() {
201
+ var pending = store.get('pendingSessionEntryNavigation');
202
+ if (!pending) return;
203
+ if (!isActiveEntryNavigation(pending)) {
204
+ store.set({ pendingSessionEntryNavigation: null });
205
+ return;
206
+ }
207
+ requestAnimationFrame(function () {
208
+ if (store.get('pendingSessionEntryNavigation') === pending) jumpToSessionEntry(pending.entryIndex, pending);
209
+ });
210
+ }
211
+
212
+ function isActiveEntryNavigation(pending) {
213
+ return String(store.get('currentSlug') || (ctx && ctx.currentSlug ? ctx.currentSlug() : "")) === String(pending.projectSlug) && String(store.get('activeSessionId')) === String(pending.sessionId);
214
+ }
215
+
216
+ function jumpToSessionEntry(entryIndex, pending) {
217
+ if (pending && !isActiveEntryNavigation(pending)) return;
218
+ var messages = document.querySelectorAll("#messages [data-history-index], #messages [data-history-indexes]");
219
+ var target = null;
220
+ for (var i = 0; i < messages.length; i++) {
221
+ var indexes = (messages[i].dataset.historyIndexes || messages[i].dataset.historyIndex || "").split(",");
222
+ if (indexes.indexOf(String(entryIndex)) !== -1) { target = messages[i]; break; }
223
+ }
224
+ store.set({ pendingSessionEntryNavigation: null });
225
+ if (!target) return;
226
+ disarmStickyBottom();
227
+ var oldTargets = document.querySelectorAll("#messages .session-entry-target");
228
+ for (var oldIndex = 0; oldIndex < oldTargets.length; oldIndex++) oldTargets[oldIndex].classList.remove("session-entry-target");
229
+ var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
230
+ target.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "center" });
231
+ target.classList.remove("session-entry-target");
232
+ void target.offsetWidth;
233
+ target.classList.add("session-entry-target");
234
+ setTimeout(function () { target.classList.remove("session-entry-target"); }, reducedMotion ? 700 : 1800);
157
235
  }
158
236
 
159
237
  function navigateToLog(target) {
@@ -357,13 +435,24 @@ function activateItem(entry) {
357
435
  if (entry.type === "mate-session" || entry.type === "session") navigateToSession(entry.data, entry.query);
358
436
  }
359
437
 
360
- function navigateToSession(item, query) {
438
+ function navigateToSession(item, query, entryIndex) {
361
439
  closeCommandPalette();
440
+ pendingNav = null;
441
+ var hasEntry = entryIndex !== null && entryIndex !== undefined && Number.isSafeInteger(Number(entryIndex)) && Number(entryIndex) >= 0;
442
+ store.set({ pendingSessionEntryNavigation: hasEntry ? { projectSlug: item.projectSlug, sessionId: item.sessionId, entryIndex: Number(entryIndex) } : null });
362
443
  if (ctx.currentSlug && ctx.currentSlug() === item.projectSlug) {
444
+ var alreadyActive = String(store.get('activeSessionId')) === String(item.sessionId);
445
+ if (alreadyActive) {
446
+ if (query) setTimeout(function () { openSessionSearch(query); }, 400);
447
+ if (hasEntry) requestAnimationFrame(function () {
448
+ if (!store.get('replayingHistory')) handlePaletteHistoryDone();
449
+ });
450
+ return;
451
+ }
363
452
  ctx.selectSession(item.sessionId);
364
453
  if (query) setTimeout(function () { openSessionSearch(query); }, 400);
365
454
  } else {
366
- pendingNav = { slug: item.projectSlug, sessionId: item.sessionId, query: query };
455
+ pendingNav = { slug: item.projectSlug, sessionId: item.sessionId, query: query, entryIndex: hasEntry ? Number(entryIndex) : null };
367
456
  ctx.switchProject(item.projectSlug);
368
457
  }
369
458
  }
@@ -84,7 +84,7 @@ export function enhanceClaySessionLinks(root) {
84
84
  fragment.appendChild(document.createTextNode(text.slice(offset, match.index)));
85
85
  var button = document.createElement("button");
86
86
  button.type = "button";
87
- button.className = "clayos-session-link";
87
+ button.className = "clayos-session-link clayos-record-link";
88
88
  button.dataset.sessionRef = match[1];
89
89
  button.setAttribute("aria-label", "Open session" + (match[2] ? " from " + match[2].trim() : ""));
90
90
  var label = document.createElement("span");
@@ -0,0 +1,69 @@
1
+ function decode(value) {
2
+ var base64 = value.replace(/-/g, '+').replace(/_/g, '/');
3
+ return Uint8Array.from(atob(base64), function (character) { return character.charCodeAt(0); });
4
+ }
5
+ function encode(value) {
6
+ var bytes = new Uint8Array(value); var binary = '';
7
+ bytes.forEach(function (byte) { binary += String.fromCharCode(byte); });
8
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
9
+ }
10
+ function serialize(credential) {
11
+ if (!credential) throw new Error('No passkey was selected.');
12
+ var response = { clientDataJSON: encode(credential.response.clientDataJSON) };
13
+ ['attestationObject', 'authenticatorData', 'signature', 'userHandle'].forEach(function (key) {
14
+ if (credential.response[key]) response[key] = encode(credential.response[key]);
15
+ });
16
+ if (credential.response.getTransports) response.transports = credential.response.getTransports();
17
+ return { id: credential.id, rawId: encode(credential.rawId), type: credential.type, response: response,
18
+ clientExtensionResults: credential.getClientExtensionResults(), authenticatorAttachment: credential.authenticatorAttachment };
19
+ }
20
+ function prepare(options) {
21
+ var publicKey = Object.assign({}, options, { challenge: decode(options.challenge) });
22
+ ['allowCredentials', 'excludeCredentials'].forEach(function (field) {
23
+ if (options[field]) publicKey[field] = options[field].map(function (key) { return Object.assign({}, key, { id: decode(key.id) }); });
24
+ });
25
+ if (options.user) publicKey.user = Object.assign({}, options.user, { id: decode(options.user.id) });
26
+ return publicKey;
27
+ }
28
+ export function supported() { return !!(window.isSecureContext && window.PublicKeyCredential && navigator.credentials); }
29
+ export function passkeyError(error) {
30
+ if (error.name === 'NotAllowedError' || error.name === 'AbortError') return 'Passkey request cancelled or timed out. You can try again or use your password.';
31
+ if (error.name === 'InvalidStateError') return 'This passkey is already registered. Choose a different device or passkey.';
32
+ return error.message || 'Could not use this passkey.';
33
+ }
34
+ export async function createPasskey(options) {
35
+ if (!supported()) throw new Error('Passkeys need a supported browser and a secure connection.');
36
+ return serialize(await navigator.credentials.create({ publicKey: prepare(options) }));
37
+ }
38
+ export async function getPasskey(options) {
39
+ if (!supported()) throw new Error('Passkeys need a supported browser and a secure connection.');
40
+ return serialize(await navigator.credentials.get({ publicKey: prepare(options) }));
41
+ }
42
+ export async function request(url, data) {
43
+ var response = await fetch(url, data === undefined ? { credentials: 'same-origin' } : {
44
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
45
+ });
46
+ var result = await response.json();
47
+ if (!response.ok) throw new Error(result.error || 'Could not complete this request.');
48
+ return result;
49
+ }
50
+ export async function initPasskeyLogin() {
51
+ var button = document.getElementById('passkey-login');
52
+ if (!button || !supported()) return;
53
+ try {
54
+ var status = await request('/auth/passkeys/status');
55
+ if (status.origin !== location.origin || document.getElementById('mode-row').hidden) return;
56
+ button.hidden = false;
57
+ button.onclick = async function () {
58
+ button.disabled = true;
59
+ var error = document.getElementById('error'); error.textContent = '';
60
+ try {
61
+ var options = await request('/auth/passkeys/login/options', {});
62
+ var response = await getPasskey(options.options);
63
+ await request('/auth/passkeys/login/verify', { response: response });
64
+ location.href = '/';
65
+ } catch (failure) { error.textContent = passkeyError(failure); }
66
+ finally { button.disabled = false; }
67
+ };
68
+ } catch (failure) { /* Password sign-in remains available when passkeys cannot load. */ }
69
+ }
@@ -0,0 +1,57 @@
1
+ import { store } from './store.js';
2
+ import { setSettingsDraft, clearSettingsDraft } from './server-settings-state.js';
3
+
4
+ function render(state) {
5
+ var section = document.getElementById('passkey-policy');
6
+ if (!section) return;
7
+ section.hidden = !state.available;
8
+ var input = section.querySelector('[name=origin]');
9
+ if (state.draft === undefined) input.value = state.origin || '';
10
+ input.disabled = !!state.pending || !!state.locked;
11
+ section.querySelector('[name=currentPassword]').disabled = !!state.pending;
12
+ section.querySelector('button').disabled = !!state.pending || !!state.locked;
13
+ section.querySelector('[data-passkey-editor]').hidden = !!state.locked;
14
+ section.querySelector('[role=status]').textContent = state.error || (state.pending ? 'Saving…' : state.origin ?
15
+ 'Passkeys are available at ' + state.origin + (state.locked ? '. Remove registered passkeys before changing this address.' : '.') : 'Not configured. Password sign-in remains available.');
16
+ }
17
+ export async function loadPasskeyServerSettings() {
18
+ if (!document.getElementById('passkey-policy') || (store.get('passkeyPolicy') || {}).draft !== undefined) return;
19
+ try {
20
+ var response = await fetch('/api/passkey-policy'); var data = await response.json();
21
+ if (!response.ok) throw new Error(data.error || 'Could not load passkey settings.');
22
+ store.set({ passkeyPolicy: data }); render(data);
23
+ } catch (error) {
24
+ var state = Object.assign({}, store.get('passkeyPolicy'), { error: error.message });
25
+ store.set({ passkeyPolicy: state }); render(state);
26
+ }
27
+ }
28
+ export function initPasskeyServerSettings() {
29
+ var anchor = document.getElementById('account-auth-policy');
30
+ if (!anchor || document.getElementById('passkey-policy')) return;
31
+ var section = document.createElement('section'); section.id = 'passkey-policy'; section.className = 'settings-card'; section.hidden = true;
32
+ section.innerHTML = '<h3>Passkey sign-in address</h3><p class="settings-hint">Configure the shared server address for passkey sign-in. Users register and manage their own passkeys in Personal settings → Account. Passwords remain available.</p>' +
33
+ '<form><label class="settings-label" for="passkey-origin">Sign-in address</label><input class="us-text-input" id="passkey-origin" name="origin" type="url" placeholder="https://clay.example.com" required>' +
34
+ '<p class="settings-hint">Use the permanent HTTPS address for this server. Local development can use http://localhost with its port.</p>' +
35
+ '<div data-passkey-editor><label class="settings-label" for="passkey-admin-password">Administrator password</label><input class="us-text-input" id="passkey-admin-password" name="currentPassword" type="password" autocomplete="current-password" required>' +
36
+ '<div class="settings-form-actions"><button class="settings-btn-sm" type="submit">Save passkey address</button></div></div></form><p class="settings-hint" role="status"></p>';
37
+ anchor.after(section);
38
+ var input = section.querySelector('[name=origin]');
39
+ input.oninput = function () {
40
+ var state = Object.assign({}, store.get('passkeyPolicy'), { draft: input.value, error: '' });
41
+ if (input.value === (state.origin || '')) { delete state.draft; clearSettingsDraft('passkeys'); }
42
+ else setSettingsDraft('passkeys', true);
43
+ store.set({ passkeyPolicy: state }); render(state);
44
+ };
45
+ section.querySelector('form').onsubmit = async function (event) {
46
+ event.preventDefault();
47
+ var state = Object.assign({}, store.get('passkeyPolicy'), { pending: true, error: '' });
48
+ store.set({ passkeyPolicy: state }); render(state);
49
+ var password = section.querySelector('[name=currentPassword]');
50
+ try {
51
+ var response = await fetch('/api/passkey-policy', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ origin: input.value, currentPassword: password.value }) });
52
+ var data = await response.json(); if (!response.ok) throw new Error(data.error || 'Could not save passkey settings.');
53
+ state = data; clearSettingsDraft('passkeys');
54
+ } catch (error) { state.pending = false; state.error = error.message; }
55
+ finally { password.value = ''; store.set({ passkeyPolicy: state }); render(state); }
56
+ };
57
+ }
@@ -366,11 +366,12 @@ export function renderDetail(detailEl, entry, handlers) {
366
366
  formatTime(entry.updatedAt || entry.createdAt),
367
367
  ].filter(Boolean).join(" · ");
368
368
  setText(header, ".project-log-byline", byline);
369
- if (handlers && handlers.canDelete && !entry.scheduledResult) {
369
+ if (handlers && handlers.canDelete && !entry.scheduledResult && !entry.deleted) {
370
370
  var deleteButton = document.createElement("button");
371
371
  deleteButton.type = "button";
372
372
  deleteButton.className = "project-log-delete-button";
373
- deleteButton.innerHTML = '<i data-lucide="trash-2"></i><span>Delete log</span>';
373
+ deleteButton.innerHTML = '<i data-lucide="trash-2"></i><span></span>';
374
+ deleteButton.querySelector("span").textContent = handlers.deleteLabel || "Delete log";
374
375
  deleteButton.addEventListener("click", function () { handlers.onDelete(entry); });
375
376
  header.appendChild(deleteButton);
376
377
  }
@@ -10,6 +10,7 @@ function status(id, text) {
10
10
  }
11
11
 
12
12
  function sendSetting(type, values, buttonId, statusId) {
13
+ if (type === 'set_pin' && store.get('serverPinAvailable') !== true) return;
13
14
  var ws = getWs();
14
15
  if (!ws || ws.readyState !== 1) { status(statusId, 'Disconnected. Your change has not been saved.'); return; }
15
16
  var button = document.getElementById(buttonId);
@@ -29,6 +30,15 @@ function sendSetting(type, values, buttonId, statusId) {
29
30
  }, 12000);
30
31
  }
31
32
 
33
+ export function setServerPinAvailability(available) {
34
+ store.set({ serverPinAvailable: !!available });
35
+ var section = document.getElementById('settings-server-pin');
36
+ if (!section) return;
37
+ section.hidden = !available;
38
+ section.querySelectorAll('input,button').forEach(function (element) { element.disabled = !available; });
39
+ if (!available) hidePinForm();
40
+ }
41
+
32
42
  export function handleServerSettingResult(msg) {
33
43
  var type = msg.type.replace(/_result$/, '');
34
44
  var pending = Object.assign({}, store.get('serverSettingsPending') || {});