clay-server 4.0.0-beta.16 → 4.0.0-beta.18

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 (50) hide show
  1. package/lib/daemon-projects.js +10 -5
  2. package/lib/daemon.js +61 -10
  3. package/lib/project-log-context.js +171 -0
  4. package/lib/project-log-feedback-delivery.js +47 -5
  5. package/lib/project-logs-context-state.js +42 -0
  6. package/lib/project-logs-mcp-server.js +15 -4
  7. package/lib/project-logs-query.js +18 -0
  8. package/lib/project-logs-root.js +8 -0
  9. package/lib/project-logs-service.js +121 -15
  10. package/lib/project-logs-snapshot.js +3 -0
  11. package/lib/project-logs-store.js +29 -32
  12. package/lib/project-logs-versioning.js +6 -2
  13. package/lib/project-logs.js +61 -13
  14. package/lib/project-message-delivery.js +70 -0
  15. package/lib/project-user-message.js +11 -0
  16. package/lib/project.js +6 -1
  17. package/lib/public/app.js +5 -0
  18. package/lib/public/css/command-palette.css +19 -9
  19. package/lib/public/css/project-logs.css +20 -2
  20. package/lib/public/css/sticky-notes.css +7 -0
  21. package/lib/public/index.html +2 -2
  22. package/lib/public/modules/admin.js +8 -8
  23. package/lib/public/modules/app-connection.js +22 -3
  24. package/lib/public/modules/app-messages.js +29 -1
  25. package/lib/public/modules/app-rendering.js +1 -0
  26. package/lib/public/modules/chat-bubble-renderer.js +1 -1
  27. package/lib/public/modules/clay-log-links.js +70 -0
  28. package/lib/public/modules/command-palette.js +52 -15
  29. package/lib/public/modules/input.js +6 -1
  30. package/lib/public/modules/markdown.js +2 -0
  31. package/lib/public/modules/message-delivery.js +97 -0
  32. package/lib/public/modules/project-logs-render.js +48 -2
  33. package/lib/public/modules/project-logs.js +140 -11
  34. package/lib/public/modules/sidebar-session-hierarchy.js +16 -7
  35. package/lib/public/modules/sidebar-sessions.js +3 -1
  36. package/lib/public/modules/sticky-note-markdown.js +5 -0
  37. package/lib/public/modules/sticky-notes-browser.js +2 -0
  38. package/lib/public/modules/sticky-notes-card.js +2 -0
  39. package/lib/public/modules/sticky-notes-editor.js +3 -0
  40. package/lib/public/modules/sticky-notes.js +2 -0
  41. package/lib/public/style.css +1 -1
  42. package/lib/sdk-bridge.js +20 -10
  43. package/lib/server-global-ws.js +4 -0
  44. package/lib/server-home-chat-events.js +3 -0
  45. package/lib/server-home-chat.js +9 -2
  46. package/lib/server-home-clay-entry.js +1 -1
  47. package/lib/server-home-clay-session-links.js +34 -5
  48. package/lib/server.js +14 -3
  49. package/lib/ws-schema.js +8 -2
  50. package/package.json +1 -1
@@ -7,8 +7,8 @@
7
7
  //
8
8
  // Authorship is split deliberately. Canonical entries are created and revised
9
9
  // only by this project's agent sessions through `clay-logs`; a connected human
10
- // reads the ledger and appends attributed comments. That keeps the log an
11
- // honest record of the work rather than a shared wiki.
10
+ // reads the ledger, appends attributed comments, and lets the project owner
11
+ // delete an entry. That keeps the log an honest record rather than a wiki.
12
12
 
13
13
  var logsMcp = require("./project-logs-mcp-server");
14
14
 
@@ -19,7 +19,7 @@ var MAX_NOTICE_SUMMARY_CHARS = 240;
19
19
  // Canonical writes that move the ledger forward. A plain comment, a
20
20
  // clarification, and a decline are participation, not revisions, so they never
21
21
  // notify.
22
- var NOTIFYING_OPS = ["create", "update", "link", "incorporate", "revert"];
22
+ var NOTIFYING_OPS = ["create", "update", "link", "incorporate", "revert", "delete"];
23
23
 
