@cryptiklemur/lattice 1.39.0 → 1.40.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.
@@ -31,7 +31,7 @@ function groupProjectsBySlug(projects: ProjectInfo[], nodes: NodeInfo[]): Projec
31
31
  var nodeEntry = {
32
32
  nodeId: p.nodeId,
33
33
  nodeName: p.nodeName,
34
- online: node ? node.online : false,
34
+ online: node ? node.online : (p.online ?? !p.isRemote),
35
35
  path: p.path,
36
36
  };
37
37
  if (existing) {
@@ -0,0 +1,35 @@
1
+ import { WifiOff, Loader2 } from "lucide-react";
2
+ import { useProjects } from "../../hooks/useProjects";
3
+ import { useMesh } from "../../hooks/useMesh";
4
+ import { useSidebar } from "../../hooks/useSidebar";
5
+
6
+ export function NodeDisconnectedOverlay() {
7
+ var { activeProject } = useProjects();
8
+ var { nodes } = useMesh();
9
+ var sidebar = useSidebar();
10
+
11
+ if (!activeProject || !activeProject.isRemote) return null;
12
+ if (sidebar.activeView.type !== "chat") return null;
13
+
14
+ var remoteNode = nodes.find(function (n) { return n.id === activeProject!.nodeId; });
15
+ if (!remoteNode || remoteNode.online) return null;
16
+
17
+ return (
18
+ <div className="absolute inset-0 z-40 flex items-center justify-center bg-base-100/80 backdrop-blur-sm">
19
+ <div className="bg-base-300 border border-base-content/15 rounded-2xl shadow-2xl px-8 py-6 max-w-sm text-center">
20
+ <WifiOff size={28} className="text-warning mx-auto mb-3" />
21
+ <h3 className="text-[15px] font-mono font-bold text-base-content mb-1">Node Disconnected</h3>
22
+ <p className="text-[13px] text-base-content/50 mb-3">
23
+ <span className="font-semibold text-base-content/70">{remoteNode.name}</span> is unreachable.
24
+ </p>
25
+ <div className="flex items-center justify-center gap-2 text-[12px] text-base-content/40">
26
+ <Loader2 size={12} className="animate-spin" />
27
+ Attempting to reconnect...
28
+ </div>
29
+ <p className="text-[11px] text-base-content/25 mt-3">
30
+ Session state preserved. Operations will resume on reconnect.
31
+ </p>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
@@ -22,14 +22,30 @@ export function useProjects(): UseProjectsResult {
22
22
  useEffect(function () {
23
23
  handleRef.current = function (msg: ServerMessage) {
24
24
  if (msg.type === "projects:list") {
25
- var list = (msg as ProjectsListMessage).projects;
26
- setProjects(list);
25
+ var incoming = (msg as ProjectsListMessage).projects;
26
+ setProjects(function (prev) {
27
+ var incomingKeys = new Set(incoming.map(function (p) { return p.slug + "@" + p.nodeId; }));
28
+ var kept = prev.filter(function (p) {
29
+ if (!p.isRemote) return false;
30
+ return !incomingKeys.has(p.slug + "@" + p.nodeId);
31
+ });
32
+ for (var i = 0; i < kept.length; i++) {
33
+ (kept[i] as any).online = false;
34
+ }
35
+ return incoming.concat(kept);
36
+ });
27
37
  var storeState = getSidebarStore().state;
28
38
  var currentSlug = storeState.activeProjectSlug;
29
39
  if (currentSlug !== null) {
30
- var found = list.find(function (p: typeof list[number]) { return p.slug === currentSlug; });
31
- if (!found && list.length > 0) {
32
- setActiveProjectSlug(list[0].slug);
40
+ var found = incoming.find(function (p: typeof incoming[number]) { return p.slug === currentSlug; });
41
+ if (!found) {
42
+ setProjects(function (current) {
43
+ var stillExists = current.find(function (p) { return p.slug === currentSlug; });
44
+ if (!stillExists && current.length > 0) {
45
+ setActiveProjectSlug(current[0].slug);
46
+ }
47
+ return current;
48
+ });
33
49
  }
34
50
  }
35
51
  }
@@ -16,6 +16,7 @@ import { useSidebar } from "./hooks/useSidebar";
16
16
  import { useWorkspace } from "./hooks/useWorkspace";
17
17
  import { useWebSocket } from "./hooks/useWebSocket";
18
18
  import { UpdateBanner } from "./components/ui/UpdateBanner";
19
+ import { NodeDisconnectedOverlay } from "./components/ui/NodeDisconnectedOverlay";
19
20
  import { useSwipeDrawer } from "./hooks/useSwipeDrawer";
20
21
  import { exitSettings, getSidebarStore, handlePopState, closeDrawer, toggleDrawer } from "./stores/sidebar";
21
22
 
@@ -422,9 +423,10 @@ function RootLayout() {
422
423
  readOnly
423
424
  />
424
425
 
425
- <main id="main-content" className="drawer-content flex flex-col h-full min-w-0 overflow-hidden">
426
+ <main id="main-content" className="drawer-content flex flex-col h-full min-w-0 overflow-hidden relative">
426
427
  <UpdateBanner />
427
428
  <Outlet />
429
+ <NodeDisconnectedOverlay />
428
430
  </main>
429
431
 
430
432
  <div ref={drawerSideRef} className="drawer-side z-50 h-full">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.39.0",
3
+ "version": "1.40.1",
4
4
  "description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
5
5
  "license": "MIT",
6
6
  "author": "Aaron Scherer <me@aaronscherer.me>",
@@ -401,7 +401,7 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
401
401
  var remoteProjects = getAllRemoteProjects(currentIdentity.id);
402
402
  broadcast({
403
403
  type: "projects:list",
404
- projects: localProjects.concat(remoteProjects as typeof localProjects),
404
+ projects: localProjects.concat(remoteProjects as unknown as typeof localProjects),
405
405
  });
406
406
  var updateInfo = getCachedUpdateInfo();
407
407
  if (updateInfo && updateInfo.updateAvailable) {
@@ -245,9 +245,9 @@ registerHandler("mesh", function (clientId: string, message: ClientMessage) {
245
245
  }
246
246
 
247
247
  var inboundWs = getClientWebSocket(clientId);
248
- log.meshHello(" registering inbound connection for %s (ws=%s)", hello.name, !!inboundWs);
248
+ log.meshHello(" registering inbound connection for %s (ws=%s, projects=%d)", hello.name, !!inboundWs, hello.projects?.length ?? 0);
249
249
  if (inboundWs) {
250
- registerInboundPeer(hello.nodeId, inboundWs as any);
250
+ registerInboundPeer(hello.nodeId, inboundWs as any, hello.projects ?? []);
251
251
  }
252
252
 
253
253
  var identity = loadOrCreateIdentity();
@@ -17,6 +17,7 @@ interface PeerConnection {
17
17
  }
18
18
 
19
19
  var connections = new Map<string, PeerConnection>();
20
+ var lastKnownProjects = new Map<string, Array<{ slug: string; title: string }>>();
20
21
  var connectedCallbacks: Array<(nodeId: string) => void> = [];
21
22
  var disconnectedCallbacks: Array<(nodeId: string) => void> = [];
22
23
  var messageCallbacks: Array<(nodeId: string, msg: MeshMessage) => void> = [];
@@ -176,6 +177,9 @@ function openConnection(conn: PeerConnection, url: string): void {
176
177
 
177
178
  if (msg.type === "mesh:hello") {
178
179
  conn.projects = msg.projects;
180
+ if (msg.projects.length > 0) {
181
+ lastKnownProjects.set(conn.nodeId, msg.projects);
182
+ }
179
183
  } else if (msg.type === "mesh:session_sync") {
180
184
  handleSessionSync(conn.nodeId, msg as MeshSessionSyncMessage);
181
185
  } else if (msg.type === "mesh:session_request") {
@@ -248,7 +252,7 @@ export function getPeerConnection(nodeId: string): WebSocket | undefined {
248
252
  return conn.ws;
249
253
  }
250
254
 
251
- export function registerInboundPeer(nodeId: string, ws: { send: (data: string) => void; readyState: number }): void {
255
+ export function registerInboundPeer(nodeId: string, ws: { send: (data: string) => void; readyState: number }, peerProjects?: Array<{ slug: string; title: string }>): void {
252
256
  var existing = connections.get(nodeId);
253
257
  if (existing && !existing.dead && existing.ws.readyState === WebSocket.OPEN) {
254
258
  log.meshConnect("inbound peer %s already connected, skipping", nodeId.slice(0, 8));
@@ -265,15 +269,20 @@ export function registerInboundPeer(nodeId: string, ws: { send: (data: string) =
265
269
 
266
270
  circuitBreakers.delete(nodeId);
267
271
 
272
+ var incomingProjects = peerProjects ?? [];
268
273
  var conn: PeerConnection = {
269
274
  nodeId: nodeId,
270
275
  ws: ws as WebSocket,
271
276
  backoffMs: 1000,
272
277
  retryTimer: null,
273
278
  dead: false,
274
- projects: [],
279
+ projects: incomingProjects,
275
280
  };
276
281
 
282
+ if (incomingProjects.length > 0) {
283
+ lastKnownProjects.set(nodeId, incomingProjects);
284
+ }
285
+
277
286
  connections.set(nodeId, conn);
278
287
 
279
288
  var peers = loadPeers();
@@ -358,21 +367,33 @@ export function getConnectedPeerProjects(nodeId: string): Array<{ slug: string;
358
367
  return conn.projects;
359
368
  }
360
369
 
361
- export function getAllRemoteProjects(localNodeId: string): Array<{ slug: string; path: string; title: string; nodeId: string; nodeName: string; isRemote: boolean }> {
362
- var results: Array<{ slug: string; path: string; title: string; nodeId: string; nodeName: string; isRemote: boolean }> = [];
363
- for (var [nodeId, conn] of connections) {
364
- if (conn.ws.readyState !== WebSocket.OPEN) continue;
365
- var peers = require("./peers") as typeof import("./peers");
366
- var peer = peers.getPeer(nodeId);
367
- var peerName = peer ? peer.name : nodeId;
368
- for (var i = 0; i < conn.projects.length; i++) {
370
+ export function getAllRemoteProjects(localNodeId: string): Array<{ slug: string; path: string; title: string; nodeId: string; nodeName: string; isRemote: boolean; online: boolean }> {
371
+ var peersModule = require("./peers") as typeof import("./peers");
372
+ var allPeers = peersModule.loadPeers();
373
+ var connectedIds = new Set(getConnectedPeerIds());
374
+ var results: Array<{ slug: string; path: string; title: string; nodeId: string; nodeName: string; isRemote: boolean; online: boolean }> = [];
375
+
376
+ for (var p = 0; p < allPeers.length; p++) {
377
+ var peer = allPeers[p];
378
+ var isOnline = connectedIds.has(peer.id);
379
+ var projects: Array<{ slug: string; title: string }> = [];
380
+
381
+ var conn = connections.get(peer.id);
382
+ if (conn && conn.projects.length > 0) {
383
+ projects = conn.projects;
384
+ } else if (lastKnownProjects.has(peer.id)) {
385
+ projects = lastKnownProjects.get(peer.id)!;
386
+ }
387
+
388
+ for (var i = 0; i < projects.length; i++) {
369
389
  results.push({
370
- slug: conn.projects[i].slug,
390
+ slug: projects[i].slug,
371
391
  path: "",
372
- title: conn.projects[i].title,
373
- nodeId: nodeId,
374
- nodeName: peerName,
392
+ title: projects[i].title,
393
+ nodeId: peer.id,
394
+ nodeName: peer.name,
375
395
  isRemote: true,
396
+ online: isOnline,
376
397
  });
377
398
  }
378
399
  }
@@ -21,6 +21,7 @@ export interface ProjectSummary {
21
21
  export interface ProjectInfo extends ProjectSummary {
22
22
  nodeName: string;
23
23
  isRemote: boolean;
24
+ online?: boolean;
24
25
  ideProjectName?: string;
25
26
  }
26
27