@cryptiklemur/lattice 1.14.2 → 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.
- package/.github/workflows/ci.yml +72 -0
- package/bun.lock +5 -1
- package/client/src/App.tsx +2 -0
- package/client/src/components/analytics/ChartCard.tsx +6 -10
- package/client/src/components/chat/ChatView.tsx +5 -1
- package/client/src/components/chat/ToolResultRenderer.tsx +6 -2
- package/client/src/components/mesh/PairingDialog.tsx +6 -17
- package/client/src/components/project-settings/ProjectMemory.tsx +10 -19
- package/client/src/components/sidebar/AddProjectModal.tsx +7 -11
- package/client/src/components/sidebar/NodeSettingsModal.tsx +6 -11
- package/client/src/components/sidebar/ProjectRail.tsx +11 -1
- package/client/src/components/ui/KeyboardShortcuts.tsx +129 -0
- package/client/src/components/ui/Toast.tsx +22 -2
- package/client/src/components/workspace/TaskEditModal.tsx +7 -2
- package/client/src/hooks/useFocusTrap.ts +72 -0
- package/client/src/router.tsx +6 -11
- package/package.json +1 -1
- package/server/package.json +2 -0
- package/server/src/daemon.ts +8 -7
- package/server/src/handlers/session.ts +10 -9
- package/server/src/logger.ts +12 -0
- package/server/src/mesh/connector.ts +7 -6
- package/server/src/project/sdk-bridge.ts +62 -3
- package/server/src/project/session.ts +5 -4
- package/server/src/ws/router.ts +5 -4
- 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.
|
|
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=="],
|
package/client/src/App.tsx
CHANGED
|
@@ -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) {
|
|
@@ -885,7 +889,7 @@ export function ChatView() {
|
|
|
885
889
|
)}
|
|
886
890
|
|
|
887
891
|
{confirmStopExternal && (
|
|
888
|
-
<div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="End External Process"
|
|
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">
|
|
889
893
|
<div className="absolute inset-0 bg-black/50" onClick={function () { setConfirmStopExternal(false); }} />
|
|
890
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">
|
|
891
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
|
|
26
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
|
@@ -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
|
-
},
|
|
119
|
+
}, timeout);
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
toastListeners.push(listener);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, useRef, useCallback } from "react";
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
|
+
import { useFocusTrap } from "../../hooks/useFocusTrap";
|
|
3
4
|
import cronstrue from "cronstrue";
|
|
4
5
|
import type { ScheduledTask } from "@lattice/shared";
|
|
5
6
|
|
|
@@ -25,6 +26,10 @@ export function TaskEditModal(props: TaskEditModalProps) {
|
|
|
25
26
|
var [prompt, setPrompt] = useState(task ? task.prompt : "");
|
|
26
27
|
var [cron, setCron] = useState(task ? task.cron : "0 9 * * 1-5");
|
|
27
28
|
|
|
29
|
+
var modalRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
var stableOnClose = useCallback(function () { onClose(); }, [onClose]);
|
|
31
|
+
useFocusTrap(modalRef, stableOnClose);
|
|
32
|
+
|
|
28
33
|
var cronPreview = getCronPreview(cron);
|
|
29
34
|
var cronValid = cronPreview !== "Invalid cron expression" && cronPreview !== "";
|
|
30
35
|
|
|
@@ -40,12 +45,12 @@ export function TaskEditModal(props: TaskEditModalProps) {
|
|
|
40
45
|
|
|
41
46
|
return (
|
|
42
47
|
<div
|
|
48
|
+
ref={modalRef}
|
|
43
49
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
44
50
|
onClick={handleBackdrop}
|
|
45
51
|
role="dialog"
|
|
46
52
|
aria-modal="true"
|
|
47
53
|
aria-label={task ? "Edit Task" : "New Scheduled Task"}
|
|
48
|
-
onKeyDown={function (e) { if (e.key === "Escape") onClose(); }}
|
|
49
54
|
>
|
|
50
55
|
<div className="bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-md mx-4">
|
|
51
56
|
<div className="flex items-center justify-between px-5 py-4 border-b border-base-content/15">
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
var FOCUSABLE_SELECTOR = [
|
|
4
|
+
"a[href]",
|
|
5
|
+
"button:not([disabled])",
|
|
6
|
+
"input:not([disabled])",
|
|
7
|
+
"select:not([disabled])",
|
|
8
|
+
"textarea:not([disabled])",
|
|
9
|
+
"[tabindex]:not([tabindex='-1'])",
|
|
10
|
+
].join(", ");
|
|
11
|
+
|
|
12
|
+
export function useFocusTrap(
|
|
13
|
+
containerRef: React.RefObject<HTMLElement | null>,
|
|
14
|
+
onClose: () => void,
|
|
15
|
+
active: boolean = true,
|
|
16
|
+
): void {
|
|
17
|
+
var previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(function () {
|
|
20
|
+
if (!active) return;
|
|
21
|
+
|
|
22
|
+
var container = containerRef.current;
|
|
23
|
+
if (!container) return;
|
|
24
|
+
|
|
25
|
+
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
|
|
26
|
+
|
|
27
|
+
var focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
28
|
+
if (focusable.length > 0) {
|
|
29
|
+
focusable[0].focus();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
33
|
+
if (e.key === "Escape") {
|
|
34
|
+
onClose();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (e.key !== "Tab") return;
|
|
39
|
+
|
|
40
|
+
var currentContainer = containerRef.current;
|
|
41
|
+
if (!currentContainer) return;
|
|
42
|
+
|
|
43
|
+
var elements = currentContainer.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
44
|
+
if (elements.length === 0) return;
|
|
45
|
+
|
|
46
|
+
var first = elements[0];
|
|
47
|
+
var last = elements[elements.length - 1];
|
|
48
|
+
|
|
49
|
+
if (e.shiftKey) {
|
|
50
|
+
if (document.activeElement === first) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
last.focus();
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
if (document.activeElement === last) {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
first.focus();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
var el = container;
|
|
63
|
+
el.addEventListener("keydown", handleKeyDown);
|
|
64
|
+
|
|
65
|
+
return function () {
|
|
66
|
+
el.removeEventListener("keydown", handleKeyDown);
|
|
67
|
+
if (previouslyFocusedRef.current && typeof previouslyFocusedRef.current.focus === "function") {
|
|
68
|
+
previouslyFocusedRef.current.focus();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}, [containerRef, onClose, active]);
|
|
72
|
+
}
|
package/client/src/router.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRouter, createRootRoute, createRoute, createMemoryHistory } from "@tanstack/react-router";
|
|
2
2
|
import { Outlet } from "@tanstack/react-router";
|
|
3
|
-
import { useState, useEffect, useRef } from "react";
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import { useFocusTrap } from "./hooks/useFocusTrap";
|
|
4
5
|
import { Sidebar } from "./components/sidebar/Sidebar";
|
|
5
6
|
import { WorkspaceView } from "./components/workspace/WorkspaceView";
|
|
6
7
|
import { SetupWizard } from "./components/setup/SetupWizard";
|
|
@@ -299,6 +300,9 @@ function RemoveProjectConfirm() {
|
|
|
299
300
|
var sidebar = useSidebar();
|
|
300
301
|
var ws = useWebSocket();
|
|
301
302
|
var slug = sidebar.confirmRemoveSlug;
|
|
303
|
+
var removeModalRef = useRef<HTMLDivElement>(null);
|
|
304
|
+
var stableCloseRemove = useCallback(function () { sidebar.closeConfirmRemove(); }, [sidebar.closeConfirmRemove]);
|
|
305
|
+
useFocusTrap(removeModalRef, stableCloseRemove, !!slug);
|
|
302
306
|
|
|
303
307
|
if (!slug) return null;
|
|
304
308
|
|
|
@@ -311,17 +315,8 @@ function RemoveProjectConfirm() {
|
|
|
311
315
|
}
|
|
312
316
|
})();
|
|
313
317
|
|
|
314
|
-
useEffect(function () {
|
|
315
|
-
if (!slug) return;
|
|
316
|
-
function handleKeyDown(e: KeyboardEvent) {
|
|
317
|
-
if (e.key === "Escape") sidebar.closeConfirmRemove();
|
|
318
|
-
}
|
|
319
|
-
document.addEventListener("keydown", handleKeyDown);
|
|
320
|
-
return function () { document.removeEventListener("keydown", handleKeyDown); };
|
|
321
|
-
}, [slug]);
|
|
322
|
-
|
|
323
318
|
return (
|
|
324
|
-
<div className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="Remove Project">
|
|
319
|
+
<div ref={removeModalRef} className="fixed inset-0 z-[9999] flex items-center justify-center" role="dialog" aria-modal="true" aria-label="Remove Project">
|
|
325
320
|
<div className="absolute inset-0 bg-black/50" onClick={sidebar.closeConfirmRemove} />
|
|
326
321
|
<div className="relative bg-base-200 border border-base-content/15 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden">
|
|
327
322
|
<div className="px-5 py-4 border-b border-base-content/15">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cryptiklemur/lattice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.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>",
|
package/server/package.json
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
"@anthropic-ai/claude-agent-sdk": "^0.2.79",
|
|
12
12
|
"@lattice/shared": "workspace:*",
|
|
13
13
|
"bonjour-service": "^1.3.0",
|
|
14
|
+
"debug": "^4.4.3",
|
|
14
15
|
"js-tiktoken": "^1.0.21",
|
|
15
16
|
"node-pty": "^1.1.0",
|
|
16
17
|
"qrcode": "^1.5.4"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
20
|
+
"@types/debug": "^4.1.13",
|
|
19
21
|
"@types/qrcode": "^1.5.6",
|
|
20
22
|
"bun-types": "latest"
|
|
21
23
|
}
|
package/server/src/daemon.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { handleProxyRequest, handleProxyResponse } from "./mesh/proxy";
|
|
|
12
12
|
import { verifyPassphrase, generateSessionToken, addSession, isValidSession } from "./auth/passphrase";
|
|
13
13
|
import { ensureCerts } from "./tls";
|
|
14
14
|
import type { ClientMessage, MeshMessage } from "@lattice/shared";
|
|
15
|
+
import { log } from "./logger";
|
|
15
16
|
import "./handlers/session";
|
|
16
17
|
import "./handlers/chat";
|
|
17
18
|
import "./handlers/attachment";
|
|
@@ -194,8 +195,8 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
194
195
|
}
|
|
195
196
|
var identity = loadOrCreateIdentity();
|
|
196
197
|
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
log.server("Node: %s (%s)", config.name, identity.id);
|
|
199
|
+
log.server("Home: %s", getLatticeHome());
|
|
199
200
|
|
|
200
201
|
var clientDir = join(import.meta.dir, "../../client/dist");
|
|
201
202
|
|
|
@@ -207,7 +208,7 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
207
208
|
cert: readFileSync(certs.cert),
|
|
208
209
|
key: readFileSync(certs.key),
|
|
209
210
|
};
|
|
210
|
-
|
|
211
|
+
log.server("TLS enabled");
|
|
211
212
|
} catch (err) {
|
|
212
213
|
console.error("[lattice] Failed to load TLS certs, falling back to HTTP:", err);
|
|
213
214
|
}
|
|
@@ -297,7 +298,7 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
297
298
|
websocket: {
|
|
298
299
|
open(ws: ServerWebSocket<WsData>) {
|
|
299
300
|
addClient(ws);
|
|
300
|
-
|
|
301
|
+
log.ws("Client connected: %s", ws.data.id);
|
|
301
302
|
sendTo(ws.data.id, { type: "mesh:nodes", nodes: buildNodesMessage() });
|
|
302
303
|
},
|
|
303
304
|
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
|
|
@@ -318,7 +319,7 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
318
319
|
var msg = JSON.parse(text) as ClientMessage;
|
|
319
320
|
routeMessage(ws.data.id, msg);
|
|
320
321
|
} catch (err) {
|
|
321
|
-
|
|
322
|
+
log.ws("Invalid JSON message: %O", err);
|
|
322
323
|
}
|
|
323
324
|
},
|
|
324
325
|
close(ws: ServerWebSocket<WsData>) {
|
|
@@ -334,12 +335,12 @@ export async function startDaemon(portOverride?: number | null): Promise<void> {
|
|
|
334
335
|
cleanupClientAttachments(ws.data.id);
|
|
335
336
|
cleanupClientPermissions(ws.data.id);
|
|
336
337
|
clientRateLimits.delete(ws.data.id);
|
|
337
|
-
|
|
338
|
+
log.ws("Client disconnected: %s", ws.data.id);
|
|
338
339
|
},
|
|
339
340
|
},
|
|
340
341
|
});
|
|
341
342
|
|
|
342
|
-
|
|
343
|
+
log.server("Listening on %s://0.0.0.0:%d", protocol, config.port);
|
|
343
344
|
|
|
344
345
|
startDiscovery(identity.id, config.name, config.port);
|
|
345
346
|
|
|
@@ -25,6 +25,7 @@ import { getContextBreakdown } from "../project/context-breakdown";
|
|
|
25
25
|
import { setActiveSession } from "./chat";
|
|
26
26
|
import { setActiveProject } from "./fs";
|
|
27
27
|
import { wasSessionInterrupted, clearInterruptedFlag, isSessionBusy, watchSessionLock, stopExternalSession } from "../project/sdk-bridge";
|
|
28
|
+
import { log } from "../logger";
|
|
28
29
|
|
|
29
30
|
registerHandler("session", function (clientId: string, message: ClientMessage) {
|
|
30
31
|
if (message.type === "session:list_request") {
|
|
@@ -90,19 +91,19 @@ registerHandler("session", function (clientId: string, message: ClientMessage) {
|
|
|
90
91
|
watchSessionLock(activateMsg.sessionId);
|
|
91
92
|
void Promise.all([
|
|
92
93
|
loadSessionHistory(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
93
|
-
|
|
94
|
+
log.session("Failed to load session history: %O", err);
|
|
94
95
|
return null;
|
|
95
96
|
}),
|
|
96
97
|
getSessionTitle(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
97
|
-
|
|
98
|
+
log.session("Failed to load session title: %O", err);
|
|
98
99
|
return null;
|
|
99
100
|
}),
|
|
100
101
|
getSessionUsage(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
101
|
-
|
|
102
|
+
log.session("Failed to load session usage: %O", err);
|
|
102
103
|
return null;
|
|
103
104
|
}),
|
|
104
105
|
getContextBreakdown(activateMsg.projectSlug, activateMsg.sessionId).catch(function (err) {
|
|
105
|
-
|
|
106
|
+
log.session("Failed to load context breakdown: %O", err);
|
|
106
107
|
return null;
|
|
107
108
|
}),
|
|
108
109
|
]).then(function (results) {
|
|
@@ -122,7 +123,7 @@ registerHandler("session", function (clientId: string, message: ClientMessage) {
|
|
|
122
123
|
busy: busy || undefined,
|
|
123
124
|
});
|
|
124
125
|
} catch (err) {
|
|
125
|
-
|
|
126
|
+
log.session("Error sending session history: %O", err);
|
|
126
127
|
sendTo(clientId, { type: "chat:error", message: "Failed to load session history" });
|
|
127
128
|
}
|
|
128
129
|
try {
|
|
@@ -138,7 +139,7 @@ registerHandler("session", function (clientId: string, message: ClientMessage) {
|
|
|
138
139
|
});
|
|
139
140
|
}
|
|
140
141
|
} catch (err) {
|
|
141
|
-
|
|
142
|
+
log.session("Error sending context usage: %O", err);
|
|
142
143
|
}
|
|
143
144
|
try {
|
|
144
145
|
var breakdown = results[3];
|
|
@@ -151,10 +152,10 @@ registerHandler("session", function (clientId: string, message: ClientMessage) {
|
|
|
151
152
|
});
|
|
152
153
|
}
|
|
153
154
|
} catch (err) {
|
|
154
|
-
|
|
155
|
+
log.session("Error sending context breakdown: %O", err);
|
|
155
156
|
}
|
|
156
157
|
}).catch(function (err) {
|
|
157
|
-
|
|
158
|
+
log.session("Failed to activate session: %O", err);
|
|
158
159
|
sendTo(clientId, { type: "chat:error", message: "Failed to activate session" });
|
|
159
160
|
});
|
|
160
161
|
return;
|
|
@@ -203,7 +204,7 @@ registerHandler("session", function (clientId: string, message: ClientMessage) {
|
|
|
203
204
|
var stopMsg = message as { type: string; sessionId: string };
|
|
204
205
|
var stopped = stopExternalSession(stopMsg.sessionId);
|
|
205
206
|
if (stopped) {
|
|
206
|
-
|
|
207
|
+
log.session("Sent SIGINT to external CLI process for session %s", stopMsg.sessionId);
|
|
207
208
|
} else {
|
|
208
209
|
sendTo(clientId, { type: "chat:error", message: "No external process found for this session." });
|
|
209
210
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import createDebug from "debug";
|
|
2
|
+
|
|
3
|
+
export var log = {
|
|
4
|
+
server: createDebug("lattice:server"),
|
|
5
|
+
ws: createDebug("lattice:ws"),
|
|
6
|
+
chat: createDebug("lattice:chat"),
|
|
7
|
+
session: createDebug("lattice:session"),
|
|
8
|
+
mesh: createDebug("lattice:mesh"),
|
|
9
|
+
auth: createDebug("lattice:auth"),
|
|
10
|
+
fs: createDebug("lattice:fs"),
|
|
11
|
+
analytics: createDebug("lattice:analytics"),
|
|
12
|
+
};
|
|
@@ -5,6 +5,7 @@ import { loadConfig } from "../config";
|
|
|
5
5
|
import { listProjects } from "../project/registry";
|
|
6
6
|
import { getProjectBySlug } from "../project/registry";
|
|
7
7
|
import { handleSessionSync, handleSessionRequest } from "./session-sync";
|
|
8
|
+
import { log } from "../logger";
|
|
8
9
|
|
|
9
10
|
interface PeerConnection {
|
|
10
11
|
nodeId: string;
|
|
@@ -102,7 +103,7 @@ function openConnection(conn: PeerConnection, url: string): void {
|
|
|
102
103
|
|
|
103
104
|
var connectionTimer = setTimeout(function () {
|
|
104
105
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
105
|
-
|
|
106
|
+
log.mesh("Connection timeout for peer: %s", conn.nodeId);
|
|
106
107
|
ws.close();
|
|
107
108
|
}
|
|
108
109
|
}, CONNECTION_TIMEOUT_MS);
|
|
@@ -132,7 +133,7 @@ function openConnection(conn: PeerConnection, url: string): void {
|
|
|
132
133
|
|
|
133
134
|
ws.send(JSON.stringify(hello));
|
|
134
135
|
|
|
135
|
-
|
|
136
|
+
log.mesh("Connected to peer: %s", conn.nodeId);
|
|
136
137
|
|
|
137
138
|
for (var i = 0; i < connectedCallbacks.length; i++) {
|
|
138
139
|
connectedCallbacks[i](conn.nodeId);
|
|
@@ -163,7 +164,7 @@ function openConnection(conn: PeerConnection, url: string): void {
|
|
|
163
164
|
messageCallbacks[i](conn.nodeId, msg);
|
|
164
165
|
}
|
|
165
166
|
} catch {
|
|
166
|
-
|
|
167
|
+
log.mesh("Invalid message from peer: %s", conn.nodeId);
|
|
167
168
|
}
|
|
168
169
|
});
|
|
169
170
|
|
|
@@ -183,10 +184,10 @@ function openConnection(conn: PeerConnection, url: string): void {
|
|
|
183
184
|
if (circuit.halfOpen) {
|
|
184
185
|
circuit.halfOpen = false;
|
|
185
186
|
circuit.openUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN;
|
|
186
|
-
|
|
187
|
+
log.mesh("Circuit breaker open for peer: %s (half-open attempt failed)", conn.nodeId);
|
|
187
188
|
} else if (circuit.failures >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
188
189
|
circuit.openUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN;
|
|
189
|
-
|
|
190
|
+
log.mesh("Circuit breaker open for peer: %s after %d consecutive failures", conn.nodeId, circuit.failures);
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
for (var i = 0; i < disconnectedCallbacks.length; i++) {
|
|
@@ -206,7 +207,7 @@ function openConnection(conn: PeerConnection, url: string): void {
|
|
|
206
207
|
});
|
|
207
208
|
|
|
208
209
|
ws.addEventListener("error", function () {
|
|
209
|
-
|
|
210
|
+
log.mesh("WebSocket error for peer: %s", conn.nodeId);
|
|
210
211
|
});
|
|
211
212
|
}
|
|
212
213
|
|
|
@@ -10,8 +10,9 @@ import { homedir } from "node:os";
|
|
|
10
10
|
import { sendTo, broadcast } from "../ws/broadcast";
|
|
11
11
|
import { syncSessionToPeers } from "../mesh/session-sync";
|
|
12
12
|
import { resolveSkillContent } from "../handlers/skills";
|
|
13
|
-
import { guessContextWindow } from "./session";
|
|
13
|
+
import { guessContextWindow, getSessionTitle, renameSession, listSessions } from "./session";
|
|
14
14
|
import { getLatticeHome } from "../config";
|
|
15
|
+
import { log } from "../logger";
|
|
15
16
|
|
|
16
17
|
interface PendingPermission {
|
|
17
18
|
resolve: (result: PermissionResult) => void;
|
|
@@ -154,7 +155,7 @@ export function cleanupClientPermissions(clientId: string): void {
|
|
|
154
155
|
pendingPermissions.delete(toRemove[i]);
|
|
155
156
|
}
|
|
156
157
|
if (toRemove.length > 0) {
|
|
157
|
-
|
|
158
|
+
log.chat("Cleaned up %d pending permission(s) for disconnected client %s", toRemove.length, clientId);
|
|
158
159
|
}
|
|
159
160
|
}
|
|
160
161
|
|
|
@@ -340,9 +341,53 @@ export function buildPermissionRule(toolName: string, input: Record<string, unkn
|
|
|
340
341
|
return toolName;
|
|
341
342
|
}
|
|
342
343
|
|
|
344
|
+
var STRIP_PREFIXES = [
|
|
345
|
+
/^please\s+/i,
|
|
346
|
+
/^can\s+you\s+/i,
|
|
347
|
+
/^could\s+you\s+/i,
|
|
348
|
+
/^help\s+me\s+/i,
|
|
349
|
+
/^i\s+need\s+you\s+to\s+/i,
|
|
350
|
+
/^i\s+want\s+you\s+to\s+/i,
|
|
351
|
+
/^i'd\s+like\s+you\s+to\s+/i,
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
function generateSessionTitle(userMessage: string): string {
|
|
355
|
+
var title = userMessage
|
|
356
|
+
.replace(/[#*_`~>\[\]()!]/g, "")
|
|
357
|
+
.replace(/\n+/g, " ")
|
|
358
|
+
.trim();
|
|
359
|
+
|
|
360
|
+
for (var i = 0; i < STRIP_PREFIXES.length; i++) {
|
|
361
|
+
title = title.replace(STRIP_PREFIXES[i], "");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
title = title.trim();
|
|
365
|
+
|
|
366
|
+
if (title.length > 50) {
|
|
367
|
+
title = title.slice(0, 50);
|
|
368
|
+
var lastSpace = title.lastIndexOf(" ");
|
|
369
|
+
if (lastSpace > 30) {
|
|
370
|
+
title = title.slice(0, lastSpace);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!title) {
|
|
375
|
+
return "";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return title.charAt(0).toUpperCase() + title.slice(1);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function isDefaultTitle(title: string): boolean {
|
|
382
|
+
if (title === "Untitled") return true;
|
|
383
|
+
if (/^Session\s+\d/.test(title)) return true;
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
|
|
343
387
|
export function startChatStream(options: ChatStreamOptions): void {
|
|
344
388
|
var { projectSlug, sessionId, text, attachments, clientId, cwd, env, model, effort, isNewSession } = options;
|
|
345
389
|
var startTime = Date.now();
|
|
390
|
+
var firstUserMessage = text;
|
|
346
391
|
|
|
347
392
|
if (activeStreams.has(sessionId) || pendingStreams.has(sessionId)) {
|
|
348
393
|
sendTo(clientId, { type: "chat:error", message: "Session already has an active stream." });
|
|
@@ -670,7 +715,7 @@ export function startChatStream(options: ChatStreamOptions): void {
|
|
|
670
715
|
if (sysMsg.subtype === "init") {
|
|
671
716
|
var toolCount = (sysMsg.tools || []).length;
|
|
672
717
|
var mcpCount = (sysMsg.mcp_servers || []).filter(function (s) { return s.status === "connected"; }).length;
|
|
673
|
-
|
|
718
|
+
log.chat("Session ready: %d tools, %d MCP servers connected", toolCount, mcpCount);
|
|
674
719
|
}
|
|
675
720
|
return;
|
|
676
721
|
}
|
|
@@ -855,6 +900,20 @@ export function startChatStream(options: ChatStreamOptions): void {
|
|
|
855
900
|
sendTo(clientId, { type: "chat:done", cost: cost, duration: dur });
|
|
856
901
|
broadcast({ type: "session:busy", sessionId, busy: false }, clientId);
|
|
857
902
|
syncSessionToPeers(cwd, projectSlug, sessionId);
|
|
903
|
+
|
|
904
|
+
void getSessionTitle(projectSlug, sessionId).then(function (currentTitle) {
|
|
905
|
+
if (!isDefaultTitle(currentTitle)) return;
|
|
906
|
+
var newTitle = generateSessionTitle(firstUserMessage);
|
|
907
|
+
if (!newTitle) return;
|
|
908
|
+
void renameSession(projectSlug, sessionId, newTitle).then(function (ok) {
|
|
909
|
+
if (!ok) return;
|
|
910
|
+
log.session("Auto-titled session %s: %s", sessionId, newTitle);
|
|
911
|
+
void listSessions(projectSlug).then(function (sessions) {
|
|
912
|
+
broadcast({ type: "session:list", projectSlug, sessions });
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
|
|
858
917
|
return;
|
|
859
918
|
}
|
|
860
919
|
}
|
|
@@ -11,6 +11,7 @@ import { randomUUID } from "node:crypto";
|
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import type { HistoryMessage, SessionPreview, SessionSummary } from "@lattice/shared";
|
|
13
13
|
import { loadConfig } from "../config";
|
|
14
|
+
import { log } from "../logger";
|
|
14
15
|
|
|
15
16
|
function getProjectPath(projectSlug: string): string | null {
|
|
16
17
|
var config = loadConfig();
|
|
@@ -417,7 +418,7 @@ export async function listSessions(projectSlug: string): Promise<SessionSummary[
|
|
|
417
418
|
summaries.sort(function (a, b) { return b.updatedAt - a.updatedAt; });
|
|
418
419
|
return summaries;
|
|
419
420
|
} catch (err) {
|
|
420
|
-
|
|
421
|
+
log.session("Failed to list SDK sessions: %O", err);
|
|
421
422
|
return [];
|
|
422
423
|
}
|
|
423
424
|
}
|
|
@@ -442,7 +443,7 @@ export async function loadSessionHistory(projectSlug: string, sessionId: string)
|
|
|
442
443
|
var messages = await getSessionMessages(sessionId, options);
|
|
443
444
|
return convertSessionMessages(messages);
|
|
444
445
|
} catch (err) {
|
|
445
|
-
|
|
446
|
+
log.session("Failed to load session history: %O", err);
|
|
446
447
|
return [];
|
|
447
448
|
}
|
|
448
449
|
}
|
|
@@ -467,7 +468,7 @@ export async function renameSession(projectSlug: string, sessionId: string, titl
|
|
|
467
468
|
await sdkRenameSession(sessionId, title, options);
|
|
468
469
|
return true;
|
|
469
470
|
} catch (err) {
|
|
470
|
-
|
|
471
|
+
log.session("Failed to rename session: %O", err);
|
|
471
472
|
return false;
|
|
472
473
|
}
|
|
473
474
|
}
|
|
@@ -489,7 +490,7 @@ export async function deleteSession(projectSlug: string, sessionId: string): Pro
|
|
|
489
490
|
unlinkSync(sessionFile);
|
|
490
491
|
return true;
|
|
491
492
|
} catch (err) {
|
|
492
|
-
|
|
493
|
+
log.session("Failed to delete session: %O", err);
|
|
493
494
|
return false;
|
|
494
495
|
}
|
|
495
496
|
}
|
package/server/src/ws/router.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ClientMessage } from "@lattice/shared";
|
|
2
2
|
import { sendTo } from "./broadcast";
|
|
3
|
+
import { log } from "../logger";
|
|
3
4
|
|
|
4
5
|
var _registry: typeof import("../project/registry") | null = null;
|
|
5
6
|
var _connector: typeof import("../mesh/connector") | null = null;
|
|
@@ -81,18 +82,18 @@ export function routeMessage(clientId: string, message: ClientMessage): void {
|
|
|
81
82
|
if (result && typeof result.then === "function") {
|
|
82
83
|
result.then(undefined, function (err: unknown) {
|
|
83
84
|
var stack = err instanceof Error ? err.stack : String(err);
|
|
84
|
-
|
|
85
|
+
log.ws("Async handler error for %s: %s", message.type, stack);
|
|
85
86
|
sendTo(clientId, { type: "chat:error", message: "Internal server error processing " + message.type });
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
} catch (err) {
|
|
89
90
|
var stack = err instanceof Error ? (err as Error).stack : String(err);
|
|
90
|
-
|
|
91
|
+
log.ws("Handler error for %s: %s", message.type, stack);
|
|
91
92
|
sendTo(clientId, { type: "chat:error", message: "Internal server error processing " + message.type });
|
|
92
93
|
}
|
|
93
94
|
return;
|
|
94
95
|
}
|
|
95
|
-
|
|
96
|
+
log.ws("No handler for message type: %s", message.type);
|
|
96
97
|
sendTo(clientId, { type: "error", message: `Unknown message type: ${message.type}` });
|
|
97
98
|
}
|
|
98
99
|
|
|
@@ -112,7 +113,7 @@ function proxyMessage(clientId: string, nodeId: string, projectSlug: string, mes
|
|
|
112
113
|
try {
|
|
113
114
|
getProxy().proxyToRemoteNode(nodeId, projectSlug, clientId, message);
|
|
114
115
|
} catch (err) {
|
|
115
|
-
|
|
116
|
+
log.ws("Failed to proxy message: %O", err);
|
|
116
117
|
sendTo(clientId, { type: "chat:error", message: "Failed to proxy message to remote node" });
|
|
117
118
|
}
|
|
118
119
|
}
|
package/shared/src/models.ts
CHANGED
|
@@ -58,8 +58,10 @@ export interface Attachment {
|
|
|
58
58
|
lineCount?: number;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export type HistoryMessageType = "user" | "assistant" | "tool_start" | "tool_result" | "permission_request" | "prompt_question" | "todo_update";
|
|
62
|
+
|
|
61
63
|
export interface HistoryMessage {
|
|
62
|
-
type:
|
|
64
|
+
type: HistoryMessageType;
|
|
63
65
|
uuid?: string;
|
|
64
66
|
text?: string;
|
|
65
67
|
toolId?: string;
|