@cryptiklemur/lattice 1.42.3 → 1.43.0

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.
@@ -1,3 +1,4 @@
1
+ import { useEffect } from "react";
1
2
  import { RouterProvider } from "@tanstack/react-router";
2
3
  import { router } from "./router";
3
4
  import { WebSocketProvider } from "./providers/WebSocketProvider";
@@ -10,6 +11,15 @@ import { UpdatePrompt } from "./components/ui/UpdatePrompt";
10
11
  function AppInner() {
11
12
  var { items, dismiss } = useToastState();
12
13
 
14
+ useEffect(function () {
15
+ function blockContextMenu(e: MouseEvent) {
16
+ if ((e.target as HTMLElement).closest("[data-allow-context-menu]")) return;
17
+ e.preventDefault();
18
+ }
19
+ document.addEventListener("contextmenu", blockContextMenu);
20
+ return function () { document.removeEventListener("contextmenu", blockContextMenu); };
21
+ }, []);
22
+
13
23
  return (
14
24
  <>
15
25
  <RouterProvider router={router} />
@@ -478,6 +478,7 @@ export function ChatInput(props: ChatInputProps) {
478
478
  <span className="absolute left-0 top-[1px] text-primary/50 font-mono text-[14px] leading-relaxed select-none pointer-events-none">›</span>
479
479
  <textarea
480
480
  ref={textareaRef}
481
+ data-allow-context-menu
481
482
  aria-label="Message input"
482
483
  placeholder={props.disabled ? (props.disabledPlaceholder || "Claude is responding...") : "Message Claude..."}
483
484
  disabled={props.disabled}
@@ -185,7 +185,7 @@ function parseSkillInvocation(text: string): { skillName: string; content: strin
185
185
  function SkillMessage(props: { skillName: string; content: string; time: string | null; uuid?: string }) {
186
186
  var [expanded, setExpanded] = useState(false);
187
187
  return (
188
- <div id={props.uuid ? "msg-" + props.uuid : undefined} className="chat chat-end px-5 py-1 group/msg">
188
+ <div id={props.uuid ? "msg-" + props.uuid : undefined} data-allow-context-menu className="chat chat-end px-5 py-1 group/msg">
189
189
  <div className="chat-bubble chat-bubble-primary text-[13px] leading-relaxed break-words max-w-[95%] sm:max-w-[85%] shadow-sm">
190
190
  <button
191
191
  type="button"
@@ -230,7 +230,7 @@ function UserMessage(props: { message: HistoryMessage }) {
230
230
  return <SkillMessage skillName={skill.skillName} content={skill.content} time={time} uuid={msg.uuid} />;
231
231
  }
232
232
  return (
233
- <div id={msg.uuid ? "msg-" + msg.uuid : undefined} className="chat chat-end px-5 py-1 group/msg">
233
+ <div id={msg.uuid ? "msg-" + msg.uuid : undefined} data-allow-context-menu className="chat chat-end px-5 py-1 group/msg">
234
234
  <div className="chat-bubble chat-bubble-primary text-[13px] leading-relaxed break-words max-w-[95%] sm:max-w-[85%] shadow-sm">
235
235
  <div className="prose prose-sm max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 prose-headings:text-primary-content prose-p:text-primary-content prose-strong:text-primary-content prose-code:text-primary-content/80 prose-pre:bg-primary/20 prose-a:text-primary-content/90 prose-a:underline">
236
236
  <Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>{text}</Markdown>
@@ -265,7 +265,7 @@ function AssistantMessage(props: { message: HistoryMessage; responseCost?: numbe
265
265
  var msg = props.message;
266
266
  var time = formatTime(msg.timestamp);
267
267
  return (
268
- <div id={msg.uuid ? "msg-" + msg.uuid : undefined} className="chat chat-start px-5 py-1 group/msg">
268
+ <div id={msg.uuid ? "msg-" + msg.uuid : undefined} data-allow-context-menu className="chat chat-start px-5 py-1 group/msg">
269
269
  <div className="chat-image">
270
270
  <div className="w-6 h-6 rounded-full bg-primary/15 border border-primary/20 flex items-center justify-center">
271
271
  <div className="w-2.5 h-2.5 rounded-full bg-primary" />
@@ -147,7 +147,7 @@ function ProjectButton(props: ProjectButtonProps) {
147
147
  );
148
148
  }
149
149
 
150
- function NodeIndicator({ node }: { node: NodeInfo }) {
150
+ function NodeIndicator({ node, onContextMenu }: { node: NodeInfo; onContextMenu: (e: React.MouseEvent, node: NodeInfo) => void }) {
151
151
  var [hovered, setHovered] = useState(false);
152
152
  var [tooltipTop, setTooltipTop] = useState(0);
153
153
  var sidebar = useSidebar();
@@ -157,6 +157,7 @@ function NodeIndicator({ node }: { node: NodeInfo }) {
157
157
  <div className="relative flex items-center">
158
158
  <button
159
159
  onClick={function () { sidebar.openSettings("nodes"); }}
160
+ onContextMenu={function (e) { e.preventDefault(); onContextMenu(e, node); }}
160
161
  onMouseEnter={function (e) {
161
162
  var rect = e.currentTarget.getBoundingClientRect();
162
163
  setTooltipTop(rect.top + rect.height / 2);
@@ -215,7 +216,9 @@ export function ProjectRail(props: ProjectRailProps) {
215
216
  var ws = useWebSocket();
216
217
  var sidebar = useSidebar();
217
218
  var groups = groupProjectsBySlug(props.projects, props.nodes);
219
+ var localNode = props.nodes.find(function (n) { return n.isLocal; });
218
220
  var remoteNodes = props.nodes.filter(function (n) { return !n.isLocal; });
221
+ var allMeshNodes = localNode ? [localNode].concat(remoteNodes) : remoteNodes;
219
222
  var [contextMenu, setContextMenu] = useState<ContextMenuState>({
220
223
  visible: false,
221
224
  x: 0,
@@ -223,23 +226,29 @@ export function ProjectRail(props: ProjectRailProps) {
223
226
  slug: null,
224
227
  });
225
228
  var menuRef = useRef<HTMLDivElement>(null);
229
+ var [nodeMenu, setNodeMenu] = useState<{ visible: boolean; x: number; y: number; node: NodeInfo | null }>({
230
+ visible: false, x: 0, y: 0, node: null,
231
+ });
226
232
 
227
233
  useEffect(
228
234
  function () {
229
- if (!contextMenu.visible) return;
235
+ if (!contextMenu.visible && !nodeMenu.visible) return;
230
236
 
231
237
  function handleClick() {
232
238
  setContextMenu(function (prev) { return { ...prev, visible: false }; });
239
+ setNodeMenu(function (prev) { return { ...prev, visible: false }; });
233
240
  }
234
241
 
235
242
  function handleKeyDown(e: KeyboardEvent) {
236
243
  if (e.key === "Escape") {
237
244
  setContextMenu(function (prev) { return { ...prev, visible: false }; });
245
+ setNodeMenu(function (prev) { return { ...prev, visible: false }; });
238
246
  }
239
247
  }
240
248
 
241
249
  function handleScroll() {
242
250
  setContextMenu(function (prev) { return { ...prev, visible: false }; });
251
+ setNodeMenu(function (prev) { return { ...prev, visible: false }; });
243
252
  }
244
253
 
245
254
  window.addEventListener("click", handleClick);
@@ -252,9 +261,17 @@ export function ProjectRail(props: ProjectRailProps) {
252
261
  window.removeEventListener("scroll", handleScroll, true);
253
262
  };
254
263
  },
255
- [contextMenu.visible]
264
+ [contextMenu.visible, nodeMenu.visible]
256
265
  );
257
266
 
267
+ function handleNodeContextMenu(e: React.MouseEvent, node: NodeInfo) {
268
+ var cx = e.clientX;
269
+ var cy = e.clientY;
270
+ if (cx + 160 > window.innerWidth - 8) cx = window.innerWidth - 168;
271
+ if (cy + 100 > window.innerHeight - 8) cy = window.innerHeight - 108;
272
+ setNodeMenu({ visible: true, x: cx, y: cy, node: node });
273
+ }
274
+
258
275
  function handleContextMenu(e: React.MouseEvent, slug: string) {
259
276
  var menuWidth = 160;
260
277
  var menuHeight = 100;
@@ -320,18 +337,10 @@ export function ProjectRail(props: ProjectRailProps) {
320
337
  })}
321
338
 
322
339
 
323
- {groups.length > 0 && remoteNodes.length > 0 && (
340
+ {groups.length > 0 && (
324
341
  <div className="w-6 h-px bg-base-300 my-0.5 flex-shrink-0" />
325
342
  )}
326
343
 
327
- {remoteNodes.map(function (node) {
328
- return (
329
- <NodeIndicator key={node.id} node={node} />
330
- );
331
- })}
332
-
333
- <div className="w-6 h-px bg-base-300 my-0.5 flex-shrink-0" />
334
-
335
344
  <button
336
345
  onClick={function () { sidebar.openAddProject(); }}
337
346
  className="w-[42px] h-[42px] flex items-center justify-center rounded-full border-2 border-dashed border-base-content/25 text-base-content/20 hover:border-base-content/40 hover:text-base-content/40 transition-colors duration-[120ms] flex-shrink-0 cursor-pointer"
@@ -340,6 +349,24 @@ export function ProjectRail(props: ProjectRailProps) {
340
349
  <Plus size={18} />
341
350
  </button>
342
351
 
352
+ {allMeshNodes.length > 0 && (
353
+ <div className="w-6 h-px bg-base-300 my-0.5 flex-shrink-0" />
354
+ )}
355
+
356
+ {allMeshNodes.map(function (node) {
357
+ return (
358
+ <NodeIndicator key={node.id} node={node} onContextMenu={handleNodeContextMenu} />
359
+ );
360
+ })}
361
+
362
+ <button
363
+ onClick={function () { sidebar.openSettings("nodes"); }}
364
+ className="w-[26px] h-[26px] flex items-center justify-center rounded-full border border-dashed border-base-content/15 text-base-content/15 hover:border-base-content/30 hover:text-base-content/30 transition-colors duration-[120ms] flex-shrink-0 cursor-pointer"
365
+ title="Pair a node"
366
+ >
367
+ <Plus size={12} />
368
+ </button>
369
+
343
370
  <div className="flex-1" />
344
371
 
345
372
  {contextMenu.visible && createPortal(
@@ -394,6 +421,59 @@ export function ProjectRail(props: ProjectRailProps) {
394
421
  document.body
395
422
  )}
396
423
 
424
+ {nodeMenu.visible && nodeMenu.node && createPortal(
425
+ <div
426
+ role="menu"
427
+ aria-label="Node actions"
428
+ onClick={function (e) { e.stopPropagation(); }}
429
+ className="fixed z-[99999] bg-base-300 border border-base-content/20 rounded-lg shadow-2xl py-1 min-w-[160px]"
430
+ style={{ left: nodeMenu.x + "px", top: nodeMenu.y + "px" }}
431
+ >
432
+ <button
433
+ role="menuitem"
434
+ className="w-full text-left px-3 py-1.5 text-sm text-base-content hover:bg-base-content/10 transition-colors"
435
+ onClick={function () {
436
+ sidebar.openSettings("nodes");
437
+ setNodeMenu(function (prev) { return { ...prev, visible: false }; });
438
+ }}
439
+ >
440
+ Node Settings
441
+ </button>
442
+ {!nodeMenu.node.isLocal && (
443
+ <>
444
+ {!nodeMenu.node.online && (
445
+ <button
446
+ role="menuitem"
447
+ className="w-full text-left px-3 py-1.5 text-sm text-base-content hover:bg-base-content/10 transition-colors"
448
+ onClick={function () {
449
+ if (nodeMenu.node) {
450
+ ws.send({ type: "mesh:reconnect", nodeId: nodeMenu.node.id } as any);
451
+ }
452
+ setNodeMenu(function (prev) { return { ...prev, visible: false }; });
453
+ }}
454
+ >
455
+ Reconnect
456
+ </button>
457
+ )}
458
+ <div className="my-1 h-px bg-base-content/10" />
459
+ <button
460
+ role="menuitem"
461
+ className="w-full text-left px-3 py-1.5 text-sm text-error hover:bg-error/10 transition-colors"
462
+ onClick={function () {
463
+ if (nodeMenu.node) {
464
+ ws.send({ type: "mesh:unpair", nodeId: nodeMenu.node.id });
465
+ }
466
+ setNodeMenu(function (prev) { return { ...prev, visible: false }; });
467
+ }}
468
+ >
469
+ Unpair
470
+ </button>
471
+ </>
472
+ )}
473
+ </div>,
474
+ document.body
475
+ )}
476
+
397
477
  </div>
398
478
  );
399
479
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.42.3",
3
+ "version": "1.43.0",
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>",