clay-server 3.0.0 → 3.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/notes.js CHANGED
@@ -59,6 +59,12 @@ function createNotesManager(opts) {
59
59
  createdAt: now,
60
60
  updatedAt: now,
61
61
  };
62
+ if (data.origin && data.origin.sessionId !== undefined) {
63
+ note.origin = {
64
+ sessionId: data.origin.sessionId,
65
+ vendor: data.origin.vendor || "claude",
66
+ };
67
+ }
62
68
  notes.push(note);
63
69
  saveToDisk();
64
70
  return note;
@@ -676,13 +676,10 @@ function attachHTTP(ctx) {
676
676
  defBr = hrRef.replace(/^origin\//, "");
677
677
  } catch (e) {}
678
678
  res.writeHead(200, { "Content-Type": "application/json" });
679
- res.end(JSON.stringify({ branches: brList, defaultBranch: defBr, isGitRepo: true }));
679
+ res.end(JSON.stringify({ branches: brList, defaultBranch: defBr }));
680
680
  } catch (e) {
681
- // git failed: not a repository (or git missing). The branch chip
682
- // hides itself on isGitRepo:false; the fallback branch list keeps
683
- // the worktree modal from crashing if it is somehow reached.
684
681
  res.writeHead(200, { "Content-Type": "application/json" });
685
- res.end(JSON.stringify({ branches: ["main"], defaultBranch: "main", isGitRepo: false }));
682
+ res.end(JSON.stringify({ branches: ["main"], defaultBranch: "main" }));
686
683
  }
687
684
  return true;
688
685
  }
@@ -700,7 +697,7 @@ function attachHTTP(ctx) {
700
697
  }
701
698
  var sessionId = Number(body.sessionId);
702
699
  if (!Number.isInteger(sessionId)) sessionId = null;
