clay-server 4.0.0-beta.11 → 4.0.0-beta.13

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.
@@ -21,21 +21,22 @@ var LOGS_CONTRACT =
21
21
  "Do not log conversation summaries, task narration, restatements of the request, completed-work announcements, speculation, or anything the repository and its history already record. " +
22
22
  "Every write is attributed and permanently revision-tracked, so keep entries short, concrete, and true.";
23
23
 
24
- // Learning moments are a durable project asset, so capturing them is a default
25
- // rather than an option. The point is to help the person's own model get
26
- // sharper, not to replace their thinking: a concept they can name is a concept
27
- // they can reuse without asking again.
24
+ // User learning moments are a durable project asset, so capturing them is a
25
+ // default rather than an option. This category is about a change in the user's
26
+ // conceptual model, never knowledge the Driver acquired while doing the work.
28
27
  var LEARNING_CONTRACT =
29
- "Capture durable learning moments as Project Logs, normally under the category `learning`. " +
30
- "There are two kinds. First, the person asks a conceptual question directly and the answer is durable and relevant to this project. " +
31
- "Second, and easier to miss: the person describes something in their own approximate words and you identify the precise term, model, or mechanism behind it. " +
28
+ "Capture durable user learning moments as Project Logs, normally under the category `learning`. " +
29
+ "A learning entry is exclusively about the user's learning: the user must have engaged with a concept they did not previously know, or expressed an approximate mental model that you made more precise. It is never a record of something you, the Driver, learned or discovered while inspecting the project. " +
30
+ "There are two kinds. First, the user asks a conceptual question directly and the answer is durable and relevant to this project. " +
31
+ "Second, and easier to miss: the user describes something in their own approximate words and you identify the precise term, model, or mechanism behind it. " +
32
32
  "If someone says the background is transparent and blurry and you name that as backdrop blur, implemented with the CSS backdrop-filter property, that is a learning moment and it should not evaporate when the conversation scrolls away. " +
33
- "Record four things: the person's original wording or mental model, the precise concept it corresponds to, why and how it applies in this project, and any boundary or common misconception worth knowing. " +
33
+ "Record four things: the user's original wording or mental model, the precise concept it corresponds to, why and how it applies in this project, and any boundary or common misconception worth knowing. " +
34
34
  "Write the title and summary so they teach at a glance: someone reading only the ledger row should come away knowing the concept. " +
35
35
  "When it comes to learning, always capture once these criteria are met; treat it as the default rather than a judgement call. " +
36
36
  "Never fabricate a learning moment, and never claim someone learned something they did not actually engage with. " +
37
37
  "Attribute respectfully and factually: write that a concept was clarified in discussion. Never grade, rank, or characterise the person's knowledge. " +
38
- "Do not log routine command syntax, trivial confirmations, facts the person clearly already knows, or every explanation you happen to give. Capture when a conceptual model becomes measurably more precise. " +
38
+ "Do not classify engineering lessons, repository discoveries, investigation outcomes, defect causes, implementation insights, decisions, or facts you learned during the work as learning; use an appropriate category such as `investigation`, `defect`, `decision`, or `reference`. " +
39
+ "Do not log routine command syntax, trivial confirmations, facts the user clearly already knows, or every explanation you happen to give. Capture when the user's conceptual model becomes measurably more precise. " +
39
40
  "When new learning refines or supersedes an existing learning entry, revise that entry instead of adding a near-duplicate.";
40
41
 
41
42
  // Sticky Notes and Project Logs are different layers, and the failure mode is
@@ -71,9 +72,10 @@ var CATEGORY_DESCRIPTION = "Record category: a short lowercase hyphen-separated
71
72
  "Letters and digits in any script are accepted, so a project may keep its vocabulary in the language it works in. " +
72
73
  "This project's own vocabulary, not a fixed list. Call list_logs or search_logs first and reuse an established category when one fits; " +
73
74
  "coin a new concise one only when this project needs a durable distinction it does not yet have. " +
