@cryptiklemur/lattice 1.14.1 → 1.15.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.
Files changed (34) hide show
  1. package/.github/workflows/ci.yml +72 -0
  2. package/bun.lock +5 -1
  3. package/client/src/App.tsx +2 -0
  4. package/client/src/components/analytics/ChartCard.tsx +6 -10
  5. package/client/src/components/chat/ChatView.tsx +7 -9
  6. package/client/src/components/chat/ToolResultRenderer.tsx +6 -2
  7. package/client/src/components/mesh/PairingDialog.tsx +6 -17
  8. package/client/src/components/project-settings/ProjectMemory.tsx +10 -19
  9. package/client/src/components/sidebar/AddProjectModal.tsx +7 -11
  10. package/client/src/components/sidebar/NodeSettingsModal.tsx +6 -11
  11. package/client/src/components/sidebar/ProjectRail.tsx +11 -1
  12. package/client/src/components/sidebar/SessionList.tsx +4 -0
  13. package/client/src/components/ui/KeyboardShortcuts.tsx +129 -0
  14. package/client/src/components/ui/Toast.tsx +22 -2
  15. package/client/src/components/workspace/TaskEditModal.tsx +7 -2
  16. package/client/src/hooks/useFocusTrap.ts +72 -0
  17. package/client/src/hooks/useWebSocket.ts +1 -0
  18. package/client/src/providers/WebSocketProvider.tsx +17 -3
  19. package/client/src/router.tsx +6 -11
  20. package/package.json +1 -1
  21. package/server/package.json +2 -0
  22. package/server/src/auth/passphrase.ts +23 -3
  23. package/server/src/daemon.ts +29 -7
  24. package/server/src/handlers/attachment.ts +17 -1
  25. package/server/src/handlers/session.ts +64 -35
  26. package/server/src/index.ts +27 -8
  27. package/server/src/logger.ts +12 -0
  28. package/server/src/mesh/connector.ts +54 -4
  29. package/server/src/mesh/pairing.ts +23 -3
  30. package/server/src/project/sdk-bridge.ts +82 -6
  31. package/server/src/project/session.ts +5 -4
  32. package/server/src/ws/broadcast.ts +7 -0
  33. package/server/src/ws/router.ts +36 -21
  34. package/shared/src/models.ts +3 -1
@@ -0,0 +1,72 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - main
10
+
11
+ jobs:
12
+ typecheck:
13
+ name: Typecheck
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v5
18
+
19
+ - name: Setup Bun
20
+ uses: oven-sh/setup-bun@v2
21
+ with:
22
+ bun-version: latest
23
+
24
+ - name: Setup Node.js
25
+ uses: actions/setup-node@v4
26
+ with:
27
+ node-version: 22
28
+
29
+ - name: Cache bun install
30
+ uses: actions/cache@v4
31
+ with:
32
+ path: ~/.bun/install/cache
33
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
34
+ restore-keys: |
35
+ ${{ runner.os }}-bun-
36
+
37
+ - name: Install dependencies
38
+ run: bun install --frozen-lockfile
39
+
40
+ - name: Typecheck
41
+ run: bunx tsc --noEmit -p tsconfig.json
42
+
43
+ build:
44
+ name: Build
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - name: Checkout
48
+ uses: actions/checkout@v5
49
+
50
+ - name: Setup Bun
51
+ uses: oven-sh/setup-bun@v2
52
+ with:
53
+ bun-version: latest
54
+
55
+ - name: Setup Node.js
56
+ uses: actions/setup-node@v4
57
+ with:
58
+ node-version: 22
59
+
60
+ - name: Cache bun install
61
+ uses: actions/cache@v4
62
+ with:
63
+ path: ~/.bun/install/cache
64
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
65
+ restore-keys: |
66
+ ${{ runner.os }}-bun-
67
+
68
+ - name: Install dependencies
69
+ run: bun install --frozen-lockfile
70
+
71
+ - name: Build client
72
+ run: cd client && npx vite build
package/bun.lock CHANGED
@@ -55,11 +55,13 @@
55
55
  "@anthropic-ai/claude-agent-sdk": "^0.2.79",
