clay-server 2.11.0-beta.15 → 2.11.0-beta.17

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/bin/cli.js CHANGED
@@ -443,6 +443,7 @@ async function restartDaemonFromConfig() {
443
443
  projects: (lastConfig.projects || []).filter(function (p) {
444
444
  return fs.existsSync(p.path);
445
445
  }),
446
+ removedProjects: lastConfig.removedProjects || [],
446
447
  };
447
448
 
448
449
  ensureConfigDir();
@@ -1223,13 +1224,29 @@ function setup(callback) {
1223
1224
  log("");
1224
1225
  log(sym.pointer + " " + a.bold + "Clay" + a.reset + a.dim + " · Unofficial, open-source project" + a.reset);
1225
1226
  log(sym.bar);
1226
- log(sym.bar + " " + a.dim + "Anyone with the URL gets full Claude Code access to this machine." + a.reset);
1227
- log(sym.bar + " " + a.dim + "Use a private network (Tailscale, VPN)." + a.reset);
1228
- log(sym.bar + " " + a.dim + "The authors assume no responsibility for any damage or data loss." + a.reset);
1227
+ log(sym.bar + " " + a.yellow + sym.warn + " Disclaimer" + a.reset);
1228
+ log(sym.bar);
1229
+ log(sym.bar + " " + a.dim + "This is an independent project and is not affiliated with Anthropic." + a.reset);
1230
+ log(sym.bar + " " + a.dim + "Claude is a trademark of Anthropic." + a.reset);
1231
+ log(sym.bar);
1232
+ log(sym.bar + " " + a.dim + "Clay is provided \"as is\" without warranty of any kind. Users are" + a.reset);
1233
+ log(sym.bar + " " + a.dim + "responsible for complying with the terms of service of underlying AI" + a.reset);
1234
+ log(sym.bar + " " + a.dim + "providers (e.g., Anthropic, OpenAI) and all applicable terms of any" + a.reset);
1235
+ log(sym.bar + " " + a.dim + "third-party services." + a.reset);
1236
+ log(sym.bar);
1237
+ log(sym.bar + " " + a.dim + "Features such as multi-user mode are experimental and may involve" + a.reset);
1238
+ log(sym.bar + " " + a.dim + "sharing access to API-based services. Before enabling such features," + a.reset);
1239
+ log(sym.bar + " " + a.dim + "review your provider's usage policies regarding account sharing," + a.reset);
1240
+ log(sym.bar + " " + a.dim + "acceptable use, and any applicable rate limits or restrictions." + a.reset);
1241
+ log(sym.bar);
1242
+ log(sym.bar + " " + a.dim + "The authors assume no liability for misuse or violations arising" + a.reset);
1243
+ log(sym.bar + " " + a.dim + "from the use of this software." + a.reset);
1244
+ log(sym.bar);
1245
+ log(sym.bar + " Type " + a.bold + "agree" + a.reset + " to accept and continue.");
1229
1246
  log(sym.bar);
1230
1247
 
1231
- promptToggle("Accept and continue", null, true, function (accepted) {
1232
- if (!accepted) {
1248
+ promptText("", "", function (val) {
1249
+ if (!val || val.trim().toLowerCase() !== "agree") {
1233
1250
  log(sym.end + " " + a.dim + "Aborted." + a.reset);
1234
1251
  log("");
1235
1252
  process.exit(0);
@@ -1284,8 +1301,37 @@ function setup(callback) {
1284
1301
  return;
1285
1302
  }
1286
1303
  log(sym.bar);
1287
- promptToggle("Enable OS-level user isolation?", "Run each user's sessions as a separate Linux account", false, function (wantOsUsers) {
1288
- if (wantOsUsers) {
1304
+ promptSelect("Enable OS-level user isolation?", [
1305
+ { label: "Yes", value: "yes" },
1306
+ { label: "No", value: "no" },
1307
+ ], function (choice) {
1308
+ if (choice !== "yes") {
1309
+ finishSetup(mode, false);
1310
+ return;
1311
+ }
1312
+ log(sym.bar);
1313
+ log(sym.bar + " " + a.yellow + sym.warn + " OS-Level User Isolation" + a.reset);
1314
+ log(sym.bar);
1315
+ log(sym.bar + " " + a.dim + "This feature maps each Clay user to a Linux OS user account." + a.reset);
1316
+ log(sym.bar + " " + a.dim + "The daemon must run as root and will spawn processes (SDK workers," + a.reset);
1317
+ log(sym.bar + " " + a.dim + "terminals, file operations) as the mapped Linux user." + a.reset);
1318
+ log(sym.bar);
1319
+ log(sym.bar + " " + a.dim + "What this means:" + a.reset);
1320
+ log(sym.bar + " " + a.dim + "- Each mapped user uses their own ~/.claude/ credentials" + a.reset);
1321
+ log(sym.bar + " " + a.dim + "- Terminals and file access follow Linux permissions" + a.reset);
1322
+ log(sym.bar + " " + a.dim + "- Linux user accounts are created automatically (clay-username)" + a.reset);
1323
+ log(sym.bar);
1324
+ log(sym.bar + " " + a.dim + "Recommended: Run on a dedicated Clay server or cloud instance," + a.reset);
1325
+ log(sym.bar + " " + a.dim + "not on a personal computer or general-purpose server." + a.reset);
1326
+ log(sym.bar);
1327
+ promptSelect("Confirm", [
1328
+ { label: "Enable OS-level user isolation", value: "confirm" },
1329
+ { label: "Cancel", value: "cancel" },
1330
+ ], function (confirmChoice) {
1331
+ if (confirmChoice !== "confirm") {
1332
+ finishSetup(mode, false);
1333
+ return;
1334
+ }
1289
1335
  var isRoot = typeof process.getuid === "function" && process.getuid() === 0;
1290
1336
  if (!isRoot) {
1291
1337
  // Save config so sudo clay can pick it up
@@ -1307,8 +1353,8 @@ function setup(callback) {
1307
1353
  process.exit(0);
1308
1354
  return;
1309
1355
  }
1310
- }
1311
- finishSetup(mode, wantOsUsers);
1356
+ finishSetup(mode, true);
1357
+ });
1312
1358
  });
1313
1359
  }
1314
1360
 
package/lib/daemon.js CHANGED
@@ -82,6 +82,18 @@ var lanIp = (function () {
82
82
  return null;
83
83
  })();
84
84
 
85
+ // --- Helper: get removed projects filtered by existing paths and userId ---
86
+ function getFilteredRemovedProjects(userId) {
87
+ if (!config.removedProjects || config.removedProjects.length === 0) return [];
88
+ return config.removedProjects.filter(function (rp) {
89
+ // In single-user mode (no userId), show entries with no userId
90
+ // In multi-user mode, only show entries belonging to this user
91
+ if (userId && rp.userId && rp.userId !== userId) return false;
92
+ if (!userId && rp.userId) return false;
93
+ return fs.existsSync(rp.path);
94
+ });
95
+ }
96
+
85
97
  // --- Create multi-project server ---
86
98
  var listenHost = config.host || "0.0.0.0";
87
99
 
@@ -94,6 +106,7 @@ var relay = createServer({
94
106
  dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
95
107
  osUsers: config.osUsers || false,
96
108
  lanHost: lanIp ? lanIp + ":" + config.port : null,
109
+ getRemovedProjects: function (userId) { return getFilteredRemovedProjects(userId); },
97
110
  onAddProject: function (absPath) {
98
111
  // Check if already registered
99
112
  for (var j = 0; j < config.projects.length; j++) {
@@ -105,6 +118,10 @@ var relay = createServer({
105
118
  var slug = generateSlug(absPath, slugs);
106
119
  relay.addProject(absPath, slug);
107
120
  config.projects.push({ path: absPath, slug: slug, addedAt: Date.now() });
121
+ // Remove from removedProjects if present
122
+ if (config.removedProjects) {
123
+ config.removedProjects = config.removedProjects.filter(function (rp) { return rp.path !== absPath; });
124
+ }
108
125
  saveConfig(config);
109
126
  try { syncClayrc(config.projects); } catch (e) {}
110
127
  console.log("[daemon] Added project (web):", slug, "→", absPath);
@@ -261,22 +278,30 @@ var relay = createServer({
261
278
  callback({ ok: false, error: "Failed to start git clone: " + err.message });
262
279
  });
263
280
  },
264
- onRemoveProject: function (slug) {
265
- var found = false;
281
+ onRemoveProject: function (slug, userId) {
282
+ var found = null;
266
283
  for (var j = 0; j < config.projects.length; j++) {
267
- if (config.projects[j].slug === slug) { found = true; break; }
284
+ if (config.projects[j].slug === slug) { found = config.projects[j]; break; }
268
285
  }
269
286
  if (!found) return { ok: false, error: "Project not found" };
270
- // Find path before removing so we can clean up .clayrc
271
- var removedPath = null;
272
- for (var rj = 0; rj < config.projects.length; rj++) {
273
- if (config.projects[rj].slug === slug) { removedPath = config.projects[rj].path; break; }
287
+ // Save to removedProjects for re-add functionality
288
+ if (!config.removedProjects) config.removedProjects = [];
289
+ config.removedProjects.push({
290
+ path: found.path,
291
+ title: found.title || null,
292
+ icon: found.icon || null,
293
+ userId: userId || null,
294
+ removedAt: Date.now(),
295
+ });
296
+ // Cap at 20 entries (oldest first)
297
+ if (config.removedProjects.length > 20) {
298
+ config.removedProjects = config.removedProjects.slice(config.removedProjects.length - 20);
274
299
  }
275
300
  relay.removeProject(slug);
276
301
  config.projects = config.projects.filter(function (p) { return p.slug !== slug; });
277
302
  saveConfig(config);
278
303
  // Remove from .clayrc so it doesn't appear in restore prompt
279
- if (removedPath) { try { removeFromClayrc(removedPath); } catch (e) {} }
304
+ if (found.path) { try { removeFromClayrc(found.path); } catch (e) {} }
280
305
  try { syncClayrc(config.projects); } catch (e) {}
281
306
  console.log("[daemon] Removed project (web):", slug);
282
307
  relay.broadcastAll({
@@ -1056,25 +1081,31 @@ if (config.keepAwake && process.platform === "darwin") {
1056
1081
 
1057
1082
  // --- Spawn new daemon and graceful restart ---
1058
1083
  function spawnAndRestart() {
1059
- var { spawn: spawnRestart } = require("child_process");
1060
- var { logPath: restartLogPath, configPath: restartConfigPath } = require("./config");
1061
- var daemonScript = path.join(__dirname, "daemon.js");
1062
- var logFd = fs.openSync(restartLogPath(), "a");
1063
- var child = spawnRestart(process.execPath, [daemonScript], {
1064
- detached: true,
1065
- windowsHide: true,
1066
- stdio: ["ignore", logFd, logFd],
1067
- env: Object.assign({}, process.env, {
1068
- CLAY_CONFIG: restartConfigPath(),
1069
- }),
1070
- });
1071
- child.unref();
1072
- fs.closeSync(logFd);
1073
- config.pid = child.pid;
1074
- saveConfig(config);
1075
- console.log("[daemon] Spawned new daemon (PID " + child.pid + "), shutting down...");
1076
- updateHandoff = true;
1077
- setTimeout(function () { gracefulShutdown(); }, 100);
1084
+ try {
1085
+ var { spawn: spawnRestart } = require("child_process");
1086
+ var { logPath: restartLogPath, configPath: restartConfigPath } = require("./config");
1087
+ var daemonScript = path.join(__dirname, "daemon.js");
1088
+ var logFd = fs.openSync(restartLogPath(), "a");
1089
+ var child = spawnRestart(process.execPath, [daemonScript], {
1090
+ detached: true,
1091
+ windowsHide: true,
1092
+ stdio: ["ignore", logFd, logFd],
1093
+ env: Object.assign({}, process.env, {
1094
+ CLAY_CONFIG: restartConfigPath(),
1095
+ }),
1096
+ });
1097
+ child.unref();
1098
+ fs.closeSync(logFd);
1099
+ config.pid = child.pid;
1100
+ saveConfig(config);
1101
+ console.log("[daemon] Spawned new daemon (PID " + child.pid + "), shutting down...");
1102
+ updateHandoff = true;
1103
+ setTimeout(function () { gracefulShutdown(); }, 100);
1104
+ } catch (e) {
1105
+ console.error("[daemon] Restart failed:", e.message);
1106
+ relay.broadcastAll({ type: "toast", level: "error", message: "Restart failed: " + e.message });
1107
+ relay.broadcastAll({ type: "restart_server_result", ok: false, error: e.message });
1108
+ }
1078
1109
  }
1079
1110
 
1080
1111
  // --- Graceful shutdown ---
package/lib/project.js CHANGED
@@ -108,6 +108,7 @@ function createProjectContext(opts) {
108
108
  var moveAllSchedulesToProject = opts.moveAllSchedulesToProject || function () { return { ok: false, error: "Not supported" }; };
109
109
  var getScheduleCount = opts.getScheduleCount || function () { return 0; };
110
110
  var onProcessingChanged = opts.onProcessingChanged || function () {};
111
+ var onSessionDone = opts.onSessionDone || function () {};
111
112
  var onPresenceChange = opts.onPresenceChange || function () {};
112
113
  var updateChannel = opts.updateChannel || "stable";
113
114
  var osUsers = opts.osUsers || false;
@@ -176,6 +177,13 @@ function createProjectContext(opts) {
176
177
  if (ws.readyState === 1) ws.send(JSON.stringify(obj));
177
178
  }
178
179
 
180
+ function sendToAdmins(obj) {
181
+ var data = JSON.stringify(obj);
182
+ for (var ws of clients) {
183
+ if (ws.readyState === 1 && ws._clayUser && ws._clayUser.role === "admin") ws.send(data);
184
+ }
185
+ }
186
+
179
187
  function broadcastClientCount() {
180
188
  var msg = { type: "client_count", count: clients.size };
181
189
  if (usersModule.isMultiUser()) {
@@ -338,6 +346,7 @@ function createProjectContext(opts) {
338
346
  fn(ws, filterFn);
339
347
  }
340
348
  },
349
+ onSessionDone: onSessionDone,
341
350
  });
342
351
  var _projMode = typeof opts.onGetProjectDefaultMode === "function" ? opts.onGetProjectDefaultMode(slug) : null;
343
352
  var _srvMode = typeof opts.onGetServerDefaultMode === "function" ? opts.onGetServerDefaultMode() : null;
@@ -998,11 +1007,11 @@ function createProjectContext(opts) {
998
1007
  var tm = createTerminalManager({ cwd: cwd, send: send, sendTo: sendTo });
999
1008
  var nm = createNotesManager({ cwd: cwd, send: send, sendTo: sendTo });
1000
1009
 
1001
- // Check for updates in background
1010
+ // Check for updates in background (admin only)
1002
1011
  fetchVersion(updateChannel).then(function (v) {
1003
1012
  if (v && isNewer(v, currentVersion)) {
1004
1013
  latestVersion = v;
1005
- send({ type: "update_available", version: v });
1014
+ sendToAdmins({ type: "update_available", version: v });
1006
1015
  }
1007
1016
  });
1008
1017
 
@@ -1022,7 +1031,7 @@ function createProjectContext(opts) {
1022
1031
  var _userId = ws._clayUser ? ws._clayUser.id : null;
1023
1032
  var _filteredProjects = getProjectList(_userId);
1024
1033
  sendTo(ws, { type: "info", cwd: cwd, slug: slug, project: title || project, version: currentVersion, debug: !!debug, dangerouslySkipPermissions: dangerouslySkipPermissions, osUsers: osUsers, lanHost: lanHost, projectCount: _filteredProjects.length, projects: _filteredProjects, projectOwnerId: projectOwnerId });
1025
- if (latestVersion) {
1034
+ if (latestVersion && ws._clayUser && ws._clayUser.role === "admin") {
1026
1035
  sendTo(ws, { type: "update_available", version: latestVersion });
1027
1036
  }
1028
1037
  if (sm.slashCommands) {
@@ -1203,7 +1212,7 @@ function createProjectContext(opts) {
1203
1212
 
1204
1213
  function handleMessage(ws, msg) {
1205
1214
  // --- DM messages (delegated to server-level handler) ---
1206
- if (msg.type === "dm_open" || msg.type === "dm_send" || msg.type === "dm_list" || msg.type === "dm_typing") {
1215
+ if (msg.type === "dm_open" || msg.type === "dm_send" || msg.type === "dm_list" || msg.type === "dm_typing" || msg.type === "dm_add_favorite" || msg.type === "dm_remove_favorite") {
1207
1216
  if (typeof opts.onDmMessage === "function") {
1208
1217
  opts.onDmMessage(ws, msg);
1209
1218
  }
@@ -1405,9 +1414,17 @@ function createProjectContext(opts) {
1405
1414
  if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return;
1406
1415
  var newChannel = msg.channel === "beta" ? "beta" : "stable";
1407
1416
  updateChannel = newChannel;
1417
+ latestVersion = null;
1408
1418
  if (typeof opts.onSetUpdateChannel === "function") {
1409
1419
  opts.onSetUpdateChannel(newChannel);
1410
1420
  }
1421
+ // Re-fetch with new channel and broadcast to admin clients
1422
+ fetchVersion(updateChannel).then(function (v) {
1423
+ if (v && isNewer(v, currentVersion)) {
1424
+ latestVersion = v;
1425
+ sendToAdmins({ type: "update_available", version: v });
1426
+ }
1427
+ }).catch(function () {});
1411
1428
  return;
1412
1429
  }
1413
1430
 
@@ -1815,7 +1832,7 @@ function createProjectContext(opts) {
1815
1832
  new Promise(function (resolve) { setTimeout(resolve, 3000); }),
1816
1833
  ]).then(function () {
1817
1834
  try {
1818
- var newSession = sm.createSession();
1835
+ var newSession = sm.createSession(null, ws);
1819
1836
  // Send the plan as the first user message (with planContent for UI rendering)
1820
1837
  var userMsg = { type: "user_message", text: planPrompt, planContent: clientPlanContent || null };
1821
1838
  newSession.history.push(userMsg);
@@ -2029,8 +2046,10 @@ function createProjectContext(opts) {
2029
2046
  moveAllSchedulesToProject(removeSlug, msg.moveTasksTo);
2030
2047
  }
2031
2048
  if (typeof opts.onRemoveProject === "function") {
2032
- var removeResult = opts.onRemoveProject(removeSlug);
2033
- sendTo(ws, { type: "remove_project_result", ok: removeResult.ok, slug: removeSlug, error: removeResult.error });
2049
+ // Send result before removing so the WS is still open
2050
+ sendTo(ws, { type: "remove_project_result", ok: true, slug: removeSlug });
2051
+ var removeUserId = ws._clayUser ? ws._clayUser.id : null;
2052
+ opts.onRemoveProject(removeSlug, removeUserId);
2034
2053
  } else {
2035
2054
  sendTo(ws, { type: "remove_project_result", ok: false, error: "Not supported" });
2036
2055
  }
package/lib/public/app.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { showToast, copyToClipboard, escapeHtml } from './modules/utils.js';
2
2
  import { refreshIcons, iconHtml, randomThinkingVerb } from './modules/icons.js';
3
3
  import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks, closeMermaidModal, parseEmojis } from './modules/markdown.js';
4
- import { initSidebar, renderSessionList, handleSearchResults, updateSessionPresence, updatePageTitle, getActiveSearchQuery, buildSearchTimeline, removeSearchTimeline, populateCliSessionList, renderIconStrip, renderSidebarPresence, initIconStrip, getEmojiCategories, renderUserStrip, setCurrentDmUser, updateDmBadge } from './modules/sidebar.js';
4
+ import { initSidebar, renderSessionList, handleSearchResults, updateSessionPresence, updatePageTitle, getActiveSearchQuery, buildSearchTimeline, removeSearchTimeline, populateCliSessionList, renderIconStrip, renderSidebarPresence, initIconStrip, getEmojiCategories, renderUserStrip, setCurrentDmUser, updateDmBadge, updateSessionBadge, updateProjectBadge, closeDmUserPicker, spawnDustParticles } from './modules/sidebar.js';
5
5
  import { initRewind, setRewindMode, showRewindModal, clearPendingRewindUuid, addRewindButton } from './modules/rewind.js';
6
6
  import { initNotifications, showDoneNotification, playDoneSound, isNotifAlertEnabled, isNotifSoundEnabled } from './modules/notifications.js';
7
7
  import { initInput, clearPendingImages, handleInputSync, autoResize, builtinCommands, sendMessage } from './modules/input.js';
8
8
  import { initQrCode } from './modules/qrcode.js';
9
9
  import { initFileBrowser, loadRootDirectory, refreshTree, handleFsList, handleFsRead, handleDirChanged, refreshIfOpen, handleFileChanged, handleFileHistory, handleGitDiff, handleFileAt, getPendingNavigate, closeFileViewer, resetFileBrowser } from './modules/filebrowser.js';
10
10
  import { initTerminal, openTerminal, closeTerminal, resetTerminals, handleTermList, handleTermCreated, handleTermOutput, handleTermExited, handleTermClosed, sendTerminalCommand } from './modules/terminal.js';
11
- import { initStickyNotes, handleNotesList, handleNoteCreated, handleNoteUpdated, handleNoteDeleted, openArchive, closeArchive, isArchiveOpen } from './modules/sticky-notes.js';
11
+ import { initStickyNotes, handleNotesList, handleNoteCreated, handleNoteUpdated, handleNoteDeleted, openArchive, closeArchive, isArchiveOpen, hideNotes, showNotes, isNotesVisible } from './modules/sticky-notes.js';
12
12
  import { initTheme, getThemeColor, getComputedVar, onThemeChange, getCurrentTheme } from './modules/theme.js';
13
13
  import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUserQuestion, markAskUserAnswered, renderPermissionRequest, markPermissionResolved, markPermissionCancelled, renderElicitationRequest, markElicitationResolved, renderPlanBanner, renderPlanCard, handleTodoWrite, handleTaskCreate, handleTaskUpdate, startThinking, appendThinking, stopThinking, resetThinkingGroup, createToolItem, updateToolExecuting, updateToolResult, markAllToolsDone, addTurnMeta, enableMainInput, getTools, getPlanContent, setPlanContent, isPlanFilePath, getTodoTools, updateSubagentActivity, addSubagentToolEntry, markSubagentDone, updateSubagentProgress, initSubagentStop, closeToolGroup, removeToolFromGroup } from './modules/tools.js';
14
14
  import { initServerSettings, updateSettingsStats, updateSettingsModels, updateDaemonConfig, handleSetPinResult, handleKeepAwakeChanged, handleRestartResult, handleShutdownResult, handleSharedEnv, handleSharedEnvSaved, handleGlobalClaudeMdRead, handleGlobalClaudeMdWrite } from './modules/server-settings.js';
@@ -55,6 +55,9 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
55
55
  var dmTargetUser = null;
56
56
  var dmUnread = {}; // { otherUserId: count }
57
57
  var cachedAllUsers = [];
58
+ var cachedDmFavorites = [];
59
+ var cachedDmConversations = [];
60
+ var dmRemovedUsers = {}; // { userId: true } - users explicitly removed from favorites
58
61
 
59
62
  // --- Home Hub ---
60
63
  var homeHub = $("home-hub");
@@ -562,6 +565,9 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
562
565
  // Hide home hub if visible
563
566
  hideHomeHub();
564
567
 
568
+ // Hide sticky notes if visible
569
+ hideNotes();
570
+
565
571
  // Hide project UI + sidebar, show DM UI
566
572
  var mainCol = document.getElementById("main-column");
567
573
  if (mainCol) mainCol.classList.add("dm-mode");
@@ -821,12 +827,16 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
821
827
  var projectHintDismiss = $("project-hint-dismiss");
822
828
  var cachedProjects = [];
823
829
  var cachedProjectCount = 0;
830
+ var cachedRemovedProjects = [];
824
831
  var currentProjectOwnerId = null;
825
832
  var currentSlug = slugMatch ? slugMatch[1] : null;
826
833
 
827
834
  function updateProjectList(msg) {
828
835
  if (typeof msg.projectCount === "number") cachedProjectCount = msg.projectCount;
829
836
  if (msg.projects) cachedProjects = msg.projects;
837
+ if (msg.removedProjects) cachedRemovedProjects = msg.removedProjects;
838
+ else if (msg.removedProjects === undefined) { /* keep cached */ }
839
+ else cachedRemovedProjects = [];
830
840
  var count = cachedProjectCount || 0;
831
841
  renderProjectList();
832
842
  if (count === 1 && projectHint) {
@@ -845,8 +855,10 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
845
855
  // Update user strip (DM targets) in icon strip
846
856
  if (msg.allUsers) {
847
857
  cachedAllUsers = msg.allUsers;
858
+ if (msg.dmFavorites) cachedDmFavorites = msg.dmFavorites;
859
+ if (msg.dmConversations) cachedDmConversations = msg.dmConversations;
848
860
  var onlineIds = (msg.serverUsers || []).map(function (u) { return u.id; });
849
- renderUserStrip(msg.allUsers, onlineIds, myUserId);
861
+ renderUserStrip(msg.allUsers, onlineIds, myUserId, cachedDmFavorites, cachedDmConversations, dmUnread, dmRemovedUsers);
850
862
  // Render my avatar (always present, hidden behind user-island)
851
863
  var meEl = document.getElementById("icon-strip-me");
852
864
  if (meEl && !meEl.hasChildNodes()) {
@@ -885,7 +897,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
885
897
  function renderProjectList() {
886
898
  // Render icon strip projects
887
899
  var iconStripProjects = cachedProjects.map(function (p) {
888
- return { slug: p.slug, name: p.title || p.project, icon: p.icon || null, isProcessing: p.isProcessing, onlineUsers: p.onlineUsers || [] };
900
+ return { slug: p.slug, name: p.title || p.project, icon: p.icon || null, isProcessing: p.isProcessing, onlineUsers: p.onlineUsers || [], unread: p.unread || 0 };
889
901
  });
890
902
  renderIconStrip(iconStripProjects, currentSlug);
891
903
  // Update title bar project name and icon if it changed
@@ -1225,6 +1237,8 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
1225
1237
  get projectOwnerId() { return currentProjectOwnerId; },
1226
1238
  openDm: function (userId) { openDm(userId); },
1227
1239
  openAddProjectModal: function () { openAddProjectModal(); },
1240
+ sendWs: function (msg) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg)); },
1241
+ onDmRemoveUser: function (userId) { dmRemovedUsers[userId] = true; },
1228
1242
  };
1229
1243
  initSidebar(sidebarCtx);
1230
1244
  initIconStrip(sidebarCtx);
@@ -2734,12 +2748,19 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
2734
2748
  // --- Project switching (no full reload) ---
2735
2749
  function switchProject(slug) {
2736
2750
  if (!slug) return;
2751
+ var wasDm = dmMode;
2737
2752
  if (dmMode) exitDmMode();
2738
2753
  if (homeHubVisible) {
2739
2754
  hideHomeHub();
2740
2755
  if (slug === currentSlug) return;
2741
2756
  }
2742
- if (slug === currentSlug) return;
2757
+ if (slug === currentSlug) {
2758
+ // Returning from DM mode to the same project: re-switch to restore session
2759
+ if (wasDm && ws && ws.readyState === 1) {
2760
+ ws.send(JSON.stringify({ type: "switch_session", id: activeSessionId }));
2761
+ }
2762
+ return;
2763
+ }
2743
2764
  resetFileBrowser();
2744
2765
  closeArchive();
2745
2766
  if (isSchedulerOpen()) closeScheduler();
@@ -3126,6 +3147,10 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
3126
3147
  blinkSessionDot(msg.id);
3127
3148
  break;
3128
3149
 
3150
+ case "session_unread":
3151
+ updateSessionBadge(msg.id, msg.count);
3152
+ break;
3153
+
3129
3154
  case "search_results":
3130
3155
  handleSearchResults(msg);
3131
3156
  break;
@@ -3622,6 +3647,12 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
3622
3647
  var fromId = msg.message.from;
3623
3648
  if (fromId && fromId !== myUserId) {
3624
3649
  dmUnread[fromId] = (dmUnread[fromId] || 0) + 1;
3650
+ // Re-render strip so non-favorited sender appears
3651
+ var onlineIdsForDm = (cachedAllUsers || []).filter(function (u) {
3652
+ var el = document.querySelector('.icon-strip-user[data-user-id="' + u.id + '"]');
3653
+ return el && el.classList.contains("online");
3654
+ }).map(function (u) { return u.id; });
3655
+ renderUserStrip(cachedAllUsers, onlineIdsForDm, myUserId, cachedDmFavorites, cachedDmConversations, dmUnread, dmRemovedUsers);
3625
3656
  updateDmBadge(fromId, dmUnread[fromId]);
3626
3657
  }
3627
3658
  }
@@ -3637,6 +3668,29 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
3637
3668
  // Could be used for DM list view later
3638
3669
  break;
3639
3670
 
3671
+ case "dm_favorites_updated":
3672
+ // Track users explicitly removed from favorites
3673
+ if (cachedDmFavorites && msg.dmFavorites) {
3674
+ for (var ri = 0; ri < cachedDmFavorites.length; ri++) {
3675
+ if (msg.dmFavorites.indexOf(cachedDmFavorites[ri]) === -1) {
3676
+ dmRemovedUsers[cachedDmFavorites[ri]] = true;
3677
+ }
3678
+ }
3679
+ }
3680
+ // Clear removed flag for users being added back
3681
+ if (msg.dmFavorites) {
3682
+ for (var ai = 0; ai < msg.dmFavorites.length; ai++) {
3683
+ delete dmRemovedUsers[msg.dmFavorites[ai]];
3684
+ }
3685
+ }
3686
+ cachedDmFavorites = msg.dmFavorites || [];
3687
+ var onlineIds2 = (cachedAllUsers || []).filter(function (u) {
3688
+ var el = document.querySelector('.icon-strip-user[data-user-id="' + u.id + '"]');
3689
+ return el && el.classList.contains("online");
3690
+ }).map(function (u) { return u.id; });
3691
+ renderUserStrip(cachedAllUsers, onlineIds2, myUserId, cachedDmFavorites, cachedDmConversations, dmUnread, dmRemovedUsers);
3692
+ break;
3693
+
3640
3694
  case "daemon_config":
3641
3695
  updateDaemonConfig(msg.config);
3642
3696
  break;
@@ -5022,12 +5076,20 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
5022
5076
  // Project has tasks — show dialog with options
5023
5077
  showRemoveProjectTaskDialog(slug, name, msg.count);
5024
5078
  } else {
5025
- // No tasks — simple confirm
5026
- showConfirm('Remove project "' + name + '"?', function () {
5027
- if (ws && ws.readyState === 1) {
5028
- ws.send(JSON.stringify({ type: "remove_project", slug: slug }));
5079
+ // No tasks — confirm then particle burst + remove
5080
+ showConfirm('Remove "' + name + '"? You can re-add it later.', function () {
5081
+ // Find the icon strip item to anchor the particle burst
5082
+ var iconEl = document.querySelector('.icon-strip-item[data-slug="' + slug + '"]');
5083
+ if (iconEl) {
5084
+ var rect = iconEl.getBoundingClientRect();
5085
+ spawnDustParticles(rect.left + rect.width / 2, rect.top + rect.height / 2);
5029
5086
  }
5030
- });
5087
+ setTimeout(function () {
5088
+ if (ws && ws.readyState === 1) {
5089
+ ws.send(JSON.stringify({ type: "remove_project", slug: slug }));
5090
+ }
5091
+ }, 1000);
5092
+ }, "Remove", true);
5031
5093
  }
5032
5094
  pendingRemoveSlug = null;
5033
5095
  pendingRemoveName = null;
@@ -5092,9 +5154,33 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
5092
5154
  function handleRemoveProjectResult(msg) {
5093
5155
  if (msg.ok) {
5094
5156
  showToast("Project removed", "success");
5095
- // If we removed the current project, navigate to first available
5157
+ // If we removed the current project, go to home hub without full reload
5096
5158
  if (msg.slug === currentSlug) {
5097
- window.location.href = "/";
5159
+ // Suppress disconnect overlay and reconnect by detaching the WS
5160
+ if (ws) { ws.onclose = null; ws.onerror = null; ws.close(); ws = null; }
5161
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
5162
+ connected = false;
5163
+ connectOverlay.classList.add("hidden");
5164
+ // Add to cached removed projects for re-add UI
5165
+ var removedProj = null;
5166
+ for (var ri = 0; ri < cachedProjects.length; ri++) {
5167
+ if (cachedProjects[ri].slug === msg.slug) { removedProj = cachedProjects[ri]; break; }
5168
+ }
5169
+ if (removedProj) {
5170
+ cachedRemovedProjects.push({
5171
+ path: removedProj.path || "",
5172
+ title: removedProj.title || null,
5173
+ icon: removedProj.icon || null,
5174
+ removedAt: Date.now(),
5175
+ });
5176
+ }
5177
+ // Remove from cached projects and re-render icon strip
5178
+ cachedProjects = cachedProjects.filter(function (p) { return p.slug !== msg.slug; });
5179
+ cachedProjectCount = cachedProjects.length;
5180
+ currentSlug = null;
5181
+ renderProjectList();
5182
+ resetClientState();
5183
+ showHomeHub();
5098
5184
  }
5099
5185
  } else {
5100
5186
  showToast(msg.error || "Failed to remove project", "error");
@@ -5113,6 +5199,7 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
5113
5199
  var addProjectCancel = document.getElementById("add-project-cancel");
5114
5200
  var addProjectModeBtns = addProjectModal.querySelectorAll(".add-project-mode-btn");
5115
5201
  var addProjectPanels = addProjectModal.querySelectorAll(".add-project-panel");
5202
+ var addProjectRemoved = document.getElementById("add-project-removed");
5116
5203
  var addProjectDebounce = null;
5117
5204
  var addProjectActiveIdx = -1;
5118
5205
  var addProjectMode = "existing";
@@ -5185,6 +5272,48 @@ import { initAdmin, checkAdminAccess } from './modules/admin.js';
5185
5272
  existingBtn.disabled = false;
5186
5273
  switchAddProjectMode("existing");
5187
5274
  }
5275
+ // Render removed projects for re-add
5276
+ renderRemovedProjectsList();
5277
+ }
5278
+
5279
+ function renderRemovedProjectsList() {
5280
+ if (!addProjectRemoved) return;
5281
+ addProjectRemoved.innerHTML = "";
5282
+ if (!cachedRemovedProjects || cachedRemovedProjects.length === 0) {
5283
+ addProjectRemoved.classList.add("hidden");
5284
+ return;
5285
+ }
5286
+ addProjectRemoved.classList.remove("hidden");
5287
+ for (var ri = 0; ri < cachedRemovedProjects.length; ri++) {
5288
+ var rp = cachedRemovedProjects[ri];
5289
+ var item = document.createElement("div");
5290
+ item.className = "add-project-removed-item";
5291
+ item.dataset.path = rp.path;
5292
+ item.addEventListener("click", function () {
5293
+ var p = this.dataset.path;
5294
+ if (ws && ws.readyState === 1) {
5295
+ ws.send(JSON.stringify({ type: "add_project", path: p }));
5296
+ }
5297
+ closeAddProjectModal();
5298
+ });
5299
+ var iconEl = document.createElement("span");
5300
+ iconEl.className = "add-project-removed-icon";
5301
+ iconEl.textContent = rp.icon || "📁";
5302
+ item.appendChild(iconEl);
5303
+ var info = document.createElement("div");
5304
+ info.className = "add-project-removed-info";
5305
+ var nameEl = document.createElement("div");
5306
+ nameEl.className = "add-project-removed-name";
5307
+ nameEl.textContent = rp.title || rp.path.split("/").pop() || rp.path;
5308
+ info.appendChild(nameEl);
5309
+ var pathEl = document.createElement("div");
5310
+ pathEl.className = "add-project-removed-path";
5311
+ pathEl.textContent = rp.path;
5312
+ info.appendChild(pathEl);
5313
+ item.appendChild(info);
5314
+ addProjectRemoved.appendChild(item);
5315
+ }
5316
+ try { parseEmojis(addProjectRemoved); } catch (e) {}
5188
5317
  }
5189
5318
 
5190
5319
  function closeAddProjectModal() {
@@ -895,6 +895,7 @@
895
895
  width: 50%;
896
896
  max-width: 720px;
897
897
  min-width: 360px;
898
+ min-height: 0;
898
899
  border-left: 1px solid var(--border);
899
900
  background: var(--bg);
900
901
  display: flex;
@@ -1382,6 +1383,11 @@
1382
1383
  justify-content: space-between;
1383
1384
  padding: 10px 16px;
1384
1385
  flex-shrink: 0;
1386
+ position: sticky;
1387
+ top: 0;
1388
+ z-index: 10;
1389
+ background: var(--bg);
1390
+ border-bottom: 1px solid var(--border);
1385
1391
  }
1386
1392
 
1387
1393
  .file-history-view-toggle {