74
- "Common starting points are " + logsSchema.SEED_CATEGORIES.join(", ") + ". A category is dry metadata, never a persona or an identifier.";
75
+ "Common starting points are " + logsSchema.SEED_CATEGORIES.join(", ") + ". A category is dry metadata, never a persona or an identifier. " +
76
+ "Use `learning` only for a user learning moment described by the learning contract, never for knowledge or lessons acquired by the Driver.";
75
77
  var PRIORITY_DESCRIPTION = "How much this outranks routine work: " + logsSchema.PRIORITIES.join(", ") + ". Defaults to normal. Priority is independent of category, so an urgent decision is both.";
76
- var SUMMARY_DESCRIPTION = "One or two sentences explaining what was decided or done and why. This is what a reader sees in the ledger, so it must stand alone. For a learning entry, name the concept plainly enough that the row itself teaches it.";
78
+ var SUMMARY_DESCRIPTION = "One or two sentences explaining what was decided or done and why. This is what a reader sees in the ledger, so it must stand alone. For a learning entry, identify the concept the user engaged with plainly enough that the row itself teaches it.";
77
79
  var REF_DESCRIPTION = "Opaque log reference returned by list_logs, search_logs, or create_log.";
78
80
 
79
81
  function textResult(value) {
@@ -152,7 +154,7 @@ function projectTools(bound) {
152
154
  priority: { type: "string", enum: logsSchema.PRIORITIES, description: PRIORITY_DESCRIPTION },
153
155
  title: { type: "string", description: "Short factual title, plain text, written like a good commit subject." },
154
156
  summary: { type: "string", description: SUMMARY_DESCRIPTION },
155
- body: { type: "string", description: "The durable facts, context, and outcome in Markdown. No narration of your own activity. For a learning entry, cover the original wording or mental model, the precise concept, how it applies here, and any boundary or misconception." },
157
+ body: { type: "string", description: "The durable facts, context, and outcome in Markdown. No narration of your own activity. For a learning entry, cover the user's original wording or mental model, the precise concept, how it applies here, and any boundary or misconception." },
156
158
  tags: { type: "string", description: "Optional JSON array of short tag strings." },
157
159
  }, ["kind", "title", "summary"]),
158
160
  handler: handler(bound, "createLog"),