56
56
  "@lattice/shared": "workspace:*",
57
57
  "bonjour-service": "^1.3.0",
58
+ "debug": "^4.4.3",
58
59
  "js-tiktoken": "^1.0.21",
59
60
  "node-pty": "^1.1.0",
60
61
  "qrcode": "^1.5.4",
61
62
  },
62
63
  "devDependencies": {
64
+ "@types/debug": "^4.1.13",
63
65
  "@types/qrcode": "^1.5.6",
64
66
  "bun-types": "latest",
65
67
  },
@@ -508,7 +510,7 @@
508
510
 
509
511
  "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
510
512
 
511
- "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
513
+ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
512
514
 
513
515
  "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
514
516
 
@@ -1774,6 +1776,8 @@
1774
1776
 
1775
1777
  "make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
1776
1778
 
1779
+ "micromark/@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
1780
+
1777
1781
  "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
1778
1782
 
1779
1783
  "npm/@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
@@ -4,6 +4,7 @@ import { WebSocketProvider } from "./providers/WebSocketProvider";
4
4
  import { ErrorBoundary } from "./components/ui/ErrorBoundary";
5
5
  import { Toast, useToastState } from "./components/ui/Toast";
6
6
  import { CommandPalette } from "./components/ui/CommandPalette";
7
+ import { KeyboardShortcuts } from "./components/ui/KeyboardShortcuts";
7
8
  import { UpdatePrompt } from "./components/ui/UpdatePrompt";
8
9
 