24
24
  function clip(value, max) {
25
25
  if (typeof value !== "string") return "";
@@ -71,6 +71,7 @@ var HANDLED_TYPES = [
71
71
  "project_logs_list",
72
72
  "project_log_read",
73
73
  "project_log_comment",
74
+ "project_log_delete",
74
75
  // Retired human mutations. Still claimed so an older client gets an
75
76
  // explicit refusal rather than silence.
76
77
  "project_log_create",
@@ -105,6 +106,21 @@ function attachProjectLogs(ctx) {
105
106
  }
106
107
  }
107
108
 
109
+ function broadcastCommentReview(entry, request) {
110
+ if (isMate || !entry || !request || !request.commentId) return;
111
+ var notice = {
112
+ type: "project_log_comment_reviewed",
113
+ ref: entry.ref,
114
+ commentId: request.commentId,
115
+ action: request.action,
116
+ };
117
+ var clients = getClients();
118
+ for (var client of clients) {
119
+ if (!client || client.readyState !== 1 || client._clayPane) continue;
120
+ sendTo(client, notice);
121
+ }
122
+ }
123
+
108
124
  // Wrap a canonical write so a successful one notifies. A throw never reaches
109
125
  // here, so a failed or no-op write is silent.
110
126
  function notifying(op, run) {
@@ -149,13 +165,27 @@ function attachProjectLogs(ctx) {
149
165
  // read and neither path dumps a record body into the list.
150
166
  // Returns both the rows and this project's live category vocabulary, so the
151
167
  // filter is populated solely from what this project actually uses.
152
- function listEntries(bound, query, category) {
168
+ function listEntries(bound, query, category, contextMode) {
169
+ var options = { kind: category, limit: LIST_LIMIT, contextMode: contextMode };
153
170
  if (query) {
154
- var hits = bound.searchLogs({ query: query, kind: category, limit: LIST_LIMIT });
155
- return { entries: hits.results, categories: hits.categories || [] };
171
+ options.query = query;
172
+ var hits = bound.searchLogs(options);
173
+ return {
174
+ entries: hits.results,
175
+ categories: hits.categories || [],
176
+ contextMode: hits.contextMode,
177
+ currentChangeSetId: hits.currentChangeSetId,
178
+ isWorktree: hits.isWorktree,
179
+ };
156
180
  }
157
- var listed = bound.listLogs({ kind: category, limit: LIST_LIMIT });
158
- return { entries: listed.entries, categories: listed.categories || [] };
181
+ var listed = bound.listLogs(options);
182
+ return {
183
+ entries: listed.entries,
184
+ categories: listed.categories || [],
185
+ contextMode: listed.contextMode,
186
+ currentChangeSetId: listed.currentChangeSetId,
187
+ isWorktree: listed.isWorktree,
188
+ };
159
189
  }
160
190
 
161
191
  function handleLogsMessage(ws, msg) {
@@ -187,27 +217,40 @@ function attachProjectLogs(ctx) {
187
217
  var query = typeof msg.query === "string" ? msg.query.trim() : "";
188
218
  // A view preference, not an authorization input: the store validates it.
189
219
  var category = typeof msg.category === "string" && msg.category ? msg.category : null;
190
- var listing = listEntries(bound, query, category);
220
+ var contextMode = typeof msg.contextMode === "string" ? msg.contextMode : null;
221
+ var listing = listEntries(bound, query, category, contextMode);
191
222
  sendTo(ws, {
192
223
  type: "project_logs_state",
193
224
  requestId: requestId,
194
225
  entries: listing.entries,
195
226
  categories: listing.categories,
227
+ contextMode: listing.contextMode,
228
+ currentChangeSetId: listing.currentChangeSetId,
229
+ isWorktree: listing.isWorktree === true,
230
+ canDelete: bound.canDelete === true,
196
231
  });
197
232
  return true;
198
233
  }
199
234
 
200
235
  if (msg.type === "project_log_read") {
201
- sendTo(ws, { type: "project_log_entry", requestId: requestId, entry: bound.readLog({ ref: msg.ref }) });
236
+ sendTo(ws, { type: "project_log_entry", requestId: requestId, entry: bound.readLog({ ref: msg.ref }), canDelete: bound.canDelete === true });
237
+ return true;
238
+ }
239
+
240
+ if (msg.type === "project_log_delete") {
241
+ var removed = bound.removeLog({ ref: msg.ref });
242
+ sendTo(ws, { type: "project_log_deleted", requestId: requestId, ref: removed.ref });
243
+ broadcastUpdate(removed, "delete");
202
244
  return true;
203
245
  }
204
246
 
205
247
  if (msg.type === "project_log_comment") {
206
248
  var commented = bound.commentLog({ ref: msg.ref, body: msg.body });
207
- sendTo(ws, { type: "project_log_commented", requestId: requestId, entry: commented });
208
- try { onFeedback(commented); } catch (deliveryError) {
249
+ var reviewQueued = false;
250
+ try { reviewQueued = onFeedback(commented) === true; } catch (deliveryError) {
209
251
  console.error("[project-logs] Failed to deliver comment:", deliveryError.message || deliveryError);
210
252
  }
253
+ sendTo(ws, { type: "project_log_commented", requestId: requestId, entry: commented, reviewQueued: reviewQueued });
211
254
  return true;
212
255
  }
213
256
  } catch (e) {
@@ -232,6 +275,7 @@ function attachProjectLogs(ctx) {
232
275
  reviewLogComment: function (args) {
233
276
  var request = coerceToolArgs(args);
234
277
  var result = bound.reviewLogComment(request);
278
+ broadcastCommentReview(result, request);
235
279
  // Only an incorporation is a canonical revision; clarify and decline
236
280
  // resolve a comment and change nothing.
237
281
  if (request && request.action === "incorporate") broadcastUpdate(result, "incorporate");
@@ -322,7 +366,11 @@ function attachProjectLogs(ctx) {
322
366
  }
323
367
 
324
368
  function getSystemPrompt(session) {
325
- if (isMate || !service) return "";
369
+ if (!service) return "";
370
+ if (isMate) {
371
+ if (!includeGlobal() || (session && !sessionBinding(session))) return "";
372
+ return SYSTEM_PROMPT_LABEL + "\n" + logsMcp.CLAY_READ_CONTRACT;
373
+ }
326
374
  if (session && !sessionBinding(session)) return "";
327
375
  return SYSTEM_PROMPT_LABEL + "\n" + logsMcp.LOGS_CONTRACT +
328
376
  "\n" + logsMcp.LEARNING_CONTRACT +
@@ -0,0 +1,70 @@
1
+ // Idempotent acknowledgement support for ordinary project chat messages.
2
+
3
+ var CLIENT_MESSAGE_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
4
+
5
+ function normalizeClientMessageId(value) {
6
+ if (typeof value !== "string" || !CLIENT_MESSAGE_ID_RE.test(value)) return null;
7
+ return value;
8
+ }
9
+
10
+ function ownerKey(ws) {
11
+ return ws && ws._clayUser && ws._clayUser.id ? String(ws._clayUser.id) : "_default";
12
+ }
13
+
14
+ function deliveryKey(ownerId, clientMessageId) {
15
+ return ownerId + ":" + clientMessageId;
16
+ }
17
+
18
+ function ensureRecordedMessages(session) {
19
+ if (session._recordedClientMessages) return session._recordedClientMessages;
20
+ var recorded = new Map();
21
+ var history = Array.isArray(session.history) ? session.history : [];
22
+ for (var i = 0; i < history.length; i++) {
23
+ var item = history[i];
24
+ var clientMessageId = normalizeClientMessageId(item && item.clientMessageId);
25
+ if (!clientMessageId) continue;
26
+ recorded.set(deliveryKey(item.from ? String(item.from) : "_default", clientMessageId), item);
27
+ }
28
+ session._recordedClientMessages = recorded;
29
+ return recorded;
30
+ }
31
+
32
+ function createProjectMessageDelivery(sendTo) {
33
+ function inspect(ws, session, msg) {
34
+ var clientMessageId = normalizeClientMessageId(msg && msg.clientMessageId);
35
+ if (!clientMessageId) return { clientMessageId: null, ownerId: ownerKey(ws), duplicate: false };
36
+ var ownerId = ownerKey(ws);
37
+ var recordedMessage = ensureRecordedMessages(session).get(deliveryKey(ownerId, clientMessageId)) || null;
38
+ return {
39
+ clientMessageId: clientMessageId,
40
+ ownerId: ownerId,
41
+ duplicate: !!recordedMessage,
42
+ recordedMessage: recordedMessage,
43
+ };
44
+ }
45
+
46
+ function markRecorded(session, receipt, message) {
47
+ if (!receipt || !receipt.clientMessageId) return;
48
+ ensureRecordedMessages(session).set(deliveryKey(receipt.ownerId, receipt.clientMessageId), message);
49
+ }
50
+
51
+ function acknowledge(ws, receipt, message) {
52
+ if (!receipt || !receipt.clientMessageId) return;
53
+ sendTo(ws, {
54
+ type: "message_ack",
55
+ clientMessageId: receipt.clientMessageId,
56
+ message: message || receipt.recordedMessage || null,
57
+ });
58
+ }
59
+
60
+ return {
61
+ inspect: inspect,
62
+ markRecorded: markRecorded,
63
+ acknowledge: acknowledge,
64
+ };
65
+ }
66
+
67
+ module.exports = {
68
+ createProjectMessageDelivery: createProjectMessageDelivery,
69
+ normalizeClientMessageId: normalizeClientMessageId,
70
+ };
@@ -35,12 +35,14 @@ function attachUserMessage(ctx) {
35
35
  var sdk = ctx.sdk;
36
36
  var nm = ctx.nm;
37
37
  var notesLifecycle = require("./notes-lifecycle");
38
+ var createProjectMessageDelivery = require("./project-message-delivery").createProjectMessageDelivery;
38
39
  var tm = ctx.tm;
39
40
 
40
41
  var send = ctx.send;
41
42
  var sendTo = ctx.sendTo;
42
43
  var sendToSession = ctx.sendToSession;
43
44
  var sendToSessionOthers = ctx.sendToSessionOthers;
45
+ var messageDelivery = createProjectMessageDelivery(sendTo);
44
46
 
45
47
  var clients = ctx.clients;
46
48
  var opts = ctx.opts;
@@ -316,6 +318,12 @@ function attachUserMessage(ctx) {
316
318
  var session = getSessionForWs(ws);
317
319
  if (!session) return true;
318
320
 
321
+ var deliveryReceipt = messageDelivery.inspect(ws, session, msg);
322
+ if (deliveryReceipt.duplicate) {
323
+ messageDelivery.acknowledge(ws, deliveryReceipt, hydrateImageRefs(deliveryReceipt.recordedMessage));
324
+ return true;
325
+ }
326
+
319
327
  // A configured Split Worker is Driver-operated: its work arrives through
320
328
  // the Driver's delegation, not from a person typing into its pane. The UI
321
329
  // hides the composer, but the refusal has to live here too — a legacy
@@ -375,9 +383,12 @@ function attachUserMessage(ctx) {
375
383
  if (msg.pastes && msg.pastes.length > 0) {
376
384
  userMsg2.pastes = msg.pastes;
377
385
  }
386
+ if (deliveryReceipt.clientMessageId) userMsg2.clientMessageId = deliveryReceipt.clientMessageId;
378
387
  if (!userMsg2._ts) userMsg2._ts = Date.now();
379
388
  session.history.push(userMsg2);
380
389
  sm.appendToSessionFile(session, userMsg2);
390
+ messageDelivery.markRecorded(session, deliveryReceipt, userMsg2);
391
+ messageDelivery.acknowledge(ws, deliveryReceipt, hydrateImageRefs(userMsg2));
381
392
  sendToSessionOthers(ws, session.localId, hydrateImageRefs(userMsg2));
382
393
 
383
394
  if (!session.title) {
package/lib/project.js CHANGED
@@ -215,6 +215,7 @@ function createProjectContext(opts) {
215
215
  var updateChannel = opts.updateChannel || "stable";
216
216
  var osUsers = opts.osUsers || false;
217
217
  var projectOwnerId = opts.projectOwnerId || null;
218
+ var projectKnowledgeId = opts.projectKnowledgeId || null;
218
219
  var worktreeMeta = opts.worktreeMeta || null; // { parentSlug, branch, external }
219
220
  var isMate = opts.isMate || false;
220
221
  var mateId = opts.mateId || (isMate ? path.basename(cwd) : null);
@@ -1318,7 +1319,7 @@ function createProjectContext(opts) {
1318
1319
  if (typeof opts.onDmMessage === "function") opts.onDmMessage(ws, msg);
1319
1320
  return;
1320
1321
  }
1321
- if (msg.type === "home_clay_ask" || msg.type === "home_clay_session_resolve") {
1322
+ if (msg.type === "home_clay_ask" || msg.type === "home_clay_session_resolve" || msg.type === "home_clay_log_resolve") {
1322
1323
  if (typeof opts.onDmMessage === "function") opts.onDmMessage(ws, msg, slug);
1323
1324
  return;
1324
1325
  }
@@ -2188,6 +2189,7 @@ function createProjectContext(opts) {
2188
2189
  isProcessing: hasProcessing,
2189
2190
  pendingPermissions: pendingPermCount,
2190
2191
  projectOwnerId: projectOwnerId,
2192
+ projectKnowledgeId: projectKnowledgeId,
2191
2193
  };
2192
2194
  if (isMate) {
2193
2195
  status.isMate = true;
@@ -2197,6 +2199,9 @@ function createProjectContext(opts) {
2197
2199
  status.isWorktree = true;
2198
2200
  status.parentSlug = worktreeMeta.parentSlug;
2199
2201
  status.branch = worktreeMeta.branch;
2202
+ status.changeSetId = worktreeMeta.changeSetId || null;
2203
+ status.baseCommit = worktreeMeta.baseCommit || null;
2204
+ status.headCommit = worktreeMeta.headCommit || null;
2200
2205
  status.worktreeExternal = worktreeMeta.external === true;
2201
2206
  }
2202
2207
  if (usersModule.isMultiUser()) {
package/lib/public/app.js CHANGED
@@ -289,6 +289,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
289
289
  isHeadlessMode: false,
290
290
  savedMainSlug: null,
291
291
  connected: false,
292
+ pendingOutboundMessages: [],
292
293
  homeChatMateId: null,
293
294
  homeChatSessionId: null,
294
295
  homeChatSessionModel: null,
@@ -326,11 +327,15 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
326
327
  projectLogsSelectedRef: null,
327
328
  projectLogsView: 'list',
328
329
  projectLogsCategory: '',
330
+ projectLogsContextMode: '',
331
+ projectLogsIsWorktree: false,
329
332
  projectLogsListScroll: 0,
330
333
  projectLogsListRequestId: null,
331
334
  projectLogsReadRequestId: null,
332
335
  projectLogsCommentRequestId: null,
333
336
  projectLogsCommentStatusEl: null,
337
+ projectLogsDeleteRequestId: null,
338
+ projectLogsCanDelete: false,
334
339
  projectLogsUnread: 0,
335
340
  projectLogsSeenRevisions: {},
336
341
  installedTools: [],
@@ -486,7 +486,8 @@ body.mate-dm-active .search-clay-transcript .search-clay-message-user .bubble,
486
486
  .search-clay-activity-item small { color: var(--text-dimmer); font-size: 10px; line-height: 1.35; }
487
487
  @keyframes searchClaySpin { to { transform: rotate(360deg); } }
488
488
 
489
- .clayos-session-link {
489
+ .clayos-session-link,
490
+ .clayos-log-link {
490
491
  display: inline-flex;
491
492
  align-items: center;
492
493
  gap: 6px;
@@ -503,7 +504,8 @@ body.mate-dm-active .search-clay-transcript .search-clay-message-user .bubble,
503
504
  vertical-align: middle;
504
505
  cursor: pointer;
505
506
  }
506
- .clayos-session-link::before {
507
+ .clayos-session-link::before,
508
+ .clayos-log-link::before {
507
509
  content: "\2197";
508
510
  display: grid;
509
511
  place-items: center;
@@ -514,13 +516,21 @@ body.mate-dm-active .search-clay-transcript .search-clay-message-user .bubble,
514
516
  color: var(--accent);
515
517
  font-size: 12px;
516
518
  }
517
- .clayos-session-link > span { font-weight: 620; }
518
- .clayos-session-link > small { color: var(--text-muted); font-size: 10px; }
519
- .clayos-session-link:hover { border-color: color-mix(in srgb, var(--accent) 52%, var(--border)); background: color-mix(in srgb, var(--accent) 11%, var(--bg-alt)); }
520
- .clayos-session-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
521
- .clayos-session-link.is-loading { opacity: 0.62; cursor: progress; }
522
- .clayos-session-link.is-loading::before { animation: searchClaySpin 1.2s linear infinite; }
523
- .clayos-session-link.is-error { border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); }
519
+ .clayos-log-link::before { content: "\2261"; }
520
+ .clayos-session-link > span,
521
+ .clayos-log-link > span { font-weight: 620; }
522
+ .clayos-session-link > small,
523
+ .clayos-log-link > small { color: var(--text-muted); font-size: 10px; }
524
+ .clayos-session-link:hover,
525
+ .clayos-log-link:hover { border-color: color-mix(in srgb, var(--accent) 52%, var(--border)); background: color-mix(in srgb, var(--accent) 11%, var(--bg-alt)); }
526
+ .clayos-session-link:focus-visible,
527
+ .clayos-log-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
528
+ .clayos-session-link.is-loading,
529
+ .clayos-log-link.is-loading { opacity: 0.62; cursor: progress; }
530
+ .clayos-session-link.is-loading::before,
531
+ .clayos-log-link.is-loading::before { animation: searchClaySpin 1.2s linear infinite; }
532
+ .clayos-session-link.is-error,
533
+ .clayos-log-link.is-error { border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); }
524
534
 
525
535
  .search-clay-pending {
526
536
  align-self: flex-start;
@@ -105,7 +105,11 @@
105
105
  .project-logs-search .lucide { width: 14px; flex: 0 0 auto; }
106
106
  .project-logs-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); font: inherit; font-size: 12px; }
107
107
  .project-logs-search:focus-within, .project-logs-filter:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent); border-color: var(--accent); }
108
- .project-logs-filter { height: 36px; max-width: 42%; padding: 0 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text-secondary); font: inherit; font-size: 12px; cursor: pointer; }
108
+ .project-logs-filter { height: 36px; max-width: 180px; padding: 0 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text-secondary); font: inherit; font-size: 12px; cursor: pointer; }
109
+ .project-logs-context-filter { max-width: 148px; }
110
+ .project-log-context-chip { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 7px; border-radius: 999px; background: var(--bg-alt); color: var(--text-secondary); font-size: 11px; font-weight: 600; }
111
+ .project-log-context-chip[data-status="archived"] { opacity: .72; }
112
+ .project-log-context-chip[data-status="merged"] { color: var(--success, #2f8f57); }
109
113
 
110
114
  /* --- Ledger list: full pane, chronological, commit-history feel --- */
111
115
  .project-logs-list { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 0 18px; }
@@ -139,8 +143,22 @@
139
143
  .project-log-document h1 { margin: 0 0 10px; color: var(--text); font-family: var(--font-display); font-size: clamp(24px, 2.8vw, 34px); letter-spacing: -0.025em; line-height: 1.12; }
140
144
  .project-log-doc-summary { margin: 0 0 13px; color: var(--text-secondary); font-size: 14px; line-height: 1.6; }
141
145
  .project-log-byline { margin: 0 0 25px; padding-bottom: 18px; border-bottom: 2px solid var(--border-subtle); color: var(--text-dimmer); font-size: 11px; font-family: var(--font-mono); }
146
+ .project-log-delete-button { display: inline-flex; align-items: center; gap: 6px; margin: -11px 0 24px; padding: 6px 9px; border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border)); border-radius: 7px; background: transparent; color: var(--danger); font: inherit; font-size: 11px; font-weight: 700; cursor: pointer; }
147
+ .project-log-delete-button .lucide { width: 13px; }
142
148
  .project-log-markdown { font-size: 14px; line-height: 1.75; }
143
149
 
150
+ .project-log-delete-dialog { position: fixed; inset: 0; z-index: 420; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(var(--shadow-rgb), 0.5); }
151
+ .project-log-delete-dialog.hidden { display: none; }
152
+ .project-log-delete-dialog-card { width: min(420px, 100%); padding: 20px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); box-shadow: 0 18px 50px rgba(var(--shadow-rgb), 0.3); }
153
+ .project-log-delete-dialog-card h2 { margin: 0 0 9px; color: var(--text); font-family: var(--font-display); font-size: 18px; }
154
+ .project-log-delete-dialog-card p { margin: 0; color: var(--text-secondary); font-size: 13px; line-height: 1.55; }
155
+ .project-log-delete-dialog-title { margin-top: 9px !important; color: var(--text) !important; font-weight: 700; overflow-wrap: anywhere; }
156
+ .project-log-delete-dialog-status { min-height: 17px; margin-top: 11px !important; color: var(--danger) !important; font-size: 11px !important; }
157
+ .project-log-delete-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
158
+ .project-log-delete-dialog-actions button { padding: 7px 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-alt); color: var(--text-secondary); font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; }
159
+ .project-log-delete-dialog-actions button.danger { border-color: var(--danger); background: var(--danger); color: #fff; }
160
+ .project-log-delete-dialog-actions button:disabled { opacity: 0.5; cursor: default; }
161
+
144
162
  /* --- Discussion --- */
145
163
  .project-log-discussion { margin-top: 30px; padding-top: 18px; border-top: 1px solid var(--border-subtle); }
146
164
  .project-log-discussion h2 { margin: 0 0 12px; color: var(--text); font-family: var(--font-display); font-size: 15px; }
@@ -174,7 +192,7 @@
174
192
  .project-logs-subtitle { display: none; }
175
193
  .project-logs-topbar { gap: 8px; padding: 0 12px; }
176
194
  .project-logs-toolbar { padding: 10px 12px; }
177
- .project-logs-filter { max-width: 44%; }
195
+ .project-logs-filter { max-width: 42%; }
178
196
  .project-log-row { padding: 14px 14px 13px; }
179
197
  .project-log-document { padding: 24px 18px 38px; }
180
198
  }
@@ -85,9 +85,13 @@
85
85
  min-height: 80px;
86
86
  border: 1px solid color-mix(in srgb, var(--note-border) calc(var(--note-opacity, 0.64) * 100%), transparent);
87
87
  background: color-mix(in srgb, var(--note-surface) calc(var(--note-opacity, 0.64) * 100%), transparent);
88
+ -webkit-backdrop-filter: blur(7px) saturate(0.92) contrast(1.02);
89
+ backdrop-filter: blur(7px) saturate(0.92) contrast(1.02);
88
90
  color: var(--note-ink);
89
91
  transition: box-shadow 0.15s;
90
92
  overflow: hidden;
93
+ contain: layout style;
94
+ will-change: transform, backdrop-filter;
91
95
  }
92
96
 
93
97
  .sticky-note:hover {
@@ -798,8 +802,11 @@
798
802
 
799
803
  @media (prefers-reduced-transparency: reduce) {
800
804
  .sticky-note {
805
+ -webkit-backdrop-filter: none;
806
+ backdrop-filter: none;
801
807
  border-color: var(--note-border);
802
808
  background: var(--note-surface);
809
+ will-change: transform;
803
810
  }
804
811
  }
805
812
 
@@ -31,7 +31,7 @@
31
31
  (function(){try{var k="clay-theme-vars",v=localStorage.getItem(k),r=document.documentElement;if(v){var o=JSON.parse(v),p;for(p in o)r.style.setProperty(p,o[p]);var vt=localStorage.getItem(k.replace("-vars","-variant"));if(vt==="light"){r.classList.add("light-theme");r.classList.remove("dark-theme")}else{r.classList.add("dark-theme");r.classList.remove("light-theme")}var m=document.querySelector('meta[name="theme-color"]');if(m&&o["--bg"])m.setAttribute("content",o["--bg"])}else{var sl=window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches;if(sl){r.classList.add("light-theme");r.classList.remove("dark-theme")}}}catch(e){}})();
32
32
  </script>
33
33
  <script>if(window.navigator.standalone||window.matchMedia("(display-mode:standalone)").matches){document.documentElement.classList.add("pwa-standalone")}</script>
34
- <link rel="stylesheet" href="style.css?v=20260901-clay-chat6">
34
+ <link rel="stylesheet" href="style.css?v=20260908-log-links2">
35
35
  <style>
36
36
  @media(max-width:768px){
37
37
  /* User messages: vertical stack, avatar on top, right-aligned */
@@ -2337,7 +2337,7 @@
2337
2337
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0/lib/addon-fit.min.js"></script>
2338
2338
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0/lib/addon-web-links.min.js"></script>
2339
2339
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0/lib/addon-webgl.min.js"></script>
2340
- <script type="module" src="app.js?v=20260902-markdown-boundary1"></script>
2340
+ <script type="module" src="app.js?v=20260908-log-links2"></script>
2341
2341
  <div id="pwa-install-modal" class="pwa-modal hidden">
2342
2342
  <div class="pwa-modal-backdrop"></div>
2343
2343
  <div class="pwa-modal-card">
@@ -2,15 +2,15 @@
2
2
  import { iconHtml, refreshIcons } from './icons.js';
3
3
  import { showToast, copyToClipboard, escapeHtml } from './utils.js';
4
4
 
5
- function showConfirmDialog(message, onConfirm) {
5
+ function showConfirmDialog(message, actionLabel, onConfirm) {
6
6
  var modal = document.createElement("div");
7
7
  modal.className = "admin-modal-overlay";
8
- var html = '<div class="admin-modal">' +
8
+ var html = '<div class="admin-modal" role="dialog" aria-modal="true" aria-label="Confirm action">' +
9
9
  '<div class="admin-modal-body" style="padding:20px 16px 16px">' +
10
10
  '<p class="admin-modal-desc" style="margin:0;font-size:14px;color:var(--text)">' + escapeHtml(message) + '</p>' +
11
11
  '</div>' +
12
12
  '<div class="admin-modal-footer">' +
13
- '<button class="admin-modal-save admin-modal-confirm-danger">Revoke</button>' +
13
+ '<button class="admin-modal-save admin-modal-confirm-danger">' + escapeHtml(actionLabel) + '</button>' +
14
14
  '<button class="admin-modal-cancel">Cancel</button>' +
15
15
  '</div></div>';
16
16
  modal.innerHTML = html;
@@ -198,9 +198,9 @@ function renderUsersTab(body) {
198
198
  var userId = this.dataset.userId;
199
199
  var user = cachedUsers.find(function (u) { return u.id === userId; });
200
200
  var name = user ? (user.displayName || user.username) : "this user";
201
- if (confirm("Remove " + name + "? This cannot be undone.")) {
201
+ showConfirmDialog("Remove " + name + "? This cannot be undone.", "Remove user", function () {
202
202
  removeUser(userId, body);
203
- }
203
+ });
204
204
  });
205
205
  }
206
206
  }
@@ -509,7 +509,7 @@ function renderInvitesTab(body) {
509
509
  revokeBtns[k].addEventListener("click", function () {
510
510
  var code = this.dataset.code;
511
511
  var btn = this;
512
- showConfirmDialog("Revoke this invite? The link will no longer work.", function () {
512
+ showConfirmDialog("Revoke this invite? The link will no longer work.", "Revoke", function () {
513
513
  btn.disabled = true;
514
514
  fetch("/api/admin/invites/" + encodeURIComponent(code), { method: "DELETE" })
515
515
  .then(function (r) { return r.json(); })
@@ -751,12 +751,12 @@ function renderSmtpTab(body, cfg) {
751
751
  var removeBtn = body.querySelector("#smtp-remove");
752
752
  if (removeBtn) {
753
753
  removeBtn.addEventListener("click", function () {
754
- if (confirm("Remove SMTP configuration? Users will need to use PIN login.")) {
754
+ showConfirmDialog("Remove SMTP configuration? Users will need to use PIN login.", "Remove SMTP", function () {
755
755
  apiPost("/api/admin/smtp", { host: "", user: "", pass: "", from: "" }).then(function () {
756
756
  showToast("SMTP configuration removed");
757
757
  loadSmtpTab(body);
758
758
  });
759
- }
759
+ });
760
760
  });
761
761
  }
762
762
  }
@@ -39,6 +39,7 @@ var overlayGraceTimer = null;
39
39
  // timer per live socket.
40
40
  var HEARTBEAT_MS = 25000;
41
41
  var heartbeatTimer = null;
42
+ var heartbeatDeadlineTimer = null;
42
43
  var disconnectedAt = 0;
43
44
 
44
45
  function clearOverlayGrace() {
@@ -62,13 +63,17 @@ export function stopHeartbeat() {
62
63
  clearInterval(heartbeatTimer);
63
64
  heartbeatTimer = null;
64
65
  }
66
+ if (heartbeatDeadlineTimer) {
67
+ clearTimeout(heartbeatDeadlineTimer);
68
+ heartbeatDeadlineTimer = null;
69
+ }
65
70
  }
66
71
 
67
72
  // Started once per live socket. The socket it was started for is captured, so
68
73
  // a timer that outlives its socket stops itself instead of writing to a stale
69
- // connection. The pong the server sends back is what actually keeps a proxy's
70
- // upstream read timeout from expiring, but a missing or late pong is never
71
- // treated as a failure: only the socket's own close event drives reconnection.
74
+ // connection. The pong the server sends back keeps a proxy's upstream read
75
+ // timeout from expiring. A missing pong also identifies a half-open socket,
76
+ // which browsers can otherwise leave OPEN while silently losing sends.
72
77
  export function startHeartbeat(socket) {
73
78
  stopHeartbeat();
74
79
  if (!socket) return;
@@ -78,11 +83,20 @@ export function startHeartbeat(socket) {
78
83
  return;
79
84
  }
80
85
  try { socket.send(JSON.stringify({ type: "ping" })); } catch (e) {}
86
+ if (heartbeatDeadlineTimer) clearTimeout(heartbeatDeadlineTimer);
87
+ heartbeatDeadlineTimer = setTimeout(function () {
88
+ heartbeatDeadlineTimer = null;
89
+ if (getWs() === socket && socket.readyState === 1) connect();
90
+ }, 10000);
81
91
  }, HEARTBEAT_MS);
82
92
  }
83
93
 
84
94
  export function initConnection() {
85
95
  connectOverlay = document.getElementById("connect-overlay");
96
+ window.addEventListener("clay-message-delivery-timeout", function (event) {
97
+ var socket = event.detail && event.detail.socket;
98
+ if (!socket || getWs() === socket) connect();
99
+ });
86
100
 
87
101
  // --- Reactive UI sync for connected/processing state ---
88
102
  store.subscribe(function (state, prev) {
@@ -206,6 +220,7 @@ export function connect() {
206
220
  // Tear down the previous socket's heartbeat before a new socket exists, so
207
221
  // two sockets can never both be pinging.
208
222
  stopHeartbeat();
223
+ if (ws && ws.readyState === 1) setStatus("disconnected");
209
224
  if (ws) { ws.onclose = null; ws.close(); }
210
225
  if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
211
226
 
@@ -280,6 +295,10 @@ export function connect() {
280
295
  blinkIO();
281
296
  var msg;
282
297
  try { msg = JSON.parse(event.data); } catch (e) { return; }
298
+ if (msg.type === "pong" && heartbeatDeadlineTimer) {
299
+ clearTimeout(heartbeatDeadlineTimer);
300
+ heartbeatDeadlineTimer = null;
301
+ }
283
302
  processMessage(msg);
284
303
  };
285
304
  }
@@ -3,6 +3,7 @@
3
3
  // All dependencies are direct imports; no context injection needed.
4
4
 
5
5
  import { store } from './store.js';
6
+ import { acknowledgeMessage, replayPendingMessages } from './message-delivery.js';
6
7
  import { getWs } from './ws-ref.js';
7
8
 
8
9
  // --- Leaf module imports ---
@@ -41,7 +42,7 @@ import { tuiModalHandleTermOutput, tuiModalHandleTermResized, tuiModalHandleTerm
41
42
  import { updateTerminalList, handleContextSourcesState, updateEmailAccountList, updateEmailUnreadCounts, handleEmailTestResult, handleEmailAddResult, handleEmailRemoveResult } from './context-sources.js';
42
43
  import { refreshEmailSettings } from './user-settings.js';
43
44
  import { handleNotesList, handleNoteCreated, handleNoteUpdated, handleNoteDeleted, handleNoteWritten } from './sticky-notes.js';
44
- import { handleProjectLogsState, handleProjectLogEntry, handleProjectLogCommented, handleProjectLogUpdated, handleProjectLogsError } from './project-logs.js';
45
+ import { handleProjectLogsState, handleProjectLogEntry, handleProjectLogCommented, handleProjectLogCommentReviewed, handleProjectLogDeleted, handleProjectLogUpdated, handleProjectLogsError } from './project-logs.js';
45
46
  import { handleSkillInstalled, handleSkillUninstalled } from './skills.js';
46
47
  import { showRewindModal, onRewindComplete, setRewindMode, onRewindError, clearPendingRewindUuid, addRewindButton } from './rewind.js';
47
48
  import { checkAdminAccess } from './admin.js';
@@ -163,6 +164,20 @@ export function processMessage(msg) {
163
164
  }
164
165
 
165
166
  switch (msg.type) {
167
+ case "message_ack":
168
+ var acknowledgedEl = msg.clientMessageId
169
+ ? messagesEl.querySelector('[data-client-message-id="' + msg.clientMessageId + '"]')
170
+ : null;
171
+ acknowledgeMessage(msg.clientMessageId);
172
+ if (!acknowledgedEl && msg.message) {
173
+ addUserMessage(msg.message.text, msg.message.images || null, msg.message.pastes || null, msg.message.from, msg.message.fromName);
174
+ var acknowledgedUsers = messagesEl.querySelectorAll(".msg-user");
175
+ if (acknowledgedUsers.length > 0) {
176
+ acknowledgedUsers[acknowledgedUsers.length - 1].dataset.clientMessageId = msg.clientMessageId;
177
+ }
178
+ }
179
+ break;
180
+
166
181
  case "history_meta":
167
182
  store.set({ historyFrom: msg.from, historyTotal: msg.total, replayingHistory: true });
168
183
  updateHistorySentinel();
@@ -766,6 +781,7 @@ export function processMessage(msg) {
766
781
  ? msg.runtimeTerminalId
767
782
  : (typeof msg.terminalId === "number" ? msg.terminalId : null);
768
783
  store.set({ activeSessionId: msg.id, cliSessionId: msg.cliSessionId || null, currentModel: msg.model || "", currentEffort: msg.effort || store.get('currentEffort'), vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing, activeSessionMode: _effectiveMode, activeTerminalId: _effectiveTerminalId, sessionHasHistory: !!msg.hasHistory, sessionVendorBound: !!msg.vendor || !!msg.hasHistory, sessionFullAccess: msg.permissionMode === "bypassPermissions", currentMode: msg.permissionMode || store.get('currentMode') });
784
+ replayPendingMessages(msg.id);
769
785
  // TUI sessions swap the chat UI for an embedded xterm running
770
786
  // `claude` inside a real PTY. Mount or tear down before the rest of
771
787
  // the chat-side bookkeeping runs so we don't waste work on hidden DOM.
@@ -887,6 +903,10 @@ export function processMessage(msg) {
887
903
  } else {
888
904
  addUserMessage(msg.text, msg.images || null, msg.pastes || null, msg.from, msg.fromName, msg.delegated ? msg : null);
889
905
  }
906
+ if (msg.clientMessageId) {
907
+ var replayedUsers = messagesEl.querySelectorAll(".msg-user");
908
+ if (replayedUsers.length > 0) replayedUsers[replayedUsers.length - 1].dataset.clientMessageId = msg.clientMessageId;
909
+ }
890
910
  // Split Worker pane: a delegated instruction starts (or replays into) a
891
911
  // driven turn; surface the composer notice. Cleared on "done".
892
912
  if (msg.delegated) showWorkerDelegationNotice(msg.delegatedByTitle || null);
@@ -1608,6 +1628,14 @@ export function processMessage(msg) {
1608
1628
  handleProjectLogCommented(msg);
1609
1629
  break;
1610
1630
 
1631
+ case "project_log_comment_reviewed":
1632
+ handleProjectLogCommentReviewed(msg);
1633
+ break;
1634
+
1635
+ case "project_log_deleted":
1636
+ handleProjectLogDeleted(msg);
1637
+ break;
1638
+
1611
1639
  case "project_log_updated":
1612
1640
  handleProjectLogUpdated(msg);
1613
1641
  break;