@@ -130,6 +130,7 @@ function activityStatus(session) {
130
130
  function attachPairLifecycle(ctx) {
131
131
  var sm = ctx.sm;
132
132
  var store = ctx.splitStore;
133
+ var turnControl = ctx.turnControl;
133
134
 
134
135
  // Per-Driver ledger of the Worker generations it has run, newest last.
135
136
  // Lives on the live Driver session object: it informs the Driver's next
@@ -219,7 +220,7 @@ function attachPairLifecycle(ctx) {
219
220
  function partnerStatus(caller) {
220
221
  var resolved = resolveDriverPair(caller);
221
222
  var worker = resolved.worker;
222
- var blocked = replaceBlockedReason(worker);
223
+ var blocked = turnControl.blockedReason(caller) || replaceBlockedReason(worker);
223
224
  var ledger = ledgerFor(caller).map(function (record) {
224
225
  return {
225
226
  generation: record.generation,
@@ -242,6 +243,7 @@ function attachPairLifecycle(ctx) {
242
243
  activity: activityStatus(worker),
243
244
  context: contextStatus(worker),
244
245
  continuity: continuityStatus(worker),
246
+ orchestration: turnControl.status(caller),
245
247
  replaceSafe: !blocked,
246
248
  replaceBlockedReason: blocked,
247
249
  driverTier: resolved.tier ? resolved.tier.name : null,
@@ -258,6 +260,7 @@ function attachPairLifecycle(ctx) {
258
260
  var resolved = resolveDriverPair(caller);
259
261
  var group = resolved.group;
260
262
  var oldWorker = resolved.worker;
263
+ turnControl.assertWorkerAction(caller);
261
264
 
262
265
  // Validate the replacement while the old pair is still fully intact. An
263
266
  // uninstalled vendor, an unavailable model or an unsupported effort must
@@ -275,6 +278,9 @@ function attachPairLifecycle(ctx) {
275
278
  "; call again with interrupt set to true to stop it first");
276
279
  }
277
280
 
281
+ if (args.evaluation) validateEvaluation(args.evaluation);
282
+ var creationTicket = turnControl.reserveCreation(caller, "replace");
283
+
278
284
  if (blocked) {
279
285
  oldWorker.taskStopRequested = true;
280
286
  if (oldWorker.abortController) {
@@ -290,15 +296,12 @@ function attachPairLifecycle(ctx) {
290
296
  ctx.cancelWorkerPermissions(oldWorker, "The Driver replaced this Split Worker.");
291
297
  }
292
298
 
293
- // The outgoing generation is closed only after the replacement exists, so a
294
- // rollback leaves its ledger entry open and its evaluation untouched. The
295
- // evaluation shape is still checked here, before anything is destroyed, so
296
- // a malformed assessment cannot dissolve the pair and then be rejected.
297
- if (args.evaluation) validateEvaluation(args.evaluation);
298
-
299
299
  var ws = { _clayUser: caller.ownerId ? { id: caller.ownerId } : null };
300
300
  var dissolved = store.dissolve(ws, { id: group.id });
301
- if (!dissolved.ok) throw new Error(dissolved.error || "could not dissolve the existing pair");
301
+ if (!dissolved.ok) {
302
+ turnControl.releaseCreation(creationTicket);
303
+ throw new Error(dissolved.error || "could not dissolve the existing pair");
304
+ }
302
305
 
303
306
  // History is preserved: the old Worker session stays in the project.
304
307
  //
@@ -334,6 +337,7 @@ function attachPairLifecycle(ctx) {
334
337
  var interruptNote = blocked
335
338
  ? " Its interrupted turn cannot be resumed, because stopping it was explicitly requested."
336
339
  : "";
340
+ turnControl.releaseCreation(creationTicket);
337
341
  throw new Error("could not create the replacement Split Worker: " + (e.message || String(e)) +
338
342
  ". " + restoreNote + interruptNote);
339
343
  }
@@ -2,12 +2,11 @@ var pairMcp = require("./session-pair-mcp-server");
2
2
  var pairPrompts = require("./session-pair-prompts");
3
3
  var { attachPairFactory } = require("./session-pair-factory");
4
4
  var { attachPairLifecycle } = require("./project-pair-lifecycle");
5
+ var { attachPairTurnControl } = require("./session-pair-turn-control");
5
6
  var driverEligibility = require("./session-driver-eligibility");
6
7
  var { attachWorkerPermission } = require("./project-worker-permission");
7
8
  var { attachWorkerProposal } = require("./project-worker-proposal");
8
-
9
9
  var MAX_RESPONSE_CHARS = 30000;
10
-
11
10
  function toolResult(value) {
12
11
  return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
13
12
  }
@@ -65,13 +64,10 @@ function attachSessionPair(ctx) {
65
64
  var workerProposal;
66
65
  var workerPermission;
67
66
  var lifecycle;
68
-
67
+ var turnControl = attachPairTurnControl({ sm: sm, splitStore: store });
69
68
  function groupAndPartner(caller) {
70
69
  if (!caller) throw new Error("partner tools require a session-bound tool server");
71
- // Exact live object identity. MCP tool handlers are captured per query and
72
- // outlive the session they were bound to, so a stale session object — or a
73
- // different object reusing the same localId — must not be able to drive a
74
- // pair through a handler someone still holds a reference to.
70
+ // Captured handlers must remain bound to the exact live Session object.
75
71
  if (sm.sessions.get(caller.localId) !== caller) {
76
72
  throw new Error("this session is no longer live; the partner tools are bound to an exact session");
77
73
  }
@@ -106,11 +102,10 @@ function attachSessionPair(ctx) {
106
102
  broadcastDelegation(group, caller, partner, false);
107
103
  }
108
104
 
109
- // Deliver an internal message to the Driver and make sure it is running so
110
- // the message is actually consumed. Shared by the delegated-result push-back
111
- // and by Split Worker permission routing, so both use one resume path.
105
+ // Shared resume path for delegated results and Worker permission routing.
112
106
  function resumeDriverWithMessage(caller, text, meta) {
113
107
  if (!caller || caller.destroying) return false;
108
+ if (turnControl.blockedReason(caller)) return false;
114
109
  var record = {
115
110
  type: "user_message",
116
111
  text: text,
@@ -165,9 +160,7 @@ function attachSessionPair(ctx) {
165
160
  });
166
161
  }
167
162
 
168
- // A Driver blocked inside a waiting send_to_partner cannot answer a tool
169
- // call. Asking the wait to detach lets its turn continue; the delegated
170
- // result then arrives through the existing detached push-back path.
163
+ // Give a waiting Driver its turn back so it can answer Worker permissions.
171
164
  function requestDetach(worker) {
172
165
  var token = worker && worker._pairDelegation;
173
166
  if (!token || token.detached || token.detachRequested) return false;
@@ -216,9 +209,7 @@ function attachSessionPair(ctx) {
216
209
  });
217
210
  return;
218
211
  }
219
- // Either the deadline passed, or something needs the Driver's turn
220
- // back before then (a Split Worker permission request). Both detach
221
- // the same way, so the completed result still returns automatically.
212
+ // Timeouts and permission requests both detach into the push-back path.
222
213
  if (Date.now() >= deadline || token.detachRequested) {
223
214
  clearInterval(timer);
224
215
  token.detached = true;
@@ -245,9 +236,12 @@ function attachSessionPair(ctx) {
245
236
  if (caller && caller._delegatedBy) throw new Error("delegated turns cannot delegate to another session");
246
237
  var message = typeof args.message === "string" ? args.message.trim() : "";
247
238
  if (!message) throw new Error("message is required");
239
+ turnControl.assertWorkerAction(caller);
248
240
  var created = null;
249
241
  if (caller && !store.groupForMember(caller.localId)) {
250
- created = createWorkerForDriver(caller, args);
242
+ var creationTicket = turnControl.reserveCreation(caller, "create");
243
+ try { created = createWorkerForDriver(caller, args); }
244
+ catch (createError) { turnControl.releaseCreation(creationTicket); throw createError; }
251
245
  }
252
246
  var resolved = groupAndPartner(caller);
253
247
  if (created && created.worker) lifecycle.recordGenerationStart(caller, created.worker);
@@ -322,8 +316,7 @@ function attachSessionPair(ctx) {
322
316
  title: resolved.partner.title || "New Session",
323
317
  turns: count > 0 ? recentTurns(resolved.partner, count) : [],
324
318
  };
325
- // The same bounded capacity report partner_status returns, so a Driver
326
- // already reading turns needs no second call; null for anyone else.
319
+ // partner_status's bounded report, so reading turns needs no second call.
327
320
  payload.capacity = lifecycle.optionalStatus(caller);
328
321
  return toolResult(payload);
329
322
  } catch (e) {
@@ -368,6 +361,13 @@ function attachSessionPair(ctx) {
368
361
  }
369
362
  }
370
363
 
364
+ function handleHumanStop(session) {
365
+ var roles = turnControl.markHumanStop(session);
366
+ if (!roles) return false;
367
+ workerPermission.cancelForSession(roles.worker, "The human stopped this Split Worker turn.");
368
+ return true;
369
+ }
370
+
371
371
  function getToolDefs(boundSession) {
372
372
  if (!boundSession) return pairMcp.getToolDefs({
373
373
  send: function () { return toolError(new Error("send_to_partner requires a session-bound tool server")); },
@@ -378,40 +378,37 @@ function attachSessionPair(ctx) {
378
378
  replace: function () { return toolError(new Error("replace_partner requires a session-bound tool server")); },
379
379
  evaluate: function () { return toolError(new Error("record_partner_evaluation requires a session-bound tool server")); },
380
380
  });
381
- // Mount whenever we have a bound session: MCP servers are fixed for the
382
- // lifetime of a query, and Claude queries live across turns, so gating on
383
- // group membership HERE would permanently hide the tools from a session
384
- // whose split is created mid-conversation (the ad-hoc flow). Handlers
385
- // re-resolve the group on every call, so an ungrouped session gets a
386
- // clear error, and a session that gains a partner later just works.
387
- // The one structural exclusion: a session that is ALREADY a configured
388
- // pair worker at query start never sees the tools.
381
+ // Mount for the query lifetime; handlers re-resolve live pair state.
389
382
  var group = store.groupForMember(boundSession.localId);
390
383
  if (group && group.pair && group.pair.driverId !== boundSession.localId) return [];
391
- // A plain side-by-side split has no Driver role, so it keeps exactly the
392
- // partner tools it always had. Every path that would make this session a
393
- // Driver — an existing configured pair, or an unpaired session whose
394
- // send_to_partner would create one — requires the hard tier invariant. A
395
- // model below its family's threshold never receives the autonomous
396
- // lifecycle tools and cannot create or direct a pair.
384
+ // Plain splits keep base tools; autonomous Drivers must pass the tier gate.
397
385
  var adHocSplit = !!(group && !group.pair);
398
386
  if (!adHocSplit && !driverEligibility.isEligibleDriverSession(boundSession, sm)) return [];
399
387
  var lifecycleHandlers = lifecycle.toolHandlers(boundSession);
400
388
  var tools = pairMcp.getToolDefs({
401
- send: function (args) { return sendToPartner(args, boundSession); },
389
+ send: function (args) {
390
+ return turnControl.runOperation(boundSession, "send", args && args.operationId, function () {
391
+ return sendToPartner(args || {}, boundSession);
392
+ });
393
+ },
402
394
  read: function (args) { return readPartner(args, boundSession); },
403
395
  interrupt: function (args) { return interruptPartner(args, boundSession); },
404
396
  close: function (args) { return closePartner(args, boundSession); },
405
397
  status: lifecycleHandlers.status,
406
- replace: lifecycleHandlers.replace,
398
+ replace: function (args) {
399
+ return turnControl.runOperation(boundSession, "replace", args && args.operationId, function () {
400
+ return lifecycleHandlers.replace(args || {});
401
+ });
402
+ },
407
403
  evaluate: lifecycleHandlers.evaluate,
408
404
  }, { lifecycle: !adHocSplit });
409
405
  return tools
410
- .concat(workerPermission.getToolDefs(boundSession))
406
+ .concat(workerPermission.getToolDefs(boundSession, { dormantDriver: !group }))
411
407
  .concat(workerProposal.getToolDefs(boundSession));
412
408
  }
413
409
 
414
- var factory = attachPairFactory({ sm: sm, splitStore: store, ctx: ctx });
410
+ // Passed through as-is; re-wrapping drops fields. See session-pair-factory.js.
411
+ var factory = attachPairFactory(ctx);
415
412
  var createPairRecord = factory.createPairRecord;
416
413
  var createWorkerForDriver = factory.createWorkerForDriver;
417
414
  var createPair = factory.createPair;
@@ -427,6 +424,7 @@ function attachSessionPair(ctx) {
427
424
  cancelWorkerPermissions: function (worker, reason) {
428
425
  return workerPermission.cancelForSession(worker, reason);
429
426
  },
427
+ turnControl: turnControl,
430
428
  });
431
429
 
432
430
  workerPermission = attachWorkerPermission({
@@ -483,7 +481,9 @@ function attachSessionPair(ctx) {
483
481
  }
484
482
 
485
483
  return {
484
+ beginHumanTurn: turnControl.beginHumanTurn,
486
485
  getToolDefs: getToolDefs,
486
+ handleHumanStop: handleHumanStop,
487
487
  handleMessage: handleMessage,
488
488
  handleTurnDone: handleTurnDone,
489
489
  getSystemPrompt: getSystemPrompt,
@@ -1004,7 +1004,8 @@ function attachSessions(ctx) {
1004
1004
 
1005
1005
  if (msg.type === "stop") {
1006
1006
  var session = getSessionForWs(ws);
1007
- if (session && session.isProcessing) {
1007
+ if (session && (session.isProcessing || session._queryStarting)) {
1008
+ if (typeof ctx.onHumanPairStop === "function") ctx.onHumanPairStop(session);
1008
1009
  session.taskStopRequested = true;
1009
1010
  if (session.abortController) session.abortController.abort();
1010
1011
  }
@@ -331,6 +331,7 @@ function attachUserMessage(ctx) {
331
331
  });
332
332
  return true;
333
333
  }
334
+ if (typeof ctx.beginHumanPairTurn === "function") ctx.beginHumanPairTurn(session);
334
335
  if (gitAttribution) gitAttribution.beginTurn(session);
335
336
 
336
337
  // Bind vendor to session on first message (if not already set)
@@ -328,10 +328,11 @@ function attachWorkerPermission(ctx) {
328
328
  }
329
329
 
330
330
  // Exposed to the Driver only, and only while it really is a pair Driver.
331
- function getToolDefs(boundSession) {
331
+ function getToolDefs(boundSession, options) {
332
332
  if (!boundSession || !store) return [];
333
333
  var group = store.groupForMember(boundSession.localId);
334
- if (!group || !group.pair || group.pair.driverId !== boundSession.localId) return [];
334
+ var dormantDriver = !!(options && options.dormantDriver);
335
+ if (!dormantDriver && (!group || !group.pair || group.pair.driverId !== boundSession.localId)) return [];
335
336
  return [{
336
337
  name: "respond_to_worker_permission",
337
338
  description: "Approve or deny one tool-permission request raised by your paired Split Worker. " +
package/lib/project.js CHANGED
@@ -1599,6 +1599,7 @@ function createProjectContext(opts) {
1599
1599
  matesModule: matesModule,
1600
1600
  pushModule: pushModule,
1601
1601
  getSessionForWs: getSessionForWs,
1602
+ onHumanPairStop: function (session) { return _sessionPair.handleHumanStop(session); },
1602
1603
  getLinuxUserForSession: getLinuxUserForSession,
1603
1604
  ensureProjectAccessForSession: ensureProjectAccessForSession,
1604
1605
  getOsUserInfoForWs: getOsUserInfoForWs,
@@ -1674,6 +1675,7 @@ function createProjectContext(opts) {
1674
1675
  if (isMate || !_sessionPair.workerPermission) return false;
1675
1676
  return _sessionPair.workerPermission.isDriverOperated(session);
1676
1677
  },
1678
+ beginHumanPairTurn: function (session) { return _sessionPair.beginHumanTurn(session); },
1677
1679
  cwd: cwd,
1678
1680
  slug: slug,
1679
1681
  isMate: isMate,
package/lib/public/app.js CHANGED
@@ -344,8 +344,6 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
344
344
  projectLogsCommentStatusEl: null,
345
345
  projectLogsUnread: 0,
346
346
  projectLogsSeenRevisions: {},
347
- projectLogsPreview: false,
348
- projectLogsPinned: false,
349
347
  installedTools: [],
350
348
  homeToolRegistryLoaded: false,
351
349
  toolScanErrors: [],
@@ -1,4 +1,4 @@
1
- /* Project Logs — restrained, factual project ledger. */
1
+ /* Project Logs — a restrained editorial ledger, designed for close reading. */
2
2
 
3
3
  /* Wide screens: bounded workbench window beside #app, matching the geometry of
4
4
  the document viewer and the terminal. */
@@ -94,90 +94,32 @@
94
94
  .project-logs-icon-btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
95
95
  .project-logs-icon-btn:disabled { opacity: 0.4; cursor: default; }
96
96
  .project-logs-window-actions { display: inline-flex; align-items: center; gap: 6px; }
97
- /* --- Ambient edge handle ---------------------------------------------
98
- A slim notch on the right workbench boundary, offered only where a real
99
- pointer can hover. Touch keeps the explicit toolbar route. */
100
- @media (min-width: 1024px) {
101
- .project-logs-handle {
102
- position: absolute;
103
- top: 50%;
104
- right: 0;
105
- transform: translateY(-50%);
106
- width: 14px;
107
- height: 88px;
108
- padding: 0;
109
- display: flex;
110
- align-items: center;
111
- justify-content: center;
112
- border: 1px solid var(--border);
113
- border-right: 0;
114
- border-radius: 8px 0 0 8px;
115
- background: var(--bg-alt);
116
- cursor: pointer;
117
- z-index: 40;
118
- transition: width 0.16s ease, background 0.16s ease;
119
- }
120
- .project-logs-handle:hover,
121
- .project-logs-handle:focus-visible { width: 18px; background: var(--bg); }
122
- .project-logs-handle:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
123
- .project-logs-handle.hidden { display: none; }
124
- .project-logs-handle-notch {
125
- width: 3px;
126
- height: 34px;
127
- border-radius: 999px;
128
- background: var(--border);
129
- transition: background 0.16s ease, height 0.16s ease;
130
- }
131
- .project-logs-handle:hover .project-logs-handle-notch { background: var(--text-dimmer); }
132
- /* Unread: a brighter notch, no motion of its own. */
133
- .project-logs-handle.has-unread .project-logs-handle-notch { background: var(--accent); height: 46px; }
134
- }
135
-
136
- /* Touch and narrow viewports never depend on hover. */
137
- @media (max-width: 1023px) {
138
- .project-logs-handle { display: none; }
139
- }
140
-
141
- /* A preview is the same pane, revealed but uncommitted. */
142
- #project-logs-panel.project-logs-previewing { box-shadow: 0 0 0 1px var(--accent), 4px 8px 24px rgba(var(--shadow-rgb), 0.2); }
143
-
144
- /* --- Unread cues --- */
97
+ /* The explicit tool button is the only discovery affordance. */
145
98
  #project-logs-btn.project-logs-unread { color: var(--accent); }
146
99
  #project-logs-count { background: var(--accent); color: #fff; }
147
100
 
148
- @keyframes project-logs-pulse {
149
- 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent); }
150
- 70% { box-shadow: 0 0 0 7px transparent; }
151
- 100% { box-shadow: 0 0 0 0 transparent; }
152
- }
153
- .project-logs-pulse { animation: project-logs-pulse 1.1s ease-out 2; }
154
-
155
- /* Reduced motion keeps the static unread marker and drops every flourish. */
156
- @media (prefers-reduced-motion: reduce) {
157
- .project-logs-pulse { animation: none; }
158
- .project-logs-handle { transition: none; }
159
- .project-logs-handle-notch { transition: none; }
160
- }
161
-
162
101
  /* --- Ledger toolbar --- */
163
- .project-logs-toolbar { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border-subtle); flex: 0 0 auto; }
102
+ .project-logs-toolbar { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border-subtle); flex: 0 0 auto; background: color-mix(in srgb, var(--bg-alt) 72%, transparent); }
164
103
  .project-logs-toolbar.hidden { display: none; }
165
- .project-logs-search { flex: 1; min-width: 0; height: 32px; display: flex; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-alt); color: var(--text-dimmer); }
104
+ .project-logs-search { flex: 1; min-width: 0; height: 36px; display: flex; align-items: center; gap: 8px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text-dimmer); }
166
105
  .project-logs-search .lucide { width: 14px; flex: 0 0 auto; }
167
106
  .project-logs-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); font: inherit; font-size: 12px; }
168
- .project-logs-filter { height: 32px; max-width: 42%; padding: 0 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-alt); color: var(--text-secondary); font: inherit; font-size: 12px; cursor: pointer; }
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; }
169
109
 
170
110
  /* --- Ledger list: full pane, chronological, commit-history feel --- */
171
- .project-logs-list { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 0; }
111
+ .project-logs-list { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 0 18px; }
172
112
  .project-logs-list.hidden { display: none; }
173
- .project-log-row { width: 100%; display: block; padding: 12px 14px; border: 0; border-bottom: 1px solid var(--border-subtle); background: transparent; color: inherit; text-align: left; cursor: pointer; }
174
- .project-log-row:hover { background: rgba(var(--overlay-rgb), 0.05); }
113
+ .project-log-row { width: 100%; display: block; padding: 15px 18px 14px; border: 0; border-bottom: 1px solid var(--border-subtle); border-left: 2px solid transparent; background: transparent; color: inherit; text-align: left; cursor: pointer; transition: background 0.14s ease, border-color 0.14s ease; }
114
+ .project-log-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); border-left-color: var(--accent); }
115
+ .project-log-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
175
116
  .project-log-row:last-child { border-bottom: 0; }
176
117
  .project-log-row-head { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; }
177
- .project-log-row-title { flex: 1; min-width: 0; font-size: 13px; font-weight: 650; color: var(--text); }
178
- .project-log-row-summary { margin: 5px 0 0; color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
179
- .project-log-row-meta { margin-top: 6px; color: var(--text-dimmer); font-size: 10.5px; font-family: var(--font-mono); }
180
- .project-logs-list-empty { padding: 32px 20px; color: var(--text-dimmer); font-size: 12px; text-align: center; line-height: 1.6; }
118
+ .project-log-row-title { flex: 1; min-width: 0; font-size: 14px; font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
119
+ .project-log-row-summary { margin: 7px 0 0; color: var(--text-secondary); font-size: 12.5px; line-height: 1.55; }
120
+ .project-log-row-meta { margin-top: 8px; color: var(--text-dimmer); font-size: 10.5px; font-family: var(--font-mono); }
121
+ .project-logs-list-empty { max-width: 320px; margin: 54px auto; padding: 20px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); color: var(--text-dimmer); font-size: 12px; text-align: center; line-height: 1.7; }
122
+ .project-logs-list[aria-busy="true"] .project-logs-list-empty { color: var(--text-secondary); font-family: var(--font-mono); }
181
123
 
182
124
  /* Category chip and priority signal. Categories are project-defined, so the
183
125
  chip is deliberately neutral: no per-category selectors and no exhaustive
@@ -191,13 +133,13 @@
191
133
  /* --- Entry detail: full pane, replaces the list --- */
192
134
  .project-logs-detail { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; }
193
135
  .project-logs-detail.hidden { display: none; }
194
- .project-log-document { max-width: 820px; margin: 0 auto; padding: 22px 22px 40px; }
136
+ .project-log-document { max-width: 760px; margin: 0 auto; padding: 32px 28px 52px; }
195
137
  #project-logs-panel.panel-fullscreen .project-log-document { padding: clamp(28px, 5vw, 64px); }
196
138
  .project-log-doc-chips { display: flex; align-items: center; gap: 7px; margin-bottom: 10px; }
197
- .project-log-document h1 { margin: 0 0 8px; color: var(--text); font-family: var(--font-display); font-size: clamp(20px, 2.4vw, 28px); line-height: 1.2; }
198
- .project-log-doc-summary { margin: 0 0 10px; color: var(--text-secondary); font-size: 13px; line-height: 1.55; }
199
- .project-log-byline { margin: 0 0 18px; padding-bottom: 16px; border-bottom: 1px solid var(--border-subtle); color: var(--text-dimmer); font-size: 11px; font-family: var(--font-mono); }
200
- .project-log-markdown { line-height: 1.65; }
139
+ .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
+ .project-log-doc-summary { margin: 0 0 13px; color: var(--text-secondary); font-size: 14px; line-height: 1.6; }
141
+ .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); }
142
+ .project-log-markdown { font-size: 14px; line-height: 1.75; }
201
143
 
202
144
  /* --- Discussion --- */
203
145
  .project-log-discussion { margin-top: 30px; padding-top: 18px; border-top: 1px solid var(--border-subtle); }
@@ -230,5 +172,9 @@
230
172
 
231
173
  @media (max-width: 768px) {
232
174
  .project-logs-subtitle { display: none; }
233
- .project-log-document { padding: 18px 16px 32px; }
175
+ .project-logs-topbar { gap: 8px; padding: 0 12px; }
176
+ .project-logs-toolbar { padding: 10px 12px; }
177
+ .project-logs-filter { max-width: 44%; }
178
+ .project-log-row { padding: 14px 14px 13px; }
179
+ .project-log-document { padding: 24px 18px 38px; }
234
180
  }
@@ -159,12 +159,12 @@ function renderRow(entry, onOpen) {
159
159
  return row;
160
160
  }
161
161
 
162
- export function renderList(listEl, entries, onOpen) {
162
+ export function renderList(listEl, entries, onOpen, emptyState) {
163
163
  listEl.innerHTML = "";
164
164
  if (!entries.length) {
165
165
  var empty = document.createElement("div");
166
166
  empty.className = "project-logs-list-empty";
167
- empty.textContent = "No logs yet. This project's agent sessions record decisions and work here.";
167
+ empty.textContent = emptyState || "No logs yet. This project's agent sessions record decisions and work here.";
168
168
  listEl.appendChild(empty);
169
169
  return;
170
170
  }