9
10
  function AppInner() {
@@ -13,6 +14,7 @@ function AppInner() {
13
14
  <>
14
15
  <RouterProvider router={router} />
15
16
  <CommandPalette />
17
+ <KeyboardShortcuts />
16
18
  <Toast items={items} onDismiss={dismiss} />
17
19
  <UpdatePrompt />
18
20
  </>
@@ -1,4 +1,5 @@
1
- import { useState, useEffect, useRef, createContext, useContext } from "react";
1
+ import { useState, useEffect, useRef, createContext, useContext, useCallback } from "react";
2
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
2
3
  import { Maximize2, Minimize2 } from "lucide-react";
3
4
  import type { ReactNode } from "react";
4
5
 
@@ -31,6 +32,9 @@ export function ChartCard(props: ChartCardProps) {
31
32
  var cardRef = useRef<HTMLDivElement>(null);
32
33
  var [originRect, setOriginRect] = useState<DOMRect | null>(null);
33
34
  var [animating, setAnimating] = useState(false);
35
+ var fullscreenModalRef = useRef<HTMLDivElement>(null);
36
+ var closeFullscreenCb = useCallback(function () { closeFullscreen(); }, []);
37
+ useFocusTrap(fullscreenModalRef, closeFullscreenCb, isFullscreen);
34
38
 
35
39
  function openFullscreen() {
36
40
  if (cardRef.current) {
@@ -54,15 +58,6 @@ export function ChartCard(props: ChartCardProps) {
54
58
  }, 250);
55
59
  }
56
60
 
57
- useEffect(function () {
58
- if (!isFullscreen) return;
59
- function handleKeyDown(e: KeyboardEvent) {
60
- if (e.key === "Escape") closeFullscreen();
61
- }
62
- document.addEventListener("keydown", handleKeyDown);
63
- return function () { document.removeEventListener("keydown", handleKeyDown); };
64
- }, [isFullscreen]);
65
-
66
61
  useEffect(function () {
67
62
  if (isFullscreen) {
68
63
  document.body.style.overflow = "hidden";
@@ -134,6 +129,7 @@ export function ChartCard(props: ChartCardProps) {
134
129
  onClick={closeFullscreen}
135
130
  />
136
131
  <div
132
+ ref={fullscreenModalRef}
137
133
  className="fixed inset-0 z-[9999] flex items-center justify-center p-8"
138
134
  style={{ pointerEvents: "none" }}
139
135
  role="dialog"
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef, useCallback, useState, useMemo } from "react";
2
2
  import { Terminal, Info, ArrowDown, Pencil, Copy, Check, Menu, AlertTriangle, Zap, Square, X } from "lucide-react";
3
3
  import { LatticeLogomark } from "../ui/LatticeLogomark";
4
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
4
5
  import { useVirtualizer } from "@tanstack/react-virtual";
5
6
  import { useSession } from "../../hooks/useSession";
6
7
  import { useProjects } from "../../hooks/useProjects";
@@ -38,6 +39,9 @@ export function ChatView() {
38
39
  var [showInfo, setShowInfo] = useState<boolean>(false);
39
40
  var [confirmStopExternal, setConfirmStopExternal] = useState<boolean>(false);
40
41
  var [prefillText, setPrefillText] = useState<string | null>(null);
42
+ var stopExternalModalRef = useRef<HTMLDivElement>(null);
43
+ var closeStopExternal = useCallback(function () { setConfirmStopExternal(false); }, []);
44
+ useFocusTrap(stopExternalModalRef, closeStopExternal, confirmStopExternal);
41
45
 
42
46
  useEffect(function () {
43
47
  if (pendingPrefill && !historyLoading) {
@@ -92,7 +96,7 @@ export function ChatView() {
92
96
  if (msg.type === "user") return 100;
93
97
  return 200;
94
98
  },
95
- overscan: 30,
99
+ overscan: isMobile ? 10 : 20,
96
100
  });
97
101
 
98
102
  var scrollToBottom = useCallback(function () {
@@ -122,18 +126,12 @@ export function ChatView() {
122
126
  requestAnimationFrame(function () {
123
127
  var el = scrollParentRef.current;
124
128
  if (el) el.scrollTop = el.scrollHeight;
125
- requestAnimationFrame(function () {
126
- if (el) el.scrollTop = el.scrollHeight;
127
- });
128
129
  });
129
130
  } else {
130
131
  var count = messages.length;
131
132
  var virt = virtualizer;
132
133
  requestAnimationFrame(function () {
133
134
  virt.scrollToIndex(count - 1, { align: "end" });
134
- requestAnimationFrame(function () {
135
- virt.scrollToIndex(count - 1, { align: "end" });
136
- });
137
135
  });
138
136
  }
139
137
  return;
@@ -252,7 +250,7 @@ export function ChatView() {
252
250
  filled = contextUsage.inputTokens + contextUsage.cacheReadTokens + contextUsage.cacheCreationTokens;
253
251
  percent = Math.min(100, Math.round((filled / contextUsage.contextWindow) * 100));
254
252
  }
255
- var autocompact = contextBreakdown ? Math.round((contextBreakdown.autocompactAt / contextBreakdown.contextWindow) * 100) : 90;
253
+ var autocompact = contextBreakdown && contextBreakdown.contextWindow > 0 ? Math.round((contextBreakdown.autocompactAt / contextBreakdown.contextWindow) * 100) : 90;
256
254
  return {
257
255
  contextPercent: percent,
258
256
  contextFilled: filled,
@@ -891,7 +889,7 @@ export function ChatView() {
891
889
  )}
892
890
 
893
891
  {confirmStopExternal && (
894
- <div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="End External Process" onKeyDown={function (e) { if (e.key === "Escape") setConfirmStopExternal(false); }}>
892
+ <div ref={stopExternalModalRef} className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="End External Process">
895
893
  <div className="absolute inset-0 bg-black/50" onClick={function () { setConfirmStopExternal(false); }} />
896
894
  <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden">
897
895
  <div className="px-5 py-4 border-b border-base-content/15">
@@ -1,4 +1,5 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
2
3
  import { createPortal } from "react-dom";
3
4
  import Markdown from "react-markdown";
4
5
  import remarkGfm from "remark-gfm";
@@ -175,6 +176,9 @@ function ImageRenderer(props: { path: string }) {
175
176
  var [error, setError] = useState(false);
176
177
  var [modalOpen, setModalOpen] = useState(false);
177
178
  var imgSrc = "/api/file?path=" + encodeURIComponent(props.path);
179
+ var imageModalRef = useRef<HTMLDivElement>(null);
180
+ var closeImageModal = useCallback(function () { setModalOpen(false); }, []);
181
+ useFocusTrap(imageModalRef, closeImageModal, modalOpen);
178
182
 
179
183
  useEffect(function () {
180
184
  if (!modalOpen) return;
@@ -213,9 +217,9 @@ function ImageRenderer(props: { path: string }) {
213
217
  </div>
214
218
  {modalOpen && createPortal(
215
219
  <div
220
+ ref={imageModalRef}
216
221
  className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 sm:p-8 cursor-pointer overscroll-contain"
217
222
  onClick={function () { setModalOpen(false); }}
218
- onKeyDown={function (e) { if (e.key === "Escape") setModalOpen(false); }}
219
223
  onWheel={function (e) { e.stopPropagation(); }}
220
224
  onTouchMove={function (e) { e.stopPropagation(); }}
221
225
  role="dialog"
@@ -1,4 +1,5 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
2
3
  import { X, Copy, Check } from "lucide-react";
3
4
  import { useWebSocket } from "../../hooks/useWebSocket";
4
5
  import { useMesh } from "../../hooks/useMesh";
@@ -21,22 +22,9 @@ export function PairingDialog(props: PairingDialogProps) {
21
22
  var [pairStatus, setPairStatus] = useState<PairStatus>("idle");
22
23
  var [pairError, setPairError] = useState<string | null>(null);
23
24
  var [copied, setCopied] = useState(false);
24
-
25
- var handleKeyDown = useCallback(function (e: KeyboardEvent) {
26
- if (e.key === "Escape") {
27
- props.onClose();
28
- }
29
- }, [props.onClose]);
30
-
31
- useEffect(function () {
32
- if (!props.isOpen) {
33
- return;
34
- }
35
- document.addEventListener("keydown", handleKeyDown);
36
- return function () {
37
- document.removeEventListener("keydown", handleKeyDown);
38
- };
39
- }, [props.isOpen, handleKeyDown]);
25
+ var modalRef = useRef<HTMLDivElement>(null);
26
+ var stableOnClose = useCallback(function () { props.onClose(); }, [props.onClose]);
27
+ useFocusTrap(modalRef, stableOnClose, props.isOpen);
40
28
 
41
29
  useEffect(function () {
42
30
  if (!props.isOpen) {
@@ -113,6 +101,7 @@ export function PairingDialog(props: PairingDialogProps) {
113
101
 
114
102
  return (
115
103
  <div
104
+ ref={modalRef}
116
105
  role="dialog"
117
106
  aria-modal="true"
118
107
  aria-label="Pair a node"
@@ -1,4 +1,5 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
2
3
  import { Plus, Trash2, Pencil, X, Loader2, Brain, ExternalLink } from "lucide-react";
3
4
  import Markdown from "react-markdown";
4
5
  import remarkGfm from "remark-gfm";
@@ -120,17 +121,12 @@ function MemoryViewModal({
120
121
  }) {
121
122
  var parsed = parseFrontmatter(content);
122
123
  var hasMeta = Object.keys(parsed.meta).length > 0;
123
-
124
- useEffect(function () {
125
- function handleKeyDown(e: KeyboardEvent) {
126
- if (e.key === "Escape") onClose();
127
- }
128
- document.addEventListener("keydown", handleKeyDown);
129
- return function () { document.removeEventListener("keydown", handleKeyDown); };
130
- }, [onClose]);
124
+ var modalRef = useRef<HTMLDivElement>(null);
125
+ var stableOnClose = useCallback(function () { onClose(); }, [onClose]);
126
+ useFocusTrap(modalRef, stableOnClose);
131
127
 
132
128
  return (
133
- <div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label={"Memory: " + (parsed.meta.name || memory.filename)}>
129
+ <div ref={modalRef} className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label={"Memory: " + (parsed.meta.name || memory.filename)}>
134
130
  <div className="absolute inset-0 bg-black/50" onClick={onClose} />
135
131
  <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col overflow-hidden">
136
132
  <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15 flex-shrink-0">
@@ -209,6 +205,9 @@ function MemoryEditModal({
209
205
  var [description, setDescription] = useState(parsed.meta.description || (memory ? memory.description : ""));
210
206
  var [type, setType] = useState(parsed.meta.type || (memory ? memory.type : "project"));
211
207
  var [body, setBody] = useState(parsed.body);
208
+ var editModalRef = useRef<HTMLDivElement>(null);
209
+ var stableOnClose = useCallback(function () { onClose(); }, [onClose]);
210
+ useFocusTrap(editModalRef, stableOnClose);
212
211
 
213
212
  function handleSave() {
214
213
  var content = buildContent(name, description, type, body);
@@ -216,16 +215,8 @@ function MemoryEditModal({
216
215
  onSave(filename, content);
217
216
  }
218
217
 
219
- useEffect(function () {
220
- function handleKeyDown(e: KeyboardEvent) {
221
- if (e.key === "Escape") onClose();
222
- }
223
- document.addEventListener("keydown", handleKeyDown);
224
- return function () { document.removeEventListener("keydown", handleKeyDown); };
225
- }, [onClose]);
226
-
227
218
  return (
228
- <div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label={isNew ? "New Memory" : "Edit Memory"}>
219
+ <div ref={editModalRef} className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label={isNew ? "New Memory" : "Edit Memory"}>
229
220
  <div className="absolute inset-0 bg-black/50" onClick={onClose} />
230
221
  <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col overflow-hidden">
231
222
  <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15 flex-shrink-0">
@@ -1,4 +1,5 @@
1
- import { useState, useEffect, useRef } from "react";
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
2
3
  import { X, FolderOpen, FileText, Loader2 } from "lucide-react";
3
4
  import { useWebSocket } from "../../hooks/useWebSocket";
4
5
  import { useProjects } from "../../hooks/useProjects";
@@ -35,6 +36,9 @@ export function AddProjectModal({ isOpen, onClose }: AddProjectModalProps) {
35
36
  var dropdownRef = useRef<HTMLDivElement>(null);
36
37
  var inputFocusedRef = useRef(false);
37
38
  var addingRef = useRef(false);
39
+ var modalRef = useRef<HTMLDivElement>(null);
40
+ var stableOnClose = useCallback(function () { onClose(); }, [onClose]);
41
+ useFocusTrap(modalRef, stableOnClose, isOpen);
38
42
 
39
43
  useEffect(function () {
40
44
  if (!isOpen) return;
@@ -241,6 +245,7 @@ export function AddProjectModal({ isOpen, onClose }: AddProjectModalProps) {
241
245
  handleAdd();
242
246
  }
243
247
  } else if (e.key === "Escape") {
248
+ e.stopPropagation();
244
249
  setDropdownOpen(false);
245
250
  setHighlightIndex(-1);
246
251
  }
@@ -273,22 +278,13 @@ export function AddProjectModal({ isOpen, onClose }: AddProjectModalProps) {
273
278
  } as any);
274
279
  }
275
280
 
276
- useEffect(function () {
277
- if (!isOpen) return;
278
- function handleKeyDown(e: KeyboardEvent) {
279
- if (e.key === "Escape" && !dropdownOpen) onClose();
280
- }
281
- document.addEventListener("keydown", handleKeyDown);
282
- return function () { document.removeEventListener("keydown", handleKeyDown); };
283
- }, [isOpen, dropdownOpen, onClose]);
284
-
285
281
  if (!isOpen) return null;
286
282
 
287
283
  var filtered = getFilteredEntries();
288
284
  var validation = getValidationMessage();
289
285
 
290
286
  return (
291
- <div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="Add Project">
287
+ <div ref={modalRef} className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="Add Project">
292
288
  <div className="absolute inset-0 bg-black/50" onClick={onClose} />
293
289
  <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-3xl mx-4 overflow-hidden">
294
290
  <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
@@ -1,4 +1,5 @@
1
- import { useState, useEffect, useRef } from "react";
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
2
3
  import { X, Copy, Check } from "lucide-react";
3
4
  import { useWebSocket } from "../../hooks/useWebSocket";
4
5
  import { useMesh } from "../../hooks/useMesh";
@@ -27,6 +28,9 @@ export function NodeSettingsModal({ isOpen, onClose }: NodeSettingsModalProps) {
27
28
  var [copied, setCopied] = useState(false);
28
29
  var [wsl, setWsl] = useState<boolean | "auto">("auto");
29
30
  var copyTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
31
+ var modalRef = useRef<HTMLDivElement>(null);
32
+ var stableOnClose = useCallback(function () { onClose(); }, [onClose]);
33
+ useFocusTrap(modalRef, stableOnClose, isOpen);
30
34
 
31
35
  useEffect(function () {
32
36
  if (!isOpen) return;
@@ -79,21 +83,12 @@ export function NodeSettingsModal({ isOpen, onClose }: NodeSettingsModalProps) {
79
83
  copyTimeout.current = setTimeout(function () { setCopied(false); }, 2000);
80
84
  }
81
85
 
82
- useEffect(function () {
83
- if (!isOpen) return;
84
- function handleKeyDown(e: KeyboardEvent) {
85
- if (e.key === "Escape") onClose();
86
- }
87
- document.addEventListener("keydown", handleKeyDown);
88
- return function () { document.removeEventListener("keydown", handleKeyDown); };
89
- }, [isOpen, onClose]);
90
-
91
86
  if (!isOpen) return null;
92
87
 
93
88
  var inputClass = "w-full h-9 px-3 bg-base-300 border border-base-content/15 rounded-xl text-base-content text-[13px] focus:border-primary focus-visible:outline-none transition-colors duration-[120ms]";
94
89
 
95
90
  return (
96
- <div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="Node Settings">
91
+ <div ref={modalRef} className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="Node Settings">
97
92
  <div className="absolute inset-0 bg-black/50" onClick={onClose} />
98
93
  <div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
99
94
  <div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
@@ -208,7 +208,7 @@ export function ProjectRail(props: ProjectRailProps) {
208
208
  <button
209
209
  onClick={props.onDashboardClick}
210
210
  className={
211
- "w-[42px] h-[42px] flex items-center justify-center cursor-pointer transition-all duration-[120ms] flex-shrink-0 " +
211
+ "relative w-[42px] h-[42px] flex items-center justify-center cursor-pointer transition-all duration-[120ms] flex-shrink-0 " +
212
212
  (props.isDashboardActive
213
213
  ? "rounded-xl bg-primary text-primary-content"
214
214
  : "rounded-full bg-base-200 text-base-content/60 hover:rounded-xl hover:bg-primary/20 hover:text-primary")
@@ -216,6 +216,16 @@ export function ProjectRail(props: ProjectRailProps) {
216
216
  title="Lattice Dashboard"
217
217
  >
218
218
  <LatticeLogomark size={22} />
219
+ <div
220
+ className={
221
+ "absolute bottom-0 right-0 w-2 h-2 rounded-full border-[1.5px] border-base-100 pointer-events-none " +
222
+ (ws.status === "connected"
223
+ ? "bg-success"
224
+ : ws.status === "connecting"
225
+ ? "bg-warning animate-pulse"
226
+ : "bg-error")
227
+ }
228
+ />
219
229
  </button>
220
230
  </div>
221
231
 
@@ -248,6 +248,10 @@ export function SessionList(props: SessionListProps) {
248
248
  var previewMsg = msg as SessionPreviewMessage;
249
249
  setPreviews(function (prev3) {
250
250
  var next = new Map(prev3);
251
+ if (next.size >= 100 && !next.has(previewMsg.sessionId)) {
252
+ var oldest = next.keys().next().value;
253
+ if (oldest !== undefined) next.delete(oldest);
254
+ }
251
255
  next.set(previewMsg.sessionId, previewMsg.preview);
252
256
  return next;
253
257
  });
@@ -0,0 +1,129 @@
1
+ import { useEffect, useState } from "react";
2
+ import { X } from "lucide-react";
3
+
4
+ interface ShortcutEntry {
5
+ keys: string[];
6
+ description: string;
7
+ }
8
+
9
+ interface ShortcutCategory {
10
+ name: string;
11
+ shortcuts: ShortcutEntry[];
12
+ }
13
+
14
+ var isMac = typeof navigator !== "undefined" && navigator.platform.indexOf("Mac") !== -1;
15
+ var modKey = isMac ? "\u2318" : "Ctrl";
16
+
17
+ var categories: ShortcutCategory[] = [
18
+ {
19
+ name: "Chat",
20
+ shortcuts: [
21
+ { keys: ["Enter"], description: "Send message" },
22
+ { keys: ["Shift", "Enter"], description: "New line" },
23
+ { keys: ["\u2191"], description: "Previous input history" },
24
+ { keys: ["\u2193"], description: "Next input history" },
25
+ { keys: ["/"], description: "Slash commands" },
26
+ { keys: ["Escape"], description: "Close autocomplete" },
27
+ ],
28
+ },
29
+ {
30
+ name: "Navigation",
31
+ shortcuts: [
32
+ { keys: [modKey, "K"], description: "Command palette" },
33
+ { keys: ["?"], description: "Keyboard shortcuts" },
34
+ { keys: ["Escape"], description: "Close modal / exit settings" },
35
+ ],
36
+ },
37
+ {
38
+ name: "Session",
39
+ shortcuts: [
40
+ { keys: ["/clear"], description: "New session" },
41
+ { keys: ["/copy"], description: "Copy last response" },
42
+ { keys: ["/export"], description: "Export conversation" },
43
+ { keys: ["/rename"], description: "Rename session" },
44
+ ],
45
+ },
46
+ ];
47
+
48
+ function Kbd(props: { children: string }) {
49
+ return (
50
+ <kbd className="bg-base-300 border border-base-content/20 rounded px-1.5 py-0.5 text-[11px] font-mono text-base-content/70 leading-none inline-flex items-center justify-center min-w-[22px]">
51
+ {props.children}
52
+ </kbd>
53
+ );
54
+ }
55
+
56
+ export function KeyboardShortcuts() {
57
+ var [open, setOpen] = useState(false);
58
+
59
+ useEffect(function () {
60
+ function handleKeyDown(e: KeyboardEvent) {
61
+ if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey) {
62
+ var tag = (e.target as HTMLElement).tagName;
63
+ if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
64
+ e.preventDefault();
65
+ setOpen(true);
66
+ }
67
+ if (e.key === "Escape" && open) {
68
+ setOpen(false);
69
+ }
70
+ }
71
+ document.addEventListener("keydown", handleKeyDown);
72
+ return function () { document.removeEventListener("keydown", handleKeyDown); };
73
+ }, [open]);
74
+
75
+ if (!open) return null;
76
+
77
+ return (
78
+ <div className="fixed inset-0 z-[9998] flex items-center justify-center" onClick={function () { setOpen(false); }}>
79
+ <div className="absolute inset-0 bg-black/50" />
80
+ <div
81
+ className="relative w-full max-w-[540px] mx-4 bg-base-200 border border-base-content/15 rounded-2xl shadow-xl overflow-hidden"
82
+ onClick={function (e) { e.stopPropagation(); }}
83
+ >
84
+ <div className="flex items-center justify-between px-5 py-3.5 border-b border-base-content/10">
85
+ <h2 className="text-[14px] font-mono font-bold text-base-content tracking-tight">Keyboard Shortcuts</h2>
86
+ <button
87
+ onClick={function () { setOpen(false); }}
88
+ className="w-6 h-6 rounded flex items-center justify-center text-base-content/30 hover:text-base-content/60 transition-colors"
89
+ >
90
+ <X size={14} />
91
+ </button>
92
+ </div>
93
+ <div className="px-5 py-4 max-h-[70vh] overflow-y-auto">
94
+ <div className="flex flex-col gap-5">
95
+ {categories.map(function (cat) {
96
+ return (
97
+ <div key={cat.name}>
98
+ <div className="text-[9px] uppercase tracking-widest text-base-content/30 font-mono font-bold mb-2">{cat.name}</div>
99
+ <div className="flex flex-col gap-1">
100
+ {cat.shortcuts.map(function (shortcut, i) {
101
+ return (
102
+ <div key={i} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-base-content/5 transition-colors">
103
+ <span className="text-[13px] text-base-content/60">{shortcut.description}</span>
104
+ <div className="flex items-center gap-1 flex-shrink-0 ml-4">
105
+ {shortcut.keys.map(function (key, ki) {
106
+ return (
107
+ <span key={ki} className="flex items-center gap-1">
108
+ {ki > 0 && <span className="text-[10px] text-base-content/20">+</span>}
109
+ <Kbd>{key}</Kbd>
110
+ </span>
111
+ );
112
+ })}
113
+ </div>
114
+ </div>
115
+ );
116
+ })}
117
+ </div>
118
+ </div>
119
+ );
120
+ })}
121
+ </div>
122
+ </div>
123
+ <div className="px-5 py-2.5 border-t border-base-content/10 flex justify-end">
124
+ <span className="text-[10px] font-mono text-base-content/20">Press <Kbd>Esc</Kbd> to close</span>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ );
129
+ }
@@ -1,10 +1,17 @@
1
1
  import { useEffect, useState, useCallback } from "react";
2
2
  import { X, Info, AlertTriangle, AlertCircle } from "lucide-react";
3
3
 
4
+ export interface ToastOptions {
5
+ persistent?: boolean;
6
+ duration?: number;
7
+ }
8
+
4
9
  export interface ToastItem {
5
10
  id: string;
6
11
  message: string;
7
12
  type: "info" | "error" | "warning";
13
+ persistent?: boolean;
14
+ duration?: number;
8
15
  }
9
16
 
10
17
  interface ToastProps {
@@ -63,11 +70,20 @@ export function Toast(props: ToastProps) {
63
70
 
64
71
  var toastListeners: Array<(item: ToastItem) => void> = [];
65
72
 
66
- export function showToast(message: string, type: ToastItem["type"] = "info"): void {
73
+ export function showToast(message: string, type: ToastItem["type"] = "info", options?: ToastOptions): void {
74
+ var persistent = options?.persistent;
75
+ var duration = options?.duration;
76
+
77
+ if (type === "error" && persistent === undefined && duration === undefined) {
78
+ persistent = true;
79
+ }
80
+
67
81
  var item: ToastItem = {
68
82
  id: Math.random().toString(36).slice(2),
69
83
  message,
70
84
  type,
85
+ persistent,
86
+ duration,
71
87
  };
72
88
  toastListeners.forEach(function (listener) {
73
89
  listener(item);
@@ -90,13 +106,17 @@ export function useToastState(): { items: ToastItem[]; dismiss: (id: string) =>
90
106
  setItems(function (prev) {
91
107
  return [...prev, item];
92
108
  });
109
+
110
+ if (item.persistent) return;
111
+
112
+ var timeout = item.duration ?? 5000;
93
113
  setTimeout(function () {
94
114
  setItems(function (prev) {
95
115
  return prev.filter(function (i) {
96
116
  return i.id !== item.id;
97
117
  });
98
118
  });
99
- }, 5000);
119
+ }, timeout);
100
120
  }
101
121
 
102
122
  toastListeners.push(listener);