703
- var handler = getMcpBridgeHandler(sessionId, body.pairOnly === true);
700
+ var handler = getMcpBridgeHandler(sessionId, body.sessionOnly === true);
704
701
  if (!handler) {
705
702
  res.writeHead(500, { "Content-Type": "application/json" });
706
703
  res.end('{"error":"MCP bridge handler unavailable"}');
@@ -0,0 +1,217 @@
1
+ var sessionNotesMcp = require("./session-notes-mcp-server");
2
+
3
+ var MAX_NOTE_TEXT_CHARS = 2000;
4
+ var MAX_ACTIVE_NOTES = 20;
5
+ var MAX_PROMPT_CHARS = 2000;
6
+ var MAX_NOTE_PROMPT_CHARS = 240;
7
+ var NOTES_LABEL = "--- Project sticky notes (shared memory; manage via clay-notes tools) ---";
8
+ var NOTES_HINT = "- More notes are available; call list_notes for the rest.";
9
+ var PROACTIVE_POLICY = "Use the board proactively: when the user states a decision, preference, correction, or durable project fact that future sessions will need, record it with write_note WITHOUT being asked, and say so in one short clause. The bar: a person skimming the board a week from now must still find the note useful, and it must stand on its own without this conversation. Never write announcement or narration notes ('leaving a note', 'did X just now'), routine progress, transient state, or anything the repo already records. Update or remove your stale notes instead of adding near-duplicates. A task should rarely add more than one or two notes.";
10
+ var NOTE_COLORS = ["yellow", "blue", "green", "pink", "orange", "purple"];
11
+
12
+ function toolResult(value) {
13
+ return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
14
+ }
15
+
16
+ function toolError(message) {
17
+ return Promise.resolve({
18
+ content: [{ type: "text", text: "Error: " + message }],
19
+ isError: true,
20
+ });
21
+ }
22
+
23
+ function activeNotes(notes) {
24
+ return (notes || []).filter(function (note) {
25
+ return note && !note.hidden;
26
+ });
27
+ }
28
+
29
+ function memoryNotes(notes) {
30
+ return activeNotes(notes).filter(function (note) {
31
+ return typeof note.text === "string" && note.text.trim();
32
+ });
33
+ }
34
+
35
+ function publicNote(note) {
36
+ return {
37
+ id: note.id,
38
+ text: note.text,
39
+ color: note.color,
40
+ updatedAt: note.updatedAt,
41
+ origin: note.origin || null,
42
+ };
43
+ }
44
+
45
+ function findNote(notes, id) {
46
+ for (var i = 0; i < notes.length; i++) {
47
+ if (notes[i] && notes[i].id === id) return notes[i];
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function autoPlacement(notes) {
53
+ if (!notes || notes.length === 0) return { x: 100, y: 100 };
54
+ var last = notes[notes.length - 1];
55
+ var baseX = typeof last.x === "number" ? last.x : 100;
56
+ var baseY = typeof last.y === "number" ? last.y : 100;
57
+ var step = 1;
58
+ while (step <= notes.length + 1) {
59
+ var x = baseX + step * 30;
60
+ var y = baseY + step * 30;
61
+ var occupied = notes.some(function (note) {
62
+ return note && note.x === x && note.y === y;
63
+ });
64
+ if (!occupied) return { x: x, y: y };
65
+ step++;
66
+ }
67
+ return { x: baseX + (notes.length + 2) * 30, y: baseY + (notes.length + 2) * 30 };
68
+ }
69
+
70
+ function formatNoteLine(note) {
71
+ var prefix = "- " + (note.color ? "[" + note.color + "] " : "");
72
+ var text = note.text.trim();
73
+ var marker = "… (list_notes for the full note)";
74
+ if (prefix.length + text.length <= MAX_NOTE_PROMPT_CHARS) return prefix + text;
75
+ var previewLength = Math.max(0, MAX_NOTE_PROMPT_CHARS - prefix.length - marker.length);
76
+ return prefix + text.slice(0, previewLength).trimEnd() + marker;
77
+ }
78
+
79
+ function buildNotesPrompt(notes) {
80
+ var newest = memoryNotes(notes).sort(function (a, b) {
81
+ return (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0);
82
+ });
83
+ if (newest.length === 0) return NOTES_LABEL + "\n(board is empty)\n" + PROACTIVE_POLICY;
84
+ var lines = newest.map(formatNoteLine);
85
+ var full = NOTES_LABEL + "\n" + lines.join("\n");
86
+ if (full.length <= MAX_PROMPT_CHARS) return full + "\n" + PROACTIVE_POLICY;
87
+
88
+ var result = NOTES_LABEL;
89
+ var available = MAX_PROMPT_CHARS - result.length - NOTES_HINT.length - 2;
90
+ for (var i = 0; i < lines.length && available > 0; i++) {
91
+ var prefix = "\n";
92
+ if (prefix.length + lines[i].length <= available) {
93
+ result += prefix + lines[i];
94
+ available -= prefix.length + lines[i].length;
95
+ continue;
96
+ }
97
+ var partialLength = Math.max(0, available - prefix.length - 1);
98
+ if (partialLength > 0) result += prefix + lines[i].slice(0, partialLength) + "…";
99
+ break;
100
+ }
101
+ return (result + "\n" + NOTES_HINT).slice(0, MAX_PROMPT_CHARS) + "\n" + PROACTIVE_POLICY;
102
+ }
103
+
104
+ function composeSystemPrompts(parts) {
105
+ return (parts || []).filter(function (part) { return typeof part === "string" && part.trim(); }).join("\n\n");
106
+ }
107
+
108
+ function attachSessionNotes(ctx) {
109
+ var nm = ctx.nm;
110
+ var send = ctx.send || function () {};
111
+ var broadcastWritten = ctx.broadcastWritten || function () {};
112
+
113
+ function notifyWritten(note, caller) {
114
+ broadcastWritten({
115
+ type: "note_written",
116
+ id: note.id,
117
+ byTitle: caller.title || "Agent",
118
+ vendor: caller.vendor || "claude",
119
+ preview: (note.text || "").slice(0, 60),
120
+ });
121
+ }
122
+
123
+ function listNotes(args, caller) {
124
+ if (!caller) return toolError("list_notes requires a session-bound tool server");
125
+ return toolResult(activeNotes(nm.list()).map(publicNote));
126
+ }
127
+
128
+ function writeNote(args, caller) {
129
+ if (!caller) return toolError("write_note requires a session-bound tool server");
130
+ var rawText = typeof args.text === "string" ? args.text : "";
131
+ if (rawText.length > MAX_NOTE_TEXT_CHARS) return toolError("text exceeds 2000 characters");
132
+ var text = rawText.trim();
133
+ if (!text) return toolError("text is required");
134
+ var notes = nm.list() || [];
135
+ var color = NOTE_COLORS.indexOf(args.color) !== -1 ? args.color : undefined;
136
+ if (args.id) {
137
+ var existing = findNote(notes, args.id);
138
+ if (!existing) return toolError("note not found: " + args.id);
139
+ var changes = { text: text };
140
+ if (color) changes.color = color;
141
+ var updated = nm.update(args.id, changes);
142
+ if (!updated) return toolError("note could not be updated: " + args.id);
143
+ send({ type: "note_updated", note: updated });
144
+ notifyWritten(updated, caller);
145
+ return toolResult(publicNote(updated));
146
+ }
147
+ if (activeNotes(notes).length >= MAX_ACTIVE_NOTES) {
148
+ return toolError("20 active notes already exist; consolidate or remove stale notes before creating another");
149
+ }
150
+ var placement = autoPlacement(notes);
151
+ var created = nm.create({
152
+ text: text,
153
+ color: color || "yellow",
154
+ x: placement.x,
155
+ y: placement.y,
156
+ origin: { sessionId: caller.localId, vendor: caller.vendor || "claude" },
157
+ });
158
+ if (!created) return toolError("note could not be created");
159
+ send({ type: "note_created", note: created });
160
+ notifyWritten(created, caller);
161
+ return toolResult(publicNote(created));
162
+ }
163
+
164
+ function removeNote(args, caller) {
165
+ if (!caller) return toolError("remove_note requires a session-bound tool server");
166
+ var note = findNote(nm.list() || [], args.id);
167
+ if (!note) return toolError("note not found: " + (args.id || "unknown"));
168
+ if (!note.origin || note.origin.sessionId !== caller.localId) {
169
+ return toolError("this session can only remove notes it created");
170
+ }
171
+ if (!nm.remove(note.id)) return toolError("note could not be removed: " + note.id);
172
+ send({ type: "note_deleted", id: note.id });
173
+ return toolResult({ removed: true, id: note.id });
174
+ }
175
+
176
+ function getToolDefs(boundSession) {
177
+ if (ctx.isMate) return [];
178
+ return sessionNotesMcp.getToolDefs({
179
+ list: function (args) { return listNotes(args, boundSession || null); },
180
+ write: function (args) { return writeNote(args, boundSession || null); },
181
+ remove: function (args) { return removeNote(args, boundSession || null); },
182
+ });
183
+ }
184
+
185
+ function createMcpServer(adapter, boundSession) {
186
+ if (ctx.isMate || !adapter || typeof adapter.createToolServer !== "function") return null;
187
+ return adapter.createToolServer({
188
+ name: "clay-notes",
189
+ version: "1.0.0",
190
+ tools: getToolDefs(boundSession || null),
191
+ });
192
+ }
193
+
194
+ function getSystemPrompt() {
195
+ if (ctx.isMate) return "";
196
+ return buildNotesPrompt(nm.list());
197
+ }
198
+
199
+ return {
200
+ createMcpServer: createMcpServer,
201
+ getSystemPrompt: getSystemPrompt,
202
+ getToolDefs: getToolDefs,
203
+ };
204
+ }
205
+
206
+ module.exports = {
207
+ MAX_NOTE_TEXT_CHARS: MAX_NOTE_TEXT_CHARS,
208
+ MAX_ACTIVE_NOTES: MAX_ACTIVE_NOTES,
209
+ MAX_NOTE_PROMPT_CHARS: MAX_NOTE_PROMPT_CHARS,
210
+ MAX_PROMPT_CHARS: MAX_PROMPT_CHARS,
211
+ NOTES_LABEL: NOTES_LABEL,
212
+ PROACTIVE_POLICY: PROACTIVE_POLICY,
213
+ attachSessionNotes: attachSessionNotes,
214
+ autoPlacement: autoPlacement,
215
+ buildNotesPrompt: buildNotesPrompt,
216
+ composeSystemPrompts: composeSystemPrompts,
217
+ };
package/lib/project.js CHANGED
@@ -32,6 +32,7 @@ var { createLocalMcp } = require("./mcp-local");
32
32
  var { attachEmail: attachEmailModule } = require("./project-email");
33
33
  var { attachSessionSpawn } = require("./project-session-spawn");
34
34
  var { attachSessionPair } = require("./project-session-pair");
35
+ var { attachSessionNotes, composeSystemPrompts } = require("./project-session-notes");
35
36
  var { attachSplitGroups } = require("./session-split-groups");
36
37
  // project-notifications is attached globally in server.js, passed via opts.notificationsModule
37
38
 
@@ -492,6 +493,21 @@ function createProjectContext(opts) {
492
493
  },
493
494
  });
494
495
 
496
+ // Sticky-note storage is initialized before session tool wiring so every
497
+ // vendor receives the same handlers and prompt snapshot at query start.
498
+ var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
499
+ var _sessionNotes = attachSessionNotes({
500
+ nm: nm,
501
+ send: send,
502
+ isMate: isMate,
503
+ broadcastWritten: function (message) {
504
+ for (var noteWs of clients) {
505
+ if (noteWs.readyState !== 1 || noteWs._clayPane) continue;
506
+ sendTo(noteWs, message);
507
+ }
508
+ },
509
+ });
510
+
495
511
  // The SDK bridge is created after local MCP servers. Session spawning uses
496
512
  // a getter so tool handlers see the initialized bridge when they run.
497
513
  var _sessionPair = attachSessionPair({
@@ -539,6 +555,16 @@ function createProjectContext(opts) {
539
555
  }
540
556
  }
541
557
 
558
+ // Shared sticky-note memory (main projects only).
559
+ if (!isMate) {
560
+ try {
561
+ var sessionNotesMcpConfig = _sessionNotes.createMcpServer(adapter);
562
+ if (sessionNotesMcpConfig) servers[sessionNotesMcpConfig.name || "clay-notes"] = sessionNotesMcpConfig;
563
+ } catch (e) {
564
+ console.error("[project] Failed to create session notes MCP server:", e.message);
565
+ }
566
+ }
567
+
542
568
  // Debate MCP server (available to both mates and main project)
543
569
  try {
544
570
  var debateMcp = require("./debate-mcp-server");
@@ -702,15 +728,14 @@ function createProjectContext(opts) {
702
728
  // clay-email -> only when the user has an account or server SMTP
703
729
  //
704
730
  // forSession (optional): the session whose query these servers are mounted
705
- // into. clay-sessions must know its caller (depth guard + child ownership),
706
- // so it is re-instantiated bound to that session; the static instance in
707
- // mcpServers only serves descriptor listing and fails closed on calls.
731
+ // into. clay-sessions and clay-notes must know their caller for depth and
732
+ // ownership rules, so they are re-instantiated bound to that session; the
733
+ // static instances only serve descriptor listing and fail closed on calls.
708
734
  function getLocalMcpServers(forSession) {
709
- if (!mcpServers) return undefined;
710
735
  var extWs = browserState._extensionWs;
711
736
  var extConnected = !!(extWs && extWs.readyState === 1);
712
737
  var emailAvailable = !!(_email && typeof _email.hasEmailCapability === "function" && _email.hasEmailCapability());
713
- var keys = Object.keys(mcpServers);
738
+ var keys = Object.keys(mcpServers || {});
714
739
  var filtered = {};
715
740
  var hasAny = false;
716
741
  for (var i = 0; i < keys.length; i++) {
@@ -726,6 +751,15 @@ function createProjectContext(opts) {
726
751
  }
727
752
  continue;
728
753
  }
754
+ if (name === "clay-notes" && forSession) {
755
+ try {
756
+ var boundNotes = _sessionNotes.createMcpServer(adapter, forSession);
757
+ if (boundNotes) { filtered[name] = boundNotes; hasAny = true; }
758
+ } catch (e) {
759
+ console.error("[project] Failed to bind session notes MCP server:", e.message);
760
+ }
761
+ continue;
762
+ }
729
763
  filtered[name] = mcpServers[name];
730
764
  hasAny = true;
731
765
  }
@@ -769,8 +803,15 @@ function createProjectContext(opts) {
769
803
  }
770
804
  return false;
771
805
  },
772
- getSessionSystemPrompt: function (session) { return _sessionPair.getSystemPrompt(session); },
773
- getSessionToolDefs: function (session) { return _sessionPair.getToolDefs(session); },
806
+ getSessionSystemPrompt: function (session) {
807
+ return composeSystemPrompts([
808
+ _sessionPair.getSystemPrompt(session),
809
+ _sessionNotes.getSystemPrompt(session),
810
+ ]);
811
+ },
812
+ getSessionToolDefs: function (session) {
813
+ return _sessionPair.getToolDefs(session).concat(_sessionNotes.getToolDefs(session));
814
+ },
774
815
  });
775
816
 
776
817
  // --- Loop engine (delegated to project-loop.js) ---
@@ -807,7 +848,6 @@ function createProjectContext(opts) {
807
848
 
808
849
  // --- Terminal manager ---
809
850
  var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
810
- var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
811
851
 
812
852
  // Check for updates in background (admin only). The result is stored in
813
853
  // latestVersion; broadcast is handled by the hourly scheduler below, so
@@ -1375,8 +1415,8 @@ function createProjectContext(opts) {
1375
1415
  // --- MCP bridge handler for Codex and session-bound Kiro tools ---
1376
1416
  // Provides list_tools and call_tool operations over HTTP for mcp-bridge-server.js.
1377
1417
  // The normal Codex bridge excludes local MCP servers it manages natively;
1378
- // Kiro's pair-only bridge exposes only the tools bound to its Clay session.
1379
- function getMcpBridgeHandler(sessionId, pairOnly) {
1418
+ // Kiro's session-only bridge exposes only tools bound to its Clay session.
1419
+ function getMcpBridgeHandler(sessionId, sessionOnly) {
1380
1420
  var boundSession = Number.isInteger(sessionId) ? sm.sessions.get(sessionId) : null;
1381
1421
  // Build set of local MCP server names to exclude (Codex handles these natively)
1382
1422
  var localMcpNames = {};
@@ -1393,7 +1433,19 @@ function createProjectContext(opts) {
1393
1433
  listTools: function () {
1394
1434
  var tools = [];
1395
1435
  var toJSONSchema;
1396
- try { toJSONSchema = require("zod").toJSONSchema; } catch (e) { /* fallback */ }
1436
+ var zod;
1437
+ try { zod = require("zod"); toJSONSchema = zod.toJSONSchema; } catch (e) { /* fallback */ }
1438
+
1439
+ function normalizeToolSchema(inputSchema) {
1440
+ if (inputSchema && typeof inputSchema.type === "string") return inputSchema;
1441
+ try {
1442
+ if (toJSONSchema && inputSchema) {
1443
+ var schema = inputSchema.safeParse ? inputSchema : zod.object(inputSchema);
1444
+ return toJSONSchema(schema);
1445
+ }
1446
+ } catch (e) { /* fallback */ }
1447
+ return { type: "object", properties: {} };
1448
+ }
1397
1449
 
1398
1450
  // Helper to extract tools from an SDK MCP server object
1399
1451
  function extractServerTools(serverName, server) {
@@ -1401,10 +1453,7 @@ function createProjectContext(opts) {
1401
1453
  var toolNames = Object.keys(server.instance._registeredTools);
1402
1454
  for (var j = 0; j < toolNames.length; j++) {
1403
1455
  var toolDef = server.instance._registeredTools[toolNames[j]];
1404
- var inputSchema = { type: "object", properties: {} };
1405
- try {
1406
- if (toJSONSchema && toolDef.inputSchema) inputSchema = toJSONSchema(toolDef.inputSchema);
1407
- } catch (e) { /* fallback */ }
1456
+ var inputSchema = normalizeToolSchema(toolDef.inputSchema);
1408
1457
  tools.push({
1409
1458
  server: serverName,
1410
1459
  name: toolNames[j],
@@ -1421,12 +1470,21 @@ function createProjectContext(opts) {
1421
1470
  server: "clay-sessions",
1422
1471
  name: pairTools[pti].name,
1423
1472
  description: pairTools[pti].description || pairTools[pti].name,
1424
- inputSchema: pairTools[pti].inputSchema || { type: "object", properties: {} },
1473
+ inputSchema: normalizeToolSchema(pairTools[pti].inputSchema),
1474
+ });
1475
+ }
1476
+ var noteTools = _sessionNotes.getToolDefs(boundSession);
1477
+ for (var nti = 0; nti < noteTools.length; nti++) {
1478
+ tools.push({
1479
+ server: "clay-notes",
1480
+ name: noteTools[nti].name,
1481
+ description: noteTools[nti].description || noteTools[nti].name,
1482
+ inputSchema: normalizeToolSchema(noteTools[nti].inputSchema),
1425
1483
  });
1426
1484
  }
1427
1485
  }
1428
1486
 
1429
- if (pairOnly) return Promise.resolve(tools);
1487
+ if (sessionOnly) return Promise.resolve(tools);
1430
1488
 
1431
1489
  // In-app MCP servers (debate, browser, email).
1432
1490
  // Use getLocalMcpServers() so clay-browser is hidden unless the
@@ -1461,7 +1519,15 @@ function createProjectContext(opts) {
1461
1519
  }
1462
1520
  }
1463
1521
  }
1464
- if (pairOnly) return Promise.reject(new Error("Pair tool not found: " + toolName));
1522
+ if (boundSession && serverName === "clay-notes") {
1523
+ var noteTools = _sessionNotes.getToolDefs(boundSession);
1524
+ for (var nti = 0; nti < noteTools.length; nti++) {
1525
+ if (noteTools[nti].name === toolName && typeof noteTools[nti].handler === "function") {
1526
+ return Promise.resolve(noteTools[nti].handler(args || {}));
1527
+ }
1528
+ }
1529
+ }
1530
+ if (sessionOnly) return Promise.reject(new Error("Session tool not found: " + serverName + "/" + toolName));
1465
1531
  // Try in-app servers first (gated by extension connectivity for clay-browser).
1466
1532
  var localMcp = getLocalMcpServers();
1467
1533
  if (localMcp && localMcp[serverName]) {
package/lib/public/app.js CHANGED
@@ -42,7 +42,6 @@ import { initProfile, getProfileLang } from './modules/profile.js';
42
42
  import { initUserSettings } from './modules/user-settings.js';
43
43
  import { initToolPalettes } from './modules/tool-palette.js';
44
44
  import { initProjectSwitcher } from './modules/project-switcher.js';
45
- import { initBranchSwitcher } from './modules/branch-switcher.js';
46
45
  import { initSplitView } from './modules/split-view.js';
47
46
  import { initPaneBridge } from './modules/pane-bridge.js';
48
47
  import { initAdmin, checkAdminAccess } from './modules/admin.js';
@@ -97,6 +96,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
97
96
  var sendBtn = $("send-btn");
98
97
  function getStatusDot() {
99
98
  return document.querySelector("#icon-strip-projects .icon-strip-item.active .icon-strip-status") ||
99
+ document.querySelector("#icon-strip-projects .icon-strip-wt-item.active .icon-strip-status") ||
100
100
  document.querySelector("#icon-strip-users .icon-strip-mate.active .icon-strip-status");
101
101
  }
102
102
  var headerTitleEl = $("header-title");
@@ -287,8 +287,6 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
287
287
  projectName: projectName,
288
288
  cwd: "",
289
289
  currentSlug: currentSlug,
290
- projects: [],
291
- homeHubVisible: false,
292
290
  currentProjectOwnerId: null,
293
291
  isOsUsers: false,
294
292
  skipPermsEnabled: false,
@@ -481,7 +479,6 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
481
479
  // keydown listener registers later — capture-phase ordering doesn't
482
480
  // matter here but it keeps related bootstrap steps adjacent.
483
481
  initProjectSwitcher();
484
- initBranchSwitcher();
485
482
  initSplitView();
486
483
  initPaneBridge();
487
484
 
@@ -223,6 +223,7 @@
223
223
 
224
224
  /* Show dot only on active item or when processing */
225
225
  .icon-strip-item.active .icon-strip-status,
226
+ .icon-strip-wt-item.active .icon-strip-status,
226
227
  .icon-strip-status.processing {
227
228
  opacity: 1;
228
229
  }
@@ -247,6 +248,7 @@
247
248
 
248
249
  /* Pending permission shake */
249
250
  .icon-strip-item.has-pending-perm,
251
+ .icon-strip-wt-item.has-pending-perm,
250
252
  .icon-strip-mate.has-pending-perm {
251
253
  animation: permShake 2s ease-in-out infinite;
252
254
  }
@@ -1144,6 +1146,155 @@
1144
1146
  opacity: 1;
1145
1147
  }
1146
1148
 
1149
+ /* --- Worktree folder groups --- */
1150
+ .icon-strip-group {
1151
+ display: flex;
1152
+ flex-direction: column;
1153
+ align-items: center;
1154
+ position: relative;
1155
+ }
1156
+
1157
+ .icon-strip-group .folder-header {
1158
+ position: relative;
1159
+ }
1160
+
1161
+ .icon-strip-group-chevron {
1162
+ position: absolute;
1163
+ bottom: 2px;
1164
+ right: 2px;
1165
+ width: 16px;
1166
+ height: 16px;
1167
+ border-radius: 50%;
1168
+ background: var(--bg-alt);
1169
+ border: 1px solid var(--border);
1170
+ display: flex;
1171
+ align-items: center;
1172
+ justify-content: center;
1173
+ color: var(--text-dimmer);
1174
+ pointer-events: auto;
1175
+ cursor: pointer;
1176
+ transition: transform 0.15s ease, background 0.15s, border-color 0.15s;
1177
+ z-index: 2;
1178
+ line-height: 1;
1179
+ }
1180
+
1181
+ .icon-strip-group-chevron i,
1182
+ .icon-strip-group-chevron svg {
1183
+ width: 10px;
1184
+ height: 10px;
1185
+ }
1186
+
1187
+ .icon-strip-group-chevron:hover {
1188
+ background: var(--bg-hover, var(--bg-alt));
1189
+ border-color: var(--text-dimmer);
1190
+ color: var(--text-secondary);
1191
+ }
1192
+
1193
+ .icon-strip-group.collapsed .icon-strip-group-chevron {
1194
+ background: var(--border);
1195
+ }
1196
+
1197
+ .icon-strip-group-items {
1198
+ display: flex;
1199
+ flex-direction: column;
1200
+ align-items: center;
1201
+ overflow: hidden;
1202
+ transition: max-height 0.2s ease;
1203
+ max-height: 500px;
1204
+ border-left: 2px solid var(--border);
1205
+ margin-left: 0;
1206
+ padding-left: 0;
1207
+ }
1208
+
1209
+ .icon-strip-group.collapsed .icon-strip-group-items {
1210
+ max-height: 0;
1211
+ }
1212
+
1213
+ /* Worktree items (smaller than regular project icons) */
1214
+ .icon-strip-wt-item {
1215
+ width: 48px;
1216
+ height: 40px;
1217
+ display: flex;
1218
+ align-items: center;
1219
+ justify-content: center;
1220
+ cursor: pointer;
1221
+ position: relative;
1222
+ text-decoration: none;
1223
+ color: var(--text-secondary);
1224
+ transition: background 0.15s;
1225
+ }
1226
+
1227
+ .icon-strip-wt-item::before {
1228
+ content: "";
1229
+ position: absolute;
1230
+ width: 30px;
1231
+ height: 30px;
1232
+ border-radius: 8px;
1233
+ background: var(--bg-alt);
1234
+ opacity: 0.7;
1235
+ transition: opacity 0.15s, background 0.15s;
1236
+ }
1237
+
1238
+ .icon-strip-wt-item:hover::before {
1239
+ opacity: 1;
1240
+ }
1241
+
1242
+ .icon-strip-wt-item.active::before {
1243
+ background: var(--accent);
1244
+ opacity: 1;
1245
+ }
1246
+
1247
+ .icon-strip-wt-item.active {
1248
+ color: #fff;
1249
+ }
1250
+
1251
+ .icon-strip-wt-item .wt-branch-abbrev {
1252
+ font-size: 11px;
1253
+ font-weight: 600;
1254
+ letter-spacing: -0.5px;
1255
+ pointer-events: none;
1256
+ position: relative;
1257
+ z-index: 1;
1258
+ }
1259
+
1260
+ /* Disabled/inaccessible worktree */
1261
+ .icon-strip-wt-item.wt-disabled {
1262
+ opacity: 0.35;
1263
+ cursor: not-allowed;
1264
+ }
1265
+
1266
+ .icon-strip-wt-item.wt-disabled::after {
1267
+ content: "\1F6AB";
1268
+ position: absolute;
1269
+ font-size: 14px;
1270
+ z-index: 3;
1271
+ filter: grayscale(0.3);
1272
+ }
1273
+
1274
+ /* Group add button */
1275
+ .icon-strip-group-add {
1276
+ width: 30px;
1277
+ height: 30px;
1278
+ border-radius: 8px;
1279
+ border: 1.5px dashed var(--border);
1280
+ background: transparent;
1281
+ color: var(--text-dimmer);
1282
+ font-size: 16px;
1283
+ font-weight: 400;
1284
+ cursor: pointer;
1285
+ display: flex;
1286
+ align-items: center;
1287
+ justify-content: center;
1288
+ margin: 2px 0 4px 0;
1289
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
1290
+ }
1291
+
1292
+ .icon-strip-group-add:hover {
1293
+ border-color: var(--accent);
1294
+ color: var(--accent);
1295
+ background: rgba(var(--accent-rgb, 124, 58, 237), 0.08);
1296
+ }
1297
+
1147
1298
  /* --- Worktree creation modal --- */
1148
1299
  .wt-modal-overlay {
1149
1300
  position: fixed;
@@ -1258,6 +1409,45 @@ select.wt-modal-input {
1258
1409
  cursor: not-allowed;
1259
1410
  }
1260
1411
 
1412
+ /* --- Mobile worktree folder --- */
1413
+ .mobile-project-folder {
1414
+ display: flex;
1415
+ flex-direction: column;
1416
+ }
1417
+
1418
+ .mobile-project-item.wt-item {
1419
+ padding-left: 28px;
1420
+ opacity: 0.85;
1421
+ }
1422
+
1423
+ .mobile-project-item.wt-disabled {
1424
+ opacity: 0.35;
1425
+ cursor: not-allowed;
1426
+ }
1427
+
1428
+ .mobile-folder-chevron {
1429
+ font-size: 8px;
1430
+ color: var(--text-dimmer);
1431
+ margin-left: auto;
1432
+ cursor: pointer;
1433
+ transition: transform 0.15s ease;
1434
+ padding: 4px;
1435
+ }
1436
+
1437
+ .mobile-project-folder.collapsed .mobile-folder-chevron {
1438
+ transform: rotate(-90deg);
1439
+ }
1440
+
1441
+ .mobile-folder-items {
1442
+ overflow: hidden;
1443
+ transition: max-height 0.2s ease;
1444
+ max-height: 500px;
1445
+ }
1446
+
1447
+ .mobile-project-folder.collapsed .mobile-folder-items {
1448
+ max-height: 0;
1449
+ }
1450
+
1261
1451
  /* --- Per-project presence avatars --- */
1262
1452
  /* --- Mobile: hide icon strip --- */
1263
1453
  /* --- Skeleton loading placeholders --- */